detached c8de770

feat(palette): add explicit CNS destinations (#239)

* feat(palette): add explicit session destinations

* fix(palette): address review findings
commit
c8de7707cca8bf3dff5d4cd0e1195bc47f305f41
author
bnema · · 2026-08-29 13:44 UTC
committer
GitHub · · 2026-08-29 13:44 UTC
parent
b788892
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
38 files · +1189 -97
M README.md +2 -0
@@ -65,6 +65,8 @@ Type a code or fuzzy-search the palette. Common default codes:
6565 | `MFP` / `MAT` | move pane / tab | `SSP` | session picker |
6666 | `DET` | detach | `NTC` | notifications |
6767
68+When a client is attached to both a local and a remote daemon, `CNS` and `CNS <name>` list eligible daemon destinations in the existing palette. If two or more destinations exist, choose one explicitly with Up or Down before pressing Enter. The unqualified choice appears only when the client has an exact local route; remote choices remain host-qualified. A direct remote start without a proven local route never labels its home route as local.
69+
6870 Scroll up to enter copy mode; use vim motions, `v` to select, and `y` to copy. All codes and bindings are configurable in [configuration](docs/configuration.md).
6971
7072 ## Remote attach
@@ -65,6 +65,8 @@ Type a code or fuzzy-search the palette. Common default codes:
65 | `MFP` / `MAT` | move pane / tab | `SSP` | session picker |65 | `MFP` / `MAT` | move pane / tab | `SSP` | session picker |
66 | `DET` | detach | `NTC` | notifications |66 | `DET` | detach | `NTC` | notifications |
67 67
68+When a client is attached to both a local and a remote daemon, `CNS` and `CNS <name>` list eligible daemon destinations in the existing palette. If two or more destinations exist, choose one explicitly with Up or Down before pressing Enter. The unqualified choice appears only when the client has an exact local route; remote choices remain host-qualified. A direct remote start without a proven local route never labels its home route as local.
69+
68 Scroll up to enter copy mode; use vim motions, `v` to select, and `y` to copy. All codes and bindings are configurable in [configuration](docs/configuration.md).70 Scroll up to enter copy mode; use vim motions, `v` to select, and `y` to copy. All codes and bindings are configurable in [configuration](docs/configuration.md).
69 71
70 ## Remote attach72 ## Remote attach
M internal/adapters/sessionwire/client.go +10 -1
@@ -101,6 +101,8 @@ func decodeServer(frame wire.Frame) (protocol.ServerMessage, error) {
101101 return wire.UnmarshalCommittedRouteIdentity(frame.Payload)
102102 case wire.MsgNavigateRecentRoute:
103103 return wire.UnmarshalRouteNavigationAction(frame.Payload)
104+ case wire.MsgRouteCreateSession:
105+ return wire.UnmarshalRouteCreateSessionAction(frame.Payload)
104106 case wire.MsgRouteNavigationFailure:
105107 return wire.UnmarshalRouteNavigationFailure(frame.Payload)
106108 case wire.MsgRoutePosition:
@@ -114,7 +116,7 @@ func decodeServer(frame wire.Frame) (protocol.ServerMessage, error) {
114116 wire.MsgClientNotice, wire.MsgCommand, wire.MsgOutputResetRequest,
115117 wire.MsgRemotePreviewRequest, wire.MsgRouteAttentionSubscription,
116118 wire.MsgSamePeerSwitchRequest, wire.MsgParkedRouteRequest,
117- wire.MsgRecentRouteSnapshot:
119+ wire.MsgRecentRouteSnapshot, wire.MsgSessionCreationFailure:
118120 return nil, ErrWrongDirection
119121 default:
120122 return nil, ErrUnknownMessageType
@@ -183,6 +185,9 @@ func encodeClient(message protocol.ClientMessage) (wire.Frame, error) {
183185 case protocol.RouteNavigationFailure:
184186 payload, err := wire.MarshalRouteNavigationFailure(m)
185187 return wire.Frame{Type: wire.MsgRouteNavigationFailure, Payload: payload}, err
188+ case protocol.SessionCreationFailure:
189+ payload, err := wire.MarshalSessionCreationFailure(m)
190+ return wire.Frame{Type: wire.MsgSessionCreationFailure, Payload: payload}, err
186191 case *protocol.Hello:
187192 if m != nil {
188193 return encodeClient(*m)
@@ -259,6 +264,10 @@ func encodeClient(message protocol.ClientMessage) (wire.Frame, error) {
259264 if m != nil {
260265 return encodeClient(*m)
261266 }
267+ case *protocol.SessionCreationFailure:
268+ if m != nil {
269+ return encodeClient(*m)
270+ }
262271 default:
263272 return wire.Frame{}, ErrWrongDirection
264273 }
@@ -101,6 +101,8 @@ func decodeServer(frame wire.Frame) (protocol.ServerMessage, error) {
101 return wire.UnmarshalCommittedRouteIdentity(frame.Payload)101 return wire.UnmarshalCommittedRouteIdentity(frame.Payload)
102 case wire.MsgNavigateRecentRoute:102 case wire.MsgNavigateRecentRoute:
103 return wire.UnmarshalRouteNavigationAction(frame.Payload)103 return wire.UnmarshalRouteNavigationAction(frame.Payload)
104+ case wire.MsgRouteCreateSession:
105+ return wire.UnmarshalRouteCreateSessionAction(frame.Payload)
104 case wire.MsgRouteNavigationFailure:106 case wire.MsgRouteNavigationFailure:
105 return wire.UnmarshalRouteNavigationFailure(frame.Payload)107 return wire.UnmarshalRouteNavigationFailure(frame.Payload)
106 case wire.MsgRoutePosition:108 case wire.MsgRoutePosition:
@@ -114,7 +116,7 @@ func decodeServer(frame wire.Frame) (protocol.ServerMessage, error) {
114 wire.MsgClientNotice, wire.MsgCommand, wire.MsgOutputResetRequest,116 wire.MsgClientNotice, wire.MsgCommand, wire.MsgOutputResetRequest,
115 wire.MsgRemotePreviewRequest, wire.MsgRouteAttentionSubscription,117 wire.MsgRemotePreviewRequest, wire.MsgRouteAttentionSubscription,
116 wire.MsgSamePeerSwitchRequest, wire.MsgParkedRouteRequest,118 wire.MsgSamePeerSwitchRequest, wire.MsgParkedRouteRequest,
117- wire.MsgRecentRouteSnapshot:119+ wire.MsgRecentRouteSnapshot, wire.MsgSessionCreationFailure:
118 return nil, ErrWrongDirection120 return nil, ErrWrongDirection
119 default:121 default:
120 return nil, ErrUnknownMessageType122 return nil, ErrUnknownMessageType
@@ -183,6 +185,9 @@ func encodeClient(message protocol.ClientMessage) (wire.Frame, error) {
183 case protocol.RouteNavigationFailure:185 case protocol.RouteNavigationFailure:
184 payload, err := wire.MarshalRouteNavigationFailure(m)186 payload, err := wire.MarshalRouteNavigationFailure(m)
185 return wire.Frame{Type: wire.MsgRouteNavigationFailure, Payload: payload}, err187 return wire.Frame{Type: wire.MsgRouteNavigationFailure, Payload: payload}, err
188+ case protocol.SessionCreationFailure:
189+ payload, err := wire.MarshalSessionCreationFailure(m)
190+ return wire.Frame{Type: wire.MsgSessionCreationFailure, Payload: payload}, err
186 case *protocol.Hello:191 case *protocol.Hello:
187 if m != nil {192 if m != nil {
188 return encodeClient(*m)193 return encodeClient(*m)
@@ -259,6 +264,10 @@ func encodeClient(message protocol.ClientMessage) (wire.Frame, error) {
259 if m != nil {264 if m != nil {
260 return encodeClient(*m)265 return encodeClient(*m)
261 }266 }
267+ case *protocol.SessionCreationFailure:
268+ if m != nil {
269+ return encodeClient(*m)
270+ }
262 default:271 default:
263 return wire.Frame{}, ErrWrongDirection272 return wire.Frame{}, ErrWrongDirection
264 }273 }
M internal/adapters/sessionwire/client_test.go +7 -0
@@ -40,6 +40,8 @@ func TestClientConnectionEncodesEveryClientMessage(t *testing.T) {
4040 {name: "parked", message: protocol.ParkedRouteRequest{RequestID: 1, LeaseID: lease, Action: protocol.ParkedRoutePrepare}, typeID: wire.MsgParkedRouteRequest},
4141 {name: "snapshot", message: protocol.RecentRouteSnapshot{}, typeID: wire.MsgRecentRouteSnapshot},
4242 {name: "route failure", message: protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable}, typeID: wire.MsgRouteNavigationFailure},
43+ {name: "creation failure", message: protocol.SessionCreationFailure{RequestID: 1, Code: protocol.RouteFailureUnavailable}, typeID: wire.MsgSessionCreationFailure},
44+ {name: "creation failure pointer", message: &protocol.SessionCreationFailure{RequestID: 1, Code: protocol.RouteFailureUnavailable}, typeID: wire.MsgSessionCreationFailure},
4345 }
4446 for _, tt := range tests {
4547 t.Run(tt.name, func(t *testing.T) {
@@ -48,6 +50,10 @@ func TestClientConnectionEncodesEveryClientMessage(t *testing.T) {
4850 require.Equal(t, tt.typeID, raw.sent.Type)
4951 got, err := decodeClient(raw.sent)
5052 require.NoError(t, err)
53+ if message, ok := tt.message.(*protocol.SessionCreationFailure); ok {
54+ require.Equal(t, *message, got)
55+ return
56+ }
5157 require.Equal(t, tt.message, got)
5258 })
5359 }
@@ -72,6 +78,7 @@ func TestClientConnectionDecodesEveryServerMessage(t *testing.T) {
7278 {name: "preview", message: protocol.RemotePreview{Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewUnavailable}},
7379 {name: "identity", message: protocol.CommittedRouteIdentity{Target: exact}},
7480 {name: "route action", message: protocol.RouteNavigationAction{SnapshotGeneration: 1, Key: 1, Generation: 1}},
81+ {name: "route create", message: protocol.RouteCreateSessionAction{RequestID: 1, SnapshotGeneration: 1, Key: 1, Generation: 1, SessionName: "example"}},
7582 {name: "route failure", message: protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable}},
7683 {name: "route position", message: protocol.RoutePosition{Target: exact, ActiveTabID: "tab-1"}},
7784 {name: "switch failure", message: protocol.SamePeerSwitchFailure{RequestID: 1, Code: protocol.SamePeerSwitchUnavailable}},
@@ -40,6 +40,8 @@ func TestClientConnectionEncodesEveryClientMessage(t *testing.T) {
40 {name: "parked", message: protocol.ParkedRouteRequest{RequestID: 1, LeaseID: lease, Action: protocol.ParkedRoutePrepare}, typeID: wire.MsgParkedRouteRequest},40 {name: "parked", message: protocol.ParkedRouteRequest{RequestID: 1, LeaseID: lease, Action: protocol.ParkedRoutePrepare}, typeID: wire.MsgParkedRouteRequest},
41 {name: "snapshot", message: protocol.RecentRouteSnapshot{}, typeID: wire.MsgRecentRouteSnapshot},41 {name: "snapshot", message: protocol.RecentRouteSnapshot{}, typeID: wire.MsgRecentRouteSnapshot},
42 {name: "route failure", message: protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable}, typeID: wire.MsgRouteNavigationFailure},42 {name: "route failure", message: protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable}, typeID: wire.MsgRouteNavigationFailure},
43+ {name: "creation failure", message: protocol.SessionCreationFailure{RequestID: 1, Code: protocol.RouteFailureUnavailable}, typeID: wire.MsgSessionCreationFailure},
44+ {name: "creation failure pointer", message: &protocol.SessionCreationFailure{RequestID: 1, Code: protocol.RouteFailureUnavailable}, typeID: wire.MsgSessionCreationFailure},
43 }45 }
44 for _, tt := range tests {46 for _, tt := range tests {
45 t.Run(tt.name, func(t *testing.T) {47 t.Run(tt.name, func(t *testing.T) {
@@ -48,6 +50,10 @@ func TestClientConnectionEncodesEveryClientMessage(t *testing.T) {
48 require.Equal(t, tt.typeID, raw.sent.Type)50 require.Equal(t, tt.typeID, raw.sent.Type)
49 got, err := decodeClient(raw.sent)51 got, err := decodeClient(raw.sent)
50 require.NoError(t, err)52 require.NoError(t, err)
53+ if message, ok := tt.message.(*protocol.SessionCreationFailure); ok {
54+ require.Equal(t, *message, got)
55+ return
56+ }
51 require.Equal(t, tt.message, got)57 require.Equal(t, tt.message, got)
52 })58 })
53 }59 }
@@ -72,6 +78,7 @@ func TestClientConnectionDecodesEveryServerMessage(t *testing.T) {
72 {name: "preview", message: protocol.RemotePreview{Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewUnavailable}},78 {name: "preview", message: protocol.RemotePreview{Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewUnavailable}},
73 {name: "identity", message: protocol.CommittedRouteIdentity{Target: exact}},79 {name: "identity", message: protocol.CommittedRouteIdentity{Target: exact}},
74 {name: "route action", message: protocol.RouteNavigationAction{SnapshotGeneration: 1, Key: 1, Generation: 1}},80 {name: "route action", message: protocol.RouteNavigationAction{SnapshotGeneration: 1, Key: 1, Generation: 1}},
81+ {name: "route create", message: protocol.RouteCreateSessionAction{RequestID: 1, SnapshotGeneration: 1, Key: 1, Generation: 1, SessionName: "example"}},
75 {name: "route failure", message: protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable}},82 {name: "route failure", message: protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable}},
76 {name: "route position", message: protocol.RoutePosition{Target: exact, ActiveTabID: "tab-1"}},83 {name: "route position", message: protocol.RoutePosition{Target: exact, ActiveTabID: "tab-1"}},
77 {name: "switch failure", message: protocol.SamePeerSwitchFailure{RequestID: 1, Code: protocol.SamePeerSwitchUnavailable}},84 {name: "switch failure", message: protocol.SamePeerSwitchFailure{RequestID: 1, Code: protocol.SamePeerSwitchUnavailable}},
M internal/adapters/sessionwire/server.go +10 -1
@@ -232,10 +232,12 @@ func decodeClient(frame wire.Frame) (protocol.ClientMessage, error) {
232232 return wire.UnmarshalRecentRouteSnapshot(frame.Payload)
233233 case wire.MsgRouteNavigationFailure:
234234 return wire.UnmarshalRouteNavigationFailure(frame.Payload)
235+ case wire.MsgSessionCreationFailure:
236+ return wire.UnmarshalSessionCreationFailure(frame.Payload)
235237 case wire.MsgWelcome, wire.MsgError, wire.MsgOutput, wire.MsgDetached, wire.MsgPong,
236238 wire.MsgSessions, wire.MsgCommandResult, wire.MsgNavigationAction,
237239 wire.MsgAttachTarget, wire.MsgRemotePreviewResponse,
238- wire.MsgCommittedRouteIdentity, wire.MsgNavigateRecentRoute,
240+ wire.MsgCommittedRouteIdentity, wire.MsgNavigateRecentRoute, wire.MsgRouteCreateSession,
239241 wire.MsgRoutePosition, wire.MsgSamePeerSwitchFailure, wire.MsgParkedRouteResponse:
240242 return nil, ErrWrongDirection
241243 default:
@@ -291,6 +293,9 @@ func encodeServer(message protocol.ServerMessage) (wire.Frame, error) {
291293 case protocol.RouteNavigationAction:
292294 payload, err := wire.MarshalRouteNavigationAction(m)
293295 return wire.Frame{Type: wire.MsgNavigateRecentRoute, Payload: payload}, err
296+ case protocol.RouteCreateSessionAction:
297+ payload, err := wire.MarshalRouteCreateSessionAction(m)
298+ return wire.Frame{Type: wire.MsgRouteCreateSession, Payload: payload}, err
294299 case protocol.RouteNavigationFailure:
295300 payload, err := wire.MarshalRouteNavigationFailure(m)
296301 return wire.Frame{Type: wire.MsgRouteNavigationFailure, Payload: payload}, err
@@ -354,6 +359,10 @@ func encodeServer(message protocol.ServerMessage) (wire.Frame, error) {
354359 if m != nil {
355360 return encodeServer(*m)
356361 }
362+ case *protocol.RouteCreateSessionAction:
363+ if m != nil {
364+ return encodeServer(*m)
365+ }
357366 case *protocol.RouteNavigationFailure:
358367 if m != nil {
359368 return encodeServer(*m)
@@ -232,10 +232,12 @@ func decodeClient(frame wire.Frame) (protocol.ClientMessage, error) {
232 return wire.UnmarshalRecentRouteSnapshot(frame.Payload)232 return wire.UnmarshalRecentRouteSnapshot(frame.Payload)
233 case wire.MsgRouteNavigationFailure:233 case wire.MsgRouteNavigationFailure:
234 return wire.UnmarshalRouteNavigationFailure(frame.Payload)234 return wire.UnmarshalRouteNavigationFailure(frame.Payload)
235+ case wire.MsgSessionCreationFailure:
236+ return wire.UnmarshalSessionCreationFailure(frame.Payload)
235 case wire.MsgWelcome, wire.MsgError, wire.MsgOutput, wire.MsgDetached, wire.MsgPong,237 case wire.MsgWelcome, wire.MsgError, wire.MsgOutput, wire.MsgDetached, wire.MsgPong,
236 wire.MsgSessions, wire.MsgCommandResult, wire.MsgNavigationAction,238 wire.MsgSessions, wire.MsgCommandResult, wire.MsgNavigationAction,
237 wire.MsgAttachTarget, wire.MsgRemotePreviewResponse,239 wire.MsgAttachTarget, wire.MsgRemotePreviewResponse,
238- wire.MsgCommittedRouteIdentity, wire.MsgNavigateRecentRoute,240+ wire.MsgCommittedRouteIdentity, wire.MsgNavigateRecentRoute, wire.MsgRouteCreateSession,
239 wire.MsgRoutePosition, wire.MsgSamePeerSwitchFailure, wire.MsgParkedRouteResponse:241 wire.MsgRoutePosition, wire.MsgSamePeerSwitchFailure, wire.MsgParkedRouteResponse:
240 return nil, ErrWrongDirection242 return nil, ErrWrongDirection
241 default:243 default:
@@ -291,6 +293,9 @@ func encodeServer(message protocol.ServerMessage) (wire.Frame, error) {
291 case protocol.RouteNavigationAction:293 case protocol.RouteNavigationAction:
292 payload, err := wire.MarshalRouteNavigationAction(m)294 payload, err := wire.MarshalRouteNavigationAction(m)
293 return wire.Frame{Type: wire.MsgNavigateRecentRoute, Payload: payload}, err295 return wire.Frame{Type: wire.MsgNavigateRecentRoute, Payload: payload}, err
296+ case protocol.RouteCreateSessionAction:
297+ payload, err := wire.MarshalRouteCreateSessionAction(m)
298+ return wire.Frame{Type: wire.MsgRouteCreateSession, Payload: payload}, err
294 case protocol.RouteNavigationFailure:299 case protocol.RouteNavigationFailure:
295 payload, err := wire.MarshalRouteNavigationFailure(m)300 payload, err := wire.MarshalRouteNavigationFailure(m)
296 return wire.Frame{Type: wire.MsgRouteNavigationFailure, Payload: payload}, err301 return wire.Frame{Type: wire.MsgRouteNavigationFailure, Payload: payload}, err
@@ -354,6 +359,10 @@ func encodeServer(message protocol.ServerMessage) (wire.Frame, error) {
354 if m != nil {359 if m != nil {
355 return encodeServer(*m)360 return encodeServer(*m)
356 }361 }
362+ case *protocol.RouteCreateSessionAction:
363+ if m != nil {
364+ return encodeServer(*m)
365+ }
357 case *protocol.RouteNavigationFailure:366 case *protocol.RouteNavigationFailure:
358 if m != nil {367 if m != nil {
359 return encodeServer(*m)368 return encodeServer(*m)
M internal/adapters/sessionwire/server_test.go +8 -0
@@ -69,6 +69,8 @@ func TestServerConnectionDecodesEveryClientMessage(t *testing.T) {
6969 require.NoError(t, err)
7070 failurePayload, err := wire.MarshalRouteNavigationFailure(protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable})
7171 require.NoError(t, err)
72+ creationFailurePayload, err := wire.MarshalSessionCreationFailure(protocol.SessionCreationFailure{RequestID: 1, Code: protocol.RouteFailureUnavailable})
73+ require.NoError(t, err)
7274
7375 tests := []struct {
7476 name string
@@ -94,6 +96,7 @@ func TestServerConnectionDecodesEveryClientMessage(t *testing.T) {
9496 {name: "parked", frame: wire.Frame{Type: wire.MsgParkedRouteRequest, Payload: wire.MarshalParkedRouteRequest(protocol.ParkedRouteRequest{RequestID: 4, LeaseID: lease, Action: protocol.ParkedRoutePrepare})}, want: protocol.ParkedRouteRequest{RequestID: 4, LeaseID: lease, Action: protocol.ParkedRoutePrepare}},
9597 {name: "snapshot", frame: wire.Frame{Type: wire.MsgRecentRouteSnapshot, Payload: snapshotPayload}, want: protocol.RecentRouteSnapshot{}},
9698 {name: "route failure", frame: wire.Frame{Type: wire.MsgRouteNavigationFailure, Payload: failurePayload}, want: protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable}},
99+ {name: "creation failure", frame: wire.Frame{Type: wire.MsgSessionCreationFailure, Payload: creationFailurePayload}, want: protocol.SessionCreationFailure{RequestID: 1, Code: protocol.RouteFailureUnavailable}},
97100 }
98101 for _, tt := range tests {
99102 t.Run(tt.name, func(t *testing.T) {
@@ -120,6 +123,7 @@ func TestServerConnectionEncodesEveryServerMessage(t *testing.T) {
120123 preview := protocol.RemotePreview{Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewUnavailable}
121124 identity := protocol.CommittedRouteIdentity{Target: exact}
122125 routeAction := protocol.RouteNavigationAction{SnapshotGeneration: 1, Key: 1, Generation: 1}
126+ routeCreate := protocol.RouteCreateSessionAction{RequestID: 1, SnapshotGeneration: 1, Key: 1, Generation: 1, SessionName: "example"}
123127 routeFailure := protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable}
124128 routePosition := protocol.RoutePosition{Target: exact, ActiveTabID: "tab-1"}
125129 switchFailure := protocol.SamePeerSwitchFailure{RequestID: 1, Code: protocol.SamePeerSwitchUnavailable}
@@ -131,6 +135,8 @@ func TestServerConnectionEncodesEveryServerMessage(t *testing.T) {
131135 require.NoError(t, err)
132136 routeActionPayload, err := wire.MarshalRouteNavigationAction(routeAction)
133137 require.NoError(t, err)
138+ routeCreatePayload, err := wire.MarshalRouteCreateSessionAction(routeCreate)
139+ require.NoError(t, err)
134140 routeFailurePayload, err := wire.MarshalRouteNavigationFailure(routeFailure)
135141 require.NoError(t, err)
136142 routePositionPayload, err := wire.MarshalRoutePosition(routePosition)
@@ -156,6 +162,8 @@ func TestServerConnectionEncodesEveryServerMessage(t *testing.T) {
156162 {name: "preview", message: preview, typeID: wire.MsgRemotePreviewResponse, payload: wire.MarshalRemotePreview(preview)},
157163 {name: "identity", message: identity, typeID: wire.MsgCommittedRouteIdentity, payload: identityPayload},
158164 {name: "route action", message: routeAction, typeID: wire.MsgNavigateRecentRoute, payload: routeActionPayload},
165+ {name: "route create", message: routeCreate, typeID: wire.MsgRouteCreateSession, payload: routeCreatePayload},
166+ {name: "route create pointer", message: &routeCreate, typeID: wire.MsgRouteCreateSession, payload: routeCreatePayload},
159167 {name: "route failure", message: routeFailure, typeID: wire.MsgRouteNavigationFailure, payload: routeFailurePayload},
160168 {name: "route position", message: routePosition, typeID: wire.MsgRoutePosition, payload: routePositionPayload},
161169 {name: "switch failure", message: switchFailure, typeID: wire.MsgSamePeerSwitchFailure, payload: switchFailurePayload},
@@ -69,6 +69,8 @@ func TestServerConnectionDecodesEveryClientMessage(t *testing.T) {
69 require.NoError(t, err)69 require.NoError(t, err)
70 failurePayload, err := wire.MarshalRouteNavigationFailure(protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable})70 failurePayload, err := wire.MarshalRouteNavigationFailure(protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable})
71 require.NoError(t, err)71 require.NoError(t, err)
72+ creationFailurePayload, err := wire.MarshalSessionCreationFailure(protocol.SessionCreationFailure{RequestID: 1, Code: protocol.RouteFailureUnavailable})
73+ require.NoError(t, err)
72 74
73 tests := []struct {75 tests := []struct {
74 name string76 name string
@@ -94,6 +96,7 @@ func TestServerConnectionDecodesEveryClientMessage(t *testing.T) {
94 {name: "parked", frame: wire.Frame{Type: wire.MsgParkedRouteRequest, Payload: wire.MarshalParkedRouteRequest(protocol.ParkedRouteRequest{RequestID: 4, LeaseID: lease, Action: protocol.ParkedRoutePrepare})}, want: protocol.ParkedRouteRequest{RequestID: 4, LeaseID: lease, Action: protocol.ParkedRoutePrepare}},96 {name: "parked", frame: wire.Frame{Type: wire.MsgParkedRouteRequest, Payload: wire.MarshalParkedRouteRequest(protocol.ParkedRouteRequest{RequestID: 4, LeaseID: lease, Action: protocol.ParkedRoutePrepare})}, want: protocol.ParkedRouteRequest{RequestID: 4, LeaseID: lease, Action: protocol.ParkedRoutePrepare}},
95 {name: "snapshot", frame: wire.Frame{Type: wire.MsgRecentRouteSnapshot, Payload: snapshotPayload}, want: protocol.RecentRouteSnapshot{}},97 {name: "snapshot", frame: wire.Frame{Type: wire.MsgRecentRouteSnapshot, Payload: snapshotPayload}, want: protocol.RecentRouteSnapshot{}},
96 {name: "route failure", frame: wire.Frame{Type: wire.MsgRouteNavigationFailure, Payload: failurePayload}, want: protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable}},98 {name: "route failure", frame: wire.Frame{Type: wire.MsgRouteNavigationFailure, Payload: failurePayload}, want: protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable}},
99+ {name: "creation failure", frame: wire.Frame{Type: wire.MsgSessionCreationFailure, Payload: creationFailurePayload}, want: protocol.SessionCreationFailure{RequestID: 1, Code: protocol.RouteFailureUnavailable}},
97 }100 }
98 for _, tt := range tests {101 for _, tt := range tests {
99 t.Run(tt.name, func(t *testing.T) {102 t.Run(tt.name, func(t *testing.T) {
@@ -120,6 +123,7 @@ func TestServerConnectionEncodesEveryServerMessage(t *testing.T) {
120 preview := protocol.RemotePreview{Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewUnavailable}123 preview := protocol.RemotePreview{Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewUnavailable}
121 identity := protocol.CommittedRouteIdentity{Target: exact}124 identity := protocol.CommittedRouteIdentity{Target: exact}
122 routeAction := protocol.RouteNavigationAction{SnapshotGeneration: 1, Key: 1, Generation: 1}125 routeAction := protocol.RouteNavigationAction{SnapshotGeneration: 1, Key: 1, Generation: 1}
126+ routeCreate := protocol.RouteCreateSessionAction{RequestID: 1, SnapshotGeneration: 1, Key: 1, Generation: 1, SessionName: "example"}
123 routeFailure := protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable}127 routeFailure := protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable}
124 routePosition := protocol.RoutePosition{Target: exact, ActiveTabID: "tab-1"}128 routePosition := protocol.RoutePosition{Target: exact, ActiveTabID: "tab-1"}
125 switchFailure := protocol.SamePeerSwitchFailure{RequestID: 1, Code: protocol.SamePeerSwitchUnavailable}129 switchFailure := protocol.SamePeerSwitchFailure{RequestID: 1, Code: protocol.SamePeerSwitchUnavailable}
@@ -131,6 +135,8 @@ func TestServerConnectionEncodesEveryServerMessage(t *testing.T) {
131 require.NoError(t, err)135 require.NoError(t, err)
132 routeActionPayload, err := wire.MarshalRouteNavigationAction(routeAction)136 routeActionPayload, err := wire.MarshalRouteNavigationAction(routeAction)
133 require.NoError(t, err)137 require.NoError(t, err)
138+ routeCreatePayload, err := wire.MarshalRouteCreateSessionAction(routeCreate)
139+ require.NoError(t, err)
134 routeFailurePayload, err := wire.MarshalRouteNavigationFailure(routeFailure)140 routeFailurePayload, err := wire.MarshalRouteNavigationFailure(routeFailure)
135 require.NoError(t, err)141 require.NoError(t, err)
136 routePositionPayload, err := wire.MarshalRoutePosition(routePosition)142 routePositionPayload, err := wire.MarshalRoutePosition(routePosition)
@@ -156,6 +162,8 @@ func TestServerConnectionEncodesEveryServerMessage(t *testing.T) {
156 {name: "preview", message: preview, typeID: wire.MsgRemotePreviewResponse, payload: wire.MarshalRemotePreview(preview)},162 {name: "preview", message: preview, typeID: wire.MsgRemotePreviewResponse, payload: wire.MarshalRemotePreview(preview)},
157 {name: "identity", message: identity, typeID: wire.MsgCommittedRouteIdentity, payload: identityPayload},163 {name: "identity", message: identity, typeID: wire.MsgCommittedRouteIdentity, payload: identityPayload},
158 {name: "route action", message: routeAction, typeID: wire.MsgNavigateRecentRoute, payload: routeActionPayload},164 {name: "route action", message: routeAction, typeID: wire.MsgNavigateRecentRoute, payload: routeActionPayload},
165+ {name: "route create", message: routeCreate, typeID: wire.MsgRouteCreateSession, payload: routeCreatePayload},
166+ {name: "route create pointer", message: &routeCreate, typeID: wire.MsgRouteCreateSession, payload: routeCreatePayload},
159 {name: "route failure", message: routeFailure, typeID: wire.MsgRouteNavigationFailure, payload: routeFailurePayload},167 {name: "route failure", message: routeFailure, typeID: wire.MsgRouteNavigationFailure, payload: routeFailurePayload},
160 {name: "route position", message: routePosition, typeID: wire.MsgRoutePosition, payload: routePositionPayload},168 {name: "route position", message: routePosition, typeID: wire.MsgRoutePosition, payload: routePositionPayload},
161 {name: "switch failure", message: switchFailure, typeID: wire.MsgSamePeerSwitchFailure, payload: switchFailurePayload},169 {name: "switch failure", message: switchFailure, typeID: wire.MsgSamePeerSwitchFailure, payload: switchFailurePayload},
M internal/app/run.go +4 -4
@@ -907,8 +907,8 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
907907 }
908908 }
909909 pickerHandoff := remoteTarget == ""
910- pickerEnvironmentPolicy := func(target *domain.RemoteSessionTarget, policy protocol.EnvironmentPolicy) protocol.EnvironmentPolicy {
911- if pickerHandoff && target == nil {
910+ pickerEnvironmentPolicy := func(target *domain.RemoteSessionTarget, intent uint8, policy protocol.EnvironmentPolicy) protocol.EnvironmentPolicy {
911+ if pickerHandoff && target == nil && intent != protocol.IntentNew {
912912 return protocol.EnvironmentPolicyDaemonOwned
913913 }
914914 return policy
@@ -932,7 +932,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
932932 if err != nil {
933933 return nil, client.AttachRequest{}, err
934934 }
935- policy := pickerEnvironmentPolicy(selection, target.EnvironmentPolicy)
935+ policy := pickerEnvironmentPolicy(selection, target.Intent, target.EnvironmentPolicy)
936936 displayOrigin := domain.RemoteDisplayOrigin(target.Endpoint)
937937 if selection != nil {
938938 displayOrigin = selection.DisplayOrigin
@@ -1026,7 +1026,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
10261026 remoteSelection = &copyTarget
10271027 remoteDisplayOrigin = copyTarget.DisplayOrigin
10281028 }
1029- remoteEnvironmentPolicy = pickerEnvironmentPolicy(remoteSelection, handoffErr.Target.EnvironmentPolicy)
1029+ remoteEnvironmentPolicy = pickerEnvironmentPolicy(remoteSelection, handoffErr.Target.Intent, handoffErr.Target.EnvironmentPolicy)
10301030 if modeErr != nil {
10311031 return modeErr
10321032 }
@@ -907,8 +907,8 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
907 }907 }
908 }908 }
909 pickerHandoff := remoteTarget == ""909 pickerHandoff := remoteTarget == ""
910- pickerEnvironmentPolicy := func(target *domain.RemoteSessionTarget, policy protocol.EnvironmentPolicy) protocol.EnvironmentPolicy {910+ pickerEnvironmentPolicy := func(target *domain.RemoteSessionTarget, intent uint8, policy protocol.EnvironmentPolicy) protocol.EnvironmentPolicy {
911- if pickerHandoff && target == nil {911+ if pickerHandoff && target == nil && intent != protocol.IntentNew {
912 return protocol.EnvironmentPolicyDaemonOwned912 return protocol.EnvironmentPolicyDaemonOwned
913 }913 }
914 return policy914 return policy
@@ -932,7 +932,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
932 if err != nil {932 if err != nil {
933 return nil, client.AttachRequest{}, err933 return nil, client.AttachRequest{}, err
934 }934 }
935- policy := pickerEnvironmentPolicy(selection, target.EnvironmentPolicy)935+ policy := pickerEnvironmentPolicy(selection, target.Intent, target.EnvironmentPolicy)
936 displayOrigin := domain.RemoteDisplayOrigin(target.Endpoint)936 displayOrigin := domain.RemoteDisplayOrigin(target.Endpoint)
937 if selection != nil {937 if selection != nil {
938 displayOrigin = selection.DisplayOrigin938 displayOrigin = selection.DisplayOrigin
@@ -1026,7 +1026,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
1026 remoteSelection = &copyTarget1026 remoteSelection = &copyTarget
1027 remoteDisplayOrigin = copyTarget.DisplayOrigin1027 remoteDisplayOrigin = copyTarget.DisplayOrigin
1028 }1028 }
1029- remoteEnvironmentPolicy = pickerEnvironmentPolicy(remoteSelection, handoffErr.Target.EnvironmentPolicy)1029+ remoteEnvironmentPolicy = pickerEnvironmentPolicy(remoteSelection, handoffErr.Target.Intent, handoffErr.Target.EnvironmentPolicy)
1030 if modeErr != nil {1030 if modeErr != nil {
1031 return modeErr1031 return modeErr
1032 }1032 }
M internal/protocol/messages.go +18 -16
@@ -25,20 +25,22 @@ func (SamePeerSwitchRequest) clientMessage() {}
2525 func (ParkedRouteRequest) clientMessage() {}
2626 func (RecentRouteSnapshot) clientMessage() {}
2727 func (RouteNavigationFailure) clientMessage() {}
28+func (SessionCreationFailure) clientMessage() {}
2829
29-func (Welcome) serverMessage() {}
30-func (ErrorMsg) serverMessage() {}
31-func (Output) serverMessage() {}
32-func (Detached) serverMessage() {}
33-func (Pong) serverMessage() {}
34-func (Sessions) serverMessage() {}
35-func (CommandResult) serverMessage() {}
36-func (NavigationDirective) serverMessage() {}
37-func (AttachTarget) serverMessage() {}
38-func (RemotePreview) serverMessage() {}
39-func (CommittedRouteIdentity) serverMessage() {}
40-func (RouteNavigationAction) serverMessage() {}
41-func (RouteNavigationFailure) serverMessage() {}
42-func (RoutePosition) serverMessage() {}
43-func (SamePeerSwitchFailure) serverMessage() {}
44-func (ParkedRouteResponse) serverMessage() {}
30+func (Welcome) serverMessage() {}
31+func (ErrorMsg) serverMessage() {}
32+func (Output) serverMessage() {}
33+func (Detached) serverMessage() {}
34+func (Pong) serverMessage() {}
35+func (Sessions) serverMessage() {}
36+func (CommandResult) serverMessage() {}
37+func (NavigationDirective) serverMessage() {}
38+func (AttachTarget) serverMessage() {}
39+func (RemotePreview) serverMessage() {}
40+func (CommittedRouteIdentity) serverMessage() {}
41+func (RouteNavigationAction) serverMessage() {}
42+func (RouteCreateSessionAction) serverMessage() {}
43+func (RouteNavigationFailure) serverMessage() {}
44+func (RoutePosition) serverMessage() {}
45+func (SamePeerSwitchFailure) serverMessage() {}
46+func (ParkedRouteResponse) serverMessage() {}
@@ -25,20 +25,22 @@ func (SamePeerSwitchRequest) clientMessage() {}
25 func (ParkedRouteRequest) clientMessage() {}25 func (ParkedRouteRequest) clientMessage() {}
26 func (RecentRouteSnapshot) clientMessage() {}26 func (RecentRouteSnapshot) clientMessage() {}
27 func (RouteNavigationFailure) clientMessage() {}27 func (RouteNavigationFailure) clientMessage() {}
28+func (SessionCreationFailure) clientMessage() {}
28 29
29-func (Welcome) serverMessage() {}30+func (Welcome) serverMessage() {}
30-func (ErrorMsg) serverMessage() {}31+func (ErrorMsg) serverMessage() {}
31-func (Output) serverMessage() {}32+func (Output) serverMessage() {}
32-func (Detached) serverMessage() {}33+func (Detached) serverMessage() {}
33-func (Pong) serverMessage() {}34+func (Pong) serverMessage() {}
34-func (Sessions) serverMessage() {}35+func (Sessions) serverMessage() {}
35-func (CommandResult) serverMessage() {}36+func (CommandResult) serverMessage() {}
36-func (NavigationDirective) serverMessage() {}37+func (NavigationDirective) serverMessage() {}
37-func (AttachTarget) serverMessage() {}38+func (AttachTarget) serverMessage() {}
38-func (RemotePreview) serverMessage() {}39+func (RemotePreview) serverMessage() {}
39-func (CommittedRouteIdentity) serverMessage() {}40+func (CommittedRouteIdentity) serverMessage() {}
40-func (RouteNavigationAction) serverMessage() {}41+func (RouteNavigationAction) serverMessage() {}
41-func (RouteNavigationFailure) serverMessage() {}42+func (RouteCreateSessionAction) serverMessage() {}
42-func (RoutePosition) serverMessage() {}43+func (RouteNavigationFailure) serverMessage() {}
43-func (SamePeerSwitchFailure) serverMessage() {}44+func (RoutePosition) serverMessage() {}
44-func (ParkedRouteResponse) serverMessage() {}45+func (SamePeerSwitchFailure) serverMessage() {}
46+func (ParkedRouteResponse) serverMessage() {}
M internal/protocol/navigation.go +3 -0
@@ -87,6 +87,9 @@ func ValidateNavigation(capabilities NavigationCapabilities, overlay StartupOver
8787 }
8888
8989 func validateHelloNavigation(h Hello) error {
90+ if h.Intent == IntentNew {
91+ return ValidateNavigation(h.NavigationCapabilities, h.StartupOverlay, true)
92+ }
9093 if h.Intent != IntentAttach && h.Intent != IntentResume {
9194 if h.NavigationCapabilities != 0 || h.StartupOverlay != StartupOverlayNone {
9295 return ErrInvalidNavigation
@@ -87,6 +87,9 @@ func ValidateNavigation(capabilities NavigationCapabilities, overlay StartupOver
87 }87 }
88 88
89 func validateHelloNavigation(h Hello) error {89 func validateHelloNavigation(h Hello) error {
90+ if h.Intent == IntentNew {
91+ return ValidateNavigation(h.NavigationCapabilities, h.StartupOverlay, true)
92+ }
90 if h.Intent != IntentAttach && h.Intent != IntentResume {93 if h.Intent != IntentAttach && h.Intent != IntentResume {
91 if h.NavigationCapabilities != 0 || h.StartupOverlay != StartupOverlayNone {94 if h.NavigationCapabilities != 0 || h.StartupOverlay != StartupOverlayNone {
92 return ErrInvalidNavigation95 return ErrInvalidNavigation
M internal/protocol/routes.go +31 -0
@@ -234,6 +234,23 @@ type RouteNavigationAction struct {
234234 Generation uint64
235235 }
236236
237+// RouteCreateSessionAction asks the client to create a named session through
238+// one exact route authority from the latest complete snapshot.
239+type RouteCreateSessionAction struct {
240+ RequestID uint64
241+ SnapshotGeneration uint64
242+ Key uint64
243+ Generation uint64
244+ SessionName string
245+}
246+
247+func (a RouteCreateSessionAction) Validate() error {
248+ if a.RequestID == 0 || a.SnapshotGeneration == 0 || a.Key == 0 || a.Generation == 0 || domain.ValidateSessionName(a.SessionName) != nil {
249+ return ErrInvalidRouteWire
250+ }
251+ return nil
252+}
253+
237254 // SamePeerSwitchRequest confirms a daemon-offered endpoint-empty target. It
238255 // carries only the exact lifecycle identity and the client-owned tab cursor;
239256 // transport origin remains proven by the existing authenticated connection.
@@ -325,6 +342,20 @@ func (c RouteFailureCode) Validate() error {
325342 return nil
326343 }
327344
345+// SessionCreationFailure reports a correlated pre-commit create transition
346+// failure after the client has restored the source route.
347+type SessionCreationFailure struct {
348+ RequestID uint64
349+ Code RouteFailureCode
350+}
351+
352+func (f SessionCreationFailure) Validate() error {
353+ if f.RequestID == 0 || !f.Code.valid() {
354+ return ErrInvalidRouteWire
355+ }
356+ return nil
357+}
358+
328359 const (
329360 // RouteSnapshotMaxEntries bounds one immutable publication and the amount
330361 // of work a receiver performs before returning to its transport loop. The
@@ -234,6 +234,23 @@ type RouteNavigationAction struct {
234 Generation uint64234 Generation uint64
235 }235 }
236 236
237+// RouteCreateSessionAction asks the client to create a named session through
238+// one exact route authority from the latest complete snapshot.
239+type RouteCreateSessionAction struct {
240+ RequestID uint64
241+ SnapshotGeneration uint64
242+ Key uint64
243+ Generation uint64
244+ SessionName string
245+}
246+
247+func (a RouteCreateSessionAction) Validate() error {
248+ if a.RequestID == 0 || a.SnapshotGeneration == 0 || a.Key == 0 || a.Generation == 0 || domain.ValidateSessionName(a.SessionName) != nil {
249+ return ErrInvalidRouteWire
250+ }
251+ return nil
252+}
253+
237 // SamePeerSwitchRequest confirms a daemon-offered endpoint-empty target. It254 // SamePeerSwitchRequest confirms a daemon-offered endpoint-empty target. It
238 // carries only the exact lifecycle identity and the client-owned tab cursor;255 // carries only the exact lifecycle identity and the client-owned tab cursor;
239 // transport origin remains proven by the existing authenticated connection.256 // transport origin remains proven by the existing authenticated connection.
@@ -325,6 +342,20 @@ func (c RouteFailureCode) Validate() error {
325 return nil342 return nil
326 }343 }
327 344
345+// SessionCreationFailure reports a correlated pre-commit create transition
346+// failure after the client has restored the source route.
347+type SessionCreationFailure struct {
348+ RequestID uint64
349+ Code RouteFailureCode
350+}
351+
352+func (f SessionCreationFailure) Validate() error {
353+ if f.RequestID == 0 || !f.Code.valid() {
354+ return ErrInvalidRouteWire
355+ }
356+ return nil
357+}
358+
328 const (359 const (
329 // RouteSnapshotMaxEntries bounds one immutable publication and the amount360 // RouteSnapshotMaxEntries bounds one immutable publication and the amount
330 // of work a receiver performs before returning to its transport loop. The361 // of work a receiver performs before returning to its transport loop. The
M internal/protocol/session.go +5 -1
@@ -13,7 +13,7 @@ import (
1313 )
1414
1515 // Version is the negotiated vev session protocol version.
16-const Version uint16 = 37
16+const Version uint16 = 38
1717
1818 // HandshakeTimeout bounds every transport handshake from connect through the
1919 // first committed publication.
@@ -197,6 +197,7 @@ type Output struct {
197197 }
198198
199199 type AttachTarget struct {
200+ RequestID uint64
200201 Endpoint string
201202 Session string
202203 Intent uint8
@@ -375,6 +376,9 @@ func ValidateAttachTarget(m AttachTarget) error {
375376 if m.Intent == IntentResume || !validEnvironmentPolicy(m.EnvironmentPolicy) {
376377 return ErrInvalidAttachTarget
377378 }
379+ if m.Intent == IntentNew && m.Endpoint != "" && m.RequestID == 0 || m.RequestID != 0 && m.Intent != IntentNew {
380+ return ErrInvalidAttachTarget
381+ }
378382 if m.ExactTarget != nil && (m.ExactTarget.SessionName != m.Session || m.ExactTarget.Validate() != nil) {
379383 return ErrInvalidAttachTarget
380384 }
@@ -13,7 +13,7 @@ import (
13 )13 )
14 14
15 // Version is the negotiated vev session protocol version.15 // Version is the negotiated vev session protocol version.
16-const Version uint16 = 3716+const Version uint16 = 38
17 17
18 // HandshakeTimeout bounds every transport handshake from connect through the18 // HandshakeTimeout bounds every transport handshake from connect through the
19 // first committed publication.19 // first committed publication.
@@ -197,6 +197,7 @@ type Output struct {
197 }197 }
198 198
199 type AttachTarget struct {199 type AttachTarget struct {
200+ RequestID uint64
200 Endpoint string201 Endpoint string
201 Session string202 Session string
202 Intent uint8203 Intent uint8
@@ -375,6 +376,9 @@ func ValidateAttachTarget(m AttachTarget) error {
375 if m.Intent == IntentResume || !validEnvironmentPolicy(m.EnvironmentPolicy) {376 if m.Intent == IntentResume || !validEnvironmentPolicy(m.EnvironmentPolicy) {
376 return ErrInvalidAttachTarget377 return ErrInvalidAttachTarget
377 }378 }
379+ if m.Intent == IntentNew && m.Endpoint != "" && m.RequestID == 0 || m.RequestID != 0 && m.Intent != IntentNew {
380+ return ErrInvalidAttachTarget
381+ }
378 if m.ExactTarget != nil && (m.ExactTarget.SessionName != m.Session || m.ExactTarget.Validate() != nil) {382 if m.ExactTarget != nil && (m.ExactTarget.SessionName != m.Session || m.ExactTarget.Validate() != nil) {
379 return ErrInvalidAttachTarget383 return ErrInvalidAttachTarget
380 }384 }
M internal/protocol/session_test.go +1 -1
@@ -10,7 +10,7 @@ import (
1010 )
1111
1212 func TestVersionRemainsWireCompatible(t *testing.T) {
13- require.Equal(t, uint16(37), Version)
13+ require.Equal(t, uint16(38), Version)
1414 require.Equal(t, (16<<20)-55, MaxOutputDataLen)
1515 }
1616
@@ -10,7 +10,7 @@ import (
10 )10 )
11 11
12 func TestVersionRemainsWireCompatible(t *testing.T) {12 func TestVersionRemainsWireCompatible(t *testing.T) {
13- require.Equal(t, uint16(37), Version)13+ require.Equal(t, uint16(38), Version)
14 require.Equal(t, (16<<20)-55, MaxOutputDataLen)14 require.Equal(t, (16<<20)-55, MaxOutputDataLen)
15 }15 }
16 16
M internal/protocol/wire/frame.go +4 -2
@@ -8,8 +8,8 @@ const MaxFrameLen = 16 << 20
88 type MsgType uint8
99
1010 // Frame message types use non-contiguous allocations: the legacy client range
11-// is 1–13 and 15, and newer client controls use 32–33 and 35. Server-originated
12-// messages occupy 16–23, 25–31, 34, and 36. Values 14 and 24 remain reserved for
11+// is 1–13 and 15, and newer client controls use 32–33, 35, and 37. Server-originated
12+// messages occupy 16–23, 25–31, 34, 36, and 38. Values 14 and 24 remain reserved for
1313 // future extensions.
1414 const (
1515 MsgHello MsgType = 1
@@ -29,6 +29,7 @@ const (
2929 MsgRouteAttentionSubscription MsgType = 32
3030 MsgSamePeerSwitchRequest MsgType = 33
3131 MsgParkedRouteRequest MsgType = 35
32+ MsgSessionCreationFailure MsgType = 37
3233
3334 MsgWelcome MsgType = 16
3435 MsgError MsgType = 17
@@ -47,6 +48,7 @@ const (
4748 MsgRoutePosition MsgType = 31
4849 MsgSamePeerSwitchFailure MsgType = 34
4950 MsgParkedRouteResponse MsgType = 36
51+ MsgRouteCreateSession MsgType = 38
5052 )
5153
5254 // Frame is the unit of exchange over a Transport: a typed, length-delimited
@@ -8,8 +8,8 @@ const MaxFrameLen = 16 << 20
8 type MsgType uint88 type MsgType uint8
9 9
10 // Frame message types use non-contiguous allocations: the legacy client range10 // Frame message types use non-contiguous allocations: the legacy client range
11-// is 1–13 and 15, and newer client controls use 32–33 and 35. Server-originated11+// is 1–13 and 15, and newer client controls use 32–33, 35, and 37. Server-originated
12-// messages occupy 16–23, 25–31, 34, and 36. Values 14 and 24 remain reserved for12+// messages occupy 16–23, 25–31, 34, 36, and 38. Values 14 and 24 remain reserved for
13 // future extensions.13 // future extensions.
14 const (14 const (
15 MsgHello MsgType = 115 MsgHello MsgType = 1
@@ -29,6 +29,7 @@ const (
29 MsgRouteAttentionSubscription MsgType = 3229 MsgRouteAttentionSubscription MsgType = 32
30 MsgSamePeerSwitchRequest MsgType = 3330 MsgSamePeerSwitchRequest MsgType = 33
31 MsgParkedRouteRequest MsgType = 3531 MsgParkedRouteRequest MsgType = 35
32+ MsgSessionCreationFailure MsgType = 37
32 33
33 MsgWelcome MsgType = 1634 MsgWelcome MsgType = 16
34 MsgError MsgType = 1735 MsgError MsgType = 17
@@ -47,6 +48,7 @@ const (
47 MsgRoutePosition MsgType = 3148 MsgRoutePosition MsgType = 31
48 MsgSamePeerSwitchFailure MsgType = 3449 MsgSamePeerSwitchFailure MsgType = 34
49 MsgParkedRouteResponse MsgType = 3650 MsgParkedRouteResponse MsgType = 36
51+ MsgRouteCreateSession MsgType = 38
50 )52 )
51 53
52 // Frame is the unit of exchange over a Transport: a typed, length-delimited54 // Frame is the unit of exchange over a Transport: a typed, length-delimited
M internal/protocol/wire/frame_test.go +4 -4
@@ -7,8 +7,8 @@ import (
77 )
88
99 func TestProtocolVersion(t *testing.T) {
10- if protocol.Version != 37 {
11- t.Fatalf("ProtocolVersion = %d, want 37", protocol.Version)
10+ if protocol.Version != 38 {
11+ t.Fatalf("ProtocolVersion = %d, want 38", protocol.Version)
1212 }
1313 }
1414
@@ -37,14 +37,14 @@ func TestMsgTypeUnique(t *testing.T) {
3737 {"MsgKill", MsgKill}, {"MsgTheme", MsgTheme}, {"MsgAck", MsgAck},
3838 {"MsgImagePush", MsgImagePush}, {"MsgClientNotice", MsgClientNotice},
3939 {"MsgCommand", MsgCommand}, {"MsgOutputResetRequest", MsgOutputResetRequest}, {"MsgRemotePreviewRequest", MsgRemotePreviewRequest},
40- {"MsgRouteAttentionSubscription", MsgRouteAttentionSubscription}, {"MsgSamePeerSwitchRequest", MsgSamePeerSwitchRequest}, {"MsgParkedRouteRequest", MsgParkedRouteRequest},
40+ {"MsgRouteAttentionSubscription", MsgRouteAttentionSubscription}, {"MsgSamePeerSwitchRequest", MsgSamePeerSwitchRequest}, {"MsgParkedRouteRequest", MsgParkedRouteRequest}, {"MsgSessionCreationFailure", MsgSessionCreationFailure},
4141 {"MsgWelcome", MsgWelcome}, {"MsgError", MsgError}, {"MsgOutput", MsgOutput},
4242 {"MsgDetached", MsgDetached}, {"MsgPong", MsgPong}, {"MsgSessions", MsgSessions},
4343 {"MsgCommandResult", MsgCommandResult},
4444 {"MsgRemotePreviewResponse", MsgRemotePreviewResponse}, {"MsgAttachTarget", MsgAttachTarget}, {"MsgNavigationAction", MsgNavigationAction},
4545 {"MsgCommittedRouteIdentity", MsgCommittedRouteIdentity}, {"MsgRecentRouteSnapshot", MsgRecentRouteSnapshot},
4646 {"MsgNavigateRecentRoute", MsgNavigateRecentRoute}, {"MsgRouteNavigationFailure", MsgRouteNavigationFailure},
47- {"MsgRoutePosition", MsgRoutePosition}, {"MsgSamePeerSwitchFailure", MsgSamePeerSwitchFailure}, {"MsgParkedRouteResponse", MsgParkedRouteResponse},
47+ {"MsgRoutePosition", MsgRoutePosition}, {"MsgSamePeerSwitchFailure", MsgSamePeerSwitchFailure}, {"MsgParkedRouteResponse", MsgParkedRouteResponse}, {"MsgRouteCreateSession", MsgRouteCreateSession},
4848 }
4949
5050 seen := make(map[MsgType]string, len(tests))
@@ -7,8 +7,8 @@ import (
7 )7 )
8 8
9 func TestProtocolVersion(t *testing.T) {9 func TestProtocolVersion(t *testing.T) {
10- if protocol.Version != 37 {10+ if protocol.Version != 38 {
11- t.Fatalf("ProtocolVersion = %d, want 37", protocol.Version)11+ t.Fatalf("ProtocolVersion = %d, want 38", protocol.Version)
12 }12 }
13 }13 }
14 14
@@ -37,14 +37,14 @@ func TestMsgTypeUnique(t *testing.T) {
37 {"MsgKill", MsgKill}, {"MsgTheme", MsgTheme}, {"MsgAck", MsgAck},37 {"MsgKill", MsgKill}, {"MsgTheme", MsgTheme}, {"MsgAck", MsgAck},
38 {"MsgImagePush", MsgImagePush}, {"MsgClientNotice", MsgClientNotice},38 {"MsgImagePush", MsgImagePush}, {"MsgClientNotice", MsgClientNotice},
39 {"MsgCommand", MsgCommand}, {"MsgOutputResetRequest", MsgOutputResetRequest}, {"MsgRemotePreviewRequest", MsgRemotePreviewRequest},39 {"MsgCommand", MsgCommand}, {"MsgOutputResetRequest", MsgOutputResetRequest}, {"MsgRemotePreviewRequest", MsgRemotePreviewRequest},
40- {"MsgRouteAttentionSubscription", MsgRouteAttentionSubscription}, {"MsgSamePeerSwitchRequest", MsgSamePeerSwitchRequest}, {"MsgParkedRouteRequest", MsgParkedRouteRequest},40+ {"MsgRouteAttentionSubscription", MsgRouteAttentionSubscription}, {"MsgSamePeerSwitchRequest", MsgSamePeerSwitchRequest}, {"MsgParkedRouteRequest", MsgParkedRouteRequest}, {"MsgSessionCreationFailure", MsgSessionCreationFailure},
41 {"MsgWelcome", MsgWelcome}, {"MsgError", MsgError}, {"MsgOutput", MsgOutput},41 {"MsgWelcome", MsgWelcome}, {"MsgError", MsgError}, {"MsgOutput", MsgOutput},
42 {"MsgDetached", MsgDetached}, {"MsgPong", MsgPong}, {"MsgSessions", MsgSessions},42 {"MsgDetached", MsgDetached}, {"MsgPong", MsgPong}, {"MsgSessions", MsgSessions},
43 {"MsgCommandResult", MsgCommandResult},43 {"MsgCommandResult", MsgCommandResult},
44 {"MsgRemotePreviewResponse", MsgRemotePreviewResponse}, {"MsgAttachTarget", MsgAttachTarget}, {"MsgNavigationAction", MsgNavigationAction},44 {"MsgRemotePreviewResponse", MsgRemotePreviewResponse}, {"MsgAttachTarget", MsgAttachTarget}, {"MsgNavigationAction", MsgNavigationAction},
45 {"MsgCommittedRouteIdentity", MsgCommittedRouteIdentity}, {"MsgRecentRouteSnapshot", MsgRecentRouteSnapshot},45 {"MsgCommittedRouteIdentity", MsgCommittedRouteIdentity}, {"MsgRecentRouteSnapshot", MsgRecentRouteSnapshot},
46 {"MsgNavigateRecentRoute", MsgNavigateRecentRoute}, {"MsgRouteNavigationFailure", MsgRouteNavigationFailure},46 {"MsgNavigateRecentRoute", MsgNavigateRecentRoute}, {"MsgRouteNavigationFailure", MsgRouteNavigationFailure},
47- {"MsgRoutePosition", MsgRoutePosition}, {"MsgSamePeerSwitchFailure", MsgSamePeerSwitchFailure}, {"MsgParkedRouteResponse", MsgParkedRouteResponse},47+ {"MsgRoutePosition", MsgRoutePosition}, {"MsgSamePeerSwitchFailure", MsgSamePeerSwitchFailure}, {"MsgParkedRouteResponse", MsgParkedRouteResponse}, {"MsgRouteCreateSession", MsgRouteCreateSession},
48 }48 }
49 49
50 seen := make(map[MsgType]string, len(tests))50 seen := make(map[MsgType]string, len(tests))
M internal/protocol/wire/navigation_test.go +3 -2
@@ -153,8 +153,9 @@ func TestHelloNavigationValidationTable(t *testing.T) {
153153 {name: "client-owned home picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityHomePicker}, valid: false},
154154 {name: "daemon-owned back picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, valid: false},
155155 {name: "remote-target back picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, valid: false},
156- {name: "back with startup picker", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: true, capability: protocol.NavigationCapabilityBack, overlay: protocol.StartupOverlaySessionPicker, wantPayload: "002502" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "00000000" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "02010000"},
157- {name: "new rejects navigation", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: false},
156+ {name: "back with startup picker", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: true, capability: protocol.NavigationCapabilityBack, overlay: protocol.StartupOverlaySessionPicker, wantPayload: "002602" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "00000000" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "02010000"},
157+ {name: "new accepts retained home picker", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityHomePicker}, valid: true, capability: protocol.NavigationCapabilityHomePicker},
158+ {name: "new rejects back navigation", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: false},
158159 {name: "unknown capability", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false},
159160 {name: "back without picker", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack}, valid: false},
160161 {name: "picker without back", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: false},
@@ -153,8 +153,9 @@ func TestHelloNavigationValidationTable(t *testing.T) {
153 {name: "client-owned home picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityHomePicker}, valid: false},153 {name: "client-owned home picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityHomePicker}, valid: false},
154 {name: "daemon-owned back picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, valid: false},154 {name: "daemon-owned back picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, valid: false},
155 {name: "remote-target back picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, valid: false},155 {name: "remote-target back picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, valid: false},
156- {name: "back with startup picker", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: true, capability: protocol.NavigationCapabilityBack, overlay: protocol.StartupOverlaySessionPicker, wantPayload: "002502" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "00000000" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "02010000"},156+ {name: "back with startup picker", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: true, capability: protocol.NavigationCapabilityBack, overlay: protocol.StartupOverlaySessionPicker, wantPayload: "002602" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "00000000" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "02010000"},
157- {name: "new rejects navigation", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: false},157+ {name: "new accepts retained home picker", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityHomePicker}, valid: true, capability: protocol.NavigationCapabilityHomePicker},
158+ {name: "new rejects back navigation", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: false},
158 {name: "unknown capability", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false},159 {name: "unknown capability", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false},
159 {name: "back without picker", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack}, valid: false},160 {name: "back without picker", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack}, valid: false},
160 {name: "picker without back", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: false},161 {name: "picker without back", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: false},
M internal/protocol/wire/remote_preview_hardening_test.go +1 -0
@@ -194,6 +194,7 @@ func TestRemoteTargetWireRichGoldenRoundTrip(t *testing.T) {
194194 RemoteTarget: target, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
195195 })
196196 want := []byte{
197+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
197198 0x00, 0x0f, 'b', 'u', 'i', 'l', 'd', '@', 'm', 'u', 'l', 'e', ':', '2', '2', '2', '2',
198199 0x00, 0x04, 'w', 'o', 'r', 'k', protocol.IntentAttach,
199200 0x01,
@@ -194,6 +194,7 @@ func TestRemoteTargetWireRichGoldenRoundTrip(t *testing.T) {
194 RemoteTarget: target, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,194 RemoteTarget: target, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
195 })195 })
196 want := []byte{196 want := []byte{
197+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
197 0x00, 0x0f, 'b', 'u', 'i', 'l', 'd', '@', 'm', 'u', 'l', 'e', ':', '2', '2', '2', '2',198 0x00, 0x0f, 'b', 'u', 'i', 'l', 'd', '@', 'm', 'u', 'l', 'e', ':', '2', '2', '2', '2',
198 0x00, 0x04, 'w', 'o', 'r', 'k', protocol.IntentAttach,199 0x00, 0x04, 'w', 'o', 'r', 'k', protocol.IntentAttach,
199 0x01,200 0x01,
M internal/protocol/wire/remote_target_wire_test.go +1 -1
@@ -196,7 +196,7 @@ func TestAttachTargetRejectsResumeIntent(t *testing.T) {
196196 }
197197
198198 func TestTargetWireIncludesRequiredV27Section(t *testing.T) {
199- want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 0, 0}
199+ want := []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 0, 0}
200200 got := MarshalAttachTarget(protocol.AttachTarget{Endpoint: "host", Session: "work", Intent: protocol.IntentAttach})
201201 if !bytes.Equal(got, want) {
202202 t.Fatalf("target bytes = %x, want %x", got, want)
@@ -196,7 +196,7 @@ func TestAttachTargetRejectsResumeIntent(t *testing.T) {
196 }196 }
197 197
198 func TestTargetWireIncludesRequiredV27Section(t *testing.T) {198 func TestTargetWireIncludesRequiredV27Section(t *testing.T) {
199- want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 0, 0}199+ want := []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 0, 0}
200 got := MarshalAttachTarget(protocol.AttachTarget{Endpoint: "host", Session: "work", Intent: protocol.IntentAttach})200 got := MarshalAttachTarget(protocol.AttachTarget{Endpoint: "host", Session: "work", Intent: protocol.IntentAttach})
201 if !bytes.Equal(got, want) {201 if !bytes.Equal(got, want) {
202 t.Fatalf("target bytes = %x, want %x", got, want)202 t.Fatalf("target bytes = %x, want %x", got, want)
M internal/protocol/wire/routes_test.go +52 -0
@@ -244,6 +244,58 @@ func TestRouteLabelsRejectTerminalUnsafeText(t *testing.T) {
244244 }
245245 }
246246
247+func TestRouteCreateSessionCodecsAreStrict(t *testing.T) {
248+ action := protocol.RouteCreateSessionAction{RequestID: 5, SnapshotGeneration: 9, Key: 7, Generation: 8, SessionName: "example"}
249+ failure := protocol.SessionCreationFailure{RequestID: 5, Code: protocol.RouteFailureUnavailable}
250+ tests := []struct {
251+ name string
252+ golden string
253+ want any
254+ marshal func() ([]byte, error)
255+ unmarshal func([]byte) (any, error)
256+ }{
257+ {
258+ name: "create action", golden: "000000000000000500000000000000090000000000000007000000000000000800076578616d706c65", want: action,
259+ marshal: func() ([]byte, error) { return MarshalRouteCreateSessionAction(action) },
260+ unmarshal: func(data []byte) (any, error) {
261+ return UnmarshalRouteCreateSessionAction(data)
262+ },
263+ },
264+ {
265+ name: "creation failure", golden: "000000000000000503", want: failure,
266+ marshal: func() ([]byte, error) { return MarshalSessionCreationFailure(failure) },
267+ unmarshal: func(data []byte) (any, error) {
268+ return UnmarshalSessionCreationFailure(data)
269+ },
270+ },
271+ }
272+ for _, tt := range tests {
273+ t.Run(tt.name, func(t *testing.T) {
274+ encoded, err := tt.marshal()
275+ require.NoError(t, err)
276+ require.Equal(t, tt.golden, hex.EncodeToString(encoded))
277+ for i := range encoded {
278+ _, err := tt.unmarshal(encoded[:i])
279+ require.Error(t, err, "prefix length %d", i)
280+ }
281+ _, err = tt.unmarshal(append(append([]byte(nil), encoded...), 0))
282+ require.Error(t, err)
283+ got, err := tt.unmarshal(encoded)
284+ require.NoError(t, err)
285+ require.Equal(t, tt.want, got)
286+ })
287+ }
288+
289+ for _, invalid := range []protocol.RouteCreateSessionAction{
290+ {},
291+ {RequestID: 1, SnapshotGeneration: 1, Key: 1, Generation: 1},
292+ {RequestID: 1, SnapshotGeneration: 1, Key: 1, Generation: 1, SessionName: "bad name"},
293+ } {
294+ _, err := MarshalRouteCreateSessionAction(invalid)
295+ require.Error(t, err)
296+ }
297+}
298+
247299 func TestRouteActionAndFailureCodecsAreBounded(t *testing.T) {
248300 action := protocol.RouteNavigationAction{SnapshotGeneration: 9, Key: 7, Generation: 8}
249301 encoded, err := MarshalRouteNavigationAction(action)
@@ -244,6 +244,58 @@ func TestRouteLabelsRejectTerminalUnsafeText(t *testing.T) {
244 }244 }
245 }245 }
246 246
247+func TestRouteCreateSessionCodecsAreStrict(t *testing.T) {
248+ action := protocol.RouteCreateSessionAction{RequestID: 5, SnapshotGeneration: 9, Key: 7, Generation: 8, SessionName: "example"}
249+ failure := protocol.SessionCreationFailure{RequestID: 5, Code: protocol.RouteFailureUnavailable}
250+ tests := []struct {
251+ name string
252+ golden string
253+ want any
254+ marshal func() ([]byte, error)
255+ unmarshal func([]byte) (any, error)
256+ }{
257+ {
258+ name: "create action", golden: "000000000000000500000000000000090000000000000007000000000000000800076578616d706c65", want: action,
259+ marshal: func() ([]byte, error) { return MarshalRouteCreateSessionAction(action) },
260+ unmarshal: func(data []byte) (any, error) {
261+ return UnmarshalRouteCreateSessionAction(data)
262+ },
263+ },
264+ {
265+ name: "creation failure", golden: "000000000000000503", want: failure,
266+ marshal: func() ([]byte, error) { return MarshalSessionCreationFailure(failure) },
267+ unmarshal: func(data []byte) (any, error) {
268+ return UnmarshalSessionCreationFailure(data)
269+ },
270+ },
271+ }
272+ for _, tt := range tests {
273+ t.Run(tt.name, func(t *testing.T) {
274+ encoded, err := tt.marshal()
275+ require.NoError(t, err)
276+ require.Equal(t, tt.golden, hex.EncodeToString(encoded))
277+ for i := range encoded {
278+ _, err := tt.unmarshal(encoded[:i])
279+ require.Error(t, err, "prefix length %d", i)
280+ }
281+ _, err = tt.unmarshal(append(append([]byte(nil), encoded...), 0))
282+ require.Error(t, err)
283+ got, err := tt.unmarshal(encoded)
284+ require.NoError(t, err)
285+ require.Equal(t, tt.want, got)
286+ })
287+ }
288+
289+ for _, invalid := range []protocol.RouteCreateSessionAction{
290+ {},
291+ {RequestID: 1, SnapshotGeneration: 1, Key: 1, Generation: 1},
292+ {RequestID: 1, SnapshotGeneration: 1, Key: 1, Generation: 1, SessionName: "bad name"},
293+ } {
294+ _, err := MarshalRouteCreateSessionAction(invalid)
295+ require.Error(t, err)
296+ }
297+}
298+
247 func TestRouteActionAndFailureCodecsAreBounded(t *testing.T) {299 func TestRouteActionAndFailureCodecsAreBounded(t *testing.T) {
248 action := protocol.RouteNavigationAction{SnapshotGeneration: 9, Key: 7, Generation: 8}300 action := protocol.RouteNavigationAction{SnapshotGeneration: 9, Key: 7, Generation: 8}
249 encoded, err := MarshalRouteNavigationAction(action)301 encoded, err := MarshalRouteNavigationAction(action)
M internal/protocol/wire/routes_wire.go +72 -0
@@ -631,6 +631,47 @@ func UnmarshalRouteNavigationAction(b []byte) (protocol.RouteNavigationAction, e
631631 return action, nil
632632 }
633633
634+func MarshalRouteCreateSessionAction(action protocol.RouteCreateSessionAction) ([]byte, error) {
635+ if err := action.Validate(); err != nil {
636+ return nil, err
637+ }
638+ w := payloadWriter{}
639+ w.putUint64(action.RequestID)
640+ w.putUint64(action.SnapshotGeneration)
641+ w.putUint64(action.Key)
642+ w.putUint64(action.Generation)
643+ w.putString(action.SessionName)
644+ return w.b, nil
645+}
646+
647+func UnmarshalRouteCreateSessionAction(b []byte) (protocol.RouteCreateSessionAction, error) {
648+ r := payloadReader{b: b}
649+ var action protocol.RouteCreateSessionAction
650+ var err error
651+ if action.RequestID, err = r.getUint64(); err != nil {
652+ return protocol.RouteCreateSessionAction{}, err
653+ }
654+ if action.SnapshotGeneration, err = r.getUint64(); err != nil {
655+ return protocol.RouteCreateSessionAction{}, err
656+ }
657+ if action.Key, err = r.getUint64(); err != nil {
658+ return protocol.RouteCreateSessionAction{}, err
659+ }
660+ if action.Generation, err = r.getUint64(); err != nil {
661+ return protocol.RouteCreateSessionAction{}, err
662+ }
663+ if action.SessionName, err = r.getString(); err != nil {
664+ return protocol.RouteCreateSessionAction{}, err
665+ }
666+ if err := r.done(); err != nil {
667+ return protocol.RouteCreateSessionAction{}, err
668+ }
669+ if err := action.Validate(); err != nil {
670+ return protocol.RouteCreateSessionAction{}, err
671+ }
672+ return action, nil
673+}
674+
634675 func validateRouteNavigationFailure(failure protocol.RouteNavigationFailure) error {
635676 if failure.Key == 0 || failure.Generation == 0 {
636677 return fmt.Errorf("%w: failure identity is zero", protocol.ErrInvalidRouteWire)
@@ -652,6 +693,37 @@ func MarshalRouteNavigationFailure(failure protocol.RouteNavigationFailure) ([]b
652693 return w.b, nil
653694 }
654695
696+func MarshalSessionCreationFailure(failure protocol.SessionCreationFailure) ([]byte, error) {
697+ if err := failure.Validate(); err != nil {
698+ return nil, err
699+ }
700+ w := payloadWriter{}
701+ w.putUint64(failure.RequestID)
702+ w.putUint8(uint8(failure.Code))
703+ return w.b, nil
704+}
705+
706+func UnmarshalSessionCreationFailure(b []byte) (protocol.SessionCreationFailure, error) {
707+ r := payloadReader{b: b}
708+ var failure protocol.SessionCreationFailure
709+ var err error
710+ if failure.RequestID, err = r.getUint64(); err != nil {
711+ return protocol.SessionCreationFailure{}, err
712+ }
713+ code, err := r.getUint8()
714+ if err != nil {
715+ return protocol.SessionCreationFailure{}, err
716+ }
717+ failure.Code = protocol.RouteFailureCode(code)
718+ if err := r.done(); err != nil {
719+ return protocol.SessionCreationFailure{}, err
720+ }
721+ if err := failure.Validate(); err != nil {
722+ return protocol.SessionCreationFailure{}, err
723+ }
724+ return failure, nil
725+}
726+
655727 func UnmarshalRouteNavigationFailure(b []byte) (protocol.RouteNavigationFailure, error) {
656728 r := payloadReader{b: b}
657729 var failure protocol.RouteNavigationFailure
@@ -631,6 +631,47 @@ func UnmarshalRouteNavigationAction(b []byte) (protocol.RouteNavigationAction, e
631 return action, nil631 return action, nil
632 }632 }
633 633
634+func MarshalRouteCreateSessionAction(action protocol.RouteCreateSessionAction) ([]byte, error) {
635+ if err := action.Validate(); err != nil {
636+ return nil, err
637+ }
638+ w := payloadWriter{}
639+ w.putUint64(action.RequestID)
640+ w.putUint64(action.SnapshotGeneration)
641+ w.putUint64(action.Key)
642+ w.putUint64(action.Generation)
643+ w.putString(action.SessionName)
644+ return w.b, nil
645+}
646+
647+func UnmarshalRouteCreateSessionAction(b []byte) (protocol.RouteCreateSessionAction, error) {
648+ r := payloadReader{b: b}
649+ var action protocol.RouteCreateSessionAction
650+ var err error
651+ if action.RequestID, err = r.getUint64(); err != nil {
652+ return protocol.RouteCreateSessionAction{}, err
653+ }
654+ if action.SnapshotGeneration, err = r.getUint64(); err != nil {
655+ return protocol.RouteCreateSessionAction{}, err
656+ }
657+ if action.Key, err = r.getUint64(); err != nil {
658+ return protocol.RouteCreateSessionAction{}, err
659+ }
660+ if action.Generation, err = r.getUint64(); err != nil {
661+ return protocol.RouteCreateSessionAction{}, err
662+ }
663+ if action.SessionName, err = r.getString(); err != nil {
664+ return protocol.RouteCreateSessionAction{}, err
665+ }
666+ if err := r.done(); err != nil {
667+ return protocol.RouteCreateSessionAction{}, err
668+ }
669+ if err := action.Validate(); err != nil {
670+ return protocol.RouteCreateSessionAction{}, err
671+ }
672+ return action, nil
673+}
674+
634 func validateRouteNavigationFailure(failure protocol.RouteNavigationFailure) error {675 func validateRouteNavigationFailure(failure protocol.RouteNavigationFailure) error {
635 if failure.Key == 0 || failure.Generation == 0 {676 if failure.Key == 0 || failure.Generation == 0 {
636 return fmt.Errorf("%w: failure identity is zero", protocol.ErrInvalidRouteWire)677 return fmt.Errorf("%w: failure identity is zero", protocol.ErrInvalidRouteWire)
@@ -652,6 +693,37 @@ func MarshalRouteNavigationFailure(failure protocol.RouteNavigationFailure) ([]b
652 return w.b, nil693 return w.b, nil
653 }694 }
654 695
696+func MarshalSessionCreationFailure(failure protocol.SessionCreationFailure) ([]byte, error) {
697+ if err := failure.Validate(); err != nil {
698+ return nil, err
699+ }
700+ w := payloadWriter{}
701+ w.putUint64(failure.RequestID)
702+ w.putUint8(uint8(failure.Code))
703+ return w.b, nil
704+}
705+
706+func UnmarshalSessionCreationFailure(b []byte) (protocol.SessionCreationFailure, error) {
707+ r := payloadReader{b: b}
708+ var failure protocol.SessionCreationFailure
709+ var err error
710+ if failure.RequestID, err = r.getUint64(); err != nil {
711+ return protocol.SessionCreationFailure{}, err
712+ }
713+ code, err := r.getUint8()
714+ if err != nil {
715+ return protocol.SessionCreationFailure{}, err
716+ }
717+ failure.Code = protocol.RouteFailureCode(code)
718+ if err := r.done(); err != nil {
719+ return protocol.SessionCreationFailure{}, err
720+ }
721+ if err := failure.Validate(); err != nil {
722+ return protocol.SessionCreationFailure{}, err
723+ }
724+ return failure, nil
725+}
726+
655 func UnmarshalRouteNavigationFailure(b []byte) (protocol.RouteNavigationFailure, error) {727 func UnmarshalRouteNavigationFailure(b []byte) (protocol.RouteNavigationFailure, error) {
656 r := payloadReader{b: b}728 r := payloadReader{b: b}
657 var failure protocol.RouteNavigationFailure729 var failure protocol.RouteNavigationFailure
M internal/protocol/wire/wire.go +7 -0
@@ -1200,6 +1200,7 @@ func MarshalAttachTarget(m protocol.AttachTarget) []byte {
12001200 return nil
12011201 }
12021202 w := payloadWriter{}
1203+ w.putUint64(m.RequestID)
12031204 w.putString(m.Endpoint)
12041205 w.putString(m.Session)
12051206 w.putUint8(m.Intent)
@@ -1213,6 +1214,9 @@ func MarshalAttachTarget(m protocol.AttachTarget) []byte {
12131214 func UnmarshalAttachTarget(b []byte) (protocol.AttachTarget, error) {
12141215 // Preflight lengths and intent before getString can allocate either value.
12151216 probe := payloadReader{b: b}
1217+ if _, err := probe.getUint64(); err != nil {
1218+ return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget
1219+ }
12161220 endpointLen, err := probe.getUint16()
12171221 if err != nil || int(endpointLen) > len(probe.b) {
12181222 return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget
@@ -1242,6 +1246,9 @@ func UnmarshalAttachTarget(b []byte) (protocol.AttachTarget, error) {
12421246
12431247 r := payloadReader{b: b}
12441248 var m protocol.AttachTarget
1249+ if m.RequestID, err = r.getUint64(); err != nil {
1250+ return protocol.AttachTarget{}, err
1251+ }
12451252 if m.Endpoint, err = r.getString(); err != nil {
12461253 return protocol.AttachTarget{}, err
12471254 }
@@ -1200,6 +1200,7 @@ func MarshalAttachTarget(m protocol.AttachTarget) []byte {
1200 return nil1200 return nil
1201 }1201 }
1202 w := payloadWriter{}1202 w := payloadWriter{}
1203+ w.putUint64(m.RequestID)
1203 w.putString(m.Endpoint)1204 w.putString(m.Endpoint)
1204 w.putString(m.Session)1205 w.putString(m.Session)
1205 w.putUint8(m.Intent)1206 w.putUint8(m.Intent)
@@ -1213,6 +1214,9 @@ func MarshalAttachTarget(m protocol.AttachTarget) []byte {
1213 func UnmarshalAttachTarget(b []byte) (protocol.AttachTarget, error) {1214 func UnmarshalAttachTarget(b []byte) (protocol.AttachTarget, error) {
1214 // Preflight lengths and intent before getString can allocate either value.1215 // Preflight lengths and intent before getString can allocate either value.
1215 probe := payloadReader{b: b}1216 probe := payloadReader{b: b}
1217+ if _, err := probe.getUint64(); err != nil {
1218+ return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget
1219+ }
1216 endpointLen, err := probe.getUint16()1220 endpointLen, err := probe.getUint16()
1217 if err != nil || int(endpointLen) > len(probe.b) {1221 if err != nil || int(endpointLen) > len(probe.b) {
1218 return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget1222 return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget
@@ -1242,6 +1246,9 @@ func UnmarshalAttachTarget(b []byte) (protocol.AttachTarget, error) {
1242 1246
1243 r := payloadReader{b: b}1247 r := payloadReader{b: b}
1244 var m protocol.AttachTarget1248 var m protocol.AttachTarget
1249+ if m.RequestID, err = r.getUint64(); err != nil {
1250+ return protocol.AttachTarget{}, err
1251+ }
1245 if m.Endpoint, err = r.getString(); err != nil {1252 if m.Endpoint, err = r.getString(); err != nil {
1246 return protocol.AttachTarget{}, err1253 return protocol.AttachTarget{}, err
1247 }1254 }
M internal/protocol/wire/wire_final_test.go +20 -7
@@ -12,8 +12,8 @@ import (
1212 )
1313
1414 func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
15- if protocol.Version != 37 {
16- t.Fatalf("ProtocolVersion = %d, want 37", protocol.Version)
15+ if protocol.Version != 38 {
16+ t.Fatalf("ProtocolVersion = %d, want 38", protocol.Version)
1717 }
1818 payload := MarshalHello(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}})
1919 if len(payload) < 2 {
@@ -27,7 +27,7 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
2727
2828 func TestFinalHelloGoldenStrict(t *testing.T) {
2929 msg := protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}
30- want := append([]byte{0, 37, 2}, make([]byte, 16+8)...)
30+ want := append([]byte{0, 38, 2}, make([]byte, 16+8)...)
3131 want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
3232 want = append(want, 0, 0)
3333 got := MarshalHello(msg)
@@ -196,7 +196,7 @@ func TestFinalAckGoldenStrict(t *testing.T) {
196196
197197 func TestFinalAttachTargetGoldenStrict(t *testing.T) {
198198 msg := protocol.AttachTarget{Endpoint: "host", Session: "work", Intent: protocol.IntentAttach}
199- want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', 2, 0, 0, 0, 0}
199+ want := []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', 2, 0, 0, 0, 0}
200200 got := MarshalAttachTarget(msg)
201201 if !bytes.Equal(got, want) {
202202 t.Fatalf("AttachTarget bytes = %x, want %x", got, want)
@@ -212,7 +212,7 @@ func TestFinalAttachTargetGoldenStrict(t *testing.T) {
212212 if localPayload == nil {
213213 t.Fatal("MarshalAttachTarget rejected same-peer route handoff")
214214 }
215- wantLocal := []byte{0, 0, 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 0, 0}
215+ wantLocal := []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 0, 0}
216216 if !bytes.Equal(localPayload, wantLocal) {
217217 t.Fatalf("same-peer bytes = %x, want %x", localPayload, wantLocal)
218218 }
@@ -222,7 +222,19 @@ func TestFinalAttachTargetGoldenStrict(t *testing.T) {
222222 }
223223 assertAllPrefixesFail(t, localPayload, UnmarshalAttachTarget)
224224 assertTrailingGarbageFails(t, localPayload, UnmarshalAttachTarget)
225- for _, bad := range []protocol.AttachTarget{{Endpoint: "host", Intent: protocol.IntentAttach}, {Endpoint: "host", Session: "work", Intent: 99}} {
225+ creation := protocol.AttachTarget{RequestID: 7, Endpoint: "host", Session: "example", Intent: protocol.IntentNew, EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned}
226+ creationPayload := MarshalAttachTarget(creation)
227+ if creationPayload == nil {
228+ t.Fatal("MarshalAttachTarget rejected correlated remote creation")
229+ }
230+ decodedCreation, err := UnmarshalAttachTarget(creationPayload)
231+ if err != nil || decodedCreation != creation {
232+ t.Fatalf("creation target = %+v, error %v, want %+v", decodedCreation, err, creation)
233+ }
234+ assertAllPrefixesFail(t, creationPayload, UnmarshalAttachTarget)
235+ assertTrailingGarbageFails(t, creationPayload, UnmarshalAttachTarget)
236+
237+ for _, bad := range []protocol.AttachTarget{{Endpoint: "host", Intent: protocol.IntentAttach}, {Endpoint: "host", Session: "work", Intent: 99}, {Endpoint: "host", Session: "example", Intent: protocol.IntentNew}} {
226238 if got := MarshalAttachTarget(bad); got != nil {
227239 t.Fatalf("MarshalAttachTarget(%+v) = %x, want nil", bad, got)
228240 }
@@ -234,7 +246,8 @@ func TestAttachTargetExactTargetWireStrict(t *testing.T) {
234246 lifecycle[0] = 1
235247 target := protocol.AttachTarget{Session: "work", Intent: protocol.IntentAttach, ExactTarget: &protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "work"}, SamePeer: true}
236248 payload := MarshalAttachTarget(target)
237- want := append([]byte{0, 0, 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 1, 1}, make([]byte, 15)...)
249+ want := append(make([]byte, 8), []byte{0, 0, 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 1, 1}...)
250+ want = append(want, make([]byte, 15)...)
238251 want = append(want, 0, 4, 'w', 'o', 'r', 'k', 1)
239252 if !bytes.Equal(payload, want) {
240253 t.Fatalf("exact target bytes = %x, want %x", payload, want)
@@ -12,8 +12,8 @@ import (
12 )12 )
13 13
14 func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {14 func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
15- if protocol.Version != 37 {15+ if protocol.Version != 38 {
16- t.Fatalf("ProtocolVersion = %d, want 37", protocol.Version)16+ t.Fatalf("ProtocolVersion = %d, want 38", protocol.Version)
17 }17 }
18 payload := MarshalHello(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}})18 payload := MarshalHello(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}})
19 if len(payload) < 2 {19 if len(payload) < 2 {
@@ -27,7 +27,7 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
27 27
28 func TestFinalHelloGoldenStrict(t *testing.T) {28 func TestFinalHelloGoldenStrict(t *testing.T) {
29 msg := protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}29 msg := protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}
30- want := append([]byte{0, 37, 2}, make([]byte, 16+8)...)30+ want := append([]byte{0, 38, 2}, make([]byte, 16+8)...)
31 want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)31 want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
32 want = append(want, 0, 0)32 want = append(want, 0, 0)
33 got := MarshalHello(msg)33 got := MarshalHello(msg)
@@ -196,7 +196,7 @@ func TestFinalAckGoldenStrict(t *testing.T) {
196 196
197 func TestFinalAttachTargetGoldenStrict(t *testing.T) {197 func TestFinalAttachTargetGoldenStrict(t *testing.T) {
198 msg := protocol.AttachTarget{Endpoint: "host", Session: "work", Intent: protocol.IntentAttach}198 msg := protocol.AttachTarget{Endpoint: "host", Session: "work", Intent: protocol.IntentAttach}
199- want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', 2, 0, 0, 0, 0}199+ want := []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', 2, 0, 0, 0, 0}
200 got := MarshalAttachTarget(msg)200 got := MarshalAttachTarget(msg)
201 if !bytes.Equal(got, want) {201 if !bytes.Equal(got, want) {
202 t.Fatalf("AttachTarget bytes = %x, want %x", got, want)202 t.Fatalf("AttachTarget bytes = %x, want %x", got, want)
@@ -212,7 +212,7 @@ func TestFinalAttachTargetGoldenStrict(t *testing.T) {
212 if localPayload == nil {212 if localPayload == nil {
213 t.Fatal("MarshalAttachTarget rejected same-peer route handoff")213 t.Fatal("MarshalAttachTarget rejected same-peer route handoff")
214 }214 }
215- wantLocal := []byte{0, 0, 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 0, 0}215+ wantLocal := []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 0, 0}
216 if !bytes.Equal(localPayload, wantLocal) {216 if !bytes.Equal(localPayload, wantLocal) {
217 t.Fatalf("same-peer bytes = %x, want %x", localPayload, wantLocal)217 t.Fatalf("same-peer bytes = %x, want %x", localPayload, wantLocal)
218 }218 }
@@ -222,7 +222,19 @@ func TestFinalAttachTargetGoldenStrict(t *testing.T) {
222 }222 }
223 assertAllPrefixesFail(t, localPayload, UnmarshalAttachTarget)223 assertAllPrefixesFail(t, localPayload, UnmarshalAttachTarget)
224 assertTrailingGarbageFails(t, localPayload, UnmarshalAttachTarget)224 assertTrailingGarbageFails(t, localPayload, UnmarshalAttachTarget)
225- for _, bad := range []protocol.AttachTarget{{Endpoint: "host", Intent: protocol.IntentAttach}, {Endpoint: "host", Session: "work", Intent: 99}} {225+ creation := protocol.AttachTarget{RequestID: 7, Endpoint: "host", Session: "example", Intent: protocol.IntentNew, EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned}
226+ creationPayload := MarshalAttachTarget(creation)
227+ if creationPayload == nil {
228+ t.Fatal("MarshalAttachTarget rejected correlated remote creation")
229+ }
230+ decodedCreation, err := UnmarshalAttachTarget(creationPayload)
231+ if err != nil || decodedCreation != creation {
232+ t.Fatalf("creation target = %+v, error %v, want %+v", decodedCreation, err, creation)
233+ }
234+ assertAllPrefixesFail(t, creationPayload, UnmarshalAttachTarget)
235+ assertTrailingGarbageFails(t, creationPayload, UnmarshalAttachTarget)
236+
237+ for _, bad := range []protocol.AttachTarget{{Endpoint: "host", Intent: protocol.IntentAttach}, {Endpoint: "host", Session: "work", Intent: 99}, {Endpoint: "host", Session: "example", Intent: protocol.IntentNew}} {
226 if got := MarshalAttachTarget(bad); got != nil {238 if got := MarshalAttachTarget(bad); got != nil {
227 t.Fatalf("MarshalAttachTarget(%+v) = %x, want nil", bad, got)239 t.Fatalf("MarshalAttachTarget(%+v) = %x, want nil", bad, got)
228 }240 }
@@ -234,7 +246,8 @@ func TestAttachTargetExactTargetWireStrict(t *testing.T) {
234 lifecycle[0] = 1246 lifecycle[0] = 1
235 target := protocol.AttachTarget{Session: "work", Intent: protocol.IntentAttach, ExactTarget: &protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "work"}, SamePeer: true}247 target := protocol.AttachTarget{Session: "work", Intent: protocol.IntentAttach, ExactTarget: &protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "work"}, SamePeer: true}
236 payload := MarshalAttachTarget(target)248 payload := MarshalAttachTarget(target)
237- want := append([]byte{0, 0, 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 1, 1}, make([]byte, 15)...)249+ want := append(make([]byte, 8), []byte{0, 0, 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 1, 1}...)
250+ want = append(want, make([]byte, 15)...)
238 want = append(want, 0, 4, 'w', 'o', 'r', 'k', 1)251 want = append(want, 0, 4, 'w', 'o', 'r', 'k', 1)
239 if !bytes.Equal(payload, want) {252 if !bytes.Equal(payload, want) {
240 t.Fatalf("exact target bytes = %x, want %x", payload, want)253 t.Fatalf("exact target bytes = %x, want %x", payload, want)
M internal/protocol/wire/wire_test.go +6 -6
@@ -136,7 +136,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
136136 }
137137 got := MarshalHello(msg)
138138 want := []byte{
139- 0x00, 0x25, 0x00, // version, intent, first client ID byte
139+ 0x00, 0x26, 0x00, // version, intent, first client ID byte
140140 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
141141 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
142142 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
@@ -367,7 +367,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T)
367367 }
368368 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
369369 require.Equal(t, want, MarshalTheme(cleared))
370- require.Equal(t, uint16(37), protocol.Version)
370+ require.Equal(t, uint16(38), protocol.Version)
371371 }
372372
373373 func TestResizeGoldenAndRoundTrip(t *testing.T) {
@@ -528,7 +528,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
528528 name: "minimal",
529529 msg: protocol.CommandRequest{Version: protocol.Version, Slug: "split-right"},
530530 want: []byte{
531- 0x00, 0x25,
531+ 0x00, 0x26,
532532 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
533533 0x00, // attached
534534 0x00, // self
@@ -551,7 +551,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
551551 JSON: true,
552552 },
553553 want: []byte{
554- 0x00, 0x25,
554+ 0x00, 0x26,
555555 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
556556 0x00, // attached
557557 0x01, // self
@@ -988,7 +988,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
988988 name: "attached",
989989 msg: protocol.CommandRequest{Version: protocol.Version, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},
990990 want: []byte{
991- 0x00, 0x25, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
991+ 0x00, 0x26, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
992992 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
993993 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',
994994 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -998,7 +998,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
998998 name: "control",
999999 msg: protocol.CommandRequest{Version: protocol.Version, Slug: "ls"},
10001000 want: []byte{
1001- 0x00, 0x25, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
1001+ 0x00, 0x26, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
10021002 0x00, 0x02, 'l', 's', 0x00, 0x00,
10031003 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
10041004 },
@@ -136,7 +136,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
136 }136 }
137 got := MarshalHello(msg)137 got := MarshalHello(msg)
138 want := []byte{138 want := []byte{
139- 0x00, 0x25, 0x00, // version, intent, first client ID byte139+ 0x00, 0x26, 0x00, // version, intent, first client ID byte
140 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,140 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
141 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID141 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
142 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token142 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
@@ -367,7 +367,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T)
367 }367 }
368 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)368 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
369 require.Equal(t, want, MarshalTheme(cleared))369 require.Equal(t, want, MarshalTheme(cleared))
370- require.Equal(t, uint16(37), protocol.Version)370+ require.Equal(t, uint16(38), protocol.Version)
371 }371 }
372 372
373 func TestResizeGoldenAndRoundTrip(t *testing.T) {373 func TestResizeGoldenAndRoundTrip(t *testing.T) {
@@ -528,7 +528,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
528 name: "minimal",528 name: "minimal",
529 msg: protocol.CommandRequest{Version: protocol.Version, Slug: "split-right"},529 msg: protocol.CommandRequest{Version: protocol.Version, Slug: "split-right"},
530 want: []byte{530 want: []byte{
531- 0x00, 0x25,531+ 0x00, 0x26,
532 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID532 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
533 0x00, // attached533 0x00, // attached
534 0x00, // self534 0x00, // self
@@ -551,7 +551,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
551 JSON: true,551 JSON: true,
552 },552 },
553 want: []byte{553 want: []byte{
554- 0x00, 0x25,554+ 0x00, 0x26,
555 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID555 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
556 0x00, // attached556 0x00, // attached
557 0x01, // self557 0x01, // self
@@ -988,7 +988,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
988 name: "attached",988 name: "attached",
989 msg: protocol.CommandRequest{Version: protocol.Version, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},989 msg: protocol.CommandRequest{Version: protocol.Version, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},
990 want: []byte{990 want: []byte{
991- 0x00, 0x25, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,991+ 0x00, 0x26, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
992 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',992 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
993 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',993 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',
994 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,994 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -998,7 +998,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
998 name: "control",998 name: "control",
999 msg: protocol.CommandRequest{Version: protocol.Version, Slug: "ls"},999 msg: protocol.CommandRequest{Version: protocol.Version, Slug: "ls"},
1000 want: []byte{1000 want: []byte{
1001- 0x00, 0x25, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,1001+ 0x00, 0x26, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
1002 0x00, 0x02, 'l', 's', 0x00, 0x00,1002 0x00, 0x02, 'l', 's', 0x00, 0x00,
1003 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,1003 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
1004 },1004 },
M internal/usecase/client/client.go +156 -3
@@ -229,6 +229,7 @@ type Runner struct {
229229 origin protocol.RouteOrigin
230230 ledger *routeLedger
231231 routeFailure *protocol.RouteNavigationFailure
232+ creationFailure *protocol.SessionCreationFailure
232233
233234 inputMu sync.Mutex
234235 input *terminalInputPump
@@ -393,7 +394,8 @@ func validateAttachRequest(request AttachRequest) error {
393394 if err := (SessionTarget{Intent: request.Intent, SessionName: request.SessionName}).validate(); err != nil {
394395 return fmt.Errorf("vev: invalid session target: %w", err)
395396 }
396- if err := protocol.ValidateNavigation(request.NavigationCapabilities, request.StartupOverlay, request.RemoteTarget != nil || request.EnvironmentPolicy == protocol.EnvironmentPolicyDaemonOwned); err != nil {
397+ homePickerRoute := request.RemoteTarget != nil || request.EnvironmentPolicy == protocol.EnvironmentPolicyDaemonOwned || request.Intent == protocol.IntentNew
398+ if err := protocol.ValidateNavigation(request.NavigationCapabilities, request.StartupOverlay, homePickerRoute); err != nil {
397399 return fmt.Errorf("vev: invalid navigation route: %w", err)
398400 }
399401 if request.RemoteTarget == nil {
@@ -468,6 +470,9 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
468470 var routeNavigationSelection *routeNavigationSelection
469471 var routeNavigationFallback *attachRoute
470472 var routeNavigationAction *protocol.RouteNavigationAction
473+ creationPending := false
474+ var creationFallback *attachRoute
475+ var creationRequestID uint64
471476 backoff := defaultReconnectBackoff.initial
472477 themeState := &terminalThemeState{}
473478 var rememberOnce sync.Once
@@ -628,6 +633,16 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
628633 if err != nil {
629634 err = handshakeContextError(ctx, timedOut, err)
630635 finishHandshake()
636+ if creationPending && creationFallback != nil && ctx.Err() == nil {
637+ r.creationFailure = &protocol.SessionCreationFailure{RequestID: creationRequestID, Code: routeFailureCode(err)}
638+ route := *creationFallback
639+ creationFallback = nil
640+ creationPending = false
641+ creationRequestID = 0
642+ returnNavigationPending = true
643+ restoreReturnRoute(route)
644+ continue
645+ }
631646 if resumeToken == 0 || ctx.Err() != nil {
632647 if clearErr := reconnect.clear(); clearErr != nil {
633648 return errors.Join(err, clearErr)
@@ -655,7 +670,18 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
655670 ms.dialed = true
656671 if transport == nil {
657672 finishHandshake()
658- return errors.New("vev: dialer returned nil transport")
673+ err := errors.New("vev: dialer returned nil transport")
674+ if creationPending && creationFallback != nil {
675+ r.creationFailure = &protocol.SessionCreationFailure{RequestID: creationRequestID, Code: protocol.RouteFailureUnavailable}
676+ route := *creationFallback
677+ creationFallback = nil
678+ creationPending = false
679+ creationRequestID = 0
680+ returnNavigationPending = true
681+ restoreReturnRoute(route)
682+ continue
683+ }
684+ return err
659685 }
660686 stopHandshakeTransport := watchHandshakeTransport(handshakeCtx, transport)
661687 connection, err := NewSessionConnection(transport, SessionTarget{
@@ -666,6 +692,16 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
666692 stopHandshakeTransport()
667693 finishHandshake()
668694 _ = transport.Close()
695+ if creationPending && creationFallback != nil {
696+ r.creationFailure = &protocol.SessionCreationFailure{RequestID: creationRequestID, Code: protocol.RouteFailureUnavailable}
697+ route := *creationFallback
698+ creationFallback = nil
699+ creationPending = false
700+ creationRequestID = 0
701+ returnNavigationPending = true
702+ restoreReturnRoute(route)
703+ continue
704+ }
669705 return err
670706 }
671707
@@ -731,6 +767,11 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
731767 attemptRequest.PreferredTabID = result.routePosition.ActiveTabID
732768 attemptRequest.RemoteTarget = nil
733769 }
770+ if result.err == nil && result.welcomed && creationPending {
771+ creationPending = false
772+ creationFallback = nil
773+ creationRequestID = 0
774+ }
734775 if result.err == nil && result.welcomed && routeNavigationPending {
735776 routeNavigationPending = false
736777 routeNavigationSelection = nil
@@ -738,6 +779,40 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
738779 routeNavigationFallback = nil
739780 routeNavigationAction = nil
740781 }
782+ if result.routeCreateAction != nil {
783+ action := *result.routeCreateAction
784+ source := attachRoute{dialer: dialer, request: cloneAttachRequest(attemptRequest), resumeToken: resumeToken}
785+ if r.ledger == nil {
786+ r.creationFailure = &protocol.SessionCreationFailure{RequestID: action.RequestID, Code: protocol.RouteFailureUnavailable}
787+ returnNavigationPending = true
788+ restoreReturnRoute(source)
789+ continue
790+ }
791+ selection, valid := r.ledger.creationSelection(action)
792+ if !valid {
793+ r.creationFailure = &protocol.SessionCreationFailure{RequestID: action.RequestID, Code: protocol.RouteFailureStaleSelection}
794+ returnNavigationPending = true
795+ restoreReturnRoute(source)
796+ continue
797+ }
798+ creationFallback = &attachRoute{dialer: selection.prior.dialer, request: cloneAttachRequest(selection.prior.request), resumeToken: selection.prior.resumeToken}
799+ dialer = selection.selected.dialer
800+ attemptRequest = cloneAttachRequest(selection.selected.request)
801+ attemptRequest.Intent = protocol.IntentNew
802+ attemptRequest.SessionName = action.SessionName
803+ attemptRequest.ExactTarget = nil
804+ attemptRequest.RemoteTarget = nil
805+ attemptRequest.PreferredTabID = ""
806+ attemptRequest.StartupOverlay = protocol.StartupOverlayNone
807+ attemptRequest.NavigationCapabilities = 0
808+ attemptRequest.EnvironmentPolicy = protocol.EnvironmentPolicyClientOwned
809+ resumeToken = 0
810+ creationPending = true
811+ creationRequestID = action.RequestID
812+ remote = syncReconnectRemote(reconnect, attemptRequest.Remote || r.remote)
813+ backoff = defaultReconnectBackoff.initial
814+ continue
815+ }
741816 if result.routeAction != nil {
742817 if r.ledger == nil {
743818 return errors.New("vev: route ledger unavailable")
@@ -846,13 +921,19 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
846921 }
847922 nextDialer, nextRequest, handoffErr = r.attachHandoff(*target)
848923 if handoffErr != nil {
924+ if target.Intent == protocol.IntentNew && target.RequestID != 0 {
925+ r.creationFailure = &protocol.SessionCreationFailure{RequestID: target.RequestID, Code: routeFailureCode(handoffErr)}
926+ returnNavigationPending = true
927+ restoreReturnRoute(source)
928+ continue
929+ }
849930 return handoffErr
850931 }
851932 }
852933 if target.ExactTarget != nil {
853934 nextRequest.ExactTarget = target.ExactTarget
854935 }
855- if homeRoute != nil && (nextRequest.RemoteTarget != nil || nextRequest.EnvironmentPolicy == protocol.EnvironmentPolicyDaemonOwned) {
936+ if homeRoute != nil && (nextRequest.RemoteTarget != nil || nextRequest.EnvironmentPolicy == protocol.EnvironmentPolicyDaemonOwned || target.Intent == protocol.IntentNew) {
856937 nextRequest.NavigationCapabilities |= protocol.NavigationCapabilityHomePicker
857938 }
858939 if attemptRequest.StartupOverlay == protocol.StartupOverlaySessionPicker {
@@ -861,11 +942,29 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
861942 }
862943 nextRequest = cloneAttachRequest(nextRequest)
863944 if err := validateAttachRequest(nextRequest); err != nil {
945+ if target.Intent == protocol.IntentNew && target.RequestID != 0 {
946+ r.creationFailure = &protocol.SessionCreationFailure{RequestID: target.RequestID, Code: protocol.RouteFailureUnavailable}
947+ returnNavigationPending = true
948+ restoreReturnRoute(source)
949+ continue
950+ }
864951 return fmt.Errorf("vev: invalid route handoff request: %w", err)
865952 }
866953 if nextDialer == nil {
954+ if target.Intent == protocol.IntentNew && target.RequestID != 0 {
955+ r.creationFailure = &protocol.SessionCreationFailure{RequestID: target.RequestID, Code: protocol.RouteFailureUnavailable}
956+ returnNavigationPending = true
957+ restoreReturnRoute(source)
958+ continue
959+ }
867960 return errors.New("vev: route handoff returned nil dialer")
868961 }
962+ if target.Intent == protocol.IntentNew && target.RequestID != 0 {
963+ fallback := source
964+ creationFallback = &fallback
965+ creationPending = true
966+ creationRequestID = target.RequestID
967+ }
869968 dialer = nextDialer
870969 attemptRequest = nextRequest
871970 remote = syncReconnectRemote(reconnect, attemptRequest.Remote || r.remote)
@@ -894,6 +993,16 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
894993 restoreReturnRoute(route)
895994 continue
896995 }
996+ if creationPending && creationFallback != nil {
997+ r.creationFailure = &protocol.SessionCreationFailure{RequestID: creationRequestID, Code: routeFailureCode(result.err)}
998+ route := *creationFallback
999+ creationFallback = nil
1000+ creationPending = false
1001+ creationRequestID = 0
1002+ returnNavigationPending = true
1003+ restoreReturnRoute(route)
1004+ continue
1005+ }
8971006 if routeNavigationPending && routeNavigationResumeFallback && resumeNeedsExactAttach(result.err) {
8981007 attemptRequest.Intent = protocol.IntentAttach
8991008 resumeToken = 0
@@ -1121,6 +1230,7 @@ type attachResult struct {
11211230 handoff *attachHandoff
11221231 action protocol.NavigationAction
11231232 routeAction *protocol.RouteNavigationAction
1233+ routeCreateAction *protocol.RouteCreateSessionAction
11241234 err error
11251235 }
11261236
@@ -1325,6 +1435,9 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
13251435 if request.ExactTarget != nil && *request.ExactTarget != committedIdentity.Target {
13261436 return welcomedResult(errRouteTargetChanged)
13271437 }
1438+ if request.Intent == protocol.IntentNew && request.SessionName != committedIdentity.Target.SessionName {
1439+ return welcomedResult(errRouteTargetChanged)
1440+ }
13281441 if request.RemoteTarget != nil && (request.RemoteTarget.LifecycleID != committedIdentity.Target.LifecycleID || request.RemoteTarget.SessionName != committedIdentity.Target.SessionName) {
13291442 return welcomedResult(errRouteTargetChanged)
13301443 }
@@ -1352,6 +1465,14 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
13521465 return welcomedResult(fmt.Errorf("vev: publishing route snapshot: %w", err))
13531466 }
13541467 }
1468+ if a.runner.creationFailure != nil {
1469+ if err := sendHandshake(func() error {
1470+ return transport.SendClient(*a.runner.creationFailure)
1471+ }); err != nil {
1472+ return welcomedResult(fmt.Errorf("vev: publishing session creation failure: %w", err))
1473+ }
1474+ a.runner.creationFailure = nil
1475+ }
13551476 if a.runner.routeFailure != nil {
13561477 if err := sendHandshake(func() error {
13571478 return transport.SendClient(*a.runner.routeFailure)
@@ -1777,6 +1898,18 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
17771898 return context.Canceled
17781899 }
17791900 }
1901+ sendCreationFailure := func(requestID uint64, code protocol.RouteFailureCode) error {
1902+ failure := protocol.SessionCreationFailure{RequestID: requestID, Code: code}
1903+ if failure.Validate() != nil {
1904+ return protocol.ErrInvalidRouteWire
1905+ }
1906+ select {
1907+ case sendCh <- failure:
1908+ return nil
1909+ case <-loopCtx.Done():
1910+ return context.Canceled
1911+ }
1912+ }
17801913 dismissReconnect := func() error {
17811914 if !reconnect.showing {
17821915 return nil
@@ -2038,6 +2171,26 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
20382171 navigation := welcomedResult(nil)
20392172 navigation.action = directive.Action
20402173 return navigation
2174+ case protocol.RouteCreateSessionAction:
2175+ action := message
2176+ if action.Validate() != nil {
2177+ continue
2178+ }
2179+ if a.runner.ledger == nil {
2180+ if ferr := sendCreationFailure(action.RequestID, protocol.RouteFailureUnavailable); ferr != nil {
2181+ return welcomedResult(ferr)
2182+ }
2183+ continue
2184+ }
2185+ if _, valid := a.runner.ledger.creationSelection(action); !valid {
2186+ if ferr := sendCreationFailure(action.RequestID, protocol.RouteFailureStaleSelection); ferr != nil {
2187+ return welcomedResult(ferr)
2188+ }
2189+ continue
2190+ }
2191+ result := welcomedResult(nil)
2192+ result.routeCreateAction = &action
2193+ return result
20412194 case protocol.RouteNavigationAction:
20422195 action := message
20432196 if a.runner.ledger == nil {
@@ -229,6 +229,7 @@ type Runner struct {
229 origin protocol.RouteOrigin229 origin protocol.RouteOrigin
230 ledger *routeLedger230 ledger *routeLedger
231 routeFailure *protocol.RouteNavigationFailure231 routeFailure *protocol.RouteNavigationFailure
232+ creationFailure *protocol.SessionCreationFailure
232 233
233 inputMu sync.Mutex234 inputMu sync.Mutex
234 input *terminalInputPump235 input *terminalInputPump
@@ -393,7 +394,8 @@ func validateAttachRequest(request AttachRequest) error {
393 if err := (SessionTarget{Intent: request.Intent, SessionName: request.SessionName}).validate(); err != nil {394 if err := (SessionTarget{Intent: request.Intent, SessionName: request.SessionName}).validate(); err != nil {
394 return fmt.Errorf("vev: invalid session target: %w", err)395 return fmt.Errorf("vev: invalid session target: %w", err)
395 }396 }
396- if err := protocol.ValidateNavigation(request.NavigationCapabilities, request.StartupOverlay, request.RemoteTarget != nil || request.EnvironmentPolicy == protocol.EnvironmentPolicyDaemonOwned); err != nil {397+ homePickerRoute := request.RemoteTarget != nil || request.EnvironmentPolicy == protocol.EnvironmentPolicyDaemonOwned || request.Intent == protocol.IntentNew
398+ if err := protocol.ValidateNavigation(request.NavigationCapabilities, request.StartupOverlay, homePickerRoute); err != nil {
397 return fmt.Errorf("vev: invalid navigation route: %w", err)399 return fmt.Errorf("vev: invalid navigation route: %w", err)
398 }400 }
399 if request.RemoteTarget == nil {401 if request.RemoteTarget == nil {
@@ -468,6 +470,9 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
468 var routeNavigationSelection *routeNavigationSelection470 var routeNavigationSelection *routeNavigationSelection
469 var routeNavigationFallback *attachRoute471 var routeNavigationFallback *attachRoute
470 var routeNavigationAction *protocol.RouteNavigationAction472 var routeNavigationAction *protocol.RouteNavigationAction
473+ creationPending := false
474+ var creationFallback *attachRoute
475+ var creationRequestID uint64
471 backoff := defaultReconnectBackoff.initial476 backoff := defaultReconnectBackoff.initial
472 themeState := &terminalThemeState{}477 themeState := &terminalThemeState{}
473 var rememberOnce sync.Once478 var rememberOnce sync.Once
@@ -628,6 +633,16 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
628 if err != nil {633 if err != nil {
629 err = handshakeContextError(ctx, timedOut, err)634 err = handshakeContextError(ctx, timedOut, err)
630 finishHandshake()635 finishHandshake()
636+ if creationPending && creationFallback != nil && ctx.Err() == nil {
637+ r.creationFailure = &protocol.SessionCreationFailure{RequestID: creationRequestID, Code: routeFailureCode(err)}
638+ route := *creationFallback
639+ creationFallback = nil
640+ creationPending = false
641+ creationRequestID = 0
642+ returnNavigationPending = true
643+ restoreReturnRoute(route)
644+ continue
645+ }
631 if resumeToken == 0 || ctx.Err() != nil {646 if resumeToken == 0 || ctx.Err() != nil {
632 if clearErr := reconnect.clear(); clearErr != nil {647 if clearErr := reconnect.clear(); clearErr != nil {
633 return errors.Join(err, clearErr)648 return errors.Join(err, clearErr)
@@ -655,7 +670,18 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
655 ms.dialed = true670 ms.dialed = true
656 if transport == nil {671 if transport == nil {
657 finishHandshake()672 finishHandshake()
658- return errors.New("vev: dialer returned nil transport")673+ err := errors.New("vev: dialer returned nil transport")
674+ if creationPending && creationFallback != nil {
675+ r.creationFailure = &protocol.SessionCreationFailure{RequestID: creationRequestID, Code: protocol.RouteFailureUnavailable}
676+ route := *creationFallback
677+ creationFallback = nil
678+ creationPending = false
679+ creationRequestID = 0
680+ returnNavigationPending = true
681+ restoreReturnRoute(route)
682+ continue
683+ }
684+ return err
659 }685 }
660 stopHandshakeTransport := watchHandshakeTransport(handshakeCtx, transport)686 stopHandshakeTransport := watchHandshakeTransport(handshakeCtx, transport)
661 connection, err := NewSessionConnection(transport, SessionTarget{687 connection, err := NewSessionConnection(transport, SessionTarget{
@@ -666,6 +692,16 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
666 stopHandshakeTransport()692 stopHandshakeTransport()
667 finishHandshake()693 finishHandshake()
668 _ = transport.Close()694 _ = transport.Close()
695+ if creationPending && creationFallback != nil {
696+ r.creationFailure = &protocol.SessionCreationFailure{RequestID: creationRequestID, Code: protocol.RouteFailureUnavailable}
697+ route := *creationFallback
698+ creationFallback = nil
699+ creationPending = false
700+ creationRequestID = 0
701+ returnNavigationPending = true
702+ restoreReturnRoute(route)
703+ continue
704+ }
669 return err705 return err
670 }706 }
671 707
@@ -731,6 +767,11 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
731 attemptRequest.PreferredTabID = result.routePosition.ActiveTabID767 attemptRequest.PreferredTabID = result.routePosition.ActiveTabID
732 attemptRequest.RemoteTarget = nil768 attemptRequest.RemoteTarget = nil
733 }769 }
770+ if result.err == nil && result.welcomed && creationPending {
771+ creationPending = false
772+ creationFallback = nil
773+ creationRequestID = 0
774+ }
734 if result.err == nil && result.welcomed && routeNavigationPending {775 if result.err == nil && result.welcomed && routeNavigationPending {
735 routeNavigationPending = false776 routeNavigationPending = false
736 routeNavigationSelection = nil777 routeNavigationSelection = nil
@@ -738,6 +779,40 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
738 routeNavigationFallback = nil779 routeNavigationFallback = nil
739 routeNavigationAction = nil780 routeNavigationAction = nil
740 }781 }
782+ if result.routeCreateAction != nil {
783+ action := *result.routeCreateAction
784+ source := attachRoute{dialer: dialer, request: cloneAttachRequest(attemptRequest), resumeToken: resumeToken}
785+ if r.ledger == nil {
786+ r.creationFailure = &protocol.SessionCreationFailure{RequestID: action.RequestID, Code: protocol.RouteFailureUnavailable}
787+ returnNavigationPending = true
788+ restoreReturnRoute(source)
789+ continue
790+ }
791+ selection, valid := r.ledger.creationSelection(action)
792+ if !valid {
793+ r.creationFailure = &protocol.SessionCreationFailure{RequestID: action.RequestID, Code: protocol.RouteFailureStaleSelection}
794+ returnNavigationPending = true
795+ restoreReturnRoute(source)
796+ continue
797+ }
798+ creationFallback = &attachRoute{dialer: selection.prior.dialer, request: cloneAttachRequest(selection.prior.request), resumeToken: selection.prior.resumeToken}
799+ dialer = selection.selected.dialer
800+ attemptRequest = cloneAttachRequest(selection.selected.request)
801+ attemptRequest.Intent = protocol.IntentNew
802+ attemptRequest.SessionName = action.SessionName
803+ attemptRequest.ExactTarget = nil
804+ attemptRequest.RemoteTarget = nil
805+ attemptRequest.PreferredTabID = ""
806+ attemptRequest.StartupOverlay = protocol.StartupOverlayNone
807+ attemptRequest.NavigationCapabilities = 0
808+ attemptRequest.EnvironmentPolicy = protocol.EnvironmentPolicyClientOwned
809+ resumeToken = 0
810+ creationPending = true
811+ creationRequestID = action.RequestID
812+ remote = syncReconnectRemote(reconnect, attemptRequest.Remote || r.remote)
813+ backoff = defaultReconnectBackoff.initial
814+ continue
815+ }
741 if result.routeAction != nil {816 if result.routeAction != nil {
742 if r.ledger == nil {817 if r.ledger == nil {
743 return errors.New("vev: route ledger unavailable")818 return errors.New("vev: route ledger unavailable")
@@ -846,13 +921,19 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
846 }921 }
847 nextDialer, nextRequest, handoffErr = r.attachHandoff(*target)922 nextDialer, nextRequest, handoffErr = r.attachHandoff(*target)
848 if handoffErr != nil {923 if handoffErr != nil {
924+ if target.Intent == protocol.IntentNew && target.RequestID != 0 {
925+ r.creationFailure = &protocol.SessionCreationFailure{RequestID: target.RequestID, Code: routeFailureCode(handoffErr)}
926+ returnNavigationPending = true
927+ restoreReturnRoute(source)
928+ continue
929+ }
849 return handoffErr930 return handoffErr
850 }931 }
851 }932 }
852 if target.ExactTarget != nil {933 if target.ExactTarget != nil {
853 nextRequest.ExactTarget = target.ExactTarget934 nextRequest.ExactTarget = target.ExactTarget
854 }935 }
855- if homeRoute != nil && (nextRequest.RemoteTarget != nil || nextRequest.EnvironmentPolicy == protocol.EnvironmentPolicyDaemonOwned) {936+ if homeRoute != nil && (nextRequest.RemoteTarget != nil || nextRequest.EnvironmentPolicy == protocol.EnvironmentPolicyDaemonOwned || target.Intent == protocol.IntentNew) {
856 nextRequest.NavigationCapabilities |= protocol.NavigationCapabilityHomePicker937 nextRequest.NavigationCapabilities |= protocol.NavigationCapabilityHomePicker
857 }938 }
858 if attemptRequest.StartupOverlay == protocol.StartupOverlaySessionPicker {939 if attemptRequest.StartupOverlay == protocol.StartupOverlaySessionPicker {
@@ -861,11 +942,29 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
861 }942 }
862 nextRequest = cloneAttachRequest(nextRequest)943 nextRequest = cloneAttachRequest(nextRequest)
863 if err := validateAttachRequest(nextRequest); err != nil {944 if err := validateAttachRequest(nextRequest); err != nil {
945+ if target.Intent == protocol.IntentNew && target.RequestID != 0 {
946+ r.creationFailure = &protocol.SessionCreationFailure{RequestID: target.RequestID, Code: protocol.RouteFailureUnavailable}
947+ returnNavigationPending = true
948+ restoreReturnRoute(source)
949+ continue
950+ }
864 return fmt.Errorf("vev: invalid route handoff request: %w", err)951 return fmt.Errorf("vev: invalid route handoff request: %w", err)
865 }952 }
866 if nextDialer == nil {953 if nextDialer == nil {
954+ if target.Intent == protocol.IntentNew && target.RequestID != 0 {
955+ r.creationFailure = &protocol.SessionCreationFailure{RequestID: target.RequestID, Code: protocol.RouteFailureUnavailable}
956+ returnNavigationPending = true
957+ restoreReturnRoute(source)
958+ continue
959+ }
867 return errors.New("vev: route handoff returned nil dialer")960 return errors.New("vev: route handoff returned nil dialer")
868 }961 }
962+ if target.Intent == protocol.IntentNew && target.RequestID != 0 {
963+ fallback := source
964+ creationFallback = &fallback
965+ creationPending = true
966+ creationRequestID = target.RequestID
967+ }
869 dialer = nextDialer968 dialer = nextDialer
870 attemptRequest = nextRequest969 attemptRequest = nextRequest
871 remote = syncReconnectRemote(reconnect, attemptRequest.Remote || r.remote)970 remote = syncReconnectRemote(reconnect, attemptRequest.Remote || r.remote)
@@ -894,6 +993,16 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
894 restoreReturnRoute(route)993 restoreReturnRoute(route)
895 continue994 continue
896 }995 }
996+ if creationPending && creationFallback != nil {
997+ r.creationFailure = &protocol.SessionCreationFailure{RequestID: creationRequestID, Code: routeFailureCode(result.err)}
998+ route := *creationFallback
999+ creationFallback = nil
1000+ creationPending = false
1001+ creationRequestID = 0
1002+ returnNavigationPending = true
1003+ restoreReturnRoute(route)
1004+ continue
1005+ }
897 if routeNavigationPending && routeNavigationResumeFallback && resumeNeedsExactAttach(result.err) {1006 if routeNavigationPending && routeNavigationResumeFallback && resumeNeedsExactAttach(result.err) {
898 attemptRequest.Intent = protocol.IntentAttach1007 attemptRequest.Intent = protocol.IntentAttach
899 resumeToken = 01008 resumeToken = 0
@@ -1121,6 +1230,7 @@ type attachResult struct {
1121 handoff *attachHandoff1230 handoff *attachHandoff
1122 action protocol.NavigationAction1231 action protocol.NavigationAction
1123 routeAction *protocol.RouteNavigationAction1232 routeAction *protocol.RouteNavigationAction
1233+ routeCreateAction *protocol.RouteCreateSessionAction
1124 err error1234 err error
1125 }1235 }
1126 1236
@@ -1325,6 +1435,9 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1325 if request.ExactTarget != nil && *request.ExactTarget != committedIdentity.Target {1435 if request.ExactTarget != nil && *request.ExactTarget != committedIdentity.Target {
1326 return welcomedResult(errRouteTargetChanged)1436 return welcomedResult(errRouteTargetChanged)
1327 }1437 }
1438+ if request.Intent == protocol.IntentNew && request.SessionName != committedIdentity.Target.SessionName {
1439+ return welcomedResult(errRouteTargetChanged)
1440+ }
1328 if request.RemoteTarget != nil && (request.RemoteTarget.LifecycleID != committedIdentity.Target.LifecycleID || request.RemoteTarget.SessionName != committedIdentity.Target.SessionName) {1441 if request.RemoteTarget != nil && (request.RemoteTarget.LifecycleID != committedIdentity.Target.LifecycleID || request.RemoteTarget.SessionName != committedIdentity.Target.SessionName) {
1329 return welcomedResult(errRouteTargetChanged)1442 return welcomedResult(errRouteTargetChanged)
1330 }1443 }
@@ -1352,6 +1465,14 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1352 return welcomedResult(fmt.Errorf("vev: publishing route snapshot: %w", err))1465 return welcomedResult(fmt.Errorf("vev: publishing route snapshot: %w", err))
1353 }1466 }
1354 }1467 }
1468+ if a.runner.creationFailure != nil {
1469+ if err := sendHandshake(func() error {
1470+ return transport.SendClient(*a.runner.creationFailure)
1471+ }); err != nil {
1472+ return welcomedResult(fmt.Errorf("vev: publishing session creation failure: %w", err))
1473+ }
1474+ a.runner.creationFailure = nil
1475+ }
1355 if a.runner.routeFailure != nil {1476 if a.runner.routeFailure != nil {
1356 if err := sendHandshake(func() error {1477 if err := sendHandshake(func() error {
1357 return transport.SendClient(*a.runner.routeFailure)1478 return transport.SendClient(*a.runner.routeFailure)
@@ -1777,6 +1898,18 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1777 return context.Canceled1898 return context.Canceled
1778 }1899 }
1779 }1900 }
1901+ sendCreationFailure := func(requestID uint64, code protocol.RouteFailureCode) error {
1902+ failure := protocol.SessionCreationFailure{RequestID: requestID, Code: code}
1903+ if failure.Validate() != nil {
1904+ return protocol.ErrInvalidRouteWire
1905+ }
1906+ select {
1907+ case sendCh <- failure:
1908+ return nil
1909+ case <-loopCtx.Done():
1910+ return context.Canceled
1911+ }
1912+ }
1780 dismissReconnect := func() error {1913 dismissReconnect := func() error {
1781 if !reconnect.showing {1914 if !reconnect.showing {
1782 return nil1915 return nil
@@ -2038,6 +2171,26 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
2038 navigation := welcomedResult(nil)2171 navigation := welcomedResult(nil)
2039 navigation.action = directive.Action2172 navigation.action = directive.Action
2040 return navigation2173 return navigation
2174+ case protocol.RouteCreateSessionAction:
2175+ action := message
2176+ if action.Validate() != nil {
2177+ continue
2178+ }
2179+ if a.runner.ledger == nil {
2180+ if ferr := sendCreationFailure(action.RequestID, protocol.RouteFailureUnavailable); ferr != nil {
2181+ return welcomedResult(ferr)
2182+ }
2183+ continue
2184+ }
2185+ if _, valid := a.runner.ledger.creationSelection(action); !valid {
2186+ if ferr := sendCreationFailure(action.RequestID, protocol.RouteFailureStaleSelection); ferr != nil {
2187+ return welcomedResult(ferr)
2188+ }
2189+ continue
2190+ }
2191+ result := welcomedResult(nil)
2192+ result.routeCreateAction = &action
2193+ return result
2041 case protocol.RouteNavigationAction:2194 case protocol.RouteNavigationAction:
2042 action := message2195 action := message
2043 if a.runner.ledger == nil {2196 if a.runner.ledger == nil {
M internal/usecase/client/client_test.go +63 -0
@@ -1158,6 +1158,69 @@ func TestHybridPickerDifferentHostFallsBackToNewRemoteDial(t *testing.T) {
11581158 require.Positive(t, sourceRemote.closed.Load())
11591159 }
11601160
1161+func TestRemoteCreationFailuresRestoreSourceAndReportCorrelation(t *testing.T) {
1162+ tests := []struct {
1163+ name string
1164+ handoffErr error
1165+ dialErr error
1166+ wantTargetCalls int32
1167+ }{
1168+ {name: "handoff resolution", handoffErr: errors.New("handoff failed")},
1169+ {name: "target dial", dialErr: errors.New("dial failed"), wantTargetCalls: 1},
1170+ }
1171+ for _, tt := range tests {
1172+ t.Run(tt.name, func(t *testing.T) {
1173+ term := newRunTerminal()
1174+ defer term.in.unblock()
1175+
1176+ lifecycle := domain.SessionLifecycleID{1}
1177+ target := protocol.AttachTarget{
1178+ RequestID: 7, Endpoint: "host-a", Session: "example", Intent: protocol.IntentNew,
1179+ EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned,
1180+ }
1181+ initial := &recordingTransport{recvs: []recvItem{
1182+ {f: hybridWelcomeFrame("work", lifecycle)},
1183+ {f: frameOf(wire.MsgAttachTarget, wire.MarshalAttachTarget(target))},
1184+ }}
1185+ restored := &recordingTransport{recvs: []recvItem{
1186+ {f: hybridWelcomeFrame("work", lifecycle)},
1187+ {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))},
1188+ }}
1189+ sourceDialer := &sequenceDialer{trs: []wire.Transport{initial, restored}}
1190+ targetDialer := &sequenceDialer{errs: []error{tt.dialErr}}
1191+ deps := testDependencies(sourceDialer, term, realClock{}, nil, nil)
1192+ deps.AttachHandoff = func(got protocol.AttachTarget) (ports.ClientDialer, client.AttachRequest, error) {
1193+ require.Equal(t, target, got)
1194+ if tt.handoffErr != nil {
1195+ return nil, client.AttachRequest{}, tt.handoffErr
1196+ }
1197+ return targetDialer, client.AttachRequest{
1198+ Intent: protocol.IntentNew, SessionName: "example", Remote: true,
1199+ Origin: protocol.RouteOriginDiscovery, OriginKey: "host-a", HostLabel: "host-a",
1200+ EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned,
1201+ }, nil
1202+ }
1203+
1204+ require.NoError(t, runTestClient(context.Background(), deps, client.AttachRequest{
1205+ Intent: protocol.IntentAttach, SessionName: "work", Origin: protocol.RouteOriginLocal, OriginKey: "local",
1206+ }))
1207+ require.Equal(t, int32(2), sourceDialer.calls.Load())
1208+ require.Equal(t, tt.wantTargetCalls, targetDialer.calls.Load())
1209+ var failure protocol.SessionCreationFailure
1210+ for _, frame := range restored.Sends() {
1211+ if frame.Type != wire.MsgSessionCreationFailure {
1212+ continue
1213+ }
1214+ var err error
1215+ failure, err = wire.UnmarshalSessionCreationFailure(frame.Payload)
1216+ require.NoError(t, err)
1217+ }
1218+ require.Equal(t, uint64(7), failure.RequestID)
1219+ require.Equal(t, protocol.RouteFailureUnavailable, failure.Code)
1220+ })
1221+ }
1222+}
1223+
11611224 func TestRouteNavigationPreservesRemoteHomePickerAcrossLocalReturn(t *testing.T) {
11621225 term := newRunTerminal()
11631226 defer term.in.unblock()
@@ -1158,6 +1158,69 @@ func TestHybridPickerDifferentHostFallsBackToNewRemoteDial(t *testing.T) {
1158 require.Positive(t, sourceRemote.closed.Load())1158 require.Positive(t, sourceRemote.closed.Load())
1159 }1159 }
1160 1160
1161+func TestRemoteCreationFailuresRestoreSourceAndReportCorrelation(t *testing.T) {
1162+ tests := []struct {
1163+ name string
1164+ handoffErr error
1165+ dialErr error
1166+ wantTargetCalls int32
1167+ }{
1168+ {name: "handoff resolution", handoffErr: errors.New("handoff failed")},
1169+ {name: "target dial", dialErr: errors.New("dial failed"), wantTargetCalls: 1},
1170+ }
1171+ for _, tt := range tests {
1172+ t.Run(tt.name, func(t *testing.T) {
1173+ term := newRunTerminal()
1174+ defer term.in.unblock()
1175+
1176+ lifecycle := domain.SessionLifecycleID{1}
1177+ target := protocol.AttachTarget{
1178+ RequestID: 7, Endpoint: "host-a", Session: "example", Intent: protocol.IntentNew,
1179+ EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned,
1180+ }
1181+ initial := &recordingTransport{recvs: []recvItem{
1182+ {f: hybridWelcomeFrame("work", lifecycle)},
1183+ {f: frameOf(wire.MsgAttachTarget, wire.MarshalAttachTarget(target))},
1184+ }}
1185+ restored := &recordingTransport{recvs: []recvItem{
1186+ {f: hybridWelcomeFrame("work", lifecycle)},
1187+ {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))},
1188+ }}
1189+ sourceDialer := &sequenceDialer{trs: []wire.Transport{initial, restored}}
1190+ targetDialer := &sequenceDialer{errs: []error{tt.dialErr}}
1191+ deps := testDependencies(sourceDialer, term, realClock{}, nil, nil)
1192+ deps.AttachHandoff = func(got protocol.AttachTarget) (ports.ClientDialer, client.AttachRequest, error) {
1193+ require.Equal(t, target, got)
1194+ if tt.handoffErr != nil {
1195+ return nil, client.AttachRequest{}, tt.handoffErr
1196+ }
1197+ return targetDialer, client.AttachRequest{
1198+ Intent: protocol.IntentNew, SessionName: "example", Remote: true,
1199+ Origin: protocol.RouteOriginDiscovery, OriginKey: "host-a", HostLabel: "host-a",
1200+ EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned,
1201+ }, nil
1202+ }
1203+
1204+ require.NoError(t, runTestClient(context.Background(), deps, client.AttachRequest{
1205+ Intent: protocol.IntentAttach, SessionName: "work", Origin: protocol.RouteOriginLocal, OriginKey: "local",
1206+ }))
1207+ require.Equal(t, int32(2), sourceDialer.calls.Load())
1208+ require.Equal(t, tt.wantTargetCalls, targetDialer.calls.Load())
1209+ var failure protocol.SessionCreationFailure
1210+ for _, frame := range restored.Sends() {
1211+ if frame.Type != wire.MsgSessionCreationFailure {
1212+ continue
1213+ }
1214+ var err error
1215+ failure, err = wire.UnmarshalSessionCreationFailure(frame.Payload)
1216+ require.NoError(t, err)
1217+ }
1218+ require.Equal(t, uint64(7), failure.RequestID)
1219+ require.Equal(t, protocol.RouteFailureUnavailable, failure.Code)
1220+ })
1221+ }
1222+}
1223+
1161 func TestRouteNavigationPreservesRemoteHomePickerAcrossLocalReturn(t *testing.T) {1224 func TestRouteNavigationPreservesRemoteHomePickerAcrossLocalReturn(t *testing.T) {
1162 term := newRunTerminal()1225 term := newRunTerminal()
1163 defer term.in.unblock()1226 defer term.in.unblock()
M internal/usecase/client/navigation_test.go +1 -0
@@ -39,6 +39,7 @@ func TestValidateAttachRequestNavigationTable(t *testing.T) {
3939 {name: "ordinary route", request: AttachRequest{}, valid: true},
4040 {name: "home picker without remote route", request: AttachRequest{Intent: protocol.IntentAttach, SessionName: "work", NavigationCapabilities: protocol.NavigationCapabilityHomePicker}, valid: false},
4141 {name: "home picker on daemon-owned remote route", request: AttachRequest{Intent: protocol.IntentAttach, SessionName: "work", Remote: true, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, NavigationCapabilities: protocol.NavigationCapabilityHomePicker}, valid: true},
42+ {name: "home picker on client-owned remote new route", request: AttachRequest{Intent: protocol.IntentNew, SessionName: "example", Remote: true, EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned, NavigationCapabilities: protocol.NavigationCapabilityHomePicker}, valid: true},
4243 {name: "back on client-owned route", request: AttachRequest{StartupOverlay: protocol.StartupOverlaySessionPicker, NavigationCapabilities: protocol.NavigationCapabilityBack}, valid: true},
4344 {name: "back on remote-target route", request: AttachRequest{Intent: protocol.IntentAttach, SessionName: "work", RemoteTarget: remoteRoute, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, StartupOverlay: protocol.StartupOverlaySessionPicker, NavigationCapabilities: protocol.NavigationCapabilityBack}, valid: false},
4445 {name: "unknown capability", request: AttachRequest{NavigationCapabilities: 4}, valid: false},
@@ -39,6 +39,7 @@ func TestValidateAttachRequestNavigationTable(t *testing.T) {
39 {name: "ordinary route", request: AttachRequest{}, valid: true},39 {name: "ordinary route", request: AttachRequest{}, valid: true},
40 {name: "home picker without remote route", request: AttachRequest{Intent: protocol.IntentAttach, SessionName: "work", NavigationCapabilities: protocol.NavigationCapabilityHomePicker}, valid: false},40 {name: "home picker without remote route", request: AttachRequest{Intent: protocol.IntentAttach, SessionName: "work", NavigationCapabilities: protocol.NavigationCapabilityHomePicker}, valid: false},
41 {name: "home picker on daemon-owned remote route", request: AttachRequest{Intent: protocol.IntentAttach, SessionName: "work", Remote: true, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, NavigationCapabilities: protocol.NavigationCapabilityHomePicker}, valid: true},41 {name: "home picker on daemon-owned remote route", request: AttachRequest{Intent: protocol.IntentAttach, SessionName: "work", Remote: true, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, NavigationCapabilities: protocol.NavigationCapabilityHomePicker}, valid: true},
42+ {name: "home picker on client-owned remote new route", request: AttachRequest{Intent: protocol.IntentNew, SessionName: "example", Remote: true, EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned, NavigationCapabilities: protocol.NavigationCapabilityHomePicker}, valid: true},
42 {name: "back on client-owned route", request: AttachRequest{StartupOverlay: protocol.StartupOverlaySessionPicker, NavigationCapabilities: protocol.NavigationCapabilityBack}, valid: true},43 {name: "back on client-owned route", request: AttachRequest{StartupOverlay: protocol.StartupOverlaySessionPicker, NavigationCapabilities: protocol.NavigationCapabilityBack}, valid: true},
43 {name: "back on remote-target route", request: AttachRequest{Intent: protocol.IntentAttach, SessionName: "work", RemoteTarget: remoteRoute, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, StartupOverlay: protocol.StartupOverlaySessionPicker, NavigationCapabilities: protocol.NavigationCapabilityBack}, valid: false},44 {name: "back on remote-target route", request: AttachRequest{Intent: protocol.IntentAttach, SessionName: "work", RemoteTarget: remoteRoute, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, StartupOverlay: protocol.StartupOverlaySessionPicker, NavigationCapabilities: protocol.NavigationCapabilityBack}, valid: false},
44 {name: "unknown capability", request: AttachRequest{NavigationCapabilities: 4}, valid: false},45 {name: "unknown capability", request: AttachRequest{NavigationCapabilities: 4}, valid: false},
M internal/usecase/client/routes.go +15 -0
@@ -679,6 +679,21 @@ func (l *routeLedger) navigationSelection(action protocol.RouteNavigationAction)
679679 return selection, true
680680 }
681681
682+func (l *routeLedger) creationSelection(action protocol.RouteCreateSessionAction) (routeNavigationSelection, bool) {
683+ if action.Validate() != nil {
684+ return routeNavigationSelection{}, false
685+ }
686+ selection, ok := l.navigationSelection(protocol.RouteNavigationAction{
687+ SnapshotGeneration: action.SnapshotGeneration,
688+ Key: action.Key,
689+ Generation: action.Generation,
690+ })
691+ if !ok || selection.noOp || selection.selected.presentation.kind != protocol.RouteKindLocal {
692+ return routeNavigationSelection{}, false
693+ }
694+ return selection, true
695+}
696+
682697 // navigationRecord validates that an action belongs to the latest complete
683698 // snapshot. The active route is metadata-only and selecting it is a no-op.
684699 func (l *routeLedger) navigationRecord(action protocol.RouteNavigationAction) (routeRecord, bool, bool) {
@@ -679,6 +679,21 @@ func (l *routeLedger) navigationSelection(action protocol.RouteNavigationAction)
679 return selection, true679 return selection, true
680 }680 }
681 681
682+func (l *routeLedger) creationSelection(action protocol.RouteCreateSessionAction) (routeNavigationSelection, bool) {
683+ if action.Validate() != nil {
684+ return routeNavigationSelection{}, false
685+ }
686+ selection, ok := l.navigationSelection(protocol.RouteNavigationAction{
687+ SnapshotGeneration: action.SnapshotGeneration,
688+ Key: action.Key,
689+ Generation: action.Generation,
690+ })
691+ if !ok || selection.noOp || selection.selected.presentation.kind != protocol.RouteKindLocal {
692+ return routeNavigationSelection{}, false
693+ }
694+ return selection, true
695+}
696+
682 // navigationRecord validates that an action belongs to the latest complete697 // navigationRecord validates that an action belongs to the latest complete
683 // snapshot. The active route is metadata-only and selecting it is a no-op.698 // snapshot. The active route is metadata-only and selecting it is a no-op.
684 func (l *routeLedger) navigationRecord(action protocol.RouteNavigationAction) (routeRecord, bool, bool) {699 func (l *routeLedger) navigationRecord(action protocol.RouteNavigationAction) (routeRecord, bool, bool) {
M internal/usecase/client/routes_test.go +35 -0
@@ -331,6 +331,41 @@ func TestRouteLedgerConcurrentInitialAttachmentsKeepOneHome(t *testing.T) {
331331 require.Equal(t, 1, homeCount)
332332 }
333333
334+func TestRouteLedgerCreationSelectionRequiresExactLocalRoute(t *testing.T) {
335+ ledger := newRouteLedger()
336+ local, err := ledger.commit(routeTestCandidate(1, protocol.RouteOriginLocal))
337+ require.NoError(t, err)
338+ remoteCandidate := routeTestCandidate(2, protocol.RouteOriginRemote)
339+ remoteCandidate.presentation.kind = protocol.RouteKindRemote
340+ remote, err := ledger.commit(remoteCandidate)
341+ require.NoError(t, err)
342+ snapshotGeneration := ledger.snapshot().Generation
343+
344+ tests := []struct {
345+ name string
346+ selected routeIdentity
347+ snapshotGeneration uint64
348+ want bool
349+ }{
350+ {name: "exact local route", selected: local, snapshotGeneration: snapshotGeneration, want: true},
351+ {name: "stale snapshot", selected: local, snapshotGeneration: snapshotGeneration - 1},
352+ {name: "remote route", selected: remote, snapshotGeneration: snapshotGeneration},
353+ }
354+ for _, tt := range tests {
355+ t.Run(tt.name, func(t *testing.T) {
356+ action := protocol.RouteCreateSessionAction{
357+ RequestID: 1, SnapshotGeneration: tt.snapshotGeneration,
358+ Key: uint64(tt.selected.key), Generation: uint64(tt.selected.generation), SessionName: "example",
359+ }
360+ selection, ok := ledger.creationSelection(action)
361+ require.Equal(t, tt.want, ok)
362+ if ok {
363+ require.Equal(t, protocol.RouteKindLocal, selection.selected.presentation.kind)
364+ }
365+ })
366+ }
367+}
368+
334369 func TestRouteLedgerTransitionRejectsConcurrentCommit(t *testing.T) {
335370 ledger := newRouteLedger()
336371 selected, err := ledger.commit(routeTestCandidate(1, protocol.RouteOriginLocal))
@@ -331,6 +331,41 @@ func TestRouteLedgerConcurrentInitialAttachmentsKeepOneHome(t *testing.T) {
331 require.Equal(t, 1, homeCount)331 require.Equal(t, 1, homeCount)
332 }332 }
333 333
334+func TestRouteLedgerCreationSelectionRequiresExactLocalRoute(t *testing.T) {
335+ ledger := newRouteLedger()
336+ local, err := ledger.commit(routeTestCandidate(1, protocol.RouteOriginLocal))
337+ require.NoError(t, err)
338+ remoteCandidate := routeTestCandidate(2, protocol.RouteOriginRemote)
339+ remoteCandidate.presentation.kind = protocol.RouteKindRemote
340+ remote, err := ledger.commit(remoteCandidate)
341+ require.NoError(t, err)
342+ snapshotGeneration := ledger.snapshot().Generation
343+
344+ tests := []struct {
345+ name string
346+ selected routeIdentity
347+ snapshotGeneration uint64
348+ want bool
349+ }{
350+ {name: "exact local route", selected: local, snapshotGeneration: snapshotGeneration, want: true},
351+ {name: "stale snapshot", selected: local, snapshotGeneration: snapshotGeneration - 1},
352+ {name: "remote route", selected: remote, snapshotGeneration: snapshotGeneration},
353+ }
354+ for _, tt := range tests {
355+ t.Run(tt.name, func(t *testing.T) {
356+ action := protocol.RouteCreateSessionAction{
357+ RequestID: 1, SnapshotGeneration: tt.snapshotGeneration,
358+ Key: uint64(tt.selected.key), Generation: uint64(tt.selected.generation), SessionName: "example",
359+ }
360+ selection, ok := ledger.creationSelection(action)
361+ require.Equal(t, tt.want, ok)
362+ if ok {
363+ require.Equal(t, protocol.RouteKindLocal, selection.selected.presentation.kind)
364+ }
365+ })
366+ }
367+}
368+
334 func TestRouteLedgerTransitionRejectsConcurrentCommit(t *testing.T) {369 func TestRouteLedgerTransitionRejectsConcurrentCommit(t *testing.T) {
335 ledger := newRouteLedger()370 ledger := newRouteLedger()
336 selected, err := ledger.commit(routeTestCandidate(1, protocol.RouteOriginLocal))371 selected, err := ledger.commit(routeTestCandidate(1, protocol.RouteOriginLocal))
M internal/usecase/client/typed_connection_external_test.go +5 -0
@@ -137,6 +137,9 @@ func externalClientFrame(m protocol.ClientMessage) (wire.Frame, error) {
137137 case protocol.RouteNavigationFailure:
138138 p, e := wire.MarshalRouteNavigationFailure(x)
139139 return wire.Frame{Type: wire.MsgRouteNavigationFailure, Payload: p}, e
140+ case protocol.SessionCreationFailure:
141+ p, e := wire.MarshalSessionCreationFailure(x)
142+ return wire.Frame{Type: wire.MsgSessionCreationFailure, Payload: p}, e
140143 default:
141144 return wire.Frame{}, errors.New("test client: unsupported message")
142145 }
@@ -161,6 +164,8 @@ func externalServerMessage(f wire.Frame) (protocol.ServerMessage, error) {
161164 return wire.UnmarshalParkedRouteResponse(f.Payload)
162165 case wire.MsgNavigateRecentRoute:
163166 return wire.UnmarshalRouteNavigationAction(f.Payload)
167+ case wire.MsgRouteCreateSession:
168+ return wire.UnmarshalRouteCreateSessionAction(f.Payload)
164169 case wire.MsgCommittedRouteIdentity:
165170 return wire.UnmarshalCommittedRouteIdentity(f.Payload)
166171 case wire.MsgSamePeerSwitchFailure:
@@ -137,6 +137,9 @@ func externalClientFrame(m protocol.ClientMessage) (wire.Frame, error) {
137 case protocol.RouteNavigationFailure:137 case protocol.RouteNavigationFailure:
138 p, e := wire.MarshalRouteNavigationFailure(x)138 p, e := wire.MarshalRouteNavigationFailure(x)
139 return wire.Frame{Type: wire.MsgRouteNavigationFailure, Payload: p}, e139 return wire.Frame{Type: wire.MsgRouteNavigationFailure, Payload: p}, e
140+ case protocol.SessionCreationFailure:
141+ p, e := wire.MarshalSessionCreationFailure(x)
142+ return wire.Frame{Type: wire.MsgSessionCreationFailure, Payload: p}, e
140 default:143 default:
141 return wire.Frame{}, errors.New("test client: unsupported message")144 return wire.Frame{}, errors.New("test client: unsupported message")
142 }145 }
@@ -161,6 +164,8 @@ func externalServerMessage(f wire.Frame) (protocol.ServerMessage, error) {
161 return wire.UnmarshalParkedRouteResponse(f.Payload)164 return wire.UnmarshalParkedRouteResponse(f.Payload)
162 case wire.MsgNavigateRecentRoute:165 case wire.MsgNavigateRecentRoute:
163 return wire.UnmarshalRouteNavigationAction(f.Payload)166 return wire.UnmarshalRouteNavigationAction(f.Payload)
167+ case wire.MsgRouteCreateSession:
168+ return wire.UnmarshalRouteCreateSessionAction(f.Payload)
164 case wire.MsgCommittedRouteIdentity:169 case wire.MsgCommittedRouteIdentity:
165 return wire.UnmarshalCommittedRouteIdentity(f.Payload)170 return wire.UnmarshalCommittedRouteIdentity(f.Payload)
166 case wire.MsgSamePeerSwitchFailure:171 case wire.MsgSamePeerSwitchFailure:
M internal/usecase/daemon/client_frame_routing.go +13 -0
@@ -140,6 +140,19 @@ func (d *Daemon) handleAttachmentClientMessage(capability attachmentCapability,
140140 case protocol.RouteAttentionSubscription:
141141 effect.ac.setRouteAttentionSubscription(message)
142142 d.invalidateRender(effect.sess, effect.ac, false, "client_frame_routing.go:route-attention")
143+ case protocol.SessionCreationFailure:
144+ notice := "couldn't create session"
145+ switch message.Code {
146+ case protocol.RouteFailureStaleSelection, protocol.RouteFailureNoSuchRoute:
147+ notice = "that destination is no longer available"
148+ case protocol.RouteFailureTargetChanged:
149+ notice = "the destination returned an unexpected session"
150+ case protocol.RouteFailureOriginUnavailable:
151+ notice = "couldn't restore the original session after creation failed"
152+ case protocol.RouteFailureUnavailable:
153+ notice = "the selected destination is unavailable"
154+ }
155+ d.notify(effect.sess, domain.NoticeWarn, domain.NoticeSessionUnavailable, notice, nil)
143156 case protocol.RouteNavigationFailure:
144157 notice := "route navigation failed"
145158 switch message.Code {
@@ -140,6 +140,19 @@ func (d *Daemon) handleAttachmentClientMessage(capability attachmentCapability,
140 case protocol.RouteAttentionSubscription:140 case protocol.RouteAttentionSubscription:
141 effect.ac.setRouteAttentionSubscription(message)141 effect.ac.setRouteAttentionSubscription(message)
142 d.invalidateRender(effect.sess, effect.ac, false, "client_frame_routing.go:route-attention")142 d.invalidateRender(effect.sess, effect.ac, false, "client_frame_routing.go:route-attention")
143+ case protocol.SessionCreationFailure:
144+ notice := "couldn't create session"
145+ switch message.Code {
146+ case protocol.RouteFailureStaleSelection, protocol.RouteFailureNoSuchRoute:
147+ notice = "that destination is no longer available"
148+ case protocol.RouteFailureTargetChanged:
149+ notice = "the destination returned an unexpected session"
150+ case protocol.RouteFailureOriginUnavailable:
151+ notice = "couldn't restore the original session after creation failed"
152+ case protocol.RouteFailureUnavailable:
153+ notice = "the selected destination is unavailable"
154+ }
155+ d.notify(effect.sess, domain.NoticeWarn, domain.NoticeSessionUnavailable, notice, nil)
143 case protocol.RouteNavigationFailure:156 case protocol.RouteNavigationFailure:
144 notice := "route navigation failed"157 notice := "route navigation failed"
145 switch message.Code {158 switch message.Code {
M internal/usecase/daemon/daemon.go +1 -0
@@ -84,6 +84,7 @@ type Daemon struct {
8484 // reusing a lifecycle identity.
8585 lastAllocatedCreatedAt int64
8686 mruSeq atomic.Uint64
87+ creationRequestSeq atomic.Uint64
8788 // pickerSort is the picker ordering mode for this daemon's lifetime
8889 // (pickerSortMode); not persisted across restarts.
8990 pickerSort atomic.Uint32
@@ -84,6 +84,7 @@ type Daemon struct {
84 // reusing a lifecycle identity.84 // reusing a lifecycle identity.
85 lastAllocatedCreatedAt int6485 lastAllocatedCreatedAt int64
86 mruSeq atomic.Uint6486 mruSeq atomic.Uint64
87+ creationRequestSeq atomic.Uint64
87 // pickerSort is the picker ordering mode for this daemon's lifetime88 // pickerSort is the picker ordering mode for this daemon's lifetime
88 // (pickerSortMode); not persisted across restarts.89 // (pickerSortMode); not persisted across restarts.
89 pickerSort atomic.Uint3290 pickerSort atomic.Uint32
M internal/usecase/daemon/palette.go +202 -4
@@ -22,7 +22,10 @@ const (
2222 paletteRailWidth = 64
2323 )
2424
25-var paletteModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Commands ", Anchor: domain.AnchorBottom, Margins: ui.Margins{Top: 1, Right: 1, Bottom: 1, Left: 1}}
25+var (
26+ paletteModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Commands ", Anchor: domain.AnchorBottom, Margins: ui.Margins{Top: 1, Right: 1, Bottom: 1, Left: 1}}
27+ errCreateDestinationUnavailable = errors.New("that destination is no longer available")
28+)
2629
2730 func paletteModalFor(size domain.Size, cfg domain.PaletteConfig) ui.Modal {
2831 modal := paletteModal
@@ -97,6 +100,56 @@ func paletteDaemonDisplayOrigin(snapshot protocol.RecentRouteSnapshot, lifecycle
97100 return paletteRemoteDisplayOrigin(active.HostLabel)
98101 }
99102
103+func routeEntryForRef(snapshot protocol.RecentRouteSnapshot, ref protocol.RouteRef) (protocol.RecentRouteEntry, bool) {
104+ if ref.IsZero() {
105+ return protocol.RecentRouteEntry{}, false
106+ }
107+ if snapshot.Active == ref {
108+ entryRef := protocol.RouteRef{Key: snapshot.ActiveEntry.Key, Generation: snapshot.ActiveEntry.Generation}
109+ if entryRef == ref {
110+ return snapshot.ActiveEntry, true
111+ }
112+ }
113+ for _, entry := range snapshot.Entries {
114+ if entry.Key == ref.Key && entry.Generation == ref.Generation {
115+ return entry, true
116+ }
117+ }
118+ return protocol.RecentRouteEntry{}, false
119+}
120+
121+func createSessionDestinationResults(snapshot protocol.RecentRouteSnapshot, currentLifecycle domain.SessionLifecycleID, remoteCatalog []remoteCatalogPresentationEntry, hostRanks map[string]int) []palette.Result {
122+ results := make([]palette.Result, 0, len(remoteCatalog)+2)
123+ servingOrigin := ""
124+ if home, ok := routeEntryForRef(snapshot, snapshot.Home); ok && home.Kind == protocol.RouteKindLocal {
125+ results = append(results, palette.NewCreateSessionDestination(
126+ palette.CreateSessionOnLocalRoute, "", "", snapshot.Home, snapshot.Generation,
127+ ))
128+ }
129+ if active, ok := activeRouteEntryForLifecycle(snapshot, currentLifecycle); ok && active.Kind == protocol.RouteKindRemote {
130+ servingOrigin = paletteRemoteDisplayOrigin(active.HostLabel)
131+ results = append(results, palette.NewCreateSessionDestination(
132+ palette.CreateSessionOnServingDaemon, servingOrigin, "", protocol.RouteRef{}, 0,
133+ ))
134+ }
135+ for _, host := range remoteCatalog {
136+ if host.status != remoteHostFresh || host.entry.Host == "" {
137+ continue
138+ }
139+ if _, configured := hostRanks[host.entry.Host]; !configured {
140+ continue
141+ }
142+ displayOrigin := paletteRemoteDisplayOrigin(host.entry.Host)
143+ if servingOrigin != "" && displayOrigin == servingOrigin {
144+ continue
145+ }
146+ results = append(results, palette.NewCreateSessionDestination(
147+ palette.CreateSessionOnRemoteHost, displayOrigin, host.entry.Host, protocol.RouteRef{}, 0,
148+ ))
149+ }
150+ return results
151+}
152+
100153 func paletteRouteRepresentsDaemonSession(entry protocol.RecentRouteEntry, daemonDisplayOrigin string) bool {
101154 if entry.Kind == protocol.RouteKindLocal {
102155 return true
@@ -135,16 +188,19 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro
135188 daemonDisplayOrigin := paletteDaemonDisplayOrigin(routeSnapshot, currentLifecycle)
136189
137190 remoteCatalog := d.remoteCatalogSnapshot()
138- sortRemoteCatalog(remoteCatalog, d.remoteHostRanks())
191+ hostRanks := d.remoteHostRanks()
192+ sortRemoteCatalog(remoteCatalog, hostRanks)
139193 remoteSessionCount := 0
140194 for _, host := range remoteCatalog {
141195 remoteSessionCount += len(host.entry.Sessions)
142196 }
143197
144- results := make([]palette.Result, 0, len(commands)+len(sessions)+len(stopped)+remoteSessionCount+len(routeSnapshot.Entries))
198+ destinations := createSessionDestinationResults(routeSnapshot, currentLifecycle, remoteCatalog, hostRanks)
199+ results := make([]palette.Result, 0, len(commands)+len(destinations)+len(sessions)+len(stopped)+remoteSessionCount+len(routeSnapshot.Entries))
145200 for _, cmd := range commands {
146201 results = append(results, palette.NewCommandResult(cmd))
147202 }
203+ results = append(results, destinations...)
148204 active := make([]palette.Result, 0, len(sessions))
149205 for _, candidate := range sessions {
150206 snap := candidate.snapshotView(viewOptions{})
@@ -345,11 +401,13 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
345401 var hasRemoteTarget bool
346402 var routeTarget protocol.RouteNavigationAction
347403 var hasRouteTarget bool
404+ var createDestination palette.Result
405+ var hasCreateDestination bool
348406 var args []string
349407 var routeSnapshot protocol.RecentRouteSnapshot
350408 var generation uint64
351409 var rawQuery string
352- changed, cancel, execute := false, false, false
410+ changed, cancel, execute, chooseDestination := false, false, false, false
353411 var closedDiscovery remoteDiscoveryInstance
354412 var effect *attachmentEffect
355413 if len(effects) != 0 {
@@ -404,6 +462,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
404462 selected, ok := ac.overlays.palette.Selected()
405463 if !ok {
406464 changed = true
465+ chooseDestination = true
407466 return
408467 }
409468 rawQuery = ac.overlays.palette.Query()
@@ -435,6 +494,9 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
435494 } else if action, ok := selected.RouteNavigationAction(); ok {
436495 routeTarget = action
437496 hasRouteTarget = true
497+ } else if _, _, _, _, _, ok := selected.CreateSessionDestination(); ok {
498+ createDestination = selected
499+ hasCreateDestination = true
438500 } else if _, ok := selected.SessionTarget(); ok {
439501 sessionTarget = selected
440502 hasSessionTarget = true
@@ -448,6 +510,9 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
448510 })
449511 if changed {
450512 ac.overlays.paletteFeedback = ""
513+ if chooseDestination {
514+ ac.overlays.paletteFeedback = "Choose a destination"
515+ }
451516 ac.overlays.palettePreview = ""
452517 active, ok := ac.overlays.palette.ArgumentCommand()
453518 if ok {
@@ -480,6 +545,35 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
480545 return
481546 }
482547
548+ if hasCreateDestination {
549+ name, _, _, _, _, _ := createDestination.CreateSessionDestination()
550+ exec := paletteExec{d: d, sess: sess, attachment: entry, ac: ac, effect: effect}
551+ if err := exec.validateCreateSessionDestination(effect, createDestination); err != nil {
552+ ac.paletteFailure(generation, rawQuery, errCreateDestinationUnavailable.Error())
553+ d.invalidateRender(entry, ac, true, "palette.go")
554+ return
555+ }
556+ if !d.closeExecutedPalette(ac, generation, rawQuery) {
557+ return
558+ }
559+ if name == "" {
560+ d.enterTransitionPrompt(entry, ac, " Create session ", "", func(name string, promptEffect *attachmentEffect) error {
561+ return exec.createSessionOnDestination(promptEffect, createDestination, name)
562+ })
563+ d.invalidateRender(entry, ac, true, "palette.go")
564+ return
565+ }
566+ err := exec.createSessionOnValidatedDestination(effect, createDestination, name)
567+ if err != nil && !errors.Is(err, errAttachmentTransition) {
568+ d.reportAttachmentError(entry, domain.UserErr(domain.NoticeSessionUnavailable, "couldn't create session", err))
569+ return
570+ }
571+ if current := ac.currentAttachmentSession(); current != nil {
572+ d.invalidateRender(current, ac, true, "palette.go")
573+ }
574+ return
575+ }
576+
483577 if hasRemoteTarget {
484578 if effect == nil {
485579 ac.paletteFailure(generation, rawQuery, "requested remote session is unavailable")
@@ -739,6 +833,110 @@ func (e paletteExec) CreateSessionNamed(name string) error {
739833 return e.d.createSessionAndSwitchForAttachment(e.effect, name)
740834 }
741835
836+func (e paletteExec) validateCreateSessionDestination(effect *attachmentEffect, result palette.Result) error {
837+ _, kind, _, endpoint, route, ok := result.CreateSessionDestination()
838+ if !ok {
839+ return errCreateDestinationUnavailable
840+ }
841+ switch kind {
842+ case palette.CreateSessionOnServingDaemon:
843+ return nil
844+ case palette.CreateSessionOnLocalRoute:
845+ if e.ac == nil {
846+ return errCreateDestinationUnavailable
847+ }
848+ snapshotGeneration, ok := result.CreateSessionSnapshotGeneration()
849+ if !ok || snapshotGeneration == 0 {
850+ return errCreateDestinationUnavailable
851+ }
852+ snapshot := e.ac.routeSnapshotCopy()
853+ entry, valid := routeEntryForRef(snapshot, route)
854+ if !valid || snapshot.Generation != snapshotGeneration || entry.Kind != protocol.RouteKindLocal {
855+ return errCreateDestinationUnavailable
856+ }
857+ if effect == nil && snapshot.Active != route {
858+ return errCreateDestinationUnavailable
859+ }
860+ return nil
861+ case palette.CreateSessionOnRemoteHost:
862+ if effect == nil || !e.d.remoteCreateHostReady(endpoint) {
863+ return errCreateDestinationUnavailable
864+ }
865+ return nil
866+ default:
867+ return errCreateDestinationUnavailable
868+ }
869+}
870+
871+func (e paletteExec) createSessionOnDestination(effect *attachmentEffect, result palette.Result, name string) error {
872+ if err := e.validateCreateSessionDestination(effect, result); err != nil {
873+ return err
874+ }
875+ return e.createSessionOnValidatedDestination(effect, result, name)
876+}
877+
878+func (e paletteExec) createSessionOnValidatedDestination(effect *attachmentEffect, result palette.Result, name string) error {
879+ if err := domain.ValidateSessionName(name); err != nil {
880+ return err
881+ }
882+ _, kind, _, endpoint, route, _ := result.CreateSessionDestination()
883+ if kind == palette.CreateSessionOnServingDaemon ||
884+ (kind == palette.CreateSessionOnLocalRoute && e.ac != nil && e.ac.routeSnapshotCopy().Active == route) {
885+ if effect == nil {
886+ return e.d.createSessionAndSwitch(e.sess, e.ac, name)
887+ }
888+ return e.d.createSessionAndSwitchForAttachment(effect, name)
889+ }
890+ if effect == nil {
891+ return errAttachmentTransition
892+ }
893+ requestID := e.d.creationRequestSeq.Add(1)
894+ if requestID == 0 {
895+ requestID = e.d.creationRequestSeq.Add(1)
896+ }
897+ switch kind {
898+ case palette.CreateSessionOnLocalRoute:
899+ snapshotGeneration, _ := result.CreateSessionSnapshotGeneration()
900+ action := protocol.RouteCreateSessionAction{
901+ RequestID: requestID, SnapshotGeneration: snapshotGeneration,
902+ Key: route.Key, Generation: route.Generation, SessionName: name,
903+ }
904+ if err := effect.sendControl(action); err != nil {
905+ return err
906+ }
907+ return nil
908+ case palette.CreateSessionOnRemoteHost:
909+ handoff := protocol.AttachTarget{
910+ RequestID: requestID, Endpoint: endpoint, Session: name, Intent: protocol.IntentNew,
911+ EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned,
912+ }
913+ if err := protocol.ValidateAttachTarget(handoff); err != nil {
914+ return errAttachmentTransition
915+ }
916+ if err := effect.sendControl(handoff); err != nil {
917+ return err
918+ }
919+ e.d.clientGoneForAttachment(effect, false)
920+ return nil
921+ default:
922+ return errAttachmentTransition
923+ }
924+}
925+
926+func (d *Daemon) remoteCreateHostReady(endpoint string) bool {
927+ if d == nil || domain.ValidateRemoteHostTarget(endpoint) != nil {
928+ return false
929+ }
930+ ranks := d.remoteHostRanks()
931+ if _, ok := ranks[endpoint]; !ok {
932+ return false
933+ }
934+ d.remoteCatalog.mu.Lock()
935+ defer d.remoteCatalog.mu.Unlock()
936+ _, ok := d.remoteCatalogEntryLocked(endpoint)
937+ return ok
938+}
939+
742940 func (e paletteExec) CreateEphemeralSession() error {
743941 if e.effect == nil {
744942 return e.d.createEphemeralSessionAndSwitch(e.sess, e.ac)
@@ -22,7 +22,10 @@ const (
22 paletteRailWidth = 6422 paletteRailWidth = 64
23 )23 )
24 24
25-var paletteModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Commands ", Anchor: domain.AnchorBottom, Margins: ui.Margins{Top: 1, Right: 1, Bottom: 1, Left: 1}}25+var (
26+ paletteModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Commands ", Anchor: domain.AnchorBottom, Margins: ui.Margins{Top: 1, Right: 1, Bottom: 1, Left: 1}}
27+ errCreateDestinationUnavailable = errors.New("that destination is no longer available")
28+)
26 29
27 func paletteModalFor(size domain.Size, cfg domain.PaletteConfig) ui.Modal {30 func paletteModalFor(size domain.Size, cfg domain.PaletteConfig) ui.Modal {
28 modal := paletteModal31 modal := paletteModal
@@ -97,6 +100,56 @@ func paletteDaemonDisplayOrigin(snapshot protocol.RecentRouteSnapshot, lifecycle
97 return paletteRemoteDisplayOrigin(active.HostLabel)100 return paletteRemoteDisplayOrigin(active.HostLabel)
98 }101 }
99 102
103+func routeEntryForRef(snapshot protocol.RecentRouteSnapshot, ref protocol.RouteRef) (protocol.RecentRouteEntry, bool) {
104+ if ref.IsZero() {
105+ return protocol.RecentRouteEntry{}, false
106+ }
107+ if snapshot.Active == ref {
108+ entryRef := protocol.RouteRef{Key: snapshot.ActiveEntry.Key, Generation: snapshot.ActiveEntry.Generation}
109+ if entryRef == ref {
110+ return snapshot.ActiveEntry, true
111+ }
112+ }
113+ for _, entry := range snapshot.Entries {
114+ if entry.Key == ref.Key && entry.Generation == ref.Generation {
115+ return entry, true
116+ }
117+ }
118+ return protocol.RecentRouteEntry{}, false
119+}
120+
121+func createSessionDestinationResults(snapshot protocol.RecentRouteSnapshot, currentLifecycle domain.SessionLifecycleID, remoteCatalog []remoteCatalogPresentationEntry, hostRanks map[string]int) []palette.Result {
122+ results := make([]palette.Result, 0, len(remoteCatalog)+2)
123+ servingOrigin := ""
124+ if home, ok := routeEntryForRef(snapshot, snapshot.Home); ok && home.Kind == protocol.RouteKindLocal {
125+ results = append(results, palette.NewCreateSessionDestination(
126+ palette.CreateSessionOnLocalRoute, "", "", snapshot.Home, snapshot.Generation,
127+ ))
128+ }
129+ if active, ok := activeRouteEntryForLifecycle(snapshot, currentLifecycle); ok && active.Kind == protocol.RouteKindRemote {
130+ servingOrigin = paletteRemoteDisplayOrigin(active.HostLabel)
131+ results = append(results, palette.NewCreateSessionDestination(
132+ palette.CreateSessionOnServingDaemon, servingOrigin, "", protocol.RouteRef{}, 0,
133+ ))
134+ }
135+ for _, host := range remoteCatalog {
136+ if host.status != remoteHostFresh || host.entry.Host == "" {
137+ continue
138+ }
139+ if _, configured := hostRanks[host.entry.Host]; !configured {
140+ continue
141+ }
142+ displayOrigin := paletteRemoteDisplayOrigin(host.entry.Host)
143+ if servingOrigin != "" && displayOrigin == servingOrigin {
144+ continue
145+ }
146+ results = append(results, palette.NewCreateSessionDestination(
147+ palette.CreateSessionOnRemoteHost, displayOrigin, host.entry.Host, protocol.RouteRef{}, 0,
148+ ))
149+ }
150+ return results
151+}
152+
100 func paletteRouteRepresentsDaemonSession(entry protocol.RecentRouteEntry, daemonDisplayOrigin string) bool {153 func paletteRouteRepresentsDaemonSession(entry protocol.RecentRouteEntry, daemonDisplayOrigin string) bool {
101 if entry.Kind == protocol.RouteKindLocal {154 if entry.Kind == protocol.RouteKindLocal {
102 return true155 return true
@@ -135,16 +188,19 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro
135 daemonDisplayOrigin := paletteDaemonDisplayOrigin(routeSnapshot, currentLifecycle)188 daemonDisplayOrigin := paletteDaemonDisplayOrigin(routeSnapshot, currentLifecycle)
136 189
137 remoteCatalog := d.remoteCatalogSnapshot()190 remoteCatalog := d.remoteCatalogSnapshot()
138- sortRemoteCatalog(remoteCatalog, d.remoteHostRanks())191+ hostRanks := d.remoteHostRanks()
192+ sortRemoteCatalog(remoteCatalog, hostRanks)
139 remoteSessionCount := 0193 remoteSessionCount := 0
140 for _, host := range remoteCatalog {194 for _, host := range remoteCatalog {
141 remoteSessionCount += len(host.entry.Sessions)195 remoteSessionCount += len(host.entry.Sessions)
142 }196 }
143 197
144- results := make([]palette.Result, 0, len(commands)+len(sessions)+len(stopped)+remoteSessionCount+len(routeSnapshot.Entries))198+ destinations := createSessionDestinationResults(routeSnapshot, currentLifecycle, remoteCatalog, hostRanks)
199+ results := make([]palette.Result, 0, len(commands)+len(destinations)+len(sessions)+len(stopped)+remoteSessionCount+len(routeSnapshot.Entries))
145 for _, cmd := range commands {200 for _, cmd := range commands {
146 results = append(results, palette.NewCommandResult(cmd))201 results = append(results, palette.NewCommandResult(cmd))
147 }202 }
203+ results = append(results, destinations...)
148 active := make([]palette.Result, 0, len(sessions))204 active := make([]palette.Result, 0, len(sessions))
149 for _, candidate := range sessions {205 for _, candidate := range sessions {
150 snap := candidate.snapshotView(viewOptions{})206 snap := candidate.snapshotView(viewOptions{})
@@ -345,11 +401,13 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
345 var hasRemoteTarget bool401 var hasRemoteTarget bool
346 var routeTarget protocol.RouteNavigationAction402 var routeTarget protocol.RouteNavigationAction
347 var hasRouteTarget bool403 var hasRouteTarget bool
404+ var createDestination palette.Result
405+ var hasCreateDestination bool
348 var args []string406 var args []string
349 var routeSnapshot protocol.RecentRouteSnapshot407 var routeSnapshot protocol.RecentRouteSnapshot
350 var generation uint64408 var generation uint64
351 var rawQuery string409 var rawQuery string
352- changed, cancel, execute := false, false, false410+ changed, cancel, execute, chooseDestination := false, false, false, false
353 var closedDiscovery remoteDiscoveryInstance411 var closedDiscovery remoteDiscoveryInstance
354 var effect *attachmentEffect412 var effect *attachmentEffect
355 if len(effects) != 0 {413 if len(effects) != 0 {
@@ -404,6 +462,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
404 selected, ok := ac.overlays.palette.Selected()462 selected, ok := ac.overlays.palette.Selected()
405 if !ok {463 if !ok {
406 changed = true464 changed = true
465+ chooseDestination = true
407 return466 return
408 }467 }
409 rawQuery = ac.overlays.palette.Query()468 rawQuery = ac.overlays.palette.Query()
@@ -435,6 +494,9 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
435 } else if action, ok := selected.RouteNavigationAction(); ok {494 } else if action, ok := selected.RouteNavigationAction(); ok {
436 routeTarget = action495 routeTarget = action
437 hasRouteTarget = true496 hasRouteTarget = true
497+ } else if _, _, _, _, _, ok := selected.CreateSessionDestination(); ok {
498+ createDestination = selected
499+ hasCreateDestination = true
438 } else if _, ok := selected.SessionTarget(); ok {500 } else if _, ok := selected.SessionTarget(); ok {
439 sessionTarget = selected501 sessionTarget = selected
440 hasSessionTarget = true502 hasSessionTarget = true
@@ -448,6 +510,9 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
448 })510 })
449 if changed {511 if changed {
450 ac.overlays.paletteFeedback = ""512 ac.overlays.paletteFeedback = ""
513+ if chooseDestination {
514+ ac.overlays.paletteFeedback = "Choose a destination"
515+ }
451 ac.overlays.palettePreview = ""516 ac.overlays.palettePreview = ""
452 active, ok := ac.overlays.palette.ArgumentCommand()517 active, ok := ac.overlays.palette.ArgumentCommand()
453 if ok {518 if ok {
@@ -480,6 +545,35 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
480 return545 return
481 }546 }
482 547
548+ if hasCreateDestination {
549+ name, _, _, _, _, _ := createDestination.CreateSessionDestination()
550+ exec := paletteExec{d: d, sess: sess, attachment: entry, ac: ac, effect: effect}
551+ if err := exec.validateCreateSessionDestination(effect, createDestination); err != nil {
552+ ac.paletteFailure(generation, rawQuery, errCreateDestinationUnavailable.Error())
553+ d.invalidateRender(entry, ac, true, "palette.go")
554+ return
555+ }
556+ if !d.closeExecutedPalette(ac, generation, rawQuery) {
557+ return
558+ }
559+ if name == "" {
560+ d.enterTransitionPrompt(entry, ac, " Create session ", "", func(name string, promptEffect *attachmentEffect) error {
561+ return exec.createSessionOnDestination(promptEffect, createDestination, name)
562+ })
563+ d.invalidateRender(entry, ac, true, "palette.go")
564+ return
565+ }
566+ err := exec.createSessionOnValidatedDestination(effect, createDestination, name)
567+ if err != nil && !errors.Is(err, errAttachmentTransition) {
568+ d.reportAttachmentError(entry, domain.UserErr(domain.NoticeSessionUnavailable, "couldn't create session", err))
569+ return
570+ }
571+ if current := ac.currentAttachmentSession(); current != nil {
572+ d.invalidateRender(current, ac, true, "palette.go")
573+ }
574+ return
575+ }
576+
483 if hasRemoteTarget {577 if hasRemoteTarget {
484 if effect == nil {578 if effect == nil {
485 ac.paletteFailure(generation, rawQuery, "requested remote session is unavailable")579 ac.paletteFailure(generation, rawQuery, "requested remote session is unavailable")
@@ -739,6 +833,110 @@ func (e paletteExec) CreateSessionNamed(name string) error {
739 return e.d.createSessionAndSwitchForAttachment(e.effect, name)833 return e.d.createSessionAndSwitchForAttachment(e.effect, name)
740 }834 }
741 835
836+func (e paletteExec) validateCreateSessionDestination(effect *attachmentEffect, result palette.Result) error {
837+ _, kind, _, endpoint, route, ok := result.CreateSessionDestination()
838+ if !ok {
839+ return errCreateDestinationUnavailable
840+ }
841+ switch kind {
842+ case palette.CreateSessionOnServingDaemon:
843+ return nil
844+ case palette.CreateSessionOnLocalRoute:
845+ if e.ac == nil {
846+ return errCreateDestinationUnavailable
847+ }
848+ snapshotGeneration, ok := result.CreateSessionSnapshotGeneration()
849+ if !ok || snapshotGeneration == 0 {
850+ return errCreateDestinationUnavailable
851+ }
852+ snapshot := e.ac.routeSnapshotCopy()
853+ entry, valid := routeEntryForRef(snapshot, route)
854+ if !valid || snapshot.Generation != snapshotGeneration || entry.Kind != protocol.RouteKindLocal {
855+ return errCreateDestinationUnavailable
856+ }
857+ if effect == nil && snapshot.Active != route {
858+ return errCreateDestinationUnavailable
859+ }
860+ return nil
861+ case palette.CreateSessionOnRemoteHost:
862+ if effect == nil || !e.d.remoteCreateHostReady(endpoint) {
863+ return errCreateDestinationUnavailable
864+ }
865+ return nil
866+ default:
867+ return errCreateDestinationUnavailable
868+ }
869+}
870+
871+func (e paletteExec) createSessionOnDestination(effect *attachmentEffect, result palette.Result, name string) error {
872+ if err := e.validateCreateSessionDestination(effect, result); err != nil {
873+ return err
874+ }
875+ return e.createSessionOnValidatedDestination(effect, result, name)
876+}
877+
878+func (e paletteExec) createSessionOnValidatedDestination(effect *attachmentEffect, result palette.Result, name string) error {
879+ if err := domain.ValidateSessionName(name); err != nil {
880+ return err
881+ }
882+ _, kind, _, endpoint, route, _ := result.CreateSessionDestination()
883+ if kind == palette.CreateSessionOnServingDaemon ||
884+ (kind == palette.CreateSessionOnLocalRoute && e.ac != nil && e.ac.routeSnapshotCopy().Active == route) {
885+ if effect == nil {
886+ return e.d.createSessionAndSwitch(e.sess, e.ac, name)
887+ }
888+ return e.d.createSessionAndSwitchForAttachment(effect, name)
889+ }
890+ if effect == nil {
891+ return errAttachmentTransition
892+ }
893+ requestID := e.d.creationRequestSeq.Add(1)
894+ if requestID == 0 {
895+ requestID = e.d.creationRequestSeq.Add(1)
896+ }
897+ switch kind {
898+ case palette.CreateSessionOnLocalRoute:
899+ snapshotGeneration, _ := result.CreateSessionSnapshotGeneration()
900+ action := protocol.RouteCreateSessionAction{
901+ RequestID: requestID, SnapshotGeneration: snapshotGeneration,
902+ Key: route.Key, Generation: route.Generation, SessionName: name,
903+ }
904+ if err := effect.sendControl(action); err != nil {
905+ return err
906+ }
907+ return nil
908+ case palette.CreateSessionOnRemoteHost:
909+ handoff := protocol.AttachTarget{
910+ RequestID: requestID, Endpoint: endpoint, Session: name, Intent: protocol.IntentNew,
911+ EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned,
912+ }
913+ if err := protocol.ValidateAttachTarget(handoff); err != nil {
914+ return errAttachmentTransition
915+ }
916+ if err := effect.sendControl(handoff); err != nil {
917+ return err
918+ }
919+ e.d.clientGoneForAttachment(effect, false)
920+ return nil
921+ default:
922+ return errAttachmentTransition
923+ }
924+}
925+
926+func (d *Daemon) remoteCreateHostReady(endpoint string) bool {
927+ if d == nil || domain.ValidateRemoteHostTarget(endpoint) != nil {
928+ return false
929+ }
930+ ranks := d.remoteHostRanks()
931+ if _, ok := ranks[endpoint]; !ok {
932+ return false
933+ }
934+ d.remoteCatalog.mu.Lock()
935+ defer d.remoteCatalog.mu.Unlock()
936+ _, ok := d.remoteCatalogEntryLocked(endpoint)
937+ return ok
938+}
939+
742 func (e paletteExec) CreateEphemeralSession() error {940 func (e paletteExec) CreateEphemeralSession() error {
743 if e.effect == nil {941 if e.effect == nil {
744 return e.d.createEphemeralSessionAndSwitch(e.sess, e.ac)942 return e.d.createEphemeralSessionAndSwitch(e.sess, e.ac)
M internal/usecase/daemon/palette_test.go +70 -0
@@ -47,6 +47,76 @@ func beginRecentRoutePaletteEffect(t *testing.T, d *Daemon, sess *session, ac *a
4747 return effect
4848 }
4949
50+func TestCreateSessionDestinationResultsUseStructuredRouteAuthority(t *testing.T) {
51+ lifecycle := domain.SessionLifecycleID{1}
52+ snapshot := protocol.RecentRouteSnapshot{
53+ Generation: 4,
54+ Active: protocol.RouteRef{Key: 2, Generation: 3},
55+ ActiveEntry: protocol.RecentRouteEntry{
56+ Key: 2, Generation: 3, Kind: protocol.RouteKindRemote, HostLabel: "host-a",
57+ Target: protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "work"}, Name: "work",
58+ },
59+ Home: protocol.RouteRef{Key: 1, Generation: 2},
60+ Entries: []protocol.RecentRouteEntry{{
61+ Key: 1, Generation: 2, Kind: protocol.RouteKindLocal,
62+ Target: testRouteTarget("home", 2), Name: "home",
63+ }},
64+ }
65+ catalog := []remoteCatalogPresentationEntry{
66+ {entry: catalogue.RemoteCatalogCacheEntry{Host: "host-a"}, status: remoteHostFresh},
67+ {entry: catalogue.RemoteCatalogCacheEntry{Host: "host-b"}, status: remoteHostFresh},
68+ {entry: catalogue.RemoteCatalogCacheEntry{Host: "host-c"}, status: remoteHostStale},
69+ {entry: catalogue.RemoteCatalogCacheEntry{Host: "host-d"}, status: remoteHostFresh},
70+ }
71+
72+ results := createSessionDestinationResults(snapshot, lifecycle, catalog, map[string]int{"host-a": 0, "host-b": 1, "host-c": 2})
73+ require.Len(t, results, 3)
74+ _, kind, _, endpoint, route, ok := results[0].CreateSessionDestination()
75+ require.True(t, ok)
76+ require.Equal(t, palette.CreateSessionOnLocalRoute, kind)
77+ require.Equal(t, snapshot.Home, route)
78+ require.Empty(t, endpoint)
79+ snapshotGeneration, ok := results[0].CreateSessionSnapshotGeneration()
80+ require.True(t, ok)
81+ require.Equal(t, snapshot.Generation, snapshotGeneration)
82+ _, kind, origin, endpoint, _, ok := results[1].CreateSessionDestination()
83+ require.True(t, ok)
84+ require.Equal(t, palette.CreateSessionOnServingDaemon, kind)
85+ require.Equal(t, "host-a", origin)
86+ require.Empty(t, endpoint)
87+ _, kind, origin, endpoint, _, ok = results[2].CreateSessionDestination()
88+ require.True(t, ok)
89+ require.Equal(t, palette.CreateSessionOnRemoteHost, kind)
90+ require.Equal(t, "host-b", origin)
91+ require.Equal(t, "host-b", endpoint)
92+}
93+
94+func TestRemoteCreateSessionDestinationRequiresAttachmentEffect(t *testing.T) {
95+ result := palette.NewCreateSessionDestination(
96+ palette.CreateSessionOnRemoteHost, "host-a", "host-a", protocol.RouteRef{}, 0,
97+ )
98+ err := (paletteExec{}).validateCreateSessionDestination(nil, result)
99+ require.ErrorIs(t, err, errCreateDestinationUnavailable)
100+}
101+
102+func TestCreateSessionDestinationResultsDoNotInferLocalFromRemoteHome(t *testing.T) {
103+ lifecycle := domain.SessionLifecycleID{1}
104+ snapshot := protocol.RecentRouteSnapshot{
105+ Generation: 1,
106+ Active: protocol.RouteRef{Key: 1, Generation: 1},
107+ Home: protocol.RouteRef{Key: 1, Generation: 1},
108+ ActiveEntry: protocol.RecentRouteEntry{
109+ Key: 1, Generation: 1, Kind: protocol.RouteKindRemote, HostLabel: "host-a",
110+ Target: protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "work"}, Name: "work",
111+ },
112+ }
113+ results := createSessionDestinationResults(snapshot, lifecycle, nil, nil)
114+ require.Len(t, results, 1)
115+ _, kind, _, _, _, ok := results[0].CreateSessionDestination()
116+ require.True(t, ok)
117+ require.Equal(t, palette.CreateSessionOnServingDaemon, kind)
118+}
119+
50120 func TestCaptureOverlayLayersPreservesPaletteDescriptionSurfaceAcrossFallbacks(t *testing.T) {
51121 paletteColors := [16]renderer.RGB{}
52122 paletteColors[2] = renderer.RGB{R: 10, G: 230, B: 120}
@@ -47,6 +47,76 @@ func beginRecentRoutePaletteEffect(t *testing.T, d *Daemon, sess *session, ac *a
47 return effect47 return effect
48 }48 }
49 49
50+func TestCreateSessionDestinationResultsUseStructuredRouteAuthority(t *testing.T) {
51+ lifecycle := domain.SessionLifecycleID{1}
52+ snapshot := protocol.RecentRouteSnapshot{
53+ Generation: 4,
54+ Active: protocol.RouteRef{Key: 2, Generation: 3},
55+ ActiveEntry: protocol.RecentRouteEntry{
56+ Key: 2, Generation: 3, Kind: protocol.RouteKindRemote, HostLabel: "host-a",
57+ Target: protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "work"}, Name: "work",
58+ },
59+ Home: protocol.RouteRef{Key: 1, Generation: 2},
60+ Entries: []protocol.RecentRouteEntry{{
61+ Key: 1, Generation: 2, Kind: protocol.RouteKindLocal,
62+ Target: testRouteTarget("home", 2), Name: "home",
63+ }},
64+ }
65+ catalog := []remoteCatalogPresentationEntry{
66+ {entry: catalogue.RemoteCatalogCacheEntry{Host: "host-a"}, status: remoteHostFresh},
67+ {entry: catalogue.RemoteCatalogCacheEntry{Host: "host-b"}, status: remoteHostFresh},
68+ {entry: catalogue.RemoteCatalogCacheEntry{Host: "host-c"}, status: remoteHostStale},
69+ {entry: catalogue.RemoteCatalogCacheEntry{Host: "host-d"}, status: remoteHostFresh},
70+ }
71+
72+ results := createSessionDestinationResults(snapshot, lifecycle, catalog, map[string]int{"host-a": 0, "host-b": 1, "host-c": 2})
73+ require.Len(t, results, 3)
74+ _, kind, _, endpoint, route, ok := results[0].CreateSessionDestination()
75+ require.True(t, ok)
76+ require.Equal(t, palette.CreateSessionOnLocalRoute, kind)
77+ require.Equal(t, snapshot.Home, route)
78+ require.Empty(t, endpoint)
79+ snapshotGeneration, ok := results[0].CreateSessionSnapshotGeneration()
80+ require.True(t, ok)
81+ require.Equal(t, snapshot.Generation, snapshotGeneration)
82+ _, kind, origin, endpoint, _, ok := results[1].CreateSessionDestination()
83+ require.True(t, ok)
84+ require.Equal(t, palette.CreateSessionOnServingDaemon, kind)
85+ require.Equal(t, "host-a", origin)
86+ require.Empty(t, endpoint)
87+ _, kind, origin, endpoint, _, ok = results[2].CreateSessionDestination()
88+ require.True(t, ok)
89+ require.Equal(t, palette.CreateSessionOnRemoteHost, kind)
90+ require.Equal(t, "host-b", origin)
91+ require.Equal(t, "host-b", endpoint)
92+}
93+
94+func TestRemoteCreateSessionDestinationRequiresAttachmentEffect(t *testing.T) {
95+ result := palette.NewCreateSessionDestination(
96+ palette.CreateSessionOnRemoteHost, "host-a", "host-a", protocol.RouteRef{}, 0,
97+ )
98+ err := (paletteExec{}).validateCreateSessionDestination(nil, result)
99+ require.ErrorIs(t, err, errCreateDestinationUnavailable)
100+}
101+
102+func TestCreateSessionDestinationResultsDoNotInferLocalFromRemoteHome(t *testing.T) {
103+ lifecycle := domain.SessionLifecycleID{1}
104+ snapshot := protocol.RecentRouteSnapshot{
105+ Generation: 1,
106+ Active: protocol.RouteRef{Key: 1, Generation: 1},
107+ Home: protocol.RouteRef{Key: 1, Generation: 1},
108+ ActiveEntry: protocol.RecentRouteEntry{
109+ Key: 1, Generation: 1, Kind: protocol.RouteKindRemote, HostLabel: "host-a",
110+ Target: protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "work"}, Name: "work",
111+ },
112+ }
113+ results := createSessionDestinationResults(snapshot, lifecycle, nil, nil)
114+ require.Len(t, results, 1)
115+ _, kind, _, _, _, ok := results[0].CreateSessionDestination()
116+ require.True(t, ok)
117+ require.Equal(t, palette.CreateSessionOnServingDaemon, kind)
118+}
119+
50 func TestCaptureOverlayLayersPreservesPaletteDescriptionSurfaceAcrossFallbacks(t *testing.T) {120 func TestCaptureOverlayLayersPreservesPaletteDescriptionSurfaceAcrossFallbacks(t *testing.T) {
51 paletteColors := [16]renderer.RGB{}121 paletteColors := [16]renderer.RGB{}
52 paletteColors[2] = renderer.RGB{R: 10, G: 230, B: 120}122 paletteColors[2] = renderer.RGB{R: 10, G: 230, B: 120}
M internal/usecase/daemon/prompt_test.go +30 -0
@@ -81,6 +81,36 @@ func TestPromptSubmitErrorKeepsPromptOpen(t *testing.T) {
8181 require.Contains(t, string(out.Data), "name already in use")
8282 }
8383
84+func TestTransitionPromptFailuresStayVisible(t *testing.T) {
85+ tests := []struct {
86+ name string
87+ submit error
88+ wantErr string
89+ }{
90+ {name: "destination unavailable", submit: errCreateDestinationUnavailable, wantErr: "that destination is no longer available"},
91+ {name: "transition failed", submit: errors.New("transition failed"), wantErr: "transition failed"},
92+ }
93+ for _, tt := range tests {
94+ t.Run(tt.name, func(t *testing.T) {
95+ p, release := newBlockingPTY(t)
96+ defer release()
97+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
98+
99+ d.enterTransitionPrompt(sess, ac, " Create session ", "", func(string, *attachmentEffect) error {
100+ return tt.submit
101+ })
102+ awaitFrame(t, sends, wire.MsgOutput)
103+ d.handlePromptInput(ac, []byte("example\r"))
104+ repaint := awaitFrame(t, sends, wire.MsgOutput)
105+
106+ require.True(t, ac.overlays.promptActive())
107+ out, err := wire.UnmarshalOutput(repaint.Payload)
108+ require.NoError(t, err)
109+ require.Contains(t, string(out.Data), tt.wantErr)
110+ })
111+ }
112+}
113+
84114 func TestPromptSubmitSessionSpawnFailureReportsOneSafeNotice(t *testing.T) {
85115 p, release := newBlockingPTY(t)
86116 defer release()
@@ -81,6 +81,36 @@ func TestPromptSubmitErrorKeepsPromptOpen(t *testing.T) {
81 require.Contains(t, string(out.Data), "name already in use")81 require.Contains(t, string(out.Data), "name already in use")
82 }82 }
83 83
84+func TestTransitionPromptFailuresStayVisible(t *testing.T) {
85+ tests := []struct {
86+ name string
87+ submit error
88+ wantErr string
89+ }{
90+ {name: "destination unavailable", submit: errCreateDestinationUnavailable, wantErr: "that destination is no longer available"},
91+ {name: "transition failed", submit: errors.New("transition failed"), wantErr: "transition failed"},
92+ }
93+ for _, tt := range tests {
94+ t.Run(tt.name, func(t *testing.T) {
95+ p, release := newBlockingPTY(t)
96+ defer release()
97+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
98+
99+ d.enterTransitionPrompt(sess, ac, " Create session ", "", func(string, *attachmentEffect) error {
100+ return tt.submit
101+ })
102+ awaitFrame(t, sends, wire.MsgOutput)
103+ d.handlePromptInput(ac, []byte("example\r"))
104+ repaint := awaitFrame(t, sends, wire.MsgOutput)
105+
106+ require.True(t, ac.overlays.promptActive())
107+ out, err := wire.UnmarshalOutput(repaint.Payload)
108+ require.NoError(t, err)
109+ require.Contains(t, string(out.Data), tt.wantErr)
110+ })
111+ }
112+}
113+
84 func TestPromptSubmitSessionSpawnFailureReportsOneSafeNotice(t *testing.T) {114 func TestPromptSubmitSessionSpawnFailureReportsOneSafeNotice(t *testing.T) {
85 p, release := newBlockingPTY(t)115 p, release := newBlockingPTY(t)
86 defer release()116 defer release()
M internal/usecase/daemon/recent_route_presentation.go +4 -13
@@ -40,15 +40,10 @@ func formatRecentRoutePresentations(entries []recentRoutePresentation) []recentR
4040 if entries == nil {
4141 return nil
4242 }
43- nameCounts := make(map[string]int, len(entries))
44- for _, entry := range entries {
45- nameCounts[entry.name]++
46- }
47-
4843 out := make([]recentRouteDisplay, len(entries))
4944 for i, entry := range entries {
5045 out[i] = recentRouteDisplay{
51- name: formatRecentRouteName(entry, nameCounts[entry.name] > 1),
46+ name: formatRecentRouteName(entry),
5247 kind: entry.kind,
5348 ephemeral: entry.ephemeral,
5449 attention: entry.attention,
@@ -57,22 +52,18 @@ func formatRecentRoutePresentations(entries []recentRoutePresentation) []recentR
5752 return out
5853 }
5954
60-func formatRecentRouteName(entry recentRoutePresentation, ambiguous bool) string {
55+func formatRecentRouteName(entry recentRoutePresentation) string {
6156 name := entry.name
6257 if entry.ephemeral {
6358 name += "*"
6459 }
65- if entry.kind != recentRouteRemote && !ambiguous {
60+ if entry.kind != recentRouteRemote {
6661 return name
6762 }
6863
6964 host := entry.hostLabel
7065 if host == "" {
71- if entry.kind == recentRouteRemote {
72- host = "remote"
73- } else {
74- host = "local"
75- }
66+ host = "remote"
7667 }
7768 return domain.RemoteSessionDisplay(name, host)
7869 }
@@ -40,15 +40,10 @@ func formatRecentRoutePresentations(entries []recentRoutePresentation) []recentR
40 if entries == nil {40 if entries == nil {
41 return nil41 return nil
42 }42 }
43- nameCounts := make(map[string]int, len(entries))
44- for _, entry := range entries {
45- nameCounts[entry.name]++
46- }
47-
48 out := make([]recentRouteDisplay, len(entries))43 out := make([]recentRouteDisplay, len(entries))
49 for i, entry := range entries {44 for i, entry := range entries {
50 out[i] = recentRouteDisplay{45 out[i] = recentRouteDisplay{
51- name: formatRecentRouteName(entry, nameCounts[entry.name] > 1),46+ name: formatRecentRouteName(entry),
52 kind: entry.kind,47 kind: entry.kind,
53 ephemeral: entry.ephemeral,48 ephemeral: entry.ephemeral,
54 attention: entry.attention,49 attention: entry.attention,
@@ -57,22 +52,18 @@ func formatRecentRoutePresentations(entries []recentRoutePresentation) []recentR
57 return out52 return out
58 }53 }
59 54
60-func formatRecentRouteName(entry recentRoutePresentation, ambiguous bool) string {55+func formatRecentRouteName(entry recentRoutePresentation) string {
61 name := entry.name56 name := entry.name
62 if entry.ephemeral {57 if entry.ephemeral {
63 name += "*"58 name += "*"
64 }59 }
65- if entry.kind != recentRouteRemote && !ambiguous {60+ if entry.kind != recentRouteRemote {
66 return name61 return name
67 }62 }
68 63
69 host := entry.hostLabel64 host := entry.hostLabel
70 if host == "" {65 if host == "" {
71- if entry.kind == recentRouteRemote {66+ host = "remote"
72- host = "remote"
73- } else {
74- host = "local"
75- }
76 }67 }
77 return domain.RemoteSessionDisplay(name, host)68 return domain.RemoteSessionDisplay(name, host)
78 }69 }
M internal/usecase/daemon/recent_route_presentation_test.go +17 -6
@@ -34,16 +34,27 @@ func TestFormatRecentRoutePresentations(t *testing.T) {
3434 }},
3535 },
3636 {
37- name: "duplicate local and remote names qualify every entry",
37+ name: "duplicate local and remote names keep local compact",
3838 input: []recentRoutePresentation{
3939 {name: "logs", kind: recentRouteLocal},
4040 {name: "logs", hostLabel: "edge", kind: recentRouteRemote},
4141 },
4242 want: []recentRouteDisplay{
43- {name: "logs@local", kind: recentRouteLocal},
43+ {name: "logs", kind: recentRouteLocal},
4444 {name: "logs@edge", kind: recentRouteRemote},
4545 },
4646 },
47+ {
48+ name: "duplicate local generations stay compact",
49+ input: []recentRoutePresentation{
50+ {name: "logs", kind: recentRouteLocal},
51+ {name: "logs", kind: recentRouteLocal},
52+ },
53+ want: []recentRouteDisplay{
54+ {name: "logs", kind: recentRouteLocal},
55+ {name: "logs", kind: recentRouteLocal},
56+ },
57+ },
4758 {
4859 name: "missing host uses kind fallback",
4960 input: []recentRoutePresentation{{
@@ -86,8 +97,8 @@ func TestRecentSessionHintsAndRankedRenderingShareCanonicalLabels(t *testing.T)
8697
8798 hints := recentRouteHints(snapshot, nil)
8899 require.Equal(t, []palette.RecentSessionHint{
89- {Rank: 1, Name: "logs@local", SnapshotGeneration: 1, Key: 1, Generation: 1},
90- {Rank: 2, Name: "logs@local", SnapshotGeneration: 1, Key: 2, Generation: 1},
100+ {Rank: 1, Name: "logs", SnapshotGeneration: 1, Key: 1, Generation: 1},
101+ {Rank: 2, Name: "logs", SnapshotGeneration: 1, Key: 2, Generation: 1},
91102 }, hints.Recent)
92103
93104 hints = palette.ContextualHints{
@@ -101,8 +112,8 @@ func TestRecentSessionHintsAndRankedRenderingShareCanonicalLabels(t *testing.T)
101112 ranked := rankedRecentForHintsWithSnapshot(&hints, snapshot)
102113
103114 require.Equal(t, []rankedRecent{
104- {rank: 4, name: "logs@local", kind: recentRouteLocal, attention: true},
105- {rank: 9, name: "logs@local", kind: recentRouteLocal, selected: true},
115+ {rank: 4, name: "logs", kind: recentRouteLocal, attention: true},
116+ {rank: 9, name: "logs", kind: recentRouteLocal, selected: true},
106117 }, ranked)
107118 }
108119
@@ -34,16 +34,27 @@ func TestFormatRecentRoutePresentations(t *testing.T) {
34 }},34 }},
35 },35 },
36 {36 {
37- name: "duplicate local and remote names qualify every entry",37+ name: "duplicate local and remote names keep local compact",
38 input: []recentRoutePresentation{38 input: []recentRoutePresentation{
39 {name: "logs", kind: recentRouteLocal},39 {name: "logs", kind: recentRouteLocal},
40 {name: "logs", hostLabel: "edge", kind: recentRouteRemote},40 {name: "logs", hostLabel: "edge", kind: recentRouteRemote},
41 },41 },
42 want: []recentRouteDisplay{42 want: []recentRouteDisplay{
43- {name: "logs@local", kind: recentRouteLocal},43+ {name: "logs", kind: recentRouteLocal},
44 {name: "logs@edge", kind: recentRouteRemote},44 {name: "logs@edge", kind: recentRouteRemote},
45 },45 },
46 },46 },
47+ {
48+ name: "duplicate local generations stay compact",
49+ input: []recentRoutePresentation{
50+ {name: "logs", kind: recentRouteLocal},
51+ {name: "logs", kind: recentRouteLocal},
52+ },
53+ want: []recentRouteDisplay{
54+ {name: "logs", kind: recentRouteLocal},
55+ {name: "logs", kind: recentRouteLocal},
56+ },
57+ },
47 {58 {
48 name: "missing host uses kind fallback",59 name: "missing host uses kind fallback",
49 input: []recentRoutePresentation{{60 input: []recentRoutePresentation{{
@@ -86,8 +97,8 @@ func TestRecentSessionHintsAndRankedRenderingShareCanonicalLabels(t *testing.T)
86 97
87 hints := recentRouteHints(snapshot, nil)98 hints := recentRouteHints(snapshot, nil)
88 require.Equal(t, []palette.RecentSessionHint{99 require.Equal(t, []palette.RecentSessionHint{
89- {Rank: 1, Name: "logs@local", SnapshotGeneration: 1, Key: 1, Generation: 1},100+ {Rank: 1, Name: "logs", SnapshotGeneration: 1, Key: 1, Generation: 1},
90- {Rank: 2, Name: "logs@local", SnapshotGeneration: 1, Key: 2, Generation: 1},101+ {Rank: 2, Name: "logs", SnapshotGeneration: 1, Key: 2, Generation: 1},
91 }, hints.Recent)102 }, hints.Recent)
92 103
93 hints = palette.ContextualHints{104 hints = palette.ContextualHints{
@@ -101,8 +112,8 @@ func TestRecentSessionHintsAndRankedRenderingShareCanonicalLabels(t *testing.T)
101 ranked := rankedRecentForHintsWithSnapshot(&hints, snapshot)112 ranked := rankedRecentForHintsWithSnapshot(&hints, snapshot)
102 113
103 require.Equal(t, []rankedRecent{114 require.Equal(t, []rankedRecent{
104- {rank: 4, name: "logs@local", kind: recentRouteLocal, attention: true},115+ {rank: 4, name: "logs", kind: recentRouteLocal, attention: true},
105- {rank: 9, name: "logs@local", kind: recentRouteLocal, selected: true},116+ {rank: 9, name: "logs", kind: recentRouteLocal, selected: true},
106 }, ranked)117 }, ranked)
107 }118 }
108 119
M internal/usecase/daemon/status.go +1 -1
@@ -203,7 +203,7 @@ func (s *session) statusSegmentsFor(ac *attachedClient, includeTerminalTitle boo
203203 presentation.kind = recentRouteRemote
204204 }
205205 }
206- name := formatRecentRouteName(presentation, false)
206+ name := formatRecentRouteName(presentation)
207207 snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))}
208208 activeIndex := 0
209209 if ac != nil {
@@ -203,7 +203,7 @@ func (s *session) statusSegmentsFor(ac *attachedClient, includeTerminalTitle boo
203 presentation.kind = recentRouteRemote203 presentation.kind = recentRouteRemote
204 }204 }
205 }205 }
206- name := formatRecentRouteName(presentation, false)206+ name := formatRecentRouteName(presentation)
207 snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))}207 snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))}
208 activeIndex := 0208 activeIndex := 0
209 if ac != nil {209 if ac != nil {
M internal/usecase/palette/model.go +105 -19
@@ -36,20 +36,24 @@ type RenderOptions struct {
3636 }
3737
3838 type Model struct {
39- results []Result
40- argumentResults []Result
41- input ui.TextInput
42- matches []Match
43- selected int
44- scroll int
39+ results []Result
40+ argumentResults []Result
41+ createDestinations []Result
42+ input ui.TextInput
43+ matches []Match
44+ selected int
45+ scroll int
46+ destinationMode bool
47+ selectionMade bool
4548 }
4649
4750 // New accepts typed immutable palette results.
4851 func New(results []Result) *Model {
49- copied := append([]Result(nil), results...)
52+ ordinary, destinations := splitResults(results)
5053 m := &Model{
51- results: copied,
52- argumentResults: requiredArgumentResults(copied),
54+ results: ordinary,
55+ argumentResults: requiredArgumentResults(ordinary),
56+ createDestinations: destinations,
5357 }
5458 m.refresh()
5559 return m
@@ -63,20 +67,32 @@ func (m *Model) ReplaceResults(results []Result) {
6367 if m == nil {
6468 return
6569 }
70+ wasDestinationMode := m.destinationMode
71+ wasSelectionMade := m.selectionMade
6672 selected, hadSelection := m.Selected()
67- m.results = append([]Result(nil), results...)
73+ selectedDestination := hadSelection && selected.Kind() == ResultKindCreateSessionDestination
74+ m.results, m.createDestinations = splitResults(results)
6875 m.argumentResults = requiredArgumentResults(m.results)
6976 m.refresh()
7077 if !hadSelection {
78+ if m.destinationMode || wasDestinationMode && !wasSelectionMade {
79+ m.selectionMade = false
80+ }
7181 return
7282 }
7383 for i, match := range m.matches {
7484 if match.Result.sameTarget(selected) {
7585 m.selected = i
86+ m.selectionMade = true
7687 m.clamp()
7788 return
7889 }
7990 }
91+ if selectedDestination || m.destinationMode {
92+ m.selectionMade = false
93+ m.selected, m.scroll = 0, 0
94+ m.clamp()
95+ }
8096 }
8197
8298 func DefaultRenderStyles() RenderStyles {
@@ -93,14 +109,14 @@ func DefaultRenderStyles() RenderStyles {
93109 func (m *Model) Insert(r rune) {
94110 if m != nil {
95111 m.input.Insert(r)
96- m.selected, m.scroll = 0, 0
112+ m.selected, m.scroll, m.selectionMade = 0, 0, false
97113 m.refresh()
98114 }
99115 }
100116 func (m *Model) Backspace() {
101117 if m != nil && m.input.Value() != "" {
102118 m.input.Backspace()
103- m.selected, m.scroll = 0, 0
119+ m.selected, m.scroll, m.selectionMade = 0, 0, false
104120 m.refresh()
105121 }
106122 }
@@ -112,13 +128,31 @@ func (m *Model) Query() string {
112128 return m.input.Value()
113129 }
114130 func (m *Model) Up() {
115- if m != nil && m.selected > 0 {
131+ if m == nil || len(m.matches) == 0 {
132+ return
133+ }
134+ if !m.selectionMade {
135+ m.selected = len(m.matches) - 1
136+ m.selectionMade = true
137+ m.clamp()
138+ return
139+ }
140+ if m.selected > 0 {
116141 m.selected--
117142 m.clamp()
118143 }
119144 }
120145 func (m *Model) Down() {
121- if m != nil && m.selected+1 < len(m.matches) {
146+ if m == nil || len(m.matches) == 0 {
147+ return
148+ }
149+ if !m.selectionMade {
150+ m.selected = 0
151+ m.selectionMade = true
152+ m.clamp()
153+ return
154+ }
155+ if m.selected+1 < len(m.matches) {
122156 m.selected++
123157 m.clamp()
124158 }
@@ -126,7 +160,7 @@ func (m *Model) Down() {
126160
127161 // Selected returns the typed immutable target, not an executable command.
128162 func (m *Model) Selected() (Result, bool) {
129- if m == nil || m.selected < 0 || m.selected >= len(m.matches) {
163+ if m == nil || !m.selectionMade || m.selected < 0 || m.selected >= len(m.matches) {
130164 return Result{}, false
131165 }
132166 return m.matches[m.selected].Result, true
@@ -162,7 +196,7 @@ func (m *Model) CompleteSelected() bool {
162196 }
163197
164198 m.input.SetValue(completed)
165- m.selected, m.scroll = 0, 0
199+ m.selected, m.scroll, m.selectionMade = 0, 0, false
166200 m.refresh()
167201 return true
168202 }
@@ -206,6 +240,18 @@ func (m *Model) Matches() []Match {
206240
207241 func (m *Model) refresh() {
208242 query := m.input.Value()
243+ wasDestinationMode := m.destinationMode
244+ if destinations, ok := m.cnsDestinationMatches(query); ok {
245+ m.destinationMode = true
246+ if !wasDestinationMode {
247+ m.selectionMade = false
248+ }
249+ m.matches = destinations
250+ m.clamp()
251+ return
252+ }
253+ m.destinationMode = false
254+ m.selectionMade = true
209255 m.matches = Fuzzy(m.results, query)
210256 // Keep an exact command row selected over fuzzy session matches.
211257 if result, ok := ExactCommandResult(m.results, query); ok {
@@ -226,6 +272,44 @@ func (m *Model) refresh() {
226272 m.clamp()
227273 }
228274
275+func splitResults(results []Result) (ordinary, destinations []Result) {
276+ ordinary = make([]Result, 0, len(results))
277+ for _, result := range results {
278+ if result.Kind() == ResultKindCreateSessionDestination {
279+ destinations = append(destinations, result)
280+ continue
281+ }
282+ ordinary = append(ordinary, result)
283+ }
284+ return ordinary, destinations
285+}
286+
287+func (m *Model) cnsDestinationMatches(query string) ([]Match, bool) {
288+ if len(m.createDestinations) < 2 {
289+ return nil, false
290+ }
291+ fields := strings.Fields(query)
292+ if len(fields) == 0 || len(fields) > 2 {
293+ return nil, false
294+ }
295+ cmd, ok := ArgumentCommand(m.results, query)
296+ if !ok || cmd.Slug != "new-session" {
297+ return nil, false
298+ }
299+ name := ""
300+ if len(fields) == 2 {
301+ name = fields[1]
302+ if domain.ValidateSessionName(name) != nil {
303+ return nil, false
304+ }
305+ }
306+ matches := make([]Match, len(m.createDestinations))
307+ for i, destination := range m.createDestinations {
308+ matches[i] = newMatch(destination.withCreateSessionName(name), 0)
309+ }
310+ return matches, true
311+}
312+
229313 func requiredArgumentResults(results []Result) []Result {
230314 commands := make([]Result, 0, len(results))
231315 for _, result := range results {
@@ -315,15 +399,17 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame {
315399 }
316400 match := m.matches[idx]
317401 style := row
318- if idx == m.selected {
402+ rowSelected := idx == m.selected && m.selectionMade
403+ if rowSelected {
319404 style = selection
320405 }
321406 ui.FillRect(frame, domain.Rect{Y: y + start, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style})
322407 if cmd, ok := match.Result.Command(); ok {
323- m.renderCommand(frame, y+start, style, selection, desc, selectionDesc, codeWidth, cmd, match.Positions, activeCmd, activeOK, opts.Guidance, opts.Preview, opts.Feedback, idx == m.selected)
408+ m.renderCommand(frame, y+start, style, selection, desc, selectionDesc, codeWidth, cmd, match.Positions, activeCmd, activeOK, opts.Guidance, opts.Preview, opts.Feedback, rowSelected)
324409 continue
325410 }
326- m.renderSession(frame, y+start, style, selection, match, opts.Feedback, idx == m.selected)
411+ showFeedback := rowSelected || !m.selectionMade && idx == 0
412+ m.renderSession(frame, y+start, style, selection, match, opts.Feedback, showFeedback)
327413 }
328414 return frame
329415 }
@@ -36,20 +36,24 @@ type RenderOptions struct {
36 }36 }
37 37
38 type Model struct {38 type Model struct {
39- results []Result39+ results []Result
40- argumentResults []Result40+ argumentResults []Result
41- input ui.TextInput41+ createDestinations []Result
42- matches []Match42+ input ui.TextInput
43- selected int43+ matches []Match
44- scroll int44+ selected int
45+ scroll int
46+ destinationMode bool
47+ selectionMade bool
45 }48 }
46 49
47 // New accepts typed immutable palette results.50 // New accepts typed immutable palette results.
48 func New(results []Result) *Model {51 func New(results []Result) *Model {
49- copied := append([]Result(nil), results...)52+ ordinary, destinations := splitResults(results)
50 m := &Model{53 m := &Model{
51- results: copied,54+ results: ordinary,
52- argumentResults: requiredArgumentResults(copied),55+ argumentResults: requiredArgumentResults(ordinary),
56+ createDestinations: destinations,
53 }57 }
54 m.refresh()58 m.refresh()
55 return m59 return m
@@ -63,20 +67,32 @@ func (m *Model) ReplaceResults(results []Result) {
63 if m == nil {67 if m == nil {
64 return68 return
65 }69 }
70+ wasDestinationMode := m.destinationMode
71+ wasSelectionMade := m.selectionMade
66 selected, hadSelection := m.Selected()72 selected, hadSelection := m.Selected()
67- m.results = append([]Result(nil), results...)73+ selectedDestination := hadSelection && selected.Kind() == ResultKindCreateSessionDestination
74+ m.results, m.createDestinations = splitResults(results)
68 m.argumentResults = requiredArgumentResults(m.results)75 m.argumentResults = requiredArgumentResults(m.results)
69 m.refresh()76 m.refresh()
70 if !hadSelection {77 if !hadSelection {
78+ if m.destinationMode || wasDestinationMode && !wasSelectionMade {
79+ m.selectionMade = false
80+ }
71 return81 return
72 }82 }
73 for i, match := range m.matches {83 for i, match := range m.matches {
74 if match.Result.sameTarget(selected) {84 if match.Result.sameTarget(selected) {
75 m.selected = i85 m.selected = i
86+ m.selectionMade = true
76 m.clamp()87 m.clamp()
77 return88 return
78 }89 }
79 }90 }
91+ if selectedDestination || m.destinationMode {
92+ m.selectionMade = false
93+ m.selected, m.scroll = 0, 0
94+ m.clamp()
95+ }
80 }96 }
81 97
82 func DefaultRenderStyles() RenderStyles {98 func DefaultRenderStyles() RenderStyles {
@@ -93,14 +109,14 @@ func DefaultRenderStyles() RenderStyles {
93 func (m *Model) Insert(r rune) {109 func (m *Model) Insert(r rune) {
94 if m != nil {110 if m != nil {
95 m.input.Insert(r)111 m.input.Insert(r)
96- m.selected, m.scroll = 0, 0112+ m.selected, m.scroll, m.selectionMade = 0, 0, false
97 m.refresh()113 m.refresh()
98 }114 }
99 }115 }
100 func (m *Model) Backspace() {116 func (m *Model) Backspace() {
101 if m != nil && m.input.Value() != "" {117 if m != nil && m.input.Value() != "" {
102 m.input.Backspace()118 m.input.Backspace()
103- m.selected, m.scroll = 0, 0119+ m.selected, m.scroll, m.selectionMade = 0, 0, false
104 m.refresh()120 m.refresh()
105 }121 }
106 }122 }
@@ -112,13 +128,31 @@ func (m *Model) Query() string {
112 return m.input.Value()128 return m.input.Value()
113 }129 }
114 func (m *Model) Up() {130 func (m *Model) Up() {
115- if m != nil && m.selected > 0 {131+ if m == nil || len(m.matches) == 0 {
132+ return
133+ }
134+ if !m.selectionMade {
135+ m.selected = len(m.matches) - 1
136+ m.selectionMade = true
137+ m.clamp()
138+ return
139+ }
140+ if m.selected > 0 {
116 m.selected--141 m.selected--
117 m.clamp()142 m.clamp()
118 }143 }
119 }144 }
120 func (m *Model) Down() {145 func (m *Model) Down() {
121- if m != nil && m.selected+1 < len(m.matches) {146+ if m == nil || len(m.matches) == 0 {
147+ return
148+ }
149+ if !m.selectionMade {
150+ m.selected = 0
151+ m.selectionMade = true
152+ m.clamp()
153+ return
154+ }
155+ if m.selected+1 < len(m.matches) {
122 m.selected++156 m.selected++
123 m.clamp()157 m.clamp()
124 }158 }
@@ -126,7 +160,7 @@ func (m *Model) Down() {
126 160
127 // Selected returns the typed immutable target, not an executable command.161 // Selected returns the typed immutable target, not an executable command.
128 func (m *Model) Selected() (Result, bool) {162 func (m *Model) Selected() (Result, bool) {
129- if m == nil || m.selected < 0 || m.selected >= len(m.matches) {163+ if m == nil || !m.selectionMade || m.selected < 0 || m.selected >= len(m.matches) {
130 return Result{}, false164 return Result{}, false
131 }165 }
132 return m.matches[m.selected].Result, true166 return m.matches[m.selected].Result, true
@@ -162,7 +196,7 @@ func (m *Model) CompleteSelected() bool {
162 }196 }
163 197
164 m.input.SetValue(completed)198 m.input.SetValue(completed)
165- m.selected, m.scroll = 0, 0199+ m.selected, m.scroll, m.selectionMade = 0, 0, false
166 m.refresh()200 m.refresh()
167 return true201 return true
168 }202 }
@@ -206,6 +240,18 @@ func (m *Model) Matches() []Match {
206 240
207 func (m *Model) refresh() {241 func (m *Model) refresh() {
208 query := m.input.Value()242 query := m.input.Value()
243+ wasDestinationMode := m.destinationMode
244+ if destinations, ok := m.cnsDestinationMatches(query); ok {
245+ m.destinationMode = true
246+ if !wasDestinationMode {
247+ m.selectionMade = false
248+ }
249+ m.matches = destinations
250+ m.clamp()
251+ return
252+ }
253+ m.destinationMode = false
254+ m.selectionMade = true
209 m.matches = Fuzzy(m.results, query)255 m.matches = Fuzzy(m.results, query)
210 // Keep an exact command row selected over fuzzy session matches.256 // Keep an exact command row selected over fuzzy session matches.
211 if result, ok := ExactCommandResult(m.results, query); ok {257 if result, ok := ExactCommandResult(m.results, query); ok {
@@ -226,6 +272,44 @@ func (m *Model) refresh() {
226 m.clamp()272 m.clamp()
227 }273 }
228 274
275+func splitResults(results []Result) (ordinary, destinations []Result) {
276+ ordinary = make([]Result, 0, len(results))
277+ for _, result := range results {
278+ if result.Kind() == ResultKindCreateSessionDestination {
279+ destinations = append(destinations, result)
280+ continue
281+ }
282+ ordinary = append(ordinary, result)
283+ }
284+ return ordinary, destinations
285+}
286+
287+func (m *Model) cnsDestinationMatches(query string) ([]Match, bool) {
288+ if len(m.createDestinations) < 2 {
289+ return nil, false
290+ }
291+ fields := strings.Fields(query)
292+ if len(fields) == 0 || len(fields) > 2 {
293+ return nil, false
294+ }
295+ cmd, ok := ArgumentCommand(m.results, query)
296+ if !ok || cmd.Slug != "new-session" {
297+ return nil, false
298+ }
299+ name := ""
300+ if len(fields) == 2 {
301+ name = fields[1]
302+ if domain.ValidateSessionName(name) != nil {
303+ return nil, false
304+ }
305+ }
306+ matches := make([]Match, len(m.createDestinations))
307+ for i, destination := range m.createDestinations {
308+ matches[i] = newMatch(destination.withCreateSessionName(name), 0)
309+ }
310+ return matches, true
311+}
312+
229 func requiredArgumentResults(results []Result) []Result {313 func requiredArgumentResults(results []Result) []Result {
230 commands := make([]Result, 0, len(results))314 commands := make([]Result, 0, len(results))
231 for _, result := range results {315 for _, result := range results {
@@ -315,15 +399,17 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame {
315 }399 }
316 match := m.matches[idx]400 match := m.matches[idx]
317 style := row401 style := row
318- if idx == m.selected {402+ rowSelected := idx == m.selected && m.selectionMade
403+ if rowSelected {
319 style = selection404 style = selection
320 }405 }
321 ui.FillRect(frame, domain.Rect{Y: y + start, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style})406 ui.FillRect(frame, domain.Rect{Y: y + start, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style})
322 if cmd, ok := match.Result.Command(); ok {407 if cmd, ok := match.Result.Command(); ok {
323- m.renderCommand(frame, y+start, style, selection, desc, selectionDesc, codeWidth, cmd, match.Positions, activeCmd, activeOK, opts.Guidance, opts.Preview, opts.Feedback, idx == m.selected)408+ m.renderCommand(frame, y+start, style, selection, desc, selectionDesc, codeWidth, cmd, match.Positions, activeCmd, activeOK, opts.Guidance, opts.Preview, opts.Feedback, rowSelected)
324 continue409 continue
325 }410 }
326- m.renderSession(frame, y+start, style, selection, match, opts.Feedback, idx == m.selected)411+ showFeedback := rowSelected || !m.selectionMade && idx == 0
412+ m.renderSession(frame, y+start, style, selection, match, opts.Feedback, showFeedback)
327 }413 }
328 return frame414 return frame
329 }415 }
M internal/usecase/palette/model_test.go +128 -0
@@ -7,11 +7,139 @@ import (
77
88 renderer "github.com/bnema/vev-vt"
99 "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/protocol"
1011 "github.com/bnema/vev/internal/usecase/command"
1112 themeui "github.com/bnema/vev/internal/usecase/theme"
1213 "github.com/stretchr/testify/require"
1314 )
1415
16+func cnsCommand(t *testing.T) command.Command {
17+ t.Helper()
18+ for _, candidate := range command.PaletteRegistry() {
19+ if candidate.Slug == "new-session" {
20+ return candidate
21+ }
22+ }
23+ require.FailNow(t, "new-session command missing from palette registry")
24+ return command.Command{}
25+}
26+
27+func TestCNSDestinationModeRequiresExplicitSelection(t *testing.T) {
28+ cns := cnsCommand(t)
29+ local := NewCreateSessionDestination(CreateSessionOnLocalRoute, "", "", protocol.RouteRef{Key: 1, Generation: 1}, 1)
30+ remote := NewCreateSessionDestination(CreateSessionOnRemoteHost, "host-a", "host-a", protocol.RouteRef{}, 0)
31+
32+ tests := []struct {
33+ name string
34+ query string
35+ navigate func(*Model)
36+ wantText string
37+ wantSelect bool
38+ wantFirst bool
39+ }{
40+ {name: "plain requires selection", query: "CNS", wantText: "Create session locally…", wantFirst: true},
41+ {name: "named requires selection", query: "CNS example", wantText: "Create session “example”", wantFirst: true},
42+ {name: "down chooses first", query: "CNS example", navigate: (*Model).Down, wantText: "Create session “example”", wantSelect: true, wantFirst: true},
43+ {name: "up chooses last", query: "CNS example", navigate: (*Model).Up, wantText: "Create session “example” on host-a", wantSelect: true},
44+ }
45+ for _, tt := range tests {
46+ t.Run(tt.name, func(t *testing.T) {
47+ m := New([]Result{NewCommandResult(cns), local, remote})
48+ for _, r := range tt.query {
49+ m.Insert(r)
50+ }
51+ require.Len(t, m.Matches(), 2)
52+ if tt.wantFirst {
53+ require.Equal(t, tt.wantText, m.Matches()[0].Result.DisplayText())
54+ }
55+ if tt.navigate != nil {
56+ tt.navigate(m)
57+ }
58+ selected, ok := m.Selected()
59+ require.Equal(t, tt.wantSelect, ok)
60+ if ok {
61+ require.Equal(t, tt.wantText, selected.DisplayText())
62+ }
63+ })
64+ }
65+}
66+
67+func TestCNSDestinationRefreshPreservesOnlyExplicitSelection(t *testing.T) {
68+ cns := cnsCommand(t)
69+ local := NewCreateSessionDestination(CreateSessionOnLocalRoute, "", "", protocol.RouteRef{Key: 1, Generation: 1}, 1)
70+ remote := NewCreateSessionDestination(CreateSessionOnRemoteHost, "host-a", "host-a", protocol.RouteRef{}, 0)
71+ m := New([]Result{NewCommandResult(cns), local, remote})
72+ for _, r := range "CNS example" {
73+ m.Insert(r)
74+ }
75+ m.ReplaceResults([]Result{NewCommandResult(cns), local, remote})
76+ _, ok := m.Selected()
77+ require.False(t, ok)
78+
79+ m.Down()
80+ m.ReplaceResults([]Result{NewCommandResult(cns), remote, local})
81+ selected, ok := m.Selected()
82+ require.True(t, ok)
83+ _, kind, _, _, _, ok := selected.CreateSessionDestination()
84+ require.True(t, ok)
85+ require.Equal(t, CreateSessionOnLocalRoute, kind)
86+
87+ remoteB := NewCreateSessionDestination(CreateSessionOnRemoteHost, "host-b", "host-b", protocol.RouteRef{}, 0)
88+ m.ReplaceResults([]Result{NewCommandResult(cns), remote, remoteB})
89+ _, ok = m.Selected()
90+ require.False(t, ok, "removing the explicit destination must not select its replacement")
91+
92+ m.Down()
93+ m.ReplaceResults([]Result{NewCommandResult(cns), remote})
94+ _, ok = m.Selected()
95+ require.False(t, ok, "two-to-one refresh must not promote the generic command")
96+
97+ m.ReplaceResults([]Result{NewCommandResult(cns), remote, remoteB})
98+ m.Down()
99+ m.ReplaceResults([]Result{NewCommandResult(cns)})
100+ _, ok = m.Selected()
101+ require.False(t, ok, "two-to-zero refresh must not promote the generic command")
102+}
103+
104+func TestCNSUnselectedDestinationCollapseDoesNotPromoteCommand(t *testing.T) {
105+ cns := cnsCommand(t)
106+ local := NewCreateSessionDestination(CreateSessionOnLocalRoute, "", "", protocol.RouteRef{Key: 1, Generation: 1}, 1)
107+ remote := NewCreateSessionDestination(CreateSessionOnRemoteHost, "host-a", "host-a", protocol.RouteRef{}, 0)
108+ newModel := func() *Model {
109+ m := New([]Result{NewCommandResult(cns), local, remote})
110+ for _, r := range "CNS example" {
111+ m.Insert(r)
112+ }
113+ return m
114+ }
115+
116+ m := newModel()
117+ m.ReplaceResults([]Result{NewCommandResult(cns), local})
118+ _, ok := m.Selected()
119+ require.False(t, ok, "unselected two-to-one refresh must not promote CNS")
120+
121+ m = newModel()
122+ m.ReplaceResults([]Result{NewCommandResult(cns)})
123+ _, ok = m.Selected()
124+ require.False(t, ok, "unselected two-to-zero refresh must not promote CNS")
125+}
126+
127+func TestCNSDestinationRefreshDoesNotPromoteCommandSelection(t *testing.T) {
128+ cns := cnsCommand(t)
129+ local := NewCreateSessionDestination(CreateSessionOnLocalRoute, "", "", protocol.RouteRef{Key: 1, Generation: 1}, 1)
130+ remote := NewCreateSessionDestination(CreateSessionOnRemoteHost, "host-a", "host-a", protocol.RouteRef{}, 0)
131+ m := New([]Result{NewCommandResult(cns), local})
132+ for _, r := range "CNS" {
133+ m.Insert(r)
134+ }
135+ _, ok := m.Selected()
136+ require.True(t, ok, "single-daemon CNS keeps ordinary command selection")
137+
138+ m.ReplaceResults([]Result{NewCommandResult(cns), local, remote})
139+ _, ok = m.Selected()
140+ require.False(t, ok, "entering multi-destination mode requires fresh navigation")
141+}
142+
15143 func TestReplaceResultsPreservesExactRemoteSelection(t *testing.T) {
16144 result := func(endpoint string, lifecycle byte) Result {
17145 key := domain.RemoteSessionKey{
@@ -7,11 +7,139 @@ import (
7 7
8 renderer "github.com/bnema/vev-vt"8 renderer "github.com/bnema/vev-vt"
9 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/protocol"
10 "github.com/bnema/vev/internal/usecase/command"11 "github.com/bnema/vev/internal/usecase/command"
11 themeui "github.com/bnema/vev/internal/usecase/theme"12 themeui "github.com/bnema/vev/internal/usecase/theme"
12 "github.com/stretchr/testify/require"13 "github.com/stretchr/testify/require"
13 )14 )
14 15
16+func cnsCommand(t *testing.T) command.Command {
17+ t.Helper()
18+ for _, candidate := range command.PaletteRegistry() {
19+ if candidate.Slug == "new-session" {
20+ return candidate
21+ }
22+ }
23+ require.FailNow(t, "new-session command missing from palette registry")
24+ return command.Command{}
25+}
26+
27+func TestCNSDestinationModeRequiresExplicitSelection(t *testing.T) {
28+ cns := cnsCommand(t)
29+ local := NewCreateSessionDestination(CreateSessionOnLocalRoute, "", "", protocol.RouteRef{Key: 1, Generation: 1}, 1)
30+ remote := NewCreateSessionDestination(CreateSessionOnRemoteHost, "host-a", "host-a", protocol.RouteRef{}, 0)
31+
32+ tests := []struct {
33+ name string
34+ query string
35+ navigate func(*Model)
36+ wantText string
37+ wantSelect bool
38+ wantFirst bool
39+ }{
40+ {name: "plain requires selection", query: "CNS", wantText: "Create session locally…", wantFirst: true},
41+ {name: "named requires selection", query: "CNS example", wantText: "Create session “example”", wantFirst: true},
42+ {name: "down chooses first", query: "CNS example", navigate: (*Model).Down, wantText: "Create session “example”", wantSelect: true, wantFirst: true},
43+ {name: "up chooses last", query: "CNS example", navigate: (*Model).Up, wantText: "Create session “example” on host-a", wantSelect: true},
44+ }
45+ for _, tt := range tests {
46+ t.Run(tt.name, func(t *testing.T) {
47+ m := New([]Result{NewCommandResult(cns), local, remote})
48+ for _, r := range tt.query {
49+ m.Insert(r)
50+ }
51+ require.Len(t, m.Matches(), 2)
52+ if tt.wantFirst {
53+ require.Equal(t, tt.wantText, m.Matches()[0].Result.DisplayText())
54+ }
55+ if tt.navigate != nil {
56+ tt.navigate(m)
57+ }
58+ selected, ok := m.Selected()
59+ require.Equal(t, tt.wantSelect, ok)
60+ if ok {
61+ require.Equal(t, tt.wantText, selected.DisplayText())
62+ }
63+ })
64+ }
65+}
66+
67+func TestCNSDestinationRefreshPreservesOnlyExplicitSelection(t *testing.T) {
68+ cns := cnsCommand(t)
69+ local := NewCreateSessionDestination(CreateSessionOnLocalRoute, "", "", protocol.RouteRef{Key: 1, Generation: 1}, 1)
70+ remote := NewCreateSessionDestination(CreateSessionOnRemoteHost, "host-a", "host-a", protocol.RouteRef{}, 0)
71+ m := New([]Result{NewCommandResult(cns), local, remote})
72+ for _, r := range "CNS example" {
73+ m.Insert(r)
74+ }
75+ m.ReplaceResults([]Result{NewCommandResult(cns), local, remote})
76+ _, ok := m.Selected()
77+ require.False(t, ok)
78+
79+ m.Down()
80+ m.ReplaceResults([]Result{NewCommandResult(cns), remote, local})
81+ selected, ok := m.Selected()
82+ require.True(t, ok)
83+ _, kind, _, _, _, ok := selected.CreateSessionDestination()
84+ require.True(t, ok)
85+ require.Equal(t, CreateSessionOnLocalRoute, kind)
86+
87+ remoteB := NewCreateSessionDestination(CreateSessionOnRemoteHost, "host-b", "host-b", protocol.RouteRef{}, 0)
88+ m.ReplaceResults([]Result{NewCommandResult(cns), remote, remoteB})
89+ _, ok = m.Selected()
90+ require.False(t, ok, "removing the explicit destination must not select its replacement")
91+
92+ m.Down()
93+ m.ReplaceResults([]Result{NewCommandResult(cns), remote})
94+ _, ok = m.Selected()
95+ require.False(t, ok, "two-to-one refresh must not promote the generic command")
96+
97+ m.ReplaceResults([]Result{NewCommandResult(cns), remote, remoteB})
98+ m.Down()
99+ m.ReplaceResults([]Result{NewCommandResult(cns)})
100+ _, ok = m.Selected()
101+ require.False(t, ok, "two-to-zero refresh must not promote the generic command")
102+}
103+
104+func TestCNSUnselectedDestinationCollapseDoesNotPromoteCommand(t *testing.T) {
105+ cns := cnsCommand(t)
106+ local := NewCreateSessionDestination(CreateSessionOnLocalRoute, "", "", protocol.RouteRef{Key: 1, Generation: 1}, 1)
107+ remote := NewCreateSessionDestination(CreateSessionOnRemoteHost, "host-a", "host-a", protocol.RouteRef{}, 0)
108+ newModel := func() *Model {
109+ m := New([]Result{NewCommandResult(cns), local, remote})
110+ for _, r := range "CNS example" {
111+ m.Insert(r)
112+ }
113+ return m
114+ }
115+
116+ m := newModel()
117+ m.ReplaceResults([]Result{NewCommandResult(cns), local})
118+ _, ok := m.Selected()
119+ require.False(t, ok, "unselected two-to-one refresh must not promote CNS")
120+
121+ m = newModel()
122+ m.ReplaceResults([]Result{NewCommandResult(cns)})
123+ _, ok = m.Selected()
124+ require.False(t, ok, "unselected two-to-zero refresh must not promote CNS")
125+}
126+
127+func TestCNSDestinationRefreshDoesNotPromoteCommandSelection(t *testing.T) {
128+ cns := cnsCommand(t)
129+ local := NewCreateSessionDestination(CreateSessionOnLocalRoute, "", "", protocol.RouteRef{Key: 1, Generation: 1}, 1)
130+ remote := NewCreateSessionDestination(CreateSessionOnRemoteHost, "host-a", "host-a", protocol.RouteRef{}, 0)
131+ m := New([]Result{NewCommandResult(cns), local})
132+ for _, r := range "CNS" {
133+ m.Insert(r)
134+ }
135+ _, ok := m.Selected()
136+ require.True(t, ok, "single-daemon CNS keeps ordinary command selection")
137+
138+ m.ReplaceResults([]Result{NewCommandResult(cns), local, remote})
139+ _, ok = m.Selected()
140+ require.False(t, ok, "entering multi-destination mode requires fresh navigation")
141+}
142+
15 func TestReplaceResultsPreservesExactRemoteSelection(t *testing.T) {143 func TestReplaceResultsPreservesExactRemoteSelection(t *testing.T) {
16 result := func(endpoint string, lifecycle byte) Result {144 result := func(endpoint string, lifecycle byte) Result {
17 key := domain.RemoteSessionKey{145 key := domain.RemoteSessionKey{
M internal/usecase/palette/result.go +74 -5
@@ -17,16 +17,27 @@ const (
1717 ResultKindStoppedSession
1818 ResultKindRemoteSession
1919 ResultKindRecentRoute
20+ ResultKindCreateSessionDestination
21+)
22+
23+// CreateSessionDestinationKind identifies where a CNS result creates a session.
24+type CreateSessionDestinationKind uint8
25+
26+const (
27+ CreateSessionOnServingDaemon CreateSessionDestinationKind = iota + 1
28+ CreateSessionOnLocalRoute
29+ CreateSessionOnRemoteHost
2030 )
2131
2232 // Result is an immutable palette target. Its kind is the sole discriminator
2333 // for its private command, local-session, remote-session, or route payload.
2434 type Result struct {
25- kind ResultKind
26- command command.Command
27- session sessionPayload
28- remoteSession remoteSessionPayload
29- route routePayload
35+ kind ResultKind
36+ command command.Command
37+ session sessionPayload
38+ remoteSession remoteSessionPayload
39+ route routePayload
40+ createDestination createSessionDestinationPayload
3041 }
3142
3243 type sessionPayload struct {
@@ -46,6 +57,15 @@ type routePayload struct {
4657 action protocol.RouteNavigationAction
4758 }
4859
60+type createSessionDestinationPayload struct {
61+ name string
62+ kind CreateSessionDestinationKind
63+ displayOrigin string
64+ endpoint string
65+ route protocol.RouteRef
66+ snapshotGen uint64
67+}
68+
4969 // NewCommandResult creates a static command palette target.
5070 func NewCommandResult(cmd command.Command) Result {
5171 return Result{kind: ResultKindCommand, command: cmd}
@@ -102,6 +122,19 @@ func NewRecentRouteResult(label string, action protocol.RouteNavigationAction) R
102122 return Result{kind: ResultKindRecentRoute, route: routePayload{label: label, action: action}}
103123 }
104124
125+// NewCreateSessionDestination creates an immutable destination template. The
126+// palette binds the current validated CNS name without parsing rendered text.
127+func NewCreateSessionDestination(kind CreateSessionDestinationKind, displayOrigin, endpoint string, route protocol.RouteRef, snapshotGeneration uint64) Result {
128+ return Result{kind: ResultKindCreateSessionDestination, createDestination: createSessionDestinationPayload{
129+ kind: kind, displayOrigin: displayOrigin, endpoint: endpoint, route: route, snapshotGen: snapshotGeneration,
130+ }}
131+}
132+
133+func (r Result) withCreateSessionName(name string) Result {
134+ r.createDestination.name = name
135+ return r
136+}
137+
105138 func (r Result) Kind() ResultKind { return r.kind }
106139
107140 func (r Result) sameTarget(other Result) bool {
@@ -117,6 +150,11 @@ func (r Result) sameTarget(other Result) bool {
117150 return r.remoteSession.key == other.remoteSession.key && r.remoteSession.target == other.remoteSession.target
118151 case ResultKindRecentRoute:
119152 return r.route.action == other.route.action
153+ case ResultKindCreateSessionDestination:
154+ return r.createDestination.kind == other.createDestination.kind &&
155+ r.createDestination.endpoint == other.createDestination.endpoint &&
156+ r.createDestination.route == other.createDestination.route &&
157+ r.createDestination.snapshotGen == other.createDestination.snapshotGen
120158 default:
121159 return false
122160 }
@@ -132,9 +170,27 @@ func (r Result) DisplayText() string {
132170 if r.kind == ResultKindRecentRoute {
133171 return activeSessionDisplayPrefix + r.route.label
134172 }
173+ if r.kind == ResultKindCreateSessionDestination {
174+ return r.createSessionDestinationDisplay()
175+ }
135176 return r.sessionDisplayPrefix() + r.session.display
136177 }
137178
179+func (r Result) createSessionDestinationDisplay() string {
180+ destination := r.createDestination
181+ if destination.name == "" {
182+ if destination.kind == CreateSessionOnLocalRoute {
183+ return "Create session locally…"
184+ }
185+ return "Create session on " + destination.displayOrigin + "…"
186+ }
187+ text := "Create session “" + destination.name + "”"
188+ if destination.kind != CreateSessionOnLocalRoute {
189+ text += " on " + destination.displayOrigin
190+ }
191+ return text
192+}
193+
138194 func (r Result) sessionDisplayPrefix() string {
139195 if r.kind == ResultKindStoppedSession {
140196 return stoppedSessionDisplayPrefix
@@ -191,6 +247,19 @@ func (r Result) RouteNavigationAction() (protocol.RouteNavigationAction, bool) {
191247 return r.route.action, r.kind == ResultKindRecentRoute
192248 }
193249
250+// CreateSessionDestination returns structured creation authority only for CNS
251+// destination rows. Labels are never parsed back into these values.
252+func (r Result) CreateSessionDestination() (name string, kind CreateSessionDestinationKind, displayOrigin, endpoint string, route protocol.RouteRef, ok bool) {
253+ d := r.createDestination
254+ return d.name, d.kind, d.displayOrigin, d.endpoint, d.route, r.kind == ResultKindCreateSessionDestination
255+}
256+
257+// CreateSessionSnapshotGeneration returns the route snapshot generation bound
258+// to a local destination. Other destination kinds carry zero.
259+func (r Result) CreateSessionSnapshotGeneration() (uint64, bool) {
260+ return r.createDestination.snapshotGen, r.kind == ResultKindCreateSessionDestination
261+}
262+
194263 const (
195264 activeSessionDisplayPrefix = "Switch to session "
196265 stoppedSessionDisplayPrefix = "Resume session "
@@ -17,16 +17,27 @@ const (
17 ResultKindStoppedSession17 ResultKindStoppedSession
18 ResultKindRemoteSession18 ResultKindRemoteSession
19 ResultKindRecentRoute19 ResultKindRecentRoute
20+ ResultKindCreateSessionDestination
21+)
22+
23+// CreateSessionDestinationKind identifies where a CNS result creates a session.
24+type CreateSessionDestinationKind uint8
25+
26+const (
27+ CreateSessionOnServingDaemon CreateSessionDestinationKind = iota + 1
28+ CreateSessionOnLocalRoute
29+ CreateSessionOnRemoteHost
20 )30 )
21 31
22 // Result is an immutable palette target. Its kind is the sole discriminator32 // Result is an immutable palette target. Its kind is the sole discriminator
23 // for its private command, local-session, remote-session, or route payload.33 // for its private command, local-session, remote-session, or route payload.
24 type Result struct {34 type Result struct {
25- kind ResultKind35+ kind ResultKind
26- command command.Command36+ command command.Command
27- session sessionPayload37+ session sessionPayload
28- remoteSession remoteSessionPayload38+ remoteSession remoteSessionPayload
29- route routePayload39+ route routePayload
40+ createDestination createSessionDestinationPayload
30 }41 }
31 42
32 type sessionPayload struct {43 type sessionPayload struct {
@@ -46,6 +57,15 @@ type routePayload struct {
46 action protocol.RouteNavigationAction57 action protocol.RouteNavigationAction
47 }58 }
48 59
60+type createSessionDestinationPayload struct {
61+ name string
62+ kind CreateSessionDestinationKind
63+ displayOrigin string
64+ endpoint string
65+ route protocol.RouteRef
66+ snapshotGen uint64
67+}
68+
49 // NewCommandResult creates a static command palette target.69 // NewCommandResult creates a static command palette target.
50 func NewCommandResult(cmd command.Command) Result {70 func NewCommandResult(cmd command.Command) Result {
51 return Result{kind: ResultKindCommand, command: cmd}71 return Result{kind: ResultKindCommand, command: cmd}
@@ -102,6 +122,19 @@ func NewRecentRouteResult(label string, action protocol.RouteNavigationAction) R
102 return Result{kind: ResultKindRecentRoute, route: routePayload{label: label, action: action}}122 return Result{kind: ResultKindRecentRoute, route: routePayload{label: label, action: action}}
103 }123 }
104 124
125+// NewCreateSessionDestination creates an immutable destination template. The
126+// palette binds the current validated CNS name without parsing rendered text.
127+func NewCreateSessionDestination(kind CreateSessionDestinationKind, displayOrigin, endpoint string, route protocol.RouteRef, snapshotGeneration uint64) Result {
128+ return Result{kind: ResultKindCreateSessionDestination, createDestination: createSessionDestinationPayload{
129+ kind: kind, displayOrigin: displayOrigin, endpoint: endpoint, route: route, snapshotGen: snapshotGeneration,
130+ }}
131+}
132+
133+func (r Result) withCreateSessionName(name string) Result {
134+ r.createDestination.name = name
135+ return r
136+}
137+
105 func (r Result) Kind() ResultKind { return r.kind }138 func (r Result) Kind() ResultKind { return r.kind }
106 139
107 func (r Result) sameTarget(other Result) bool {140 func (r Result) sameTarget(other Result) bool {
@@ -117,6 +150,11 @@ func (r Result) sameTarget(other Result) bool {
117 return r.remoteSession.key == other.remoteSession.key && r.remoteSession.target == other.remoteSession.target150 return r.remoteSession.key == other.remoteSession.key && r.remoteSession.target == other.remoteSession.target
118 case ResultKindRecentRoute:151 case ResultKindRecentRoute:
119 return r.route.action == other.route.action152 return r.route.action == other.route.action
153+ case ResultKindCreateSessionDestination:
154+ return r.createDestination.kind == other.createDestination.kind &&
155+ r.createDestination.endpoint == other.createDestination.endpoint &&
156+ r.createDestination.route == other.createDestination.route &&
157+ r.createDestination.snapshotGen == other.createDestination.snapshotGen
120 default:158 default:
121 return false159 return false
122 }160 }
@@ -132,9 +170,27 @@ func (r Result) DisplayText() string {
132 if r.kind == ResultKindRecentRoute {170 if r.kind == ResultKindRecentRoute {
133 return activeSessionDisplayPrefix + r.route.label171 return activeSessionDisplayPrefix + r.route.label
134 }172 }
173+ if r.kind == ResultKindCreateSessionDestination {
174+ return r.createSessionDestinationDisplay()
175+ }
135 return r.sessionDisplayPrefix() + r.session.display176 return r.sessionDisplayPrefix() + r.session.display
136 }177 }
137 178
179+func (r Result) createSessionDestinationDisplay() string {
180+ destination := r.createDestination
181+ if destination.name == "" {
182+ if destination.kind == CreateSessionOnLocalRoute {
183+ return "Create session locally…"
184+ }
185+ return "Create session on " + destination.displayOrigin + "…"
186+ }
187+ text := "Create session “" + destination.name + "”"
188+ if destination.kind != CreateSessionOnLocalRoute {
189+ text += " on " + destination.displayOrigin
190+ }
191+ return text
192+}
193+
138 func (r Result) sessionDisplayPrefix() string {194 func (r Result) sessionDisplayPrefix() string {
139 if r.kind == ResultKindStoppedSession {195 if r.kind == ResultKindStoppedSession {
140 return stoppedSessionDisplayPrefix196 return stoppedSessionDisplayPrefix
@@ -191,6 +247,19 @@ func (r Result) RouteNavigationAction() (protocol.RouteNavigationAction, bool) {
191 return r.route.action, r.kind == ResultKindRecentRoute247 return r.route.action, r.kind == ResultKindRecentRoute
192 }248 }
193 249
250+// CreateSessionDestination returns structured creation authority only for CNS
251+// destination rows. Labels are never parsed back into these values.
252+func (r Result) CreateSessionDestination() (name string, kind CreateSessionDestinationKind, displayOrigin, endpoint string, route protocol.RouteRef, ok bool) {
253+ d := r.createDestination
254+ return d.name, d.kind, d.displayOrigin, d.endpoint, d.route, r.kind == ResultKindCreateSessionDestination
255+}
256+
257+// CreateSessionSnapshotGeneration returns the route snapshot generation bound
258+// to a local destination. Other destination kinds carry zero.
259+func (r Result) CreateSessionSnapshotGeneration() (uint64, bool) {
260+ return r.createDestination.snapshotGen, r.kind == ResultKindCreateSessionDestination
261+}
262+
194 const (263 const (
195 activeSessionDisplayPrefix = "Switch to session "264 activeSessionDisplayPrefix = "Switch to session "
196 stoppedSessionDisplayPrefix = "Resume session "265 stoppedSessionDisplayPrefix = "Resume session "

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