detached 1690f30

fix(routes): refresh attention indicators on active daemon

commit
1690f307d0e096c24c6ba01f400d948efe81c739
author
brice · · 2026-08-11 03:30 UTC
parent
80c61e3
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
19 files · +426 -61
M internal/ports/frame.go +17 -16
@@ -12,7 +12,7 @@ import (
1212 const HandshakeTimeout = 15 * time.Second
1313
1414 // ProtocolVersion is the current vev IPC wire protocol version.
15-const ProtocolVersion uint16 = 29
15+const ProtocolVersion uint16 = 30
1616
1717 // MaxFrameLen is the largest permitted frame length, including the type byte
1818 // and excluding the four-byte length prefix.
@@ -21,25 +21,26 @@ const MaxFrameLen = 16 << 20
2121 // MsgType identifies the kind of payload carried by a Frame.
2222 type MsgType uint8
2323
24-// Frame message types. Client-originated messages occupy 1–13 and 15;
24+// Frame message types. Client-originated messages occupy 1–13, 15, and 32;
2525 // server-originated messages occupy 16–23 and 25–31. Values 14 and 24 remain
2626 // reserved for future extensions. Route metadata/navigation messages use the
2727 // post-26 range.
2828 const (
29- MsgHello MsgType = 1
30- MsgInput MsgType = 2
31- MsgResize MsgType = 3
32- MsgDetach MsgType = 4
33- MsgPing MsgType = 5
34- MsgList MsgType = 6
35- MsgKill MsgType = 7
36- MsgTheme MsgType = 8
37- MsgAck MsgType = 9
38- MsgImagePush MsgType = 10
39- MsgClientNotice MsgType = 11
40- MsgCommand MsgType = 12
41- MsgOutputResetRequest MsgType = 13
42- MsgRemotePreviewRequest MsgType = 15
29+ MsgHello MsgType = 1
30+ MsgInput MsgType = 2
31+ MsgResize MsgType = 3
32+ MsgDetach MsgType = 4
33+ MsgPing MsgType = 5
34+ MsgList MsgType = 6
35+ MsgKill MsgType = 7
36+ MsgTheme MsgType = 8
37+ MsgAck MsgType = 9
38+ MsgImagePush MsgType = 10
39+ MsgClientNotice MsgType = 11
40+ MsgCommand MsgType = 12
41+ MsgOutputResetRequest MsgType = 13
42+ MsgRemotePreviewRequest MsgType = 15
43+ MsgRouteAttentionSubscription MsgType = 32
4344
4445 MsgWelcome MsgType = 16
4546 MsgError MsgType = 17
@@ -12,7 +12,7 @@ import (
12 const HandshakeTimeout = 15 * time.Second12 const HandshakeTimeout = 15 * time.Second
13 13
14 // ProtocolVersion is the current vev IPC wire protocol version.14 // ProtocolVersion is the current vev IPC wire protocol version.
15-const ProtocolVersion uint16 = 2915+const ProtocolVersion uint16 = 30
16 16
17 // MaxFrameLen is the largest permitted frame length, including the type byte17 // MaxFrameLen is the largest permitted frame length, including the type byte
18 // and excluding the four-byte length prefix.18 // and excluding the four-byte length prefix.
@@ -21,25 +21,26 @@ const MaxFrameLen = 16 << 20
21 // MsgType identifies the kind of payload carried by a Frame.21 // MsgType identifies the kind of payload carried by a Frame.
22 type MsgType uint822 type MsgType uint8
23 23
24-// Frame message types. Client-originated messages occupy 1–13 and 15;24+// Frame message types. Client-originated messages occupy 1–13, 15, and 32;
25 // server-originated messages occupy 16–23 and 25–31. Values 14 and 24 remain25 // server-originated messages occupy 16–23 and 25–31. Values 14 and 24 remain
26 // reserved for future extensions. Route metadata/navigation messages use the26 // reserved for future extensions. Route metadata/navigation messages use the
27 // post-26 range.27 // post-26 range.
28 const (28 const (
29- MsgHello MsgType = 129+ MsgHello MsgType = 1
30- MsgInput MsgType = 230+ MsgInput MsgType = 2
31- MsgResize MsgType = 331+ MsgResize MsgType = 3
32- MsgDetach MsgType = 432+ MsgDetach MsgType = 4
33- MsgPing MsgType = 533+ MsgPing MsgType = 5
34- MsgList MsgType = 634+ MsgList MsgType = 6
35- MsgKill MsgType = 735+ MsgKill MsgType = 7
36- MsgTheme MsgType = 836+ MsgTheme MsgType = 8
37- MsgAck MsgType = 937+ MsgAck MsgType = 9
38- MsgImagePush MsgType = 1038+ MsgImagePush MsgType = 10
39- MsgClientNotice MsgType = 1139+ MsgClientNotice MsgType = 11
40- MsgCommand MsgType = 1240+ MsgCommand MsgType = 12
41- MsgOutputResetRequest MsgType = 1341+ MsgOutputResetRequest MsgType = 13
42- MsgRemotePreviewRequest MsgType = 1542+ MsgRemotePreviewRequest MsgType = 15
43+ MsgRouteAttentionSubscription MsgType = 32
43 44
44 MsgWelcome MsgType = 1645 MsgWelcome MsgType = 16
45 MsgError MsgType = 1746 MsgError MsgType = 17
M internal/ports/frame_test.go +3 -3
@@ -3,8 +3,8 @@ package ports
33 import "testing"
44
55 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 29 {
7- t.Fatalf("ProtocolVersion = %d, want 29", ProtocolVersion)
6+ if ProtocolVersion != 30 {
7+ t.Fatalf("ProtocolVersion = %d, want 30", ProtocolVersion)
88 }
99 }
1010
@@ -32,7 +32,7 @@ func TestMsgTypeUnique(t *testing.T) {
3232 {"MsgDetach", MsgDetach}, {"MsgPing", MsgPing}, {"MsgList", MsgList},
3333 {"MsgKill", MsgKill}, {"MsgTheme", MsgTheme}, {"MsgAck", MsgAck},
3434 {"MsgImagePush", MsgImagePush}, {"MsgClientNotice", MsgClientNotice},
35- {"MsgCommand", MsgCommand}, {"MsgOutputResetRequest", MsgOutputResetRequest}, {"MsgRemotePreviewRequest", MsgRemotePreviewRequest},
35+ {"MsgCommand", MsgCommand}, {"MsgOutputResetRequest", MsgOutputResetRequest}, {"MsgRemotePreviewRequest", MsgRemotePreviewRequest}, {"MsgRouteAttentionSubscription", MsgRouteAttentionSubscription},
3636 {"MsgWelcome", MsgWelcome}, {"MsgError", MsgError}, {"MsgOutput", MsgOutput},
3737 {"MsgDetached", MsgDetached}, {"MsgPong", MsgPong}, {"MsgSessions", MsgSessions},
3838 {"MsgCommandResult", MsgCommandResult},
@@ -3,8 +3,8 @@ package ports
3 import "testing"3 import "testing"
4 4
5 func TestProtocolVersion(t *testing.T) {5 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 29 {6+ if ProtocolVersion != 30 {
7- t.Fatalf("ProtocolVersion = %d, want 29", ProtocolVersion)7+ t.Fatalf("ProtocolVersion = %d, want 30", ProtocolVersion)
8 }8 }
9 }9 }
10 10
@@ -32,7 +32,7 @@ func TestMsgTypeUnique(t *testing.T) {
32 {"MsgDetach", MsgDetach}, {"MsgPing", MsgPing}, {"MsgList", MsgList},32 {"MsgDetach", MsgDetach}, {"MsgPing", MsgPing}, {"MsgList", MsgList},
33 {"MsgKill", MsgKill}, {"MsgTheme", MsgTheme}, {"MsgAck", MsgAck},33 {"MsgKill", MsgKill}, {"MsgTheme", MsgTheme}, {"MsgAck", MsgAck},
34 {"MsgImagePush", MsgImagePush}, {"MsgClientNotice", MsgClientNotice},34 {"MsgImagePush", MsgImagePush}, {"MsgClientNotice", MsgClientNotice},
35- {"MsgCommand", MsgCommand}, {"MsgOutputResetRequest", MsgOutputResetRequest}, {"MsgRemotePreviewRequest", MsgRemotePreviewRequest},35+ {"MsgCommand", MsgCommand}, {"MsgOutputResetRequest", MsgOutputResetRequest}, {"MsgRemotePreviewRequest", MsgRemotePreviewRequest}, {"MsgRouteAttentionSubscription", MsgRouteAttentionSubscription},
36 {"MsgWelcome", MsgWelcome}, {"MsgError", MsgError}, {"MsgOutput", MsgOutput},36 {"MsgWelcome", MsgWelcome}, {"MsgError", MsgError}, {"MsgOutput", MsgOutput},
37 {"MsgDetached", MsgDetached}, {"MsgPong", MsgPong}, {"MsgSessions", MsgSessions},37 {"MsgDetached", MsgDetached}, {"MsgPong", MsgPong}, {"MsgSessions", MsgSessions},
38 {"MsgCommandResult", MsgCommandResult},38 {"MsgCommandResult", MsgCommandResult},
M internal/ports/navigation_test.go +1 -1
@@ -62,7 +62,7 @@ func TestHelloNavigationValidationTable(t *testing.T) {
6262 {name: "client-owned home picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityHomePicker}, valid: false},
6363 {name: "daemon-owned back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false},
6464 {name: "remote-target back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false},
65- {name: "back with startup picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "001d02" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "0000" + "0201"},
65+ {name: "back with startup picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "001e02" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "0000" + "0201"},
6666 {name: "new rejects navigation", hello: Hello{Version: ProtocolVersion, Intent: IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: false},
6767 {name: "unknown capability", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false},
6868 {name: "back without picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack}, valid: false},
@@ -62,7 +62,7 @@ func TestHelloNavigationValidationTable(t *testing.T) {
62 {name: "client-owned home picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityHomePicker}, valid: false},62 {name: "client-owned home picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityHomePicker}, valid: false},
63 {name: "daemon-owned back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false},63 {name: "daemon-owned back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false},
64 {name: "remote-target back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false},64 {name: "remote-target back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false},
65- {name: "back with startup picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "001d02" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "0000" + "0201"},65+ {name: "back with startup picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "001e02" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "0000" + "0201"},
66 {name: "new rejects navigation", hello: Hello{Version: ProtocolVersion, Intent: IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: false},66 {name: "new rejects navigation", hello: Hello{Version: ProtocolVersion, Intent: IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: false},
67 {name: "unknown capability", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false},67 {name: "unknown capability", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false},
68 {name: "back without picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack}, valid: false},68 {name: "back without picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack}, valid: false},
M internal/ports/routes.go +14 -0
@@ -167,6 +167,20 @@ type RouteRef struct {
167167 Generation uint64
168168 }
169169
170+// RouteAttentionTarget binds one displayed route reference to an exact
171+// lifecycle on the daemon currently serving the client. It carries no dialer,
172+// credential, or endpoint.
173+type RouteAttentionTarget struct {
174+ Ref RouteRef
175+ Target ExactSessionTarget
176+}
177+
178+// RouteAttentionSubscription is the bounded, client-owned mapping a daemon
179+// uses solely to resolve live attention while rendering its route snapshot.
180+type RouteAttentionSubscription struct {
181+ Targets []RouteAttentionTarget
182+}
183+
170184 func (r RouteRef) empty() bool { return r.Key == 0 && r.Generation == 0 }
171185
172186 func (r RouteRef) Validate() error {
@@ -167,6 +167,20 @@ type RouteRef struct {
167 Generation uint64167 Generation uint64
168 }168 }
169 169
170+// RouteAttentionTarget binds one displayed route reference to an exact
171+// lifecycle on the daemon currently serving the client. It carries no dialer,
172+// credential, or endpoint.
173+type RouteAttentionTarget struct {
174+ Ref RouteRef
175+ Target ExactSessionTarget
176+}
177+
178+// RouteAttentionSubscription is the bounded, client-owned mapping a daemon
179+// uses solely to resolve live attention while rendering its route snapshot.
180+type RouteAttentionSubscription struct {
181+ Targets []RouteAttentionTarget
182+}
183+
170 func (r RouteRef) empty() bool { return r.Key == 0 && r.Generation == 0 }184 func (r RouteRef) empty() bool { return r.Key == 0 && r.Generation == 0 }
171 185
172 func (r RouteRef) Validate() error {186 func (r RouteRef) Validate() error {
M internal/ports/routes_test.go +21 -0
@@ -162,6 +162,27 @@ func TestRecentRouteSnapshotCodecRoundTripAndBounds(t *testing.T) {
162162 }
163163 }
164164
165+func TestRouteAttentionSubscriptionCodecIsStrict(t *testing.T) {
166+ want := RouteAttentionSubscription{Targets: []RouteAttentionTarget{{
167+ Ref: RouteRef{Key: 7, Generation: 3},
168+ Target: testExactTarget(),
169+ }}}
170+ encoded, err := MarshalRouteAttentionSubscription(want)
171+ require.NoError(t, err)
172+ assertAllPrefixesFail(t, encoded, UnmarshalRouteAttentionSubscription)
173+ _, err = UnmarshalRouteAttentionSubscription(append(append([]byte(nil), encoded...), 0))
174+ require.Error(t, err)
175+
176+ got, err := UnmarshalRouteAttentionSubscription(encoded)
177+ require.NoError(t, err)
178+ require.Equal(t, want, got)
179+
180+ _, err = MarshalRouteAttentionSubscription(RouteAttentionSubscription{Targets: []RouteAttentionTarget{
181+ want.Targets[0], want.Targets[0],
182+ }})
183+ require.ErrorIs(t, err, ErrInvalidRouteWire)
184+}
185+
165186 func TestRecentRouteSnapshotRejectsOversizedFrameBeforeParsing(t *testing.T) {
166187 _, err := UnmarshalRecentRouteSnapshot(make([]byte, MaxFrameLen))
167188 require.ErrorIs(t, err, ErrInvalidRouteWire)
@@ -162,6 +162,27 @@ func TestRecentRouteSnapshotCodecRoundTripAndBounds(t *testing.T) {
162 }162 }
163 }163 }
164 164
165+func TestRouteAttentionSubscriptionCodecIsStrict(t *testing.T) {
166+ want := RouteAttentionSubscription{Targets: []RouteAttentionTarget{{
167+ Ref: RouteRef{Key: 7, Generation: 3},
168+ Target: testExactTarget(),
169+ }}}
170+ encoded, err := MarshalRouteAttentionSubscription(want)
171+ require.NoError(t, err)
172+ assertAllPrefixesFail(t, encoded, UnmarshalRouteAttentionSubscription)
173+ _, err = UnmarshalRouteAttentionSubscription(append(append([]byte(nil), encoded...), 0))
174+ require.Error(t, err)
175+
176+ got, err := UnmarshalRouteAttentionSubscription(encoded)
177+ require.NoError(t, err)
178+ require.Equal(t, want, got)
179+
180+ _, err = MarshalRouteAttentionSubscription(RouteAttentionSubscription{Targets: []RouteAttentionTarget{
181+ want.Targets[0], want.Targets[0],
182+ }})
183+ require.ErrorIs(t, err, ErrInvalidRouteWire)
184+}
185+
165 func TestRecentRouteSnapshotRejectsOversizedFrameBeforeParsing(t *testing.T) {186 func TestRecentRouteSnapshotRejectsOversizedFrameBeforeParsing(t *testing.T) {
166 _, err := UnmarshalRecentRouteSnapshot(make([]byte, MaxFrameLen))187 _, err := UnmarshalRecentRouteSnapshot(make([]byte, MaxFrameLen))
167 require.ErrorIs(t, err, ErrInvalidRouteWire)188 require.ErrorIs(t, err, ErrInvalidRouteWire)
M internal/ports/routes_wire.go +67 -0
@@ -340,6 +340,73 @@ func MarshalRecentRouteSnapshot(snapshot RecentRouteSnapshot) ([]byte, error) {
340340 return w.b, nil
341341 }
342342
343+func validateRouteAttentionSubscription(subscription RouteAttentionSubscription) error {
344+ if len(subscription.Targets) > RouteSnapshotMaxEntries {
345+ return fmt.Errorf("%w: too many attention targets", ErrInvalidRouteWire)
346+ }
347+ refs := make(map[RouteRef]struct{}, len(subscription.Targets))
348+ for _, target := range subscription.Targets {
349+ if err := validateRouteRef(target.Ref); err != nil || target.Ref.empty() {
350+ return fmt.Errorf("%w: invalid attention route reference", ErrInvalidRouteWire)
351+ }
352+ if err := target.Target.Validate(); err != nil {
353+ return fmt.Errorf("%w: invalid attention target: %v", ErrInvalidRouteWire, err)
354+ }
355+ if _, exists := refs[target.Ref]; exists {
356+ return fmt.Errorf("%w: duplicate attention route reference", ErrInvalidRouteWire)
357+ }
358+ refs[target.Ref] = struct{}{}
359+ }
360+ return nil
361+}
362+
363+// MarshalRouteAttentionSubscription encodes the bounded client mapping used
364+// by the connected daemon to resolve live route-attention indicators.
365+func MarshalRouteAttentionSubscription(subscription RouteAttentionSubscription) ([]byte, error) {
366+ if err := validateRouteAttentionSubscription(subscription); err != nil {
367+ return nil, err
368+ }
369+ w := payloadWriter{}
370+ w.putUint8(uint8(len(subscription.Targets)))
371+ for _, target := range subscription.Targets {
372+ marshalRouteRef(&w, target.Ref)
373+ marshalExactSessionTarget(&w, target.Target)
374+ }
375+ return w.b, nil
376+}
377+
378+// UnmarshalRouteAttentionSubscription decodes one strict bounded attention
379+// mapping and rejects truncated, malformed, and trailing payloads.
380+func UnmarshalRouteAttentionSubscription(b []byte) (RouteAttentionSubscription, error) {
381+ r := payloadReader{b: b}
382+ count, err := r.getUint8()
383+ if err != nil {
384+ return RouteAttentionSubscription{}, err
385+ }
386+ if int(count) > RouteSnapshotMaxEntries {
387+ return RouteAttentionSubscription{}, fmt.Errorf("%w: too many attention targets", ErrInvalidRouteWire)
388+ }
389+ subscription := RouteAttentionSubscription{Targets: make([]RouteAttentionTarget, 0, int(count))}
390+ for range int(count) {
391+ ref, err := unmarshalRouteRef(&r)
392+ if err != nil {
393+ return RouteAttentionSubscription{}, err
394+ }
395+ target, err := unmarshalExactSessionTarget(&r)
396+ if err != nil {
397+ return RouteAttentionSubscription{}, err
398+ }
399+ subscription.Targets = append(subscription.Targets, RouteAttentionTarget{Ref: ref, Target: target})
400+ }
401+ if err := r.done(); err != nil {
402+ return RouteAttentionSubscription{}, err
403+ }
404+ if err := validateRouteAttentionSubscription(subscription); err != nil {
405+ return RouteAttentionSubscription{}, err
406+ }
407+ return subscription, nil
408+}
409+
343410 // UnmarshalRecentRouteSnapshot decodes and strictly validates one complete
344411 // immutable snapshot, including every referenced active/previous/home entry.
345412 func UnmarshalRecentRouteSnapshot(b []byte) (RecentRouteSnapshot, error) {
@@ -340,6 +340,73 @@ func MarshalRecentRouteSnapshot(snapshot RecentRouteSnapshot) ([]byte, error) {
340 return w.b, nil340 return w.b, nil
341 }341 }
342 342
343+func validateRouteAttentionSubscription(subscription RouteAttentionSubscription) error {
344+ if len(subscription.Targets) > RouteSnapshotMaxEntries {
345+ return fmt.Errorf("%w: too many attention targets", ErrInvalidRouteWire)
346+ }
347+ refs := make(map[RouteRef]struct{}, len(subscription.Targets))
348+ for _, target := range subscription.Targets {
349+ if err := validateRouteRef(target.Ref); err != nil || target.Ref.empty() {
350+ return fmt.Errorf("%w: invalid attention route reference", ErrInvalidRouteWire)
351+ }
352+ if err := target.Target.Validate(); err != nil {
353+ return fmt.Errorf("%w: invalid attention target: %v", ErrInvalidRouteWire, err)
354+ }
355+ if _, exists := refs[target.Ref]; exists {
356+ return fmt.Errorf("%w: duplicate attention route reference", ErrInvalidRouteWire)
357+ }
358+ refs[target.Ref] = struct{}{}
359+ }
360+ return nil
361+}
362+
363+// MarshalRouteAttentionSubscription encodes the bounded client mapping used
364+// by the connected daemon to resolve live route-attention indicators.
365+func MarshalRouteAttentionSubscription(subscription RouteAttentionSubscription) ([]byte, error) {
366+ if err := validateRouteAttentionSubscription(subscription); err != nil {
367+ return nil, err
368+ }
369+ w := payloadWriter{}
370+ w.putUint8(uint8(len(subscription.Targets)))
371+ for _, target := range subscription.Targets {
372+ marshalRouteRef(&w, target.Ref)
373+ marshalExactSessionTarget(&w, target.Target)
374+ }
375+ return w.b, nil
376+}
377+
378+// UnmarshalRouteAttentionSubscription decodes one strict bounded attention
379+// mapping and rejects truncated, malformed, and trailing payloads.
380+func UnmarshalRouteAttentionSubscription(b []byte) (RouteAttentionSubscription, error) {
381+ r := payloadReader{b: b}
382+ count, err := r.getUint8()
383+ if err != nil {
384+ return RouteAttentionSubscription{}, err
385+ }
386+ if int(count) > RouteSnapshotMaxEntries {
387+ return RouteAttentionSubscription{}, fmt.Errorf("%w: too many attention targets", ErrInvalidRouteWire)
388+ }
389+ subscription := RouteAttentionSubscription{Targets: make([]RouteAttentionTarget, 0, int(count))}
390+ for range int(count) {
391+ ref, err := unmarshalRouteRef(&r)
392+ if err != nil {
393+ return RouteAttentionSubscription{}, err
394+ }
395+ target, err := unmarshalExactSessionTarget(&r)
396+ if err != nil {
397+ return RouteAttentionSubscription{}, err
398+ }
399+ subscription.Targets = append(subscription.Targets, RouteAttentionTarget{Ref: ref, Target: target})
400+ }
401+ if err := r.done(); err != nil {
402+ return RouteAttentionSubscription{}, err
403+ }
404+ if err := validateRouteAttentionSubscription(subscription); err != nil {
405+ return RouteAttentionSubscription{}, err
406+ }
407+ return subscription, nil
408+}
409+
343 // UnmarshalRecentRouteSnapshot decodes and strictly validates one complete410 // UnmarshalRecentRouteSnapshot decodes and strictly validates one complete
344 // immutable snapshot, including every referenced active/previous/home entry.411 // immutable snapshot, including every referenced active/previous/home entry.
345 func UnmarshalRecentRouteSnapshot(b []byte) (RecentRouteSnapshot, error) {412 func UnmarshalRecentRouteSnapshot(b []byte) (RecentRouteSnapshot, error) {
M internal/ports/wire_final_test.go +3 -3
@@ -11,8 +11,8 @@ import (
1111 )
1212
1313 func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
14- if ProtocolVersion != 29 {
15- t.Fatalf("ProtocolVersion = %d, want 29", ProtocolVersion)
14+ if ProtocolVersion != 30 {
15+ t.Fatalf("ProtocolVersion = %d, want 30", ProtocolVersion)
1616 }
1717 payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}})
1818 if len(payload) < 2 {
@@ -26,7 +26,7 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
2626
2727 func TestFinalHelloGoldenStrict(t *testing.T) {
2828 msg := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}
29- want := append([]byte{0, 29, 2}, make([]byte, 16+8)...)
29+ want := append([]byte{0, 30, 2}, make([]byte, 16+8)...)
3030 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)
3131 got := MarshalHello(msg)
3232 if !bytes.Equal(got, want) {
@@ -11,8 +11,8 @@ import (
11 )11 )
12 12
13 func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {13 func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
14- if ProtocolVersion != 29 {14+ if ProtocolVersion != 30 {
15- t.Fatalf("ProtocolVersion = %d, want 29", ProtocolVersion)15+ t.Fatalf("ProtocolVersion = %d, want 30", ProtocolVersion)
16 }16 }
17 payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}})17 payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}})
18 if len(payload) < 2 {18 if len(payload) < 2 {
@@ -26,7 +26,7 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
26 26
27 func TestFinalHelloGoldenStrict(t *testing.T) {27 func TestFinalHelloGoldenStrict(t *testing.T) {
28 msg := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}28 msg := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}
29- want := append([]byte{0, 29, 2}, make([]byte, 16+8)...)29+ want := append([]byte{0, 30, 2}, make([]byte, 16+8)...)
30 want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)30 want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
31 got := MarshalHello(msg)31 got := MarshalHello(msg)
32 if !bytes.Equal(got, want) {32 if !bytes.Equal(got, want) {
M internal/ports/wire_test.go +6 -6
@@ -131,7 +131,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
131131 }
132132 got := MarshalHello(msg)
133133 want := []byte{
134- 0x00, 0x1d, 0x00, // version, intent, first client ID byte
134+ 0x00, 0x1e, 0x00, // version, intent, first client ID byte
135135 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
136136 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
137137 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
@@ -361,7 +361,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T)
361361 }
362362 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
363363 require.Equal(t, want, MarshalTheme(cleared))
364- require.Equal(t, uint16(29), ProtocolVersion)
364+ require.Equal(t, uint16(30), ProtocolVersion)
365365 }
366366
367367 func TestResizeGoldenAndRoundTrip(t *testing.T) {
@@ -522,7 +522,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
522522 name: "minimal",
523523 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},
524524 want: []byte{
525- 0x00, 0x1d,
525+ 0x00, 0x1e,
526526 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
527527 0x00, // attached
528528 0x00, // self
@@ -545,7 +545,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
545545 JSON: true,
546546 },
547547 want: []byte{
548- 0x00, 0x1d,
548+ 0x00, 0x1e,
549549 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
550550 0x00, // attached
551551 0x01, // self
@@ -974,7 +974,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
974974 name: "attached",
975975 msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},
976976 want: []byte{
977- 0x00, 0x1d, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
977+ 0x00, 0x1e, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
978978 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
979979 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',
980980 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -984,7 +984,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
984984 name: "control",
985985 msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"},
986986 want: []byte{
987- 0x00, 0x1d, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
987+ 0x00, 0x1e, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
988988 0x00, 0x02, 'l', 's', 0x00, 0x00,
989989 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
990990 },
@@ -131,7 +131,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
131 }131 }
132 got := MarshalHello(msg)132 got := MarshalHello(msg)
133 want := []byte{133 want := []byte{
134- 0x00, 0x1d, 0x00, // version, intent, first client ID byte134+ 0x00, 0x1e, 0x00, // version, intent, first client ID byte
135 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,135 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
136 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID136 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
137 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token137 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
@@ -361,7 +361,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T)
361 }361 }
362 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)362 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
363 require.Equal(t, want, MarshalTheme(cleared))363 require.Equal(t, want, MarshalTheme(cleared))
364- require.Equal(t, uint16(29), ProtocolVersion)364+ require.Equal(t, uint16(30), ProtocolVersion)
365 }365 }
366 366
367 func TestResizeGoldenAndRoundTrip(t *testing.T) {367 func TestResizeGoldenAndRoundTrip(t *testing.T) {
@@ -522,7 +522,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
522 name: "minimal",522 name: "minimal",
523 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},523 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},
524 want: []byte{524 want: []byte{
525- 0x00, 0x1d,525+ 0x00, 0x1e,
526 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID526 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
527 0x00, // attached527 0x00, // attached
528 0x00, // self528 0x00, // self
@@ -545,7 +545,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
545 JSON: true,545 JSON: true,
546 },546 },
547 want: []byte{547 want: []byte{
548- 0x00, 0x1d,548+ 0x00, 0x1e,
549 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID549 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
550 0x00, // attached550 0x00, // attached
551 0x01, // self551 0x01, // self
@@ -974,7 +974,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
974 name: "attached",974 name: "attached",
975 msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},975 msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},
976 want: []byte{976 want: []byte{
977- 0x00, 0x1d, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,977+ 0x00, 0x1e, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
978 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',978 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
979 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',979 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',
980 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,980 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -984,7 +984,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
984 name: "control",984 name: "control",
985 msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"},985 msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"},
986 want: []byte{986 want: []byte{
987- 0x00, 0x1d, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,987+ 0x00, 0x1e, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
988 0x00, 0x02, 'l', 's', 0x00, 0x00,988 0x00, 0x02, 'l', 's', 0x00, 0x00,
989 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,989 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
990 },990 },
M internal/usecase/client/client.go +24 -8
@@ -1118,12 +1118,19 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
11181118 if commitErr != nil {
11191119 return welcomedResult(fmt.Errorf("vev: committing route identity: %w", commitErr))
11201120 }
1121- payload, err := ports.MarshalRecentRouteSnapshot(a.runner.ledger.snapshot())
1121+ snapshotPayload, err := ports.MarshalRecentRouteSnapshot(a.runner.ledger.snapshot())
11221122 if err != nil {
11231123 return welcomedResult(fmt.Errorf("vev: encoding route snapshot: %w", err))
11241124 }
1125+ attentionPayload, err := ports.MarshalRouteAttentionSubscription(a.runner.ledger.attentionSubscription())
1126+ if err != nil {
1127+ return welcomedResult(fmt.Errorf("vev: encoding route attention subscription: %w", err))
1128+ }
11251129 if err := sendHandshake(func() error {
1126- return transport.Send(ports.Frame{Type: ports.MsgRecentRouteSnapshot, Payload: payload})
1130+ if err := transport.Send(ports.Frame{Type: ports.MsgRecentRouteSnapshot, Payload: snapshotPayload}); err != nil {
1131+ return err
1132+ }
1133+ return transport.Send(ports.Frame{Type: ports.MsgRouteAttentionSubscription, Payload: attentionPayload})
11271134 }); err != nil {
11281135 return welcomedResult(fmt.Errorf("vev: publishing route snapshot: %w", err))
11291136 }
@@ -1359,16 +1366,25 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
13591366 if a.runner.ledger == nil {
13601367 return errors.New("vev: route ledger unavailable")
13611368 }
1362- payload, err := ports.MarshalRecentRouteSnapshot(a.runner.ledger.snapshot())
1369+ snapshotPayload, err := ports.MarshalRecentRouteSnapshot(a.runner.ledger.snapshot())
13631370 if err != nil {
13641371 return err
13651372 }
1366- select {
1367- case sendCh <- ports.Frame{Type: ports.MsgRecentRouteSnapshot, Payload: payload}:
1368- return nil
1369- case <-loopCtx.Done():
1370- return context.Canceled
1373+ attentionPayload, err := ports.MarshalRouteAttentionSubscription(a.runner.ledger.attentionSubscription())
1374+ if err != nil {
1375+ return err
13711376 }
1377+ for _, frame := range []ports.Frame{
1378+ {Type: ports.MsgRecentRouteSnapshot, Payload: snapshotPayload},
1379+ {Type: ports.MsgRouteAttentionSubscription, Payload: attentionPayload},
1380+ } {
1381+ select {
1382+ case sendCh <- frame:
1383+ case <-loopCtx.Done():
1384+ return context.Canceled
1385+ }
1386+ }
1387+ return nil
13721388 }
13731389 sendRouteFailure := func(action ports.RouteNavigationAction, code ports.RouteFailureCode) error {
13741390 payload, err := ports.MarshalRouteNavigationFailure(ports.RouteNavigationFailure{
@@ -1118,12 +1118,19 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1118 if commitErr != nil {1118 if commitErr != nil {
1119 return welcomedResult(fmt.Errorf("vev: committing route identity: %w", commitErr))1119 return welcomedResult(fmt.Errorf("vev: committing route identity: %w", commitErr))
1120 }1120 }
1121- payload, err := ports.MarshalRecentRouteSnapshot(a.runner.ledger.snapshot())1121+ snapshotPayload, err := ports.MarshalRecentRouteSnapshot(a.runner.ledger.snapshot())
1122 if err != nil {1122 if err != nil {
1123 return welcomedResult(fmt.Errorf("vev: encoding route snapshot: %w", err))1123 return welcomedResult(fmt.Errorf("vev: encoding route snapshot: %w", err))
1124 }1124 }
1125+ attentionPayload, err := ports.MarshalRouteAttentionSubscription(a.runner.ledger.attentionSubscription())
1126+ if err != nil {
1127+ return welcomedResult(fmt.Errorf("vev: encoding route attention subscription: %w", err))
1128+ }
1125 if err := sendHandshake(func() error {1129 if err := sendHandshake(func() error {
1126- return transport.Send(ports.Frame{Type: ports.MsgRecentRouteSnapshot, Payload: payload})1130+ if err := transport.Send(ports.Frame{Type: ports.MsgRecentRouteSnapshot, Payload: snapshotPayload}); err != nil {
1131+ return err
1132+ }
1133+ return transport.Send(ports.Frame{Type: ports.MsgRouteAttentionSubscription, Payload: attentionPayload})
1127 }); err != nil {1134 }); err != nil {
1128 return welcomedResult(fmt.Errorf("vev: publishing route snapshot: %w", err))1135 return welcomedResult(fmt.Errorf("vev: publishing route snapshot: %w", err))
1129 }1136 }
@@ -1359,16 +1366,25 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1359 if a.runner.ledger == nil {1366 if a.runner.ledger == nil {
1360 return errors.New("vev: route ledger unavailable")1367 return errors.New("vev: route ledger unavailable")
1361 }1368 }
1362- payload, err := ports.MarshalRecentRouteSnapshot(a.runner.ledger.snapshot())1369+ snapshotPayload, err := ports.MarshalRecentRouteSnapshot(a.runner.ledger.snapshot())
1363 if err != nil {1370 if err != nil {
1364 return err1371 return err
1365 }1372 }
1366- select {1373+ attentionPayload, err := ports.MarshalRouteAttentionSubscription(a.runner.ledger.attentionSubscription())
1367- case sendCh <- ports.Frame{Type: ports.MsgRecentRouteSnapshot, Payload: payload}:1374+ if err != nil {
1368- return nil1375+ return err
1369- case <-loopCtx.Done():
1370- return context.Canceled
1371 }1376 }
1377+ for _, frame := range []ports.Frame{
1378+ {Type: ports.MsgRecentRouteSnapshot, Payload: snapshotPayload},
1379+ {Type: ports.MsgRouteAttentionSubscription, Payload: attentionPayload},
1380+ } {
1381+ select {
1382+ case sendCh <- frame:
1383+ case <-loopCtx.Done():
1384+ return context.Canceled
1385+ }
1386+ }
1387+ return nil
1372 }1388 }
1373 sendRouteFailure := func(action ports.RouteNavigationAction, code ports.RouteFailureCode) error {1389 sendRouteFailure := func(action ports.RouteNavigationAction, code ports.RouteFailureCode) error {
1374 payload, err := ports.MarshalRouteNavigationFailure(ports.RouteNavigationFailure{1390 payload, err := ports.MarshalRouteNavigationFailure(ports.RouteNavigationFailure{
M internal/usecase/client/client_test.go +17 -8
@@ -438,17 +438,26 @@ func TestAttachPublishesCommittedRouteSnapshot(t *testing.T) {
438438 require.NoError(t, err)
439439
440440 var snapshot ports.RecentRouteSnapshot
441- found := false
441+ var subscription ports.RouteAttentionSubscription
442+ foundSnapshot := false
443+ foundSubscription := false
442444 for _, frame := range tr.Sends() {
443- if frame.Type != ports.MsgRecentRouteSnapshot {
444- continue
445+ switch frame.Type {
446+ case ports.MsgRecentRouteSnapshot:
447+ var err error
448+ snapshot, err = ports.UnmarshalRecentRouteSnapshot(frame.Payload)
449+ require.NoError(t, err)
450+ foundSnapshot = true
451+ case ports.MsgRouteAttentionSubscription:
452+ var err error
453+ subscription, err = ports.UnmarshalRouteAttentionSubscription(frame.Payload)
454+ require.NoError(t, err)
455+ foundSubscription = true
445456 }
446- var err error
447- snapshot, err = ports.UnmarshalRecentRouteSnapshot(frame.Payload)
448- require.NoError(t, err)
449- found = true
450457 }
451- require.True(t, found, "successful Welcome must publish a route snapshot")
458+ require.True(t, foundSnapshot, "successful Welcome must publish a route snapshot")
459+ require.True(t, foundSubscription, "successful Welcome must publish a route attention subscription")
460+ require.Empty(t, subscription.Targets, "the active route itself needs no status subscription")
452461 require.NotZero(t, snapshot.Generation)
453462 require.NotZero(t, snapshot.Active.Key)
454463 require.Empty(t, snapshot.Entries, "the active route is metadata-only")
@@ -438,17 +438,26 @@ func TestAttachPublishesCommittedRouteSnapshot(t *testing.T) {
438 require.NoError(t, err)438 require.NoError(t, err)
439 439
440 var snapshot ports.RecentRouteSnapshot440 var snapshot ports.RecentRouteSnapshot
441- found := false441+ var subscription ports.RouteAttentionSubscription
442+ foundSnapshot := false
443+ foundSubscription := false
442 for _, frame := range tr.Sends() {444 for _, frame := range tr.Sends() {
443- if frame.Type != ports.MsgRecentRouteSnapshot {445+ switch frame.Type {
444- continue446+ case ports.MsgRecentRouteSnapshot:
447+ var err error
448+ snapshot, err = ports.UnmarshalRecentRouteSnapshot(frame.Payload)
449+ require.NoError(t, err)
450+ foundSnapshot = true
451+ case ports.MsgRouteAttentionSubscription:
452+ var err error
453+ subscription, err = ports.UnmarshalRouteAttentionSubscription(frame.Payload)
454+ require.NoError(t, err)
455+ foundSubscription = true
445 }456 }
446- var err error
447- snapshot, err = ports.UnmarshalRecentRouteSnapshot(frame.Payload)
448- require.NoError(t, err)
449- found = true
450 }457 }
451- require.True(t, found, "successful Welcome must publish a route snapshot")458+ require.True(t, foundSnapshot, "successful Welcome must publish a route snapshot")
459+ require.True(t, foundSubscription, "successful Welcome must publish a route attention subscription")
460+ require.Empty(t, subscription.Targets, "the active route itself needs no status subscription")
452 require.NotZero(t, snapshot.Generation)461 require.NotZero(t, snapshot.Generation)
453 require.NotZero(t, snapshot.Active.Key)462 require.NotZero(t, snapshot.Active.Key)
454 require.Empty(t, snapshot.Entries, "the active route is metadata-only")463 require.Empty(t, snapshot.Entries, "the active route is metadata-only")
M internal/usecase/client/routes.go +47 -1
@@ -347,10 +347,32 @@ func (l *routeLedger) commitCommittedIdentity(identity ports.CommittedRouteIdent
347347 if activeIndex < 0 {
348348 return routeIdentity{}, errors.New("active route unavailable")
349349 }
350- candidate, err := prepareRouteCandidate(routeCandidateForCommittedIdentity(l.entries[activeIndex], identity))
350+ active := l.entries[activeIndex]
351+ candidate, err := prepareRouteCandidate(routeCandidateForCommittedIdentity(active, identity))
351352 if err != nil {
352353 return routeIdentity{}, err
353354 }
355+ // A session rename changes its exact attach target but not its lifecycle.
356+ // Keep the active route's process-local identity so the obsolete name cannot
357+ // survive as a separately selectable history entry.
358+ if active.target.LifecycleID == candidate.target.LifecycleID {
359+ if l.generation == ^routeGeneration(0) {
360+ return routeIdentity{}, errors.New("route ledger identity space exhausted")
361+ }
362+ l.generation++
363+ l.entries[activeIndex] = routeRecord{
364+ identity: active.identity,
365+ origin: candidate.origin,
366+ originKey: candidate.originKey,
367+ target: candidate.target,
368+ presentation: candidate.presentation,
369+ dialer: candidate.dialer,
370+ request: candidate.request,
371+ resumeToken: candidate.resumeToken,
372+ home: active.home,
373+ }
374+ return active.identity, nil
375+ }
354376 return l.commitLocked(candidate)
355377 }
356378
@@ -453,6 +475,30 @@ func cloneRouteRecord(entry routeRecord) routeRecord {
453475 return entry
454476 }
455477
478+// attentionSubscription publishes only routes served by the active route's
479+// daemon. Other route origins remain private to their owning daemon.
480+func (l *routeLedger) attentionSubscription() ports.RouteAttentionSubscription {
481+ l.mu.RLock()
482+ defer l.mu.RUnlock()
483+
484+ activeIndex := l.indexByIdentityLocked(l.active)
485+ if activeIndex < 0 {
486+ return ports.RouteAttentionSubscription{}
487+ }
488+ active := l.entries[activeIndex]
489+ subscription := ports.RouteAttentionSubscription{Targets: make([]ports.RouteAttentionTarget, 0, len(l.entries))}
490+ for _, entry := range l.entries {
491+ if entry.identity == active.identity || entry.origin != active.origin || entry.originKey != active.originKey {
492+ continue
493+ }
494+ subscription.Targets = append(subscription.Targets, ports.RouteAttentionTarget{
495+ Ref: entry.identity.wire(),
496+ Target: entry.target,
497+ })
498+ }
499+ return subscription
500+}
501+
456502 func (l *routeLedger) snapshot() ports.RecentRouteSnapshot {
457503 l.mu.RLock()
458504 defer l.mu.RUnlock()
@@ -347,10 +347,32 @@ func (l *routeLedger) commitCommittedIdentity(identity ports.CommittedRouteIdent
347 if activeIndex < 0 {347 if activeIndex < 0 {
348 return routeIdentity{}, errors.New("active route unavailable")348 return routeIdentity{}, errors.New("active route unavailable")
349 }349 }
350- candidate, err := prepareRouteCandidate(routeCandidateForCommittedIdentity(l.entries[activeIndex], identity))350+ active := l.entries[activeIndex]
351+ candidate, err := prepareRouteCandidate(routeCandidateForCommittedIdentity(active, identity))
351 if err != nil {352 if err != nil {
352 return routeIdentity{}, err353 return routeIdentity{}, err
353 }354 }
355+ // A session rename changes its exact attach target but not its lifecycle.
356+ // Keep the active route's process-local identity so the obsolete name cannot
357+ // survive as a separately selectable history entry.
358+ if active.target.LifecycleID == candidate.target.LifecycleID {
359+ if l.generation == ^routeGeneration(0) {
360+ return routeIdentity{}, errors.New("route ledger identity space exhausted")
361+ }
362+ l.generation++
363+ l.entries[activeIndex] = routeRecord{
364+ identity: active.identity,
365+ origin: candidate.origin,
366+ originKey: candidate.originKey,
367+ target: candidate.target,
368+ presentation: candidate.presentation,
369+ dialer: candidate.dialer,
370+ request: candidate.request,
371+ resumeToken: candidate.resumeToken,
372+ home: active.home,
373+ }
374+ return active.identity, nil
375+ }
354 return l.commitLocked(candidate)376 return l.commitLocked(candidate)
355 }377 }
356 378
@@ -453,6 +475,30 @@ func cloneRouteRecord(entry routeRecord) routeRecord {
453 return entry475 return entry
454 }476 }
455 477
478+// attentionSubscription publishes only routes served by the active route's
479+// daemon. Other route origins remain private to their owning daemon.
480+func (l *routeLedger) attentionSubscription() ports.RouteAttentionSubscription {
481+ l.mu.RLock()
482+ defer l.mu.RUnlock()
483+
484+ activeIndex := l.indexByIdentityLocked(l.active)
485+ if activeIndex < 0 {
486+ return ports.RouteAttentionSubscription{}
487+ }
488+ active := l.entries[activeIndex]
489+ subscription := ports.RouteAttentionSubscription{Targets: make([]ports.RouteAttentionTarget, 0, len(l.entries))}
490+ for _, entry := range l.entries {
491+ if entry.identity == active.identity || entry.origin != active.origin || entry.originKey != active.originKey {
492+ continue
493+ }
494+ subscription.Targets = append(subscription.Targets, ports.RouteAttentionTarget{
495+ Ref: entry.identity.wire(),
496+ Target: entry.target,
497+ })
498+ }
499+ return subscription
500+}
501+
456 func (l *routeLedger) snapshot() ports.RecentRouteSnapshot {502 func (l *routeLedger) snapshot() ports.RecentRouteSnapshot {
457 l.mu.RLock()503 l.mu.RLock()
458 defer l.mu.RUnlock()504 defer l.mu.RUnlock()
M internal/usecase/client/routes_test.go +49 -0
@@ -109,6 +109,55 @@ func TestRouteLedgerRemembersTabsIndependentlyPerExactRoute(t *testing.T) {
109109 require.Equal(t, domain.TabStableID("tab-second"), secondRecord.request.PreferredTabID)
110110 }
111111
112+func TestRouteAttentionSubscriptionIncludesOnlyActiveOriginRoutes(t *testing.T) {
113+ ledger := newRouteLedger()
114+ first := routeTestCandidate(0, ports.RouteOriginRemote)
115+ first.originKey = "host-a"
116+ firstIdentity, err := ledger.commit(first)
117+ require.NoError(t, err)
118+ other := routeTestCandidate(1, ports.RouteOriginLocal)
119+ _, err = ledger.commit(other)
120+ require.NoError(t, err)
121+ active := routeTestCandidate(2, ports.RouteOriginRemote)
122+ active.originKey = "host-a"
123+ _, err = ledger.commit(active)
124+ require.NoError(t, err)
125+
126+ subscription := ledger.attentionSubscription()
127+
128+ require.Equal(t, []ports.RouteAttentionTarget{{
129+ Ref: firstIdentity.wire(),
130+ Target: first.target,
131+ }}, subscription.Targets)
132+}
133+
134+func TestCommittedIdentityRenamesActiveRouteInPlace(t *testing.T) {
135+ ledger := newRouteLedger()
136+ original := routeTestCandidate(0, ports.RouteOriginLocal)
137+ original.target = ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "0"}
138+ original.presentation.name = "0"
139+ original.request.SessionName = "0"
140+ identity, err := ledger.commit(original)
141+ require.NoError(t, err)
142+ before := ledger.snapshot()
143+
144+ committed, err := ledger.commitCommittedIdentity(ports.CommittedRouteIdentity{
145+ Target: ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "vps-infra"},
146+ Ephemeral: false,
147+ })
148+ require.NoError(t, err)
149+
150+ snapshot := ledger.snapshot()
151+ require.Equal(t, identity, committed, "a rename must retain the route's process-local identity")
152+ require.Greater(t, snapshot.Generation, before.Generation, "a rename invalidates prior route snapshots")
153+ require.Empty(t, snapshot.Entries, "the old ephemeral label must not remain in route history")
154+ active, ok := ledger.lookup(snapshot.Active)
155+ require.True(t, ok)
156+ require.Equal(t, "vps-infra", active.presentation.name)
157+ require.Equal(t, "vps-infra", active.target.SessionName)
158+ require.False(t, active.presentation.ephemeral)
159+}
160+
112161 func TestCommittedIdentityDoesNotReassignHomeRoute(t *testing.T) {
113162 ledger := newRouteLedger()
114163 home := routeTestCandidate(1, ports.RouteOriginLocal)
@@ -109,6 +109,55 @@ func TestRouteLedgerRemembersTabsIndependentlyPerExactRoute(t *testing.T) {
109 require.Equal(t, domain.TabStableID("tab-second"), secondRecord.request.PreferredTabID)109 require.Equal(t, domain.TabStableID("tab-second"), secondRecord.request.PreferredTabID)
110 }110 }
111 111
112+func TestRouteAttentionSubscriptionIncludesOnlyActiveOriginRoutes(t *testing.T) {
113+ ledger := newRouteLedger()
114+ first := routeTestCandidate(0, ports.RouteOriginRemote)
115+ first.originKey = "host-a"
116+ firstIdentity, err := ledger.commit(first)
117+ require.NoError(t, err)
118+ other := routeTestCandidate(1, ports.RouteOriginLocal)
119+ _, err = ledger.commit(other)
120+ require.NoError(t, err)
121+ active := routeTestCandidate(2, ports.RouteOriginRemote)
122+ active.originKey = "host-a"
123+ _, err = ledger.commit(active)
124+ require.NoError(t, err)
125+
126+ subscription := ledger.attentionSubscription()
127+
128+ require.Equal(t, []ports.RouteAttentionTarget{{
129+ Ref: firstIdentity.wire(),
130+ Target: first.target,
131+ }}, subscription.Targets)
132+}
133+
134+func TestCommittedIdentityRenamesActiveRouteInPlace(t *testing.T) {
135+ ledger := newRouteLedger()
136+ original := routeTestCandidate(0, ports.RouteOriginLocal)
137+ original.target = ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "0"}
138+ original.presentation.name = "0"
139+ original.request.SessionName = "0"
140+ identity, err := ledger.commit(original)
141+ require.NoError(t, err)
142+ before := ledger.snapshot()
143+
144+ committed, err := ledger.commitCommittedIdentity(ports.CommittedRouteIdentity{
145+ Target: ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "vps-infra"},
146+ Ephemeral: false,
147+ })
148+ require.NoError(t, err)
149+
150+ snapshot := ledger.snapshot()
151+ require.Equal(t, identity, committed, "a rename must retain the route's process-local identity")
152+ require.Greater(t, snapshot.Generation, before.Generation, "a rename invalidates prior route snapshots")
153+ require.Empty(t, snapshot.Entries, "the old ephemeral label must not remain in route history")
154+ active, ok := ledger.lookup(snapshot.Active)
155+ require.True(t, ok)
156+ require.Equal(t, "vps-infra", active.presentation.name)
157+ require.Equal(t, "vps-infra", active.target.SessionName)
158+ require.False(t, active.presentation.ephemeral)
159+}
160+
112 func TestCommittedIdentityDoesNotReassignHomeRoute(t *testing.T) {161 func TestCommittedIdentityDoesNotReassignHomeRoute(t *testing.T) {
113 ledger := newRouteLedger()162 ledger := newRouteLedger()
114 home := routeTestCandidate(1, ports.RouteOriginLocal)163 home := routeTestCandidate(1, ports.RouteOriginLocal)
M internal/usecase/daemon/client.go +33 -14
@@ -85,20 +85,21 @@ type attachedClient struct {
8585 // view is attachment-local navigation state. It is never inferred from a
8686 // session-wide active tab, so multiple attachments can observe different
8787 // tabs and panes without changing shared session ownership.
88- viewMu sync.Mutex
89- view attachmentView
90- sess Guarded[*session]
91- mouseScan mouse.Scanner
92- themeMu sync.Mutex
93- clientTheme themeui.Theme
94- appliedTheme appliedTheme
95- lastCursor cursorOut
96- lastRoutePosition ports.RoutePosition
97- renderStages renderStageHooks // optional render and handoff observability hooks
98- linkMu sync.Mutex
99- sendMu sync.Mutex
100- routeMu sync.RWMutex
101- routeSnapshot ports.RecentRouteSnapshot
88+ viewMu sync.Mutex
89+ view attachmentView
90+ sess Guarded[*session]
91+ mouseScan mouse.Scanner
92+ themeMu sync.Mutex
93+ clientTheme themeui.Theme
94+ appliedTheme appliedTheme
95+ lastCursor cursorOut
96+ lastRoutePosition ports.RoutePosition
97+ renderStages renderStageHooks // optional render and handoff observability hooks
98+ linkMu sync.Mutex
99+ sendMu sync.Mutex
100+ routeMu sync.RWMutex
101+ routeSnapshot ports.RecentRouteSnapshot
102+ routeAttentionSubscription ports.RouteAttentionSubscription
102103 // routeCreatedSession marks a session created by this attachment's route.
103104 // A handshake that never commits Welcome must tear down that exact empty
104105 // session, while an attachment routed to an existing session must not.
@@ -366,6 +367,24 @@ func (ac *attachedClient) routeSnapshotCopy() ports.RecentRouteSnapshot {
366367 return snapshot
367368 }
368369
370+func (ac *attachedClient) setRouteAttentionSubscription(subscription ports.RouteAttentionSubscription) {
371+ subscription.Targets = append([]ports.RouteAttentionTarget(nil), subscription.Targets...)
372+ ac.routeMu.Lock()
373+ ac.routeAttentionSubscription = subscription
374+ ac.routeMu.Unlock()
375+}
376+
377+func (ac *attachedClient) routeAttentionTarget(ref ports.RouteRef) (ports.ExactSessionTarget, bool) {
378+ ac.routeMu.RLock()
379+ defer ac.routeMu.RUnlock()
380+ for _, target := range ac.routeAttentionSubscription.Targets {
381+ if target.Ref == ref {
382+ return target.Target, true
383+ }
384+ }
385+ return ports.ExactSessionTarget{}, false
386+}
387+
369388 func (ac *attachedClient) ackOutputState(epoch, state uint64) {
370389 ac.sendMu.Lock()
371390 defer ac.sendMu.Unlock()
@@ -85,20 +85,21 @@ type attachedClient struct {
85 // view is attachment-local navigation state. It is never inferred from a85 // view is attachment-local navigation state. It is never inferred from a
86 // session-wide active tab, so multiple attachments can observe different86 // session-wide active tab, so multiple attachments can observe different
87 // tabs and panes without changing shared session ownership.87 // tabs and panes without changing shared session ownership.
88- viewMu sync.Mutex88+ viewMu sync.Mutex
89- view attachmentView89+ view attachmentView
90- sess Guarded[*session]90+ sess Guarded[*session]
91- mouseScan mouse.Scanner91+ mouseScan mouse.Scanner
92- themeMu sync.Mutex92+ themeMu sync.Mutex
93- clientTheme themeui.Theme93+ clientTheme themeui.Theme
94- appliedTheme appliedTheme94+ appliedTheme appliedTheme
95- lastCursor cursorOut95+ lastCursor cursorOut
96- lastRoutePosition ports.RoutePosition96+ lastRoutePosition ports.RoutePosition
97- renderStages renderStageHooks // optional render and handoff observability hooks97+ renderStages renderStageHooks // optional render and handoff observability hooks
98- linkMu sync.Mutex98+ linkMu sync.Mutex
99- sendMu sync.Mutex99+ sendMu sync.Mutex
100- routeMu sync.RWMutex100+ routeMu sync.RWMutex
101- routeSnapshot ports.RecentRouteSnapshot101+ routeSnapshot ports.RecentRouteSnapshot
102+ routeAttentionSubscription ports.RouteAttentionSubscription
102 // routeCreatedSession marks a session created by this attachment's route.103 // routeCreatedSession marks a session created by this attachment's route.
103 // A handshake that never commits Welcome must tear down that exact empty104 // A handshake that never commits Welcome must tear down that exact empty
104 // session, while an attachment routed to an existing session must not.105 // session, while an attachment routed to an existing session must not.
@@ -366,6 +367,24 @@ func (ac *attachedClient) routeSnapshotCopy() ports.RecentRouteSnapshot {
366 return snapshot367 return snapshot
367 }368 }
368 369
370+func (ac *attachedClient) setRouteAttentionSubscription(subscription ports.RouteAttentionSubscription) {
371+ subscription.Targets = append([]ports.RouteAttentionTarget(nil), subscription.Targets...)
372+ ac.routeMu.Lock()
373+ ac.routeAttentionSubscription = subscription
374+ ac.routeMu.Unlock()
375+}
376+
377+func (ac *attachedClient) routeAttentionTarget(ref ports.RouteRef) (ports.ExactSessionTarget, bool) {
378+ ac.routeMu.RLock()
379+ defer ac.routeMu.RUnlock()
380+ for _, target := range ac.routeAttentionSubscription.Targets {
381+ if target.Ref == ref {
382+ return target.Target, true
383+ }
384+ }
385+ return ports.ExactSessionTarget{}, false
386+}
387+
369 func (ac *attachedClient) ackOutputState(epoch, state uint64) {388 func (ac *attachedClient) ackOutputState(epoch, state uint64) {
370 ac.sendMu.Lock()389 ac.sendMu.Lock()
371 defer ac.sendMu.Unlock()390 defer ac.sendMu.Unlock()
M internal/usecase/daemon/client_frame_routing.go +8 -0
@@ -133,6 +133,14 @@ func (d *Daemon) handleAttachmentClientFrame(token attachmentConnectionToken, f
133133 } else {
134134 d.log.Warn("malformed recent route snapshot", "err", derr)
135135 }
136+ case ports.MsgRouteAttentionSubscription:
137+ subscription, derr := ports.UnmarshalRouteAttentionSubscription(f.Payload)
138+ if derr != nil {
139+ d.log.Warn("malformed route attention subscription", "err", derr)
140+ break
141+ }
142+ token.ac.setRouteAttentionSubscription(subscription)
143+ d.invalidateRender(token.sess, token.ac, false, "client_frame_routing.go:route-attention")
136144 case ports.MsgRouteNavigationFailure:
137145 failure, derr := ports.UnmarshalRouteNavigationFailure(f.Payload)
138146 if derr != nil {
@@ -133,6 +133,14 @@ func (d *Daemon) handleAttachmentClientFrame(token attachmentConnectionToken, f
133 } else {133 } else {
134 d.log.Warn("malformed recent route snapshot", "err", derr)134 d.log.Warn("malformed recent route snapshot", "err", derr)
135 }135 }
136+ case ports.MsgRouteAttentionSubscription:
137+ subscription, derr := ports.UnmarshalRouteAttentionSubscription(f.Payload)
138+ if derr != nil {
139+ d.log.Warn("malformed route attention subscription", "err", derr)
140+ break
141+ }
142+ token.ac.setRouteAttentionSubscription(subscription)
143+ d.invalidateRender(token.sess, token.ac, false, "client_frame_routing.go:route-attention")
136 case ports.MsgRouteNavigationFailure:144 case ports.MsgRouteNavigationFailure:
137 failure, derr := ports.UnmarshalRouteNavigationFailure(f.Payload)145 failure, derr := ports.UnmarshalRouteNavigationFailure(f.Payload)
138 if derr != nil {146 if derr != nil {
M internal/usecase/daemon/recent_route_snapshot.go +37 -0
@@ -26,3 +26,40 @@ func recentRoutePresentationsFromSnapshot(snapshot ports.RecentRouteSnapshot) []
2626 func formatRecentRouteSnapshot(snapshot ports.RecentRouteSnapshot) []recentRouteDisplay {
2727 return formatRecentRoutePresentations(recentRoutePresentationsFromSnapshot(snapshot))
2828 }
29+
30+// formatRecentRouteSnapshotForAttachment resolves attention against this
31+// daemon's live sessions. The client retains route ordering and presentation;
32+// it subscribes only the exact routes this daemon serves.
33+func (d *Daemon) formatRecentRouteSnapshotForAttachment(ac *attachedClient, snapshot ports.RecentRouteSnapshot) []recentRouteDisplay {
34+ presentations := recentRoutePresentationsFromSnapshot(snapshot)
35+ if d == nil || ac == nil {
36+ return formatRecentRoutePresentations(presentations)
37+ }
38+ for i, entry := range snapshot.Entries {
39+ target, ok := ac.routeAttentionTarget(ports.RouteRef{Key: entry.Key, Generation: entry.Generation})
40+ if ok {
41+ presentations[i].attention = d.routeHasAttention(target)
42+ }
43+ }
44+ return formatRecentRoutePresentations(presentations)
45+}
46+
47+func (d *Daemon) routeHasAttention(target ports.ExactSessionTarget) bool {
48+ d.mu.Lock()
49+ sess := d.findByNameLocked(target.SessionName)
50+ d.mu.Unlock()
51+ if sess == nil {
52+ return false
53+ }
54+ sess.mu.Lock()
55+ defer sess.mu.Unlock()
56+ if sess.incarnation != target.LifecycleID {
57+ return false
58+ }
59+ for _, tb := range sess.tabs {
60+ if tb.attention {
61+ return true
62+ }
63+ }
64+ return false
65+}
@@ -26,3 +26,40 @@ func recentRoutePresentationsFromSnapshot(snapshot ports.RecentRouteSnapshot) []
26 func formatRecentRouteSnapshot(snapshot ports.RecentRouteSnapshot) []recentRouteDisplay {26 func formatRecentRouteSnapshot(snapshot ports.RecentRouteSnapshot) []recentRouteDisplay {
27 return formatRecentRoutePresentations(recentRoutePresentationsFromSnapshot(snapshot))27 return formatRecentRoutePresentations(recentRoutePresentationsFromSnapshot(snapshot))
28 }28 }
29+
30+// formatRecentRouteSnapshotForAttachment resolves attention against this
31+// daemon's live sessions. The client retains route ordering and presentation;
32+// it subscribes only the exact routes this daemon serves.
33+func (d *Daemon) formatRecentRouteSnapshotForAttachment(ac *attachedClient, snapshot ports.RecentRouteSnapshot) []recentRouteDisplay {
34+ presentations := recentRoutePresentationsFromSnapshot(snapshot)
35+ if d == nil || ac == nil {
36+ return formatRecentRoutePresentations(presentations)
37+ }
38+ for i, entry := range snapshot.Entries {
39+ target, ok := ac.routeAttentionTarget(ports.RouteRef{Key: entry.Key, Generation: entry.Generation})
40+ if ok {
41+ presentations[i].attention = d.routeHasAttention(target)
42+ }
43+ }
44+ return formatRecentRoutePresentations(presentations)
45+}
46+
47+func (d *Daemon) routeHasAttention(target ports.ExactSessionTarget) bool {
48+ d.mu.Lock()
49+ sess := d.findByNameLocked(target.SessionName)
50+ d.mu.Unlock()
51+ if sess == nil {
52+ return false
53+ }
54+ sess.mu.Lock()
55+ defer sess.mu.Unlock()
56+ if sess.incarnation != target.LifecycleID {
57+ return false
58+ }
59+ for _, tb := range sess.tabs {
60+ if tb.attention {
61+ return true
62+ }
63+ }
64+ return false
65+}
M internal/usecase/daemon/recent_route_snapshot_test.go +27 -0
@@ -3,6 +3,7 @@ package daemon
33 import (
44 "testing"
55
6+ "github.com/bnema/vev/internal/domain"
67 "github.com/bnema/vev/internal/ports"
78 "github.com/stretchr/testify/require"
89 )
@@ -24,6 +25,32 @@ func TestAttachmentStatusUsesClientRouteSnapshot(t *testing.T) {
2425 require.Equal(t, []string{"logs@edge", "work"}, []string{state.mru[0].name, state.mru[1].name})
2526 }
2627
28+func TestAttachmentStatusResolvesSubscribedRouteAttention(t *testing.T) {
29+ d, sess, ac, _ := newManualSessionWithPTYs(t, nil)
30+ sess.mu.Lock()
31+ sess.incarnation = domain.SessionLifecycleID{9}
32+ sess.tabs[0].attention = true
33+ sess.mu.Unlock()
34+ ref := ports.RouteRef{Key: 2, Generation: 1}
35+ ac.setRouteSnapshot(ports.RecentRouteSnapshot{
36+ Generation: 2,
37+ Active: ports.RouteRef{Key: 3, Generation: 2},
38+ Entries: []ports.RecentRouteEntry{{Key: ref.Key, Generation: ref.Generation, Name: sess.name, Kind: ports.RouteKindLocal}},
39+ })
40+ ac.setRouteAttentionSubscription(ports.RouteAttentionSubscription{Targets: []ports.RouteAttentionTarget{{
41+ Ref: ref,
42+ Target: ports.ExactSessionTarget{
43+ LifecycleID: sess.incarnation,
44+ SessionName: sess.name,
45+ },
46+ }}})
47+
48+ state := d.barStateForAttachmentPaletteHintsFor(sess, ac, "", nil, ports.RecentRouteSnapshot{})
49+
50+ require.Len(t, state.mru, 1)
51+ require.True(t, state.mru[0].attention)
52+}
53+
2754 func TestAttachmentRouteSnapshotCopiesPublishedEntries(t *testing.T) {
2855 _, _, ac, _ := newManualSessionWithPTYs(t, nil)
2956 entries := []ports.RecentRouteEntry{{Key: 1, Generation: 2, Name: "before", Kind: ports.RouteKindLocal}}
@@ -3,6 +3,7 @@ package daemon
3 import (3 import (
4 "testing"4 "testing"
5 5
6+ "github.com/bnema/vev/internal/domain"
6 "github.com/bnema/vev/internal/ports"7 "github.com/bnema/vev/internal/ports"
7 "github.com/stretchr/testify/require"8 "github.com/stretchr/testify/require"
8 )9 )
@@ -24,6 +25,32 @@ func TestAttachmentStatusUsesClientRouteSnapshot(t *testing.T) {
24 require.Equal(t, []string{"logs@edge", "work"}, []string{state.mru[0].name, state.mru[1].name})25 require.Equal(t, []string{"logs@edge", "work"}, []string{state.mru[0].name, state.mru[1].name})
25 }26 }
26 27
28+func TestAttachmentStatusResolvesSubscribedRouteAttention(t *testing.T) {
29+ d, sess, ac, _ := newManualSessionWithPTYs(t, nil)
30+ sess.mu.Lock()
31+ sess.incarnation = domain.SessionLifecycleID{9}
32+ sess.tabs[0].attention = true
33+ sess.mu.Unlock()
34+ ref := ports.RouteRef{Key: 2, Generation: 1}
35+ ac.setRouteSnapshot(ports.RecentRouteSnapshot{
36+ Generation: 2,
37+ Active: ports.RouteRef{Key: 3, Generation: 2},
38+ Entries: []ports.RecentRouteEntry{{Key: ref.Key, Generation: ref.Generation, Name: sess.name, Kind: ports.RouteKindLocal}},
39+ })
40+ ac.setRouteAttentionSubscription(ports.RouteAttentionSubscription{Targets: []ports.RouteAttentionTarget{{
41+ Ref: ref,
42+ Target: ports.ExactSessionTarget{
43+ LifecycleID: sess.incarnation,
44+ SessionName: sess.name,
45+ },
46+ }}})
47+
48+ state := d.barStateForAttachmentPaletteHintsFor(sess, ac, "", nil, ports.RecentRouteSnapshot{})
49+
50+ require.Len(t, state.mru, 1)
51+ require.True(t, state.mru[0].attention)
52+}
53+
27 func TestAttachmentRouteSnapshotCopiesPublishedEntries(t *testing.T) {54 func TestAttachmentRouteSnapshotCopiesPublishedEntries(t *testing.T) {
28 _, _, ac, _ := newManualSessionWithPTYs(t, nil)55 _, _, ac, _ := newManualSessionWithPTYs(t, nil)
29 entries := []ports.RecentRouteEntry{{Key: 1, Generation: 2, Name: "before", Kind: ports.RouteKindLocal}}56 entries := []ports.RecentRouteEntry{{Key: 1, Generation: 2, Name: "before", Kind: ports.RouteKindLocal}}
M internal/usecase/daemon/session.go +27 -0
@@ -915,9 +915,36 @@ func (d *Daemon) renameSession(sess *session, name string) error {
915915 sess.snapshotMu.Unlock()
916916 }
917917 markSnapshotDirty(sess)
918+ d.publishRenamedSessionRouteIdentity(sess)
918919 return nil
919920 }
920921
922+// publishRenamedSessionRouteIdentity updates each ready client's exact route
923+// target after a session rename. A renamed session retains its lifecycle but
924+// its old name is no longer attachable, so clients must replace—not retain—the
925+// prior route record.
926+func (d *Daemon) publishRenamedSessionRouteIdentity(sess *session) {
927+ for _, ac := range sess.snapshotAttachments() {
928+ if ac.routeSnapshotCopy().Generation == 0 {
929+ continue
930+ }
931+ token := sess.attachmentToken(ac, ac.transport())
932+ if token.ac == nil {
933+ continue
934+ }
935+ effect, admitted := ac.beginAttachmentEffect(token)
936+ if !admitted {
937+ continue
938+ }
939+ token = effect.connectionToken()
940+ err := d.sendCommittedRouteIdentityForAttachment(token)
941+ effect.End()
942+ if err != nil {
943+ d.detachOnAttachmentSendError(token, token.transport.transport)
944+ }
945+ }
946+}
947+
921948 func (d *Daemon) renameTab(sess *session, tb *tab, name string) error {
922949 if tb == nil {
923950 return errors.New("tab required")
@@ -915,9 +915,36 @@ func (d *Daemon) renameSession(sess *session, name string) error {
915 sess.snapshotMu.Unlock()915 sess.snapshotMu.Unlock()
916 }916 }
917 markSnapshotDirty(sess)917 markSnapshotDirty(sess)
918+ d.publishRenamedSessionRouteIdentity(sess)
918 return nil919 return nil
919 }920 }
920 921
922+// publishRenamedSessionRouteIdentity updates each ready client's exact route
923+// target after a session rename. A renamed session retains its lifecycle but
924+// its old name is no longer attachable, so clients must replace—not retain—the
925+// prior route record.
926+func (d *Daemon) publishRenamedSessionRouteIdentity(sess *session) {
927+ for _, ac := range sess.snapshotAttachments() {
928+ if ac.routeSnapshotCopy().Generation == 0 {
929+ continue
930+ }
931+ token := sess.attachmentToken(ac, ac.transport())
932+ if token.ac == nil {
933+ continue
934+ }
935+ effect, admitted := ac.beginAttachmentEffect(token)
936+ if !admitted {
937+ continue
938+ }
939+ token = effect.connectionToken()
940+ err := d.sendCommittedRouteIdentityForAttachment(token)
941+ effect.End()
942+ if err != nil {
943+ d.detachOnAttachmentSendError(token, token.transport.transport)
944+ }
945+ }
946+}
947+
921 func (d *Daemon) renameTab(sess *session, tb *tab, name string) error {948 func (d *Daemon) renameTab(sess *session, tb *tab, name string) error {
922 if tb == nil {949 if tb == nil {
923 return errors.New("tab required")950 return errors.New("tab required")
M internal/usecase/daemon/session_rename_purge_test.go +24 -0
@@ -6,9 +6,33 @@ import (
66 "github.com/stretchr/testify/require"
77
88 "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
910 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1011 )
1112
13+func TestRenamePublishesCommittedRouteIdentityToAttachedClients(t *testing.T) {
14+ d, sess, ac, sends := newManualSessionWithPTYs(t)
15+ sess.mu.Lock()
16+ sess.name = "0"
17+ sess.ephemeral = true
18+ sess.incarnation = domain.IncarnationID{1}
19+ sess.mu.Unlock()
20+ token := sess.attachmentToken(ac, ac.transport())
21+ ac.publishAttachmentCapability(token)
22+ ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1})
23+
24+ require.NoError(t, d.renameSession(sess, "vps-infra"))
25+
26+ frame := awaitFrame(t, sends, ports.MsgCommittedRouteIdentity)
27+ identity, err := ports.UnmarshalCommittedRouteIdentity(frame.Payload)
28+ require.NoError(t, err)
29+ require.Equal(t, ports.ExactSessionTarget{
30+ LifecycleID: domain.IncarnationID{1},
31+ SessionName: "vps-infra",
32+ }, identity.Target)
33+ require.False(t, identity.Ephemeral)
34+}
35+
1236 func TestRenamePreservesIncarnationSnapshotSources(t *testing.T) {
1337 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
1438 repository := &retryablePurgeRepository{}
@@ -6,9 +6,33 @@ import (
6 "github.com/stretchr/testify/require"6 "github.com/stretchr/testify/require"
7 7
8 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
9 portsmocks "github.com/bnema/vev/internal/ports/mocks"10 portsmocks "github.com/bnema/vev/internal/ports/mocks"
10 )11 )
11 12
13+func TestRenamePublishesCommittedRouteIdentityToAttachedClients(t *testing.T) {
14+ d, sess, ac, sends := newManualSessionWithPTYs(t)
15+ sess.mu.Lock()
16+ sess.name = "0"
17+ sess.ephemeral = true
18+ sess.incarnation = domain.IncarnationID{1}
19+ sess.mu.Unlock()
20+ token := sess.attachmentToken(ac, ac.transport())
21+ ac.publishAttachmentCapability(token)
22+ ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1})
23+
24+ require.NoError(t, d.renameSession(sess, "vps-infra"))
25+
26+ frame := awaitFrame(t, sends, ports.MsgCommittedRouteIdentity)
27+ identity, err := ports.UnmarshalCommittedRouteIdentity(frame.Payload)
28+ require.NoError(t, err)
29+ require.Equal(t, ports.ExactSessionTarget{
30+ LifecycleID: domain.IncarnationID{1},
31+ SessionName: "vps-infra",
32+ }, identity.Target)
33+ require.False(t, identity.Ephemeral)
34+}
35+
12 func TestRenamePreservesIncarnationSnapshotSources(t *testing.T) {36 func TestRenamePreservesIncarnationSnapshotSources(t *testing.T) {
13 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})37 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
14 repository := &retryablePurgeRepository{}38 repository := &retryablePurgeRepository{}
M internal/usecase/daemon/status.go +1 -1
@@ -290,7 +290,7 @@ func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur *session, ac *attached
290290 if ranked != nil {
291291 state.rankedRecent = ranked
292292 } else if routeSnapshot.Generation != 0 {
293- state.mru = formatRecentRouteSnapshot(routeSnapshot)
293+ state.mru = d.formatRecentRouteSnapshotForAttachment(ac, routeSnapshot)
294294 }
295295 return state
296296 }
@@ -290,7 +290,7 @@ func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur *session, ac *attached
290 if ranked != nil {290 if ranked != nil {
291 state.rankedRecent = ranked291 state.rankedRecent = ranked
292 } else if routeSnapshot.Generation != 0 {292 } else if routeSnapshot.Generation != 0 {
293- state.mru = formatRecentRouteSnapshot(routeSnapshot)293+ state.mru = d.formatRecentRouteSnapshotForAttachment(ac, routeSnapshot)
294 }294 }
295 return state295 return state
296 }296 }

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