detached 1fe5198

fix(client): normalize created remote routes (#242)

commit
1fe51988d3397cfc19993eb77a0a91430ee2727d
author
bnema · · 2026-08-30 15:41 UTC
committer
GitHub · · 2026-08-30 15:41 UTC
parent
0bf02ca
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
2 files · +73 -0
M internal/usecase/client/client_test.go +65 -0
@@ -1262,6 +1262,71 @@ func TestRemoteCreationFailuresRestoreSourceAndReportCorrelation(t *testing.T) {
12621262 }
12631263 }
12641264
1265+func TestRouteNavigationReturnsToCreatedRemoteSession(t *testing.T) {
1266+ term := newRunTerminal()
1267+ defer term.in.unblock()
1268+
1269+ localLifecycle := domain.SessionLifecycleID{1}
1270+ remoteLifecycle := domain.SessionLifecycleID{2}
1271+ welcome := func(name string, lifecycle domain.SessionLifecycleID) wire.Frame {
1272+ return frameOf(wire.MsgWelcome, wire.MarshalWelcome(protocol.Welcome{
1273+ SessionID: name + "-id", SessionName: name, ResumeToken: 1,
1274+ Capabilities: protocol.CapabilityResume,
1275+ CommittedIdentity: &protocol.CommittedRouteIdentity{
1276+ Target: protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: name},
1277+ },
1278+ }))
1279+ }
1280+ createRemote := protocol.AttachTarget{
1281+ RequestID: 1, Endpoint: "remote", Session: "created", Intent: protocol.IntentNew,
1282+ EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned,
1283+ }
1284+ local1 := &recordingTransport{recvs: []recvItem{
1285+ {f: welcome("misc", localLifecycle)},
1286+ {f: frameOf(wire.MsgAttachTarget, wire.MarshalAttachTarget(createRemote))},
1287+ }}
1288+ remote1 := &recordingTransport{recvs: []recvItem{
1289+ {f: welcome("created", remoteLifecycle)},
1290+ {f: frameOf(wire.MsgNavigateRecentRoute, mustMarshalRouteAction(protocol.RouteNavigationAction{
1291+ SnapshotGeneration: 2, Key: 1, Generation: 1,
1292+ }))},
1293+ }}
1294+ local2 := &recordingTransport{recvs: []recvItem{
1295+ {f: welcome("misc", localLifecycle)},
1296+ {f: frameOf(wire.MsgNavigateRecentRoute, mustMarshalRouteAction(protocol.RouteNavigationAction{
1297+ SnapshotGeneration: 3, Key: 2, Generation: 2,
1298+ }))},
1299+ }}
1300+ remote2 := &recordingTransport{recvs: []recvItem{
1301+ {f: welcome("created", remoteLifecycle)},
1302+ {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))},
1303+ }}
1304+ localDialer := &sequenceDialer{trs: []wire.Transport{local1, local2}}
1305+ remoteDialer := &sequenceDialer{trs: []wire.Transport{remote1, remote2}}
1306+ deps := testDependencies(localDialer, term, realClock{}, nil, nil)
1307+ deps.AttachHandoff = func(target protocol.AttachTarget) (ports.ClientDialer, client.AttachRequest, error) {
1308+ require.Equal(t, createRemote, target)
1309+ return remoteDialer, client.AttachRequest{
1310+ Intent: protocol.IntentNew, SessionName: target.Session, Remote: true,
1311+ Origin: protocol.RouteOriginDiscovery, OriginKey: target.Endpoint,
1312+ EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned,
1313+ }, nil
1314+ }
1315+
1316+ err := runTestClient(context.Background(), deps, client.AttachRequest{
1317+ Intent: protocol.IntentAttach, SessionName: "misc", Origin: protocol.RouteOriginLocal, OriginKey: "local",
1318+ })
1319+ require.NoError(t, err)
1320+ require.Equal(t, int32(2), remoteDialer.calls.Load())
1321+ returnHello := helloFromSend(t, remote2)
1322+ require.Equal(t, protocol.IntentResume, returnHello.Intent)
1323+ require.Equal(t, "created", returnHello.Name)
1324+ require.Equal(t, &protocol.ExactSessionTarget{LifecycleID: remoteLifecycle, SessionName: "created"}, returnHello.ExactTarget)
1325+ require.Nil(t, returnHello.RemoteTarget)
1326+ require.Equal(t, protocol.EnvironmentPolicyDaemonOwned, returnHello.EnvironmentPolicy)
1327+ require.Equal(t, protocol.NavigationCapabilityHomePicker, returnHello.NavigationCapabilities)
1328+}
1329+
12651330 func TestRouteNavigationPreservesRemoteHomePickerAcrossLocalReturn(t *testing.T) {
12661331 term := newRunTerminal()
12671332 defer term.in.unblock()
@@ -1262,6 +1262,71 @@ func TestRemoteCreationFailuresRestoreSourceAndReportCorrelation(t *testing.T) {
1262 }1262 }
1263 }1263 }
1264 1264
1265+func TestRouteNavigationReturnsToCreatedRemoteSession(t *testing.T) {
1266+ term := newRunTerminal()
1267+ defer term.in.unblock()
1268+
1269+ localLifecycle := domain.SessionLifecycleID{1}
1270+ remoteLifecycle := domain.SessionLifecycleID{2}
1271+ welcome := func(name string, lifecycle domain.SessionLifecycleID) wire.Frame {
1272+ return frameOf(wire.MsgWelcome, wire.MarshalWelcome(protocol.Welcome{
1273+ SessionID: name + "-id", SessionName: name, ResumeToken: 1,
1274+ Capabilities: protocol.CapabilityResume,
1275+ CommittedIdentity: &protocol.CommittedRouteIdentity{
1276+ Target: protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: name},
1277+ },
1278+ }))
1279+ }
1280+ createRemote := protocol.AttachTarget{
1281+ RequestID: 1, Endpoint: "remote", Session: "created", Intent: protocol.IntentNew,
1282+ EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned,
1283+ }
1284+ local1 := &recordingTransport{recvs: []recvItem{
1285+ {f: welcome("misc", localLifecycle)},
1286+ {f: frameOf(wire.MsgAttachTarget, wire.MarshalAttachTarget(createRemote))},
1287+ }}
1288+ remote1 := &recordingTransport{recvs: []recvItem{
1289+ {f: welcome("created", remoteLifecycle)},
1290+ {f: frameOf(wire.MsgNavigateRecentRoute, mustMarshalRouteAction(protocol.RouteNavigationAction{
1291+ SnapshotGeneration: 2, Key: 1, Generation: 1,
1292+ }))},
1293+ }}
1294+ local2 := &recordingTransport{recvs: []recvItem{
1295+ {f: welcome("misc", localLifecycle)},
1296+ {f: frameOf(wire.MsgNavigateRecentRoute, mustMarshalRouteAction(protocol.RouteNavigationAction{
1297+ SnapshotGeneration: 3, Key: 2, Generation: 2,
1298+ }))},
1299+ }}
1300+ remote2 := &recordingTransport{recvs: []recvItem{
1301+ {f: welcome("created", remoteLifecycle)},
1302+ {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))},
1303+ }}
1304+ localDialer := &sequenceDialer{trs: []wire.Transport{local1, local2}}
1305+ remoteDialer := &sequenceDialer{trs: []wire.Transport{remote1, remote2}}
1306+ deps := testDependencies(localDialer, term, realClock{}, nil, nil)
1307+ deps.AttachHandoff = func(target protocol.AttachTarget) (ports.ClientDialer, client.AttachRequest, error) {
1308+ require.Equal(t, createRemote, target)
1309+ return remoteDialer, client.AttachRequest{
1310+ Intent: protocol.IntentNew, SessionName: target.Session, Remote: true,
1311+ Origin: protocol.RouteOriginDiscovery, OriginKey: target.Endpoint,
1312+ EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned,
1313+ }, nil
1314+ }
1315+
1316+ err := runTestClient(context.Background(), deps, client.AttachRequest{
1317+ Intent: protocol.IntentAttach, SessionName: "misc", Origin: protocol.RouteOriginLocal, OriginKey: "local",
1318+ })
1319+ require.NoError(t, err)
1320+ require.Equal(t, int32(2), remoteDialer.calls.Load())
1321+ returnHello := helloFromSend(t, remote2)
1322+ require.Equal(t, protocol.IntentResume, returnHello.Intent)
1323+ require.Equal(t, "created", returnHello.Name)
1324+ require.Equal(t, &protocol.ExactSessionTarget{LifecycleID: remoteLifecycle, SessionName: "created"}, returnHello.ExactTarget)
1325+ require.Nil(t, returnHello.RemoteTarget)
1326+ require.Equal(t, protocol.EnvironmentPolicyDaemonOwned, returnHello.EnvironmentPolicy)
1327+ require.Equal(t, protocol.NavigationCapabilityHomePicker, returnHello.NavigationCapabilities)
1328+}
1329+
1265 func TestRouteNavigationPreservesRemoteHomePickerAcrossLocalReturn(t *testing.T) {1330 func TestRouteNavigationPreservesRemoteHomePickerAcrossLocalReturn(t *testing.T) {
1266 term := newRunTerminal()1331 term := newRunTerminal()
1267 defer term.in.unblock()1332 defer term.in.unblock()
M internal/usecase/client/routes.go +8 -0
@@ -116,6 +116,7 @@ func cloneRoutePosition(position *protocol.RoutePosition) *protocol.RoutePositio
116116 }
117117
118118 func routeCandidateForAttach(request AttachRequest, identity protocol.CommittedRouteIdentity, dialer ports.ClientDialer, resumeToken uint64) routeCandidate {
119+ created := request.Intent == protocol.IntentNew
119120 origin := normalizeRouteOrigin(request.Origin, request.Remote)
120121 originKey := normalizeRouteOriginKey(request.OriginKey, origin)
121122 kind := protocol.RouteKindLocal
@@ -135,9 +136,16 @@ func routeCandidateForAttach(request AttachRequest, identity protocol.CommittedR
135136 request = cloneAttachRequest(request)
136137 request.Origin = origin
137138 request.OriginKey = originKey
139+ request.Intent = protocol.IntentAttach
138140 request.SessionName = identity.Target.SessionName
139141 request.ExactTarget = &identity.Target
140142 request.HostLabel = hostLabel
143+ // Client-owned environment is creation input, not durable route metadata.
144+ // Once a remote creation commits, later navigation must preserve the new
145+ // session's daemon-owned environment and can safely advertise hybrid Home.
146+ if created && kind == protocol.RouteKindRemote {
147+ request.EnvironmentPolicy = protocol.EnvironmentPolicyDaemonOwned
148+ }
141149 // Discovery targets are point-in-time attach authority, including a live
142150 // tab selector. Once Welcome commits an exact route, mutable tab memory is
143151 // carried independently by PreferredTabID.
@@ -116,6 +116,7 @@ func cloneRoutePosition(position *protocol.RoutePosition) *protocol.RoutePositio
116 }116 }
117 117
118 func routeCandidateForAttach(request AttachRequest, identity protocol.CommittedRouteIdentity, dialer ports.ClientDialer, resumeToken uint64) routeCandidate {118 func routeCandidateForAttach(request AttachRequest, identity protocol.CommittedRouteIdentity, dialer ports.ClientDialer, resumeToken uint64) routeCandidate {
119+ created := request.Intent == protocol.IntentNew
119 origin := normalizeRouteOrigin(request.Origin, request.Remote)120 origin := normalizeRouteOrigin(request.Origin, request.Remote)
120 originKey := normalizeRouteOriginKey(request.OriginKey, origin)121 originKey := normalizeRouteOriginKey(request.OriginKey, origin)
121 kind := protocol.RouteKindLocal122 kind := protocol.RouteKindLocal
@@ -135,9 +136,16 @@ func routeCandidateForAttach(request AttachRequest, identity protocol.CommittedR
135 request = cloneAttachRequest(request)136 request = cloneAttachRequest(request)
136 request.Origin = origin137 request.Origin = origin
137 request.OriginKey = originKey138 request.OriginKey = originKey
139+ request.Intent = protocol.IntentAttach
138 request.SessionName = identity.Target.SessionName140 request.SessionName = identity.Target.SessionName
139 request.ExactTarget = &identity.Target141 request.ExactTarget = &identity.Target
140 request.HostLabel = hostLabel142 request.HostLabel = hostLabel
143+ // Client-owned environment is creation input, not durable route metadata.
144+ // Once a remote creation commits, later navigation must preserve the new
145+ // session's daemon-owned environment and can safely advertise hybrid Home.
146+ if created && kind == protocol.RouteKindRemote {
147+ request.EnvironmentPolicy = protocol.EnvironmentPolicyDaemonOwned
148+ }
141 // Discovery targets are point-in-time attach authority, including a live149 // Discovery targets are point-in-time attach authority, including a live
142 // tab selector. Once Welcome commits an exact route, mutable tab memory is150 // tab selector. Once Welcome commits an exact route, mutable tab memory is
143 // carried independently by PreferredTabID.151 // carried independently by PreferredTabID.

Keyboard shortcuts

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

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

Branches 12

Tags 11

↑↓ moveenter openesc close