fix(navigation): resolve session labels by lifecycle identity
- commit
- f22cb3fe9a60c8be65e9ebf08e31e78de33dd393
- author
- brice · · 2026-08-18 19:54 UTC
- committer
- brice · · 2026-08-19 04:15 UTC
- parent
- 274b25c
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
34 files · +345 -295
M internal/app/run.go +2 -2
| @@ -942,7 +942,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||
| 942 | 942 | Origin: ports.RouteOriginDiscovery, | |
| 943 | 943 | OriginKey: target.Endpoint, | |
| 944 | 944 | RemoteTarget: selection, | |
| 945 | - | RemoteOrigin: displayOrigin, | |
| 945 | + | HostLabel: displayOrigin, | |
| 946 | 946 | EnvironmentPolicy: policy, | |
| 947 | 947 | }, nil | |
| 948 | 948 | } | |
| @@ -977,7 +977,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||
| 977 | 977 | Origin: routeOrigin, | |
| 978 | 978 | OriginKey: routeOriginKey, | |
| 979 | 979 | RemoteTarget: remoteSelection, | |
| 980 | - | RemoteOrigin: remoteDisplayOrigin, | |
| 980 | + | HostLabel: remoteDisplayOrigin, | |
| 981 | 981 | EnvironmentPolicy: remoteEnvironmentPolicy, | |
| 982 | 982 | }) | |
| 983 | 983 | } 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 | }, nil | 947 | }, 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) | |||
| 962 | 962 | runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error { | |
| 963 | 963 | if deps.Remote { | |
| 964 | 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 | 966 | return nil | |
| 967 | 967 | } | |
| 968 | 968 | 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 nil | 966 | return nil | ||
| 967 | } | 967 | } | ||
| 968 | localCalls++ | 968 | localCalls++ | ||
M internal/ports/frame.go +1 -1
| @@ -12,7 +12,7 @@ import ( | |||
| 12 | 12 | const HandshakeTimeout = 15 * time.Second | |
| 13 | 13 | ||
| 14 | 14 | // ProtocolVersion is the current vev IPC wire protocol version. | |
| 15 | - | const ProtocolVersion uint16 = 31 | |
| 15 | + | const ProtocolVersion uint16 = 32 | |
| 16 | 16 | ||
| 17 | 17 | // MaxFrameLen is the largest permitted frame length, including the type byte | |
| 18 | 18 | // and excluding the four-byte length prefix. | |
| @@ -12,7 +12,7 @@ import ( | |||||
| 12 | const HandshakeTimeout = 15 * time.Second | 12 | 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 = 31 | 15 | + | const ProtocolVersion uint16 = 32 |
| 16 | 16 | ||||
| 17 | // MaxFrameLen is the largest permitted frame length, including the type byte | 17 | // 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 | |||
| 3 | 3 | import "testing" | |
| 4 | 4 | ||
| 5 | 5 | 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) | |
| 8 | 8 | } | |
| 9 | 9 | } | |
| 10 | 10 | ||
| @@ -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) { | |||
| 62 | 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 | 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 | 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 | 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 | 67 | {name: "unknown capability", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false}, | |
| 68 | 68 | {name: "back without picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack}, valid: false}, | |
| @@ -62,7 +62,7 @@ func TestHelloNavigationValidationTable(t *testing.T) { | |||||
| 62 | {name: "client-owned home picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityHomePicker}, valid: false}, | 62 | {name: "client-owned home picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityHomePicker}, valid: false}, | ||
| 63 | {name: "daemon-owned back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false}, | 63 | {name: "daemon-owned back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false}, | ||
| 64 | {name: "remote-target back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false}, | 64 | {name: "remote-target back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false}, | ||
| 65 | - | {name: "back with startup picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "001f02" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "0000" + "0201"}, | 65 | + | {name: "back with startup picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "002002" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "0201"}, |
| 66 | {name: "new rejects navigation", hello: Hello{Version: ProtocolVersion, Intent: IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: false}, | 66 | {name: "new rejects navigation", hello: Hello{Version: ProtocolVersion, Intent: IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: false}, | ||
| 67 | {name: "unknown capability", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false}, | 67 | {name: "unknown capability", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false}, | ||
| 68 | {name: "back without picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack}, valid: false}, | 68 | {name: "back without picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack}, valid: false}, | ||
M internal/ports/routes.go +11 -6
| @@ -193,12 +193,13 @@ func (r RouteRef) Validate() error { | |||
| 193 | 193 | return nil | |
| 194 | 194 | } | |
| 195 | 195 | ||
| 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. | |
| 199 | 199 | type RecentRouteEntry struct { | |
| 200 | 200 | Key uint64 | |
| 201 | 201 | Generation uint64 | |
| 202 | + | Target ExactSessionTarget | |
| 202 | 203 | Name string | |
| 203 | 204 | HostLabel string | |
| 204 | 205 | Kind RouteKind | |
| @@ -212,9 +213,13 @@ type RecentRouteEntry struct { | |||
| 212 | 213 | type RecentRouteSnapshot struct { | |
| 213 | 214 | Generation uint64 | |
| 214 | 215 | 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 | |
| 218 | 223 | } | |
| 219 | 224 | ||
| 220 | 225 | // RouteNavigationAction asks the client to resolve one entry from a specific | |
| @@ -193,12 +193,13 @@ func (r RouteRef) Validate() error { | |||||
| 193 | return nil | 193 | 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 attach | 197 | + | // 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 uint64 | 200 | Key uint64 | ||
| 201 | Generation uint64 | 201 | Generation uint64 | ||
| 202 | + | Target ExactSessionTarget | |||
| 202 | Name string | 203 | Name string | ||
| 203 | HostLabel string | 204 | HostLabel string | ||
| 204 | Kind RouteKind | 205 | Kind RouteKind | ||
| @@ -212,9 +213,13 @@ type RecentRouteEntry struct { | |||||
| 212 | type RecentRouteSnapshot struct { | 213 | type RecentRouteSnapshot struct { | ||
| 213 | Generation uint64 | 214 | Generation uint64 | ||
| 214 | Active RouteRef | 215 | Active RouteRef | ||
| 215 | - | Previous RouteRef | 216 | + | // ActiveEntry maps Active's committed lifecycle UUID to its presentation. |
| 216 | - | Home RouteRef | 217 | + | // It is not a navigation candidate and therefore remains separate from |
| 217 | - | Entries []RecentRouteEntry | 218 | + | // 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 specific | 225 | // RouteNavigationAction asks the client to resolve one entry from a specific | ||
M internal/ports/routes_test.go +16 -10
| @@ -111,13 +111,16 @@ func TestCommittedRouteIdentityCodec(t *testing.T) { | |||
| 111 | 111 | } | |
| 112 | 112 | ||
| 113 | 113 | func testRouteSnapshot() RecentRouteSnapshot { | |
| 114 | + | activeTarget := testExactTarget() | |
| 115 | + | logsTarget := ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{4, 5, 6}, SessionName: "logs"} | |
| 114 | 116 | return RecentRouteSnapshot{ | |
| 115 | - | Generation: 9, | |
| 116 | - | Active: RouteRef{Key: 11, Generation: 3}, | |
| 117 | - | Previous: RouteRef{Key: 12, Generation: 4}, | |
| 118 | - | Home: RouteRef{Key: 11, Generation: 3}, | |
| 117 | + | Generation: 9, | |
| 118 | + | Active: RouteRef{Key: 11, Generation: 3}, | |
| 119 | + | ActiveEntry: RecentRouteEntry{Key: 11, Generation: 3, Target: activeTarget, Name: "work", Kind: RouteKindLocal, Reachability: RouteReachabilityUnknown}, | |
| 120 | + | Previous: RouteRef{Key: 12, Generation: 4}, | |
| 121 | + | Home: RouteRef{Key: 11, Generation: 3}, | |
| 119 | 122 | Entries: []RecentRouteEntry{ | |
| 120 | - | {Key: 12, Generation: 4, Name: "logs", HostLabel: "edge", Kind: RouteKindRemote, Attention: true, Reachability: RouteReachabilityUnknown}, | |
| 123 | + | {Key: 12, Generation: 4, Target: logsTarget, Name: "logs", HostLabel: "edge", Kind: RouteKindRemote, Attention: true, Reachability: RouteReachabilityUnknown}, | |
| 121 | 124 | }, | |
| 122 | 125 | } | |
| 123 | 126 | } | |
| @@ -131,7 +134,7 @@ func TestRecentRouteSnapshotCodecRoundTripAndBounds(t *testing.T) { | |||
| 131 | 134 | require.NoError(t, err) | |
| 132 | 135 | require.Equal(t, want, got) | |
| 133 | 136 | ||
| 134 | - | require.Equal(t, "0000000000000009000000000000000b0000000000000003000000000000000c0000000000000004000000000000000b000000000000000301000000000000000c000000000000000400046c6f677300046564676502000100", hex.EncodeToString(encoded)) | |
| 137 | + | require.Equal(t, "0000000000000009000000000000000b0000000000000003000000000000000b0000000000000003010203000000000000000000000000000004776f726b0004776f726b000001000000000000000000000c0000000000000004000000000000000b000000000000000301000000000000000c00000000000000040405060000000000000000000000000000046c6f677300046c6f677300046564676502000100", hex.EncodeToString(encoded)) | |
| 135 | 138 | assertAllPrefixesFail(t, encoded, UnmarshalRecentRouteSnapshot) | |
| 136 | 139 | _, err = UnmarshalRecentRouteSnapshot(append(append([]byte(nil), encoded...), 0)) | |
| 137 | 140 | require.Error(t, err) | |
| @@ -141,14 +144,17 @@ func TestRecentRouteSnapshotCodecRoundTripAndBounds(t *testing.T) { | |||
| 141 | 144 | mutate func(*RecentRouteSnapshot) | |
| 142 | 145 | }{ | |
| 143 | 146 | {name: "missing previous reference", mutate: func(snapshot *RecentRouteSnapshot) { snapshot.Previous = RouteRef{Key: 99, Generation: 99} }}, | |
| 147 | + | {name: "missing active presentation", mutate: func(snapshot *RecentRouteSnapshot) { snapshot.ActiveEntry = RecentRouteEntry{} }}, | |
| 148 | + | {name: "route name differs from lifecycle target", mutate: func(snapshot *RecentRouteSnapshot) { snapshot.Entries[0].Name = "other" }}, | |
| 144 | 149 | {name: "entry is active", mutate: func(snapshot *RecentRouteSnapshot) { | |
| 145 | - | snapshot.Entries = append(snapshot.Entries, RecentRouteEntry{Key: 11, Generation: 3, Name: "work", Kind: RouteKindLocal}) | |
| 150 | + | snapshot.Entries = append(snapshot.Entries, snapshot.ActiveEntry) | |
| 146 | 151 | }}, | |
| 147 | 152 | {name: "duplicate entry", mutate: func(snapshot *RecentRouteSnapshot) { snapshot.Entries = append(snapshot.Entries, snapshot.Entries[0]) }}, | |
| 148 | 153 | {name: "too many entries", mutate: func(snapshot *RecentRouteSnapshot) { | |
| 149 | 154 | snapshot.Entries = make([]RecentRouteEntry, RouteSnapshotMaxEntries+1) | |
| 150 | 155 | for i := range snapshot.Entries { | |
| 151 | - | snapshot.Entries[i] = RecentRouteEntry{Key: uint64(i + 1), Generation: uint64(i + 1), Name: "x", Kind: RouteKindLocal} | |
| 156 | + | target := ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{byte(i + 1)}, SessionName: "x"} | |
| 157 | + | snapshot.Entries[i] = RecentRouteEntry{Key: uint64(i + 1), Generation: uint64(i + 1), Target: target, Name: "x", Kind: RouteKindLocal} | |
| 152 | 158 | } | |
| 153 | 159 | }}, | |
| 154 | 160 | } | |
| @@ -195,14 +201,14 @@ func TestRouteLabelsRejectTerminalUnsafeText(t *testing.T) { | |||
| 195 | 201 | t.Run("name/"+value, func(t *testing.T) { | |
| 196 | 202 | _, err := MarshalRecentRouteSnapshot(RecentRouteSnapshot{ | |
| 197 | 203 | Generation: 1, | |
| 198 | - | Entries: []RecentRouteEntry{{Key: 1, Generation: 1, Name: value, Kind: RouteKindLocal}}, | |
| 204 | + | Entries: []RecentRouteEntry{{Key: 1, Generation: 1, Target: ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: value}, Name: value, Kind: RouteKindLocal}}, | |
| 199 | 205 | }) | |
| 200 | 206 | require.Error(t, err) | |
| 201 | 207 | }) | |
| 202 | 208 | t.Run("host/"+value, func(t *testing.T) { | |
| 203 | 209 | _, err := MarshalRecentRouteSnapshot(RecentRouteSnapshot{ | |
| 204 | 210 | Generation: 1, | |
| 205 | - | Entries: []RecentRouteEntry{{Key: 1, Generation: 1, Name: "work", HostLabel: value, Kind: RouteKindRemote}}, | |
| 211 | + | Entries: []RecentRouteEntry{{Key: 1, Generation: 1, Target: testExactTarget(), Name: "work", HostLabel: value, Kind: RouteKindRemote}}, | |
| 206 | 212 | }) | |
| 207 | 213 | require.Error(t, err) | |
| 208 | 214 | }) | |
| @@ -111,13 +111,16 @@ func TestCommittedRouteIdentityCodec(t *testing.T) { | |||||
| 111 | } | 111 | } | ||
| 112 | 112 | ||||
| 113 | func testRouteSnapshot() RecentRouteSnapshot { | 113 | func testRouteSnapshot() RecentRouteSnapshot { | ||
| 114 | + | activeTarget := testExactTarget() | |||
| 115 | + | logsTarget := ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{4, 5, 6}, SessionName: "logs"} | |||
| 114 | return RecentRouteSnapshot{ | 116 | return RecentRouteSnapshot{ | ||
| 115 | - | Generation: 9, | 117 | + | Generation: 9, |
| 116 | - | Active: RouteRef{Key: 11, Generation: 3}, | 118 | + | Active: RouteRef{Key: 11, Generation: 3}, |
| 117 | - | Previous: RouteRef{Key: 12, Generation: 4}, | 119 | + | ActiveEntry: RecentRouteEntry{Key: 11, Generation: 3, Target: activeTarget, Name: "work", Kind: RouteKindLocal, Reachability: RouteReachabilityUnknown}, |
| 118 | - | Home: RouteRef{Key: 11, Generation: 3}, | 120 | + | Previous: RouteRef{Key: 12, Generation: 4}, |
| 121 | + | Home: RouteRef{Key: 11, Generation: 3}, | |||
| 119 | Entries: []RecentRouteEntry{ | 122 | Entries: []RecentRouteEntry{ | ||
| 120 | - | {Key: 12, Generation: 4, Name: "logs", HostLabel: "edge", Kind: RouteKindRemote, Attention: true, Reachability: RouteReachabilityUnknown}, | 123 | + | {Key: 12, Generation: 4, Target: logsTarget, Name: "logs", HostLabel: "edge", Kind: RouteKindRemote, Attention: true, Reachability: RouteReachabilityUnknown}, |
| 121 | }, | 124 | }, | ||
| 122 | } | 125 | } | ||
| 123 | } | 126 | } | ||
| @@ -131,7 +134,7 @@ func TestRecentRouteSnapshotCodecRoundTripAndBounds(t *testing.T) { | |||||
| 131 | require.NoError(t, err) | 134 | require.NoError(t, err) | ||
| 132 | require.Equal(t, want, got) | 135 | require.Equal(t, want, got) | ||
| 133 | 136 | ||||
| 134 | - | require.Equal(t, "0000000000000009000000000000000b0000000000000003000000000000000c0000000000000004000000000000000b000000000000000301000000000000000c000000000000000400046c6f677300046564676502000100", hex.EncodeToString(encoded)) | 137 | + | require.Equal(t, "0000000000000009000000000000000b0000000000000003000000000000000b0000000000000003010203000000000000000000000000000004776f726b0004776f726b000001000000000000000000000c0000000000000004000000000000000b000000000000000301000000000000000c00000000000000040405060000000000000000000000000000046c6f677300046c6f677300046564676502000100", hex.EncodeToString(encoded)) |
| 135 | assertAllPrefixesFail(t, encoded, UnmarshalRecentRouteSnapshot) | 138 | assertAllPrefixesFail(t, encoded, UnmarshalRecentRouteSnapshot) | ||
| 136 | _, err = UnmarshalRecentRouteSnapshot(append(append([]byte(nil), encoded...), 0)) | 139 | _, err = UnmarshalRecentRouteSnapshot(append(append([]byte(nil), encoded...), 0)) | ||
| 137 | require.Error(t, err) | 140 | require.Error(t, err) | ||
| @@ -141,14 +144,17 @@ func TestRecentRouteSnapshotCodecRoundTripAndBounds(t *testing.T) { | |||||
| 141 | mutate func(*RecentRouteSnapshot) | 144 | mutate func(*RecentRouteSnapshot) | ||
| 142 | }{ | 145 | }{ | ||
| 143 | {name: "missing previous reference", mutate: func(snapshot *RecentRouteSnapshot) { snapshot.Previous = RouteRef{Key: 99, Generation: 99} }}, | 146 | {name: "missing previous reference", mutate: func(snapshot *RecentRouteSnapshot) { snapshot.Previous = RouteRef{Key: 99, Generation: 99} }}, | ||
| 147 | + | {name: "missing active presentation", mutate: func(snapshot *RecentRouteSnapshot) { snapshot.ActiveEntry = RecentRouteEntry{} }}, | |||
| 148 | + | {name: "route name differs from lifecycle target", mutate: func(snapshot *RecentRouteSnapshot) { snapshot.Entries[0].Name = "other" }}, | |||
| 144 | {name: "entry is active", mutate: func(snapshot *RecentRouteSnapshot) { | 149 | {name: "entry is active", mutate: func(snapshot *RecentRouteSnapshot) { | ||
| 145 | - | snapshot.Entries = append(snapshot.Entries, RecentRouteEntry{Key: 11, Generation: 3, Name: "work", Kind: RouteKindLocal}) | 150 | + | snapshot.Entries = append(snapshot.Entries, snapshot.ActiveEntry) |
| 146 | }}, | 151 | }}, | ||
| 147 | {name: "duplicate entry", mutate: func(snapshot *RecentRouteSnapshot) { snapshot.Entries = append(snapshot.Entries, snapshot.Entries[0]) }}, | 152 | {name: "duplicate entry", mutate: func(snapshot *RecentRouteSnapshot) { snapshot.Entries = append(snapshot.Entries, snapshot.Entries[0]) }}, | ||
| 148 | {name: "too many entries", mutate: func(snapshot *RecentRouteSnapshot) { | 153 | {name: "too many entries", mutate: func(snapshot *RecentRouteSnapshot) { | ||
| 149 | snapshot.Entries = make([]RecentRouteEntry, RouteSnapshotMaxEntries+1) | 154 | snapshot.Entries = make([]RecentRouteEntry, RouteSnapshotMaxEntries+1) | ||
| 150 | for i := range snapshot.Entries { | 155 | for i := range snapshot.Entries { | ||
| 151 | - | snapshot.Entries[i] = RecentRouteEntry{Key: uint64(i + 1), Generation: uint64(i + 1), Name: "x", Kind: RouteKindLocal} | 156 | + | target := ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{byte(i + 1)}, SessionName: "x"} |
| 157 | + | snapshot.Entries[i] = RecentRouteEntry{Key: uint64(i + 1), Generation: uint64(i + 1), Target: target, Name: "x", Kind: RouteKindLocal} | |||
| 152 | } | 158 | } | ||
| 153 | }}, | 159 | }}, | ||
| 154 | } | 160 | } | ||
| @@ -195,14 +201,14 @@ func TestRouteLabelsRejectTerminalUnsafeText(t *testing.T) { | |||||
| 195 | t.Run("name/"+value, func(t *testing.T) { | 201 | t.Run("name/"+value, func(t *testing.T) { | ||
| 196 | _, err := MarshalRecentRouteSnapshot(RecentRouteSnapshot{ | 202 | _, err := MarshalRecentRouteSnapshot(RecentRouteSnapshot{ | ||
| 197 | Generation: 1, | 203 | Generation: 1, | ||
| 198 | - | Entries: []RecentRouteEntry{{Key: 1, Generation: 1, Name: value, Kind: RouteKindLocal}}, | 204 | + | Entries: []RecentRouteEntry{{Key: 1, Generation: 1, Target: ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: value}, Name: value, Kind: RouteKindLocal}}, |
| 199 | }) | 205 | }) | ||
| 200 | require.Error(t, err) | 206 | require.Error(t, err) | ||
| 201 | }) | 207 | }) | ||
| 202 | t.Run("host/"+value, func(t *testing.T) { | 208 | t.Run("host/"+value, func(t *testing.T) { | ||
| 203 | _, err := MarshalRecentRouteSnapshot(RecentRouteSnapshot{ | 209 | _, err := MarshalRecentRouteSnapshot(RecentRouteSnapshot{ | ||
| 204 | Generation: 1, | 210 | Generation: 1, | ||
| 205 | - | Entries: []RecentRouteEntry{{Key: 1, Generation: 1, Name: "work", HostLabel: value, Kind: RouteKindRemote}}, | 211 | + | Entries: []RecentRouteEntry{{Key: 1, Generation: 1, Target: testExactTarget(), Name: "work", HostLabel: value, Kind: RouteKindRemote}}, |
| 206 | }) | 212 | }) | ||
| 207 | require.Error(t, err) | 213 | require.Error(t, err) | ||
| 208 | }) | 214 | }) | ||
M internal/ports/routes_wire.go +78 -35
| @@ -243,6 +243,12 @@ func validateRecentRouteEntry(entry RecentRouteEntry) error { | |||
| 243 | 243 | if entry.Key == 0 || entry.Generation == 0 { | |
| 244 | 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 | 252 | if err := validateRouteString(entry.Name, "route name", false); err != nil { | |
| 247 | 253 | return fmt.Errorf("%w: %v", ErrInvalidRouteWire, err) | |
| 248 | 254 | } | |
| @@ -262,7 +268,7 @@ func validateRecentRouteSnapshot(snapshot RecentRouteSnapshot) error { | |||
| 262 | 268 | if len(snapshot.Entries) > RouteSnapshotMaxEntries { | |
| 263 | 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 | 272 | return fmt.Errorf("%w: non-empty snapshot has zero generation", ErrInvalidRouteWire) | |
| 267 | 273 | } | |
| 268 | 274 | if err := validateRouteRef(snapshot.Active); err != nil { | |
| @@ -274,6 +280,18 @@ func validateRecentRouteSnapshot(snapshot RecentRouteSnapshot) error { | |||
| 274 | 280 | if err := validateRouteRef(snapshot.Home); err != nil { | |
| 275 | 281 | 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 | 295 | if !snapshot.Active.empty() && snapshot.Active == snapshot.Previous { | |
| 278 | 296 | return fmt.Errorf("%w: active and previous routes are identical", ErrInvalidRouteWire) | |
| 279 | 297 | } | |
| @@ -310,6 +328,55 @@ func validateRecentRouteSnapshot(snapshot RecentRouteSnapshot) error { | |||
| 310 | 328 | 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 | 380 | // MarshalRecentRouteSnapshot encodes a complete bounded client route view. | |
| 314 | 381 | func MarshalRecentRouteSnapshot(snapshot RecentRouteSnapshot) ([]byte, error) { | |
| 315 | 382 | if err := validateRecentRouteSnapshot(snapshot); err != nil { | |
| @@ -318,18 +385,14 @@ func MarshalRecentRouteSnapshot(snapshot RecentRouteSnapshot) ([]byte, error) { | |||
| 318 | 385 | w := payloadWriter{} | |
| 319 | 386 | w.putUint64(snapshot.Generation) | |
| 320 | 387 | marshalRouteRef(&w, snapshot.Active) | |
| 388 | + | if !snapshot.Active.empty() { | |
| 389 | + | marshalRecentRouteEntry(&w, snapshot.ActiveEntry) | |
| 390 | + | } | |
| 321 | 391 | marshalRouteRef(&w, snapshot.Previous) | |
| 322 | 392 | marshalRouteRef(&w, snapshot.Home) | |
| 323 | 393 | w.putUint8(uint8(len(snapshot.Entries))) | |
| 324 | 394 | 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) | |
| 333 | 396 | } | |
| 334 | 397 | // Keep the frame bound defensive even though current entry/label bounds | |
| 335 | 398 | // make this unreachable; it protects the wire contract if those bounds | |
| @@ -422,6 +485,11 @@ func UnmarshalRecentRouteSnapshot(b []byte) (RecentRouteSnapshot, error) { | |||
| 422 | 485 | if snapshot.Active, err = unmarshalRouteRef(&r); err != nil { | |
| 423 | 486 | 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 | 493 | if snapshot.Previous, err = unmarshalRouteRef(&r); err != nil { | |
| 426 | 494 | return RecentRouteSnapshot{}, err | |
| 427 | 495 | } | |
| @@ -439,35 +507,10 @@ func UnmarshalRecentRouteSnapshot(b []byte) (RecentRouteSnapshot, error) { | |||
| 439 | 507 | snapshot.Entries = make([]RecentRouteEntry, 0, int(count)) | |
| 440 | 508 | } | |
| 441 | 509 | 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) | |
| 467 | 511 | if err != nil { | |
| 468 | 512 | return RecentRouteSnapshot{}, err | |
| 469 | 513 | } | |
| 470 | - | entry.Reachability = RouteReachability(reachability) | |
| 471 | 514 | snapshot.Entries = append(snapshot.Entries, entry) | |
| 472 | 515 | } | |
| 473 | 516 | 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 err | 281 | 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 nil | 328 | 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 bounds | 397 | // Keep the frame bound defensive even though current entry/label bounds | ||
| 335 | // make this unreachable; it protects the wire contract if those bounds | 398 | // 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{}, err | 486 | 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{}, err | 494 | 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 RecentRouteEntry | 510 | + | 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{}, err | 512 | 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 { | |||
| 169 | 169 | // PreferredTabID is a client-owned route cursor. The daemon treats it as an | |
| 170 | 170 | // attachment-local hint and falls back when the stable tab no longer exists. | |
| 171 | 171 | PreferredTabID domain.TabStableID | |
| 172 | - | RemoteOrigin string | |
| 173 | 172 | EnvironmentPolicy EnvironmentPolicy | |
| 174 | 173 | NavigationCapabilities NavigationCapabilities | |
| 175 | 174 | StartupOverlay StartupOverlay | |
| @@ -730,11 +729,6 @@ func ValidateHello(h Hello) error { | |||
| 730 | 729 | 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 | 732 | if h.PreferredTabID != "" { | |
| 739 | 733 | if h.Intent != IntentAttach && h.Intent != IntentResume { | |
| 740 | 734 | return ErrInvalidHello | |
| @@ -878,7 +872,6 @@ func MarshalHello(h Hello) []byte { | |||
| 878 | 872 | marshalRemoteTargetSection(&w, h.RemoteTarget, h.EnvironmentPolicy) | |
| 879 | 873 | marshalExactTargetSection(&w, h.ExactTarget) | |
| 880 | 874 | w.putString(string(h.PreferredTabID)) | |
| 881 | - | w.putString(h.RemoteOrigin) | |
| 882 | 875 | w.putUint8(uint8(h.NavigationCapabilities)) | |
| 883 | 876 | w.putUint8(uint8(h.StartupOverlay)) | |
| 884 | 877 | return w.b | |
| @@ -945,9 +938,6 @@ func preflightHello(b []byte) error { | |||
| 945 | 938 | if err := r.skipString(); err != nil { | |
| 946 | 939 | return err | |
| 947 | 940 | } | |
| 948 | - | if err := r.skipString(); err != nil { | |
| 949 | - | return err | |
| 950 | - | } | |
| 951 | 941 | if _, err := r.getUint8(); err != nil { | |
| 952 | 942 | return err | |
| 953 | 943 | } | |
| @@ -1037,9 +1027,6 @@ func UnmarshalHello(b []byte) (Hello, error) { | |||
| 1037 | 1027 | return Hello{}, err | |
| 1038 | 1028 | } | |
| 1039 | 1029 | h.PreferredTabID = domain.TabStableID(preferredTabID) | |
| 1040 | - | if h.RemoteOrigin, err = r.getString(); err != nil { | |
| 1041 | - | return Hello{}, err | |
| 1042 | - | } | |
| 1043 | 1030 | capabilities, err := r.getUint8() | |
| 1044 | 1031 | if err != nil { | |
| 1045 | 1032 | return Hello{}, err | |
| @@ -169,7 +169,6 @@ type Hello struct { | |||||
| 169 | // PreferredTabID is a client-owned route cursor. The daemon treats it as an | 169 | // 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.TabStableID | 171 | PreferredTabID domain.TabStableID | ||
| 172 | - | RemoteOrigin string | |||
| 173 | EnvironmentPolicy EnvironmentPolicy | 172 | EnvironmentPolicy EnvironmentPolicy | ||
| 174 | NavigationCapabilities NavigationCapabilities | 173 | NavigationCapabilities NavigationCapabilities | ||
| 175 | StartupOverlay StartupOverlay | 174 | StartupOverlay StartupOverlay | ||
| @@ -730,11 +729,6 @@ func ValidateHello(h Hello) error { | |||||
| 730 | return ErrInvalidHello | 729 | 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 ErrInvalidHello | 734 | 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.b | 877 | 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 err | 939 | 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 err | 942 | return err | ||
| 953 | } | 943 | } | ||
| @@ -1037,9 +1027,6 @@ func UnmarshalHello(b []byte) (Hello, error) { | |||||
| 1037 | return Hello{}, err | 1027 | 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{}, err | 1032 | return Hello{}, err | ||
M internal/ports/wire_final_test.go +4 -4
| @@ -11,8 +11,8 @@ import ( | |||
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | 13 | 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) | |
| 16 | 16 | } | |
| 17 | 17 | payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}) | |
| 18 | 18 | if len(payload) < 2 { | |
| @@ -26,8 +26,8 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) { | |||
| 26 | 26 | ||
| 27 | 27 | func TestFinalHelloGoldenStrict(t *testing.T) { | |
| 28 | 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)...) | |
| 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) | |
| 31 | 31 | got := MarshalHello(msg) | |
| 32 | 32 | if !bytes.Equal(got, want) { | |
| 33 | 33 | t.Fatalf("Hello bytes = %x, want %x", got, want) | |
| @@ -11,8 +11,8 @@ import ( | |||||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
| 13 | func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) { | 13 | func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) { | ||
| 14 | - | if ProtocolVersion != 31 { | 14 | + | if ProtocolVersion != 32 { |
| 15 | - | t.Fatalf("ProtocolVersion = %d, want 31", ProtocolVersion) | 15 | + | t.Fatalf("ProtocolVersion = %d, want 32", ProtocolVersion) |
| 16 | } | 16 | } | ||
| 17 | payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}) | 17 | payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}) | ||
| 18 | if len(payload) < 2 { | 18 | if len(payload) < 2 { | ||
| @@ -26,8 +26,8 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) { | |||||
| 26 | 26 | ||||
| 27 | func TestFinalHelloGoldenStrict(t *testing.T) { | 27 | func TestFinalHelloGoldenStrict(t *testing.T) { | ||
| 28 | msg := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}} | 28 | msg := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}} | ||
| 29 | - | want := append([]byte{0, 31, 2}, make([]byte, 16+8)...) | 29 | + | want := append([]byte{0, 32, 2}, make([]byte, 16+8)...) |
| 30 | - | want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) | 30 | + | want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) |
| 31 | got := MarshalHello(msg) | 31 | got := MarshalHello(msg) | ||
| 32 | if !bytes.Equal(got, want) { | 32 | if !bytes.Equal(got, want) { | ||
| 33 | t.Fatalf("Hello bytes = %x, want %x", got, want) | 33 | t.Fatalf("Hello bytes = %x, want %x", got, want) | ||
M internal/ports/wire_test.go +9 -9
| @@ -54,9 +54,9 @@ func assertTrailingGarbageFails[T any](t *testing.T, full []byte, unmarshal func | |||
| 54 | 54 | } | |
| 55 | 55 | ||
| 56 | 56 | 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) | |
| 60 | 60 | } | |
| 61 | 61 | ||
| 62 | 62 | func TestHelloGoldenAndRoundTrip(t *testing.T) { | |
| @@ -131,7 +131,7 @@ func TestHelloEnvironmentCodec(t *testing.T) { | |||
| 131 | 131 | } | |
| 132 | 132 | got := MarshalHello(msg) | |
| 133 | 133 | want := []byte{ | |
| 134 | - | 0x00, 0x1f, 0x00, // version, intent, first client ID byte | |
| 134 | + | 0x00, 0x20, 0x00, // version, intent, first client ID byte | |
| 135 | 135 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 136 | 136 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | |
| 137 | 137 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | |
| @@ -361,7 +361,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T) | |||
| 361 | 361 | } | |
| 362 | 362 | want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...) | |
| 363 | 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 | 367 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | |
| @@ -522,7 +522,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||
| 522 | 522 | name: "minimal", | |
| 523 | 523 | msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"}, | |
| 524 | 524 | want: []byte{ | |
| 525 | - | 0x00, 0x1f, | |
| 525 | + | 0x00, 0x20, | |
| 526 | 526 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | |
| 527 | 527 | 0x00, // attached | |
| 528 | 528 | 0x00, // self | |
| @@ -545,7 +545,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||
| 545 | 545 | JSON: true, | |
| 546 | 546 | }, | |
| 547 | 547 | want: []byte{ | |
| 548 | - | 0x00, 0x1f, | |
| 548 | + | 0x00, 0x20, | |
| 549 | 549 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | |
| 550 | 550 | 0x00, // attached | |
| 551 | 551 | 0x01, // self | |
| @@ -974,7 +974,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) { | |||
| 974 | 974 | name: "attached", | |
| 975 | 975 | msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}}, | |
| 976 | 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 | 978 | 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't', | |
| 979 | 979 | 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l', | |
| 980 | 980 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| @@ -984,7 +984,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) { | |||
| 984 | 984 | name: "control", | |
| 985 | 985 | msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"}, | |
| 986 | 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 | 988 | 0x00, 0x02, 'l', 's', 0x00, 0x00, | |
| 989 | 989 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 990 | 990 | }, | |
| @@ -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 byte | 134 | + | 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 ID | 136 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | ||
| 137 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | 137 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | ||
| @@ -361,7 +361,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T) | |||||
| 361 | } | 361 | } | ||
| 362 | want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...) | 362 | want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...) | ||
| 363 | require.Equal(t, want, MarshalTheme(cleared)) | 363 | require.Equal(t, want, MarshalTheme(cleared)) | ||
| 364 | - | require.Equal(t, uint16(31), ProtocolVersion) | 364 | + | require.Equal(t, uint16(32), ProtocolVersion) |
| 365 | } | 365 | } | ||
| 366 | 366 | ||||
| 367 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | 367 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | ||
| @@ -522,7 +522,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||||
| 522 | name: "minimal", | 522 | name: "minimal", | ||
| 523 | msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"}, | 523 | msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"}, | ||
| 524 | want: []byte{ | 524 | want: []byte{ | ||
| 525 | - | 0x00, 0x1f, | 525 | + | 0x00, 0x20, |
| 526 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | 526 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | ||
| 527 | 0x00, // attached | 527 | 0x00, // attached | ||
| 528 | 0x00, // self | 528 | 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 ID | 549 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | ||
| 550 | 0x00, // attached | 550 | 0x00, // attached | ||
| 551 | 0x01, // self | 551 | 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 { | |||
| 181 | 181 | RemoteTarget *domain.RemoteSessionTarget | |
| 182 | 182 | ExactTarget *ports.ExactSessionTarget | |
| 183 | 183 | PreferredTabID domain.TabStableID | |
| 184 | - | RemoteOrigin string | |
| 184 | + | HostLabel string | |
| 185 | 185 | EnvironmentPolicy ports.EnvironmentPolicy | |
| 186 | 186 | NavigationCapabilities ports.NavigationCapabilities | |
| 187 | 187 | StartupOverlay ports.StartupOverlay | |
| @@ -1054,7 +1054,6 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 1054 | 1054 | RemoteTarget: request.RemoteTarget, | |
| 1055 | 1055 | ExactTarget: exactTarget, | |
| 1056 | 1056 | PreferredTabID: request.PreferredTabID, | |
| 1057 | - | RemoteOrigin: request.RemoteOrigin, | |
| 1058 | 1057 | EnvironmentPolicy: request.EnvironmentPolicy, | |
| 1059 | 1058 | NavigationCapabilities: request.NavigationCapabilities, | |
| 1060 | 1059 | StartupOverlay: request.StartupOverlay, | |
| @@ -181,7 +181,7 @@ type AttachRequest struct { | |||||
| 181 | RemoteTarget *domain.RemoteSessionTarget | 181 | RemoteTarget *domain.RemoteSessionTarget | ||
| 182 | ExactTarget *ports.ExactSessionTarget | 182 | ExactTarget *ports.ExactSessionTarget | ||
| 183 | PreferredTabID domain.TabStableID | 183 | PreferredTabID domain.TabStableID | ||
| 184 | - | RemoteOrigin string | 184 | + | HostLabel string |
| 185 | EnvironmentPolicy ports.EnvironmentPolicy | 185 | EnvironmentPolicy ports.EnvironmentPolicy | ||
| 186 | NavigationCapabilities ports.NavigationCapabilities | 186 | NavigationCapabilities ports.NavigationCapabilities | ||
| 187 | StartupOverlay ports.StartupOverlay | 187 | 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 ( | |||
| 6 | 6 | "fmt" | |
| 7 | 7 | "sync" | |
| 8 | 8 | ||
| 9 | + | "github.com/bnema/vev/internal/domain" | |
| 9 | 10 | "github.com/bnema/vev/internal/ports" | |
| 10 | 11 | ) | |
| 11 | 12 | ||
| @@ -23,7 +24,7 @@ var ( | |||
| 23 | 24 | // routeKey is process-local identity. It is never derived from a daemon | |
| 24 | 25 | // lifecycle ID and is not exposed outside the client ledger. originKey keeps | |
| 25 | 26 | // 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 | 28 | type routeKey uint64 | |
| 28 | 29 | ||
| 29 | 30 | type routeGeneration uint64 | |
| @@ -121,10 +122,13 @@ func routeCandidateForAttach(request AttachRequest, identity ports.CommittedRout | |||
| 121 | 122 | if request.Remote || request.RemoteTarget != nil || origin != ports.RouteOriginLocal { | |
| 122 | 123 | kind = ports.RouteKindRemote | |
| 123 | 124 | if request.RemoteTarget != nil { | |
| 124 | - | hostLabel = request.RemoteTarget.DisplayOrigin | |
| 125 | + | hostLabel = domain.RemoteDisplayOrigin(request.RemoteTarget.DisplayOrigin) | |
| 125 | 126 | } | |
| 126 | 127 | if hostLabel == "" { | |
| 127 | - | hostLabel = originKey | |
| 128 | + | hostLabel = domain.RemoteDisplayOrigin(request.HostLabel) | |
| 129 | + | } | |
| 130 | + | if hostLabel == "" { | |
| 131 | + | hostLabel = domain.RemoteDisplayOrigin(originKey) | |
| 128 | 132 | } | |
| 129 | 133 | } | |
| 130 | 134 | request = cloneAttachRequest(request) | |
| @@ -132,7 +136,7 @@ func routeCandidateForAttach(request AttachRequest, identity ports.CommittedRout | |||
| 132 | 136 | request.OriginKey = originKey | |
| 133 | 137 | request.SessionName = identity.Target.SessionName | |
| 134 | 138 | request.ExactTarget = &identity.Target | |
| 135 | - | request.RemoteOrigin = hostLabel | |
| 139 | + | request.HostLabel = hostLabel | |
| 136 | 140 | // Discovery targets are point-in-time attach authority, including a live | |
| 137 | 141 | // tab selector. Once Welcome commits an exact route, mutable tab memory is | |
| 138 | 142 | // carried independently by PreferredTabID. | |
| @@ -499,6 +503,20 @@ func (l *routeLedger) attentionSubscription() ports.RouteAttentionSubscription { | |||
| 499 | 503 | 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 | 520 | func (l *routeLedger) snapshot() ports.RecentRouteSnapshot { | |
| 503 | 521 | l.mu.RLock() | |
| 504 | 522 | defer l.mu.RUnlock() | |
| @@ -515,18 +533,10 @@ func (l *routeLedger) snapshot() ports.RecentRouteSnapshot { | |||
| 515 | 533 | snapshot.Entries = make([]ports.RecentRouteEntry, 0, len(l.entries)) | |
| 516 | 534 | for _, entry := range l.entries { | |
| 517 | 535 | if entry.identity == l.active { | |
| 536 | + | snapshot.ActiveEntry = entry.snapshotEntry() | |
| 518 | 537 | continue | |
| 519 | 538 | } | |
| 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()) | |
| 530 | 540 | } | |
| 531 | 541 | return snapshot | |
| 532 | 542 | } | |
| @@ -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 daemon | 24 | // routeKey is process-local identity. It is never derived from a daemon | ||
| 24 | // lifecycle ID and is not exposed outside the client ledger. originKey keeps | 25 | // lifecycle ID and is not exposed outside the client ledger. originKey keeps | ||
| 25 | // distinct daemon endpoints separate even when their lifecycle/name pairs | 26 | // 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 uint64 | 28 | type routeKey uint64 | ||
| 28 | 29 | ||||
| 29 | type routeGeneration uint64 | 30 | 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.RouteKindRemote | 123 | kind = ports.RouteKindRemote | ||
| 123 | if request.RemoteTarget != nil { | 124 | if request.RemoteTarget != nil { | ||
| 124 | - | hostLabel = request.RemoteTarget.DisplayOrigin | 125 | + | hostLabel = domain.RemoteDisplayOrigin(request.RemoteTarget.DisplayOrigin) |
| 125 | } | 126 | } | ||
| 126 | if hostLabel == "" { | 127 | if hostLabel == "" { | ||
| 127 | - | hostLabel = originKey | 128 | + | 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 = originKey | 136 | request.OriginKey = originKey | ||
| 133 | request.SessionName = identity.Target.SessionName | 137 | request.SessionName = identity.Target.SessionName | ||
| 134 | request.ExactTarget = &identity.Target | 138 | request.ExactTarget = &identity.Target | ||
| 135 | - | request.RemoteOrigin = hostLabel | 139 | + | request.HostLabel = hostLabel |
| 136 | // Discovery targets are point-in-time attach authority, including a live | 140 | // Discovery targets are point-in-time attach authority, including a live | ||
| 137 | // tab selector. Once Welcome commits an exact route, mutable tab memory is | 141 | // 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 subscription | 503 | 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 | continue | 537 | 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 snapshot | 541 | 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 { | |||
| 33 | 33 | } | |
| 34 | 34 | } | |
| 35 | 35 | ||
| 36 | - | func TestRouteCandidateRetainsRemoteOriginWithoutDiscoveryTarget(t *testing.T) { | |
| 36 | + | func TestRouteCandidateRetainsHostLabelWithoutDiscoveryTarget(t *testing.T) { | |
| 37 | 37 | target := routeTestTarget(1) | |
| 38 | 38 | candidate := routeCandidateForAttach(AttachRequest{ | |
| 39 | 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 | 41 | }, ports.CommittedRouteIdentity{Target: target}, nil, 0) | |
| 42 | 42 | ||
| 43 | 43 | require.Nil(t, candidate.request.RemoteTarget) | |
| 44 | - | require.Equal(t, "remote", candidate.request.RemoteOrigin) | |
| 44 | + | require.Equal(t, "remote", candidate.request.HostLabel) | |
| 45 | 45 | require.Equal(t, "remote", candidate.presentation.hostLabel) | |
| 46 | 46 | } | |
| 47 | 47 | ||
| @@ -60,7 +60,11 @@ func TestRouteLedgerBoundsAndImmutableSnapshot(t *testing.T) { | |||
| 60 | 60 | require.Len(t, ledger.entries, maxRouteLedgerEntries) | |
| 61 | 61 | require.Len(t, snapshot.Entries, maxRouteLedgerEntries-1) | |
| 62 | 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 | 65 | for _, entry := range snapshot.Entries { | |
| 66 | + | require.NoError(t, entry.Target.Validate()) | |
| 67 | + | require.Equal(t, entry.Name, entry.Target.SessionName) | |
| 64 | 68 | require.NotEqual(t, snapshot.Active, ports.RouteRef{Key: entry.Key, Generation: entry.Generation}) | |
| 65 | 69 | } | |
| 66 | 70 | require.Equal(t, uint64(keys[len(keys)-2]), snapshot.Previous.Key) | |
| @@ -33,15 +33,15 @@ func routeTestCandidate(index byte, origin ports.RouteOrigin) routeCandidate { | |||||
| 33 | } | 33 | } | ||
| 34 | } | 34 | } | ||
| 35 | 35 | ||||
| 36 | - | func TestRouteCandidateRetainsRemoteOriginWithoutDiscoveryTarget(t *testing.T) { | 36 | + | func TestRouteCandidateRetainsHostLabelWithoutDiscoveryTarget(t *testing.T) { |
| 37 | target := routeTestTarget(1) | 37 | target := routeTestTarget(1) | ||
| 38 | candidate := routeCandidateForAttach(AttachRequest{ | 38 | candidate := routeCandidateForAttach(AttachRequest{ | ||
| 39 | Intent: ports.IntentAttach, SessionName: target.SessionName, Remote: true, | 39 | Intent: ports.IntentAttach, SessionName: target.SessionName, Remote: true, | ||
| 40 | - | Origin: ports.RouteOriginRemote, OriginKey: "remote", | 40 | + | Origin: ports.RouteOriginRemote, OriginKey: "user@remote", HostLabel: "remote", |
| 41 | }, ports.CommittedRouteIdentity{Target: target}, nil, 0) | 41 | }, ports.CommittedRouteIdentity{Target: target}, nil, 0) | ||
| 42 | 42 | ||||
| 43 | require.Nil(t, candidate.request.RemoteTarget) | 43 | require.Nil(t, candidate.request.RemoteTarget) | ||
| 44 | - | require.Equal(t, "remote", candidate.request.RemoteOrigin) | 44 | + | require.Equal(t, "remote", candidate.request.HostLabel) |
| 45 | require.Equal(t, "remote", candidate.presentation.hostLabel) | 45 | require.Equal(t, "remote", candidate.presentation.hostLabel) | ||
| 46 | } | 46 | } | ||
| 47 | 47 | ||||
| @@ -60,7 +60,11 @@ func TestRouteLedgerBoundsAndImmutableSnapshot(t *testing.T) { | |||||
| 60 | require.Len(t, ledger.entries, maxRouteLedgerEntries) | 60 | require.Len(t, ledger.entries, maxRouteLedgerEntries) | ||
| 61 | require.Len(t, snapshot.Entries, maxRouteLedgerEntries-1) | 61 | require.Len(t, snapshot.Entries, maxRouteLedgerEntries-1) | ||
| 62 | require.Equal(t, uint64(keys[len(keys)-1]), snapshot.Active.Key) | 62 | require.Equal(t, uint64(keys[len(keys)-1]), snapshot.Active.Key) | ||
| 63 | + | require.Equal(t, routeTestTarget(maxRouteLedgerEntries+7), snapshot.ActiveEntry.Target) | |||
| 64 | + | require.Equal(t, snapshot.Active, ports.RouteRef{Key: snapshot.ActiveEntry.Key, Generation: snapshot.ActiveEntry.Generation}) | |||
| 63 | for _, entry := range snapshot.Entries { | 65 | for _, entry := range snapshot.Entries { | ||
| 66 | + | require.NoError(t, entry.Target.Validate()) | |||
| 67 | + | require.Equal(t, entry.Name, entry.Target.SessionName) | |||
| 64 | require.NotEqual(t, snapshot.Active, ports.RouteRef{Key: entry.Key, Generation: entry.Generation}) | 68 | require.NotEqual(t, snapshot.Active, ports.RouteRef{Key: entry.Key, Generation: entry.Generation}) | ||
| 65 | } | 69 | } | ||
| 66 | require.Equal(t, uint64(keys[len(keys)-2]), snapshot.Previous.Key) | 70 | require.Equal(t, uint64(keys[len(keys)-2]), snapshot.Previous.Key) | ||
M internal/usecase/daemon/attachment_effect_gate_test.go +8 -7
| @@ -676,13 +676,14 @@ func TestAttachedRouteSnapshotIsAcceptedAsAnAttachmentValue(t *testing.T) { | |||
| 676 | 676 | token.lease = rc.attachmentLease(ac) | |
| 677 | 677 | ac.publishAttachmentCapability(token) | |
| 678 | 678 | ||
| 679 | + | activeRef := ports.RouteRef{Key: 8, Generation: 4} | |
| 680 | + | activeTarget := testRouteTarget(source.name, 8) | |
| 679 | 681 | 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)}, | |
| 686 | 687 | } | |
| 687 | 688 | payload, err := ports.MarshalRecentRouteSnapshot(snapshot) | |
| 688 | 689 | require.NoError(t, err) | |
| @@ -703,7 +704,7 @@ func TestAttachedNavigationCommandSendsResultAfterLocalTransition(t *testing.T) | |||
| 703 | 704 | Generation: 2, | |
| 704 | 705 | Active: ports.RouteRef{Key: 2, Generation: 2}, | |
| 705 | 706 | Previous: ports.RouteRef{Key: 1, Generation: 1}, | |
| 706 | - | Entries: []ports.RecentRouteEntry{{Key: 1, Generation: 1, Name: "target", Kind: ports.RouteKindLocal}}, | |
| 707 | + | Entries: []ports.RecentRouteEntry{testRouteEntry(1, 1, "target", 1, ports.RouteKindLocal)}, | |
| 707 | 708 | }) | |
| 708 | 709 | ||
| 709 | 710 | transport := &closeTrackingTransport{} | |
| @@ -676,13 +676,14 @@ func TestAttachedRouteSnapshotIsAcceptedAsAnAttachmentValue(t *testing.T) { | |||||
| 676 | token.lease = rc.attachmentLease(ac) | 676 | token.lease = rc.attachmentLease(ac) | ||
| 677 | ac.publishAttachmentCapability(token) | 677 | ac.publishAttachmentCapability(token) | ||
| 678 | 678 | ||||
| 679 | + | activeRef := ports.RouteRef{Key: 8, Generation: 4} | |||
| 680 | + | activeTarget := testRouteTarget(source.name, 8) | |||
| 679 | snapshot := ports.RecentRouteSnapshot{ | 681 | snapshot := ports.RecentRouteSnapshot{ | ||
| 680 | - | Generation: 4, | 682 | + | Generation: 4, |
| 681 | - | Active: ports.RouteRef{Key: 8, Generation: 4}, | 683 | + | Active: activeRef, |
| 682 | - | Home: ports.RouteRef{Key: 8, Generation: 4}, | 684 | + | ActiveEntry: ports.RecentRouteEntry{Key: 8, Generation: 4, Target: activeTarget, Name: source.name, Kind: ports.RouteKindLocal}, |
| 683 | - | Entries: []ports.RecentRouteEntry{{ | 685 | + | Home: activeRef, |
| 684 | - | Key: 7, Generation: 3, Name: "previous", Kind: ports.RouteKindLocal, | 686 | + | Entries: []ports.RecentRouteEntry{testRouteEntry(7, 3, "previous", 7, ports.RouteKindLocal)}, |
| 685 | - | }}, | |||
| 686 | } | 687 | } | ||
| 687 | payload, err := ports.MarshalRecentRouteSnapshot(snapshot) | 688 | payload, err := ports.MarshalRecentRouteSnapshot(snapshot) | ||
| 688 | require.NoError(t, err) | 689 | require.NoError(t, err) | ||
| @@ -703,7 +704,7 @@ func TestAttachedNavigationCommandSendsResultAfterLocalTransition(t *testing.T) | |||||
| 703 | Generation: 2, | 704 | Generation: 2, | ||
| 704 | Active: ports.RouteRef{Key: 2, Generation: 2}, | 705 | Active: ports.RouteRef{Key: 2, Generation: 2}, | ||
| 705 | Previous: ports.RouteRef{Key: 1, Generation: 1}, | 706 | Previous: ports.RouteRef{Key: 1, Generation: 1}, | ||
| 706 | - | Entries: []ports.RecentRouteEntry{{Key: 1, Generation: 1, Name: "target", Kind: ports.RouteKindLocal}}, | 707 | + | Entries: []ports.RecentRouteEntry{testRouteEntry(1, 1, "target", 1, ports.RouteKindLocal)}, |
| 707 | }) | 708 | }) | ||
| 708 | 709 | ||||
| 709 | transport := &closeTrackingTransport{} | 710 | transport := &closeTrackingTransport{} | ||
M internal/usecase/daemon/client.go +6 -11
| @@ -33,15 +33,12 @@ type appliedTheme struct { | |||
| 33 | 33 | } | |
| 34 | 34 | ||
| 35 | 35 | 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 | |
| 45 | 42 | terminalCapabilities ports.TerminalCapabilities | |
| 46 | 43 | navigationCapabilities ports.NavigationCapabilities | |
| 47 | 44 | startupOverlay ports.StartupOverlay | |
| @@ -628,7 +625,6 @@ type attachClientOptions struct { | |||
| 628 | 625 | clientID [16]byte | |
| 629 | 626 | resumeCapable bool | |
| 630 | 627 | maxOutputInFlight uint8 | |
| 631 | - | remoteOrigin string | |
| 632 | 628 | terminalCapabilities ports.TerminalCapabilities | |
| 633 | 629 | capabilitiesSet bool | |
| 634 | 630 | navigationCapabilities ports.NavigationCapabilities | |
| @@ -691,7 +687,6 @@ func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, sz domain.Size, | |||
| 691 | 687 | size: sz, | |
| 692 | 688 | view: attachmentView{windowRows: sz.Rows, windowSet: true}, | |
| 693 | 689 | clientID: opts.clientID, | |
| 694 | - | remoteOrigin: opts.remoteOrigin, | |
| 695 | 690 | terminalCapabilities: opts.terminalCapabilities, | |
| 696 | 691 | navigationCapabilities: opts.navigationCapabilities, | |
| 697 | 692 | 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.Transport | 36 | + | tr ports.Transport |
| 37 | - | transportIncarnation uint64 | 37 | + | transportIncarnation uint64 |
| 38 | - | output *outputStateStream | 38 | + | output *outputStateStream |
| 39 | - | overlays *overlayRuntime | 39 | + | overlays *overlayRuntime |
| 40 | - | overlayOnce sync.Once | 40 | + | overlayOnce sync.Once |
| 41 | - | clientID [16]byte | 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 | |||
| 45 | terminalCapabilities ports.TerminalCapabilities | 42 | terminalCapabilities ports.TerminalCapabilities | ||
| 46 | navigationCapabilities ports.NavigationCapabilities | 43 | navigationCapabilities ports.NavigationCapabilities | ||
| 47 | startupOverlay ports.StartupOverlay | 44 | startupOverlay ports.StartupOverlay | ||
| @@ -628,7 +625,6 @@ type attachClientOptions struct { | |||||
| 628 | clientID [16]byte | 625 | clientID [16]byte | ||
| 629 | resumeCapable bool | 626 | resumeCapable bool | ||
| 630 | maxOutputInFlight uint8 | 627 | maxOutputInFlight uint8 | ||
| 631 | - | remoteOrigin string | |||
| 632 | terminalCapabilities ports.TerminalCapabilities | 628 | terminalCapabilities ports.TerminalCapabilities | ||
| 633 | capabilitiesSet bool | 629 | capabilitiesSet bool | ||
| 634 | navigationCapabilities ports.NavigationCapabilities | 630 | navigationCapabilities ports.NavigationCapabilities | ||
| @@ -691,7 +687,6 @@ func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, sz domain.Size, | |||||
| 691 | size: sz, | 687 | size: sz, | ||
| 692 | view: attachmentView{windowRows: sz.Rows, windowSet: true}, | 688 | view: attachmentView{windowRows: sz.Rows, windowSet: true}, | ||
| 693 | clientID: opts.clientID, | 689 | clientID: opts.clientID, | ||
| 694 | - | remoteOrigin: opts.remoteOrigin, | |||
| 695 | terminalCapabilities: opts.terminalCapabilities, | 690 | terminalCapabilities: opts.terminalCapabilities, | ||
| 696 | navigationCapabilities: opts.navigationCapabilities, | 691 | navigationCapabilities: opts.navigationCapabilities, | ||
| 697 | startupOverlay: opts.startupOverlay, | 692 | startupOverlay: opts.startupOverlay, | ||
M internal/usecase/daemon/daemon.go +0 -4
| @@ -1288,13 +1288,9 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size, | |||
| 1288 | 1288 | maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight), | |
| 1289 | 1289 | navigationCapabilities: h.NavigationCapabilities, | |
| 1290 | 1290 | startupOverlay: h.StartupOverlay, | |
| 1291 | - | remoteOrigin: h.RemoteOrigin, | |
| 1292 | 1291 | terminalCapabilities: terminalCapabilities, | |
| 1293 | 1292 | capabilitiesSet: true, | |
| 1294 | 1293 | } | |
| 1295 | - | if opts.remoteOrigin == "" && h.RemoteTarget != nil { | |
| 1296 | - | opts.remoteOrigin = h.RemoteTarget.DisplayOrigin | |
| 1297 | - | } | |
| 1298 | 1294 | ac := d.prepareAttachedClientLocked(tr, sz, opts) | |
| 1299 | 1295 | d.mu.Unlock() | |
| 1300 | 1296 | 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) { | |||
| 794 | 794 | Generation: 3, | |
| 795 | 795 | Active: ports.RouteRef{Key: 3, Generation: 3}, | |
| 796 | 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 | 800 | require.NoError(t, d.backSessionForAttachment(token)) | |
| @@ -794,7 +794,7 @@ func TestPaletteBackSessionSendsClientPreviousRouteAction(t *testing.T) { | |||||
| 794 | Generation: 3, | 794 | Generation: 3, | ||
| 795 | Active: ports.RouteRef{Key: 3, Generation: 3}, | 795 | Active: ports.RouteRef{Key: 3, Generation: 3}, | ||
| 796 | Previous: ports.RouteRef{Key: 2, Generation: 2}, | 796 | Previous: ports.RouteRef{Key: 2, Generation: 2}, | ||
| 797 | - | Entries: []ports.RecentRouteEntry{{Key: 2, Generation: 2, Name: "recent", Kind: ports.RouteKindLocal}}, | 797 | + | Entries: []ports.RecentRouteEntry{testRouteEntry(2, 2, "recent", 2, ports.RouteKindLocal)}, |
| 798 | }) | 798 | }) | ||
| 799 | 799 | ||||
| 800 | require.NoError(t, d.backSessionForAttachment(token)) | 800 | require.NoError(t, d.backSessionForAttachment(token)) | ||
M internal/usecase/daemon/palette.go +16 -26
| @@ -42,7 +42,7 @@ func (d *Daemon) enterPalette(sess *session, ac *attachedClient) { | |||
| 42 | 42 | // consults its own session history for recent-route commands. | |
| 43 | 43 | routeSnapshot := ac.routeSnapshotCopy() | |
| 44 | 44 | commands := d.paletteCommands() | |
| 45 | - | results := d.paletteResults(sess, commands, routeSnapshot, ac.remoteOrigin) | |
| 45 | + | results := d.paletteResults(sess, commands, routeSnapshot) | |
| 46 | 46 | ac.overlays.paletteMu.Lock() | |
| 47 | 47 | ac.overlays.paletteGeneration++ | |
| 48 | 48 | ac.overlays.palette = palette.New(results) | |
| @@ -57,7 +57,7 @@ func (d *Daemon) enterPalette(sess *session, ac *attachedClient) { | |||
| 57 | 57 | ||
| 58 | 58 | // paletteResults captures eligible named sessions before paletteMu so the | |
| 59 | 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 | 61 | d.mu.Lock() | |
| 62 | 62 | sessions := d.sessionsSnapshotLocked() | |
| 63 | 63 | stopped := make([]stoppedSession, 0, len(d.stopped)) | |
| @@ -79,7 +79,8 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro | |||
| 79 | 79 | } | |
| 80 | 80 | snap := candidate.snapshotView(viewOptions{}) | |
| 81 | 81 | if snap.name != "" && !snap.ephemeral { | |
| 82 | - | active = append(active, palette.NewActiveSessionResult(snap.name, time.Unix(0, snap.createdAt), snap.id)) | |
| 82 | + | target := ports.ExactSessionTarget{LifecycleID: snap.incarnation, SessionName: snap.name} | |
| 83 | + | active = append(active, palette.NewActiveSessionResult(snap.name, time.Unix(0, snap.createdAt), target)) | |
| 83 | 84 | } | |
| 84 | 85 | } | |
| 85 | 86 | sort.Slice(active, func(i, j int) bool { | |
| @@ -89,14 +90,15 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro | |||
| 89 | 90 | }) | |
| 90 | 91 | sort.Slice(stopped, func(i, j int) bool { return stopped[i].name < stopped[j].name }) | |
| 91 | 92 | results = append(results, active...) | |
| 92 | - | localNames := make(map[string]struct{}, len(active)+len(stopped)) | |
| 93 | + | localLifecycles := make(map[domain.SessionLifecycleID]struct{}, len(active)+len(stopped)) | |
| 93 | 94 | for _, candidate := range active { | |
| 94 | - | name, _ := candidate.SessionName() | |
| 95 | - | localNames[name] = struct{}{} | |
| 95 | + | target, _ := candidate.SessionTarget() | |
| 96 | + | localLifecycles[target.LifecycleID] = struct{}{} | |
| 96 | 97 | } | |
| 97 | 98 | for _, candidate := range stopped { | |
| 98 | - | results = append(results, palette.NewStoppedSessionResult(candidate.name, time.Unix(0, candidate.createdAt))) | |
| 99 | - | localNames[candidate.name] = struct{}{} | |
| 99 | + | target := ports.ExactSessionTarget{LifecycleID: candidate.incarnation, SessionName: candidate.name} | |
| 100 | + | results = append(results, palette.NewStoppedSessionResult(candidate.name, time.Unix(0, candidate.createdAt), target)) | |
| 101 | + | localLifecycles[target.LifecycleID] = struct{}{} | |
| 100 | 102 | } | |
| 101 | 103 | formattedRoutes := formatRecentRouteSnapshot(routeSnapshot) | |
| 102 | 104 | for i, entry := range routeSnapshot.Entries { | |
| @@ -107,19 +109,8 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro | |||
| 107 | 109 | if label == "" { | |
| 108 | 110 | continue | |
| 109 | 111 | } | |
| 110 | - | if _, represented := localNames[entry.Name]; represented { | |
| 111 | - | sameOrigin := (entry.Kind == ports.RouteKindLocal && currentRemoteOrigin == "") || | |
| 112 | - | (entry.Kind == ports.RouteKindRemote && currentRemoteOrigin != "" && entry.HostLabel == currentRemoteOrigin) | |
| 113 | - | if sameOrigin { | |
| 114 | - | continue | |
| 115 | - | } | |
| 116 | - | if entry.Kind == ports.RouteKindLocal && currentRemoteOrigin != "" { | |
| 117 | - | label = formatRecentRouteName(recentRoutePresentation{ | |
| 118 | - | name: entry.Name, | |
| 119 | - | kind: recentRouteLocal, | |
| 120 | - | ephemeral: entry.Ephemeral, | |
| 121 | - | }, true) | |
| 122 | - | } | |
| 112 | + | if _, represented := localLifecycles[entry.Target.LifecycleID]; represented { | |
| 113 | + | continue | |
| 123 | 114 | } | |
| 124 | 115 | results = append(results, palette.NewRecentRouteResult(label, ports.RouteNavigationAction{ | |
| 125 | 116 | SnapshotGeneration: routeSnapshot.Generation, | |
| @@ -347,11 +338,10 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 347 | 338 | createdAt, _ := sessionTarget.SessionCreatedAt() | |
| 348 | 339 | expectedCreatedAt := createdAt.UnixNano() | |
| 349 | 340 | name, _ := sessionTarget.SessionName() | |
| 350 | - | target := picker.Target{Name: name, TabIndex: -1, ExpectedCreatedAt: &expectedCreatedAt} | |
| 351 | - | if id, active := sessionTarget.SessionID(); active { | |
| 352 | - | target.Session = id | |
| 353 | - | } else { | |
| 354 | - | target.Stopped = true | |
| 341 | + | exactTarget, _ := sessionTarget.SessionTarget() | |
| 342 | + | target := picker.Target{ | |
| 343 | + | Incarnation: exactTarget.LifecycleID, Name: name, TabIndex: -1, | |
| 344 | + | ExpectedCreatedAt: &expectedCreatedAt, Stopped: sessionTarget.Kind() == palette.ResultKindStoppedSession, | |
| 355 | 345 | } | |
| 356 | 346 | var err error | |
| 357 | 347 | 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 the | 58 | // paletteResults captures eligible named sessions before paletteMu so the | ||
| 59 | // palette has an immutable lifecycle target without violating lock ordering. | 59 | // palette has an immutable lifecycle target without violating lock ordering. | ||
| 60 | - | func (d *Daemon) paletteResults(current *session, commands []command.Command, routeSnapshot ports.RecentRouteSnapshot, currentRemoteOrigin string) []palette.Result { | 60 | + | func (d *Daemon) paletteResults(current *session, commands []command.Command, routeSnapshot ports.RecentRouteSnapshot) []palette.Result { |
| 61 | d.mu.Lock() | 61 | d.mu.Lock() | ||
| 62 | sessions := d.sessionsSnapshotLocked() | 62 | sessions := d.sessionsSnapshotLocked() | ||
| 63 | stopped := make([]stoppedSession, 0, len(d.stopped)) | 63 | stopped := make([]stoppedSession, 0, len(d.stopped)) | ||
| @@ -79,7 +79,8 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro | |||||
| 79 | } | 79 | } | ||
| 80 | snap := candidate.snapshotView(viewOptions{}) | 80 | snap := candidate.snapshotView(viewOptions{}) | ||
| 81 | if snap.name != "" && !snap.ephemeral { | 81 | if snap.name != "" && !snap.ephemeral { | ||
| 82 | - | active = append(active, palette.NewActiveSessionResult(snap.name, time.Unix(0, snap.createdAt), snap.id)) | 82 | + | target := ports.ExactSessionTarget{LifecycleID: snap.incarnation, SessionName: snap.name} |
| 83 | + | active = append(active, palette.NewActiveSessionResult(snap.name, time.Unix(0, snap.createdAt), target)) | |||
| 83 | } | 84 | } | ||
| 84 | } | 85 | } | ||
| 85 | sort.Slice(active, func(i, j int) bool { | 86 | sort.Slice(active, func(i, j int) bool { | ||
| @@ -89,14 +90,15 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro | |||||
| 89 | }) | 90 | }) | ||
| 90 | sort.Slice(stopped, func(i, j int) bool { return stopped[i].name < stopped[j].name }) | 91 | sort.Slice(stopped, func(i, j int) bool { return stopped[i].name < stopped[j].name }) | ||
| 91 | results = append(results, active...) | 92 | results = append(results, active...) | ||
| 92 | - | localNames := make(map[string]struct{}, len(active)+len(stopped)) | 93 | + | localLifecycles := make(map[domain.SessionLifecycleID]struct{}, len(active)+len(stopped)) |
| 93 | for _, candidate := range active { | 94 | for _, candidate := range active { | ||
| 94 | - | name, _ := candidate.SessionName() | 95 | + | target, _ := candidate.SessionTarget() |
| 95 | - | localNames[name] = struct{}{} | 96 | + | localLifecycles[target.LifecycleID] = struct{}{} |
| 96 | } | 97 | } | ||
| 97 | for _, candidate := range stopped { | 98 | for _, candidate := range stopped { | ||
| 98 | - | results = append(results, palette.NewStoppedSessionResult(candidate.name, time.Unix(0, candidate.createdAt))) | 99 | + | target := ports.ExactSessionTarget{LifecycleID: candidate.incarnation, SessionName: candidate.name} |
| 99 | - | localNames[candidate.name] = struct{}{} | 100 | + | results = append(results, palette.NewStoppedSessionResult(candidate.name, time.Unix(0, candidate.createdAt), target)) |
| 101 | + | localLifecycles[target.LifecycleID] = struct{}{} | |||
| 100 | } | 102 | } | ||
| 101 | formattedRoutes := formatRecentRouteSnapshot(routeSnapshot) | 103 | formattedRoutes := formatRecentRouteSnapshot(routeSnapshot) | ||
| 102 | for i, entry := range routeSnapshot.Entries { | 104 | for i, entry := range routeSnapshot.Entries { | ||
| @@ -107,19 +109,8 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro | |||||
| 107 | if label == "" { | 109 | if label == "" { | ||
| 108 | continue | 110 | continue | ||
| 109 | } | 111 | } | ||
| 110 | - | if _, represented := localNames[entry.Name]; represented { | 112 | + | if _, represented := localLifecycles[entry.Target.LifecycleID]; represented { |
| 111 | - | sameOrigin := (entry.Kind == ports.RouteKindLocal && currentRemoteOrigin == "") || | 113 | + | continue |
| 112 | - | (entry.Kind == ports.RouteKindRemote && currentRemoteOrigin != "" && entry.HostLabel == currentRemoteOrigin) | |||
| 113 | - | if sameOrigin { | |||
| 114 | - | continue | |||
| 115 | - | } | |||
| 116 | - | if entry.Kind == ports.RouteKindLocal && currentRemoteOrigin != "" { | |||
| 117 | - | label = formatRecentRouteName(recentRoutePresentation{ | |||
| 118 | - | name: entry.Name, | |||
| 119 | - | kind: recentRouteLocal, | |||
| 120 | - | ephemeral: entry.Ephemeral, | |||
| 121 | - | }, true) | |||
| 122 | - | } | |||
| 123 | } | 114 | } | ||
| 124 | results = append(results, palette.NewRecentRouteResult(label, ports.RouteNavigationAction{ | 115 | results = append(results, palette.NewRecentRouteResult(label, ports.RouteNavigationAction{ | ||
| 125 | SnapshotGeneration: routeSnapshot.Generation, | 116 | SnapshotGeneration: routeSnapshot.Generation, | ||
| @@ -347,11 +338,10 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||||
| 347 | createdAt, _ := sessionTarget.SessionCreatedAt() | 338 | createdAt, _ := sessionTarget.SessionCreatedAt() | ||
| 348 | expectedCreatedAt := createdAt.UnixNano() | 339 | expectedCreatedAt := createdAt.UnixNano() | ||
| 349 | name, _ := sessionTarget.SessionName() | 340 | name, _ := sessionTarget.SessionName() | ||
| 350 | - | target := picker.Target{Name: name, TabIndex: -1, ExpectedCreatedAt: &expectedCreatedAt} | 341 | + | exactTarget, _ := sessionTarget.SessionTarget() |
| 351 | - | if id, active := sessionTarget.SessionID(); active { | 342 | + | target := picker.Target{ |
| 352 | - | target.Session = id | 343 | + | Incarnation: exactTarget.LifecycleID, Name: name, TabIndex: -1, |
| 353 | - | } else { | 344 | + | ExpectedCreatedAt: &expectedCreatedAt, Stopped: sessionTarget.Kind() == palette.ResultKindStoppedSession, |
| 354 | - | target.Stopped = true | |||
| 355 | } | 345 | } | ||
| 356 | var err error | 346 | var err error | ||
| 357 | if effect != nil { | 347 | if effect != nil { | ||
M internal/usecase/daemon/palette_test.go +26 -34
| @@ -2,6 +2,7 @@ package daemon | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "errors" | |
| 5 | + | "strings" | |
| 5 | 6 | "sync" | |
| 6 | 7 | "testing" | |
| 7 | 8 | ||
| @@ -23,8 +24,8 @@ func testRecentRouteSnapshot() ports.RecentRouteSnapshot { | |||
| 23 | 24 | Generation: 1, | |
| 24 | 25 | Active: ports.RouteRef{Key: 1, Generation: 1}, | |
| 25 | 26 | Entries: []ports.RecentRouteEntry{ | |
| 26 | - | {Key: 2, Generation: 1, Name: "recent", Kind: ports.RouteKindLocal}, | |
| 27 | - | {Key: 3, Generation: 1, Name: "older", Kind: ports.RouteKindLocal}, | |
| 27 | + | testRouteEntry(2, 1, "recent", 2, ports.RouteKindLocal), | |
| 28 | + | testRouteEntry(3, 1, "older", 3, ports.RouteKindLocal), | |
| 28 | 29 | }, | |
| 29 | 30 | } | |
| 30 | 31 | } | |
| @@ -419,43 +420,34 @@ func TestPaletteJRSUsesEffectiveOverrideOnly(t *testing.T) { | |||
| 419 | 420 | require.True(t, ac.overlays.paletteActive(), "literal JRS must not execute an overridden jump command") | |
| 420 | 421 | } | |
| 421 | 422 | ||
| 422 | - | func TestPaletteResultsDeduplicateRoutesLocalToCurrentRemoteDaemon(t *testing.T) { | |
| 423 | + | func TestPaletteResultsDeduplicateByLifecycleAndKeepEqualLabels(t *testing.T) { | |
| 423 | 424 | d := newTestDaemon(t, nil, stubClock{}) | |
| 424 | 425 | current := addControlSession(d, "hi", "tab-1", "pane-1") | |
| 425 | - | represented := addControlSession(d, "remote-manual", "tab-2", "pane-2") | |
| 426 | - | represented.ephemeral = false | |
| 426 | + | local := addControlSession(d, "vev", "tab-2", "pane-2") | |
| 427 | + | local.ephemeral = false | |
| 428 | + | remoteOne := ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{21}, SessionName: "vev"} | |
| 429 | + | remoteTwo := ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{22}, SessionName: "vev"} | |
| 427 | 430 | snapshot := ports.RecentRouteSnapshot{ | |
| 428 | 431 | Generation: 2, | |
| 429 | - | Active: ports.RouteRef{Key: 1, Generation: 1}, | |
| 430 | - | Entries: []ports.RecentRouteEntry{{ | |
| 431 | - | Key: 2, Generation: 1, Name: "remote-manual", HostLabel: "remote", Kind: ports.RouteKindRemote, | |
| 432 | - | }}, | |
| 432 | + | Entries: []ports.RecentRouteEntry{ | |
| 433 | + | {Key: 2, Generation: 1, Target: ports.ExactSessionTarget{LifecycleID: local.incarnation, SessionName: "vev"}, Name: "vev", Kind: ports.RouteKindLocal}, | |
| 434 | + | {Key: 3, Generation: 1, Target: remoteOne, Name: "vev", HostLabel: "arch", Kind: ports.RouteKindRemote}, | |
| 435 | + | {Key: 4, Generation: 1, Target: remoteTwo, Name: "vev", HostLabel: "arch", Kind: ports.RouteKindRemote}, | |
| 436 | + | }, | |
| 433 | 437 | } | |
| 434 | 438 | ||
| 435 | - | for _, test := range []struct { | |
| 436 | - | name string | |
| 437 | - | currentOrigin string | |
| 438 | - | want []string | |
| 439 | - | }{ | |
| 440 | - | {name: "remote daemon", currentOrigin: "remote", want: []string{"Switch to session remote-manual"}}, | |
| 441 | - | {name: "remote daemon keeps distinct local route", currentOrigin: "remote", want: []string{"Switch to session remote-manual", "Switch to session remote-manual@local"}}, | |
| 442 | - | {name: "home daemon keeps distinct remote", want: []string{"Switch to session remote-manual", "Switch to session remote-manual@remote"}}, | |
| 443 | - | } { | |
| 444 | - | t.Run(test.name, func(t *testing.T) { | |
| 445 | - | testSnapshot := snapshot | |
| 446 | - | if test.name == "remote daemon keeps distinct local route" { | |
| 447 | - | testSnapshot.Entries = append(testSnapshot.Entries, ports.RecentRouteEntry{Key: 3, Generation: 1, Name: "remote-manual", Kind: ports.RouteKindLocal}) | |
| 448 | - | } | |
| 449 | - | results := d.paletteResults(current, nil, testSnapshot, test.currentOrigin) | |
| 450 | - | var matching []string | |
| 451 | - | for _, result := range results { | |
| 452 | - | if result.DisplayText() == "Switch to session remote-manual" || result.DisplayText() == "Switch to session remote-manual@local" || result.DisplayText() == "Switch to session remote-manual@remote" { | |
| 453 | - | matching = append(matching, result.DisplayText()) | |
| 454 | - | } | |
| 455 | - | } | |
| 456 | - | require.Equal(t, test.want, matching) | |
| 457 | - | }) | |
| 439 | + | results := d.paletteResults(current, nil, snapshot) | |
| 440 | + | var matching []string | |
| 441 | + | for _, result := range results { | |
| 442 | + | if strings.Contains(result.DisplayText(), "session vev") { | |
| 443 | + | matching = append(matching, result.DisplayText()) | |
| 444 | + | } | |
| 458 | 445 | } | |
| 446 | + | require.Equal(t, []string{ | |
| 447 | + | "Switch to session vev", | |
| 448 | + | "Switch to session vev@arch", | |
| 449 | + | "Switch to session vev@arch", | |
| 450 | + | }, matching) | |
| 459 | 451 | } | |
| 460 | 452 | ||
| 461 | 453 | func TestPaletteFuzzyRemoteRecentRouteSendsExactNavigationAction(t *testing.T) { | |
| @@ -465,7 +457,7 @@ func TestPaletteFuzzyRemoteRecentRouteSendsExactNavigationAction(t *testing.T) { | |||
| 465 | 457 | Generation: 9, | |
| 466 | 458 | Active: ports.RouteRef{Key: 1, Generation: 1}, | |
| 467 | 459 | Entries: []ports.RecentRouteEntry{{ | |
| 468 | - | Key: 8, Generation: 4, Name: "logs", HostLabel: "edge", Kind: ports.RouteKindRemote, | |
| 460 | + | Key: 8, Generation: 4, Target: testRouteTarget("logs", 8), Name: "logs", HostLabel: "edge", Kind: ports.RouteKindRemote, | |
| 469 | 461 | }}, | |
| 470 | 462 | }) | |
| 471 | 463 | token := beginRecentRoutePaletteEffect(t, d, current, ac) | |
| @@ -543,7 +535,7 @@ func TestPaletteJRSDoesNotRevalidateTargetInDaemon(t *testing.T) { | |||
| 543 | 535 | ac.setRouteSnapshot(ports.RecentRouteSnapshot{ | |
| 544 | 536 | Generation: 7, | |
| 545 | 537 | Active: ports.RouteRef{Key: 1, Generation: 1}, | |
| 546 | - | Entries: []ports.RecentRouteEntry{{Key: 9, Generation: 4, Name: "captured", Kind: ports.RouteKindLocal}}, | |
| 538 | + | Entries: []ports.RecentRouteEntry{testRouteEntry(9, 4, "captured", 9, ports.RouteKindLocal)}, | |
| 547 | 539 | }) | |
| 548 | 540 | ||
| 549 | 541 | validated := make(chan struct{}) | |
| @@ -2,6 +2,7 @@ package daemon | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "errors" | 4 | "errors" | ||
| 5 | + | "strings" | |||
| 5 | "sync" | 6 | "sync" | ||
| 6 | "testing" | 7 | "testing" | ||
| 7 | 8 | ||||
| @@ -23,8 +24,8 @@ func testRecentRouteSnapshot() ports.RecentRouteSnapshot { | |||||
| 23 | Generation: 1, | 24 | Generation: 1, | ||
| 24 | Active: ports.RouteRef{Key: 1, Generation: 1}, | 25 | Active: ports.RouteRef{Key: 1, Generation: 1}, | ||
| 25 | Entries: []ports.RecentRouteEntry{ | 26 | Entries: []ports.RecentRouteEntry{ | ||
| 26 | - | {Key: 2, Generation: 1, Name: "recent", Kind: ports.RouteKindLocal}, | 27 | + | testRouteEntry(2, 1, "recent", 2, ports.RouteKindLocal), |
| 27 | - | {Key: 3, Generation: 1, Name: "older", Kind: ports.RouteKindLocal}, | 28 | + | testRouteEntry(3, 1, "older", 3, ports.RouteKindLocal), |
| 28 | }, | 29 | }, | ||
| 29 | } | 30 | } | ||
| 30 | } | 31 | } | ||
| @@ -419,43 +420,34 @@ func TestPaletteJRSUsesEffectiveOverrideOnly(t *testing.T) { | |||||
| 419 | require.True(t, ac.overlays.paletteActive(), "literal JRS must not execute an overridden jump command") | 420 | require.True(t, ac.overlays.paletteActive(), "literal JRS must not execute an overridden jump command") | ||
| 420 | } | 421 | } | ||
| 421 | 422 | ||||
| 422 | - | func TestPaletteResultsDeduplicateRoutesLocalToCurrentRemoteDaemon(t *testing.T) { | 423 | + | func TestPaletteResultsDeduplicateByLifecycleAndKeepEqualLabels(t *testing.T) { |
| 423 | d := newTestDaemon(t, nil, stubClock{}) | 424 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 424 | current := addControlSession(d, "hi", "tab-1", "pane-1") | 425 | current := addControlSession(d, "hi", "tab-1", "pane-1") | ||
| 425 | - | represented := addControlSession(d, "remote-manual", "tab-2", "pane-2") | 426 | + | local := addControlSession(d, "vev", "tab-2", "pane-2") |
| 426 | - | represented.ephemeral = false | 427 | + | local.ephemeral = false |
| 428 | + | remoteOne := ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{21}, SessionName: "vev"} | |||
| 429 | + | remoteTwo := ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{22}, SessionName: "vev"} | |||
| 427 | snapshot := ports.RecentRouteSnapshot{ | 430 | snapshot := ports.RecentRouteSnapshot{ | ||
| 428 | Generation: 2, | 431 | Generation: 2, | ||
| 429 | - | Active: ports.RouteRef{Key: 1, Generation: 1}, | 432 | + | Entries: []ports.RecentRouteEntry{ |
| 430 | - | Entries: []ports.RecentRouteEntry{{ | 433 | + | {Key: 2, Generation: 1, Target: ports.ExactSessionTarget{LifecycleID: local.incarnation, SessionName: "vev"}, Name: "vev", Kind: ports.RouteKindLocal}, |
| 431 | - | Key: 2, Generation: 1, Name: "remote-manual", HostLabel: "remote", Kind: ports.RouteKindRemote, | 434 | + | {Key: 3, Generation: 1, Target: remoteOne, Name: "vev", HostLabel: "arch", Kind: ports.RouteKindRemote}, |
| 432 | - | }}, | 435 | + | {Key: 4, Generation: 1, Target: remoteTwo, Name: "vev", HostLabel: "arch", Kind: ports.RouteKindRemote}, |
| 436 | + | }, | |||
| 433 | } | 437 | } | ||
| 434 | 438 | ||||
| 435 | - | for _, test := range []struct { | 439 | + | results := d.paletteResults(current, nil, snapshot) |
| 436 | - | name string | 440 | + | var matching []string |
| 437 | - | currentOrigin string | 441 | + | for _, result := range results { |
| 438 | - | want []string | 442 | + | if strings.Contains(result.DisplayText(), "session vev") { |
| 439 | - | }{ | 443 | + | matching = append(matching, result.DisplayText()) |
| 440 | - | {name: "remote daemon", currentOrigin: "remote", want: []string{"Switch to session remote-manual"}}, | 444 | + | } |
| 441 | - | {name: "remote daemon keeps distinct local route", currentOrigin: "remote", want: []string{"Switch to session remote-manual", "Switch to session remote-manual@local"}}, | |||
| 442 | - | {name: "home daemon keeps distinct remote", want: []string{"Switch to session remote-manual", "Switch to session remote-manual@remote"}}, | |||
| 443 | - | } { | |||
| 444 | - | t.Run(test.name, func(t *testing.T) { | |||
| 445 | - | testSnapshot := snapshot | |||
| 446 | - | if test.name == "remote daemon keeps distinct local route" { | |||
| 447 | - | testSnapshot.Entries = append(testSnapshot.Entries, ports.RecentRouteEntry{Key: 3, Generation: 1, Name: "remote-manual", Kind: ports.RouteKindLocal}) | |||
| 448 | - | } | |||
| 449 | - | results := d.paletteResults(current, nil, testSnapshot, test.currentOrigin) | |||
| 450 | - | var matching []string | |||
| 451 | - | for _, result := range results { | |||
| 452 | - | if result.DisplayText() == "Switch to session remote-manual" || result.DisplayText() == "Switch to session remote-manual@local" || result.DisplayText() == "Switch to session remote-manual@remote" { | |||
| 453 | - | matching = append(matching, result.DisplayText()) | |||
| 454 | - | } | |||
| 455 | - | } | |||
| 456 | - | require.Equal(t, test.want, matching) | |||
| 457 | - | }) | |||
| 458 | } | 445 | } | ||
| 446 | + | require.Equal(t, []string{ | |||
| 447 | + | "Switch to session vev", | |||
| 448 | + | "Switch to session vev@arch", | |||
| 449 | + | "Switch to session vev@arch", | |||
| 450 | + | }, matching) | |||
| 459 | } | 451 | } | ||
| 460 | 452 | ||||
| 461 | func TestPaletteFuzzyRemoteRecentRouteSendsExactNavigationAction(t *testing.T) { | 453 | func TestPaletteFuzzyRemoteRecentRouteSendsExactNavigationAction(t *testing.T) { | ||
| @@ -465,7 +457,7 @@ func TestPaletteFuzzyRemoteRecentRouteSendsExactNavigationAction(t *testing.T) { | |||||
| 465 | Generation: 9, | 457 | Generation: 9, | ||
| 466 | Active: ports.RouteRef{Key: 1, Generation: 1}, | 458 | Active: ports.RouteRef{Key: 1, Generation: 1}, | ||
| 467 | Entries: []ports.RecentRouteEntry{{ | 459 | Entries: []ports.RecentRouteEntry{{ | ||
| 468 | - | Key: 8, Generation: 4, Name: "logs", HostLabel: "edge", Kind: ports.RouteKindRemote, | 460 | + | Key: 8, Generation: 4, Target: testRouteTarget("logs", 8), Name: "logs", HostLabel: "edge", Kind: ports.RouteKindRemote, |
| 469 | }}, | 461 | }}, | ||
| 470 | }) | 462 | }) | ||
| 471 | token := beginRecentRoutePaletteEffect(t, d, current, ac) | 463 | token := beginRecentRoutePaletteEffect(t, d, current, ac) | ||
| @@ -543,7 +535,7 @@ func TestPaletteJRSDoesNotRevalidateTargetInDaemon(t *testing.T) { | |||||
| 543 | ac.setRouteSnapshot(ports.RecentRouteSnapshot{ | 535 | ac.setRouteSnapshot(ports.RecentRouteSnapshot{ | ||
| 544 | Generation: 7, | 536 | Generation: 7, | ||
| 545 | Active: ports.RouteRef{Key: 1, Generation: 1}, | 537 | Active: ports.RouteRef{Key: 1, Generation: 1}, | ||
| 546 | - | Entries: []ports.RecentRouteEntry{{Key: 9, Generation: 4, Name: "captured", Kind: ports.RouteKindLocal}}, | 538 | + | Entries: []ports.RecentRouteEntry{testRouteEntry(9, 4, "captured", 9, ports.RouteKindLocal)}, |
| 547 | }) | 539 | }) | ||
| 548 | 540 | ||||
| 549 | validated := make(chan struct{}) | 541 | validated := make(chan struct{}) | ||
M internal/usecase/daemon/recent_route_presentation_test.go +2 -2
| @@ -79,8 +79,8 @@ func TestRecentSessionHintsAndRankedRenderingShareCanonicalLabels(t *testing.T) | |||
| 79 | 79 | snapshot := ports.RecentRouteSnapshot{ | |
| 80 | 80 | Generation: 1, | |
| 81 | 81 | 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), | |
| 84 | 84 | }, | |
| 85 | 85 | } | |
| 86 | 86 | ||
| @@ -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 @@ | |||
| 1 | 1 | 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 | 8 | func recentRoutePresentationsFromSnapshot(snapshot ports.RecentRouteSnapshot) []recentRoutePresentation { | |
| 6 | 9 | if snapshot.Entries == nil { | |
| @@ -14,7 +17,7 @@ func recentRoutePresentationsFromSnapshot(snapshot ports.RecentRouteSnapshot) [] | |||
| 14 | 17 | } | |
| 15 | 18 | out[i] = recentRoutePresentation{ | |
| 16 | 19 | name: entry.Name, | |
| 17 | - | hostLabel: entry.HostLabel, | |
| 20 | + | hostLabel: domain.RemoteDisplayOrigin(entry.HostLabel), | |
| 18 | 21 | kind: kind, | |
| 19 | 22 | ephemeral: entry.Ephemeral, | |
| 20 | 23 | attention: entry.Attention, | |
| @@ -1,6 +1,9 @@ | |||||
| 1 | package daemon | 1 | 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) { | |||
| 14 | 14 | Generation: 2, | |
| 15 | 15 | Active: ports.RouteRef{Key: 3, Generation: 2}, | |
| 16 | 16 | 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), | |
| 19 | 19 | }, | |
| 20 | 20 | }) | |
| 21 | 21 | ||
| @@ -44,7 +44,7 @@ func TestAttachmentStatusResolvesSubscribedRouteAttention(t *testing.T) { | |||
| 44 | 44 | ac.setRouteSnapshot(ports.RecentRouteSnapshot{ | |
| 45 | 45 | Generation: 2, | |
| 46 | 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 | 49 | ac.setRouteAttentionSubscription(ports.RouteAttentionSubscription{Targets: []ports.RouteAttentionTarget{{ | |
| 50 | 50 | Ref: ref, | |
| @@ -77,7 +77,7 @@ func TestRecentRouteSnapshotRepaintsWithoutDeferredIdentity(t *testing.T) { | |||
| 77 | 77 | ||
| 78 | 78 | func TestAttachmentRouteSnapshotCopiesPublishedEntries(t *testing.T) { | |
| 79 | 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 | 81 | snapshot := ports.RecentRouteSnapshot{Generation: 3, Entries: entries} | |
| 82 | 82 | ac.setRouteSnapshot(snapshot) | |
| 83 | 83 | ||
| @@ -105,7 +105,7 @@ func TestPaletteRecentRouteSelectionSendsTypedClientAction(t *testing.T) { | |||
| 105 | 105 | d: d, sess: source, attachment: source, ac: ac, | |
| 106 | 106 | routeSnapshot: ports.RecentRouteSnapshot{ | |
| 107 | 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 | 110 | effect: effect, | |
| 111 | 111 | } | |
| @@ -131,7 +131,7 @@ func TestRecentRouteHintsRetainSnapshotSelectionIdentity(t *testing.T) { | |||
| 131 | 131 | snapshot := ports.RecentRouteSnapshot{ | |
| 132 | 132 | Generation: 8, | |
| 133 | 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 | ||
| @@ -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 ( | |||
| 10 | 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 | 13 | func TestRouteAppliesPreferredTabPerAttachment(t *testing.T) { | |
| 32 | 14 | for _, test := range []struct { | |
| 33 | 15 | 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 string | 15 | name string | ||
M internal/usecase/daemon/session_back_route_test.go +1 -3
| @@ -23,9 +23,7 @@ func TestBackSessionUsesClientPreviousRouteAfterSnapshotPublication(t *testing.T | |||
| 23 | 23 | Generation: 5, | |
| 24 | 24 | Active: ports.RouteRef{Key: 9, Generation: 5}, | |
| 25 | 25 | 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)}, | |
| 29 | 27 | }) | |
| 30 | 28 | ||
| 31 | 29 | 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) | |||
| 46 | 46 | require.NoError(t, d.renameSession(sess, "vps-infra")) | |
| 47 | 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 | 50 | 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, | |
| 53 | 55 | } | |
| 54 | 56 | payload, err := ports.MarshalRecentRouteSnapshot(snapshot) | |
| 55 | 57 | 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 { | |||
| 189 | 189 | } | |
| 190 | 190 | ||
| 191 | 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 | 197 | s.mu.Lock() | |
| 193 | 198 | 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 | + | } | |
| 200 | 207 | } | |
| 208 | + | name := formatRecentRouteName(presentation, false) | |
| 201 | 209 | snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))} | |
| 202 | 210 | activeIndex := 0 | |
| 203 | 211 | 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.name | 199 | + | 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.remoteOrigin | 204 | + | 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 := 0 | 210 | activeIndex := 0 | ||
| 203 | if ac != nil { | 211 | if ac != nil { | ||
M internal/usecase/daemon/status_test.go +23 -21
| @@ -109,29 +109,31 @@ func TestStatusCompositionGolden(t *testing.T) { | |||
| 109 | 109 | } | |
| 110 | 110 | } | |
| 111 | 111 | ||
| 112 | - | func TestStatusSegmentsIncludesAttachmentRemoteOrigin(t *testing.T) { | |
| 113 | - | tests := []struct { | |
| 114 | - | name string | |
| 115 | - | remoteOrigin string | |
| 116 | - | wantAttachmentBar string | |
| 117 | - | }{ | |
| 118 | - | {name: "remote origin", remoteOrigin: "arch", wantAttachmentBar: "vive@arch"}, | |
| 119 | - | {name: "local attachment", wantAttachmentBar: "vive"}, | |
| 120 | - | } | |
| 112 | + | func TestStatusSegmentsResolvesActiveLifecyclePresentation(t *testing.T) { | |
| 113 | + | p, releasePTY := newBlockingPTY(t) | |
| 114 | + | _, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 115 | + | defer releasePTY() | |
| 121 | 116 | ||
| 122 | - | for _, test := range tests { | |
| 123 | - | t.Run(test.name, func(t *testing.T) { | |
| 124 | - | p, releasePTY := newBlockingPTY(t) | |
| 125 | - | _, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 126 | - | defer releasePTY() | |
| 117 | + | sess.name = "vive" | |
| 118 | + | sess.incarnation = domain.SessionLifecycleID{1} | |
| 119 | + | ref := ports.RouteRef{Key: 1, Generation: 1} | |
| 120 | + | target := ports.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name} | |
| 121 | + | snapshot := ports.RecentRouteSnapshot{ | |
| 122 | + | Generation: 1, | |
| 123 | + | Active: ref, | |
| 124 | + | ActiveEntry: ports.RecentRouteEntry{ | |
| 125 | + | Key: ref.Key, Generation: ref.Generation, Target: target, | |
| 126 | + | Name: "vive", HostLabel: "user@arch", Kind: ports.RouteKindRemote, | |
| 127 | + | }, | |
| 128 | + | } | |
| 129 | + | ac.setRouteSnapshot(snapshot) | |
| 127 | 130 | ||
| 128 | - | sess.name = "vive" | |
| 129 | - | ac.remoteOrigin = test.remoteOrigin | |
| 131 | + | require.Equal(t, "vive@arch", sess.statusSegmentsFor(ac, true).session) | |
| 132 | + | require.Equal(t, "vive", sess.statusSegments(true).session, "route presentation belongs only to the selected attachment") | |
| 130 | 133 | ||
| 131 | - | require.Equal(t, test.wantAttachmentBar, sess.statusSegmentsFor(ac, true).session) | |
| 132 | - | require.Equal(t, "vive", sess.statusSegments(true).session, "remote provenance belongs only to the selected attachment") | |
| 133 | - | }) | |
| 134 | - | } | |
| 134 | + | snapshot.ActiveEntry.Target.LifecycleID = domain.SessionLifecycleID{99} | |
| 135 | + | ac.setRouteSnapshot(snapshot) | |
| 136 | + | require.Equal(t, "vive", sess.statusSegmentsFor(ac, true).session, "a stale UUID must not label the active session") | |
| 135 | 137 | } | |
| 136 | 138 | ||
| 137 | 139 | func TestStatusSegmentsIncludesFocusedPaneTitle(t *testing.T) { | |
| @@ -981,7 +983,7 @@ func TestBarStateForContextualRecentUsesClientSnapshot(t *testing.T) { | |||
| 981 | 983 | ||
| 982 | 984 | snapshot := ports.RecentRouteSnapshot{ | |
| 983 | 985 | Generation: 1, | |
| 984 | - | Entries: []ports.RecentRouteEntry{{Key: 2, Generation: 1, Name: "captured", Kind: ports.RouteKindLocal, Attention: true}}, | |
| 986 | + | Entries: []ports.RecentRouteEntry{{Key: 2, Generation: 1, Target: testRouteTarget("captured", 2), Name: "captured", Kind: ports.RouteKindLocal, Attention: true}}, | |
| 985 | 987 | } | |
| 986 | 988 | hints := palette.ContextualHints{ | |
| 987 | 989 | Kind: command.ContextHintRecentSessions, | |
| @@ -109,29 +109,31 @@ func TestStatusCompositionGolden(t *testing.T) { | |||||
| 109 | } | 109 | } | ||
| 110 | } | 110 | } | ||
| 111 | 111 | ||||
| 112 | - | func TestStatusSegmentsIncludesAttachmentRemoteOrigin(t *testing.T) { | 112 | + | func TestStatusSegmentsResolvesActiveLifecyclePresentation(t *testing.T) { |
| 113 | - | tests := []struct { | 113 | + | p, releasePTY := newBlockingPTY(t) |
| 114 | - | name string | 114 | + | _, sess, ac, _ := newManualSessionWithPTYs(t, p) |
| 115 | - | remoteOrigin string | 115 | + | defer releasePTY() |
| 116 | - | wantAttachmentBar string | |||
| 117 | - | }{ | |||
| 118 | - | {name: "remote origin", remoteOrigin: "arch", wantAttachmentBar: "vive@arch"}, | |||
| 119 | - | {name: "local attachment", wantAttachmentBar: "vive"}, | |||
| 120 | - | } | |||
| 121 | 116 | ||||
| 122 | - | for _, test := range tests { | 117 | + | sess.name = "vive" |
| 123 | - | t.Run(test.name, func(t *testing.T) { | 118 | + | sess.incarnation = domain.SessionLifecycleID{1} |
| 124 | - | p, releasePTY := newBlockingPTY(t) | 119 | + | ref := ports.RouteRef{Key: 1, Generation: 1} |
| 125 | - | _, sess, ac, _ := newManualSessionWithPTYs(t, p) | 120 | + | target := ports.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name} |
| 126 | - | defer releasePTY() | 121 | + | snapshot := ports.RecentRouteSnapshot{ |
| 122 | + | Generation: 1, | |||
| 123 | + | Active: ref, | |||
| 124 | + | ActiveEntry: ports.RecentRouteEntry{ | |||
| 125 | + | Key: ref.Key, Generation: ref.Generation, Target: target, | |||
| 126 | + | Name: "vive", HostLabel: "user@arch", Kind: ports.RouteKindRemote, | |||
| 127 | + | }, | |||
| 128 | + | } | |||
| 129 | + | ac.setRouteSnapshot(snapshot) | |||
| 127 | 130 | ||||
| 128 | - | sess.name = "vive" | 131 | + | require.Equal(t, "vive@arch", sess.statusSegmentsFor(ac, true).session) |
| 129 | - | ac.remoteOrigin = test.remoteOrigin | 132 | + | require.Equal(t, "vive", sess.statusSegments(true).session, "route presentation belongs only to the selected attachment") |
| 130 | 133 | ||||
| 131 | - | require.Equal(t, test.wantAttachmentBar, sess.statusSegmentsFor(ac, true).session) | 134 | + | snapshot.ActiveEntry.Target.LifecycleID = domain.SessionLifecycleID{99} |
| 132 | - | require.Equal(t, "vive", sess.statusSegments(true).session, "remote provenance belongs only to the selected attachment") | 135 | + | ac.setRouteSnapshot(snapshot) |
| 133 | - | }) | 136 | + | require.Equal(t, "vive", sess.statusSegmentsFor(ac, true).session, "a stale UUID must not label the active session") |
| 134 | - | } | |||
| 135 | } | 137 | } | ||
| 136 | 138 | ||||
| 137 | func TestStatusSegmentsIncludesFocusedPaneTitle(t *testing.T) { | 139 | func TestStatusSegmentsIncludesFocusedPaneTitle(t *testing.T) { | ||
| @@ -981,7 +983,7 @@ func TestBarStateForContextualRecentUsesClientSnapshot(t *testing.T) { | |||||
| 981 | 983 | ||||
| 982 | snapshot := ports.RecentRouteSnapshot{ | 984 | snapshot := ports.RecentRouteSnapshot{ | ||
| 983 | Generation: 1, | 985 | Generation: 1, | ||
| 984 | - | Entries: []ports.RecentRouteEntry{{Key: 2, Generation: 1, Name: "captured", Kind: ports.RouteKindLocal, Attention: true}}, | 986 | + | Entries: []ports.RecentRouteEntry{{Key: 2, Generation: 1, Target: testRouteTarget("captured", 2), Name: "captured", Kind: ports.RouteKindLocal, Attention: true}}, |
| 985 | } | 987 | } | ||
| 986 | hints := palette.ContextualHints{ | 988 | hints := palette.ContextualHints{ | ||
| 987 | Kind: command.ContextHintRecentSessions, | 989 | Kind: command.ContextHintRecentSessions, | ||
M internal/usecase/palette/action_test.go +1 -1
| @@ -54,7 +54,7 @@ func TestArgumentCommandKeepsExactRowVisible(t *testing.T) { | |||
| 54 | 54 | ||
| 55 | 55 | func TestArgumentResultReturnsTheMatchingRowAndCommand(t *testing.T) { | |
| 56 | 56 | jrs := command.Command{Code: "JRS", Arguments: command.ArgumentsRequired} | |
| 57 | - | results := []Result{NewStoppedSessionResult("JRS", time.Time{}), NewCommandResult(jrs)} | |
| 57 | + | results := []Result{NewStoppedSessionResult("JRS", time.Time{}, testExactTarget("JRS", 1)), NewCommandResult(jrs)} | |
| 58 | 58 | ||
| 59 | 59 | result, got, ok := argumentResult(results, "jrs 1") | |
| 60 | 60 | ||
| @@ -54,7 +54,7 @@ func TestArgumentCommandKeepsExactRowVisible(t *testing.T) { | |||||
| 54 | 54 | ||||
| 55 | func TestArgumentResultReturnsTheMatchingRowAndCommand(t *testing.T) { | 55 | func TestArgumentResultReturnsTheMatchingRowAndCommand(t *testing.T) { | ||
| 56 | jrs := command.Command{Code: "JRS", Arguments: command.ArgumentsRequired} | 56 | jrs := command.Command{Code: "JRS", Arguments: command.ArgumentsRequired} | ||
| 57 | - | results := []Result{NewStoppedSessionResult("JRS", time.Time{}), NewCommandResult(jrs)} | 57 | + | results := []Result{NewStoppedSessionResult("JRS", time.Time{}, testExactTarget("JRS", 1)), NewCommandResult(jrs)} |
| 58 | 58 | ||||
| 59 | result, got, ok := argumentResult(results, "jrs 1") | 59 | result, got, ok := argumentResult(results, "jrs 1") | ||
| 60 | 60 | ||||
M internal/usecase/palette/fuzzy_test.go +23 -21
| @@ -4,7 +4,7 @@ import ( | |||
| 4 | 4 | "testing" | |
| 5 | 5 | "time" | |
| 6 | 6 | ||
| 7 | - | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/bnema/vev/internal/ports" | |
| 8 | 8 | "github.com/bnema/vev/internal/usecase/command" | |
| 9 | 9 | "github.com/stretchr/testify/require" | |
| 10 | 10 | ) | |
| @@ -81,20 +81,20 @@ func TestFuzzyEmptyQueryPreservesRegistryOrder(t *testing.T) { | |||
| 81 | 81 | func TestFuzzyOrdersMixedResults(t *testing.T) { | |
| 82 | 82 | created := time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC) | |
| 83 | 83 | 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 | |
| 91 | 91 | }{ | |
| 92 | 92 | { | |
| 93 | 93 | name: "command shortcode precedes sessions and description matches", | |
| 94 | 94 | results: []Result{ | |
| 95 | - | NewStoppedSessionResult("work", created), | |
| 95 | + | NewStoppedSessionResult("work", created, testExactTarget("work", 1)), | |
| 96 | 96 | NewCommandResult(cmd("WORK", "", "Create a workspace")), | |
| 97 | - | NewActiveSessionResult("work", created, domain.SessionID("work-id")), | |
| 97 | + | NewActiveSessionResult("work", created, testExactTarget("work", 2)), | |
| 98 | 98 | NewCommandResult(cmd("WQORRK", "", "")), | |
| 99 | 99 | NewCommandResult(cmd("ZZZ", "", "work tools")), | |
| 100 | 100 | }, | |
| @@ -106,8 +106,8 @@ func TestFuzzyOrdersMixedResults(t *testing.T) { | |||
| 106 | 106 | { | |
| 107 | 107 | name: "equivalent sessions sort by normalized text", | |
| 108 | 108 | results: []Result{ | |
| 109 | - | NewStoppedSessionResult("aBravo", time.Time{}), | |
| 110 | - | NewStoppedSessionResult("aAlpha", time.Time{}), | |
| 109 | + | NewStoppedSessionResult("aBravo", time.Time{}, testExactTarget("aBravo", 1)), | |
| 110 | + | NewStoppedSessionResult("aAlpha", time.Time{}, testExactTarget("aAlpha", 2)), | |
| 111 | 111 | }, | |
| 112 | 112 | query: "a", | |
| 113 | 113 | wantText: []string{"Resume session aAlpha", "Resume session aBravo"}, | |
| @@ -117,18 +117,20 @@ func TestFuzzyOrdersMixedResults(t *testing.T) { | |||
| 117 | 117 | { | |
| 118 | 118 | name: "display prefix positions sort before later active names", | |
| 119 | 119 | results: []Result{ | |
| 120 | - | NewStoppedSessionResult("aBravo", time.Time{}), | |
| 121 | - | NewStoppedSessionResult("aAlpha", time.Time{}), | |
| 122 | - | NewActiveSessionResult("aZulu", time.Time{}, "z"), | |
| 123 | - | NewActiveSessionResult("aEcho", time.Time{}, "e"), | |
| 124 | - | NewActiveSessionResult("aEcho", time.Time{}, "e2"), | |
| 120 | + | NewStoppedSessionResult("aBravo", time.Time{}, testExactTarget("aBravo", 1)), | |
| 121 | + | NewStoppedSessionResult("aAlpha", time.Time{}, testExactTarget("aAlpha", 2)), | |
| 122 | + | NewActiveSessionResult("aZulu", time.Time{}, testExactTarget("aZulu", 3)), | |
| 123 | + | NewActiveSessionResult("aEcho", time.Time{}, testExactTarget("aEcho", 4)), | |
| 124 | + | NewActiveSessionResult("aEcho", time.Time{}, testExactTarget("aEcho", 5)), | |
| 125 | 125 | NewCommandResult(cmd("AX", "", "")), | |
| 126 | 126 | }, | |
| 127 | 127 | query: "a", | |
| 128 | 128 | wantText: []string{"AX", "Resume session aAlpha", "Resume session aBravo", "Switch to session aEcho", "Switch to session aEcho", "Switch to session aZulu"}, | |
| 129 | 129 | wantKinds: []ResultKind{ResultKindCommand, ResultKindStoppedSession, ResultKindStoppedSession, ResultKindActiveSession, ResultKindActiveSession, ResultKindActiveSession}, | |
| 130 | 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 | 144 | positions[i] = matches[i].Positions | |
| 143 | 145 | } | |
| 144 | 146 | 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() | |
| 147 | 149 | require.True(t, ok) | |
| 148 | - | require.Equal(t, wantID, gotID) | |
| 150 | + | require.Equal(t, wantTarget, gotTarget) | |
| 149 | 151 | } | |
| 150 | 152 | }) | |
| 151 | 153 | } | |
| @@ -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 string | 84 | + | name string |
| 85 | - | results []Result | 85 | + | results []Result |
| 86 | - | query string | 86 | + | query string |
| 87 | - | wantText []string | 87 | + | wantText []string |
| 88 | - | wantKinds []ResultKind | 88 | + | wantKinds []ResultKind |
| 89 | - | wantPositions [][]int | 89 | + | wantPositions [][]int |
| 90 | - | wantActiveIDs map[int]domain.SessionID | 90 | + | wantActiveTargets map[int]ports.ExactSessionTarget |
| 91 | }{ | 91 | }{ | ||
| 92 | { | 92 | { | ||
| 93 | name: "command shortcode precedes sessions and description matches", | 93 | name: "command shortcode precedes sessions and description matches", | ||
| 94 | results: []Result{ | 94 | results: []Result{ | ||
| 95 | - | NewStoppedSessionResult("work", created), | 95 | + | NewStoppedSessionResult("work", created, testExactTarget("work", 1)), |
| 96 | NewCommandResult(cmd("WORK", "", "Create a workspace")), | 96 | NewCommandResult(cmd("WORK", "", "Create a workspace")), | ||
| 97 | - | NewActiveSessionResult("work", created, domain.SessionID("work-id")), | 97 | + | NewActiveSessionResult("work", created, testExactTarget("work", 2)), |
| 98 | NewCommandResult(cmd("WQORRK", "", "")), | 98 | NewCommandResult(cmd("WQORRK", "", "")), | ||
| 99 | NewCommandResult(cmd("ZZZ", "", "work tools")), | 99 | NewCommandResult(cmd("ZZZ", "", "work tools")), | ||
| 100 | }, | 100 | }, | ||
| @@ -106,8 +106,8 @@ func TestFuzzyOrdersMixedResults(t *testing.T) { | |||||
| 106 | { | 106 | { | ||
| 107 | name: "equivalent sessions sort by normalized text", | 107 | name: "equivalent sessions sort by normalized text", | ||
| 108 | results: []Result{ | 108 | results: []Result{ | ||
| 109 | - | NewStoppedSessionResult("aBravo", time.Time{}), | 109 | + | NewStoppedSessionResult("aBravo", time.Time{}, testExactTarget("aBravo", 1)), |
| 110 | - | NewStoppedSessionResult("aAlpha", time.Time{}), | 110 | + | NewStoppedSessionResult("aAlpha", time.Time{}, testExactTarget("aAlpha", 2)), |
| 111 | }, | 111 | }, | ||
| 112 | query: "a", | 112 | query: "a", | ||
| 113 | wantText: []string{"Resume session aAlpha", "Resume session aBravo"}, | 113 | wantText: []string{"Resume session aAlpha", "Resume session aBravo"}, | ||
| @@ -117,18 +117,20 @@ func TestFuzzyOrdersMixedResults(t *testing.T) { | |||||
| 117 | { | 117 | { | ||
| 118 | name: "display prefix positions sort before later active names", | 118 | name: "display prefix positions sort before later active names", | ||
| 119 | results: []Result{ | 119 | results: []Result{ | ||
| 120 | - | NewStoppedSessionResult("aBravo", time.Time{}), | 120 | + | NewStoppedSessionResult("aBravo", time.Time{}, testExactTarget("aBravo", 1)), |
| 121 | - | NewStoppedSessionResult("aAlpha", time.Time{}), | 121 | + | NewStoppedSessionResult("aAlpha", time.Time{}, testExactTarget("aAlpha", 2)), |
| 122 | - | NewActiveSessionResult("aZulu", time.Time{}, "z"), | 122 | + | NewActiveSessionResult("aZulu", time.Time{}, testExactTarget("aZulu", 3)), |
| 123 | - | NewActiveSessionResult("aEcho", time.Time{}, "e"), | 123 | + | NewActiveSessionResult("aEcho", time.Time{}, testExactTarget("aEcho", 4)), |
| 124 | - | NewActiveSessionResult("aEcho", time.Time{}, "e2"), | 124 | + | NewActiveSessionResult("aEcho", time.Time{}, testExactTarget("aEcho", 5)), |
| 125 | NewCommandResult(cmd("AX", "", "")), | 125 | NewCommandResult(cmd("AX", "", "")), | ||
| 126 | }, | 126 | }, | ||
| 127 | query: "a", | 127 | query: "a", | ||
| 128 | wantText: []string{"AX", "Resume session aAlpha", "Resume session aBravo", "Switch to session aEcho", "Switch to session aEcho", "Switch to session aZulu"}, | 128 | wantText: []string{"AX", "Resume session aAlpha", "Resume session aBravo", "Switch to session aEcho", "Switch to session aEcho", "Switch to session aZulu"}, | ||
| 129 | wantKinds: []ResultKind{ResultKindCommand, ResultKindStoppedSession, ResultKindStoppedSession, ResultKindActiveSession, ResultKindActiveSession, ResultKindActiveSession}, | 129 | wantKinds: []ResultKind{ResultKindCommand, ResultKindStoppedSession, ResultKindStoppedSession, ResultKindActiveSession, ResultKindActiveSession, ResultKindActiveSession}, | ||
| 130 | wantPositions: [][]int{{0}, {15}, {15}, {18}, {18}, {18}}, | 130 | wantPositions: [][]int{{0}, {15}, {15}, {18}, {18}, {18}}, | ||
| 131 | - | wantActiveIDs: map[int]domain.SessionID{3: "e", 4: "e2", 5: "z"}, | 131 | + | wantActiveTargets: map[int]ports.ExactSessionTarget{ |
| 132 | + | 3: testExactTarget("aEcho", 4), 4: testExactTarget("aEcho", 5), 5: testExactTarget("aZulu", 3), | |||
| 133 | + | }, | |||
| 132 | }, | 134 | }, | ||
| 133 | } | 135 | } | ||
| 134 | 136 | ||||
| @@ -142,10 +144,10 @@ func TestFuzzyOrdersMixedResults(t *testing.T) { | |||||
| 142 | positions[i] = matches[i].Positions | 144 | 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) { | |||
| 460 | 460 | ||
| 461 | 461 | func TestModelUsesDefensiveTypedResultsAndKeepsSessionsCommandInert(t *testing.T) { | |
| 462 | 462 | created := time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC) | |
| 463 | + | workTarget := testExactTarget("work", 1) | |
| 463 | 464 | results := []Result{ | |
| 464 | 465 | NewCommandResult(cmd("JRS", "Jump", "Jump to recent session")), | |
| 465 | - | NewActiveSessionResult("work", created, "work-id"), | |
| 466 | - | NewStoppedSessionResult("work", created), | |
| 466 | + | NewActiveSessionResult("work", created, workTarget), | |
| 467 | + | NewStoppedSessionResult("work", created, testExactTarget("work", 2)), | |
| 467 | 468 | } | |
| 468 | 469 | m := New(results) | |
| 469 | - | results[1] = NewActiveSessionResult("changed", created, "changed-id") | |
| 470 | + | results[1] = NewActiveSessionResult("changed", created, testExactTarget("changed", 3)) | |
| 470 | 471 | ||
| 471 | 472 | for _, r := range "switch" { | |
| 472 | 473 | m.Insert(r) | |
| @@ -480,9 +481,9 @@ func TestModelUsesDefensiveTypedResultsAndKeepsSessionsCommandInert(t *testing.T | |||
| 480 | 481 | selected, ok := m.Selected() | |
| 481 | 482 | require.True(t, ok) | |
| 482 | 483 | require.Equal(t, ResultKindActiveSession, selected.Kind()) | |
| 483 | - | id, active := selected.SessionID() | |
| 484 | + | target, active := selected.SessionTarget() | |
| 484 | 485 | require.True(t, active) | |
| 485 | - | require.Equal(t, domain.SessionID("work-id"), id) | |
| 486 | + | require.Equal(t, workTarget, target) | |
| 486 | 487 | require.False(t, m.CompleteSelected(), "sessions never participate in tab completion") | |
| 487 | 488 | require.Equal(t, "switch", m.Query()) | |
| 488 | 489 | _, argument := m.ArgumentCommand() | |
| @@ -493,7 +494,7 @@ func TestModelUsesDefensiveTypedResultsAndKeepsSessionsCommandInert(t *testing.T | |||
| 493 | 494 | } | |
| 494 | 495 | ||
| 495 | 496 | func TestRenderStoppedSessionHighlightsNameAfterResumePrefix(t *testing.T) { | |
| 496 | - | m := New([]Result{NewStoppedSessionResult("work", time.Unix(0, 1))}) | |
| 497 | + | m := New([]Result{NewStoppedSessionResult("work", time.Unix(0, 1), testExactTarget("work", 1))}) | |
| 497 | 498 | m.Insert('w') | |
| 498 | 499 | m.Insert('k') | |
| 499 | 500 | ||
| @@ -506,7 +507,7 @@ func TestRenderStoppedSessionHighlightsNameAfterResumePrefix(t *testing.T) { | |||
| 506 | 507 | } | |
| 507 | 508 | ||
| 508 | 509 | func TestRenderFeedbackUsesSelectedSessionRowWithoutAddingResult(t *testing.T) { | |
| 509 | - | m := New([]Result{NewActiveSessionResult("work", time.Unix(0, 1), "work")}) | |
| 510 | + | m := New([]Result{NewActiveSessionResult("work", time.Unix(0, 1), testExactTarget("work", 1))}) | |
| 510 | 511 | frame := m.Render(domain.Size{Cols: 64, Rows: 3}, RenderOptions{Styles: DefaultRenderStyles(), Feedback: "requested session is unavailable"}) | |
| 511 | 512 | require.Len(t, m.Matches(), 1) | |
| 512 | 513 | require.Contains(t, frameRow(frame, 1), "requested session is unavailable") | |
| @@ -460,13 +460,14 @@ func TestModelCompleteSelected(t *testing.T) { | |||||
| 460 | 460 | ||||
| 461 | func TestModelUsesDefensiveTypedResultsAndKeepsSessionsCommandInert(t *testing.T) { | 461 | func TestModelUsesDefensiveTypedResultsAndKeepsSessionsCommandInert(t *testing.T) { | ||
| 462 | created := time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC) | 462 | created := time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC) | ||
| 463 | + | workTarget := testExactTarget("work", 1) | |||
| 463 | results := []Result{ | 464 | results := []Result{ | ||
| 464 | NewCommandResult(cmd("JRS", "Jump", "Jump to recent session")), | 465 | NewCommandResult(cmd("JRS", "Jump", "Jump to recent session")), | ||
| 465 | - | NewActiveSessionResult("work", created, "work-id"), | 466 | + | NewActiveSessionResult("work", created, workTarget), |
| 466 | - | NewStoppedSessionResult("work", created), | 467 | + | NewStoppedSessionResult("work", created, testExactTarget("work", 2)), |
| 467 | } | 468 | } | ||
| 468 | m := New(results) | 469 | m := New(results) | ||
| 469 | - | results[1] = NewActiveSessionResult("changed", created, "changed-id") | 470 | + | results[1] = NewActiveSessionResult("changed", created, testExactTarget("changed", 3)) |
| 470 | 471 | ||||
| 471 | for _, r := range "switch" { | 472 | for _, r := range "switch" { | ||
| 472 | m.Insert(r) | 473 | m.Insert(r) | ||
| @@ -480,9 +481,9 @@ func TestModelUsesDefensiveTypedResultsAndKeepsSessionsCommandInert(t *testing.T | |||||
| 480 | selected, ok := m.Selected() | 481 | selected, ok := m.Selected() | ||
| 481 | require.True(t, ok) | 482 | require.True(t, ok) | ||
| 482 | require.Equal(t, ResultKindActiveSession, selected.Kind()) | 483 | require.Equal(t, ResultKindActiveSession, selected.Kind()) | ||
| 483 | - | id, active := selected.SessionID() | 484 | + | target, active := selected.SessionTarget() |
| 484 | require.True(t, active) | 485 | require.True(t, active) | ||
| 485 | - | require.Equal(t, domain.SessionID("work-id"), id) | 486 | + | require.Equal(t, workTarget, target) |
| 486 | require.False(t, m.CompleteSelected(), "sessions never participate in tab completion") | 487 | require.False(t, m.CompleteSelected(), "sessions never participate in tab completion") | ||
| 487 | require.Equal(t, "switch", m.Query()) | 488 | require.Equal(t, "switch", m.Query()) | ||
| 488 | _, argument := m.ArgumentCommand() | 489 | _, argument := m.ArgumentCommand() | ||
| @@ -493,7 +494,7 @@ func TestModelUsesDefensiveTypedResultsAndKeepsSessionsCommandInert(t *testing.T | |||||
| 493 | } | 494 | } | ||
| 494 | 495 | ||||
| 495 | func TestRenderStoppedSessionHighlightsNameAfterResumePrefix(t *testing.T) { | 496 | func TestRenderStoppedSessionHighlightsNameAfterResumePrefix(t *testing.T) { | ||
| 496 | - | m := New([]Result{NewStoppedSessionResult("work", time.Unix(0, 1))}) | 497 | + | m := New([]Result{NewStoppedSessionResult("work", time.Unix(0, 1), testExactTarget("work", 1))}) |
| 497 | m.Insert('w') | 498 | m.Insert('w') | ||
| 498 | m.Insert('k') | 499 | m.Insert('k') | ||
| 499 | 500 | ||||
| @@ -506,7 +507,7 @@ func TestRenderStoppedSessionHighlightsNameAfterResumePrefix(t *testing.T) { | |||||
| 506 | } | 507 | } | ||
| 507 | 508 | ||||
| 508 | func TestRenderFeedbackUsesSelectedSessionRowWithoutAddingResult(t *testing.T) { | 509 | func TestRenderFeedbackUsesSelectedSessionRowWithoutAddingResult(t *testing.T) { | ||
| 509 | - | m := New([]Result{NewActiveSessionResult("work", time.Unix(0, 1), "work")}) | 510 | + | m := New([]Result{NewActiveSessionResult("work", time.Unix(0, 1), testExactTarget("work", 1))}) |
| 510 | frame := m.Render(domain.Size{Cols: 64, Rows: 3}, RenderOptions{Styles: DefaultRenderStyles(), Feedback: "requested session is unavailable"}) | 511 | frame := m.Render(domain.Size{Cols: 64, Rows: 3}, RenderOptions{Styles: DefaultRenderStyles(), Feedback: "requested session is unavailable"}) | ||
| 511 | require.Len(t, m.Matches(), 1) | 512 | require.Len(t, m.Matches(), 1) | ||
| 512 | require.Contains(t, frameRow(frame, 1), "requested session is unavailable") | 513 | require.Contains(t, frameRow(frame, 1), "requested session is unavailable") | ||
M internal/usecase/palette/result.go +9 -9
| @@ -3,7 +3,6 @@ package palette | |||
| 3 | 3 | import ( | |
| 4 | 4 | "time" | |
| 5 | 5 | ||
| 6 | - | "github.com/bnema/vev/internal/domain" | |
| 7 | 6 | "github.com/bnema/vev/internal/ports" | |
| 8 | 7 | "github.com/bnema/vev/internal/usecase/command" | |
| 9 | 8 | ) | |
| @@ -30,7 +29,7 @@ type Result struct { | |||
| 30 | 29 | type sessionPayload struct { | |
| 31 | 30 | name string | |
| 32 | 31 | createdAt time.Time | |
| 33 | - | sessionID domain.SessionID | |
| 32 | + | target ports.ExactSessionTarget | |
| 34 | 33 | } | |
| 35 | 34 | ||
| 36 | 35 | type routePayload struct { | |
| @@ -44,18 +43,18 @@ func NewCommandResult(cmd command.Command) Result { | |||
| 44 | 43 | } | |
| 45 | 44 | ||
| 46 | 45 | // NewActiveSessionResult creates an immutable active named-session target. | |
| 47 | - | func NewActiveSessionResult(name string, createdAt time.Time, sessionID domain.SessionID) Result { | |
| 46 | + | func NewActiveSessionResult(name string, createdAt time.Time, target ports.ExactSessionTarget) Result { | |
| 48 | 47 | return Result{ | |
| 49 | 48 | kind: ResultKindActiveSession, | |
| 50 | - | session: sessionPayload{name: name, createdAt: createdAt, sessionID: sessionID}, | |
| 49 | + | session: sessionPayload{name: name, createdAt: createdAt, target: target}, | |
| 51 | 50 | } | |
| 52 | 51 | } | |
| 53 | 52 | ||
| 54 | 53 | // NewStoppedSessionResult creates an immutable stopped named-session target. | |
| 55 | - | func NewStoppedSessionResult(name string, createdAt time.Time) Result { | |
| 54 | + | func NewStoppedSessionResult(name string, createdAt time.Time, target ports.ExactSessionTarget) Result { | |
| 56 | 55 | return Result{ | |
| 57 | 56 | kind: ResultKindStoppedSession, | |
| 58 | - | session: sessionPayload{name: name, createdAt: createdAt}, | |
| 57 | + | session: sessionPayload{name: name, createdAt: createdAt, target: target}, | |
| 59 | 58 | } | |
| 60 | 59 | } | |
| 61 | 60 | ||
| @@ -102,9 +101,10 @@ func (r Result) SessionCreatedAt() (time.Time, bool) { | |||
| 102 | 101 | 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. | |
| 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 | |
| 108 | 108 | } | |
| 109 | 109 | ||
| 110 | 110 | // 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 string | 30 | name string | ||
| 32 | createdAt time.Time | 31 | createdAt time.Time | ||
| 33 | - | sessionID domain.SessionID | 32 | + | target ports.ExactSessionTarget |
| 34 | } | 33 | } | ||
| 35 | 34 | ||||
| 36 | type routePayload struct { | 35 | type routePayload struct { | ||
| @@ -44,18 +43,18 @@ func NewCommandResult(cmd command.Command) Result { | |||||
| 44 | } | 43 | } | ||
| 45 | 44 | ||||
| 46 | // NewActiveSessionResult creates an immutable active named-session target. | 45 | // NewActiveSessionResult creates an immutable active named-session target. | ||
| 47 | - | func NewActiveSessionResult(name string, createdAt time.Time, sessionID domain.SessionID) Result { | 46 | + | func NewActiveSessionResult(name string, createdAt time.Time, target ports.ExactSessionTarget) Result { |
| 48 | return Result{ | 47 | return Result{ | ||
| 49 | kind: ResultKindActiveSession, | 48 | kind: ResultKindActiveSession, | ||
| 50 | - | session: sessionPayload{name: name, createdAt: createdAt, sessionID: sessionID}, | 49 | + | session: sessionPayload{name: name, createdAt: createdAt, target: target}, |
| 51 | } | 50 | } | ||
| 52 | } | 51 | } | ||
| 53 | 52 | ||||
| 54 | // NewStoppedSessionResult creates an immutable stopped named-session target. | 53 | // NewStoppedSessionResult creates an immutable stopped named-session target. | ||
| 55 | - | func NewStoppedSessionResult(name string, createdAt time.Time) Result { | 54 | + | func NewStoppedSessionResult(name string, createdAt time.Time, target ports.ExactSessionTarget) Result { |
| 56 | return Result{ | 55 | return Result{ | ||
| 57 | kind: ResultKindStoppedSession, | 56 | kind: ResultKindStoppedSession, | ||
| 58 | - | session: sessionPayload{name: name, createdAt: createdAt}, | 57 | + | session: sessionPayload{name: name, createdAt: createdAt, target: target}, |
| 59 | } | 58 | } | ||
| 60 | } | 59 | } | ||
| 61 | 60 | ||||
| @@ -102,9 +101,10 @@ func (r Result) SessionCreatedAt() (time.Time, bool) { | |||||
| 102 | return r.session.createdAt, r.kind == ResultKindActiveSession || r.kind == ResultKindStoppedSession | 101 | 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 == ResultKindActiveSession | 106 | + | 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 a | 110 | // RouteNavigationAction returns the exact client-ledger target only for a | ||
M internal/usecase/palette/result_test.go +17 -10
| @@ -10,11 +10,17 @@ import ( | |||
| 10 | 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 | 17 | func TestResultKindsAndSessionLifecycleTargets(t *testing.T) { | |
| 14 | 18 | created := time.Date(2026, time.March, 1, 2, 3, 4, 0, time.UTC) | |
| 15 | 19 | commandResult := NewCommandResult(command.Command{Code: "NT", Desc: "Create tab"}) | |
| 16 | - | active := NewActiveSessionResult("work", created, domain.SessionID("session-work")) | |
| 17 | - | stopped := NewStoppedSessionResult("archive", created) | |
| 20 | + | activeTarget := testExactTarget("work", 1) | |
| 21 | + | stoppedTarget := testExactTarget("archive", 2) | |
| 22 | + | active := NewActiveSessionResult("work", created, activeTarget) | |
| 23 | + | stopped := NewStoppedSessionResult("archive", created, stoppedTarget) | |
| 18 | 24 | ||
| 19 | 25 | require.Equal(t, ResultKindCommand, commandResult.Kind()) | |
| 20 | 26 | require.Equal(t, "NT", commandResult.DisplayText()) | |
| @@ -32,9 +38,9 @@ func TestResultKindsAndSessionLifecycleTargets(t *testing.T) { | |||
| 32 | 38 | require.Equal(t, created, createdAt) | |
| 33 | 39 | require.Equal(t, "Switch to session work", active.DisplayText()) | |
| 34 | 40 | require.Equal(t, "Switch to session work", active.SearchText()) | |
| 35 | - | id, ok := active.SessionID() | |
| 41 | + | target, ok := active.SessionTarget() | |
| 36 | 42 | require.True(t, ok) | |
| 37 | - | require.Equal(t, domain.SessionID("session-work"), id) | |
| 43 | + | require.Equal(t, activeTarget, target) | |
| 38 | 44 | ||
| 39 | 45 | require.Equal(t, ResultKindStoppedSession, stopped.Kind()) | |
| 40 | 46 | name, ok = stopped.SessionName() | |
| @@ -45,8 +51,9 @@ func TestResultKindsAndSessionLifecycleTargets(t *testing.T) { | |||
| 45 | 51 | require.Equal(t, created, createdAt) | |
| 46 | 52 | require.Equal(t, "Resume session archive", stopped.DisplayText()) | |
| 47 | 53 | 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) | |
| 50 | 57 | } | |
| 51 | 58 | ||
| 52 | 59 | func TestRecentRouteResultCarriesExactNavigationAction(t *testing.T) { | |
| @@ -71,10 +78,10 @@ func TestSessionResultsSearchDisplayedActionText(t *testing.T) { | |||
| 71 | 78 | query string | |
| 72 | 79 | positions []int | |
| 73 | 80 | }{ | |
| 74 | - | {name: "active prefix", result: NewActiveSessionResult("work", created, "work-id"), query: "switch", positions: []int{0, 1, 2, 3, 4, 5}}, | |
| 75 | - | {name: "active name", result: NewActiveSessionResult("work", created, "work-id"), query: "work", positions: []int{1, 8, 20, 21}}, | |
| 76 | - | {name: "stopped prefix", result: NewStoppedSessionResult("archive", created), query: "resume", positions: []int{0, 1, 2, 3, 4, 5}}, | |
| 77 | - | {name: "stopped name", result: NewStoppedSessionResult("archive", created), query: "archive", positions: []int{15, 16, 17, 18, 19, 20, 21}}, | |
| 81 | + | {name: "active prefix", result: NewActiveSessionResult("work", created, testExactTarget("work", 1)), query: "switch", positions: []int{0, 1, 2, 3, 4, 5}}, | |
| 82 | + | {name: "active name", result: NewActiveSessionResult("work", created, testExactTarget("work", 1)), query: "work", positions: []int{1, 8, 20, 21}}, | |
| 83 | + | {name: "stopped prefix", result: NewStoppedSessionResult("archive", created, testExactTarget("archive", 2)), query: "resume", positions: []int{0, 1, 2, 3, 4, 5}}, | |
| 84 | + | {name: "stopped name", result: NewStoppedSessionResult("archive", created, testExactTarget("archive", 2)), query: "archive", positions: []int{15, 16, 17, 18, 19, 20, 21}}, | |
| 78 | 85 | } | |
| 79 | 86 | ||
| 80 | 87 | for _, tt := range tests { | |
| @@ -10,11 +10,17 @@ import ( | |||||
| 10 | "github.com/stretchr/testify/require" | 10 | "github.com/stretchr/testify/require" | ||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
| 13 | + | func testExactTarget(name string, marker byte) ports.ExactSessionTarget { | |||
| 14 | + | return ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{marker}, SessionName: name} | |||
| 15 | + | } | |||
| 16 | + | ||||
| 13 | func TestResultKindsAndSessionLifecycleTargets(t *testing.T) { | 17 | func TestResultKindsAndSessionLifecycleTargets(t *testing.T) { | ||
| 14 | created := time.Date(2026, time.March, 1, 2, 3, 4, 0, time.UTC) | 18 | created := time.Date(2026, time.March, 1, 2, 3, 4, 0, time.UTC) | ||
| 15 | commandResult := NewCommandResult(command.Command{Code: "NT", Desc: "Create tab"}) | 19 | commandResult := NewCommandResult(command.Command{Code: "NT", Desc: "Create tab"}) | ||
| 16 | - | active := NewActiveSessionResult("work", created, domain.SessionID("session-work")) | 20 | + | activeTarget := testExactTarget("work", 1) |
| 17 | - | stopped := NewStoppedSessionResult("archive", created) | 21 | + | stoppedTarget := testExactTarget("archive", 2) |
| 22 | + | active := NewActiveSessionResult("work", created, activeTarget) | |||
| 23 | + | stopped := NewStoppedSessionResult("archive", created, stoppedTarget) | |||
| 18 | 24 | ||||
| 19 | require.Equal(t, ResultKindCommand, commandResult.Kind()) | 25 | require.Equal(t, ResultKindCommand, commandResult.Kind()) | ||
| 20 | require.Equal(t, "NT", commandResult.DisplayText()) | 26 | require.Equal(t, "NT", commandResult.DisplayText()) | ||
| @@ -32,9 +38,9 @@ func TestResultKindsAndSessionLifecycleTargets(t *testing.T) { | |||||
| 32 | require.Equal(t, created, createdAt) | 38 | require.Equal(t, created, createdAt) | ||
| 33 | require.Equal(t, "Switch to session work", active.DisplayText()) | 39 | require.Equal(t, "Switch to session work", active.DisplayText()) | ||
| 34 | require.Equal(t, "Switch to session work", active.SearchText()) | 40 | require.Equal(t, "Switch to session work", active.SearchText()) | ||
| 35 | - | id, ok := active.SessionID() | 41 | + | target, ok := active.SessionTarget() |
| 36 | require.True(t, ok) | 42 | require.True(t, ok) | ||
| 37 | - | require.Equal(t, domain.SessionID("session-work"), id) | 43 | + | require.Equal(t, activeTarget, target) |
| 38 | 44 | ||||
| 39 | require.Equal(t, ResultKindStoppedSession, stopped.Kind()) | 45 | require.Equal(t, ResultKindStoppedSession, stopped.Kind()) | ||
| 40 | name, ok = stopped.SessionName() | 46 | name, ok = stopped.SessionName() | ||
| @@ -45,8 +51,9 @@ func TestResultKindsAndSessionLifecycleTargets(t *testing.T) { | |||||
| 45 | require.Equal(t, created, createdAt) | 51 | require.Equal(t, created, createdAt) | ||
| 46 | require.Equal(t, "Resume session archive", stopped.DisplayText()) | 52 | require.Equal(t, "Resume session archive", stopped.DisplayText()) | ||
| 47 | require.Equal(t, "Resume session archive", stopped.SearchText()) | 53 | require.Equal(t, "Resume session archive", stopped.SearchText()) | ||
| 48 | - | _, ok = stopped.SessionID() | 54 | + | target, ok = stopped.SessionTarget() |
| 49 | - | require.False(t, ok) | 55 | + | require.True(t, ok) |
| 56 | + | require.Equal(t, stoppedTarget, target) | |||
| 50 | } | 57 | } | ||
| 51 | 58 | ||||
| 52 | func TestRecentRouteResultCarriesExactNavigationAction(t *testing.T) { | 59 | func TestRecentRouteResultCarriesExactNavigationAction(t *testing.T) { | ||
| @@ -71,10 +78,10 @@ func TestSessionResultsSearchDisplayedActionText(t *testing.T) { | |||||
| 71 | query string | 78 | query string | ||
| 72 | positions []int | 79 | positions []int | ||
| 73 | }{ | 80 | }{ | ||
| 74 | - | {name: "active prefix", result: NewActiveSessionResult("work", created, "work-id"), query: "switch", positions: []int{0, 1, 2, 3, 4, 5}}, | 81 | + | {name: "active prefix", result: NewActiveSessionResult("work", created, testExactTarget("work", 1)), query: "switch", positions: []int{0, 1, 2, 3, 4, 5}}, |
| 75 | - | {name: "active name", result: NewActiveSessionResult("work", created, "work-id"), query: "work", positions: []int{1, 8, 20, 21}}, | 82 | + | {name: "active name", result: NewActiveSessionResult("work", created, testExactTarget("work", 1)), query: "work", positions: []int{1, 8, 20, 21}}, |
| 76 | - | {name: "stopped prefix", result: NewStoppedSessionResult("archive", created), query: "resume", positions: []int{0, 1, 2, 3, 4, 5}}, | 83 | + | {name: "stopped prefix", result: NewStoppedSessionResult("archive", created, testExactTarget("archive", 2)), query: "resume", positions: []int{0, 1, 2, 3, 4, 5}}, |
| 77 | - | {name: "stopped name", result: NewStoppedSessionResult("archive", created), query: "archive", positions: []int{15, 16, 17, 18, 19, 20, 21}}, | 84 | + | {name: "stopped name", result: NewStoppedSessionResult("archive", created, testExactTarget("archive", 2)), query: "archive", positions: []int{15, 16, 17, 18, 19, 20, 21}}, |
| 78 | } | 85 | } | ||
| 79 | 86 | ||||
| 80 | for _, tt := range tests { | 87 | for _, tt := range tests { | ||