detached f22cb3f

fix(navigation): resolve session labels by lifecycle identity

commit
f22cb3fe9a60c8be65e9ebf08e31e78de33dd393
author
brice · · 2026-08-18 19:54 UTC
committer
brice · · 2026-08-19 04:15 UTC
parent
274b25c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
34 files · +345 -295
M internal/app/run.go +2 -2
@@ -942,7 +942,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
942942 Origin: ports.RouteOriginDiscovery,
943943 OriginKey: target.Endpoint,
944944 RemoteTarget: selection,
945- RemoteOrigin: displayOrigin,
945+ HostLabel: displayOrigin,
946946 EnvironmentPolicy: policy,
947947 }, nil
948948 }
@@ -977,7 +977,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
977977 Origin: routeOrigin,
978978 OriginKey: routeOriginKey,
979979 RemoteTarget: remoteSelection,
980- RemoteOrigin: remoteDisplayOrigin,
980+ HostLabel: remoteDisplayOrigin,
981981 EnvironmentPolicy: remoteEnvironmentPolicy,
982982 })
983983 } else {
@@ -942,7 +942,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
942 Origin: ports.RouteOriginDiscovery,942 Origin: ports.RouteOriginDiscovery,
943 OriginKey: target.Endpoint,943 OriginKey: target.Endpoint,
944 RemoteTarget: selection,944 RemoteTarget: selection,
945- RemoteOrigin: displayOrigin,945+ HostLabel: displayOrigin,
946 EnvironmentPolicy: policy,946 EnvironmentPolicy: policy,
947 }, nil947 }, nil
948 }948 }
@@ -977,7 +977,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
977 Origin: routeOrigin,977 Origin: routeOrigin,
978 OriginKey: routeOriginKey,978 OriginKey: routeOriginKey,
979 RemoteTarget: remoteSelection,979 RemoteTarget: remoteSelection,
980- RemoteOrigin: remoteDisplayOrigin,980+ HostLabel: remoteDisplayOrigin,
981 EnvironmentPolicy: remoteEnvironmentPolicy,981 EnvironmentPolicy: remoteEnvironmentPolicy,
982 })982 })
983 } else {983 } else {
M internal/app/run_test.go +1 -1
@@ -962,7 +962,7 @@ func TestRunAttachWithDepsLocalPickerHandoffAttachesSelectedRemote(t *testing.T)
962962 runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error {
963963 if deps.Remote {
964964 remoteCalls++
965- require.Equal(t, client.AttachRequest{Intent: ports.IntentAttach, SessionName: "picked", Remote: true, Origin: ports.RouteOriginDiscovery, OriginKey: "selected.example", RemoteOrigin: "selected.example", EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned}, request)
965+ require.Equal(t, client.AttachRequest{Intent: ports.IntentAttach, SessionName: "picked", Remote: true, Origin: ports.RouteOriginDiscovery, OriginKey: "selected.example", HostLabel: "selected.example", EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned}, request)
966966 return nil
967967 }
968968 localCalls++
@@ -962,7 +962,7 @@ func TestRunAttachWithDepsLocalPickerHandoffAttachesSelectedRemote(t *testing.T)
962 runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error {962 runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error {
963 if deps.Remote {963 if deps.Remote {
964 remoteCalls++964 remoteCalls++
965- require.Equal(t, client.AttachRequest{Intent: ports.IntentAttach, SessionName: "picked", Remote: true, Origin: ports.RouteOriginDiscovery, OriginKey: "selected.example", RemoteOrigin: "selected.example", EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned}, request)965+ require.Equal(t, client.AttachRequest{Intent: ports.IntentAttach, SessionName: "picked", Remote: true, Origin: ports.RouteOriginDiscovery, OriginKey: "selected.example", HostLabel: "selected.example", EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned}, request)
966 return nil966 return nil
967 }967 }
968 localCalls++968 localCalls++
M internal/ports/frame.go +1 -1
@@ -12,7 +12,7 @@ import (
1212 const HandshakeTimeout = 15 * time.Second
1313
1414 // ProtocolVersion is the current vev IPC wire protocol version.
15-const ProtocolVersion uint16 = 31
15+const ProtocolVersion uint16 = 32
1616
1717 // MaxFrameLen is the largest permitted frame length, including the type byte
1818 // and excluding the four-byte length prefix.
@@ -12,7 +12,7 @@ import (
12 const HandshakeTimeout = 15 * time.Second12 const HandshakeTimeout = 15 * time.Second
13 13
14 // ProtocolVersion is the current vev IPC wire protocol version.14 // ProtocolVersion is the current vev IPC wire protocol version.
15-const ProtocolVersion uint16 = 3115+const ProtocolVersion uint16 = 32
16 16
17 // MaxFrameLen is the largest permitted frame length, including the type byte17 // MaxFrameLen is the largest permitted frame length, including the type byte
18 // and excluding the four-byte length prefix.18 // and excluding the four-byte length prefix.
M internal/ports/frame_test.go +2 -2
@@ -3,8 +3,8 @@ package ports
33 import "testing"
44
55 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 31 {
7- t.Fatalf("ProtocolVersion = %d, want 31", ProtocolVersion)
6+ if ProtocolVersion != 32 {
7+ t.Fatalf("ProtocolVersion = %d, want 32", ProtocolVersion)
88 }
99 }
1010
@@ -3,8 +3,8 @@ package ports
3 import "testing"3 import "testing"
4 4
5 func TestProtocolVersion(t *testing.T) {5 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 31 {6+ if ProtocolVersion != 32 {
7- t.Fatalf("ProtocolVersion = %d, want 31", ProtocolVersion)7+ t.Fatalf("ProtocolVersion = %d, want 32", ProtocolVersion)
8 }8 }
9 }9 }
10 10
M internal/ports/navigation_test.go +1 -1
@@ -62,7 +62,7 @@ func TestHelloNavigationValidationTable(t *testing.T) {
6262 {name: "client-owned home picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityHomePicker}, valid: false},
6363 {name: "daemon-owned back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false},
6464 {name: "remote-target back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false},
65- {name: "back with startup picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "001f02" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "0000" + "0201"},
65+ {name: "back with startup picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "002002" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "0201"},
6666 {name: "new rejects navigation", hello: Hello{Version: ProtocolVersion, Intent: IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: false},
6767 {name: "unknown capability", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false},
6868 {name: "back without picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack}, valid: false},
@@ -62,7 +62,7 @@ func TestHelloNavigationValidationTable(t *testing.T) {
62 {name: "client-owned home picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityHomePicker}, valid: false},62 {name: "client-owned home picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityHomePicker}, valid: false},
63 {name: "daemon-owned back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false},63 {name: "daemon-owned back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false},
64 {name: "remote-target back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false},64 {name: "remote-target back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false},
65- {name: "back with startup picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "001f02" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "0000" + "0201"},65+ {name: "back with startup picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "002002" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "0201"},
66 {name: "new rejects navigation", hello: Hello{Version: ProtocolVersion, Intent: IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: false},66 {name: "new rejects navigation", hello: Hello{Version: ProtocolVersion, Intent: IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: false},
67 {name: "unknown capability", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false},67 {name: "unknown capability", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false},
68 {name: "back without picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack}, valid: false},68 {name: "back without picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack}, valid: false},
M internal/ports/routes.go +11 -6
@@ -193,12 +193,13 @@ func (r RouteRef) Validate() error {
193193 return nil
194194 }
195195
196-// RecentRouteEntry is the complete daemon-neutral display view of one route.
197-// It contains no dialer, credential, endpoint, live session pointer, or attach
198-// target. The client ledger keeps those private beside this value.
196+// RecentRouteEntry is the complete daemon-neutral identity and display view of
197+// one route. Target carries only the committed lifecycle UUID and name; it
198+// contains no dialer, credential, endpoint, or live session pointer.
199199 type RecentRouteEntry struct {
200200 Key uint64
201201 Generation uint64
202+ Target ExactSessionTarget
202203 Name string
203204 HostLabel string
204205 Kind RouteKind
@@ -212,9 +213,13 @@ type RecentRouteEntry struct {
212213 type RecentRouteSnapshot struct {
213214 Generation uint64
214215 Active RouteRef
215- Previous RouteRef
216- Home RouteRef
217- Entries []RecentRouteEntry
216+ // ActiveEntry maps Active's committed lifecycle UUID to its presentation.
217+ // It is not a navigation candidate and therefore remains separate from
218+ // Entries, which are ordered from most recent to least recent.
219+ ActiveEntry RecentRouteEntry
220+ Previous RouteRef
221+ Home RouteRef
222+ Entries []RecentRouteEntry
218223 }
219224
220225 // RouteNavigationAction asks the client to resolve one entry from a specific
@@ -193,12 +193,13 @@ func (r RouteRef) Validate() error {
193 return nil193 return nil
194 }194 }
195 195
196-// RecentRouteEntry is the complete daemon-neutral display view of one route.196+// RecentRouteEntry is the complete daemon-neutral identity and display view of
197-// It contains no dialer, credential, endpoint, live session pointer, or attach197+// one route. Target carries only the committed lifecycle UUID and name; it
198-// target. The client ledger keeps those private beside this value.198+// contains no dialer, credential, endpoint, or live session pointer.
199 type RecentRouteEntry struct {199 type RecentRouteEntry struct {
200 Key uint64200 Key uint64
201 Generation uint64201 Generation uint64
202+ Target ExactSessionTarget
202 Name string203 Name string
203 HostLabel string204 HostLabel string
204 Kind RouteKind205 Kind RouteKind
@@ -212,9 +213,13 @@ type RecentRouteEntry struct {
212 type RecentRouteSnapshot struct {213 type RecentRouteSnapshot struct {
213 Generation uint64214 Generation uint64
214 Active RouteRef215 Active RouteRef
215- Previous RouteRef216+ // ActiveEntry maps Active's committed lifecycle UUID to its presentation.
216- Home RouteRef217+ // It is not a navigation candidate and therefore remains separate from
217- Entries []RecentRouteEntry218+ // Entries, which are ordered from most recent to least recent.
219+ ActiveEntry RecentRouteEntry
220+ Previous RouteRef
221+ Home RouteRef
222+ Entries []RecentRouteEntry
218 }223 }
219 224
220 // RouteNavigationAction asks the client to resolve one entry from a specific225 // RouteNavigationAction asks the client to resolve one entry from a specific
M internal/ports/routes_test.go +16 -10
@@ -111,13 +111,16 @@ func TestCommittedRouteIdentityCodec(t *testing.T) {
111111 }
112112
113113 func testRouteSnapshot() RecentRouteSnapshot {
114+ activeTarget := testExactTarget()
115+ logsTarget := ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{4, 5, 6}, SessionName: "logs"}
114116 return RecentRouteSnapshot{
115- Generation: 9,
116- Active: RouteRef{Key: 11, Generation: 3},
117- Previous: RouteRef{Key: 12, Generation: 4},
118- Home: RouteRef{Key: 11, Generation: 3},
117+ Generation: 9,
118+ Active: RouteRef{Key: 11, Generation: 3},
119+ ActiveEntry: RecentRouteEntry{Key: 11, Generation: 3, Target: activeTarget, Name: "work", Kind: RouteKindLocal, Reachability: RouteReachabilityUnknown},
120+ Previous: RouteRef{Key: 12, Generation: 4},
121+ Home: RouteRef{Key: 11, Generation: 3},
119122 Entries: []RecentRouteEntry{
120- {Key: 12, Generation: 4, Name: "logs", HostLabel: "edge", Kind: RouteKindRemote, Attention: true, Reachability: RouteReachabilityUnknown},
123+ {Key: 12, Generation: 4, Target: logsTarget, Name: "logs", HostLabel: "edge", Kind: RouteKindRemote, Attention: true, Reachability: RouteReachabilityUnknown},
121124 },
122125 }
123126 }
@@ -131,7 +134,7 @@ func TestRecentRouteSnapshotCodecRoundTripAndBounds(t *testing.T) {
131134 require.NoError(t, err)
132135 require.Equal(t, want, got)
133136
134- require.Equal(t, "0000000000000009000000000000000b0000000000000003000000000000000c0000000000000004000000000000000b000000000000000301000000000000000c000000000000000400046c6f677300046564676502000100", hex.EncodeToString(encoded))
137+ require.Equal(t, "0000000000000009000000000000000b0000000000000003000000000000000b0000000000000003010203000000000000000000000000000004776f726b0004776f726b000001000000000000000000000c0000000000000004000000000000000b000000000000000301000000000000000c00000000000000040405060000000000000000000000000000046c6f677300046c6f677300046564676502000100", hex.EncodeToString(encoded))
135138 assertAllPrefixesFail(t, encoded, UnmarshalRecentRouteSnapshot)
136139 _, err = UnmarshalRecentRouteSnapshot(append(append([]byte(nil), encoded...), 0))
137140 require.Error(t, err)
@@ -141,14 +144,17 @@ func TestRecentRouteSnapshotCodecRoundTripAndBounds(t *testing.T) {
141144 mutate func(*RecentRouteSnapshot)
142145 }{
143146 {name: "missing previous reference", mutate: func(snapshot *RecentRouteSnapshot) { snapshot.Previous = RouteRef{Key: 99, Generation: 99} }},
147+ {name: "missing active presentation", mutate: func(snapshot *RecentRouteSnapshot) { snapshot.ActiveEntry = RecentRouteEntry{} }},
148+ {name: "route name differs from lifecycle target", mutate: func(snapshot *RecentRouteSnapshot) { snapshot.Entries[0].Name = "other" }},
144149 {name: "entry is active", mutate: func(snapshot *RecentRouteSnapshot) {
145- snapshot.Entries = append(snapshot.Entries, RecentRouteEntry{Key: 11, Generation: 3, Name: "work", Kind: RouteKindLocal})
150+ snapshot.Entries = append(snapshot.Entries, snapshot.ActiveEntry)
146151 }},
147152 {name: "duplicate entry", mutate: func(snapshot *RecentRouteSnapshot) { snapshot.Entries = append(snapshot.Entries, snapshot.Entries[0]) }},
148153 {name: "too many entries", mutate: func(snapshot *RecentRouteSnapshot) {
149154 snapshot.Entries = make([]RecentRouteEntry, RouteSnapshotMaxEntries+1)
150155 for i := range snapshot.Entries {
151- snapshot.Entries[i] = RecentRouteEntry{Key: uint64(i + 1), Generation: uint64(i + 1), Name: "x", Kind: RouteKindLocal}
156+ target := ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{byte(i + 1)}, SessionName: "x"}
157+ snapshot.Entries[i] = RecentRouteEntry{Key: uint64(i + 1), Generation: uint64(i + 1), Target: target, Name: "x", Kind: RouteKindLocal}
152158 }
153159 }},
154160 }
@@ -195,14 +201,14 @@ func TestRouteLabelsRejectTerminalUnsafeText(t *testing.T) {
195201 t.Run("name/"+value, func(t *testing.T) {
196202 _, err := MarshalRecentRouteSnapshot(RecentRouteSnapshot{
197203 Generation: 1,
198- Entries: []RecentRouteEntry{{Key: 1, Generation: 1, Name: value, Kind: RouteKindLocal}},
204+ Entries: []RecentRouteEntry{{Key: 1, Generation: 1, Target: ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: value}, Name: value, Kind: RouteKindLocal}},
199205 })
200206 require.Error(t, err)
201207 })
202208 t.Run("host/"+value, func(t *testing.T) {
203209 _, err := MarshalRecentRouteSnapshot(RecentRouteSnapshot{
204210 Generation: 1,
205- Entries: []RecentRouteEntry{{Key: 1, Generation: 1, Name: "work", HostLabel: value, Kind: RouteKindRemote}},
211+ Entries: []RecentRouteEntry{{Key: 1, Generation: 1, Target: testExactTarget(), Name: "work", HostLabel: value, Kind: RouteKindRemote}},
206212 })
207213 require.Error(t, err)
208214 })
@@ -111,13 +111,16 @@ func TestCommittedRouteIdentityCodec(t *testing.T) {
111 }111 }
112 112
113 func testRouteSnapshot() RecentRouteSnapshot {113 func testRouteSnapshot() RecentRouteSnapshot {
114+ activeTarget := testExactTarget()
115+ logsTarget := ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{4, 5, 6}, SessionName: "logs"}
114 return RecentRouteSnapshot{116 return RecentRouteSnapshot{
115- Generation: 9,117+ Generation: 9,
116- Active: RouteRef{Key: 11, Generation: 3},118+ Active: RouteRef{Key: 11, Generation: 3},
117- Previous: RouteRef{Key: 12, Generation: 4},119+ ActiveEntry: RecentRouteEntry{Key: 11, Generation: 3, Target: activeTarget, Name: "work", Kind: RouteKindLocal, Reachability: RouteReachabilityUnknown},
118- Home: RouteRef{Key: 11, Generation: 3},120+ Previous: RouteRef{Key: 12, Generation: 4},
121+ Home: RouteRef{Key: 11, Generation: 3},
119 Entries: []RecentRouteEntry{122 Entries: []RecentRouteEntry{
120- {Key: 12, Generation: 4, Name: "logs", HostLabel: "edge", Kind: RouteKindRemote, Attention: true, Reachability: RouteReachabilityUnknown},123+ {Key: 12, Generation: 4, Target: logsTarget, Name: "logs", HostLabel: "edge", Kind: RouteKindRemote, Attention: true, Reachability: RouteReachabilityUnknown},
121 },124 },
122 }125 }
123 }126 }
@@ -131,7 +134,7 @@ func TestRecentRouteSnapshotCodecRoundTripAndBounds(t *testing.T) {
131 require.NoError(t, err)134 require.NoError(t, err)
132 require.Equal(t, want, got)135 require.Equal(t, want, got)
133 136
134- require.Equal(t, "0000000000000009000000000000000b0000000000000003000000000000000c0000000000000004000000000000000b000000000000000301000000000000000c000000000000000400046c6f677300046564676502000100", hex.EncodeToString(encoded))137+ require.Equal(t, "0000000000000009000000000000000b0000000000000003000000000000000b0000000000000003010203000000000000000000000000000004776f726b0004776f726b000001000000000000000000000c0000000000000004000000000000000b000000000000000301000000000000000c00000000000000040405060000000000000000000000000000046c6f677300046c6f677300046564676502000100", hex.EncodeToString(encoded))
135 assertAllPrefixesFail(t, encoded, UnmarshalRecentRouteSnapshot)138 assertAllPrefixesFail(t, encoded, UnmarshalRecentRouteSnapshot)
136 _, err = UnmarshalRecentRouteSnapshot(append(append([]byte(nil), encoded...), 0))139 _, err = UnmarshalRecentRouteSnapshot(append(append([]byte(nil), encoded...), 0))
137 require.Error(t, err)140 require.Error(t, err)
@@ -141,14 +144,17 @@ func TestRecentRouteSnapshotCodecRoundTripAndBounds(t *testing.T) {
141 mutate func(*RecentRouteSnapshot)144 mutate func(*RecentRouteSnapshot)
142 }{145 }{
143 {name: "missing previous reference", mutate: func(snapshot *RecentRouteSnapshot) { snapshot.Previous = RouteRef{Key: 99, Generation: 99} }},146 {name: "missing previous reference", mutate: func(snapshot *RecentRouteSnapshot) { snapshot.Previous = RouteRef{Key: 99, Generation: 99} }},
147+ {name: "missing active presentation", mutate: func(snapshot *RecentRouteSnapshot) { snapshot.ActiveEntry = RecentRouteEntry{} }},
148+ {name: "route name differs from lifecycle target", mutate: func(snapshot *RecentRouteSnapshot) { snapshot.Entries[0].Name = "other" }},
144 {name: "entry is active", mutate: func(snapshot *RecentRouteSnapshot) {149 {name: "entry is active", mutate: func(snapshot *RecentRouteSnapshot) {
145- snapshot.Entries = append(snapshot.Entries, RecentRouteEntry{Key: 11, Generation: 3, Name: "work", Kind: RouteKindLocal})150+ snapshot.Entries = append(snapshot.Entries, snapshot.ActiveEntry)
146 }},151 }},
147 {name: "duplicate entry", mutate: func(snapshot *RecentRouteSnapshot) { snapshot.Entries = append(snapshot.Entries, snapshot.Entries[0]) }},152 {name: "duplicate entry", mutate: func(snapshot *RecentRouteSnapshot) { snapshot.Entries = append(snapshot.Entries, snapshot.Entries[0]) }},
148 {name: "too many entries", mutate: func(snapshot *RecentRouteSnapshot) {153 {name: "too many entries", mutate: func(snapshot *RecentRouteSnapshot) {
149 snapshot.Entries = make([]RecentRouteEntry, RouteSnapshotMaxEntries+1)154 snapshot.Entries = make([]RecentRouteEntry, RouteSnapshotMaxEntries+1)
150 for i := range snapshot.Entries {155 for i := range snapshot.Entries {
151- snapshot.Entries[i] = RecentRouteEntry{Key: uint64(i + 1), Generation: uint64(i + 1), Name: "x", Kind: RouteKindLocal}156+ target := ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{byte(i + 1)}, SessionName: "x"}
157+ snapshot.Entries[i] = RecentRouteEntry{Key: uint64(i + 1), Generation: uint64(i + 1), Target: target, Name: "x", Kind: RouteKindLocal}
152 }158 }
153 }},159 }},
154 }160 }
@@ -195,14 +201,14 @@ func TestRouteLabelsRejectTerminalUnsafeText(t *testing.T) {
195 t.Run("name/"+value, func(t *testing.T) {201 t.Run("name/"+value, func(t *testing.T) {
196 _, err := MarshalRecentRouteSnapshot(RecentRouteSnapshot{202 _, err := MarshalRecentRouteSnapshot(RecentRouteSnapshot{
197 Generation: 1,203 Generation: 1,
198- Entries: []RecentRouteEntry{{Key: 1, Generation: 1, Name: value, Kind: RouteKindLocal}},204+ Entries: []RecentRouteEntry{{Key: 1, Generation: 1, Target: ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: value}, Name: value, Kind: RouteKindLocal}},
199 })205 })
200 require.Error(t, err)206 require.Error(t, err)
201 })207 })
202 t.Run("host/"+value, func(t *testing.T) {208 t.Run("host/"+value, func(t *testing.T) {
203 _, err := MarshalRecentRouteSnapshot(RecentRouteSnapshot{209 _, err := MarshalRecentRouteSnapshot(RecentRouteSnapshot{
204 Generation: 1,210 Generation: 1,
205- Entries: []RecentRouteEntry{{Key: 1, Generation: 1, Name: "work", HostLabel: value, Kind: RouteKindRemote}},211+ Entries: []RecentRouteEntry{{Key: 1, Generation: 1, Target: testExactTarget(), Name: "work", HostLabel: value, Kind: RouteKindRemote}},
206 })212 })
207 require.Error(t, err)213 require.Error(t, err)
208 })214 })
M internal/ports/routes_wire.go +78 -35
@@ -243,6 +243,12 @@ func validateRecentRouteEntry(entry RecentRouteEntry) error {
243243 if entry.Key == 0 || entry.Generation == 0 {
244244 return fmt.Errorf("%w: route entry identity is zero", ErrInvalidRouteWire)
245245 }
246+ if err := entry.Target.Validate(); err != nil {
247+ return fmt.Errorf("%w: invalid route lifecycle target: %v", ErrInvalidRouteWire, err)
248+ }
249+ if entry.Name != entry.Target.SessionName {
250+ return fmt.Errorf("%w: route name does not match lifecycle target", ErrInvalidRouteWire)
251+ }
246252 if err := validateRouteString(entry.Name, "route name", false); err != nil {
247253 return fmt.Errorf("%w: %v", ErrInvalidRouteWire, err)
248254 }
@@ -262,7 +268,7 @@ func validateRecentRouteSnapshot(snapshot RecentRouteSnapshot) error {
262268 if len(snapshot.Entries) > RouteSnapshotMaxEntries {
263269 return fmt.Errorf("%w: too many route entries", ErrInvalidRouteWire)
264270 }
265- if snapshot.Generation == 0 && len(snapshot.Entries) != 0 {
271+ if snapshot.Generation == 0 && (len(snapshot.Entries) != 0 || snapshot.ActiveEntry != (RecentRouteEntry{})) {
266272 return fmt.Errorf("%w: non-empty snapshot has zero generation", ErrInvalidRouteWire)
267273 }
268274 if err := validateRouteRef(snapshot.Active); err != nil {
@@ -274,6 +280,18 @@ func validateRecentRouteSnapshot(snapshot RecentRouteSnapshot) error {
274280 if err := validateRouteRef(snapshot.Home); err != nil {
275281 return err
276282 }
283+ if snapshot.Active.empty() {
284+ if snapshot.ActiveEntry != (RecentRouteEntry{}) {
285+ return fmt.Errorf("%w: active presentation has no active route", ErrInvalidRouteWire)
286+ }
287+ } else {
288+ if err := validateRecentRouteEntry(snapshot.ActiveEntry); err != nil {
289+ return err
290+ }
291+ if snapshot.Active != (RouteRef{Key: snapshot.ActiveEntry.Key, Generation: snapshot.ActiveEntry.Generation}) {
292+ return fmt.Errorf("%w: active presentation does not match active route", ErrInvalidRouteWire)
293+ }
294+ }
277295 if !snapshot.Active.empty() && snapshot.Active == snapshot.Previous {
278296 return fmt.Errorf("%w: active and previous routes are identical", ErrInvalidRouteWire)
279297 }
@@ -310,6 +328,55 @@ func validateRecentRouteSnapshot(snapshot RecentRouteSnapshot) error {
310328 return nil
311329 }
312330
331+func marshalRecentRouteEntry(w *payloadWriter, entry RecentRouteEntry) {
332+ w.putUint64(entry.Key)
333+ w.putUint64(entry.Generation)
334+ marshalExactSessionTarget(w, entry.Target)
335+ w.putString(entry.Name)
336+ w.putString(entry.HostLabel)
337+ w.putUint8(uint8(entry.Kind))
338+ w.putBool(entry.Ephemeral)
339+ w.putBool(entry.Attention)
340+ w.putUint8(uint8(entry.Reachability))
341+}
342+
343+func unmarshalRecentRouteEntry(r *payloadReader) (RecentRouteEntry, error) {
344+ var entry RecentRouteEntry
345+ var err error
346+ if entry.Key, err = r.getUint64(); err != nil {
347+ return RecentRouteEntry{}, err
348+ }
349+ if entry.Generation, err = r.getUint64(); err != nil {
350+ return RecentRouteEntry{}, err
351+ }
352+ if entry.Target, err = unmarshalExactSessionTarget(r); err != nil {
353+ return RecentRouteEntry{}, err
354+ }
355+ if entry.Name, err = r.getString(); err != nil {
356+ return RecentRouteEntry{}, err
357+ }
358+ if entry.HostLabel, err = r.getString(); err != nil {
359+ return RecentRouteEntry{}, err
360+ }
361+ kind, err := r.getUint8()
362+ if err != nil {
363+ return RecentRouteEntry{}, err
364+ }
365+ entry.Kind = RouteKind(kind)
366+ if entry.Ephemeral, err = r.getBool(); err != nil {
367+ return RecentRouteEntry{}, err
368+ }
369+ if entry.Attention, err = r.getBool(); err != nil {
370+ return RecentRouteEntry{}, err
371+ }
372+ reachability, err := r.getUint8()
373+ if err != nil {
374+ return RecentRouteEntry{}, err
375+ }
376+ entry.Reachability = RouteReachability(reachability)
377+ return entry, nil
378+}
379+
313380 // MarshalRecentRouteSnapshot encodes a complete bounded client route view.
314381 func MarshalRecentRouteSnapshot(snapshot RecentRouteSnapshot) ([]byte, error) {
315382 if err := validateRecentRouteSnapshot(snapshot); err != nil {
@@ -318,18 +385,14 @@ func MarshalRecentRouteSnapshot(snapshot RecentRouteSnapshot) ([]byte, error) {
318385 w := payloadWriter{}
319386 w.putUint64(snapshot.Generation)
320387 marshalRouteRef(&w, snapshot.Active)
388+ if !snapshot.Active.empty() {
389+ marshalRecentRouteEntry(&w, snapshot.ActiveEntry)
390+ }
321391 marshalRouteRef(&w, snapshot.Previous)
322392 marshalRouteRef(&w, snapshot.Home)
323393 w.putUint8(uint8(len(snapshot.Entries)))
324394 for _, entry := range snapshot.Entries {
325- w.putUint64(entry.Key)
326- w.putUint64(entry.Generation)
327- w.putString(entry.Name)
328- w.putString(entry.HostLabel)
329- w.putUint8(uint8(entry.Kind))
330- w.putBool(entry.Ephemeral)
331- w.putBool(entry.Attention)
332- w.putUint8(uint8(entry.Reachability))
395+ marshalRecentRouteEntry(&w, entry)
333396 }
334397 // Keep the frame bound defensive even though current entry/label bounds
335398 // make this unreachable; it protects the wire contract if those bounds
@@ -422,6 +485,11 @@ func UnmarshalRecentRouteSnapshot(b []byte) (RecentRouteSnapshot, error) {
422485 if snapshot.Active, err = unmarshalRouteRef(&r); err != nil {
423486 return RecentRouteSnapshot{}, err
424487 }
488+ if !snapshot.Active.empty() {
489+ if snapshot.ActiveEntry, err = unmarshalRecentRouteEntry(&r); err != nil {
490+ return RecentRouteSnapshot{}, err
491+ }
492+ }
425493 if snapshot.Previous, err = unmarshalRouteRef(&r); err != nil {
426494 return RecentRouteSnapshot{}, err
427495 }
@@ -439,35 +507,10 @@ func UnmarshalRecentRouteSnapshot(b []byte) (RecentRouteSnapshot, error) {
439507 snapshot.Entries = make([]RecentRouteEntry, 0, int(count))
440508 }
441509 for range int(count) {
442- var entry RecentRouteEntry
443- if entry.Key, err = r.getUint64(); err != nil {
444- return RecentRouteSnapshot{}, err
445- }
446- if entry.Generation, err = r.getUint64(); err != nil {
447- return RecentRouteSnapshot{}, err
448- }
449- if entry.Name, err = r.getString(); err != nil {
450- return RecentRouteSnapshot{}, err
451- }
452- if entry.HostLabel, err = r.getString(); err != nil {
453- return RecentRouteSnapshot{}, err
454- }
455- kind, err := r.getUint8()
456- if err != nil {
457- return RecentRouteSnapshot{}, err
458- }
459- entry.Kind = RouteKind(kind)
460- if entry.Ephemeral, err = r.getBool(); err != nil {
461- return RecentRouteSnapshot{}, err
462- }
463- if entry.Attention, err = r.getBool(); err != nil {
464- return RecentRouteSnapshot{}, err
465- }
466- reachability, err := r.getUint8()
510+ entry, err := unmarshalRecentRouteEntry(&r)
467511 if err != nil {
468512 return RecentRouteSnapshot{}, err
469513 }
470- entry.Reachability = RouteReachability(reachability)
471514 snapshot.Entries = append(snapshot.Entries, entry)
472515 }
473516 if err := r.done(); err != nil {
@@ -243,6 +243,12 @@ func validateRecentRouteEntry(entry RecentRouteEntry) error {
243 if entry.Key == 0 || entry.Generation == 0 {243 if entry.Key == 0 || entry.Generation == 0 {
244 return fmt.Errorf("%w: route entry identity is zero", ErrInvalidRouteWire)244 return fmt.Errorf("%w: route entry identity is zero", ErrInvalidRouteWire)
245 }245 }
246+ if err := entry.Target.Validate(); err != nil {
247+ return fmt.Errorf("%w: invalid route lifecycle target: %v", ErrInvalidRouteWire, err)
248+ }
249+ if entry.Name != entry.Target.SessionName {
250+ return fmt.Errorf("%w: route name does not match lifecycle target", ErrInvalidRouteWire)
251+ }
246 if err := validateRouteString(entry.Name, "route name", false); err != nil {252 if err := validateRouteString(entry.Name, "route name", false); err != nil {
247 return fmt.Errorf("%w: %v", ErrInvalidRouteWire, err)253 return fmt.Errorf("%w: %v", ErrInvalidRouteWire, err)
248 }254 }
@@ -262,7 +268,7 @@ func validateRecentRouteSnapshot(snapshot RecentRouteSnapshot) error {
262 if len(snapshot.Entries) > RouteSnapshotMaxEntries {268 if len(snapshot.Entries) > RouteSnapshotMaxEntries {
263 return fmt.Errorf("%w: too many route entries", ErrInvalidRouteWire)269 return fmt.Errorf("%w: too many route entries", ErrInvalidRouteWire)
264 }270 }
265- if snapshot.Generation == 0 && len(snapshot.Entries) != 0 {271+ if snapshot.Generation == 0 && (len(snapshot.Entries) != 0 || snapshot.ActiveEntry != (RecentRouteEntry{})) {
266 return fmt.Errorf("%w: non-empty snapshot has zero generation", ErrInvalidRouteWire)272 return fmt.Errorf("%w: non-empty snapshot has zero generation", ErrInvalidRouteWire)
267 }273 }
268 if err := validateRouteRef(snapshot.Active); err != nil {274 if err := validateRouteRef(snapshot.Active); err != nil {
@@ -274,6 +280,18 @@ func validateRecentRouteSnapshot(snapshot RecentRouteSnapshot) error {
274 if err := validateRouteRef(snapshot.Home); err != nil {280 if err := validateRouteRef(snapshot.Home); err != nil {
275 return err281 return err
276 }282 }
283+ if snapshot.Active.empty() {
284+ if snapshot.ActiveEntry != (RecentRouteEntry{}) {
285+ return fmt.Errorf("%w: active presentation has no active route", ErrInvalidRouteWire)
286+ }
287+ } else {
288+ if err := validateRecentRouteEntry(snapshot.ActiveEntry); err != nil {
289+ return err
290+ }
291+ if snapshot.Active != (RouteRef{Key: snapshot.ActiveEntry.Key, Generation: snapshot.ActiveEntry.Generation}) {
292+ return fmt.Errorf("%w: active presentation does not match active route", ErrInvalidRouteWire)
293+ }
294+ }
277 if !snapshot.Active.empty() && snapshot.Active == snapshot.Previous {295 if !snapshot.Active.empty() && snapshot.Active == snapshot.Previous {
278 return fmt.Errorf("%w: active and previous routes are identical", ErrInvalidRouteWire)296 return fmt.Errorf("%w: active and previous routes are identical", ErrInvalidRouteWire)
279 }297 }
@@ -310,6 +328,55 @@ func validateRecentRouteSnapshot(snapshot RecentRouteSnapshot) error {
310 return nil328 return nil
311 }329 }
312 330
331+func marshalRecentRouteEntry(w *payloadWriter, entry RecentRouteEntry) {
332+ w.putUint64(entry.Key)
333+ w.putUint64(entry.Generation)
334+ marshalExactSessionTarget(w, entry.Target)
335+ w.putString(entry.Name)
336+ w.putString(entry.HostLabel)
337+ w.putUint8(uint8(entry.Kind))
338+ w.putBool(entry.Ephemeral)
339+ w.putBool(entry.Attention)
340+ w.putUint8(uint8(entry.Reachability))
341+}
342+
343+func unmarshalRecentRouteEntry(r *payloadReader) (RecentRouteEntry, error) {
344+ var entry RecentRouteEntry
345+ var err error
346+ if entry.Key, err = r.getUint64(); err != nil {
347+ return RecentRouteEntry{}, err
348+ }
349+ if entry.Generation, err = r.getUint64(); err != nil {
350+ return RecentRouteEntry{}, err
351+ }
352+ if entry.Target, err = unmarshalExactSessionTarget(r); err != nil {
353+ return RecentRouteEntry{}, err
354+ }
355+ if entry.Name, err = r.getString(); err != nil {
356+ return RecentRouteEntry{}, err
357+ }
358+ if entry.HostLabel, err = r.getString(); err != nil {
359+ return RecentRouteEntry{}, err
360+ }
361+ kind, err := r.getUint8()
362+ if err != nil {
363+ return RecentRouteEntry{}, err
364+ }
365+ entry.Kind = RouteKind(kind)
366+ if entry.Ephemeral, err = r.getBool(); err != nil {
367+ return RecentRouteEntry{}, err
368+ }
369+ if entry.Attention, err = r.getBool(); err != nil {
370+ return RecentRouteEntry{}, err
371+ }
372+ reachability, err := r.getUint8()
373+ if err != nil {
374+ return RecentRouteEntry{}, err
375+ }
376+ entry.Reachability = RouteReachability(reachability)
377+ return entry, nil
378+}
379+
313 // MarshalRecentRouteSnapshot encodes a complete bounded client route view.380 // MarshalRecentRouteSnapshot encodes a complete bounded client route view.
314 func MarshalRecentRouteSnapshot(snapshot RecentRouteSnapshot) ([]byte, error) {381 func MarshalRecentRouteSnapshot(snapshot RecentRouteSnapshot) ([]byte, error) {
315 if err := validateRecentRouteSnapshot(snapshot); err != nil {382 if err := validateRecentRouteSnapshot(snapshot); err != nil {
@@ -318,18 +385,14 @@ func MarshalRecentRouteSnapshot(snapshot RecentRouteSnapshot) ([]byte, error) {
318 w := payloadWriter{}385 w := payloadWriter{}
319 w.putUint64(snapshot.Generation)386 w.putUint64(snapshot.Generation)
320 marshalRouteRef(&w, snapshot.Active)387 marshalRouteRef(&w, snapshot.Active)
388+ if !snapshot.Active.empty() {
389+ marshalRecentRouteEntry(&w, snapshot.ActiveEntry)
390+ }
321 marshalRouteRef(&w, snapshot.Previous)391 marshalRouteRef(&w, snapshot.Previous)
322 marshalRouteRef(&w, snapshot.Home)392 marshalRouteRef(&w, snapshot.Home)
323 w.putUint8(uint8(len(snapshot.Entries)))393 w.putUint8(uint8(len(snapshot.Entries)))
324 for _, entry := range snapshot.Entries {394 for _, entry := range snapshot.Entries {
325- w.putUint64(entry.Key)395+ marshalRecentRouteEntry(&w, entry)
326- w.putUint64(entry.Generation)
327- w.putString(entry.Name)
328- w.putString(entry.HostLabel)
329- w.putUint8(uint8(entry.Kind))
330- w.putBool(entry.Ephemeral)
331- w.putBool(entry.Attention)
332- w.putUint8(uint8(entry.Reachability))
333 }396 }
334 // Keep the frame bound defensive even though current entry/label bounds397 // Keep the frame bound defensive even though current entry/label bounds
335 // make this unreachable; it protects the wire contract if those bounds398 // make this unreachable; it protects the wire contract if those bounds
@@ -422,6 +485,11 @@ func UnmarshalRecentRouteSnapshot(b []byte) (RecentRouteSnapshot, error) {
422 if snapshot.Active, err = unmarshalRouteRef(&r); err != nil {485 if snapshot.Active, err = unmarshalRouteRef(&r); err != nil {
423 return RecentRouteSnapshot{}, err486 return RecentRouteSnapshot{}, err
424 }487 }
488+ if !snapshot.Active.empty() {
489+ if snapshot.ActiveEntry, err = unmarshalRecentRouteEntry(&r); err != nil {
490+ return RecentRouteSnapshot{}, err
491+ }
492+ }
425 if snapshot.Previous, err = unmarshalRouteRef(&r); err != nil {493 if snapshot.Previous, err = unmarshalRouteRef(&r); err != nil {
426 return RecentRouteSnapshot{}, err494 return RecentRouteSnapshot{}, err
427 }495 }
@@ -439,35 +507,10 @@ func UnmarshalRecentRouteSnapshot(b []byte) (RecentRouteSnapshot, error) {
439 snapshot.Entries = make([]RecentRouteEntry, 0, int(count))507 snapshot.Entries = make([]RecentRouteEntry, 0, int(count))
440 }508 }
441 for range int(count) {509 for range int(count) {
442- var entry RecentRouteEntry510+ entry, err := unmarshalRecentRouteEntry(&r)
443- if entry.Key, err = r.getUint64(); err != nil {
444- return RecentRouteSnapshot{}, err
445- }
446- if entry.Generation, err = r.getUint64(); err != nil {
447- return RecentRouteSnapshot{}, err
448- }
449- if entry.Name, err = r.getString(); err != nil {
450- return RecentRouteSnapshot{}, err
451- }
452- if entry.HostLabel, err = r.getString(); err != nil {
453- return RecentRouteSnapshot{}, err
454- }
455- kind, err := r.getUint8()
456- if err != nil {
457- return RecentRouteSnapshot{}, err
458- }
459- entry.Kind = RouteKind(kind)
460- if entry.Ephemeral, err = r.getBool(); err != nil {
461- return RecentRouteSnapshot{}, err
462- }
463- if entry.Attention, err = r.getBool(); err != nil {
464- return RecentRouteSnapshot{}, err
465- }
466- reachability, err := r.getUint8()
467 if err != nil {511 if err != nil {
468 return RecentRouteSnapshot{}, err512 return RecentRouteSnapshot{}, err
469 }513 }
470- entry.Reachability = RouteReachability(reachability)
471 snapshot.Entries = append(snapshot.Entries, entry)514 snapshot.Entries = append(snapshot.Entries, entry)
472 }515 }
473 if err := r.done(); err != nil {516 if err := r.done(); err != nil {
M internal/ports/wire.go +0 -13
@@ -169,7 +169,6 @@ type Hello struct {
169169 // PreferredTabID is a client-owned route cursor. The daemon treats it as an
170170 // attachment-local hint and falls back when the stable tab no longer exists.
171171 PreferredTabID domain.TabStableID
172- RemoteOrigin string
173172 EnvironmentPolicy EnvironmentPolicy
174173 NavigationCapabilities NavigationCapabilities
175174 StartupOverlay StartupOverlay
@@ -730,11 +729,6 @@ func ValidateHello(h Hello) error {
730729 return ErrInvalidHello
731730 }
732731 }
733- if h.RemoteOrigin != "" {
734- if err := domain.ValidateRemoteDisplayOrigin(h.RemoteOrigin); err != nil {
735- return fmt.Errorf("%w: remote origin: %v", ErrInvalidHello, err)
736- }
737- }
738732 if h.PreferredTabID != "" {
739733 if h.Intent != IntentAttach && h.Intent != IntentResume {
740734 return ErrInvalidHello
@@ -878,7 +872,6 @@ func MarshalHello(h Hello) []byte {
878872 marshalRemoteTargetSection(&w, h.RemoteTarget, h.EnvironmentPolicy)
879873 marshalExactTargetSection(&w, h.ExactTarget)
880874 w.putString(string(h.PreferredTabID))
881- w.putString(h.RemoteOrigin)
882875 w.putUint8(uint8(h.NavigationCapabilities))
883876 w.putUint8(uint8(h.StartupOverlay))
884877 return w.b
@@ -945,9 +938,6 @@ func preflightHello(b []byte) error {
945938 if err := r.skipString(); err != nil {
946939 return err
947940 }
948- if err := r.skipString(); err != nil {
949- return err
950- }
951941 if _, err := r.getUint8(); err != nil {
952942 return err
953943 }
@@ -1037,9 +1027,6 @@ func UnmarshalHello(b []byte) (Hello, error) {
10371027 return Hello{}, err
10381028 }
10391029 h.PreferredTabID = domain.TabStableID(preferredTabID)
1040- if h.RemoteOrigin, err = r.getString(); err != nil {
1041- return Hello{}, err
1042- }
10431030 capabilities, err := r.getUint8()
10441031 if err != nil {
10451032 return Hello{}, err
@@ -169,7 +169,6 @@ type Hello struct {
169 // PreferredTabID is a client-owned route cursor. The daemon treats it as an169 // PreferredTabID is a client-owned route cursor. The daemon treats it as an
170 // attachment-local hint and falls back when the stable tab no longer exists.170 // attachment-local hint and falls back when the stable tab no longer exists.
171 PreferredTabID domain.TabStableID171 PreferredTabID domain.TabStableID
172- RemoteOrigin string
173 EnvironmentPolicy EnvironmentPolicy172 EnvironmentPolicy EnvironmentPolicy
174 NavigationCapabilities NavigationCapabilities173 NavigationCapabilities NavigationCapabilities
175 StartupOverlay StartupOverlay174 StartupOverlay StartupOverlay
@@ -730,11 +729,6 @@ func ValidateHello(h Hello) error {
730 return ErrInvalidHello729 return ErrInvalidHello
731 }730 }
732 }731 }
733- if h.RemoteOrigin != "" {
734- if err := domain.ValidateRemoteDisplayOrigin(h.RemoteOrigin); err != nil {
735- return fmt.Errorf("%w: remote origin: %v", ErrInvalidHello, err)
736- }
737- }
738 if h.PreferredTabID != "" {732 if h.PreferredTabID != "" {
739 if h.Intent != IntentAttach && h.Intent != IntentResume {733 if h.Intent != IntentAttach && h.Intent != IntentResume {
740 return ErrInvalidHello734 return ErrInvalidHello
@@ -878,7 +872,6 @@ func MarshalHello(h Hello) []byte {
878 marshalRemoteTargetSection(&w, h.RemoteTarget, h.EnvironmentPolicy)872 marshalRemoteTargetSection(&w, h.RemoteTarget, h.EnvironmentPolicy)
879 marshalExactTargetSection(&w, h.ExactTarget)873 marshalExactTargetSection(&w, h.ExactTarget)
880 w.putString(string(h.PreferredTabID))874 w.putString(string(h.PreferredTabID))
881- w.putString(h.RemoteOrigin)
882 w.putUint8(uint8(h.NavigationCapabilities))875 w.putUint8(uint8(h.NavigationCapabilities))
883 w.putUint8(uint8(h.StartupOverlay))876 w.putUint8(uint8(h.StartupOverlay))
884 return w.b877 return w.b
@@ -945,9 +938,6 @@ func preflightHello(b []byte) error {
945 if err := r.skipString(); err != nil {938 if err := r.skipString(); err != nil {
946 return err939 return err
947 }940 }
948- if err := r.skipString(); err != nil {
949- return err
950- }
951 if _, err := r.getUint8(); err != nil {941 if _, err := r.getUint8(); err != nil {
952 return err942 return err
953 }943 }
@@ -1037,9 +1027,6 @@ func UnmarshalHello(b []byte) (Hello, error) {
1037 return Hello{}, err1027 return Hello{}, err
1038 }1028 }
1039 h.PreferredTabID = domain.TabStableID(preferredTabID)1029 h.PreferredTabID = domain.TabStableID(preferredTabID)
1040- if h.RemoteOrigin, err = r.getString(); err != nil {
1041- return Hello{}, err
1042- }
1043 capabilities, err := r.getUint8()1030 capabilities, err := r.getUint8()
1044 if err != nil {1031 if err != nil {
1045 return Hello{}, err1032 return Hello{}, err
M internal/ports/wire_final_test.go +4 -4
@@ -11,8 +11,8 @@ import (
1111 )
1212
1313 func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
14- if ProtocolVersion != 31 {
15- t.Fatalf("ProtocolVersion = %d, want 31", ProtocolVersion)
14+ if ProtocolVersion != 32 {
15+ t.Fatalf("ProtocolVersion = %d, want 32", ProtocolVersion)
1616 }
1717 payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}})
1818 if len(payload) < 2 {
@@ -26,8 +26,8 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
2626
2727 func TestFinalHelloGoldenStrict(t *testing.T) {
2828 msg := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}
29- want := append([]byte{0, 31, 2}, make([]byte, 16+8)...)
30- 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)
29+ want := append([]byte{0, 32, 2}, make([]byte, 16+8)...)
30+ 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)
3131 got := MarshalHello(msg)
3232 if !bytes.Equal(got, want) {
3333 t.Fatalf("Hello bytes = %x, want %x", got, want)
@@ -11,8 +11,8 @@ import (
11 )11 )
12 12
13 func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {13 func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
14- if ProtocolVersion != 31 {14+ if ProtocolVersion != 32 {
15- t.Fatalf("ProtocolVersion = %d, want 31", ProtocolVersion)15+ t.Fatalf("ProtocolVersion = %d, want 32", ProtocolVersion)
16 }16 }
17 payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}})17 payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}})
18 if len(payload) < 2 {18 if len(payload) < 2 {
@@ -26,8 +26,8 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
26 26
27 func TestFinalHelloGoldenStrict(t *testing.T) {27 func TestFinalHelloGoldenStrict(t *testing.T) {
28 msg := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}28 msg := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}
29- want := append([]byte{0, 31, 2}, make([]byte, 16+8)...)29+ want := append([]byte{0, 32, 2}, make([]byte, 16+8)...)
30- 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)30+ 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)
31 got := MarshalHello(msg)31 got := MarshalHello(msg)
32 if !bytes.Equal(got, want) {32 if !bytes.Equal(got, want) {
33 t.Fatalf("Hello bytes = %x, want %x", got, want)33 t.Fatalf("Hello bytes = %x, want %x", got, want)
M internal/ports/wire_test.go +9 -9
@@ -54,9 +54,9 @@ func assertTrailingGarbageFails[T any](t *testing.T, full []byte, unmarshal func
5454 }
5555
5656 func appendNoNavigationTail(payload []byte) []byte {
57- // exact-target absent, preferred-tab absent, remote-origin absent,
58- // navigation capabilities none, startup overlay none.
59- return append(payload, 0, 0, 0, 0, 0, 0, 0)
57+ // exact-target absent, preferred-tab absent, navigation capabilities none,
58+ // startup overlay none.
59+ return append(payload, 0, 0, 0, 0, 0)
6060 }
6161
6262 func TestHelloGoldenAndRoundTrip(t *testing.T) {
@@ -131,7 +131,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
131131 }
132132 got := MarshalHello(msg)
133133 want := []byte{
134- 0x00, 0x1f, 0x00, // version, intent, first client ID byte
134+ 0x00, 0x20, 0x00, // version, intent, first client ID byte
135135 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
136136 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
137137 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
@@ -361,7 +361,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T)
361361 }
362362 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
363363 require.Equal(t, want, MarshalTheme(cleared))
364- require.Equal(t, uint16(31), ProtocolVersion)
364+ require.Equal(t, uint16(32), ProtocolVersion)
365365 }
366366
367367 func TestResizeGoldenAndRoundTrip(t *testing.T) {
@@ -522,7 +522,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
522522 name: "minimal",
523523 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},
524524 want: []byte{
525- 0x00, 0x1f,
525+ 0x00, 0x20,
526526 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
527527 0x00, // attached
528528 0x00, // self
@@ -545,7 +545,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
545545 JSON: true,
546546 },
547547 want: []byte{
548- 0x00, 0x1f,
548+ 0x00, 0x20,
549549 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
550550 0x00, // attached
551551 0x01, // self
@@ -974,7 +974,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
974974 name: "attached",
975975 msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},
976976 want: []byte{
977- 0x00, 0x1f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
977+ 0x00, 0x20, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
978978 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
979979 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',
980980 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -984,7 +984,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
984984 name: "control",
985985 msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"},
986986 want: []byte{
987- 0x00, 0x1f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
987+ 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
988988 0x00, 0x02, 'l', 's', 0x00, 0x00,
989989 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
990990 },
@@ -54,9 +54,9 @@ func assertTrailingGarbageFails[T any](t *testing.T, full []byte, unmarshal func
54 }54 }
55 55
56 func appendNoNavigationTail(payload []byte) []byte {56 func appendNoNavigationTail(payload []byte) []byte {
57- // exact-target absent, preferred-tab absent, remote-origin absent,57+ // exact-target absent, preferred-tab absent, navigation capabilities none,
58- // navigation capabilities none, startup overlay none.58+ // startup overlay none.
59- return append(payload, 0, 0, 0, 0, 0, 0, 0)59+ return append(payload, 0, 0, 0, 0, 0)
60 }60 }
61 61
62 func TestHelloGoldenAndRoundTrip(t *testing.T) {62 func TestHelloGoldenAndRoundTrip(t *testing.T) {
@@ -131,7 +131,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
131 }131 }
132 got := MarshalHello(msg)132 got := MarshalHello(msg)
133 want := []byte{133 want := []byte{
134- 0x00, 0x1f, 0x00, // version, intent, first client ID byte134+ 0x00, 0x20, 0x00, // version, intent, first client ID byte
135 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,135 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
136 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID136 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
137 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token137 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
@@ -361,7 +361,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T)
361 }361 }
362 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)362 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
363 require.Equal(t, want, MarshalTheme(cleared))363 require.Equal(t, want, MarshalTheme(cleared))
364- require.Equal(t, uint16(31), ProtocolVersion)364+ require.Equal(t, uint16(32), ProtocolVersion)
365 }365 }
366 366
367 func TestResizeGoldenAndRoundTrip(t *testing.T) {367 func TestResizeGoldenAndRoundTrip(t *testing.T) {
@@ -522,7 +522,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
522 name: "minimal",522 name: "minimal",
523 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},523 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},
524 want: []byte{524 want: []byte{
525- 0x00, 0x1f,525+ 0x00, 0x20,
526 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID526 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
527 0x00, // attached527 0x00, // attached
528 0x00, // self528 0x00, // self
@@ -545,7 +545,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
545 JSON: true,545 JSON: true,
546 },546 },
547 want: []byte{547 want: []byte{
548- 0x00, 0x1f,548+ 0x00, 0x20,
549 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID549 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
550 0x00, // attached550 0x00, // attached
551 0x01, // self551 0x01, // self
@@ -974,7 +974,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
974 name: "attached",974 name: "attached",
975 msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},975 msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},
976 want: []byte{976 want: []byte{
977- 0x00, 0x1f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,977+ 0x00, 0x20, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
978 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',978 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
979 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',979 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',
980 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,980 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -984,7 +984,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
984 name: "control",984 name: "control",
985 msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"},985 msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"},
986 want: []byte{986 want: []byte{
987- 0x00, 0x1f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,987+ 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
988 0x00, 0x02, 'l', 's', 0x00, 0x00,988 0x00, 0x02, 'l', 's', 0x00, 0x00,
989 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,989 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
990 },990 },
M internal/usecase/client/client.go +1 -2
@@ -181,7 +181,7 @@ type AttachRequest struct {
181181 RemoteTarget *domain.RemoteSessionTarget
182182 ExactTarget *ports.ExactSessionTarget
183183 PreferredTabID domain.TabStableID
184- RemoteOrigin string
184+ HostLabel string
185185 EnvironmentPolicy ports.EnvironmentPolicy
186186 NavigationCapabilities ports.NavigationCapabilities
187187 StartupOverlay ports.StartupOverlay
@@ -1054,7 +1054,6 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
10541054 RemoteTarget: request.RemoteTarget,
10551055 ExactTarget: exactTarget,
10561056 PreferredTabID: request.PreferredTabID,
1057- RemoteOrigin: request.RemoteOrigin,
10581057 EnvironmentPolicy: request.EnvironmentPolicy,
10591058 NavigationCapabilities: request.NavigationCapabilities,
10601059 StartupOverlay: request.StartupOverlay,
@@ -181,7 +181,7 @@ type AttachRequest struct {
181 RemoteTarget *domain.RemoteSessionTarget181 RemoteTarget *domain.RemoteSessionTarget
182 ExactTarget *ports.ExactSessionTarget182 ExactTarget *ports.ExactSessionTarget
183 PreferredTabID domain.TabStableID183 PreferredTabID domain.TabStableID
184- RemoteOrigin string184+ HostLabel string
185 EnvironmentPolicy ports.EnvironmentPolicy185 EnvironmentPolicy ports.EnvironmentPolicy
186 NavigationCapabilities ports.NavigationCapabilities186 NavigationCapabilities ports.NavigationCapabilities
187 StartupOverlay ports.StartupOverlay187 StartupOverlay ports.StartupOverlay
@@ -1054,7 +1054,6 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1054 RemoteTarget: request.RemoteTarget,1054 RemoteTarget: request.RemoteTarget,
1055 ExactTarget: exactTarget,1055 ExactTarget: exactTarget,
1056 PreferredTabID: request.PreferredTabID,1056 PreferredTabID: request.PreferredTabID,
1057- RemoteOrigin: request.RemoteOrigin,
1058 EnvironmentPolicy: request.EnvironmentPolicy,1057 EnvironmentPolicy: request.EnvironmentPolicy,
1059 NavigationCapabilities: request.NavigationCapabilities,1058 NavigationCapabilities: request.NavigationCapabilities,
1060 StartupOverlay: request.StartupOverlay,1059 StartupOverlay: request.StartupOverlay,
M internal/usecase/client/routes.go +24 -14
@@ -6,6 +6,7 @@ import (
66 "fmt"
77 "sync"
88
9+ "github.com/bnema/vev/internal/domain"
910 "github.com/bnema/vev/internal/ports"
1011 )
1112
@@ -23,7 +24,7 @@ var (
2324 // routeKey is process-local identity. It is never derived from a daemon
2425 // lifecycle ID and is not exposed outside the client ledger. originKey keeps
2526 // distinct daemon endpoints separate even when their lifecycle/name pairs
26-// happen to match; neither is serialized into the route snapshot.
27+// happen to match; it is not serialized into the route snapshot.
2728 type routeKey uint64
2829
2930 type routeGeneration uint64
@@ -121,10 +122,13 @@ func routeCandidateForAttach(request AttachRequest, identity ports.CommittedRout
121122 if request.Remote || request.RemoteTarget != nil || origin != ports.RouteOriginLocal {
122123 kind = ports.RouteKindRemote
123124 if request.RemoteTarget != nil {
124- hostLabel = request.RemoteTarget.DisplayOrigin
125+ hostLabel = domain.RemoteDisplayOrigin(request.RemoteTarget.DisplayOrigin)
125126 }
126127 if hostLabel == "" {
127- hostLabel = originKey
128+ hostLabel = domain.RemoteDisplayOrigin(request.HostLabel)
129+ }
130+ if hostLabel == "" {
131+ hostLabel = domain.RemoteDisplayOrigin(originKey)
128132 }
129133 }
130134 request = cloneAttachRequest(request)
@@ -132,7 +136,7 @@ func routeCandidateForAttach(request AttachRequest, identity ports.CommittedRout
132136 request.OriginKey = originKey
133137 request.SessionName = identity.Target.SessionName
134138 request.ExactTarget = &identity.Target
135- request.RemoteOrigin = hostLabel
139+ request.HostLabel = hostLabel
136140 // Discovery targets are point-in-time attach authority, including a live
137141 // tab selector. Once Welcome commits an exact route, mutable tab memory is
138142 // carried independently by PreferredTabID.
@@ -499,6 +503,20 @@ func (l *routeLedger) attentionSubscription() ports.RouteAttentionSubscription {
499503 return subscription
500504 }
501505
506+func (r routeRecord) snapshotEntry() ports.RecentRouteEntry {
507+ return ports.RecentRouteEntry{
508+ Key: uint64(r.identity.key),
509+ Generation: uint64(r.identity.generation),
510+ Target: r.target,
511+ Name: r.presentation.name,
512+ HostLabel: r.presentation.hostLabel,
513+ Kind: r.presentation.kind,
514+ Ephemeral: r.presentation.ephemeral,
515+ Attention: r.presentation.attention,
516+ Reachability: r.presentation.reachability,
517+ }
518+}
519+
502520 func (l *routeLedger) snapshot() ports.RecentRouteSnapshot {
503521 l.mu.RLock()
504522 defer l.mu.RUnlock()
@@ -515,18 +533,10 @@ func (l *routeLedger) snapshot() ports.RecentRouteSnapshot {
515533 snapshot.Entries = make([]ports.RecentRouteEntry, 0, len(l.entries))
516534 for _, entry := range l.entries {
517535 if entry.identity == l.active {
536+ snapshot.ActiveEntry = entry.snapshotEntry()
518537 continue
519538 }
520- snapshot.Entries = append(snapshot.Entries, ports.RecentRouteEntry{
521- Key: uint64(entry.identity.key),
522- Generation: uint64(entry.identity.generation),
523- Name: entry.presentation.name,
524- HostLabel: entry.presentation.hostLabel,
525- Kind: entry.presentation.kind,
526- Ephemeral: entry.presentation.ephemeral,
527- Attention: entry.presentation.attention,
528- Reachability: entry.presentation.reachability,
529- })
539+ snapshot.Entries = append(snapshot.Entries, entry.snapshotEntry())
530540 }
531541 return snapshot
532542 }
@@ -6,6 +6,7 @@ import (
6 "fmt"6 "fmt"
7 "sync"7 "sync"
8 8
9+ "github.com/bnema/vev/internal/domain"
9 "github.com/bnema/vev/internal/ports"10 "github.com/bnema/vev/internal/ports"
10 )11 )
11 12
@@ -23,7 +24,7 @@ var (
23 // routeKey is process-local identity. It is never derived from a daemon24 // routeKey is process-local identity. It is never derived from a daemon
24 // lifecycle ID and is not exposed outside the client ledger. originKey keeps25 // lifecycle ID and is not exposed outside the client ledger. originKey keeps
25 // distinct daemon endpoints separate even when their lifecycle/name pairs26 // distinct daemon endpoints separate even when their lifecycle/name pairs
26-// happen to match; neither is serialized into the route snapshot.27+// happen to match; it is not serialized into the route snapshot.
27 type routeKey uint6428 type routeKey uint64
28 29
29 type routeGeneration uint6430 type routeGeneration uint64
@@ -121,10 +122,13 @@ func routeCandidateForAttach(request AttachRequest, identity ports.CommittedRout
121 if request.Remote || request.RemoteTarget != nil || origin != ports.RouteOriginLocal {122 if request.Remote || request.RemoteTarget != nil || origin != ports.RouteOriginLocal {
122 kind = ports.RouteKindRemote123 kind = ports.RouteKindRemote
123 if request.RemoteTarget != nil {124 if request.RemoteTarget != nil {
124- hostLabel = request.RemoteTarget.DisplayOrigin125+ hostLabel = domain.RemoteDisplayOrigin(request.RemoteTarget.DisplayOrigin)
125 }126 }
126 if hostLabel == "" {127 if hostLabel == "" {
127- hostLabel = originKey128+ hostLabel = domain.RemoteDisplayOrigin(request.HostLabel)
129+ }
130+ if hostLabel == "" {
131+ hostLabel = domain.RemoteDisplayOrigin(originKey)
128 }132 }
129 }133 }
130 request = cloneAttachRequest(request)134 request = cloneAttachRequest(request)
@@ -132,7 +136,7 @@ func routeCandidateForAttach(request AttachRequest, identity ports.CommittedRout
132 request.OriginKey = originKey136 request.OriginKey = originKey
133 request.SessionName = identity.Target.SessionName137 request.SessionName = identity.Target.SessionName
134 request.ExactTarget = &identity.Target138 request.ExactTarget = &identity.Target
135- request.RemoteOrigin = hostLabel139+ request.HostLabel = hostLabel
136 // Discovery targets are point-in-time attach authority, including a live140 // Discovery targets are point-in-time attach authority, including a live
137 // tab selector. Once Welcome commits an exact route, mutable tab memory is141 // tab selector. Once Welcome commits an exact route, mutable tab memory is
138 // carried independently by PreferredTabID.142 // carried independently by PreferredTabID.
@@ -499,6 +503,20 @@ func (l *routeLedger) attentionSubscription() ports.RouteAttentionSubscription {
499 return subscription503 return subscription
500 }504 }
501 505
506+func (r routeRecord) snapshotEntry() ports.RecentRouteEntry {
507+ return ports.RecentRouteEntry{
508+ Key: uint64(r.identity.key),
509+ Generation: uint64(r.identity.generation),
510+ Target: r.target,
511+ Name: r.presentation.name,
512+ HostLabel: r.presentation.hostLabel,
513+ Kind: r.presentation.kind,
514+ Ephemeral: r.presentation.ephemeral,
515+ Attention: r.presentation.attention,
516+ Reachability: r.presentation.reachability,
517+ }
518+}
519+
502 func (l *routeLedger) snapshot() ports.RecentRouteSnapshot {520 func (l *routeLedger) snapshot() ports.RecentRouteSnapshot {
503 l.mu.RLock()521 l.mu.RLock()
504 defer l.mu.RUnlock()522 defer l.mu.RUnlock()
@@ -515,18 +533,10 @@ func (l *routeLedger) snapshot() ports.RecentRouteSnapshot {
515 snapshot.Entries = make([]ports.RecentRouteEntry, 0, len(l.entries))533 snapshot.Entries = make([]ports.RecentRouteEntry, 0, len(l.entries))
516 for _, entry := range l.entries {534 for _, entry := range l.entries {
517 if entry.identity == l.active {535 if entry.identity == l.active {
536+ snapshot.ActiveEntry = entry.snapshotEntry()
518 continue537 continue
519 }538 }
520- snapshot.Entries = append(snapshot.Entries, ports.RecentRouteEntry{539+ snapshot.Entries = append(snapshot.Entries, entry.snapshotEntry())
521- Key: uint64(entry.identity.key),
522- Generation: uint64(entry.identity.generation),
523- Name: entry.presentation.name,
524- HostLabel: entry.presentation.hostLabel,
525- Kind: entry.presentation.kind,
526- Ephemeral: entry.presentation.ephemeral,
527- Attention: entry.presentation.attention,
528- Reachability: entry.presentation.reachability,
529- })
530 }540 }
531 return snapshot541 return snapshot
532 }542 }
M internal/usecase/client/routes_test.go +7 -3
@@ -33,15 +33,15 @@ func routeTestCandidate(index byte, origin ports.RouteOrigin) routeCandidate {
3333 }
3434 }
3535
36-func TestRouteCandidateRetainsRemoteOriginWithoutDiscoveryTarget(t *testing.T) {
36+func TestRouteCandidateRetainsHostLabelWithoutDiscoveryTarget(t *testing.T) {
3737 target := routeTestTarget(1)
3838 candidate := routeCandidateForAttach(AttachRequest{
3939 Intent: ports.IntentAttach, SessionName: target.SessionName, Remote: true,
40- Origin: ports.RouteOriginRemote, OriginKey: "remote",
40+ Origin: ports.RouteOriginRemote, OriginKey: "user@remote", HostLabel: "remote",
4141 }, ports.CommittedRouteIdentity{Target: target}, nil, 0)
4242
4343 require.Nil(t, candidate.request.RemoteTarget)
44- require.Equal(t, "remote", candidate.request.RemoteOrigin)
44+ require.Equal(t, "remote", candidate.request.HostLabel)
4545 require.Equal(t, "remote", candidate.presentation.hostLabel)
4646 }
4747
@@ -60,7 +60,11 @@ func TestRouteLedgerBoundsAndImmutableSnapshot(t *testing.T) {
6060 require.Len(t, ledger.entries, maxRouteLedgerEntries)
6161 require.Len(t, snapshot.Entries, maxRouteLedgerEntries-1)
6262 require.Equal(t, uint64(keys[len(keys)-1]), snapshot.Active.Key)
63+ require.Equal(t, routeTestTarget(maxRouteLedgerEntries+7), snapshot.ActiveEntry.Target)
64+ require.Equal(t, snapshot.Active, ports.RouteRef{Key: snapshot.ActiveEntry.Key, Generation: snapshot.ActiveEntry.Generation})
6365 for _, entry := range snapshot.Entries {
66+ require.NoError(t, entry.Target.Validate())
67+ require.Equal(t, entry.Name, entry.Target.SessionName)
6468 require.NotEqual(t, snapshot.Active, ports.RouteRef{Key: entry.Key, Generation: entry.Generation})
6569 }
6670 require.Equal(t, uint64(keys[len(keys)-2]), snapshot.Previous.Key)
@@ -33,15 +33,15 @@ func routeTestCandidate(index byte, origin ports.RouteOrigin) routeCandidate {
33 }33 }
34 }34 }
35 35
36-func TestRouteCandidateRetainsRemoteOriginWithoutDiscoveryTarget(t *testing.T) {36+func TestRouteCandidateRetainsHostLabelWithoutDiscoveryTarget(t *testing.T) {
37 target := routeTestTarget(1)37 target := routeTestTarget(1)
38 candidate := routeCandidateForAttach(AttachRequest{38 candidate := routeCandidateForAttach(AttachRequest{
39 Intent: ports.IntentAttach, SessionName: target.SessionName, Remote: true,39 Intent: ports.IntentAttach, SessionName: target.SessionName, Remote: true,
40- Origin: ports.RouteOriginRemote, OriginKey: "remote",40+ Origin: ports.RouteOriginRemote, OriginKey: "user@remote", HostLabel: "remote",
41 }, ports.CommittedRouteIdentity{Target: target}, nil, 0)41 }, ports.CommittedRouteIdentity{Target: target}, nil, 0)
42 42
43 require.Nil(t, candidate.request.RemoteTarget)43 require.Nil(t, candidate.request.RemoteTarget)
44- require.Equal(t, "remote", candidate.request.RemoteOrigin)44+ require.Equal(t, "remote", candidate.request.HostLabel)
45 require.Equal(t, "remote", candidate.presentation.hostLabel)45 require.Equal(t, "remote", candidate.presentation.hostLabel)
46 }46 }
47 47
@@ -60,7 +60,11 @@ func TestRouteLedgerBoundsAndImmutableSnapshot(t *testing.T) {
60 require.Len(t, ledger.entries, maxRouteLedgerEntries)60 require.Len(t, ledger.entries, maxRouteLedgerEntries)
61 require.Len(t, snapshot.Entries, maxRouteLedgerEntries-1)61 require.Len(t, snapshot.Entries, maxRouteLedgerEntries-1)
62 require.Equal(t, uint64(keys[len(keys)-1]), snapshot.Active.Key)62 require.Equal(t, uint64(keys[len(keys)-1]), snapshot.Active.Key)
63+ require.Equal(t, routeTestTarget(maxRouteLedgerEntries+7), snapshot.ActiveEntry.Target)
64+ require.Equal(t, snapshot.Active, ports.RouteRef{Key: snapshot.ActiveEntry.Key, Generation: snapshot.ActiveEntry.Generation})
63 for _, entry := range snapshot.Entries {65 for _, entry := range snapshot.Entries {
66+ require.NoError(t, entry.Target.Validate())
67+ require.Equal(t, entry.Name, entry.Target.SessionName)
64 require.NotEqual(t, snapshot.Active, ports.RouteRef{Key: entry.Key, Generation: entry.Generation})68 require.NotEqual(t, snapshot.Active, ports.RouteRef{Key: entry.Key, Generation: entry.Generation})
65 }69 }
66 require.Equal(t, uint64(keys[len(keys)-2]), snapshot.Previous.Key)70 require.Equal(t, uint64(keys[len(keys)-2]), snapshot.Previous.Key)
M internal/usecase/daemon/attachment_effect_gate_test.go +8 -7
@@ -676,13 +676,14 @@ func TestAttachedRouteSnapshotIsAcceptedAsAnAttachmentValue(t *testing.T) {
676676 token.lease = rc.attachmentLease(ac)
677677 ac.publishAttachmentCapability(token)
678678
679+ activeRef := ports.RouteRef{Key: 8, Generation: 4}
680+ activeTarget := testRouteTarget(source.name, 8)
679681 snapshot := ports.RecentRouteSnapshot{
680- Generation: 4,
681- Active: ports.RouteRef{Key: 8, Generation: 4},
682- Home: ports.RouteRef{Key: 8, Generation: 4},
683- Entries: []ports.RecentRouteEntry{{
684- Key: 7, Generation: 3, Name: "previous", Kind: ports.RouteKindLocal,
685- }},
682+ Generation: 4,
683+ Active: activeRef,
684+ ActiveEntry: ports.RecentRouteEntry{Key: 8, Generation: 4, Target: activeTarget, Name: source.name, Kind: ports.RouteKindLocal},
685+ Home: activeRef,
686+ Entries: []ports.RecentRouteEntry{testRouteEntry(7, 3, "previous", 7, ports.RouteKindLocal)},
686687 }
687688 payload, err := ports.MarshalRecentRouteSnapshot(snapshot)
688689 require.NoError(t, err)
@@ -703,7 +704,7 @@ func TestAttachedNavigationCommandSendsResultAfterLocalTransition(t *testing.T)
703704 Generation: 2,
704705 Active: ports.RouteRef{Key: 2, Generation: 2},
705706 Previous: ports.RouteRef{Key: 1, Generation: 1},
706- Entries: []ports.RecentRouteEntry{{Key: 1, Generation: 1, Name: "target", Kind: ports.RouteKindLocal}},
707+ Entries: []ports.RecentRouteEntry{testRouteEntry(1, 1, "target", 1, ports.RouteKindLocal)},
707708 })
708709
709710 transport := &closeTrackingTransport{}
@@ -676,13 +676,14 @@ func TestAttachedRouteSnapshotIsAcceptedAsAnAttachmentValue(t *testing.T) {
676 token.lease = rc.attachmentLease(ac)676 token.lease = rc.attachmentLease(ac)
677 ac.publishAttachmentCapability(token)677 ac.publishAttachmentCapability(token)
678 678
679+ activeRef := ports.RouteRef{Key: 8, Generation: 4}
680+ activeTarget := testRouteTarget(source.name, 8)
679 snapshot := ports.RecentRouteSnapshot{681 snapshot := ports.RecentRouteSnapshot{
680- Generation: 4,682+ Generation: 4,
681- Active: ports.RouteRef{Key: 8, Generation: 4},683+ Active: activeRef,
682- Home: ports.RouteRef{Key: 8, Generation: 4},684+ ActiveEntry: ports.RecentRouteEntry{Key: 8, Generation: 4, Target: activeTarget, Name: source.name, Kind: ports.RouteKindLocal},
683- Entries: []ports.RecentRouteEntry{{685+ Home: activeRef,
684- Key: 7, Generation: 3, Name: "previous", Kind: ports.RouteKindLocal,686+ Entries: []ports.RecentRouteEntry{testRouteEntry(7, 3, "previous", 7, ports.RouteKindLocal)},
685- }},
686 }687 }
687 payload, err := ports.MarshalRecentRouteSnapshot(snapshot)688 payload, err := ports.MarshalRecentRouteSnapshot(snapshot)
688 require.NoError(t, err)689 require.NoError(t, err)
@@ -703,7 +704,7 @@ func TestAttachedNavigationCommandSendsResultAfterLocalTransition(t *testing.T)
703 Generation: 2,704 Generation: 2,
704 Active: ports.RouteRef{Key: 2, Generation: 2},705 Active: ports.RouteRef{Key: 2, Generation: 2},
705 Previous: ports.RouteRef{Key: 1, Generation: 1},706 Previous: ports.RouteRef{Key: 1, Generation: 1},
706- Entries: []ports.RecentRouteEntry{{Key: 1, Generation: 1, Name: "target", Kind: ports.RouteKindLocal}},707+ Entries: []ports.RecentRouteEntry{testRouteEntry(1, 1, "target", 1, ports.RouteKindLocal)},
707 })708 })
708 709
709 transport := &closeTrackingTransport{}710 transport := &closeTrackingTransport{}
M internal/usecase/daemon/client.go +6 -11
@@ -33,15 +33,12 @@ type appliedTheme struct {
3333 }
3434
3535 type attachedClient struct {
36- tr ports.Transport
37- transportIncarnation uint64
38- output *outputStateStream
39- overlays *overlayRuntime
40- overlayOnce sync.Once
41- clientID [16]byte
42- // remoteOrigin is attachment-specific presentation metadata supplied by a
43- // validated picker handoff; it never participates in routing.
44- remoteOrigin string
36+ tr ports.Transport
37+ transportIncarnation uint64
38+ output *outputStateStream
39+ overlays *overlayRuntime
40+ overlayOnce sync.Once
41+ clientID [16]byte
4542 terminalCapabilities ports.TerminalCapabilities
4643 navigationCapabilities ports.NavigationCapabilities
4744 startupOverlay ports.StartupOverlay
@@ -628,7 +625,6 @@ type attachClientOptions struct {
628625 clientID [16]byte
629626 resumeCapable bool
630627 maxOutputInFlight uint8
631- remoteOrigin string
632628 terminalCapabilities ports.TerminalCapabilities
633629 capabilitiesSet bool
634630 navigationCapabilities ports.NavigationCapabilities
@@ -691,7 +687,6 @@ func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, sz domain.Size,
691687 size: sz,
692688 view: attachmentView{windowRows: sz.Rows, windowSet: true},
693689 clientID: opts.clientID,
694- remoteOrigin: opts.remoteOrigin,
695690 terminalCapabilities: opts.terminalCapabilities,
696691 navigationCapabilities: opts.navigationCapabilities,
697692 startupOverlay: opts.startupOverlay,
@@ -33,15 +33,12 @@ type appliedTheme struct {
33 }33 }
34 34
35 type attachedClient struct {35 type attachedClient struct {
36- tr ports.Transport36+ tr ports.Transport
37- transportIncarnation uint6437+ transportIncarnation uint64
38- output *outputStateStream38+ output *outputStateStream
39- overlays *overlayRuntime39+ overlays *overlayRuntime
40- overlayOnce sync.Once40+ overlayOnce sync.Once
41- clientID [16]byte41+ clientID [16]byte
42- // remoteOrigin is attachment-specific presentation metadata supplied by a
43- // validated picker handoff; it never participates in routing.
44- remoteOrigin string
45 terminalCapabilities ports.TerminalCapabilities42 terminalCapabilities ports.TerminalCapabilities
46 navigationCapabilities ports.NavigationCapabilities43 navigationCapabilities ports.NavigationCapabilities
47 startupOverlay ports.StartupOverlay44 startupOverlay ports.StartupOverlay
@@ -628,7 +625,6 @@ type attachClientOptions struct {
628 clientID [16]byte625 clientID [16]byte
629 resumeCapable bool626 resumeCapable bool
630 maxOutputInFlight uint8627 maxOutputInFlight uint8
631- remoteOrigin string
632 terminalCapabilities ports.TerminalCapabilities628 terminalCapabilities ports.TerminalCapabilities
633 capabilitiesSet bool629 capabilitiesSet bool
634 navigationCapabilities ports.NavigationCapabilities630 navigationCapabilities ports.NavigationCapabilities
@@ -691,7 +687,6 @@ func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, sz domain.Size,
691 size: sz,687 size: sz,
692 view: attachmentView{windowRows: sz.Rows, windowSet: true},688 view: attachmentView{windowRows: sz.Rows, windowSet: true},
693 clientID: opts.clientID,689 clientID: opts.clientID,
694- remoteOrigin: opts.remoteOrigin,
695 terminalCapabilities: opts.terminalCapabilities,690 terminalCapabilities: opts.terminalCapabilities,
696 navigationCapabilities: opts.navigationCapabilities,691 navigationCapabilities: opts.navigationCapabilities,
697 startupOverlay: opts.startupOverlay,692 startupOverlay: opts.startupOverlay,
M internal/usecase/daemon/daemon.go +0 -4
@@ -1288,13 +1288,9 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size,
12881288 maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight),
12891289 navigationCapabilities: h.NavigationCapabilities,
12901290 startupOverlay: h.StartupOverlay,
1291- remoteOrigin: h.RemoteOrigin,
12921291 terminalCapabilities: terminalCapabilities,
12931292 capabilitiesSet: true,
12941293 }
1295- if opts.remoteOrigin == "" && h.RemoteTarget != nil {
1296- opts.remoteOrigin = h.RemoteTarget.DisplayOrigin
1297- }
12981294 ac := d.prepareAttachedClientLocked(tr, sz, opts)
12991295 d.mu.Unlock()
13001296 result, err := d.transitionAttachment(attachmentTransitionRequest{
@@ -1288,13 +1288,9 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size,
1288 maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight),1288 maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight),
1289 navigationCapabilities: h.NavigationCapabilities,1289 navigationCapabilities: h.NavigationCapabilities,
1290 startupOverlay: h.StartupOverlay,1290 startupOverlay: h.StartupOverlay,
1291- remoteOrigin: h.RemoteOrigin,
1292 terminalCapabilities: terminalCapabilities,1291 terminalCapabilities: terminalCapabilities,
1293 capabilitiesSet: true,1292 capabilitiesSet: true,
1294 }1293 }
1295- if opts.remoteOrigin == "" && h.RemoteTarget != nil {
1296- opts.remoteOrigin = h.RemoteTarget.DisplayOrigin
1297- }
1298 ac := d.prepareAttachedClientLocked(tr, sz, opts)1294 ac := d.prepareAttachedClientLocked(tr, sz, opts)
1299 d.mu.Unlock()1295 d.mu.Unlock()
1300 result, err := d.transitionAttachment(attachmentTransitionRequest{1296 result, err := d.transitionAttachment(attachmentTransitionRequest{
M internal/usecase/daemon/input_test.go +1 -1
@@ -794,7 +794,7 @@ func TestPaletteBackSessionSendsClientPreviousRouteAction(t *testing.T) {
794794 Generation: 3,
795795 Active: ports.RouteRef{Key: 3, Generation: 3},
796796 Previous: ports.RouteRef{Key: 2, Generation: 2},
797- Entries: []ports.RecentRouteEntry{{Key: 2, Generation: 2, Name: "recent", Kind: ports.RouteKindLocal}},
797+ Entries: []ports.RecentRouteEntry{testRouteEntry(2, 2, "recent", 2, ports.RouteKindLocal)},
798798 })
799799
800800 require.NoError(t, d.backSessionForAttachment(token))
@@ -794,7 +794,7 @@ func TestPaletteBackSessionSendsClientPreviousRouteAction(t *testing.T) {
794 Generation: 3,794 Generation: 3,
795 Active: ports.RouteRef{Key: 3, Generation: 3},795 Active: ports.RouteRef{Key: 3, Generation: 3},
796 Previous: ports.RouteRef{Key: 2, Generation: 2},796 Previous: ports.RouteRef{Key: 2, Generation: 2},
797- Entries: []ports.RecentRouteEntry{{Key: 2, Generation: 2, Name: "recent", Kind: ports.RouteKindLocal}},797+ Entries: []ports.RecentRouteEntry{testRouteEntry(2, 2, "recent", 2, ports.RouteKindLocal)},
798 })798 })
799 799
800 require.NoError(t, d.backSessionForAttachment(token))800 require.NoError(t, d.backSessionForAttachment(token))
M internal/usecase/daemon/palette.go +16 -26
@@ -42,7 +42,7 @@ func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
4242 // consults its own session history for recent-route commands.
4343 routeSnapshot := ac.routeSnapshotCopy()
4444 commands := d.paletteCommands()
45- results := d.paletteResults(sess, commands, routeSnapshot, ac.remoteOrigin)
45+ results := d.paletteResults(sess, commands, routeSnapshot)
4646 ac.overlays.paletteMu.Lock()
4747 ac.overlays.paletteGeneration++
4848 ac.overlays.palette = palette.New(results)
@@ -57,7 +57,7 @@ func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
5757
5858 // paletteResults captures eligible named sessions before paletteMu so the
5959 // palette has an immutable lifecycle target without violating lock ordering.
60-func (d *Daemon) paletteResults(current *session, commands []command.Command, routeSnapshot ports.RecentRouteSnapshot, currentRemoteOrigin string) []palette.Result {
60+func (d *Daemon) paletteResults(current *session, commands []command.Command, routeSnapshot ports.RecentRouteSnapshot) []palette.Result {
6161 d.mu.Lock()
6262 sessions := d.sessionsSnapshotLocked()
6363 stopped := make([]stoppedSession, 0, len(d.stopped))
@@ -79,7 +79,8 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro
7979 }
8080 snap := candidate.snapshotView(viewOptions{})
8181 if snap.name != "" && !snap.ephemeral {
82- active = append(active, palette.NewActiveSessionResult(snap.name, time.Unix(0, snap.createdAt), snap.id))
82+ target := ports.ExactSessionTarget{LifecycleID: snap.incarnation, SessionName: snap.name}
83+ active = append(active, palette.NewActiveSessionResult(snap.name, time.Unix(0, snap.createdAt), target))
8384 }
8485 }
8586 sort.Slice(active, func(i, j int) bool {
@@ -89,14 +90,15 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro
8990 })
9091 sort.Slice(stopped, func(i, j int) bool { return stopped[i].name < stopped[j].name })
9192 results = append(results, active...)
92- localNames := make(map[string]struct{}, len(active)+len(stopped))
93+ localLifecycles := make(map[domain.SessionLifecycleID]struct{}, len(active)+len(stopped))
9394 for _, candidate := range active {
94- name, _ := candidate.SessionName()
95- localNames[name] = struct{}{}
95+ target, _ := candidate.SessionTarget()
96+ localLifecycles[target.LifecycleID] = struct{}{}
9697 }
9798 for _, candidate := range stopped {
98- results = append(results, palette.NewStoppedSessionResult(candidate.name, time.Unix(0, candidate.createdAt)))
99- localNames[candidate.name] = struct{}{}
99+ target := ports.ExactSessionTarget{LifecycleID: candidate.incarnation, SessionName: candidate.name}
100+ results = append(results, palette.NewStoppedSessionResult(candidate.name, time.Unix(0, candidate.createdAt), target))
101+ localLifecycles[target.LifecycleID] = struct{}{}
100102 }
101103 formattedRoutes := formatRecentRouteSnapshot(routeSnapshot)
102104 for i, entry := range routeSnapshot.Entries {
@@ -107,19 +109,8 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro
107109 if label == "" {
108110 continue
109111 }
110- if _, represented := localNames[entry.Name]; represented {
111- sameOrigin := (entry.Kind == ports.RouteKindLocal && currentRemoteOrigin == "") ||
112- (entry.Kind == ports.RouteKindRemote && currentRemoteOrigin != "" && entry.HostLabel == currentRemoteOrigin)
113- if sameOrigin {
114- continue
115- }
116- if entry.Kind == ports.RouteKindLocal && currentRemoteOrigin != "" {
117- label = formatRecentRouteName(recentRoutePresentation{
118- name: entry.Name,
119- kind: recentRouteLocal,
120- ephemeral: entry.Ephemeral,
121- }, true)
122- }
112+ if _, represented := localLifecycles[entry.Target.LifecycleID]; represented {
113+ continue
123114 }
124115 results = append(results, palette.NewRecentRouteResult(label, ports.RouteNavigationAction{
125116 SnapshotGeneration: routeSnapshot.Generation,
@@ -347,11 +338,10 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
347338 createdAt, _ := sessionTarget.SessionCreatedAt()
348339 expectedCreatedAt := createdAt.UnixNano()
349340 name, _ := sessionTarget.SessionName()
350- target := picker.Target{Name: name, TabIndex: -1, ExpectedCreatedAt: &expectedCreatedAt}
351- if id, active := sessionTarget.SessionID(); active {
352- target.Session = id
353- } else {
354- target.Stopped = true
341+ exactTarget, _ := sessionTarget.SessionTarget()
342+ target := picker.Target{
343+ Incarnation: exactTarget.LifecycleID, Name: name, TabIndex: -1,
344+ ExpectedCreatedAt: &expectedCreatedAt, Stopped: sessionTarget.Kind() == palette.ResultKindStoppedSession,
355345 }
356346 var err error
357347 if effect != nil {
@@ -42,7 +42,7 @@ func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
42 // consults its own session history for recent-route commands.42 // consults its own session history for recent-route commands.
43 routeSnapshot := ac.routeSnapshotCopy()43 routeSnapshot := ac.routeSnapshotCopy()
44 commands := d.paletteCommands()44 commands := d.paletteCommands()
45- results := d.paletteResults(sess, commands, routeSnapshot, ac.remoteOrigin)45+ results := d.paletteResults(sess, commands, routeSnapshot)
46 ac.overlays.paletteMu.Lock()46 ac.overlays.paletteMu.Lock()
47 ac.overlays.paletteGeneration++47 ac.overlays.paletteGeneration++
48 ac.overlays.palette = palette.New(results)48 ac.overlays.palette = palette.New(results)
@@ -57,7 +57,7 @@ func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
57 57
58 // paletteResults captures eligible named sessions before paletteMu so the58 // paletteResults captures eligible named sessions before paletteMu so the
59 // palette has an immutable lifecycle target without violating lock ordering.59 // palette has an immutable lifecycle target without violating lock ordering.
60-func (d *Daemon) paletteResults(current *session, commands []command.Command, routeSnapshot ports.RecentRouteSnapshot, currentRemoteOrigin string) []palette.Result {60+func (d *Daemon) paletteResults(current *session, commands []command.Command, routeSnapshot ports.RecentRouteSnapshot) []palette.Result {
61 d.mu.Lock()61 d.mu.Lock()
62 sessions := d.sessionsSnapshotLocked()62 sessions := d.sessionsSnapshotLocked()
63 stopped := make([]stoppedSession, 0, len(d.stopped))63 stopped := make([]stoppedSession, 0, len(d.stopped))
@@ -79,7 +79,8 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro
79 }79 }
80 snap := candidate.snapshotView(viewOptions{})80 snap := candidate.snapshotView(viewOptions{})
81 if snap.name != "" && !snap.ephemeral {81 if snap.name != "" && !snap.ephemeral {
82- active = append(active, palette.NewActiveSessionResult(snap.name, time.Unix(0, snap.createdAt), snap.id))82+ target := ports.ExactSessionTarget{LifecycleID: snap.incarnation, SessionName: snap.name}
83+ active = append(active, palette.NewActiveSessionResult(snap.name, time.Unix(0, snap.createdAt), target))
83 }84 }
84 }85 }
85 sort.Slice(active, func(i, j int) bool {86 sort.Slice(active, func(i, j int) bool {
@@ -89,14 +90,15 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro
89 })90 })
90 sort.Slice(stopped, func(i, j int) bool { return stopped[i].name < stopped[j].name })91 sort.Slice(stopped, func(i, j int) bool { return stopped[i].name < stopped[j].name })
91 results = append(results, active...)92 results = append(results, active...)
92- localNames := make(map[string]struct{}, len(active)+len(stopped))93+ localLifecycles := make(map[domain.SessionLifecycleID]struct{}, len(active)+len(stopped))
93 for _, candidate := range active {94 for _, candidate := range active {
94- name, _ := candidate.SessionName()95+ target, _ := candidate.SessionTarget()
95- localNames[name] = struct{}{}96+ localLifecycles[target.LifecycleID] = struct{}{}
96 }97 }
97 for _, candidate := range stopped {98 for _, candidate := range stopped {
98- results = append(results, palette.NewStoppedSessionResult(candidate.name, time.Unix(0, candidate.createdAt)))99+ target := ports.ExactSessionTarget{LifecycleID: candidate.incarnation, SessionName: candidate.name}
99- localNames[candidate.name] = struct{}{}100+ results = append(results, palette.NewStoppedSessionResult(candidate.name, time.Unix(0, candidate.createdAt), target))
101+ localLifecycles[target.LifecycleID] = struct{}{}
100 }102 }
101 formattedRoutes := formatRecentRouteSnapshot(routeSnapshot)103 formattedRoutes := formatRecentRouteSnapshot(routeSnapshot)
102 for i, entry := range routeSnapshot.Entries {104 for i, entry := range routeSnapshot.Entries {
@@ -107,19 +109,8 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro
107 if label == "" {109 if label == "" {
108 continue110 continue
109 }111 }
110- if _, represented := localNames[entry.Name]; represented {112+ if _, represented := localLifecycles[entry.Target.LifecycleID]; represented {
111- sameOrigin := (entry.Kind == ports.RouteKindLocal && currentRemoteOrigin == "") ||113+ continue
112- (entry.Kind == ports.RouteKindRemote && currentRemoteOrigin != "" && entry.HostLabel == currentRemoteOrigin)
113- if sameOrigin {
114- continue
115- }
116- if entry.Kind == ports.RouteKindLocal && currentRemoteOrigin != "" {
117- label = formatRecentRouteName(recentRoutePresentation{
118- name: entry.Name,
119- kind: recentRouteLocal,
120- ephemeral: entry.Ephemeral,
121- }, true)
122- }
123 }114 }
124 results = append(results, palette.NewRecentRouteResult(label, ports.RouteNavigationAction{115 results = append(results, palette.NewRecentRouteResult(label, ports.RouteNavigationAction{
125 SnapshotGeneration: routeSnapshot.Generation,116 SnapshotGeneration: routeSnapshot.Generation,
@@ -347,11 +338,10 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
347 createdAt, _ := sessionTarget.SessionCreatedAt()338 createdAt, _ := sessionTarget.SessionCreatedAt()
348 expectedCreatedAt := createdAt.UnixNano()339 expectedCreatedAt := createdAt.UnixNano()
349 name, _ := sessionTarget.SessionName()340 name, _ := sessionTarget.SessionName()
350- target := picker.Target{Name: name, TabIndex: -1, ExpectedCreatedAt: &expectedCreatedAt}341+ exactTarget, _ := sessionTarget.SessionTarget()
351- if id, active := sessionTarget.SessionID(); active {342+ target := picker.Target{
352- target.Session = id343+ Incarnation: exactTarget.LifecycleID, Name: name, TabIndex: -1,
353- } else {344+ ExpectedCreatedAt: &expectedCreatedAt, Stopped: sessionTarget.Kind() == palette.ResultKindStoppedSession,
354- target.Stopped = true
355 }345 }
356 var err error346 var err error
357 if effect != nil {347 if effect != nil {
M internal/usecase/daemon/palette_test.go +26 -34
@@ -2,6 +2,7 @@ package daemon
22
33 import (
44 "errors"
5+ "strings"
56 "sync"
67 "testing"
78
@@ -23,8 +24,8 @@ func testRecentRouteSnapshot() ports.RecentRouteSnapshot {
2324 Generation: 1,
2425 Active: ports.RouteRef{Key: 1, Generation: 1},
2526 Entries: []ports.RecentRouteEntry{
26- {Key: 2, Generation: 1, Name: "recent", Kind: ports.RouteKindLocal},
27- {Key: 3, Generation: 1, Name: "older", Kind: ports.RouteKindLocal},
27+ testRouteEntry(2, 1, "recent", 2, ports.RouteKindLocal),
28+ testRouteEntry(3, 1, "older", 3, ports.RouteKindLocal),
2829 },
2930 }
3031 }
@@ -419,43 +420,34 @@ func TestPaletteJRSUsesEffectiveOverrideOnly(t *testing.T) {
419420 require.True(t, ac.overlays.paletteActive(), "literal JRS must not execute an overridden jump command")
420421 }
421422
422-func TestPaletteResultsDeduplicateRoutesLocalToCurrentRemoteDaemon(t *testing.T) {
423+func TestPaletteResultsDeduplicateByLifecycleAndKeepEqualLabels(t *testing.T) {
423424 d := newTestDaemon(t, nil, stubClock{})
424425 current := addControlSession(d, "hi", "tab-1", "pane-1")
425- represented := addControlSession(d, "remote-manual", "tab-2", "pane-2")
426- represented.ephemeral = false
426+ local := addControlSession(d, "vev", "tab-2", "pane-2")
427+ local.ephemeral = false
428+ remoteOne := ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{21}, SessionName: "vev"}
429+ remoteTwo := ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{22}, SessionName: "vev"}
427430 snapshot := ports.RecentRouteSnapshot{
428431 Generation: 2,
429- Active: ports.RouteRef{Key: 1, Generation: 1},
430- Entries: []ports.RecentRouteEntry{{
431- Key: 2, Generation: 1, Name: "remote-manual", HostLabel: "remote", Kind: ports.RouteKindRemote,
432- }},
432+ Entries: []ports.RecentRouteEntry{
433+ {Key: 2, Generation: 1, Target: ports.ExactSessionTarget{LifecycleID: local.incarnation, SessionName: "vev"}, Name: "vev", Kind: ports.RouteKindLocal},
434+ {Key: 3, Generation: 1, Target: remoteOne, Name: "vev", HostLabel: "arch", Kind: ports.RouteKindRemote},
435+ {Key: 4, Generation: 1, Target: remoteTwo, Name: "vev", HostLabel: "arch", Kind: ports.RouteKindRemote},
436+ },
433437 }
434438
435- for _, test := range []struct {
436- name string
437- currentOrigin string
438- want []string
439- }{
440- {name: "remote daemon", currentOrigin: "remote", want: []string{"Switch to session remote-manual"}},
441- {name: "remote daemon keeps distinct local route", currentOrigin: "remote", want: []string{"Switch to session remote-manual", "Switch to session remote-manual@local"}},
442- {name: "home daemon keeps distinct remote", want: []string{"Switch to session remote-manual", "Switch to session remote-manual@remote"}},
443- } {
444- t.Run(test.name, func(t *testing.T) {
445- testSnapshot := snapshot
446- if test.name == "remote daemon keeps distinct local route" {
447- testSnapshot.Entries = append(testSnapshot.Entries, ports.RecentRouteEntry{Key: 3, Generation: 1, Name: "remote-manual", Kind: ports.RouteKindLocal})
448- }
449- results := d.paletteResults(current, nil, testSnapshot, test.currentOrigin)
450- var matching []string
451- for _, result := range results {
452- if result.DisplayText() == "Switch to session remote-manual" || result.DisplayText() == "Switch to session remote-manual@local" || result.DisplayText() == "Switch to session remote-manual@remote" {
453- matching = append(matching, result.DisplayText())
454- }
455- }
456- require.Equal(t, test.want, matching)
457- })
439+ results := d.paletteResults(current, nil, snapshot)
440+ var matching []string
441+ for _, result := range results {
442+ if strings.Contains(result.DisplayText(), "session vev") {
443+ matching = append(matching, result.DisplayText())
444+ }
458445 }
446+ require.Equal(t, []string{
447+ "Switch to session vev",
448+ "Switch to session vev@arch",
449+ "Switch to session vev@arch",
450+ }, matching)
459451 }
460452
461453 func TestPaletteFuzzyRemoteRecentRouteSendsExactNavigationAction(t *testing.T) {
@@ -465,7 +457,7 @@ func TestPaletteFuzzyRemoteRecentRouteSendsExactNavigationAction(t *testing.T) {
465457 Generation: 9,
466458 Active: ports.RouteRef{Key: 1, Generation: 1},
467459 Entries: []ports.RecentRouteEntry{{
468- Key: 8, Generation: 4, Name: "logs", HostLabel: "edge", Kind: ports.RouteKindRemote,
460+ Key: 8, Generation: 4, Target: testRouteTarget("logs", 8), Name: "logs", HostLabel: "edge", Kind: ports.RouteKindRemote,
469461 }},
470462 })
471463 token := beginRecentRoutePaletteEffect(t, d, current, ac)
@@ -543,7 +535,7 @@ func TestPaletteJRSDoesNotRevalidateTargetInDaemon(t *testing.T) {
543535 ac.setRouteSnapshot(ports.RecentRouteSnapshot{
544536 Generation: 7,
545537 Active: ports.RouteRef{Key: 1, Generation: 1},
546- Entries: []ports.RecentRouteEntry{{Key: 9, Generation: 4, Name: "captured", Kind: ports.RouteKindLocal}},
538+ Entries: []ports.RecentRouteEntry{testRouteEntry(9, 4, "captured", 9, ports.RouteKindLocal)},
547539 })
548540
549541 validated := make(chan struct{})
@@ -2,6 +2,7 @@ package daemon
2 2
3 import (3 import (
4 "errors"4 "errors"
5+ "strings"
5 "sync"6 "sync"
6 "testing"7 "testing"
7 8
@@ -23,8 +24,8 @@ func testRecentRouteSnapshot() ports.RecentRouteSnapshot {
23 Generation: 1,24 Generation: 1,
24 Active: ports.RouteRef{Key: 1, Generation: 1},25 Active: ports.RouteRef{Key: 1, Generation: 1},
25 Entries: []ports.RecentRouteEntry{26 Entries: []ports.RecentRouteEntry{
26- {Key: 2, Generation: 1, Name: "recent", Kind: ports.RouteKindLocal},27+ testRouteEntry(2, 1, "recent", 2, ports.RouteKindLocal),
27- {Key: 3, Generation: 1, Name: "older", Kind: ports.RouteKindLocal},28+ testRouteEntry(3, 1, "older", 3, ports.RouteKindLocal),
28 },29 },
29 }30 }
30 }31 }
@@ -419,43 +420,34 @@ func TestPaletteJRSUsesEffectiveOverrideOnly(t *testing.T) {
419 require.True(t, ac.overlays.paletteActive(), "literal JRS must not execute an overridden jump command")420 require.True(t, ac.overlays.paletteActive(), "literal JRS must not execute an overridden jump command")
420 }421 }
421 422
422-func TestPaletteResultsDeduplicateRoutesLocalToCurrentRemoteDaemon(t *testing.T) {423+func TestPaletteResultsDeduplicateByLifecycleAndKeepEqualLabels(t *testing.T) {
423 d := newTestDaemon(t, nil, stubClock{})424 d := newTestDaemon(t, nil, stubClock{})
424 current := addControlSession(d, "hi", "tab-1", "pane-1")425 current := addControlSession(d, "hi", "tab-1", "pane-1")
425- represented := addControlSession(d, "remote-manual", "tab-2", "pane-2")426+ local := addControlSession(d, "vev", "tab-2", "pane-2")
426- represented.ephemeral = false427+ local.ephemeral = false
428+ remoteOne := ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{21}, SessionName: "vev"}
429+ remoteTwo := ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{22}, SessionName: "vev"}
427 snapshot := ports.RecentRouteSnapshot{430 snapshot := ports.RecentRouteSnapshot{
428 Generation: 2,431 Generation: 2,
429- Active: ports.RouteRef{Key: 1, Generation: 1},432+ Entries: []ports.RecentRouteEntry{
430- Entries: []ports.RecentRouteEntry{{433+ {Key: 2, Generation: 1, Target: ports.ExactSessionTarget{LifecycleID: local.incarnation, SessionName: "vev"}, Name: "vev", Kind: ports.RouteKindLocal},
431- Key: 2, Generation: 1, Name: "remote-manual", HostLabel: "remote", Kind: ports.RouteKindRemote,434+ {Key: 3, Generation: 1, Target: remoteOne, Name: "vev", HostLabel: "arch", Kind: ports.RouteKindRemote},
432- }},435+ {Key: 4, Generation: 1, Target: remoteTwo, Name: "vev", HostLabel: "arch", Kind: ports.RouteKindRemote},
436+ },
433 }437 }
434 438
435- for _, test := range []struct {439+ results := d.paletteResults(current, nil, snapshot)
436- name string440+ var matching []string
437- currentOrigin string441+ for _, result := range results {
438- want []string442+ if strings.Contains(result.DisplayText(), "session vev") {
439- }{443+ matching = append(matching, result.DisplayText())
440- {name: "remote daemon", currentOrigin: "remote", want: []string{"Switch to session remote-manual"}},444+ }
441- {name: "remote daemon keeps distinct local route", currentOrigin: "remote", want: []string{"Switch to session remote-manual", "Switch to session remote-manual@local"}},
442- {name: "home daemon keeps distinct remote", want: []string{"Switch to session remote-manual", "Switch to session remote-manual@remote"}},
443- } {
444- t.Run(test.name, func(t *testing.T) {
445- testSnapshot := snapshot
446- if test.name == "remote daemon keeps distinct local route" {
447- testSnapshot.Entries = append(testSnapshot.Entries, ports.RecentRouteEntry{Key: 3, Generation: 1, Name: "remote-manual", Kind: ports.RouteKindLocal})
448- }
449- results := d.paletteResults(current, nil, testSnapshot, test.currentOrigin)
450- var matching []string
451- for _, result := range results {
452- if result.DisplayText() == "Switch to session remote-manual" || result.DisplayText() == "Switch to session remote-manual@local" || result.DisplayText() == "Switch to session remote-manual@remote" {
453- matching = append(matching, result.DisplayText())
454- }
455- }
456- require.Equal(t, test.want, matching)
457- })
458 }445 }
446+ require.Equal(t, []string{
447+ "Switch to session vev",
448+ "Switch to session vev@arch",
449+ "Switch to session vev@arch",
450+ }, matching)
459 }451 }
460 452
461 func TestPaletteFuzzyRemoteRecentRouteSendsExactNavigationAction(t *testing.T) {453 func TestPaletteFuzzyRemoteRecentRouteSendsExactNavigationAction(t *testing.T) {
@@ -465,7 +457,7 @@ func TestPaletteFuzzyRemoteRecentRouteSendsExactNavigationAction(t *testing.T) {
465 Generation: 9,457 Generation: 9,
466 Active: ports.RouteRef{Key: 1, Generation: 1},458 Active: ports.RouteRef{Key: 1, Generation: 1},
467 Entries: []ports.RecentRouteEntry{{459 Entries: []ports.RecentRouteEntry{{
468- Key: 8, Generation: 4, Name: "logs", HostLabel: "edge", Kind: ports.RouteKindRemote,460+ Key: 8, Generation: 4, Target: testRouteTarget("logs", 8), Name: "logs", HostLabel: "edge", Kind: ports.RouteKindRemote,
469 }},461 }},
470 })462 })
471 token := beginRecentRoutePaletteEffect(t, d, current, ac)463 token := beginRecentRoutePaletteEffect(t, d, current, ac)
@@ -543,7 +535,7 @@ func TestPaletteJRSDoesNotRevalidateTargetInDaemon(t *testing.T) {
543 ac.setRouteSnapshot(ports.RecentRouteSnapshot{535 ac.setRouteSnapshot(ports.RecentRouteSnapshot{
544 Generation: 7,536 Generation: 7,
545 Active: ports.RouteRef{Key: 1, Generation: 1},537 Active: ports.RouteRef{Key: 1, Generation: 1},
546- Entries: []ports.RecentRouteEntry{{Key: 9, Generation: 4, Name: "captured", Kind: ports.RouteKindLocal}},538+ Entries: []ports.RecentRouteEntry{testRouteEntry(9, 4, "captured", 9, ports.RouteKindLocal)},
547 })539 })
548 540
549 validated := make(chan struct{})541 validated := make(chan struct{})
M internal/usecase/daemon/recent_route_presentation_test.go +2 -2
@@ -79,8 +79,8 @@ func TestRecentSessionHintsAndRankedRenderingShareCanonicalLabels(t *testing.T)
7979 snapshot := ports.RecentRouteSnapshot{
8080 Generation: 1,
8181 Entries: []ports.RecentRouteEntry{
82- {Key: 1, Generation: 1, Name: "logs", Kind: ports.RouteKindLocal, Attention: true},
83- {Key: 2, Generation: 1, Name: "logs", Kind: ports.RouteKindLocal},
82+ {Key: 1, Generation: 1, Target: testRouteTarget("logs", 1), Name: "logs", Kind: ports.RouteKindLocal, Attention: true},
83+ testRouteEntry(2, 1, "logs", 2, ports.RouteKindLocal),
8484 },
8585 }
8686
@@ -79,8 +79,8 @@ func TestRecentSessionHintsAndRankedRenderingShareCanonicalLabels(t *testing.T)
79 snapshot := ports.RecentRouteSnapshot{79 snapshot := ports.RecentRouteSnapshot{
80 Generation: 1,80 Generation: 1,
81 Entries: []ports.RecentRouteEntry{81 Entries: []ports.RecentRouteEntry{
82- {Key: 1, Generation: 1, Name: "logs", Kind: ports.RouteKindLocal, Attention: true},82+ {Key: 1, Generation: 1, Target: testRouteTarget("logs", 1), Name: "logs", Kind: ports.RouteKindLocal, Attention: true},
83- {Key: 2, Generation: 1, Name: "logs", Kind: ports.RouteKindLocal},83+ testRouteEntry(2, 1, "logs", 2, ports.RouteKindLocal),
84 },84 },
85 }85 }
86 86
M internal/usecase/daemon/recent_route_snapshot.go +5 -2
@@ -1,6 +1,9 @@
11 package daemon
22
3-import "github.com/bnema/vev/internal/ports"
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/ports"
6+)
47
58 func recentRoutePresentationsFromSnapshot(snapshot ports.RecentRouteSnapshot) []recentRoutePresentation {
69 if snapshot.Entries == nil {
@@ -14,7 +17,7 @@ func recentRoutePresentationsFromSnapshot(snapshot ports.RecentRouteSnapshot) []
1417 }
1518 out[i] = recentRoutePresentation{
1619 name: entry.Name,
17- hostLabel: entry.HostLabel,
20+ hostLabel: domain.RemoteDisplayOrigin(entry.HostLabel),
1821 kind: kind,
1922 ephemeral: entry.Ephemeral,
2023 attention: entry.Attention,
@@ -1,6 +1,9 @@
1 package daemon1 package daemon
2 2
3-import "github.com/bnema/vev/internal/ports"3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/ports"
6+)
4 7
5 func recentRoutePresentationsFromSnapshot(snapshot ports.RecentRouteSnapshot) []recentRoutePresentation {8 func recentRoutePresentationsFromSnapshot(snapshot ports.RecentRouteSnapshot) []recentRoutePresentation {
6 if snapshot.Entries == nil {9 if snapshot.Entries == nil {
@@ -14,7 +17,7 @@ func recentRoutePresentationsFromSnapshot(snapshot ports.RecentRouteSnapshot) []
14 }17 }
15 out[i] = recentRoutePresentation{18 out[i] = recentRoutePresentation{
16 name: entry.Name,19 name: entry.Name,
17- hostLabel: entry.HostLabel,20+ hostLabel: domain.RemoteDisplayOrigin(entry.HostLabel),
18 kind: kind,21 kind: kind,
19 ephemeral: entry.Ephemeral,22 ephemeral: entry.Ephemeral,
20 attention: entry.Attention,23 attention: entry.Attention,
M internal/usecase/daemon/recent_route_snapshot_test.go +6 -6
@@ -14,8 +14,8 @@ func TestAttachmentStatusUsesClientRouteSnapshot(t *testing.T) {
1414 Generation: 2,
1515 Active: ports.RouteRef{Key: 3, Generation: 2},
1616 Entries: []ports.RecentRouteEntry{
17- {Key: 2, Generation: 1, Name: "logs", HostLabel: "edge", Kind: ports.RouteKindRemote},
18- {Key: 1, Generation: 1, Name: "work", Kind: ports.RouteKindLocal},
17+ {Key: 2, Generation: 1, Target: testRouteTarget("logs", 2), Name: "logs", HostLabel: "user@edge", Kind: ports.RouteKindRemote},
18+ testRouteEntry(1, 1, "work", 1, ports.RouteKindLocal),
1919 },
2020 })
2121
@@ -44,7 +44,7 @@ func TestAttachmentStatusResolvesSubscribedRouteAttention(t *testing.T) {
4444 ac.setRouteSnapshot(ports.RecentRouteSnapshot{
4545 Generation: 2,
4646 Active: ports.RouteRef{Key: 3, Generation: 2},
47- Entries: []ports.RecentRouteEntry{{Key: ref.Key, Generation: ref.Generation, Name: sess.name, Kind: ports.RouteKindLocal}},
47+ Entries: []ports.RecentRouteEntry{{Key: ref.Key, Generation: ref.Generation, Target: ports.ExactSessionTarget{LifecycleID: tt.targetID, SessionName: sess.name}, Name: sess.name, Kind: ports.RouteKindLocal}},
4848 })
4949 ac.setRouteAttentionSubscription(ports.RouteAttentionSubscription{Targets: []ports.RouteAttentionTarget{{
5050 Ref: ref,
@@ -77,7 +77,7 @@ func TestRecentRouteSnapshotRepaintsWithoutDeferredIdentity(t *testing.T) {
7777
7878 func TestAttachmentRouteSnapshotCopiesPublishedEntries(t *testing.T) {
7979 _, _, ac, _ := newManualSessionWithPTYs(t, nil)
80- entries := []ports.RecentRouteEntry{{Key: 1, Generation: 2, Name: "before", Kind: ports.RouteKindLocal}}
80+ entries := []ports.RecentRouteEntry{testRouteEntry(1, 2, "before", 1, ports.RouteKindLocal)}
8181 snapshot := ports.RecentRouteSnapshot{Generation: 3, Entries: entries}
8282 ac.setRouteSnapshot(snapshot)
8383
@@ -105,7 +105,7 @@ func TestPaletteRecentRouteSelectionSendsTypedClientAction(t *testing.T) {
105105 d: d, sess: source, attachment: source, ac: ac,
106106 routeSnapshot: ports.RecentRouteSnapshot{
107107 Generation: 4,
108- Entries: []ports.RecentRouteEntry{{Key: 2, Generation: 3, Name: "remote", Kind: ports.RouteKindRemote}},
108+ Entries: []ports.RecentRouteEntry{testRouteEntry(2, 3, "remote", 2, ports.RouteKindRemote)},
109109 },
110110 effect: effect,
111111 }
@@ -131,7 +131,7 @@ func TestRecentRouteHintsRetainSnapshotSelectionIdentity(t *testing.T) {
131131 snapshot := ports.RecentRouteSnapshot{
132132 Generation: 8,
133133 Entries: []ports.RecentRouteEntry{{
134- Key: 11, Generation: 7, Name: "logs", HostLabel: "edge", Kind: ports.RouteKindRemote,
134+ Key: 11, Generation: 7, Target: testRouteTarget("logs", 11), Name: "logs", HostLabel: "edge", Kind: ports.RouteKindRemote,
135135 }},
136136 }
137137
@@ -14,8 +14,8 @@ func TestAttachmentStatusUsesClientRouteSnapshot(t *testing.T) {
14 Generation: 2,14 Generation: 2,
15 Active: ports.RouteRef{Key: 3, Generation: 2},15 Active: ports.RouteRef{Key: 3, Generation: 2},
16 Entries: []ports.RecentRouteEntry{16 Entries: []ports.RecentRouteEntry{
17- {Key: 2, Generation: 1, Name: "logs", HostLabel: "edge", Kind: ports.RouteKindRemote},17+ {Key: 2, Generation: 1, Target: testRouteTarget("logs", 2), Name: "logs", HostLabel: "user@edge", Kind: ports.RouteKindRemote},
18- {Key: 1, Generation: 1, Name: "work", Kind: ports.RouteKindLocal},18+ testRouteEntry(1, 1, "work", 1, ports.RouteKindLocal),
19 },19 },
20 })20 })
21 21
@@ -44,7 +44,7 @@ func TestAttachmentStatusResolvesSubscribedRouteAttention(t *testing.T) {
44 ac.setRouteSnapshot(ports.RecentRouteSnapshot{44 ac.setRouteSnapshot(ports.RecentRouteSnapshot{
45 Generation: 2,45 Generation: 2,
46 Active: ports.RouteRef{Key: 3, Generation: 2},46 Active: ports.RouteRef{Key: 3, Generation: 2},
47- Entries: []ports.RecentRouteEntry{{Key: ref.Key, Generation: ref.Generation, Name: sess.name, Kind: ports.RouteKindLocal}},47+ Entries: []ports.RecentRouteEntry{{Key: ref.Key, Generation: ref.Generation, Target: ports.ExactSessionTarget{LifecycleID: tt.targetID, SessionName: sess.name}, Name: sess.name, Kind: ports.RouteKindLocal}},
48 })48 })
49 ac.setRouteAttentionSubscription(ports.RouteAttentionSubscription{Targets: []ports.RouteAttentionTarget{{49 ac.setRouteAttentionSubscription(ports.RouteAttentionSubscription{Targets: []ports.RouteAttentionTarget{{
50 Ref: ref,50 Ref: ref,
@@ -77,7 +77,7 @@ func TestRecentRouteSnapshotRepaintsWithoutDeferredIdentity(t *testing.T) {
77 77
78 func TestAttachmentRouteSnapshotCopiesPublishedEntries(t *testing.T) {78 func TestAttachmentRouteSnapshotCopiesPublishedEntries(t *testing.T) {
79 _, _, ac, _ := newManualSessionWithPTYs(t, nil)79 _, _, ac, _ := newManualSessionWithPTYs(t, nil)
80- entries := []ports.RecentRouteEntry{{Key: 1, Generation: 2, Name: "before", Kind: ports.RouteKindLocal}}80+ entries := []ports.RecentRouteEntry{testRouteEntry(1, 2, "before", 1, ports.RouteKindLocal)}
81 snapshot := ports.RecentRouteSnapshot{Generation: 3, Entries: entries}81 snapshot := ports.RecentRouteSnapshot{Generation: 3, Entries: entries}
82 ac.setRouteSnapshot(snapshot)82 ac.setRouteSnapshot(snapshot)
83 83
@@ -105,7 +105,7 @@ func TestPaletteRecentRouteSelectionSendsTypedClientAction(t *testing.T) {
105 d: d, sess: source, attachment: source, ac: ac,105 d: d, sess: source, attachment: source, ac: ac,
106 routeSnapshot: ports.RecentRouteSnapshot{106 routeSnapshot: ports.RecentRouteSnapshot{
107 Generation: 4,107 Generation: 4,
108- Entries: []ports.RecentRouteEntry{{Key: 2, Generation: 3, Name: "remote", Kind: ports.RouteKindRemote}},108+ Entries: []ports.RecentRouteEntry{testRouteEntry(2, 3, "remote", 2, ports.RouteKindRemote)},
109 },109 },
110 effect: effect,110 effect: effect,
111 }111 }
@@ -131,7 +131,7 @@ func TestRecentRouteHintsRetainSnapshotSelectionIdentity(t *testing.T) {
131 snapshot := ports.RecentRouteSnapshot{131 snapshot := ports.RecentRouteSnapshot{
132 Generation: 8,132 Generation: 8,
133 Entries: []ports.RecentRouteEntry{{133 Entries: []ports.RecentRouteEntry{{
134- Key: 11, Generation: 7, Name: "logs", HostLabel: "edge", Kind: ports.RouteKindRemote,134+ Key: 11, Generation: 7, Target: testRouteTarget("logs", 11), Name: "logs", HostLabel: "edge", Kind: ports.RouteKindRemote,
135 }},135 }},
136 }136 }
137 137
A internal/usecase/daemon/route_identity_test_helpers_test.go +17 -0
@@ -0,0 +1,17 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/ports"
6+)
7+
8+func testRouteTarget(name string, marker byte) ports.ExactSessionTarget {
9+ return ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{marker}, SessionName: name}
10+}
11+
12+func testRouteEntry(key, generation uint64, name string, marker byte, kind ports.RouteKind) ports.RecentRouteEntry {
13+ return ports.RecentRouteEntry{
14+ Key: key, Generation: generation, Target: testRouteTarget(name, marker),
15+ Name: name, Kind: kind,
16+ }
17+}
@@ -0,0 +1,17 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/ports"
6+)
7+
8+func testRouteTarget(name string, marker byte) ports.ExactSessionTarget {
9+ return ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{marker}, SessionName: name}
10+}
11+
12+func testRouteEntry(key, generation uint64, name string, marker byte, kind ports.RouteKind) ports.RecentRouteEntry {
13+ return ports.RecentRouteEntry{
14+ Key: key, Generation: generation, Target: testRouteTarget(name, marker),
15+ Name: name, Kind: kind,
16+ }
17+}
M internal/usecase/daemon/route_position_test.go +0 -18
@@ -10,24 +10,6 @@ import (
1010 "github.com/bnema/vev/internal/ports"
1111 )
1212
13-func TestRouteRetainsRemoteOriginWithoutDiscoveryTarget(t *testing.T) {
14- d := newTestDaemon(t, nil, stubClock{})
15- sess := addControlSession(d, "work", "tab-1", "pane-1")
16- sess.incarnation = domain.SessionLifecycleID{1}
17- tr := &closeTrackingTransport{}
18- target := ports.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}
19- hello := ports.Hello{
20- Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: sess.name,
21- Size: defaultSize, ExactTarget: &target, RemoteOrigin: "remote",
22- EnvironmentPolicy: ports.EnvironmentPolicyClientOwned,
23- }
24-
25- _, ac, err := d.routeWithContext(context.Background(), hello, tr)
26- require.NoError(t, err)
27- require.Equal(t, "remote", ac.remoteOrigin)
28- d.clientGone(sess, ac, tr, false)
29-}
30-
3113 func TestRouteAppliesPreferredTabPerAttachment(t *testing.T) {
3214 for _, test := range []struct {
3315 name string
@@ -10,24 +10,6 @@ import (
10 "github.com/bnema/vev/internal/ports"10 "github.com/bnema/vev/internal/ports"
11 )11 )
12 12
13-func TestRouteRetainsRemoteOriginWithoutDiscoveryTarget(t *testing.T) {
14- d := newTestDaemon(t, nil, stubClock{})
15- sess := addControlSession(d, "work", "tab-1", "pane-1")
16- sess.incarnation = domain.SessionLifecycleID{1}
17- tr := &closeTrackingTransport{}
18- target := ports.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}
19- hello := ports.Hello{
20- Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: sess.name,
21- Size: defaultSize, ExactTarget: &target, RemoteOrigin: "remote",
22- EnvironmentPolicy: ports.EnvironmentPolicyClientOwned,
23- }
24-
25- _, ac, err := d.routeWithContext(context.Background(), hello, tr)
26- require.NoError(t, err)
27- require.Equal(t, "remote", ac.remoteOrigin)
28- d.clientGone(sess, ac, tr, false)
29-}
30-
31 func TestRouteAppliesPreferredTabPerAttachment(t *testing.T) {13 func TestRouteAppliesPreferredTabPerAttachment(t *testing.T) {
32 for _, test := range []struct {14 for _, test := range []struct {
33 name string15 name string
M internal/usecase/daemon/session_back_route_test.go +1 -3
@@ -23,9 +23,7 @@ func TestBackSessionUsesClientPreviousRouteAfterSnapshotPublication(t *testing.T
2323 Generation: 5,
2424 Active: ports.RouteRef{Key: 9, Generation: 5},
2525 Previous: ports.RouteRef{Key: 7, Generation: 3},
26- Entries: []ports.RecentRouteEntry{{
27- Key: 7, Generation: 3, Name: "previous", Kind: ports.RouteKindLocal,
28- }},
26+ Entries: []ports.RecentRouteEntry{testRouteEntry(7, 3, "previous", 7, ports.RouteKindLocal)},
2927 })
3028
3129 require.NoError(t, d.backSessionForAttachment(token))
@@ -23,9 +23,7 @@ func TestBackSessionUsesClientPreviousRouteAfterSnapshotPublication(t *testing.T
23 Generation: 5,23 Generation: 5,
24 Active: ports.RouteRef{Key: 9, Generation: 5},24 Active: ports.RouteRef{Key: 9, Generation: 5},
25 Previous: ports.RouteRef{Key: 7, Generation: 3},25 Previous: ports.RouteRef{Key: 7, Generation: 3},
26- Entries: []ports.RecentRouteEntry{{26+ Entries: []ports.RecentRouteEntry{testRouteEntry(7, 3, "previous", 7, ports.RouteKindLocal)},
27- Key: 7, Generation: 3, Name: "previous", Kind: ports.RouteKindLocal,
28- }},
29 })27 })
30 28
31 require.NoError(t, d.backSessionForAttachment(token))29 require.NoError(t, d.backSessionForAttachment(token))
M internal/usecase/daemon/session_rename_purge_test.go +5 -3
@@ -46,10 +46,12 @@ func TestRenameDefersCommittedRouteIdentityUntilFirstRouteSnapshot(t *testing.T)
4646 require.NoError(t, d.renameSession(sess, "vps-infra"))
4747 require.Empty(t, sends, "the route ledger must be published before its identity can be updated")
4848
49+ activeRef := ports.RouteRef{Key: 1, Generation: 1}
4950 snapshot := ports.RecentRouteSnapshot{
50- Generation: 1,
51- Active: ports.RouteRef{Key: 1, Generation: 1},
52- Home: ports.RouteRef{Key: 1, Generation: 1},
51+ Generation: 1,
52+ Active: activeRef,
53+ ActiveEntry: ports.RecentRouteEntry{Key: 1, Generation: 1, Target: testRouteTarget("0", 1), Name: "0", Kind: ports.RouteKindLocal},
54+ Home: activeRef,
5355 }
5456 payload, err := ports.MarshalRecentRouteSnapshot(snapshot)
5557 require.NoError(t, err)
@@ -46,10 +46,12 @@ func TestRenameDefersCommittedRouteIdentityUntilFirstRouteSnapshot(t *testing.T)
46 require.NoError(t, d.renameSession(sess, "vps-infra"))46 require.NoError(t, d.renameSession(sess, "vps-infra"))
47 require.Empty(t, sends, "the route ledger must be published before its identity can be updated")47 require.Empty(t, sends, "the route ledger must be published before its identity can be updated")
48 48
49+ activeRef := ports.RouteRef{Key: 1, Generation: 1}
49 snapshot := ports.RecentRouteSnapshot{50 snapshot := ports.RecentRouteSnapshot{
50- Generation: 1,51+ Generation: 1,
51- Active: ports.RouteRef{Key: 1, Generation: 1},52+ Active: activeRef,
52- Home: ports.RouteRef{Key: 1, Generation: 1},53+ ActiveEntry: ports.RecentRouteEntry{Key: 1, Generation: 1, Target: testRouteTarget("0", 1), Name: "0", Kind: ports.RouteKindLocal},
54+ Home: activeRef,
53 }55 }
54 payload, err := ports.MarshalRecentRouteSnapshot(snapshot)56 payload, err := ports.MarshalRecentRouteSnapshot(snapshot)
55 require.NoError(t, err)57 require.NoError(t, err)
M internal/usecase/daemon/status.go +14 -6
@@ -189,15 +189,23 @@ func (s *session) statusSegments(includeTerminalTitle bool) statusSnapshot {
189189 }
190190
191191 func (s *session) statusSegmentsFor(ac *attachedClient, includeTerminalTitle bool) statusSnapshot {
192+ var routeSnapshot ports.RecentRouteSnapshot
193+ if ac != nil {
194+ routeSnapshot = ac.routeSnapshotCopy()
195+ }
196+
192197 s.mu.Lock()
193198 defer s.mu.Unlock()
194- name := s.name
195- if s.ephemeral {
196- name += "*"
197- }
198- if ac != nil && ac.remoteOrigin != "" {
199- name += "@" + ac.remoteOrigin
199+ presentation := recentRoutePresentation{name: s.name, ephemeral: s.ephemeral}
200+ active := routeSnapshot.ActiveEntry
201+ activeRef := ports.RouteRef{Key: active.Key, Generation: active.Generation}
202+ if s.incarnation != (domain.SessionLifecycleID{}) && active.Target.LifecycleID == s.incarnation && routeSnapshot.Active == activeRef {
203+ presentation.hostLabel = domain.RemoteDisplayOrigin(active.HostLabel)
204+ if active.Kind == ports.RouteKindRemote {
205+ presentation.kind = recentRouteRemote
206+ }
200207 }
208+ name := formatRecentRouteName(presentation, false)
201209 snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))}
202210 activeIndex := 0
203211 if ac != nil {
@@ -189,15 +189,23 @@ func (s *session) statusSegments(includeTerminalTitle bool) statusSnapshot {
189 }189 }
190 190
191 func (s *session) statusSegmentsFor(ac *attachedClient, includeTerminalTitle bool) statusSnapshot {191 func (s *session) statusSegmentsFor(ac *attachedClient, includeTerminalTitle bool) statusSnapshot {
192+ var routeSnapshot ports.RecentRouteSnapshot
193+ if ac != nil {
194+ routeSnapshot = ac.routeSnapshotCopy()
195+ }
196+
192 s.mu.Lock()197 s.mu.Lock()
193 defer s.mu.Unlock()198 defer s.mu.Unlock()
194- name := s.name199+ presentation := recentRoutePresentation{name: s.name, ephemeral: s.ephemeral}
195- if s.ephemeral {200+ active := routeSnapshot.ActiveEntry
196- name += "*"201+ activeRef := ports.RouteRef{Key: active.Key, Generation: active.Generation}
197- }202+ if s.incarnation != (domain.SessionLifecycleID{}) && active.Target.LifecycleID == s.incarnation && routeSnapshot.Active == activeRef {
198- if ac != nil && ac.remoteOrigin != "" {203+ presentation.hostLabel = domain.RemoteDisplayOrigin(active.HostLabel)
199- name += "@" + ac.remoteOrigin204+ if active.Kind == ports.RouteKindRemote {
205+ presentation.kind = recentRouteRemote
206+ }
200 }207 }
208+ name := formatRecentRouteName(presentation, false)
201 snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))}209 snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))}
202 activeIndex := 0210 activeIndex := 0
203 if ac != nil {211 if ac != nil {
M internal/usecase/daemon/status_test.go +23 -21
@@ -109,29 +109,31 @@ func TestStatusCompositionGolden(t *testing.T) {
109109 }
110110 }
111111
112-func TestStatusSegmentsIncludesAttachmentRemoteOrigin(t *testing.T) {
113- tests := []struct {
114- name string
115- remoteOrigin string
116- wantAttachmentBar string
117- }{
118- {name: "remote origin", remoteOrigin: "arch", wantAttachmentBar: "vive@arch"},
119- {name: "local attachment", wantAttachmentBar: "vive"},
120- }
112+func TestStatusSegmentsResolvesActiveLifecyclePresentation(t *testing.T) {
113+ p, releasePTY := newBlockingPTY(t)
114+ _, sess, ac, _ := newManualSessionWithPTYs(t, p)
115+ defer releasePTY()
121116
122- for _, test := range tests {
123- t.Run(test.name, func(t *testing.T) {
124- p, releasePTY := newBlockingPTY(t)
125- _, sess, ac, _ := newManualSessionWithPTYs(t, p)
126- defer releasePTY()
117+ sess.name = "vive"
118+ sess.incarnation = domain.SessionLifecycleID{1}
119+ ref := ports.RouteRef{Key: 1, Generation: 1}
120+ target := ports.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}
121+ snapshot := ports.RecentRouteSnapshot{
122+ Generation: 1,
123+ Active: ref,
124+ ActiveEntry: ports.RecentRouteEntry{
125+ Key: ref.Key, Generation: ref.Generation, Target: target,
126+ Name: "vive", HostLabel: "user@arch", Kind: ports.RouteKindRemote,
127+ },
128+ }
129+ ac.setRouteSnapshot(snapshot)
127130
128- sess.name = "vive"
129- ac.remoteOrigin = test.remoteOrigin
131+ require.Equal(t, "vive@arch", sess.statusSegmentsFor(ac, true).session)
132+ require.Equal(t, "vive", sess.statusSegments(true).session, "route presentation belongs only to the selected attachment")
130133
131- require.Equal(t, test.wantAttachmentBar, sess.statusSegmentsFor(ac, true).session)
132- require.Equal(t, "vive", sess.statusSegments(true).session, "remote provenance belongs only to the selected attachment")
133- })
134- }
134+ snapshot.ActiveEntry.Target.LifecycleID = domain.SessionLifecycleID{99}
135+ ac.setRouteSnapshot(snapshot)
136+ require.Equal(t, "vive", sess.statusSegmentsFor(ac, true).session, "a stale UUID must not label the active session")
135137 }
136138
137139 func TestStatusSegmentsIncludesFocusedPaneTitle(t *testing.T) {
@@ -981,7 +983,7 @@ func TestBarStateForContextualRecentUsesClientSnapshot(t *testing.T) {
981983
982984 snapshot := ports.RecentRouteSnapshot{
983985 Generation: 1,
984- Entries: []ports.RecentRouteEntry{{Key: 2, Generation: 1, Name: "captured", Kind: ports.RouteKindLocal, Attention: true}},
986+ Entries: []ports.RecentRouteEntry{{Key: 2, Generation: 1, Target: testRouteTarget("captured", 2), Name: "captured", Kind: ports.RouteKindLocal, Attention: true}},
985987 }
986988 hints := palette.ContextualHints{
987989 Kind: command.ContextHintRecentSessions,
@@ -109,29 +109,31 @@ func TestStatusCompositionGolden(t *testing.T) {
109 }109 }
110 }110 }
111 111
112-func TestStatusSegmentsIncludesAttachmentRemoteOrigin(t *testing.T) {112+func TestStatusSegmentsResolvesActiveLifecyclePresentation(t *testing.T) {
113- tests := []struct {113+ p, releasePTY := newBlockingPTY(t)
114- name string114+ _, sess, ac, _ := newManualSessionWithPTYs(t, p)
115- remoteOrigin string115+ defer releasePTY()
116- wantAttachmentBar string
117- }{
118- {name: "remote origin", remoteOrigin: "arch", wantAttachmentBar: "vive@arch"},
119- {name: "local attachment", wantAttachmentBar: "vive"},
120- }
121 116
122- for _, test := range tests {117+ sess.name = "vive"
123- t.Run(test.name, func(t *testing.T) {118+ sess.incarnation = domain.SessionLifecycleID{1}
124- p, releasePTY := newBlockingPTY(t)119+ ref := ports.RouteRef{Key: 1, Generation: 1}
125- _, sess, ac, _ := newManualSessionWithPTYs(t, p)120+ target := ports.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}
126- defer releasePTY()121+ snapshot := ports.RecentRouteSnapshot{
122+ Generation: 1,
123+ Active: ref,
124+ ActiveEntry: ports.RecentRouteEntry{
125+ Key: ref.Key, Generation: ref.Generation, Target: target,
126+ Name: "vive", HostLabel: "user@arch", Kind: ports.RouteKindRemote,
127+ },
128+ }
129+ ac.setRouteSnapshot(snapshot)
127 130
128- sess.name = "vive"131+ require.Equal(t, "vive@arch", sess.statusSegmentsFor(ac, true).session)
129- ac.remoteOrigin = test.remoteOrigin132+ require.Equal(t, "vive", sess.statusSegments(true).session, "route presentation belongs only to the selected attachment")
130 133
131- require.Equal(t, test.wantAttachmentBar, sess.statusSegmentsFor(ac, true).session)134+ snapshot.ActiveEntry.Target.LifecycleID = domain.SessionLifecycleID{99}
132- require.Equal(t, "vive", sess.statusSegments(true).session, "remote provenance belongs only to the selected attachment")135+ ac.setRouteSnapshot(snapshot)
133- })136+ require.Equal(t, "vive", sess.statusSegmentsFor(ac, true).session, "a stale UUID must not label the active session")
134- }
135 }137 }
136 138
137 func TestStatusSegmentsIncludesFocusedPaneTitle(t *testing.T) {139 func TestStatusSegmentsIncludesFocusedPaneTitle(t *testing.T) {
@@ -981,7 +983,7 @@ func TestBarStateForContextualRecentUsesClientSnapshot(t *testing.T) {
981 983
982 snapshot := ports.RecentRouteSnapshot{984 snapshot := ports.RecentRouteSnapshot{
983 Generation: 1,985 Generation: 1,
984- Entries: []ports.RecentRouteEntry{{Key: 2, Generation: 1, Name: "captured", Kind: ports.RouteKindLocal, Attention: true}},986+ Entries: []ports.RecentRouteEntry{{Key: 2, Generation: 1, Target: testRouteTarget("captured", 2), Name: "captured", Kind: ports.RouteKindLocal, Attention: true}},
985 }987 }
986 hints := palette.ContextualHints{988 hints := palette.ContextualHints{
987 Kind: command.ContextHintRecentSessions,989 Kind: command.ContextHintRecentSessions,
M internal/usecase/palette/action_test.go +1 -1
@@ -54,7 +54,7 @@ func TestArgumentCommandKeepsExactRowVisible(t *testing.T) {
5454
5555 func TestArgumentResultReturnsTheMatchingRowAndCommand(t *testing.T) {
5656 jrs := command.Command{Code: "JRS", Arguments: command.ArgumentsRequired}
57- results := []Result{NewStoppedSessionResult("JRS", time.Time{}), NewCommandResult(jrs)}
57+ results := []Result{NewStoppedSessionResult("JRS", time.Time{}, testExactTarget("JRS", 1)), NewCommandResult(jrs)}
5858
5959 result, got, ok := argumentResult(results, "jrs 1")
6060
@@ -54,7 +54,7 @@ func TestArgumentCommandKeepsExactRowVisible(t *testing.T) {
54 54
55 func TestArgumentResultReturnsTheMatchingRowAndCommand(t *testing.T) {55 func TestArgumentResultReturnsTheMatchingRowAndCommand(t *testing.T) {
56 jrs := command.Command{Code: "JRS", Arguments: command.ArgumentsRequired}56 jrs := command.Command{Code: "JRS", Arguments: command.ArgumentsRequired}
57- results := []Result{NewStoppedSessionResult("JRS", time.Time{}), NewCommandResult(jrs)}57+ results := []Result{NewStoppedSessionResult("JRS", time.Time{}, testExactTarget("JRS", 1)), NewCommandResult(jrs)}
58 58
59 result, got, ok := argumentResult(results, "jrs 1")59 result, got, ok := argumentResult(results, "jrs 1")
60 60
M internal/usecase/palette/fuzzy_test.go +23 -21
@@ -4,7 +4,7 @@ import (
44 "testing"
55 "time"
66
7- "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/ports"
88 "github.com/bnema/vev/internal/usecase/command"
99 "github.com/stretchr/testify/require"
1010 )
@@ -81,20 +81,20 @@ func TestFuzzyEmptyQueryPreservesRegistryOrder(t *testing.T) {
8181 func TestFuzzyOrdersMixedResults(t *testing.T) {
8282 created := time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC)
8383 tests := []struct {
84- name string
85- results []Result
86- query string
87- wantText []string
88- wantKinds []ResultKind
89- wantPositions [][]int
90- wantActiveIDs map[int]domain.SessionID
84+ name string
85+ results []Result
86+ query string
87+ wantText []string
88+ wantKinds []ResultKind
89+ wantPositions [][]int
90+ wantActiveTargets map[int]ports.ExactSessionTarget
9191 }{
9292 {
9393 name: "command shortcode precedes sessions and description matches",
9494 results: []Result{
95- NewStoppedSessionResult("work", created),
95+ NewStoppedSessionResult("work", created, testExactTarget("work", 1)),
9696 NewCommandResult(cmd("WORK", "", "Create a workspace")),
97- NewActiveSessionResult("work", created, domain.SessionID("work-id")),
97+ NewActiveSessionResult("work", created, testExactTarget("work", 2)),
9898 NewCommandResult(cmd("WQORRK", "", "")),
9999 NewCommandResult(cmd("ZZZ", "", "work tools")),
100100 },
@@ -106,8 +106,8 @@ func TestFuzzyOrdersMixedResults(t *testing.T) {
106106 {
107107 name: "equivalent sessions sort by normalized text",
108108 results: []Result{
109- NewStoppedSessionResult("aBravo", time.Time{}),
110- NewStoppedSessionResult("aAlpha", time.Time{}),
109+ NewStoppedSessionResult("aBravo", time.Time{}, testExactTarget("aBravo", 1)),
110+ NewStoppedSessionResult("aAlpha", time.Time{}, testExactTarget("aAlpha", 2)),
111111 },
112112 query: "a",
113113 wantText: []string{"Resume session aAlpha", "Resume session aBravo"},
@@ -117,18 +117,20 @@ func TestFuzzyOrdersMixedResults(t *testing.T) {
117117 {
118118 name: "display prefix positions sort before later active names",
119119 results: []Result{
120- NewStoppedSessionResult("aBravo", time.Time{}),
121- NewStoppedSessionResult("aAlpha", time.Time{}),
122- NewActiveSessionResult("aZulu", time.Time{}, "z"),
123- NewActiveSessionResult("aEcho", time.Time{}, "e"),
124- NewActiveSessionResult("aEcho", time.Time{}, "e2"),
120+ NewStoppedSessionResult("aBravo", time.Time{}, testExactTarget("aBravo", 1)),
121+ NewStoppedSessionResult("aAlpha", time.Time{}, testExactTarget("aAlpha", 2)),
122+ NewActiveSessionResult("aZulu", time.Time{}, testExactTarget("aZulu", 3)),
123+ NewActiveSessionResult("aEcho", time.Time{}, testExactTarget("aEcho", 4)),
124+ NewActiveSessionResult("aEcho", time.Time{}, testExactTarget("aEcho", 5)),
125125 NewCommandResult(cmd("AX", "", "")),
126126 },
127127 query: "a",
128128 wantText: []string{"AX", "Resume session aAlpha", "Resume session aBravo", "Switch to session aEcho", "Switch to session aEcho", "Switch to session aZulu"},
129129 wantKinds: []ResultKind{ResultKindCommand, ResultKindStoppedSession, ResultKindStoppedSession, ResultKindActiveSession, ResultKindActiveSession, ResultKindActiveSession},
130130 wantPositions: [][]int{{0}, {15}, {15}, {18}, {18}, {18}},
131- wantActiveIDs: map[int]domain.SessionID{3: "e", 4: "e2", 5: "z"},
131+ wantActiveTargets: map[int]ports.ExactSessionTarget{
132+ 3: testExactTarget("aEcho", 4), 4: testExactTarget("aEcho", 5), 5: testExactTarget("aZulu", 3),
133+ },
132134 },
133135 }
134136
@@ -142,10 +144,10 @@ func TestFuzzyOrdersMixedResults(t *testing.T) {
142144 positions[i] = matches[i].Positions
143145 }
144146 require.Equal(t, tt.wantPositions, positions)
145- for index, wantID := range tt.wantActiveIDs {
146- gotID, ok := matches[index].Result.SessionID()
147+ for index, wantTarget := range tt.wantActiveTargets {
148+ gotTarget, ok := matches[index].Result.SessionTarget()
147149 require.True(t, ok)
148- require.Equal(t, wantID, gotID)
150+ require.Equal(t, wantTarget, gotTarget)
149151 }
150152 })
151153 }
@@ -4,7 +4,7 @@ import (
4 "testing"4 "testing"
5 "time"5 "time"
6 6
7- "github.com/bnema/vev/internal/domain"7+ "github.com/bnema/vev/internal/ports"
8 "github.com/bnema/vev/internal/usecase/command"8 "github.com/bnema/vev/internal/usecase/command"
9 "github.com/stretchr/testify/require"9 "github.com/stretchr/testify/require"
10 )10 )
@@ -81,20 +81,20 @@ func TestFuzzyEmptyQueryPreservesRegistryOrder(t *testing.T) {
81 func TestFuzzyOrdersMixedResults(t *testing.T) {81 func TestFuzzyOrdersMixedResults(t *testing.T) {
82 created := time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC)82 created := time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC)
83 tests := []struct {83 tests := []struct {
84- name string84+ name string
85- results []Result85+ results []Result
86- query string86+ query string
87- wantText []string87+ wantText []string
88- wantKinds []ResultKind88+ wantKinds []ResultKind
89- wantPositions [][]int89+ wantPositions [][]int
90- wantActiveIDs map[int]domain.SessionID90+ wantActiveTargets map[int]ports.ExactSessionTarget
91 }{91 }{
92 {92 {
93 name: "command shortcode precedes sessions and description matches",93 name: "command shortcode precedes sessions and description matches",
94 results: []Result{94 results: []Result{
95- NewStoppedSessionResult("work", created),95+ NewStoppedSessionResult("work", created, testExactTarget("work", 1)),
96 NewCommandResult(cmd("WORK", "", "Create a workspace")),96 NewCommandResult(cmd("WORK", "", "Create a workspace")),
97- NewActiveSessionResult("work", created, domain.SessionID("work-id")),97+ NewActiveSessionResult("work", created, testExactTarget("work", 2)),
98 NewCommandResult(cmd("WQORRK", "", "")),98 NewCommandResult(cmd("WQORRK", "", "")),
99 NewCommandResult(cmd("ZZZ", "", "work tools")),99 NewCommandResult(cmd("ZZZ", "", "work tools")),
100 },100 },
@@ -106,8 +106,8 @@ func TestFuzzyOrdersMixedResults(t *testing.T) {
106 {106 {
107 name: "equivalent sessions sort by normalized text",107 name: "equivalent sessions sort by normalized text",
108 results: []Result{108 results: []Result{
109- NewStoppedSessionResult("aBravo", time.Time{}),109+ NewStoppedSessionResult("aBravo", time.Time{}, testExactTarget("aBravo", 1)),
110- NewStoppedSessionResult("aAlpha", time.Time{}),110+ NewStoppedSessionResult("aAlpha", time.Time{}, testExactTarget("aAlpha", 2)),
111 },111 },
112 query: "a",112 query: "a",
113 wantText: []string{"Resume session aAlpha", "Resume session aBravo"},113 wantText: []string{"Resume session aAlpha", "Resume session aBravo"},
@@ -117,18 +117,20 @@ func TestFuzzyOrdersMixedResults(t *testing.T) {
117 {117 {
118 name: "display prefix positions sort before later active names",118 name: "display prefix positions sort before later active names",
119 results: []Result{119 results: []Result{
120- NewStoppedSessionResult("aBravo", time.Time{}),120+ NewStoppedSessionResult("aBravo", time.Time{}, testExactTarget("aBravo", 1)),
121- NewStoppedSessionResult("aAlpha", time.Time{}),121+ NewStoppedSessionResult("aAlpha", time.Time{}, testExactTarget("aAlpha", 2)),
122- NewActiveSessionResult("aZulu", time.Time{}, "z"),122+ NewActiveSessionResult("aZulu", time.Time{}, testExactTarget("aZulu", 3)),
123- NewActiveSessionResult("aEcho", time.Time{}, "e"),123+ NewActiveSessionResult("aEcho", time.Time{}, testExactTarget("aEcho", 4)),
124- NewActiveSessionResult("aEcho", time.Time{}, "e2"),124+ NewActiveSessionResult("aEcho", time.Time{}, testExactTarget("aEcho", 5)),
125 NewCommandResult(cmd("AX", "", "")),125 NewCommandResult(cmd("AX", "", "")),
126 },126 },
127 query: "a",127 query: "a",
128 wantText: []string{"AX", "Resume session aAlpha", "Resume session aBravo", "Switch to session aEcho", "Switch to session aEcho", "Switch to session aZulu"},128 wantText: []string{"AX", "Resume session aAlpha", "Resume session aBravo", "Switch to session aEcho", "Switch to session aEcho", "Switch to session aZulu"},
129 wantKinds: []ResultKind{ResultKindCommand, ResultKindStoppedSession, ResultKindStoppedSession, ResultKindActiveSession, ResultKindActiveSession, ResultKindActiveSession},129 wantKinds: []ResultKind{ResultKindCommand, ResultKindStoppedSession, ResultKindStoppedSession, ResultKindActiveSession, ResultKindActiveSession, ResultKindActiveSession},
130 wantPositions: [][]int{{0}, {15}, {15}, {18}, {18}, {18}},130 wantPositions: [][]int{{0}, {15}, {15}, {18}, {18}, {18}},
131- wantActiveIDs: map[int]domain.SessionID{3: "e", 4: "e2", 5: "z"},131+ wantActiveTargets: map[int]ports.ExactSessionTarget{
132+ 3: testExactTarget("aEcho", 4), 4: testExactTarget("aEcho", 5), 5: testExactTarget("aZulu", 3),
133+ },
132 },134 },
133 }135 }
134 136
@@ -142,10 +144,10 @@ func TestFuzzyOrdersMixedResults(t *testing.T) {
142 positions[i] = matches[i].Positions144 positions[i] = matches[i].Positions
143 }145 }
144 require.Equal(t, tt.wantPositions, positions)146 require.Equal(t, tt.wantPositions, positions)
145- for index, wantID := range tt.wantActiveIDs {147+ for index, wantTarget := range tt.wantActiveTargets {
146- gotID, ok := matches[index].Result.SessionID()148+ gotTarget, ok := matches[index].Result.SessionTarget()
147 require.True(t, ok)149 require.True(t, ok)
148- require.Equal(t, wantID, gotID)150+ require.Equal(t, wantTarget, gotTarget)
149 }151 }
150 })152 })
151 }153 }
M internal/usecase/palette/model_test.go +8 -7
@@ -460,13 +460,14 @@ func TestModelCompleteSelected(t *testing.T) {
460460
461461 func TestModelUsesDefensiveTypedResultsAndKeepsSessionsCommandInert(t *testing.T) {
462462 created := time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC)
463+ workTarget := testExactTarget("work", 1)
463464 results := []Result{
464465 NewCommandResult(cmd("JRS", "Jump", "Jump to recent session")),
465- NewActiveSessionResult("work", created, "work-id"),
466- NewStoppedSessionResult("work", created),
466+ NewActiveSessionResult("work", created, workTarget),
467+ NewStoppedSessionResult("work", created, testExactTarget("work", 2)),
467468 }
468469 m := New(results)
469- results[1] = NewActiveSessionResult("changed", created, "changed-id")
470+ results[1] = NewActiveSessionResult("changed", created, testExactTarget("changed", 3))
470471
471472 for _, r := range "switch" {
472473 m.Insert(r)
@@ -480,9 +481,9 @@ func TestModelUsesDefensiveTypedResultsAndKeepsSessionsCommandInert(t *testing.T
480481 selected, ok := m.Selected()
481482 require.True(t, ok)
482483 require.Equal(t, ResultKindActiveSession, selected.Kind())
483- id, active := selected.SessionID()
484+ target, active := selected.SessionTarget()
484485 require.True(t, active)
485- require.Equal(t, domain.SessionID("work-id"), id)
486+ require.Equal(t, workTarget, target)
486487 require.False(t, m.CompleteSelected(), "sessions never participate in tab completion")
487488 require.Equal(t, "switch", m.Query())
488489 _, argument := m.ArgumentCommand()
@@ -493,7 +494,7 @@ func TestModelUsesDefensiveTypedResultsAndKeepsSessionsCommandInert(t *testing.T
493494 }
494495
495496 func TestRenderStoppedSessionHighlightsNameAfterResumePrefix(t *testing.T) {
496- m := New([]Result{NewStoppedSessionResult("work", time.Unix(0, 1))})
497+ m := New([]Result{NewStoppedSessionResult("work", time.Unix(0, 1), testExactTarget("work", 1))})
497498 m.Insert('w')
498499 m.Insert('k')
499500
@@ -506,7 +507,7 @@ func TestRenderStoppedSessionHighlightsNameAfterResumePrefix(t *testing.T) {
506507 }
507508
508509 func TestRenderFeedbackUsesSelectedSessionRowWithoutAddingResult(t *testing.T) {
509- m := New([]Result{NewActiveSessionResult("work", time.Unix(0, 1), "work")})
510+ m := New([]Result{NewActiveSessionResult("work", time.Unix(0, 1), testExactTarget("work", 1))})
510511 frame := m.Render(domain.Size{Cols: 64, Rows: 3}, RenderOptions{Styles: DefaultRenderStyles(), Feedback: "requested session is unavailable"})
511512 require.Len(t, m.Matches(), 1)
512513 require.Contains(t, frameRow(frame, 1), "requested session is unavailable")
@@ -460,13 +460,14 @@ func TestModelCompleteSelected(t *testing.T) {
460 460
461 func TestModelUsesDefensiveTypedResultsAndKeepsSessionsCommandInert(t *testing.T) {461 func TestModelUsesDefensiveTypedResultsAndKeepsSessionsCommandInert(t *testing.T) {
462 created := time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC)462 created := time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC)
463+ workTarget := testExactTarget("work", 1)
463 results := []Result{464 results := []Result{
464 NewCommandResult(cmd("JRS", "Jump", "Jump to recent session")),465 NewCommandResult(cmd("JRS", "Jump", "Jump to recent session")),
465- NewActiveSessionResult("work", created, "work-id"),466+ NewActiveSessionResult("work", created, workTarget),
466- NewStoppedSessionResult("work", created),467+ NewStoppedSessionResult("work", created, testExactTarget("work", 2)),
467 }468 }
468 m := New(results)469 m := New(results)
469- results[1] = NewActiveSessionResult("changed", created, "changed-id")470+ results[1] = NewActiveSessionResult("changed", created, testExactTarget("changed", 3))
470 471
471 for _, r := range "switch" {472 for _, r := range "switch" {
472 m.Insert(r)473 m.Insert(r)
@@ -480,9 +481,9 @@ func TestModelUsesDefensiveTypedResultsAndKeepsSessionsCommandInert(t *testing.T
480 selected, ok := m.Selected()481 selected, ok := m.Selected()
481 require.True(t, ok)482 require.True(t, ok)
482 require.Equal(t, ResultKindActiveSession, selected.Kind())483 require.Equal(t, ResultKindActiveSession, selected.Kind())
483- id, active := selected.SessionID()484+ target, active := selected.SessionTarget()
484 require.True(t, active)485 require.True(t, active)
485- require.Equal(t, domain.SessionID("work-id"), id)486+ require.Equal(t, workTarget, target)
486 require.False(t, m.CompleteSelected(), "sessions never participate in tab completion")487 require.False(t, m.CompleteSelected(), "sessions never participate in tab completion")
487 require.Equal(t, "switch", m.Query())488 require.Equal(t, "switch", m.Query())
488 _, argument := m.ArgumentCommand()489 _, argument := m.ArgumentCommand()
@@ -493,7 +494,7 @@ func TestModelUsesDefensiveTypedResultsAndKeepsSessionsCommandInert(t *testing.T
493 }494 }
494 495
495 func TestRenderStoppedSessionHighlightsNameAfterResumePrefix(t *testing.T) {496 func TestRenderStoppedSessionHighlightsNameAfterResumePrefix(t *testing.T) {
496- m := New([]Result{NewStoppedSessionResult("work", time.Unix(0, 1))})497+ m := New([]Result{NewStoppedSessionResult("work", time.Unix(0, 1), testExactTarget("work", 1))})
497 m.Insert('w')498 m.Insert('w')
498 m.Insert('k')499 m.Insert('k')
499 500
@@ -506,7 +507,7 @@ func TestRenderStoppedSessionHighlightsNameAfterResumePrefix(t *testing.T) {
506 }507 }
507 508
508 func TestRenderFeedbackUsesSelectedSessionRowWithoutAddingResult(t *testing.T) {509 func TestRenderFeedbackUsesSelectedSessionRowWithoutAddingResult(t *testing.T) {
509- m := New([]Result{NewActiveSessionResult("work", time.Unix(0, 1), "work")})510+ m := New([]Result{NewActiveSessionResult("work", time.Unix(0, 1), testExactTarget("work", 1))})
510 frame := m.Render(domain.Size{Cols: 64, Rows: 3}, RenderOptions{Styles: DefaultRenderStyles(), Feedback: "requested session is unavailable"})511 frame := m.Render(domain.Size{Cols: 64, Rows: 3}, RenderOptions{Styles: DefaultRenderStyles(), Feedback: "requested session is unavailable"})
511 require.Len(t, m.Matches(), 1)512 require.Len(t, m.Matches(), 1)
512 require.Contains(t, frameRow(frame, 1), "requested session is unavailable")513 require.Contains(t, frameRow(frame, 1), "requested session is unavailable")
M internal/usecase/palette/result.go +9 -9
@@ -3,7 +3,6 @@ package palette
33 import (
44 "time"
55
6- "github.com/bnema/vev/internal/domain"
76 "github.com/bnema/vev/internal/ports"
87 "github.com/bnema/vev/internal/usecase/command"
98 )
@@ -30,7 +29,7 @@ type Result struct {
3029 type sessionPayload struct {
3130 name string
3231 createdAt time.Time
33- sessionID domain.SessionID
32+ target ports.ExactSessionTarget
3433 }
3534
3635 type routePayload struct {
@@ -44,18 +43,18 @@ func NewCommandResult(cmd command.Command) Result {
4443 }
4544
4645 // NewActiveSessionResult creates an immutable active named-session target.
47-func NewActiveSessionResult(name string, createdAt time.Time, sessionID domain.SessionID) Result {
46+func NewActiveSessionResult(name string, createdAt time.Time, target ports.ExactSessionTarget) Result {
4847 return Result{
4948 kind: ResultKindActiveSession,
50- session: sessionPayload{name: name, createdAt: createdAt, sessionID: sessionID},
49+ session: sessionPayload{name: name, createdAt: createdAt, target: target},
5150 }
5251 }
5352
5453 // NewStoppedSessionResult creates an immutable stopped named-session target.
55-func NewStoppedSessionResult(name string, createdAt time.Time) Result {
54+func NewStoppedSessionResult(name string, createdAt time.Time, target ports.ExactSessionTarget) Result {
5655 return Result{
5756 kind: ResultKindStoppedSession,
58- session: sessionPayload{name: name, createdAt: createdAt},
57+ session: sessionPayload{name: name, createdAt: createdAt, target: target},
5958 }
6059 }
6160
@@ -102,9 +101,10 @@ func (r Result) SessionCreatedAt() (time.Time, bool) {
102101 return r.session.createdAt, r.kind == ResultKindActiveSession || r.kind == ResultKindStoppedSession
103102 }
104103
105-// SessionID returns the lifecycle identity only for active session results.
106-func (r Result) SessionID() (domain.SessionID, bool) {
107- return r.session.sessionID, r.kind == ResultKindActiveSession
104+// SessionTarget returns the exact lifecycle UUID and name for local active and
105+// stopped session results.
106+func (r Result) SessionTarget() (ports.ExactSessionTarget, bool) {
107+ return r.session.target, r.kind == ResultKindActiveSession || r.kind == ResultKindStoppedSession
108108 }
109109
110110 // RouteNavigationAction returns the exact client-ledger target only for a
@@ -3,7 +3,6 @@ package palette
3 import (3 import (
4 "time"4 "time"
5 5
6- "github.com/bnema/vev/internal/domain"
7 "github.com/bnema/vev/internal/ports"6 "github.com/bnema/vev/internal/ports"
8 "github.com/bnema/vev/internal/usecase/command"7 "github.com/bnema/vev/internal/usecase/command"
9 )8 )
@@ -30,7 +29,7 @@ type Result struct {
30 type sessionPayload struct {29 type sessionPayload struct {
31 name string30 name string
32 createdAt time.Time31 createdAt time.Time
33- sessionID domain.SessionID32+ target ports.ExactSessionTarget
34 }33 }
35 34
36 type routePayload struct {35 type routePayload struct {
@@ -44,18 +43,18 @@ func NewCommandResult(cmd command.Command) Result {
44 }43 }
45 44
46 // NewActiveSessionResult creates an immutable active named-session target.45 // NewActiveSessionResult creates an immutable active named-session target.
47-func NewActiveSessionResult(name string, createdAt time.Time, sessionID domain.SessionID) Result {46+func NewActiveSessionResult(name string, createdAt time.Time, target ports.ExactSessionTarget) Result {
48 return Result{47 return Result{
49 kind: ResultKindActiveSession,48 kind: ResultKindActiveSession,
50- session: sessionPayload{name: name, createdAt: createdAt, sessionID: sessionID},49+ session: sessionPayload{name: name, createdAt: createdAt, target: target},
51 }50 }
52 }51 }
53 52
54 // NewStoppedSessionResult creates an immutable stopped named-session target.53 // NewStoppedSessionResult creates an immutable stopped named-session target.
55-func NewStoppedSessionResult(name string, createdAt time.Time) Result {54+func NewStoppedSessionResult(name string, createdAt time.Time, target ports.ExactSessionTarget) Result {
56 return Result{55 return Result{
57 kind: ResultKindStoppedSession,56 kind: ResultKindStoppedSession,
58- session: sessionPayload{name: name, createdAt: createdAt},57+ session: sessionPayload{name: name, createdAt: createdAt, target: target},
59 }58 }
60 }59 }
61 60
@@ -102,9 +101,10 @@ func (r Result) SessionCreatedAt() (time.Time, bool) {
102 return r.session.createdAt, r.kind == ResultKindActiveSession || r.kind == ResultKindStoppedSession101 return r.session.createdAt, r.kind == ResultKindActiveSession || r.kind == ResultKindStoppedSession
103 }102 }
104 103
105-// SessionID returns the lifecycle identity only for active session results.104+// SessionTarget returns the exact lifecycle UUID and name for local active and
106-func (r Result) SessionID() (domain.SessionID, bool) {105+// stopped session results.
107- return r.session.sessionID, r.kind == ResultKindActiveSession106+func (r Result) SessionTarget() (ports.ExactSessionTarget, bool) {
107+ return r.session.target, r.kind == ResultKindActiveSession || r.kind == ResultKindStoppedSession
108 }108 }
109 109
110 // RouteNavigationAction returns the exact client-ledger target only for a110 // RouteNavigationAction returns the exact client-ledger target only for a
M internal/usecase/palette/result_test.go +17 -10
@@ -10,11 +10,17 @@ import (
1010 "github.com/stretchr/testify/require"
1111 )
1212
13+func testExactTarget(name string, marker byte) ports.ExactSessionTarget {
14+ return ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{marker}, SessionName: name}
15+}
16+
1317 func TestResultKindsAndSessionLifecycleTargets(t *testing.T) {
1418 created := time.Date(2026, time.March, 1, 2, 3, 4, 0, time.UTC)
1519 commandResult := NewCommandResult(command.Command{Code: "NT", Desc: "Create tab"})
16- active := NewActiveSessionResult("work", created, domain.SessionID("session-work"))
17- stopped := NewStoppedSessionResult("archive", created)
20+ activeTarget := testExactTarget("work", 1)
21+ stoppedTarget := testExactTarget("archive", 2)
22+ active := NewActiveSessionResult("work", created, activeTarget)
23+ stopped := NewStoppedSessionResult("archive", created, stoppedTarget)
1824
1925 require.Equal(t, ResultKindCommand, commandResult.Kind())
2026 require.Equal(t, "NT", commandResult.DisplayText())
@@ -32,9 +38,9 @@ func TestResultKindsAndSessionLifecycleTargets(t *testing.T) {
3238 require.Equal(t, created, createdAt)
3339 require.Equal(t, "Switch to session work", active.DisplayText())
3440 require.Equal(t, "Switch to session work", active.SearchText())
35- id, ok := active.SessionID()
41+ target, ok := active.SessionTarget()
3642 require.True(t, ok)
37- require.Equal(t, domain.SessionID("session-work"), id)
43+ require.Equal(t, activeTarget, target)
3844
3945 require.Equal(t, ResultKindStoppedSession, stopped.Kind())
4046 name, ok = stopped.SessionName()
@@ -45,8 +51,9 @@ func TestResultKindsAndSessionLifecycleTargets(t *testing.T) {
4551 require.Equal(t, created, createdAt)
4652 require.Equal(t, "Resume session archive", stopped.DisplayText())
4753 require.Equal(t, "Resume session archive", stopped.SearchText())
48- _, ok = stopped.SessionID()
49- require.False(t, ok)
54+ target, ok = stopped.SessionTarget()
55+ require.True(t, ok)
56+ require.Equal(t, stoppedTarget, target)
5057 }
5158
5259 func TestRecentRouteResultCarriesExactNavigationAction(t *testing.T) {
@@ -71,10 +78,10 @@ func TestSessionResultsSearchDisplayedActionText(t *testing.T) {
7178 query string
7279 positions []int
7380 }{
74- {name: "active prefix", result: NewActiveSessionResult("work", created, "work-id"), query: "switch", positions: []int{0, 1, 2, 3, 4, 5}},
75- {name: "active name", result: NewActiveSessionResult("work", created, "work-id"), query: "work", positions: []int{1, 8, 20, 21}},
76- {name: "stopped prefix", result: NewStoppedSessionResult("archive", created), query: "resume", positions: []int{0, 1, 2, 3, 4, 5}},
77- {name: "stopped name", result: NewStoppedSessionResult("archive", created), query: "archive", positions: []int{15, 16, 17, 18, 19, 20, 21}},
81+ {name: "active prefix", result: NewActiveSessionResult("work", created, testExactTarget("work", 1)), query: "switch", positions: []int{0, 1, 2, 3, 4, 5}},
82+ {name: "active name", result: NewActiveSessionResult("work", created, testExactTarget("work", 1)), query: "work", positions: []int{1, 8, 20, 21}},
83+ {name: "stopped prefix", result: NewStoppedSessionResult("archive", created, testExactTarget("archive", 2)), query: "resume", positions: []int{0, 1, 2, 3, 4, 5}},
84+ {name: "stopped name", result: NewStoppedSessionResult("archive", created, testExactTarget("archive", 2)), query: "archive", positions: []int{15, 16, 17, 18, 19, 20, 21}},
7885 }
7986
8087 for _, tt := range tests {
@@ -10,11 +10,17 @@ import (
10 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
11 )11 )
12 12
13+func testExactTarget(name string, marker byte) ports.ExactSessionTarget {
14+ return ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{marker}, SessionName: name}
15+}
16+
13 func TestResultKindsAndSessionLifecycleTargets(t *testing.T) {17 func TestResultKindsAndSessionLifecycleTargets(t *testing.T) {
14 created := time.Date(2026, time.March, 1, 2, 3, 4, 0, time.UTC)18 created := time.Date(2026, time.March, 1, 2, 3, 4, 0, time.UTC)
15 commandResult := NewCommandResult(command.Command{Code: "NT", Desc: "Create tab"})19 commandResult := NewCommandResult(command.Command{Code: "NT", Desc: "Create tab"})
16- active := NewActiveSessionResult("work", created, domain.SessionID("session-work"))20+ activeTarget := testExactTarget("work", 1)
17- stopped := NewStoppedSessionResult("archive", created)21+ stoppedTarget := testExactTarget("archive", 2)
22+ active := NewActiveSessionResult("work", created, activeTarget)
23+ stopped := NewStoppedSessionResult("archive", created, stoppedTarget)
18 24
19 require.Equal(t, ResultKindCommand, commandResult.Kind())25 require.Equal(t, ResultKindCommand, commandResult.Kind())
20 require.Equal(t, "NT", commandResult.DisplayText())26 require.Equal(t, "NT", commandResult.DisplayText())
@@ -32,9 +38,9 @@ func TestResultKindsAndSessionLifecycleTargets(t *testing.T) {
32 require.Equal(t, created, createdAt)38 require.Equal(t, created, createdAt)
33 require.Equal(t, "Switch to session work", active.DisplayText())39 require.Equal(t, "Switch to session work", active.DisplayText())
34 require.Equal(t, "Switch to session work", active.SearchText())40 require.Equal(t, "Switch to session work", active.SearchText())
35- id, ok := active.SessionID()41+ target, ok := active.SessionTarget()
36 require.True(t, ok)42 require.True(t, ok)
37- require.Equal(t, domain.SessionID("session-work"), id)43+ require.Equal(t, activeTarget, target)
38 44
39 require.Equal(t, ResultKindStoppedSession, stopped.Kind())45 require.Equal(t, ResultKindStoppedSession, stopped.Kind())
40 name, ok = stopped.SessionName()46 name, ok = stopped.SessionName()
@@ -45,8 +51,9 @@ func TestResultKindsAndSessionLifecycleTargets(t *testing.T) {
45 require.Equal(t, created, createdAt)51 require.Equal(t, created, createdAt)
46 require.Equal(t, "Resume session archive", stopped.DisplayText())52 require.Equal(t, "Resume session archive", stopped.DisplayText())
47 require.Equal(t, "Resume session archive", stopped.SearchText())53 require.Equal(t, "Resume session archive", stopped.SearchText())
48- _, ok = stopped.SessionID()54+ target, ok = stopped.SessionTarget()
49- require.False(t, ok)55+ require.True(t, ok)
56+ require.Equal(t, stoppedTarget, target)
50 }57 }
51 58
52 func TestRecentRouteResultCarriesExactNavigationAction(t *testing.T) {59 func TestRecentRouteResultCarriesExactNavigationAction(t *testing.T) {
@@ -71,10 +78,10 @@ func TestSessionResultsSearchDisplayedActionText(t *testing.T) {
71 query string78 query string
72 positions []int79 positions []int
73 }{80 }{
74- {name: "active prefix", result: NewActiveSessionResult("work", created, "work-id"), query: "switch", positions: []int{0, 1, 2, 3, 4, 5}},81+ {name: "active prefix", result: NewActiveSessionResult("work", created, testExactTarget("work", 1)), query: "switch", positions: []int{0, 1, 2, 3, 4, 5}},
75- {name: "active name", result: NewActiveSessionResult("work", created, "work-id"), query: "work", positions: []int{1, 8, 20, 21}},82+ {name: "active name", result: NewActiveSessionResult("work", created, testExactTarget("work", 1)), query: "work", positions: []int{1, 8, 20, 21}},
76- {name: "stopped prefix", result: NewStoppedSessionResult("archive", created), query: "resume", positions: []int{0, 1, 2, 3, 4, 5}},83+ {name: "stopped prefix", result: NewStoppedSessionResult("archive", created, testExactTarget("archive", 2)), query: "resume", positions: []int{0, 1, 2, 3, 4, 5}},
77- {name: "stopped name", result: NewStoppedSessionResult("archive", created), query: "archive", positions: []int{15, 16, 17, 18, 19, 20, 21}},84+ {name: "stopped name", result: NewStoppedSessionResult("archive", created, testExactTarget("archive", 2)), query: "archive", positions: []int{15, 16, 17, 18, 19, 20, 21}},
78 }85 }
79 86
80 for _, tt := range tests {87 for _, tt := range tests {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close