detached 9e386a7

feat(protocol): carry route hosts and attention order in route snapshots

RecentRouteSnapshot gains Hosts (client-ledger creation destinations
besides the serving daemon, no endpoint) and RecentRouteEntry gains
AttentionSeq, the client-assigned attention onset order used to jump to
the oldest attention across daemons. Stays on protocol version 58.
commit
9e386a7b4f789b9873bcf442bf20be2d6c254c67
author
brice · · 2026-09-22 20:32 UTC
committer
brice · · 2026-09-22 20:32 UTC
parent
42155b1
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +358 -81
M internal/adapters/sessionwire/proto_convert_navigation.go +17 -0
@@ -71,9 +71,25 @@ func recentRouteEntryToWire(entry protocol.RecentRouteEntry) (*wire.RecentRouteE
7171 Ephemeral: entry.Ephemeral,
7272 Attention: entry.Attention,
7373 Reachability: uint32(entry.Reachability),
74+ AttentionSeq: entry.AttentionSeq,
7475 }, nil
7576 }
7677
78+func routeHostToWire(host protocol.RouteHost) *wire.RouteHost {
79+ return &wire.RouteHost{Key: host.Key, Generation: host.Generation, Label: host.Label, Kind: uint32(host.Kind)}
80+}
81+
82+func routeHostFromWire(message *wire.RouteHost) (protocol.RouteHost, error) {
83+ if message == nil {
84+ return protocol.RouteHost{}, protocol.ErrInvalidRouteWire
85+ }
86+ kind, err := enum8[protocol.RouteKind](message.GetKind())
87+ if err != nil {
88+ return protocol.RouteHost{}, err
89+ }
90+ return protocol.RouteHost{Key: message.GetKey(), Generation: message.GetGeneration(), Label: message.GetLabel(), Kind: kind}, nil
91+}
92+
7793 func recentRouteEntryFromWire(message *wire.RecentRouteEntry) (protocol.RecentRouteEntry, error) {
7894 var entry protocol.RecentRouteEntry
7995 if message == nil {
@@ -98,6 +114,7 @@ func recentRouteEntryFromWire(message *wire.RecentRouteEntry) (protocol.RecentRo
98114 if err != nil {
99115 return protocol.RecentRouteEntry{}, err
100116 }
117+ entry.AttentionSeq = message.GetAttentionSeq()
101118 return entry, nil
102119 }
103120
@@ -71,9 +71,25 @@ func recentRouteEntryToWire(entry protocol.RecentRouteEntry) (*wire.RecentRouteE
71 Ephemeral: entry.Ephemeral,71 Ephemeral: entry.Ephemeral,
72 Attention: entry.Attention,72 Attention: entry.Attention,
73 Reachability: uint32(entry.Reachability),73 Reachability: uint32(entry.Reachability),
74+ AttentionSeq: entry.AttentionSeq,
74 }, nil75 }, nil
75 }76 }
76 77
78+func routeHostToWire(host protocol.RouteHost) *wire.RouteHost {
79+ return &wire.RouteHost{Key: host.Key, Generation: host.Generation, Label: host.Label, Kind: uint32(host.Kind)}
80+}
81+
82+func routeHostFromWire(message *wire.RouteHost) (protocol.RouteHost, error) {
83+ if message == nil {
84+ return protocol.RouteHost{}, protocol.ErrInvalidRouteWire
85+ }
86+ kind, err := enum8[protocol.RouteKind](message.GetKind())
87+ if err != nil {
88+ return protocol.RouteHost{}, err
89+ }
90+ return protocol.RouteHost{Key: message.GetKey(), Generation: message.GetGeneration(), Label: message.GetLabel(), Kind: kind}, nil
91+}
92+
77 func recentRouteEntryFromWire(message *wire.RecentRouteEntry) (protocol.RecentRouteEntry, error) {93 func recentRouteEntryFromWire(message *wire.RecentRouteEntry) (protocol.RecentRouteEntry, error) {
78 var entry protocol.RecentRouteEntry94 var entry protocol.RecentRouteEntry
79 if message == nil {95 if message == nil {
@@ -98,6 +114,7 @@ func recentRouteEntryFromWire(message *wire.RecentRouteEntry) (protocol.RecentRo
98 if err != nil {114 if err != nil {
99 return protocol.RecentRouteEntry{}, err115 return protocol.RecentRouteEntry{}, err
100 }116 }
117+ entry.AttentionSeq = message.GetAttentionSeq()
101 return entry, nil118 return entry, nil
102 }119 }
103 120
A internal/adapters/sessionwire/proto_convert_route_hosts_test.go +93 -0
@@ -0,0 +1,93 @@
1+package sessionwire
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+ "google.golang.org/protobuf/proto"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/protocol"
11+ "github.com/bnema/vev/internal/protocol/wire"
12+)
13+
14+func routeHostsTestSnapshot() protocol.RecentRouteSnapshot {
15+ target := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{7}, SessionName: "work"}
16+ return protocol.RecentRouteSnapshot{
17+ Generation: 3,
18+ Entries: []protocol.RecentRouteEntry{{
19+ Key: 1, Generation: 1, Target: target, Name: "work", HostLabel: "box",
20+ Kind: protocol.RouteKindRemote, Attention: true, AttentionSeq: 5,
21+ }},
22+ Hosts: []protocol.RouteHost{
23+ {Key: 2, Generation: 1, Label: "a", Kind: protocol.RouteKindRemote},
24+ {Key: 3, Generation: 1, Label: "local", Kind: protocol.RouteKindLocal},
25+ },
26+ }
27+}
28+
29+// TestProtoRouteHostsWire pins the new route snapshot fields byte for byte
30+// and checks the envelope rejects a truncated prefix and trailing garbage.
31+func TestProtoRouteHostsWire(t *testing.T) {
32+ hostBytes, err := proto.Marshal(routeHostToWire(protocol.RouteHost{Key: 2, Generation: 1, Label: "a", Kind: protocol.RouteKindRemote}))
33+ require.NoError(t, err)
34+ require.Equal(t, []byte{0x08, 0x02, 0x10, 0x01, 0x1A, 0x01, 'a', 0x20, 0x02}, hostBytes)
35+ seqBytes, err := proto.Marshal(&wire.RecentRouteEntry{AttentionSeq: 5})
36+ require.NoError(t, err)
37+ require.Equal(t, []byte{0x50, 0x05}, seqBytes)
38+
39+ snapshot := routeHostsTestSnapshot()
40+ envelope, err := encodeProtoClient(snapshot)
41+ require.NoError(t, err)
42+ raw, err := proto.Marshal(envelope)
43+ require.NoError(t, err)
44+
45+ tests := []struct {
46+ name string
47+ raw []byte
48+ wantErr bool
49+ }{
50+ {name: "exact", raw: raw},
51+ {name: "truncated prefix", raw: raw[:len(raw)-1], wantErr: true},
52+ {name: "trailing garbage", raw: append(append([]byte(nil), raw...), 0xFF), wantErr: true},
53+ }
54+ for _, tt := range tests {
55+ t.Run(tt.name, func(t *testing.T) {
56+ err := wire.ScanEnvelope(&wire.ClientEnvelope{}, tt.raw)
57+ if tt.wantErr {
58+ require.Error(t, err)
59+ return
60+ }
61+ require.NoError(t, err)
62+ decoded := &wire.ClientEnvelope{}
63+ require.NoError(t, proto.Unmarshal(tt.raw, decoded))
64+ got, err := decodeProtoClient(decoded)
65+ require.NoError(t, err)
66+ require.Equal(t, snapshot, got)
67+ })
68+ }
69+}
70+
71+func TestProtoRouteHostsRejectInvalid(t *testing.T) {
72+ tests := []struct {
73+ name string
74+ mutate func(*wire.RecentRouteSnapshot)
75+ }{
76+ {name: "zero host identity", mutate: func(s *wire.RecentRouteSnapshot) { s.Hosts[0].Key = 0 }},
77+ {name: "empty host label", mutate: func(s *wire.RecentRouteSnapshot) { s.Hosts[0].Label = "" }},
78+ {name: "unknown host kind", mutate: func(s *wire.RecentRouteSnapshot) { s.Hosts[0].Kind = 9 }},
79+ {name: "host collides with entry", mutate: func(s *wire.RecentRouteSnapshot) { s.Hosts[0].Key = 1 }},
80+ {name: "duplicate host", mutate: func(s *wire.RecentRouteSnapshot) { s.Hosts[1].Key = s.Hosts[0].Key }},
81+ {name: "attention order without attention", mutate: func(s *wire.RecentRouteSnapshot) { s.Entries[0].Attention = false }},
82+ {name: "nil host", mutate: func(s *wire.RecentRouteSnapshot) { s.Hosts[0] = nil }},
83+ }
84+ for _, tt := range tests {
85+ t.Run(tt.name, func(t *testing.T) {
86+ message, err := recentRouteSnapshotToWire(routeHostsTestSnapshot())
87+ require.NoError(t, err)
88+ tt.mutate(message)
89+ _, err = recentRouteSnapshotFromWire(message)
90+ require.Error(t, err)
91+ })
92+ }
93+}
@@ -0,0 +1,93 @@
1+package sessionwire
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+ "google.golang.org/protobuf/proto"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/protocol"
11+ "github.com/bnema/vev/internal/protocol/wire"
12+)
13+
14+func routeHostsTestSnapshot() protocol.RecentRouteSnapshot {
15+ target := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{7}, SessionName: "work"}
16+ return protocol.RecentRouteSnapshot{
17+ Generation: 3,
18+ Entries: []protocol.RecentRouteEntry{{
19+ Key: 1, Generation: 1, Target: target, Name: "work", HostLabel: "box",
20+ Kind: protocol.RouteKindRemote, Attention: true, AttentionSeq: 5,
21+ }},
22+ Hosts: []protocol.RouteHost{
23+ {Key: 2, Generation: 1, Label: "a", Kind: protocol.RouteKindRemote},
24+ {Key: 3, Generation: 1, Label: "local", Kind: protocol.RouteKindLocal},
25+ },
26+ }
27+}
28+
29+// TestProtoRouteHostsWire pins the new route snapshot fields byte for byte
30+// and checks the envelope rejects a truncated prefix and trailing garbage.
31+func TestProtoRouteHostsWire(t *testing.T) {
32+ hostBytes, err := proto.Marshal(routeHostToWire(protocol.RouteHost{Key: 2, Generation: 1, Label: "a", Kind: protocol.RouteKindRemote}))
33+ require.NoError(t, err)
34+ require.Equal(t, []byte{0x08, 0x02, 0x10, 0x01, 0x1A, 0x01, 'a', 0x20, 0x02}, hostBytes)
35+ seqBytes, err := proto.Marshal(&wire.RecentRouteEntry{AttentionSeq: 5})
36+ require.NoError(t, err)
37+ require.Equal(t, []byte{0x50, 0x05}, seqBytes)
38+
39+ snapshot := routeHostsTestSnapshot()
40+ envelope, err := encodeProtoClient(snapshot)
41+ require.NoError(t, err)
42+ raw, err := proto.Marshal(envelope)
43+ require.NoError(t, err)
44+
45+ tests := []struct {
46+ name string
47+ raw []byte
48+ wantErr bool
49+ }{
50+ {name: "exact", raw: raw},
51+ {name: "truncated prefix", raw: raw[:len(raw)-1], wantErr: true},
52+ {name: "trailing garbage", raw: append(append([]byte(nil), raw...), 0xFF), wantErr: true},
53+ }
54+ for _, tt := range tests {
55+ t.Run(tt.name, func(t *testing.T) {
56+ err := wire.ScanEnvelope(&wire.ClientEnvelope{}, tt.raw)
57+ if tt.wantErr {
58+ require.Error(t, err)
59+ return
60+ }
61+ require.NoError(t, err)
62+ decoded := &wire.ClientEnvelope{}
63+ require.NoError(t, proto.Unmarshal(tt.raw, decoded))
64+ got, err := decodeProtoClient(decoded)
65+ require.NoError(t, err)
66+ require.Equal(t, snapshot, got)
67+ })
68+ }
69+}
70+
71+func TestProtoRouteHostsRejectInvalid(t *testing.T) {
72+ tests := []struct {
73+ name string
74+ mutate func(*wire.RecentRouteSnapshot)
75+ }{
76+ {name: "zero host identity", mutate: func(s *wire.RecentRouteSnapshot) { s.Hosts[0].Key = 0 }},
77+ {name: "empty host label", mutate: func(s *wire.RecentRouteSnapshot) { s.Hosts[0].Label = "" }},
78+ {name: "unknown host kind", mutate: func(s *wire.RecentRouteSnapshot) { s.Hosts[0].Kind = 9 }},
79+ {name: "host collides with entry", mutate: func(s *wire.RecentRouteSnapshot) { s.Hosts[0].Key = 1 }},
80+ {name: "duplicate host", mutate: func(s *wire.RecentRouteSnapshot) { s.Hosts[1].Key = s.Hosts[0].Key }},
81+ {name: "attention order without attention", mutate: func(s *wire.RecentRouteSnapshot) { s.Entries[0].Attention = false }},
82+ {name: "nil host", mutate: func(s *wire.RecentRouteSnapshot) { s.Hosts[0] = nil }},
83+ }
84+ for _, tt := range tests {
85+ t.Run(tt.name, func(t *testing.T) {
86+ message, err := recentRouteSnapshotToWire(routeHostsTestSnapshot())
87+ require.NoError(t, err)
88+ tt.mutate(message)
89+ _, err = recentRouteSnapshotFromWire(message)
90+ require.Error(t, err)
91+ })
92+ }
93+}
M internal/adapters/sessionwire/proto_convert_routes.go +10 -0
@@ -175,6 +175,9 @@ func recentRouteSnapshotToWire(message protocol.RecentRouteSnapshot) (*wire.Rece
175175 }
176176 out.Entries = append(out.Entries, converted)
177177 }
178+ for _, host := range message.Hosts {
179+ out.Hosts = append(out.Hosts, routeHostToWire(host))
180+ }
178181 return out, nil
179182 }
180183
@@ -199,6 +202,13 @@ func recentRouteSnapshotFromWire(message *wire.RecentRouteSnapshot) (protocol.Re
199202 }
200203 snapshot.Entries = append(snapshot.Entries, converted)
201204 }
205+ for _, host := range message.GetHosts() {
206+ converted, err := routeHostFromWire(host)
207+ if err != nil {
208+ return protocol.RecentRouteSnapshot{}, err
209+ }
210+ snapshot.Hosts = append(snapshot.Hosts, converted)
211+ }
202212 if err := snapshot.Validate(); err != nil {
203213 return protocol.RecentRouteSnapshot{}, err
204214 }
@@ -175,6 +175,9 @@ func recentRouteSnapshotToWire(message protocol.RecentRouteSnapshot) (*wire.Rece
175 }175 }
176 out.Entries = append(out.Entries, converted)176 out.Entries = append(out.Entries, converted)
177 }177 }
178+ for _, host := range message.Hosts {
179+ out.Hosts = append(out.Hosts, routeHostToWire(host))
180+ }
178 return out, nil181 return out, nil
179 }182 }
180 183
@@ -199,6 +202,13 @@ func recentRouteSnapshotFromWire(message *wire.RecentRouteSnapshot) (protocol.Re
199 }202 }
200 snapshot.Entries = append(snapshot.Entries, converted)203 snapshot.Entries = append(snapshot.Entries, converted)
201 }204 }
205+ for _, host := range message.GetHosts() {
206+ converted, err := routeHostFromWire(host)
207+ if err != nil {
208+ return protocol.RecentRouteSnapshot{}, err
209+ }
210+ snapshot.Hosts = append(snapshot.Hosts, converted)
211+ }
202 if err := snapshot.Validate(); err != nil {212 if err := snapshot.Validate(); err != nil {
203 return protocol.RecentRouteSnapshot{}, err213 return protocol.RecentRouteSnapshot{}, err
204 }214 }
M internal/protocol/routes.go +48 -2
@@ -349,6 +349,19 @@ type RecentRouteEntry struct {
349349 Ephemeral bool
350350 Attention bool
351351 Reachability RouteReachability
352+ // AttentionSeq orders attention onsets across every daemon the client
353+ // observes: a smaller value is older. It is zero without attention.
354+ AttentionSeq uint64
355+}
356+
357+// RouteHost is one daemon the client can create a session on, other than
358+// the serving daemon. Its reference shares the entry namespace; it carries
359+// no endpoint, dialer, or credential.
360+type RouteHost struct {
361+ Key uint64
362+ Generation uint64
363+ Label string
364+ Kind RouteKind
352365 }
353366
354367 // RecentRouteSnapshot is an immutable, bounded publication of the client
@@ -363,6 +376,8 @@ type RecentRouteSnapshot struct {
363376 Previous RouteRef
364377 Home RouteRef
365378 Entries []RecentRouteEntry
379+ // Hosts are the creation destinations besides the serving daemon.
380+ Hosts []RouteHost
366381 }
367382
368383 // validateRecentRouteEntry enforces one entry's identity and display bounds.
@@ -388,6 +403,22 @@ func validateRecentRouteEntry(entry RecentRouteEntry) error {
388403 if entry.Reachability.Validate() != nil {
389404 return fmt.Errorf("%w: invalid route reachability", ErrInvalidRouteWire)
390405 }
406+ if entry.AttentionSeq != 0 && !entry.Attention {
407+ return fmt.Errorf("%w: attention order without attention", ErrInvalidRouteWire)
408+ }
409+ return nil
410+}
411+
412+func validateRouteHost(host RouteHost) error {
413+ if host.Key == 0 || host.Generation == 0 {
414+ return fmt.Errorf("%w: route host identity is zero", ErrInvalidRouteWire)
415+ }
416+ if err := ValidateRouteLabel(host.Label, false); err != nil {
417+ return fmt.Errorf("%w: route host label: %v", ErrInvalidRouteWire, err)
418+ }
419+ if host.Kind.Validate() != nil {
420+ return fmt.Errorf("%w: invalid route host kind", ErrInvalidRouteWire)
421+ }
391422 return nil
392423 }
393424
@@ -400,7 +431,10 @@ func (s RecentRouteSnapshot) Validate() error {
400431 if len(s.Entries) > RouteSnapshotMaxEntries {
401432 return fmt.Errorf("%w: too many route entries", ErrInvalidRouteWire)
402433 }
403- if s.Generation == 0 && (len(s.Entries) != 0 || s.ActiveEntry != (RecentRouteEntry{})) {
434+ if len(s.Hosts) > RouteSnapshotMaxHosts {
435+ return fmt.Errorf("%w: too many route hosts", ErrInvalidRouteWire)
436+ }
437+ if s.Generation == 0 && (len(s.Entries) != 0 || len(s.Hosts) != 0 || s.ActiveEntry != (RecentRouteEntry{})) {
404438 return fmt.Errorf("%w: non-empty snapshot has zero generation", ErrInvalidRouteWire)
405439 }
406440 for _, ref := range []RouteRef{s.Active, s.Previous, s.Home} {
@@ -437,6 +471,16 @@ func (s RecentRouteSnapshot) Validate() error {
437471 }
438472 refs[ref] = struct{}{}
439473 }
474+ for _, host := range s.Hosts {
475+ if err := validateRouteHost(host); err != nil {
476+ return err
477+ }
478+ ref := RouteRef{Key: host.Key, Generation: host.Generation}
479+ if _, exists := refs[ref]; exists || ref == s.Active {
480+ return fmt.Errorf("%w: duplicate route host", ErrInvalidRouteWire)
481+ }
482+ refs[ref] = struct{}{}
483+ }
440484 for _, item := range []struct {
441485 name string
442486 ref RouteRef
@@ -612,5 +656,7 @@ const (
612656 // of work a receiver performs before returning to its transport loop. The
613657 // private client history may use a smaller product cap.
614658 RouteSnapshotMaxEntries = 32
615- RouteLabelMaxBytes = 256
659+ // RouteSnapshotMaxHosts bounds the creation destinations of one snapshot.
660+ RouteSnapshotMaxHosts = 32
661+ RouteLabelMaxBytes = 256
616662 )
@@ -349,6 +349,19 @@ type RecentRouteEntry struct {
349 Ephemeral bool349 Ephemeral bool
350 Attention bool350 Attention bool
351 Reachability RouteReachability351 Reachability RouteReachability
352+ // AttentionSeq orders attention onsets across every daemon the client
353+ // observes: a smaller value is older. It is zero without attention.
354+ AttentionSeq uint64
355+}
356+
357+// RouteHost is one daemon the client can create a session on, other than
358+// the serving daemon. Its reference shares the entry namespace; it carries
359+// no endpoint, dialer, or credential.
360+type RouteHost struct {
361+ Key uint64
362+ Generation uint64
363+ Label string
364+ Kind RouteKind
352 }365 }
353 366
354 // RecentRouteSnapshot is an immutable, bounded publication of the client367 // RecentRouteSnapshot is an immutable, bounded publication of the client
@@ -363,6 +376,8 @@ type RecentRouteSnapshot struct {
363 Previous RouteRef376 Previous RouteRef
364 Home RouteRef377 Home RouteRef
365 Entries []RecentRouteEntry378 Entries []RecentRouteEntry
379+ // Hosts are the creation destinations besides the serving daemon.
380+ Hosts []RouteHost
366 }381 }
367 382
368 // validateRecentRouteEntry enforces one entry's identity and display bounds.383 // validateRecentRouteEntry enforces one entry's identity and display bounds.
@@ -388,6 +403,22 @@ func validateRecentRouteEntry(entry RecentRouteEntry) error {
388 if entry.Reachability.Validate() != nil {403 if entry.Reachability.Validate() != nil {
389 return fmt.Errorf("%w: invalid route reachability", ErrInvalidRouteWire)404 return fmt.Errorf("%w: invalid route reachability", ErrInvalidRouteWire)
390 }405 }
406+ if entry.AttentionSeq != 0 && !entry.Attention {
407+ return fmt.Errorf("%w: attention order without attention", ErrInvalidRouteWire)
408+ }
409+ return nil
410+}
411+
412+func validateRouteHost(host RouteHost) error {
413+ if host.Key == 0 || host.Generation == 0 {
414+ return fmt.Errorf("%w: route host identity is zero", ErrInvalidRouteWire)
415+ }
416+ if err := ValidateRouteLabel(host.Label, false); err != nil {
417+ return fmt.Errorf("%w: route host label: %v", ErrInvalidRouteWire, err)
418+ }
419+ if host.Kind.Validate() != nil {
420+ return fmt.Errorf("%w: invalid route host kind", ErrInvalidRouteWire)
421+ }
391 return nil422 return nil
392 }423 }
393 424
@@ -400,7 +431,10 @@ func (s RecentRouteSnapshot) Validate() error {
400 if len(s.Entries) > RouteSnapshotMaxEntries {431 if len(s.Entries) > RouteSnapshotMaxEntries {
401 return fmt.Errorf("%w: too many route entries", ErrInvalidRouteWire)432 return fmt.Errorf("%w: too many route entries", ErrInvalidRouteWire)
402 }433 }
403- if s.Generation == 0 && (len(s.Entries) != 0 || s.ActiveEntry != (RecentRouteEntry{})) {434+ if len(s.Hosts) > RouteSnapshotMaxHosts {
435+ return fmt.Errorf("%w: too many route hosts", ErrInvalidRouteWire)
436+ }
437+ if s.Generation == 0 && (len(s.Entries) != 0 || len(s.Hosts) != 0 || s.ActiveEntry != (RecentRouteEntry{})) {
404 return fmt.Errorf("%w: non-empty snapshot has zero generation", ErrInvalidRouteWire)438 return fmt.Errorf("%w: non-empty snapshot has zero generation", ErrInvalidRouteWire)
405 }439 }
406 for _, ref := range []RouteRef{s.Active, s.Previous, s.Home} {440 for _, ref := range []RouteRef{s.Active, s.Previous, s.Home} {
@@ -437,6 +471,16 @@ func (s RecentRouteSnapshot) Validate() error {
437 }471 }
438 refs[ref] = struct{}{}472 refs[ref] = struct{}{}
439 }473 }
474+ for _, host := range s.Hosts {
475+ if err := validateRouteHost(host); err != nil {
476+ return err
477+ }
478+ ref := RouteRef{Key: host.Key, Generation: host.Generation}
479+ if _, exists := refs[ref]; exists || ref == s.Active {
480+ return fmt.Errorf("%w: duplicate route host", ErrInvalidRouteWire)
481+ }
482+ refs[ref] = struct{}{}
483+ }
440 for _, item := range []struct {484 for _, item := range []struct {
441 name string485 name string
442 ref RouteRef486 ref RouteRef
@@ -612,5 +656,7 @@ const (
612 // of work a receiver performs before returning to its transport loop. The656 // of work a receiver performs before returning to its transport loop. The
613 // private client history may use a smaller product cap.657 // private client history may use a smaller product cap.
614 RouteSnapshotMaxEntries = 32658 RouteSnapshotMaxEntries = 32
615- RouteLabelMaxBytes = 256659+ // RouteSnapshotMaxHosts bounds the creation destinations of one snapshot.
660+ RouteSnapshotMaxHosts = 32
661+ RouteLabelMaxBytes = 256
616 )662 )
M internal/protocol/wire/schema/session.proto +12 -0
@@ -160,6 +160,17 @@ message RecentRouteEntry {
160160 bool attention = 8;
161161 // Closed reachability taxonomy: 0 = unknown, 1 = reachable, 2 = unavailable.
162162 uint32 reachability = 9;
163+ // Client-assigned attention onset order; zero without attention.
164+ uint64 attention_seq = 10;
165+}
166+
167+// RouteHost is one creation destination other than the serving daemon.
168+message RouteHost {
169+ uint64 key = 1;
170+ uint64 generation = 2;
171+ string label = 3;
172+ // Closed kind taxonomy: 1 = local, 2 = remote.
173+ uint32 kind = 4;
163174 }
164175
165176 // RecentRouteSnapshot is an immutable bounded route publication.
@@ -170,6 +181,7 @@ message RecentRouteSnapshot {
170181 RouteRef previous = 4;
171182 RouteRef home = 5;
172183 repeated RecentRouteEntry entries = 6;
184+ repeated RouteHost hosts = 7;
173185 }
174186
175187 // RouteNavigationAction asks the client to resolve one ledger entry.
@@ -160,6 +160,17 @@ message RecentRouteEntry {
160 bool attention = 8;160 bool attention = 8;
161 // Closed reachability taxonomy: 0 = unknown, 1 = reachable, 2 = unavailable.161 // Closed reachability taxonomy: 0 = unknown, 1 = reachable, 2 = unavailable.
162 uint32 reachability = 9;162 uint32 reachability = 9;
163+ // Client-assigned attention onset order; zero without attention.
164+ uint64 attention_seq = 10;
165+}
166+
167+// RouteHost is one creation destination other than the serving daemon.
168+message RouteHost {
169+ uint64 key = 1;
170+ uint64 generation = 2;
171+ string label = 3;
172+ // Closed kind taxonomy: 1 = local, 2 = remote.
173+ uint32 kind = 4;
163 }174 }
164 175
165 // RecentRouteSnapshot is an immutable bounded route publication.176 // RecentRouteSnapshot is an immutable bounded route publication.
@@ -170,6 +181,7 @@ message RecentRouteSnapshot {
170 RouteRef previous = 4;181 RouteRef previous = 4;
171 RouteRef home = 5;182 RouteRef home = 5;
172 repeated RecentRouteEntry entries = 6;183 repeated RecentRouteEntry entries = 6;
184+ repeated RouteHost hosts = 7;
173 }185 }
174 186
175 // RouteNavigationAction asks the client to resolve one ledger entry.187 // RouteNavigationAction asks the client to resolve one ledger entry.
M internal/protocol/wire/session.pb.go +178 -79 generated file: collapsed

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