detached 8a96e64

feat(daemon): use client route snapshots for palette and attention

commit
8a96e643eb26c495b19fb79f72dfc55266746019
author
brice · · 2026-09-22 20:51 UTC
parent
657a96a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
16 files · +304 -1562
M internal/ports/broker_observation.go +2 -4
@@ -12,10 +12,8 @@ import (
1212 // Broker-native daemon observation (Plan 001 P5.2a).
1313 //
1414 // BrokerDaemonObservation is the broker's own projection of one daemon, local
15-// or remote. It deliberately does not reuse RemoteHostSnapshot: that type
16-// belongs to the legacy remote monitor and carries no daemon identity,
17-// incarnation, version, capability, or policy authority. The broker
18-// projection keeps configured authority separate from observed state:
15+// or remote, and the only one: no daemon monitors another. It keeps
16+// configured authority separate from observed state:
1917 //
2018 // - configured authority: Local flag, Endpoint, Registration, Policy, and
2119 // the presentation hints DisplayOrigin and Rank. The registry stamps
@@ -12,10 +12,8 @@ import (
12 // Broker-native daemon observation (Plan 001 P5.2a).12 // Broker-native daemon observation (Plan 001 P5.2a).
13 //13 //
14 // BrokerDaemonObservation is the broker's own projection of one daemon, local14 // BrokerDaemonObservation is the broker's own projection of one daemon, local
15-// or remote. It deliberately does not reuse RemoteHostSnapshot: that type15+// or remote, and the only one: no daemon monitors another. It keeps
16-// belongs to the legacy remote monitor and carries no daemon identity,16+// configured authority separate from observed state:
17-// incarnation, version, capability, or policy authority. The broker
18-// projection keeps configured authority separate from observed state:
19 //17 //
20 // - configured authority: Local flag, Endpoint, Registration, Policy, and18 // - configured authority: Local flag, Endpoint, Registration, Policy, and
21 // the presentation hints DisplayOrigin and Rank. The registry stamps19 // the presentation hints DisplayOrigin and Rank. The registry stamps
M internal/ports/mocks/remote_mock.go +0 -106
@@ -9,7 +9,6 @@ import (
99
1010 "github.com/bnema/vev/internal/domain"
1111 "github.com/bnema/vev/internal/ports"
12- "github.com/bnema/vev/internal/protocol"
1312 "github.com/bnema/vev/internal/protocol/catalogue"
1413 mock "github.com/stretchr/testify/mock"
1514 )
@@ -461,111 +460,6 @@ func (_c *MockRemoteCatalogClient_List_Call) RunAndReturn(run func(ctx context.C
461460 return _c
462461 }
463462
464-// NewMockRemotePreviewClient creates a new instance of MockRemotePreviewClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
465-// The first argument is typically a *testing.T value.
466-func NewMockRemotePreviewClient(t interface {
467- mock.TestingT
468- Cleanup(func())
469-}) *MockRemotePreviewClient {
470- mock := &MockRemotePreviewClient{}
471- mock.Mock.Test(t)
472-
473- t.Cleanup(func() { mock.AssertExpectations(t) })
474-
475- return mock
476-}
477-
478-// MockRemotePreviewClient is an autogenerated mock type for the RemotePreviewClient type
479-type MockRemotePreviewClient struct {
480- mock.Mock
481-}
482-
483-type MockRemotePreviewClient_Expecter struct {
484- mock *mock.Mock
485-}
486-
487-func (_m *MockRemotePreviewClient) EXPECT() *MockRemotePreviewClient_Expecter {
488- return &MockRemotePreviewClient_Expecter{mock: &_m.Mock}
489-}
490-
491-// Preview provides a mock function for the type MockRemotePreviewClient
492-func (_mock *MockRemotePreviewClient) Preview(ctx context.Context, target domain.RemoteSessionTarget, width uint16, height uint16) (protocol.RemotePreview, error) {
493- ret := _mock.Called(ctx, target, width, height)
494-
495- if len(ret) == 0 {
496- panic("no return value specified for Preview")
497- }
498-
499- var r0 protocol.RemotePreview
500- var r1 error
501- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RemoteSessionTarget, uint16, uint16) (protocol.RemotePreview, error)); ok {
502- return returnFunc(ctx, target, width, height)
503- }
504- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RemoteSessionTarget, uint16, uint16) protocol.RemotePreview); ok {
505- r0 = returnFunc(ctx, target, width, height)
506- } else {
507- r0 = ret.Get(0).(protocol.RemotePreview)
508- }
509- if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RemoteSessionTarget, uint16, uint16) error); ok {
510- r1 = returnFunc(ctx, target, width, height)
511- } else {
512- r1 = ret.Error(1)
513- }
514- return r0, r1
515-}
516-
517-// MockRemotePreviewClient_Preview_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Preview'
518-type MockRemotePreviewClient_Preview_Call struct {
519- *mock.Call
520-}
521-
522-// Preview is a helper method to define mock.On call
523-// - ctx context.Context
524-// - target domain.RemoteSessionTarget
525-// - width uint16
526-// - height uint16
527-func (_e *MockRemotePreviewClient_Expecter) Preview(ctx any, target any, width any, height any) *MockRemotePreviewClient_Preview_Call {
528- return &MockRemotePreviewClient_Preview_Call{Call: _e.mock.On("Preview", ctx, target, width, height)}
529-}
530-
531-func (_c *MockRemotePreviewClient_Preview_Call) Run(run func(ctx context.Context, target domain.RemoteSessionTarget, width uint16, height uint16)) *MockRemotePreviewClient_Preview_Call {
532- _c.Call.Run(func(args mock.Arguments) {
533- var arg0 context.Context
534- if args[0] != nil {
535- arg0 = args[0].(context.Context)
536- }
537- var arg1 domain.RemoteSessionTarget
538- if args[1] != nil {
539- arg1 = args[1].(domain.RemoteSessionTarget)
540- }
541- var arg2 uint16
542- if args[2] != nil {
543- arg2 = args[2].(uint16)
544- }
545- var arg3 uint16
546- if args[3] != nil {
547- arg3 = args[3].(uint16)
548- }
549- run(
550- arg0,
551- arg1,
552- arg2,
553- arg3,
554- )
555- })
556- return _c
557-}
558-
559-func (_c *MockRemotePreviewClient_Preview_Call) Return(remotePreview protocol.RemotePreview, err error) *MockRemotePreviewClient_Preview_Call {
560- _c.Call.Return(remotePreview, err)
561- return _c
562-}
563-
564-func (_c *MockRemotePreviewClient_Preview_Call) RunAndReturn(run func(ctx context.Context, target domain.RemoteSessionTarget, width uint16, height uint16) (protocol.RemotePreview, error)) *MockRemotePreviewClient_Preview_Call {
565- _c.Call.Return(run)
566- return _c
567-}
568-
569463 // NewMockRemoteCatalogCache creates a new instance of MockRemoteCatalogCache. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
570464 // The first argument is typically a *testing.T value.
571465 func NewMockRemoteCatalogCache(t interface {
@@ -9,7 +9,6 @@ import (
9 9
10 "github.com/bnema/vev/internal/domain"10 "github.com/bnema/vev/internal/domain"
11 "github.com/bnema/vev/internal/ports"11 "github.com/bnema/vev/internal/ports"
12- "github.com/bnema/vev/internal/protocol"
13 "github.com/bnema/vev/internal/protocol/catalogue"12 "github.com/bnema/vev/internal/protocol/catalogue"
14 mock "github.com/stretchr/testify/mock"13 mock "github.com/stretchr/testify/mock"
15 )14 )
@@ -461,111 +460,6 @@ func (_c *MockRemoteCatalogClient_List_Call) RunAndReturn(run func(ctx context.C
461 return _c460 return _c
462 }461 }
463 462
464-// NewMockRemotePreviewClient creates a new instance of MockRemotePreviewClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
465-// The first argument is typically a *testing.T value.
466-func NewMockRemotePreviewClient(t interface {
467- mock.TestingT
468- Cleanup(func())
469-}) *MockRemotePreviewClient {
470- mock := &MockRemotePreviewClient{}
471- mock.Mock.Test(t)
472-
473- t.Cleanup(func() { mock.AssertExpectations(t) })
474-
475- return mock
476-}
477-
478-// MockRemotePreviewClient is an autogenerated mock type for the RemotePreviewClient type
479-type MockRemotePreviewClient struct {
480- mock.Mock
481-}
482-
483-type MockRemotePreviewClient_Expecter struct {
484- mock *mock.Mock
485-}
486-
487-func (_m *MockRemotePreviewClient) EXPECT() *MockRemotePreviewClient_Expecter {
488- return &MockRemotePreviewClient_Expecter{mock: &_m.Mock}
489-}
490-
491-// Preview provides a mock function for the type MockRemotePreviewClient
492-func (_mock *MockRemotePreviewClient) Preview(ctx context.Context, target domain.RemoteSessionTarget, width uint16, height uint16) (protocol.RemotePreview, error) {
493- ret := _mock.Called(ctx, target, width, height)
494-
495- if len(ret) == 0 {
496- panic("no return value specified for Preview")
497- }
498-
499- var r0 protocol.RemotePreview
500- var r1 error
501- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RemoteSessionTarget, uint16, uint16) (protocol.RemotePreview, error)); ok {
502- return returnFunc(ctx, target, width, height)
503- }
504- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RemoteSessionTarget, uint16, uint16) protocol.RemotePreview); ok {
505- r0 = returnFunc(ctx, target, width, height)
506- } else {
507- r0 = ret.Get(0).(protocol.RemotePreview)
508- }
509- if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RemoteSessionTarget, uint16, uint16) error); ok {
510- r1 = returnFunc(ctx, target, width, height)
511- } else {
512- r1 = ret.Error(1)
513- }
514- return r0, r1
515-}
516-
517-// MockRemotePreviewClient_Preview_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Preview'
518-type MockRemotePreviewClient_Preview_Call struct {
519- *mock.Call
520-}
521-
522-// Preview is a helper method to define mock.On call
523-// - ctx context.Context
524-// - target domain.RemoteSessionTarget
525-// - width uint16
526-// - height uint16
527-func (_e *MockRemotePreviewClient_Expecter) Preview(ctx any, target any, width any, height any) *MockRemotePreviewClient_Preview_Call {
528- return &MockRemotePreviewClient_Preview_Call{Call: _e.mock.On("Preview", ctx, target, width, height)}
529-}
530-
531-func (_c *MockRemotePreviewClient_Preview_Call) Run(run func(ctx context.Context, target domain.RemoteSessionTarget, width uint16, height uint16)) *MockRemotePreviewClient_Preview_Call {
532- _c.Call.Run(func(args mock.Arguments) {
533- var arg0 context.Context
534- if args[0] != nil {
535- arg0 = args[0].(context.Context)
536- }
537- var arg1 domain.RemoteSessionTarget
538- if args[1] != nil {
539- arg1 = args[1].(domain.RemoteSessionTarget)
540- }
541- var arg2 uint16
542- if args[2] != nil {
543- arg2 = args[2].(uint16)
544- }
545- var arg3 uint16
546- if args[3] != nil {
547- arg3 = args[3].(uint16)
548- }
549- run(
550- arg0,
551- arg1,
552- arg2,
553- arg3,
554- )
555- })
556- return _c
557-}
558-
559-func (_c *MockRemotePreviewClient_Preview_Call) Return(remotePreview protocol.RemotePreview, err error) *MockRemotePreviewClient_Preview_Call {
560- _c.Call.Return(remotePreview, err)
561- return _c
562-}
563-
564-func (_c *MockRemotePreviewClient_Preview_Call) RunAndReturn(run func(ctx context.Context, target domain.RemoteSessionTarget, width uint16, height uint16) (protocol.RemotePreview, error)) *MockRemotePreviewClient_Preview_Call {
565- _c.Call.Return(run)
566- return _c
567-}
568-
569 // NewMockRemoteCatalogCache creates a new instance of MockRemoteCatalogCache. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.463 // NewMockRemoteCatalogCache creates a new instance of MockRemoteCatalogCache. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
570 // The first argument is typically a *testing.T value.464 // The first argument is typically a *testing.T value.
571 func NewMockRemoteCatalogCache(t interface {465 func NewMockRemoteCatalogCache(t interface {
D internal/ports/mocks/remote_state_mock.go +0 -306
@@ -1,306 +0,0 @@
1-// Code generated by mockery; DO NOT EDIT.
2-// github.com/vektra/mockery
3-// template: testify
4-
5-package portsmocks
6-
7-import (
8- "github.com/bnema/vev/internal/ports"
9- mock "github.com/stretchr/testify/mock"
10-)
11-
12-// NewMockRemoteDirectorySubscription creates a new instance of MockRemoteDirectorySubscription. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
13-// The first argument is typically a *testing.T value.
14-func NewMockRemoteDirectorySubscription(t interface {
15- mock.TestingT
16- Cleanup(func())
17-}) *MockRemoteDirectorySubscription {
18- mock := &MockRemoteDirectorySubscription{}
19- mock.Mock.Test(t)
20-
21- t.Cleanup(func() { mock.AssertExpectations(t) })
22-
23- return mock
24-}
25-
26-// MockRemoteDirectorySubscription is an autogenerated mock type for the RemoteDirectorySubscription type
27-type MockRemoteDirectorySubscription struct {
28- mock.Mock
29-}
30-
31-type MockRemoteDirectorySubscription_Expecter struct {
32- mock *mock.Mock
33-}
34-
35-func (_m *MockRemoteDirectorySubscription) EXPECT() *MockRemoteDirectorySubscription_Expecter {
36- return &MockRemoteDirectorySubscription_Expecter{mock: &_m.Mock}
37-}
38-
39-// Changed provides a mock function for the type MockRemoteDirectorySubscription
40-func (_mock *MockRemoteDirectorySubscription) Changed() <-chan struct{} {
41- ret := _mock.Called()
42-
43- if len(ret) == 0 {
44- panic("no return value specified for Changed")
45- }
46-
47- var r0 <-chan struct{}
48- if returnFunc, ok := ret.Get(0).(func() <-chan struct{}); ok {
49- r0 = returnFunc()
50- } else {
51- if ret.Get(0) != nil {
52- r0 = ret.Get(0).(<-chan struct{})
53- }
54- }
55- return r0
56-}
57-
58-// MockRemoteDirectorySubscription_Changed_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Changed'
59-type MockRemoteDirectorySubscription_Changed_Call struct {
60- *mock.Call
61-}
62-
63-// Changed is a helper method to define mock.On call
64-func (_e *MockRemoteDirectorySubscription_Expecter) Changed() *MockRemoteDirectorySubscription_Changed_Call {
65- return &MockRemoteDirectorySubscription_Changed_Call{Call: _e.mock.On("Changed")}
66-}
67-
68-func (_c *MockRemoteDirectorySubscription_Changed_Call) Run(run func()) *MockRemoteDirectorySubscription_Changed_Call {
69- _c.Call.Run(func(args mock.Arguments) {
70- run()
71- })
72- return _c
73-}
74-
75-func (_c *MockRemoteDirectorySubscription_Changed_Call) Return(valCh <-chan struct{}) *MockRemoteDirectorySubscription_Changed_Call {
76- _c.Call.Return(valCh)
77- return _c
78-}
79-
80-func (_c *MockRemoteDirectorySubscription_Changed_Call) RunAndReturn(run func() <-chan struct{}) *MockRemoteDirectorySubscription_Changed_Call {
81- _c.Call.Return(run)
82- return _c
83-}
84-
85-// Close provides a mock function for the type MockRemoteDirectorySubscription
86-func (_mock *MockRemoteDirectorySubscription) Close() {
87- _mock.Called()
88- return
89-}
90-
91-// MockRemoteDirectorySubscription_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
92-type MockRemoteDirectorySubscription_Close_Call struct {
93- *mock.Call
94-}
95-
96-// Close is a helper method to define mock.On call
97-func (_e *MockRemoteDirectorySubscription_Expecter) Close() *MockRemoteDirectorySubscription_Close_Call {
98- return &MockRemoteDirectorySubscription_Close_Call{Call: _e.mock.On("Close")}
99-}
100-
101-func (_c *MockRemoteDirectorySubscription_Close_Call) Run(run func()) *MockRemoteDirectorySubscription_Close_Call {
102- _c.Call.Run(func(args mock.Arguments) {
103- run()
104- })
105- return _c
106-}
107-
108-func (_c *MockRemoteDirectorySubscription_Close_Call) Return() *MockRemoteDirectorySubscription_Close_Call {
109- _c.Call.Return()
110- return _c
111-}
112-
113-func (_c *MockRemoteDirectorySubscription_Close_Call) RunAndReturn(run func()) *MockRemoteDirectorySubscription_Close_Call {
114- _c.Run(run)
115- return _c
116-}
117-
118-// NewMockRemoteDirectory creates a new instance of MockRemoteDirectory. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
119-// The first argument is typically a *testing.T value.
120-func NewMockRemoteDirectory(t interface {
121- mock.TestingT
122- Cleanup(func())
123-}) *MockRemoteDirectory {
124- mock := &MockRemoteDirectory{}
125- mock.Mock.Test(t)
126-
127- t.Cleanup(func() { mock.AssertExpectations(t) })
128-
129- return mock
130-}
131-
132-// MockRemoteDirectory is an autogenerated mock type for the RemoteDirectory type
133-type MockRemoteDirectory struct {
134- mock.Mock
135-}
136-
137-type MockRemoteDirectory_Expecter struct {
138- mock *mock.Mock
139-}
140-
141-func (_m *MockRemoteDirectory) EXPECT() *MockRemoteDirectory_Expecter {
142- return &MockRemoteDirectory_Expecter{mock: &_m.Mock}
143-}
144-
145-// RegistryChanged provides a mock function for the type MockRemoteDirectory
146-func (_mock *MockRemoteDirectory) RegistryChanged() {
147- _mock.Called()
148- return
149-}
150-
151-// MockRemoteDirectory_RegistryChanged_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegistryChanged'
152-type MockRemoteDirectory_RegistryChanged_Call struct {
153- *mock.Call
154-}
155-
156-// RegistryChanged is a helper method to define mock.On call
157-func (_e *MockRemoteDirectory_Expecter) RegistryChanged() *MockRemoteDirectory_RegistryChanged_Call {
158- return &MockRemoteDirectory_RegistryChanged_Call{Call: _e.mock.On("RegistryChanged")}
159-}
160-
161-func (_c *MockRemoteDirectory_RegistryChanged_Call) Run(run func()) *MockRemoteDirectory_RegistryChanged_Call {
162- _c.Call.Run(func(args mock.Arguments) {
163- run()
164- })
165- return _c
166-}
167-
168-func (_c *MockRemoteDirectory_RegistryChanged_Call) Return() *MockRemoteDirectory_RegistryChanged_Call {
169- _c.Call.Return()
170- return _c
171-}
172-
173-func (_c *MockRemoteDirectory_RegistryChanged_Call) RunAndReturn(run func()) *MockRemoteDirectory_RegistryChanged_Call {
174- _c.Run(run)
175- return _c
176-}
177-
178-// RequestReconcile provides a mock function for the type MockRemoteDirectory
179-func (_mock *MockRemoteDirectory) RequestReconcile(endpoint string) {
180- _mock.Called(endpoint)
181- return
182-}
183-
184-// MockRemoteDirectory_RequestReconcile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RequestReconcile'
185-type MockRemoteDirectory_RequestReconcile_Call struct {
186- *mock.Call
187-}
188-
189-// RequestReconcile is a helper method to define mock.On call
190-// - endpoint string
191-func (_e *MockRemoteDirectory_Expecter) RequestReconcile(endpoint any) *MockRemoteDirectory_RequestReconcile_Call {
192- return &MockRemoteDirectory_RequestReconcile_Call{Call: _e.mock.On("RequestReconcile", endpoint)}
193-}
194-
195-func (_c *MockRemoteDirectory_RequestReconcile_Call) Run(run func(endpoint string)) *MockRemoteDirectory_RequestReconcile_Call {
196- _c.Call.Run(func(args mock.Arguments) {
197- var arg0 string
198- if args[0] != nil {
199- arg0 = args[0].(string)
200- }
201- run(
202- arg0,
203- )
204- })
205- return _c
206-}
207-
208-func (_c *MockRemoteDirectory_RequestReconcile_Call) Return() *MockRemoteDirectory_RequestReconcile_Call {
209- _c.Call.Return()
210- return _c
211-}
212-
213-func (_c *MockRemoteDirectory_RequestReconcile_Call) RunAndReturn(run func(endpoint string)) *MockRemoteDirectory_RequestReconcile_Call {
214- _c.Run(run)
215- return _c
216-}
217-
218-// Snapshot provides a mock function for the type MockRemoteDirectory
219-func (_mock *MockRemoteDirectory) Snapshot() ports.RemoteDirectorySnapshot {
220- ret := _mock.Called()
221-
222- if len(ret) == 0 {
223- panic("no return value specified for Snapshot")
224- }
225-
226- var r0 ports.RemoteDirectorySnapshot
227- if returnFunc, ok := ret.Get(0).(func() ports.RemoteDirectorySnapshot); ok {
228- r0 = returnFunc()
229- } else {
230- r0 = ret.Get(0).(ports.RemoteDirectorySnapshot)
231- }
232- return r0
233-}
234-
235-// MockRemoteDirectory_Snapshot_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Snapshot'
236-type MockRemoteDirectory_Snapshot_Call struct {
237- *mock.Call
238-}
239-
240-// Snapshot is a helper method to define mock.On call
241-func (_e *MockRemoteDirectory_Expecter) Snapshot() *MockRemoteDirectory_Snapshot_Call {
242- return &MockRemoteDirectory_Snapshot_Call{Call: _e.mock.On("Snapshot")}
243-}
244-
245-func (_c *MockRemoteDirectory_Snapshot_Call) Run(run func()) *MockRemoteDirectory_Snapshot_Call {
246- _c.Call.Run(func(args mock.Arguments) {
247- run()
248- })
249- return _c
250-}
251-
252-func (_c *MockRemoteDirectory_Snapshot_Call) Return(remoteDirectorySnapshot ports.RemoteDirectorySnapshot) *MockRemoteDirectory_Snapshot_Call {
253- _c.Call.Return(remoteDirectorySnapshot)
254- return _c
255-}
256-
257-func (_c *MockRemoteDirectory_Snapshot_Call) RunAndReturn(run func() ports.RemoteDirectorySnapshot) *MockRemoteDirectory_Snapshot_Call {
258- _c.Call.Return(run)
259- return _c
260-}
261-
262-// Subscribe provides a mock function for the type MockRemoteDirectory
263-func (_mock *MockRemoteDirectory) Subscribe() ports.RemoteDirectorySubscription {
264- ret := _mock.Called()
265-
266- if len(ret) == 0 {
267- panic("no return value specified for Subscribe")
268- }
269-
270- var r0 ports.RemoteDirectorySubscription
271- if returnFunc, ok := ret.Get(0).(func() ports.RemoteDirectorySubscription); ok {
272- r0 = returnFunc()
273- } else {
274- if ret.Get(0) != nil {
275- r0 = ret.Get(0).(ports.RemoteDirectorySubscription)
276- }
277- }
278- return r0
279-}
280-
281-// MockRemoteDirectory_Subscribe_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Subscribe'
282-type MockRemoteDirectory_Subscribe_Call struct {
283- *mock.Call
284-}
285-
286-// Subscribe is a helper method to define mock.On call
287-func (_e *MockRemoteDirectory_Expecter) Subscribe() *MockRemoteDirectory_Subscribe_Call {
288- return &MockRemoteDirectory_Subscribe_Call{Call: _e.mock.On("Subscribe")}
289-}
290-
291-func (_c *MockRemoteDirectory_Subscribe_Call) Run(run func()) *MockRemoteDirectory_Subscribe_Call {
292- _c.Call.Run(func(args mock.Arguments) {
293- run()
294- })
295- return _c
296-}
297-
298-func (_c *MockRemoteDirectory_Subscribe_Call) Return(remoteDirectorySubscription ports.RemoteDirectorySubscription) *MockRemoteDirectory_Subscribe_Call {
299- _c.Call.Return(remoteDirectorySubscription)
300- return _c
301-}
302-
303-func (_c *MockRemoteDirectory_Subscribe_Call) RunAndReturn(run func() ports.RemoteDirectorySubscription) *MockRemoteDirectory_Subscribe_Call {
304- _c.Call.Return(run)
305- return _c
306-}
@@ -1,306 +0,0 @@
1-// Code generated by mockery; DO NOT EDIT.
2-// github.com/vektra/mockery
3-// template: testify
4-
5-package portsmocks
6-
7-import (
8- "github.com/bnema/vev/internal/ports"
9- mock "github.com/stretchr/testify/mock"
10-)
11-
12-// NewMockRemoteDirectorySubscription creates a new instance of MockRemoteDirectorySubscription. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
13-// The first argument is typically a *testing.T value.
14-func NewMockRemoteDirectorySubscription(t interface {
15- mock.TestingT
16- Cleanup(func())
17-}) *MockRemoteDirectorySubscription {
18- mock := &MockRemoteDirectorySubscription{}
19- mock.Mock.Test(t)
20-
21- t.Cleanup(func() { mock.AssertExpectations(t) })
22-
23- return mock
24-}
25-
26-// MockRemoteDirectorySubscription is an autogenerated mock type for the RemoteDirectorySubscription type
27-type MockRemoteDirectorySubscription struct {
28- mock.Mock
29-}
30-
31-type MockRemoteDirectorySubscription_Expecter struct {
32- mock *mock.Mock
33-}
34-
35-func (_m *MockRemoteDirectorySubscription) EXPECT() *MockRemoteDirectorySubscription_Expecter {
36- return &MockRemoteDirectorySubscription_Expecter{mock: &_m.Mock}
37-}
38-
39-// Changed provides a mock function for the type MockRemoteDirectorySubscription
40-func (_mock *MockRemoteDirectorySubscription) Changed() <-chan struct{} {
41- ret := _mock.Called()
42-
43- if len(ret) == 0 {
44- panic("no return value specified for Changed")
45- }
46-
47- var r0 <-chan struct{}
48- if returnFunc, ok := ret.Get(0).(func() <-chan struct{}); ok {
49- r0 = returnFunc()
50- } else {
51- if ret.Get(0) != nil {
52- r0 = ret.Get(0).(<-chan struct{})
53- }
54- }
55- return r0
56-}
57-
58-// MockRemoteDirectorySubscription_Changed_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Changed'
59-type MockRemoteDirectorySubscription_Changed_Call struct {
60- *mock.Call
61-}
62-
63-// Changed is a helper method to define mock.On call
64-func (_e *MockRemoteDirectorySubscription_Expecter) Changed() *MockRemoteDirectorySubscription_Changed_Call {
65- return &MockRemoteDirectorySubscription_Changed_Call{Call: _e.mock.On("Changed")}
66-}
67-
68-func (_c *MockRemoteDirectorySubscription_Changed_Call) Run(run func()) *MockRemoteDirectorySubscription_Changed_Call {
69- _c.Call.Run(func(args mock.Arguments) {
70- run()
71- })
72- return _c
73-}
74-
75-func (_c *MockRemoteDirectorySubscription_Changed_Call) Return(valCh <-chan struct{}) *MockRemoteDirectorySubscription_Changed_Call {
76- _c.Call.Return(valCh)
77- return _c
78-}
79-
80-func (_c *MockRemoteDirectorySubscription_Changed_Call) RunAndReturn(run func() <-chan struct{}) *MockRemoteDirectorySubscription_Changed_Call {
81- _c.Call.Return(run)
82- return _c
83-}
84-
85-// Close provides a mock function for the type MockRemoteDirectorySubscription
86-func (_mock *MockRemoteDirectorySubscription) Close() {
87- _mock.Called()
88- return
89-}
90-
91-// MockRemoteDirectorySubscription_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
92-type MockRemoteDirectorySubscription_Close_Call struct {
93- *mock.Call
94-}
95-
96-// Close is a helper method to define mock.On call
97-func (_e *MockRemoteDirectorySubscription_Expecter) Close() *MockRemoteDirectorySubscription_Close_Call {
98- return &MockRemoteDirectorySubscription_Close_Call{Call: _e.mock.On("Close")}
99-}
100-
101-func (_c *MockRemoteDirectorySubscription_Close_Call) Run(run func()) *MockRemoteDirectorySubscription_Close_Call {
102- _c.Call.Run(func(args mock.Arguments) {
103- run()
104- })
105- return _c
106-}
107-
108-func (_c *MockRemoteDirectorySubscription_Close_Call) Return() *MockRemoteDirectorySubscription_Close_Call {
109- _c.Call.Return()
110- return _c
111-}
112-
113-func (_c *MockRemoteDirectorySubscription_Close_Call) RunAndReturn(run func()) *MockRemoteDirectorySubscription_Close_Call {
114- _c.Run(run)
115- return _c
116-}
117-
118-// NewMockRemoteDirectory creates a new instance of MockRemoteDirectory. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
119-// The first argument is typically a *testing.T value.
120-func NewMockRemoteDirectory(t interface {
121- mock.TestingT
122- Cleanup(func())
123-}) *MockRemoteDirectory {
124- mock := &MockRemoteDirectory{}
125- mock.Mock.Test(t)
126-
127- t.Cleanup(func() { mock.AssertExpectations(t) })
128-
129- return mock
130-}
131-
132-// MockRemoteDirectory is an autogenerated mock type for the RemoteDirectory type
133-type MockRemoteDirectory struct {
134- mock.Mock
135-}
136-
137-type MockRemoteDirectory_Expecter struct {
138- mock *mock.Mock
139-}
140-
141-func (_m *MockRemoteDirectory) EXPECT() *MockRemoteDirectory_Expecter {
142- return &MockRemoteDirectory_Expecter{mock: &_m.Mock}
143-}
144-
145-// RegistryChanged provides a mock function for the type MockRemoteDirectory
146-func (_mock *MockRemoteDirectory) RegistryChanged() {
147- _mock.Called()
148- return
149-}
150-
151-// MockRemoteDirectory_RegistryChanged_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegistryChanged'
152-type MockRemoteDirectory_RegistryChanged_Call struct {
153- *mock.Call
154-}
155-
156-// RegistryChanged is a helper method to define mock.On call
157-func (_e *MockRemoteDirectory_Expecter) RegistryChanged() *MockRemoteDirectory_RegistryChanged_Call {
158- return &MockRemoteDirectory_RegistryChanged_Call{Call: _e.mock.On("RegistryChanged")}
159-}
160-
161-func (_c *MockRemoteDirectory_RegistryChanged_Call) Run(run func()) *MockRemoteDirectory_RegistryChanged_Call {
162- _c.Call.Run(func(args mock.Arguments) {
163- run()
164- })
165- return _c
166-}
167-
168-func (_c *MockRemoteDirectory_RegistryChanged_Call) Return() *MockRemoteDirectory_RegistryChanged_Call {
169- _c.Call.Return()
170- return _c
171-}
172-
173-func (_c *MockRemoteDirectory_RegistryChanged_Call) RunAndReturn(run func()) *MockRemoteDirectory_RegistryChanged_Call {
174- _c.Run(run)
175- return _c
176-}
177-
178-// RequestReconcile provides a mock function for the type MockRemoteDirectory
179-func (_mock *MockRemoteDirectory) RequestReconcile(endpoint string) {
180- _mock.Called(endpoint)
181- return
182-}
183-
184-// MockRemoteDirectory_RequestReconcile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RequestReconcile'
185-type MockRemoteDirectory_RequestReconcile_Call struct {
186- *mock.Call
187-}
188-
189-// RequestReconcile is a helper method to define mock.On call
190-// - endpoint string
191-func (_e *MockRemoteDirectory_Expecter) RequestReconcile(endpoint any) *MockRemoteDirectory_RequestReconcile_Call {
192- return &MockRemoteDirectory_RequestReconcile_Call{Call: _e.mock.On("RequestReconcile", endpoint)}
193-}
194-
195-func (_c *MockRemoteDirectory_RequestReconcile_Call) Run(run func(endpoint string)) *MockRemoteDirectory_RequestReconcile_Call {
196- _c.Call.Run(func(args mock.Arguments) {
197- var arg0 string
198- if args[0] != nil {
199- arg0 = args[0].(string)
200- }
201- run(
202- arg0,
203- )
204- })
205- return _c
206-}
207-
208-func (_c *MockRemoteDirectory_RequestReconcile_Call) Return() *MockRemoteDirectory_RequestReconcile_Call {
209- _c.Call.Return()
210- return _c
211-}
212-
213-func (_c *MockRemoteDirectory_RequestReconcile_Call) RunAndReturn(run func(endpoint string)) *MockRemoteDirectory_RequestReconcile_Call {
214- _c.Run(run)
215- return _c
216-}
217-
218-// Snapshot provides a mock function for the type MockRemoteDirectory
219-func (_mock *MockRemoteDirectory) Snapshot() ports.RemoteDirectorySnapshot {
220- ret := _mock.Called()
221-
222- if len(ret) == 0 {
223- panic("no return value specified for Snapshot")
224- }
225-
226- var r0 ports.RemoteDirectorySnapshot
227- if returnFunc, ok := ret.Get(0).(func() ports.RemoteDirectorySnapshot); ok {
228- r0 = returnFunc()
229- } else {
230- r0 = ret.Get(0).(ports.RemoteDirectorySnapshot)
231- }
232- return r0
233-}
234-
235-// MockRemoteDirectory_Snapshot_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Snapshot'
236-type MockRemoteDirectory_Snapshot_Call struct {
237- *mock.Call
238-}
239-
240-// Snapshot is a helper method to define mock.On call
241-func (_e *MockRemoteDirectory_Expecter) Snapshot() *MockRemoteDirectory_Snapshot_Call {
242- return &MockRemoteDirectory_Snapshot_Call{Call: _e.mock.On("Snapshot")}
243-}
244-
245-func (_c *MockRemoteDirectory_Snapshot_Call) Run(run func()) *MockRemoteDirectory_Snapshot_Call {
246- _c.Call.Run(func(args mock.Arguments) {
247- run()
248- })
249- return _c
250-}
251-
252-func (_c *MockRemoteDirectory_Snapshot_Call) Return(remoteDirectorySnapshot ports.RemoteDirectorySnapshot) *MockRemoteDirectory_Snapshot_Call {
253- _c.Call.Return(remoteDirectorySnapshot)
254- return _c
255-}
256-
257-func (_c *MockRemoteDirectory_Snapshot_Call) RunAndReturn(run func() ports.RemoteDirectorySnapshot) *MockRemoteDirectory_Snapshot_Call {
258- _c.Call.Return(run)
259- return _c
260-}
261-
262-// Subscribe provides a mock function for the type MockRemoteDirectory
263-func (_mock *MockRemoteDirectory) Subscribe() ports.RemoteDirectorySubscription {
264- ret := _mock.Called()
265-
266- if len(ret) == 0 {
267- panic("no return value specified for Subscribe")
268- }
269-
270- var r0 ports.RemoteDirectorySubscription
271- if returnFunc, ok := ret.Get(0).(func() ports.RemoteDirectorySubscription); ok {
272- r0 = returnFunc()
273- } else {
274- if ret.Get(0) != nil {
275- r0 = ret.Get(0).(ports.RemoteDirectorySubscription)
276- }
277- }
278- return r0
279-}
280-
281-// MockRemoteDirectory_Subscribe_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Subscribe'
282-type MockRemoteDirectory_Subscribe_Call struct {
283- *mock.Call
284-}
285-
286-// Subscribe is a helper method to define mock.On call
287-func (_e *MockRemoteDirectory_Expecter) Subscribe() *MockRemoteDirectory_Subscribe_Call {
288- return &MockRemoteDirectory_Subscribe_Call{Call: _e.mock.On("Subscribe")}
289-}
290-
291-func (_c *MockRemoteDirectory_Subscribe_Call) Run(run func()) *MockRemoteDirectory_Subscribe_Call {
292- _c.Call.Run(func(args mock.Arguments) {
293- run()
294- })
295- return _c
296-}
297-
298-func (_c *MockRemoteDirectory_Subscribe_Call) Return(remoteDirectorySubscription ports.RemoteDirectorySubscription) *MockRemoteDirectory_Subscribe_Call {
299- _c.Call.Return(remoteDirectorySubscription)
300- return _c
301-}
302-
303-func (_c *MockRemoteDirectory_Subscribe_Call) RunAndReturn(run func() ports.RemoteDirectorySubscription) *MockRemoteDirectory_Subscribe_Call {
304- _c.Call.Return(run)
305- return _c
306-}
M internal/ports/remote.go +0 -6
@@ -4,7 +4,6 @@ import (
44 "context"
55
66 "github.com/bnema/vev/internal/domain"
7- "github.com/bnema/vev/internal/protocol"
87 "github.com/bnema/vev/internal/protocol/catalogue"
98 )
109
@@ -26,11 +25,6 @@ type RemoteCatalogClient interface {
2625 List(ctx context.Context, target string) (catalogue.RemoteCatalog, error)
2726 }
2827
29-// RemotePreviewClient fetches one bounded, exact-target viewport.
30-type RemotePreviewClient interface {
31- Preview(ctx context.Context, target domain.RemoteSessionTarget, width, height uint16) (protocol.RemotePreview, error)
32-}
33-
3428 // RemoteCatalogCache persists complete remote discovery snapshots independently
3529 // from the remote host registry.
3630 type RemoteCatalogCache interface {
@@ -4,7 +4,6 @@ import (
4 "context"4 "context"
5 5
6 "github.com/bnema/vev/internal/domain"6 "github.com/bnema/vev/internal/domain"
7- "github.com/bnema/vev/internal/protocol"
8 "github.com/bnema/vev/internal/protocol/catalogue"7 "github.com/bnema/vev/internal/protocol/catalogue"
9 )8 )
10 9
@@ -26,11 +25,6 @@ type RemoteCatalogClient interface {
26 List(ctx context.Context, target string) (catalogue.RemoteCatalog, error)25 List(ctx context.Context, target string) (catalogue.RemoteCatalog, error)
27 }26 }
28 27
29-// RemotePreviewClient fetches one bounded, exact-target viewport.
30-type RemotePreviewClient interface {
31- Preview(ctx context.Context, target domain.RemoteSessionTarget, width, height uint16) (protocol.RemotePreview, error)
32-}
33-
34 // RemoteCatalogCache persists complete remote discovery snapshots independently28 // RemoteCatalogCache persists complete remote discovery snapshots independently
35 // from the remote host registry.29 // from the remote host registry.
36 type RemoteCatalogCache interface {30 type RemoteCatalogCache interface {
D internal/ports/remote_state.go +0 -92
@@ -1,92 +0,0 @@
1-package ports
2-
3-import (
4- "time"
5-
6- "github.com/bnema/vev/internal/domain"
7- "github.com/bnema/vev/internal/protocol/catalogue"
8-)
9-
10-// RemoteHostSnapshot is one ordered host projection from the remote directory.
11-// Sessions are cloned ordered catalogue values; the slice is never mutated
12-// after publication. Checking is independent of Availability: an in-flight
13-// observation preserves the last-known availability and inventory.
14-type RemoteHostSnapshot struct {
15- Endpoint string
16- DisplayOrigin string
17- Rank int
18- Registration domain.RemoteRegistration
19- Availability domain.RemoteAvailability
20- Checking bool
21- LastAttempt time.Time
22- LastSuccess time.Time
23- NextDue time.Time
24- ConsecutiveFailures uint
25- FailureEpisode uint64
26- LastFailure domain.RemoteFailure
27- InventoryKnown bool
28- Sessions []catalogue.RemoteCatalogSession
29-}
30-
31-// Clone returns a defensive copy with an independent session slice. Each
32-// session keeps the exact nil-ness of its tab list: an empty but present list
33-// is catalogue-valid and must not become absent through a copy.
34-func (s RemoteHostSnapshot) Clone() RemoteHostSnapshot {
35- out := s
36- out.Sessions = append([]catalogue.RemoteCatalogSession(nil), s.Sessions...)
37- for i := range out.Sessions {
38- if s.Sessions[i].Tabs == nil {
39- continue
40- }
41- out.Sessions[i].Tabs = append(make([]catalogue.RemoteCatalogTab, 0, len(s.Sessions[i].Tabs)), s.Sessions[i].Tabs...)
42- }
43- return out
44-}
45-
46-// RemoteDirectorySnapshot is an immutable, fully defensive publication of the
47-// remote directory. Nested slices are never mutated after publication.
48-type RemoteDirectorySnapshot struct {
49- Revision uint64
50- Initialized bool
51- RegistryError error
52- Hosts []RemoteHostSnapshot
53-}
54-
55-// Clone returns a defensive copy with independent host and session slices.
56-// RegistryError is shared: published errors are immutable values.
57-func (s RemoteDirectorySnapshot) Clone() RemoteDirectorySnapshot {
58- out := s
59- out.Hosts = make([]RemoteHostSnapshot, len(s.Hosts))
60- for i, host := range s.Hosts {
61- out.Hosts[i] = host.Clone()
62- }
63- return out
64-}
65-
66-// Find returns the snapshot for an exact endpoint identity.
67-func (s RemoteDirectorySnapshot) Find(endpoint string) (RemoteHostSnapshot, bool) {
68- for _, host := range s.Hosts {
69- if host.Endpoint == endpoint {
70- return host, true
71- }
72- }
73- return RemoteHostSnapshot{}, false
74-}
75-
76-// RemoteDirectorySubscription wakes one subscriber when a newer snapshot is
77-// available. Each subscriber owns a capacity-one channel and re-reads the
78-// newest snapshot on wake; channels are never shared between subscribers.
79-type RemoteDirectorySubscription interface {
80- Changed() <-chan struct{}
81- Close()
82-}
83-
84-// RemoteDirectory is the read-only projection seam between the remote monitor
85-// and presentation. Snapshot and subscription access perform no I/O; request
86-// methods are non-blocking coalesced hints, never synchronous refreshes.
87-type RemoteDirectory interface {
88- Snapshot() RemoteDirectorySnapshot
89- Subscribe() RemoteDirectorySubscription
90- RequestReconcile(endpoint string)
91- RegistryChanged()
92-}
@@ -1,92 +0,0 @@
1-package ports
2-
3-import (
4- "time"
5-
6- "github.com/bnema/vev/internal/domain"
7- "github.com/bnema/vev/internal/protocol/catalogue"
8-)
9-
10-// RemoteHostSnapshot is one ordered host projection from the remote directory.
11-// Sessions are cloned ordered catalogue values; the slice is never mutated
12-// after publication. Checking is independent of Availability: an in-flight
13-// observation preserves the last-known availability and inventory.
14-type RemoteHostSnapshot struct {
15- Endpoint string
16- DisplayOrigin string
17- Rank int
18- Registration domain.RemoteRegistration
19- Availability domain.RemoteAvailability
20- Checking bool
21- LastAttempt time.Time
22- LastSuccess time.Time
23- NextDue time.Time
24- ConsecutiveFailures uint
25- FailureEpisode uint64
26- LastFailure domain.RemoteFailure
27- InventoryKnown bool
28- Sessions []catalogue.RemoteCatalogSession
29-}
30-
31-// Clone returns a defensive copy with an independent session slice. Each
32-// session keeps the exact nil-ness of its tab list: an empty but present list
33-// is catalogue-valid and must not become absent through a copy.
34-func (s RemoteHostSnapshot) Clone() RemoteHostSnapshot {
35- out := s
36- out.Sessions = append([]catalogue.RemoteCatalogSession(nil), s.Sessions...)
37- for i := range out.Sessions {
38- if s.Sessions[i].Tabs == nil {
39- continue
40- }
41- out.Sessions[i].Tabs = append(make([]catalogue.RemoteCatalogTab, 0, len(s.Sessions[i].Tabs)), s.Sessions[i].Tabs...)
42- }
43- return out
44-}
45-
46-// RemoteDirectorySnapshot is an immutable, fully defensive publication of the
47-// remote directory. Nested slices are never mutated after publication.
48-type RemoteDirectorySnapshot struct {
49- Revision uint64
50- Initialized bool
51- RegistryError error
52- Hosts []RemoteHostSnapshot
53-}
54-
55-// Clone returns a defensive copy with independent host and session slices.
56-// RegistryError is shared: published errors are immutable values.
57-func (s RemoteDirectorySnapshot) Clone() RemoteDirectorySnapshot {
58- out := s
59- out.Hosts = make([]RemoteHostSnapshot, len(s.Hosts))
60- for i, host := range s.Hosts {
61- out.Hosts[i] = host.Clone()
62- }
63- return out
64-}
65-
66-// Find returns the snapshot for an exact endpoint identity.
67-func (s RemoteDirectorySnapshot) Find(endpoint string) (RemoteHostSnapshot, bool) {
68- for _, host := range s.Hosts {
69- if host.Endpoint == endpoint {
70- return host, true
71- }
72- }
73- return RemoteHostSnapshot{}, false
74-}
75-
76-// RemoteDirectorySubscription wakes one subscriber when a newer snapshot is
77-// available. Each subscriber owns a capacity-one channel and re-reads the
78-// newest snapshot on wake; channels are never shared between subscribers.
79-type RemoteDirectorySubscription interface {
80- Changed() <-chan struct{}
81- Close()
82-}
83-
84-// RemoteDirectory is the read-only projection seam between the remote monitor
85-// and presentation. Snapshot and subscription access perform no I/O; request
86-// methods are non-blocking coalesced hints, never synchronous refreshes.
87-type RemoteDirectory interface {
88- Snapshot() RemoteDirectorySnapshot
89- Subscribe() RemoteDirectorySubscription
90- RequestReconcile(endpoint string)
91- RegistryChanged()
92-}
D internal/ports/remote_state_test.go +0 -92
@@ -1,92 +0,0 @@
1-package ports
2-
3-import (
4- "testing"
5-
6- "github.com/bnema/vev/internal/domain"
7- "github.com/bnema/vev/internal/protocol/catalogue"
8-)
9-
10-func TestRemoteJobKindString(t *testing.T) {
11- tests := []struct {
12- name string
13- value RemoteJobKind
14- want string
15- }{
16- {name: "registry read", value: RemoteJobRegistryRead, want: "registry_read"},
17- {name: "cache load", value: RemoteJobCacheLoad, want: "cache_load"},
18- {name: "catalog observe", value: RemoteJobCatalogObserve, want: "catalog_observe"},
19- {name: "cache store", value: RemoteJobCacheStore, want: "cache_store"},
20- {name: "zero is unknown", value: RemoteJobKind(0), want: "unknown"},
21- {name: "out of range is unknown", value: RemoteJobKind(99), want: "unknown"},
22- }
23- for _, test := range tests {
24- t.Run(test.name, func(t *testing.T) {
25- if got := test.value.String(); got != test.want {
26- t.Fatalf("String() = %q, want %q", got, test.want)
27- }
28- })
29- }
30-}
31-
32-func TestRemoteDirectorySnapshotCloneIsDefensive(t *testing.T) {
33- original := RemoteDirectorySnapshot{
34- Revision: 7, Initialized: true,
35- Hosts: []RemoteHostSnapshot{{
36- Endpoint: "user@arch", DisplayOrigin: "arch", Rank: 0,
37- Registration: domain.RemoteRegistration{Endpoint: "user@arch", Incarnation: [16]byte{3}, Generation: 1},
38- Availability: domain.RemoteAvailabilityReachable,
39- Checking: true,
40- InventoryKnown: true,
41- Sessions: []catalogue.RemoteCatalogSession{{
42- Name: "work", State: catalogue.RemoteCatalogSessionUp,
43- Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "shell"}},
44- }},
45- }},
46- }
47- cloned := original.Clone()
48- if found, ok := cloned.Find("user@arch"); !ok || found.Availability != domain.RemoteAvailabilityReachable {
49- t.Fatalf("Find() = %+v, %t, want reachable host", found, ok)
50- }
51- if _, ok := cloned.Find("user@mule"); ok {
52- t.Fatal("Find() must miss unknown endpoints")
53- }
54-
55- cloned.Hosts[0].Endpoint = "mutated"
56- cloned.Hosts[0].Sessions[0].Name = "mutated"
57- cloned.Hosts[0].Sessions[0].Tabs[0].ID = "mutated"
58- if original.Hosts[0].Endpoint != "user@arch" || original.Hosts[0].Sessions[0].Name != "work" ||
59- original.Hosts[0].Sessions[0].Tabs[0].ID != "tab-1" {
60- t.Fatalf("Clone() shares memory with the original: %+v", original.Hosts[0])
61- }
62-
63- empty := RemoteDirectorySnapshot{}.Clone()
64- if empty.Hosts == nil {
65- t.Fatal("Clone() of an empty snapshot must keep a non-nil host slice")
66- }
67- if _, ok := empty.Find("user@arch"); ok {
68- t.Fatal("Find() on an empty snapshot must miss")
69- }
70-}
71-
72-func TestRemoteHostSnapshotCloneIsDefensive(t *testing.T) {
73- original := RemoteHostSnapshot{
74- Endpoint: "user@arch",
75- Sessions: []catalogue.RemoteCatalogSession{{Name: "work"}, {Name: "empty", Tabs: []catalogue.RemoteCatalogTab{}}},
76- }
77- cloned := original.Clone()
78- cloned.Sessions[0].Name = "mutated"
79- if original.Sessions[0].Name != "work" {
80- t.Fatal("Clone() shares session memory with the original")
81- }
82- if (RemoteHostSnapshot{}).Clone().Sessions != nil {
83- t.Fatal("Clone() of a session-less host must stay nil")
84- }
85- if cloned.Sessions[1].Tabs == nil {
86- t.Fatal("Clone() must keep an empty but present tab list")
87- }
88- cloned.Sessions[1].Tabs = append(cloned.Sessions[1].Tabs, catalogue.RemoteCatalogTab{ID: "tab-1"})
89- if original.Sessions[1].Tabs == nil || len(original.Sessions[1].Tabs) != 0 {
90- t.Fatalf("Clone() shares tab memory with the original: %+v", original.Sessions[1].Tabs)
91- }
92-}
@@ -1,92 +0,0 @@
1-package ports
2-
3-import (
4- "testing"
5-
6- "github.com/bnema/vev/internal/domain"
7- "github.com/bnema/vev/internal/protocol/catalogue"
8-)
9-
10-func TestRemoteJobKindString(t *testing.T) {
11- tests := []struct {
12- name string
13- value RemoteJobKind
14- want string
15- }{
16- {name: "registry read", value: RemoteJobRegistryRead, want: "registry_read"},
17- {name: "cache load", value: RemoteJobCacheLoad, want: "cache_load"},
18- {name: "catalog observe", value: RemoteJobCatalogObserve, want: "catalog_observe"},
19- {name: "cache store", value: RemoteJobCacheStore, want: "cache_store"},
20- {name: "zero is unknown", value: RemoteJobKind(0), want: "unknown"},
21- {name: "out of range is unknown", value: RemoteJobKind(99), want: "unknown"},
22- }
23- for _, test := range tests {
24- t.Run(test.name, func(t *testing.T) {
25- if got := test.value.String(); got != test.want {
26- t.Fatalf("String() = %q, want %q", got, test.want)
27- }
28- })
29- }
30-}
31-
32-func TestRemoteDirectorySnapshotCloneIsDefensive(t *testing.T) {
33- original := RemoteDirectorySnapshot{
34- Revision: 7, Initialized: true,
35- Hosts: []RemoteHostSnapshot{{
36- Endpoint: "user@arch", DisplayOrigin: "arch", Rank: 0,
37- Registration: domain.RemoteRegistration{Endpoint: "user@arch", Incarnation: [16]byte{3}, Generation: 1},
38- Availability: domain.RemoteAvailabilityReachable,
39- Checking: true,
40- InventoryKnown: true,
41- Sessions: []catalogue.RemoteCatalogSession{{
42- Name: "work", State: catalogue.RemoteCatalogSessionUp,
43- Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "shell"}},
44- }},
45- }},
46- }
47- cloned := original.Clone()
48- if found, ok := cloned.Find("user@arch"); !ok || found.Availability != domain.RemoteAvailabilityReachable {
49- t.Fatalf("Find() = %+v, %t, want reachable host", found, ok)
50- }
51- if _, ok := cloned.Find("user@mule"); ok {
52- t.Fatal("Find() must miss unknown endpoints")
53- }
54-
55- cloned.Hosts[0].Endpoint = "mutated"
56- cloned.Hosts[0].Sessions[0].Name = "mutated"
57- cloned.Hosts[0].Sessions[0].Tabs[0].ID = "mutated"
58- if original.Hosts[0].Endpoint != "user@arch" || original.Hosts[0].Sessions[0].Name != "work" ||
59- original.Hosts[0].Sessions[0].Tabs[0].ID != "tab-1" {
60- t.Fatalf("Clone() shares memory with the original: %+v", original.Hosts[0])
61- }
62-
63- empty := RemoteDirectorySnapshot{}.Clone()
64- if empty.Hosts == nil {
65- t.Fatal("Clone() of an empty snapshot must keep a non-nil host slice")
66- }
67- if _, ok := empty.Find("user@arch"); ok {
68- t.Fatal("Find() on an empty snapshot must miss")
69- }
70-}
71-
72-func TestRemoteHostSnapshotCloneIsDefensive(t *testing.T) {
73- original := RemoteHostSnapshot{
74- Endpoint: "user@arch",
75- Sessions: []catalogue.RemoteCatalogSession{{Name: "work"}, {Name: "empty", Tabs: []catalogue.RemoteCatalogTab{}}},
76- }
77- cloned := original.Clone()
78- cloned.Sessions[0].Name = "mutated"
79- if original.Sessions[0].Name != "work" {
80- t.Fatal("Clone() shares session memory with the original")
81- }
82- if (RemoteHostSnapshot{}).Clone().Sessions != nil {
83- t.Fatal("Clone() of a session-less host must stay nil")
84- }
85- if cloned.Sessions[1].Tabs == nil {
86- t.Fatal("Clone() must keep an empty but present tab list")
87- }
88- cloned.Sessions[1].Tabs = append(cloned.Sessions[1].Tabs, catalogue.RemoteCatalogTab{ID: "tab-1"})
89- if original.Sessions[1].Tabs == nil || len(original.Sessions[1].Tabs) != 0 {
90- t.Fatalf("Clone() shares tab memory with the original: %+v", original.Sessions[1].Tabs)
91- }
92-}
M internal/usecase/daemon/attention.go +68 -5
@@ -2,10 +2,12 @@ package daemon
22
33 import (
44 "context"
5+ "errors"
56 "time"
67
78 "github.com/bnema/vev/internal/domain"
89 "github.com/bnema/vev/internal/ports"
10+ "github.com/bnema/vev/internal/protocol"
911 "github.com/bnema/vev/internal/usecase/picker"
1012 )
1113
@@ -45,10 +47,12 @@ func (d *Daemon) noteAttention(sess *session, tb *tab) {
4547 d.pokeAttentionTicker()
4648 }
4749
48-// jumpAttention switches to the oldest pending attention target, local tab
49-// first and then another session's. Returning nil never implies a switch
50-// happened — it also covers "no target exists", which is routine and not an
51-// error. Only a failure to reach a target that does exist is a genuine error.
50+// jumpAttention switches to the oldest pending attention target: a tab of the
51+// attached session first, then another session of this daemon, then the
52+// oldest bell among the client's routes on any other daemon, which the client
53+// navigates to (Plan 003 C5). Returning nil never implies a switch happened —
54+// it also covers "no target exists", which is routine and not an error. Only
55+// a failure to reach a target that does exist is a genuine error.
5256 func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) error {
5357 return d.jumpAttentionForAttachment(sess, ac, nil)
5458 }
@@ -71,7 +75,7 @@ func (d *Daemon) jumpAttentionForAttachment(sess *session, ac *attachedClient, e
7175
7276 target, ok := d.oldestOtherSessionAttention(sess)
7377 if !ok {
74- return nil
78+ return d.jumpRouteAttention(sess, ac, effect)
7579 }
7680 pickerTarget := picker.Target{Session: target.sessionID, TabIndex: target.tabIndex}
7781 if effect == nil {
@@ -80,6 +84,65 @@ func (d *Daemon) jumpAttentionForAttachment(sess *session, ac *attachedClient, e
8084 return d.switchActiveTargetForAttachment(effect, pickerTarget)
8185 }
8286
87+// jumpRouteAttention asks the client to navigate to the oldest attention
88+// route it published for another daemon. The serving daemon's own sessions
89+// were already considered, so a local route of this daemon is skipped.
90+func (d *Daemon) jumpRouteAttention(sess *session, ac *attachedClient, effect *attachmentEffect) error {
91+ entry, ok := oldestRouteAttention(ac.routeSnapshotCopy(), d.ownsLifecycle)
92+ if !ok {
93+ return nil
94+ }
95+ exec := paletteExec{d: d, sess: sess, attachment: sess, ac: ac, effect: effect}
96+ err := exec.NavigateRecentRoute(entry)
97+ if errors.Is(err, errAttachmentTransition) {
98+ return nil
99+ }
100+ return err
101+}
102+
103+// oldestRouteAttention selects the route whose attention the client observed
104+// first. owned reports lifecycles this daemon serves itself.
105+func oldestRouteAttention(snapshot protocol.RecentRouteSnapshot, owned func(domain.SessionLifecycleID) bool) (protocol.RouteNavigationAction, bool) {
106+ var best protocol.RecentRouteEntry
107+ found := false
108+ for _, entry := range snapshot.Entries {
109+ if !entry.Attention || entry.AttentionSeq == 0 || owned(entry.Target.LifecycleID) {
110+ continue
111+ }
112+ if !found || entry.AttentionSeq < best.AttentionSeq {
113+ best, found = entry, true
114+ }
115+ }
116+ if !found || snapshot.Generation == 0 {
117+ return protocol.RouteNavigationAction{}, false
118+ }
119+ return protocol.RouteNavigationAction{SnapshotGeneration: snapshot.Generation, Key: best.Key, Generation: best.Generation}, true
120+}
121+
122+// ownsLifecycle reports whether this daemon holds the lifecycle, live or
123+// stopped.
124+func (d *Daemon) ownsLifecycle(lifecycle domain.SessionLifecycleID) bool {
125+ d.mu.Lock()
126+ sessions := sessionsSnapshot(d.sessions)
127+ stopped := false
128+ for _, inactive := range d.inactive {
129+ stopped = stopped || inactive.incarnation == lifecycle
130+ }
131+ d.mu.Unlock()
132+ if stopped {
133+ return true
134+ }
135+ for _, sess := range sessions {
136+ sess.mu.Lock()
137+ owned := sess.incarnation == lifecycle
138+ sess.mu.Unlock()
139+ if owned {
140+ return true
141+ }
142+ }
143+ return false
144+}
145+
83146 func oldestAttentionTab(sess *session) (int, bool) {
84147 idx, _, ok := oldestAttentionTabWithTime(sess)
85148 return idx, ok
@@ -2,10 +2,12 @@ package daemon
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "time"6 "time"
6 7
7 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
8 "github.com/bnema/vev/internal/ports"9 "github.com/bnema/vev/internal/ports"
10+ "github.com/bnema/vev/internal/protocol"
9 "github.com/bnema/vev/internal/usecase/picker"11 "github.com/bnema/vev/internal/usecase/picker"
10 )12 )
11 13
@@ -45,10 +47,12 @@ func (d *Daemon) noteAttention(sess *session, tb *tab) {
45 d.pokeAttentionTicker()47 d.pokeAttentionTicker()
46 }48 }
47 49
48-// jumpAttention switches to the oldest pending attention target, local tab50+// jumpAttention switches to the oldest pending attention target: a tab of the
49-// first and then another session's. Returning nil never implies a switch51+// attached session first, then another session of this daemon, then the
50-// happened — it also covers "no target exists", which is routine and not an52+// oldest bell among the client's routes on any other daemon, which the client
51-// error. Only a failure to reach a target that does exist is a genuine error.53+// navigates to (Plan 003 C5). Returning nil never implies a switch happened —
54+// it also covers "no target exists", which is routine and not an error. Only
55+// a failure to reach a target that does exist is a genuine error.
52 func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) error {56 func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) error {
53 return d.jumpAttentionForAttachment(sess, ac, nil)57 return d.jumpAttentionForAttachment(sess, ac, nil)
54 }58 }
@@ -71,7 +75,7 @@ func (d *Daemon) jumpAttentionForAttachment(sess *session, ac *attachedClient, e
71 75
72 target, ok := d.oldestOtherSessionAttention(sess)76 target, ok := d.oldestOtherSessionAttention(sess)
73 if !ok {77 if !ok {
74- return nil78+ return d.jumpRouteAttention(sess, ac, effect)
75 }79 }
76 pickerTarget := picker.Target{Session: target.sessionID, TabIndex: target.tabIndex}80 pickerTarget := picker.Target{Session: target.sessionID, TabIndex: target.tabIndex}
77 if effect == nil {81 if effect == nil {
@@ -80,6 +84,65 @@ func (d *Daemon) jumpAttentionForAttachment(sess *session, ac *attachedClient, e
80 return d.switchActiveTargetForAttachment(effect, pickerTarget)84 return d.switchActiveTargetForAttachment(effect, pickerTarget)
81 }85 }
82 86
87+// jumpRouteAttention asks the client to navigate to the oldest attention
88+// route it published for another daemon. The serving daemon's own sessions
89+// were already considered, so a local route of this daemon is skipped.
90+func (d *Daemon) jumpRouteAttention(sess *session, ac *attachedClient, effect *attachmentEffect) error {
91+ entry, ok := oldestRouteAttention(ac.routeSnapshotCopy(), d.ownsLifecycle)
92+ if !ok {
93+ return nil
94+ }
95+ exec := paletteExec{d: d, sess: sess, attachment: sess, ac: ac, effect: effect}
96+ err := exec.NavigateRecentRoute(entry)
97+ if errors.Is(err, errAttachmentTransition) {
98+ return nil
99+ }
100+ return err
101+}
102+
103+// oldestRouteAttention selects the route whose attention the client observed
104+// first. owned reports lifecycles this daemon serves itself.
105+func oldestRouteAttention(snapshot protocol.RecentRouteSnapshot, owned func(domain.SessionLifecycleID) bool) (protocol.RouteNavigationAction, bool) {
106+ var best protocol.RecentRouteEntry
107+ found := false
108+ for _, entry := range snapshot.Entries {
109+ if !entry.Attention || entry.AttentionSeq == 0 || owned(entry.Target.LifecycleID) {
110+ continue
111+ }
112+ if !found || entry.AttentionSeq < best.AttentionSeq {
113+ best, found = entry, true
114+ }
115+ }
116+ if !found || snapshot.Generation == 0 {
117+ return protocol.RouteNavigationAction{}, false
118+ }
119+ return protocol.RouteNavigationAction{SnapshotGeneration: snapshot.Generation, Key: best.Key, Generation: best.Generation}, true
120+}
121+
122+// ownsLifecycle reports whether this daemon holds the lifecycle, live or
123+// stopped.
124+func (d *Daemon) ownsLifecycle(lifecycle domain.SessionLifecycleID) bool {
125+ d.mu.Lock()
126+ sessions := sessionsSnapshot(d.sessions)
127+ stopped := false
128+ for _, inactive := range d.inactive {
129+ stopped = stopped || inactive.incarnation == lifecycle
130+ }
131+ d.mu.Unlock()
132+ if stopped {
133+ return true
134+ }
135+ for _, sess := range sessions {
136+ sess.mu.Lock()
137+ owned := sess.incarnation == lifecycle
138+ sess.mu.Unlock()
139+ if owned {
140+ return true
141+ }
142+ }
143+ return false
144+}
145+
83 func oldestAttentionTab(sess *session) (int, bool) {146 func oldestAttentionTab(sess *session) (int, bool) {
84 idx, _, ok := oldestAttentionTabWithTime(sess)147 idx, _, ok := oldestAttentionTabWithTime(sess)
85 return idx, ok148 return idx, ok
A internal/usecase/daemon/attention_route_test.go +85 -0
@@ -0,0 +1,85 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/protocol"
10+)
11+
12+func attentionRouteEntry(key uint64, name string, marker byte, seq uint64) protocol.RecentRouteEntry {
13+ entry := testRouteEntry(key, 1, name, marker, protocol.RouteKindRemote)
14+ entry.HostLabel = "box"
15+ entry.Attention = seq != 0
16+ entry.AttentionSeq = seq
17+ return entry
18+}
19+
20+// TestOldestRouteAttention pins the cross-daemon half of jump-to-attention
21+// (Plan 003 C5): the oldest client-observed bell wins, and a lifecycle this
22+// daemon owns is never delegated to the client.
23+func TestOldestRouteAttention(t *testing.T) {
24+ owned := testRouteTarget("mine", 9).LifecycleID
25+ tests := []struct {
26+ name string
27+ entries []protocol.RecentRouteEntry
28+ wantKey uint64
29+ wantOK bool
30+ }{
31+ {name: "no bells", entries: []protocol.RecentRouteEntry{attentionRouteEntry(1, "a", 1, 0)}},
32+ {
33+ name: "oldest onset wins over recency",
34+ entries: []protocol.RecentRouteEntry{attentionRouteEntry(1, "new", 1, 7), attentionRouteEntry(2, "old", 2, 3), attentionRouteEntry(3, "quiet", 3, 0)},
35+ wantKey: 2, wantOK: true,
36+ },
37+ {
38+ name: "this daemon's own lifecycle is skipped",
39+ entries: []protocol.RecentRouteEntry{attentionRouteEntry(1, "mine", 9, 1), attentionRouteEntry(2, "theirs", 2, 4)},
40+ wantKey: 2, wantOK: true,
41+ },
42+ }
43+ for _, tt := range tests {
44+ t.Run(tt.name, func(t *testing.T) {
45+ snapshot := protocol.RecentRouteSnapshot{Generation: 5, Entries: tt.entries}
46+ require.NoError(t, snapshot.Validate())
47+ action, ok := oldestRouteAttention(snapshot, func(lifecycle domain.SessionLifecycleID) bool { return lifecycle == owned })
48+ require.Equal(t, tt.wantOK, ok)
49+ if tt.wantOK {
50+ require.Equal(t, protocol.RouteNavigationAction{SnapshotGeneration: 5, Key: tt.wantKey, Generation: 1}, action)
51+ }
52+ })
53+ }
54+}
55+
56+// TestJumpAttentionDelegatesOtherDaemonsToTheClient drives the real jump:
57+// with no bell on this daemon, the oldest route bell becomes a client
58+// navigation request on the attachment.
59+func TestJumpAttentionDelegatesOtherDaemonsToTheClient(t *testing.T) {
60+ d, source, ac, _ := newManualSessionWithPTYs(t, nil)
61+ transport := &closeTrackingTransport{}
62+ ac.replaceTransport(transport)
63+ rc := d.attachCoordinator(source, nil, ac, true)
64+ token := source.captureAttachmentCapability(ac, transport)
65+ token.lease = rc.attachmentLease(ac)
66+ ac.installTestAttachmentCapability(token)
67+ effect, admitted := ac.beginAttachmentEffect(token)
68+ require.True(t, admitted)
69+ defer effect.End()
70+ ac.setRouteSnapshot(protocol.RecentRouteSnapshot{
71+ Generation: 6,
72+ Entries: []protocol.RecentRouteEntry{attentionRouteEntry(4, "later", 4, 9), attentionRouteEntry(5, "first", 5, 2)},
73+ })
74+
75+ require.NoError(t, d.jumpAttentionForAttachment(source, ac, effect))
76+
77+ var actions []protocol.RouteNavigationAction
78+ for _, frame := range transport.Sends() {
79+ if action, ok := decodeServerMessage(t, frame).(protocol.RouteNavigationAction); ok {
80+ actions = append(actions, action)
81+ }
82+ }
83+ require.Equal(t, []protocol.RouteNavigationAction{{SnapshotGeneration: 6, Key: 5, Generation: 1}}, actions)
84+ require.Same(t, source, ac.currentSession(), "the client navigates; the daemon never switches")
85+}
@@ -0,0 +1,85 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/protocol"
10+)
11+
12+func attentionRouteEntry(key uint64, name string, marker byte, seq uint64) protocol.RecentRouteEntry {
13+ entry := testRouteEntry(key, 1, name, marker, protocol.RouteKindRemote)
14+ entry.HostLabel = "box"
15+ entry.Attention = seq != 0
16+ entry.AttentionSeq = seq
17+ return entry
18+}
19+
20+// TestOldestRouteAttention pins the cross-daemon half of jump-to-attention
21+// (Plan 003 C5): the oldest client-observed bell wins, and a lifecycle this
22+// daemon owns is never delegated to the client.
23+func TestOldestRouteAttention(t *testing.T) {
24+ owned := testRouteTarget("mine", 9).LifecycleID
25+ tests := []struct {
26+ name string
27+ entries []protocol.RecentRouteEntry
28+ wantKey uint64
29+ wantOK bool
30+ }{
31+ {name: "no bells", entries: []protocol.RecentRouteEntry{attentionRouteEntry(1, "a", 1, 0)}},
32+ {
33+ name: "oldest onset wins over recency",
34+ entries: []protocol.RecentRouteEntry{attentionRouteEntry(1, "new", 1, 7), attentionRouteEntry(2, "old", 2, 3), attentionRouteEntry(3, "quiet", 3, 0)},
35+ wantKey: 2, wantOK: true,
36+ },
37+ {
38+ name: "this daemon's own lifecycle is skipped",
39+ entries: []protocol.RecentRouteEntry{attentionRouteEntry(1, "mine", 9, 1), attentionRouteEntry(2, "theirs", 2, 4)},
40+ wantKey: 2, wantOK: true,
41+ },
42+ }
43+ for _, tt := range tests {
44+ t.Run(tt.name, func(t *testing.T) {
45+ snapshot := protocol.RecentRouteSnapshot{Generation: 5, Entries: tt.entries}
46+ require.NoError(t, snapshot.Validate())
47+ action, ok := oldestRouteAttention(snapshot, func(lifecycle domain.SessionLifecycleID) bool { return lifecycle == owned })
48+ require.Equal(t, tt.wantOK, ok)
49+ if tt.wantOK {
50+ require.Equal(t, protocol.RouteNavigationAction{SnapshotGeneration: 5, Key: tt.wantKey, Generation: 1}, action)
51+ }
52+ })
53+ }
54+}
55+
56+// TestJumpAttentionDelegatesOtherDaemonsToTheClient drives the real jump:
57+// with no bell on this daemon, the oldest route bell becomes a client
58+// navigation request on the attachment.
59+func TestJumpAttentionDelegatesOtherDaemonsToTheClient(t *testing.T) {
60+ d, source, ac, _ := newManualSessionWithPTYs(t, nil)
61+ transport := &closeTrackingTransport{}
62+ ac.replaceTransport(transport)
63+ rc := d.attachCoordinator(source, nil, ac, true)
64+ token := source.captureAttachmentCapability(ac, transport)
65+ token.lease = rc.attachmentLease(ac)
66+ ac.installTestAttachmentCapability(token)
67+ effect, admitted := ac.beginAttachmentEffect(token)
68+ require.True(t, admitted)
69+ defer effect.End()
70+ ac.setRouteSnapshot(protocol.RecentRouteSnapshot{
71+ Generation: 6,
72+ Entries: []protocol.RecentRouteEntry{attentionRouteEntry(4, "later", 4, 9), attentionRouteEntry(5, "first", 5, 2)},
73+ })
74+
75+ require.NoError(t, d.jumpAttentionForAttachment(source, ac, effect))
76+
77+ var actions []protocol.RouteNavigationAction
78+ for _, frame := range transport.Sends() {
79+ if action, ok := decodeServerMessage(t, frame).(protocol.RouteNavigationAction); ok {
80+ actions = append(actions, action)
81+ }
82+ }
83+ require.Equal(t, []protocol.RouteNavigationAction{{SnapshotGeneration: 6, Key: 5, Generation: 1}}, actions)
84+ require.Same(t, source, ac.currentSession(), "the client navigates; the daemon never switches")
85+}
M internal/usecase/daemon/daemon.go +2 -84
@@ -361,19 +361,6 @@ type Daemon struct {
361361 notices *noticeCenter
362362 resumeParkGrace time.Duration
363363 suspendedSafetyExpiry time.Duration
364- remotePreview remotePreviewState
365- remotePreviewClient ports.RemotePreviewClient
366- // remoteDirectory is the snapshot-only projection served by the remote
367- // monitor. Presentation reads it without I/O; nil disables remote
368- // directory projections without affecting local behavior.
369- remoteDirectory ports.RemoteDirectory
370- // remoteFailureNoticed tracks active observation failure episodes so
371- // bounded retries do not produce repeated global toasts.
372- remoteFailureNoticeMu sync.Mutex
373- remoteFailureNoticed map[string]uint64
374- // remoteMonitorRun starts the monitor policy loop. Serve owns its
375- // lifetime; nil disables background monitoring.
376- remoteMonitorRun func(context.Context) error
377364 // tempDir overrides os.TempDir() for clipboard-image-transfer writes
378365 // (see clipboard.go); empty means use os.TempDir().
379366 tempDir string
@@ -487,25 +474,6 @@ func WithRuntimeObserver(observer ports.SerializedRuntimeObserver) Option {
487474 return func(d *Daemon) { d.runtimeObserver = observer }
488475 }
489476
490-// WithRemoteMonitor installs the snapshot-only remote directory and the
491-// monitor runner. Serve starts the runner without waiting for registry,
492-// cache or runtime readiness and stops it with its own context.
493-//
494-// The daemon-side monitor stays intentionally active in this state: it is part
495-// of the coordinated P7 removal set, so no polling gate, feature switch, or
496-// early shutdown path is added here (GO-001, deferred to P7).
497-func WithRemoteMonitor(directory ports.RemoteDirectory, run func(context.Context) error) Option {
498- return func(d *Daemon) {
499- d.remoteDirectory = directory
500- d.remoteMonitorRun = run
501- }
502-}
503-
504-// WithRemotePreview installs the optional non-attaching remote viewport client.
505-func WithRemotePreview(client ports.RemotePreviewClient) Option {
506- return func(d *Daemon) { d.remotePreviewClient = client }
507-}
508-
509477 // WithShell overrides the command (and its args) each session spawns. The
510478 // default is $SHELL (or /bin/sh) with no arguments; tests use this to run a
511479 // deterministic program.
@@ -833,11 +801,6 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
833801 return d
834802 }
835803
836-// remoteMonitorShutdownJoin bounds the daemon's wait for monitor teardown.
837-// The monitor bounds its own runtime cleanup to two seconds; this join only
838-// covers scheduling slack and never enters the session worker group.
839-const remoteMonitorShutdownJoin = 3 * time.Second
840-
841804 // Serve runs the accept loop over l, owning it for the loop's lifetime. It
842805 // returns only on ctx cancellation or an explicit daemon shutdown request
843806 // (KillDaemon), never when the session registry drains to empty: final
@@ -851,49 +814,6 @@ func (d *Daemon) Serve(ctx context.Context, l ports.ServerListener) error {
851814 d.hardCtx, d.hardCancel = context.WithCancel(context.Background())
852815 defer d.hardCancel()
853816
854- // Remote directory projections relay without I/O: the watcher rebuilds
855- // only open views on publication and never requests reconciliation.
856- // The watcher and the monitor runner share one bounded shutdown budget
857- // below, outside the session worker group.
858- watcherDone := make(chan struct{})
859- if d.remoteDirectory != nil {
860- go func() {
861- defer close(watcherDone)
862- d.watchRemoteDirectory(d.serveCtx)
863- }()
864- } else {
865- close(watcherDone)
866- }
867- var monitorDone chan error
868- if d.remoteMonitorRun != nil {
869- monitorDone = make(chan error, 1)
870- go func() { monitorDone <- d.remoteMonitorRun(d.serveCtx) }()
871- }
872- // Bounded join outside the session worker group: remote teardown never
873- // extends daemon shutdown past this single budget. The monitor runner
874- // owns its runtime's two-second cleanup join, so joining the runner
875- // transitively joins the workers; the watcher owns no I/O or workers.
876- // This join is a wall-time operational bound on purpose: it uses the
877- // package wall clock (like the handshake timeout) instead of the
878- // injected daemon clock, whose timers may never fire in tests.
879- defer func() {
880- join := systemClock{}.NewTimer(remoteMonitorShutdownJoin)
881- defer join.Stop()
882- if monitorDone != nil {
883- select {
884- case <-monitorDone:
885- case <-join.C():
886- d.log.Warn("remote monitor shutdown join timed out")
887- return
888- }
889- }
890- select {
891- case <-watcherDone:
892- case <-join.C():
893- d.log.Warn("remote directory watcher shutdown join timed out")
894- }
895- }()
896-
897817 d.sessWg.Go(func() {
898818 d.attentionAnimator(d.serveCtx)
899819 })
@@ -1212,12 +1132,10 @@ func (d *Daemon) handleConn(tr ports.ServerConnection) {
12121132 if err := d.handleCommand(tr, message); err != nil {
12131133 d.log.Warn("command handler failed", "err", err)
12141134 }
1215- case protocol.RemotePreviewRequest:
1135+ case protocol.RemotePreviewWatch:
12161136 stopTransport()
12171137 finishHandshake()
1218- if err := d.handleRemotePreview(tr, message); err != nil {
1219- d.log.Warn("remote preview handler failed", "err", err)
1220- }
1138+ d.serveRemotePreviewWatch(tr, message)
12211139 case protocol.NavigationInventoryRequest:
12221140 stopTransport()
12231141 finishHandshake()
@@ -361,19 +361,6 @@ type Daemon struct {
361 notices *noticeCenter361 notices *noticeCenter
362 resumeParkGrace time.Duration362 resumeParkGrace time.Duration
363 suspendedSafetyExpiry time.Duration363 suspendedSafetyExpiry time.Duration
364- remotePreview remotePreviewState
365- remotePreviewClient ports.RemotePreviewClient
366- // remoteDirectory is the snapshot-only projection served by the remote
367- // monitor. Presentation reads it without I/O; nil disables remote
368- // directory projections without affecting local behavior.
369- remoteDirectory ports.RemoteDirectory
370- // remoteFailureNoticed tracks active observation failure episodes so
371- // bounded retries do not produce repeated global toasts.
372- remoteFailureNoticeMu sync.Mutex
373- remoteFailureNoticed map[string]uint64
374- // remoteMonitorRun starts the monitor policy loop. Serve owns its
375- // lifetime; nil disables background monitoring.
376- remoteMonitorRun func(context.Context) error
377 // tempDir overrides os.TempDir() for clipboard-image-transfer writes364 // tempDir overrides os.TempDir() for clipboard-image-transfer writes
378 // (see clipboard.go); empty means use os.TempDir().365 // (see clipboard.go); empty means use os.TempDir().
379 tempDir string366 tempDir string
@@ -487,25 +474,6 @@ func WithRuntimeObserver(observer ports.SerializedRuntimeObserver) Option {
487 return func(d *Daemon) { d.runtimeObserver = observer }474 return func(d *Daemon) { d.runtimeObserver = observer }
488 }475 }
489 476
490-// WithRemoteMonitor installs the snapshot-only remote directory and the
491-// monitor runner. Serve starts the runner without waiting for registry,
492-// cache or runtime readiness and stops it with its own context.
493-//
494-// The daemon-side monitor stays intentionally active in this state: it is part
495-// of the coordinated P7 removal set, so no polling gate, feature switch, or
496-// early shutdown path is added here (GO-001, deferred to P7).
497-func WithRemoteMonitor(directory ports.RemoteDirectory, run func(context.Context) error) Option {
498- return func(d *Daemon) {
499- d.remoteDirectory = directory
500- d.remoteMonitorRun = run
501- }
502-}
503-
504-// WithRemotePreview installs the optional non-attaching remote viewport client.
505-func WithRemotePreview(client ports.RemotePreviewClient) Option {
506- return func(d *Daemon) { d.remotePreviewClient = client }
507-}
508-
509 // WithShell overrides the command (and its args) each session spawns. The477 // WithShell overrides the command (and its args) each session spawns. The
510 // default is $SHELL (or /bin/sh) with no arguments; tests use this to run a478 // default is $SHELL (or /bin/sh) with no arguments; tests use this to run a
511 // deterministic program.479 // deterministic program.
@@ -833,11 +801,6 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
833 return d801 return d
834 }802 }
835 803
836-// remoteMonitorShutdownJoin bounds the daemon's wait for monitor teardown.
837-// The monitor bounds its own runtime cleanup to two seconds; this join only
838-// covers scheduling slack and never enters the session worker group.
839-const remoteMonitorShutdownJoin = 3 * time.Second
840-
841 // Serve runs the accept loop over l, owning it for the loop's lifetime. It804 // Serve runs the accept loop over l, owning it for the loop's lifetime. It
842 // returns only on ctx cancellation or an explicit daemon shutdown request805 // returns only on ctx cancellation or an explicit daemon shutdown request
843 // (KillDaemon), never when the session registry drains to empty: final806 // (KillDaemon), never when the session registry drains to empty: final
@@ -851,49 +814,6 @@ func (d *Daemon) Serve(ctx context.Context, l ports.ServerListener) error {
851 d.hardCtx, d.hardCancel = context.WithCancel(context.Background())814 d.hardCtx, d.hardCancel = context.WithCancel(context.Background())
852 defer d.hardCancel()815 defer d.hardCancel()
853 816
854- // Remote directory projections relay without I/O: the watcher rebuilds
855- // only open views on publication and never requests reconciliation.
856- // The watcher and the monitor runner share one bounded shutdown budget
857- // below, outside the session worker group.
858- watcherDone := make(chan struct{})
859- if d.remoteDirectory != nil {
860- go func() {
861- defer close(watcherDone)
862- d.watchRemoteDirectory(d.serveCtx)
863- }()
864- } else {
865- close(watcherDone)
866- }
867- var monitorDone chan error
868- if d.remoteMonitorRun != nil {
869- monitorDone = make(chan error, 1)
870- go func() { monitorDone <- d.remoteMonitorRun(d.serveCtx) }()
871- }
872- // Bounded join outside the session worker group: remote teardown never
873- // extends daemon shutdown past this single budget. The monitor runner
874- // owns its runtime's two-second cleanup join, so joining the runner
875- // transitively joins the workers; the watcher owns no I/O or workers.
876- // This join is a wall-time operational bound on purpose: it uses the
877- // package wall clock (like the handshake timeout) instead of the
878- // injected daemon clock, whose timers may never fire in tests.
879- defer func() {
880- join := systemClock{}.NewTimer(remoteMonitorShutdownJoin)
881- defer join.Stop()
882- if monitorDone != nil {
883- select {
884- case <-monitorDone:
885- case <-join.C():
886- d.log.Warn("remote monitor shutdown join timed out")
887- return
888- }
889- }
890- select {
891- case <-watcherDone:
892- case <-join.C():
893- d.log.Warn("remote directory watcher shutdown join timed out")
894- }
895- }()
896-
897 d.sessWg.Go(func() {817 d.sessWg.Go(func() {
898 d.attentionAnimator(d.serveCtx)818 d.attentionAnimator(d.serveCtx)
899 })819 })
@@ -1212,12 +1132,10 @@ func (d *Daemon) handleConn(tr ports.ServerConnection) {
1212 if err := d.handleCommand(tr, message); err != nil {1132 if err := d.handleCommand(tr, message); err != nil {
1213 d.log.Warn("command handler failed", "err", err)1133 d.log.Warn("command handler failed", "err", err)
1214 }1134 }
1215- case protocol.RemotePreviewRequest:1135+ case protocol.RemotePreviewWatch:
1216 stopTransport()1136 stopTransport()
1217 finishHandshake()1137 finishHandshake()
1218- if err := d.handleRemotePreview(tr, message); err != nil {1138+ d.serveRemotePreviewWatch(tr, message)
1219- d.log.Warn("remote preview handler failed", "err", err)
1220- }
1221 case protocol.NavigationInventoryRequest:1139 case protocol.NavigationInventoryRequest:
1222 stopTransport()1140 stopTransport()
1223 finishHandshake()1141 finishHandshake()
M internal/usecase/daemon/navigation_inventory.go +7 -123
@@ -6,7 +6,6 @@ import (
66 "github.com/bnema/vev/internal/domain"
77 "github.com/bnema/vev/internal/ports"
88 "github.com/bnema/vev/internal/protocol"
9- "github.com/bnema/vev/internal/protocol/catalogue"
109 )
1110
1211 // navigationInventoryEntryKey derives a stable opaque key for an unchanged
@@ -41,31 +40,15 @@ func (d *Daemon) answerNavigationInventory(request protocol.NavigationInventoryR
4140 return d.snapshotNavigationInventory(request.RequestID)
4241 }
4342
44-// snapshotNavigationInventory packs complete source groups, local first, then
45-// deterministic host order. A source exceeding its row limit becomes an
46-// explicit too_large row with no activatable entries; it never invalidates
47-// other complete groups. Encoded-byte overflow degrades the largest group to
48-// status-only instead of silently truncating.
43+// snapshotNavigationInventory answers with this daemon's own sessions only:
44+// other daemons are observed by the client's broker, never relayed here.
4945 func (d *Daemon) snapshotNavigationInventory(requestID uint64) protocol.NavigationInventoryResponse {
50- inv := d.captureSessionInventory(viewOptions{}, false)
51- groups := make([]protocol.NavigationInventorySourceGroup, 0, len(inv.hosts)+1)
52- groups = append(groups, d.localInventoryGroup(inv.localSessionInventory))
53- for _, host := range inv.hosts {
54- groups = append(groups, remoteInventoryGroup(host))
55- }
56- if len(groups) > protocol.NavigationInventoryMaxSourceGroups {
57- groups = groups[:protocol.NavigationInventoryMaxSourceGroups]
58- }
59- response := protocol.NavigationInventoryResponse{RequestID: requestID, Operation: protocol.NavigationInventorySnapshot, Status: protocol.NavigationInventoryOK, Groups: groups}
60- return fitNavigationInventoryResponse(response)
46+ return d.localNavigationInventorySnapshot(requestID)
6147 }
6248
6349 // localNavigationInventorySnapshot is the prepared local-only navigation
6450 // projection (Plan 001 P4.3): exactly one local source group, built from the
65-// remote-free capture. It never reads the remote directory, so the source
66-// control daemon's own export cannot observe or depend on remote monitoring.
67-// The hybrid snapshotNavigationInventory composes this group with foreign
68-// source groups through the same localInventoryGroup projection.
51+// daemon's own capture.
6952 func (d *Daemon) localNavigationInventorySnapshot(requestID uint64) protocol.NavigationInventoryResponse {
7053 inv := d.captureLocalSessionInventory(viewOptions{}, false)
7154 response := protocol.NavigationInventoryResponse{
@@ -107,66 +90,6 @@ func (d *Daemon) localInventoryGroup(inv localSessionInventory) protocol.Navigat
10790 return group
10891 }
10992
110-// remoteInventoryGroup projects one configured remote source with its own
111-// status. Broken sessions keep a diagnostic row; stale, unreachable,
112-// auth-failed, and malformed observations stay attemptable with a
113-// presentation reason, per the shared activation policy.
114-func remoteInventoryGroup(host ports.RemoteHostSnapshot) protocol.NavigationInventorySourceGroup {
115- if host.Endpoint == "" {
116- return protocol.NavigationInventorySourceGroup{Status: protocol.NavigationInventorySourceUnavailable}
117- }
118- // Remote source keys are opaque identifiers, never raw endpoints: the
119- // relay forwards them to the serving daemon, which only ever sees
120- // display origins. The endpoint hash keeps keys stable across polls
121- // (so refreshes do not churn admissions) while a host literally named
122- // "local" cannot collide with the reserved local source. Resolve maps
123- // back through the request registration, never the key.
124- group := protocol.NavigationInventorySourceGroup{SourceKey: remoteInventorySourceKey(host.Endpoint), Status: protocol.NavigationInventorySourceOK}
125- eligible := 0
126- for _, session := range host.Sessions {
127- if session.Ephemeral {
128- continue
129- }
130- eligible++
131- }
132- if eligible > protocol.NavigationInventoryMaxRemoteEntriesPerSource {
133- return protocol.NavigationInventorySourceGroup{SourceKey: group.SourceKey, Status: protocol.NavigationInventorySourceTooLarge}
134- }
135- for _, session := range host.Sessions {
136- if session.Ephemeral {
137- continue
138- }
139- key, target := remoteCatalogSessionTarget(domain.RemoteSessionKey{Host: host.Endpoint, Name: session.Name}, session)
140- if key.Validate() != nil {
141- continue
142- }
143- reason := directorySessionReason(host, session, target)
144- state := string(session.State)
145- if session.State == catalogue.RemoteCatalogSessionBroken || target.Validate() != nil {
146- state = string(catalogue.RemoteCatalogSessionBroken)
147- }
148- group.Entries = append(group.Entries, protocol.NavigationInventoryEntry{
149- SourceKey: group.SourceKey,
150- EntryKey: navigationInventoryEntryKey(session.LifecycleID, session.Name),
151- Name: session.Name,
152- DisplayOrigin: key.DisplayOrigin,
153- State: state,
154- Reason: reason,
155- })
156- }
157- if host.Availability != domain.RemoteAvailabilityReachable && len(group.Entries) == 0 {
158- group.Status = protocol.NavigationInventorySourceUnavailable
159- }
160- return group
161-}
162-
163-// remoteInventorySourceKey derives the stable opaque identifier for a
164-// configured remote endpoint. The hash domain-separates inventory keys so
165-// relayed identifiers disclose nothing about local SSH configuration.
166-func remoteInventorySourceKey(endpoint string) string {
167- return protocol.RemoteInventorySourceKey(endpoint)
168-}
169-
17093 // navigationInventoryEncodedBudget mirrors the wire export budget without
17194 // importing the codec layer. The daemon never encodes frames itself; the
17295 // estimate below upper-bounds the real payload so fit degrades early rather
@@ -236,22 +159,9 @@ func (d *Daemon) resolveNavigationInventory(request protocol.NavigationInventory
236159 response.Resolved = &resolved
237160 return response
238161 }
239- target, ok := d.resolveRemoteInventoryEntry(request)
240- if !ok {
241- response.Status = protocol.NavigationInventoryUnavailable
242- return response
243- }
244- if target.Validate() != nil {
245- response.Status = protocol.NavigationInventoryInvalid
246- return response
247- }
248- response.Status = protocol.NavigationInventoryOK
249- resolved := protocol.AttachTarget{
250- Endpoint: request.Registration.Endpoint, Session: target.SessionName,
251- Intent: protocol.IntentAttach, SessionTarget: ptrSessionAttachTarget(protocol.SessionAttachTargetFromRemote(target)),
252- EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
253- }
254- response.Resolved = &resolved
162+ // Remote sources are the client broker's; this daemon resolves only its
163+ // own sessions.
164+ response.Status = protocol.NavigationInventoryUnavailable
255165 return response
256166 }
257167
@@ -291,29 +201,3 @@ func localNavigationResolveEntry(inv localSessionInventory, entryKey string) (pr
291201 }
292202 return protocol.ExactSessionTarget{}, false
293203 }
294-
295-// resolveRemoteInventoryEntry checks the stored registration identity, then
296-// matches the opaque key against the source's current sessions. Known valid
297-// cached targets with stale or failed observations stay attemptable;
298-// destination attach still validates exactly.
299-func (d *Daemon) resolveRemoteInventoryEntry(request protocol.NavigationInventoryRequest) (domain.RemoteSessionTarget, bool) {
300- snapshot := d.remoteDirectorySnapshot()
301- host, ok := snapshot.Find(request.Registration.Endpoint)
302- if !ok || !host.Registration.Equal(request.Registration) {
303- return domain.RemoteSessionTarget{}, false
304- }
305- for _, session := range host.Sessions {
306- if session.Ephemeral {
307- continue
308- }
309- if navigationInventoryEntryKey(session.LifecycleID, session.Name) != request.EntryKey {
310- continue
311- }
312- _, target := remoteCatalogSessionTarget(domain.RemoteSessionKey{Host: host.Endpoint, Name: session.Name}, session)
313- if session.State == catalogue.RemoteCatalogSessionBroken {
314- return domain.RemoteSessionTarget{}, false
315- }
316- return target, true
317- }
318- return domain.RemoteSessionTarget{}, false
319-}
@@ -6,7 +6,6 @@ import (
6 "github.com/bnema/vev/internal/domain"6 "github.com/bnema/vev/internal/domain"
7 "github.com/bnema/vev/internal/ports"7 "github.com/bnema/vev/internal/ports"
8 "github.com/bnema/vev/internal/protocol"8 "github.com/bnema/vev/internal/protocol"
9- "github.com/bnema/vev/internal/protocol/catalogue"
10 )9 )
11 10
12 // navigationInventoryEntryKey derives a stable opaque key for an unchanged11 // navigationInventoryEntryKey derives a stable opaque key for an unchanged
@@ -41,31 +40,15 @@ func (d *Daemon) answerNavigationInventory(request protocol.NavigationInventoryR
41 return d.snapshotNavigationInventory(request.RequestID)40 return d.snapshotNavigationInventory(request.RequestID)
42 }41 }
43 42
44-// snapshotNavigationInventory packs complete source groups, local first, then43+// snapshotNavigationInventory answers with this daemon's own sessions only:
45-// deterministic host order. A source exceeding its row limit becomes an44+// other daemons are observed by the client's broker, never relayed here.
46-// explicit too_large row with no activatable entries; it never invalidates
47-// other complete groups. Encoded-byte overflow degrades the largest group to
48-// status-only instead of silently truncating.
49 func (d *Daemon) snapshotNavigationInventory(requestID uint64) protocol.NavigationInventoryResponse {45 func (d *Daemon) snapshotNavigationInventory(requestID uint64) protocol.NavigationInventoryResponse {
50- inv := d.captureSessionInventory(viewOptions{}, false)46+ return d.localNavigationInventorySnapshot(requestID)
51- groups := make([]protocol.NavigationInventorySourceGroup, 0, len(inv.hosts)+1)
52- groups = append(groups, d.localInventoryGroup(inv.localSessionInventory))
53- for _, host := range inv.hosts {
54- groups = append(groups, remoteInventoryGroup(host))
55- }
56- if len(groups) > protocol.NavigationInventoryMaxSourceGroups {
57- groups = groups[:protocol.NavigationInventoryMaxSourceGroups]
58- }
59- response := protocol.NavigationInventoryResponse{RequestID: requestID, Operation: protocol.NavigationInventorySnapshot, Status: protocol.NavigationInventoryOK, Groups: groups}
60- return fitNavigationInventoryResponse(response)
61 }47 }
62 48
63 // localNavigationInventorySnapshot is the prepared local-only navigation49 // localNavigationInventorySnapshot is the prepared local-only navigation
64 // projection (Plan 001 P4.3): exactly one local source group, built from the50 // projection (Plan 001 P4.3): exactly one local source group, built from the
65-// remote-free capture. It never reads the remote directory, so the source51+// daemon's own capture.
66-// control daemon's own export cannot observe or depend on remote monitoring.
67-// The hybrid snapshotNavigationInventory composes this group with foreign
68-// source groups through the same localInventoryGroup projection.
69 func (d *Daemon) localNavigationInventorySnapshot(requestID uint64) protocol.NavigationInventoryResponse {52 func (d *Daemon) localNavigationInventorySnapshot(requestID uint64) protocol.NavigationInventoryResponse {
70 inv := d.captureLocalSessionInventory(viewOptions{}, false)53 inv := d.captureLocalSessionInventory(viewOptions{}, false)
71 response := protocol.NavigationInventoryResponse{54 response := protocol.NavigationInventoryResponse{
@@ -107,66 +90,6 @@ func (d *Daemon) localInventoryGroup(inv localSessionInventory) protocol.Navigat
107 return group90 return group
108 }91 }
109 92
110-// remoteInventoryGroup projects one configured remote source with its own
111-// status. Broken sessions keep a diagnostic row; stale, unreachable,
112-// auth-failed, and malformed observations stay attemptable with a
113-// presentation reason, per the shared activation policy.
114-func remoteInventoryGroup(host ports.RemoteHostSnapshot) protocol.NavigationInventorySourceGroup {
115- if host.Endpoint == "" {
116- return protocol.NavigationInventorySourceGroup{Status: protocol.NavigationInventorySourceUnavailable}
117- }
118- // Remote source keys are opaque identifiers, never raw endpoints: the
119- // relay forwards them to the serving daemon, which only ever sees
120- // display origins. The endpoint hash keeps keys stable across polls
121- // (so refreshes do not churn admissions) while a host literally named
122- // "local" cannot collide with the reserved local source. Resolve maps
123- // back through the request registration, never the key.
124- group := protocol.NavigationInventorySourceGroup{SourceKey: remoteInventorySourceKey(host.Endpoint), Status: protocol.NavigationInventorySourceOK}
125- eligible := 0
126- for _, session := range host.Sessions {
127- if session.Ephemeral {
128- continue
129- }
130- eligible++
131- }
132- if eligible > protocol.NavigationInventoryMaxRemoteEntriesPerSource {
133- return protocol.NavigationInventorySourceGroup{SourceKey: group.SourceKey, Status: protocol.NavigationInventorySourceTooLarge}
134- }
135- for _, session := range host.Sessions {
136- if session.Ephemeral {
137- continue
138- }
139- key, target := remoteCatalogSessionTarget(domain.RemoteSessionKey{Host: host.Endpoint, Name: session.Name}, session)
140- if key.Validate() != nil {
141- continue
142- }
143- reason := directorySessionReason(host, session, target)
144- state := string(session.State)
145- if session.State == catalogue.RemoteCatalogSessionBroken || target.Validate() != nil {
146- state = string(catalogue.RemoteCatalogSessionBroken)
147- }
148- group.Entries = append(group.Entries, protocol.NavigationInventoryEntry{
149- SourceKey: group.SourceKey,
150- EntryKey: navigationInventoryEntryKey(session.LifecycleID, session.Name),
151- Name: session.Name,
152- DisplayOrigin: key.DisplayOrigin,
153- State: state,
154- Reason: reason,
155- })
156- }
157- if host.Availability != domain.RemoteAvailabilityReachable && len(group.Entries) == 0 {
158- group.Status = protocol.NavigationInventorySourceUnavailable
159- }
160- return group
161-}
162-
163-// remoteInventorySourceKey derives the stable opaque identifier for a
164-// configured remote endpoint. The hash domain-separates inventory keys so
165-// relayed identifiers disclose nothing about local SSH configuration.
166-func remoteInventorySourceKey(endpoint string) string {
167- return protocol.RemoteInventorySourceKey(endpoint)
168-}
169-
170 // navigationInventoryEncodedBudget mirrors the wire export budget without93 // navigationInventoryEncodedBudget mirrors the wire export budget without
171 // importing the codec layer. The daemon never encodes frames itself; the94 // importing the codec layer. The daemon never encodes frames itself; the
172 // estimate below upper-bounds the real payload so fit degrades early rather95 // estimate below upper-bounds the real payload so fit degrades early rather
@@ -236,22 +159,9 @@ func (d *Daemon) resolveNavigationInventory(request protocol.NavigationInventory
236 response.Resolved = &resolved159 response.Resolved = &resolved
237 return response160 return response
238 }161 }
239- target, ok := d.resolveRemoteInventoryEntry(request)162+ // Remote sources are the client broker's; this daemon resolves only its
240- if !ok {163+ // own sessions.
241- response.Status = protocol.NavigationInventoryUnavailable164+ response.Status = protocol.NavigationInventoryUnavailable
242- return response
243- }
244- if target.Validate() != nil {
245- response.Status = protocol.NavigationInventoryInvalid
246- return response
247- }
248- response.Status = protocol.NavigationInventoryOK
249- resolved := protocol.AttachTarget{
250- Endpoint: request.Registration.Endpoint, Session: target.SessionName,
251- Intent: protocol.IntentAttach, SessionTarget: ptrSessionAttachTarget(protocol.SessionAttachTargetFromRemote(target)),
252- EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
253- }
254- response.Resolved = &resolved
255 return response165 return response
256 }166 }
257 167
@@ -291,29 +201,3 @@ func localNavigationResolveEntry(inv localSessionInventory, entryKey string) (pr
291 }201 }
292 return protocol.ExactSessionTarget{}, false202 return protocol.ExactSessionTarget{}, false
293 }203 }
294-
295-// resolveRemoteInventoryEntry checks the stored registration identity, then
296-// matches the opaque key against the source's current sessions. Known valid
297-// cached targets with stale or failed observations stay attemptable;
298-// destination attach still validates exactly.
299-func (d *Daemon) resolveRemoteInventoryEntry(request protocol.NavigationInventoryRequest) (domain.RemoteSessionTarget, bool) {
300- snapshot := d.remoteDirectorySnapshot()
301- host, ok := snapshot.Find(request.Registration.Endpoint)
302- if !ok || !host.Registration.Equal(request.Registration) {
303- return domain.RemoteSessionTarget{}, false
304- }
305- for _, session := range host.Sessions {
306- if session.Ephemeral {
307- continue
308- }
309- if navigationInventoryEntryKey(session.LifecycleID, session.Name) != request.EntryKey {
310- continue
311- }
312- _, target := remoteCatalogSessionTarget(domain.RemoteSessionKey{Host: host.Endpoint, Name: session.Name}, session)
313- if session.State == catalogue.RemoteCatalogSessionBroken {
314- return domain.RemoteSessionTarget{}, false
315- }
316- return target, true
317- }
318- return domain.RemoteSessionTarget{}, false
319-}
M internal/usecase/daemon/palette.go +53 -156
@@ -7,7 +7,6 @@ import (
77
88 renderer "github.com/bnema/vev-vt"
99 "github.com/bnema/vev/internal/domain"
10- "github.com/bnema/vev/internal/ports"
1110 "github.com/bnema/vev/internal/protocol"
1211 "github.com/bnema/vev/internal/usecase/command"
1312 "github.com/bnema/vev/internal/usecase/layout"
@@ -116,51 +115,57 @@ func routeEntryForRef(snapshot protocol.RecentRouteSnapshot, ref protocol.RouteR
116115 return protocol.RecentRouteEntry{}, false
117116 }
118117
119-func createSessionDestinationResults(snapshot protocol.RecentRouteSnapshot, currentLifecycle domain.SessionLifecycleID, hosts []ports.RemoteHostSnapshot) []palette.Result {
120- results := make([]palette.Result, 0, len(hosts)+2)
121- servingOrigin := ""
122- servingEndpoint := ""
118+// createSessionDestinationResults lists where CNS may create a session: the
119+// serving daemon itself, and every other daemon the client published as a
120+// route host (Plan 003 E3). Only the serving daemon creates in place; every
121+// other destination is created by the client through its broker.
122+func createSessionDestinationResults(snapshot protocol.RecentRouteSnapshot, currentLifecycle domain.SessionLifecycleID) []palette.Result {
123+ results := make([]palette.Result, 0, len(snapshot.Hosts)+1)
123124 if home, ok := routeEntryForRef(snapshot, snapshot.Home); ok && home.Kind == protocol.RouteKindLocal {
124125 results = append(results, palette.NewCreateSessionDestination(
125126 palette.CreateSessionOnLocalRoute, "", "", snapshot.Home, snapshot.Generation,
126127 ))
127128 }
128129 if active, ok := activeRouteEntryForLifecycle(snapshot, currentLifecycle); ok && active.Kind == protocol.RouteKindRemote {
129- servingOrigin = paletteRemoteDisplayOrigin(active.HostLabel)
130- servingEndpoint = servingRouteEndpoint(hosts, active.Target.LifecycleID)
131130 results = append(results, palette.NewCreateSessionDestination(
132- palette.CreateSessionOnServingDaemon, servingOrigin, "", protocol.RouteRef{}, 0,
131+ palette.CreateSessionOnServingDaemon, paletteRemoteDisplayOrigin(active.HostLabel), "", protocol.RouteRef{}, 0,
133132 ))
134133 }
135- for _, host := range hosts {
136- if host.Endpoint == "" {
137- continue
138- }
139- displayOrigin := paletteRemoteDisplayOrigin(host.Endpoint)
140- if servingEndpoint != "" && host.Endpoint == servingEndpoint {
134+ for _, host := range snapshot.Hosts {
135+ ref := protocol.RouteRef{Key: host.Key, Generation: host.Generation}
136+ if host.Kind == protocol.RouteKindLocal {
137+ results = append(results, palette.NewCreateSessionDestination(
138+ palette.CreateSessionOnLocalRoute, "", "", ref, snapshot.Generation,
139+ ))
141140 continue
142141 }
143142 results = append(results, palette.NewCreateSessionDestination(
144- palette.CreateSessionOnRemoteHost, displayOrigin, host.Endpoint, protocol.RouteRef{}, 0,
143+ palette.CreateSessionOnRemoteHost, paletteRemoteDisplayOrigin(host.Label), "", ref, snapshot.Generation,
145144 ))
146145 }
147146 return results
148147 }
149148
150-// servingRouteEndpoint resolves the exact endpoint serving the active
151-// remote route by matching its session lifecycle against the directory
152-// inventory. Display origins collide across login prefixes (vev@arch vs
153-// arch), so only an exact lifecycle match suppresses the duplicate
154-// per-host destination.
155-func servingRouteEndpoint(hosts []ports.RemoteHostSnapshot, lifecycle domain.SessionLifecycleID) string {
156- for _, host := range hosts {
157- for _, session := range host.Sessions {
158- if session.LifecycleID == lifecycle {
159- return host.Endpoint
160- }
149+// routeHostForRef finds one creation host of the snapshot.
150+func routeHostForRef(snapshot protocol.RecentRouteSnapshot, ref protocol.RouteRef) (protocol.RouteHost, bool) {
151+ for _, host := range snapshot.Hosts {
152+ if host.Key == ref.Key && host.Generation == ref.Generation && !ref.IsZero() {
153+ return host, true
161154 }
162155 }
163- return ""
156+ return protocol.RouteHost{}, false
157+}
158+
159+// routeCreationKind reports the kind of daemon one creation reference names:
160+// a route entry (or the active route) or a route host.
161+func routeCreationKind(snapshot protocol.RecentRouteSnapshot, ref protocol.RouteRef) (protocol.RouteKind, bool) {
162+ if entry, ok := routeEntryForRef(snapshot, ref); ok {
163+ return entry.Kind, true
164+ }
165+ if host, ok := routeHostForRef(snapshot, ref); ok {
166+ return host.Kind, true
167+ }
168+ return 0, false
164169 }
165170
166171 func paletteRouteRepresentsDaemonSession(entry protocol.RecentRouteEntry, daemonDisplayOrigin string) bool {
@@ -171,13 +176,13 @@ func paletteRouteRepresentsDaemonSession(entry protocol.RecentRouteEntry, daemon
171176 paletteRemoteDisplayOrigin(entry.HostLabel) == daemonDisplayOrigin
172177 }
173178
174-// paletteResults projects the shared daemon inventory for the palette. It keeps
175-// quick-switch exclusions, compact labels, commands, and history behavior;
176-// discovery, exact-target construction, and attemptability facts stay shared
177-// with the picker through remoteCatalogSessionTarget/directorySessionReason.
179+// paletteResults projects the daemon's own inventory plus the client's route
180+// snapshot for the palette. It keeps quick-switch exclusions, compact labels,
181+// commands, and history behavior; other daemons' sessions and hosts come only
182+// from the client routes (Plan 003 E3), and committing one navigates through
183+// the client.
178184 func (d *Daemon) paletteResults(current *session, commands []command.Command, routeSnapshot protocol.RecentRouteSnapshot) []palette.Result {
179185 inv := d.captureSessionInventory(viewOptions{}, false)
180- hosts := inv.hosts
181186 stopped := inv.resumableStopped()
182187 represented := make(map[paletteSessionIdentity]struct{}, len(inv.live)+len(inv.stopped)+len(routeSnapshot.Entries))
183188 for _, entry := range inv.stopped {
@@ -190,7 +195,7 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro
190195 current.mu.Unlock()
191196 }
192197 daemonDisplayOrigin := paletteDaemonDisplayOrigin(routeSnapshot, currentLifecycle)
193- hybrid := len(hosts) > 0 || daemonDisplayOrigin != ""
198+ hybrid := len(routeSnapshot.Hosts) > 0 || daemonDisplayOrigin != ""
194199 for _, entry := range routeSnapshot.Entries {
195200 hybrid = hybrid || entry.Kind == protocol.RouteKindRemote
196201 }
@@ -198,13 +203,8 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro
198203 daemonDisplayOrigin = "local"
199204 }
200205
201- remoteSessionCount := 0
202- for _, host := range hosts {
203- remoteSessionCount += len(host.Sessions)
204- }
205-
206- destinations := createSessionDestinationResults(routeSnapshot, currentLifecycle, hosts)
207- results := make([]palette.Result, 0, len(commands)+len(destinations)+len(inv.live)+len(stopped)+remoteSessionCount+len(routeSnapshot.Entries))
206+ destinations := createSessionDestinationResults(routeSnapshot, currentLifecycle)
207+ results := make([]palette.Result, 0, len(commands)+len(destinations)+len(inv.live)+len(stopped)+len(routeSnapshot.Entries))
208208 for _, cmd := range commands {
209209 results = append(results, palette.NewCommandResult(cmd))
210210 }
@@ -234,34 +234,6 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro
234234 results = append(results, palette.NewStoppedSessionResultWithDisplayOrigin(target, time.Unix(0, candidate.createdAt), daemonDisplayOrigin))
235235 represented[localPaletteSessionIdentity(target.LifecycleID)] = struct{}{}
236236 }
237- type discoveredRemote struct {
238- identity paletteSessionIdentity
239- result palette.Result
240- }
241- discovered := make([]discoveredRemote, 0, remoteSessionCount)
242- discoveredByPresentation := make(map[remotePalettePresentationIdentity][]paletteSessionIdentity, remoteSessionCount)
243- for _, host := range hosts {
244- for _, session := range host.Sessions {
245- if session.Ephemeral {
246- continue
247- }
248- key, target := remoteCatalogSessionTarget(domain.RemoteSessionKey{
249- Host: host.Endpoint, Name: session.Name,
250- }, session)
251- if key.Validate() != nil || target.Validate() != nil {
252- continue
253- }
254- identity := remotePaletteSessionIdentity(key.Host, session.LifecycleID)
255- presentation := remotePalettePresentationIdentity{
256- origin: key.DisplayOrigin, name: session.Name, lifecycle: session.LifecycleID,
257- }
258- discovered = append(discovered, discoveredRemote{
259- identity: identity,
260- result: palette.NewRemoteSessionResult(key, target, directorySessionReason(host, session, target)),
261- })
262- discoveredByPresentation[presentation] = append(discoveredByPresentation[presentation], identity)
263- }
264- }
265237 for _, entry := range routeSnapshot.Entries {
266238 if entry.Name == "" {
267239 continue
@@ -285,24 +257,8 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro
285257 }))
286258 if entry.Kind == protocol.RouteKindLocal {
287259 represented[identity] = struct{}{}
288- continue
289- }
290- presentation := remotePalettePresentationIdentity{
291- origin: domain.RemoteDisplayOrigin(entry.HostLabel),
292- name: entry.Target.SessionName,
293- lifecycle: entry.Target.LifecycleID,
294- }
295- if matches := discoveredByPresentation[presentation]; len(matches) == 1 {
296- represented[matches[0]] = struct{}{}
297260 }
298261 }
299- for _, remote := range discovered {
300- if _, exists := represented[remote.identity]; exists {
301- continue
302- }
303- results = append(results, remote.result)
304- represented[remote.identity] = struct{}{}
305- }
306262 return results
307263 }
308264
@@ -407,10 +363,6 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
407363 var cmd command.Command
408364 var sessionTarget palette.Result
409365 var hasSessionTarget bool
410- var remoteTarget domain.RemoteSessionTarget
411- var remoteKey domain.RemoteSessionKey
412- var remoteUnavailableReason string
413- var hasRemoteTarget bool
414366 var importedSourceKey, importedEntryKey string
415367 var hasImportedTarget bool
416368 var routeTarget protocol.RouteNavigationAction
@@ -496,16 +448,6 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
496448 routeSnapshot = ac.overlays.paletteRouteSnapshot
497449 routeSnapshot.Entries = append([]protocol.RecentRouteEntry(nil), routeSnapshot.Entries...)
498450 }
499- } else if target, ok := selected.RemoteSessionTarget(); ok {
500- key, keyOK := selected.RemoteSessionKey()
501- if !keyOK {
502- changed = true
503- return
504- }
505- remoteTarget = target
506- remoteKey = key
507- remoteUnavailableReason, _ = selected.RemoteSessionUnavailableReason()
508- hasRemoteTarget = true
509451 } else if sourceKey, entryKey, ok := selected.ImportedSessionKey(); ok {
510452 importedSourceKey = sourceKey
511453 importedEntryKey = entryKey
@@ -596,29 +538,6 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
596538 return
597539 }
598540
599- if hasRemoteTarget {
600- if effect == nil {
601- ac.paletteFailure(generation, rawQuery, "requested remote session is unavailable")
602- d.invalidateRender(entry, ac, true, "palette.go")
603- return
604- }
605- target := picker.Target{
606- Session: remoteKey.ID(), Incarnation: remoteKey.LifecycleID, Name: remoteKey.Display(),
607- RemoteKey: &remoteKey, RemoteTarget: &remoteTarget, RemoteHost: remoteKey.Host,
608- UnavailableReason: remoteUnavailableReason, TabIndex: -1,
609- }
610- err := d.switchToTargetForAttachment(effect, target, sessionHandoffGuard{}, "palette-remote-session")
611- if err == nil {
612- d.closeExecutedPalette(ac, effect, generation, rawQuery)
613- return
614- }
615- if !errors.Is(err, errAttachmentTransition) {
616- ac.paletteFailure(generation, rawQuery, "requested remote session is unavailable")
617- d.invalidateRender(entry, ac, true, "palette.go")
618- }
619- return
620- }
621-
622541 if hasImportedTarget {
623542 if effect == nil {
624543 ac.paletteFailure(generation, rawQuery, "requested imported session is unavailable")
@@ -900,14 +819,16 @@ func (e paletteExec) CreateSessionNamed(name string) error {
900819 }
901820
902821 func (e paletteExec) validateCreateSessionDestination(effect *attachmentEffect, result palette.Result) error {
903- _, kind, _, endpoint, route, ok := result.CreateSessionDestination()
822+ _, kind, _, _, route, ok := result.CreateSessionDestination()
904823 if !ok {
905824 return errCreateDestinationUnavailable
906825 }
907826 switch kind {
908827 case palette.CreateSessionOnServingDaemon:
909828 return nil
910- case palette.CreateSessionOnLocalRoute:
829+ case palette.CreateSessionOnLocalRoute, palette.CreateSessionOnRemoteHost:
830+ // Another daemon is a client route: its reference must still name a
831+ // daemon of the kind shown in the latest snapshot.
911832 if e.ac == nil {
912833 return errCreateDestinationUnavailable
913834 }
@@ -915,20 +836,19 @@ func (e paletteExec) validateCreateSessionDestination(effect *attachmentEffect,
915836 if !ok || snapshotGeneration == 0 {
916837 return errCreateDestinationUnavailable
917838 }
839+ want := protocol.RouteKindLocal
840+ if kind == palette.CreateSessionOnRemoteHost {
841+ want = protocol.RouteKindRemote
842+ }
918843 snapshot := e.ac.routeSnapshotCopy()
919- entry, valid := routeEntryForRef(snapshot, route)
920- if !valid || snapshot.Generation != snapshotGeneration || entry.Kind != protocol.RouteKindLocal {
844+ got, valid := routeCreationKind(snapshot, route)
845+ if !valid || snapshot.Generation != snapshotGeneration || got != want {
921846 return errCreateDestinationUnavailable
922847 }
923848 if effect == nil && snapshot.Active != route {
924849 return errCreateDestinationUnavailable
925850 }
926851 return nil
927- case palette.CreateSessionOnRemoteHost:
928- if effect == nil || !e.d.remoteCreateHostReady(endpoint) {
929- return errCreateDestinationUnavailable
930- }
931- return nil
932852 default:
933853 return errCreateDestinationUnavailable
934854 }
@@ -945,7 +865,7 @@ func (e paletteExec) createSessionOnValidatedDestination(effect *attachmentEffec
945865 if err := domain.ValidateSessionName(name); err != nil {
946866 return err
947867 }
948- _, kind, _, endpoint, route, _ := result.CreateSessionDestination()
868+ _, kind, _, _, route, _ := result.CreateSessionDestination()
949869 if kind == palette.CreateSessionOnServingDaemon ||
950870 (kind == palette.CreateSessionOnLocalRoute && e.ac != nil && e.ac.routeSnapshotCopy().Active == route) {
951871 if effect == nil {
@@ -961,7 +881,8 @@ func (e paletteExec) createSessionOnValidatedDestination(effect *attachmentEffec
961881 requestID = e.d.creationRequestSeq.Add(1)
962882 }
963883 switch kind {
964- case palette.CreateSessionOnLocalRoute:
884+ case palette.CreateSessionOnLocalRoute, palette.CreateSessionOnRemoteHost:
885+ // The client creates it through its broker and swaps to it.
965886 snapshotGeneration, _ := result.CreateSessionSnapshotGeneration()
966887 action := protocol.RouteCreateSessionAction{
967888 RequestID: requestID, SnapshotGeneration: snapshotGeneration,
@@ -971,35 +892,11 @@ func (e paletteExec) createSessionOnValidatedDestination(effect *attachmentEffec
971892 return err
972893 }
973894 return nil
974- case palette.CreateSessionOnRemoteHost:
975- handoff := protocol.AttachTarget{
976- RequestID: requestID, Endpoint: endpoint, Session: name, Intent: protocol.IntentNew,
977- EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
978- }
979- if err := protocol.ValidateAttachTarget(handoff); err != nil {
980- return errAttachmentTransition
981- }
982- if err := effect.sendControl(handoff); err != nil {
983- return err
984- }
985- e.d.clientGoneForAttachment(effect, false)
986- return nil
987895 default:
988896 return errAttachmentTransition
989897 }
990898 }
991899
992-func (d *Daemon) remoteCreateHostReady(endpoint string) bool {
993- if d == nil || domain.ValidateRemoteHostTarget(endpoint) != nil {
994- return false
995- }
996- // Registration is creation authority: any registered endpoint is
997- // attemptable at any inventory age. Freshness renders; the destination
998- // rejects precisely on exact identity.
999- _, ok := d.remoteDirectorySnapshot().Find(endpoint)
1000- return ok
1001-}
1002-
1003900 func (e paletteExec) CreateEphemeralSession() error {
1004901 if e.effect == nil {
1005902 return e.d.createEphemeralSessionAndSwitch(e.sess, e.ac)
@@ -7,7 +7,6 @@ import (
7 7
8 renderer "github.com/bnema/vev-vt"8 renderer "github.com/bnema/vev-vt"
9 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
10- "github.com/bnema/vev/internal/ports"
11 "github.com/bnema/vev/internal/protocol"10 "github.com/bnema/vev/internal/protocol"
12 "github.com/bnema/vev/internal/usecase/command"11 "github.com/bnema/vev/internal/usecase/command"
13 "github.com/bnema/vev/internal/usecase/layout"12 "github.com/bnema/vev/internal/usecase/layout"
@@ -116,51 +115,57 @@ func routeEntryForRef(snapshot protocol.RecentRouteSnapshot, ref protocol.RouteR
116 return protocol.RecentRouteEntry{}, false115 return protocol.RecentRouteEntry{}, false
117 }116 }
118 117
119-func createSessionDestinationResults(snapshot protocol.RecentRouteSnapshot, currentLifecycle domain.SessionLifecycleID, hosts []ports.RemoteHostSnapshot) []palette.Result {118+// createSessionDestinationResults lists where CNS may create a session: the
120- results := make([]palette.Result, 0, len(hosts)+2)119+// serving daemon itself, and every other daemon the client published as a
121- servingOrigin := ""120+// route host (Plan 003 E3). Only the serving daemon creates in place; every
122- servingEndpoint := ""121+// other destination is created by the client through its broker.
122+func createSessionDestinationResults(snapshot protocol.RecentRouteSnapshot, currentLifecycle domain.SessionLifecycleID) []palette.Result {
123+ results := make([]palette.Result, 0, len(snapshot.Hosts)+1)
123 if home, ok := routeEntryForRef(snapshot, snapshot.Home); ok && home.Kind == protocol.RouteKindLocal {124 if home, ok := routeEntryForRef(snapshot, snapshot.Home); ok && home.Kind == protocol.RouteKindLocal {
124 results = append(results, palette.NewCreateSessionDestination(125 results = append(results, palette.NewCreateSessionDestination(
125 palette.CreateSessionOnLocalRoute, "", "", snapshot.Home, snapshot.Generation,126 palette.CreateSessionOnLocalRoute, "", "", snapshot.Home, snapshot.Generation,
126 ))127 ))
127 }128 }
128 if active, ok := activeRouteEntryForLifecycle(snapshot, currentLifecycle); ok && active.Kind == protocol.RouteKindRemote {129 if active, ok := activeRouteEntryForLifecycle(snapshot, currentLifecycle); ok && active.Kind == protocol.RouteKindRemote {
129- servingOrigin = paletteRemoteDisplayOrigin(active.HostLabel)
130- servingEndpoint = servingRouteEndpoint(hosts, active.Target.LifecycleID)
131 results = append(results, palette.NewCreateSessionDestination(130 results = append(results, palette.NewCreateSessionDestination(
132- palette.CreateSessionOnServingDaemon, servingOrigin, "", protocol.RouteRef{}, 0,131+ palette.CreateSessionOnServingDaemon, paletteRemoteDisplayOrigin(active.HostLabel), "", protocol.RouteRef{}, 0,
133 ))132 ))
134 }133 }
135- for _, host := range hosts {134+ for _, host := range snapshot.Hosts {
136- if host.Endpoint == "" {135+ ref := protocol.RouteRef{Key: host.Key, Generation: host.Generation}
137- continue136+ if host.Kind == protocol.RouteKindLocal {
138- }137+ results = append(results, palette.NewCreateSessionDestination(
139- displayOrigin := paletteRemoteDisplayOrigin(host.Endpoint)138+ palette.CreateSessionOnLocalRoute, "", "", ref, snapshot.Generation,
140- if servingEndpoint != "" && host.Endpoint == servingEndpoint {139+ ))
141 continue140 continue
142 }141 }
143 results = append(results, palette.NewCreateSessionDestination(142 results = append(results, palette.NewCreateSessionDestination(
144- palette.CreateSessionOnRemoteHost, displayOrigin, host.Endpoint, protocol.RouteRef{}, 0,143+ palette.CreateSessionOnRemoteHost, paletteRemoteDisplayOrigin(host.Label), "", ref, snapshot.Generation,
145 ))144 ))
146 }145 }
147 return results146 return results
148 }147 }
149 148
150-// servingRouteEndpoint resolves the exact endpoint serving the active149+// routeHostForRef finds one creation host of the snapshot.
151-// remote route by matching its session lifecycle against the directory150+func routeHostForRef(snapshot protocol.RecentRouteSnapshot, ref protocol.RouteRef) (protocol.RouteHost, bool) {
152-// inventory. Display origins collide across login prefixes (vev@arch vs151+ for _, host := range snapshot.Hosts {
153-// arch), so only an exact lifecycle match suppresses the duplicate152+ if host.Key == ref.Key && host.Generation == ref.Generation && !ref.IsZero() {
154-// per-host destination.153+ return host, true
155-func servingRouteEndpoint(hosts []ports.RemoteHostSnapshot, lifecycle domain.SessionLifecycleID) string {
156- for _, host := range hosts {
157- for _, session := range host.Sessions {
158- if session.LifecycleID == lifecycle {
159- return host.Endpoint
160- }
161 }154 }
162 }155 }
163- return ""156+ return protocol.RouteHost{}, false
157+}
158+
159+// routeCreationKind reports the kind of daemon one creation reference names:
160+// a route entry (or the active route) or a route host.
161+func routeCreationKind(snapshot protocol.RecentRouteSnapshot, ref protocol.RouteRef) (protocol.RouteKind, bool) {
162+ if entry, ok := routeEntryForRef(snapshot, ref); ok {
163+ return entry.Kind, true
164+ }
165+ if host, ok := routeHostForRef(snapshot, ref); ok {
166+ return host.Kind, true
167+ }
168+ return 0, false
164 }169 }
165 170
166 func paletteRouteRepresentsDaemonSession(entry protocol.RecentRouteEntry, daemonDisplayOrigin string) bool {171 func paletteRouteRepresentsDaemonSession(entry protocol.RecentRouteEntry, daemonDisplayOrigin string) bool {
@@ -171,13 +176,13 @@ func paletteRouteRepresentsDaemonSession(entry protocol.RecentRouteEntry, daemon
171 paletteRemoteDisplayOrigin(entry.HostLabel) == daemonDisplayOrigin176 paletteRemoteDisplayOrigin(entry.HostLabel) == daemonDisplayOrigin
172 }177 }
173 178
174-// paletteResults projects the shared daemon inventory for the palette. It keeps179+// paletteResults projects the daemon's own inventory plus the client's route
175-// quick-switch exclusions, compact labels, commands, and history behavior;180+// snapshot for the palette. It keeps quick-switch exclusions, compact labels,
176-// discovery, exact-target construction, and attemptability facts stay shared181+// commands, and history behavior; other daemons' sessions and hosts come only
177-// with the picker through remoteCatalogSessionTarget/directorySessionReason.182+// from the client routes (Plan 003 E3), and committing one navigates through
183+// the client.
178 func (d *Daemon) paletteResults(current *session, commands []command.Command, routeSnapshot protocol.RecentRouteSnapshot) []palette.Result {184 func (d *Daemon) paletteResults(current *session, commands []command.Command, routeSnapshot protocol.RecentRouteSnapshot) []palette.Result {
179 inv := d.captureSessionInventory(viewOptions{}, false)185 inv := d.captureSessionInventory(viewOptions{}, false)
180- hosts := inv.hosts
181 stopped := inv.resumableStopped()186 stopped := inv.resumableStopped()
182 represented := make(map[paletteSessionIdentity]struct{}, len(inv.live)+len(inv.stopped)+len(routeSnapshot.Entries))187 represented := make(map[paletteSessionIdentity]struct{}, len(inv.live)+len(inv.stopped)+len(routeSnapshot.Entries))
183 for _, entry := range inv.stopped {188 for _, entry := range inv.stopped {
@@ -190,7 +195,7 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro
190 current.mu.Unlock()195 current.mu.Unlock()
191 }196 }
192 daemonDisplayOrigin := paletteDaemonDisplayOrigin(routeSnapshot, currentLifecycle)197 daemonDisplayOrigin := paletteDaemonDisplayOrigin(routeSnapshot, currentLifecycle)
193- hybrid := len(hosts) > 0 || daemonDisplayOrigin != ""198+ hybrid := len(routeSnapshot.Hosts) > 0 || daemonDisplayOrigin != ""
194 for _, entry := range routeSnapshot.Entries {199 for _, entry := range routeSnapshot.Entries {
195 hybrid = hybrid || entry.Kind == protocol.RouteKindRemote200 hybrid = hybrid || entry.Kind == protocol.RouteKindRemote
196 }201 }
@@ -198,13 +203,8 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro
198 daemonDisplayOrigin = "local"203 daemonDisplayOrigin = "local"
199 }204 }
200 205
201- remoteSessionCount := 0206+ destinations := createSessionDestinationResults(routeSnapshot, currentLifecycle)
202- for _, host := range hosts {207+ results := make([]palette.Result, 0, len(commands)+len(destinations)+len(inv.live)+len(stopped)+len(routeSnapshot.Entries))
203- remoteSessionCount += len(host.Sessions)
204- }
205-
206- destinations := createSessionDestinationResults(routeSnapshot, currentLifecycle, hosts)
207- results := make([]palette.Result, 0, len(commands)+len(destinations)+len(inv.live)+len(stopped)+remoteSessionCount+len(routeSnapshot.Entries))
208 for _, cmd := range commands {208 for _, cmd := range commands {
209 results = append(results, palette.NewCommandResult(cmd))209 results = append(results, palette.NewCommandResult(cmd))
210 }210 }
@@ -234,34 +234,6 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro
234 results = append(results, palette.NewStoppedSessionResultWithDisplayOrigin(target, time.Unix(0, candidate.createdAt), daemonDisplayOrigin))234 results = append(results, palette.NewStoppedSessionResultWithDisplayOrigin(target, time.Unix(0, candidate.createdAt), daemonDisplayOrigin))
235 represented[localPaletteSessionIdentity(target.LifecycleID)] = struct{}{}235 represented[localPaletteSessionIdentity(target.LifecycleID)] = struct{}{}
236 }236 }
237- type discoveredRemote struct {
238- identity paletteSessionIdentity
239- result palette.Result
240- }
241- discovered := make([]discoveredRemote, 0, remoteSessionCount)
242- discoveredByPresentation := make(map[remotePalettePresentationIdentity][]paletteSessionIdentity, remoteSessionCount)
243- for _, host := range hosts {
244- for _, session := range host.Sessions {
245- if session.Ephemeral {
246- continue
247- }
248- key, target := remoteCatalogSessionTarget(domain.RemoteSessionKey{
249- Host: host.Endpoint, Name: session.Name,
250- }, session)
251- if key.Validate() != nil || target.Validate() != nil {
252- continue
253- }
254- identity := remotePaletteSessionIdentity(key.Host, session.LifecycleID)
255- presentation := remotePalettePresentationIdentity{
256- origin: key.DisplayOrigin, name: session.Name, lifecycle: session.LifecycleID,
257- }
258- discovered = append(discovered, discoveredRemote{
259- identity: identity,
260- result: palette.NewRemoteSessionResult(key, target, directorySessionReason(host, session, target)),
261- })
262- discoveredByPresentation[presentation] = append(discoveredByPresentation[presentation], identity)
263- }
264- }
265 for _, entry := range routeSnapshot.Entries {237 for _, entry := range routeSnapshot.Entries {
266 if entry.Name == "" {238 if entry.Name == "" {
267 continue239 continue
@@ -285,24 +257,8 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro
285 }))257 }))
286 if entry.Kind == protocol.RouteKindLocal {258 if entry.Kind == protocol.RouteKindLocal {
287 represented[identity] = struct{}{}259 represented[identity] = struct{}{}
288- continue
289- }
290- presentation := remotePalettePresentationIdentity{
291- origin: domain.RemoteDisplayOrigin(entry.HostLabel),
292- name: entry.Target.SessionName,
293- lifecycle: entry.Target.LifecycleID,
294- }
295- if matches := discoveredByPresentation[presentation]; len(matches) == 1 {
296- represented[matches[0]] = struct{}{}
297 }260 }
298 }261 }
299- for _, remote := range discovered {
300- if _, exists := represented[remote.identity]; exists {
301- continue
302- }
303- results = append(results, remote.result)
304- represented[remote.identity] = struct{}{}
305- }
306 return results262 return results
307 }263 }
308 264
@@ -407,10 +363,6 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
407 var cmd command.Command363 var cmd command.Command
408 var sessionTarget palette.Result364 var sessionTarget palette.Result
409 var hasSessionTarget bool365 var hasSessionTarget bool
410- var remoteTarget domain.RemoteSessionTarget
411- var remoteKey domain.RemoteSessionKey
412- var remoteUnavailableReason string
413- var hasRemoteTarget bool
414 var importedSourceKey, importedEntryKey string366 var importedSourceKey, importedEntryKey string
415 var hasImportedTarget bool367 var hasImportedTarget bool
416 var routeTarget protocol.RouteNavigationAction368 var routeTarget protocol.RouteNavigationAction
@@ -496,16 +448,6 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
496 routeSnapshot = ac.overlays.paletteRouteSnapshot448 routeSnapshot = ac.overlays.paletteRouteSnapshot
497 routeSnapshot.Entries = append([]protocol.RecentRouteEntry(nil), routeSnapshot.Entries...)449 routeSnapshot.Entries = append([]protocol.RecentRouteEntry(nil), routeSnapshot.Entries...)
498 }450 }
499- } else if target, ok := selected.RemoteSessionTarget(); ok {
500- key, keyOK := selected.RemoteSessionKey()
501- if !keyOK {
502- changed = true
503- return
504- }
505- remoteTarget = target
506- remoteKey = key
507- remoteUnavailableReason, _ = selected.RemoteSessionUnavailableReason()
508- hasRemoteTarget = true
509 } else if sourceKey, entryKey, ok := selected.ImportedSessionKey(); ok {451 } else if sourceKey, entryKey, ok := selected.ImportedSessionKey(); ok {
510 importedSourceKey = sourceKey452 importedSourceKey = sourceKey
511 importedEntryKey = entryKey453 importedEntryKey = entryKey
@@ -596,29 +538,6 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
596 return538 return
597 }539 }
598 540
599- if hasRemoteTarget {
600- if effect == nil {
601- ac.paletteFailure(generation, rawQuery, "requested remote session is unavailable")
602- d.invalidateRender(entry, ac, true, "palette.go")
603- return
604- }
605- target := picker.Target{
606- Session: remoteKey.ID(), Incarnation: remoteKey.LifecycleID, Name: remoteKey.Display(),
607- RemoteKey: &remoteKey, RemoteTarget: &remoteTarget, RemoteHost: remoteKey.Host,
608- UnavailableReason: remoteUnavailableReason, TabIndex: -1,
609- }
610- err := d.switchToTargetForAttachment(effect, target, sessionHandoffGuard{}, "palette-remote-session")
611- if err == nil {
612- d.closeExecutedPalette(ac, effect, generation, rawQuery)
613- return
614- }
615- if !errors.Is(err, errAttachmentTransition) {
616- ac.paletteFailure(generation, rawQuery, "requested remote session is unavailable")
617- d.invalidateRender(entry, ac, true, "palette.go")
618- }
619- return
620- }
621-
622 if hasImportedTarget {541 if hasImportedTarget {
623 if effect == nil {542 if effect == nil {
624 ac.paletteFailure(generation, rawQuery, "requested imported session is unavailable")543 ac.paletteFailure(generation, rawQuery, "requested imported session is unavailable")
@@ -900,14 +819,16 @@ func (e paletteExec) CreateSessionNamed(name string) error {
900 }819 }
901 820
902 func (e paletteExec) validateCreateSessionDestination(effect *attachmentEffect, result palette.Result) error {821 func (e paletteExec) validateCreateSessionDestination(effect *attachmentEffect, result palette.Result) error {
903- _, kind, _, endpoint, route, ok := result.CreateSessionDestination()822+ _, kind, _, _, route, ok := result.CreateSessionDestination()
904 if !ok {823 if !ok {
905 return errCreateDestinationUnavailable824 return errCreateDestinationUnavailable
906 }825 }
907 switch kind {826 switch kind {
908 case palette.CreateSessionOnServingDaemon:827 case palette.CreateSessionOnServingDaemon:
909 return nil828 return nil
910- case palette.CreateSessionOnLocalRoute:829+ case palette.CreateSessionOnLocalRoute, palette.CreateSessionOnRemoteHost:
830+ // Another daemon is a client route: its reference must still name a
831+ // daemon of the kind shown in the latest snapshot.
911 if e.ac == nil {832 if e.ac == nil {
912 return errCreateDestinationUnavailable833 return errCreateDestinationUnavailable
913 }834 }
@@ -915,20 +836,19 @@ func (e paletteExec) validateCreateSessionDestination(effect *attachmentEffect,
915 if !ok || snapshotGeneration == 0 {836 if !ok || snapshotGeneration == 0 {
916 return errCreateDestinationUnavailable837 return errCreateDestinationUnavailable
917 }838 }
839+ want := protocol.RouteKindLocal
840+ if kind == palette.CreateSessionOnRemoteHost {
841+ want = protocol.RouteKindRemote
842+ }
918 snapshot := e.ac.routeSnapshotCopy()843 snapshot := e.ac.routeSnapshotCopy()
919- entry, valid := routeEntryForRef(snapshot, route)844+ got, valid := routeCreationKind(snapshot, route)
920- if !valid || snapshot.Generation != snapshotGeneration || entry.Kind != protocol.RouteKindLocal {845+ if !valid || snapshot.Generation != snapshotGeneration || got != want {
921 return errCreateDestinationUnavailable846 return errCreateDestinationUnavailable
922 }847 }
923 if effect == nil && snapshot.Active != route {848 if effect == nil && snapshot.Active != route {
924 return errCreateDestinationUnavailable849 return errCreateDestinationUnavailable
925 }850 }
926 return nil851 return nil
927- case palette.CreateSessionOnRemoteHost:
928- if effect == nil || !e.d.remoteCreateHostReady(endpoint) {
929- return errCreateDestinationUnavailable
930- }
931- return nil
932 default:852 default:
933 return errCreateDestinationUnavailable853 return errCreateDestinationUnavailable
934 }854 }
@@ -945,7 +865,7 @@ func (e paletteExec) createSessionOnValidatedDestination(effect *attachmentEffec
945 if err := domain.ValidateSessionName(name); err != nil {865 if err := domain.ValidateSessionName(name); err != nil {
946 return err866 return err
947 }867 }
948- _, kind, _, endpoint, route, _ := result.CreateSessionDestination()868+ _, kind, _, _, route, _ := result.CreateSessionDestination()
949 if kind == palette.CreateSessionOnServingDaemon ||869 if kind == palette.CreateSessionOnServingDaemon ||
950 (kind == palette.CreateSessionOnLocalRoute && e.ac != nil && e.ac.routeSnapshotCopy().Active == route) {870 (kind == palette.CreateSessionOnLocalRoute && e.ac != nil && e.ac.routeSnapshotCopy().Active == route) {
951 if effect == nil {871 if effect == nil {
@@ -961,7 +881,8 @@ func (e paletteExec) createSessionOnValidatedDestination(effect *attachmentEffec
961 requestID = e.d.creationRequestSeq.Add(1)881 requestID = e.d.creationRequestSeq.Add(1)
962 }882 }
963 switch kind {883 switch kind {
964- case palette.CreateSessionOnLocalRoute:884+ case palette.CreateSessionOnLocalRoute, palette.CreateSessionOnRemoteHost:
885+ // The client creates it through its broker and swaps to it.
965 snapshotGeneration, _ := result.CreateSessionSnapshotGeneration()886 snapshotGeneration, _ := result.CreateSessionSnapshotGeneration()
966 action := protocol.RouteCreateSessionAction{887 action := protocol.RouteCreateSessionAction{
967 RequestID: requestID, SnapshotGeneration: snapshotGeneration,888 RequestID: requestID, SnapshotGeneration: snapshotGeneration,
@@ -971,35 +892,11 @@ func (e paletteExec) createSessionOnValidatedDestination(effect *attachmentEffec
971 return err892 return err
972 }893 }
973 return nil894 return nil
974- case palette.CreateSessionOnRemoteHost:
975- handoff := protocol.AttachTarget{
976- RequestID: requestID, Endpoint: endpoint, Session: name, Intent: protocol.IntentNew,
977- EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
978- }
979- if err := protocol.ValidateAttachTarget(handoff); err != nil {
980- return errAttachmentTransition
981- }
982- if err := effect.sendControl(handoff); err != nil {
983- return err
984- }
985- e.d.clientGoneForAttachment(effect, false)
986- return nil
987 default:895 default:
988 return errAttachmentTransition896 return errAttachmentTransition
989 }897 }
990 }898 }
991 899
992-func (d *Daemon) remoteCreateHostReady(endpoint string) bool {
993- if d == nil || domain.ValidateRemoteHostTarget(endpoint) != nil {
994- return false
995- }
996- // Registration is creation authority: any registered endpoint is
997- // attemptable at any inventory age. Freshness renders; the destination
998- // rejects precisely on exact identity.
999- _, ok := d.remoteDirectorySnapshot().Find(endpoint)
1000- return ok
1001-}
1002-
1003 func (e paletteExec) CreateEphemeralSession() error {900 func (e paletteExec) CreateEphemeralSession() error {
1004 if e.effect == nil {901 if e.effect == nil {
1005 return e.d.createEphemeralSessionAndSwitch(e.sess, e.ac)902 return e.d.createEphemeralSessionAndSwitch(e.sess, e.ac)
M internal/usecase/daemon/palette_test.go +68 -98
@@ -10,10 +10,8 @@ import (
1010
1111 renderer "github.com/bnema/vev-vt"
1212 "github.com/bnema/vev/internal/domain"
13- "github.com/bnema/vev/internal/ports"
1413 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1514 "github.com/bnema/vev/internal/protocol"
16- "github.com/bnema/vev/internal/protocol/catalogue"
1715 "github.com/bnema/vev/internal/usecase/command"
1816 "github.com/bnema/vev/internal/usecase/palette"
1917 "github.com/bnema/vev/internal/usecase/picker"
@@ -46,115 +44,87 @@ func beginRecentRoutePaletteEffect(t *testing.T, d *Daemon, sess *session, ac *a
4644 return effect
4745 }
4846
49-func TestCreateSessionDestinationResultsUseStructuredRouteAuthority(t *testing.T) {
47+// TestCreateSessionDestinationResultsUseClientRouteHosts pins E3: every
48+// create destination besides the serving daemon is a client route host,
49+// carried by reference only (no endpoint), and the serving daemon creates in
50+// place.
51+func TestCreateSessionDestinationResultsUseClientRouteHosts(t *testing.T) {
5052 lifecycle := domain.SessionLifecycleID{1}
51- snapshot := protocol.RecentRouteSnapshot{
52- Generation: 4,
53- Active: protocol.RouteRef{Key: 2, Generation: 3},
54- ActiveEntry: protocol.RecentRouteEntry{
55- Key: 2, Generation: 3, Kind: protocol.RouteKindRemote, HostLabel: "host-a",
56- Target: protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "work"}, Name: "work",
53+ remoteActive := protocol.RecentRouteEntry{
54+ Key: 2, Generation: 3, Kind: protocol.RouteKindRemote, HostLabel: "host-a",
55+ Target: protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "work"}, Name: "work",
56+ }
57+ localActive := remoteActive
58+ localActive.Kind, localActive.HostLabel = protocol.RouteKindLocal, ""
59+ type want struct {
60+ kind palette.CreateSessionDestinationKind
61+ origin string
62+ route protocol.RouteRef
63+ }
64+ tests := []struct {
65+ name string
66+ snapshot protocol.RecentRouteSnapshot
67+ want []want
68+ }{
69+ {
70+ name: "remote attachment creates in place, locally, or on another host",
71+ snapshot: protocol.RecentRouteSnapshot{
72+ Generation: 4, Active: protocol.RouteRef{Key: 2, Generation: 3}, ActiveEntry: remoteActive,
73+ Hosts: []protocol.RouteHost{
74+ {Key: 5, Generation: 1, Label: "local", Kind: protocol.RouteKindLocal},
75+ {Key: 6, Generation: 1, Label: "ops@arch", Kind: protocol.RouteKindRemote},
76+ },
77+ },
78+ want: []want{
79+ {kind: palette.CreateSessionOnServingDaemon, origin: "host-a"},
80+ {kind: palette.CreateSessionOnLocalRoute, route: protocol.RouteRef{Key: 5, Generation: 1}},
81+ {kind: palette.CreateSessionOnRemoteHost, origin: "arch", route: protocol.RouteRef{Key: 6, Generation: 1}},
82+ },
83+ },
84+ {
85+ name: "local attachment is home",
86+ snapshot: protocol.RecentRouteSnapshot{
87+ Generation: 4, Active: protocol.RouteRef{Key: 2, Generation: 3}, Home: protocol.RouteRef{Key: 2, Generation: 3}, ActiveEntry: localActive,
88+ Hosts: []protocol.RouteHost{{Key: 6, Generation: 1, Label: "host-b", Kind: protocol.RouteKindRemote}},
89+ },
90+ want: []want{
91+ {kind: palette.CreateSessionOnLocalRoute, route: protocol.RouteRef{Key: 2, Generation: 3}},
92+ {kind: palette.CreateSessionOnRemoteHost, origin: "host-b", route: protocol.RouteRef{Key: 6, Generation: 1}},
93+ },
94+ },
95+ {
96+ name: "a remote home is never inferred local",
97+ snapshot: protocol.RecentRouteSnapshot{
98+ Generation: 1, Active: protocol.RouteRef{Key: 2, Generation: 3}, Home: protocol.RouteRef{Key: 2, Generation: 3}, ActiveEntry: remoteActive,
99+ },
100+ want: []want{{kind: palette.CreateSessionOnServingDaemon, origin: "host-a"}},
57101 },
58- Home: protocol.RouteRef{Key: 1, Generation: 2},
59- Entries: []protocol.RecentRouteEntry{{
60- Key: 1, Generation: 2, Kind: protocol.RouteKindLocal,
61- Target: testRouteTarget("home", 2), Name: "home",
62- }},
63102 }
64- hosts := []ports.RemoteHostSnapshot{
65- {Endpoint: "host-a", Sessions: []catalogue.RemoteCatalogSession{{Name: "work", LifecycleID: lifecycle}}},
66- {Endpoint: "host-b"},
67- {Endpoint: "host-c"},
68- {Endpoint: "host-d"},
103+ for _, tt := range tests {
104+ t.Run(tt.name, func(t *testing.T) {
105+ require.NoError(t, tt.snapshot.Validate())
106+ results := createSessionDestinationResults(tt.snapshot, lifecycle)
107+ require.Len(t, results, len(tt.want))
108+ for i, expected := range tt.want {
109+ _, kind, origin, endpoint, route, ok := results[i].CreateSessionDestination()
110+ require.True(t, ok)
111+ require.Equal(t, expected.kind, kind)
112+ require.Equal(t, expected.origin, origin)
113+ require.Equal(t, expected.route, route)
114+ require.Empty(t, endpoint, "a destination never carries an endpoint")
115+ }
116+ })
69117 }
70-
71- results := createSessionDestinationResults(snapshot, lifecycle, hosts)
72- require.Len(t, results, 5)
73- _, kind, _, endpoint, route, ok := results[0].CreateSessionDestination()
74- require.True(t, ok)
75- require.Equal(t, palette.CreateSessionOnLocalRoute, kind)
76- require.Equal(t, snapshot.Home, route)
77- require.Empty(t, endpoint)
78- snapshotGeneration, ok := results[0].CreateSessionSnapshotGeneration()
79- require.True(t, ok)
80- require.Equal(t, snapshot.Generation, snapshotGeneration)
81- _, kind, origin, endpoint, _, ok := results[1].CreateSessionDestination()
82- require.True(t, ok)
83- require.Equal(t, palette.CreateSessionOnServingDaemon, kind)
84- require.Equal(t, "host-a", origin)
85- require.Empty(t, endpoint)
86- _, kind, origin, endpoint, _, ok = results[2].CreateSessionDestination()
87- require.True(t, ok)
88- require.Equal(t, palette.CreateSessionOnRemoteHost, kind)
89- require.Equal(t, "host-b", origin)
90- require.Equal(t, "host-b", endpoint)
91- // Stale inventory stays attemptable: freshness renders, registration
92- // authorizes. The destination rejects precisely on exact identity.
93- _, kind, origin, endpoint, _, ok = results[3].CreateSessionDestination()
94- require.True(t, ok)
95- require.Equal(t, palette.CreateSessionOnRemoteHost, kind)
96- require.Equal(t, "host-c", origin)
97- require.Equal(t, "host-c", endpoint)
98118 }
99119
100120 func TestRemoteCreateSessionDestinationRequiresAttachmentEffect(t *testing.T) {
101121 result := palette.NewCreateSessionDestination(
102- palette.CreateSessionOnRemoteHost, "host-a", "host-a", protocol.RouteRef{}, 0,
122+ palette.CreateSessionOnRemoteHost, "host-a", "", protocol.RouteRef{Key: 1, Generation: 1}, 1,
103123 )
104124 err := (paletteExec{}).validateCreateSessionDestination(nil, result)
105125 require.ErrorIs(t, err, errCreateDestinationUnavailable)
106126 }
107127
108-// TestCreateSessionDestinationsKeepCollidingDisplayOrigins proves the
109-// serving-daemon suppression compares exact endpoint identity, not display
110-// labels: user@arch and ops@arch share the origin "arch", but only the
111-// endpoint serving the active route is suppressed.
112-func TestCreateSessionDestinationsKeepCollidingDisplayOrigins(t *testing.T) {
113- lifecycle := domain.SessionLifecycleID{7}
114- snapshot := protocol.RecentRouteSnapshot{
115- Generation: 1,
116- Active: protocol.RouteRef{Key: 1, Generation: 1},
117- ActiveEntry: protocol.RecentRouteEntry{
118- Key: 1, Generation: 1, Kind: protocol.RouteKindRemote, HostLabel: "arch",
119- Target: protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "work"}, Name: "work",
120- },
121- }
122- hosts := []ports.RemoteHostSnapshot{
123- {Endpoint: "user@arch", Sessions: []catalogue.RemoteCatalogSession{{Name: "work", LifecycleID: lifecycle}}},
124- {Endpoint: "ops@arch"},
125- }
126-
127- results := createSessionDestinationResults(snapshot, lifecycle, hosts)
128- require.Len(t, results, 2)
129- _, kind, origin, endpoint, _, ok := results[0].CreateSessionDestination()
130- require.True(t, ok)
131- require.Equal(t, palette.CreateSessionOnServingDaemon, kind)
132- require.Equal(t, "arch", origin)
133- _, kind, origin, endpoint, _, ok = results[1].CreateSessionDestination()
134- require.True(t, ok)
135- require.Equal(t, palette.CreateSessionOnRemoteHost, kind)
136- require.Equal(t, "arch", origin)
137- require.Equal(t, "ops@arch", endpoint)
138-}
139-
140-func TestCreateSessionDestinationResultsDoNotInferLocalFromRemoteHome(t *testing.T) {
141- lifecycle := domain.SessionLifecycleID{1}
142- snapshot := protocol.RecentRouteSnapshot{
143- Generation: 1,
144- Active: protocol.RouteRef{Key: 1, Generation: 1},
145- Home: protocol.RouteRef{Key: 1, Generation: 1},
146- ActiveEntry: protocol.RecentRouteEntry{
147- Key: 1, Generation: 1, Kind: protocol.RouteKindRemote, HostLabel: "host-a",
148- Target: protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "work"}, Name: "work",
149- },
150- }
151- results := createSessionDestinationResults(snapshot, lifecycle, nil)
152- require.Len(t, results, 1)
153- _, kind, _, _, _, ok := results[0].CreateSessionDestination()
154- require.True(t, ok)
155- require.Equal(t, palette.CreateSessionOnServingDaemon, kind)
156-}
157-
158128 func TestCaptureOverlayLayersPreservesPaletteDescriptionSurfaceAcrossFallbacks(t *testing.T) {
159129 paletteColors := [16]renderer.RGB{}
160130 paletteColors[2] = renderer.RGB{R: 10, G: 230, B: 120}
@@ -10,10 +10,8 @@ import (
10 10
11 renderer "github.com/bnema/vev-vt"11 renderer "github.com/bnema/vev-vt"
12 "github.com/bnema/vev/internal/domain"12 "github.com/bnema/vev/internal/domain"
13- "github.com/bnema/vev/internal/ports"
14 portsmocks "github.com/bnema/vev/internal/ports/mocks"13 portsmocks "github.com/bnema/vev/internal/ports/mocks"
15 "github.com/bnema/vev/internal/protocol"14 "github.com/bnema/vev/internal/protocol"
16- "github.com/bnema/vev/internal/protocol/catalogue"
17 "github.com/bnema/vev/internal/usecase/command"15 "github.com/bnema/vev/internal/usecase/command"
18 "github.com/bnema/vev/internal/usecase/palette"16 "github.com/bnema/vev/internal/usecase/palette"
19 "github.com/bnema/vev/internal/usecase/picker"17 "github.com/bnema/vev/internal/usecase/picker"
@@ -46,115 +44,87 @@ func beginRecentRoutePaletteEffect(t *testing.T, d *Daemon, sess *session, ac *a
46 return effect44 return effect
47 }45 }
48 46
49-func TestCreateSessionDestinationResultsUseStructuredRouteAuthority(t *testing.T) {47+// TestCreateSessionDestinationResultsUseClientRouteHosts pins E3: every
48+// create destination besides the serving daemon is a client route host,
49+// carried by reference only (no endpoint), and the serving daemon creates in
50+// place.
51+func TestCreateSessionDestinationResultsUseClientRouteHosts(t *testing.T) {
50 lifecycle := domain.SessionLifecycleID{1}52 lifecycle := domain.SessionLifecycleID{1}
51- snapshot := protocol.RecentRouteSnapshot{53+ remoteActive := protocol.RecentRouteEntry{
52- Generation: 4,54+ Key: 2, Generation: 3, Kind: protocol.RouteKindRemote, HostLabel: "host-a",
53- Active: protocol.RouteRef{Key: 2, Generation: 3},55+ Target: protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "work"}, Name: "work",
54- ActiveEntry: protocol.RecentRouteEntry{56+ }
55- Key: 2, Generation: 3, Kind: protocol.RouteKindRemote, HostLabel: "host-a",57+ localActive := remoteActive
56- Target: protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "work"}, Name: "work",58+ localActive.Kind, localActive.HostLabel = protocol.RouteKindLocal, ""
59+ type want struct {
60+ kind palette.CreateSessionDestinationKind
61+ origin string
62+ route protocol.RouteRef
63+ }
64+ tests := []struct {
65+ name string
66+ snapshot protocol.RecentRouteSnapshot
67+ want []want
68+ }{
69+ {
70+ name: "remote attachment creates in place, locally, or on another host",
71+ snapshot: protocol.RecentRouteSnapshot{
72+ Generation: 4, Active: protocol.RouteRef{Key: 2, Generation: 3}, ActiveEntry: remoteActive,
73+ Hosts: []protocol.RouteHost{
74+ {Key: 5, Generation: 1, Label: "local", Kind: protocol.RouteKindLocal},
75+ {Key: 6, Generation: 1, Label: "ops@arch", Kind: protocol.RouteKindRemote},
76+ },
77+ },
78+ want: []want{
79+ {kind: palette.CreateSessionOnServingDaemon, origin: "host-a"},
80+ {kind: palette.CreateSessionOnLocalRoute, route: protocol.RouteRef{Key: 5, Generation: 1}},
81+ {kind: palette.CreateSessionOnRemoteHost, origin: "arch", route: protocol.RouteRef{Key: 6, Generation: 1}},
82+ },
83+ },
84+ {
85+ name: "local attachment is home",
86+ snapshot: protocol.RecentRouteSnapshot{
87+ Generation: 4, Active: protocol.RouteRef{Key: 2, Generation: 3}, Home: protocol.RouteRef{Key: 2, Generation: 3}, ActiveEntry: localActive,
88+ Hosts: []protocol.RouteHost{{Key: 6, Generation: 1, Label: "host-b", Kind: protocol.RouteKindRemote}},
89+ },
90+ want: []want{
91+ {kind: palette.CreateSessionOnLocalRoute, route: protocol.RouteRef{Key: 2, Generation: 3}},
92+ {kind: palette.CreateSessionOnRemoteHost, origin: "host-b", route: protocol.RouteRef{Key: 6, Generation: 1}},
93+ },
94+ },
95+ {
96+ name: "a remote home is never inferred local",
97+ snapshot: protocol.RecentRouteSnapshot{
98+ Generation: 1, Active: protocol.RouteRef{Key: 2, Generation: 3}, Home: protocol.RouteRef{Key: 2, Generation: 3}, ActiveEntry: remoteActive,
99+ },
100+ want: []want{{kind: palette.CreateSessionOnServingDaemon, origin: "host-a"}},
57 },101 },
58- Home: protocol.RouteRef{Key: 1, Generation: 2},
59- Entries: []protocol.RecentRouteEntry{{
60- Key: 1, Generation: 2, Kind: protocol.RouteKindLocal,
61- Target: testRouteTarget("home", 2), Name: "home",
62- }},
63 }102 }
64- hosts := []ports.RemoteHostSnapshot{103+ for _, tt := range tests {
65- {Endpoint: "host-a", Sessions: []catalogue.RemoteCatalogSession{{Name: "work", LifecycleID: lifecycle}}},104+ t.Run(tt.name, func(t *testing.T) {
66- {Endpoint: "host-b"},105+ require.NoError(t, tt.snapshot.Validate())
67- {Endpoint: "host-c"},106+ results := createSessionDestinationResults(tt.snapshot, lifecycle)
68- {Endpoint: "host-d"},107+ require.Len(t, results, len(tt.want))
108+ for i, expected := range tt.want {
109+ _, kind, origin, endpoint, route, ok := results[i].CreateSessionDestination()
110+ require.True(t, ok)
111+ require.Equal(t, expected.kind, kind)
112+ require.Equal(t, expected.origin, origin)
113+ require.Equal(t, expected.route, route)
114+ require.Empty(t, endpoint, "a destination never carries an endpoint")
115+ }
116+ })
69 }117 }
70-
71- results := createSessionDestinationResults(snapshot, lifecycle, hosts)
72- require.Len(t, results, 5)
73- _, kind, _, endpoint, route, ok := results[0].CreateSessionDestination()
74- require.True(t, ok)
75- require.Equal(t, palette.CreateSessionOnLocalRoute, kind)
76- require.Equal(t, snapshot.Home, route)
77- require.Empty(t, endpoint)
78- snapshotGeneration, ok := results[0].CreateSessionSnapshotGeneration()
79- require.True(t, ok)
80- require.Equal(t, snapshot.Generation, snapshotGeneration)
81- _, kind, origin, endpoint, _, ok := results[1].CreateSessionDestination()
82- require.True(t, ok)
83- require.Equal(t, palette.CreateSessionOnServingDaemon, kind)
84- require.Equal(t, "host-a", origin)
85- require.Empty(t, endpoint)
86- _, kind, origin, endpoint, _, ok = results[2].CreateSessionDestination()
87- require.True(t, ok)
88- require.Equal(t, palette.CreateSessionOnRemoteHost, kind)
89- require.Equal(t, "host-b", origin)
90- require.Equal(t, "host-b", endpoint)
91- // Stale inventory stays attemptable: freshness renders, registration
92- // authorizes. The destination rejects precisely on exact identity.
93- _, kind, origin, endpoint, _, ok = results[3].CreateSessionDestination()
94- require.True(t, ok)
95- require.Equal(t, palette.CreateSessionOnRemoteHost, kind)
96- require.Equal(t, "host-c", origin)
97- require.Equal(t, "host-c", endpoint)
98 }118 }
99 119
100 func TestRemoteCreateSessionDestinationRequiresAttachmentEffect(t *testing.T) {120 func TestRemoteCreateSessionDestinationRequiresAttachmentEffect(t *testing.T) {
101 result := palette.NewCreateSessionDestination(121 result := palette.NewCreateSessionDestination(
102- palette.CreateSessionOnRemoteHost, "host-a", "host-a", protocol.RouteRef{}, 0,122+ palette.CreateSessionOnRemoteHost, "host-a", "", protocol.RouteRef{Key: 1, Generation: 1}, 1,
103 )123 )
104 err := (paletteExec{}).validateCreateSessionDestination(nil, result)124 err := (paletteExec{}).validateCreateSessionDestination(nil, result)
105 require.ErrorIs(t, err, errCreateDestinationUnavailable)125 require.ErrorIs(t, err, errCreateDestinationUnavailable)
106 }126 }
107 127
108-// TestCreateSessionDestinationsKeepCollidingDisplayOrigins proves the
109-// serving-daemon suppression compares exact endpoint identity, not display
110-// labels: user@arch and ops@arch share the origin "arch", but only the
111-// endpoint serving the active route is suppressed.
112-func TestCreateSessionDestinationsKeepCollidingDisplayOrigins(t *testing.T) {
113- lifecycle := domain.SessionLifecycleID{7}
114- snapshot := protocol.RecentRouteSnapshot{
115- Generation: 1,
116- Active: protocol.RouteRef{Key: 1, Generation: 1},
117- ActiveEntry: protocol.RecentRouteEntry{
118- Key: 1, Generation: 1, Kind: protocol.RouteKindRemote, HostLabel: "arch",
119- Target: protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "work"}, Name: "work",
120- },
121- }
122- hosts := []ports.RemoteHostSnapshot{
123- {Endpoint: "user@arch", Sessions: []catalogue.RemoteCatalogSession{{Name: "work", LifecycleID: lifecycle}}},
124- {Endpoint: "ops@arch"},
125- }
126-
127- results := createSessionDestinationResults(snapshot, lifecycle, hosts)
128- require.Len(t, results, 2)
129- _, kind, origin, endpoint, _, ok := results[0].CreateSessionDestination()
130- require.True(t, ok)
131- require.Equal(t, palette.CreateSessionOnServingDaemon, kind)
132- require.Equal(t, "arch", origin)
133- _, kind, origin, endpoint, _, ok = results[1].CreateSessionDestination()
134- require.True(t, ok)
135- require.Equal(t, palette.CreateSessionOnRemoteHost, kind)
136- require.Equal(t, "arch", origin)
137- require.Equal(t, "ops@arch", endpoint)
138-}
139-
140-func TestCreateSessionDestinationResultsDoNotInferLocalFromRemoteHome(t *testing.T) {
141- lifecycle := domain.SessionLifecycleID{1}
142- snapshot := protocol.RecentRouteSnapshot{
143- Generation: 1,
144- Active: protocol.RouteRef{Key: 1, Generation: 1},
145- Home: protocol.RouteRef{Key: 1, Generation: 1},
146- ActiveEntry: protocol.RecentRouteEntry{
147- Key: 1, Generation: 1, Kind: protocol.RouteKindRemote, HostLabel: "host-a",
148- Target: protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "work"}, Name: "work",
149- },
150- }
151- results := createSessionDestinationResults(snapshot, lifecycle, nil)
152- require.Len(t, results, 1)
153- _, kind, _, _, _, ok := results[0].CreateSessionDestination()
154- require.True(t, ok)
155- require.Equal(t, palette.CreateSessionOnServingDaemon, kind)
156-}
157-
158 func TestCaptureOverlayLayersPreservesPaletteDescriptionSurfaceAcrossFallbacks(t *testing.T) {128 func TestCaptureOverlayLayersPreservesPaletteDescriptionSurfaceAcrossFallbacks(t *testing.T) {
159 paletteColors := [16]renderer.RGB{}129 paletteColors := [16]renderer.RGB{}
160 paletteColors[2] = renderer.RGB{R: 10, G: 230, B: 120}130 paletteColors[2] = renderer.RGB{R: 10, G: 230, B: 120}
D internal/usecase/daemon/remote_directory_view.go +0 -163
@@ -1,163 +0,0 @@
1-package daemon
2-
3-import (
4- "context"
5- "errors"
6- "sort"
7-
8- "github.com/bnema/vev/internal/domain"
9- "github.com/bnema/vev/internal/ports"
10-)
11-
12-// remoteDirectorySnapshot returns the newest directory publication, or an
13-// empty uninitialized snapshot when monitoring is disabled. It performs no
14-// I/O and never waits for the service loop: empty state is valid and
15-// immediately renderable.
16-func (d *Daemon) remoteDirectorySnapshot() ports.RemoteDirectorySnapshot {
17- if d == nil || d.remoteDirectory == nil {
18- return ports.RemoteDirectorySnapshot{Hosts: []ports.RemoteHostSnapshot{}}
19- }
20- return d.remoteDirectory.Snapshot()
21-}
22-
23-// currentRemoteDirectory returns a sorted defensive copy of the newest
24-// publication plus the monitoring state. It performs no I/O and never waits
25-// for the service loop. Centralizing it keeps the hybrid capture and the
26-// foreign picker projection reading exactly the same rows (coordinated P7
27-// removal set).
28-func (d *Daemon) currentRemoteDirectory() (hosts []ports.RemoteHostSnapshot, monitored, initialized bool) {
29- directory := d.remoteDirectorySnapshot()
30- hosts = append([]ports.RemoteHostSnapshot(nil), directory.Hosts...)
31- sortDirectoryHosts(hosts)
32- monitored = d != nil && d.remoteDirectory != nil
33- return hosts, monitored, directory.Initialized
34-}
35-
36-// sortDirectoryHosts orders hosts by registry rank, breaking ties by
37-// endpoint. Snapshot slice order is alphabetical; Rank carries registry
38-// (pinned-then-learned) order for presentation.
39-func sortDirectoryHosts(hosts []ports.RemoteHostSnapshot) {
40- sort.Slice(hosts, func(i, j int) bool {
41- if hosts[i].Rank != hosts[j].Rank {
42- return hosts[i].Rank < hosts[j].Rank
43- }
44- return hosts[i].Endpoint < hosts[j].Endpoint
45- })
46-}
47-
48-// watchRemoteDirectory relays directory publications to open remote views.
49-// It is not an attachment: it claims no geometry, history, or keepalive,
50-// and losing it never affects session lifetime.
51-func (d *Daemon) watchRemoteDirectory(ctx context.Context) {
52- directory := d.remoteDirectory
53- if d == nil || directory == nil {
54- return
55- }
56- sub := directory.Subscribe()
57- defer sub.Close()
58- for {
59- select {
60- case <-ctx.Done():
61- return
62- case <-sub.Changed():
63- d.refreshRemoteDirectoryViews()
64- }
65- }
66-}
67-
68-// refreshRemoteDirectoryViews rebuilds only open remote views from the
69-// newest snapshot, then invalidates through existing attachment generations
70-// after releasing state locks. Overlay entry builds from the latest snapshot
71-// immediately; overlay exit releases view state only.
72-func (d *Daemon) refreshRemoteDirectoryViews() {
73- if d == nil {
74- return
75- }
76- d.notifyNewRemoteFailures(d.remoteDirectorySnapshot())
77- d.mu.Lock()
78- sessions := d.sessionsSnapshotLocked()
79- d.mu.Unlock()
80- for _, sess := range sessions {
81- if sess == nil {
82- continue
83- }
84- for _, ac := range sess.snapshotAttachments() {
85- d.reconcileRouteHistory(ac)
86- d.refreshRemoteDirectoryViewsFor(ac)
87- }
88- }
89-}
90-
91-func (d *Daemon) notifyNewRemoteFailures(snapshot ports.RemoteDirectorySnapshot) {
92- var notices []domain.Notification
93- d.remoteFailureNoticeMu.Lock()
94- if d.remoteFailureNoticed == nil {
95- d.remoteFailureNoticed = make(map[string]uint64)
96- }
97- present := make(map[string]struct{}, len(snapshot.Hosts))
98- for _, host := range snapshot.Hosts {
99- present[host.Endpoint] = struct{}{}
100- if host.LastFailure.Err == nil || host.ConsecutiveFailures == 0 {
101- continue
102- }
103- if d.remoteFailureNoticed[host.Endpoint] == host.FailureEpisode {
104- continue
105- }
106- d.remoteFailureNoticed[host.Endpoint] = host.FailureEpisode
107- notices = append(notices, domain.Notification{
108- Severity: domain.NoticeWarn,
109- Code: domain.NoticeRemoteObservation,
110- Message: remoteFailureNoticeMessage(host),
111- Details: host.LastFailure.Err.Error(),
112- })
113- }
114- for endpoint := range d.remoteFailureNoticed {
115- if _, ok := present[endpoint]; !ok {
116- delete(d.remoteFailureNoticed, endpoint)
117- }
118- }
119- d.remoteFailureNoticeMu.Unlock()
120- for _, notice := range notices {
121- d.notify(nil, notice.Severity, notice.Code, notice.Message, errors.New(notice.Details))
122- }
123-}
124-
125-func remoteFailureNoticeMessage(host ports.RemoteHostSnapshot) string {
126- prefix := "Remote check failed: " + host.Endpoint + " — "
127- switch host.LastFailure.Kind {
128- case domain.RemoteFailureAuthentication:
129- return prefix + "SSH authentication failed; verify non-interactive SSH access"
130- case domain.RemoteFailureTrust:
131- return prefix + "SSH host verification failed; verify the host key policy"
132- case domain.RemoteFailureIncompatible:
133- return prefix + "remote vev version is incompatible"
134- case domain.RemoteFailureInvalidResponse:
135- return prefix + "remote catalog response is invalid"
136- case domain.RemoteFailureTimeout:
137- return prefix + "SSH timed out"
138- default:
139- return prefix + "SSH connection failed; verify SSH access"
140- }
141-}
142-
143-func (d *Daemon) refreshRemoteDirectoryViewsFor(ac *attachedClient) {
144- if ac == nil || ac.overlays == nil {
145- return
146- }
147- pickerOpen := ac.overlays.pickerClientActive()
148- paletteOpen := ac.overlays.paletteActive()
149- if !pickerOpen && !paletteOpen {
150- return
151- }
152- // The picker and the palette both read the directory: republish the
153- // interaction snapshot with a new revision when the row set changed.
154- if pickerOpen {
155- d.refreshPickerSnapshot(ac)
156- }
157- if paletteOpen {
158- d.refreshPalette(ac)
159- }
160- if entry := ac.currentAttachmentSession(); entry != nil {
161- d.invalidateRender(entry, ac, true, "remote_directory_view.go")
162- }
163-}
@@ -1,163 +0,0 @@
1-package daemon
2-
3-import (
4- "context"
5- "errors"
6- "sort"
7-
8- "github.com/bnema/vev/internal/domain"
9- "github.com/bnema/vev/internal/ports"
10-)
11-
12-// remoteDirectorySnapshot returns the newest directory publication, or an
13-// empty uninitialized snapshot when monitoring is disabled. It performs no
14-// I/O and never waits for the service loop: empty state is valid and
15-// immediately renderable.
16-func (d *Daemon) remoteDirectorySnapshot() ports.RemoteDirectorySnapshot {
17- if d == nil || d.remoteDirectory == nil {
18- return ports.RemoteDirectorySnapshot{Hosts: []ports.RemoteHostSnapshot{}}
19- }
20- return d.remoteDirectory.Snapshot()
21-}
22-
23-// currentRemoteDirectory returns a sorted defensive copy of the newest
24-// publication plus the monitoring state. It performs no I/O and never waits
25-// for the service loop. Centralizing it keeps the hybrid capture and the
26-// foreign picker projection reading exactly the same rows (coordinated P7
27-// removal set).
28-func (d *Daemon) currentRemoteDirectory() (hosts []ports.RemoteHostSnapshot, monitored, initialized bool) {
29- directory := d.remoteDirectorySnapshot()
30- hosts = append([]ports.RemoteHostSnapshot(nil), directory.Hosts...)
31- sortDirectoryHosts(hosts)
32- monitored = d != nil && d.remoteDirectory != nil
33- return hosts, monitored, directory.Initialized
34-}
35-
36-// sortDirectoryHosts orders hosts by registry rank, breaking ties by
37-// endpoint. Snapshot slice order is alphabetical; Rank carries registry
38-// (pinned-then-learned) order for presentation.
39-func sortDirectoryHosts(hosts []ports.RemoteHostSnapshot) {
40- sort.Slice(hosts, func(i, j int) bool {
41- if hosts[i].Rank != hosts[j].Rank {
42- return hosts[i].Rank < hosts[j].Rank
43- }
44- return hosts[i].Endpoint < hosts[j].Endpoint
45- })
46-}
47-
48-// watchRemoteDirectory relays directory publications to open remote views.
49-// It is not an attachment: it claims no geometry, history, or keepalive,
50-// and losing it never affects session lifetime.
51-func (d *Daemon) watchRemoteDirectory(ctx context.Context) {
52- directory := d.remoteDirectory
53- if d == nil || directory == nil {
54- return
55- }
56- sub := directory.Subscribe()
57- defer sub.Close()
58- for {
59- select {
60- case <-ctx.Done():
61- return
62- case <-sub.Changed():
63- d.refreshRemoteDirectoryViews()
64- }
65- }
66-}
67-
68-// refreshRemoteDirectoryViews rebuilds only open remote views from the
69-// newest snapshot, then invalidates through existing attachment generations
70-// after releasing state locks. Overlay entry builds from the latest snapshot
71-// immediately; overlay exit releases view state only.
72-func (d *Daemon) refreshRemoteDirectoryViews() {
73- if d == nil {
74- return
75- }
76- d.notifyNewRemoteFailures(d.remoteDirectorySnapshot())
77- d.mu.Lock()
78- sessions := d.sessionsSnapshotLocked()
79- d.mu.Unlock()
80- for _, sess := range sessions {
81- if sess == nil {
82- continue
83- }
84- for _, ac := range sess.snapshotAttachments() {
85- d.reconcileRouteHistory(ac)
86- d.refreshRemoteDirectoryViewsFor(ac)
87- }
88- }
89-}
90-
91-func (d *Daemon) notifyNewRemoteFailures(snapshot ports.RemoteDirectorySnapshot) {
92- var notices []domain.Notification
93- d.remoteFailureNoticeMu.Lock()
94- if d.remoteFailureNoticed == nil {
95- d.remoteFailureNoticed = make(map[string]uint64)
96- }
97- present := make(map[string]struct{}, len(snapshot.Hosts))
98- for _, host := range snapshot.Hosts {
99- present[host.Endpoint] = struct{}{}
100- if host.LastFailure.Err == nil || host.ConsecutiveFailures == 0 {
101- continue
102- }
103- if d.remoteFailureNoticed[host.Endpoint] == host.FailureEpisode {
104- continue
105- }
106- d.remoteFailureNoticed[host.Endpoint] = host.FailureEpisode
107- notices = append(notices, domain.Notification{
108- Severity: domain.NoticeWarn,
109- Code: domain.NoticeRemoteObservation,
110- Message: remoteFailureNoticeMessage(host),
111- Details: host.LastFailure.Err.Error(),
112- })
113- }
114- for endpoint := range d.remoteFailureNoticed {
115- if _, ok := present[endpoint]; !ok {
116- delete(d.remoteFailureNoticed, endpoint)
117- }
118- }
119- d.remoteFailureNoticeMu.Unlock()
120- for _, notice := range notices {
121- d.notify(nil, notice.Severity, notice.Code, notice.Message, errors.New(notice.Details))
122- }
123-}
124-
125-func remoteFailureNoticeMessage(host ports.RemoteHostSnapshot) string {
126- prefix := "Remote check failed: " + host.Endpoint + " — "
127- switch host.LastFailure.Kind {
128- case domain.RemoteFailureAuthentication:
129- return prefix + "SSH authentication failed; verify non-interactive SSH access"
130- case domain.RemoteFailureTrust:
131- return prefix + "SSH host verification failed; verify the host key policy"
132- case domain.RemoteFailureIncompatible:
133- return prefix + "remote vev version is incompatible"
134- case domain.RemoteFailureInvalidResponse:
135- return prefix + "remote catalog response is invalid"
136- case domain.RemoteFailureTimeout:
137- return prefix + "SSH timed out"
138- default:
139- return prefix + "SSH connection failed; verify SSH access"
140- }
141-}
142-
143-func (d *Daemon) refreshRemoteDirectoryViewsFor(ac *attachedClient) {
144- if ac == nil || ac.overlays == nil {
145- return
146- }
147- pickerOpen := ac.overlays.pickerClientActive()
148- paletteOpen := ac.overlays.paletteActive()
149- if !pickerOpen && !paletteOpen {
150- return
151- }
152- // The picker and the palette both read the directory: republish the
153- // interaction snapshot with a new revision when the row set changed.
154- if pickerOpen {
155- d.refreshPickerSnapshot(ac)
156- }
157- if paletteOpen {
158- d.refreshPalette(ac)
159- }
160- if entry := ac.currentAttachmentSession(); entry != nil {
161- d.invalidateRender(entry, ac, true, "remote_directory_view.go")
162- }
163-}
D internal/usecase/daemon/remote_picker.go +0 -280
@@ -1,280 +0,0 @@
1-package daemon
2-
3-import (
4- "encoding/base64"
5- "fmt"
6- "math"
7- "time"
8-
9- "github.com/bnema/vev/internal/domain"
10- "github.com/bnema/vev/internal/ports"
11- "github.com/bnema/vev/internal/protocol/catalogue"
12-)
13-
14-// directoryInventoryStaleAge is the presentation freshness threshold: past
15-// this age inventory renders stale, but staleness never gates activation.
16-const directoryInventoryStaleAge = 30 * time.Second
17-
18-func remoteSessionStateStopped(state catalogue.RemoteCatalogSessionState) bool {
19- return state == catalogue.RemoteCatalogSessionDown
20-}
21-
22-func remoteCatalogSessionTarget(key domain.RemoteSessionKey, session catalogue.RemoteCatalogSession) (domain.RemoteSessionKey, domain.RemoteSessionTarget) {
23- key.LifecycleID = session.LifecycleID
24- key.DisplayOrigin = domain.RemoteDisplayOrigin(key.Host)
25- target := domain.RemoteSessionTarget{
26- Endpoint: key.Host,
27- DisplayOrigin: key.DisplayOrigin,
28- LifecycleID: session.LifecycleID,
29- SessionName: session.Name,
30- Stopped: remoteSessionStateStopped(session.State),
31- }
32- tabs := catalogue.CatalogTabs(session)
33- if len(tabs) == 0 {
34- return key, target
35- }
36- if target.Stopped {
37- first := tabs[0]
38- if first.ID != "" {
39- target.StoppedTab = domain.NewStableTabSelector(domain.TabStableID(first.ID))
40- } else {
41- tabCount := min(len(tabs), math.MaxUint16)
42- target.StoppedTab = domain.NewOrdinalTabSelector(0, first.Name, uint16(tabCount))
43- }
44- return key, target
45- }
46- active := 0
47- for i, tab := range tabs {
48- if session.ActiveTabID != "" && tab.ID == session.ActiveTabID {
49- active = i
50- break
51- }
52- }
53- target.LiveTabID = domain.TabStableID(tabs[active].ID)
54- return key, target
55-}
56-
57-// directoryInventoryStale reports whether the host's last success is past
58-// the presentation freshness threshold. Staleness renders; it never gates.
59-func directoryInventoryStale(host ports.RemoteHostSnapshot, now time.Time) bool {
60- if host.LastSuccess.IsZero() {
61- return host.InventoryKnown
62- }
63- return !now.Before(host.LastSuccess.Add(directoryInventoryStaleAge))
64-}
65-
66-func remotePickerView(key domain.RemoteSessionKey, session catalogue.RemoteCatalogSession, host ports.RemoteHostSnapshot, now time.Time) pickerSessionView {
67- key, target := remoteCatalogSessionTarget(key, session)
68- stopped := remoteSessionStateStopped(session.State)
69- broken := session.State == catalogue.RemoteCatalogSessionBroken
70- tabs := catalogue.CatalogTabs(session)
71- viewTabs := make([]pickerTabEntry, 0, len(tabs))
72- active := 0
73- for i, tab := range tabs {
74- if session.ActiveTabID != "" && tab.ID == session.ActiveTabID {
75- active = i
76- }
77- name := tab.Name
78- if name == "" {
79- name = fmt.Sprintf("%d", int(tab.Index)+1)
80- }
81- viewTabs = append(viewTabs, pickerTabEntry{
82- TabID: domain.TabStableID(tab.ID),
83- Name: name,
84- RawName: tab.Name,
85- Detail: tab.Detail,
86- Attention: tab.Attention,
87- })
88- }
89- // Keep the structured session identity on rows even when one tab ID is
90- // malformed or a broken session cannot be activated. Cursor navigation
91- // must remain possible, while sendRemoteAttachTargetForAttachment will
92- // fail closed on the invalid target.
93- remoteTarget := &target
94- reason := directorySessionReason(host, session, target)
95- targetValid := remoteTarget.Validate() == nil
96- activation := pickerRemoteUnavailable
97- if !broken && targetValid && host.Availability != domain.RemoteAvailabilityIncompatible {
98- // Known inventory is attemptable at any age: freshness is
99- // presentation information, not attach authority. Exact session,
100- // lifecycle and tab validation still applies at submit, and the
101- // destination rejects precisely.
102- if stopped {
103- activation = pickerRemoteRestart
104- } else {
105- activation = pickerRemoteAttach
106- }
107- }
108-
109- return pickerSessionView{
110- ID: key.ID(),
111- // A remote row's incarnation is its session lifecycle. Leaving it zero
112- // would make every live session on one host share one row identity, so
113- // two of them would collide and the whole snapshot would be rejected.
114- Incarnation: session.LifecycleID,
115- Name: key.Display(),
116- RemoteKey: &key,
117- RemoteTarget: remoteTarget,
118- RemoteHost: key.Host,
119- RemoteAvailability: directoryPickerAvailability(host, now),
120- RemoteDetail: directoryPickerDetail(host, session, reason, activation, now),
121- RemoteReason: reason,
122- RemoteActivation: activation,
123- Tabs: viewTabs,
124- Active: active,
125- Stopped: stopped,
126- CannotAcceptMoves: true,
127- }
128-}
129-
130-// directorySessionReason labels a session row for display. It never gates
131-// activation except through broken, invalid, or incompatible states; age and
132-// observation failures render while the row stays attemptable.
133-func directorySessionReason(host ports.RemoteHostSnapshot, session catalogue.RemoteCatalogSession, target domain.RemoteSessionTarget) string {
134- if session.State == catalogue.RemoteCatalogSessionBroken {
135- return domain.RemoteReasonSessionBroken
136- }
137- if target.Validate() != nil {
138- return domain.RemoteReasonIdentityChanged
139- }
140- switch host.Availability {
141- case domain.RemoteAvailabilityReachable:
142- if host.Checking {
143- return domain.RemoteReasonRefreshing
144- }
145- return ""
146- case domain.RemoteAvailabilityUnreachable:
147- return domain.RemoteReasonHostUnreachable
148- case domain.RemoteAvailabilityIncompatible:
149- return domain.RemoteReasonVersionMismatch
150- case domain.RemoteAvailabilityAuthFailed:
151- return domain.RemoteReasonAuthFailure
152- case domain.RemoteAvailabilityInvalidResponse:
153- return domain.RemoteReasonMalformed
154- default:
155- return domain.RemoteReasonRefreshing
156- }
157-}
158-
159-func directoryPickerAvailability(host ports.RemoteHostSnapshot, now time.Time) pickerRemoteAvailability {
160- switch host.Availability {
161- case domain.RemoteAvailabilityReachable:
162- if directoryInventoryStale(host, now) {
163- return pickerRemoteStale
164- }
165- return pickerRemoteFresh
166- case domain.RemoteAvailabilityIncompatible:
167- return pickerRemoteVersionMismatch
168- case domain.RemoteAvailabilityUnknown:
169- return pickerRemoteCached
170- default:
171- if host.Checking {
172- return pickerRemoteCached
173- }
174- return pickerRemoteStale
175- }
176-}
177-
178-func directoryPickerDetail(host ports.RemoteHostSnapshot, session catalogue.RemoteCatalogSession, reason string, activation pickerRemoteActivation, now time.Time) string {
179- if host.Checking {
180- return "checking remote…"
181- }
182- stopped := remoteSessionStateStopped(session.State)
183- broken := session.State == catalogue.RemoteCatalogSessionBroken
184- switch host.Availability {
185- case domain.RemoteAvailabilityReachable:
186- if !host.LastSuccess.IsZero() && directoryInventoryStale(host, now) {
187- return "catalog stale since " + host.LastSuccess.Format(time.RFC3339)
188- }
189- case domain.RemoteAvailabilityUnreachable:
190- if host.InventoryKnown && !host.LastSuccess.IsZero() {
191- return "stale since " + host.LastSuccess.Format(time.RFC3339)
192- }
193- return "unreachable"
194- case domain.RemoteAvailabilityIncompatible:
195- return "version mismatch"
196- case domain.RemoteAvailabilityAuthFailed:
197- return "authentication failed"
198- case domain.RemoteAvailabilityInvalidResponse:
199- return "catalog malformed"
200- default:
201- return "checking remote…"
202- }
203- switch {
204- case broken:
205- return "session broken"
206- case reason == domain.RemoteReasonIdentityChanged:
207- return "identity changed"
208- case activation == pickerRemoteRestart:
209- return "stopped — Enter to restart"
210- case session.State == catalogue.RemoteCatalogSessionUp:
211- return "up"
212- case stopped:
213- return "stopped"
214- default:
215- return string(session.State)
216- }
217-}
218-
219-// remotePickerCheckingView is the non-actionable placeholder published
220-// while the remote directory has never been initialized: it distinguishes
221-// "still checking remotes" from "no remotes configured". Enter on the
222-// row fails safe through the usual unavailable-target path.
223-func remotePickerCheckingView() pickerSessionView {
224- return pickerSessionView{
225- ID: domain.SessionID("remote:checking"),
226- Name: "checking remotes…",
227- RemoteActivation: pickerRemoteUnavailable,
228- CannotAcceptMoves: true,
229- }
230-}
231-
232-func remotePickerHostView(host ports.RemoteHostSnapshot, now time.Time) pickerSessionView {
233- return pickerSessionView{
234- ID: domain.SessionID("remote-host:" + base64.RawURLEncoding.EncodeToString([]byte(host.Endpoint))),
235- Name: host.Endpoint,
236- RemoteHost: host.Endpoint,
237- RemoteReason: directoryHostReason(host),
238- RemoteAvailability: directoryPickerAvailability(host, now),
239- RemoteDetail: directoryHostDetail(host),
240- RemoteActivation: pickerRemoteUnavailable,
241- CannotAcceptMoves: true,
242- }
243-}
244-
245-func directoryHostReason(host ports.RemoteHostSnapshot) string {
246- switch host.Availability {
247- case domain.RemoteAvailabilityReachable:
248- return ""
249- case domain.RemoteAvailabilityUnreachable:
250- return domain.RemoteReasonHostUnreachable
251- case domain.RemoteAvailabilityIncompatible:
252- return domain.RemoteReasonVersionMismatch
253- case domain.RemoteAvailabilityAuthFailed:
254- return domain.RemoteReasonAuthFailure
255- case domain.RemoteAvailabilityInvalidResponse:
256- return domain.RemoteReasonMalformed
257- default:
258- return domain.RemoteReasonRefreshing
259- }
260-}
261-
262-func directoryHostDetail(host ports.RemoteHostSnapshot) string {
263- if host.Checking {
264- return "checking remote…"
265- }
266- switch host.Availability {
267- case domain.RemoteAvailabilityReachable:
268- return "no sessions"
269- case domain.RemoteAvailabilityUnreachable:
270- return "unreachable"
271- case domain.RemoteAvailabilityIncompatible:
272- return "version mismatch"
273- case domain.RemoteAvailabilityAuthFailed:
274- return "authentication failed"
275- case domain.RemoteAvailabilityInvalidResponse:
276- return "catalog malformed"
277- default:
278- return "checking remote…"
279- }
280-}
@@ -1,280 +0,0 @@
1-package daemon
2-
3-import (
4- "encoding/base64"
5- "fmt"
6- "math"
7- "time"
8-
9- "github.com/bnema/vev/internal/domain"
10- "github.com/bnema/vev/internal/ports"
11- "github.com/bnema/vev/internal/protocol/catalogue"
12-)
13-
14-// directoryInventoryStaleAge is the presentation freshness threshold: past
15-// this age inventory renders stale, but staleness never gates activation.
16-const directoryInventoryStaleAge = 30 * time.Second
17-
18-func remoteSessionStateStopped(state catalogue.RemoteCatalogSessionState) bool {
19- return state == catalogue.RemoteCatalogSessionDown
20-}
21-
22-func remoteCatalogSessionTarget(key domain.RemoteSessionKey, session catalogue.RemoteCatalogSession) (domain.RemoteSessionKey, domain.RemoteSessionTarget) {
23- key.LifecycleID = session.LifecycleID
24- key.DisplayOrigin = domain.RemoteDisplayOrigin(key.Host)
25- target := domain.RemoteSessionTarget{
26- Endpoint: key.Host,
27- DisplayOrigin: key.DisplayOrigin,
28- LifecycleID: session.LifecycleID,
29- SessionName: session.Name,
30- Stopped: remoteSessionStateStopped(session.State),
31- }
32- tabs := catalogue.CatalogTabs(session)
33- if len(tabs) == 0 {
34- return key, target
35- }
36- if target.Stopped {
37- first := tabs[0]
38- if first.ID != "" {
39- target.StoppedTab = domain.NewStableTabSelector(domain.TabStableID(first.ID))
40- } else {
41- tabCount := min(len(tabs), math.MaxUint16)
42- target.StoppedTab = domain.NewOrdinalTabSelector(0, first.Name, uint16(tabCount))
43- }
44- return key, target
45- }
46- active := 0
47- for i, tab := range tabs {
48- if session.ActiveTabID != "" && tab.ID == session.ActiveTabID {
49- active = i
50- break
51- }
52- }
53- target.LiveTabID = domain.TabStableID(tabs[active].ID)
54- return key, target
55-}
56-
57-// directoryInventoryStale reports whether the host's last success is past
58-// the presentation freshness threshold. Staleness renders; it never gates.
59-func directoryInventoryStale(host ports.RemoteHostSnapshot, now time.Time) bool {
60- if host.LastSuccess.IsZero() {
61- return host.InventoryKnown
62- }
63- return !now.Before(host.LastSuccess.Add(directoryInventoryStaleAge))
64-}
65-
66-func remotePickerView(key domain.RemoteSessionKey, session catalogue.RemoteCatalogSession, host ports.RemoteHostSnapshot, now time.Time) pickerSessionView {
67- key, target := remoteCatalogSessionTarget(key, session)
68- stopped := remoteSessionStateStopped(session.State)
69- broken := session.State == catalogue.RemoteCatalogSessionBroken
70- tabs := catalogue.CatalogTabs(session)
71- viewTabs := make([]pickerTabEntry, 0, len(tabs))
72- active := 0
73- for i, tab := range tabs {
74- if session.ActiveTabID != "" && tab.ID == session.ActiveTabID {
75- active = i
76- }
77- name := tab.Name
78- if name == "" {
79- name = fmt.Sprintf("%d", int(tab.Index)+1)
80- }
81- viewTabs = append(viewTabs, pickerTabEntry{
82- TabID: domain.TabStableID(tab.ID),
83- Name: name,
84- RawName: tab.Name,
85- Detail: tab.Detail,
86- Attention: tab.Attention,
87- })
88- }
89- // Keep the structured session identity on rows even when one tab ID is
90- // malformed or a broken session cannot be activated. Cursor navigation
91- // must remain possible, while sendRemoteAttachTargetForAttachment will
92- // fail closed on the invalid target.
93- remoteTarget := &target
94- reason := directorySessionReason(host, session, target)
95- targetValid := remoteTarget.Validate() == nil
96- activation := pickerRemoteUnavailable
97- if !broken && targetValid && host.Availability != domain.RemoteAvailabilityIncompatible {
98- // Known inventory is attemptable at any age: freshness is
99- // presentation information, not attach authority. Exact session,
100- // lifecycle and tab validation still applies at submit, and the
101- // destination rejects precisely.
102- if stopped {
103- activation = pickerRemoteRestart
104- } else {
105- activation = pickerRemoteAttach
106- }
107- }
108-
109- return pickerSessionView{
110- ID: key.ID(),
111- // A remote row's incarnation is its session lifecycle. Leaving it zero
112- // would make every live session on one host share one row identity, so
113- // two of them would collide and the whole snapshot would be rejected.
114- Incarnation: session.LifecycleID,
115- Name: key.Display(),
116- RemoteKey: &key,
117- RemoteTarget: remoteTarget,
118- RemoteHost: key.Host,
119- RemoteAvailability: directoryPickerAvailability(host, now),
120- RemoteDetail: directoryPickerDetail(host, session, reason, activation, now),
121- RemoteReason: reason,
122- RemoteActivation: activation,
123- Tabs: viewTabs,
124- Active: active,
125- Stopped: stopped,
126- CannotAcceptMoves: true,
127- }
128-}
129-
130-// directorySessionReason labels a session row for display. It never gates
131-// activation except through broken, invalid, or incompatible states; age and
132-// observation failures render while the row stays attemptable.
133-func directorySessionReason(host ports.RemoteHostSnapshot, session catalogue.RemoteCatalogSession, target domain.RemoteSessionTarget) string {
134- if session.State == catalogue.RemoteCatalogSessionBroken {
135- return domain.RemoteReasonSessionBroken
136- }
137- if target.Validate() != nil {
138- return domain.RemoteReasonIdentityChanged
139- }
140- switch host.Availability {
141- case domain.RemoteAvailabilityReachable:
142- if host.Checking {
143- return domain.RemoteReasonRefreshing
144- }
145- return ""
146- case domain.RemoteAvailabilityUnreachable:
147- return domain.RemoteReasonHostUnreachable
148- case domain.RemoteAvailabilityIncompatible:
149- return domain.RemoteReasonVersionMismatch
150- case domain.RemoteAvailabilityAuthFailed:
151- return domain.RemoteReasonAuthFailure
152- case domain.RemoteAvailabilityInvalidResponse:
153- return domain.RemoteReasonMalformed
154- default:
155- return domain.RemoteReasonRefreshing
156- }
157-}
158-
159-func directoryPickerAvailability(host ports.RemoteHostSnapshot, now time.Time) pickerRemoteAvailability {
160- switch host.Availability {
161- case domain.RemoteAvailabilityReachable:
162- if directoryInventoryStale(host, now) {
163- return pickerRemoteStale
164- }
165- return pickerRemoteFresh
166- case domain.RemoteAvailabilityIncompatible:
167- return pickerRemoteVersionMismatch
168- case domain.RemoteAvailabilityUnknown:
169- return pickerRemoteCached
170- default:
171- if host.Checking {
172- return pickerRemoteCached
173- }
174- return pickerRemoteStale
175- }
176-}
177-
178-func directoryPickerDetail(host ports.RemoteHostSnapshot, session catalogue.RemoteCatalogSession, reason string, activation pickerRemoteActivation, now time.Time) string {
179- if host.Checking {
180- return "checking remote…"
181- }
182- stopped := remoteSessionStateStopped(session.State)
183- broken := session.State == catalogue.RemoteCatalogSessionBroken
184- switch host.Availability {
185- case domain.RemoteAvailabilityReachable:
186- if !host.LastSuccess.IsZero() && directoryInventoryStale(host, now) {
187- return "catalog stale since " + host.LastSuccess.Format(time.RFC3339)
188- }
189- case domain.RemoteAvailabilityUnreachable:
190- if host.InventoryKnown && !host.LastSuccess.IsZero() {
191- return "stale since " + host.LastSuccess.Format(time.RFC3339)
192- }
193- return "unreachable"
194- case domain.RemoteAvailabilityIncompatible:
195- return "version mismatch"
196- case domain.RemoteAvailabilityAuthFailed:
197- return "authentication failed"
198- case domain.RemoteAvailabilityInvalidResponse:
199- return "catalog malformed"
200- default:
201- return "checking remote…"
202- }
203- switch {
204- case broken:
205- return "session broken"
206- case reason == domain.RemoteReasonIdentityChanged:
207- return "identity changed"
208- case activation == pickerRemoteRestart:
209- return "stopped — Enter to restart"
210- case session.State == catalogue.RemoteCatalogSessionUp:
211- return "up"
212- case stopped:
213- return "stopped"
214- default:
215- return string(session.State)
216- }
217-}
218-
219-// remotePickerCheckingView is the non-actionable placeholder published
220-// while the remote directory has never been initialized: it distinguishes
221-// "still checking remotes" from "no remotes configured". Enter on the
222-// row fails safe through the usual unavailable-target path.
223-func remotePickerCheckingView() pickerSessionView {
224- return pickerSessionView{
225- ID: domain.SessionID("remote:checking"),
226- Name: "checking remotes…",
227- RemoteActivation: pickerRemoteUnavailable,
228- CannotAcceptMoves: true,
229- }
230-}
231-
232-func remotePickerHostView(host ports.RemoteHostSnapshot, now time.Time) pickerSessionView {
233- return pickerSessionView{
234- ID: domain.SessionID("remote-host:" + base64.RawURLEncoding.EncodeToString([]byte(host.Endpoint))),
235- Name: host.Endpoint,
236- RemoteHost: host.Endpoint,
237- RemoteReason: directoryHostReason(host),
238- RemoteAvailability: directoryPickerAvailability(host, now),
239- RemoteDetail: directoryHostDetail(host),
240- RemoteActivation: pickerRemoteUnavailable,
241- CannotAcceptMoves: true,
242- }
243-}
244-
245-func directoryHostReason(host ports.RemoteHostSnapshot) string {
246- switch host.Availability {
247- case domain.RemoteAvailabilityReachable:
248- return ""
249- case domain.RemoteAvailabilityUnreachable:
250- return domain.RemoteReasonHostUnreachable
251- case domain.RemoteAvailabilityIncompatible:
252- return domain.RemoteReasonVersionMismatch
253- case domain.RemoteAvailabilityAuthFailed:
254- return domain.RemoteReasonAuthFailure
255- case domain.RemoteAvailabilityInvalidResponse:
256- return domain.RemoteReasonMalformed
257- default:
258- return domain.RemoteReasonRefreshing
259- }
260-}
261-
262-func directoryHostDetail(host ports.RemoteHostSnapshot) string {
263- if host.Checking {
264- return "checking remote…"
265- }
266- switch host.Availability {
267- case domain.RemoteAvailabilityReachable:
268- return "no sessions"
269- case domain.RemoteAvailabilityUnreachable:
270- return "unreachable"
271- case domain.RemoteAvailabilityIncompatible:
272- return "version mismatch"
273- case domain.RemoteAvailabilityAuthFailed:
274- return "authentication failed"
275- case domain.RemoteAvailabilityInvalidResponse:
276- return "catalog malformed"
277- default:
278- return "checking remote…"
279- }
280-}
M internal/usecase/daemon/route_retirement.go +13 -29
@@ -5,41 +5,25 @@ import (
55 "maps"
66 "time"
77
8- "github.com/bnema/vev/internal/domain"
98 "github.com/bnema/vev/internal/protocol"
109 )
1110
12-// retiredRoutes uses only authoritative observations. An unavailable remote
13-// source retains its history; a successful empty catalogue proves absence.
14-func retiredRoutes(subscription protocol.RouteAttentionSubscription, after map[protocol.RouteAttentionTarget]time.Time, inv sessionInventory) []protocol.RouteRetired {
11+// retiredRoutes uses only authoritative observations: this daemon's own
12+// lifecycles. Another daemon's routes are the client's broker to retire.
13+func retiredRoutes(subscription protocol.RouteAttentionSubscription, _ map[protocol.RouteAttentionTarget]time.Time, inv sessionInventory) []protocol.RouteRetired {
1514 var retired []protocol.RouteRetired
1615 for _, target := range subscription.Targets {
17- known, present := target.SourceKey == "", false
18- if known {
19- for _, item := range inv.live {
20- present = present || item.view.incarnation == target.Target.LifecycleID
21- }
22- for _, stopped := range inv.stopped {
23- present = present || stopped.incarnation == target.Target.LifecycleID
24- }
25- } else {
26- for _, host := range inv.hosts {
27- if protocol.RemoteInventorySourceKey(host.Endpoint) != target.SourceKey {
28- continue
29- }
30- // A cached success (or a fetch already running at admission)
31- // may predate creation of this lifecycle. Only a later-started,
32- // completed successful observation can establish absence.
33- admitted, fenced := after[target]
34- known = fenced && host.InventoryKnown && host.Availability == domain.RemoteAvailabilityReachable &&
35- !host.Checking && host.LastAttempt.After(admitted) && !host.LastSuccess.Before(host.LastAttempt)
36- for _, session := range host.Sessions {
37- present = present || session.LifecycleID == target.Target.LifecycleID
38- }
39- break
40- }
16+ if target.SourceKey != "" {
17+ continue
18+ }
19+ present := false
20+ for _, item := range inv.live {
21+ present = present || item.view.incarnation == target.Target.LifecycleID
22+ }
23+ for _, stopped := range inv.stopped {
24+ present = present || stopped.incarnation == target.Target.LifecycleID
4125 }
42- if known && !present {
26+ if !present {
4327 retired = append(retired, protocol.RouteRetired{Ref: target.Ref, Target: target.Target})
4428 }
4529 }
@@ -5,41 +5,25 @@ import (
5 "maps"5 "maps"
6 "time"6 "time"
7 7
8- "github.com/bnema/vev/internal/domain"
9 "github.com/bnema/vev/internal/protocol"8 "github.com/bnema/vev/internal/protocol"
10 )9 )
11 10
12-// retiredRoutes uses only authoritative observations. An unavailable remote11+// retiredRoutes uses only authoritative observations: this daemon's own
13-// source retains its history; a successful empty catalogue proves absence.12+// lifecycles. Another daemon's routes are the client's broker to retire.
14-func retiredRoutes(subscription protocol.RouteAttentionSubscription, after map[protocol.RouteAttentionTarget]time.Time, inv sessionInventory) []protocol.RouteRetired {13+func retiredRoutes(subscription protocol.RouteAttentionSubscription, _ map[protocol.RouteAttentionTarget]time.Time, inv sessionInventory) []protocol.RouteRetired {
15 var retired []protocol.RouteRetired14 var retired []protocol.RouteRetired
16 for _, target := range subscription.Targets {15 for _, target := range subscription.Targets {
17- known, present := target.SourceKey == "", false16+ if target.SourceKey != "" {
18- if known {17+ continue
19- for _, item := range inv.live {18+ }
20- present = present || item.view.incarnation == target.Target.LifecycleID19+ present := false
21- }20+ for _, item := range inv.live {
22- for _, stopped := range inv.stopped {21+ present = present || item.view.incarnation == target.Target.LifecycleID
23- present = present || stopped.incarnation == target.Target.LifecycleID22+ }
24- }23+ for _, stopped := range inv.stopped {
25- } else {24+ present = present || stopped.incarnation == target.Target.LifecycleID
26- for _, host := range inv.hosts {
27- if protocol.RemoteInventorySourceKey(host.Endpoint) != target.SourceKey {
28- continue
29- }
30- // A cached success (or a fetch already running at admission)
31- // may predate creation of this lifecycle. Only a later-started,
32- // completed successful observation can establish absence.
33- admitted, fenced := after[target]
34- known = fenced && host.InventoryKnown && host.Availability == domain.RemoteAvailabilityReachable &&
35- !host.Checking && host.LastAttempt.After(admitted) && !host.LastSuccess.Before(host.LastAttempt)
36- for _, session := range host.Sessions {
37- present = present || session.LifecycleID == target.Target.LifecycleID
38- }
39- break
40- }
41 }25 }
42- if known && !present {26+ if !present {
43 retired = append(retired, protocol.RouteRetired{Ref: target.Ref, Target: target.Target})27 retired = append(retired, protocol.RouteRetired{Ref: target.Ref, Target: target.Target})
44 }28 }
45 }29 }
M internal/usecase/daemon/session_inventory.go +6 -18
@@ -2,8 +2,6 @@ package daemon
22
33 import (
44 "time"
5-
6- "github.com/bnema/vev/internal/ports"
75 )
86
97 // inventoriedLiveSession pairs a live session pointer with its immutable
@@ -18,8 +16,7 @@ type inventoriedLiveSession struct {
1816 // localSessionInventory is the remote-free capture consumed by the prepared
1917 // local projections: the local catalogue export, the local navigation source
2018 // group, and the local picker/control projection. It copies active and
21-// inactive records and never reads the remote directory, so a local-only
22-// projection cannot observe or depend on remote monitoring.
19+// inactive records of this daemon only.
2320 //
2421 // d.mu is released before any per-session snapshot, preserving the
2522 // established lock ordering. Copied values are coherent per exact target,
@@ -31,14 +28,11 @@ type localSessionInventory struct {
3128 }
3229
3330 // sessionInventory is the daemon-owned common capture consumed by the
34-// palette, picker, and catalog export projections. It composes the remote-free
35-// local capture (localSessionInventory) with the current foreign
36-// remote-directory rows. Monitoring state itself is not carried here: the
37-// hybrid picker projection reads it directly from currentRemoteDirectory, so
38-// there is exactly one source for it.
31+// palette, picker, and catalog export projections. The daemon observes only
32+// its own sessions: other daemons reach the palette through the client's
33+// route snapshot (Plan 003 E3).
3934 type sessionInventory struct {
4035 localSessionInventory
41- hosts []ports.RemoteHostSnapshot
4236 }
4337
4438 // captureLocalSessionInventory copies registry pointers and inactive records
@@ -74,15 +68,9 @@ func (d *Daemon) captureLocalSessionInventory(opts viewOptions, refreshTitles bo
7468 return localSessionInventory{live: live, stopped: stopped, now: d.daemonNow()}
7569 }
7670
77-// captureSessionInventory composes the remote-free local capture with the
78-// current foreign remote-directory rows. The daemon-side monitor stays
79-// intentionally active in this state (coordinated P7 removal set), so hybrid
80-// projections keep their foreign rows; a nil directory yields an empty,
81-// uninitialized publication and never gates local behavior.
71+// captureSessionInventory is the palette, picker, and catalog capture.
8272 func (d *Daemon) captureSessionInventory(opts viewOptions, refreshTitles bool) sessionInventory {
83- local := d.captureLocalSessionInventory(opts, refreshTitles)
84- hosts, _, _ := d.currentRemoteDirectory()
85- return sessionInventory{localSessionInventory: local, hosts: hosts}
73+ return sessionInventory{localSessionInventory: d.captureLocalSessionInventory(opts, refreshTitles)}
8674 }
8775
8876 // resumableStopped returns stopped sessions eligible for resume display,
@@ -2,8 +2,6 @@ package daemon
2 2
3 import (3 import (
4 "time"4 "time"
5-
6- "github.com/bnema/vev/internal/ports"
7 )5 )
8 6
9 // inventoriedLiveSession pairs a live session pointer with its immutable7 // inventoriedLiveSession pairs a live session pointer with its immutable
@@ -18,8 +16,7 @@ type inventoriedLiveSession struct {
18 // localSessionInventory is the remote-free capture consumed by the prepared16 // localSessionInventory is the remote-free capture consumed by the prepared
19 // local projections: the local catalogue export, the local navigation source17 // local projections: the local catalogue export, the local navigation source
20 // group, and the local picker/control projection. It copies active and18 // group, and the local picker/control projection. It copies active and
21-// inactive records and never reads the remote directory, so a local-only19+// inactive records of this daemon only.
22-// projection cannot observe or depend on remote monitoring.
23 //20 //
24 // d.mu is released before any per-session snapshot, preserving the21 // d.mu is released before any per-session snapshot, preserving the
25 // established lock ordering. Copied values are coherent per exact target,22 // established lock ordering. Copied values are coherent per exact target,
@@ -31,14 +28,11 @@ type localSessionInventory struct {
31 }28 }
32 29
33 // sessionInventory is the daemon-owned common capture consumed by the30 // sessionInventory is the daemon-owned common capture consumed by the
34-// palette, picker, and catalog export projections. It composes the remote-free31+// palette, picker, and catalog export projections. The daemon observes only
35-// local capture (localSessionInventory) with the current foreign32+// its own sessions: other daemons reach the palette through the client's
36-// remote-directory rows. Monitoring state itself is not carried here: the33+// route snapshot (Plan 003 E3).
37-// hybrid picker projection reads it directly from currentRemoteDirectory, so
38-// there is exactly one source for it.
39 type sessionInventory struct {34 type sessionInventory struct {
40 localSessionInventory35 localSessionInventory
41- hosts []ports.RemoteHostSnapshot
42 }36 }
43 37
44 // captureLocalSessionInventory copies registry pointers and inactive records38 // captureLocalSessionInventory copies registry pointers and inactive records
@@ -74,15 +68,9 @@ func (d *Daemon) captureLocalSessionInventory(opts viewOptions, refreshTitles bo
74 return localSessionInventory{live: live, stopped: stopped, now: d.daemonNow()}68 return localSessionInventory{live: live, stopped: stopped, now: d.daemonNow()}
75 }69 }
76 70
77-// captureSessionInventory composes the remote-free local capture with the71+// captureSessionInventory is the palette, picker, and catalog capture.
78-// current foreign remote-directory rows. The daemon-side monitor stays
79-// intentionally active in this state (coordinated P7 removal set), so hybrid
80-// projections keep their foreign rows; a nil directory yields an empty,
81-// uninitialized publication and never gates local behavior.
82 func (d *Daemon) captureSessionInventory(opts viewOptions, refreshTitles bool) sessionInventory {72 func (d *Daemon) captureSessionInventory(opts viewOptions, refreshTitles bool) sessionInventory {
83- local := d.captureLocalSessionInventory(opts, refreshTitles)73+ return sessionInventory{localSessionInventory: d.captureLocalSessionInventory(opts, refreshTitles)}
84- hosts, _, _ := d.currentRemoteDirectory()
85- return sessionInventory{localSessionInventory: local, hosts: hosts}
86 }74 }
87 75
88 // resumableStopped returns stopped sessions eligible for resume display,76 // resumableStopped returns stopped sessions eligible for resume display,

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