detached 680dbb1

Coherent session navigation (protocol v42 + inventory relay) (#263)

* refactor(daemon): share session inventory capture across palette picker catalog

* feat(protocol): add navigation inventory semantic types and capability

* feat(protocol): wire inventory 42-47, version 42, daemon control source

* chore(navigation): version 42 references and unknown-capability fixtures

* feat(client): shared navigation transition with distinct authority and return route

* test(harness): migrate acceptance to Arch image with local and two remote roles

* test(navigation): characterize fallback capture, known-red relay gap, D5 table

* refactor(client): route creation and recent fallbacks through navigationTransition

* feat(client): inventory relay core with dial-only local control source

* feat(daemon): palette inventory demand, publication admission, imported selection

* feat(client): inventory relay attempt wiring with poll, resolve, and handoff

* feat(nav): read-only remote vision tags with attach guard plus compat matrix

* docs(nav): session navigation with inventory relay and compat

* fix(nav): senior review blockers and correctness findings

* fix(nav): copilot review findings on relay prod path and failure durability

* fix(navigation): qualify palette origins and preserve exact session returns

Retire resume credentials from previous routes when an attachment moves between sessions. Deduplicate imported local destinations against exact history identities and correlate inventory handoffs with UI actions.

* fix(palette): qualify hybrid sessions with at-sign origins

* refactor(navigation): simplify recovery state and replace obsolete acceptance harness

Capture inventory recovery after serving metadata rebasing, cancel closed snapshot queries without releasing their physical slot, and emit a single palette close. Replace the unused controller and pending matrix with executable UI-driver navigation acceptance.

* fix(client): correlate inventory handoff failures and settle at commit

Settle the inventory transition when the destination's initial publications commit instead of when the long-lived attachment exits, so a later transport failure reconnects the active destination. Emit correlated stale-identity failures for selections retired before arrival and for resolves retired in flight. Retain the palette selection in the transition and publish NavigationInventoryNavigationFailed to the restored serving daemon when the destination dial fails.
commit
680dbb128af4796c94927906b6a0b54bd462a1e2
author
bnema · · 2026-09-08 10:50 UTC
committer
GitHub · · 2026-09-08 10:50 UTC
parent
1de3199
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
67 files · +5525 -2478
M docs/architecture.md +1 -1
@@ -56,4 +56,4 @@ Use cases exchange `protocol.ClientMessage` and `protocol.ServerMessage` values.
5656 - Add remote catalogue schema fields and validation to `internal/protocol/catalogue`.
5757 - Add message IDs, binary layouts, strict decoding, compression, or raw carriage contracts to `internal/protocol/wire`.
5858 - Implement I/O, queues, workers, environment integration, or technology selection in an adapter or `internal/app`.
59-- Bump `internal/protocol.Version` for negotiated wire layout changes (currently `41`, including UI view context and processing-fence messages).
59+- Bump `internal/protocol.Version` for negotiated wire layout changes (currently `42`, including navigation inventory, UI view context and processing-fence messages).
A docs/session-navigation.md +64 -0
@@ -0,0 +1,64 @@
1+# Session navigation
2+
3+Palette destinations use bare session names in local-only mode. With remote
4+hosts configured, local sessions use `session@local` and remote sessions use
5+`session@host`, relative to the attaching client regardless of the serving
6+daemon. Offline hosts still enable qualified local labels. Local inventory
7+rows replace history rows for the same exact session;
8+homonyms with different origins or lifecycles remain distinct. A resume
9+credential belongs only to the route currently holding its attachment, so
10+older sessions in history use exact attachment after a same-daemon switch.
11+
12+The command palette lists sessions from two origins: native results owned by
13+the serving daemon (active, stopped, remote, recent routes) and imported rows
14+relayed from the attaching client's own local daemon. Imported rows carry
15+only opaque keys plus display metadata; resolution happens through a
16+dial-only local control connection in the same interaction namespace.
17+
18+## Inventory relay
19+
20+While attached to a remote daemon, the client runs one relay per attachment
21+when it holds a committed local route. The relay dials only the local
22+control socket: no Hello, no session creation, no attachment, no daemon
23+startup.
24+
25+- The serving palette sends an open demand with an interaction generation
26+ when it opens, and a close demand on every successful close (execute,
27+ select, or cancel). Demands are capability-gated on both sides: the
28+ client advertises inventory in Hello only while a committed local route
29+ and the dial-only control source are available.
30+- While open, the relay polls the local source once per second with at most
31+ one query in flight plus one coalesced latest update. Only changed
32+ snapshots publish, with a monotonic publication generation per
33+ interaction. Admitted keys and retirements never cross interactions.
34+- Enter on an imported row sends a selection with the opaque keys and the
35+ admitted input cause, which stays zero for ordinary keyboard input.
36+ Selection crosses before the close demand on the guarded serving
37+ connection.
38+- Resolve revalidates the key against a fresh capture and returns a
39+ non-mutating attach target. Endpoint-empty targets resolve against the
40+ committed local route as authority; the serving route stays the recovery
41+ destination through the inventory transition (settle once, restore on
42+ every destination failure).
43+- Failures (stale identity, gone source, incompatible, invalid target,
44+ navigation or restore failure) surface as palette feedback with the exact
45+ cause while the originating interaction is still open; stale failures
46+ from closed interactions drop. Native commands stay usable throughout.
47+
48+## Remote vision
49+
50+Remote-origin rows display availability tags (`down`, `broken`, stopped)
51+inline so they never look attachable. Remote vision is read-only: Enter on
52+a remote row reports feedback and emits no selection, and selections for
53+non-local sources reject before resolve. Non-OK groups project no rows.
54+
55+## Compatibility
56+
57+Demands gate both sides on the negotiated inventory capability: a serving
58+palette that never receives the bit never demands, and a client without a
59+committed local route or control dialer ignores demands that decode. With
60+no demands the relay stays idle with zero control dials, and local
61+attachments ignore demands even when they decode. Unknown frame types
62+stay ignored by the receive pump. Cross-version attachment still fails at
63+the strict-equality handshake; graceful degradation applies only within
64+one negotiated version.
M internal/adapters/sessionwire/client.go +38 -1
@@ -111,6 +111,12 @@ func decodeServer(frame wire.Frame) (protocol.ServerMessage, error) {
111111 return wire.UnmarshalSamePeerSwitchFailure(frame.Payload)
112112 case wire.MsgParkedRouteResponse:
113113 return wire.UnmarshalParkedRouteResponse(frame.Payload)
114+ case wire.MsgNavigationInventoryResponse:
115+ return wire.UnmarshalNavigationInventoryResponse(frame.Payload)
116+ case wire.MsgNavigationInventoryDemand:
117+ return wire.UnmarshalNavigationInventoryDemand(frame.Payload)
118+ case wire.MsgNavigationInventorySelection:
119+ return wire.UnmarshalNavigationInventorySelection(frame.Payload)
114120 case wire.MsgUIReceipt:
115121 return wire.UnmarshalUIReceipt(frame.Payload)
116122 case wire.MsgUIViewUpdate:
@@ -120,7 +126,8 @@ func decodeServer(frame wire.Frame) (protocol.ServerMessage, error) {
120126 wire.MsgClientNotice, wire.MsgCommand, wire.MsgOutputResetRequest,
121127 wire.MsgRemotePreviewRequest, wire.MsgRouteAttentionSubscription,
122128 wire.MsgSamePeerSwitchRequest, wire.MsgParkedRouteRequest,
123- wire.MsgRecentRouteSnapshot, wire.MsgSessionCreationFailure, wire.MsgUIFence:
129+ wire.MsgRecentRouteSnapshot, wire.MsgSessionCreationFailure, wire.MsgUIFence,
130+ wire.MsgNavigationInventoryRequest, wire.MsgNavigationInventoryPublication, wire.MsgNavigationInventoryFailure:
124131 return nil, ErrWrongDirection
125132 default:
126133 return nil, ErrUnknownMessageType
@@ -186,6 +193,24 @@ func encodeClient(message protocol.ClientMessage) (wire.Frame, error) {
186193 return wire.Frame{}, ErrInvalidMessage
187194 }
188195 return wire.Frame{Type: wire.MsgParkedRouteRequest, Payload: payload}, nil
196+ case protocol.NavigationInventoryRequest:
197+ payload := wire.MarshalNavigationInventoryRequest(m)
198+ if payload == nil {
199+ return wire.Frame{}, ErrInvalidMessage
200+ }
201+ return wire.Frame{Type: wire.MsgNavigationInventoryRequest, Payload: payload}, nil
202+ case protocol.NavigationInventoryPublication:
203+ payload := wire.MarshalNavigationInventoryPublication(m)
204+ if payload == nil {
205+ return wire.Frame{}, ErrInvalidMessage
206+ }
207+ return wire.Frame{Type: wire.MsgNavigationInventoryPublication, Payload: payload}, nil
208+ case protocol.NavigationInventoryFailure:
209+ payload := wire.MarshalNavigationInventoryFailure(m)
210+ if payload == nil {
211+ return wire.Frame{}, ErrInvalidMessage
212+ }
213+ return wire.Frame{Type: wire.MsgNavigationInventoryFailure, Payload: payload}, nil
189214 case protocol.RecentRouteSnapshot:
190215 payload, err := wire.MarshalRecentRouteSnapshot(m)
191216 return wire.Frame{Type: wire.MsgRecentRouteSnapshot, Payload: payload}, err
@@ -267,6 +292,18 @@ func encodeClient(message protocol.ClientMessage) (wire.Frame, error) {
267292 if m != nil {
268293 return encodeClient(*m)
269294 }
295+ case *protocol.NavigationInventoryRequest:
296+ if m != nil {
297+ return encodeClient(*m)
298+ }
299+ case *protocol.NavigationInventoryPublication:
300+ if m != nil {
301+ return encodeClient(*m)
302+ }
303+ case *protocol.NavigationInventoryFailure:
304+ if m != nil {
305+ return encodeClient(*m)
306+ }
270307 case *protocol.RecentRouteSnapshot:
271308 if m != nil {
272309 return encodeClient(*m)
M internal/adapters/sessionwire/server.go +42 -1
@@ -55,6 +55,10 @@ func (c *serverConnection) ReceiveClient() (protocol.ClientMessage, error) {
5555 failure.Kind = protocol.DecodeMessageKill
5656 case wire.MsgRemotePreviewRequest:
5757 failure.Kind = protocol.DecodeMessageRemotePreview
58+ case wire.MsgNavigationInventoryRequest:
59+ failure.Kind = protocol.DecodeMessageNavigationInventory
60+ failure.Version, _ = wire.PeekNavigationInventoryVersion(frame.Payload)
61+ failure.RequestID, failure.HasRequestID = wire.PeekNavigationInventoryRequestID(frame.Payload)
5862 }
5963 return nil, failure
6064 }
@@ -228,6 +232,12 @@ func decodeClient(frame wire.Frame) (protocol.ClientMessage, error) {
228232 return wire.UnmarshalSamePeerSwitchRequest(frame.Payload)
229233 case wire.MsgParkedRouteRequest:
230234 return wire.UnmarshalParkedRouteRequest(frame.Payload)
235+ case wire.MsgNavigationInventoryRequest:
236+ return wire.UnmarshalNavigationInventoryRequest(frame.Payload)
237+ case wire.MsgNavigationInventoryPublication:
238+ return wire.UnmarshalNavigationInventoryPublication(frame.Payload)
239+ case wire.MsgNavigationInventoryFailure:
240+ return wire.UnmarshalNavigationInventoryFailure(frame.Payload)
231241 case wire.MsgRecentRouteSnapshot:
232242 return wire.UnmarshalRecentRouteSnapshot(frame.Payload)
233243 case wire.MsgRouteNavigationFailure:
@@ -241,7 +251,8 @@ func decodeClient(frame wire.Frame) (protocol.ClientMessage, error) {
241251 wire.MsgAttachTarget, wire.MsgRemotePreviewResponse,
242252 wire.MsgCommittedRouteIdentity, wire.MsgNavigateRecentRoute, wire.MsgRouteCreateSession,
243253 wire.MsgRoutePosition, wire.MsgSamePeerSwitchFailure, wire.MsgParkedRouteResponse,
244- wire.MsgUIReceipt, wire.MsgUIViewUpdate:
254+ wire.MsgUIReceipt, wire.MsgUIViewUpdate, wire.MsgNavigationInventoryResponse,
255+ wire.MsgNavigationInventoryDemand, wire.MsgNavigationInventorySelection:
245256 return nil, ErrWrongDirection
246257 default:
247258 return nil, ErrUnknownMessageType
@@ -320,6 +331,24 @@ func encodeServer(message protocol.ServerMessage) (wire.Frame, error) {
320331 return wire.Frame{}, ErrInvalidMessage
321332 }
322333 return wire.Frame{Type: wire.MsgParkedRouteResponse, Payload: payload}, nil
334+ case protocol.NavigationInventoryResponse:
335+ payload := wire.MarshalNavigationInventoryResponse(m)
336+ if payload == nil {
337+ return wire.Frame{}, ErrInvalidMessage
338+ }
339+ return wire.Frame{Type: wire.MsgNavigationInventoryResponse, Payload: payload}, nil
340+ case protocol.NavigationInventoryDemand:
341+ payload := wire.MarshalNavigationInventoryDemand(m)
342+ if payload == nil {
343+ return wire.Frame{}, ErrInvalidMessage
344+ }
345+ return wire.Frame{Type: wire.MsgNavigationInventoryDemand, Payload: payload}, nil
346+ case protocol.NavigationInventorySelection:
347+ payload := wire.MarshalNavigationInventorySelection(m)
348+ if payload == nil {
349+ return wire.Frame{}, ErrInvalidMessage
350+ }
351+ return wire.Frame{Type: wire.MsgNavigationInventorySelection, Payload: payload}, nil
323352 case *protocol.Welcome:
324353 if m != nil {
325354 return encodeServer(*m)
@@ -396,6 +425,18 @@ func encodeServer(message protocol.ServerMessage) (wire.Frame, error) {
396425 if m != nil {
397426 return encodeServer(*m)
398427 }
428+ case *protocol.NavigationInventoryResponse:
429+ if m != nil {
430+ return encodeServer(*m)
431+ }
432+ case *protocol.NavigationInventoryDemand:
433+ if m != nil {
434+ return encodeServer(*m)
435+ }
436+ case *protocol.NavigationInventorySelection:
437+ if m != nil {
438+ return encodeServer(*m)
439+ }
399440 default:
400441 return wire.Frame{}, ErrWrongDirection
401442 }
A internal/app/dial_only_test.go +30 -0
@@ -0,0 +1,30 @@
1+package app
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+)
8+
9+// TestDialOnlyLocalDialerNeverStartsDaemon pins the D2 source contract: the
10+// inventory control dialer performs a bare socket dial with no ensure, spawn,
11+// or attachment path. Against an isolated directory it must fail fast with a
12+// dial error instead of implicitly starting a daemon.
13+func TestDialOnlyLocalDialerNeverStartsDaemon(t *testing.T) {
14+ dir := t.TempDir()
15+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
16+ defer cancel()
17+ start := time.Now()
18+ dialer := dialOnlyLocalDialer{dir: dir}
19+ _, err := dialer.Dial(ctx)
20+ if err == nil {
21+ t.Fatal("dial-only control dial against an empty dir must fail")
22+ }
23+ if elapsed := time.Since(start); elapsed > 10*time.Second {
24+ t.Fatalf("dial-only control dial took %v; must not retry or spawn", elapsed)
25+ }
26+ plain := dialOnlyLocalDialer{dir: dir, observer: nil}
27+ if _, err := plain.Dial(context.Background()); err == nil {
28+ t.Fatal("dial-only control dial without observer must also fail fast")
29+ }
30+}
M internal/app/run.go +19 -0
@@ -1046,6 +1046,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
10461046 }
10471047 err = runClient(ctx, client.Dependencies{
10481048 Dialer: sessionwire.NewClientDialer(dialer),
1049+ LocalControlDialer: sessionwire.NewClientDialer(dialOnlyLocalDialer{dir: ipc.SocketDir(), observer: deps.runtimeObserver}),
10491050 Terminal: clientTerminal(deps),
10501051 Clock: clientClock(deps),
10511052 DisableCapabilityProbe: deps.disableCapabilityProbe,
@@ -1075,6 +1076,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
10751076 }
10761077 err = runClient(ctx, client.Dependencies{
10771078 Dialer: sessionwire.NewClientDialer(localDialer()),
1079+ LocalControlDialer: sessionwire.NewClientDialer(dialOnlyLocalDialer{dir: ipc.SocketDir(), observer: deps.runtimeObserver}),
10781080 Terminal: clientTerminal(deps),
10791081 Clock: clientClock(deps),
10801082 DisableCapabilityProbe: deps.disableCapabilityProbe,
@@ -1136,6 +1138,23 @@ type localDaemonDialer struct {
11361138 environment []string
11371139 }
11381140
1141+// dialOnlyLocalDialer is the inventory control source: a typed dial-only
1142+// connection to the local daemon socket. Unlike localDaemonDialer it never
1143+// ensures, spawns, or starts the daemon and never creates an attachment: a
1144+// missing socket reports local control unavailability instead of starting
1145+// a daemon implicitly.
1146+type dialOnlyLocalDialer struct {
1147+ dir string
1148+ observer ports.SerializedRuntimeObserver
1149+}
1150+
1151+func (d dialOnlyLocalDialer) Dial(ctx context.Context) (wire.Transport, error) {
1152+ if d.observer != nil {
1153+ return ipc.DialContext(ctx, d.dir, ipc.WithRuntimeObserver(d.observer))
1154+ }
1155+ return ipc.DialContext(ctx, d.dir)
1156+}
1157+
11391158 func (d localDaemonDialer) Dial(ctx context.Context) (wire.Transport, error) {
11401159 dial := realDial
11411160 if d.observer != nil {
M internal/domain/notice.go +23 -21
@@ -42,6 +42,7 @@ const (
4242 NoticeAutoResume
4343 NoticeConnection
4444 NoticeUser
45+ NoticeNavigationInventory
4546
4647 // noticeCodeLimit is an append-only declaration sentinel, not a persisted
4748 // valid notice code. Keep it last when adding a NoticeCode.
@@ -49,27 +50,28 @@ const (
4950 )
5051
5152 var noticeSlugs = map[NoticeCode]string{
52- NoticeInternal: "internal",
53- NoticePaneSpawn: "pane-spawn",
54- NoticeTabSpawn: "tab-spawn",
55- NoticeFloatingSpawn: "floating-spawn",
56- NoticeSessionSpawn: "session-spawn",
57- NoticeLayoutTooSmall: "layout-too-small",
58- NoticePaneNotFound: "pane-not-found",
59- NoticeSessionUnavailable: "session-unavailable",
60- NoticePersistDisabled: "persist-disabled",
61- NoticeSnapshotWrite: "snapshot-write",
62- NoticeSnapshotRestore: "snapshot-restore",
63- NoticeSnapshotSaturated: "snapshot-saturated",
64- NoticePersistDelete: "persist-delete",
65- NoticeConfigReload: "config-reload",
66- NoticeInputDropped: "input-dropped",
67- NoticeResizeFailed: "resize-failed",
68- NoticeClipboard: "clipboard",
69- NoticeClipboardTooLarge: "clipboard-too-large",
70- NoticeAutoResume: "auto-resume",
71- NoticeConnection: "connection",
72- NoticeUser: "user",
53+ NoticeInternal: "internal",
54+ NoticePaneSpawn: "pane-spawn",
55+ NoticeTabSpawn: "tab-spawn",
56+ NoticeFloatingSpawn: "floating-spawn",
57+ NoticeSessionSpawn: "session-spawn",
58+ NoticeLayoutTooSmall: "layout-too-small",
59+ NoticePaneNotFound: "pane-not-found",
60+ NoticeSessionUnavailable: "session-unavailable",
61+ NoticePersistDisabled: "persist-disabled",
62+ NoticeSnapshotWrite: "snapshot-write",
63+ NoticeSnapshotRestore: "snapshot-restore",
64+ NoticeSnapshotSaturated: "snapshot-saturated",
65+ NoticePersistDelete: "persist-delete",
66+ NoticeConfigReload: "config-reload",
67+ NoticeInputDropped: "input-dropped",
68+ NoticeResizeFailed: "resize-failed",
69+ NoticeClipboard: "clipboard",
70+ NoticeClipboardTooLarge: "clipboard-too-large",
71+ NoticeAutoResume: "auto-resume",
72+ NoticeConnection: "connection",
73+ NoticeUser: "user",
74+ NoticeNavigationInventory: "navigation-inventory",
7375 }
7476
7577 func (c NoticeCode) String() string {
M internal/protocol/decode.go +1 -0
@@ -21,6 +21,7 @@ const (
2121 DecodeMessageCommand
2222 DecodeMessageKill
2323 DecodeMessageRemotePreview
24+ DecodeMessageNavigationInventory
2425 )
2526
2627 // DecodeFailure describes rejected encoded input without retaining payload bytes.
M internal/protocol/messages.go +46 -40
@@ -6,44 +6,50 @@ type ClientMessage interface{ clientMessage() }
66 // ServerMessage is a closed set of messages emitted by a session daemon.
77 type ServerMessage interface{ serverMessage() }
88
9-func (Hello) clientMessage() {}
10-func (Input) clientMessage() {}
11-func (Resize) clientMessage() {}
12-func (Detach) clientMessage() {}
13-func (Ping) clientMessage() {}
14-func (List) clientMessage() {}
15-func (Kill) clientMessage() {}
16-func (Theme) clientMessage() {}
17-func (Ack) clientMessage() {}
18-func (ImagePush) clientMessage() {}
19-func (ClientNotice) clientMessage() {}
20-func (CommandRequest) clientMessage() {}
21-func (OutputResetRequest) clientMessage() {}
22-func (RemotePreviewRequest) clientMessage() {}
23-func (RouteAttentionSubscription) clientMessage() {}
24-func (SamePeerSwitchRequest) clientMessage() {}
25-func (ParkedRouteRequest) clientMessage() {}
26-func (RecentRouteSnapshot) clientMessage() {}
27-func (RouteNavigationFailure) clientMessage() {}
28-func (SessionCreationFailure) clientMessage() {}
29-func (UIFence) clientMessage() {}
9+func (Hello) clientMessage() {}
10+func (Input) clientMessage() {}
11+func (Resize) clientMessage() {}
12+func (Detach) clientMessage() {}
13+func (Ping) clientMessage() {}
14+func (List) clientMessage() {}
15+func (Kill) clientMessage() {}
16+func (Theme) clientMessage() {}
17+func (Ack) clientMessage() {}
18+func (ImagePush) clientMessage() {}
19+func (ClientNotice) clientMessage() {}
20+func (CommandRequest) clientMessage() {}
21+func (OutputResetRequest) clientMessage() {}
22+func (RemotePreviewRequest) clientMessage() {}
23+func (RouteAttentionSubscription) clientMessage() {}
24+func (SamePeerSwitchRequest) clientMessage() {}
25+func (ParkedRouteRequest) clientMessage() {}
26+func (RecentRouteSnapshot) clientMessage() {}
27+func (RouteNavigationFailure) clientMessage() {}
28+func (SessionCreationFailure) clientMessage() {}
29+func (UIFence) clientMessage() {}
30+func (NavigationInventoryRequest) clientMessage() {}
31+func (NavigationInventoryPublication) clientMessage() {}
32+func (NavigationInventoryFailure) clientMessage() {}
3033
31-func (Welcome) serverMessage() {}
32-func (ErrorMsg) serverMessage() {}
33-func (Output) serverMessage() {}
34-func (Detached) serverMessage() {}
35-func (Pong) serverMessage() {}
36-func (Sessions) serverMessage() {}
37-func (CommandResult) serverMessage() {}
38-func (NavigationDirective) serverMessage() {}
39-func (AttachTarget) serverMessage() {}
40-func (RemotePreview) serverMessage() {}
41-func (CommittedRouteIdentity) serverMessage() {}
42-func (RouteNavigationAction) serverMessage() {}
43-func (RouteCreateSessionAction) serverMessage() {}
44-func (RouteNavigationFailure) serverMessage() {}
45-func (RoutePosition) serverMessage() {}
46-func (SamePeerSwitchFailure) serverMessage() {}
47-func (ParkedRouteResponse) serverMessage() {}
48-func (UIReceipt) serverMessage() {}
49-func (UIViewUpdate) serverMessage() {}
34+func (Welcome) serverMessage() {}
35+func (ErrorMsg) serverMessage() {}
36+func (Output) serverMessage() {}
37+func (Detached) serverMessage() {}
38+func (Pong) serverMessage() {}
39+func (Sessions) serverMessage() {}
40+func (CommandResult) serverMessage() {}
41+func (NavigationDirective) serverMessage() {}
42+func (AttachTarget) serverMessage() {}
43+func (RemotePreview) serverMessage() {}
44+func (CommittedRouteIdentity) serverMessage() {}
45+func (RouteNavigationAction) serverMessage() {}
46+func (RouteCreateSessionAction) serverMessage() {}
47+func (RouteNavigationFailure) serverMessage() {}
48+func (RoutePosition) serverMessage() {}
49+func (SamePeerSwitchFailure) serverMessage() {}
50+func (ParkedRouteResponse) serverMessage() {}
51+func (UIReceipt) serverMessage() {}
52+func (UIViewUpdate) serverMessage() {}
53+func (NavigationInventoryResponse) serverMessage() {}
54+func (NavigationInventoryDemand) serverMessage() {}
55+func (NavigationInventorySelection) serverMessage() {}
M internal/protocol/navigation.go +2 -1
@@ -7,6 +7,7 @@ type NavigationCapabilities uint8
77 const (
88 NavigationCapabilityHomePicker NavigationCapabilities = 1 << iota
99 NavigationCapabilityBack
10+ NavigationCapabilityInventory
1011 )
1112
1213 type StartupOverlay uint8
@@ -65,7 +66,7 @@ type ParkedRouteResponse struct {
6566 }
6667
6768 func validNavigationCapabilities(capabilities NavigationCapabilities) bool {
68- return capabilities&^(NavigationCapabilityHomePicker|NavigationCapabilityBack) == 0
69+ return capabilities&^(NavigationCapabilityHomePicker|NavigationCapabilityBack|NavigationCapabilityInventory) == 0
6970 }
7071
7172 func validStartupOverlay(overlay StartupOverlay) bool {
A internal/protocol/navigation_inventory.go +369 -0
@@ -0,0 +1,369 @@
1+package protocol
2+
3+import (
4+ "unicode/utf8"
5+
6+ "github.com/bnema/vev/internal/domain"
7+)
8+
9+// Navigation inventory bounds. These are export limits for UI listings, not
10+// limits on stored or running sessions.
11+const (
12+ NavigationInventoryMaxSourceGroups = 65
13+ NavigationInventoryMaxLocalEntries = 4096
14+ NavigationInventoryMaxRemoteEntriesPerSource = 256
15+ NavigationInventoryMaxDisplayBytes = 256
16+ NavigationInventoryMaxKeyBytes = 128
17+)
18+
19+// NavigationInventoryLocalSourceKey identifies the control daemon's own
20+// sessions inside inventory groups.
21+const NavigationInventoryLocalSourceKey = "local"
22+
23+// NavigationInventoryRemoteSourcePrefix namespaces remote source keys so
24+// relayed identifiers never expose raw configured endpoints and never
25+// collide with the reserved local source.
26+const NavigationInventoryRemoteSourcePrefix = "remote/"
27+
28+// NavigationInventoryOperation selects the control-source query.
29+type NavigationInventoryOperation uint8
30+
31+const (
32+ NavigationInventorySnapshot NavigationInventoryOperation = 1
33+ NavigationInventoryResolve NavigationInventoryOperation = 2
34+)
35+
36+// NavigationInventoryStatus is the overall source-control outcome.
37+type NavigationInventoryStatus uint8
38+
39+const (
40+ NavigationInventoryOK NavigationInventoryStatus = 1
41+ NavigationInventoryUnavailable NavigationInventoryStatus = 2
42+ NavigationInventoryInvalid NavigationInventoryStatus = 3
43+ NavigationInventoryVersionMismatch NavigationInventoryStatus = 4
44+)
45+
46+// NavigationInventorySourceStatus is the per-source outcome. Healthy sources
47+// survive another source's size or error failure.
48+type NavigationInventorySourceStatus uint8
49+
50+const (
51+ NavigationInventorySourceOK NavigationInventorySourceStatus = 1
52+ NavigationInventorySourceUnavailable NavigationInventorySourceStatus = 2
53+ NavigationInventorySourceTooLarge NavigationInventorySourceStatus = 3
54+)
55+
56+// NavigationInventoryFailureCode classifies resolve/selection failures.
57+type NavigationInventoryFailureCode uint8
58+
59+const (
60+ NavigationInventoryStaleIdentity NavigationInventoryFailureCode = 1
61+ NavigationInventorySourceGone NavigationInventoryFailureCode = 2
62+ NavigationInventoryIncompatible NavigationInventoryFailureCode = 3
63+ NavigationInventoryInvalidTarget NavigationInventoryFailureCode = 4
64+ NavigationInventoryNavigationFailed NavigationInventoryFailureCode = 5
65+ NavigationInventoryRestoreFailed NavigationInventoryFailureCode = 6
66+)
67+
68+// NavigationInventoryEntry is one sanitized public row. It carries display
69+// facts only, never exact endpoints, connection parameters, CWD,
70+// environment, preview, or pane content.
71+type NavigationInventoryEntry struct {
72+ SourceKey string
73+ EntryKey string
74+ Name string
75+ DisplayOrigin string
76+ State string
77+ Reason string
78+}
79+
80+// NavigationInventorySourceGroup is one packed source with its own status.
81+type NavigationInventorySourceGroup struct {
82+ SourceKey string
83+ Status NavigationInventorySourceStatus
84+ Entries []NavigationInventoryEntry
85+}
86+
87+// NavigationInventoryRequest queries a source control connection without
88+// creating an attachment or Hello session.
89+type NavigationInventoryRequest struct {
90+ Version uint16
91+ RequestID uint64
92+ Operation NavigationInventoryOperation
93+ SourceKey string
94+ EntryKey string
95+ Registration domain.RemoteRegistration
96+}
97+
98+// NavigationInventoryResponse answers a control query. Snapshot carries
99+// source groups; resolve carries a non-mutating AttachTarget; never both.
100+// Version-mismatch and malformed responses never carry a target.
101+type NavigationInventoryResponse struct {
102+ RequestID uint64
103+ Operation NavigationInventoryOperation
104+ Status NavigationInventoryStatus
105+ Groups []NavigationInventorySourceGroup
106+ Resolved *AttachTarget
107+}
108+
109+// NavigationInventoryDemand is serving daemon → client.
110+type NavigationInventoryDemand struct {
111+ InteractionGeneration uint64
112+ Open bool
113+}
114+
115+// NavigationInventoryPublication is client → serving daemon.
116+type NavigationInventoryPublication struct {
117+ InteractionGeneration uint64
118+ PublicationGeneration uint64
119+ Groups []NavigationInventorySourceGroup
120+}
121+
122+// NavigationInventorySelection is serving daemon → client. No endpoint or
123+// name is supplied as authority; the key resolves through the source.
124+type NavigationInventorySelection struct {
125+ CauseActionID uint64
126+ InteractionGeneration uint64
127+ PublicationGeneration uint64
128+ SourceKey string
129+ EntryKey string
130+}
131+
132+// NavigationInventoryFailure is client → serving daemon.
133+type NavigationInventoryFailure struct {
134+ CauseActionID uint64
135+ InteractionGeneration uint64
136+ SourceKey string
137+ EntryKey string
138+ Code NavigationInventoryFailureCode
139+}
140+
141+func validNavigationInventoryOperation(op NavigationInventoryOperation) bool {
142+ return op == NavigationInventorySnapshot || op == NavigationInventoryResolve
143+}
144+
145+func validNavigationInventoryStatus(status NavigationInventoryStatus) bool {
146+ switch status {
147+ case NavigationInventoryOK,
148+ NavigationInventoryUnavailable,
149+ NavigationInventoryInvalid,
150+ NavigationInventoryVersionMismatch:
151+ return true
152+ default:
153+ return false
154+ }
155+}
156+
157+func validNavigationInventorySourceStatus(status NavigationInventorySourceStatus) bool {
158+ switch status {
159+ case NavigationInventorySourceOK,
160+ NavigationInventorySourceUnavailable,
161+ NavigationInventorySourceTooLarge:
162+ return true
163+ default:
164+ return false
165+ }
166+}
167+
168+func validNavigationInventoryFailureCode(code NavigationInventoryFailureCode) bool {
169+ switch code {
170+ case NavigationInventoryStaleIdentity,
171+ NavigationInventorySourceGone,
172+ NavigationInventoryIncompatible,
173+ NavigationInventoryInvalidTarget,
174+ NavigationInventoryNavigationFailed,
175+ NavigationInventoryRestoreFailed:
176+ return true
177+ default:
178+ return false
179+ }
180+}
181+
182+func validInventoryKey(value string) bool {
183+ if value == "" || len(value) > NavigationInventoryMaxKeyBytes {
184+ return false
185+ }
186+ if !utf8.ValidString(value) {
187+ return false
188+ }
189+ for _, r := range value {
190+ if r < 0x20 || r == 0x7f || (r >= 0x80 && r <= 0x9f) {
191+ return false
192+ }
193+ }
194+ return true
195+}
196+
197+func validInventoryDisplay(value string) bool {
198+ if len(value) > NavigationInventoryMaxDisplayBytes {
199+ return false
200+ }
201+ if !utf8.ValidString(value) {
202+ return false
203+ }
204+ for _, r := range value {
205+ if r < 0x20 || r == 0x7f || (r >= 0x80 && r <= 0x9f) {
206+ return false
207+ }
208+ }
209+ return true
210+}
211+
212+// ValidateNavigationInventoryRequest enforces nonzero IDs, operation-dependent
213+// unions, key shape, registration identity for resolve, and display safety.
214+// Local resolve carries a zero registration (the source is the control daemon
215+// itself); remote resolve carries the configured registration to fence ABA
216+// re-registration.
217+func ValidateNavigationInventoryRequest(request NavigationInventoryRequest) error {
218+ if request.RequestID == 0 || !validNavigationInventoryOperation(request.Operation) {
219+ return ErrInvalidNavigation
220+ }
221+ if request.Operation == NavigationInventorySnapshot {
222+ if request.SourceKey != "" || request.EntryKey != "" || !request.Registration.IsZero() {
223+ return ErrInvalidNavigation
224+ }
225+ return nil
226+ }
227+ if !validInventoryKey(request.SourceKey) || !validInventoryKey(request.EntryKey) {
228+ return ErrInvalidNavigation
229+ }
230+ if request.SourceKey == NavigationInventoryLocalSourceKey {
231+ if !request.Registration.IsZero() {
232+ return ErrInvalidNavigation
233+ }
234+ return nil
235+ }
236+ if request.Registration.Validate() != nil {
237+ return ErrInvalidNavigation
238+ }
239+ return nil
240+}
241+
242+// ValidateNavigationInventoryResponse enforces ID/operation echo, status
243+// codes, union shape, unique keys, and display safety. Targets are rejected
244+// on version-mismatch or malformed outcomes.
245+func ValidateNavigationInventoryResponse(response NavigationInventoryResponse) error {
246+ if response.RequestID == 0 || !validNavigationInventoryOperation(response.Operation) {
247+ return ErrInvalidNavigation
248+ }
249+ if !validNavigationInventoryStatus(response.Status) {
250+ return ErrInvalidNavigation
251+ }
252+ if response.Status == NavigationInventoryVersionMismatch {
253+ if response.Resolved != nil || len(response.Groups) != 0 {
254+ return ErrInvalidNavigation
255+ }
256+ return nil
257+ }
258+ if response.Operation == NavigationInventorySnapshot {
259+ if response.Resolved != nil {
260+ return ErrInvalidNavigation
261+ }
262+ return validateNavigationInventoryGroups(response.Groups, true)
263+ }
264+ if len(response.Groups) != 0 {
265+ return ErrInvalidNavigation
266+ }
267+ if response.Status != NavigationInventoryOK {
268+ if response.Resolved != nil {
269+ return ErrInvalidNavigation
270+ }
271+ return nil
272+ }
273+ if response.Resolved == nil {
274+ return ErrInvalidNavigation
275+ }
276+ return ValidateAttachTarget(*response.Resolved)
277+}
278+
279+func validateNavigationInventoryGroups(groups []NavigationInventorySourceGroup, snapshot bool) error {
280+ if len(groups) > NavigationInventoryMaxSourceGroups {
281+ return ErrInvalidNavigation
282+ }
283+ sources := make(map[string]struct{}, len(groups))
284+ for _, group := range groups {
285+ if !validInventoryKey(group.SourceKey) || !validNavigationInventorySourceStatus(group.Status) {
286+ return ErrInvalidNavigation
287+ }
288+ // Row limits are source-specific: the local source streams live
289+ // state, remote sources mirror bounded directory caches.
290+ limit := NavigationInventoryMaxRemoteEntriesPerSource
291+ if group.SourceKey == NavigationInventoryLocalSourceKey {
292+ limit = NavigationInventoryMaxLocalEntries
293+ }
294+ if len(group.Entries) > limit {
295+ return ErrInvalidNavigation
296+ }
297+ if _, dup := sources[group.SourceKey]; dup {
298+ return ErrInvalidNavigation
299+ }
300+ sources[group.SourceKey] = struct{}{}
301+ if group.Status != NavigationInventorySourceOK && len(group.Entries) != 0 {
302+ return ErrInvalidNavigation
303+ }
304+ entries := make(map[string]struct{}, len(group.Entries))
305+ for _, entry := range group.Entries {
306+ if entry.SourceKey != group.SourceKey || !validInventoryKey(entry.EntryKey) {
307+ return ErrInvalidNavigation
308+ }
309+ if _, dup := entries[entry.EntryKey]; dup {
310+ return ErrInvalidNavigation
311+ }
312+ entries[entry.EntryKey] = struct{}{}
313+ if !validInventoryDisplay(entry.Name) || !validInventoryDisplay(entry.DisplayOrigin) ||
314+ !validInventoryDisplay(entry.State) || !validInventoryDisplay(entry.Reason) {
315+ return ErrInvalidNavigation
316+ }
317+ }
318+ if !snapshot && len(group.Entries) == 0 {
319+ return ErrInvalidNavigation
320+ }
321+ }
322+ return nil
323+}
324+
325+// ValidateNavigationInventoryDemand requires a nonzero interaction.
326+func ValidateNavigationInventoryDemand(demand NavigationInventoryDemand) error {
327+ if demand.InteractionGeneration == 0 {
328+ return ErrInvalidNavigation
329+ }
330+ return nil
331+}
332+
333+// ValidateNavigationInventoryPublication requires nonzero generations and
334+// sanitized groups.
335+func ValidateNavigationInventoryPublication(publication NavigationInventoryPublication) error {
336+ if publication.InteractionGeneration == 0 || publication.PublicationGeneration == 0 {
337+ return ErrInvalidNavigation
338+ }
339+ return validateNavigationInventoryGroups(publication.Groups, true)
340+}
341+
342+// ValidateNavigationInventorySelection rejects unknown keys only at resolve
343+// time; wire validation enforces shape, nonzero generations, and key
344+// safety. The cause correlates input when present and stays zero for
345+// ordinary keyboard input, matching the optional UI-cause contract of
346+// other navigation actions.
347+func ValidateNavigationInventorySelection(selection NavigationInventorySelection) error {
348+ if selection.InteractionGeneration == 0 || selection.PublicationGeneration == 0 {
349+ return ErrInvalidNavigation
350+ }
351+ if !validInventoryKey(selection.SourceKey) || !validInventoryKey(selection.EntryKey) {
352+ return ErrInvalidNavigation
353+ }
354+ return nil
355+}
356+
357+// ValidateNavigationInventoryFailure enforces matching IDs and bounded codes.
358+func ValidateNavigationInventoryFailure(failure NavigationInventoryFailure) error {
359+ if failure.InteractionGeneration == 0 {
360+ return ErrInvalidNavigation
361+ }
362+ if !validInventoryKey(failure.SourceKey) || !validInventoryKey(failure.EntryKey) {
363+ return ErrInvalidNavigation
364+ }
365+ if !validNavigationInventoryFailureCode(failure.Code) {
366+ return ErrInvalidNavigation
367+ }
368+ return nil
369+}
A internal/protocol/navigation_inventory_test.go +70 -0
@@ -0,0 +1,70 @@
1+package protocol
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/bnema/vev/internal/domain"
8+)
9+
10+func TestValidateNavigationInventoryRequest(t *testing.T) {
11+ registration, err := domain.NewRemoteRegistration("user@arch", [16]byte{1})
12+ if err != nil {
13+ t.Fatalf("NewRemoteRegistration: %v", err)
14+ }
15+ tests := []struct {
16+ name string
17+ request NavigationInventoryRequest
18+ wantErr bool
19+ }{
20+ {name: "snapshot ok", request: NavigationInventoryRequest{Version: Version, RequestID: 1, Operation: NavigationInventorySnapshot}},
21+ {name: "zero request", request: NavigationInventoryRequest{Operation: NavigationInventorySnapshot}, wantErr: true},
22+ {name: "bad operation", request: NavigationInventoryRequest{Version: Version, RequestID: 1, Operation: 9}, wantErr: true},
23+ {name: "snapshot with keys", request: NavigationInventoryRequest{Version: Version, RequestID: 1, Operation: NavigationInventorySnapshot, SourceKey: "local"}, wantErr: true},
24+ {name: "resolve ok", request: NavigationInventoryRequest{Version: Version, RequestID: 2, Operation: NavigationInventoryResolve, SourceKey: "remote-a", EntryKey: "abc", Registration: registration}},
25+ {name: "resolve local ok", request: NavigationInventoryRequest{Version: Version, RequestID: 3, Operation: NavigationInventoryResolve, SourceKey: NavigationInventoryLocalSourceKey, EntryKey: "abc"}},
26+ {name: "resolve local with registration", request: NavigationInventoryRequest{Version: Version, RequestID: 3, Operation: NavigationInventoryResolve, SourceKey: NavigationInventoryLocalSourceKey, EntryKey: "abc", Registration: registration}, wantErr: true},
27+ {name: "resolve missing registration", request: NavigationInventoryRequest{Version: Version, RequestID: 2, Operation: NavigationInventoryResolve, SourceKey: "remote-a", EntryKey: "abc"}, wantErr: true},
28+ {name: "resolve bad key", request: NavigationInventoryRequest{Version: Version, RequestID: 2, Operation: NavigationInventoryResolve, SourceKey: "a\x1b", EntryKey: "abc", Registration: registration}, wantErr: true},
29+ }
30+ for _, tt := range tests {
31+ t.Run(tt.name, func(t *testing.T) {
32+ err := ValidateNavigationInventoryRequest(tt.request)
33+ if (err != nil) != tt.wantErr {
34+ t.Fatalf("ValidateNavigationInventoryRequest() = %v, wantErr %t", err, tt.wantErr)
35+ }
36+ })
37+ }
38+}
39+
40+func TestValidateNavigationInventoryResponseUnions(t *testing.T) {
41+ group := NavigationInventorySourceGroup{SourceKey: "local", Status: NavigationInventorySourceOK, Entries: []NavigationInventoryEntry{{SourceKey: "local", EntryKey: "a", Name: "one"}}}
42+ tests := []struct {
43+ name string
44+ response NavigationInventoryResponse
45+ wantErr bool
46+ }{
47+ {name: "snapshot ok", response: NavigationInventoryResponse{RequestID: 1, Operation: NavigationInventorySnapshot, Status: NavigationInventoryOK, Groups: []NavigationInventorySourceGroup{group}}},
48+ {name: "snapshot with target", response: NavigationInventoryResponse{RequestID: 1, Operation: NavigationInventorySnapshot, Status: NavigationInventoryOK, Groups: []NavigationInventorySourceGroup{group}, Resolved: &AttachTarget{}}, wantErr: true},
49+ {name: "duplicate source", response: NavigationInventoryResponse{RequestID: 1, Operation: NavigationInventorySnapshot, Status: NavigationInventoryOK, Groups: []NavigationInventorySourceGroup{group, group}}, wantErr: true},
50+ {name: "too many groups", response: NavigationInventoryResponse{RequestID: 1, Operation: NavigationInventorySnapshot, Status: NavigationInventoryOK, Groups: make([]NavigationInventorySourceGroup, NavigationInventoryMaxSourceGroups+1)}, wantErr: true},
51+ {name: "control char label", response: NavigationInventoryResponse{RequestID: 1, Operation: NavigationInventorySnapshot, Status: NavigationInventoryOK, Groups: []NavigationInventorySourceGroup{{SourceKey: "local", Status: NavigationInventorySourceOK, Entries: []NavigationInventoryEntry{{SourceKey: "local", EntryKey: "a", Name: "bad\x1b"}}}}}, wantErr: true},
52+ {name: "oversize label", response: NavigationInventoryResponse{RequestID: 1, Operation: NavigationInventorySnapshot, Status: NavigationInventoryOK, Groups: []NavigationInventorySourceGroup{{SourceKey: "local", Status: NavigationInventorySourceOK, Entries: []NavigationInventoryEntry{{SourceKey: "local", EntryKey: "a", Name: strings.Repeat("x", NavigationInventoryMaxDisplayBytes+1)}}}}}, wantErr: true},
53+ {name: "version mismatch clean", response: NavigationInventoryResponse{RequestID: 1, Operation: NavigationInventorySnapshot, Status: NavigationInventoryVersionMismatch}},
54+ {name: "version mismatch with target", response: NavigationInventoryResponse{RequestID: 1, Operation: NavigationInventorySnapshot, Status: NavigationInventoryVersionMismatch, Groups: []NavigationInventorySourceGroup{group}}, wantErr: true},
55+ }
56+ for _, tt := range tests {
57+ t.Run(tt.name, func(t *testing.T) {
58+ err := ValidateNavigationInventoryResponse(tt.response)
59+ if (err != nil) != tt.wantErr {
60+ t.Fatalf("ValidateNavigationInventoryResponse() = %v, wantErr %t", err, tt.wantErr)
61+ }
62+ })
63+ }
64+}
65+
66+func TestNavigationCapabilityInventoryValid(t *testing.T) {
67+ if err := ValidateNavigation(NavigationCapabilityInventory, StartupOverlayNone, false); err != nil {
68+ t.Fatalf("ValidateNavigation(inventory) = %v", err)
69+ }
70+}
M internal/protocol/session.go +1 -1
@@ -13,7 +13,7 @@ import (
1313 )
1414
1515 // Version is the negotiated vev session protocol version.
16-const Version uint16 = 41
16+const Version uint16 = 42
1717
1818 // HandshakeTimeout bounds every transport handshake from connect through the
1919 // first committed publication.
M internal/protocol/session_test.go +1 -1
@@ -10,7 +10,7 @@ import (
1010 )
1111
1212 func TestVersionRemainsWireCompatible(t *testing.T) {
13- require.Equal(t, uint16(41), Version)
13+ require.Equal(t, uint16(42), Version)
1414 require.Equal(t, (16<<20)-407, MaxOutputDataLen)
1515 }
1616
M internal/protocol/wire/frame.go +48 -42
@@ -8,50 +8,56 @@ const MaxFrameLen = 16 << 20
88 type MsgType uint8
99
1010 // Frame message types use non-contiguous allocations: the legacy client range
11-// is 1–13 and 15, and newer client controls use 32–33, 35, and 37. Server-originated
12-// messages occupy 16–23, 25–31, 34, 36, and 38. Values 14 and 24 remain reserved for
13-// future extensions.
11+// is 1–13 and 15, and newer client controls use 32–33, 35, 37, 39, 42, 45,
12+// and 47. Server-originated messages occupy 16–23, 25–31, 34, 36, 38, 40–41,
13+// and 43–44 and 46. Values 14 and 24 remain reserved for future extensions.
1414 const (
15- MsgHello MsgType = 1
16- MsgInput MsgType = 2
17- MsgResize MsgType = 3
18- MsgDetach MsgType = 4
19- MsgPing MsgType = 5
20- MsgList MsgType = 6
21- MsgKill MsgType = 7
22- MsgTheme MsgType = 8
23- MsgAck MsgType = 9
24- MsgImagePush MsgType = 10
25- MsgClientNotice MsgType = 11
26- MsgCommand MsgType = 12
27- MsgOutputResetRequest MsgType = 13
28- MsgRemotePreviewRequest MsgType = 15
29- MsgRouteAttentionSubscription MsgType = 32
30- MsgSamePeerSwitchRequest MsgType = 33
31- MsgParkedRouteRequest MsgType = 35
32- MsgSessionCreationFailure MsgType = 37
33- MsgUIFence MsgType = 39
15+ MsgHello MsgType = 1
16+ MsgInput MsgType = 2
17+ MsgResize MsgType = 3
18+ MsgDetach MsgType = 4
19+ MsgPing MsgType = 5
20+ MsgList MsgType = 6
21+ MsgKill MsgType = 7
22+ MsgTheme MsgType = 8
23+ MsgAck MsgType = 9
24+ MsgImagePush MsgType = 10
25+ MsgClientNotice MsgType = 11
26+ MsgCommand MsgType = 12
27+ MsgOutputResetRequest MsgType = 13
28+ MsgRemotePreviewRequest MsgType = 15
29+ MsgRouteAttentionSubscription MsgType = 32
30+ MsgSamePeerSwitchRequest MsgType = 33
31+ MsgParkedRouteRequest MsgType = 35
32+ MsgSessionCreationFailure MsgType = 37
33+ MsgUIFence MsgType = 39
34+ MsgNavigationInventoryRequest MsgType = 42
35+ MsgNavigationInventoryPublication MsgType = 45
36+ MsgNavigationInventoryFailure MsgType = 47
3437
35- MsgWelcome MsgType = 16
36- MsgError MsgType = 17
37- MsgOutput MsgType = 18
38- MsgDetached MsgType = 19
39- MsgPong MsgType = 20
40- MsgSessions MsgType = 21
41- MsgCommandResult MsgType = 22
42- MsgNavigationAction MsgType = 23
43- MsgAttachTarget MsgType = 25
44- MsgRemotePreviewResponse MsgType = 26
45- MsgCommittedRouteIdentity MsgType = 27
46- MsgRecentRouteSnapshot MsgType = 28
47- MsgNavigateRecentRoute MsgType = 29
48- MsgRouteNavigationFailure MsgType = 30
49- MsgRoutePosition MsgType = 31
50- MsgSamePeerSwitchFailure MsgType = 34
51- MsgParkedRouteResponse MsgType = 36
52- MsgRouteCreateSession MsgType = 38
53- MsgUIReceipt MsgType = 40
54- MsgUIViewUpdate MsgType = 41
38+ MsgWelcome MsgType = 16
39+ MsgError MsgType = 17
40+ MsgOutput MsgType = 18
41+ MsgDetached MsgType = 19
42+ MsgPong MsgType = 20
43+ MsgSessions MsgType = 21
44+ MsgCommandResult MsgType = 22
45+ MsgNavigationAction MsgType = 23
46+ MsgAttachTarget MsgType = 25
47+ MsgRemotePreviewResponse MsgType = 26
48+ MsgCommittedRouteIdentity MsgType = 27
49+ MsgRecentRouteSnapshot MsgType = 28
50+ MsgNavigateRecentRoute MsgType = 29
51+ MsgRouteNavigationFailure MsgType = 30
52+ MsgRoutePosition MsgType = 31
53+ MsgSamePeerSwitchFailure MsgType = 34
54+ MsgParkedRouteResponse MsgType = 36
55+ MsgRouteCreateSession MsgType = 38
56+ MsgUIReceipt MsgType = 40
57+ MsgUIViewUpdate MsgType = 41
58+ MsgNavigationInventoryResponse MsgType = 43
59+ MsgNavigationInventoryDemand MsgType = 44
60+ MsgNavigationInventorySelection MsgType = 46
5561 )
5662
5763 // Frame is the unit of exchange over a Transport: a typed, length-delimited
M internal/protocol/wire/frame_test.go +2 -2
@@ -7,8 +7,8 @@ import (
77 )
88
99 func TestProtocolVersion(t *testing.T) {
10- if protocol.Version != 41 {
11- t.Fatalf("ProtocolVersion = %d, want 41", protocol.Version)
10+ if protocol.Version != 42 {
11+ t.Fatalf("ProtocolVersion = %d, want 42", protocol.Version)
1212 }
1313 }
1414
A internal/protocol/wire/navigation_inventory.go +456 -0
@@ -0,0 +1,456 @@
1+package wire
2+
3+import (
4+ "encoding/binary"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/protocol"
8+)
9+
10+// Navigation inventory wire bounds. Complete encoded response/publication
11+// stays below MaxFrameLen; 4 MiB is the documented export budget.
12+const navigationInventoryMaxEncodedBytes = 4 << 20
13+
14+// PeekNavigationInventoryVersion returns the leading protocol version from a
15+// NavigationInventoryRequest payload, mirroring the Hello/Command peekers.
16+func PeekNavigationInventoryVersion(b []byte) (uint16, bool) {
17+ if len(b) < 2 {
18+ return 0, false
19+ }
20+ return binary.BigEndian.Uint16(b), true
21+}
22+
23+// PeekNavigationInventoryRequestID returns the request ID following the
24+// leading version in a NavigationInventoryRequest payload.
25+func PeekNavigationInventoryRequestID(b []byte) (uint64, bool) {
26+ if len(b) < 10 {
27+ return 0, false
28+ }
29+ return binary.BigEndian.Uint64(b[2:10]), true
30+}
31+
32+func marshalInventoryRegistration(w *payloadWriter, registration domain.RemoteRegistration) {
33+ w.putString(registration.Endpoint)
34+ w.putBytes(registration.Incarnation[:])
35+ w.putUint64(uint64(registration.Generation))
36+}
37+
38+func unmarshalInventoryRegistration(r *payloadReader) (domain.RemoteRegistration, error) {
39+ var registration domain.RemoteRegistration
40+ var err error
41+ if registration.Endpoint, err = r.getString(); err != nil {
42+ return domain.RemoteRegistration{}, err
43+ }
44+ incarnation, err := r.getBytes(16)
45+ if err != nil {
46+ return domain.RemoteRegistration{}, err
47+ }
48+ copy(registration.Incarnation[:], incarnation)
49+ generation, err := r.getUint64()
50+ if err != nil {
51+ return domain.RemoteRegistration{}, err
52+ }
53+ registration.Generation = domain.RemoteGeneration(generation)
54+ // Zero registrations decode structurally: local resolve carries no
55+ // registration, and the semantic union validator enforces the
56+ // operation/source coupling afterward.
57+ if !registration.IsZero() {
58+ if err := registration.Validate(); err != nil {
59+ return domain.RemoteRegistration{}, err
60+ }
61+ }
62+ return registration, nil
63+}
64+
65+func marshalInventoryGroups(w *payloadWriter, groups []protocol.NavigationInventorySourceGroup) bool {
66+ if len(groups) > protocol.NavigationInventoryMaxSourceGroups {
67+ return false
68+ }
69+ w.putUint16(uint16(len(groups)))
70+ for _, group := range groups {
71+ w.putString(group.SourceKey)
72+ w.putUint8(uint8(group.Status))
73+ if group.Status != protocol.NavigationInventorySourceOK {
74+ w.putUint32(0)
75+ continue
76+ }
77+ if len(group.Entries) > inventoryGroupEntryLimit(group.SourceKey) {
78+ return false
79+ }
80+ w.putUint32(uint32(len(group.Entries)))
81+ for _, entry := range group.Entries {
82+ w.putString(entry.EntryKey)
83+ w.putString(entry.Name)
84+ w.putString(entry.DisplayOrigin)
85+ w.putString(entry.State)
86+ w.putString(entry.Reason)
87+ }
88+ }
89+ return true
90+}
91+
92+// inventoryGroupEntryLimit mirrors semantic validation: the local source
93+// streams live state, remote sources mirror bounded directory caches.
94+func inventoryGroupEntryLimit(sourceKey string) int {
95+ if sourceKey == protocol.NavigationInventoryLocalSourceKey {
96+ return protocol.NavigationInventoryMaxLocalEntries
97+ }
98+ return protocol.NavigationInventoryMaxRemoteEntriesPerSource
99+}
100+
101+func unmarshalInventoryGroups(r *payloadReader) ([]protocol.NavigationInventorySourceGroup, error) {
102+ count, err := r.getUint16()
103+ if err != nil {
104+ return nil, err
105+ }
106+ if int(count) > protocol.NavigationInventoryMaxSourceGroups {
107+ return nil, protocol.ErrInvalidNavigation
108+ }
109+ groups := make([]protocol.NavigationInventorySourceGroup, 0, int(count))
110+ for range int(count) {
111+ var group protocol.NavigationInventorySourceGroup
112+ if group.SourceKey, err = r.getString(); err != nil {
113+ return nil, err
114+ }
115+ status, err := r.getUint8()
116+ if err != nil {
117+ return nil, err
118+ }
119+ group.Status = protocol.NavigationInventorySourceStatus(status)
120+ entryCount, err := r.getUint32()
121+ if err != nil {
122+ return nil, err
123+ }
124+ if entryCount > uint32(inventoryGroupEntryLimit(group.SourceKey)) {
125+ return nil, protocol.ErrInvalidNavigation
126+ }
127+ if group.Status != protocol.NavigationInventorySourceOK && entryCount != 0 {
128+ return nil, protocol.ErrInvalidNavigation
129+ }
130+ if entryCount > 0 {
131+ group.Entries = make([]protocol.NavigationInventoryEntry, 0, int(entryCount))
132+ for range int(entryCount) {
133+ var entry protocol.NavigationInventoryEntry
134+ entry.SourceKey = group.SourceKey
135+ if entry.EntryKey, err = r.getString(); err != nil {
136+ return nil, err
137+ }
138+ if entry.Name, err = r.getString(); err != nil {
139+ return nil, err
140+ }
141+ if entry.DisplayOrigin, err = r.getString(); err != nil {
142+ return nil, err
143+ }
144+ if entry.State, err = r.getString(); err != nil {
145+ return nil, err
146+ }
147+ if entry.Reason, err = r.getString(); err != nil {
148+ return nil, err
149+ }
150+ group.Entries = append(group.Entries, entry)
151+ }
152+ }
153+ groups = append(groups, group)
154+ }
155+ return groups, nil
156+}
157+
158+// MarshalNavigationInventoryRequest encodes a control-source query. Version
159+// stays first so the version peeker works.
160+func MarshalNavigationInventoryRequest(request protocol.NavigationInventoryRequest) []byte {
161+ if protocol.ValidateNavigationInventoryRequest(request) != nil {
162+ return nil
163+ }
164+ w := payloadWriter{}
165+ w.putUint16(request.Version)
166+ w.putUint64(request.RequestID)
167+ w.putUint8(uint8(request.Operation))
168+ if request.Operation == protocol.NavigationInventoryResolve {
169+ w.putString(request.SourceKey)
170+ w.putString(request.EntryKey)
171+ marshalInventoryRegistration(&w, request.Registration)
172+ }
173+ if len(w.b) > navigationInventoryMaxEncodedBytes {
174+ return nil
175+ }
176+ return w.b
177+}
178+
179+// UnmarshalNavigationInventoryRequest decodes a strict control-source query.
180+func UnmarshalNavigationInventoryRequest(data []byte) (protocol.NavigationInventoryRequest, error) {
181+ r := payloadReader{b: data}
182+ var request protocol.NavigationInventoryRequest
183+ var err error
184+ if request.Version, err = r.getUint16(); err != nil {
185+ return protocol.NavigationInventoryRequest{}, err
186+ }
187+ if request.RequestID, err = r.getUint64(); err != nil {
188+ return protocol.NavigationInventoryRequest{}, err
189+ }
190+ operation, err := r.getUint8()
191+ if err != nil {
192+ return protocol.NavigationInventoryRequest{}, err
193+ }
194+ request.Operation = protocol.NavigationInventoryOperation(operation)
195+ if request.Operation == protocol.NavigationInventoryResolve {
196+ if request.SourceKey, err = r.getString(); err != nil {
197+ return protocol.NavigationInventoryRequest{}, err
198+ }
199+ if request.EntryKey, err = r.getString(); err != nil {
200+ return protocol.NavigationInventoryRequest{}, err
201+ }
202+ if request.Registration, err = unmarshalInventoryRegistration(&r); err != nil {
203+ return protocol.NavigationInventoryRequest{}, protocol.ErrInvalidNavigation
204+ }
205+ }
206+ if err := r.done(); err != nil {
207+ return protocol.NavigationInventoryRequest{}, err
208+ }
209+ if err := protocol.ValidateNavigationInventoryRequest(request); err != nil {
210+ return protocol.NavigationInventoryRequest{}, err
211+ }
212+ return request, nil
213+}
214+
215+// MarshalNavigationInventoryResponse encodes a source answer. Snapshot
216+// carries groups; resolve carries an AttachTarget; never both.
217+func MarshalNavigationInventoryResponse(response protocol.NavigationInventoryResponse) []byte {
218+ if protocol.ValidateNavigationInventoryResponse(response) != nil {
219+ return nil
220+ }
221+ w := payloadWriter{}
222+ w.putUint64(response.RequestID)
223+ w.putUint8(uint8(response.Operation))
224+ w.putUint8(uint8(response.Status))
225+ if response.Operation == protocol.NavigationInventorySnapshot && response.Status != protocol.NavigationInventoryVersionMismatch {
226+ if !marshalInventoryGroups(&w, response.Groups) {
227+ return nil
228+ }
229+ } else if response.Operation == protocol.NavigationInventoryResolve && response.Status == protocol.NavigationInventoryOK && response.Resolved != nil {
230+ inner := MarshalAttachTarget(*response.Resolved)
231+ if inner == nil {
232+ return nil
233+ }
234+ w.putLongBytes(inner)
235+ }
236+ if len(w.b) > navigationInventoryMaxEncodedBytes {
237+ return nil
238+ }
239+ return w.b
240+}
241+
242+// UnmarshalNavigationInventoryResponse decodes a strict source answer.
243+func UnmarshalNavigationInventoryResponse(data []byte) (protocol.NavigationInventoryResponse, error) {
244+ if len(data) > navigationInventoryMaxEncodedBytes {
245+ return protocol.NavigationInventoryResponse{}, protocol.ErrInvalidNavigation
246+ }
247+ r := payloadReader{b: data}
248+ var response protocol.NavigationInventoryResponse
249+ var err error
250+ if response.RequestID, err = r.getUint64(); err != nil {
251+ return protocol.NavigationInventoryResponse{}, err
252+ }
253+ operation, err := r.getUint8()
254+ if err != nil {
255+ return protocol.NavigationInventoryResponse{}, err
256+ }
257+ response.Operation = protocol.NavigationInventoryOperation(operation)
258+ status, err := r.getUint8()
259+ if err != nil {
260+ return protocol.NavigationInventoryResponse{}, err
261+ }
262+ response.Status = protocol.NavigationInventoryStatus(status)
263+ if response.Status == protocol.NavigationInventoryVersionMismatch {
264+ if err := r.done(); err != nil {
265+ return protocol.NavigationInventoryResponse{}, err
266+ }
267+ if err := protocol.ValidateNavigationInventoryResponse(response); err != nil {
268+ return protocol.NavigationInventoryResponse{}, err
269+ }
270+ return response, nil
271+ }
272+ if response.Operation == protocol.NavigationInventorySnapshot {
273+ if response.Groups, err = unmarshalInventoryGroups(&r); err != nil {
274+ return protocol.NavigationInventoryResponse{}, err
275+ }
276+ } else if response.Status == protocol.NavigationInventoryOK {
277+ inner, err := r.getLongBytes()
278+ if err != nil {
279+ return protocol.NavigationInventoryResponse{}, err
280+ }
281+ target, err := UnmarshalAttachTarget(inner)
282+ if err != nil {
283+ return protocol.NavigationInventoryResponse{}, protocol.ErrInvalidNavigation
284+ }
285+ response.Resolved = &target
286+ }
287+ if err := r.done(); err != nil {
288+ return protocol.NavigationInventoryResponse{}, err
289+ }
290+ if err := protocol.ValidateNavigationInventoryResponse(response); err != nil {
291+ return protocol.NavigationInventoryResponse{}, err
292+ }
293+ return response, nil
294+}
295+
296+// MarshalNavigationInventoryDemand encodes a serving-daemon open/close flag.
297+func MarshalNavigationInventoryDemand(demand protocol.NavigationInventoryDemand) []byte {
298+ if protocol.ValidateNavigationInventoryDemand(demand) != nil {
299+ return nil
300+ }
301+ w := payloadWriter{}
302+ w.putUint64(demand.InteractionGeneration)
303+ w.putBool(demand.Open)
304+ return w.b
305+}
306+
307+// UnmarshalNavigationInventoryDemand decodes a strict demand message.
308+func UnmarshalNavigationInventoryDemand(data []byte) (protocol.NavigationInventoryDemand, error) {
309+ r := payloadReader{b: data}
310+ var demand protocol.NavigationInventoryDemand
311+ var err error
312+ if demand.InteractionGeneration, err = r.getUint64(); err != nil {
313+ return protocol.NavigationInventoryDemand{}, err
314+ }
315+ if demand.Open, err = r.getBool(); err != nil {
316+ return protocol.NavigationInventoryDemand{}, err
317+ }
318+ if err := r.done(); err != nil {
319+ return protocol.NavigationInventoryDemand{}, err
320+ }
321+ if err := protocol.ValidateNavigationInventoryDemand(demand); err != nil {
322+ return protocol.NavigationInventoryDemand{}, err
323+ }
324+ return demand, nil
325+}
326+
327+// MarshalNavigationInventoryPublication encodes sanitized source groups.
328+func MarshalNavigationInventoryPublication(publication protocol.NavigationInventoryPublication) []byte {
329+ if protocol.ValidateNavigationInventoryPublication(publication) != nil {
330+ return nil
331+ }
332+ w := payloadWriter{}
333+ w.putUint64(publication.InteractionGeneration)
334+ w.putUint64(publication.PublicationGeneration)
335+ if !marshalInventoryGroups(&w, publication.Groups) {
336+ return nil
337+ }
338+ if len(w.b) > navigationInventoryMaxEncodedBytes {
339+ return nil
340+ }
341+ return w.b
342+}
343+
344+// UnmarshalNavigationInventoryPublication decodes a strict publication.
345+func UnmarshalNavigationInventoryPublication(data []byte) (protocol.NavigationInventoryPublication, error) {
346+ if len(data) > navigationInventoryMaxEncodedBytes {
347+ return protocol.NavigationInventoryPublication{}, protocol.ErrInvalidNavigation
348+ }
349+ r := payloadReader{b: data}
350+ var publication protocol.NavigationInventoryPublication
351+ var err error
352+ if publication.InteractionGeneration, err = r.getUint64(); err != nil {
353+ return protocol.NavigationInventoryPublication{}, err
354+ }
355+ if publication.PublicationGeneration, err = r.getUint64(); err != nil {
356+ return protocol.NavigationInventoryPublication{}, err
357+ }
358+ if publication.Groups, err = unmarshalInventoryGroups(&r); err != nil {
359+ return protocol.NavigationInventoryPublication{}, err
360+ }
361+ if err := r.done(); err != nil {
362+ return protocol.NavigationInventoryPublication{}, err
363+ }
364+ if err := protocol.ValidateNavigationInventoryPublication(publication); err != nil {
365+ return protocol.NavigationInventoryPublication{}, err
366+ }
367+ return publication, nil
368+}
369+
370+// MarshalNavigationInventorySelection encodes a key-only selection.
371+func MarshalNavigationInventorySelection(selection protocol.NavigationInventorySelection) []byte {
372+ if protocol.ValidateNavigationInventorySelection(selection) != nil {
373+ return nil
374+ }
375+ w := payloadWriter{}
376+ w.putUint64(selection.CauseActionID)
377+ w.putUint64(selection.InteractionGeneration)
378+ w.putUint64(selection.PublicationGeneration)
379+ w.putString(selection.SourceKey)
380+ w.putString(selection.EntryKey)
381+ return w.b
382+}
383+
384+// UnmarshalNavigationInventorySelection decodes a strict selection.
385+func UnmarshalNavigationInventorySelection(data []byte) (protocol.NavigationInventorySelection, error) {
386+ r := payloadReader{b: data}
387+ var selection protocol.NavigationInventorySelection
388+ var err error
389+ if selection.CauseActionID, err = r.getUint64(); err != nil {
390+ return protocol.NavigationInventorySelection{}, err
391+ }
392+ if selection.InteractionGeneration, err = r.getUint64(); err != nil {
393+ return protocol.NavigationInventorySelection{}, err
394+ }
395+ if selection.PublicationGeneration, err = r.getUint64(); err != nil {
396+ return protocol.NavigationInventorySelection{}, err
397+ }
398+ if selection.SourceKey, err = r.getString(); err != nil {
399+ return protocol.NavigationInventorySelection{}, err
400+ }
401+ if selection.EntryKey, err = r.getString(); err != nil {
402+ return protocol.NavigationInventorySelection{}, err
403+ }
404+ if err := r.done(); err != nil {
405+ return protocol.NavigationInventorySelection{}, err
406+ }
407+ if err := protocol.ValidateNavigationInventorySelection(selection); err != nil {
408+ return protocol.NavigationInventorySelection{}, err
409+ }
410+ return selection, nil
411+}
412+
413+// MarshalNavigationInventoryFailure encodes a bounded failure code.
414+func MarshalNavigationInventoryFailure(failure protocol.NavigationInventoryFailure) []byte {
415+ if protocol.ValidateNavigationInventoryFailure(failure) != nil {
416+ return nil
417+ }
418+ w := payloadWriter{}
419+ w.putUint64(failure.CauseActionID)
420+ w.putUint64(failure.InteractionGeneration)
421+ w.putString(failure.SourceKey)
422+ w.putString(failure.EntryKey)
423+ w.putUint8(uint8(failure.Code))
424+ return w.b
425+}
426+
427+// UnmarshalNavigationInventoryFailure decodes a strict failure message.
428+func UnmarshalNavigationInventoryFailure(data []byte) (protocol.NavigationInventoryFailure, error) {
429+ r := payloadReader{b: data}
430+ var failure protocol.NavigationInventoryFailure
431+ var err error
432+ if failure.CauseActionID, err = r.getUint64(); err != nil {
433+ return protocol.NavigationInventoryFailure{}, err
434+ }
435+ if failure.InteractionGeneration, err = r.getUint64(); err != nil {
436+ return protocol.NavigationInventoryFailure{}, err
437+ }
438+ if failure.SourceKey, err = r.getString(); err != nil {
439+ return protocol.NavigationInventoryFailure{}, err
440+ }
441+ if failure.EntryKey, err = r.getString(); err != nil {
442+ return protocol.NavigationInventoryFailure{}, err
443+ }
444+ code, err := r.getUint8()
445+ if err != nil {
446+ return protocol.NavigationInventoryFailure{}, err
447+ }
448+ failure.Code = protocol.NavigationInventoryFailureCode(code)
449+ if err := r.done(); err != nil {
450+ return protocol.NavigationInventoryFailure{}, err
451+ }
452+ if err := protocol.ValidateNavigationInventoryFailure(failure); err != nil {
453+ return protocol.NavigationInventoryFailure{}, err
454+ }
455+ return failure, nil
456+}
A internal/protocol/wire/navigation_inventory_test.go +165 -0
@@ -0,0 +1,165 @@
1+package wire
2+
3+import (
4+ "strconv"
5+ "strings"
6+ "testing"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/protocol"
10+)
11+
12+func inventoryRegistrationForTest() domain.RemoteRegistration {
13+ registration, err := domain.NewRemoteRegistration("user@arch", [16]byte{1, 2, 3})
14+ if err != nil {
15+ panic(err)
16+ }
17+ return registration
18+}
19+
20+func TestNavigationInventoryRequestRoundTrip(t *testing.T) {
21+ tests := []struct {
22+ name string
23+ value protocol.NavigationInventoryRequest
24+ }{
25+ {name: "snapshot", value: protocol.NavigationInventoryRequest{Version: protocol.Version, RequestID: 1, Operation: protocol.NavigationInventorySnapshot}},
26+ {name: "resolve", value: protocol.NavigationInventoryRequest{Version: protocol.Version, RequestID: 2, Operation: protocol.NavigationInventoryResolve, SourceKey: "remote-a", EntryKey: "abc", Registration: inventoryRegistrationForTest()}},
27+ {name: "local resolve carries zero registration", value: protocol.NavigationInventoryRequest{Version: protocol.Version, RequestID: 3, Operation: protocol.NavigationInventoryResolve, SourceKey: "local", EntryKey: "abc"}},
28+ }
29+ for _, tt := range tests {
30+ t.Run(tt.name, func(t *testing.T) {
31+ payload := MarshalNavigationInventoryRequest(tt.value)
32+ if payload == nil {
33+ t.Fatal("MarshalNavigationInventoryRequest returned nil")
34+ }
35+ if version, ok := PeekNavigationInventoryVersion(payload); !ok || version != tt.value.Version {
36+ t.Fatalf("PeekNavigationInventoryVersion() = %d, %v; want %d, true", version, ok, tt.value.Version)
37+ }
38+ if id, ok := PeekNavigationInventoryRequestID(payload); !ok || id != tt.value.RequestID {
39+ t.Fatalf("PeekNavigationInventoryRequestID() = %d, %v; want %d, true", id, ok, tt.value.RequestID)
40+ }
41+ decoded, err := UnmarshalNavigationInventoryRequest(payload)
42+ if err != nil {
43+ t.Fatalf("UnmarshalNavigationInventoryRequest() error = %v", err)
44+ }
45+ if decoded != tt.value {
46+ t.Fatalf("UnmarshalNavigationInventoryRequest() = %#v, want %#v", decoded, tt.value)
47+ }
48+ assertAllPrefixesFail(t, payload, UnmarshalNavigationInventoryRequest)
49+ assertTrailingGarbageFails(t, payload, UnmarshalNavigationInventoryRequest)
50+ })
51+ }
52+ if MarshalNavigationInventoryRequest(protocol.NavigationInventoryRequest{}) != nil {
53+ t.Fatal("MarshalNavigationInventoryRequest accepted zero request")
54+ }
55+ if MarshalNavigationInventoryRequest(protocol.NavigationInventoryRequest{Version: protocol.Version, RequestID: 1, Operation: protocol.NavigationInventorySnapshot, SourceKey: "local"}) != nil {
56+ t.Fatal("MarshalNavigationInventoryRequest accepted snapshot with keys")
57+ }
58+}
59+
60+func TestNavigationInventoryResponseRoundTrip(t *testing.T) {
61+ group := protocol.NavigationInventorySourceGroup{SourceKey: "local", Status: protocol.NavigationInventorySourceOK, Entries: []protocol.NavigationInventoryEntry{{SourceKey: "local", EntryKey: "a", Name: "one", DisplayOrigin: "local", State: "up"}}}
62+ target := protocol.AttachTarget{Session: "work", Intent: protocol.IntentAttach}
63+ tests := []struct {
64+ name string
65+ value protocol.NavigationInventoryResponse
66+ }{
67+ {name: "snapshot", value: protocol.NavigationInventoryResponse{RequestID: 1, Operation: protocol.NavigationInventorySnapshot, Status: protocol.NavigationInventoryOK, Groups: []protocol.NavigationInventorySourceGroup{group}}},
68+ {name: "unavailable", value: protocol.NavigationInventoryResponse{RequestID: 2, Operation: protocol.NavigationInventorySnapshot, Status: protocol.NavigationInventoryUnavailable, Groups: []protocol.NavigationInventorySourceGroup{{SourceKey: "remote", Status: protocol.NavigationInventorySourceUnavailable}}}},
69+ {name: "resolve", value: protocol.NavigationInventoryResponse{RequestID: 3, Operation: protocol.NavigationInventoryResolve, Status: protocol.NavigationInventoryOK, Resolved: &target}},
70+ {name: "version mismatch", value: protocol.NavigationInventoryResponse{RequestID: 4, Operation: protocol.NavigationInventorySnapshot, Status: protocol.NavigationInventoryVersionMismatch}},
71+ }
72+ for _, tt := range tests {
73+ t.Run(tt.name, func(t *testing.T) {
74+ payload := MarshalNavigationInventoryResponse(tt.value)
75+ if payload == nil {
76+ t.Fatal("MarshalNavigationInventoryResponse returned nil")
77+ }
78+ decoded, err := UnmarshalNavigationInventoryResponse(payload)
79+ if err != nil {
80+ t.Fatalf("UnmarshalNavigationInventoryResponse() error = %v", err)
81+ }
82+ if decoded.RequestID != tt.value.RequestID || decoded.Operation != tt.value.Operation || decoded.Status != tt.value.Status {
83+ t.Fatalf("UnmarshalNavigationInventoryResponse() = %#v, want %#v", decoded, tt.value)
84+ }
85+ assertAllPrefixesFail(t, payload, UnmarshalNavigationInventoryResponse)
86+ assertTrailingGarbageFails(t, payload, UnmarshalNavigationInventoryResponse)
87+ })
88+ }
89+ bad := protocol.NavigationInventoryResponse{RequestID: 1, Operation: protocol.NavigationInventorySnapshot, Status: protocol.NavigationInventoryOK, Groups: []protocol.NavigationInventorySourceGroup{{SourceKey: "local", Status: protocol.NavigationInventorySourceOK, Entries: []protocol.NavigationInventoryEntry{{SourceKey: "local", EntryKey: "a", Name: "bad\x1b"}}}}}
90+ if MarshalNavigationInventoryResponse(bad) != nil {
91+ t.Fatal("MarshalNavigationInventoryResponse accepted control character")
92+ }
93+ oversize := protocol.NavigationInventoryResponse{RequestID: 1, Operation: protocol.NavigationInventorySnapshot, Status: protocol.NavigationInventoryOK, Groups: []protocol.NavigationInventorySourceGroup{{SourceKey: "local", Status: protocol.NavigationInventorySourceOK, Entries: []protocol.NavigationInventoryEntry{{SourceKey: "local", EntryKey: "a", Name: strings.Repeat("x", protocol.NavigationInventoryMaxDisplayBytes+1)}}}}}
94+ if MarshalNavigationInventoryResponse(oversize) != nil {
95+ t.Fatal("MarshalNavigationInventoryResponse accepted oversize label")
96+ }
97+ remoteEntries := func(n int) []protocol.NavigationInventoryEntry {
98+ entries := make([]protocol.NavigationInventoryEntry, 0, n)
99+ for i := 0; i < n; i++ {
100+ entries = append(entries, protocol.NavigationInventoryEntry{SourceKey: "remote-a", EntryKey: "e/" + strconv.Itoa(i), Name: "n"})
101+ }
102+ return entries
103+ }
104+ atLimit := protocol.NavigationInventoryResponse{RequestID: 5, Operation: protocol.NavigationInventorySnapshot, Status: protocol.NavigationInventoryOK, Groups: []protocol.NavigationInventorySourceGroup{{SourceKey: "remote-a", Status: protocol.NavigationInventorySourceOK, Entries: remoteEntries(protocol.NavigationInventoryMaxRemoteEntriesPerSource)}}}
105+ if payload := MarshalNavigationInventoryResponse(atLimit); payload == nil {
106+ t.Fatal("MarshalNavigationInventoryResponse rejected a remote group at its row limit")
107+ } else if _, err := UnmarshalNavigationInventoryResponse(payload); err != nil {
108+ t.Fatalf("UnmarshalNavigationInventoryResponse() error = %v", err)
109+ }
110+ overLimit := protocol.NavigationInventoryResponse{RequestID: 6, Operation: protocol.NavigationInventorySnapshot, Status: protocol.NavigationInventoryOK, Groups: []protocol.NavigationInventorySourceGroup{{SourceKey: "remote-a", Status: protocol.NavigationInventorySourceOK, Entries: remoteEntries(protocol.NavigationInventoryMaxRemoteEntriesPerSource + 1)}}}
111+ if MarshalNavigationInventoryResponse(overLimit) != nil {
112+ t.Fatal("MarshalNavigationInventoryResponse accepted a remote group past its row limit")
113+ }
114+}
115+
116+func TestNavigationInventoryDemandPublicationSelectionFailureRoundTrip(t *testing.T) {
117+ group := protocol.NavigationInventorySourceGroup{SourceKey: "local", Status: protocol.NavigationInventorySourceOK, Entries: []protocol.NavigationInventoryEntry{{SourceKey: "local", EntryKey: "a", Name: "one"}}}
118+ demand := protocol.NavigationInventoryDemand{InteractionGeneration: 3, Open: true}
119+ if payload := MarshalNavigationInventoryDemand(demand); payload == nil {
120+ t.Fatal("MarshalNavigationInventoryDemand returned nil")
121+ } else {
122+ decoded, err := UnmarshalNavigationInventoryDemand(payload)
123+ if err != nil || decoded != demand {
124+ t.Fatalf("UnmarshalNavigationInventoryDemand() = %#v, %v; want %#v", decoded, err, demand)
125+ }
126+ assertAllPrefixesFail(t, payload, UnmarshalNavigationInventoryDemand)
127+ assertTrailingGarbageFails(t, payload, UnmarshalNavigationInventoryDemand)
128+ }
129+ publication := protocol.NavigationInventoryPublication{InteractionGeneration: 3, PublicationGeneration: 1, Groups: []protocol.NavigationInventorySourceGroup{group}}
130+ if payload := MarshalNavigationInventoryPublication(publication); payload == nil {
131+ t.Fatal("MarshalNavigationInventoryPublication returned nil")
132+ } else {
133+ decoded, err := UnmarshalNavigationInventoryPublication(payload)
134+ if err != nil {
135+ t.Fatalf("UnmarshalNavigationInventoryPublication() error = %v", err)
136+ }
137+ if decoded.InteractionGeneration != publication.InteractionGeneration || decoded.PublicationGeneration != publication.PublicationGeneration {
138+ t.Fatalf("UnmarshalNavigationInventoryPublication() = %#v, want %#v", decoded, publication)
139+ }
140+ assertAllPrefixesFail(t, payload, UnmarshalNavigationInventoryPublication)
141+ assertTrailingGarbageFails(t, payload, UnmarshalNavigationInventoryPublication)
142+ }
143+ selection := protocol.NavigationInventorySelection{CauseActionID: 9, InteractionGeneration: 3, PublicationGeneration: 1, SourceKey: "local", EntryKey: "a"}
144+ if payload := MarshalNavigationInventorySelection(selection); payload == nil {
145+ t.Fatal("MarshalNavigationInventorySelection returned nil")
146+ } else {
147+ decoded, err := UnmarshalNavigationInventorySelection(payload)
148+ if err != nil || decoded != selection {
149+ t.Fatalf("UnmarshalNavigationInventorySelection() = %#v, %v; want %#v", decoded, err, selection)
150+ }
151+ assertAllPrefixesFail(t, payload, UnmarshalNavigationInventorySelection)
152+ assertTrailingGarbageFails(t, payload, UnmarshalNavigationInventorySelection)
153+ }
154+ failure := protocol.NavigationInventoryFailure{CauseActionID: 9, InteractionGeneration: 3, SourceKey: "local", EntryKey: "a", Code: protocol.NavigationInventoryStaleIdentity}
155+ if payload := MarshalNavigationInventoryFailure(failure); payload == nil {
156+ t.Fatal("MarshalNavigationInventoryFailure returned nil")
157+ } else {
158+ decoded, err := UnmarshalNavigationInventoryFailure(payload)
159+ if err != nil || decoded != failure {
160+ t.Fatalf("UnmarshalNavigationInventoryFailure() = %#v, %v; want %#v", decoded, err, failure)
161+ }
162+ assertAllPrefixesFail(t, payload, UnmarshalNavigationInventoryFailure)
163+ assertTrailingGarbageFails(t, payload, UnmarshalNavigationInventoryFailure)
164+ }
165+}
M internal/protocol/wire/navigation_test.go +2 -2
@@ -153,10 +153,10 @@ func TestHelloNavigationValidationTable(t *testing.T) {
153153 {name: "client-owned home picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityHomePicker}, valid: false},
154154 {name: "daemon-owned back picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, valid: false},
155155 {name: "remote-target back picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, valid: false},
156- {name: "back with startup picker", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: true, capability: protocol.NavigationCapabilityBack, overlay: protocol.StartupOverlaySessionPicker, wantPayload: "002902" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "00000000" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "02010000"},
156+ {name: "back with startup picker", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: true, capability: protocol.NavigationCapabilityBack, overlay: protocol.StartupOverlaySessionPicker, wantPayload: "002a02" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "00000000" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "02010000"},
157157 {name: "new accepts retained home picker", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityHomePicker}, valid: true, capability: protocol.NavigationCapabilityHomePicker},
158158 {name: "new rejects back navigation", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: false},
159- {name: "unknown capability", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false},
159+ {name: "unknown capability", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 8}, valid: false},
160160 {name: "back without picker", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack}, valid: false},
161161 {name: "picker without back", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: false},
162162 }
M internal/protocol/wire/wire_final_test.go +3 -3
@@ -12,8 +12,8 @@ import (
1212 )
1313
1414 func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
15- if protocol.Version != 41 {
16- t.Fatalf("ProtocolVersion = %d, want 41", protocol.Version)
15+ if protocol.Version != 42 {
16+ t.Fatalf("ProtocolVersion = %d, want 42", protocol.Version)
1717 }
1818 payload := MarshalHello(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}})
1919 if len(payload) < 2 {
@@ -27,7 +27,7 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
2727
2828 func TestFinalHelloGoldenStrict(t *testing.T) {
2929 msg := protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}
30- want := append([]byte{0, 41, 2}, make([]byte, 16+8)...)
30+ want := append([]byte{0, 42, 2}, make([]byte, 16+8)...)
3131 want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
3232 want = append(want, 0, 0)
3333 got := MarshalHello(msg)
M internal/protocol/wire/wire_test.go +6 -6
@@ -136,7 +136,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
136136 }
137137 got := MarshalHello(msg)
138138 want := []byte{
139- 0x00, 0x29, 0x00, // version, intent, first client ID byte
139+ 0x00, 0x2a, 0x00, // version, intent, first client ID byte
140140 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
141141 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
142142 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
@@ -367,7 +367,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T)
367367 }
368368 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
369369 require.Equal(t, want, MarshalTheme(cleared))
370- require.Equal(t, uint16(41), protocol.Version)
370+ require.Equal(t, uint16(42), protocol.Version)
371371 }
372372
373373 func TestResizeGoldenAndRoundTrip(t *testing.T) {
@@ -528,7 +528,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
528528 name: "minimal",
529529 msg: protocol.CommandRequest{Version: protocol.Version, Slug: "split-right"},
530530 want: []byte{
531- 0x00, 0x29,
531+ 0x00, 0x2a,
532532 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
533533 0x00, // attached
534534 0x00, // self
@@ -551,7 +551,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
551551 JSON: true,
552552 },
553553 want: []byte{
554- 0x00, 0x29,
554+ 0x00, 0x2a,
555555 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
556556 0x00, // attached
557557 0x01, // self
@@ -999,7 +999,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
999999 name: "attached",
10001000 msg: protocol.CommandRequest{Version: protocol.Version, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},
10011001 want: []byte{
1002- 0x00, 0x29, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
1002+ 0x00, 0x2a, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
10031003 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
10041004 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',
10051005 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -1009,7 +1009,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
10091009 name: "control",
10101010 msg: protocol.CommandRequest{Version: protocol.Version, Slug: "ls"},
10111011 want: []byte{
1012- 0x00, 0x29, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
1012+ 0x00, 0x2a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
10131013 0x00, 0x02, 'l', 's', 0x00, 0x00,
10141014 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
10151015 },
M internal/usecase/client/client.go +405 -70
@@ -169,6 +169,14 @@ type Dependencies struct {
169169 // AttachHandoff keeps one Runner and one terminal/input ownership across a
170170 // local daemon's structured remote handoff. It is nil for direct CLI attach.
171171 AttachHandoff AttachHandoffFunc
172+ // LocalControlDialer is an optional dial-only control source for the
173+ // local daemon inventory. It must never start the daemon, create an
174+ // attachment, or call the CLI: a missing socket reports local control
175+ // unavailability. The relay enables it only after this client has a
176+ // committed explicitly local route and while its serving attachment is
177+ // remote; local-only and direct remote-only clients leave it nil and
178+ // perform zero control dials.
179+ LocalControlDialer ports.ClientDialer
172180 // Remote selects client-side carriage presentation only; it never enters
173181 // the daemon-facing session request.
174182 Remote bool
@@ -214,6 +222,12 @@ type attachRoute struct {
214222 type attachHandoff struct {
215223 target protocol.AttachTarget
216224 source attachRoute
225+ // Run captures inventory recovery from its rebased serving request.
226+ inventory bool
227+ // inventorySelection identifies the serving palette selection behind
228+ // an inventory handoff. Run retains it so destination failure can
229+ // report NavigationInventoryNavigationFailed after restoration.
230+ inventorySelection protocol.NavigationInventorySelection
217231 }
218232
219233 func bindAttachHandoff(target protocol.AttachTarget, source attachRoute) *attachHandoff {
@@ -232,10 +246,16 @@ type Runner struct {
232246 remoteHostLearner ports.RemoteHostLearner
233247 attachHandoff AttachHandoffFunc
234248 remote bool
235- origin protocol.RouteOrigin
236- ledger *routeLedger
237- routeFailure *protocol.RouteNavigationFailure
238- creationFailure *protocol.SessionCreationFailure
249+ // localControlDialer is the dial-only inventory source. It never
250+ // starts the daemon; the relay is its sole consumer.
251+ localControlDialer ports.ClientDialer
252+ origin protocol.RouteOrigin
253+ ledger *routeLedger
254+ routeFailure *protocol.RouteNavigationFailure
255+ creationFailure *protocol.SessionCreationFailure
256+ // inventoryFailure reports a failed inventory handoff to the restored
257+ // serving daemon on the next successful handshake.
258+ inventoryFailure *protocol.NavigationInventoryFailure
239259
240260 inputMu sync.Mutex
241261 input *terminalInputPump
@@ -252,19 +272,20 @@ func NewRunner(deps Dependencies) *Runner {
252272 log = slog.Default()
253273 }
254274 return &Runner{
255- ui: deps.UI,
256- dialer: deps.Dialer,
257- term: deps.Terminal,
258- clock: deps.Clock,
259- clipboard: deps.Clipboard,
260- logger: log,
261- runtimeObserver: deps.RuntimeObserver,
262- remoteHostLearner: deps.RemoteHostLearner,
263- attachHandoff: deps.AttachHandoff,
264- remote: deps.Remote,
265- origin: normalizeRouteOrigin(deps.Origin, deps.Remote),
266- probeCapabilities: !deps.DisableCapabilityProbe,
267- ledger: newRouteLedger(),
275+ ui: deps.UI,
276+ dialer: deps.Dialer,
277+ term: deps.Terminal,
278+ clock: deps.Clock,
279+ clipboard: deps.Clipboard,
280+ logger: log,
281+ runtimeObserver: deps.RuntimeObserver,
282+ remoteHostLearner: deps.RemoteHostLearner,
283+ attachHandoff: deps.AttachHandoff,
284+ remote: deps.Remote,
285+ localControlDialer: deps.LocalControlDialer,
286+ origin: normalizeRouteOrigin(deps.Origin, deps.Remote),
287+ probeCapabilities: !deps.DisableCapabilityProbe,
288+ ledger: newRouteLedger(),
268289 }
269290 }
270291
@@ -496,16 +517,20 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
496517 returnResumeFallback := false
497518 homeNavigationPending := false
498519 returnNavigationPending := false
499- routeNavigationPending := false
520+ // navTransition owns creation and recent-route fallback lifecycle:
521+ // the captured prior route, selected identity, and settle-once state.
522+ // routeNavigationSelection and routeNavigationAction remain as the
523+ // in-attempt payload consumed by attachAttempt (commit input and
524+ // failure matching), not as cross-attempt fallback authority.
525+ // routeNavigationResumeFallback stays as the transport-level exact-attach
526+ // retry flag. Home-picker return state stays distinct per its overlay
527+ // semantics.
528+ var navTransition navigationTransition
500529 routeNavigationResumeFallback := false
501530 var routeNavigationSelection *routeNavigationSelection
502531 var killedSelection *killedRouteSelection
503532 killedResumeFallback := false
504- var routeNavigationFallback *attachRoute
505533 var routeNavigationAction *protocol.RouteNavigationAction
506- creationPending := false
507- var creationFallback *attachRoute
508- var creationRequestID uint64
509534 backoff := defaultReconnectBackoff.initial
510535 themeState := &terminalThemeState{}
511536 var rememberOnce sync.Once
@@ -605,6 +630,27 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
605630 }
606631 return nil
607632 }
633+ // restorePendingInventory recovers the serving route of an unsettled
634+ // inventory handoff whose destination dial failed. It reports whether a
635+ // restore happened; callers redial the return route on true and fail
636+ // with the dial error when the transition itself is broken.
637+ restorePendingInventory := func() bool {
638+ if !navTransition.pendingInventory() {
639+ return false
640+ }
641+ failure, hasFailure := navTransition.inventoryNavigationFailure()
642+ route, ok := navTransition.restore()
643+ if !ok {
644+ return false
645+ }
646+ navTransition.settleFailure()
647+ if hasFailure {
648+ pending := failure
649+ r.inventoryFailure = &pending
650+ }
651+ restoreReturnRoute(route)
652+ return true
653+ }
608654
609655 runTransientHomePicker := func(attemptCtx context.Context) attachResult {
610656 if homeRoute == nil {
@@ -674,16 +720,23 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
674720 if killedSelection != nil {
675721 return errors.Join(err, r.ledger.retireKilled(*killedSelection))
676722 }
677- if creationPending && creationFallback != nil && ctx.Err() == nil {
678- r.creationFailure = &protocol.SessionCreationFailure{RequestID: creationRequestID, Code: routeFailureCode(err)}
679- route := *creationFallback
680- creationFallback = nil
681- creationPending = false
682- creationRequestID = 0
723+ if navTransition.pendingCreation() && ctx.Err() == nil {
724+ r.creationFailure = &protocol.SessionCreationFailure{RequestID: navTransition.requestID, Code: routeFailureCode(err)}
725+ route, ok := navTransition.restore()
726+ if !ok {
727+ return err
728+ }
729+ navTransition.settleFailure()
683730 returnNavigationPending = true
684731 restoreReturnRoute(route)
685732 continue
686733 }
734+ if navTransition.pendingInventory() {
735+ if !restorePendingInventory() {
736+ return err
737+ }
738+ continue
739+ }
687740 if resumeToken == 0 || ctx.Err() != nil {
688741 if clearErr := reconnect.clear(); clearErr != nil {
689742 return errors.Join(err, clearErr)
@@ -715,16 +768,23 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
715768 if killedSelection != nil {
716769 return errors.Join(err, r.ledger.retireKilled(*killedSelection))
717770 }
718- if creationPending && creationFallback != nil {
719- r.creationFailure = &protocol.SessionCreationFailure{RequestID: creationRequestID, Code: protocol.RouteFailureUnavailable}
720- route := *creationFallback
721- creationFallback = nil
722- creationPending = false
723- creationRequestID = 0
771+ if navTransition.pendingCreation() {
772+ r.creationFailure = &protocol.SessionCreationFailure{RequestID: navTransition.requestID, Code: protocol.RouteFailureUnavailable}
773+ route, ok := navTransition.restore()
774+ if !ok {
775+ return err
776+ }
777+ navTransition.settleFailure()
724778 returnNavigationPending = true
725779 restoreReturnRoute(route)
726780 continue
727781 }
782+ if navTransition.pendingInventory() {
783+ if !restorePendingInventory() {
784+ return err
785+ }
786+ continue
787+ }
728788 return err
729789 }
730790 stopHandshakeTransport := watchHandshakeTransport(handshakeCtx, transport)
@@ -736,16 +796,23 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
736796 stopHandshakeTransport()
737797 finishHandshake()
738798 _ = transport.Close()
739- if creationPending && creationFallback != nil {
740- r.creationFailure = &protocol.SessionCreationFailure{RequestID: creationRequestID, Code: protocol.RouteFailureUnavailable}
741- route := *creationFallback
742- creationFallback = nil
743- creationPending = false
744- creationRequestID = 0
799+ if navTransition.pendingCreation() {
800+ r.creationFailure = &protocol.SessionCreationFailure{RequestID: navTransition.requestID, Code: protocol.RouteFailureUnavailable}
801+ route, ok := navTransition.restore()
802+ if !ok {
803+ return err
804+ }
805+ navTransition.settleFailure()
745806 returnNavigationPending = true
746807 restoreReturnRoute(route)
747808 continue
748809 }
810+ if navTransition.pendingInventory() {
811+ if !restorePendingInventory() {
812+ return err
813+ }
814+ continue
815+ }
749816 return err
750817 }
751818
@@ -778,7 +845,14 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
778845 terminalInput: func() *terminalInputPump {
779846 return input
780847 },
781- openHomePicker: runTransientHomePicker,
848+ openHomePicker: runTransientHomePicker,
849+ inventoryHome: homeRoute,
850+ inventoryDialer: r.localControlDialer,
851+ onInventoryCommitted: func() {
852+ if navTransition.pendingInventory() {
853+ navTransition.settleSuccess()
854+ }
855+ },
782856 }).run(ctx)
783857 stopHandshakeTransport()
784858 finishHandshake()
@@ -816,22 +890,22 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
816890 attemptRequest.PreferredTabID = result.routePosition.ActiveTabID
817891 attemptRequest.RemoteTarget = nil
818892 }
819- if result.err == nil && result.welcomed && creationPending {
820- creationPending = false
821- creationFallback = nil
822- creationRequestID = 0
893+ if result.err == nil && result.welcomed && navTransition.pendingCreation() {
894+ navTransition.settleSuccess()
823895 }
824896 if result.err == nil && result.welcomed && killedSelection != nil {
825897 killedSelection = nil
826898 killedResumeFallback = false
827899 }
828- if result.err == nil && result.welcomed && routeNavigationPending {
829- routeNavigationPending = false
900+ if result.err == nil && result.welcomed && navTransition.pendingRecent() {
901+ navTransition.settleSuccess()
830902 routeNavigationSelection = nil
831903 routeNavigationResumeFallback = false
832- routeNavigationFallback = nil
833904 routeNavigationAction = nil
834905 }
906+ if result.err == nil && result.welcomed && navTransition.pendingInventory() {
907+ navTransition.settleSuccess()
908+ }
835909 if result.routeCreateAction != nil {
836910 action := *result.routeCreateAction
837911 source := attachRoute{dialer: dialer, request: cloneAttachRequest(attemptRequest), resumeToken: resumeToken}
@@ -848,7 +922,8 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
848922 restoreReturnRoute(source)
849923 continue
850924 }
851- creationFallback = &attachRoute{dialer: selection.prior.dialer, request: cloneAttachRequest(selection.prior.request), resumeToken: selection.prior.resumeToken}
925+ priorRoute := attachRoute{dialer: selection.prior.dialer, request: cloneAttachRequest(selection.prior.request), resumeToken: selection.prior.resumeToken}
926+ navTransition.beginCreation(priorRoute, action.RequestID)
852927 dialer = selection.selected.dialer
853928 attemptRequest = cloneAttachRequest(selection.selected.request)
854929 attemptRequest.Intent = protocol.IntentNew
@@ -860,8 +935,6 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
860935 attemptRequest.NavigationCapabilities = 0
861936 attemptRequest.EnvironmentPolicy = protocol.EnvironmentPolicyClientOwned
862937 resumeToken = 0
863- creationPending = true
864- creationRequestID = action.RequestID
865938 remote = syncReconnectRemote(reconnect, attemptRequest.Remote || r.remote)
866939 backoff = defaultReconnectBackoff.initial
867940 continue
@@ -879,7 +952,8 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
879952 }
880953 action := *result.routeAction
881954 routeNavigationAction = &action
882- routeNavigationFallback = &attachRoute{dialer: selection.prior.dialer, request: selection.prior.request, resumeToken: selection.prior.resumeToken}
955+ priorRoute := attachRoute{dialer: selection.prior.dialer, request: selection.prior.request, resumeToken: selection.prior.resumeToken}
956+ navTransition.beginRecent(priorRoute)
883957 dialer = selection.selected.dialer
884958 attemptRequest = cloneAttachRequest(selection.selected.request)
885959 if selection.selected.resumeToken != 0 {
@@ -896,7 +970,6 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
896970 attemptRequest.NavigationCapabilities = protocol.NavigationCapabilityHomePicker
897971 }
898972 resumeToken = selection.selected.resumeToken
899- routeNavigationPending = true
900973 routeNavigationSelection = &selection
901974 routeNavigationResumeFallback = selection.selected.resumeToken != 0
902975 remote = syncReconnectRemote(reconnect, attemptRequest.Remote || r.remote)
@@ -1015,10 +1088,10 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
10151088 return errors.New("vev: route handoff returned nil dialer")
10161089 }
10171090 if target.Intent == protocol.IntentNew && target.RequestID != 0 {
1018- fallback := source
1019- creationFallback = &fallback
1020- creationPending = true
1021- creationRequestID = target.RequestID
1091+ navTransition.beginCreation(source, target.RequestID)
1092+ }
1093+ if result.handoff != nil && result.handoff.inventory {
1094+ navTransition.beginInventory(attachRoute{dialer: dialer, request: cloneAttachRequest(attemptRequest), resumeToken: resumeToken}, result.handoff.inventorySelection)
10221095 }
10231096 dialer = nextDialer
10241097 attemptRequest = nextRequest
@@ -1065,9 +1138,10 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
10651138 returnRoute = nil
10661139 homeNavigationPending = false
10671140 returnNavigationPending = false
1068- routeNavigationPending = false
1141+ if navTransition.operation == navigationOperationRecent {
1142+ navTransition.clear()
1143+ }
10691144 routeNavigationSelection = nil
1070- routeNavigationFallback = nil
10711145 routeNavigationAction = nil
10721146 remote = syncReconnectRemote(reconnect, attemptRequest.Remote || r.remote)
10731147 backoff = defaultReconnectBackoff.initial
@@ -1093,31 +1167,40 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
10931167 restoreReturnRoute(route)
10941168 continue
10951169 }
1096- if creationPending && creationFallback != nil {
1097- r.creationFailure = &protocol.SessionCreationFailure{RequestID: creationRequestID, Code: routeFailureCode(result.err)}
1098- route := *creationFallback
1099- creationFallback = nil
1100- creationPending = false
1101- creationRequestID = 0
1170+ if navTransition.pendingCreation() {
1171+ r.creationFailure = &protocol.SessionCreationFailure{RequestID: navTransition.requestID, Code: routeFailureCode(result.err)}
1172+ route, ok := navTransition.restore()
1173+ if !ok {
1174+ return result.err
1175+ }
1176+ navTransition.settleFailure()
11021177 returnNavigationPending = true
11031178 restoreReturnRoute(route)
11041179 continue
11051180 }
1106- if routeNavigationPending && routeNavigationResumeFallback && resumeNeedsExactAttach(result.err) {
1181+ if navTransition.pendingInventory() {
1182+ if !restorePendingInventory() {
1183+ return result.err
1184+ }
1185+ continue
1186+ }
1187+ if navTransition.pendingRecent() && routeNavigationResumeFallback && resumeNeedsExactAttach(result.err) {
11071188 attemptRequest.Intent = protocol.IntentAttach
11081189 resumeToken = 0
11091190 routeNavigationResumeFallback = false
11101191 continue
11111192 }
1112- if routeNavigationPending && routeNavigationFallback != nil {
1193+ if navTransition.pendingRecent() {
11131194 if routeNavigationAction != nil {
11141195 action := *routeNavigationAction
11151196 r.routeFailure = &protocol.RouteNavigationFailure{Key: action.Key, Generation: action.Generation, Code: routeFailureCode(result.err)}
11161197 routeNavigationAction = nil
11171198 }
1118- route := *routeNavigationFallback
1119- routeNavigationFallback = nil
1120- routeNavigationPending = false
1199+ route, ok := navTransition.restore()
1200+ if !ok {
1201+ return result.err
1202+ }
1203+ navTransition.settleFailure()
11211204 routeNavigationSelection = nil
11221205 routeNavigationResumeFallback = false
11231206 returnNavigationPending = true
@@ -1317,6 +1400,20 @@ type attachAttempt struct {
13171400 rememberRemoteHost func()
13181401 terminalInput func() *terminalInputPump
13191402 openHomePicker func(context.Context) attachResult
1403+ // inventoryHome is the committed local route that authorises the
1404+ // navigation-inventory relay and its handoffs. Nil disables the relay:
1405+ // local-only and ledger-less attachments never dial control sources.
1406+ inventoryHome *attachRoute
1407+ // inventoryDialer is the dial-only control source for relay queries.
1408+ // It never starts the daemon; home.dialer stays the sole attachment
1409+ // authority.
1410+ inventoryDialer ports.ClientDialer
1411+ // onInventoryCommitted settles the inventory transition when the
1412+ // destination's initial publications commit. attachAttempt.run lives
1413+ // for the attachment lifetime, so the outer loop cannot settle there:
1414+ // a later transport failure must reconnect the active destination,
1415+ // not restore the old serving route.
1416+ onInventoryCommitted func()
13201417 }
13211418
13221419 type attachResult struct {
@@ -1392,6 +1489,17 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
13921489 transition := a.transition
13931490 linkEvents := a.linkEvents
13941491 remote := a.remote || a.request.Remote
1492+ // The inventory relay serves the serving daemon's palette from the
1493+ // dial-only local control source while this attachment is remote. It
1494+ // owns no goroutine: the attempt loop drives polling and publishes
1495+ // deltas, and bounded query workers report through the poll box and
1496+ // resolve channel. Advertising the capability in Hello is what lets
1497+ // the serving palette send demands at all.
1498+ var inventory *inventoryRelay
1499+ if home := a.inventoryHome; home != nil && inventoryRelayEnabled(true, remote && !home.request.Remote, a.inventoryDialer) {
1500+ inventory = newInventoryRelay(clk, a.inventoryDialer)
1501+ request.NavigationCapabilities |= protocol.NavigationCapabilityInventory
1502+ }
13951503 clipboard := a.runner.clipboard
13961504 log := a.runner.logger
13971505 observer := a.runner.runtimeObserver
@@ -1586,6 +1694,14 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
15861694 }
15871695 a.runner.routeFailure = nil
15881696 }
1697+ if a.runner.inventoryFailure != nil {
1698+ if err := sendHandshake(func() error {
1699+ return transport.SendClient(*a.runner.inventoryFailure)
1700+ }); err != nil {
1701+ return welcomedResult(fmt.Errorf("vev: publishing inventory navigation failure: %w", err))
1702+ }
1703+ a.runner.inventoryFailure = nil
1704+ }
15891705
15901706 // 3. Enter raw mode after Welcome; Run owns restoration.
15911707 if err := enterRaw(); err != nil {
@@ -1597,6 +1713,63 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
15971713 // coordinator owner.
15981714 loopCtx, cancel := context.WithCancel(ctx)
15991715 defer cancel()
1716+ // Poll and resolve workers report through the box and channel below;
1717+ // completions carry their originating interaction and query identity.
1718+ // The relay itself is created before Hello so the capability advertises.
1719+ inventoryBox := newInventoryPollBox()
1720+ var inventoryResolveC chan inventoryResolveOutcome
1721+ inventoryResolvePending := false
1722+ var nextInventoryRequestID uint64 = 1
1723+ inventoryOpen := false
1724+ var inventoryInteraction uint64
1725+ var inventoryTimer ports.Timer
1726+ var inventoryTickC <-chan time.Time
1727+ stopInventoryTimer := func() {
1728+ if inventoryTimer != nil {
1729+ inventoryTimer.Stop()
1730+ inventoryTimer = nil
1731+ }
1732+ inventoryTickC = nil
1733+ }
1734+ defer stopInventoryTimer()
1735+ armInventoryPoll := func() {
1736+ if inventory == nil || !inventoryOpen {
1737+ stopInventoryTimer()
1738+ return
1739+ }
1740+ if inventoryTimer != nil {
1741+ inventoryTimer.Stop()
1742+ }
1743+ inventoryTimer = clk.NewTimer(inventoryPollInterval)
1744+ inventoryTickC = inventoryTimer.C()
1745+ }
1746+ var cancelInventoryPoll context.CancelFunc
1747+ stopInventoryPoll := func() {
1748+ if cancelInventoryPoll != nil {
1749+ cancelInventoryPoll()
1750+ cancelInventoryPoll = nil
1751+ }
1752+ }
1753+ defer stopInventoryPoll()
1754+ startInventoryPoll := func() {
1755+ if inventory == nil || !inventoryOpen {
1756+ return
1757+ }
1758+ interaction := inventoryInteraction
1759+ query, ok := inventory.beginPoll()
1760+ if !ok {
1761+ return
1762+ }
1763+ queryID := nextInventoryRequestID
1764+ nextInventoryRequestID++
1765+ queryCtx, cancel := context.WithCancel(loopCtx)
1766+ cancelInventoryPoll = cancel
1767+ go func() {
1768+ defer cancel()
1769+ response, err := inventory.querySnapshot(queryCtx, queryID)
1770+ inventoryBox.offer(inventoryPollOutcome{interaction: interaction, query: query, response: response, err: err})
1771+ }()
1772+ }
16001773 input := (*terminalInputPump)(nil)
16011774 if a.terminalInput != nil {
16021775 input = a.terminalInput()
@@ -1774,6 +1947,9 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
17741947 // handshake deadline must not own the long-lived runtime transport: a quiet
17751948 // or unchanged screen may legitimately produce no initial Output frame.
17761949 endHandshake()
1950+ if a.onInventoryCommitted != nil {
1951+ a.onInventoryCommitted()
1952+ }
17771953 senderStarted = true
17781954 go runSender(loopCtx, cancel, transport, controlCh, barrierCh, sendCh, inputGate, ackQueue, sendErrCh, log)
17791955
@@ -2069,6 +2245,106 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
20692245 case <-parkedFullC():
20702246 clearParkedFull()
20712247 return welcomedResult(errors.New("vev: timed out waiting for parked-route full output"))
2248+ case <-inventoryTickC:
2249+ startInventoryPoll()
2250+ armInventoryPoll()
2251+ case <-inventoryBox.wake:
2252+ pollOutcome, ok := inventoryBox.take()
2253+ if !ok {
2254+ continue
2255+ }
2256+ stopInventoryPoll()
2257+ if inventory != nil {
2258+ inventory.endPoll(pollOutcome.query)
2259+ }
2260+ if pollOutcome.interaction != inventoryInteraction {
2261+ startInventoryPoll()
2262+ }
2263+ if pollOutcome.err != nil || inventory == nil || !inventoryOpen ||
2264+ pollOutcome.interaction != inventoryInteraction {
2265+ armInventoryPoll()
2266+ continue
2267+ }
2268+ if groups, generation, changed := inventory.preparePublication(pollOutcome.response.Groups); changed {
2269+ publication := protocol.NavigationInventoryPublication{
2270+ InteractionGeneration: inventoryInteraction,
2271+ PublicationGeneration: generation, Groups: groups,
2272+ }
2273+ if err := protocol.ValidateNavigationInventoryPublication(publication); err != nil {
2274+ log.Debug("dropping invalid inventory publication", "err", err)
2275+ armInventoryPoll()
2276+ continue
2277+ }
2278+ select {
2279+ case controlCh <- publication:
2280+ case <-loopCtx.Done():
2281+ return loopCanceledResult()
2282+ }
2283+ }
2284+ armInventoryPoll()
2285+ case resolveOutcome := <-inventoryResolveC:
2286+ inventoryResolveC = nil
2287+ inventoryResolvePending = false
2288+ if inventory == nil {
2289+ continue
2290+ }
2291+ if resolveOutcome.err != nil {
2292+ // The serving palette already closed on selection; report
2293+ // the failure so its feedback names the dead entry.
2294+ code := protocol.NavigationInventoryStaleIdentity
2295+ if errors.Is(resolveOutcome.err, errInventorySourceUnavailable) {
2296+ code = protocol.NavigationInventorySourceGone
2297+ }
2298+ failure := protocol.NavigationInventoryFailure{
2299+ CauseActionID: resolveOutcome.selection.CauseActionID,
2300+ InteractionGeneration: resolveOutcome.selection.InteractionGeneration,
2301+ SourceKey: resolveOutcome.selection.SourceKey,
2302+ EntryKey: resolveOutcome.selection.EntryKey,
2303+ Code: code,
2304+ }
2305+ if err := protocol.ValidateNavigationInventoryFailure(failure); err == nil {
2306+ select {
2307+ case controlCh <- failure:
2308+ case <-loopCtx.Done():
2309+ return loopCanceledResult()
2310+ }
2311+ }
2312+ continue
2313+ }
2314+ if err := inventory.validateSelection(resolveOutcome.selection); err != nil {
2315+ // The publication retired the key while the resolve was in
2316+ // flight. The serving palette already closed on selection,
2317+ // so report the stale identity instead of dropping it.
2318+ failure := protocol.NavigationInventoryFailure{
2319+ CauseActionID: resolveOutcome.selection.CauseActionID,
2320+ InteractionGeneration: resolveOutcome.selection.InteractionGeneration,
2321+ SourceKey: resolveOutcome.selection.SourceKey,
2322+ EntryKey: resolveOutcome.selection.EntryKey,
2323+ Code: protocol.NavigationInventoryStaleIdentity,
2324+ }
2325+ if err := protocol.ValidateNavigationInventoryFailure(failure); err == nil {
2326+ select {
2327+ case controlCh <- failure:
2328+ case <-loopCtx.Done():
2329+ return loopCanceledResult()
2330+ }
2331+ }
2332+ continue
2333+ }
2334+ home := a.inventoryHome
2335+ if home == nil {
2336+ continue
2337+ }
2338+ if ui != nil {
2339+ ui.follow(uiGeneration, resolveOutcome.selection.CauseActionID)
2340+ publishUIStatus(ports.UIStatusTransitioning)
2341+ }
2342+ handoff := bindAttachHandoff(resolveOutcome.target, *home)
2343+ handoff.inventory = true
2344+ handoff.inventorySelection = resolveOutcome.selection
2345+ result := welcomedResult(nil)
2346+ result.handoff = handoff
2347+ return result
20722348 case ev, ok := <-linkEvents:
20732349 if !ok {
20742350 linkEvents = nil
@@ -2380,6 +2656,65 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
23802656 navigation := welcomedResult(nil)
23812657 navigation.action = directive.Action
23822658 return navigation
2659+ case protocol.NavigationInventoryDemand:
2660+ demand := message
2661+ if inventory == nil {
2662+ continue
2663+ }
2664+ if !demand.Open || demand.InteractionGeneration != inventoryInteraction {
2665+ stopInventoryPoll()
2666+ }
2667+ inventory.setOpen(demand.Open, demand.InteractionGeneration)
2668+ inventoryOpen = demand.Open
2669+ if demand.Open {
2670+ inventoryInteraction = demand.InteractionGeneration
2671+ startInventoryPoll()
2672+ }
2673+ armInventoryPoll()
2674+ continue
2675+ case protocol.NavigationInventorySelection:
2676+ selection := message
2677+ if inventory == nil || inventoryResolvePending {
2678+ continue
2679+ }
2680+ if err := inventory.validateSelection(selection); err != nil {
2681+ // The serving daemon rendered the row before the newer
2682+ // removal publication arrived. Its palette already
2683+ // closed on selection, so report the stale identity
2684+ // instead of leaving it without feedback.
2685+ failure := protocol.NavigationInventoryFailure{
2686+ CauseActionID: selection.CauseActionID,
2687+ InteractionGeneration: selection.InteractionGeneration,
2688+ SourceKey: selection.SourceKey,
2689+ EntryKey: selection.EntryKey,
2690+ Code: protocol.NavigationInventoryStaleIdentity,
2691+ }
2692+ if verr := protocol.ValidateNavigationInventoryFailure(failure); verr == nil {
2693+ select {
2694+ case controlCh <- failure:
2695+ case <-loopCtx.Done():
2696+ return loopCanceledResult()
2697+ }
2698+ }
2699+ continue
2700+ }
2701+ inventoryResolvePending = true
2702+ inventoryResolveC = make(chan inventoryResolveOutcome, 1)
2703+ resolveID := nextInventoryRequestID
2704+ nextInventoryRequestID++
2705+ go func() {
2706+ outcome := inventoryResolveOutcome{selection: selection}
2707+ outcome.target, outcome.err = inventory.resolveEntry(loopCtx, resolveID, selection.SourceKey, selection.EntryKey, domain.RemoteRegistration{})
2708+ select {
2709+ case inventoryResolveC <- outcome:
2710+ case <-loopCtx.Done():
2711+ }
2712+ }()
2713+ continue
2714+ case protocol.NavigationInventoryResponse:
2715+ // Overlay-owned liveness. The relay never waits on it:
2716+ // publications carry the only state it tracks.
2717+ continue
23832718 case protocol.RouteCreateSessionAction:
23842719 action := message
23852720 if action.Validate() != nil {
M internal/usecase/client/client_test.go +2 -1
@@ -551,7 +551,8 @@ func TestKilledSessionReturnsToPreviousLocalRoute(t *testing.T) {
551551 require.NoError(t, err)
552552 require.Equal(t, int32(2), dialer.calls.Load())
553553 hello := helloFromSend(t, previous)
554- require.Equal(t, protocol.IntentResume, hello.Intent)
554+ require.Equal(t, protocol.IntentAttach, hello.Intent)
555+ require.Zero(t, hello.ResumeToken)
555556 require.Equal(t, firstTarget, *hello.ExactTarget)
556557 require.Equal(t, int32(1), term.restoreCount.Load())
557558 }
A internal/usecase/client/inventory_attempt.go +68 -0
@@ -0,0 +1,68 @@
1+package client
2+
3+import (
4+ "sync"
5+
6+ "github.com/bnema/vev/internal/protocol"
7+)
8+
9+// inventoryPollOutcome is one finished control-source snapshot query. The
10+// interaction and query identity bind the completion to its originating
11+// namespace: stale results from a closed interaction drop.
12+type inventoryPollOutcome struct {
13+ interaction uint64
14+ query uint64
15+ response protocol.NavigationInventoryResponse
16+ err error
17+}
18+
19+// inventoryPollBox holds the latest finished poll outcome while the attempt
20+// loop is busy. Queries never block on a slow loop: the newest outcome wins
21+// and older ones drop, matching the single in-flight plus one coalesced
22+// update budget.
23+type inventoryPollBox struct {
24+ mu sync.Mutex
25+ outcome inventoryPollOutcome
26+ has bool
27+ wake chan struct{}
28+}
29+
30+func newInventoryPollBox() *inventoryPollBox {
31+ return &inventoryPollBox{wake: make(chan struct{}, 1)}
32+}
33+
34+func (b *inventoryPollBox) offer(outcome inventoryPollOutcome) {
35+ b.mu.Lock()
36+ // Keep the newest completion: a late outcome from an older query must
37+ // not overwrite a newer one, or the newer query's completion is lost
38+ // while its slot stays busy and polling stalls.
39+ if b.has && outcome.query < b.outcome.query {
40+ b.mu.Unlock()
41+ return
42+ }
43+ b.outcome = outcome
44+ b.has = true
45+ b.mu.Unlock()
46+ select {
47+ case b.wake <- struct{}{}:
48+ default:
49+ }
50+}
51+
52+func (b *inventoryPollBox) take() (inventoryPollOutcome, bool) {
53+ b.mu.Lock()
54+ defer b.mu.Unlock()
55+ outcome, has := b.outcome, b.has
56+ b.outcome = inventoryPollOutcome{}
57+ b.has = false
58+ return outcome, has
59+}
60+
61+// inventoryResolveOutcome is one finished selection resolve: either the
62+// non-mutating attach target bound to its validated selection or the
63+// resolve error. Stale outcomes drop on receipt when the interaction moved.
64+type inventoryResolveOutcome struct {
65+ selection protocol.NavigationInventorySelection
66+ target protocol.AttachTarget
67+ err error
68+}
A internal/usecase/client/inventory_attempt_test.go +438 -0
@@ -0,0 +1,438 @@
1+package client
2+
3+import (
4+ "context"
5+ "io"
6+ "log/slog"
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"
16+ "github.com/bnema/vev/internal/protocol/wire"
17+)
18+
19+// inventoryTestClock is a manually advanced clock. Firing advances past the
20+// poll interval so relay ticks stay deterministic.
21+type inventoryTestClock struct {
22+ mu sync.Mutex
23+ now time.Time
24+ timers []*inventoryTestTimer
25+}
26+
27+func newInventoryTestClock() *inventoryTestClock {
28+ return &inventoryTestClock{now: time.Unix(1000, 0)}
29+}
30+
31+func (c *inventoryTestClock) Now() time.Time {
32+ c.mu.Lock()
33+ defer c.mu.Unlock()
34+ return c.now
35+}
36+
37+func (c *inventoryTestClock) NewTimer(time.Duration) ports.Timer {
38+ c.mu.Lock()
39+ defer c.mu.Unlock()
40+ timer := &inventoryTestTimer{ch: make(chan time.Time, 1)}
41+ c.timers = append(c.timers, timer)
42+ return timer
43+}
44+
45+func (c *inventoryTestClock) timerCount() int {
46+ c.mu.Lock()
47+ defer c.mu.Unlock()
48+ return len(c.timers)
49+}
50+
51+func (c *inventoryTestClock) fire() {
52+ c.mu.Lock()
53+ c.now = c.now.Add(2 * inventoryPollInterval)
54+ timers := append([]*inventoryTestTimer(nil), c.timers...)
55+ c.mu.Unlock()
56+ for _, timer := range timers {
57+ select {
58+ case timer.ch <- c.Now():
59+ default:
60+ }
61+ }
62+}
63+
64+type inventoryTestTimer struct{ ch chan time.Time }
65+
66+func (t *inventoryTestTimer) C() <-chan time.Time { return t.ch }
67+func (t *inventoryTestTimer) Reset(time.Duration) bool { return false }
68+func (t *inventoryTestTimer) Stop() bool { return true }
69+
70+// inventoryScript selects the serving-transport script after the publication.
71+type inventoryScript int
72+
73+const (
74+ inventoryScriptClose inventoryScript = iota
75+ inventoryScriptSelect
76+ // inventoryScriptSilent models an old daemon that never sends
77+ // demands: the relay stays idle and dials nothing.
78+ inventoryScriptSilent
79+ // inventoryScriptLocalIgnore models demands on a local attachment:
80+ // open and close cross back to back with no publication gate, so the
81+ // single-threaded loop provably processes both before detach.
82+ inventoryScriptLocalIgnore
83+)
84+
85+// inventoryAttemptTransport scripts a remote serving daemon: welcome, open
86+// demand, then either a close demand or a valid selection once the relay
87+// publishes. Raw sends record publications for assertions.
88+type inventoryAttemptTransport struct {
89+ mu sync.Mutex
90+ calls int
91+ sent []wire.Frame
92+ publications []protocol.NavigationInventoryPublication
93+ published chan struct{}
94+ publishedOnce sync.Once
95+ release chan struct{}
96+ detach chan struct{}
97+ releaseClose chan struct{}
98+ script inventoryScript
99+}
100+
101+func (t *inventoryAttemptTransport) Send(frame wire.Frame) error {
102+ var publication protocol.NavigationInventoryPublication
103+ if frame.Type == wire.MsgNavigationInventoryPublication {
104+ var err error
105+ publication, err = wire.UnmarshalNavigationInventoryPublication(frame.Payload)
106+ if err != nil {
107+ return err
108+ }
109+ }
110+ t.mu.Lock()
111+ t.sent = append(t.sent, frame)
112+ if frame.Type == wire.MsgNavigationInventoryPublication {
113+ t.publications = append(t.publications, publication)
114+ }
115+ t.mu.Unlock()
116+ if frame.Type == wire.MsgNavigationInventoryPublication {
117+ t.publishedOnce.Do(func() { close(t.published) })
118+ }
119+ return nil
120+}
121+
122+func (t *inventoryAttemptTransport) Recv() (wire.Frame, error) {
123+ t.mu.Lock()
124+ t.calls++
125+ call := t.calls
126+ t.mu.Unlock()
127+ switch call {
128+ case 1:
129+ return wire.Frame{Type: wire.MsgWelcome, Payload: wire.MarshalWelcome(protocol.Welcome{SessionID: "s"})}, nil
130+ case 2:
131+ if t.script == inventoryScriptSilent {
132+ select {
133+ case <-t.detach:
134+ return wire.Frame{Type: wire.MsgDetached, Payload: wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach})}, nil
135+ case <-t.release:
136+ return wire.Frame{}, io.EOF
137+ }
138+ }
139+ return wire.Frame{Type: wire.MsgNavigationInventoryDemand, Payload: wire.MarshalNavigationInventoryDemand(protocol.NavigationInventoryDemand{InteractionGeneration: 7, Open: true})}, nil
140+ case 3:
141+ if t.script == inventoryScriptLocalIgnore {
142+ return wire.Frame{Type: wire.MsgNavigationInventoryDemand, Payload: wire.MarshalNavigationInventoryDemand(protocol.NavigationInventoryDemand{InteractionGeneration: 7})}, nil
143+ }
144+ select {
145+ case <-t.published:
146+ case <-t.detach:
147+ return wire.Frame{Type: wire.MsgDetached, Payload: wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach})}, nil
148+ case <-t.release:
149+ return wire.Frame{}, io.EOF
150+ }
151+ // The close script holds the close demand until the test releases
152+ // it, so polling assertions run against an open interaction.
153+ if t.script == inventoryScriptClose {
154+ select {
155+ case <-t.releaseClose:
156+ case <-t.release:
157+ return wire.Frame{}, io.EOF
158+ }
159+ }
160+ if t.script == inventoryScriptSelect {
161+ return wire.Frame{Type: wire.MsgNavigationInventorySelection, Payload: wire.MarshalNavigationInventorySelection(protocol.NavigationInventorySelection{
162+ CauseActionID: 9, InteractionGeneration: 7, PublicationGeneration: 1,
163+ SourceKey: "local", EntryKey: "aaa/alpha",
164+ })}, nil
165+ }
166+ return wire.Frame{Type: wire.MsgNavigationInventoryDemand, Payload: wire.MarshalNavigationInventoryDemand(protocol.NavigationInventoryDemand{InteractionGeneration: 7})}, nil
167+ default:
168+ select {
169+ case <-t.detach:
170+ return wire.Frame{Type: wire.MsgDetached, Payload: wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach})}, nil
171+ case <-t.release:
172+ return wire.Frame{}, io.EOF
173+ }
174+ }
175+}
176+
177+func (t *inventoryAttemptTransport) SendClient(m protocol.ClientMessage) error {
178+ f, err := testClientFrame(m)
179+ if err != nil {
180+ return err
181+ }
182+ return t.Send(f)
183+}
184+
185+func (t *inventoryAttemptTransport) ReceiveServer() (protocol.ServerMessage, error) {
186+ f, err := t.Recv()
187+ if err != nil {
188+ return nil, err
189+ }
190+ return testServerMessage(f)
191+}
192+
193+func (t *inventoryAttemptTransport) Capabilities() protocol.ConnectionCapabilities {
194+ return testClientCapabilities(t)
195+}
196+func (t *inventoryAttemptTransport) LinkState() ports.LinkState { return ports.LinkStateConnected }
197+func (t *inventoryAttemptTransport) LinkEvents() <-chan ports.LinkEvent { return nil }
198+func (t *inventoryAttemptTransport) Close() error { return nil }
199+
200+func (t *inventoryAttemptTransport) publicationCount() int {
201+ t.mu.Lock()
202+ defer t.mu.Unlock()
203+ return len(t.publications)
204+}
205+
206+func (t *inventoryAttemptTransport) hello() protocol.Hello {
207+ t.mu.Lock()
208+ defer t.mu.Unlock()
209+ for _, frame := range t.sent {
210+ if frame.Type == wire.MsgHello {
211+ hello, err := wire.UnmarshalHello(frame.Payload)
212+ if err != nil {
213+ panic(err)
214+ }
215+ return hello
216+ }
217+ }
218+ return protocol.Hello{}
219+}
220+
221+// inventoryTestControlConn answers one snapshot or resolve query from canned
222+// groups without Hello, session creation, or attachment.
223+type inventoryTestControlConn struct {
224+ request protocol.NavigationInventoryRequest
225+}
226+
227+func (c *inventoryTestControlConn) SendClient(message protocol.ClientMessage) error {
228+ request, ok := message.(protocol.NavigationInventoryRequest)
229+ if !ok {
230+ return io.ErrUnexpectedEOF
231+ }
232+ c.request = request
233+ return nil
234+}
235+
236+func (c *inventoryTestControlConn) ReceiveServer() (protocol.ServerMessage, error) {
237+ switch c.request.Operation {
238+ case protocol.NavigationInventorySnapshot:
239+ return protocol.NavigationInventoryResponse{
240+ RequestID: c.request.RequestID, Operation: protocol.NavigationInventorySnapshot,
241+ Status: protocol.NavigationInventoryOK,
242+ Groups: []protocol.NavigationInventorySourceGroup{{
243+ SourceKey: "local", Status: protocol.NavigationInventorySourceOK,
244+ Entries: []protocol.NavigationInventoryEntry{
245+ {SourceKey: "local", EntryKey: "aaa/alpha", Name: "alpha", DisplayOrigin: "local", State: "up"},
246+ },
247+ }},
248+ }, nil
249+ case protocol.NavigationInventoryResolve:
250+ lifecycle := domain.SessionLifecycleID{7}
251+ return protocol.NavigationInventoryResponse{
252+ RequestID: c.request.RequestID, Operation: protocol.NavigationInventoryResolve,
253+ Status: protocol.NavigationInventoryOK,
254+ Resolved: &protocol.AttachTarget{
255+ Session: "alpha", Intent: protocol.IntentAttach,
256+ ExactTarget: &protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "alpha"},
257+ },
258+ }, nil
259+ default:
260+ return nil, io.ErrUnexpectedEOF
261+ }
262+}
263+
264+func (c *inventoryTestControlConn) Capabilities() protocol.ConnectionCapabilities {
265+ return protocol.ConnectionCapabilities{}
266+}
267+func (c *inventoryTestControlConn) LinkState() ports.LinkState { return ports.LinkStateConnected }
268+func (c *inventoryTestControlConn) LinkEvents() <-chan ports.LinkEvent { return nil }
269+func (c *inventoryTestControlConn) Close() error { return nil }
270+
271+type inventoryTestDialer struct {
272+ mu sync.Mutex
273+ dials int
274+}
275+
276+func (d *inventoryTestDialer) Dial(context.Context) (ports.ClientConnection, error) {
277+ d.mu.Lock()
278+ d.dials++
279+ d.mu.Unlock()
280+ return &inventoryTestControlConn{}, nil
281+}
282+
283+func (d *inventoryTestDialer) count() int {
284+ d.mu.Lock()
285+ defer d.mu.Unlock()
286+ return d.dials
287+}
288+
289+func runInventoryAttempt(t *testing.T, clock *inventoryTestClock, transport *inventoryAttemptTransport, remote bool, dialer *inventoryTestDialer) attachResult {
290+ t.Helper()
291+ input := newPaletteAttachReader(nil)
292+ t.Cleanup(input.close)
293+ term := &paletteAttachTerminal{in: input, resize: make(chan domain.Geometry)}
294+ runner := &Runner{term: term, clock: clock, logger: slog.New(slog.DiscardHandler)}
295+ attempt := &attachAttempt{
296+ runner: runner, dialer: dialer, transport: transport,
297+ request: AttachRequest{Intent: protocol.IntentAttach, SessionName: "remote-work"},
298+ remote: remote,
299+ inventoryHome: &attachRoute{
300+ dialer: dialer,
301+ request: AttachRequest{Intent: protocol.IntentAttach, SessionName: "home"},
302+ },
303+ inventoryDialer: dialer,
304+ milestones: msForInventoryTest(), themeState: &terminalThemeState{},
305+ enterRaw: func() error { return nil },
306+ reconnect: &reconnectUI{term: term, rawEntered: new(bool)},
307+ }
308+ return attempt.run(context.Background())
309+}
310+
311+func msForInventoryTest() *milestones { return &milestones{} }
312+
313+// TestInventoryPollBoxKeepsNewestCompletion pins out-of-order delivery:
314+// a late outcome from an older query must not overwrite the newer one,
315+// or the newer completion is lost while its slot stays busy.
316+func TestInventoryPollBoxKeepsNewestCompletion(t *testing.T) {
317+ box := newInventoryPollBox()
318+ box.offer(inventoryPollOutcome{interaction: 2, query: 2})
319+ box.offer(inventoryPollOutcome{interaction: 2, query: 1})
320+ outcome, ok := box.take()
321+ require.True(t, ok)
322+ require.Equal(t, uint64(2), outcome.query)
323+ if _, ok := box.take(); ok {
324+ t.Fatal("take must drain the single coalesced outcome")
325+ }
326+}
327+
328+// TestInventoryAttemptPublishesAndStops pins the relay poll loop through a
329+// scripted remote serving daemon: the open demand triggers an immediate
330+// snapshot query and a delta publication, the tick re-polls without
331+// republishing unchanged inventory, and the close demand stops polling.
332+func TestInventoryAttemptPublishesAndStops(t *testing.T) {
333+ clock := newInventoryTestClock()
334+ dialer := &inventoryTestDialer{}
335+ transport := &inventoryAttemptTransport{
336+ published: make(chan struct{}), release: make(chan struct{}), detach: make(chan struct{}), releaseClose: make(chan struct{}), script: inventoryScriptClose,
337+ }
338+ t.Cleanup(func() { close(transport.release) })
339+
340+ done := make(chan attachResult, 1)
341+ go func() { done <- runInventoryAttempt(t, clock, transport, true, dialer) }()
342+
343+ select {
344+ case <-transport.published:
345+ case <-time.After(5 * time.Second):
346+ t.Fatal("relay did not publish after the open demand")
347+ }
348+ // Fire until the tick lands: pushes buffer on every known timer, so a
349+ // fire racing the post-publication re-arm is retried instead of lost
350+ // on the discarded open timer.
351+ require.Eventually(t, func() bool {
352+ clock.fire()
353+ return dialer.count() >= 2
354+ }, 5*time.Second, 10*time.Millisecond, "the tick must drive a second poll")
355+ require.Eventually(t, func() bool { return transport.publicationCount() == 1 }, 5*time.Second, 10*time.Millisecond,
356+ "unchanged inventory must not republish, only release the poll slot")
357+ close(transport.releaseClose)
358+ close(transport.detach)
359+
360+ result := <-done
361+ require.NoError(t, result.err)
362+ require.Nil(t, result.handoff)
363+ require.GreaterOrEqual(t, dialer.count(), 2, "the tick must drive a second poll that publishes nothing")
364+ require.Len(t, transport.publications, 1)
365+ publication := transport.publications[0]
366+ require.Equal(t, uint64(7), publication.InteractionGeneration)
367+ require.Equal(t, uint64(1), publication.PublicationGeneration)
368+ require.Len(t, publication.Groups, 1)
369+ require.Equal(t, "aaa/alpha", publication.Groups[0].Entries[0].EntryKey)
370+}
371+
372+// TestInventoryAttemptResolvesSelectionToHandoff pins the selection path:
373+// the serving daemon's selection validates against published state, resolves
374+// through the local control dialer, and returns a handoff bound to the home
375+// authority; Run captures the serving recovery route after rebasing metadata.
376+func TestInventoryAttemptResolvesSelectionToHandoff(t *testing.T) {
377+ clock := newInventoryTestClock()
378+ transport := &inventoryAttemptTransport{
379+ published: make(chan struct{}), release: make(chan struct{}), script: inventoryScriptSelect,
380+ }
381+ t.Cleanup(func() { close(transport.release) })
382+
383+ result := runInventoryAttempt(t, clock, transport, true, &inventoryTestDialer{})
384+ require.NoError(t, result.err)
385+ hello := transport.hello()
386+ require.NotEqual(t, uint16(0), uint16(hello.NavigationCapabilities&protocol.NavigationCapabilityInventory),
387+ "the attempt must advertise inventory in Hello so the serving palette sends demands")
388+ require.NotNil(t, result.handoff)
389+ require.Empty(t, result.handoff.target.Endpoint)
390+ require.Equal(t, "alpha", result.handoff.target.Session)
391+ require.NotNil(t, result.handoff.target.ExactTarget)
392+ require.True(t, result.handoff.inventory)
393+}
394+
395+// TestInventoryAttemptIdleWithoutDemands pins the no-demand pairing: without
396+// demands the relay stays idle, dials nothing, and publishes nothing, and
397+// the attachment lifecycle is unaffected.
398+func TestInventoryAttemptIdleWithoutDemands(t *testing.T) {
399+ clock := newInventoryTestClock()
400+ dialer := &inventoryTestDialer{}
401+ transport := &inventoryAttemptTransport{
402+ published: make(chan struct{}), release: make(chan struct{}), detach: make(chan struct{}), script: inventoryScriptSilent,
403+ }
404+ t.Cleanup(func() { close(transport.release) })
405+
406+ done := make(chan attachResult, 1)
407+ go func() { done <- runInventoryAttempt(t, clock, transport, true, dialer) }()
408+ clock.fire()
409+ close(transport.detach)
410+
411+ result := <-done
412+ require.NoError(t, result.err)
413+ require.Nil(t, result.handoff)
414+ require.Zero(t, dialer.count(), "no demand must trigger zero control dials")
415+ require.Zero(t, transport.publicationCount())
416+}
417+
418+// TestInventoryAttemptIgnoresDemandsWhenLocal pins the local-attachment
419+// pairing: demands on a local serving attachment never enable the relay,
420+// so no control dial crosses even though the frames decode.
421+func TestInventoryAttemptIgnoresDemandsWhenLocal(t *testing.T) {
422+ clock := newInventoryTestClock()
423+ dialer := &inventoryTestDialer{}
424+ transport := &inventoryAttemptTransport{
425+ published: make(chan struct{}), release: make(chan struct{}), detach: make(chan struct{}), script: inventoryScriptLocalIgnore,
426+ }
427+ t.Cleanup(func() { close(transport.release) })
428+
429+ done := make(chan attachResult, 1)
430+ go func() { done <- runInventoryAttempt(t, clock, transport, false, dialer) }()
431+ close(transport.detach)
432+
433+ result := <-done
434+ require.NoError(t, result.err)
435+ require.Nil(t, result.handoff)
436+ require.Zero(t, dialer.count())
437+ require.Zero(t, transport.publicationCount())
438+}
A internal/usecase/client/inventory_cancellation_test.go +56 -0
@@ -0,0 +1,56 @@
1+package client
2+
3+import (
4+ "context"
5+ "io"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
11+ "github.com/bnema/vev/internal/protocol"
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func TestInventoryReopenRetainsPhysicalQuerySlotUntilCancellationCompletes(t *testing.T) {
17+ clock := newInventoryTestClock()
18+ closed := make(chan struct{})
19+ entered := make(chan struct{})
20+ var once sync.Once
21+ conn := portsmocks.NewMockClientConnection(t)
22+ conn.EXPECT().SendClient(mock.Anything).Return(nil).Once()
23+ conn.EXPECT().ReceiveServer().RunAndReturn(func() (protocol.ServerMessage, error) { close(entered); <-closed; return nil, io.EOF }).Once()
24+ conn.EXPECT().Close().RunAndReturn(func() error { once.Do(func() { close(closed) }); return nil }).Maybe()
25+ dialer := portsmocks.NewMockClientDialer(t)
26+ dialer.EXPECT().Dial(mock.Anything).Return(conn, nil).Once()
27+ relay := newInventoryRelay(clock, dialer)
28+ relay.setOpen(true, 1)
29+ query, ok := relay.beginPoll()
30+ require.True(t, ok)
31+ ctx, cancel := context.WithCancel(context.Background())
32+ defer cancel()
33+ done := make(chan error, 1)
34+ go func() { _, err := relay.querySnapshot(ctx, 1); done <- err }()
35+ select {
36+ case <-entered:
37+ case <-time.After(time.Second):
38+ t.Fatal("query never started")
39+ }
40+ for interaction := uint64(2); interaction < 6; interaction++ {
41+ relay.setOpen(false, interaction-1)
42+ cancel()
43+ relay.setOpen(true, interaction)
44+ _, ok := relay.beginPoll()
45+ require.False(t, ok, "close/reopen cannot release an unaccounted worker")
46+ }
47+ select {
48+ case err := <-done:
49+ require.Error(t, err)
50+ case <-time.After(time.Second):
51+ t.Fatal("cancellation did not close exact connection")
52+ }
53+ relay.endPoll(query)
54+ _, ok = relay.beginPoll()
55+ require.True(t, ok, "latest interaction starts immediately after old worker completes")
56+}
A internal/usecase/client/inventory_recovery_test.go +90 -0
@@ -0,0 +1,90 @@
1+package client_test
2+
3+import (
4+ "context"
5+ "sync"
6+ "testing"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
10+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
11+ "github.com/bnema/vev/internal/protocol"
12+ "github.com/bnema/vev/internal/protocol/wire"
13+ "github.com/bnema/vev/internal/usecase/client"
14+ "github.com/stretchr/testify/mock"
15+ "github.com/stretchr/testify/require"
16+)
17+
18+func TestInventoryFailureRestoresLatestRemoteIdentityAfterResumeRejection(t *testing.T) {
19+ term := newRunTerminal()
20+ defer term.in.unblock()
21+ local := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "local"}
22+ first := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "remote-first"}
23+ current := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{3}, SessionName: "remote-current"}
24+ welcome := func(target protocol.ExactSessionTarget) wire.Frame {
25+ return frameOf(wire.MsgWelcome, wire.MarshalWelcome(protocol.Welcome{SessionID: target.SessionName, SessionName: target.SessionName, ResumeToken: 17, Capabilities: protocol.CapabilityResume, CommittedIdentity: &protocol.CommittedRouteIdentity{Target: target}}))
26+ }
27+ rejected := func() wire.Frame {
28+ return frameOf(wire.MsgError, wire.MarshalErrorMsg(protocol.ErrorMsg{Code: protocol.ErrNoSuchTarget, Text: "unavailable"}))
29+ }
30+ initial := &recordingTransport{recvs: []recvItem{{f: welcome(local)}, {f: frameOf(wire.MsgAttachTarget, wire.MarshalAttachTarget(protocol.AttachTarget{Endpoint: "remote", Session: first.SessionName, Intent: protocol.IntentAttach, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}))}}}
31+ published := make(chan struct{})
32+ var once sync.Once
33+ selection := protocol.NavigationInventorySelection{InteractionGeneration: 1, PublicationGeneration: 1, SourceKey: "local", EntryKey: "entry"}
34+ remote := &recordingTransport{recvs: []recvItem{
35+ {f: welcome(first)},
36+ {f: frameOf(wire.MsgCommittedRouteIdentity, mustMarshalCommittedIdentity(protocol.CommittedRouteIdentity{Target: current}))},
37+ {f: frameOf(wire.MsgRoutePosition, mustMarshalRoutePosition(protocol.RoutePosition{Target: current, ActiveTabID: "remembered-tab"}))},
38+ {f: frameOf(wire.MsgNavigationInventoryDemand, wire.MarshalNavigationInventoryDemand(protocol.NavigationInventoryDemand{InteractionGeneration: 1, Open: true}))},
39+ {f: frameOf(wire.MsgNavigationInventorySelection, wire.MarshalNavigationInventorySelection(selection)), wait: published},
40+ }, stall: make(chan struct{})}
41+ release := make(chan struct{})
42+ remote.stall = release
43+ remote.onClose = func() {
44+ select {
45+ case <-release:
46+ default:
47+ close(release)
48+ }
49+ }
50+ remote.onSend = func(f wire.Frame) {
51+ if f.Type == wire.MsgNavigationInventoryPublication {
52+ once.Do(func() { close(published) })
53+ }
54+ }
55+ failed := &recordingTransport{recvs: []recvItem{{f: rejected()}}}
56+ resume := &recordingTransport{recvs: []recvItem{{f: rejected()}}}
57+ restored := &recordingTransport{recvs: []recvItem{{f: welcome(current)}, {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))}}}
58+ localDialer := &sequenceDialer{trs: []wire.Transport{initial, failed}}
59+ remoteDialer := &sequenceDialer{trs: []wire.Transport{remote, resume, restored}}
60+ control := portsmocks.NewMockClientDialer(t)
61+ control.EXPECT().Dial(mock.Anything).RunAndReturn(func(context.Context) (ports.ClientConnection, error) {
62+ conn := portsmocks.NewMockClientConnection(t)
63+ var request protocol.NavigationInventoryRequest
64+ conn.EXPECT().SendClient(mock.Anything).RunAndReturn(func(m protocol.ClientMessage) error { request = m.(protocol.NavigationInventoryRequest); return nil }).Once()
65+ conn.EXPECT().ReceiveServer().RunAndReturn(func() (protocol.ServerMessage, error) {
66+ response := protocol.NavigationInventoryResponse{RequestID: request.RequestID, Operation: request.Operation, Status: protocol.NavigationInventoryOK}
67+ if request.Operation == protocol.NavigationInventorySnapshot {
68+ response.Groups = []protocol.NavigationInventorySourceGroup{{SourceKey: "local", Status: protocol.NavigationInventorySourceOK, Entries: []protocol.NavigationInventoryEntry{{SourceKey: "local", EntryKey: "entry", Name: "local", DisplayOrigin: "local", State: "up"}}}}
69+ } else {
70+ response.Resolved = &protocol.AttachTarget{Session: local.SessionName, Intent: protocol.IntentAttach, ExactTarget: &local, EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned}
71+ }
72+ return response, nil
73+ }).Once()
74+ conn.EXPECT().Close().Return(nil).Maybe()
75+ return conn, nil
76+ }).Twice()
77+ deps := testDependencies(localDialer, term, realClock{}, nil, nil)
78+ deps.LocalControlDialer = control
79+ deps.AttachHandoff = func(protocol.AttachTarget) (ports.ClientDialer, client.AttachRequest, error) {
80+ return remoteDialer, client.AttachRequest{Intent: protocol.IntentAttach, SessionName: first.SessionName, Remote: true, Origin: protocol.RouteOriginRemote, OriginKey: "remote", HostLabel: "remote", EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, nil
81+ }
82+ require.NoError(t, runTestClient(context.Background(), deps, client.AttachRequest{Intent: protocol.IntentAttach, SessionName: local.SessionName, Origin: protocol.RouteOriginLocal, OriginKey: "local"}))
83+ for _, tr := range []*recordingTransport{resume, restored} {
84+ hello := helloFromSend(t, tr)
85+ require.Equal(t, &current, hello.ExactTarget)
86+ require.Equal(t, current.SessionName, hello.Name)
87+ require.Equal(t, domain.TabStableID("remembered-tab"), hello.PreferredTabID)
88+ }
89+ require.Equal(t, protocol.IntentAttach, helloFromSend(t, restored).Intent)
90+}
A internal/usecase/client/navigation_fallback_capture_test.go +84 -0
@@ -0,0 +1,84 @@
1+package client
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/vev/internal/protocol"
9+)
10+
11+// TestNavigationSelectionCaptureIsLedgerDetached pins the fallback-capture
12+// contract the Run loop relies on: prior and selected routes returned for a
13+// creation or recent-route handoff are copies detached from ledger state, so
14+// later request rebasing (committed identity, cursor, tab memory) cannot
15+// corrupt the recorded return route. P3 migrates the bookkeeping to a shared
16+// transition but must preserve these detachment facts.
17+func TestNavigationSelectionCaptureIsLedgerDetached(t *testing.T) {
18+ setup := func(t *testing.T) (*routeLedger, routeIdentity, routeGeneration) {
19+ t.Helper()
20+ ledger := newRouteLedger()
21+ first, err := ledger.commit(routeTestCandidate(1, protocol.RouteOriginLocal))
22+ require.NoError(t, err)
23+ second, err := ledger.commit(routeTestCandidate(2, protocol.RouteOriginLocal))
24+ require.NoError(t, err)
25+ require.NotEqual(t, first, second)
26+ generation := routeGeneration(ledger.snapshot().Generation)
27+ return ledger, first, generation
28+ }
29+
30+ tests := []struct {
31+ name string
32+ capture func(t *testing.T, ledger *routeLedger, selected routeIdentity, generation routeGeneration) (prior, current routeRecord)
33+ }{
34+ {name: "recent", capture: func(t *testing.T, ledger *routeLedger, selected routeIdentity, generation routeGeneration) (routeRecord, routeRecord) {
35+ t.Helper()
36+ selection, ok := ledger.navigationSelection(selected.wireNavigationAction(generation))
37+ require.True(t, ok)
38+ require.False(t, selection.noOp)
39+ return selection.prior, selection.selected
40+ }},
41+ {name: "creation", capture: func(t *testing.T, ledger *routeLedger, selected routeIdentity, generation routeGeneration) (routeRecord, routeRecord) {
42+ t.Helper()
43+ selection, ok := ledger.creationSelection(protocol.RouteCreateSessionAction{
44+ RequestID: 9, SnapshotGeneration: uint64(generation),
45+ Key: uint64(selected.key), Generation: uint64(selected.generation), SessionName: "example",
46+ })
47+ require.True(t, ok)
48+ return selection.prior, selection.selected
49+ }},
50+ }
51+ for _, tt := range tests {
52+ t.Run(tt.name, func(t *testing.T) {
53+ ledger, selected, generation := setup(t)
54+ before := ledger.snapshot()
55+ prior, current := tt.capture(t, ledger, selected, generation)
56+
57+ prior.request.SessionName = "mutated-prior"
58+ prior.request.Environment = append(prior.request.Environment, "MUTATED=1")
59+ current.request.SessionName = "mutated-selected"
60+ require.Equal(t, before, ledger.snapshot(), "captured fallback routes must not alias ledger state")
61+ })
62+ }
63+}
64+
65+// TestNavigationSelectionRejectsStaleCapture pins the prevalidation contract:
66+// a selection against a superseded snapshot generation is rejected before
67+// any fallback is captured, so a stale palette action keeps the current
68+// attachment intact.
69+func TestNavigationSelectionRejectsStaleCapture(t *testing.T) {
70+ ledger := newRouteLedger()
71+ selected, err := ledger.commit(routeTestCandidate(1, protocol.RouteOriginLocal))
72+ require.NoError(t, err)
73+ staleGeneration := routeGeneration(ledger.snapshot().Generation)
74+ _, err = ledger.commit(routeTestCandidate(2, protocol.RouteOriginLocal))
75+ require.NoError(t, err)
76+
77+ _, ok := ledger.navigationSelection(selected.wireNavigationAction(staleGeneration))
78+ require.False(t, ok)
79+ _, ok = ledger.creationSelection(protocol.RouteCreateSessionAction{
80+ RequestID: 9, SnapshotGeneration: uint64(staleGeneration),
81+ Key: uint64(selected.key), Generation: uint64(selected.generation), SessionName: "example",
82+ })
83+ require.False(t, ok)
84+}
A internal/usecase/client/navigation_inventory.go +346 -0
@@ -0,0 +1,346 @@
1+package client
2+
3+import (
4+ "context"
5+ "errors"
6+ "sort"
7+ "sync"
8+ "time"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/ports"
12+ "github.com/bnema/vev/internal/protocol"
13+)
14+
15+// inventoryPollInterval bounds control-source refresh while the palette is
16+// open. inventoryQueryTimeout bounds each complete dial/query; the exact
17+// connection closes on cancellation so blocked operations unwind.
18+const (
19+ inventoryPollInterval = time.Second
20+ inventoryQueryTimeout = 5 * time.Second
21+)
22+
23+// inventoryRelayEnabled gates the concrete local source independently of
24+// server demand: only after this client has a committed explicitly local
25+// route and while its serving attachment is remote. Home labels and remote
26+// home routes are not proof. Local-only and direct remote-only perform zero
27+// control dials, including when a remote daemon sends an unsolicited demand.
28+func inventoryRelayEnabled(homeCommitted, servingRemote bool, dialer ports.ClientDialer) bool {
29+ return homeCommitted && servingRemote && dialer != nil
30+}
31+
32+// inventoryEntryID is the opaque selection identity: source plus entry key.
33+type inventoryEntryID struct {
34+ source string
35+ entry string
36+}
37+
38+// inventoryRelay is the per-attachment navigation-inventory relay state. It
39+// owns no goroutine, cache, or persistent publication: the owning attempt
40+// drives polling through pollDue/beginPoll/endPoll and publishes through
41+// preparePublication. All work binds to attachment/transport identity plus
42+// the interaction generation; late results cannot affect a new attachment.
43+type inventoryRelay struct {
44+ clock ports.Clock
45+ dialer ports.ClientDialer
46+
47+ open bool
48+ interaction uint64
49+ publication uint64
50+ lastPoll time.Time
51+ // inFlight is the outstanding query identity, or zero when the slot
52+ // is free. Identities bind completions to their originating
53+ // interaction: a late result from a closed interaction drops instead
54+ // of polluting the new namespace or clearing its slot.
55+ inFlight uint64
56+ pollSeq uint64
57+
58+ published []protocol.NavigationInventorySourceGroup
59+ admitted map[inventoryEntryID]uint64
60+ retired map[inventoryEntryID]struct{}
61+}
62+
63+func newInventoryRelay(clock ports.Clock, dialer ports.ClientDialer) *inventoryRelay {
64+ return &inventoryRelay{clock: clock, dialer: dialer, admitted: make(map[inventoryEntryID]uint64), retired: make(map[inventoryEntryID]struct{})}
65+}
66+
67+// setOpen starts or stops polling for one palette interaction. Opening keeps
68+// a fresh interaction namespace: admitted keys and retirements never cross
69+// interactions, and reappearing targets earn new keys through new lifecycles.
70+func (r *inventoryRelay) setOpen(open bool, interaction uint64) {
71+ if r == nil {
72+ return
73+ }
74+ r.open = open
75+ if open && interaction != r.interaction {
76+ r.lastPoll = time.Time{}
77+ r.interaction = interaction
78+ r.publication = 0
79+ r.published = nil
80+ r.admitted = make(map[inventoryEntryID]uint64)
81+ r.retired = make(map[inventoryEntryID]struct{})
82+ }
83+}
84+
85+// pollDue reports whether a refresh query may start: open, none in flight,
86+// and at least one interval since the last poll.
87+func (r *inventoryRelay) pollDue() bool {
88+ if r == nil || !r.open || r.inFlight != 0 || r.clock == nil {
89+ return false
90+ }
91+ return !r.clock.Now().Before(r.lastPoll.Add(inventoryPollInterval))
92+}
93+
94+// beginPoll claims the single in-flight query slot, returning the query
95+// identity the completion must present to endPoll.
96+func (r *inventoryRelay) beginPoll() (uint64, bool) {
97+ if r == nil || !r.pollDue() {
98+ return 0, false
99+ }
100+ r.pollSeq++
101+ r.inFlight = r.pollSeq
102+ r.lastPoll = r.clock.Now()
103+ return r.inFlight, true
104+}
105+
106+// endPoll releases the in-flight slot, but only for the query that owns
107+// it. Stale completions from a closed interaction drop without touching
108+// the new namespace.
109+func (r *inventoryRelay) endPoll(query uint64) {
110+ if r == nil || query == 0 || r.inFlight != query {
111+ return
112+ }
113+ r.inFlight = 0
114+}
115+
116+// cancel stops polling and drops queued updates on close, reconnect,
117+// handoff, attachment replacement, or process cancellation.
118+func (r *inventoryRelay) cancel() {
119+ if r == nil {
120+ return
121+ }
122+ r.open = false
123+ r.inFlight = 0
124+}
125+
126+// canonicalInventoryGroups sorts groups and entries by key so change
127+// comparison ignores transport order. Poll timestamps and request IDs never
128+// reach this form, so idle unchanged inventory compares equal.
129+func canonicalInventoryGroups(groups []protocol.NavigationInventorySourceGroup) []protocol.NavigationInventorySourceGroup {
130+ out := make([]protocol.NavigationInventorySourceGroup, 0, len(groups))
131+ for _, group := range groups {
132+ entries := make([]protocol.NavigationInventoryEntry, len(group.Entries))
133+ copy(entries, group.Entries)
134+ sort.Slice(entries, func(i, j int) bool { return entries[i].EntryKey < entries[j].EntryKey })
135+ out = append(out, protocol.NavigationInventorySourceGroup{SourceKey: group.SourceKey, Status: group.Status, Entries: entries})
136+ }
137+ sort.Slice(out, func(i, j int) bool { return out[i].SourceKey < out[j].SourceKey })
138+ return out
139+}
140+
141+func inventoryGroupsEqual(a, b []protocol.NavigationInventorySourceGroup) bool {
142+ if len(a) != len(b) {
143+ return false
144+ }
145+ for i := range a {
146+ if a[i].SourceKey != b[i].SourceKey || a[i].Status != b[i].Status || len(a[i].Entries) != len(b[i].Entries) {
147+ return false
148+ }
149+ for j := range a[i].Entries {
150+ if a[i].Entries[j] != b[i].Entries[j] {
151+ return false
152+ }
153+ }
154+ }
155+ return true
156+}
157+
158+// preparePublication admits a fresh snapshot and reports whether it carries
159+// a semantic change worth publishing remotely. Unchanged polls produce no
160+// new publication. Removed targets retire their keys for the interaction and
161+// never reuse them; admitted keys record the publication generation that
162+// first carried them so older displayed selections stay valid while current.
163+func (r *inventoryRelay) preparePublication(groups []protocol.NavigationInventorySourceGroup) ([]protocol.NavigationInventorySourceGroup, uint64, bool) {
164+ if r == nil {
165+ return nil, 0, false
166+ }
167+ canonical := canonicalInventoryGroups(groups)
168+ if inventoryGroupsEqual(canonical, r.published) {
169+ return nil, 0, false
170+ }
171+ r.publication++
172+ seen := make(map[inventoryEntryID]struct{}, len(canonical))
173+ for _, group := range canonical {
174+ for _, entry := range group.Entries {
175+ id := inventoryEntryID{source: entry.SourceKey, entry: entry.EntryKey}
176+ seen[id] = struct{}{}
177+ if _, ok := r.admitted[id]; !ok {
178+ r.admitted[id] = r.publication
179+ }
180+ }
181+ }
182+ for id := range r.admitted {
183+ if _, ok := seen[id]; !ok {
184+ if _, retired := r.retired[id]; !retired {
185+ r.retired[id] = struct{}{}
186+ }
187+ }
188+ }
189+ r.published = canonical
190+ return canonical, r.publication, true
191+}
192+
193+// validateSelection admits a serving-daemon selection against published
194+// state. Generations are monotonic: future generations, keys not yet
195+// published at the selected generation, and retired or unknown keys reject.
196+// An unchanged key survives refresh lag: selection from an older displayed
197+// publication stays valid while the key is still current.
198+func (r *inventoryRelay) validateSelection(selection protocol.NavigationInventorySelection) error {
199+ if r == nil {
200+ return errors.New("vev: inventory relay unavailable")
201+ }
202+ if err := protocol.ValidateNavigationInventorySelection(selection); err != nil {
203+ return err
204+ }
205+ // Remote-source resolution needs source registration the relay never
206+ // carries; remote vision stays off until the E4 slice wires it. Local
207+ // sources resolve registration-free on the control connection.
208+ if selection.SourceKey != protocol.NavigationInventoryLocalSourceKey {
209+ return errors.New("vev: inventory selection for a remote source is unavailable")
210+ }
211+ if selection.InteractionGeneration != r.interaction {
212+ return errors.New("vev: inventory selection for a closed interaction")
213+ }
214+ if selection.PublicationGeneration > r.publication {
215+ return errors.New("vev: inventory selection from a future publication")
216+ }
217+ id := inventoryEntryID{source: selection.SourceKey, entry: selection.EntryKey}
218+ if _, retired := r.retired[id]; retired {
219+ return errors.New("vev: inventory selection for a retired entry")
220+ }
221+ admittedAt, ok := r.admitted[id]
222+ if !ok || admittedAt > selection.PublicationGeneration {
223+ return errors.New("vev: inventory selection for an unpublished entry")
224+ }
225+ return nil
226+}
227+
228+var errInventorySourceUnavailable = errors.New("vev: local inventory source unavailable")
229+
230+// watchQueryConn closes the exact connection when the attempt cancels or
231+// the clock-driven query budget expires. Established IPC connections do
232+// not retain the dial context, so without this a wedged source parks the
233+// worker past teardown. The returned stop func releases the watcher.
234+func (r *inventoryRelay) watchQueryConn(ctx context.Context, closeConn func()) func() {
235+ noop := func() {}
236+ if r == nil || closeConn == nil {
237+ return noop
238+ }
239+ done := make(chan struct{})
240+ var timer ports.Timer
241+ var timerC <-chan time.Time
242+ if r.clock != nil {
243+ timer = r.clock.NewTimer(inventoryQueryTimeout)
244+ timerC = timer.C()
245+ }
246+ go func() {
247+ select {
248+ case <-ctx.Done():
249+ closeConn()
250+ case <-timerC:
251+ closeConn()
252+ case <-done:
253+ }
254+ }()
255+ return func() {
256+ close(done)
257+ if timer != nil {
258+ timer.Stop()
259+ }
260+ }
261+}
262+
263+// querySnapshot performs one bounded dial-only snapshot query without Hello,
264+// session creation, attachment, or daemon startup. A nil dialer or dial
265+// failure reports source unavailability; the serving attachment is
266+// unaffected and native commands remain usable.
267+func (r *inventoryRelay) querySnapshot(ctx context.Context, requestID uint64) (protocol.NavigationInventoryResponse, error) {
268+ if r == nil || r.dialer == nil {
269+ return protocol.NavigationInventoryResponse{}, errInventorySourceUnavailable
270+ }
271+ ctx, cancel := context.WithTimeout(ctx, inventoryQueryTimeout)
272+ defer cancel()
273+ conn, err := r.dialer.Dial(ctx)
274+ if err != nil {
275+ return protocol.NavigationInventoryResponse{}, errInventorySourceUnavailable
276+ }
277+ var closeOnce sync.Once
278+ closeConn := func() { closeOnce.Do(func() { _ = conn.Close() }) }
279+ defer closeConn()
280+ stopWatch := r.watchQueryConn(ctx, closeConn)
281+ defer stopWatch()
282+ request := protocol.NavigationInventoryRequest{Version: protocol.Version, RequestID: requestID, Operation: protocol.NavigationInventorySnapshot}
283+ if err := protocol.ValidateNavigationInventoryRequest(request); err != nil {
284+ return protocol.NavigationInventoryResponse{}, err
285+ }
286+ if err := conn.SendClient(request); err != nil {
287+ return protocol.NavigationInventoryResponse{}, err
288+ }
289+ message, err := conn.ReceiveServer()
290+ if err != nil {
291+ return protocol.NavigationInventoryResponse{}, err
292+ }
293+ response, ok := message.(protocol.NavigationInventoryResponse)
294+ if !ok {
295+ return protocol.NavigationInventoryResponse{}, errors.New("vev: unexpected inventory response type")
296+ }
297+ if err := protocol.ValidateNavigationInventoryResponse(response); err != nil {
298+ return protocol.NavigationInventoryResponse{}, err
299+ }
300+ if response.RequestID != requestID || response.Operation != protocol.NavigationInventorySnapshot {
301+ return protocol.NavigationInventoryResponse{}, errors.New("vev: inventory response identity mismatch")
302+ }
303+ return response, nil
304+}
305+
306+// resolveEntry revalidates one selected key against the source and returns
307+// the non-mutating attach target. Registration fencing happens source-side;
308+// the relay only checks response identity before admitting any data.
309+func (r *inventoryRelay) resolveEntry(ctx context.Context, requestID uint64, source, entry string, registration domain.RemoteRegistration) (protocol.AttachTarget, error) {
310+ if r == nil || r.dialer == nil {
311+ return protocol.AttachTarget{}, errInventorySourceUnavailable
312+ }
313+ ctx, cancel := context.WithTimeout(ctx, inventoryQueryTimeout)
314+ defer cancel()
315+ conn, err := r.dialer.Dial(ctx)
316+ if err != nil {
317+ return protocol.AttachTarget{}, errInventorySourceUnavailable
318+ }
319+ var closeOnce sync.Once
320+ closeConn := func() { closeOnce.Do(func() { _ = conn.Close() }) }
321+ defer closeConn()
322+ stopWatch := r.watchQueryConn(ctx, closeConn)
323+ defer stopWatch()
324+ request := protocol.NavigationInventoryRequest{Version: protocol.Version, RequestID: requestID, Operation: protocol.NavigationInventoryResolve, SourceKey: source, EntryKey: entry, Registration: registration}
325+ if err := protocol.ValidateNavigationInventoryRequest(request); err != nil {
326+ return protocol.AttachTarget{}, err
327+ }
328+ if err := conn.SendClient(request); err != nil {
329+ return protocol.AttachTarget{}, err
330+ }
331+ message, err := conn.ReceiveServer()
332+ if err != nil {
333+ return protocol.AttachTarget{}, err
334+ }
335+ response, ok := message.(protocol.NavigationInventoryResponse)
336+ if !ok {
337+ return protocol.AttachTarget{}, errors.New("vev: unexpected inventory response type")
338+ }
339+ if err := protocol.ValidateNavigationInventoryResponse(response); err != nil {
340+ return protocol.AttachTarget{}, err
341+ }
342+ if response.RequestID != requestID || response.Operation != protocol.NavigationInventoryResolve || response.Status != protocol.NavigationInventoryOK || response.Resolved == nil {
343+ return protocol.AttachTarget{}, errors.New("vev: inventory resolve rejected")
344+ }
345+ return *response.Resolved, nil
346+}
A internal/usecase/client/navigation_inventory_test.go +330 -0
@@ -0,0 +1,330 @@
1+package client
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/vev/internal/ports"
9+ "github.com/bnema/vev/internal/protocol"
10+)
11+
12+// manualClock is a fake ports.Clock with explicit time control: no sleeps.
13+type manualClock struct {
14+ now time.Time
15+}
16+
17+func (c *manualClock) Now() time.Time { return c.now }
18+
19+func (c *manualClock) NewTimer(d time.Duration) ports.Timer { return manualTimer{at: c.now.Add(d)} }
20+
21+type manualTimer struct{ at time.Time }
22+
23+func (t manualTimer) C() <-chan time.Time { return nil }
24+
25+func (t manualTimer) Reset(time.Duration) bool { return false }
26+
27+func (t manualTimer) Stop() bool { return false }
28+
29+// inventoryFakeConnection scripts one control query: records sends, replays
30+// queued server messages, counts closes.
31+type inventoryFakeConnection struct {
32+ sent []protocol.ClientMessage
33+ replies []protocol.ServerMessage
34+ recvErr error
35+ closes int
36+}
37+
38+func (c *inventoryFakeConnection) SendClient(message protocol.ClientMessage) error {
39+ c.sent = append(c.sent, message)
40+ return nil
41+}
42+
43+func (c *inventoryFakeConnection) ReceiveServer() (protocol.ServerMessage, error) {
44+ if c.recvErr != nil {
45+ return nil, c.recvErr
46+ }
47+ if len(c.replies) == 0 {
48+ return nil, context.DeadlineExceeded
49+ }
50+ message := c.replies[0]
51+ c.replies = c.replies[1:]
52+ return message, nil
53+}
54+
55+func (c *inventoryFakeConnection) Capabilities() protocol.ConnectionCapabilities {
56+ return protocol.ConnectionCapabilities{}
57+}
58+
59+func (c *inventoryFakeConnection) LinkState() ports.LinkState { return ports.LinkStateConnected }
60+
61+func (c *inventoryFakeConnection) LinkEvents() <-chan ports.LinkEvent { return nil }
62+
63+func (c *inventoryFakeConnection) Close() error {
64+ c.closes++
65+ return nil
66+}
67+
68+// inventoryFakeDialer counts dials and hands out one scripted connection.
69+// Zero dials is asserted directly for local-only and remote-only modes.
70+type inventoryFakeDialer struct {
71+ dials int
72+ conn *inventoryFakeConnection
73+ dialErr error
74+}
75+
76+func (d *inventoryFakeDialer) Dial(context.Context) (ports.ClientConnection, error) {
77+ d.dials++
78+ if d.dialErr != nil {
79+ return nil, d.dialErr
80+ }
81+ return d.conn, nil
82+}
83+
84+func inventoryTestGroups() []protocol.NavigationInventorySourceGroup {
85+ return []protocol.NavigationInventorySourceGroup{{
86+ SourceKey: protocol.NavigationInventoryLocalSourceKey,
87+ Status: protocol.NavigationInventorySourceOK,
88+ Entries: []protocol.NavigationInventoryEntry{
89+ {SourceKey: protocol.NavigationInventoryLocalSourceKey, EntryKey: "aaa/one", Name: "one"},
90+ {SourceKey: protocol.NavigationInventoryLocalSourceKey, EntryKey: "bbb/two", Name: "two"},
91+ },
92+ }}
93+}
94+
95+func inventoryTestResponse(requestID uint64) protocol.NavigationInventoryResponse {
96+ return protocol.NavigationInventoryResponse{
97+ RequestID: requestID, Operation: protocol.NavigationInventorySnapshot,
98+ Status: protocol.NavigationInventoryOK, Groups: inventoryTestGroups(),
99+ }
100+}
101+
102+func TestInventoryRelayEnabledModes(t *testing.T) {
103+ dialer := &inventoryFakeDialer{conn: &inventoryFakeConnection{}}
104+ tests := []struct {
105+ name string
106+ homeCommitted bool
107+ servingRemote bool
108+ dialer ports.ClientDialer
109+ want bool
110+ }{
111+ {name: "hybrid remote serving", homeCommitted: true, servingRemote: true, dialer: dialer, want: true},
112+ {name: "local only never dials", homeCommitted: true, servingRemote: false, dialer: dialer},
113+ {name: "direct remote only never dials", servingRemote: true, dialer: dialer},
114+ {name: "no dialer never dials", homeCommitted: true, servingRemote: true},
115+ {name: "unsolicited demand without source never dials", servingRemote: true},
116+ }
117+ for _, tt := range tests {
118+ t.Run(tt.name, func(t *testing.T) {
119+ if got := inventoryRelayEnabled(tt.homeCommitted, tt.servingRemote, tt.dialer); got != tt.want {
120+ t.Fatalf("inventoryRelayEnabled() = %t, want %t", got, tt.want)
121+ }
122+ })
123+ }
124+}
125+
126+func TestInventoryRelayZeroDialsWithoutSource(t *testing.T) {
127+ for _, dialer := range []ports.ClientDialer{nil, &inventoryFakeDialer{conn: &inventoryFakeConnection{}}} {
128+ relay := newInventoryRelay(&manualClock{now: time.Unix(1_000, 0)}, nil)
129+ if _, err := relay.querySnapshot(context.Background(), 1); err == nil {
130+ t.Fatal("nil-dialer query must report source unavailability")
131+ }
132+ _ = dialer
133+ }
134+ relay := newInventoryRelay(&manualClock{now: time.Unix(1_000, 0)}, nil)
135+ relay.setOpen(true, 1)
136+ query, ok := relay.beginPoll()
137+ if !ok || query == 0 {
138+ t.Fatal("poll slot should open without a source")
139+ }
140+ relay.endPoll(query)
141+}
142+
143+func TestInventoryRelaySnapshotAdmitsAndSuppressesUnchanged(t *testing.T) {
144+ clock := &manualClock{now: time.Unix(1_000, 0)}
145+ conn := &inventoryFakeConnection{replies: []protocol.ServerMessage{inventoryTestResponse(7), inventoryTestResponse(8)}}
146+ dialer := &inventoryFakeDialer{conn: conn}
147+ relay := newInventoryRelay(clock, dialer)
148+ relay.setOpen(true, 3)
149+ first, ok := relay.beginPoll()
150+ if !ok || first == 0 {
151+ t.Fatal("first poll should start")
152+ }
153+ response, err := relay.querySnapshot(context.Background(), 7)
154+ if err != nil {
155+ t.Fatalf("querySnapshot() error = %v", err)
156+ }
157+ relay.endPoll(first)
158+ if dialer.dials != 1 || conn.closes != 1 {
159+ t.Fatalf("dials = %d, closes = %d; want exactly one bounded query", dialer.dials, conn.closes)
160+ }
161+ groups, generation, changed := relay.preparePublication(response.Groups)
162+ if !changed || generation != 1 || len(groups) != 1 {
163+ t.Fatalf("first snapshot must publish once, got changed=%t generation=%d", changed, generation)
164+ }
165+
166+ clock.now = clock.now.Add(2 * time.Second)
167+ second, ok := relay.beginPoll()
168+ if !ok || second == 0 || second == first {
169+ t.Fatal("second poll should start after the interval with a new identity")
170+ }
171+ response, err = relay.querySnapshot(context.Background(), 8)
172+ if err != nil {
173+ t.Fatalf("second querySnapshot() error = %v", err)
174+ }
175+ relay.endPoll(second)
176+ if _, _, changed := relay.preparePublication(response.Groups); changed {
177+ t.Fatal("unchanged inventory must not republish remotely")
178+ }
179+}
180+
181+func TestInventoryRelaySelectionSurvivesRefreshLag(t *testing.T) {
182+ clock := &manualClock{now: time.Unix(1_000, 0)}
183+ groups := inventoryTestGroups()
184+ relay := newInventoryRelay(clock, &inventoryFakeDialer{})
185+ relay.setOpen(true, 5)
186+ if _, _, changed := relay.preparePublication(groups); !changed {
187+ t.Fatal("initial publication must report changed")
188+ }
189+ // Two further unchanged refreshes: no new publication, same keys.
190+ clock.now = clock.now.Add(3 * time.Second)
191+ if _, _, changed := relay.preparePublication(groups); changed {
192+ t.Fatal("unchanged refresh must stay silent")
193+ }
194+ clock.now = clock.now.Add(3 * time.Second)
195+ if _, _, changed := relay.preparePublication(groups); changed {
196+ t.Fatal("unchanged refresh must stay silent")
197+ }
198+ // Selection from the older displayed publication stays valid: the key is
199+ // still current and was already published at that generation.
200+ selection := protocol.NavigationInventorySelection{CauseActionID: 9, InteractionGeneration: 5, PublicationGeneration: 1, SourceKey: protocol.NavigationInventoryLocalSourceKey, EntryKey: "aaa/one"}
201+ if err := relay.validateSelection(selection); err != nil {
202+ t.Fatalf("lagged selection for an unchanged key must stay valid: %v", err)
203+ }
204+}
205+
206+func TestInventoryRelaySelectionRejects(t *testing.T) {
207+ clock := &manualClock{now: time.Unix(1_000, 0)}
208+ relay := newInventoryRelay(clock, &inventoryFakeDialer{})
209+ relay.setOpen(true, 5)
210+ groups, generation, changed := relay.preparePublication(inventoryTestGroups())
211+ if !changed || generation != 1 {
212+ t.Fatal("initial publication must report changed")
213+ }
214+ _ = groups
215+ valid := protocol.NavigationInventorySelection{CauseActionID: 9, InteractionGeneration: 5, PublicationGeneration: 1, SourceKey: protocol.NavigationInventoryLocalSourceKey, EntryKey: "aaa/one"}
216+ if err := relay.validateSelection(valid); err != nil {
217+ t.Fatalf("current selection must validate: %v", err)
218+ }
219+ tests := []struct {
220+ name string
221+ mutate func(*protocol.NavigationInventorySelection)
222+ wantError bool
223+ }{
224+ {name: "future generation", mutate: func(s *protocol.NavigationInventorySelection) { s.PublicationGeneration = 2 }, wantError: true},
225+ {name: "unknown key", mutate: func(s *protocol.NavigationInventorySelection) { s.EntryKey = "zzz/nope" }, wantError: true},
226+ {name: "closed interaction", mutate: func(s *protocol.NavigationInventorySelection) { s.InteractionGeneration = 4 }, wantError: true},
227+ {name: "zero publication", mutate: func(s *protocol.NavigationInventorySelection) { s.PublicationGeneration = 0 }, wantError: true},
228+ {name: "zero cause stays valid for keyboard input", mutate: func(s *protocol.NavigationInventorySelection) { s.CauseActionID = 0 }, wantError: false},
229+ }
230+ for _, tt := range tests {
231+ t.Run(tt.name, func(t *testing.T) {
232+ selection := valid
233+ tt.mutate(&selection)
234+ if err := relay.validateSelection(selection); (err != nil) != tt.wantError {
235+ t.Fatalf("validateSelection() = %v, wantError %t", err, tt.wantError)
236+ }
237+ })
238+ }
239+ // Retire the key by removing it, then check rejection.
240+ thinned := []protocol.NavigationInventorySourceGroup{{
241+ SourceKey: protocol.NavigationInventoryLocalSourceKey, Status: protocol.NavigationInventorySourceOK,
242+ Entries: []protocol.NavigationInventoryEntry{
243+ {SourceKey: protocol.NavigationInventoryLocalSourceKey, EntryKey: "bbb/two", Name: "two"},
244+ },
245+ }}
246+ if _, _, changed := relay.preparePublication(thinned); !changed {
247+ t.Fatal("removal must publish")
248+ }
249+ if err := relay.validateSelection(valid); err == nil {
250+ t.Fatal("retired key must reject without same-name substitution")
251+ }
252+}
253+
254+func TestInventoryRelayCancelAndPollGating(t *testing.T) {
255+ clock := &manualClock{now: time.Unix(1_000, 0)}
256+ relay := newInventoryRelay(clock, &inventoryFakeDialer{})
257+ if relay.pollDue() {
258+ t.Fatal("closed relay must not poll")
259+ }
260+ if _, ok := relay.beginPoll(); ok {
261+ t.Fatal("closed relay must not claim the slot")
262+ }
263+ relay.setOpen(true, 2)
264+ if !relay.pollDue() {
265+ t.Fatal("open relay must poll")
266+ }
267+ first, ok := relay.beginPoll()
268+ if !ok {
269+ t.Fatal("open relay must claim the slot")
270+ }
271+ if _, ok := relay.beginPoll(); ok {
272+ t.Fatal("at most one query in flight")
273+ }
274+ relay.endPoll(first)
275+ clock.now = clock.now.Add(inventoryPollInterval)
276+ if !relay.pollDue() {
277+ t.Fatal("poll due again after the interval")
278+ }
279+ relay.cancel()
280+ if relay.pollDue() {
281+ t.Fatal("cancelled relay must not poll")
282+ }
283+ if _, ok := relay.beginPoll(); ok {
284+ t.Fatal("cancelled relay must not claim the slot")
285+ }
286+}
287+
288+func TestInventoryRelayStaleCompletionDropsWithoutTouchingSlot(t *testing.T) {
289+ clock := &manualClock{now: time.Unix(1_000, 0)}
290+ relay := newInventoryRelay(clock, &inventoryFakeDialer{})
291+ relay.setOpen(true, 2)
292+ stale, ok := relay.beginPoll()
293+ if !ok {
294+ t.Fatal("first poll should start")
295+ }
296+ // Closing for a new interaction releases the slot without waiting
297+ // for the outstanding worker.
298+ relay.setOpen(true, 3)
299+ relay.endPoll(stale)
300+ if relay.inFlight != 0 {
301+ t.Fatal("stale completion must not clear the new namespace slot")
302+ }
303+ clock.now = clock.now.Add(inventoryPollInterval)
304+ fresh, ok := relay.beginPoll()
305+ if !ok || fresh == stale {
306+ t.Fatal("new interaction must claim a fresh slot identity")
307+ }
308+ relay.endPoll(fresh + 99)
309+ if relay.inFlight != fresh {
310+ t.Fatal("foreign identity must not release the slot")
311+ }
312+ relay.endPoll(fresh)
313+ if relay.inFlight != 0 {
314+ t.Fatal("owning identity must release the slot")
315+ }
316+}
317+
318+func TestInventoryRelayQueryRejectsIdentityMismatch(t *testing.T) {
319+ clock := &manualClock{now: time.Unix(1_000, 0)}
320+ mismatched := inventoryTestResponse(999)
321+ conn := &inventoryFakeConnection{replies: []protocol.ServerMessage{mismatched}}
322+ dialer := &inventoryFakeDialer{conn: conn}
323+ relay := newInventoryRelay(clock, dialer)
324+ if _, err := relay.querySnapshot(context.Background(), 7); err == nil {
325+ t.Fatal("response ID mismatch must not admit data")
326+ }
327+ if len(relay.admitted) != 0 {
328+ t.Fatal("mismatched response must admit nothing")
329+ }
330+}
M internal/usecase/client/navigation_test.go +1 -1
@@ -42,7 +42,7 @@ func TestValidateAttachRequestNavigationTable(t *testing.T) {
4242 {name: "home picker on client-owned remote new route", request: AttachRequest{Intent: protocol.IntentNew, SessionName: "example", Remote: true, EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned, NavigationCapabilities: protocol.NavigationCapabilityHomePicker}, valid: true},
4343 {name: "back on client-owned route", request: AttachRequest{StartupOverlay: protocol.StartupOverlaySessionPicker, NavigationCapabilities: protocol.NavigationCapabilityBack}, valid: true},
4444 {name: "back on remote-target route", request: AttachRequest{Intent: protocol.IntentAttach, SessionName: "work", RemoteTarget: remoteRoute, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, StartupOverlay: protocol.StartupOverlaySessionPicker, NavigationCapabilities: protocol.NavigationCapabilityBack}, valid: false},
45- {name: "unknown capability", request: AttachRequest{NavigationCapabilities: 4}, valid: false},
45+ {name: "unknown capability", request: AttachRequest{NavigationCapabilities: 8}, valid: false},
4646 {name: "back without startup picker", request: AttachRequest{NavigationCapabilities: protocol.NavigationCapabilityBack}, valid: false},
4747 {name: "startup picker without back", request: AttachRequest{StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: false},
4848 }
A internal/usecase/client/navigation_transition.go +94 -0
@@ -0,0 +1,94 @@
1+package client
2+
3+import "github.com/bnema/vev/internal/protocol"
4+
5+// Home-picker overlays retain their separate navigation lifecycle.
6+type navigationOperation uint8
7+
8+const (
9+ navigationOperationNone navigationOperation = iota
10+ navigationOperationCreation
11+ navigationOperationRecent
12+ navigationOperationInventory
13+)
14+
15+// navigationTransition retains recovery until destination publication. Target
16+// transport authority belongs to attachHandoff.source; history belongs to the
17+// route ledger. Neither is duplicated here.
18+type navigationTransition struct {
19+ operation navigationOperation
20+ returnRoute attachRoute
21+ requestID uint64
22+ // inventorySelection identifies the serving palette selection that
23+ // caused the handoff. It survives destination dial/handshake failure
24+ // so recovery can report NavigationInventoryNavigationFailed to the
25+ // restored serving daemon.
26+ inventorySelection protocol.NavigationInventorySelection
27+ hasInventorySelection bool
28+}
29+
30+func (t *navigationTransition) active() bool {
31+ return t != nil && t.operation != navigationOperationNone
32+}
33+
34+func (t *navigationTransition) pendingCreation() bool {
35+ return t != nil && t.operation == navigationOperationCreation
36+}
37+
38+func (t *navigationTransition) pendingRecent() bool {
39+ return t != nil && t.operation == navigationOperationRecent
40+}
41+
42+func (t *navigationTransition) pendingInventory() bool {
43+ return t != nil && t.operation == navigationOperationInventory
44+}
45+
46+func (t *navigationTransition) beginCreation(returnRoute attachRoute, requestID uint64) {
47+ *t = navigationTransition{operation: navigationOperationCreation, returnRoute: returnRoute, requestID: requestID}
48+}
49+
50+func (t *navigationTransition) beginRecent(returnRoute attachRoute) {
51+ *t = navigationTransition{operation: navigationOperationRecent, returnRoute: returnRoute}
52+}
53+
54+func (t *navigationTransition) beginInventory(returnRoute attachRoute, selection protocol.NavigationInventorySelection) {
55+ *t = navigationTransition{operation: navigationOperationInventory, returnRoute: returnRoute, inventorySelection: selection, hasInventorySelection: true}
56+}
57+
58+// inventoryNavigationFailure reports the failed handoff to the restored
59+// serving daemon. It returns false when no selection was retained or the
60+// failure does not validate.
61+func (t *navigationTransition) inventoryNavigationFailure() (protocol.NavigationInventoryFailure, bool) {
62+ if t == nil || !t.hasInventorySelection {
63+ return protocol.NavigationInventoryFailure{}, false
64+ }
65+ failure := protocol.NavigationInventoryFailure{
66+ CauseActionID: t.inventorySelection.CauseActionID,
67+ InteractionGeneration: t.inventorySelection.InteractionGeneration,
68+ SourceKey: t.inventorySelection.SourceKey,
69+ EntryKey: t.inventorySelection.EntryKey,
70+ Code: protocol.NavigationInventoryNavigationFailed,
71+ }
72+ if err := protocol.ValidateNavigationInventoryFailure(failure); err != nil {
73+ return protocol.NavigationInventoryFailure{}, false
74+ }
75+ return failure, true
76+}
77+
78+func (t *navigationTransition) restore() (attachRoute, bool) {
79+ if !t.active() {
80+ return attachRoute{}, false
81+ }
82+ return t.returnRoute, true
83+}
84+
85+// Both outcomes retire the same pending recovery record. The route ledger
86+// independently commits successful destinations.
87+func (t *navigationTransition) settleSuccess() { t.clear() }
88+func (t *navigationTransition) settleFailure() { t.clear() }
89+
90+func (t *navigationTransition) clear() {
91+ if t != nil {
92+ *t = navigationTransition{}
93+ }
94+}
A internal/usecase/client/navigation_transition_test.go +100 -0
@@ -0,0 +1,100 @@
1+package client
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/protocol"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func testAttachRoute(name string) attachRoute {
11+ return attachRoute{request: AttachRequest{SessionName: name}}
12+}
13+
14+func TestNavigationTransitionRetiresRecoveryOnce(t *testing.T) {
15+ for _, tc := range []struct {
16+ name string
17+ begin func(*navigationTransition)
18+ }{
19+ {"creation", func(n *navigationTransition) { n.beginCreation(testAttachRoute("return"), 7) }},
20+ {"recent", func(n *navigationTransition) { n.beginRecent(testAttachRoute("return")) }},
21+ {"inventory", func(n *navigationTransition) {
22+ n.beginInventory(testAttachRoute("return"), protocol.NavigationInventorySelection{
23+ CauseActionID: 1,
24+ InteractionGeneration: 2,
25+ SourceKey: protocol.NavigationInventoryLocalSourceKey,
26+ EntryKey: "entry",
27+ })
28+ }},
29+ } {
30+ t.Run(tc.name, func(t *testing.T) {
31+ for _, success := range []bool{false, true} {
32+ var n navigationTransition
33+ tc.begin(&n)
34+ require.True(t, n.active())
35+ route, ok := n.restore()
36+ require.True(t, ok)
37+ require.Equal(t, "return", route.request.SessionName)
38+ if success {
39+ n.settleSuccess()
40+ } else {
41+ n.settleFailure()
42+ }
43+ require.False(t, n.active())
44+ _, ok = n.restore()
45+ require.False(t, ok)
46+ n.clear()
47+ require.False(t, n.active())
48+ }
49+ })
50+ }
51+}
52+
53+func TestNavigationTransitionInventoryFailure(t *testing.T) {
54+ for _, tc := range []struct {
55+ name string
56+ begin func(*navigationTransition)
57+ wantCode protocol.NavigationInventoryFailureCode
58+ wantValid bool
59+ }{
60+ {
61+ name: "inventory retains selection for failed handoff",
62+ begin: func(n *navigationTransition) {
63+ n.beginInventory(testAttachRoute("return"), protocol.NavigationInventorySelection{
64+ CauseActionID: 9,
65+ InteractionGeneration: 3,
66+ SourceKey: protocol.NavigationInventoryLocalSourceKey,
67+ EntryKey: "entry",
68+ })
69+ },
70+ wantCode: protocol.NavigationInventoryNavigationFailed,
71+ wantValid: true,
72+ },
73+ {
74+ name: "creation carries no inventory failure",
75+ begin: func(n *navigationTransition) { n.beginCreation(testAttachRoute("return"), 7) },
76+ wantValid: false,
77+ },
78+ {
79+ name: "recent carries no inventory failure",
80+ begin: func(n *navigationTransition) { n.beginRecent(testAttachRoute("return")) },
81+ wantValid: false,
82+ },
83+ } {
84+ t.Run(tc.name, func(t *testing.T) {
85+ var n navigationTransition
86+ tc.begin(&n)
87+ failure, ok := n.inventoryNavigationFailure()
88+ require.Equal(t, tc.wantValid, ok)
89+ if !tc.wantValid {
90+ return
91+ }
92+ require.Equal(t, tc.wantCode, failure.Code)
93+ require.Equal(t, uint64(9), failure.CauseActionID)
94+ require.Equal(t, uint64(3), failure.InteractionGeneration)
95+ require.Equal(t, protocol.NavigationInventoryLocalSourceKey, failure.SourceKey)
96+ require.Equal(t, "entry", failure.EntryKey)
97+ require.NoError(t, protocol.ValidateNavigationInventoryFailure(failure))
98+ })
99+ }
100+}
M internal/usecase/client/routes.go +11 -0
@@ -435,6 +435,17 @@ func (l *routeLedger) commitLocked(candidate routeCandidate) (routeIdentity, err
435435 resumeToken: candidate.resumeToken,
436436 home: isHome,
437437 }
438+ // Resume credentials belong to attachments, which can move between
439+ // sessions on one daemon. Only the newly committed route may retain
440+ // this credential; older routes must use their exact attach targets.
441+ if candidate.resumeToken != 0 {
442+ for i := range l.entries {
443+ previous := &l.entries[i]
444+ if previous.origin == candidate.origin && previous.originKey == candidate.originKey && previous.resumeToken == candidate.resumeToken {
445+ previous.resumeToken = 0
446+ }
447+ }
448+ }
438449 oldActive := l.active
439450 l.moveToFrontLocked(index, entry)
440451 if !oldActive.empty() && oldActive.key != identity.key {
A internal/usecase/client/routes_resume_identity_test.go +36 -0
@@ -0,0 +1,36 @@
1+package client
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/vev/internal/protocol"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestRouteLedgerNavigationAfterSamePeerSwitchDoesNotResumePreviousSessionCredential(t *testing.T) {
12+ ledger := newRouteLedger()
13+ first := routeTestCandidate(0, protocol.RouteOriginLocal)
14+ first.resumeToken = 42
15+ _, err := ledger.commit(first)
16+ require.NoError(t, err)
17+ second := routeTestTarget(1)
18+ _, err = ledger.commitCommittedIdentity(protocol.CommittedRouteIdentity{Target: second})
19+ require.NoError(t, err)
20+ remote := routeTestCandidate(2, protocol.RouteOriginRemote)
21+ remote.originKey = "remote"
22+ _, err = ledger.commit(remote)
23+ require.NoError(t, err)
24+ snapshot := ledger.snapshot()
25+ for _, entry := range snapshot.Entries {
26+ if entry.Target != first.target {
27+ continue
28+ }
29+ connector := &routeTestConnector{resumeErr: errRouteResumeUnavailable}
30+ err = ledger.navigate(context.Background(), protocol.RouteNavigationAction{SnapshotGeneration: snapshot.Generation, Key: entry.Key, Generation: entry.Generation}, connector)
31+ require.NoError(t, err)
32+ require.Zero(t, connector.resumeCalls, "a credential follows its attachment to the new session; old history must attach exactly")
33+ return
34+ }
35+ t.Fatal("previous session missing from history")
36+}
M internal/usecase/client/typed_connection_external_test.go +8 -0
@@ -140,6 +140,10 @@ func externalClientFrame(m protocol.ClientMessage) (wire.Frame, error) {
140140 case protocol.SessionCreationFailure:
141141 p, e := wire.MarshalSessionCreationFailure(x)
142142 return wire.Frame{Type: wire.MsgSessionCreationFailure, Payload: p}, e
143+ case protocol.NavigationInventoryPublication:
144+ return wire.Frame{Type: wire.MsgNavigationInventoryPublication, Payload: wire.MarshalNavigationInventoryPublication(x)}, nil
145+ case protocol.NavigationInventoryFailure:
146+ return wire.Frame{Type: wire.MsgNavigationInventoryFailure, Payload: wire.MarshalNavigationInventoryFailure(x)}, nil
143147 default:
144148 return wire.Frame{}, errors.New("test client: unsupported message")
145149 }
@@ -148,6 +152,10 @@ func externalServerMessage(f wire.Frame) (protocol.ServerMessage, error) {
148152 switch f.Type {
149153 case wire.MsgWelcome:
150154 return wire.UnmarshalWelcome(f.Payload)
155+ case wire.MsgNavigationInventoryDemand:
156+ return wire.UnmarshalNavigationInventoryDemand(f.Payload)
157+ case wire.MsgNavigationInventorySelection:
158+ return wire.UnmarshalNavigationInventorySelection(f.Payload)
151159 case wire.MsgError:
152160 return wire.UnmarshalErrorMsg(f.Payload)
153161 case wire.MsgOutput:
M internal/usecase/client/typed_connection_test.go +24 -0
@@ -136,6 +136,24 @@ func testClientFrame(message protocol.ClientMessage) (wire.Frame, error) {
136136 case protocol.RouteNavigationFailure:
137137 p, e := wire.MarshalRouteNavigationFailure(m)
138138 return wire.Frame{Type: wire.MsgRouteNavigationFailure, Payload: p}, e
139+ case protocol.NavigationInventoryRequest:
140+ p := wire.MarshalNavigationInventoryRequest(m)
141+ if p == nil {
142+ return wire.Frame{}, errors.New("test client connection: invalid inventory request")
143+ }
144+ return wire.Frame{Type: wire.MsgNavigationInventoryRequest, Payload: p}, nil
145+ case protocol.NavigationInventoryPublication:
146+ p := wire.MarshalNavigationInventoryPublication(m)
147+ if p == nil {
148+ return wire.Frame{}, errors.New("test client connection: invalid inventory publication")
149+ }
150+ return wire.Frame{Type: wire.MsgNavigationInventoryPublication, Payload: p}, nil
151+ case protocol.NavigationInventoryFailure:
152+ p := wire.MarshalNavigationInventoryFailure(m)
153+ if p == nil {
154+ return wire.Frame{}, errors.New("test client connection: invalid inventory failure")
155+ }
156+ return wire.Frame{Type: wire.MsgNavigationInventoryFailure, Payload: p}, nil
139157 default:
140158 return wire.Frame{}, errors.New("test client connection: unsupported client message")
141159 }
@@ -175,6 +193,12 @@ func testServerMessage(frame wire.Frame) (protocol.ServerMessage, error) {
175193 return wire.UnmarshalSamePeerSwitchFailure(frame.Payload)
176194 case wire.MsgParkedRouteResponse:
177195 return wire.UnmarshalParkedRouteResponse(frame.Payload)
196+ case wire.MsgNavigationInventoryResponse:
197+ return wire.UnmarshalNavigationInventoryResponse(frame.Payload)
198+ case wire.MsgNavigationInventoryDemand:
199+ return wire.UnmarshalNavigationInventoryDemand(frame.Payload)
200+ case wire.MsgNavigationInventorySelection:
201+ return wire.UnmarshalNavigationInventorySelection(frame.Payload)
178202 default:
179203 return nil, errors.New("test client connection: unsupported server frame")
180204 }
A internal/usecase/daemon/activation_policy_test.go +133 -0
@@ -0,0 +1,133 @@
1+package daemon
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/ports"
11+ "github.com/bnema/vev/internal/protocol/catalogue"
12+ "github.com/bnema/vev/internal/usecase/picker"
13+)
14+
15+// TestActivationPolicyTable drives the D5 contract through the picker
16+// projection (not a pure helper): broken and incompatible rows are not
17+// activatable but keep their diagnostic representation, while known valid
18+// cached targets with stale or failed observations stay attemptable with a
19+// presentation reason. Destination attach still validates exactly.
20+func TestActivationPolicyTable(t *testing.T) {
21+ now := time.Unix(1_000, 0)
22+ lifecycle := domain.SessionLifecycleID{21}
23+ tabs := []catalogue.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "shell"}}
24+
25+ tests := []struct {
26+ name string
27+ host ports.RemoteHostSnapshot
28+ session catalogue.RemoteCatalogSession
29+ activation picker.RemoteActivation
30+ reason string
31+ rowPresent bool
32+ }{
33+ {
34+ name: "live reachable attaches",
35+ host: reachableDirectoryHost("user@arch", now),
36+ session: catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp, Tabs: tabs, ActiveTabID: "tab-1"},
37+ activation: picker.RemoteAttach,
38+ rowPresent: true,
39+ },
40+ {
41+ name: "stopped reachable restarts",
42+ host: reachableDirectoryHost("user@arch", now),
43+ session: catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionDown, Tabs: tabs},
44+ activation: picker.RemoteRestart,
45+ rowPresent: true,
46+ },
47+ {
48+ name: "broken is diagnostic only",
49+ host: reachableDirectoryHost("user@arch", now),
50+ session: catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionBroken, Tabs: tabs},
51+ activation: picker.RemoteUnavailable,
52+ reason: domain.RemoteReasonSessionBroken,
53+ rowPresent: true,
54+ },
55+ {
56+ name: "incompatible is diagnostic only",
57+ host: ports.RemoteHostSnapshot{
58+ Endpoint: "user@arch", Availability: domain.RemoteAvailabilityIncompatible,
59+ LastSuccess: now, InventoryKnown: true,
60+ },
61+ session: catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp, Tabs: tabs, ActiveTabID: "tab-1"},
62+ activation: picker.RemoteUnavailable,
63+ reason: domain.RemoteReasonVersionMismatch,
64+ rowPresent: true,
65+ },
66+ {
67+ name: "stale inventory stays attemptable",
68+ host: func() ports.RemoteHostSnapshot {
69+ host := reachableDirectoryHost("user@arch", now.Add(-time.Hour))
70+ return host
71+ }(),
72+ session: catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp, Tabs: tabs, ActiveTabID: "tab-1"},
73+ activation: picker.RemoteAttach,
74+ rowPresent: true,
75+ },
76+ {
77+ name: "unreachable cached stays attemptable with reason",
78+ host: ports.RemoteHostSnapshot{
79+ Endpoint: "user@arch", Availability: domain.RemoteAvailabilityUnreachable,
80+ LastSuccess: now.Add(-time.Hour), InventoryKnown: true,
81+ },
82+ session: catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp, Tabs: tabs, ActiveTabID: "tab-1"},
83+ activation: picker.RemoteAttach,
84+ reason: domain.RemoteReasonHostUnreachable,
85+ rowPresent: true,
86+ },
87+ {
88+ name: "auth failure stays attemptable with reason",
89+ host: ports.RemoteHostSnapshot{
90+ Endpoint: "user@arch", Availability: domain.RemoteAvailabilityAuthFailed,
91+ LastSuccess: now.Add(-time.Hour), InventoryKnown: true,
92+ },
93+ session: catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp, Tabs: tabs, ActiveTabID: "tab-1"},
94+ activation: picker.RemoteAttach,
95+ reason: domain.RemoteReasonAuthFailure,
96+ rowPresent: true,
97+ },
98+ {
99+ name: "malformed catalog stays attemptable with reason",
100+ host: ports.RemoteHostSnapshot{
101+ Endpoint: "user@arch", Availability: domain.RemoteAvailabilityInvalidResponse,
102+ LastSuccess: now.Add(-time.Hour), InventoryKnown: true,
103+ },
104+ session: catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp, Tabs: tabs, ActiveTabID: "tab-1"},
105+ activation: picker.RemoteAttach,
106+ reason: domain.RemoteReasonMalformed,
107+ rowPresent: true,
108+ },
109+ }
110+ for _, tt := range tests {
111+ t.Run(tt.name, func(t *testing.T) {
112+ d := newTestDaemon(t, nil, fixedRemoteRefreshClock{now: now})
113+ current := addControlSession(d, "current", "tab-1", "pane-1")
114+ current.ephemeral = false
115+ host := tt.host
116+ host.Sessions = []catalogue.RemoteCatalogSession{tt.session}
117+ seedRemoteDirectory(t, d, host)
118+
119+ views, _ := d.pickerViews(current, nil)
120+ var found *picker.SessionView
121+ for i, view := range views {
122+ if view.RemoteHost == "user@arch" && view.Name == "work@arch" {
123+ found = &views[i]
124+ }
125+ }
126+ require.NotNil(t, found, "row must be present for exact-origin diagnosis")
127+ require.Equal(t, tt.activation, found.RemoteActivation)
128+ if tt.reason != "" {
129+ require.Equal(t, tt.reason, found.RemoteReason)
130+ }
131+ })
132+ }
133+}
M internal/usecase/daemon/attachment_effect_gate.go +3 -0
@@ -217,6 +217,9 @@ func (t *attachmentEffect) sendControl(message protocol.ServerMessage) error {
217217 case protocol.RouteCreateSessionAction:
218218 navigation.CauseActionID = t.uiActionID
219219 message = navigation
220+ case protocol.NavigationInventorySelection:
221+ navigation.CauseActionID = t.uiActionID
222+ message = navigation
220223 }
221224 err := t.transport.transport.SendServer(message)
222225 if err != nil {
M internal/usecase/daemon/client_frame_routing.go +36 -0
@@ -193,6 +193,42 @@ func (d *Daemon) handleAttachmentClientMessage(capability attachmentCapability,
193193 return true
194194 }
195195 resultEffect.End()
196+ case protocol.NavigationInventoryPublication:
197+ overlays := effect.ac.overlays
198+ if overlays == nil {
199+ break
200+ }
201+ overlays.paletteMu.Lock()
202+ admitted := admitPaletteInventoryPublication(overlays, message)
203+ overlays.paletteMu.Unlock()
204+ if admitted {
205+ d.refreshPalette(effect.ac)
206+ d.invalidateRender(effect.sess, effect.ac, true, "client_frame_routing.go:inventory-publication")
207+ }
208+ case protocol.NavigationInventoryFailure:
209+ overlays := effect.ac.overlays
210+ if overlays == nil {
211+ break
212+ }
213+ overlays.paletteMu.Lock()
214+ // Failures correlate to the pending selection, which survives the
215+ // selection close; the next open clears it. Unmatched failures
216+ // from older interactions drop. An open palette for the same
217+ // interaction also shows contextual feedback; the durable notice
218+ // always records so a closed palette still surfaces the cause.
219+ matched := overlays.paletteInventorySelected != nil &&
220+ overlays.paletteInventorySelected.InteractionGeneration == message.InteractionGeneration &&
221+ overlays.paletteInventorySelected.SourceKey == message.SourceKey &&
222+ overlays.paletteInventorySelected.EntryKey == message.EntryKey
223+ if overlays.palette != nil && overlays.paletteInventoryOpen &&
224+ overlays.paletteInventoryInteraction == message.InteractionGeneration {
225+ overlays.paletteFeedback = inventoryFailureNotice(message.Code)
226+ }
227+ overlays.paletteMu.Unlock()
228+ if matched {
229+ d.notify(effect.sess, domain.NoticeError, domain.NoticeNavigationInventory, inventoryFailureNotice(message.Code), nil)
230+ }
231+ d.invalidateRender(effect.sess, effect.ac, true, "client_frame_routing.go:inventory-failure")
196232 case protocol.Ping:
197233 if err := effect.sendControl(serverPong()); err != nil {
198234 effect.End()
M internal/usecase/daemon/control.go +6 -14
@@ -702,21 +702,13 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) {
702702 if !asJSON {
703703 return "", command.ErrInvalidArguments
704704 }
705- e.d.mu.Lock()
706- sessions := sessionsSnapshot(e.d.sessions)
707- stopped := make([]inactiveSession, 0, len(e.d.inactive))
708- for _, entry := range e.d.inactive {
709- if entry.visible() {
710- stopped = append(stopped, entry)
711- }
712- }
713- e.d.mu.Unlock()
705+ inv := e.d.captureSessionInventory(viewOptions{tabDetails: true, focusedTitles: true}, true)
706+ stopped := inv.visibleStopped()
714707
715- rows := make([]catalogue.RemoteCatalogSession, 0, len(sessions)+len(stopped))
716- liveNames := make(map[string]struct{}, len(sessions))
717- for _, sess := range sessions {
718- e.d.refreshSessionFocusedTitles(sess)
719- snap := sess.snapshotView(viewOptions{tabDetails: true, focusedTitles: true, terminalTitle: false})
708+ rows := make([]catalogue.RemoteCatalogSession, 0, len(inv.live)+len(stopped))
709+ liveNames := make(map[string]struct{}, len(inv.live))
710+ for _, item := range inv.live {
711+ snap := item.view
720712 tabs, err := remoteCatalogTabs(snap)
721713 if err != nil {
722714 return "", err
M internal/usecase/daemon/daemon.go +13 -0
@@ -1141,6 +1141,10 @@ func (d *Daemon) handleConn(tr ports.ServerConnection) {
11411141 if err := d.handleRemotePreview(tr, message); err != nil {
11421142 d.log.Warn("remote preview handler failed", "err", err)
11431143 }
1144+ case protocol.NavigationInventoryRequest:
1145+ stopTransport()
1146+ finishHandshake()
1147+ d.handleNavigationInventory(tr, message)
11441148 case protocol.Kill:
11451149 stopTransport()
11461150 finishHandshake()
@@ -1182,6 +1186,15 @@ func (d *Daemon) handleInitialDecodeFailure(ctx context.Context, tr ports.Server
11821186 send(serverError(protocol.ErrInternal, "malformed kill request"))
11831187 case protocol.DecodeMessageRemotePreview:
11841188 send(protocol.RemotePreview{Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewMalformed})
1189+ case protocol.DecodeMessageNavigationInventory:
1190+ if !failure.HasRequestID {
1191+ return
1192+ }
1193+ status := protocol.NavigationInventoryInvalid
1194+ if failure.Version != 0 && failure.Version != protocol.Version {
1195+ status = protocol.NavigationInventoryVersionMismatch
1196+ }
1197+ send(protocol.NavigationInventoryResponse{RequestID: failure.RequestID, Operation: protocol.NavigationInventorySnapshot, Status: status})
11851198 default:
11861199 send(serverError(protocol.ErrInternal, "expected hello"))
11871200 }
M internal/usecase/daemon/input.go +2 -1
@@ -464,7 +464,8 @@ func (h daemonKeyHandler) Action(action keys.Action, _ []byte) {
464464 }
465465 switch action {
466466 case keys.ActionOpenPalette:
467- h.d.enterPalette(sess, h.ac)
467+ interaction := h.d.enterPalette(sess, h.ac)
468+ h.d.sendPaletteInventoryDemand(h.ac, effect, true, interaction)
468469 case keys.ActionJumpAttention:
469470 if effect == nil {
470471 if err := h.d.jumpAttention(sess, h.ac); err != nil {
A internal/usecase/daemon/navigation_inventory.go +292 -0
@@ -0,0 +1,292 @@
1+package daemon
2+
3+import (
4+ "crypto/sha256"
5+ "encoding/hex"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/ports"
9+ "github.com/bnema/vev/internal/protocol"
10+ "github.com/bnema/vev/internal/protocol/catalogue"
11+)
12+
13+// navigationInventoryEntryKey derives a stable opaque key for an unchanged
14+// private exact target (authority, lifecycle, name). Presentation-only
15+// updates keep the key; a changed or removed target retires it and a
16+// reappearing target gets a new key through its new lifecycle.
17+func navigationInventoryEntryKey(lifecycle domain.SessionLifecycleID, name string) string {
18+ return hex.EncodeToString(lifecycle[:]) + "/" + name
19+}
20+
21+// handleNavigationInventory answers a one-shot source-control query without a
22+// Hello session, attachment, startup, PTY, or geometry effect. It closes the
23+// exact connection on every exit so blocked operations unwind.
24+func (d *Daemon) handleNavigationInventory(tr ports.ServerConnection, request protocol.NavigationInventoryRequest) {
25+ defer func() { _ = tr.Close() }()
26+ response := d.answerNavigationInventory(request)
27+ _ = d.boundedControlSend(tr, response)
28+}
29+
30+// answerNavigationInventory builds the typed response for a validated or
31+// rejected inventory request.
32+func (d *Daemon) answerNavigationInventory(request protocol.NavigationInventoryRequest) protocol.NavigationInventoryResponse {
33+ if err := protocol.ValidateNavigationInventoryRequest(request); err != nil {
34+ return protocol.NavigationInventoryResponse{RequestID: request.RequestID, Operation: request.Operation, Status: protocol.NavigationInventoryInvalid}
35+ }
36+ if request.Version != protocol.Version {
37+ return protocol.NavigationInventoryResponse{RequestID: request.RequestID, Operation: request.Operation, Status: protocol.NavigationInventoryVersionMismatch}
38+ }
39+ if request.Operation == protocol.NavigationInventoryResolve {
40+ return d.resolveNavigationInventory(request)
41+ }
42+ return d.snapshotNavigationInventory(request.RequestID)
43+}
44+
45+// snapshotNavigationInventory packs complete source groups, local first, then
46+// deterministic host order. A source exceeding its row limit becomes an
47+// explicit too_large row with no activatable entries; it never invalidates
48+// other complete groups. Encoded-byte overflow degrades the largest group to
49+// status-only instead of silently truncating.
50+func (d *Daemon) snapshotNavigationInventory(requestID uint64) protocol.NavigationInventoryResponse {
51+ inv := d.captureSessionInventory(viewOptions{}, false)
52+ groups := make([]protocol.NavigationInventorySourceGroup, 0, len(inv.hosts)+1)
53+ groups = append(groups, d.localInventoryGroup(inv))
54+ for _, host := range inv.hosts {
55+ groups = append(groups, remoteInventoryGroup(host))
56+ }
57+ if len(groups) > protocol.NavigationInventoryMaxSourceGroups {
58+ groups = groups[:protocol.NavigationInventoryMaxSourceGroups]
59+ }
60+ response := protocol.NavigationInventoryResponse{RequestID: requestID, Operation: protocol.NavigationInventorySnapshot, Status: protocol.NavigationInventoryOK, Groups: groups}
61+ return fitNavigationInventoryResponse(response)
62+}
63+
64+// localInventoryGroup projects live named sessions and resumable stopped
65+// sessions. Observation failures do not exist for local state; broken
66+// records are excluded here because they are never resumable.
67+func (d *Daemon) localInventoryGroup(inv sessionInventory) protocol.NavigationInventorySourceGroup {
68+ group := protocol.NavigationInventorySourceGroup{SourceKey: protocol.NavigationInventoryLocalSourceKey, Status: protocol.NavigationInventorySourceOK}
69+ for _, item := range inv.live {
70+ view := item.view
71+ if view.name == "" || view.ephemeral {
72+ continue
73+ }
74+ group.Entries = append(group.Entries, protocol.NavigationInventoryEntry{
75+ SourceKey: group.SourceKey,
76+ EntryKey: navigationInventoryEntryKey(view.incarnation, view.name),
77+ Name: view.name,
78+ State: "up",
79+ })
80+ }
81+ for _, entry := range inv.resumableStopped() {
82+ group.Entries = append(group.Entries, protocol.NavigationInventoryEntry{
83+ SourceKey: group.SourceKey,
84+ EntryKey: navigationInventoryEntryKey(entry.incarnation, entry.name),
85+ Name: entry.name,
86+ State: "stopped",
87+ })
88+ }
89+ if len(group.Entries) > protocol.NavigationInventoryMaxLocalEntries {
90+ return protocol.NavigationInventorySourceGroup{SourceKey: group.SourceKey, Status: protocol.NavigationInventorySourceTooLarge}
91+ }
92+ return group
93+}
94+
95+// remoteInventoryGroup projects one configured remote source with its own
96+// status. Broken sessions keep a diagnostic row; stale, unreachable,
97+// auth-failed, and malformed observations stay attemptable with a
98+// presentation reason, per the shared activation policy.
99+func remoteInventoryGroup(host ports.RemoteHostSnapshot) protocol.NavigationInventorySourceGroup {
100+ if host.Endpoint == "" {
101+ return protocol.NavigationInventorySourceGroup{Status: protocol.NavigationInventorySourceUnavailable}
102+ }
103+ // Remote source keys are opaque identifiers, never raw endpoints: the
104+ // relay forwards them to the serving daemon, which only ever sees
105+ // display origins. The endpoint hash keeps keys stable across polls
106+ // (so refreshes do not churn admissions) while a host literally named
107+ // "local" cannot collide with the reserved local source. Resolve maps
108+ // back through the request registration, never the key.
109+ group := protocol.NavigationInventorySourceGroup{SourceKey: remoteInventorySourceKey(host.Endpoint), Status: protocol.NavigationInventorySourceOK}
110+ eligible := 0
111+ for _, session := range host.Sessions {
112+ if session.Ephemeral {
113+ continue
114+ }
115+ eligible++
116+ }
117+ if eligible > protocol.NavigationInventoryMaxRemoteEntriesPerSource {
118+ return protocol.NavigationInventorySourceGroup{SourceKey: group.SourceKey, Status: protocol.NavigationInventorySourceTooLarge}
119+ }
120+ for _, session := range host.Sessions {
121+ if session.Ephemeral {
122+ continue
123+ }
124+ key, target := remoteCatalogSessionTarget(domain.RemoteSessionKey{Host: host.Endpoint, Name: session.Name}, session)
125+ if key.Validate() != nil {
126+ continue
127+ }
128+ reason := directorySessionReason(host, session, target)
129+ state := string(session.State)
130+ if session.State == catalogue.RemoteCatalogSessionBroken || target.Validate() != nil {
131+ state = string(catalogue.RemoteCatalogSessionBroken)
132+ }
133+ group.Entries = append(group.Entries, protocol.NavigationInventoryEntry{
134+ SourceKey: group.SourceKey,
135+ EntryKey: navigationInventoryEntryKey(session.LifecycleID, session.Name),
136+ Name: session.Name,
137+ DisplayOrigin: key.DisplayOrigin,
138+ State: state,
139+ Reason: reason,
140+ })
141+ }
142+ if host.Availability != domain.RemoteAvailabilityReachable && len(group.Entries) == 0 {
143+ group.Status = protocol.NavigationInventorySourceUnavailable
144+ }
145+ return group
146+}
147+
148+// remoteInventorySourceKey derives the stable opaque identifier for a
149+// configured remote endpoint. The hash domain-separates inventory keys so
150+// relayed identifiers disclose nothing about local SSH configuration.
151+func remoteInventorySourceKey(endpoint string) string {
152+ sum := sha256.Sum256([]byte("vev-inventory-remote\x00" + endpoint))
153+ return protocol.NavigationInventoryRemoteSourcePrefix + hex.EncodeToString(sum[:12])
154+}
155+
156+// navigationInventoryEncodedBudget mirrors the wire export budget without
157+// importing the codec layer. The daemon never encodes frames itself; the
158+// estimate below upper-bounds the real payload so fit degrades early rather
159+// than emitting an oversized frame.
160+const navigationInventoryEncodedBudget = 4 << 20
161+
162+func navigationInventorySnapshotSize(response protocol.NavigationInventoryResponse) int {
163+ size := 10 + 2
164+ for _, group := range response.Groups {
165+ size += 2 + len(group.SourceKey) + 1 + 4
166+ for _, entry := range group.Entries {
167+ size += 2 + len(entry.EntryKey) + 2 + len(entry.Name) + 2 + len(entry.DisplayOrigin) + 2 + len(entry.State) + 2 + len(entry.Reason)
168+ }
169+ }
170+ return size
171+}
172+
173+// fitNavigationInventoryResponse degrades the largest complete group to
174+// status-only until the response fits the encoded budget. It never emits a
175+// silently truncated group or a claim of complete inventory.
176+func fitNavigationInventoryResponse(response protocol.NavigationInventoryResponse) protocol.NavigationInventoryResponse {
177+ if protocol.ValidateNavigationInventoryResponse(response) != nil {
178+ response.Groups = nil
179+ response.Status = protocol.NavigationInventoryInvalid
180+ return response
181+ }
182+ for range len(response.Groups) + 1 {
183+ if navigationInventorySnapshotSize(response) <= navigationInventoryEncodedBudget {
184+ return response
185+ }
186+ largest := -1
187+ for i, group := range response.Groups {
188+ if group.Status != protocol.NavigationInventorySourceOK || len(group.Entries) == 0 {
189+ continue
190+ }
191+ if largest == -1 || len(group.Entries) > len(response.Groups[largest].Entries) {
192+ largest = i
193+ }
194+ }
195+ if largest == -1 {
196+ response.Groups = nil
197+ response.Status = protocol.NavigationInventoryUnavailable
198+ return response
199+ }
200+ source := response.Groups[largest].SourceKey
201+ response.Groups[largest] = protocol.NavigationInventorySourceGroup{SourceKey: source, Status: protocol.NavigationInventorySourceTooLarge}
202+ }
203+ response.Groups = nil
204+ response.Status = protocol.NavigationInventoryUnavailable
205+ return response
206+}
207+
208+// resolveNavigationInventory revalidates the selected key against a fresh
209+// capture and returns a non-mutating AttachTarget. Registration is checked at
210+// resolve: a removed or re-registered source rejects without attaching a
211+// same-name replacement.
212+func (d *Daemon) resolveNavigationInventory(request protocol.NavigationInventoryRequest) protocol.NavigationInventoryResponse {
213+ response := protocol.NavigationInventoryResponse{RequestID: request.RequestID, Operation: protocol.NavigationInventoryResolve}
214+ if request.SourceKey == protocol.NavigationInventoryLocalSourceKey {
215+ target, ok := d.resolveLocalInventoryEntry(request.EntryKey)
216+ if !ok {
217+ response.Status = protocol.NavigationInventoryUnavailable
218+ return response
219+ }
220+ response.Status = protocol.NavigationInventoryOK
221+ resolved := protocol.AttachTarget{Session: target.SessionName, Intent: protocol.IntentAttach, ExactTarget: &target}
222+ response.Resolved = &resolved
223+ return response
224+ }
225+ target, ok := d.resolveRemoteInventoryEntry(request)
226+ if !ok {
227+ response.Status = protocol.NavigationInventoryUnavailable
228+ return response
229+ }
230+ if target.Validate() != nil {
231+ response.Status = protocol.NavigationInventoryInvalid
232+ return response
233+ }
234+ response.Status = protocol.NavigationInventoryOK
235+ resolved := protocol.AttachTarget{
236+ Endpoint: request.Registration.Endpoint, Session: target.SessionName,
237+ Intent: protocol.IntentAttach, RemoteTarget: &target,
238+ EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
239+ }
240+ response.Resolved = &resolved
241+ return response
242+}
243+
244+// resolveLocalInventoryEntry matches the opaque key against fresh live and
245+// resumable stopped state. A stopped target that is now live with the same
246+// lifecycle and selector stays attachable; a changed lifecycle rejects.
247+func (d *Daemon) resolveLocalInventoryEntry(entryKey string) (protocol.ExactSessionTarget, bool) {
248+ inv := d.captureSessionInventory(viewOptions{}, false)
249+ for _, item := range inv.live {
250+ view := item.view
251+ if view.name == "" || view.ephemeral {
252+ continue
253+ }
254+ if navigationInventoryEntryKey(view.incarnation, view.name) != entryKey {
255+ continue
256+ }
257+ return protocol.ExactSessionTarget{LifecycleID: view.incarnation, SessionName: view.name}, true
258+ }
259+ for _, entry := range inv.resumableStopped() {
260+ if navigationInventoryEntryKey(entry.incarnation, entry.name) != entryKey {
261+ continue
262+ }
263+ return protocol.ExactSessionTarget{LifecycleID: entry.incarnation, SessionName: entry.name}, true
264+ }
265+ return protocol.ExactSessionTarget{}, false
266+}
267+
268+// resolveRemoteInventoryEntry checks the stored registration identity, then
269+// matches the opaque key against the source's current sessions. Known valid
270+// cached targets with stale or failed observations stay attemptable;
271+// destination attach still validates exactly.
272+func (d *Daemon) resolveRemoteInventoryEntry(request protocol.NavigationInventoryRequest) (domain.RemoteSessionTarget, bool) {
273+ snapshot := d.remoteDirectorySnapshot()
274+ host, ok := snapshot.Find(request.Registration.Endpoint)
275+ if !ok || !host.Registration.Equal(request.Registration) {
276+ return domain.RemoteSessionTarget{}, false
277+ }
278+ for _, session := range host.Sessions {
279+ if session.Ephemeral {
280+ continue
281+ }
282+ if navigationInventoryEntryKey(session.LifecycleID, session.Name) != request.EntryKey {
283+ continue
284+ }
285+ _, target := remoteCatalogSessionTarget(domain.RemoteSessionKey{Host: host.Endpoint, Name: session.Name}, session)
286+ if session.State == catalogue.RemoteCatalogSessionBroken {
287+ return domain.RemoteSessionTarget{}, false
288+ }
289+ return target, true
290+ }
291+ return domain.RemoteSessionTarget{}, false
292+}
A internal/usecase/daemon/navigation_inventory_test.go +298 -0
@@ -0,0 +1,298 @@
1+package daemon
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/protocol"
11+ "github.com/bnema/vev/internal/protocol/catalogue"
12+)
13+
14+// addInventorySession inserts a minimal live session without PTY allocation.
15+// Snapshot paths only read registry fields, so no tab or PTY is needed.
16+func addInventorySession(d *Daemon, name string, lifecycle domain.SessionLifecycleID, ephemeral bool) {
17+ d.mu.Lock()
18+ defer d.mu.Unlock()
19+ sess := &session{sessionCore: sessionCore{id: domain.SessionID("sess-" + name), name: name, ephemeral: ephemeral, incarnation: lifecycle}}
20+ sess.mruAt.Store(1)
21+ if d.sessions == nil {
22+ d.sessions = make(map[domain.SessionID]*session)
23+ }
24+ d.sessions[sess.id] = sess
25+}
26+
27+func inventorySnapshot(t *testing.T, d *Daemon, requestID uint64) protocol.NavigationInventoryResponse {
28+ t.Helper()
29+ response := d.answerNavigationInventory(protocol.NavigationInventoryRequest{Version: protocol.Version, RequestID: requestID, Operation: protocol.NavigationInventorySnapshot})
30+ require.Equal(t, protocol.NavigationInventoryOK, response.Status)
31+ require.NoError(t, protocol.ValidateNavigationInventoryResponse(response))
32+ return response
33+}
34+
35+func TestNavigationInventorySnapshotHasNoSideEffects(t *testing.T) {
36+ now := time.Unix(1_000, 0)
37+ d := newTestDaemon(t, nil, fixedRemoteRefreshClock{now: now})
38+ current := addControlSession(d, "current", "tab-1", "pane-1")
39+ current.ephemeral = false
40+ current.incarnation = domain.SessionLifecycleID{11}
41+ other := addControlSession(d, "other", "tab-2", "pane-2")
42+ other.ephemeral = false
43+ other.incarnation = domain.SessionLifecycleID{12}
44+ seedRemoteDirectory(t, d, reachableDirectoryHost("user@arch", now, catalogue.RemoteCatalogSession{
45+ LifecycleID: domain.SessionLifecycleID{21}, Name: "shared", State: catalogue.RemoteCatalogSessionUp,
46+ Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-remote", Index: 0, Name: "shell"}},
47+ ActiveTabID: "tab-remote",
48+ }))
49+
50+ before := len(d.sessions)
51+ response := inventorySnapshot(t, d, 1)
52+ require.Len(t, response.Groups, 2)
53+ require.Equal(t, protocol.NavigationInventoryLocalSourceKey, response.Groups[0].SourceKey)
54+ require.Equal(t, remoteInventorySourceKey("user@arch"), response.Groups[1].SourceKey)
55+ require.Len(t, d.sessions, before, "snapshot must not create sessions")
56+ require.NotNil(t, current)
57+}
58+
59+func TestNavigationInventoryRemoteSourceKeysNeverCollideWithLocal(t *testing.T) {
60+ now := time.Unix(1_000, 0)
61+ d := newTestDaemon(t, nil, fixedRemoteRefreshClock{now: now})
62+ seedRemoteDirectory(t, d, reachableDirectoryHost("local", now, catalogue.RemoteCatalogSession{
63+ LifecycleID: domain.SessionLifecycleID{31}, Name: "doppel", State: catalogue.RemoteCatalogSessionUp,
64+ Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-x", Index: 0, Name: "shell"}},
65+ ActiveTabID: "tab-x",
66+ }))
67+
68+ response := inventorySnapshot(t, d, 1)
69+ require.NoError(t, protocol.ValidateNavigationInventoryResponse(response))
70+ seen := map[string]int{}
71+ for _, group := range response.Groups {
72+ seen[group.SourceKey]++
73+ if group.SourceKey != protocol.NavigationInventoryLocalSourceKey {
74+ require.NotContains(t, group.SourceKey, "local", "relayed keys must not expose raw endpoints")
75+ require.Equal(t, remoteInventorySourceKey("local"), group.SourceKey, "keys stay stable across snapshots")
76+ }
77+ }
78+ for key, count := range seen {
79+ require.Equal(t, 1, count, "source key %q must be unique", key)
80+ }
81+}
82+
83+func TestNavigationInventoryLocalBeyondRemoteBound(t *testing.T) {
84+ now := time.Unix(1_000, 0)
85+ d := newTestDaemon(t, nil, fixedRemoteRefreshClock{now: now})
86+ for i := range 257 {
87+ name := string(rune('a'+i%26)) + string(rune('a'+(i/26)%26)) + string(rune('0'+i/676))
88+ addInventorySession(d, name, domain.SessionLifecycleID{byte(i), byte(i >> 8)}, false)
89+ }
90+ remoteSessions := make([]catalogue.RemoteCatalogSession, 0, 257)
91+ for i := range 257 {
92+ remoteSessions = append(remoteSessions, catalogue.RemoteCatalogSession{
93+ LifecycleID: domain.SessionLifecycleID{byte(i + 1)}, Name: "remote-session",
94+ State: catalogue.RemoteCatalogSessionUp,
95+ Tabs: []catalogue.RemoteCatalogTab{{ID: "tab", Index: 0, Name: "shell"}},
96+ })
97+ }
98+ seedRemoteDirectory(t, d, reachableDirectoryHost("user@arch", now, remoteSessions...))
99+
100+ response := inventorySnapshot(t, d, 2)
101+ require.Equal(t, protocol.NavigationInventoryOK, response.Status)
102+ local := response.Groups[0]
103+ require.Equal(t, protocol.NavigationInventoryLocalSourceKey, local.SourceKey)
104+ require.Equal(t, protocol.NavigationInventorySourceOK, local.Status)
105+ require.Len(t, local.Entries, 257, "257 local sessions are not rejected by the remote 256 bound")
106+ remote := response.Groups[1]
107+ require.Equal(t, protocol.NavigationInventorySourceTooLarge, remote.Status)
108+ require.Empty(t, remote.Entries)
109+}
110+
111+func TestNavigationInventoryResolveLocalAndStale(t *testing.T) {
112+ now := time.Unix(1_000, 0)
113+ d := newTestDaemon(t, nil, fixedRemoteRefreshClock{now: now})
114+ addInventorySession(d, "work", domain.SessionLifecycleID{5}, false)
115+
116+ response := inventorySnapshot(t, d, 3)
117+ var entryKey string
118+ for _, entry := range response.Groups[0].Entries {
119+ if entry.Name == "work" {
120+ entryKey = entry.EntryKey
121+ }
122+ }
123+ require.NotEmpty(t, entryKey)
124+
125+ resolved := d.answerNavigationInventory(protocol.NavigationInventoryRequest{Version: protocol.Version, RequestID: 4, Operation: protocol.NavigationInventoryResolve, SourceKey: protocol.NavigationInventoryLocalSourceKey, EntryKey: entryKey})
126+ require.Equal(t, protocol.NavigationInventoryOK, resolved.Status)
127+ require.NotNil(t, resolved.Resolved)
128+ require.Equal(t, "work", resolved.Resolved.Session)
129+ require.NotNil(t, resolved.Resolved.ExactTarget)
130+ require.Equal(t, domain.SessionLifecycleID{5}, resolved.Resolved.ExactTarget.LifecycleID)
131+
132+ unknown := d.answerNavigationInventory(protocol.NavigationInventoryRequest{Version: protocol.Version, RequestID: 5, Operation: protocol.NavigationInventoryResolve, SourceKey: protocol.NavigationInventoryLocalSourceKey, EntryKey: "deadbeef/missing"})
133+ require.NotEqual(t, protocol.NavigationInventoryOK, unknown.Status)
134+ require.Nil(t, unknown.Resolved, "stale keys never resolve to a same-name replacement")
135+}
136+
137+func TestNavigationInventoryResolveRegistrationFencesReRegistration(t *testing.T) {
138+ now := time.Unix(1_000, 0)
139+ d := newTestDaemon(t, nil, fixedRemoteRefreshClock{now: now})
140+ lifecycle := domain.SessionLifecycleID{21}
141+ registration, err := domain.NewRemoteRegistration("user@arch", [16]byte{9})
142+ require.NoError(t, err)
143+ host := reachableDirectoryHost("user@arch", now, catalogue.RemoteCatalogSession{
144+ LifecycleID: lifecycle, Name: "shared", State: catalogue.RemoteCatalogSessionUp,
145+ Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-remote", Index: 0, Name: "shell"}},
146+ ActiveTabID: "tab-remote",
147+ })
148+ host.Registration = registration
149+ seedRemoteDirectory(t, d, host)
150+
151+ response := inventorySnapshot(t, d, 6)
152+ var entryKey string
153+ for _, group := range response.Groups {
154+ if group.SourceKey != remoteInventorySourceKey("user@arch") {
155+ continue
156+ }
157+ for _, entry := range group.Entries {
158+ if entry.Name == "shared" {
159+ entryKey = entry.EntryKey
160+ }
161+ }
162+ }
163+ require.NotEmpty(t, entryKey)
164+
165+ good := d.answerNavigationInventory(protocol.NavigationInventoryRequest{Version: protocol.Version, RequestID: 7, Operation: protocol.NavigationInventoryResolve, SourceKey: remoteInventorySourceKey("user@arch"), EntryKey: entryKey, Registration: registration})
166+ require.Equal(t, protocol.NavigationInventoryOK, good.Status)
167+ require.NotNil(t, good.Resolved)
168+ require.Equal(t, "user@arch", good.Resolved.Endpoint)
169+ require.Equal(t, protocol.EnvironmentPolicyDaemonOwned, good.Resolved.EnvironmentPolicy)
170+
171+ // Removal before resolve rejects.
172+ seedRemoteDirectory(t, d)
173+ gone := d.answerNavigationInventory(protocol.NavigationInventoryRequest{Version: protocol.Version, RequestID: 8, Operation: protocol.NavigationInventoryResolve, SourceKey: remoteInventorySourceKey("user@arch"), EntryKey: entryKey, Registration: registration})
174+ require.NotEqual(t, protocol.NavigationInventoryOK, gone.Status)
175+ require.Nil(t, gone.Resolved)
176+
177+ // Re-adding the endpoint mints a new incarnation; the old registration rejects.
178+ fresh, err := domain.NewRemoteRegistration("user@arch", [16]byte{10})
179+ require.NoError(t, err)
180+ readded := reachableDirectoryHost("user@arch", now, catalogue.RemoteCatalogSession{
181+ LifecycleID: lifecycle, Name: "shared", State: catalogue.RemoteCatalogSessionUp,
182+ Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-remote", Index: 0, Name: "shell"}},
183+ ActiveTabID: "tab-remote",
184+ })
185+ readded.Registration = fresh
186+ seedRemoteDirectory(t, d, readded)
187+ stale := d.answerNavigationInventory(protocol.NavigationInventoryRequest{Version: protocol.Version, RequestID: 9, Operation: protocol.NavigationInventoryResolve, SourceKey: remoteInventorySourceKey("user@arch"), EntryKey: entryKey, Registration: registration})
188+ require.NotEqual(t, protocol.NavigationInventoryOK, stale.Status)
189+ require.Nil(t, stale.Resolved)
190+}
191+
192+func TestNavigationInventoryStaleObservationStaysAttemptable(t *testing.T) {
193+ now := time.Unix(1_000, 0)
194+ d := newTestDaemon(t, nil, fixedRemoteRefreshClock{now: now})
195+ lifecycle := domain.SessionLifecycleID{21}
196+ registration, err := domain.NewRemoteRegistration("user@arch", [16]byte{9})
197+ require.NoError(t, err)
198+ host := reachableDirectoryHost("user@arch", now.Add(-time.Hour), catalogue.RemoteCatalogSession{
199+ LifecycleID: lifecycle, Name: "cached", State: catalogue.RemoteCatalogSessionUp,
200+ Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "shell"}},
201+ ActiveTabID: "tab-1",
202+ })
203+ host.Registration = registration
204+ host.Availability = domain.RemoteAvailabilityUnreachable
205+ host.InventoryKnown = true
206+ seedRemoteDirectory(t, d, host)
207+
208+ response := inventorySnapshot(t, d, 10)
209+ var entryKey, reason string
210+ for _, group := range response.Groups {
211+ if group.SourceKey != remoteInventorySourceKey("user@arch") {
212+ continue
213+ }
214+ for _, entry := range group.Entries {
215+ if entry.Name == "cached" {
216+ entryKey, reason = entry.EntryKey, entry.Reason
217+ }
218+ }
219+ }
220+ require.NotEmpty(t, entryKey, "stale observations keep their rows")
221+ require.NotEmpty(t, reason)
222+
223+ resolved := d.answerNavigationInventory(protocol.NavigationInventoryRequest{Version: protocol.Version, RequestID: 11, Operation: protocol.NavigationInventoryResolve, SourceKey: remoteInventorySourceKey("user@arch"), EntryKey: entryKey, Registration: registration})
224+ require.Equal(t, protocol.NavigationInventoryOK, resolved.Status, "known valid cached targets stay attemptable")
225+}
226+
227+func TestNavigationInventoryStoppedBecomingLiveResolves(t *testing.T) {
228+ now := time.Unix(1_000, 0)
229+ d := newTestDaemon(t, nil, fixedRemoteRefreshClock{now: now})
230+ lifecycle := domain.SessionLifecycleID{7}
231+ d.mu.Lock()
232+ d.inactive["work"] = inactiveSession{name: "work", cwd: "/tmp/work", createdAt: 1, incarnation: lifecycle, state: protocol.SessionDown}
233+ d.mu.Unlock()
234+
235+ response := inventorySnapshot(t, d, 12)
236+ var entryKey string
237+ for _, entry := range response.Groups[0].Entries {
238+ if entry.Name == "work" {
239+ entryKey = entry.EntryKey
240+ }
241+ }
242+ require.NotEmpty(t, entryKey)
243+
244+ // The stopped target becomes live with the same lifecycle and selector.
245+ addInventorySession(d, "work", lifecycle, false)
246+ d.mu.Lock()
247+ delete(d.inactive, "work")
248+ d.mu.Unlock()
249+
250+ resolved := d.answerNavigationInventory(protocol.NavigationInventoryRequest{Version: protocol.Version, RequestID: 13, Operation: protocol.NavigationInventoryResolve, SourceKey: protocol.NavigationInventoryLocalSourceKey, EntryKey: entryKey})
251+ require.Equal(t, protocol.NavigationInventoryOK, resolved.Status)
252+}
253+
254+func TestNavigationInventoryVersionMismatchAndMalformed(t *testing.T) {
255+ d := newTestDaemon(t, nil, stubClock{})
256+ mismatch := d.answerNavigationInventory(protocol.NavigationInventoryRequest{Version: protocol.Version + 1, RequestID: 1, Operation: protocol.NavigationInventorySnapshot})
257+ require.Equal(t, protocol.NavigationInventoryVersionMismatch, mismatch.Status)
258+ require.Nil(t, mismatch.Resolved)
259+ require.Empty(t, mismatch.Groups)
260+
261+ malformed := d.answerNavigationInventory(protocol.NavigationInventoryRequest{Operation: protocol.NavigationInventorySnapshot})
262+ require.Equal(t, protocol.NavigationInventoryInvalid, malformed.Status)
263+ require.Nil(t, malformed.Resolved)
264+}
265+
266+// TestNeverVisitedLocalSessionRequiresRelay is the P0.2 known-red product
267+// case: a palette served by daemon A cannot list daemon L's sessions, so a
268+// local session the client never visited is unreachable from remote. It
269+// currently asserts the deficiency (absence); the P4 relay must invert it
270+// to presence plus successful local commit. No pass is claimed here.
271+func TestNeverVisitedLocalSessionRequiresRelay(t *testing.T) {
272+ local := newTestDaemon(t, nil, stubClock{})
273+ addInventorySession(local, "local-unvisited", domain.SessionLifecycleID{33}, false)
274+
275+ remote := newTestDaemon(t, nil, stubClock{})
276+ serving := addControlSession(remote, "remote-one", "tab-1", "pane-1")
277+ serving.ephemeral = false
278+ serving.incarnation = domain.SessionLifecycleID{44}
279+
280+ results := remote.paletteResults(serving, nil, protocol.RecentRouteSnapshot{})
281+ for _, result := range results {
282+ if name, ok := result.SessionName(); ok {
283+ require.NotEqual(t, "local-unvisited", name, "cross-daemon leak would be a privacy violation, not the fix")
284+ }
285+ if _, ok := result.RemoteSessionTarget(); ok {
286+ continue
287+ }
288+ }
289+ localGroups := local.answerNavigationInventory(protocol.NavigationInventoryRequest{Version: protocol.Version, RequestID: 1, Operation: protocol.NavigationInventorySnapshot})
290+ require.Equal(t, protocol.NavigationInventoryOK, localGroups.Status)
291+ found := false
292+ for _, entry := range localGroups.Groups[0].Entries {
293+ if entry.Name == "local-unvisited" {
294+ found = true
295+ }
296+ }
297+ require.True(t, found, "the source control daemon exports the session; only the relay to the serving daemon is missing")
298+}
M internal/usecase/daemon/overlay_runtime.go +12 -0
@@ -50,6 +50,18 @@ type overlayRuntime struct {
5050 palettePreview string
5151 paletteFeedback string
5252 palettePending []byte
53+ // paletteInventory* carries the serving-daemon side of navigation
54+ // inventory, guarded by paletteMu alongside the palette model. Open
55+ // tracks the current interaction namespace; publication is the latest
56+ // admitted publication generation (older/duplicates discard); groups
57+ // holds the sanitized relayed rows; selected freezes the chosen row
58+ // across select-close; demandSent records open-demand emission.
59+ paletteInventoryOpen bool
60+ paletteInventoryInteraction uint64
61+ paletteInventoryPublication uint64
62+ paletteInventoryGroups []protocol.NavigationInventorySourceGroup
63+ paletteInventorySelected *protocol.NavigationInventorySelection
64+ paletteInventoryDemandSent bool
5365
5466 promptMu sync.Mutex
5567 prompt *promptui.Model
M internal/usecase/daemon/palette.go +107 -50
@@ -9,7 +9,6 @@ import (
99 "github.com/bnema/vev/internal/domain"
1010 "github.com/bnema/vev/internal/ports"
1111 "github.com/bnema/vev/internal/protocol"
12- "github.com/bnema/vev/internal/protocol/catalogue"
1312 "github.com/bnema/vev/internal/usecase/command"
1413 "github.com/bnema/vev/internal/usecase/layout"
1514 "github.com/bnema/vev/internal/usecase/palette"
@@ -41,7 +40,7 @@ func paletteModalFor(size domain.Size, cfg domain.PaletteConfig) ui.Modal {
4140 return modal
4241 }
4342
44-func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
43+func (d *Daemon) enterPalette(sess *session, ac *attachedClient) uint64 {
4544 // Capture the client-owned route snapshot before taking paletteMu. The
4645 // snapshot is immutable for this palette interaction and the daemon never
4746 // consults its own session history for recent-route commands.
@@ -57,8 +56,10 @@ func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
5756 ac.overlays.palettePreview = ""
5857 ac.overlays.paletteFeedback = ""
5958 ac.overlays.palettePending = nil
59+ interaction := openPaletteInventory(ac.overlays)
6060 ac.overlays.paletteMu.Unlock()
6161 d.invalidateRender(sess, ac, true, "palette.go")
62+ return interaction
6263 }
6364
6465 type paletteSessionIdentity struct {
@@ -170,32 +171,18 @@ func paletteRouteRepresentsDaemonSession(entry protocol.RecentRouteEntry, daemon
170171 paletteRemoteDisplayOrigin(entry.HostLabel) == daemonDisplayOrigin
171172 }
172173
173-func remotePaletteUnavailableReason(host ports.RemoteHostSnapshot, session catalogue.RemoteCatalogSession) string {
174- if session.State == catalogue.RemoteCatalogSessionBroken {
175- return domain.RemoteReasonSessionBroken
176- }
177- return directorySessionReason(host, session, mustRemotePaletteTarget(host.Endpoint, session))
178-}
179-
180-func mustRemotePaletteTarget(endpoint string, session catalogue.RemoteCatalogSession) domain.RemoteSessionTarget {
181- _, target := remoteCatalogSessionTarget(domain.RemoteSessionKey{Host: endpoint, Name: session.Name}, session)
182- return target
183-}
184-
185-// paletteResults captures eligible named sessions before paletteMu so the
186-// palette has an immutable lifecycle target without violating lock ordering.
174+// paletteResults projects the shared daemon inventory for the palette. It keeps
175+// quick-switch exclusions, compact labels, commands, and history behavior;
176+// discovery, exact-target construction, and attemptability facts stay shared
177+// with the picker through remoteCatalogSessionTarget/directorySessionReason.
187178 func (d *Daemon) paletteResults(current *session, commands []command.Command, routeSnapshot protocol.RecentRouteSnapshot) []palette.Result {
188- d.mu.Lock()
189- sessions := d.sessionsSnapshotLocked()
190- stopped := make([]inactiveSession, 0, len(d.inactive))
191- represented := make(map[paletteSessionIdentity]struct{}, len(sessions)+len(d.inactive)+len(routeSnapshot.Entries))
192- for _, entry := range d.inactive {
179+ inv := d.captureSessionInventory(viewOptions{}, false)
180+ hosts := inv.hosts
181+ stopped := inv.resumableStopped()
182+ represented := make(map[paletteSessionIdentity]struct{}, len(inv.live)+len(inv.stopped)+len(routeSnapshot.Entries))
183+ for _, entry := range inv.stopped {
193184 represented[localPaletteSessionIdentity(entry.incarnation)] = struct{}{}
194- if entry.canResume() {
195- stopped = append(stopped, entry)
196- }
197185 }
198- d.mu.Unlock()
199186 var currentLifecycle domain.SessionLifecycleID
200187 if current != nil {
201188 current.mu.Lock()
@@ -203,29 +190,33 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro
203190 current.mu.Unlock()
204191 }
205192 daemonDisplayOrigin := paletteDaemonDisplayOrigin(routeSnapshot, currentLifecycle)
193+ hybrid := len(hosts) > 0 || daemonDisplayOrigin != ""
194+ for _, entry := range routeSnapshot.Entries {
195+ hybrid = hybrid || entry.Kind == protocol.RouteKindRemote
196+ }
197+ if daemonDisplayOrigin == "" && hybrid {
198+ daemonDisplayOrigin = "local"
199+ }
206200
207- directory := d.remoteDirectorySnapshot()
208- hosts := append([]ports.RemoteHostSnapshot(nil), directory.Hosts...)
209- sortDirectoryHosts(hosts)
210201 remoteSessionCount := 0
211202 for _, host := range hosts {
212203 remoteSessionCount += len(host.Sessions)
213204 }
214205
215206 destinations := createSessionDestinationResults(routeSnapshot, currentLifecycle, hosts)
216- results := make([]palette.Result, 0, len(commands)+len(destinations)+len(sessions)+len(stopped)+remoteSessionCount+len(routeSnapshot.Entries))
207+ results := make([]palette.Result, 0, len(commands)+len(destinations)+len(inv.live)+len(stopped)+remoteSessionCount+len(routeSnapshot.Entries))
217208 for _, cmd := range commands {
218209 results = append(results, palette.NewCommandResult(cmd))
219210 }
220211 results = append(results, destinations...)
221- active := make([]palette.Result, 0, len(sessions))
222- for _, candidate := range sessions {
223- snap := candidate.snapshotView(viewOptions{})
212+ active := make([]palette.Result, 0, len(inv.live))
213+ for _, candidate := range inv.live {
214+ snap := candidate.view
224215 if snap.name == "" || snap.ephemeral {
225216 continue
226217 }
227218 represented[localPaletteSessionIdentity(snap.incarnation)] = struct{}{}
228- if candidate == current {
219+ if candidate.sess == current {
229220 continue
230221 }
231222 target := protocol.ExactSessionTarget{LifecycleID: snap.incarnation, SessionName: snap.name}
@@ -266,20 +257,23 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro
266257 }
267258 discovered = append(discovered, discoveredRemote{
268259 identity: identity,
269- result: palette.NewRemoteSessionResult(key, target, remotePaletteUnavailableReason(host, session)),
260+ result: palette.NewRemoteSessionResult(key, target, directorySessionReason(host, session, target)),
270261 })
271262 discoveredByPresentation[presentation] = append(discoveredByPresentation[presentation], identity)
272263 }
273264 }
274- formattedRoutes := formatRecentRouteSnapshot(routeSnapshot)
275- for i, entry := range routeSnapshot.Entries {
276- if i >= len(formattedRoutes) {
277- break
278- }
279- label := formattedRoutes[i].name
280- if label == "" {
265+ for _, entry := range routeSnapshot.Entries {
266+ if entry.Name == "" {
281267 continue
282268 }
269+ origin := ""
270+ if hybrid {
271+ origin = "local"
272+ }
273+ if entry.Kind == protocol.RouteKindRemote {
274+ origin = paletteRemoteDisplayOrigin(entry.HostLabel)
275+ }
276+ label := palette.SessionDisplay(entry.Name, origin)
283277 identity := localPaletteSessionIdentity(entry.Target.LifecycleID)
284278 if _, exists := represented[identity]; exists && paletteRouteRepresentsDaemonSession(entry, daemonDisplayOrigin) {
285279 continue
@@ -332,6 +326,7 @@ func (d *Daemon) refreshPalette(ac *attachedClient) {
332326 }
333327
334328 results := d.paletteResults(sess, d.paletteCommands(), routeSnapshot)
329+ results = appendImportedResults(results, ac)
335330 rt.paletteMu.Lock()
336331 defer rt.paletteMu.Unlock()
337332 if rt.paletteGeneration != generation || rt.palette != model {
@@ -416,6 +411,8 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
416411 var remoteKey domain.RemoteSessionKey
417412 var remoteUnavailableReason string
418413 var hasRemoteTarget bool
414+ var importedSourceKey, importedEntryKey string
415+ var hasImportedTarget bool
419416 var routeTarget protocol.RouteNavigationAction
420417 var hasRouteTarget bool
421418 var createDestination palette.Result
@@ -424,6 +421,8 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
424421 var routeSnapshot protocol.RecentRouteSnapshot
425422 var generation uint64
426423 var rawQuery string
424+ var cancelInventoryInteraction uint64
425+ var hasCancelInventory bool
427426 changed, cancel, execute, chooseDestination := false, false, false, false
428427 var effect *attachmentEffect
429428 if len(effects) != 0 {
@@ -507,6 +506,10 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
507506 remoteKey = key
508507 remoteUnavailableReason, _ = selected.RemoteSessionUnavailableReason()
509508 hasRemoteTarget = true
509+ } else if sourceKey, entryKey, ok := selected.ImportedSessionKey(); ok {
510+ importedSourceKey = sourceKey
511+ importedEntryKey = entryKey
512+ hasImportedTarget = true
510513 } else if action, ok := selected.RouteNavigationAction(); ok {
511514 routeTarget = action
512515 hasRouteTarget = true
@@ -546,10 +549,14 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
546549 }
547550 }
548551 if cancel {
552+ cancelInventoryInteraction, hasCancelInventory = takePaletteInventoryClose(ac.overlays)
549553 ac.clearPaletteLocked()
550554 }
551555 ac.overlays.paletteMu.Unlock()
552556 if cancel {
557+ if hasCancelInventory {
558+ d.sendPaletteInventoryDemand(ac, effect, false, cancelInventoryInteraction)
559+ }
553560 d.invalidateRender(entry, ac, true, "palette.go")
554561 return
555562 }
@@ -568,7 +575,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
568575 d.invalidateRender(entry, ac, true, "palette.go")
569576 return
570577 }
571- if !d.closeExecutedPalette(ac, generation, rawQuery) {
578+ if !d.closeExecutedPalette(ac, effect, generation, rawQuery) {
572579 return
573580 }
574581 if name == "" {
@@ -602,7 +609,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
602609 }
603610 err := d.switchToTargetForAttachment(effect, target, sessionHandoffGuard{}, "palette-remote-session")
604611 if err == nil {
605- d.closeExecutedPalette(ac, generation, rawQuery)
612+ d.closeExecutedPalette(ac, effect, generation, rawQuery)
606613 return
607614 }
608615 if !errors.Is(err, errAttachmentTransition) {
@@ -612,6 +619,49 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
612619 return
613620 }
614621
622+ if hasImportedTarget {
623+ if effect == nil {
624+ ac.paletteFailure(generation, rawQuery, "requested imported session is unavailable")
625+ d.invalidateRender(entry, ac, true, "palette.go")
626+ return
627+ }
628+ if importedSourceKey != protocol.NavigationInventoryLocalSourceKey {
629+ // Remote vision is read-only: imported remote routes surface
630+ // availability but never attach.
631+ ac.paletteFailure(generation, rawQuery, "remote sessions are visible but cannot be attached")
632+ d.invalidateRender(entry, ac, true, "palette.go")
633+ return
634+ }
635+ ac.overlays.paletteMu.Lock()
636+ open := ac.overlays.paletteInventoryOpen
637+ selection := protocol.NavigationInventorySelection{
638+ CauseActionID: effect.uiActionID,
639+ InteractionGeneration: ac.overlays.paletteInventoryInteraction,
640+ PublicationGeneration: ac.overlays.paletteInventoryPublication,
641+ SourceKey: importedSourceKey,
642+ EntryKey: importedEntryKey,
643+ }
644+ if open {
645+ freezePaletteInventorySelection(ac.overlays, selection)
646+ }
647+ ac.overlays.paletteMu.Unlock()
648+ if !open || protocol.ValidateNavigationInventorySelection(selection) != nil {
649+ ac.paletteFailure(generation, rawQuery, "requested imported session is unavailable")
650+ d.invalidateRender(entry, ac, true, "palette.go")
651+ return
652+ }
653+ // Selection crosses before the close demand on the guarded serving
654+ // connection; closing after selection must not cancel the client's
655+ // pending navigation operation.
656+ if err := effect.sendControl(selection); err != nil {
657+ ac.paletteFailure(generation, rawQuery, "requested imported session is unavailable")
658+ d.invalidateRender(entry, ac, true, "palette.go")
659+ return
660+ }
661+ d.closeExecutedPalette(ac, effect, generation, rawQuery)
662+ return
663+ }
664+
615665 if hasRouteTarget {
616666 exec := paletteExec{d: d, sess: sess, attachment: entry, ac: ac, effect: effect}
617667 if err := exec.NavigateRecentRoute(routeTarget); err != nil {
@@ -622,7 +672,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
622672 d.invalidateRender(entry, ac, true, "palette.go")
623673 return
624674 }
625- if d.closeExecutedPalette(ac, generation, rawQuery) {
675+ if d.closeExecutedPalette(ac, effect, generation, rawQuery) {
626676 d.invalidateRender(entry, ac, true, "palette.go")
627677 }
628678 return
@@ -651,7 +701,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
651701 d.invalidateRender(entry, ac, true, "palette.go")
652702 return
653703 }
654- if d.closeExecutedPalette(ac, generation, rawQuery) {
704+ if d.closeExecutedPalette(ac, effect, generation, rawQuery) {
655705 if current := ac.currentAttachmentSession(); current != nil {
656706 d.invalidateRender(current, ac, true, "palette.go")
657707 }
@@ -675,7 +725,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
675725 d.invalidateRender(entry, ac, true, "palette.go")
676726 return
677727 }
678- if d.closeExecutedPalette(ac, generation, rawQuery) {
728+ if d.closeExecutedPalette(ac, effect, generation, rawQuery) {
679729 d.recordPaletteUse(cmd.Code)
680730 if current := ac.currentAttachmentSession(); current != nil {
681731 d.invalidateRender(current, ac, true, "palette.go")
@@ -684,7 +734,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
684734 return
685735 }
686736 attachmentHandoff := cmd.Slug == "back-session" || cmd.Slug == "detach"
687- if !attachmentHandoff && !d.closeExecutedPalette(ac, generation, rawQuery) {
737+ if !attachmentHandoff && !d.closeExecutedPalette(ac, effect, generation, rawQuery) {
688738 return
689739 }
690740 sess.dispatchMu.Lock()
@@ -694,14 +744,14 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
694744 if current := ac.currentSession(); current != nil {
695745 currentToken := current.captureAttachmentCapability(ac, ac.transport())
696746 fresh, admitted := ac.beginAttachmentEffect(currentToken)
697- if d.closeExecutedPalette(ac, generation, rawQuery) {
747+ if d.closeExecutedPalette(ac, effect, generation, rawQuery) {
698748 d.invalidateRender(current, ac, true, "palette.go")
699749 }
700750 if admitted {
701751 fresh.End()
702752 }
703753 } else {
704- d.closeExecutedPalette(ac, generation, rawQuery)
754+ d.closeExecutedPalette(ac, effect, generation, rawQuery)
705755 }
706756 }
707757 if errors.Is(err, errAttachmentTransition) {
@@ -738,6 +788,7 @@ func (ac *attachedClient) clearPaletteLocked() {
738788 ac.overlays.palettePreview = ""
739789 ac.overlays.paletteFeedback = ""
740790 ac.overlays.palettePending = nil
791+ closePaletteInventory(ac.overlays)
741792 }
742793
743794 func (ac *attachedClient) paletteFailure(generation uint64, rawQuery, feedback string) {
@@ -749,14 +800,20 @@ func (ac *attachedClient) paletteFailure(generation uint64, rawQuery, feedback s
749800 ac.overlays.paletteFeedback = feedback
750801 }
751802
752-func (d *Daemon) closeExecutedPalette(ac *attachedClient, generation uint64, rawQuery string) bool {
803+func (d *Daemon) closeExecutedPalette(ac *attachedClient, effect *attachmentEffect, generation uint64, rawQuery string) bool {
753804 ac.overlays.paletteMu.Lock()
754805 if ac.overlays.palette == nil || ac.overlays.paletteGeneration != generation || ac.overlays.palette.Query() != rawQuery {
755806 ac.overlays.paletteMu.Unlock()
756807 return false
757808 }
809+ // Every successful close stops relay polling: take the close demand
810+ // before clearing so no execute path leaks a 1/s poll loop.
811+ closeInteraction, hasClose := takePaletteInventoryClose(ac.overlays)
758812 ac.clearPaletteLocked()
759813 ac.overlays.paletteMu.Unlock()
814+ if hasClose {
815+ d.sendPaletteInventoryDemand(ac, effect, false, closeInteraction)
816+ }
760817 return true
761818 }
762819
A internal/usecase/daemon/palette_inventory.go +195 -0
@@ -0,0 +1,195 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/protocol"
5+ "github.com/bnema/vev/internal/usecase/palette"
6+)
7+
8+// This file owns the serving-daemon side of navigation inventory: demand
9+// emission, publication admission, and imported-row selection. All overlay
10+// fields below are guarded by paletteMu alongside the palette model; no
11+// lock is held across sends. Helpers never lock internally: callers hold
12+// paletteMu for state transitions and release it before any sendControl.
13+
14+// inventoryDemandEnabled reports whether this attachment may receive
15+// inventory demand. The serving daemon emits demand only for the advertised
16+// capability; the client independently checks its concrete local source.
17+func inventoryDemandEnabled(ac *attachedClient) bool {
18+ return ac != nil && ac.navigationCapabilities&protocol.NavigationCapabilityInventory != 0
19+}
20+
21+// openPaletteInventory starts one palette interaction namespace. Admitted
22+// keys and retirements never cross interactions.
23+func openPaletteInventory(rt *overlayRuntime) uint64 {
24+ rt.paletteInventoryOpen = true
25+ rt.paletteInventoryInteraction++
26+ rt.paletteInventoryPublication = 0
27+ rt.paletteInventoryGroups = nil
28+ rt.paletteInventorySelected = nil
29+ rt.paletteInventoryDemandSent = false
30+ return rt.paletteInventoryInteraction
31+}
32+
33+// closePaletteInventory ends the interaction and drops relayed rows.
34+// Imported rows vanish with the palette; a reopen starts a new namespace.
35+// The pending selection survives the close so a late resolve failure still
36+// correlates to the interaction that chose it; the next open clears it.
37+func closePaletteInventory(rt *overlayRuntime) {
38+ rt.paletteInventoryOpen = false
39+ rt.paletteInventoryGroups = nil
40+ rt.paletteInventoryDemandSent = false
41+}
42+
43+// takePaletteInventoryClose snapshots a pending close-demand: the overlay
44+// must be open for the interaction. Callers send Demand{open:false} after
45+// releasing paletteMu, then close through clearPaletteLocked.
46+func takePaletteInventoryClose(rt *overlayRuntime) (uint64, bool) {
47+ if !rt.paletteInventoryOpen {
48+ return 0, false
49+ }
50+ return rt.paletteInventoryInteraction, true
51+}
52+
53+// sendPaletteInventoryDemand emits an open or close demand on the guarded
54+// serving connection. It locks paletteMu only for the emission check, never
55+// across the send. A nil effect skips the send while the overlay state still
56+// transitions; those paths coincide with attachment teardown or replacement
57+// where the client-side relay is cancelled independently. Stale close
58+// demands (superseded by a reopen) skip so generations stay ordered.
59+func (d *Daemon) sendPaletteInventoryDemand(ac *attachedClient, effect *attachmentEffect, open bool, interaction uint64) {
60+ if !inventoryDemandEnabled(ac) || effect == nil || interaction == 0 {
61+ return
62+ }
63+ ac.overlays.paletteMu.Lock()
64+ if open {
65+ if !ac.overlays.paletteInventoryOpen || ac.overlays.paletteInventoryInteraction != interaction || ac.overlays.paletteInventoryDemandSent {
66+ ac.overlays.paletteMu.Unlock()
67+ return
68+ }
69+ ac.overlays.paletteInventoryDemandSent = true
70+ } else if ac.overlays.paletteInventoryInteraction != interaction {
71+ ac.overlays.paletteMu.Unlock()
72+ return
73+ }
74+ ac.overlays.paletteMu.Unlock()
75+ demand := protocol.NavigationInventoryDemand{InteractionGeneration: interaction, Open: open}
76+ if protocol.ValidateNavigationInventoryDemand(demand) != nil {
77+ return
78+ }
79+ _ = effect.sendControl(demand)
80+}
81+
82+// admitPaletteInventoryPublication validates and stores one client
83+// publication. Older or duplicate publication generations discard: the
84+// daemon never steps its display backward. Stale interactions (palette
85+// closed or reopened since) drop silently.
86+func admitPaletteInventoryPublication(rt *overlayRuntime, message protocol.NavigationInventoryPublication) bool {
87+ if protocol.ValidateNavigationInventoryPublication(message) != nil {
88+ return false
89+ }
90+ if !rt.paletteInventoryOpen || message.InteractionGeneration != rt.paletteInventoryInteraction {
91+ return false
92+ }
93+ if message.PublicationGeneration <= rt.paletteInventoryPublication {
94+ return false
95+ }
96+ rt.paletteInventoryPublication = message.PublicationGeneration
97+ rt.paletteInventoryGroups = append([]protocol.NavigationInventorySourceGroup(nil), message.Groups...)
98+ return true
99+}
100+
101+// copyPaletteInventoryGroups snapshots the admitted relayed groups for
102+// projection. It copies under paletteMu and releases it before any capture:
103+// callers must never nest it inside registry locks. A closed palette
104+// yields nil: imported rows vanish with the overlay.
105+func copyPaletteInventoryGroups(ac *attachedClient) []protocol.NavigationInventorySourceGroup {
106+ if ac == nil || ac.overlays == nil {
107+ return nil
108+ }
109+ ac.overlays.paletteMu.Lock()
110+ defer ac.overlays.paletteMu.Unlock()
111+ if !ac.overlays.paletteInventoryOpen {
112+ return nil
113+ }
114+ return append([]protocol.NavigationInventorySourceGroup(nil), ac.overlays.paletteInventoryGroups...)
115+}
116+
117+// importedPaletteResults projects admitted sanitized groups as palette rows
118+// with explicit origin qualification. Homonyms across sources stay distinct
119+// rows; selection resolves through opaque keys, never labels.
120+func importedPaletteResults(groups []protocol.NavigationInventorySourceGroup) []palette.Result {
121+ var results []palette.Result
122+ for _, group := range groups {
123+ if group.Status != protocol.NavigationInventorySourceOK {
124+ continue
125+ }
126+ for _, entry := range group.Entries {
127+ origin := entry.DisplayOrigin
128+ if origin == "" {
129+ origin = group.SourceKey
130+ }
131+ results = append(results, palette.NewImportedSessionResult(group.SourceKey, entry.EntryKey, entry.Name, origin, entry.State, entry.Reason))
132+ }
133+ }
134+ return results
135+}
136+
137+// appendImportedResults projects admitted relayed rows after native
138+// results. Enter starts native-only (fresh namespace); refreshes pick up
139+// streamed publications. Stable opaque keys preserve cursor and query
140+// across ReplaceResults.
141+func appendImportedResults(results []palette.Result, ac *attachedClient) []palette.Result {
142+ groups := copyPaletteInventoryGroups(ac)
143+ if ac == nil || ac.overlays == nil {
144+ return results
145+ }
146+ localKeys := make(map[string]bool)
147+ for _, group := range groups {
148+ if group.SourceKey == protocol.NavigationInventoryLocalSourceKey && group.Status == protocol.NavigationInventorySourceOK {
149+ for _, entry := range group.Entries {
150+ localKeys[entry.EntryKey] = true
151+ }
152+ }
153+ }
154+ ac.overlays.paletteMu.Lock()
155+ snapshot := ac.overlays.paletteRouteSnapshot
156+ ac.overlays.paletteMu.Unlock()
157+ filtered := results[:0]
158+ for _, result := range results {
159+ if action, ok := result.RouteNavigationAction(); ok {
160+ entry, found := routeEntryForRef(snapshot, protocol.RouteRef{Key: action.Key, Generation: action.Generation})
161+ if found && entry.Kind == protocol.RouteKindLocal && localKeys[navigationInventoryEntryKey(entry.Target.LifecycleID, entry.Target.SessionName)] {
162+ continue
163+ }
164+ }
165+ filtered = append(filtered, result)
166+ }
167+ return append(filtered, importedPaletteResults(groups)...)
168+}
169+
170+// inventoryFailureNotice maps a bounded relay failure code to palette
171+// feedback through existing conventions. Native commands stay available:
172+// only the contextual feedback line changes.
173+func inventoryFailureNotice(code protocol.NavigationInventoryFailureCode) string {
174+ switch code {
175+ case protocol.NavigationInventoryStaleIdentity, protocol.NavigationInventoryInvalidTarget:
176+ return "that destination is no longer available"
177+ case protocol.NavigationInventorySourceGone:
178+ return "local inventory source unavailable"
179+ case protocol.NavigationInventoryIncompatible:
180+ return "incompatible session version"
181+ case protocol.NavigationInventoryNavigationFailed:
182+ return "couldn't switch sessions"
183+ case protocol.NavigationInventoryRestoreFailed:
184+ return "couldn't restore the original session"
185+ default:
186+ return "inventory selection unavailable"
187+ }
188+}
189+
190+// freezePaletteInventorySelection records the chosen imported row before its
191+// Selection crosses the wire. Closing after selection must not cancel the
192+// client's pending navigation operation.
193+func freezePaletteInventorySelection(rt *overlayRuntime, selection protocol.NavigationInventorySelection) {
194+ rt.paletteInventorySelected = &selection
195+}
A internal/usecase/daemon/palette_inventory_test.go +291 -0
@@ -0,0 +1,291 @@
1+package daemon
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/protocol"
11+ "github.com/bnema/vev/internal/protocol/wire"
12+)
13+
14+// TestPaletteInventoryNamespaceLifecycle pins the overlay interaction
15+// namespace: open bumps the generation and resets admit state, close drops
16+// relayed rows, and a reopen starts a new namespace.
17+func TestPaletteInventoryNamespaceLifecycle(t *testing.T) {
18+ rt := &overlayRuntime{}
19+ first := openPaletteInventory(rt)
20+ require.Equal(t, uint64(1), first)
21+ rt.paletteInventoryPublication = 3
22+ rt.paletteInventoryGroups = []protocol.NavigationInventorySourceGroup{{SourceKey: "local"}}
23+
24+ interaction, ok := takePaletteInventoryClose(rt)
25+ require.True(t, ok)
26+ require.Equal(t, first, interaction)
27+
28+ closePaletteInventory(rt)
29+ if _, ok := takePaletteInventoryClose(rt); ok {
30+ t.Fatal("closed palette must not offer a close-demand")
31+ }
32+ require.Empty(t, rt.paletteInventoryGroups)
33+
34+ second := openPaletteInventory(rt)
35+ require.Equal(t, first+1, second)
36+ require.Zero(t, rt.paletteInventoryPublication)
37+}
38+
39+// TestAdmitPaletteInventoryPublication pins admission: newer generations
40+// store, older and duplicate generations discard, stale interactions and
41+// closed palettes drop, malformed messages never store.
42+func TestAdmitPaletteInventoryPublication(t *testing.T) {
43+ groups := func() []protocol.NavigationInventorySourceGroup {
44+ return []protocol.NavigationInventorySourceGroup{{
45+ SourceKey: "local", Status: protocol.NavigationInventorySourceOK,
46+ Entries: []protocol.NavigationInventoryEntry{
47+ {SourceKey: "local", EntryKey: "aaa/one", Name: "one"},
48+ },
49+ }}
50+ }
51+ publication := func(interaction, generation uint64) protocol.NavigationInventoryPublication {
52+ return protocol.NavigationInventoryPublication{InteractionGeneration: interaction, PublicationGeneration: generation, Groups: groups()}
53+ }
54+
55+ rt := &overlayRuntime{}
56+ interaction := openPaletteInventory(rt)
57+
58+ require.True(t, admitPaletteInventoryPublication(rt, publication(interaction, 1)))
59+ require.Len(t, rt.paletteInventoryGroups, 1)
60+ require.False(t, admitPaletteInventoryPublication(rt, publication(interaction, 1)), "duplicate generation must discard")
61+ require.False(t, admitPaletteInventoryPublication(rt, publication(interaction, 0)), "older generation must discard")
62+ require.True(t, admitPaletteInventoryPublication(rt, publication(interaction, 2)))
63+ require.Equal(t, uint64(2), rt.paletteInventoryPublication)
64+ require.False(t, admitPaletteInventoryPublication(rt, publication(interaction+7, 3)), "stale interaction must drop")
65+
66+ closePaletteInventory(rt)
67+ require.False(t, admitPaletteInventoryPublication(rt, publication(interaction, 3)), "closed palette must drop")
68+
69+ other := &overlayRuntime{}
70+ openPaletteInventory(other)
71+ malformed := publication(other.paletteInventoryInteraction, 1)
72+ malformed.Groups = nil
73+ malformed.PublicationGeneration = 0
74+ require.False(t, admitPaletteInventoryPublication(other, malformed))
75+ require.Empty(t, other.paletteInventoryGroups)
76+}
77+
78+// TestPaletteInventoryDemandAndSelectionFlow drives the serving-daemon side
79+// end to end through a capturing transport: capability-gated open demand,
80+// publication admission with imported-row projection, Enter resolving
81+// through opaque keys with bound cause, Selection before close-demand wire
82+// order, and overlay teardown that preserves the frozen selection.
83+func TestPaletteInventoryDemandAndSelectionFlow(t *testing.T) {
84+ p, release := newBlockingPTY(t)
85+ defer release()
86+ d, current, ac, sends := newManualSessionWithPTYs(t, p)
87+ ac.navigationCapabilities = protocol.NavigationCapabilityInventory
88+ effect := beginRecentRoutePaletteEffect(t, d, current, ac)
89+ // No automation action ID: ordinary keyboard input carries cause zero,
90+ // which must still select.
91+
92+ interaction := d.enterPalette(current, ac)
93+ d.sendPaletteInventoryDemand(ac, effect, true, interaction)
94+ demandFrame := awaitFrame(t, sends, wire.MsgNavigationInventoryDemand)
95+ demand, err := wire.UnmarshalNavigationInventoryDemand(demandFrame.Payload)
96+ require.NoError(t, err)
97+ require.True(t, demand.Open)
98+ require.Equal(t, interaction, demand.InteractionGeneration)
99+
100+ published := protocol.NavigationInventoryPublication{
101+ InteractionGeneration: interaction, PublicationGeneration: 1,
102+ Groups: []protocol.NavigationInventorySourceGroup{{
103+ SourceKey: "local", Status: protocol.NavigationInventorySourceOK,
104+ Entries: []protocol.NavigationInventoryEntry{
105+ {SourceKey: "local", EntryKey: "aaa/zzqimported", Name: "zzqimported", DisplayOrigin: "local", State: "up"},
106+ },
107+ }},
108+ }
109+ ac.overlays.paletteMu.Lock()
110+ require.True(t, admitPaletteInventoryPublication(ac.overlays, published))
111+ ac.overlays.paletteMu.Unlock()
112+ d.refreshPalette(ac)
113+
114+ d.handlePaletteInput(ac, []byte("zzqimported\r"), effect)
115+
116+ selectionFrame := awaitFrame(t, sends, wire.MsgNavigationInventorySelection)
117+ selection, err := wire.UnmarshalNavigationInventorySelection(selectionFrame.Payload)
118+ require.NoError(t, err)
119+ require.Equal(t, uint64(0), selection.CauseActionID, "keyboard input carries no automation cause")
120+ require.Equal(t, interaction, selection.InteractionGeneration)
121+ require.Equal(t, uint64(1), selection.PublicationGeneration)
122+ require.Equal(t, "local", selection.SourceKey)
123+ require.Equal(t, "aaa/zzqimported", selection.EntryKey)
124+
125+ closeFrame := awaitFrame(t, sends, wire.MsgNavigationInventoryDemand)
126+ closeDemand, err := wire.UnmarshalNavigationInventoryDemand(closeFrame.Payload)
127+ require.NoError(t, err)
128+ require.False(t, closeDemand.Open)
129+ require.Equal(t, interaction, closeDemand.InteractionGeneration)
130+ // Input handling sends controls synchronously; no second close may be queued.
131+ for len(sends) > 0 {
132+ frame := <-sends
133+ require.NotEqual(t, wire.MsgNavigationInventoryDemand, frame.Type, "selection emits exactly one close")
134+ }
135+
136+ require.False(t, ac.overlays.paletteActive(), "select closes the overlay")
137+ ac.overlays.paletteMu.Lock()
138+ frozen := ac.overlays.paletteInventorySelected
139+ ac.overlays.paletteMu.Unlock()
140+ require.NotNil(t, frozen, "the pending selection survives the close so a late failure correlates")
141+ require.Equal(t, interaction, frozen.InteractionGeneration)
142+ require.Equal(t, "aaa/zzqimported", frozen.EntryKey)
143+}
144+
145+// TestPaletteInventoryRemoteEnterNeverAttaches pins read-only remote
146+// vision: Enter on a remote-origin row reports feedback, keeps the palette
147+// open, and emits no Selection frame.
148+func TestPaletteInventoryRemoteEnterNeverAttaches(t *testing.T) {
149+ p, release := newBlockingPTY(t)
150+ defer release()
151+ d, current, ac, sends := newManualSessionWithPTYs(t, p)
152+ ac.navigationCapabilities = protocol.NavigationCapabilityInventory
153+ effect := beginRecentRoutePaletteEffect(t, d, current, ac)
154+ effect.uiActionID = 123
155+
156+ interaction := d.enterPalette(current, ac)
157+ published := protocol.NavigationInventoryPublication{
158+ InteractionGeneration: interaction, PublicationGeneration: 1,
159+ Groups: []protocol.NavigationInventorySourceGroup{
160+ {
161+ SourceKey: "local", Status: protocol.NavigationInventorySourceOK,
162+ Entries: []protocol.NavigationInventoryEntry{
163+ {SourceKey: "local", EntryKey: "aaa/zzqlocal", Name: "zzqlocal", DisplayOrigin: "local", State: "up"},
164+ },
165+ },
166+ {
167+ SourceKey: "arch", Status: protocol.NavigationInventorySourceOK,
168+ Entries: []protocol.NavigationInventoryEntry{
169+ {SourceKey: "arch", EntryKey: "aaa/zzqremote", Name: "zzqremote", DisplayOrigin: "arch", State: "down", Reason: "unreachable"},
170+ },
171+ },
172+ },
173+ }
174+ ac.overlays.paletteMu.Lock()
175+ require.True(t, admitPaletteInventoryPublication(ac.overlays, published))
176+ ac.overlays.paletteMu.Unlock()
177+ d.refreshPalette(ac)
178+
179+ d.handlePaletteInput(ac, []byte("zzqremote\r"), effect)
180+
181+ ac.overlays.paletteMu.Lock()
182+ feedback := ac.overlays.paletteFeedback
183+ ac.overlays.paletteMu.Unlock()
184+ require.Equal(t, "remote sessions are visible but cannot be attached", feedback)
185+ require.True(t, ac.overlays.paletteActive(), "refused remote selection keeps the palette open")
186+ select {
187+ case frame := <-sends:
188+ require.NotEqual(t, wire.MsgNavigationInventorySelection, frame.Type, "remote Enter must not emit a selection")
189+ case <-time.After(200 * time.Millisecond):
190+ }
191+}
192+
193+// TestPaletteInventoryFailureCorrelatesToPendingSelection pins durable
194+// failure routing: a failure matching the frozen selection records a
195+// notice even with the palette closed, shows contextual feedback while
196+// the interaction is open, and drops when nothing matches.
197+func TestPaletteInventoryFailureCorrelatesToPendingSelection(t *testing.T) {
198+ p, release := newBlockingPTY(t)
199+ defer release()
200+ d, current, ac, _ := newManualSessionWithPTYs(t, p)
201+
202+ interaction := d.enterPalette(current, ac)
203+ selection := protocol.NavigationInventorySelection{
204+ InteractionGeneration: interaction, PublicationGeneration: 1,
205+ SourceKey: "local", EntryKey: "aaa/zzqimported",
206+ }
207+ ac.overlays.paletteMu.Lock()
208+ freezePaletteInventorySelection(ac.overlays, selection)
209+ ac.overlays.paletteMu.Unlock()
210+ d.closeExecutedPalette(ac, nil, 1, "")
211+
212+ matched := protocol.NavigationInventoryFailure{
213+ InteractionGeneration: interaction, SourceKey: "local", EntryKey: "aaa/zzqimported",
214+ Code: protocol.NavigationInventorySourceGone,
215+ }
216+ d.handleAttachmentClientMessage(captureAttachmentCapability(current, ac, ac.transport()), matched)
217+ require.True(t, hasInventoryNotice(t, ac), "matched failure must record a durable notice")
218+
219+ stale := protocol.NavigationInventoryFailure{
220+ InteractionGeneration: interaction + 1, SourceKey: "local", EntryKey: "aaa/zzqimported",
221+ Code: protocol.NavigationInventorySourceGone,
222+ }
223+ before := countInventoryNotices(t, ac)
224+ d.handleAttachmentClientMessage(captureAttachmentCapability(current, ac, ac.transport()), stale)
225+ require.Equal(t, before, countInventoryNotices(t, ac), "unmatched failure must drop")
226+}
227+
228+func hasInventoryNotice(t *testing.T, ac *attachedClient) bool {
229+ t.Helper()
230+ return countInventoryNotices(t, ac) != 0
231+}
232+
233+func countInventoryNotices(t *testing.T, ac *attachedClient) int {
234+ t.Helper()
235+ ac.overlays.noticeMu.Lock()
236+ defer ac.overlays.noticeMu.Unlock()
237+ count := 0
238+ for _, toast := range ac.overlays.noticeToasts {
239+ if toast.n.Code == domain.NoticeNavigationInventory {
240+ count++
241+ }
242+ }
243+ return count
244+}
245+
246+// TestPaletteInventoryEscapeSendsCloseDemand pins the plain-cancel path: a
247+// bare Escape cancels the unselected interaction and still stops polling.
248+func TestPaletteInventoryEscapeSendsCloseDemand(t *testing.T) {
249+ p, release := newBlockingPTY(t)
250+ defer release()
251+ d, current, ac, sends := newManualSessionWithPTYs(t, p)
252+ ac.navigationCapabilities = protocol.NavigationCapabilityInventory
253+ effect := beginRecentRoutePaletteEffect(t, d, current, ac)
254+
255+ interaction := d.enterPalette(current, ac)
256+ d.sendPaletteInventoryDemand(ac, effect, true, interaction)
257+ awaitFrame(t, sends, wire.MsgNavigationInventoryDemand)
258+
259+ d.handlePaletteInput(ac, []byte{0x1b}, effect)
260+ closeFrame := awaitFrame(t, sends, wire.MsgNavigationInventoryDemand)
261+ closeDemand, err := wire.UnmarshalNavigationInventoryDemand(closeFrame.Payload)
262+ require.NoError(t, err)
263+ require.False(t, closeDemand.Open)
264+ require.Equal(t, interaction, closeDemand.InteractionGeneration)
265+ require.False(t, ac.overlays.paletteActive())
266+}
267+
268+// TestPaletteInventoryDemandRequiresCapability pins the serving-side gate:
269+// without the advertised capability bit no demand crosses, even with an
270+// admitted effect, and the overlay namespace still opens natively.
271+func TestPaletteInventoryDemandRequiresCapability(t *testing.T) {
272+ p, release := newBlockingPTY(t)
273+ defer release()
274+ d, current, ac, sends := newManualSessionWithPTYs(t, p)
275+ effect := beginRecentRoutePaletteEffect(t, d, current, ac)
276+
277+ interaction := d.enterPalette(current, ac)
278+ d.sendPaletteInventoryDemand(ac, effect, true, interaction)
279+ deadline := time.After(time.Second)
280+ for {
281+ select {
282+ case frame := <-sends:
283+ if frame.Type == wire.MsgNavigationInventoryDemand {
284+ t.Fatalf("demand without capability must not send")
285+ }
286+ case <-deadline:
287+ require.True(t, ac.overlays.paletteActive())
288+ return
289+ }
290+ }
291+}
A internal/usecase/daemon/palette_origin_test.go +79 -0
@@ -0,0 +1,79 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/ports"
8+ "github.com/bnema/vev/internal/protocol"
9+ "github.com/bnema/vev/internal/usecase/palette"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestPaletteLocalQualificationDependsOnConfiguredHosts(t *testing.T) {
14+ for _, tc := range []struct {
15+ name string
16+ hosts []ports.RemoteHostSnapshot
17+ want string
18+ }{
19+ {name: "local only", want: "Switch to session sample"},
20+ {name: "offline host without sessions", hosts: []ports.RemoteHostSnapshot{{Endpoint: "host-a"}}, want: "Switch to session sample@local"},
21+ } {
22+ t.Run(tc.name, func(t *testing.T) {
23+ d := newTestDaemon(t, nil, stubClock{})
24+ sess := addControlSession(d, "sample", "tab-1", "pane-1")
25+ sess.ephemeral = false
26+ sess.incarnation = domain.SessionLifecycleID{1}
27+ seedRemoteDirectory(t, d, tc.hosts...)
28+ results := d.paletteResults(nil, nil, protocol.RecentRouteSnapshot{})
29+ var labels []string
30+ for _, result := range results {
31+ if _, ok := result.SessionTarget(); ok {
32+ labels = append(labels, result.DisplayText())
33+ }
34+ }
35+ require.Equal(t, []string{tc.want}, labels)
36+ })
37+ }
38+}
39+
40+func TestPaletteHistoryUsesClientRelativeOrigin(t *testing.T) {
41+ d := newTestDaemon(t, nil, stubClock{})
42+ snapshot := protocol.RecentRouteSnapshot{Generation: 1, Entries: []protocol.RecentRouteEntry{
43+ {Key: 1, Generation: 1, Name: "sample", Kind: protocol.RouteKindLocal, Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "sample"}},
44+ {Key: 2, Generation: 1, Name: "sample", Kind: protocol.RouteKindRemote, HostLabel: "host-a", Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "sample"}},
45+ }}
46+ results := d.paletteResults(nil, nil, snapshot)
47+ require.Len(t, results, 2)
48+ require.Equal(t, "Switch to session sample@local", results[0].DisplayText())
49+ require.Equal(t, "Switch to session sample@host-a", results[1].DisplayText())
50+}
51+
52+func TestPaletteImportedLocalSessionReplacesDuplicateHistoryRoute(t *testing.T) {
53+ target := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "sample"}
54+ action := protocol.RouteNavigationAction{SnapshotGeneration: 1, Key: 1, Generation: 1}
55+ ac := &attachedClient{overlays: &overlayRuntime{
56+ paletteInventoryOpen: true,
57+ paletteRouteSnapshot: protocol.RecentRouteSnapshot{Entries: []protocol.RecentRouteEntry{{Key: 1, Generation: 1, Kind: protocol.RouteKindLocal, Target: target}}},
58+ paletteInventoryGroups: []protocol.NavigationInventorySourceGroup{{SourceKey: "local", Status: protocol.NavigationInventorySourceOK, Entries: []protocol.NavigationInventoryEntry{{SourceKey: "local", EntryKey: navigationInventoryEntryKey(target.LifecycleID, target.SessionName), Name: "sample", DisplayOrigin: "local", State: "up"}}}},
59+ }}
60+ results := appendImportedResults([]palette.Result{palette.NewRecentRouteResult("sample", "sample", action)}, ac)
61+ require.Len(t, results, 1, "history and inventory represent one exact local destination")
62+ require.Equal(t, palette.ResultKindImportedSession, results[0].Kind())
63+
64+ for _, tc := range []struct {
65+ name string
66+ kind protocol.RouteKind
67+ target protocol.ExactSessionTarget
68+ }{
69+ {"replacement lifecycle", protocol.RouteKindLocal, protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "sample"}},
70+ {"remote homonym", protocol.RouteKindRemote, target},
71+ } {
72+ t.Run(tc.name, func(t *testing.T) {
73+ ac.overlays.paletteRouteSnapshot.Entries[0].Kind = tc.kind
74+ ac.overlays.paletteRouteSnapshot.Entries[0].Target = tc.target
75+ results := appendImportedResults([]palette.Result{palette.NewRecentRouteResult("sample", "sample", action)}, ac)
76+ require.Len(t, results, 2, "presentation homonyms must not erase distinct identities")
77+ })
78+ }
79+}
M internal/usecase/daemon/palette_test.go +1 -1
@@ -556,7 +556,7 @@ func TestPaletteIncludesExactRemoteCatalogTargetBesideSameNameLocalSession(t *te
556556 results := d.paletteResults(current, nil, protocol.RecentRouteSnapshot{})
557557 var matching []palette.Result
558558 for _, result := range results {
559- if result.DisplayText() == "Switch to session vev" || result.DisplayText() == "Switch to session vev@arch" {
559+ if result.DisplayText() == "Switch to session vev@local" || result.DisplayText() == "Switch to session vev@arch" {
560560 matching = append(matching, result)
561561 }
562562 }
M internal/usecase/daemon/picker.go +16 -42
@@ -9,7 +9,6 @@ import (
99
1010 renderer "github.com/bnema/vev-vt"
1111 "github.com/bnema/vev/internal/domain"
12- "github.com/bnema/vev/internal/ports"
1312 "github.com/bnema/vev/internal/protocol"
1413 "github.com/bnema/vev/internal/protocol/catalogue"
1514 "github.com/bnema/vev/internal/usecase/keys"
@@ -87,48 +86,30 @@ func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker.
8786 d.invalidateRender(sess, ac, true, "picker.go")
8887 }
8988
90-// pickerViews captures one canonical lifecycle/tab snapshot. It intentionally
91-// knows nothing about picker intent; picker.New owns all destination policy.
89+// pickerViews projects the shared daemon inventory for the picker. It keeps
90+// current/ephemeral rows, tabs/previews, grouping, and move eligibility;
91+// lifecycle and remote facts come from the common capture.
9292 func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.SessionView, picker.SourceFilter) {
93- d.mu.Lock()
94- sessions := make([]*session, 0, len(d.sessions))
95- for _, entry := range d.sessions {
96- if entry == nil {
97- continue
98- }
99- sessions = append(sessions, entry)
100- }
101- stopped := make([]inactiveSession, 0, len(d.inactive))
102- for _, s := range d.inactive {
103- if s.visible() {
104- stopped = append(stopped, s)
105- }
106- }
107- d.mu.Unlock()
108-
109- for _, entry := range sessions {
110- if entry != nil {
111- d.refreshSessionFocusedTitles(entry)
112- }
93+ if cur != nil && ac != nil {
94+ cur.repairAttachmentView(ac)
11395 }
11496 opts := viewOptions{tabDetails: true, focusedTitles: true, terminalTitle: d.currentTabsConfig().TerminalTitle}
97+ inv := d.captureSessionInventory(opts, true)
98+ stopped := inv.visibleStopped()
99+ hosts := inv.hosts
100+ now := inv.now
101+ monitored := inv.monitored
102+ initialized := inv.initialized
115103
116- type liveSnapshot struct {
117- entry *session
118- view sessionView
119- }
120104 // One snapshot per live session: sorting and view building read the same
121105 // capture, so comparators cannot observe a concurrent touchMRU or
122106 // renameSession mid-sort. Remote-catalog locks are released before sorting
123107 // and picker-row construction.
124- live := make([]liveSnapshot, 0, len(sessions))
108+ live := inv.live
125109 var current picker.SourceFilter
126- for _, entry := range sessions {
127- if entry == cur && ac != nil {
128- entry.repairAttachmentView(ac)
129- }
130- snap := entry.snapshotView(opts)
131- if entry == cur {
110+ for _, item := range live {
111+ snap := item.view
112+ if item.sess == cur {
132113 if ac != nil {
133114 view := ac.viewSnapshot()
134115 if view.tabID != "" {
@@ -138,7 +119,6 @@ func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.Session
138119 current = picker.SourceFilter{Session: snap.id, Incarnation: snap.incarnation, TabID: snap.tabs[snap.defaultTab].id}
139120 }
140121 }
141- live = append(live, liveSnapshot{entry: entry, view: snap})
142122 }
143123 sort.Slice(live, func(i, j int) bool {
144124 if live[i].view.mruAt != live[j].view.mruAt {
@@ -163,12 +143,6 @@ func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.Session
163143 })
164144 }
165145
166- directory := d.remoteDirectorySnapshot()
167- monitored := d.remoteDirectory != nil
168- hosts := append([]ports.RemoteHostSnapshot(nil), directory.Hosts...)
169- sortDirectoryHosts(hosts)
170- now := d.daemonNow()
171-
172146 catalogRows := 0
173147 for _, host := range hosts {
174148 catalogRows += len(host.Sessions)
@@ -215,7 +189,7 @@ func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.Session
215189 }
216190 // A nil directory means remote monitoring is not installed at all:
217191 // only an installed-but-unpublished monitor reads as "checking".
218- if monitored && !directory.Initialized {
192+ if monitored && !initialized {
219193 view := remotePickerCheckingView()
220194 if grouped {
221195 view.Section = "REMOTE"
A internal/usecase/daemon/session_inventory.go +101 -0
@@ -0,0 +1,101 @@
1+package daemon
2+
3+import (
4+ "time"
5+
6+ "github.com/bnema/vev/internal/ports"
7+)
8+
9+// inventoriedLiveSession pairs a live session pointer with its immutable
10+// snapshot view. The pointer preserves exact current-session identity while
11+// the view carries all display/policy facts, so projections never re-lock
12+// the registry to re-read the same session.
13+type inventoriedLiveSession struct {
14+ sess *session
15+ view sessionView
16+}
17+
18+// sessionInventory is the daemon-owned common capture consumed by the
19+// palette, picker, and catalog export projections. It copies active/inactive
20+// records and remote-directory values once through this module; projections
21+// keep only UI-specific filtering, sorting, and labeling.
22+//
23+// d.mu is released before any per-session snapshot, preserving the
24+// established lock ordering. Copied values are coherent per exact target,
25+// not a distributed atomic transaction; callers revalidate at selection.
26+type sessionInventory struct {
27+ live []inventoriedLiveSession
28+ stopped []inactiveSession
29+ hosts []ports.RemoteHostSnapshot
30+ monitored bool
31+ initialized bool
32+ now time.Time
33+}
34+
35+// captureSessionInventory copies registry pointers and inactive records under
36+// d.mu, then snapshots each live session without holding that lock. When
37+// refreshTitles is true the focused-title refresh runs per session after the
38+// registry lock is released, matching the historical picker/catalog cost;
39+// palette passes false to avoid rich tab-title work it never displays.
40+func (d *Daemon) captureSessionInventory(opts viewOptions, refreshTitles bool) sessionInventory {
41+ var sessions []*session
42+ var stopped []inactiveSession
43+ var monitored bool
44+ if d != nil {
45+ d.mu.Lock()
46+ sessions = d.sessionsSnapshotLocked()
47+ stopped = make([]inactiveSession, 0, len(d.inactive))
48+ for _, entry := range d.inactive {
49+ stopped = append(stopped, entry)
50+ }
51+ monitored = d.remoteDirectory != nil
52+ d.mu.Unlock()
53+ }
54+ if refreshTitles && d != nil {
55+ for _, sess := range sessions {
56+ d.refreshSessionFocusedTitles(sess)
57+ }
58+ }
59+ live := make([]inventoriedLiveSession, 0, len(sessions))
60+ for _, sess := range sessions {
61+ if sess == nil {
62+ continue
63+ }
64+ live = append(live, inventoriedLiveSession{sess: sess, view: sess.snapshotView(opts)})
65+ }
66+ directory := d.remoteDirectorySnapshot()
67+ hosts := append([]ports.RemoteHostSnapshot(nil), directory.Hosts...)
68+ sortDirectoryHosts(hosts)
69+ return sessionInventory{
70+ live: live,
71+ stopped: stopped,
72+ hosts: hosts,
73+ monitored: monitored,
74+ initialized: directory.Initialized,
75+ now: d.daemonNow(),
76+ }
77+}
78+
79+// resumableStopped returns stopped sessions eligible for resume display,
80+// preserving the palette's historical canResume projection.
81+func (inv sessionInventory) resumableStopped() []inactiveSession {
82+ out := make([]inactiveSession, 0, len(inv.stopped))
83+ for _, entry := range inv.stopped {
84+ if entry.canResume() {
85+ out = append(out, entry)
86+ }
87+ }
88+ return out
89+}
90+
91+// visibleStopped returns non-purging stopped sessions, preserving the
92+// picker and catalog export projections.
93+func (inv sessionInventory) visibleStopped() []inactiveSession {
94+ out := make([]inactiveSession, 0, len(inv.stopped))
95+ for _, entry := range inv.stopped {
96+ if entry.visible() {
97+ out = append(out, entry)
98+ }
99+ }
100+ return out
101+}
A internal/usecase/daemon/session_inventory_test.go +133 -0
@@ -0,0 +1,133 @@
1+package daemon
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/protocol"
11+ "github.com/bnema/vev/internal/protocol/catalogue"
12+)
13+
14+// TestSessionInventorySharedCapture verifies the daemon-owned common capture
15+// feeds palette, picker, and catalog export with identical lifecycle facts,
16+// while preserving each projection's intentional cost and filtering policy.
17+func TestSessionInventorySharedCapture(t *testing.T) {
18+ now := time.Unix(1_000, 0)
19+ d := newTestDaemon(t, nil, fixedRemoteRefreshClock{now: now})
20+ current := addControlSession(d, "current", "tab-1", "pane-1")
21+ current.ephemeral = false
22+ current.incarnation = domain.SessionLifecycleID{11}
23+ other := addControlSession(d, "other", "tab-2", "pane-2")
24+ other.ephemeral = false
25+ other.incarnation = domain.SessionLifecycleID{12}
26+ remoteLifecycle := domain.SessionLifecycleID{21}
27+ seedRemoteDirectory(t, d, reachableDirectoryHost("user@arch", now, catalogue.RemoteCatalogSession{
28+ LifecycleID: remoteLifecycle, Name: "shared", State: catalogue.RemoteCatalogSessionUp,
29+ Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-remote", Index: 0, Name: "shell"}},
30+ ActiveTabID: "tab-remote",
31+ }))
32+
33+ cheap := d.captureSessionInventory(viewOptions{}, false)
34+ rich := d.captureSessionInventory(viewOptions{tabDetails: true, focusedTitles: true}, true)
35+
36+ require.Len(t, cheap.live, 2)
37+ require.Len(t, rich.live, 2)
38+ require.Len(t, cheap.hosts, 1)
39+ require.Equal(t, cheap.hosts[0].Endpoint, rich.hosts[0].Endpoint)
40+ // Cheap palette capture avoids tab details; rich picker/catalog capture includes them.
41+ require.Empty(t, cheap.live[0].view.tabs)
42+ require.NotEmpty(t, rich.live[0].view.tabs)
43+
44+ results := d.paletteResults(current, nil, protocol.RecentRouteSnapshot{})
45+ foundOther := false
46+ foundRemote := false
47+ for _, result := range results {
48+ if name, ok := result.SessionName(); ok && name == "other" {
49+ foundOther = true
50+ }
51+ if _, ok := result.RemoteSessionTarget(); ok {
52+ foundRemote = true
53+ }
54+ }
55+ require.True(t, foundOther, "palette must project shared live sessions")
56+ require.True(t, foundRemote, "palette must project shared remote sessions")
57+
58+ views, _ := d.pickerViews(current, nil)
59+ foundPickerOther := false
60+ foundPickerRemote := false
61+ for _, view := range views {
62+ if view.Name == "other" {
63+ foundPickerOther = true
64+ }
65+ if view.RemoteTarget != nil && view.RemoteHost == "user@arch" {
66+ foundPickerRemote = true
67+ }
68+ }
69+ require.True(t, foundPickerOther)
70+ require.True(t, foundPickerRemote)
71+
72+ catalog, err := controlExec{d: d}.RemoteCatalog(true)
73+ require.NoError(t, err)
74+ require.Contains(t, catalog, `"other"`)
75+}
76+
77+// TestSessionInventoryStoppedFiltering documents the intentional projection
78+// difference: palette shows only resumable sessions while picker and catalog
79+// observe every visible stopped session through the shared capture.
80+func TestSessionInventoryStoppedFiltering(t *testing.T) {
81+ tests := []struct {
82+ name string
83+ projection string
84+ }{
85+ {name: "palette", projection: "palette"},
86+ {name: "picker", projection: "picker"},
87+ {name: "catalog", projection: "catalog"},
88+ }
89+ for _, tt := range tests {
90+ t.Run(tt.name, func(t *testing.T) {
91+ now := time.Unix(2_000, 0)
92+ d := newTestDaemon(t, nil, fixedRemoteRefreshClock{now: now})
93+ current := addControlSession(d, "current", "tab-1", "pane-1")
94+ current.ephemeral = false
95+ current.incarnation = domain.SessionLifecycleID{13}
96+ d.mu.Lock()
97+ d.inactive["resumable"] = inactiveSession{name: "resumable", cwd: "/tmp/resumable", createdAt: 1, incarnation: domain.IncarnationID{3}, state: protocol.SessionDown}
98+ d.inactive["broken"] = inactiveSession{name: "broken", cwd: "/tmp/broken", createdAt: 2, incarnation: domain.IncarnationID{4}, state: protocol.SessionBroken}
99+ d.mu.Unlock()
100+
101+ inv := d.captureSessionInventory(viewOptions{}, false)
102+ require.NotEmpty(t, inv.visibleStopped())
103+ require.NotEmpty(t, inv.resumableStopped())
104+ require.GreaterOrEqual(t, len(inv.visibleStopped()), len(inv.resumableStopped()))
105+
106+ switch tt.projection {
107+ case "palette":
108+ results := d.paletteResults(current, nil, testRecentRouteSnapshot())
109+ names := map[string]bool{}
110+ for _, result := range results {
111+ if name, ok := result.SessionName(); ok {
112+ names[name] = true
113+ }
114+ }
115+ require.True(t, names["resumable"])
116+ require.False(t, names["broken"])
117+ case "picker":
118+ views, _ := d.pickerViews(current, nil)
119+ names := map[string]bool{}
120+ for _, view := range views {
121+ names[view.Name] = true
122+ }
123+ require.True(t, names["resumable"])
124+ require.True(t, names["broken"])
125+ case "catalog":
126+ out, err := controlExec{d: d}.RemoteCatalog(true)
127+ require.NoError(t, err)
128+ require.Contains(t, out, `"resumable"`)
129+ require.Contains(t, out, `"broken"`)
130+ }
131+ })
132+ }
133+}
M internal/usecase/daemon/typed_connection_test.go +24 -0
@@ -158,6 +158,12 @@ func testDecodeClientFrame(frame wire.Frame) (protocol.ClientMessage, error) {
158158 return wire.UnmarshalSamePeerSwitchRequest(frame.Payload)
159159 case wire.MsgParkedRouteRequest:
160160 return wire.UnmarshalParkedRouteRequest(frame.Payload)
161+ case wire.MsgNavigationInventoryRequest:
162+ return wire.UnmarshalNavigationInventoryRequest(frame.Payload)
163+ case wire.MsgNavigationInventoryPublication:
164+ return wire.UnmarshalNavigationInventoryPublication(frame.Payload)
165+ case wire.MsgNavigationInventoryFailure:
166+ return wire.UnmarshalNavigationInventoryFailure(frame.Payload)
161167 case wire.MsgRecentRouteSnapshot:
162168 return wire.UnmarshalRecentRouteSnapshot(frame.Payload)
163169 case wire.MsgRouteNavigationFailure:
@@ -316,6 +322,24 @@ func testServerFrame(message protocol.ServerMessage) (wire.Frame, error) {
316322 return wire.Frame{Type: wire.MsgSamePeerSwitchFailure, Payload: payload}, err
317323 case protocol.ParkedRouteResponse:
318324 return wire.Frame{Type: wire.MsgParkedRouteResponse, Payload: wire.MarshalParkedRouteResponse(m)}, nil
325+ case protocol.NavigationInventoryResponse:
326+ payload := wire.MarshalNavigationInventoryResponse(m)
327+ if payload == nil {
328+ return wire.Frame{}, errors.New("test server connection: invalid inventory response")
329+ }
330+ return wire.Frame{Type: wire.MsgNavigationInventoryResponse, Payload: payload}, nil
331+ case protocol.NavigationInventoryDemand:
332+ payload := wire.MarshalNavigationInventoryDemand(m)
333+ if payload == nil {
334+ return wire.Frame{}, errors.New("test server connection: invalid inventory demand")
335+ }
336+ return wire.Frame{Type: wire.MsgNavigationInventoryDemand, Payload: payload}, nil
337+ case protocol.NavigationInventorySelection:
338+ payload := wire.MarshalNavigationInventorySelection(m)
339+ if payload == nil {
340+ return wire.Frame{}, errors.New("test server connection: invalid inventory selection")
341+ }
342+ return wire.Frame{Type: wire.MsgNavigationInventorySelection, Payload: payload}, nil
319343 default:
320344 return wire.Frame{}, errors.New("test server connection: unsupported server message")
321345 }
M internal/usecase/palette/result.go +102 -8
@@ -19,6 +19,7 @@ const (
1919 ResultKindRemoteSession
2020 ResultKindRecentRoute
2121 ResultKindCreateSessionDestination
22+ ResultKindImportedSession
2223 )
2324
2425 // CreateSessionDestinationKind identifies where a CNS result creates a session.
@@ -31,12 +32,14 @@ const (
3132 )
3233
3334 // Result is an immutable palette target. Its kind is the sole discriminator
34-// for its private command, local-session, remote-session, or route payload.
35+// for its private command, local-session, remote-session, imported-session,
36+// or route payload.
3537 type Result struct {
3638 kind ResultKind
3739 command command.Command
3840 session sessionPayload
3941 remoteSession remoteSessionPayload
42+ importedSession importedSessionPayload
4043 route routePayload
4144 createDestination createSessionDestinationPayload
4245 }
@@ -53,6 +56,20 @@ type remoteSessionPayload struct {
5356 unavailableReason string
5457 }
5558
59+// importedSessionPayload is one sanitized navigation-inventory row relayed
60+// from the client's local control source. It carries display facts and the
61+// opaque selection keys only: no endpoint, connection parameters, CWD,
62+// environment, preview, or pane content. Resolution happens source-side
63+// through the keys.
64+type importedSessionPayload struct {
65+ sourceKey string
66+ entryKey string
67+ name string
68+ displayOrigin string
69+ state string
70+ reason string
71+}
72+
5673 type routePayload struct {
5774 name string
5875 label string
@@ -101,12 +118,24 @@ func NewStoppedSessionResultWithDisplayOrigin(target protocol.ExactSessionTarget
101118 }
102119 }
103120
104-func newSessionPayload(target protocol.ExactSessionTarget, createdAt time.Time, displayOrigin string) sessionPayload {
105- display := target.SessionName
106- if displayOrigin != "" {
107- display = domain.RemoteSessionDisplay(target.SessionName, displayOrigin)
121+// SessionDisplay qualifies palette destinations relative to the client.
122+func SessionDisplay(name, origin string) string {
123+ if origin == "" {
124+ return name
125+ }
126+ return name + "@" + domain.RemoteDisplayOrigin(origin)
127+}
128+
129+func remoteSessionDisplay(key domain.RemoteSessionKey) string {
130+ origin := key.DisplayOrigin
131+ if origin == "" {
132+ origin = domain.RemoteDisplayOrigin(key.Host)
108133 }
109- return sessionPayload{display: display, createdAt: createdAt, target: target}
134+ return SessionDisplay(key.Name, origin)
135+}
136+
137+func newSessionPayload(target protocol.ExactSessionTarget, createdAt time.Time, displayOrigin string) sessionPayload {
138+ return sessionPayload{display: SessionDisplay(target.SessionName, displayOrigin), createdAt: createdAt, target: target}
110139 }
111140
112141 // NewRemoteSessionResult creates an immutable catalog-backed remote target.
@@ -119,6 +148,19 @@ func NewRemoteSessionResult(key domain.RemoteSessionKey, target domain.RemoteSes
119148 }
120149 }
121150
151+// NewImportedSessionResult creates an immutable inventory-relayed row.
152+// Labels are never parsed back into selection authority: Enter resolves
153+// through the opaque source/entry keys.
154+func NewImportedSessionResult(sourceKey, entryKey, name, displayOrigin, state, reason string) Result {
155+ return Result{
156+ kind: ResultKindImportedSession,
157+ importedSession: importedSessionPayload{
158+ sourceKey: sourceKey, entryKey: entryKey, name: name,
159+ displayOrigin: displayOrigin, state: state, reason: reason,
160+ },
161+ }
162+}
163+
122164 // NewRecentRouteResult creates an immutable client-ledger route target.
123165 func NewRecentRouteResult(name, label string, action protocol.RouteNavigationAction) Result {
124166 return Result{kind: ResultKindRecentRoute, route: routePayload{name: name, label: label, action: action}}
@@ -150,6 +192,8 @@ func (r Result) sameTarget(other Result) bool {
150192 return r.session.target == other.session.target
151193 case ResultKindRemoteSession:
152194 return r.remoteSession.key == other.remoteSession.key && r.remoteSession.target == other.remoteSession.target
195+ case ResultKindImportedSession:
196+ return r.importedSession.sourceKey == other.importedSession.sourceKey && r.importedSession.entryKey == other.importedSession.entryKey
153197 case ResultKindRecentRoute:
154198 return r.route.action == other.route.action
155199 case ResultKindCreateSessionDestination:
@@ -167,7 +211,10 @@ func (r Result) DisplayText() string {
167211 return r.command.Code
168212 }
169213 if r.kind == ResultKindRemoteSession {
170- return activeSessionDisplayPrefix + r.remoteSession.key.Display()
214+ return activeSessionDisplayPrefix + remoteSessionDisplay(r.remoteSession.key)
215+ }
216+ if r.kind == ResultKindImportedSession {
217+ return activeSessionDisplayPrefix + r.importedSession.display()
171218 }
172219 if r.kind == ResultKindRecentRoute {
173220 return activeSessionDisplayPrefix + r.route.label
@@ -213,7 +260,9 @@ func (r Result) searchTerms() (identity, label string, offset int, ok bool) {
213260 case ResultKindActiveSession, ResultKindStoppedSession:
214261 return r.session.target.SessionName, r.session.display, utf8.RuneCountInString(r.sessionDisplayPrefix()), true
215262 case ResultKindRemoteSession:
216- return r.remoteSession.key.Name, r.remoteSession.key.Display(), utf8.RuneCountInString(activeSessionDisplayPrefix), true
263+ return r.remoteSession.key.Name, remoteSessionDisplay(r.remoteSession.key), utf8.RuneCountInString(activeSessionDisplayPrefix), true
264+ case ResultKindImportedSession:
265+ return r.importedSession.name, r.importedSession.display(), utf8.RuneCountInString(activeSessionDisplayPrefix), true
217266 case ResultKindRecentRoute:
218267 return r.route.name, r.route.label, utf8.RuneCountInString(activeSessionDisplayPrefix), true
219268 default:
@@ -260,6 +309,51 @@ func (r Result) RemoteSessionUnavailableReason() (string, bool) {
260309 return r.remoteSession.unavailableReason, r.kind == ResultKindRemoteSession
261310 }
262311
312+// importedDisplay qualifies an inventory row with its explicit origin.
313+// Unqualified names would collapse homonyms across local and remote
314+// sources into the wrong selection.
315+func (p importedSessionPayload) qualified() string {
316+ return SessionDisplay(p.name, p.displayOrigin)
317+}
318+
319+func (p importedSessionPayload) display() string {
320+ base := p.qualified()
321+ // Remote vision is read-only availability tagging: availability
322+ // reasons surface independently of lifecycle state (a cached-up row
323+ // can still be unreachable), and any non-live state tags inline so
324+ // stale or broken entries never look attachable. Live rows without a
325+ // reason stay untagged.
326+ tag := p.state
327+ if tag == "" || tag == "up" {
328+ tag = ""
329+ }
330+ if p.reason != "" {
331+ if tag == "" {
332+ return base + " (" + p.reason + ")"
333+ }
334+ return base + " (" + tag + ": " + p.reason + ")"
335+ }
336+ if tag == "" {
337+ return base
338+ }
339+ return base + " (" + tag + ")"
340+}
341+
342+// ImportedSessionKey returns the opaque inventory selection keys only for
343+// an imported session result.
344+func (r Result) ImportedSessionKey() (sourceKey, entryKey string, ok bool) {
345+ return r.importedSession.sourceKey, r.importedSession.entryKey, r.kind == ResultKindImportedSession
346+}
347+
348+// ImportedSessionDisplay returns the qualified display label and reason only
349+// for an imported session result.
350+func (r Result) ImportedSessionDisplay() (display, reason string, ok bool) {
351+ if r.kind != ResultKindImportedSession {
352+ return "", "", false
353+ }
354+ return r.importedSession.qualified(), r.importedSession.reason, true
355+}
356+
263357 // RouteNavigationAction returns the exact client-ledger target only for a
264358 // recent-route result.
265359 func (r Result) RouteNavigationAction() (protocol.RouteNavigationAction, bool) {
A internal/usecase/palette/result_origin_test.go +25 -0
@@ -0,0 +1,25 @@
1+package palette
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestSessionResultsAlwaysQualifyClientOrigin(t *testing.T) {
12+ for _, tc := range []struct {
13+ name string
14+ result Result
15+ want string
16+ }{
17+ {"local only", NewActiveSessionResult(testExactTarget("sample", 1), time.Time{}), "Switch to session sample"},
18+ {"hybrid local", NewActiveSessionResultWithDisplayOrigin(testExactTarget("sample", 1), time.Time{}, "local"), "Switch to session sample@local"},
19+ {"serving remote", NewActiveSessionResultWithDisplayOrigin(testExactTarget("sample", 1), time.Time{}, "host-a"), "Switch to session sample@host-a"},
20+ {"imported local", NewImportedSessionResult("local", "key", "sample", "local", "up", ""), "Switch to session sample@local"},
21+ {"discovered remote", NewRemoteSessionResult(domain.RemoteSessionKey{Name: "sample", Host: "user@host-a", DisplayOrigin: "host-a"}, domain.RemoteSessionTarget{}, ""), "Switch to session sample@host-a"},
22+ } {
23+ t.Run(tc.name, func(t *testing.T) { require.Equal(t, tc.want, tc.result.DisplayText()) })
24+ }
25+}
M internal/usecase/palette/result_test.go +36 -0
@@ -14,6 +14,42 @@ func testExactTarget(name string, marker byte) protocol.ExactSessionTarget {
1414 return protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{marker}, SessionName: name}
1515 }
1616
17+func TestImportedSessionResultKeepsOpaqueKeysAndQualifiedDisplay(t *testing.T) {
18+ local := NewImportedSessionResult("local", "aaa/one", "shared", "local", "up", "")
19+ remote := NewImportedSessionResult("user@arch", "bbb/shared", "shared", "arch", "up", "")
20+
21+ require.Equal(t, ResultKindImportedSession, local.Kind())
22+ require.Equal(t, "Switch to session shared@local", local.DisplayText())
23+ require.Equal(t, "Switch to session shared@arch", remote.DisplayText())
24+
25+ source, entry, ok := local.ImportedSessionKey()
26+ require.True(t, ok)
27+ require.Equal(t, "local", source)
28+ require.Equal(t, "aaa/one", entry)
29+ display, reason, ok := remote.ImportedSessionDisplay()
30+ require.True(t, ok)
31+ require.Equal(t, "shared@arch", display)
32+ require.Empty(t, reason)
33+
34+ // Homonyms across sources stay distinct: keys never collapse.
35+ require.False(t, local.sameTarget(remote))
36+ require.True(t, local.sameTarget(NewImportedSessionResult("local", "aaa/one", "renamed", "local", "up", "")))
37+
38+ // Remote vision tags availability inline: non-live states surface so
39+ // stale or broken entries never look attachable. Live rows stay clean.
40+ down := NewImportedSessionResult("arch", "aaa/db", "db", "arch", "down", "unreachable")
41+ require.Equal(t, "Switch to session db@arch (down: unreachable)", down.DisplayText())
42+ broken := NewImportedSessionResult("arch", "aaa/db", "db", "arch", "broken", "")
43+ require.Equal(t, "Switch to session db@arch (broken)", broken.DisplayText())
44+ stopped := NewImportedSessionResult("local", "aaa/old", "old", "local", "stopped", "")
45+ require.Equal(t, "Switch to session old@local (stopped)", stopped.DisplayText())
46+
47+ // Other kinds expose no imported keys.
48+ if _, _, ok := NewCommandResult(command.Command{Code: "NT"}).ImportedSessionKey(); ok {
49+ t.Fatal("command results must not expose imported keys")
50+ }
51+}
52+
1753 func TestResultKindsAndSessionLifecycleTargets(t *testing.T) {
1854 created := time.Date(2026, time.March, 1, 2, 3, 4, 0, time.UTC)
1955 commandResult := NewCommandResult(command.Command{Code: "NT", Desc: "Create tab"})
D scripts/remote-picker-harness/Dockerfile +0 -48
@@ -1,48 +0,0 @@
1-ARG BASE_IMAGE=ubuntu:24.04
2-FROM ${BASE_IMAGE}
3-
4-ENV DEBIAN_FRONTEND=noninteractive \
5- XDG_STATE_HOME=/tmp/vev-state \
6- XDG_RUNTIME_DIR=/tmp/vev-runtime \
7- PATH=/home/test/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
8- TERM=xterm-256color \
9- COLORTERM=truecolor
10-
11-RUN apt-get update \
12- && apt-get install -y --no-install-recommends ca-certificates openssh-client openssh-server \
13- && rm -rf /var/lib/apt/lists/* \
14- && useradd --create-home --shell /bin/bash test \
15- && usermod --password x test \
16- && install -d -m 0755 /run/sshd /tmp/vev-state /tmp/vev-runtime /home/test/bin \
17- && chmod 0777 /tmp/vev-state /tmp/vev-runtime \
18- && install -d -m 0700 -o test -g test /home/test/.ssh
19-
20-COPY vev /usr/local/bin/vev.real
21-COPY harness /usr/local/bin/remote-picker-harness
22-COPY vev-wrapper /usr/local/bin/vev
23-COPY id_ed25519.pub /home/test/.ssh/authorized_keys
24-
25-RUN chown test:test /home/test/.ssh/authorized_keys \
26- && chmod 0600 /home/test/.ssh/authorized_keys \
27- && chmod 0755 /usr/local/bin/vev /usr/local/bin/vev.real /usr/local/bin/remote-picker-harness \
28- && printf '%s\n' \
29- 'Host *' \
30- ' IdentityFile /home/test/.ssh/id_ed25519' \
31- ' StrictHostKeyChecking no' \
32- ' UserKnownHostsFile /dev/null' \
33- ' LogLevel ERROR' \
34- > /home/test/.ssh/config \
35- && chown test:test /home/test/.ssh/config \
36- && chmod 0600 /home/test/.ssh/config \
37- && printf '%s\n' \
38- 'PermitRootLogin no' \
39- 'PasswordAuthentication no' \
40- 'KbdInteractiveAuthentication no' \
41- 'PubkeyAuthentication yes' \
42- 'UsePAM no' \
43- 'AllowUsers test' \
44- 'PrintMotd no' \
45- > /etc/ssh/sshd_config.d/vev-harness.conf \
46- && ssh-keygen -A
47-
48-CMD ["/usr/sbin/sshd", "-D", "-e"]
M scripts/remote-picker-harness/README.md +20 -44
@@ -1,44 +1,20 @@
1-# Remote picker container acceptance
2-
3-Run the disposable two-container acceptance harness with:
4-
5-```sh
6-make remote-acceptance
7-```
8-
9-The harness builds the current binary, starts a local client container and an
10-SSH-enabled remote daemon container on a private Docker network, and removes
11-all resources when it exits. Docker uses the normal CLI configuration, so
12-`DOCKER_HOST` or the active context selects a rootless daemon; the harness does
13-not assume a socket pathname.
14-
15-Environment overrides:
16-
17-- `VEV_HARNESS_BASE_IMAGE` selects the Docker base image used for both
18- containers (default: `ubuntu:24.04`).
19-- `VEV_HARNESS_TARGET` selects the SSH target passed to the harness binary
20- (default: `test@remote`).
21-- `VEV_HARNESS_ARTIFACT_DIR`, when set, writes a bounded
22- `remote-picker-harness.json` containing output-state, checkpoint, and event
23- metadata. It is unset by default; the artifact contains no terminal bytes,
24- raw screen text, credentials, or target output. `run.sh` copies the report to
25- that host directory before it removes the disposable containers.
26-
27-Each logical transport has one persistent `vt.Screen` and a client-equivalent
28-output-state chain. The probe writes accepted full, incremental, and side-effect
29-frames to that screen, ACKs only accepted state-bearing frames, and retains a
30-bounded set of screen checkpoints plus non-sensitive event metadata. The local
31-picker phase captures unified local/remote rows before selection, keeps the same
32-local client transport while the daemon publishes proxied remote content,
33-rejects direct `MsgAttachTarget` handoff and remote daemon chrome, verifies
34-continued input, and captures the unified rows again from the remote view.
35-
36-The run exercises real SSH-stdio and UDP attachment, typed live catalog and
37-preview requests, tab creation/removal fencing, exact lifecycle fencing after
38-replacement, daemon restart resume, slow-preview cancellation, version
39-mismatch, and unreachable-host handling. It checks environment values in
40-memory without printing terminal viewport data.
41-
42-Stopped-selector restoration and daemon-owned environment assertions are also
43-covered by the repository's daemon and route integration tests, where the
44-fixture can deterministically hold restoration at the stopped state.
1+# Remote navigation acceptance
2+
3+Run `make remote-acceptance` with Docker, Python 3 and `ssh-keygen` installed.
4+
5+The runner builds `scripts/demo/Dockerfile` from the current worktree and starts
6+two disposable Arch containers on a private network. Per-run client and host
7+keys authenticate SSH with host-key pinning. No host credentials, state or
8+sockets are mounted. Containers, image, network and temporary keys are removed
9+on success, failure or interruption.
10+
11+The real UI driver exercises local A → local B → remote → local A. It asserts
12+qualified palette labels, exactly one imported local destination, the original
13+session lifecycle at return, and a successfully committed navigation action.
14+A failed assertion fails the command. This is a targeted regression, not an
15+exhaustive transport or geometry matrix.
16+
17+`navigation_repro.py CLIENT_CONTAINER REMOTE_CONTAINER` also runs against
18+already prepared disposable demo containers. The client needs a pinned `remote`
19+SSH alias and `vev host add remote`. The script creates uniquely named sessions
20+and detaches its clients; the container owner handles session cleanup.
D scripts/remote-picker-harness/main.go +0 -1037
@@ -1,1037 +0,0 @@
1-package main
2-
3-import (
4- "bytes"
5- "context"
6- "errors"
7- "fmt"
8- "io"
9- "log/slog"
10- "os"
11- "os/exec"
12- "strings"
13- "sync"
14- "sync/atomic"
15- "time"
16-
17- "github.com/bnema/vev/internal/adapters/dgram"
18- "github.com/bnema/vev/internal/adapters/ipc"
19- remoteadapter "github.com/bnema/vev/internal/adapters/remote"
20- "github.com/bnema/vev/internal/adapters/sshstdio"
21- "github.com/bnema/vev/internal/domain"
22- "github.com/bnema/vev/internal/ports"
23- "github.com/bnema/vev/internal/protocol"
24- "github.com/bnema/vev/internal/protocol/catalogue"
25- "github.com/bnema/vev/internal/protocol/wire"
26-)
27-
28-const (
29- defaultTarget = "test@remote"
30- sessionName = "harness"
31-)
32-
33-var requestID atomic.Uint64
34-
35-func main() {
36- if err := run(); err != nil {
37- fmt.Fprintf(os.Stderr, "remote picker harness: %v\n", err)
38- os.Exit(1)
39- }
40- fmt.Println("remote picker harness: PASS")
41-}
42-
43-func run() (runErr error) {
44- target := os.Getenv("VEV_HARNESS_TARGET")
45- if target == "" {
46- target = defaultTarget
47- }
48- ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
49- defer cancel()
50-
51- artifact := newHarnessArtifact(os.Getenv("VEV_HARNESS_ARTIFACT_DIR"))
52- if artifact != nil {
53- ctx = withHarnessArtifact(ctx, artifact)
54- defer func() {
55- if err := artifact.write(runErr == nil); err != nil {
56- runErr = errors.Join(runErr, err)
57- }
58- }()
59- }
60-
61- catalog := remoteadapter.NewCatalogClient()
62- preview := remoteadapter.NewPreviewClient()
63- if err := runLocalPickerUnitedPhase(ctx, target, catalog, preview); err != nil {
64- return err
65- }
66- if err := runLiveStdioPhase(ctx, target, catalog, preview); err != nil {
67- return err
68- }
69- resumedTarget, err := runRestartResumePhase(ctx, target, catalog, preview)
70- if err != nil {
71- return err
72- }
73- if err := runUDPPhase(ctx, target); err != nil {
74- return err
75- }
76- if err := runCatalogFailurePhase(ctx, target, catalog); err != nil {
77- return err
78- }
79- if _, err := catalog.List(ctx, "test@unreachable.invalid"); err == nil {
80- return errors.New("unreachable catalog unexpectedly succeeded")
81- }
82- return runStaleTargetPhase(ctx, target, catalog, resumedTarget)
83-}
84-
85-func runLiveStdioPhase(ctx context.Context, target string, catalog ports.RemoteCatalogClient, preview ports.RemotePreviewClient) error {
86- stdio, err := openTransport(ctx, remoteadapter.TransportStdio, target)
87- if err != nil {
88- return fmt.Errorf("open stdio transport: %w", err)
89- }
90- defer func() { _ = stdio.Close() }()
91- if err := attachAndCheck(ctx, stdio, protocol.Hello{
92- Intent: protocol.IntentNew, Name: sessionName, Env: localEnvironment(), Cwd: "/tmp/local-cwd",
93- TermEnv: "xterm-256color", TrueColor: true,
94- }); err != nil {
95- return fmt.Errorf("stdio live attach: %w", err)
96- }
97- if err := sendInputAndAwait(ctx, stdio, "printf 'VEV_REMOTE_HARNESS_ENV=%s:%s:%s:%s\\n' \"$VEV_TEST_ENV\" \"$XDG_RUNTIME_DIR\" \"$WAYLAND_DISPLAY\" \"$VEV\"", "local:/run/local:wayland-local:"); err != nil {
98- return fmt.Errorf("direct client environment: %w", err)
99- }
100- live, err := waitForSession(ctx, catalog, target, sessionName, "up")
101- if err != nil {
102- return fmt.Errorf("live catalog: %w", err)
103- }
104- liveTarget, err := targetForSession(target, live, false)
105- if err != nil {
106- return fmt.Errorf("live target: %w", err)
107- }
108- if _, err := preview.Preview(ctx, liveTarget, 40, 8); err != nil {
109- return fmt.Errorf("live preview: %w", err)
110- }
111- if err := sendCommand(ctx, stdio, "new-tab"); err != nil {
112- return fmt.Errorf("create second tab: %w", err)
113- }
114- liveWithTabs, err := waitForSessionWithTabCount(ctx, catalog, target, sessionName, "up", 2)
115- if err != nil {
116- return fmt.Errorf("multi-tab catalog: %w", err)
117- }
118- beforeTabs := catalogue.CatalogTabs(liveWithTabs)
119- if err := sendCommand(ctx, stdio, "close-tab"); err != nil {
120- return fmt.Errorf("remove tab: %w", err)
121- }
122- after, err := waitForSessionWithTabCount(ctx, catalog, target, sessionName, "up", 1)
123- if err != nil {
124- return fmt.Errorf("removed-tab catalog: %w", err)
125- }
126- removedID, ok := removedCatalogTabID(beforeTabs, catalogue.CatalogTabs(after))
127- if !ok {
128- return errors.New("removed-tab catalog did not identify exactly one removed tab")
129- }
130- removedSession := liveWithTabs
131- removedSession.ActiveTabID = removedID
132- removedTarget, err := targetForSession(target, removedSession, false)
133- if err != nil {
134- return fmt.Errorf("removed-tab target: %w", err)
135- }
136- if _, err := preview.Preview(ctx, removedTarget, 40, 8); err == nil {
137- return errors.New("removed tab unexpectedly produced a preview")
138- }
139- survivingTarget, err := targetForSession(target, after, false)
140- if err != nil {
141- return fmt.Errorf("surviving-tab target: %w", err)
142- }
143- if _, err := preview.Preview(ctx, survivingTarget, 40, 8); err != nil {
144- return fmt.Errorf("surviving tab preview: %w", err)
145- }
146- if err := detach(ctx, stdio); err != nil {
147- return fmt.Errorf("stop session: %w", err)
148- }
149- return nil
150-}
151-
152-// runLocalPickerUnitedPhase drives the actual local picker against the SSH
153-// daemon container. It validates the seam that individual catalog, preview,
154-// and attach tests cannot: unified rows before and after selection, remote
155-// content publication on the same local client transport, local chrome
156-// composition, and continued input forwarding.
157-func runLocalPickerUnitedPhase(ctx context.Context, target string, catalog ports.RemoteCatalogClient, preview ports.RemotePreviewClient) (err error) {
158- const (
159- localSession = "local-picker"
160- remoteSession = "picker"
161- previewMarker = "VEV_REMOTE_PICKER_ACTIVE"
162- inputMarker = "VEV_REMOTE_PICKER_INPUT_OK"
163- )
164-
165- remote, err := openTransport(ctx, remoteadapter.TransportStdio, target)
166- if err != nil {
167- return fmt.Errorf("open remote picker source: %w", err)
168- }
169- remote.probe.configure("remote-picker-source")
170- ownsRemoteSession := false
171- defer func() {
172- _ = remote.Close()
173- if !ownsRemoteSession {
174- return
175- }
176- cleanup, cancel := context.WithTimeout(context.Background(), 5*time.Second)
177- defer cancel()
178- if cleanupErr := runRemoteCommand(cleanup, target, "vev", "kill", remoteSession); cleanupErr != nil {
179- err = errors.Join(err, fmt.Errorf("destroy remote picker session: %w", cleanupErr))
180- }
181- }()
182- if err := attachAndCheck(ctx, remote, protocol.Hello{
183- Intent: protocol.IntentNew, Name: remoteSession, Env: localEnvironment(), Cwd: "/tmp/remote-cwd",
184- TermEnv: "xterm-256color", TrueColor: true,
185- }); err != nil {
186- return fmt.Errorf("attach remote picker source: %w", err)
187- }
188- ownsRemoteSession = true
189- if err := sendCommand(ctx, remote, "new-tab"); err != nil {
190- return fmt.Errorf("create remote picker tab: %w", err)
191- }
192- if err := sendCommand(ctx, remote, "next-tab"); err != nil {
193- return fmt.Errorf("focus remote picker tab: %w", err)
194- }
195- if err := sendInputAndAwait(ctx, remote, "printf '"+previewMarker+"\\n'", previewMarker); err != nil {
196- return fmt.Errorf("seed remote picker preview: %w", err)
197- }
198- remoteCatalog, err := waitForSessionWithTabCount(ctx, catalog, target, remoteSession, "up", 2)
199- if err != nil {
200- return fmt.Errorf("remote picker catalog: %w", err)
201- }
202- if remoteCatalog.ActiveTabID == "" {
203- return errors.New("remote picker catalog omitted active tab")
204- }
205- tabs := catalogue.CatalogTabs(remoteCatalog)
206- if len(tabs) != 2 {
207- return errors.New("remote picker catalog did not retain both tabs")
208- }
209- selectedTarget, err := targetForSession(target, remoteCatalog, false)
210- if err != nil {
211- return fmt.Errorf("active remote picker target: %w", err)
212- }
213- if selectedTarget.LiveTabID != domain.TabStableID(remoteCatalog.ActiveTabID) {
214- return errors.New("remote picker catalog active tab did not resolve to the selected target")
215- }
216- activeTabIndex := -1
217- for i, tab := range tabs {
218- if domain.TabStableID(tab.ID) == selectedTarget.LiveTabID {
219- activeTabIndex = i
220- break
221- }
222- }
223- if activeTabIndex < 0 {
224- return errors.New("remote picker catalog active tab was absent from tab list")
225- }
226- selectedPreview, err := preview.Preview(ctx, selectedTarget, 55, 22)
227- if err != nil {
228- return fmt.Errorf("explicit remote tab preview: %w", err)
229- }
230- if !remotePreviewContains(selectedPreview, previewMarker) {
231- return errors.New("explicit remote tab preview omitted marker")
232- }
233-
234- if err := runLocalCommand(ctx, "vev", "host", "add", target); err != nil {
235- return fmt.Errorf("register remote picker host: %w", err)
236- }
237- localDaemon, err := startLocalDaemon(ctx)
238- if err != nil {
239- return fmt.Errorf("start local picker daemon: %w", err)
240- }
241- defer stopLocalDaemon(localDaemon)
242-
243- local, err := openLocalTransport(ctx)
244- if err != nil {
245- return fmt.Errorf("open local picker transport: %w", err)
246- }
247- // This is the one client-equivalent connection for the proof. Remote
248- // selection must replace its daemon-side owner without sending an endpoint
249- // or MsgAttachTarget to this transport.
250- local.probe.configure("local-picker")
251- defer func() { _ = local.Close() }()
252- if err := attachAndCheck(ctx, local, protocol.Hello{
253- Intent: protocol.IntentNew, Name: localSession, Env: localEnvironment(), Cwd: "/tmp/local-cwd",
254- TermEnv: "xterm-256color", TrueColor: true,
255- }); err != nil {
256- return fmt.Errorf("attach local picker session: %w", err)
257- }
258- if err := sendRawInput(local, "\x1b "); err != nil {
259- return fmt.Errorf("open command palette: %w", err)
260- }
261- if err := awaitOutputContains(ctx, local, "Commands"); err != nil {
262- return fmt.Errorf("await command palette: %w", err)
263- }
264- if err := sendRawInput(local, "SSP\r"); err != nil {
265- return fmt.Errorf("open session picker: %w", err)
266- }
267- if err := awaitOutputContains(ctx, local, remoteSession+"@remote"); err != nil {
268- return fmt.Errorf("await remote picker catalog row: %w", err)
269- }
270- beforeRows := capturePickerRows(local.probe)
271- if err := assertUnifiedPickerRows(beforeRows, localSession, remoteSession); err != nil {
272- return fmt.Errorf("capture unified picker rows before remote selection: %w", err)
273- }
274- // The local selected tab is first and the remote session header is not
275- // selectable, so one down reaches the first remote tab. Derive the rest
276- // from the catalog's stable active tab identity instead of assuming an
277- // ordinal target.
278- if err := sendRawInput(local, strings.Repeat("j", activeTabIndex+1)); err != nil {
279- return fmt.Errorf("select catalog active remote tab: %w", err)
280- }
281- if err := awaitRemotePickerPreview(ctx, local, previewMarker); err != nil {
282- return fmt.Errorf("remote picker preview: %w", err)
283- }
284- if err := sendRawInput(local, "\r"); err != nil {
285- return fmt.Errorf("commit remote picker target: %w", err)
286- }
287- if err := awaitOutputContains(ctx, local, remoteSession+"@remote"); err != nil {
288- return fmt.Errorf("publish remote content on local transport: %w", err)
289- }
290- if !local.probe.contains(previewMarker) {
291- return errors.New("published remote content omitted preview marker")
292- }
293- if err := assertNoDirectHandoff(local); err != nil {
294- return err
295- }
296- if err := assertNoRemoteChrome(local.probe, remoteSession); err != nil {
297- return fmt.Errorf("remote content chrome: %w", err)
298- }
299- // Split the marker so the terminal's echoed command line cannot satisfy the
300- // awaited-output assertion before the shell executes it.
301- if err := sendInputAndAwait(ctx, local, "printf '%s%s' 'VEV_REMOTE_PICKER' '_INPUT_OK'", inputMarker); err != nil {
302- return fmt.Errorf("continued input on local transport: %w", err)
303- }
304- if err := assertNoDirectHandoff(local); err != nil {
305- return err
306- }
307-
308- // Reopen the picker through the same local connection while its owner is a
309- // remote view. The navigation shell must remain local and retain both sides
310- // of the unified session list.
311- if err := sendRawInput(local, "\x1b "); err != nil {
312- return fmt.Errorf("reopen command palette from remote content: %w", err)
313- }
314- if err := awaitOutputContains(ctx, local, "Commands"); err != nil {
315- return fmt.Errorf("await reopened command palette: %w", err)
316- }
317- if err := sendRawInput(local, "SSP\r"); err != nil {
318- return fmt.Errorf("reopen session picker from remote content: %w", err)
319- }
320- if err := awaitOutputContains(ctx, local, remoteSession+"@remote"); err != nil {
321- return fmt.Errorf("await reopened remote picker row: %w", err)
322- }
323- afterRows := capturePickerRows(local.probe)
324- if err := assertUnifiedPickerRows(afterRows, localSession, remoteSession); err != nil {
325- return fmt.Errorf("capture unified picker rows after remote selection: %w", err)
326- }
327- if err := assertNoDirectHandoff(local); err != nil {
328- return err
329- }
330- if err := sendRawInput(local, "\x1b"); err != nil {
331- return fmt.Errorf("close reopened session picker: %w", err)
332- }
333- return nil
334-}
335-
336-func runRestartResumePhase(ctx context.Context, target string, catalog ports.RemoteCatalogClient, preview ports.RemotePreviewClient) (domain.RemoteSessionTarget, error) {
337- if err := runRemoteCommand(ctx, target, "vev", "kill", "--daemon"); err != nil {
338- return domain.RemoteSessionTarget{}, fmt.Errorf("stop remote daemon: %w", err)
339- }
340- if err := runRemoteCommand(ctx, target, "vev", "--daemon-launcher"); err != nil {
341- return domain.RemoteSessionTarget{}, fmt.Errorf("restart remote daemon: %w", err)
342- }
343- resumedCatalog, err := waitForSession(ctx, catalog, target, sessionName, "up")
344- if err != nil {
345- return domain.RemoteSessionTarget{}, fmt.Errorf("resumed catalog: %w", err)
346- }
347- resumedTarget, err := targetForSession(target, resumedCatalog, false)
348- if err != nil {
349- return domain.RemoteSessionTarget{}, fmt.Errorf("resumed target: %w", err)
350- }
351- resumed, err := openTransport(ctx, remoteadapter.TransportStdio, target)
352- if err != nil {
353- return domain.RemoteSessionTarget{}, fmt.Errorf("open restart stdio transport: %w", err)
354- }
355- defer func() { _ = resumed.Close() }()
356- if err := attachAndCheck(ctx, resumed, protocol.Hello{
357- Intent: protocol.IntentResume, Name: sessionName, Env: localEnvironment(), Cwd: "/tmp/local-cwd",
358- TermEnv: "xterm-256color", TrueColor: true, RemoteTarget: &resumedTarget,
359- EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
360- }); err != nil {
361- return domain.RemoteSessionTarget{}, fmt.Errorf("exact restart resume: %w", err)
362- }
363- if _, err := preview.Preview(ctx, resumedTarget, 40, 8); err != nil {
364- return domain.RemoteSessionTarget{}, fmt.Errorf("restart preview: %w", err)
365- }
366- if err := touchRemoteFlag(ctx, target, "/tmp/vev-slow-preview"); err != nil {
367- return domain.RemoteSessionTarget{}, fmt.Errorf("enable slow preview: %w", err)
368- }
369- slowCtx, slowCancel := context.WithTimeout(ctx, 150*time.Millisecond)
370- _, slowErr := preview.Preview(slowCtx, resumedTarget, 40, 8)
371- slowCancel()
372- if err := touchRemoteFlag(ctx, target, "/tmp/vev-slow-preview-off"); err != nil {
373- return domain.RemoteSessionTarget{}, fmt.Errorf("disable slow preview: %w", err)
374- }
375- if slowErr == nil {
376- return domain.RemoteSessionTarget{}, errors.New("slow preview did not honor cancellation")
377- }
378- if err := detach(ctx, resumed); err != nil {
379- return domain.RemoteSessionTarget{}, fmt.Errorf("stop resumed session: %w", err)
380- }
381- return resumedTarget, nil
382-}
383-
384-func runUDPPhase(ctx context.Context, target string) error {
385- udp, err := openTransport(ctx, remoteadapter.TransportUDP, target)
386- if err != nil {
387- return fmt.Errorf("open UDP transport: %w", err)
388- }
389- defer func() { _ = udp.Close() }()
390- if err := attachAndCheck(ctx, udp, protocol.Hello{
391- Intent: protocol.IntentNew, Name: "udp-harness", Env: localEnvironment(), Cwd: "/tmp/local-cwd",
392- TermEnv: "xterm-256color", TrueColor: true,
393- }); err != nil {
394- return fmt.Errorf("UDP attach: %w", err)
395- }
396- if err := detach(ctx, udp); err != nil {
397- return fmt.Errorf("UDP detach: %w", err)
398- }
399- return nil
400-}
401-
402-func runCatalogFailurePhase(ctx context.Context, target string, catalog ports.RemoteCatalogClient) error {
403- if err := touchRemoteFlag(ctx, target, "/tmp/vev-bad-catalog"); err != nil {
404- return fmt.Errorf("enable mismatched catalog: %w", err)
405- }
406- if _, err := catalog.List(ctx, target); err == nil {
407- return errors.New("version-mismatched catalog unexpectedly decoded")
408- }
409- if err := touchRemoteFlag(ctx, target, "/tmp/vev-bad-catalog-off"); err != nil {
410- return fmt.Errorf("disable mismatched catalog: %w", err)
411- }
412- return nil
413-}
414-
415-func runStaleTargetPhase(ctx context.Context, target string, catalog ports.RemoteCatalogClient, staleTarget domain.RemoteSessionTarget) error {
416- if err := runRemoteCommand(ctx, target, "vev", "kill", sessionName); err != nil {
417- return fmt.Errorf("destroy session for stale-target test: %w", err)
418- }
419- if err := waitForMissingSession(ctx, catalog, target, sessionName); err != nil {
420- return fmt.Errorf("wait for destroyed session: %w", err)
421- }
422- stale, err := openTransport(ctx, remoteadapter.TransportStdio, target)
423- if err != nil {
424- return fmt.Errorf("open stale-target transport: %w", err)
425- }
426- defer func() { _ = stale.Close() }()
427- if err := expectNoSuchTarget(ctx, stale, staleTarget); err != nil {
428- return fmt.Errorf("stale target fence: %w", err)
429- }
430- return nil
431-}
432-
433-func removedCatalogTabID(before, after []catalogue.RemoteCatalogTab) (string, bool) {
434- remaining := make(map[string]struct{}, len(after))
435- for _, tab := range after {
436- remaining[tab.ID] = struct{}{}
437- }
438- var removed string
439- for _, tab := range before {
440- if _, exists := remaining[tab.ID]; exists {
441- continue
442- }
443- if removed != "" {
444- return "", false
445- }
446- removed = tab.ID
447- }
448- return removed, removed != ""
449-}
450-
451-func localEnvironment() []string {
452- return []string{
453- "VEV_TEST_ENV=local",
454- "XDG_RUNTIME_DIR=/run/local",
455- "WAYLAND_DISPLAY=wayland-local",
456- "TERM=xterm-256color",
457- "COLORTERM=truecolor",
458- "TERM_PROGRAM=local-harness",
459- }
460-}
461-
462-type harnessFrame struct {
463- frame wire.Frame
464- err error
465-}
466-
467-type harnessTransport struct {
468- wire.Transport
469- probe *visualProbe
470- frames chan harnessFrame
471- queued []harnessFrame
472- stop chan struct{}
473- closeOnce sync.Once
474- closeErr error
475-}
476-
477-func newHarnessTransport(transport wire.Transport) *harnessTransport {
478- h := &harnessTransport{
479- Transport: transport,
480- probe: newVisualProbe(domain.Size{Cols: defaultProbeCols, Rows: defaultProbeRows}),
481- frames: make(chan harnessFrame, 16),
482- stop: make(chan struct{}),
483- }
484- go h.receiveLoop()
485- return h
486-}
487-
488-func (h *harnessTransport) receiveLoop() {
489- for {
490- frame, err := h.Transport.Recv()
491- select {
492- case h.frames <- harnessFrame{frame: frame, err: err}:
493- case <-h.stop:
494- return
495- }
496- if err != nil {
497- return
498- }
499- }
500-}
501-
502-func (h *harnessTransport) Close() error {
503- h.closeOnce.Do(func() {
504- close(h.stop)
505- h.closeErr = h.Transport.Close()
506- })
507- return h.closeErr
508-}
509-
510-func openTransport(ctx context.Context, mode remoteadapter.TransportMode, target string) (*harnessTransport, error) {
511- var transport wire.Transport
512- var err error
513- switch mode {
514- case remoteadapter.TransportStdio:
515- transport, err = sshstdio.DialContext(ctx, target, "", slog.New(slog.DiscardHandler))
516- case remoteadapter.TransportUDP:
517- dialer := dgram.NewRemoteDialerWithLogger(target, "", slog.New(slog.DiscardHandler))
518- dialer.BootstrapTimeout = 8 * time.Second
519- dialer.ProbeTimeout = 3 * time.Second
520- transport, err = dialer.Dial(ctx)
521- default:
522- return nil, fmt.Errorf("unsupported transport %q", mode)
523- }
524- if err != nil {
525- return nil, err
526- }
527- h := newHarnessTransport(transport)
528- h.probe.configure(string(mode))
529- if artifact := harnessArtifactFromContext(ctx); artifact != nil {
530- artifact.registerProbe(h.probe)
531- }
532- return h, nil
533-}
534-
535-const commandStderrLimit = 4 << 10
536-
537-type boundedStderr struct {
538- bytes.Buffer
539- truncated bool
540-}
541-
542-func (b *boundedStderr) Write(p []byte) (int, error) {
543- written := len(p)
544- remaining := commandStderrLimit - b.Len()
545- if remaining <= 0 {
546- b.truncated = b.truncated || written != 0
547- return written, nil
548- }
549- if len(p) > remaining {
550- p = p[:remaining]
551- b.truncated = true
552- }
553- _, _ = b.Buffer.Write(p)
554- return written, nil
555-}
556-
557-func (b *boundedStderr) String() string {
558- stderr := b.Buffer.String()
559- if b.truncated {
560- stderr += "\n[stderr truncated]"
561- }
562- return stderr
563-}
564-
565-func commandErrorWithStderr(err error, stderr string) error {
566- if stderr = strings.TrimSpace(stderr); stderr == "" {
567- return err
568- }
569- return fmt.Errorf("%w: stderr: %s", err, stderr)
570-}
571-
572-func startLocalDaemon(ctx context.Context) (*exec.Cmd, error) {
573- cmd := exec.CommandContext(ctx, "vev", "--daemon")
574- stderr := &boundedStderr{}
575- cmd.Stdout = io.Discard
576- cmd.Stderr = stderr
577- if err := cmd.Start(); err != nil {
578- return nil, err
579- }
580-
581- deadline, cancel := context.WithTimeout(ctx, 5*time.Second)
582- defer cancel()
583- for {
584- transport, err := ipc.DialContext(deadline, ipc.SocketDir())
585- if err == nil {
586- _ = transport.Close()
587- return cmd, nil
588- }
589- select {
590- case <-deadline.Done():
591- stopLocalProcess(cmd)
592- return nil, commandErrorWithStderr(deadline.Err(), stderr.String())
593- case <-time.After(25 * time.Millisecond):
594- }
595- }
596-}
597-
598-func stopLocalDaemon(cmd *exec.Cmd) {
599- cleanup, cancel := context.WithTimeout(context.Background(), 5*time.Second)
600- defer cancel()
601- _ = runLocalCommand(cleanup, "vev", "kill", "--daemon")
602- stopLocalProcess(cmd)
603-}
604-
605-func stopLocalProcess(cmd *exec.Cmd) {
606- if cmd == nil || cmd.Process == nil {
607- return
608- }
609- _ = cmd.Process.Kill()
610- _ = cmd.Wait()
611-}
612-
613-func runLocalCommand(ctx context.Context, name string, args ...string) error {
614- return runCapturedCommand(ctx, name, args...)
615-}
616-
617-func runCapturedCommand(ctx context.Context, name string, args ...string) error {
618- cmd := exec.CommandContext(ctx, name, args...)
619- stderr := &boundedStderr{}
620- cmd.Stdout = io.Discard
621- cmd.Stderr = stderr
622- if err := cmd.Run(); err != nil {
623- return commandErrorWithStderr(err, stderr.String())
624- }
625- return nil
626-}
627-
628-func openLocalTransport(ctx context.Context) (*harnessTransport, error) {
629- transport, err := ipc.DialContext(ctx, ipc.SocketDir())
630- if err != nil {
631- return nil, err
632- }
633- h := newHarnessTransport(transport)
634- h.probe.configure("local")
635- if artifact := harnessArtifactFromContext(ctx); artifact != nil {
636- artifact.registerProbe(h.probe)
637- }
638- return h, nil
639-}
640-
641-func attachAndCheck(ctx context.Context, tr *harnessTransport, hello protocol.Hello) error {
642- hello.Version = protocol.Version
643- hello.Size = domain.Size{Cols: 80, Rows: 24}
644- if hello.EnvironmentPolicy == 0 {
645- hello.EnvironmentPolicy = protocol.EnvironmentPolicyClientOwned
646- }
647- payload := wire.MarshalHello(hello)
648- if err := tr.Send(wire.Frame{Type: wire.MsgHello, Payload: payload}); err != nil {
649- return err
650- }
651- frame, err := receiveWithTimeout(ctx, tr)
652- if err != nil {
653- return err
654- }
655- if frame.Type == wire.MsgError {
656- message, decodeErr := wire.UnmarshalErrorMsg(frame.Payload)
657- if decodeErr != nil {
658- return decodeErr
659- }
660- return fmt.Errorf("remote error code %d", message.Code)
661- }
662- if frame.Type != wire.MsgWelcome {
663- return fmt.Errorf("welcome frame type %d", frame.Type)
664- }
665- tr.probe.recordControl(probeEventWelcome)
666- if err := tr.Send(wire.Frame{Type: wire.MsgTheme, Payload: wire.MarshalTheme(protocol.Theme{TrueColor: hello.TrueColor, SchemeKnown: true})}); err != nil {
667- return fmt.Errorf("send initial theme: %w", err)
668- }
669- return nil
670-}
671-
672-func sendRawInput(tr *harnessTransport, data string) error {
673- return tr.Send(wire.Frame{Type: wire.MsgInput, Payload: wire.MarshalInput(protocol.Input{Data: []byte(data)})})
674-}
675-
676-func processOutputFrame(tr *harnessTransport, frame wire.Frame) error {
677- output, err := wire.UnmarshalOutput(frame.Payload)
678- if err != nil {
679- return err
680- }
681- result := tr.probe.apply(output)
682- if !result.Accepted {
683- if !tr.probe.resetPending {
684- if err := tr.Send(wire.Frame{Type: wire.MsgOutputResetRequest, Payload: wire.MarshalOutputResetRequest(protocol.OutputResetRequest{})}); err != nil {
685- return err
686- }
687- tr.probe.resetPending = true
688- tr.probe.recordControl(probeEventOutputReset)
689- }
690- return nil
691- }
692- if !result.StateBearing {
693- return nil
694- }
695- payload, err := wire.MarshalAck(result.Ack)
696- if err != nil {
697- return err
698- }
699- if err := tr.Send(wire.Frame{Type: wire.MsgAck, Payload: payload}); err != nil {
700- return err
701- }
702- tr.probe.resetPending = false
703- tr.probe.markAcked(result)
704- return nil
705-}
706-
707-func awaitOutputContains(ctx context.Context, tr *harnessTransport, want string) error {
708- deadline, cancel := context.WithTimeout(ctx, 5*time.Second)
709- defer cancel()
710- visible := tr.probe.contains(want)
711- if visible {
712- return nil
713- }
714- for {
715- frame, err := receiveWithTimeout(deadline, tr)
716- if err != nil {
717- return err
718- }
719- if frame.Type != wire.MsgOutput {
720- continue
721- }
722- if err := processOutputFrame(tr, frame); err != nil {
723- return err
724- }
725- nowVisible := tr.probe.contains(want)
726- if !visible && nowVisible {
727- return nil
728- }
729- visible = nowVisible
730- }
731-}
732-
733-func awaitRemotePickerPreview(ctx context.Context, tr *harnessTransport, marker string) error {
734- deadline, cancel := context.WithTimeout(ctx, 5*time.Second)
735- defer cancel()
736- sawLoading := false
737- markerVisible := tr.probe.contains(marker)
738- for {
739- frame, err := receiveWithTimeout(deadline, tr)
740- if err != nil {
741- return fmt.Errorf("no rendered remote preview after %d output frames (loading=%t): %w", tr.probe.outputFrames, sawLoading, err)
742- }
743- if frame.Type != wire.MsgOutput {
744- continue
745- }
746- if err := processOutputFrame(tr, frame); err != nil {
747- return err
748- }
749- text := tr.probe.text()
750- sawLoading = sawLoading || strings.Contains(text, "loading remote preview")
751- if strings.Contains(text, "remote preview unavailable") {
752- return errors.New("remote preview unavailable")
753- }
754- nowMarkerVisible := strings.Contains(text, marker)
755- if !markerVisible && nowMarkerVisible {
756- return nil
757- }
758- markerVisible = nowMarkerVisible
759- }
760-}
761-
762-func remotePreviewContains(preview protocol.RemotePreview, want string) bool {
763- var text strings.Builder
764- for _, cell := range preview.Cells {
765- if !cell.Continuation {
766- text.WriteRune(cell.Rune)
767- }
768- }
769- return strings.Contains(text.String(), want)
770-}
771-
772-func sendInputAndAwait(ctx context.Context, tr *harnessTransport, command, want string) error {
773- if err := tr.Send(wire.Frame{Type: wire.MsgInput, Payload: wire.MarshalInput(protocol.Input{Data: []byte(command + "\n")})}); err != nil {
774- return err
775- }
776- deadline, cancel := context.WithTimeout(ctx, 5*time.Second)
777- defer cancel()
778- visible := tr.probe.contains(want)
779- for {
780- frame, err := receiveWithTimeout(deadline, tr)
781- if err != nil {
782- return fmt.Errorf("waiting for marker after %d output frames/%d bytes: %w", tr.probe.outputFrames, tr.probe.outputBytes, err)
783- }
784- if frame.Type != wire.MsgOutput {
785- continue
786- }
787- if err := processOutputFrame(tr, frame); err != nil {
788- return fmt.Errorf("process output: %w", err)
789- }
790- nowVisible := tr.probe.contains(want)
791- if !visible && nowVisible {
792- return nil
793- }
794- visible = nowVisible
795- }
796-}
797-
798-func sendCommand(ctx context.Context, tr *harnessTransport, slug string) error {
799- id := requestID.Add(1)
800- request := protocol.CommandRequest{
801- Version: protocol.Version,
802- RequestID: id,
803- Attached: true,
804- Slug: slug,
805- }
806- payload, err := wire.MarshalCommandRequest(request)
807- if err != nil {
808- return err
809- }
810- if err := tr.Send(wire.Frame{Type: wire.MsgCommand, Payload: payload}); err != nil {
811- return err
812- }
813- deadline, cancel := context.WithTimeout(ctx, 5*time.Second)
814- defer cancel()
815- for {
816- frame, err := receiveWithTimeout(deadline, tr)
817- if err != nil {
818- return err
819- }
820- if frame.Type == wire.MsgOutput {
821- if err := processOutputFrame(tr, frame); err != nil {
822- return err
823- }
824- continue
825- }
826- if frame.Type != wire.MsgCommandResult {
827- continue
828- }
829- result, err := wire.UnmarshalCommandResult(frame.Payload)
830- if err != nil {
831- return err
832- }
833- if !result.OK {
834- return fmt.Errorf("remote command failed (code %d): %s", result.Code, result.Text)
835- }
836- return nil
837- }
838-}
839-
840-func detach(ctx context.Context, tr *harnessTransport) error {
841- if err := tr.Send(wire.Frame{Type: wire.MsgDetach, Payload: wire.MarshalDetach(protocol.Detach{})}); err != nil {
842- return err
843- }
844- deadline, cancel := context.WithTimeout(ctx, 5*time.Second)
845- defer cancel()
846- for {
847- frame, err := receiveWithTimeout(deadline, tr)
848- if err != nil {
849- return err
850- }
851- if frame.Type == wire.MsgDetached {
852- return nil
853- }
854- }
855-}
856-
857-func expectNoSuchTarget(ctx context.Context, tr *harnessTransport, target domain.RemoteSessionTarget) error {
858- target.Endpoint = targetEndpoint(target.Endpoint)
859- target.DisplayOrigin = "remote"
860- target.SessionName = sessionName
861- if err := target.Validate(); err != nil {
862- return err
863- }
864- hello := protocol.Hello{
865- Version: protocol.Version,
866- Intent: protocol.IntentResume,
867- Name: sessionName,
868- Size: domain.Size{Cols: 80, Rows: 24},
869- TermEnv: "xterm-256color",
870- TrueColor: true,
871- Env: localEnvironment(),
872- Cwd: "/tmp/local-cwd",
873- RemoteTarget: &target,
874- EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
875- }
876- payload := wire.MarshalHello(hello)
877- if err := tr.Send(wire.Frame{Type: wire.MsgHello, Payload: payload}); err != nil {
878- return err
879- }
880- frame, err := receiveWithTimeout(ctx, tr)
881- if err != nil {
882- return err
883- }
884- if frame.Type != wire.MsgError {
885- return fmt.Errorf("stale target returned frame type %d", frame.Type)
886- }
887- message, err := wire.UnmarshalErrorMsg(frame.Payload)
888- if err != nil {
889- return err
890- }
891- if message.Code != protocol.ErrNoSuchTarget {
892- return fmt.Errorf("stale target returned error code %d", message.Code)
893- }
894- return nil
895-}
896-
897-func targetForSession(endpoint string, session catalogue.RemoteCatalogSession, stopped bool) (domain.RemoteSessionTarget, error) {
898- tabs := catalogue.CatalogTabs(session)
899- if len(tabs) == 0 {
900- return domain.RemoteSessionTarget{}, errors.New("catalog session has no tabs")
901- }
902- tab := tabs[0]
903- if !stopped && session.ActiveTabID != "" {
904- for _, candidate := range tabs {
905- if candidate.ID == session.ActiveTabID {
906- tab = candidate
907- break
908- }
909- }
910- }
911- target := domain.RemoteSessionTarget{
912- Endpoint: endpoint,
913- DisplayOrigin: "remote",
914- LifecycleID: session.LifecycleID,
915- SessionName: session.Name,
916- Stopped: stopped,
917- }
918- if stopped {
919- target.StoppedTab = domain.NewStableTabSelector(domain.TabStableID(tab.ID))
920- } else {
921- target.LiveTabID = domain.TabStableID(tab.ID)
922- }
923- if err := target.Validate(); err != nil {
924- return domain.RemoteSessionTarget{}, err
925- }
926- return target, nil
927-}
928-
929-func targetEndpoint(endpoint string) string {
930- if endpoint == "" {
931- return defaultTarget
932- }
933- return endpoint
934-}
935-
936-func waitForSession(ctx context.Context, catalog ports.RemoteCatalogClient, endpoint, name string, state catalogue.RemoteCatalogSessionState) (catalogue.RemoteCatalogSession, error) {
937- return waitForSessionWithTabCount(ctx, catalog, endpoint, name, state, 0)
938-}
939-
940-func waitForSessionWithTabCount(ctx context.Context, catalog ports.RemoteCatalogClient, endpoint, name string, state catalogue.RemoteCatalogSessionState, tabs int) (catalogue.RemoteCatalogSession, error) {
941- deadline, cancel := context.WithTimeout(ctx, 10*time.Second)
942- defer cancel()
943- for {
944- result, err := catalog.List(deadline, endpoint)
945- if err == nil {
946- for _, session := range result.Sessions {
947- if session.Name == name && session.State == state && (tabs == 0 || len(catalogue.CatalogTabs(session)) == tabs) {
948- return session, nil
949- }
950- }
951- }
952- select {
953- case <-deadline.Done():
954- if err != nil {
955- return catalogue.RemoteCatalogSession{}, err
956- }
957- return catalogue.RemoteCatalogSession{}, fmt.Errorf("session %q did not reach state %q", name, state)
958- case <-time.After(100 * time.Millisecond):
959- }
960- }
961-}
962-
963-func waitForMissingSession(ctx context.Context, catalog ports.RemoteCatalogClient, endpoint, name string) error {
964- deadline, cancel := context.WithTimeout(ctx, 10*time.Second)
965- defer cancel()
966- for {
967- result, err := catalog.List(deadline, endpoint)
968- if err == nil {
969- found := false
970- for _, session := range result.Sessions {
971- found = found || session.Name == name
972- }
973- if !found {
974- return nil
975- }
976- }
977- select {
978- case <-deadline.Done():
979- return errors.New("destroyed session remained in catalog")
980- case <-time.After(100 * time.Millisecond):
981- }
982- }
983-}
984-
985-func touchRemoteFlag(ctx context.Context, target, path string) error {
986- if strings.HasSuffix(path, "-off") {
987- path = strings.TrimSuffix(path, "-off")
988- return runRemoteCommand(ctx, target, "rm", "-f", path)
989- }
990- return runRemoteCommand(ctx, target, "touch", path)
991-}
992-
993-func runRemoteCommand(ctx context.Context, target string, command ...string) error {
994- spec := sshstdio.BuildCommandForRemoteCommand(target, command...)
995- return runCapturedCommand(ctx, spec.Path, spec.Args...)
996-}
997-
998-func assertNoDirectHandoff(tr *harnessTransport) error {
999- if tr == nil || tr.probe == nil {
1000- return errors.New("local picker transport has no visual probe")
1001- }
1002- // Inspect only the frames already queued at assertion start, retaining
1003- // non-handoff frames for the next receiveWithTimeout call.
1004- queued := len(tr.frames)
1005- for range queued {
1006- outcome := <-tr.frames
1007- if outcome.frame.Type == wire.MsgAttachTarget {
1008- tr.probe.recordIncoming(outcome.frame)
1009- } else {
1010- tr.queued = append(tr.queued, outcome)
1011- }
1012- }
1013- if tr.probe.unexpectedHandoffs != 0 {
1014- return fmt.Errorf("picker emitted %d direct MsgAttachTarget handoff frame(s)", tr.probe.unexpectedHandoffs)
1015- }
1016- return nil
1017-}
1018-
1019-func receiveWithTimeout(ctx context.Context, tr *harnessTransport) (wire.Frame, error) {
1020- if len(tr.queued) != 0 {
1021- outcome := tr.queued[0]
1022- tr.queued = tr.queued[1:]
1023- if outcome.err == nil {
1024- tr.probe.recordIncoming(outcome.frame)
1025- }
1026- return outcome.frame, outcome.err
1027- }
1028- select {
1029- case <-ctx.Done():
1030- return wire.Frame{}, ctx.Err()
1031- case outcome := <-tr.frames:
1032- if outcome.err == nil {
1033- tr.probe.recordIncoming(outcome.frame)
1034- }
1035- return outcome.frame, outcome.err
1036- }
1037-}
D scripts/remote-picker-harness/main_test.go +0 -65
@@ -1,65 +0,0 @@
1-package main
2-
3-import (
4- "errors"
5- "strings"
6- "testing"
7-
8- "github.com/bnema/vev/internal/protocol/wire"
9-)
10-
11-func TestBoundedStderrRetainsAtMostDiagnosticLimit(t *testing.T) {
12- stderr := &boundedStderr{}
13- input := strings.Repeat("x", commandStderrLimit+1)
14-
15- written, err := stderr.Write([]byte(input))
16-
17- if err != nil {
18- t.Fatal(err)
19- }
20- if written != len(input) {
21- t.Fatalf("written = %d, want %d", written, len(input))
22- }
23- if stderr.Len() != commandStderrLimit {
24- t.Fatalf("stderr length = %d, want %d", stderr.Len(), commandStderrLimit)
25- }
26- if !strings.Contains(stderr.String(), "[stderr truncated]") {
27- t.Fatalf("stderr = %q, want truncation marker", stderr.String())
28- }
29-}
30-
31-func TestCommandErrorWithStderr(t *testing.T) {
32- cause := errors.New("command failed")
33- if got := commandErrorWithStderr(cause, " daemon could not start\n"); !errors.Is(got, cause) || !strings.Contains(got.Error(), "stderr: daemon could not start") {
34- t.Fatalf("command error = %v", got)
35- }
36- if got := commandErrorWithStderr(cause, "\n"); got != cause {
37- t.Fatalf("empty stderr error = %v, want original error", got)
38- }
39-}
40-
41-func TestRunCapturedCommandIncludesStderr(t *testing.T) {
42- err := runCapturedCommand(t.Context(), "sh", "-c", "printf 'command diagnostics\\n' >&2; exit 1")
43- if err == nil || !strings.Contains(err.Error(), "stderr: command diagnostics") {
44- t.Fatalf("command error = %v", err)
45- }
46-}
47-
48-func TestAssertNoDirectHandoffProcessesInitialFramesAndRetainsOutput(t *testing.T) {
49- tr := &harnessTransport{
50- probe: &visualProbe{},
51- frames: make(chan harnessFrame, 2),
52- }
53- tr.frames <- harnessFrame{frame: wire.Frame{Type: wire.MsgOutput}}
54- tr.frames <- harnessFrame{frame: wire.Frame{Type: wire.MsgAttachTarget}}
55-
56- if err := assertNoDirectHandoff(tr); err == nil {
57- t.Fatal("direct handoff was not rejected")
58- }
59- if len(tr.queued) != 1 || tr.queued[0].frame.Type != wire.MsgOutput {
60- t.Fatalf("queued frames = %#v, want one retained output", tr.queued)
61- }
62- if tr.probe.unexpectedHandoffs != 1 {
63- t.Fatalf("unexpected handoffs = %d, want 1", tr.probe.unexpectedHandoffs)
64- }
65-}
A scripts/remote-picker-harness/navigation_repro.py +81 -0
@@ -0,0 +1,81 @@
1+#!/usr/bin/env python3
2+"""UI-driver regression against two disposable demo-image containers.
3+
4+Pass client and remote container names. The client must have a pinned SSH
5+host named remote reaching the remote container. No host session is touched.
6+"""
7+
8+import json
9+import subprocess
10+import sys
11+import uuid
12+
13+
14+class Driver:
15+ def __init__(self, container, session):
16+ self.process = subprocess.Popen(
17+ ["docker", "exec", "-i", container, "vev", "--ui-driver",
18+ "--session", session, "--cols", "100", "--rows", "30"],
19+ stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True,
20+ )
21+ ready = json.loads(self.process.stdout.readline())["result"]
22+ self.attachment = ready["attachment"]
23+ self.generation = ready["generation"]
24+ self.request_id = 0
25+
26+ def call(self, operation, **fields):
27+ self.request_id += 1
28+ request = dict(version=1, id=self.request_id, op=operation,
29+ attachment=self.attachment, **fields)
30+ if operation in ("keys", "text"):
31+ request["generation"] = self.generation
32+ self.process.stdin.write(json.dumps(request) + "\n")
33+ self.process.stdin.flush()
34+ response = json.loads(self.process.stdout.readline())
35+ assert "error" not in response, response
36+ result = response["result"]
37+ self.generation = result.get("context", {}).get("generation", self.generation)
38+ return result
39+
40+ def palette(self, query, label):
41+ self.call("keys", keys=["Alt+Space"])
42+ self.call("text", text=query)
43+ self.call("wait", expect={"text_contains": label}, timeout_ms=10000)
44+ return self.call("capture")["text"]
45+
46+ def close(self):
47+ self.process.stdin.close()
48+ self.process.wait(timeout=10)
49+
50+
51+def run(client_container, remote_container):
52+ suffix = uuid.uuid4().hex[:8]
53+ first, second, remote = (name + suffix for name in ("sample", "second", "other"))
54+ fixture = Driver(remote_container, remote)
55+ fixture.close()
56+ driver = Driver(client_container, first)
57+ try:
58+ initial = driver.call("capture")["context"]["session"]
59+ fixture = Driver(client_container, second)
60+ fixture.close()
61+ driver.palette(second, "Switch to session " + second + "@local")
62+ driver.call("keys", keys=["Enter"])
63+ driver.palette(remote, "Switch to session " + remote + "@remote")
64+ entered = driver.call("keys", keys=["Enter"])
65+ assert entered["context"]["session"]["session_name"] == remote
66+ label = "Switch to session " + first + "@local"
67+ screen = driver.palette(first, label)
68+ assert screen.count(label) == 1, screen
69+ assert "Switch to session " + first + " " not in screen, screen
70+ selected = driver.call("keys", keys=["Enter"])
71+ assert selected["context"]["session"] == initial, selected
72+ driver.call("wait", expect={"session": initial}, timeout_ms=10000)
73+ print("PASS: exact local return, one client-qualified row, committed UI action")
74+ finally:
75+ driver.close()
76+
77+
78+if __name__ == "__main__":
79+ if len(sys.argv) != 3:
80+ raise SystemExit("usage: navigation_repro.py CLIENT_CONTAINER REMOTE_CONTAINER")
81+ run(*sys.argv[1:])
M scripts/remote-picker-harness/run.sh +37 -108
@@ -1,63 +1,29 @@
11 #!/usr/bin/env bash
2+# Disposable navigation acceptance with the real client, daemons and UI driver.
23 set -euo pipefail
3-
4-usage() {
5- cat >&2 <<'USAGE'
6-Usage: scripts/remote-picker-harness/run.sh
7-
8-Builds the current vev binary into a disposable two-container environment and
9-runs the unified same-connection local-picker proof plus SSH-stdio, UDP,
10-catalog, preview, lifecycle-fence, and environment acceptance checks. Docker is
11-invoked normally,
12-so DOCKER_HOST and the active
13-Docker context select the daemon; no socket path is assumed.
14-
15-Environment:
16- DOCKER_HOST Docker endpoint, when the active context does not
17- already select the rootless daemon.
18- VEV_HARNESS_BASE_IMAGE Base image for the disposable containers
19- (default: ubuntu:24.04).
20- VEV_HARNESS_ARTIFACT_DIR Host directory for the bounded probe artifact.
21- The JSON report is copied there before container
22- cleanup; no report is written when unset.
23-USAGE
24-}
25-
264 case "${1:-}" in
27- -h|--help)
28- usage
29- exit 0
30- ;;
31- "") ;;
32- *)
33- usage
34- exit 2
35- ;;
5+ -h|--help) printf 'Usage: scripts/remote-picker-harness/run.sh\nRequires Docker, Python 3 and ssh-keygen.\n'; exit 0 ;;
6+ '') ;;
7+ *) exit 2 ;;
368 esac
37-
389 repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
39-base_image="${VEV_HARNESS_BASE_IMAGE:-ubuntu:24.04}"
40-artifact_dir="${VEV_HARNESS_ARTIFACT_DIR:-}"
41-if [ -n "$artifact_dir" ]; then
42- umask 077
43- mkdir -p "$artifact_dir"
44-fi
10+for tool in docker python3 ssh-keygen; do command -v "$tool" >/dev/null; done
11+docker info >/dev/null
4512 run_id="$(date +%s)-$$"
46-image="vev-remote-picker-harness:${run_id}"
47-network="vev-remote-picker-harness-${run_id}"
13+image="vev-acceptance:${run_id}"
14+network="vev-acceptance-${run_id}"
4815 local_container="${network}-local"
4916 remote_container="${network}-remote"
50-context_dir="$(mktemp -d "${TMPDIR:-/tmp}/vev-remote-picker-harness.XXXXXX")"
51-
17+context_dir="$(mktemp -d)"
5218 cleanup() {
5319 status=$?
5420 trap - EXIT INT TERM
5521 set +e
5622 if [ "$status" -ne 0 ]; then
57- printf 'remote picker harness: local container logs:\n' >&2
58- docker logs "$local_container" >&2
59- printf 'remote picker harness: remote container logs:\n' >&2
60- docker logs "$remote_container" >&2
23+ for container in "$local_container" "$remote_container"; do
24+ docker logs "$container" >&2
25+ docker exec "$container" sh -c 'tail -n 40 ~/.local/state/vev/vev-daemon.log' >&2
26+ done
6127 fi
6228 docker rm -f "$local_container" "$remote_container" >/dev/null 2>&1
6329 docker network rm "$network" >/dev/null 2>&1
@@ -65,68 +31,31 @@ cleanup() {
6531 rm -rf "$context_dir"
6632 exit "$status"
6733 }
68-trap cleanup EXIT INT TERM
69-
70-command -v docker >/dev/null || { echo 'remote picker harness: docker is required' >&2; exit 127; }
71-command -v ssh-keygen >/dev/null || { echo 'remote picker harness: ssh-keygen is required' >&2; exit 127; }
34+trap cleanup EXIT
35+trap 'exit 130' INT
36+trap 'exit 143' TERM
7237
73-if ! docker info >/dev/null 2>&1; then
74- echo 'remote picker harness: Docker is unavailable through the active DOCKER_HOST/context' >&2
75- exit 1
76-fi
77-
78-printf 'remote picker harness: building %s\n' "$image"
79-docker pull "$base_image" >/dev/null
80-image_arch="$(docker image inspect "$base_image" --format '{{.Architecture}}')"
81-GOOS=linux GOARCH="$image_arch" CGO_ENABLED=0 go build -o "$context_dir/vev" "$repo_root"
82-GOOS=linux GOARCH="$image_arch" CGO_ENABLED=0 go build -o "$context_dir/harness" "$repo_root/scripts/remote-picker-harness"
83-ssh-keygen -q -t ed25519 -N '' -f "$context_dir/id_ed25519"
84-cp "$repo_root/scripts/remote-picker-harness/Dockerfile" "$context_dir/Dockerfile"
85-cp "$repo_root/scripts/remote-picker-harness/vev-wrapper" "$context_dir/vev-wrapper"
86-printf '%s\n' 'id_ed25519' > "$context_dir/.dockerignore"
87-chmod 0600 "$context_dir/id_ed25519"
88-
89-docker build --build-arg "BASE_IMAGE=$base_image" -t "$image" "$context_dir" >/dev/null
38+docker build -f "$repo_root/scripts/demo/Dockerfile" -t "$image" "$repo_root" >/dev/null
9039 docker network create "$network" >/dev/null
91-
92-docker run -d --name "$remote_container" \
93- --network "$network" --network-alias remote \
94- -e VEV_UDP_PORT_RANGE=61000 \
95- "$image" >/dev/null
96-
97-docker create --name "$local_container" \
98- --network "$network" --network-alias local \
99- "$image" sleep infinity >/dev/null
100-docker cp "$context_dir/id_ed25519" "$local_container:/home/test/.ssh/id_ed25519"
101-docker start "$local_container" >/dev/null
102-docker exec "$local_container" chown test:test /home/test/.ssh/id_ed25519
103-docker exec "$local_container" chmod 0600 /home/test/.ssh/id_ed25519
104-
105-printf 'remote picker harness: waiting for SSH service\n'
106-for _ in $(seq 1 60); do
107- if docker exec --user test -e HOME=/home/test "$local_container" ssh -o BatchMode=yes -o ConnectTimeout=1 test@remote true >/dev/null 2>&1; then
108- break
109- fi
110- sleep 0.2
40+for role in local remote; do
41+ docker run -d --name "${network}-${role}" --network "$network" \
42+ --network-alias "$role" --entrypoint sleep "$image" infinity >/dev/null
11143 done
112-if ! docker exec --user test -e HOME=/home/test "$local_container" ssh -o BatchMode=yes -o ConnectTimeout=2 test@remote true >/dev/null 2>&1; then
113- echo 'remote picker harness: SSH service did not become ready' >&2
114- exit 1
115-fi
11644
117-printf 'remote picker harness: running acceptance checks\n'
118-harness_status=0
119-if [ -n "$artifact_dir" ]; then
120- docker exec --user test -e HOME=/home/test -e VEV_HARNESS_ARTIFACT_DIR=/tmp/vev-harness-artifact "$local_container" /usr/local/bin/remote-picker-harness || harness_status=$?
121- if ! docker cp "$local_container:/tmp/vev-harness-artifact/remote-picker-harness.json" "$artifact_dir/remote-picker-harness.json"; then
122- echo "remote picker harness: failed to collect probe artifact (acceptance status $harness_status)" >&2
123- if [ "$harness_status" -ne 0 ]; then
124- exit "$harness_status"
125- fi
126- exit 1
127- fi
128- chmod 0600 "$artifact_dir/remote-picker-harness.json"
129-else
130- docker exec --user test -e HOME=/home/test "$local_container" /usr/local/bin/remote-picker-harness || harness_status=$?
131-fi
132-exit "$harness_status"
45+# Only generated fixture credentials enter these containers; no host HOME or
46+# sockets are mounted. Pin the fresh remote key instead of trusting keyscan.
47+ssh-keygen -q -t ed25519 -N '' -C acceptance-client -f "$context_dir/client"
48+ssh-keygen -q -t ed25519 -N '' -C acceptance-host -f "$context_dir/host"
49+docker exec --user root "$remote_container" sh -c 'rm -f /etc/ssh/ssh_host_* /home/demo/.ssh/id_ed25519*; mkdir -p /run/sshd'
50+docker cp "$context_dir/host" "$remote_container:/etc/ssh/ssh_host_ed25519_key"
51+docker cp "$context_dir/host.pub" "$remote_container:/etc/ssh/ssh_host_ed25519_key.pub"
52+docker cp "$context_dir/client.pub" "$remote_container:/home/demo/.ssh/authorized_keys"
53+docker exec --user root "$remote_container" sh -c 'chmod 600 /etc/ssh/ssh_host_ed25519_key /home/demo/.ssh/authorized_keys; chown demo:demo /home/demo/.ssh/authorized_keys; /usr/sbin/sshd'
54+docker cp "$context_dir/client" "$local_container:/home/demo/.ssh/id_ed25519"
55+docker cp "$context_dir/client.pub" "$local_container:/home/demo/.ssh/id_ed25519.pub"
56+{ printf 'remote '; cut -d' ' -f1-2 "$context_dir/host.pub"; } > "$context_dir/known_hosts"
57+docker cp "$context_dir/known_hosts" "$local_container:/home/demo/.ssh/known_hosts"
58+docker exec --user root "$local_container" sh -c 'chown demo:demo /home/demo/.ssh/id_ed25519* /home/demo/.ssh/known_hosts; chmod 600 /home/demo/.ssh/id_ed25519 /home/demo/.ssh/known_hosts'
59+docker exec "$local_container" ssh -o BatchMode=yes -o ConnectTimeout=5 remote true
60+docker exec "$local_container" vev host add remote
61+python3 "$repo_root/scripts/remote-picker-harness/navigation_repro.py" "$local_container" "$remote_container"
D scripts/remote-picker-harness/vev-wrapper +0 -27
@@ -1,27 +0,0 @@
1-#!/bin/sh
2-set -eu
3-
4-export XDG_STATE_HOME=/tmp/vev-state
5-export XDG_RUNTIME_DIR=/tmp/vev-runtime
6-export VEV_TEST_ENV=remote
7-export WAYLAND_DISPLAY=wayland-remote
8-export TERM=xterm-256color
9-export COLORTERM=truecolor
10-
11-
12-case "$*" in
13- *remote-catalog*)
14- if [ -e /tmp/vev-bad-catalog ]; then
15- # Keep zero invalid so this fixture cannot become a valid protocol version.
16- printf '%s\n' '{"protocol_version":0,"schema_version":1,"sessions":[]}'
17- exit 0
18- fi
19- ;;
20- *_remote-preview*)
21- if [ -e /tmp/vev-slow-preview ]; then
22- sleep 3
23- fi
24- ;;
25-esac
26-
27-exec /usr/local/bin/vev.real "$@"
D scripts/remote-picker-harness/visual_probe.go +0 -482
@@ -1,482 +0,0 @@
1-package main
2-
3-import (
4- "context"
5- "encoding/json"
6- "errors"
7- "fmt"
8- "os"
9- "path/filepath"
10- "strings"
11-
12- vt "github.com/bnema/vev-vt"
13- "github.com/bnema/vev/internal/domain"
14- "github.com/bnema/vev/internal/protocol"
15- "github.com/bnema/vev/internal/protocol/wire"
16-)
17-
18-const (
19- defaultProbeCols = 80
20- defaultProbeRows = 24
21-
22- maxProbeEvents = 128
23- maxProbeCheckpoints = 16
24- maxArtifactBytes = 64 << 10
25-
26- probeEventOutput = "output"
27- probeEventWelcome = "welcome"
28- probeEventOutputReset = "output_reset_request"
29- probeEventUnexpectedHandoff = "unexpected_attach_target"
30-)
31-
32-// visualOutputState mirrors the client's output dependency chain. A probe may
33-// inspect output only after the frame's epoch, base, and view revision continue
34-// the last accepted state.
35-type visualOutputState struct {
36- epoch uint64
37- state uint64
38- viewRevision uint64
39- initialized bool
40-}
41-
42-func (s visualOutputState) next(output protocol.Output) (visualOutputState, bool) {
43- if output.Epoch == 0 {
44- return visualOutputState{}, false
45- }
46- if !s.initialized {
47- if output.New == 0 {
48- if output.Base != 0 || output.Full {
49- return visualOutputState{}, false
50- }
51- return s, true
52- }
53- if output.Base != 0 || !output.Full {
54- return visualOutputState{}, false
55- }
56- return visualOutputState{epoch: output.Epoch, state: output.New, viewRevision: output.ViewRevision, initialized: true}, true
57- }
58- if output.Epoch < s.epoch {
59- return visualOutputState{}, false
60- }
61- if output.Epoch == s.epoch {
62- if output.ViewRevision != s.viewRevision {
63- return visualOutputState{}, false
64- }
65- if output.New == 0 {
66- if output.Base != 0 || output.Full {
67- return visualOutputState{}, false
68- }
69- return s, true
70- }
71- if output.Full || output.Base != s.state || output.New != output.Base+1 {
72- return visualOutputState{}, false
73- }
74- return visualOutputState{epoch: s.epoch, state: output.New, viewRevision: s.viewRevision, initialized: true}, true
75- }
76- if !output.Full || output.Base != 0 || output.New == 0 {
77- return visualOutputState{}, false
78- }
79- return visualOutputState{epoch: output.Epoch, state: output.New, viewRevision: output.ViewRevision, initialized: true}, true
80-}
81-
82-type probeEvent struct {
83- Sequence uint64
84- Transport string
85- Kind string
86- Accepted bool
87- StateBearing bool
88- Acked bool
89- Epoch uint64
90- Base uint64
91- New uint64
92- ViewRevision uint64
93- Full bool
94- DataBytes int
95-}
96-
97-type visualCheckpoint struct {
98- event *probeEvent
99- state visualOutputState
100- snapshot vt.ScreenSnapshot
101-}
102-
103-type visualOutputResult struct {
104- Accepted bool
105- StateBearing bool
106- Ack protocol.Ack
107- event *probeEvent
108-}
109-
110-// visualProbe is deliberately transport-local. A new physical transport gets
111-// one screen and one output state; waiting for a particular marker never
112-// creates a replacement screen and therefore cannot lose prior terminal state.
113-type visualProbe struct {
114- label string
115-
116- screen *vt.Screen
117- state visualOutputState
118-
119- events []*probeEvent
120- checkpoints []visualCheckpoint
121- nextEvent uint64
122-
123- outputFrames int
124- outputBytes int
125- resetPending bool
126-
127- unexpectedHandoffs int
128-}
129-
130-type harnessArtifactContextKey struct{}
131-
132-func withHarnessArtifact(ctx context.Context, artifact *harnessArtifact) context.Context {
133- return context.WithValue(ctx, harnessArtifactContextKey{}, artifact)
134-}
135-
136-func harnessArtifactFromContext(ctx context.Context) *harnessArtifact {
137- if ctx == nil {
138- return nil
139- }
140- artifact, _ := ctx.Value(harnessArtifactContextKey{}).(*harnessArtifact)
141- return artifact
142-}
143-
144-func newVisualProbe(size domain.Size) *visualProbe {
145- width, height := size.Cols, size.Rows
146- if width <= 0 {
147- width = defaultProbeCols
148- }
149- if height <= 0 {
150- height = defaultProbeRows
151- }
152- return &visualProbe{screen: vt.NewScreen(width, height)}
153-}
154-
155-func (p *visualProbe) configure(label string) {
156- if p == nil {
157- return
158- }
159- p.label = label
160-}
161-
162-func (p *visualProbe) record(event probeEvent) *probeEvent {
163- if p == nil {
164- return nil
165- }
166- p.nextEvent++
167- event.Sequence = p.nextEvent
168- event.Transport = p.label
169- owned := &event
170- if len(p.events) == maxProbeEvents {
171- p.events = p.events[1:]
172- }
173- p.events = append(p.events, owned)
174- return owned
175-}
176-
177-func (p *visualProbe) recordControl(kind string) {
178- if p == nil {
179- return
180- }
181- p.record(probeEvent{Kind: kind, Accepted: true})
182-}
183-
184-func (p *visualProbe) apply(output protocol.Output) visualOutputResult {
185- if p == nil {
186- return visualOutputResult{}
187- }
188- p.outputFrames++
189- p.outputBytes += len(output.Data)
190- accepted := protocol.ValidateOutput(output) == nil
191- next := visualOutputState{}
192- if accepted {
193- next, accepted = p.state.next(output)
194- }
195- event := p.record(probeEvent{
196- Kind: probeEventOutput,
197- Accepted: accepted,
198- StateBearing: output.New != 0,
199- Epoch: output.Epoch,
200- Base: output.Base,
201- New: output.New,
202- ViewRevision: output.ViewRevision,
203- Full: output.Full,
204- DataBytes: len(output.Data),
205- })
206- if !accepted {
207- return visualOutputResult{event: event}
208- }
209-
210- // The client writes side effects as well as state-bearing frames, but only
211- // state-bearing frames advance the dependency chain and receive an ACK.
212- if p.screen.Columns() != output.Size.Cols || p.screen.Rows() != output.Size.Rows {
213- p.screen.Resize(output.Size.Cols, output.Size.Rows)
214- }
215- p.screen.Write(output.Data)
216- p.state = next
217- checkpoint := visualCheckpoint{event: event, state: p.state, snapshot: p.screen.Snapshot()}
218- if len(p.checkpoints) == maxProbeCheckpoints {
219- p.checkpoints = p.checkpoints[1:]
220- }
221- p.checkpoints = append(p.checkpoints, checkpoint)
222- result := visualOutputResult{Accepted: true, StateBearing: output.New != 0, event: event}
223- if result.StateBearing {
224- result.Ack = protocol.Ack{Epoch: output.Epoch, State: output.New}
225- }
226- return result
227-}
228-
229-func (p *visualProbe) text() string {
230- if p == nil || p.screen == nil {
231- return ""
232- }
233- return visualScreenText(p.screen)
234-}
235-
236-func (p *visualProbe) contains(want string) bool {
237- return strings.Contains(p.text(), want)
238-}
239-
240-func (p *visualProbe) recordIncoming(frame wire.Frame) {
241- if p == nil || frame.Type != wire.MsgAttachTarget {
242- return
243- }
244- p.unexpectedHandoffs++
245- p.record(probeEvent{Kind: probeEventUnexpectedHandoff})
246-}
247-
248-func (p *visualProbe) markAcked(result visualOutputResult) {
249- if result.event != nil && result.StateBearing && result.Accepted {
250- result.event.Acked = true
251- }
252-}
253-
254-func visualScreenLines(screen *vt.Screen) []string {
255- if screen == nil {
256- return nil
257- }
258- lines := make([]string, screen.Rows())
259- for y := range screen.Rows() {
260- var line strings.Builder
261- for x := range screen.Columns() {
262- r := screen.Cell(x, y).Rune
263- if r == 0 {
264- r = ' '
265- }
266- line.WriteRune(r)
267- }
268- lines[y] = line.String()
269- }
270- return lines
271-}
272-
273-func visualScreenText(screen *vt.Screen) string {
274- lines := visualScreenLines(screen)
275- if len(lines) == 0 {
276- return ""
277- }
278- return strings.Join(lines, "\n") + "\n"
279-}
280-
281-func capturePickerRows(probe *visualProbe) []string {
282- if probe == nil {
283- return nil
284- }
285- var rows []string
286- for _, line := range visualScreenLines(probe.screen) {
287- if line = strings.TrimSpace(line); line != "" {
288- rows = append(rows, line)
289- }
290- }
291- return rows
292-}
293-
294-func assertUnifiedPickerRows(rows []string, localSession, remoteSession string) error {
295- if len(rows) == 0 {
296- return errors.New("picker screen had no visible rows")
297- }
298- contains := func(want string) bool {
299- for _, row := range rows {
300- if strings.Contains(row, want) {
301- return true
302- }
303- }
304- return false
305- }
306- if !contains(localSession) {
307- return fmt.Errorf("unified picker rows omitted local session %q", localSession)
308- }
309- remoteRow := remoteSession + "@remote"
310- if !contains(remoteRow) {
311- return fmt.Errorf("unified picker rows omitted remote session %q", remoteRow)
312- }
313- return nil
314-}
315-
316-func assertNoRemoteChrome(probe *visualProbe, remoteSession string) error {
317- if probe == nil {
318- return errors.New("remote view probe was not created")
319- }
320- lines := visualScreenLines(probe.screen)
321- if len(lines) < 3 {
322- return errors.New("remote view did not expose a content region")
323- }
324- // The first and last rows belong to the local attachment's bars. Remote
325- // daemon chrome must not be replayed into the content rows.
326- for _, line := range lines[1 : len(lines)-1] {
327- if strings.Contains(line, remoteSession+"@remote") || strings.Contains(line, " Sessions · ") {
328- return fmt.Errorf("remote chrome leaked into content row %q", strings.TrimSpace(line))
329- }
330- }
331- return nil
332-}
333-
334-type harnessArtifact struct {
335- dir string
336- probes []*visualProbe
337-}
338-
339-func newHarnessArtifact(dir string) *harnessArtifact {
340- if dir == "" {
341- return nil
342- }
343- return &harnessArtifact{dir: dir}
344-}
345-
346-func (a *harnessArtifact) registerProbe(probe *visualProbe) {
347- if a == nil || probe == nil {
348- return
349- }
350- a.probes = append(a.probes, probe)
351-}
352-
353-type artifactEvent struct {
354- Sequence uint64 `json:"sequence"`
355- Transport string `json:"transport"`
356- Kind string `json:"kind"`
357- Accepted bool `json:"accepted"`
358- StateBearing bool `json:"state_bearing"`
359- Acked bool `json:"acked"`
360- Epoch uint64 `json:"epoch,omitempty"`
361- Base uint64 `json:"base,omitempty"`
362- New uint64 `json:"new,omitempty"`
363- ViewRevision uint64 `json:"view_revision,omitempty"`
364- Full bool `json:"full,omitempty"`
365- DataBytes int `json:"data_bytes,omitempty"`
366-}
367-
368-type artifactCheckpoint struct {
369- Sequence uint64 `json:"sequence"`
370- Epoch uint64 `json:"epoch,omitempty"`
371- State uint64 `json:"state,omitempty"`
372- ViewRevision uint64 `json:"view_revision,omitempty"`
373- Columns int `json:"columns"`
374- Rows int `json:"rows"`
375- CursorRow int `json:"cursor_row"`
376- CursorColumn int `json:"cursor_column"`
377- AlternateScreen bool `json:"alternate_screen"`
378- BracketedPaste bool `json:"bracketed_paste"`
379- SynchronizedUpdate bool `json:"synchronized_update"`
380-}
381-
382-type artifactProbe struct {
383- Transport string `json:"transport"`
384- Events []artifactEvent `json:"events"`
385- Checkpoints []artifactCheckpoint `json:"checkpoints"`
386-}
387-
388-type artifactDocument struct {
389- Version int `json:"version"`
390- Passed bool `json:"passed"`
391- Probes []artifactProbe `json:"probes"`
392-}
393-
394-func (a *harnessArtifact) document(passed bool) artifactDocument {
395- document := artifactDocument{Version: 1, Passed: passed}
396- for _, probe := range a.probes {
397- if probe == nil {
398- continue
399- }
400- item := artifactProbe{Transport: probe.label}
401- for _, event := range probe.events {
402- if event == nil {
403- continue
404- }
405- item.Events = append(item.Events, artifactEventFrom(event))
406- }
407- for _, checkpoint := range probe.checkpoints {
408- snapshot := checkpoint.snapshot
409- cursor := snapshot.Cursor()
410- modes := snapshot.Modes()
411- item.Checkpoints = append(item.Checkpoints, artifactCheckpoint{
412- Sequence: checkpoint.event.Sequence,
413- Epoch: checkpoint.state.epoch,
414- State: checkpoint.state.state,
415- ViewRevision: checkpoint.state.viewRevision,
416- Columns: snapshot.Columns(),
417- Rows: snapshot.Rows(),
418- CursorRow: cursor.Row,
419- CursorColumn: cursor.Col,
420- AlternateScreen: modes.AlternateScreen,
421- BracketedPaste: modes.BracketedPaste,
422- SynchronizedUpdate: modes.SynchronizedUpdate,
423- })
424- }
425- document.Probes = append(document.Probes, item)
426- }
427- return document
428-}
429-
430-func artifactEventFrom(event *probeEvent) artifactEvent {
431- return artifactEvent{
432- Sequence: event.Sequence,
433- Transport: event.Transport,
434- Kind: event.Kind,
435- Accepted: event.Accepted,
436- StateBearing: event.StateBearing,
437- Acked: event.Acked,
438- Epoch: event.Epoch,
439- Base: event.Base,
440- New: event.New,
441- ViewRevision: event.ViewRevision,
442- Full: event.Full,
443- DataBytes: event.DataBytes,
444- }
445-}
446-
447-func (a *harnessArtifact) write(passed bool) error {
448- if a == nil || a.dir == "" {
449- return nil
450- }
451- data, err := json.MarshalIndent(a.document(passed), "", " ")
452- if err != nil {
453- return fmt.Errorf("marshal harness artifact: %w", err)
454- }
455- if len(data) > maxArtifactBytes {
456- return fmt.Errorf("harness artifact exceeds %d bytes", maxArtifactBytes)
457- }
458- if err := os.MkdirAll(a.dir, 0o700); err != nil {
459- return fmt.Errorf("create harness artifact directory: %w", err)
460- }
461- tmp, err := os.CreateTemp(a.dir, ".remote-picker-harness-*.tmp")
462- if err != nil {
463- return fmt.Errorf("create harness artifact: %w", err)
464- }
465- tmpName := tmp.Name()
466- defer func() { _ = os.Remove(tmpName) }()
467- if err := tmp.Chmod(0o600); err != nil {
468- _ = tmp.Close()
469- return fmt.Errorf("protect harness artifact: %w", err)
470- }
471- if _, err := tmp.Write(data); err != nil {
472- _ = tmp.Close()
473- return fmt.Errorf("write harness artifact: %w", err)
474- }
475- if err := tmp.Close(); err != nil {
476- return fmt.Errorf("close harness artifact: %w", err)
477- }
478- if err := os.Rename(tmpName, filepath.Join(a.dir, "remote-picker-harness.json")); err != nil {
479- return fmt.Errorf("publish harness artifact: %w", err)
480- }
481- return nil
482-}
D scripts/remote-picker-harness/visual_probe_test.go +0 -357
@@ -1,357 +0,0 @@
1-package main
2-
3-import (
4- "encoding/json"
5- "errors"
6- "os"
7- "strings"
8- "testing"
9-
10- "github.com/bnema/vev/internal/domain"
11- "github.com/bnema/vev/internal/protocol"
12- "github.com/bnema/vev/internal/protocol/wire"
13- "github.com/stretchr/testify/require"
14-)
15-
16-const (
17- probeTestCols = 32
18- probeTestRows = 2
19-)
20-
21-type probeTestTransport struct {
22- sent []wire.Frame
23-}
24-
25-func (t *probeTestTransport) Send(frame wire.Frame) error {
26- t.sent = append(t.sent, frame)
27- return nil
28-}
29-
30-func (*probeTestTransport) Recv() (wire.Frame, error) { return wire.Frame{}, errors.New("not used") }
31-func (*probeTestTransport) Close() error { return nil }
32-
33-func probeTestSemanticOutput(output protocol.Output) protocol.Output {
34- if output.New != 0 && output.Context == nil {
35- output.Context = &protocol.ViewContext{
36- Publication: output.New,
37- Route: protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "probe-fixture"}},
38- TabID: "tab-1", FocusedPaneID: "pane-1",
39- }
40- }
41- return output
42-}
43-
44-func probeTestOutput(t *testing.T, output protocol.Output) wire.Frame {
45- t.Helper()
46- payload, err := wire.MarshalOutput(probeTestSemanticOutput(output))
47- if err != nil {
48- t.Fatalf("marshal output: %v", err)
49- }
50- return wire.Frame{Type: wire.MsgOutput, Payload: payload}
51-}
52-
53-func TestVisualProbePersistsFullAndIncrementalOutput(t *testing.T) {
54- size := domain.Size{Cols: probeTestCols, Rows: probeTestRows}
55- probe := newVisualProbe(size)
56- for _, tt := range []struct {
57- name string
58- output protocol.Output
59- want string
60- state uint64
61- }{
62- {
63- name: "full",
64- output: protocol.Output{Epoch: 1, New: 1, Full: true, ViewRevision: 4, Size: size, Data: []byte("full")},
65- want: "full",
66- state: 1,
67- },
68- {
69- name: "incremental",
70- output: protocol.Output{Epoch: 1, Base: 1, New: 2, ViewRevision: 4, Size: size, Data: []byte(" incremental")},
71- want: "full incremental",
72- state: 2,
73- },
74- } {
75- t.Run(tt.name, func(t *testing.T) {
76- result := probe.apply(probeTestSemanticOutput(tt.output))
77- if !result.Accepted || !result.StateBearing {
78- t.Fatalf("result = %+v, want accepted state-bearing output", result)
79- }
80- if result.Ack.Epoch != tt.output.Epoch || result.Ack.State != tt.state {
81- t.Fatalf("ack = %+v, want epoch=%d state=%d", result.Ack, tt.output.Epoch, tt.state)
82- }
83- if !strings.Contains(probe.text(), tt.want) {
84- t.Fatalf("screen = %q, want %q", probe.text(), tt.want)
85- }
86- if probe.state.state != tt.state {
87- t.Fatalf("state = %d, want %d", probe.state.state, tt.state)
88- }
89- })
90- }
91- if got := len(probe.checkpoints); got != 2 {
92- t.Fatalf("checkpoints = %d, want 2", got)
93- }
94- if got := len(probe.events); got != 2 {
95- t.Fatalf("events = %d, want 2", got)
96- }
97-}
98-
99-func TestVisualProbeResizesToAcceptedOutput(t *testing.T) {
100- probe := newVisualProbe(domain.Size{Cols: probeTestCols, Rows: probeTestRows})
101- outputSize := domain.Size{Cols: 48, Rows: 3}
102- result := probe.apply(probeTestSemanticOutput(protocol.Output{
103- Epoch: 1, New: 1, Full: true, ViewRevision: 1, Size: outputSize, Data: []byte("resized"),
104- }))
105-
106- require.True(t, result.Accepted)
107- require.Equal(t, outputSize.Cols, probe.screen.Columns())
108- require.Equal(t, outputSize.Rows, probe.screen.Rows())
109- require.Len(t, probe.checkpoints, 1)
110- require.Equal(t, outputSize.Cols, probe.checkpoints[0].snapshot.Columns())
111- require.Equal(t, outputSize.Rows, probe.checkpoints[0].snapshot.Rows())
112-}
113-
114-func TestVisualProbeRejectsOutputWithoutMutationOrAck(t *testing.T) {
115- size := domain.Size{Cols: probeTestCols, Rows: probeTestRows}
116- for _, tt := range []struct {
117- name string
118- output protocol.Output
119- }{
120- {
121- name: "base gap",
122- output: protocol.Output{Epoch: 1, Base: 0, New: 3, Full: true, ViewRevision: 4, Size: size, Data: []byte("rejected")},
123- },
124- {
125- name: "revision gap",
126- output: protocol.Output{Epoch: 1, Base: 1, New: 2, ViewRevision: 5, Size: size, Data: []byte("rejected")},
127- },
128- {
129- name: "stale epoch",
130- output: protocol.Output{Epoch: 0, Base: 1, New: 2, ViewRevision: 4, Size: size, Data: []byte("rejected")},
131- },
132- } {
133- t.Run(tt.name, func(t *testing.T) {
134- probe := newVisualProbe(size)
135- first := protocol.Output{Epoch: 1, New: 1, Full: true, ViewRevision: 4, Size: size, Data: []byte("keep")}
136- if result := probe.apply(probeTestSemanticOutput(first)); !result.Accepted {
137- t.Fatal("seed output was rejected")
138- }
139- beforeText, beforeState, beforeCheckpoints := probe.text(), probe.state, len(probe.checkpoints)
140- result := probe.apply(probeTestSemanticOutput(tt.output))
141- if result.Accepted || result.StateBearing || result.Ack != (protocol.Ack{}) {
142- t.Fatalf("result = %+v, want rejected output without ACK", result)
143- }
144- if got := probe.text(); got != beforeText {
145- t.Fatalf("screen changed from %q to %q", beforeText, got)
146- }
147- if probe.state != beforeState {
148- t.Fatalf("state = %+v, want unchanged %+v", probe.state, beforeState)
149- }
150- if len(probe.checkpoints) != beforeCheckpoints {
151- t.Fatalf("checkpoints = %d, want %d", len(probe.checkpoints), beforeCheckpoints)
152- }
153- })
154- }
155-}
156-
157-func TestVisualProbeRejectsInvalidUninitializedSideEffects(t *testing.T) {
158- size := domain.Size{Cols: probeTestCols, Rows: probeTestRows}
159- for _, output := range []protocol.Output{
160- {Epoch: 1, Base: 1, Size: size, Data: []byte("invalid base")},
161- {Epoch: 1, Full: true, Size: size, Data: []byte("invalid full")},
162- } {
163- probe := newVisualProbe(size)
164- result := probe.apply(output)
165- if result.Accepted || result.StateBearing || result.Ack != (protocol.Ack{}) {
166- t.Fatalf("result = %+v, want rejected uninitialized side effect", result)
167- }
168- require.Len(t, probe.events, 1)
169- require.False(t, probe.events[0].Accepted)
170- require.Empty(t, probe.checkpoints)
171- }
172-}
173-
174-func TestVisualProbeSideEffectsApplyWithoutAck(t *testing.T) {
175- size := domain.Size{Cols: probeTestCols, Rows: probeTestRows}
176- for _, tt := range []struct {
177- name string
178- seed *protocol.Output
179- effect protocol.Output
180- }{
181- {
182- name: "before state-bearing output",
183- effect: protocol.Output{Epoch: 1, Size: size, Data: []byte("side effect")},
184- },
185- {
186- name: "after state-bearing output",
187- seed: &protocol.Output{Epoch: 1, New: 1, Full: true, ViewRevision: 4, Size: size, Data: []byte("seed")},
188- effect: protocol.Output{Epoch: 1, ViewRevision: 4, Size: size, Data: []byte(" side effect")},
189- },
190- } {
191- t.Run(tt.name, func(t *testing.T) {
192- probe := newVisualProbe(size)
193- if tt.seed != nil && !probe.apply(probeTestSemanticOutput(*tt.seed)).Accepted {
194- t.Fatal("seed output was rejected")
195- }
196- beforeState, beforeCheckpoints := probe.state, len(probe.checkpoints)
197- result := probe.apply(tt.effect)
198- if !result.Accepted || result.StateBearing || result.Ack != (protocol.Ack{}) {
199- t.Fatalf("result = %+v, want accepted side effect without ACK", result)
200- }
201- if !strings.Contains(probe.text(), string(tt.effect.Data)) {
202- t.Fatalf("screen = %q, want side effect %q", probe.text(), tt.effect.Data)
203- }
204- if probe.state != beforeState {
205- t.Fatalf("state = %+v, want unchanged %+v", probe.state, beforeState)
206- }
207- if len(probe.checkpoints) != beforeCheckpoints+1 {
208- t.Fatalf("checkpoints = %d, want %d", len(probe.checkpoints), beforeCheckpoints+1)
209- }
210- })
211- }
212-}
213-
214-func TestProcessOutputFrameACKsOnlyAcceptedStateBearingFrames(t *testing.T) {
215- size := domain.Size{Cols: probeTestCols, Rows: probeTestRows}
216- for _, tt := range []struct {
217- name string
218- seed *protocol.Output
219- output protocol.Output
220- wantACKs int
221- wantResets int
222- }{
223- {
224- name: "accepted full",
225- output: protocol.Output{Epoch: 1, New: 1, Full: true, ViewRevision: 4, Size: size, Data: []byte("full")},
226- wantACKs: 1,
227- },
228- {
229- name: "accepted side effect",
230- output: protocol.Output{Epoch: 1, Size: size, Data: []byte("side effect")},
231- },
232- {
233- name: "rejected state",
234- seed: &protocol.Output{Epoch: 1, New: 1, Full: true, ViewRevision: 4, Size: size, Data: []byte("seed")},
235- output: protocol.Output{Epoch: 1, New: 3, Full: true, ViewRevision: 4, Size: size, Data: []byte("gap")},
236- wantResets: 1,
237- },
238- } {
239- t.Run(tt.name, func(t *testing.T) {
240- transport := &probeTestTransport{}
241- tr := &harnessTransport{Transport: transport, probe: newVisualProbe(size)}
242- if tt.seed != nil {
243- if err := processOutputFrame(tr, probeTestOutput(t, *tt.seed)); err != nil {
244- t.Fatal(err)
245- }
246- transport.sent = nil
247- }
248- if err := processOutputFrame(tr, probeTestOutput(t, tt.output)); err != nil {
249- t.Fatal(err)
250- }
251- var ackCount, resetCount int
252- for _, frame := range transport.sent {
253- switch frame.Type {
254- case wire.MsgAck:
255- ackCount++
256- case wire.MsgOutputResetRequest:
257- resetCount++
258- }
259- }
260- if ackCount != tt.wantACKs {
261- t.Fatalf("ACK count = %d, want %d", ackCount, tt.wantACKs)
262- }
263- if resetCount != tt.wantResets {
264- t.Fatalf("reset count = %d, want %d", resetCount, tt.wantResets)
265- }
266- })
267- }
268-}
269-
270-func TestCaptureUnifiedPickerRowsRequiresLocalAndRemoteEntries(t *testing.T) {
271- probe := newVisualProbe(domain.Size{Cols: 48, Rows: 4})
272- probe.screen.Write([]byte("local-picker\npicker@remote"))
273-
274- rows := capturePickerRows(probe)
275- if err := assertUnifiedPickerRows(rows, "local-picker", "picker"); err != nil {
276- t.Fatalf("assert unified picker rows: %v; rows = %#v", err, rows)
277- }
278-}
279-
280-func TestVisualProbeRecordsUnexpectedAttachTarget(t *testing.T) {
281- probe := newVisualProbe(domain.Size{Cols: probeTestCols, Rows: probeTestRows})
282- probe.recordIncoming(wire.Frame{Type: wire.MsgAttachTarget})
283-
284- if probe.unexpectedHandoffs != 1 {
285- t.Fatalf("unexpected handoffs = %d, want 1", probe.unexpectedHandoffs)
286- }
287- if len(probe.events) != 1 || probe.events[0].Kind != probeEventUnexpectedHandoff {
288- t.Fatalf("events = %#v, want one unexpected handoff event", probe.events)
289- }
290-}
291-
292-func TestAssertNoRemoteChromeIgnoresLocalBarsButRejectsContentLeak(t *testing.T) {
293- require.Error(t, assertNoRemoteChrome(nil, "picker"))
294- for _, test := range []struct {
295- name string
296- content string
297- wantErr bool
298- }{
299- {name: "local bars", content: "picker@remote\r\nremote content\r\nlocal bottom", wantErr: false},
300- {name: "remote status in content", content: "local top\r\npicker@remote\r\nlocal bottom", wantErr: true},
301- } {
302- t.Run(test.name, func(t *testing.T) {
303- probe := newVisualProbe(domain.Size{Cols: 48, Rows: 3})
304- probe.screen.Write([]byte(test.content))
305- err := assertNoRemoteChrome(probe, "picker")
306- if (err != nil) != test.wantErr {
307- t.Fatalf("error = %v, want error = %t", err, test.wantErr)
308- }
309- })
310- }
311-}
312-
313-func TestHarnessArtifactIsBoundedAndContainsOnlyMetadata(t *testing.T) {
314- dir := t.TempDir()
315- artifact := newHarnessArtifact(dir)
316- size := domain.Size{Cols: probeTestCols, Rows: probeTestRows}
317- probe := newVisualProbe(size)
318- probe.configure("local-picker")
319- artifact.registerProbe(probe)
320- secret := "not-for-artifact"
321- for state := uint64(1); state <= maxProbeEvents+1; state++ {
322- output := protocol.Output{Epoch: 1, Base: state - 1, New: state, Size: size, Data: []byte(secret)}
323- if state == 1 {
324- output.Full = true
325- }
326- if result := probe.apply(probeTestSemanticOutput(output)); !result.Accepted {
327- t.Fatalf("probe output %d was rejected", state)
328- }
329- }
330- if err := artifact.write(true); err != nil {
331- t.Fatal(err)
332- }
333- path := dir + "/remote-picker-harness.json"
334- data, err := os.ReadFile(path)
335- if err != nil {
336- t.Fatal(err)
337- }
338- if len(data) > maxArtifactBytes {
339- t.Fatalf("artifact bytes = %d, want <= %d", len(data), maxArtifactBytes)
340- }
341- if strings.Contains(string(data), secret) {
342- t.Fatal("artifact contained raw output")
343- }
344- var document artifactDocument
345- if err := json.Unmarshal(data, &document); err != nil {
346- t.Fatalf("artifact JSON: %v", err)
347- }
348- if !document.Passed || len(document.Probes) != 1 {
349- t.Fatalf("artifact document = %+v, want one passed probe", document)
350- }
351- if len(document.Probes[0].Events) != maxProbeEvents {
352- t.Fatalf("artifact events = %d, want %d", len(document.Probes[0].Events), maxProbeEvents)
353- }
354- if len(document.Probes[0].Checkpoints) != maxProbeCheckpoints {
355- t.Fatalf("artifact checkpoints = %d, want %d", len(document.Probes[0].Checkpoints), maxProbeCheckpoints)
356- }
357-}

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