detached 83b15cd

Merge pull request #207 from bnema/fix/remote-route-display-origin

fix(navigation): resolve sessions by lifecycle identity
commit
83b15cd6fe8f994084aab7697450376712dd86c2
author
bnema · · 2026-08-19 04:29 UTC
committer
GitHub · · 2026-08-19 04:29 UTC
merge of
274b25c + 046cebb · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
45 files · +606 -341
M internal/adapters/dgram/transport_send_test.go +2 -1
@@ -198,7 +198,8 @@ func TestOwnedSynchronousMaxSideEffectCompletesThroughConcurrentPacedWork(t *tes
198198 aPC, bPC := newPair()
199199 clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
200200 a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
201- Clock: clk, ResendAfter: 250 * time.Millisecond, MaxResendAfter: time.Second, Heartbeat: time.Hour,
201+ Clock: clk, ResendAfter: 250 * time.Millisecond, MaxResendAfter: time.Second,
202+ Heartbeat: time.Hour, DeadAfter: time.Hour,
202203 })
203204 b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
204205 defer func() { _ = a.Close() }()
@@ -198,7 +198,8 @@ func TestOwnedSynchronousMaxSideEffectCompletesThroughConcurrentPacedWork(t *tes
198 aPC, bPC := newPair()198 aPC, bPC := newPair()
199 clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))199 clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
200 a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{200 a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
201- Clock: clk, ResendAfter: 250 * time.Millisecond, MaxResendAfter: time.Second, Heartbeat: time.Hour,201+ Clock: clk, ResendAfter: 250 * time.Millisecond, MaxResendAfter: time.Second,
202+ Heartbeat: time.Hour, DeadAfter: time.Hour,
202 })203 })
203 b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)204 b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
204 defer func() { _ = a.Close() }()205 defer func() { _ = a.Close() }()
M internal/app/remote_hosts.go +1 -1
@@ -199,7 +199,7 @@ func catalogSessionsAsInfo(host string, sessions []ports.RemoteCatalogSession) [
199199 out := make([]ports.SessionInfo, 0, len(sessions))
200200 for _, session := range sessions {
201201 info := ports.SessionInfo{
202- Name: session.Name + "@" + host,
202+ Name: domain.RemoteSessionDisplay(session.Name, host),
203203 Ephemeral: session.Ephemeral,
204204 Tabs: ports.SaturateUint16(ports.CatalogTabCount(session)),
205205 Attached: session.Attached,
@@ -199,7 +199,7 @@ func catalogSessionsAsInfo(host string, sessions []ports.RemoteCatalogSession) [
199 out := make([]ports.SessionInfo, 0, len(sessions))199 out := make([]ports.SessionInfo, 0, len(sessions))
200 for _, session := range sessions {200 for _, session := range sessions {
201 info := ports.SessionInfo{201 info := ports.SessionInfo{
202- Name: session.Name + "@" + host,202+ Name: domain.RemoteSessionDisplay(session.Name, host),
203 Ephemeral: session.Ephemeral,203 Ephemeral: session.Ephemeral,
204 Tabs: ports.SaturateUint16(ports.CatalogTabCount(session)),204 Tabs: ports.SaturateUint16(ports.CatalogTabCount(session)),
205 Attached: session.Attached,205 Attached: session.Attached,
M internal/app/remote_test.go +1 -1
@@ -272,7 +272,7 @@ func TestCatalogSessionsAsInfoInvariants(t *testing.T) {
272272 }
273273 for _, tt := range tests {
274274 t.Run(tt.name, func(t *testing.T) {
275- infos := catalogSessionsAsInfo("arch", []ports.RemoteCatalogSession{tt.session})
275+ infos := catalogSessionsAsInfo("user@arch", []ports.RemoteCatalogSession{tt.session})
276276 require.Len(t, infos, 1)
277277 require.Equal(t, "dev@arch", infos[0].Name)
278278 require.Equal(t, tt.wantState, infos[0].State)
@@ -272,7 +272,7 @@ func TestCatalogSessionsAsInfoInvariants(t *testing.T) {
272 }272 }
273 for _, tt := range tests {273 for _, tt := range tests {
274 t.Run(tt.name, func(t *testing.T) {274 t.Run(tt.name, func(t *testing.T) {
275- infos := catalogSessionsAsInfo("arch", []ports.RemoteCatalogSession{tt.session})275+ infos := catalogSessionsAsInfo("user@arch", []ports.RemoteCatalogSession{tt.session})
276 require.Len(t, infos, 1)276 require.Len(t, infos, 1)
277 require.Equal(t, "dev@arch", infos[0].Name)277 require.Equal(t, "dev@arch", infos[0].Name)
278 require.Equal(t, tt.wantState, infos[0].State)278 require.Equal(t, tt.wantState, infos[0].State)
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/domain/remote_session.go +10 -3
@@ -42,17 +42,24 @@ func (k RemoteSessionKey) ID() SessionID {
4242 // the login prefix regardless of whether the route came from the CLI or a
4343 // discovered picker entry.
4444 func RemoteDisplayOrigin(endpoint string) string {
45- if i := strings.LastIndexByte(endpoint, '@'); i > 0 && i < len(endpoint)-1 {
45+ if i := strings.LastIndexByte(endpoint, '@'); i >= 0 && i < len(endpoint)-1 {
4646 return endpoint[i+1:]
4747 }
4848 return endpoint
4949 }
5050
51+// RemoteSessionDisplay returns the canonical presentation label for a remote
52+// session. endpoint may be a routing target or an already-derived origin; the
53+// login prefix is always removed.
54+func RemoteSessionDisplay(name, endpoint string) string {
55+ return name + "@" + RemoteDisplayOrigin(endpoint)
56+}
57+
5158 // Display returns the presentation label for this remote session.
5259 func (k RemoteSessionKey) Display() string {
5360 origin := k.DisplayOrigin
5461 if origin == "" {
55- origin = RemoteDisplayOrigin(k.Host)
62+ origin = k.Host
5663 }
57- return k.Name + "@" + origin
64+ return RemoteSessionDisplay(k.Name, origin)
5865 }
@@ -42,17 +42,24 @@ func (k RemoteSessionKey) ID() SessionID {
42 // the login prefix regardless of whether the route came from the CLI or a42 // the login prefix regardless of whether the route came from the CLI or a
43 // discovered picker entry.43 // discovered picker entry.
44 func RemoteDisplayOrigin(endpoint string) string {44 func RemoteDisplayOrigin(endpoint string) string {
45- if i := strings.LastIndexByte(endpoint, '@'); i > 0 && i < len(endpoint)-1 {45+ if i := strings.LastIndexByte(endpoint, '@'); i >= 0 && i < len(endpoint)-1 {
46 return endpoint[i+1:]46 return endpoint[i+1:]
47 }47 }
48 return endpoint48 return endpoint
49 }49 }
50 50
51+// RemoteSessionDisplay returns the canonical presentation label for a remote
52+// session. endpoint may be a routing target or an already-derived origin; the
53+// login prefix is always removed.
54+func RemoteSessionDisplay(name, endpoint string) string {
55+ return name + "@" + RemoteDisplayOrigin(endpoint)
56+}
57+
51 // Display returns the presentation label for this remote session.58 // Display returns the presentation label for this remote session.
52 func (k RemoteSessionKey) Display() string {59 func (k RemoteSessionKey) Display() string {
53 origin := k.DisplayOrigin60 origin := k.DisplayOrigin
54 if origin == "" {61 if origin == "" {
55- origin = RemoteDisplayOrigin(k.Host)62+ origin = k.Host
56 }63 }
57- return k.Name + "@" + origin64+ return RemoteSessionDisplay(k.Name, origin)
58 }65 }
M internal/domain/remote_session_test.go +6 -4
@@ -90,19 +90,21 @@ func TestRemoteSessionKeyValidate(t *testing.T) {
9090
9191 func TestRemoteSessionKeyDisplayIsPresentationOnly(t *testing.T) {
9292 tests := []struct {
93- host string
94- want string
93+ host string
94+ displayOrigin string
95+ want string
9596 }{
9697 {host: "arch", want: "hello@arch"},
98+ {host: "route", displayOrigin: "user@arch", want: "hello@arch"},
9799 {host: "test@arch", want: "hello@arch"},
98100 {host: "test@arch:2222", want: "hello@arch:2222"},
99101 {host: "test@[2001:db8::1]:2222", want: "hello@[2001:db8::1]:2222"},
100102 {host: "test@", want: "hello@test@"},
101- {host: "@arch", want: "hello@@arch"},
103+ {host: "@arch", want: "hello@arch"},
102104 }
103105 for _, test := range tests {
104106 t.Run(test.host, func(t *testing.T) {
105- key := RemoteSessionKey{Host: test.host, Name: "hello"}
107+ key := RemoteSessionKey{Host: test.host, Name: "hello", DisplayOrigin: test.displayOrigin}
106108 if got := key.Display(); got != test.want {
107109 t.Fatalf("RemoteSessionKey.Display() = %q, want %q", got, test.want)
108110 }
@@ -90,19 +90,21 @@ func TestRemoteSessionKeyValidate(t *testing.T) {
90 90
91 func TestRemoteSessionKeyDisplayIsPresentationOnly(t *testing.T) {91 func TestRemoteSessionKeyDisplayIsPresentationOnly(t *testing.T) {
92 tests := []struct {92 tests := []struct {
93- host string93+ host string
94- want string94+ displayOrigin string
95+ want string
95 }{96 }{
96 {host: "arch", want: "hello@arch"},97 {host: "arch", want: "hello@arch"},
98+ {host: "route", displayOrigin: "user@arch", want: "hello@arch"},
97 {host: "test@arch", want: "hello@arch"},99 {host: "test@arch", want: "hello@arch"},
98 {host: "test@arch:2222", want: "hello@arch:2222"},100 {host: "test@arch:2222", want: "hello@arch:2222"},
99 {host: "test@[2001:db8::1]:2222", want: "hello@[2001:db8::1]:2222"},101 {host: "test@[2001:db8::1]:2222", want: "hello@[2001:db8::1]:2222"},
100 {host: "test@", want: "hello@test@"},102 {host: "test@", want: "hello@test@"},
101- {host: "@arch", want: "hello@@arch"},103+ {host: "@arch", want: "hello@arch"},
102 }104 }
103 for _, test := range tests {105 for _, test := range tests {
104 t.Run(test.host, func(t *testing.T) {106 t.Run(test.host, func(t *testing.T) {
105- key := RemoteSessionKey{Host: test.host, Name: "hello"}107+ key := RemoteSessionKey{Host: test.host, Name: "hello", DisplayOrigin: test.displayOrigin}
106 if got := key.Display(); got != test.want {108 if got := key.Display(); got != test.want {
107 t.Fatalf("RemoteSessionKey.Display() = %q, want %q", got, test.want)109 t.Fatalf("RemoteSessionKey.Display() = %q, want %q", got, test.want)
108 }110 }
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 +12 -7
@@ -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
@@ -257,5 +262,5 @@ const (
257262 // of work a receiver performs before returning to its transport loop. The
258263 // private client history may use a smaller product cap.
259264 RouteSnapshotMaxEntries = 32
260- RouteLabelMaxBytes = 128
265+ RouteLabelMaxBytes = 256
261266 )
@@ -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
@@ -257,5 +262,5 @@ const (
257 // of work a receiver performs before returning to its transport loop. The262 // of work a receiver performs before returning to its transport loop. The
258 // private client history may use a smaller product cap.263 // private client history may use a smaller product cap.
259 RouteSnapshotMaxEntries = 32264 RouteSnapshotMaxEntries = 32
260- RouteLabelMaxBytes = 128265+ RouteLabelMaxBytes = 256
261 )266 )
M internal/ports/routes_test.go +38 -10
@@ -2,6 +2,7 @@ package ports
22
33 import (
44 "encoding/hex"
5+ "strings"
56 "testing"
67
78 "github.com/bnema/vev/internal/domain"
@@ -111,13 +112,16 @@ func TestCommittedRouteIdentityCodec(t *testing.T) {
111112 }
112113
113114 func testRouteSnapshot() RecentRouteSnapshot {
115+ activeTarget := testExactTarget()
116+ logsTarget := ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{4, 5, 6}, SessionName: "logs"}
114117 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},
118+ Generation: 9,
119+ Active: RouteRef{Key: 11, Generation: 3},
120+ ActiveEntry: RecentRouteEntry{Key: 11, Generation: 3, Target: activeTarget, Name: "work", Kind: RouteKindLocal, Reachability: RouteReachabilityUnknown},
121+ Previous: RouteRef{Key: 12, Generation: 4},
122+ Home: RouteRef{Key: 11, Generation: 3},
119123 Entries: []RecentRouteEntry{
120- {Key: 12, Generation: 4, Name: "logs", HostLabel: "edge", Kind: RouteKindRemote, Attention: true, Reachability: RouteReachabilityUnknown},
124+ {Key: 12, Generation: 4, Target: logsTarget, Name: "logs", HostLabel: "edge", Kind: RouteKindRemote, Attention: true, Reachability: RouteReachabilityUnknown},
121125 },
122126 }
123127 }
@@ -131,7 +135,7 @@ func TestRecentRouteSnapshotCodecRoundTripAndBounds(t *testing.T) {
131135 require.NoError(t, err)
132136 require.Equal(t, want, got)
133137
134- require.Equal(t, "0000000000000009000000000000000b0000000000000003000000000000000c0000000000000004000000000000000b000000000000000301000000000000000c000000000000000400046c6f677300046564676502000100", hex.EncodeToString(encoded))
138+ require.Equal(t, "0000000000000009000000000000000b0000000000000003000000000000000b0000000000000003010203000000000000000000000000000004776f726b0004776f726b000001000000000000000000000c0000000000000004000000000000000b000000000000000301000000000000000c00000000000000040405060000000000000000000000000000046c6f677300046c6f677300046564676502000100", hex.EncodeToString(encoded))
135139 assertAllPrefixesFail(t, encoded, UnmarshalRecentRouteSnapshot)
136140 _, err = UnmarshalRecentRouteSnapshot(append(append([]byte(nil), encoded...), 0))
137141 require.Error(t, err)
@@ -141,14 +145,17 @@ func TestRecentRouteSnapshotCodecRoundTripAndBounds(t *testing.T) {
141145 mutate func(*RecentRouteSnapshot)
142146 }{
143147 {name: "missing previous reference", mutate: func(snapshot *RecentRouteSnapshot) { snapshot.Previous = RouteRef{Key: 99, Generation: 99} }},
148+ {name: "missing active presentation", mutate: func(snapshot *RecentRouteSnapshot) { snapshot.ActiveEntry = RecentRouteEntry{} }},
149+ {name: "route name differs from lifecycle target", mutate: func(snapshot *RecentRouteSnapshot) { snapshot.Entries[0].Name = "other" }},
144150 {name: "entry is active", mutate: func(snapshot *RecentRouteSnapshot) {
145- snapshot.Entries = append(snapshot.Entries, RecentRouteEntry{Key: 11, Generation: 3, Name: "work", Kind: RouteKindLocal})
151+ snapshot.Entries = append(snapshot.Entries, snapshot.ActiveEntry)
146152 }},
147153 {name: "duplicate entry", mutate: func(snapshot *RecentRouteSnapshot) { snapshot.Entries = append(snapshot.Entries, snapshot.Entries[0]) }},
148154 {name: "too many entries", mutate: func(snapshot *RecentRouteSnapshot) {
149155 snapshot.Entries = make([]RecentRouteEntry, RouteSnapshotMaxEntries+1)
150156 for i := range snapshot.Entries {
151- snapshot.Entries[i] = RecentRouteEntry{Key: uint64(i + 1), Generation: uint64(i + 1), Name: "x", Kind: RouteKindLocal}
157+ target := ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{byte(i + 1)}, SessionName: "x"}
158+ snapshot.Entries[i] = RecentRouteEntry{Key: uint64(i + 1), Generation: uint64(i + 1), Target: target, Name: "x", Kind: RouteKindLocal}
152159 }
153160 }},
154161 }
@@ -189,20 +196,41 @@ func TestRecentRouteSnapshotRejectsOversizedFrameBeforeParsing(t *testing.T) {
189196 require.ErrorIs(t, err, ErrInvalidRouteWire)
190197 }
191198
199+func TestRouteLabelBoundsMatchRemoteDisplayOrigin(t *testing.T) {
200+ tests := []struct {
201+ name string
202+ length int
203+ wantErr bool
204+ }{
205+ {name: "maximum", length: 256},
206+ {name: "over maximum", length: 257, wantErr: true},
207+ }
208+ for _, tt := range tests {
209+ t.Run(tt.name, func(t *testing.T) {
210+ err := ValidateRouteLabel(strings.Repeat("a", tt.length), false)
211+ if tt.wantErr {
212+ require.Error(t, err)
213+ return
214+ }
215+ require.NoError(t, err)
216+ })
217+ }
218+}
219+
192220 func TestRouteLabelsRejectTerminalUnsafeText(t *testing.T) {
193221 values := []string{"line\nfeed", "\u2028line-separator", "\u2029paragraph-separator", "\x1b[31mred", "\u202eoverride", string([]byte{0xff, 0xfe})}
194222 for _, value := range values {
195223 t.Run("name/"+value, func(t *testing.T) {
196224 _, err := MarshalRecentRouteSnapshot(RecentRouteSnapshot{
197225 Generation: 1,
198- Entries: []RecentRouteEntry{{Key: 1, Generation: 1, Name: value, Kind: RouteKindLocal}},
226+ Entries: []RecentRouteEntry{{Key: 1, Generation: 1, Target: ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: value}, Name: value, Kind: RouteKindLocal}},
199227 })
200228 require.Error(t, err)
201229 })
202230 t.Run("host/"+value, func(t *testing.T) {
203231 _, err := MarshalRecentRouteSnapshot(RecentRouteSnapshot{
204232 Generation: 1,
205- Entries: []RecentRouteEntry{{Key: 1, Generation: 1, Name: "work", HostLabel: value, Kind: RouteKindRemote}},
233+ Entries: []RecentRouteEntry{{Key: 1, Generation: 1, Target: testExactTarget(), Name: "work", HostLabel: value, Kind: RouteKindRemote}},
206234 })
207235 require.Error(t, err)
208236 })
@@ -2,6 +2,7 @@ package ports
2 2
3 import (3 import (
4 "encoding/hex"4 "encoding/hex"
5+ "strings"
5 "testing"6 "testing"
6 7
7 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
@@ -111,13 +112,16 @@ func TestCommittedRouteIdentityCodec(t *testing.T) {
111 }112 }
112 113
113 func testRouteSnapshot() RecentRouteSnapshot {114 func testRouteSnapshot() RecentRouteSnapshot {
115+ activeTarget := testExactTarget()
116+ logsTarget := ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{4, 5, 6}, SessionName: "logs"}
114 return RecentRouteSnapshot{117 return RecentRouteSnapshot{
115- Generation: 9,118+ Generation: 9,
116- Active: RouteRef{Key: 11, Generation: 3},119+ Active: RouteRef{Key: 11, Generation: 3},
117- Previous: RouteRef{Key: 12, Generation: 4},120+ ActiveEntry: RecentRouteEntry{Key: 11, Generation: 3, Target: activeTarget, Name: "work", Kind: RouteKindLocal, Reachability: RouteReachabilityUnknown},
118- Home: RouteRef{Key: 11, Generation: 3},121+ Previous: RouteRef{Key: 12, Generation: 4},
122+ Home: RouteRef{Key: 11, Generation: 3},
119 Entries: []RecentRouteEntry{123 Entries: []RecentRouteEntry{
120- {Key: 12, Generation: 4, Name: "logs", HostLabel: "edge", Kind: RouteKindRemote, Attention: true, Reachability: RouteReachabilityUnknown},124+ {Key: 12, Generation: 4, Target: logsTarget, Name: "logs", HostLabel: "edge", Kind: RouteKindRemote, Attention: true, Reachability: RouteReachabilityUnknown},
121 },125 },
122 }126 }
123 }127 }
@@ -131,7 +135,7 @@ func TestRecentRouteSnapshotCodecRoundTripAndBounds(t *testing.T) {
131 require.NoError(t, err)135 require.NoError(t, err)
132 require.Equal(t, want, got)136 require.Equal(t, want, got)
133 137
134- require.Equal(t, "0000000000000009000000000000000b0000000000000003000000000000000c0000000000000004000000000000000b000000000000000301000000000000000c000000000000000400046c6f677300046564676502000100", hex.EncodeToString(encoded))138+ require.Equal(t, "0000000000000009000000000000000b0000000000000003000000000000000b0000000000000003010203000000000000000000000000000004776f726b0004776f726b000001000000000000000000000c0000000000000004000000000000000b000000000000000301000000000000000c00000000000000040405060000000000000000000000000000046c6f677300046c6f677300046564676502000100", hex.EncodeToString(encoded))
135 assertAllPrefixesFail(t, encoded, UnmarshalRecentRouteSnapshot)139 assertAllPrefixesFail(t, encoded, UnmarshalRecentRouteSnapshot)
136 _, err = UnmarshalRecentRouteSnapshot(append(append([]byte(nil), encoded...), 0))140 _, err = UnmarshalRecentRouteSnapshot(append(append([]byte(nil), encoded...), 0))
137 require.Error(t, err)141 require.Error(t, err)
@@ -141,14 +145,17 @@ func TestRecentRouteSnapshotCodecRoundTripAndBounds(t *testing.T) {
141 mutate func(*RecentRouteSnapshot)145 mutate func(*RecentRouteSnapshot)
142 }{146 }{
143 {name: "missing previous reference", mutate: func(snapshot *RecentRouteSnapshot) { snapshot.Previous = RouteRef{Key: 99, Generation: 99} }},147 {name: "missing previous reference", mutate: func(snapshot *RecentRouteSnapshot) { snapshot.Previous = RouteRef{Key: 99, Generation: 99} }},
148+ {name: "missing active presentation", mutate: func(snapshot *RecentRouteSnapshot) { snapshot.ActiveEntry = RecentRouteEntry{} }},
149+ {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) {150 {name: "entry is active", mutate: func(snapshot *RecentRouteSnapshot) {
145- snapshot.Entries = append(snapshot.Entries, RecentRouteEntry{Key: 11, Generation: 3, Name: "work", Kind: RouteKindLocal})151+ snapshot.Entries = append(snapshot.Entries, snapshot.ActiveEntry)
146 }},152 }},
147 {name: "duplicate entry", mutate: func(snapshot *RecentRouteSnapshot) { snapshot.Entries = append(snapshot.Entries, snapshot.Entries[0]) }},153 {name: "duplicate entry", mutate: func(snapshot *RecentRouteSnapshot) { snapshot.Entries = append(snapshot.Entries, snapshot.Entries[0]) }},
148 {name: "too many entries", mutate: func(snapshot *RecentRouteSnapshot) {154 {name: "too many entries", mutate: func(snapshot *RecentRouteSnapshot) {
149 snapshot.Entries = make([]RecentRouteEntry, RouteSnapshotMaxEntries+1)155 snapshot.Entries = make([]RecentRouteEntry, RouteSnapshotMaxEntries+1)
150 for i := range snapshot.Entries {156 for i := range snapshot.Entries {
151- snapshot.Entries[i] = RecentRouteEntry{Key: uint64(i + 1), Generation: uint64(i + 1), Name: "x", Kind: RouteKindLocal}157+ target := ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{byte(i + 1)}, SessionName: "x"}
158+ snapshot.Entries[i] = RecentRouteEntry{Key: uint64(i + 1), Generation: uint64(i + 1), Target: target, Name: "x", Kind: RouteKindLocal}
152 }159 }
153 }},160 }},
154 }161 }
@@ -189,20 +196,41 @@ func TestRecentRouteSnapshotRejectsOversizedFrameBeforeParsing(t *testing.T) {
189 require.ErrorIs(t, err, ErrInvalidRouteWire)196 require.ErrorIs(t, err, ErrInvalidRouteWire)
190 }197 }
191 198
199+func TestRouteLabelBoundsMatchRemoteDisplayOrigin(t *testing.T) {
200+ tests := []struct {
201+ name string
202+ length int
203+ wantErr bool
204+ }{
205+ {name: "maximum", length: 256},
206+ {name: "over maximum", length: 257, wantErr: true},
207+ }
208+ for _, tt := range tests {
209+ t.Run(tt.name, func(t *testing.T) {
210+ err := ValidateRouteLabel(strings.Repeat("a", tt.length), false)
211+ if tt.wantErr {
212+ require.Error(t, err)
213+ return
214+ }
215+ require.NoError(t, err)
216+ })
217+ }
218+}
219+
192 func TestRouteLabelsRejectTerminalUnsafeText(t *testing.T) {220 func TestRouteLabelsRejectTerminalUnsafeText(t *testing.T) {
193 values := []string{"line\nfeed", "\u2028line-separator", "\u2029paragraph-separator", "\x1b[31mred", "\u202eoverride", string([]byte{0xff, 0xfe})}221 values := []string{"line\nfeed", "\u2028line-separator", "\u2029paragraph-separator", "\x1b[31mred", "\u202eoverride", string([]byte{0xff, 0xfe})}
194 for _, value := range values {222 for _, value := range values {
195 t.Run("name/"+value, func(t *testing.T) {223 t.Run("name/"+value, func(t *testing.T) {
196 _, err := MarshalRecentRouteSnapshot(RecentRouteSnapshot{224 _, err := MarshalRecentRouteSnapshot(RecentRouteSnapshot{
197 Generation: 1,225 Generation: 1,
198- Entries: []RecentRouteEntry{{Key: 1, Generation: 1, Name: value, Kind: RouteKindLocal}},226+ Entries: []RecentRouteEntry{{Key: 1, Generation: 1, Target: ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: value}, Name: value, Kind: RouteKindLocal}},
199 })227 })
200 require.Error(t, err)228 require.Error(t, err)
201 })229 })
202 t.Run("host/"+value, func(t *testing.T) {230 t.Run("host/"+value, func(t *testing.T) {
203 _, err := MarshalRecentRouteSnapshot(RecentRouteSnapshot{231 _, err := MarshalRecentRouteSnapshot(RecentRouteSnapshot{
204 Generation: 1,232 Generation: 1,
205- Entries: []RecentRouteEntry{{Key: 1, Generation: 1, Name: "work", HostLabel: value, Kind: RouteKindRemote}},233+ Entries: []RecentRouteEntry{{Key: 1, Generation: 1, Target: testExactTarget(), Name: "work", HostLabel: value, Kind: RouteKindRemote}},
206 })234 })
207 require.Error(t, err)235 require.Error(t, err)
208 })236 })
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 +14 -14
@@ -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
@@ -173,15 +173,15 @@ func TestHelloEnvironmentCodec(t *testing.T) {
173173 base := MarshalHello(Hello{Version: ProtocolVersion, Size: domain.Size{Cols: 1, Rows: 1}})
174174 withCount := func(count byte) []byte {
175175 payload := append([]byte(nil), base...)
176- // The uint32 environment count occupies bytes len-13 through len-10.
177- payload[len(payload)-10] = count
176+ // The uint32 environment count occupies bytes len-11 through len-8.
177+ payload[len(payload)-8] = count
178178 return payload
179179 }
180180 withEntries := func(count byte, entries ...byte) []byte {
181181 payload := withCount(count)
182- // The nine-byte tail follows the environment-count field.
183- tail := append([]byte(nil), payload[len(payload)-9:]...)
184- payload = append(payload[:len(payload)-9], entries...)
182+ // The seven-byte tail follows the environment-count field.
183+ tail := append([]byte(nil), payload[len(payload)-7:]...)
184+ payload = append(payload[:len(payload)-7], entries...)
185185 return append(payload, tail...)
186186 }
187187 tests := []struct {
@@ -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
@@ -173,15 +173,15 @@ func TestHelloEnvironmentCodec(t *testing.T) {
173 base := MarshalHello(Hello{Version: ProtocolVersion, Size: domain.Size{Cols: 1, Rows: 1}})173 base := MarshalHello(Hello{Version: ProtocolVersion, Size: domain.Size{Cols: 1, Rows: 1}})
174 withCount := func(count byte) []byte {174 withCount := func(count byte) []byte {
175 payload := append([]byte(nil), base...)175 payload := append([]byte(nil), base...)
176- // The uint32 environment count occupies bytes len-13 through len-10.176+ // The uint32 environment count occupies bytes len-11 through len-8.
177- payload[len(payload)-10] = count177+ payload[len(payload)-8] = count
178 return payload178 return payload
179 }179 }
180 withEntries := func(count byte, entries ...byte) []byte {180 withEntries := func(count byte, entries ...byte) []byte {
181 payload := withCount(count)181 payload := withCount(count)
182- // The nine-byte tail follows the environment-count field.182+ // The seven-byte tail follows the environment-count field.
183- tail := append([]byte(nil), payload[len(payload)-9:]...)183+ tail := append([]byte(nil), payload[len(payload)-7:]...)
184- payload = append(payload[:len(payload)-9], entries...)184+ payload = append(payload[:len(payload)-7], entries...)
185 return append(payload, tail...)185 return append(payload, tail...)
186 }186 }
187 tests := []struct {187 tests := []struct {
@@ -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 +12 -10
@@ -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)
@@ -700,10 +701,11 @@ func TestAttachedNavigationCommandSendsResultAfterLocalTransition(t *testing.T)
700701 d.sessions[target.id] = target
701702 d.mu.Unlock()
702703 ac.setRouteSnapshot(ports.RecentRouteSnapshot{
703- Generation: 2,
704- Active: ports.RouteRef{Key: 2, Generation: 2},
705- Previous: ports.RouteRef{Key: 1, Generation: 1},
706- Entries: []ports.RecentRouteEntry{{Key: 1, Generation: 1, Name: "target", Kind: ports.RouteKindLocal}},
704+ Generation: 2,
705+ Active: ports.RouteRef{Key: 2, Generation: 2},
706+ ActiveEntry: testRouteEntry(2, 2, source.name, 2, ports.RouteKindLocal),
707+ Previous: ports.RouteRef{Key: 1, Generation: 1},
708+ Entries: []ports.RecentRouteEntry{testRouteEntry(1, 1, "target", 1, ports.RouteKindLocal)},
707709 })
708710
709711 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)
@@ -700,10 +701,11 @@ func TestAttachedNavigationCommandSendsResultAfterLocalTransition(t *testing.T)
700 d.sessions[target.id] = target701 d.sessions[target.id] = target
701 d.mu.Unlock()702 d.mu.Unlock()
702 ac.setRouteSnapshot(ports.RecentRouteSnapshot{703 ac.setRouteSnapshot(ports.RecentRouteSnapshot{
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+ ActiveEntry: testRouteEntry(2, 2, source.name, 2, ports.RouteKindLocal),
706- Entries: []ports.RecentRouteEntry{{Key: 1, Generation: 1, Name: "target", Kind: ports.RouteKindLocal}},707+ Previous: ports.RouteRef{Key: 1, Generation: 1},
708+ Entries: []ports.RecentRouteEntry{testRouteEntry(1, 1, "target", 1, ports.RouteKindLocal)},
707 })709 })
708 710
709 transport := &closeTrackingTransport{}711 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/coderabbit_regression_test.go +63 -0
@@ -13,6 +13,7 @@ import (
1313
1414 "github.com/bnema/vev/internal/domain"
1515 "github.com/bnema/vev/internal/ports"
16+ "github.com/bnema/vev/internal/usecase/picker"
1617 recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
1718 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
1819 )
@@ -243,6 +244,68 @@ func TestCreateSessionRechecksShutdownAfterCatalogueRead(t *testing.T) {
243244 require.Empty(t, d.sessions)
244245 }
245246
247+type blockingResumeCatalogue struct {
248+ *durableRecoveryCatalogue
249+ entered chan struct{}
250+ release chan struct{}
251+ once sync.Once
252+}
253+
254+func (c *blockingResumeCatalogue) Record(name string) (domain.CatalogueRecord, bool, error) {
255+ c.once.Do(func() { close(c.entered) })
256+ <-c.release
257+ return c.durableRecoveryCatalogue.Record(name)
258+}
259+
260+func TestResumeStoppedSessionRejectsReplacementDuringCatalogueRead(t *testing.T) {
261+ sourcePTY, releaseSource := newBlockingPTY(t)
262+ defer releaseSource()
263+ targetPTY, releaseTarget := newBlockingPTY(t)
264+ defer releaseTarget()
265+ d, from, ac, _ := newManualSessionWithPTYs(t, sourcePTY)
266+ d.ptys = newFactorySeq(t, targetPTY)
267+
268+ record := durableRecoveryRecord(1)
269+ record.Name = "stopped"
270+ catalogue := &blockingResumeCatalogue{
271+ durableRecoveryCatalogue: newDurableRecoveryCatalogue([]domain.CatalogueRecord{record}),
272+ entered: make(chan struct{}),
273+ release: make(chan struct{}),
274+ }
275+ d.catalogue = catalogue
276+ d.persistEnabled = true
277+ d.recovery = recoveryusecase.NewCoordinator(catalogue, noOpSnapshotRepository{}, nil)
278+ d.stopped[record.Name] = stoppedSessionFromRecord(record, ports.SessionDown, nil)
279+
280+ target := picker.Target{
281+ Name: record.Name,
282+ Stopped: true,
283+ Incarnation: record.IncarnationID,
284+ ExpectedCreatedAt: &record.CreatedAt,
285+ }
286+ result := make(chan bool, 1)
287+ go func() { result <- d.resumeStoppedAndSwitch(from, ac, target) }()
288+
289+ <-catalogue.entered
290+ replacement := record
291+ replacement.IncarnationID = domain.IncarnationID{9}
292+ replacement.CreatedAt++
293+ replacement.UpdatedAt = replacement.CreatedAt
294+ require.NoError(t, catalogue.Replace(replacement.Name, replacement))
295+ d.mu.Lock()
296+ d.stopped[replacement.Name] = stoppedSessionFromRecord(replacement, ports.SessionDown, nil)
297+ d.mu.Unlock()
298+ close(catalogue.release)
299+
300+ require.False(t, <-result)
301+ require.Same(t, from, ac.currentSession())
302+ require.Empty(t, catalogue.MetadataUpdates())
303+ d.mu.Lock()
304+ defer d.mu.Unlock()
305+ require.Nil(t, d.findByNameLocked(record.Name))
306+ require.Equal(t, replacement.IncarnationID, d.stopped[record.Name].incarnation)
307+}
308+
246309 func TestSnapshotStopContextCancelIsIdempotent(t *testing.T) {
247310 deadline := &snapshotShutdownDeadline{done: make(chan struct{})}
248311 _, cancel := snapshotStopContext(deadline)
@@ -13,6 +13,7 @@ import (
13 13
14 "github.com/bnema/vev/internal/domain"14 "github.com/bnema/vev/internal/domain"
15 "github.com/bnema/vev/internal/ports"15 "github.com/bnema/vev/internal/ports"
16+ "github.com/bnema/vev/internal/usecase/picker"
16 recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"17 recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
17 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"18 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
18 )19 )
@@ -243,6 +244,68 @@ func TestCreateSessionRechecksShutdownAfterCatalogueRead(t *testing.T) {
243 require.Empty(t, d.sessions)244 require.Empty(t, d.sessions)
244 }245 }
245 246
247+type blockingResumeCatalogue struct {
248+ *durableRecoveryCatalogue
249+ entered chan struct{}
250+ release chan struct{}
251+ once sync.Once
252+}
253+
254+func (c *blockingResumeCatalogue) Record(name string) (domain.CatalogueRecord, bool, error) {
255+ c.once.Do(func() { close(c.entered) })
256+ <-c.release
257+ return c.durableRecoveryCatalogue.Record(name)
258+}
259+
260+func TestResumeStoppedSessionRejectsReplacementDuringCatalogueRead(t *testing.T) {
261+ sourcePTY, releaseSource := newBlockingPTY(t)
262+ defer releaseSource()
263+ targetPTY, releaseTarget := newBlockingPTY(t)
264+ defer releaseTarget()
265+ d, from, ac, _ := newManualSessionWithPTYs(t, sourcePTY)
266+ d.ptys = newFactorySeq(t, targetPTY)
267+
268+ record := durableRecoveryRecord(1)
269+ record.Name = "stopped"
270+ catalogue := &blockingResumeCatalogue{
271+ durableRecoveryCatalogue: newDurableRecoveryCatalogue([]domain.CatalogueRecord{record}),
272+ entered: make(chan struct{}),
273+ release: make(chan struct{}),
274+ }
275+ d.catalogue = catalogue
276+ d.persistEnabled = true
277+ d.recovery = recoveryusecase.NewCoordinator(catalogue, noOpSnapshotRepository{}, nil)
278+ d.stopped[record.Name] = stoppedSessionFromRecord(record, ports.SessionDown, nil)
279+
280+ target := picker.Target{
281+ Name: record.Name,
282+ Stopped: true,
283+ Incarnation: record.IncarnationID,
284+ ExpectedCreatedAt: &record.CreatedAt,
285+ }
286+ result := make(chan bool, 1)
287+ go func() { result <- d.resumeStoppedAndSwitch(from, ac, target) }()
288+
289+ <-catalogue.entered
290+ replacement := record
291+ replacement.IncarnationID = domain.IncarnationID{9}
292+ replacement.CreatedAt++
293+ replacement.UpdatedAt = replacement.CreatedAt
294+ require.NoError(t, catalogue.Replace(replacement.Name, replacement))
295+ d.mu.Lock()
296+ d.stopped[replacement.Name] = stoppedSessionFromRecord(replacement, ports.SessionDown, nil)
297+ d.mu.Unlock()
298+ close(catalogue.release)
299+
300+ require.False(t, <-result)
301+ require.Same(t, from, ac.currentSession())
302+ require.Empty(t, catalogue.MetadataUpdates())
303+ d.mu.Lock()
304+ defer d.mu.Unlock()
305+ require.Nil(t, d.findByNameLocked(record.Name))
306+ require.Equal(t, replacement.IncarnationID, d.stopped[record.Name].incarnation)
307+}
308+
246 func TestSnapshotStopContextCancelIsIdempotent(t *testing.T) {309 func TestSnapshotStopContextCancelIsIdempotent(t *testing.T) {
247 deadline := &snapshotShutdownDeadline{done: make(chan struct{})}310 deadline := &snapshotShutdownDeadline{done: make(chan struct{})}
248 _, cancel := snapshotStopContext(deadline)311 _, cancel := snapshotStopContext(deadline)
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/move_picker.go +1 -1
@@ -128,7 +128,7 @@ func (d *Daemon) previewTarget(target picker.Target, intent pickerIntent) (*sess
128128 sess.mu.Lock()
129129 d.mu.Unlock()
130130 defer sess.mu.Unlock()
131- if sess.incarnation != target.Incarnation || !targetMatchesLifecycle(target, sess.name, sess.createdAt) {
131+ if sess.incarnation != target.Incarnation || !targetMatchesLifecycle(target, sess.name, sess.createdAt, sess.incarnation) {
132132 return nil, nil
133133 }
134134 if intent == pickerMoveTab {
@@ -128,7 +128,7 @@ func (d *Daemon) previewTarget(target picker.Target, intent pickerIntent) (*sess
128 sess.mu.Lock()128 sess.mu.Lock()
129 d.mu.Unlock()129 d.mu.Unlock()
130 defer sess.mu.Unlock()130 defer sess.mu.Unlock()
131- if sess.incarnation != target.Incarnation || !targetMatchesLifecycle(target, sess.name, sess.createdAt) {131+ if sess.incarnation != target.Incarnation || !targetMatchesLifecycle(target, sess.name, sess.createdAt, sess.incarnation) {
132 return nil, nil132 return nil, nil
133 }133 }
134 if intent == pickerMoveTab {134 if intent == pickerMoveTab {
M internal/usecase/daemon/navigation_attachment_incarnation_test.go +40 -0
@@ -8,6 +8,46 @@ import (
88 "github.com/stretchr/testify/require"
99 )
1010
11+func TestNavigationHandoffRejectsReplacedTargetLifecycleAtPublication(t *testing.T) {
12+ d, source, ac, _ := newManualSessionWithPTYs(t, nil)
13+ target := &session{sessionCore: sessionCore{id: "target", name: "target", incarnation: domain.SessionLifecycleID{1}}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{
14+ newTab(nil, domain.Size{Cols: 80, Rows: 23}),
15+ newTab(nil, domain.Size{Cols: 80, Rows: 23}),
16+ }}
17+ d.mu.Lock()
18+ d.sessions[target.id] = target
19+ d.mu.Unlock()
20+
21+ rc := d.attachCoordinator(source, nil, ac, true)
22+ token := source.attachmentToken(ac, ac.transport())
23+ token.lease = rc.attachmentLease(ac)
24+ ac.publishAttachmentCapability(token)
25+ effect, admitted := ac.beginAttachmentEffect(token)
26+ require.True(t, admitted)
27+
28+ d.afterAttachmentTransitionCoordinatorsLocked = func() {
29+ // This hook runs with the target session locked, immediately before the
30+ // transition's lifecycle fence is validated.
31+ target.incarnation = domain.SessionLifecycleID{2}
32+ }
33+ err := d.switchToTargetForAttachment(effect.connectionToken(), picker.Target{
34+ Session: target.id,
35+ Incarnation: domain.SessionLifecycleID{1},
36+ TabID: domain.TabStableID(target.tabs[1].stableID),
37+ TabIndex: 1,
38+ }, sessionHandoffGuard{}, "test-handoff")
39+
40+ require.Error(t, err)
41+ var userErr *domain.UserError
42+ require.ErrorAs(t, err, &userErr)
43+ require.Equal(t, domain.NoticeSessionUnavailable, userErr.Code)
44+ require.Same(t, source, ac.currentSession())
45+ target.mu.Lock()
46+ require.Empty(t, target.snapshotAttachmentsLocked())
47+ require.Zero(t, testAttachmentTabIndexLocked(target))
48+ target.mu.Unlock()
49+}
50+
1151 func TestNavigationHandoffDoesNotMutateAfterInitiatorIncarnationChanges(t *testing.T) {
1252 tests := []struct {
1353 name string
@@ -8,6 +8,46 @@ import (
8 "github.com/stretchr/testify/require"8 "github.com/stretchr/testify/require"
9 )9 )
10 10
11+func TestNavigationHandoffRejectsReplacedTargetLifecycleAtPublication(t *testing.T) {
12+ d, source, ac, _ := newManualSessionWithPTYs(t, nil)
13+ target := &session{sessionCore: sessionCore{id: "target", name: "target", incarnation: domain.SessionLifecycleID{1}}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{
14+ newTab(nil, domain.Size{Cols: 80, Rows: 23}),
15+ newTab(nil, domain.Size{Cols: 80, Rows: 23}),
16+ }}
17+ d.mu.Lock()
18+ d.sessions[target.id] = target
19+ d.mu.Unlock()
20+
21+ rc := d.attachCoordinator(source, nil, ac, true)
22+ token := source.attachmentToken(ac, ac.transport())
23+ token.lease = rc.attachmentLease(ac)
24+ ac.publishAttachmentCapability(token)
25+ effect, admitted := ac.beginAttachmentEffect(token)
26+ require.True(t, admitted)
27+
28+ d.afterAttachmentTransitionCoordinatorsLocked = func() {
29+ // This hook runs with the target session locked, immediately before the
30+ // transition's lifecycle fence is validated.
31+ target.incarnation = domain.SessionLifecycleID{2}
32+ }
33+ err := d.switchToTargetForAttachment(effect.connectionToken(), picker.Target{
34+ Session: target.id,
35+ Incarnation: domain.SessionLifecycleID{1},
36+ TabID: domain.TabStableID(target.tabs[1].stableID),
37+ TabIndex: 1,
38+ }, sessionHandoffGuard{}, "test-handoff")
39+
40+ require.Error(t, err)
41+ var userErr *domain.UserError
42+ require.ErrorAs(t, err, &userErr)
43+ require.Equal(t, domain.NoticeSessionUnavailable, userErr.Code)
44+ require.Same(t, source, ac.currentSession())
45+ target.mu.Lock()
46+ require.Empty(t, target.snapshotAttachmentsLocked())
47+ require.Zero(t, testAttachmentTabIndexLocked(target))
48+ target.mu.Unlock()
49+}
50+
11 func TestNavigationHandoffDoesNotMutateAfterInitiatorIncarnationChanges(t *testing.T) {51 func TestNavigationHandoffDoesNotMutateAfterInitiatorIncarnationChanges(t *testing.T) {
12 tests := []struct {52 tests := []struct {
13 name string53 name string
M internal/usecase/daemon/palette.go +20 -32
@@ -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))
@@ -73,14 +73,18 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro
7373 results = append(results, palette.NewCommandResult(cmd))
7474 }
7575 active := make([]palette.Result, 0, len(sessions))
76+ localLifecycles := make(map[domain.SessionLifecycleID]struct{}, len(sessions)+len(stopped))
7677 for _, candidate := range sessions {
77- if candidate == current {
78+ snap := candidate.snapshotView(viewOptions{})
79+ if snap.name == "" || snap.ephemeral {
7880 continue
7981 }
80- snap := candidate.snapshotView(viewOptions{})
81- if snap.name != "" && !snap.ephemeral {
82- active = append(active, palette.NewActiveSessionResult(snap.name, time.Unix(0, snap.createdAt), snap.id))
82+ localLifecycles[snap.incarnation] = struct{}{}
83+ if candidate == current {
84+ continue
8385 }
86+ target := ports.ExactSessionTarget{LifecycleID: snap.incarnation, SessionName: snap.name}
87+ active = append(active, palette.NewActiveSessionResult(target, time.Unix(0, snap.createdAt)))
8488 }
8589 sort.Slice(active, func(i, j int) bool {
8690 left, _ := active[i].SessionName()
@@ -89,14 +93,10 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro
8993 })
9094 sort.Slice(stopped, func(i, j int) bool { return stopped[i].name < stopped[j].name })
9195 results = append(results, active...)
92- localNames := make(map[string]struct{}, len(active)+len(stopped))
93- for _, candidate := range active {
94- name, _ := candidate.SessionName()
95- localNames[name] = struct{}{}
96- }
9796 for _, candidate := range stopped {
98- results = append(results, palette.NewStoppedSessionResult(candidate.name, time.Unix(0, candidate.createdAt)))
99- localNames[candidate.name] = struct{}{}
97+ target := ports.ExactSessionTarget{LifecycleID: candidate.incarnation, SessionName: candidate.name}
98+ results = append(results, palette.NewStoppedSessionResult(target, time.Unix(0, candidate.createdAt)))
99+ localLifecycles[target.LifecycleID] = struct{}{}
100100 }
101101 formattedRoutes := formatRecentRouteSnapshot(routeSnapshot)
102102 for i, entry := range routeSnapshot.Entries {
@@ -107,19 +107,8 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro
107107 if label == "" {
108108 continue
109109 }
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- }
110+ if _, represented := localLifecycles[entry.Target.LifecycleID]; represented {
111+ continue
123112 }
124113 results = append(results, palette.NewRecentRouteResult(label, ports.RouteNavigationAction{
125114 SnapshotGeneration: routeSnapshot.Generation,
@@ -283,7 +272,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
283272 } else if action, ok := selected.RouteNavigationAction(); ok {
284273 routeTarget = action
285274 hasRouteTarget = true
286- } else if _, ok := selected.SessionName(); ok {
275+ } else if _, ok := selected.SessionTarget(); ok {
287276 sessionTarget = selected
288277 hasSessionTarget = true
289278 } else {
@@ -347,11 +336,10 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
347336 createdAt, _ := sessionTarget.SessionCreatedAt()
348337 expectedCreatedAt := createdAt.UnixNano()
349338 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
339+ exactTarget, _ := sessionTarget.SessionTarget()
340+ target := picker.Target{
341+ Incarnation: exactTarget.LifecycleID, Name: name, TabIndex: -1,
342+ ExpectedCreatedAt: &expectedCreatedAt, Stopped: sessionTarget.Kind() == palette.ResultKindStoppedSession,
355343 }
356344 var err error
357345 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))
@@ -73,14 +73,18 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro
73 results = append(results, palette.NewCommandResult(cmd))73 results = append(results, palette.NewCommandResult(cmd))
74 }74 }
75 active := make([]palette.Result, 0, len(sessions))75 active := make([]palette.Result, 0, len(sessions))
76+ localLifecycles := make(map[domain.SessionLifecycleID]struct{}, len(sessions)+len(stopped))
76 for _, candidate := range sessions {77 for _, candidate := range sessions {
77- if candidate == current {78+ snap := candidate.snapshotView(viewOptions{})
79+ if snap.name == "" || snap.ephemeral {
78 continue80 continue
79 }81 }
80- snap := candidate.snapshotView(viewOptions{})82+ localLifecycles[snap.incarnation] = struct{}{}
81- if snap.name != "" && !snap.ephemeral {83+ if candidate == current {
82- active = append(active, palette.NewActiveSessionResult(snap.name, time.Unix(0, snap.createdAt), snap.id))84+ continue
83 }85 }
86+ target := ports.ExactSessionTarget{LifecycleID: snap.incarnation, SessionName: snap.name}
87+ active = append(active, palette.NewActiveSessionResult(target, time.Unix(0, snap.createdAt)))
84 }88 }
85 sort.Slice(active, func(i, j int) bool {89 sort.Slice(active, func(i, j int) bool {
86 left, _ := active[i].SessionName()90 left, _ := active[i].SessionName()
@@ -89,14 +93,10 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro
89 })93 })
90 sort.Slice(stopped, func(i, j int) bool { return stopped[i].name < stopped[j].name })94 sort.Slice(stopped, func(i, j int) bool { return stopped[i].name < stopped[j].name })
91 results = append(results, active...)95 results = append(results, active...)
92- localNames := make(map[string]struct{}, len(active)+len(stopped))
93- for _, candidate := range active {
94- name, _ := candidate.SessionName()
95- localNames[name] = struct{}{}
96- }
97 for _, candidate := range stopped {96 for _, candidate := range stopped {
98- results = append(results, palette.NewStoppedSessionResult(candidate.name, time.Unix(0, candidate.createdAt)))97+ target := ports.ExactSessionTarget{LifecycleID: candidate.incarnation, SessionName: candidate.name}
99- localNames[candidate.name] = struct{}{}98+ results = append(results, palette.NewStoppedSessionResult(target, time.Unix(0, candidate.createdAt)))
99+ localLifecycles[target.LifecycleID] = struct{}{}
100 }100 }
101 formattedRoutes := formatRecentRouteSnapshot(routeSnapshot)101 formattedRoutes := formatRecentRouteSnapshot(routeSnapshot)
102 for i, entry := range routeSnapshot.Entries {102 for i, entry := range routeSnapshot.Entries {
@@ -107,19 +107,8 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro
107 if label == "" {107 if label == "" {
108 continue108 continue
109 }109 }
110- if _, represented := localNames[entry.Name]; represented {110+ if _, represented := localLifecycles[entry.Target.LifecycleID]; represented {
111- sameOrigin := (entry.Kind == ports.RouteKindLocal && currentRemoteOrigin == "") ||111+ 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 }112 }
124 results = append(results, palette.NewRecentRouteResult(label, ports.RouteNavigationAction{113 results = append(results, palette.NewRecentRouteResult(label, ports.RouteNavigationAction{
125 SnapshotGeneration: routeSnapshot.Generation,114 SnapshotGeneration: routeSnapshot.Generation,
@@ -283,7 +272,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
283 } else if action, ok := selected.RouteNavigationAction(); ok {272 } else if action, ok := selected.RouteNavigationAction(); ok {
284 routeTarget = action273 routeTarget = action
285 hasRouteTarget = true274 hasRouteTarget = true
286- } else if _, ok := selected.SessionName(); ok {275+ } else if _, ok := selected.SessionTarget(); ok {
287 sessionTarget = selected276 sessionTarget = selected
288 hasSessionTarget = true277 hasSessionTarget = true
289 } else {278 } else {
@@ -347,11 +336,10 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
347 createdAt, _ := sessionTarget.SessionCreatedAt()336 createdAt, _ := sessionTarget.SessionCreatedAt()
348 expectedCreatedAt := createdAt.UnixNano()337 expectedCreatedAt := createdAt.UnixNano()
349 name, _ := sessionTarget.SessionName()338 name, _ := sessionTarget.SessionName()
350- target := picker.Target{Name: name, TabIndex: -1, ExpectedCreatedAt: &expectedCreatedAt}339+ exactTarget, _ := sessionTarget.SessionTarget()
351- if id, active := sessionTarget.SessionID(); active {340+ target := picker.Target{
352- target.Session = id341+ Incarnation: exactTarget.LifecycleID, Name: name, TabIndex: -1,
353- } else {342+ ExpectedCreatedAt: &expectedCreatedAt, Stopped: sessionTarget.Kind() == palette.ResultKindStoppedSession,
354- target.Stopped = true
355 }343 }
356 var err error344 var err error
357 if effect != nil {345 if effect != nil {
M internal/usecase/daemon/palette_test.go +71 -40
@@ -14,17 +14,19 @@ import (
1414 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1515 "github.com/bnema/vev/internal/usecase/command"
1616 "github.com/bnema/vev/internal/usecase/palette"
17+ "github.com/bnema/vev/internal/usecase/picker"
1718 themeui "github.com/bnema/vev/internal/usecase/theme"
1819 "github.com/bnema/vev/internal/usecase/ui"
1920 )
2021
2122 func testRecentRouteSnapshot() ports.RecentRouteSnapshot {
2223 return ports.RecentRouteSnapshot{
23- Generation: 1,
24- Active: ports.RouteRef{Key: 1, Generation: 1},
24+ Generation: 1,
25+ Active: ports.RouteRef{Key: 1, Generation: 1},
26+ ActiveEntry: testRouteEntry(1, 1, "current", 1, ports.RouteKindLocal),
2527 Entries: []ports.RecentRouteEntry{
26- {Key: 2, Generation: 1, Name: "recent", Kind: ports.RouteKindLocal},
27- {Key: 3, Generation: 1, Name: "older", Kind: ports.RouteKindLocal},
28+ testRouteEntry(2, 1, "recent", 2, ports.RouteKindLocal),
29+ testRouteEntry(3, 1, "older", 3, ports.RouteKindLocal),
2830 },
2931 }
3032 }
@@ -419,42 +421,69 @@ func TestPaletteJRSUsesEffectiveOverrideOnly(t *testing.T) {
419421 require.True(t, ac.overlays.paletteActive(), "literal JRS must not execute an overridden jump command")
420422 }
421423
422-func TestPaletteResultsDeduplicateRoutesLocalToCurrentRemoteDaemon(t *testing.T) {
424+func TestPaletteResultsDeduplicateByLifecycleAndKeepEqualLabels(t *testing.T) {
423425 d := newTestDaemon(t, nil, stubClock{})
424426 current := addControlSession(d, "hi", "tab-1", "pane-1")
425- represented := addControlSession(d, "remote-manual", "tab-2", "pane-2")
426- represented.ephemeral = false
427+ current.ephemeral = false
428+ current.incarnation = domain.SessionLifecycleID{1}
429+ local := addControlSession(d, "vev", "tab-2", "pane-2")
430+ local.ephemeral = false
431+ local.incarnation = domain.SessionLifecycleID{2}
432+ remoteOne := ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{21}, SessionName: "vev"}
433+ remoteTwo := ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{22}, SessionName: "vev"}
427434 snapshot := ports.RecentRouteSnapshot{
428435 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- }},
436+ Entries: []ports.RecentRouteEntry{
437+ {Key: 1, Generation: 1, Target: ports.ExactSessionTarget{LifecycleID: current.incarnation, SessionName: "hi"}, Name: "hi", Kind: ports.RouteKindLocal},
438+ {Key: 2, Generation: 1, Target: ports.ExactSessionTarget{LifecycleID: local.incarnation, SessionName: "vev"}, Name: "vev", Kind: ports.RouteKindLocal},
439+ {Key: 3, Generation: 1, Target: remoteOne, Name: "vev", HostLabel: "arch", Kind: ports.RouteKindRemote},
440+ {Key: 4, Generation: 1, Target: remoteTwo, Name: "vev", HostLabel: "arch", Kind: ports.RouteKindRemote},
441+ },
433442 }
434443
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- })
444+ results := d.paletteResults(current, nil, snapshot)
445+ var sessionTargets []ports.ExactSessionTarget
446+ var routeActions []ports.RouteNavigationAction
447+ for _, result := range results {
448+ if target, ok := result.SessionTarget(); ok {
449+ sessionTargets = append(sessionTargets, target)
450+ }
451+ if action, ok := result.RouteNavigationAction(); ok {
452+ routeActions = append(routeActions, action)
453+ }
454+ }
455+ require.Equal(t, []ports.ExactSessionTarget{{LifecycleID: local.incarnation, SessionName: "vev"}}, sessionTargets)
456+ require.Equal(t, []ports.RouteNavigationAction{
457+ {SnapshotGeneration: 2, Key: 3, Generation: 1},
458+ {SnapshotGeneration: 2, Key: 4, Generation: 1},
459+ }, routeActions)
460+ require.Equal(t, "Switch to session vev@arch", results[len(results)-2].DisplayText())
461+ require.Equal(t, "Switch to session vev@arch", results[len(results)-1].DisplayText())
462+}
463+
464+func TestPaletteLifecycleTargetRejectsSameNameReplacement(t *testing.T) {
465+ d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
466+ defer releaseAll(releases)
467+ target := d.sessions["recent"]
468+ target.incarnation = domain.SessionLifecycleID{2}
469+ target.createdAt = 7
470+ token := beginRecentRoutePaletteEffect(t, d, current, ac)
471+ expectedCreatedAt := target.createdAt
472+
473+ err := d.switchToTargetForAttachment(token, picker.Target{
474+ Name: "recent", Incarnation: domain.SessionLifecycleID{9},
475+ ExpectedCreatedAt: &expectedCreatedAt, TabIndex: -1,
476+ }, sessionHandoffGuard{}, "palette-session")
477+
478+ require.ErrorIs(t, err, errAttachmentTransition)
479+ require.Same(t, current, ac.currentSession())
480+ for {
481+ select {
482+ case frame := <-sends:
483+ require.NotEqual(t, ports.MsgAttachTarget, frame.Type)
484+ default:
485+ return
486+ }
458487 }
459488 }
460489
@@ -462,10 +491,11 @@ func TestPaletteFuzzyRemoteRecentRouteSendsExactNavigationAction(t *testing.T) {
462491 d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
463492 defer releaseAll(releases)
464493 ac.setRouteSnapshot(ports.RecentRouteSnapshot{
465- Generation: 9,
466- Active: ports.RouteRef{Key: 1, Generation: 1},
494+ Generation: 9,
495+ Active: ports.RouteRef{Key: 1, Generation: 1},
496+ ActiveEntry: testRouteEntry(1, 1, current.name, 1, ports.RouteKindLocal),
467497 Entries: []ports.RecentRouteEntry{{
468- Key: 8, Generation: 4, Name: "logs", HostLabel: "edge", Kind: ports.RouteKindRemote,
498+ Key: 8, Generation: 4, Target: testRouteTarget("logs", 8), Name: "logs", HostLabel: "edge", Kind: ports.RouteKindRemote,
469499 }},
470500 })
471501 token := beginRecentRoutePaletteEffect(t, d, current, ac)
@@ -541,9 +571,10 @@ func TestPaletteJRSDoesNotRevalidateTargetInDaemon(t *testing.T) {
541571 defer release()
542572 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
543573 ac.setRouteSnapshot(ports.RecentRouteSnapshot{
544- Generation: 7,
545- Active: ports.RouteRef{Key: 1, Generation: 1},
546- Entries: []ports.RecentRouteEntry{{Key: 9, Generation: 4, Name: "captured", Kind: ports.RouteKindLocal}},
574+ Generation: 7,
575+ Active: ports.RouteRef{Key: 1, Generation: 1},
576+ ActiveEntry: testRouteEntry(1, 1, sess.name, 1, ports.RouteKindLocal),
577+ Entries: []ports.RecentRouteEntry{testRouteEntry(9, 4, "captured", 9, ports.RouteKindLocal)},
547578 })
548579
549580 validated := make(chan struct{})
@@ -14,17 +14,19 @@ import (
14 portsmocks "github.com/bnema/vev/internal/ports/mocks"14 portsmocks "github.com/bnema/vev/internal/ports/mocks"
15 "github.com/bnema/vev/internal/usecase/command"15 "github.com/bnema/vev/internal/usecase/command"
16 "github.com/bnema/vev/internal/usecase/palette"16 "github.com/bnema/vev/internal/usecase/palette"
17+ "github.com/bnema/vev/internal/usecase/picker"
17 themeui "github.com/bnema/vev/internal/usecase/theme"18 themeui "github.com/bnema/vev/internal/usecase/theme"
18 "github.com/bnema/vev/internal/usecase/ui"19 "github.com/bnema/vev/internal/usecase/ui"
19 )20 )
20 21
21 func testRecentRouteSnapshot() ports.RecentRouteSnapshot {22 func testRecentRouteSnapshot() ports.RecentRouteSnapshot {
22 return ports.RecentRouteSnapshot{23 return ports.RecentRouteSnapshot{
23- Generation: 1,24+ Generation: 1,
24- Active: ports.RouteRef{Key: 1, Generation: 1},25+ Active: ports.RouteRef{Key: 1, Generation: 1},
26+ ActiveEntry: testRouteEntry(1, 1, "current", 1, ports.RouteKindLocal),
25 Entries: []ports.RecentRouteEntry{27 Entries: []ports.RecentRouteEntry{
26- {Key: 2, Generation: 1, Name: "recent", Kind: ports.RouteKindLocal},28+ testRouteEntry(2, 1, "recent", 2, ports.RouteKindLocal),
27- {Key: 3, Generation: 1, Name: "older", Kind: ports.RouteKindLocal},29+ testRouteEntry(3, 1, "older", 3, ports.RouteKindLocal),
28 },30 },
29 }31 }
30 }32 }
@@ -419,42 +421,69 @@ func TestPaletteJRSUsesEffectiveOverrideOnly(t *testing.T) {
419 require.True(t, ac.overlays.paletteActive(), "literal JRS must not execute an overridden jump command")421 require.True(t, ac.overlays.paletteActive(), "literal JRS must not execute an overridden jump command")
420 }422 }
421 423
422-func TestPaletteResultsDeduplicateRoutesLocalToCurrentRemoteDaemon(t *testing.T) {424+func TestPaletteResultsDeduplicateByLifecycleAndKeepEqualLabels(t *testing.T) {
423 d := newTestDaemon(t, nil, stubClock{})425 d := newTestDaemon(t, nil, stubClock{})
424 current := addControlSession(d, "hi", "tab-1", "pane-1")426 current := addControlSession(d, "hi", "tab-1", "pane-1")
425- represented := addControlSession(d, "remote-manual", "tab-2", "pane-2")427+ current.ephemeral = false
426- represented.ephemeral = false428+ current.incarnation = domain.SessionLifecycleID{1}
429+ local := addControlSession(d, "vev", "tab-2", "pane-2")
430+ local.ephemeral = false
431+ local.incarnation = domain.SessionLifecycleID{2}
432+ remoteOne := ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{21}, SessionName: "vev"}
433+ remoteTwo := ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{22}, SessionName: "vev"}
427 snapshot := ports.RecentRouteSnapshot{434 snapshot := ports.RecentRouteSnapshot{
428 Generation: 2,435 Generation: 2,
429- Active: ports.RouteRef{Key: 1, Generation: 1},436+ Entries: []ports.RecentRouteEntry{
430- Entries: []ports.RecentRouteEntry{{437+ {Key: 1, Generation: 1, Target: ports.ExactSessionTarget{LifecycleID: current.incarnation, SessionName: "hi"}, Name: "hi", Kind: ports.RouteKindLocal},
431- Key: 2, Generation: 1, Name: "remote-manual", HostLabel: "remote", Kind: ports.RouteKindRemote,438+ {Key: 2, Generation: 1, Target: ports.ExactSessionTarget{LifecycleID: local.incarnation, SessionName: "vev"}, Name: "vev", Kind: ports.RouteKindLocal},
432- }},439+ {Key: 3, Generation: 1, Target: remoteOne, Name: "vev", HostLabel: "arch", Kind: ports.RouteKindRemote},
440+ {Key: 4, Generation: 1, Target: remoteTwo, Name: "vev", HostLabel: "arch", Kind: ports.RouteKindRemote},
441+ },
433 }442 }
434 443
435- for _, test := range []struct {444+ results := d.paletteResults(current, nil, snapshot)
436- name string445+ var sessionTargets []ports.ExactSessionTarget
437- currentOrigin string446+ var routeActions []ports.RouteNavigationAction
438- want []string447+ for _, result := range results {
439- }{448+ if target, ok := result.SessionTarget(); ok {
440- {name: "remote daemon", currentOrigin: "remote", want: []string{"Switch to session remote-manual"}},449+ sessionTargets = append(sessionTargets, target)
441- {name: "remote daemon keeps distinct local route", currentOrigin: "remote", want: []string{"Switch to session remote-manual", "Switch to session remote-manual@local"}},450+ }
442- {name: "home daemon keeps distinct remote", want: []string{"Switch to session remote-manual", "Switch to session remote-manual@remote"}},451+ if action, ok := result.RouteNavigationAction(); ok {
443- } {452+ routeActions = append(routeActions, action)
444- t.Run(test.name, func(t *testing.T) {453+ }
445- testSnapshot := snapshot454+ }
446- if test.name == "remote daemon keeps distinct local route" {455+ require.Equal(t, []ports.ExactSessionTarget{{LifecycleID: local.incarnation, SessionName: "vev"}}, sessionTargets)
447- testSnapshot.Entries = append(testSnapshot.Entries, ports.RecentRouteEntry{Key: 3, Generation: 1, Name: "remote-manual", Kind: ports.RouteKindLocal})456+ require.Equal(t, []ports.RouteNavigationAction{
448- }457+ {SnapshotGeneration: 2, Key: 3, Generation: 1},
449- results := d.paletteResults(current, nil, testSnapshot, test.currentOrigin)458+ {SnapshotGeneration: 2, Key: 4, Generation: 1},
450- var matching []string459+ }, routeActions)
451- for _, result := range results {460+ require.Equal(t, "Switch to session vev@arch", results[len(results)-2].DisplayText())
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" {461+ require.Equal(t, "Switch to session vev@arch", results[len(results)-1].DisplayText())
453- matching = append(matching, result.DisplayText())462+}
454- }463+
455- }464+func TestPaletteLifecycleTargetRejectsSameNameReplacement(t *testing.T) {
456- require.Equal(t, test.want, matching)465+ d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
457- })466+ defer releaseAll(releases)
467+ target := d.sessions["recent"]
468+ target.incarnation = domain.SessionLifecycleID{2}
469+ target.createdAt = 7
470+ token := beginRecentRoutePaletteEffect(t, d, current, ac)
471+ expectedCreatedAt := target.createdAt
472+
473+ err := d.switchToTargetForAttachment(token, picker.Target{
474+ Name: "recent", Incarnation: domain.SessionLifecycleID{9},
475+ ExpectedCreatedAt: &expectedCreatedAt, TabIndex: -1,
476+ }, sessionHandoffGuard{}, "palette-session")
477+
478+ require.ErrorIs(t, err, errAttachmentTransition)
479+ require.Same(t, current, ac.currentSession())
480+ for {
481+ select {
482+ case frame := <-sends:
483+ require.NotEqual(t, ports.MsgAttachTarget, frame.Type)
484+ default:
485+ return
486+ }
458 }487 }
459 }488 }
460 489
@@ -462,10 +491,11 @@ func TestPaletteFuzzyRemoteRecentRouteSendsExactNavigationAction(t *testing.T) {
462 d, current, ac, sends, releases := newRecentNavigationTestSessions(t)491 d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
463 defer releaseAll(releases)492 defer releaseAll(releases)
464 ac.setRouteSnapshot(ports.RecentRouteSnapshot{493 ac.setRouteSnapshot(ports.RecentRouteSnapshot{
465- Generation: 9,494+ Generation: 9,
466- Active: ports.RouteRef{Key: 1, Generation: 1},495+ Active: ports.RouteRef{Key: 1, Generation: 1},
496+ ActiveEntry: testRouteEntry(1, 1, current.name, 1, ports.RouteKindLocal),
467 Entries: []ports.RecentRouteEntry{{497 Entries: []ports.RecentRouteEntry{{
468- Key: 8, Generation: 4, Name: "logs", HostLabel: "edge", Kind: ports.RouteKindRemote,498+ Key: 8, Generation: 4, Target: testRouteTarget("logs", 8), Name: "logs", HostLabel: "edge", Kind: ports.RouteKindRemote,
469 }},499 }},
470 })500 })
471 token := beginRecentRoutePaletteEffect(t, d, current, ac)501 token := beginRecentRoutePaletteEffect(t, d, current, ac)
@@ -541,9 +571,10 @@ func TestPaletteJRSDoesNotRevalidateTargetInDaemon(t *testing.T) {
541 defer release()571 defer release()
542 d, sess, ac, sends := newManualSessionWithPTYs(t, p)572 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
543 ac.setRouteSnapshot(ports.RecentRouteSnapshot{573 ac.setRouteSnapshot(ports.RecentRouteSnapshot{
544- Generation: 7,574+ Generation: 7,
545- Active: ports.RouteRef{Key: 1, Generation: 1},575+ Active: ports.RouteRef{Key: 1, Generation: 1},
546- Entries: []ports.RecentRouteEntry{{Key: 9, Generation: 4, Name: "captured", Kind: ports.RouteKindLocal}},576+ ActiveEntry: testRouteEntry(1, 1, sess.name, 1, ports.RouteKindLocal),
577+ Entries: []ports.RecentRouteEntry{testRouteEntry(9, 4, "captured", 9, ports.RouteKindLocal)},
547 })578 })
548 579
549 validated := make(chan struct{})580 validated := make(chan struct{})
M internal/usecase/daemon/picker.go +34 -21
@@ -238,7 +238,7 @@ func (d *Daemon) notifyRemotePickerUnavailable(sess *session, target picker.Targ
238238 }
239239 message := "Remote session unavailable"
240240 if target.RemoteTarget != nil {
241- message += ": " + target.RemoteTarget.SessionName + "@" + target.RemoteTarget.DisplayOrigin
241+ message += ": " + domain.RemoteSessionDisplay(target.RemoteTarget.SessionName, target.RemoteTarget.DisplayOrigin)
242242 } else if target.RemoteHost != "" {
243243 message += ": " + target.RemoteHost
244244 }
@@ -1169,12 +1169,12 @@ func (d *Daemon) resolveNamedLifecycleTargetLocked(target picker.Target) (*sessi
11691169 }
11701170 if active := d.findByNameLocked(target.Name); active != nil {
11711171 active.mu.Lock()
1172- matches := targetMatchesLifecycle(target, active.name, active.createdAt)
1172+ matches := targetMatchesLifecycle(target, active.name, active.createdAt, active.incarnation)
11731173 active.mu.Unlock()
11741174 return active, stoppedSession{}, false, matches
11751175 }
11761176 stopped, ok := d.stopped[target.Name]
1177- if !ok || stopped.purging || !targetMatchesLifecycle(target, stopped.name, stopped.createdAt) {
1177+ if !ok || stopped.purging || !targetMatchesLifecycle(target, stopped.name, stopped.createdAt, stopped.incarnation) {
11781178 return nil, stoppedSession{}, false, false
11791179 }
11801180 return nil, stopped, true, true
@@ -1191,7 +1191,7 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t
11911191 targetSess.mu.Lock()
11921192 defer targetSess.mu.Unlock()
11931193 _, attached := targetSess.attachments[ac]
1194- if !attached || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) || target.TabIndex < 0 || target.TabIndex >= len(targetSess.tabs) {
1194+ if !attached || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt, targetSess.incarnation) || target.TabIndex < 0 || target.TabIndex >= len(targetSess.tabs) {
11951195 return nil, attachmentTransitionResult{}, false
11961196 }
11971197 if !targetSess.activateAttachmentViewLocked(ac, target.TabIndex) {
@@ -1203,8 +1203,13 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t
12031203 transition, err := d.transitionAttachment(attachmentTransitionRequest{
12041204 source: from, target: targetSess, next: ac,
12051205
1206- expectedTransport: sourceToken.transport, sourceToken: sourceToken, action: action,
1207- activateTargetTab: true, targetTabIndex: target.TabIndex, preserveAttachment: true,
1206+ expectedTransport: sourceToken.transport,
1207+ sourceToken: sourceToken,
1208+ action: action,
1209+ expectedTargetLifecycle: pickerTargetLifecycleFence(target),
1210+ activateTargetTab: true,
1211+ targetTabIndex: target.TabIndex,
1212+ preserveAttachment: true,
12081213 })
12091214 d.mu.Lock()
12101215 return targetSess, transition, err == nil
@@ -1212,7 +1217,7 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t
12121217
12131218 unlock := lockAttachmentSessions(from, targetSess)
12141219 _, sourceAttached := from.attachments[ac]
1215- if !sourceAttached || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) {
1220+ if !sourceAttached || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt, targetSess.incarnation) {
12161221 unlock()
12171222 return nil, attachmentTransitionResult{}, false
12181223 }
@@ -1244,14 +1249,15 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t
12441249 target: targetSess,
12451250 next: ac,
12461251
1247- expectedTransport: expectedTransport,
1248- sourceToken: sourceToken,
1249- action: action,
1250- expectedSourceTab: guard.expectedSource,
1251- activateTargetTab: target.TabIndex >= 0,
1252- targetTabIndex: target.TabIndex,
1253- copySourceEnvironment: true,
1254- ready: true,
1252+ expectedTransport: expectedTransport,
1253+ sourceToken: sourceToken,
1254+ action: action,
1255+ expectedTargetLifecycle: pickerTargetLifecycleFence(target),
1256+ expectedSourceTab: guard.expectedSource,
1257+ activateTargetTab: target.TabIndex >= 0,
1258+ targetTabIndex: target.TabIndex,
1259+ copySourceEnvironment: true,
1260+ ready: true,
12551261 })
12561262 d.mu.Lock()
12571263 if err != nil {
@@ -1278,13 +1284,13 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
12781284 expectedSourceTab: guard.expectedSource, copySourceEnvironment: true, ready: true,
12791285 createTargetLocked: func() (*session, error) {
12801286 current, ok := d.stopped[target.Name]
1281- if !ok || current.purging || current.createdAt != stopped.createdAt || !targetMatchesLifecycle(target, current.name, current.createdAt) {
1287+ if !ok || current.purging || current.createdAt != stopped.createdAt || !targetMatchesLifecycle(target, current.name, current.createdAt, current.incarnation) {
12821288 return nil, errAttachmentTransition
12831289 }
12841290 from.mu.Lock()
12851291 cwd, env := d.dirOrHome(current.cwd), copyEnvironment(from.env)
12861292 from.mu.Unlock()
1287- created, createErr := d.createSessionLocked(target.Name, false, cwd, ac.sizeSnapshot(), env, current.tabNames)
1293+ created, createErr := d.createStoppedSessionLocked(target.Name, cwd, ac.sizeSnapshot(), env, current, current.tabNames)
12881294 targetSess = created
12891295 return created, createErr
12901296 },
@@ -1310,8 +1316,12 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
13101316 }
13111317 env := copyEnvironment(from.env)
13121318 from.mu.Unlock()
1313- cwd := d.dirOrHome(stopped.cwd)
1314- targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.sizeSnapshot(), env, stopped.tabNames)
1319+ current, ok := d.stopped[target.Name]
1320+ if !ok || current.purging || !stoppedSessionMatchesLifecycle(current, stopped) || !targetMatchesLifecycle(target, current.name, current.createdAt, current.incarnation) {
1321+ return nil, attachmentTransitionResult{}, false, errAttachmentTransition
1322+ }
1323+ cwd := d.dirOrHome(current.cwd)
1324+ targetSess, err := d.createStoppedSessionLocked(target.Name, cwd, ac.sizeSnapshot(), env, current, current.tabNames)
13151325 if err != nil {
13161326 d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name)
13171327 return nil, attachmentTransitionResult{}, false, err
@@ -1331,7 +1341,10 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
13311341 return targetSess, transition, true, nil
13321342 }
13331343
1334-func targetMatchesLifecycle(target picker.Target, name string, createdAt int64) bool {
1344+func targetMatchesLifecycle(target picker.Target, name string, createdAt int64, incarnation domain.IncarnationID) bool {
1345+ if target.Incarnation != (domain.IncarnationID{}) && target.Incarnation != incarnation {
1346+ return false
1347+ }
13351348 return target.ExpectedCreatedAt == nil || (target.Name == name && *target.ExpectedCreatedAt == createdAt)
13361349 }
13371350
@@ -1356,7 +1369,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
13561369 transition attachmentTransitionResult
13571370 switched bool
13581371 )
1359- if ok && !stopped.purging && targetMatchesLifecycle(target, stopped.name, stopped.createdAt) {
1372+ if ok && !stopped.purging && targetMatchesLifecycle(target, stopped.name, stopped.createdAt, stopped.incarnation) {
13601373 _, transition, switched, _ = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped, nil, sessionHandoffGuard{}, "")
13611374 }
13621375 d.mu.Unlock()
@@ -238,7 +238,7 @@ func (d *Daemon) notifyRemotePickerUnavailable(sess *session, target picker.Targ
238 }238 }
239 message := "Remote session unavailable"239 message := "Remote session unavailable"
240 if target.RemoteTarget != nil {240 if target.RemoteTarget != nil {
241- message += ": " + target.RemoteTarget.SessionName + "@" + target.RemoteTarget.DisplayOrigin241+ message += ": " + domain.RemoteSessionDisplay(target.RemoteTarget.SessionName, target.RemoteTarget.DisplayOrigin)
242 } else if target.RemoteHost != "" {242 } else if target.RemoteHost != "" {
243 message += ": " + target.RemoteHost243 message += ": " + target.RemoteHost
244 }244 }
@@ -1169,12 +1169,12 @@ func (d *Daemon) resolveNamedLifecycleTargetLocked(target picker.Target) (*sessi
1169 }1169 }
1170 if active := d.findByNameLocked(target.Name); active != nil {1170 if active := d.findByNameLocked(target.Name); active != nil {
1171 active.mu.Lock()1171 active.mu.Lock()
1172- matches := targetMatchesLifecycle(target, active.name, active.createdAt)1172+ matches := targetMatchesLifecycle(target, active.name, active.createdAt, active.incarnation)
1173 active.mu.Unlock()1173 active.mu.Unlock()
1174 return active, stoppedSession{}, false, matches1174 return active, stoppedSession{}, false, matches
1175 }1175 }
1176 stopped, ok := d.stopped[target.Name]1176 stopped, ok := d.stopped[target.Name]
1177- if !ok || stopped.purging || !targetMatchesLifecycle(target, stopped.name, stopped.createdAt) {1177+ if !ok || stopped.purging || !targetMatchesLifecycle(target, stopped.name, stopped.createdAt, stopped.incarnation) {
1178 return nil, stoppedSession{}, false, false1178 return nil, stoppedSession{}, false, false
1179 }1179 }
1180 return nil, stopped, true, true1180 return nil, stopped, true, true
@@ -1191,7 +1191,7 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t
1191 targetSess.mu.Lock()1191 targetSess.mu.Lock()
1192 defer targetSess.mu.Unlock()1192 defer targetSess.mu.Unlock()
1193 _, attached := targetSess.attachments[ac]1193 _, attached := targetSess.attachments[ac]
1194- if !attached || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) || target.TabIndex < 0 || target.TabIndex >= len(targetSess.tabs) {1194+ if !attached || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt, targetSess.incarnation) || target.TabIndex < 0 || target.TabIndex >= len(targetSess.tabs) {
1195 return nil, attachmentTransitionResult{}, false1195 return nil, attachmentTransitionResult{}, false
1196 }1196 }
1197 if !targetSess.activateAttachmentViewLocked(ac, target.TabIndex) {1197 if !targetSess.activateAttachmentViewLocked(ac, target.TabIndex) {
@@ -1203,8 +1203,13 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t
1203 transition, err := d.transitionAttachment(attachmentTransitionRequest{1203 transition, err := d.transitionAttachment(attachmentTransitionRequest{
1204 source: from, target: targetSess, next: ac,1204 source: from, target: targetSess, next: ac,
1205 1205
1206- expectedTransport: sourceToken.transport, sourceToken: sourceToken, action: action,1206+ expectedTransport: sourceToken.transport,
1207- activateTargetTab: true, targetTabIndex: target.TabIndex, preserveAttachment: true,1207+ sourceToken: sourceToken,
1208+ action: action,
1209+ expectedTargetLifecycle: pickerTargetLifecycleFence(target),
1210+ activateTargetTab: true,
1211+ targetTabIndex: target.TabIndex,
1212+ preserveAttachment: true,
1208 })1213 })
1209 d.mu.Lock()1214 d.mu.Lock()
1210 return targetSess, transition, err == nil1215 return targetSess, transition, err == nil
@@ -1212,7 +1217,7 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t
1212 1217
1213 unlock := lockAttachmentSessions(from, targetSess)1218 unlock := lockAttachmentSessions(from, targetSess)
1214 _, sourceAttached := from.attachments[ac]1219 _, sourceAttached := from.attachments[ac]
1215- if !sourceAttached || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) {1220+ if !sourceAttached || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt, targetSess.incarnation) {
1216 unlock()1221 unlock()
1217 return nil, attachmentTransitionResult{}, false1222 return nil, attachmentTransitionResult{}, false
1218 }1223 }
@@ -1244,14 +1249,15 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t
1244 target: targetSess,1249 target: targetSess,
1245 next: ac,1250 next: ac,
1246 1251
1247- expectedTransport: expectedTransport,1252+ expectedTransport: expectedTransport,
1248- sourceToken: sourceToken,1253+ sourceToken: sourceToken,
1249- action: action,1254+ action: action,
1250- expectedSourceTab: guard.expectedSource,1255+ expectedTargetLifecycle: pickerTargetLifecycleFence(target),
1251- activateTargetTab: target.TabIndex >= 0,1256+ expectedSourceTab: guard.expectedSource,
1252- targetTabIndex: target.TabIndex,1257+ activateTargetTab: target.TabIndex >= 0,
1253- copySourceEnvironment: true,1258+ targetTabIndex: target.TabIndex,
1254- ready: true,1259+ copySourceEnvironment: true,
1260+ ready: true,
1255 })1261 })
1256 d.mu.Lock()1262 d.mu.Lock()
1257 if err != nil {1263 if err != nil {
@@ -1278,13 +1284,13 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
1278 expectedSourceTab: guard.expectedSource, copySourceEnvironment: true, ready: true,1284 expectedSourceTab: guard.expectedSource, copySourceEnvironment: true, ready: true,
1279 createTargetLocked: func() (*session, error) {1285 createTargetLocked: func() (*session, error) {
1280 current, ok := d.stopped[target.Name]1286 current, ok := d.stopped[target.Name]
1281- if !ok || current.purging || current.createdAt != stopped.createdAt || !targetMatchesLifecycle(target, current.name, current.createdAt) {1287+ if !ok || current.purging || current.createdAt != stopped.createdAt || !targetMatchesLifecycle(target, current.name, current.createdAt, current.incarnation) {
1282 return nil, errAttachmentTransition1288 return nil, errAttachmentTransition
1283 }1289 }
1284 from.mu.Lock()1290 from.mu.Lock()
1285 cwd, env := d.dirOrHome(current.cwd), copyEnvironment(from.env)1291 cwd, env := d.dirOrHome(current.cwd), copyEnvironment(from.env)
1286 from.mu.Unlock()1292 from.mu.Unlock()
1287- created, createErr := d.createSessionLocked(target.Name, false, cwd, ac.sizeSnapshot(), env, current.tabNames)1293+ created, createErr := d.createStoppedSessionLocked(target.Name, cwd, ac.sizeSnapshot(), env, current, current.tabNames)
1288 targetSess = created1294 targetSess = created
1289 return created, createErr1295 return created, createErr
1290 },1296 },
@@ -1310,8 +1316,12 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
1310 }1316 }
1311 env := copyEnvironment(from.env)1317 env := copyEnvironment(from.env)
1312 from.mu.Unlock()1318 from.mu.Unlock()
1313- cwd := d.dirOrHome(stopped.cwd)1319+ current, ok := d.stopped[target.Name]
1314- targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.sizeSnapshot(), env, stopped.tabNames)1320+ if !ok || current.purging || !stoppedSessionMatchesLifecycle(current, stopped) || !targetMatchesLifecycle(target, current.name, current.createdAt, current.incarnation) {
1321+ return nil, attachmentTransitionResult{}, false, errAttachmentTransition
1322+ }
1323+ cwd := d.dirOrHome(current.cwd)
1324+ targetSess, err := d.createStoppedSessionLocked(target.Name, cwd, ac.sizeSnapshot(), env, current, current.tabNames)
1315 if err != nil {1325 if err != nil {
1316 d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name)1326 d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name)
1317 return nil, attachmentTransitionResult{}, false, err1327 return nil, attachmentTransitionResult{}, false, err
@@ -1331,7 +1341,10 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
1331 return targetSess, transition, true, nil1341 return targetSess, transition, true, nil
1332 }1342 }
1333 1343
1334-func targetMatchesLifecycle(target picker.Target, name string, createdAt int64) bool {1344+func targetMatchesLifecycle(target picker.Target, name string, createdAt int64, incarnation domain.IncarnationID) bool {
1345+ if target.Incarnation != (domain.IncarnationID{}) && target.Incarnation != incarnation {
1346+ return false
1347+ }
1335 return target.ExpectedCreatedAt == nil || (target.Name == name && *target.ExpectedCreatedAt == createdAt)1348 return target.ExpectedCreatedAt == nil || (target.Name == name && *target.ExpectedCreatedAt == createdAt)
1336 }1349 }
1337 1350
@@ -1356,7 +1369,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
1356 transition attachmentTransitionResult1369 transition attachmentTransitionResult
1357 switched bool1370 switched bool
1358 )1371 )
1359- if ok && !stopped.purging && targetMatchesLifecycle(target, stopped.name, stopped.createdAt) {1372+ if ok && !stopped.purging && targetMatchesLifecycle(target, stopped.name, stopped.createdAt, stopped.incarnation) {
1360 _, transition, switched, _ = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped, nil, sessionHandoffGuard{}, "")1373 _, transition, switched, _ = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped, nil, sessionHandoffGuard{}, "")
1361 }1374 }
1362 d.mu.Unlock()1375 d.mu.Unlock()
M internal/usecase/daemon/recent_route_presentation.go +3 -1
@@ -1,5 +1,7 @@
11 package daemon
22
3+import "github.com/bnema/vev/internal/domain"
4+
35 // recentRouteKind identifies the origin represented by a presentation value.
46 // It is intentionally daemon-local in this layer; ports route kinds are
57 // projected into this render-only value at the daemon boundary.
@@ -72,5 +74,5 @@ func formatRecentRouteName(entry recentRoutePresentation, ambiguous bool) string
7274 host = "local"
7375 }
7476 }
75- return name + "@" + host
77+ return domain.RemoteSessionDisplay(name, host)
7678 }
@@ -1,5 +1,7 @@
1 package daemon1 package daemon
2 2
3+import "github.com/bnema/vev/internal/domain"
4+
3 // recentRouteKind identifies the origin represented by a presentation value.5 // recentRouteKind identifies the origin represented by a presentation value.
4 // It is intentionally daemon-local in this layer; ports route kinds are6 // It is intentionally daemon-local in this layer; ports route kinds are
5 // projected into this render-only value at the daemon boundary.7 // projected into this render-only value at the daemon boundary.
@@ -72,5 +74,5 @@ func formatRecentRouteName(entry recentRoutePresentation, ambiguous bool) string
72 host = "local"74 host = "local"
73 }75 }
74 }76 }
75- return name + "@" + host77+ return domain.RemoteSessionDisplay(name, host)
76 }78 }
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.go +20 -0
@@ -186,7 +186,21 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
186186 return d.createSessionLockedWithMode(name, ephemeral, cwd, sz, env, restoredTabNames...)
187187 }
188188
189+// createStoppedSessionLocked resumes exactly expectedStopped. It rechecks the
190+// stopped lifecycle after catalogue I/O, which intentionally runs without d.mu.
191+func (d *Daemon) createStoppedSessionLocked(name string, cwd string, sz domain.Size, env []string, expectedStopped stoppedSession, restoredTabNames ...[]string) (*session, error) {
192+ return d.createSessionLockedWithModeAndStoppedFence(name, false, cwd, sz, env, &expectedStopped, restoredTabNames...)
193+}
194+
195+func stoppedSessionMatchesLifecycle(current, expected stoppedSession) bool {
196+ return current.name == expected.name && current.createdAt == expected.createdAt && current.incarnation == expected.incarnation
197+}
198+
189199 func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd string, sz domain.Size, env []string, restoredTabNames ...[]string) (*session, error) {
200+ return d.createSessionLockedWithModeAndStoppedFence(name, ephemeral, cwd, sz, env, nil, restoredTabNames...)
201+}
202+
203+func (d *Daemon) createSessionLockedWithModeAndStoppedFence(name string, ephemeral bool, cwd string, sz domain.Size, env []string, expectedStopped *stoppedSession, restoredTabNames ...[]string) (*session, error) {
190204 env = copyEnvironment(env)
191205 if _, reserved := d.creating[name]; reserved {
192206 return nil, errSessionNameInUse
@@ -194,6 +208,9 @@ func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd st
194208 d.creating[name] = struct{}{}
195209 defer delete(d.creating, name)
196210 stopped, resuming := d.stopped[name]
211+ if expectedStopped != nil && (!resuming || !stoppedSessionMatchesLifecycle(stopped, *expectedStopped)) {
212+ return nil, errAttachmentTransition
213+ }
197214 var authoritative domain.CatalogueRecord
198215 var authoritativeExists bool
199216 if !ephemeral && resuming && d.persistEnabled {
@@ -208,6 +225,9 @@ func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd st
208225 return nil, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
209226 }
210227 stopped, resuming = d.stopped[name]
228+ if expectedStopped != nil && (!resuming || !stoppedSessionMatchesLifecycle(stopped, *expectedStopped)) {
229+ return nil, errAttachmentTransition
230+ }
211231 }
212232 var createdAt int64
213233 var incarnation domain.IncarnationID
@@ -186,7 +186,21 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
186 return d.createSessionLockedWithMode(name, ephemeral, cwd, sz, env, restoredTabNames...)186 return d.createSessionLockedWithMode(name, ephemeral, cwd, sz, env, restoredTabNames...)
187 }187 }
188 188
189+// createStoppedSessionLocked resumes exactly expectedStopped. It rechecks the
190+// stopped lifecycle after catalogue I/O, which intentionally runs without d.mu.
191+func (d *Daemon) createStoppedSessionLocked(name string, cwd string, sz domain.Size, env []string, expectedStopped stoppedSession, restoredTabNames ...[]string) (*session, error) {
192+ return d.createSessionLockedWithModeAndStoppedFence(name, false, cwd, sz, env, &expectedStopped, restoredTabNames...)
193+}
194+
195+func stoppedSessionMatchesLifecycle(current, expected stoppedSession) bool {
196+ return current.name == expected.name && current.createdAt == expected.createdAt && current.incarnation == expected.incarnation
197+}
198+
189 func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd string, sz domain.Size, env []string, restoredTabNames ...[]string) (*session, error) {199 func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd string, sz domain.Size, env []string, restoredTabNames ...[]string) (*session, error) {
200+ return d.createSessionLockedWithModeAndStoppedFence(name, ephemeral, cwd, sz, env, nil, restoredTabNames...)
201+}
202+
203+func (d *Daemon) createSessionLockedWithModeAndStoppedFence(name string, ephemeral bool, cwd string, sz domain.Size, env []string, expectedStopped *stoppedSession, restoredTabNames ...[]string) (*session, error) {
190 env = copyEnvironment(env)204 env = copyEnvironment(env)
191 if _, reserved := d.creating[name]; reserved {205 if _, reserved := d.creating[name]; reserved {
192 return nil, errSessionNameInUse206 return nil, errSessionNameInUse
@@ -194,6 +208,9 @@ func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd st
194 d.creating[name] = struct{}{}208 d.creating[name] = struct{}{}
195 defer delete(d.creating, name)209 defer delete(d.creating, name)
196 stopped, resuming := d.stopped[name]210 stopped, resuming := d.stopped[name]
211+ if expectedStopped != nil && (!resuming || !stoppedSessionMatchesLifecycle(stopped, *expectedStopped)) {
212+ return nil, errAttachmentTransition
213+ }
197 var authoritative domain.CatalogueRecord214 var authoritative domain.CatalogueRecord
198 var authoritativeExists bool215 var authoritativeExists bool
199 if !ephemeral && resuming && d.persistEnabled {216 if !ephemeral && resuming && d.persistEnabled {
@@ -208,6 +225,9 @@ func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd st
208 return nil, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}225 return nil, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
209 }226 }
210 stopped, resuming = d.stopped[name]227 stopped, resuming = d.stopped[name]
228+ if expectedStopped != nil && (!resuming || !stoppedSessionMatchesLifecycle(stopped, *expectedStopped)) {
229+ return nil, errAttachmentTransition
230+ }
211 }231 }
212 var createdAt int64232 var createdAt int64
213 var incarnation domain.IncarnationID233 var incarnation domain.IncarnationID
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 +22 -13
@@ -109,27 +109,36 @@ func TestStatusCompositionGolden(t *testing.T) {
109109 }
110110 }
111111
112-func TestStatusSegmentsIncludesAttachmentRemoteOrigin(t *testing.T) {
112+func TestStatusSegmentsResolvesActiveLifecyclePresentation(t *testing.T) {
113113 tests := []struct {
114- name string
115- remoteOrigin string
116- wantAttachmentBar string
114+ name string
115+ routeLifecycle domain.SessionLifecycleID
116+ wantPresentation string
117117 }{
118- {name: "remote origin", remoteOrigin: "arch", wantAttachmentBar: "vive@arch"},
119- {name: "local attachment", wantAttachmentBar: "vive"},
118+ {name: "matching lifecycle", routeLifecycle: domain.SessionLifecycleID{1}, wantPresentation: "vive@arch"},
119+ {name: "stale lifecycle", routeLifecycle: domain.SessionLifecycleID{99}, wantPresentation: "vive"},
120120 }
121-
122- for _, test := range tests {
123- t.Run(test.name, func(t *testing.T) {
121+ for _, tt := range tests {
122+ t.Run(tt.name, func(t *testing.T) {
124123 p, releasePTY := newBlockingPTY(t)
125124 _, sess, ac, _ := newManualSessionWithPTYs(t, p)
126125 defer releasePTY()
127126
128127 sess.name = "vive"
129- ac.remoteOrigin = test.remoteOrigin
128+ sess.incarnation = domain.SessionLifecycleID{1}
129+ ref := ports.RouteRef{Key: 1, Generation: 1}
130+ ac.setRouteSnapshot(ports.RecentRouteSnapshot{
131+ Generation: 1,
132+ Active: ref,
133+ ActiveEntry: ports.RecentRouteEntry{
134+ Key: ref.Key, Generation: ref.Generation,
135+ Target: ports.ExactSessionTarget{LifecycleID: tt.routeLifecycle, SessionName: sess.name},
136+ Name: "vive", HostLabel: "user@arch", Kind: ports.RouteKindRemote,
137+ },
138+ })
130139
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")
140+ require.Equal(t, tt.wantPresentation, sess.statusSegmentsFor(ac, true).session)
141+ require.Equal(t, "vive", sess.statusSegments(true).session, "route presentation belongs only to the selected attachment")
133142 })
134143 }
135144 }
@@ -981,7 +990,7 @@ func TestBarStateForContextualRecentUsesClientSnapshot(t *testing.T) {
981990
982991 snapshot := ports.RecentRouteSnapshot{
983992 Generation: 1,
984- Entries: []ports.RecentRouteEntry{{Key: 2, Generation: 1, Name: "captured", Kind: ports.RouteKindLocal, Attention: true}},
993+ Entries: []ports.RecentRouteEntry{{Key: 2, Generation: 1, Target: testRouteTarget("captured", 2), Name: "captured", Kind: ports.RouteKindLocal, Attention: true}},
985994 }
986995 hints := palette.ContextualHints{
987996 Kind: command.ContextHintRecentSessions,
@@ -109,27 +109,36 @@ 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 tests := []struct {
114- name string114+ name string
115- remoteOrigin string115+ routeLifecycle domain.SessionLifecycleID
116- wantAttachmentBar string116+ wantPresentation string
117 }{117 }{
118- {name: "remote origin", remoteOrigin: "arch", wantAttachmentBar: "vive@arch"},118+ {name: "matching lifecycle", routeLifecycle: domain.SessionLifecycleID{1}, wantPresentation: "vive@arch"},
119- {name: "local attachment", wantAttachmentBar: "vive"},119+ {name: "stale lifecycle", routeLifecycle: domain.SessionLifecycleID{99}, wantPresentation: "vive"},
120 }120 }
121-121+ for _, tt := range tests {
122- for _, test := range tests {122+ t.Run(tt.name, func(t *testing.T) {
123- t.Run(test.name, func(t *testing.T) {
124 p, releasePTY := newBlockingPTY(t)123 p, releasePTY := newBlockingPTY(t)
125 _, sess, ac, _ := newManualSessionWithPTYs(t, p)124 _, sess, ac, _ := newManualSessionWithPTYs(t, p)
126 defer releasePTY()125 defer releasePTY()
127 126
128 sess.name = "vive"127 sess.name = "vive"
129- ac.remoteOrigin = test.remoteOrigin128+ sess.incarnation = domain.SessionLifecycleID{1}
129+ ref := ports.RouteRef{Key: 1, Generation: 1}
130+ ac.setRouteSnapshot(ports.RecentRouteSnapshot{
131+ Generation: 1,
132+ Active: ref,
133+ ActiveEntry: ports.RecentRouteEntry{
134+ Key: ref.Key, Generation: ref.Generation,
135+ Target: ports.ExactSessionTarget{LifecycleID: tt.routeLifecycle, SessionName: sess.name},
136+ Name: "vive", HostLabel: "user@arch", Kind: ports.RouteKindRemote,
137+ },
138+ })
130 139
131- require.Equal(t, test.wantAttachmentBar, sess.statusSegmentsFor(ac, true).session)140+ require.Equal(t, tt.wantPresentation, sess.statusSegmentsFor(ac, true).session)
132- require.Equal(t, "vive", sess.statusSegments(true).session, "remote provenance belongs only to the selected attachment")141+ require.Equal(t, "vive", sess.statusSegments(true).session, "route presentation belongs only to the selected attachment")
133 })142 })
134 }143 }
135 }144 }
@@ -981,7 +990,7 @@ func TestBarStateForContextualRecentUsesClientSnapshot(t *testing.T) {
981 990
982 snapshot := ports.RecentRouteSnapshot{991 snapshot := ports.RecentRouteSnapshot{
983 Generation: 1,992 Generation: 1,
984- Entries: []ports.RecentRouteEntry{{Key: 2, Generation: 1, Name: "captured", Kind: ports.RouteKindLocal, Attention: true}},993+ Entries: []ports.RecentRouteEntry{{Key: 2, Generation: 1, Target: testRouteTarget("captured", 2), Name: "captured", Kind: ports.RouteKindLocal, Attention: true}},
985 }994 }
986 hints := palette.ContextualHints{995 hints := palette.ContextualHints{
987 Kind: command.ContextHintRecentSessions,996 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(testExactTarget("JRS", 1), time.Time{}), 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(testExactTarget("JRS", 1), time.Time{}), 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(testExactTarget("work", 1), created),
9696 NewCommandResult(cmd("WORK", "", "Create a workspace")),
97- NewActiveSessionResult("work", created, domain.SessionID("work-id")),
97+ NewActiveSessionResult(testExactTarget("work", 2), created),
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(testExactTarget("aBravo", 1), time.Time{}),
110+ NewStoppedSessionResult(testExactTarget("aAlpha", 2), time.Time{}),
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(testExactTarget("aBravo", 1), time.Time{}),
121+ NewStoppedSessionResult(testExactTarget("aAlpha", 2), time.Time{}),
122+ NewActiveSessionResult(testExactTarget("aZulu", 3), time.Time{}),
123+ NewActiveSessionResult(testExactTarget("aEcho", 4), time.Time{}),
124+ NewActiveSessionResult(testExactTarget("aEcho", 5), time.Time{}),
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(testExactTarget("work", 1), created),
96 NewCommandResult(cmd("WORK", "", "Create a workspace")),96 NewCommandResult(cmd("WORK", "", "Create a workspace")),
97- NewActiveSessionResult("work", created, domain.SessionID("work-id")),97+ NewActiveSessionResult(testExactTarget("work", 2), created),
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(testExactTarget("aBravo", 1), time.Time{}),
110- NewStoppedSessionResult("aAlpha", time.Time{}),110+ NewStoppedSessionResult(testExactTarget("aAlpha", 2), time.Time{}),
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(testExactTarget("aBravo", 1), time.Time{}),
121- NewStoppedSessionResult("aAlpha", time.Time{}),121+ NewStoppedSessionResult(testExactTarget("aAlpha", 2), time.Time{}),
122- NewActiveSessionResult("aZulu", time.Time{}, "z"),122+ NewActiveSessionResult(testExactTarget("aZulu", 3), time.Time{}),
123- NewActiveSessionResult("aEcho", time.Time{}, "e"),123+ NewActiveSessionResult(testExactTarget("aEcho", 4), time.Time{}),
124- NewActiveSessionResult("aEcho", time.Time{}, "e2"),124+ NewActiveSessionResult(testExactTarget("aEcho", 5), time.Time{}),
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(workTarget, created),
467+ NewStoppedSessionResult(testExactTarget("work", 2), created),
467468 }
468469 m := New(results)
469- results[1] = NewActiveSessionResult("changed", created, "changed-id")
470+ results[1] = NewActiveSessionResult(testExactTarget("changed", 3), created)
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(testExactTarget("work", 1), time.Unix(0, 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(testExactTarget("work", 1), time.Unix(0, 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(workTarget, created),
466- NewStoppedSessionResult("work", created),467+ NewStoppedSessionResult(testExactTarget("work", 2), created),
467 }468 }
468 m := New(results)469 m := New(results)
469- results[1] = NewActiveSessionResult("changed", created, "changed-id")470+ results[1] = NewActiveSessionResult(testExactTarget("changed", 3), created)
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(testExactTarget("work", 1), time.Unix(0, 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(testExactTarget("work", 1), time.Unix(0, 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(target ports.ExactSessionTarget, createdAt time.Time) Result {
4847 return Result{
4948 kind: ResultKindActiveSession,
50- session: sessionPayload{name: name, createdAt: createdAt, sessionID: sessionID},
49+ session: sessionPayload{name: target.SessionName, 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(target ports.ExactSessionTarget, createdAt time.Time) Result {
5655 return Result{
5756 kind: ResultKindStoppedSession,
58- session: sessionPayload{name: name, createdAt: createdAt},
57+ session: sessionPayload{name: target.SessionName, 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(target ports.ExactSessionTarget, createdAt time.Time) 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: target.SessionName, 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(target ports.ExactSessionTarget, createdAt time.Time) Result {
56 return Result{55 return Result{
57 kind: ResultKindStoppedSession,56 kind: ResultKindStoppedSession,
58- session: sessionPayload{name: name, createdAt: createdAt},57+ session: sessionPayload{name: target.SessionName, 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(activeTarget, created)
23+ stopped := NewStoppedSessionResult(stoppedTarget, created)
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(testExactTarget("work", 1), created), query: "switch", positions: []int{0, 1, 2, 3, 4, 5}},
82+ {name: "active name", result: NewActiveSessionResult(testExactTarget("work", 1), created), query: "work", positions: []int{1, 8, 20, 21}},
83+ {name: "stopped prefix", result: NewStoppedSessionResult(testExactTarget("archive", 2), created), query: "resume", positions: []int{0, 1, 2, 3, 4, 5}},
84+ {name: "stopped name", result: NewStoppedSessionResult(testExactTarget("archive", 2), created), 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(activeTarget, created)
23+ stopped := NewStoppedSessionResult(stoppedTarget, created)
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(testExactTarget("work", 1), created), 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(testExactTarget("work", 1), created), 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(testExactTarget("archive", 2), 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}},84+ {name: "stopped name", result: NewStoppedSessionResult(testExactTarget("archive", 2), created), 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