feat(daemon): use client route snapshots for palette and attention
- commit
- 8a96e643eb26c495b19fb79f72dfc55266746019
- author
- brice · · 2026-09-22 20:51 UTC
- parent
- 657a96a
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
16 files · +304 -1562
M internal/ports/broker_observation.go +2 -4
| @@ -12,10 +12,8 @@ import ( | |||
| 12 | 12 | // Broker-native daemon observation (Plan 001 P5.2a). | |
| 13 | 13 | // | |
| 14 | 14 | // 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: | |
| 19 | 17 | // | |
| 20 | 18 | // - configured authority: Local flag, Endpoint, Registration, Policy, and | |
| 21 | 19 | // 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, local | 14 | // BrokerDaemonObservation is the broker's own projection of one daemon, local | ||
| 15 | - | // or remote. It deliberately does not reuse RemoteHostSnapshot: that type | 15 | + | // 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, and | 18 | // - configured authority: Local flag, Endpoint, Registration, Policy, and | ||
| 21 | // the presentation hints DisplayOrigin and Rank. The registry stamps | 19 | // the presentation hints DisplayOrigin and Rank. The registry stamps | ||
M internal/ports/mocks/remote_mock.go +0 -106
| @@ -9,7 +9,6 @@ import ( | |||
| 9 | 9 | ||
| 10 | 10 | "github.com/bnema/vev/internal/domain" | |
| 11 | 11 | "github.com/bnema/vev/internal/ports" | |
| 12 | - | "github.com/bnema/vev/internal/protocol" | |
| 13 | 12 | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 14 | 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 | 460 | 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 | 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 | 464 | // The first argument is typically a *testing.T value. | |
| 571 | 465 | 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 _c | 460 | 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 ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | ||
| 6 | 6 | "github.com/bnema/vev/internal/domain" | |
| 7 | - | "github.com/bnema/vev/internal/protocol" | |
| 8 | 7 | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 9 | 8 | ) | |
| 10 | 9 | ||
| @@ -26,11 +25,6 @@ type RemoteCatalogClient interface { | |||
| 26 | 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 | 28 | // RemoteCatalogCache persists complete remote discovery snapshots independently | |
| 35 | 29 | // from the remote host registry. | |
| 36 | 30 | 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 independently | 28 | // 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "time" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/vev/internal/domain" | |
| 8 | 9 | "github.com/bnema/vev/internal/ports" | |
| 10 | + | "github.com/bnema/vev/internal/protocol" | |
| 9 | 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 | 47 | d.pokeAttentionTicker() | |
| 46 | 48 | } | |
| 47 | 49 | ||
| 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. | |
| 52 | 56 | func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) error { | |
| 53 | 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 | 76 | target, ok := d.oldestOtherSessionAttention(sess) | |
| 73 | 77 | if !ok { | |
| 74 | - | return nil | |
| 78 | + | return d.jumpRouteAttention(sess, ac, effect) | |
| 75 | 79 | } | |
| 76 | 80 | pickerTarget := picker.Target{Session: target.sessionID, TabIndex: target.tabIndex} | |
| 77 | 81 | if effect == nil { | |
| @@ -80,6 +84,65 @@ func (d *Daemon) jumpAttentionForAttachment(sess *session, ac *attachedClient, e | |||
| 80 | 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 | 146 | func oldestAttentionTab(sess *session) (int, bool) { | |
| 84 | 147 | idx, _, ok := oldestAttentionTabWithTime(sess) | |
| 85 | 148 | 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 tab | 50 | + | // jumpAttention switches to the oldest pending attention target: a tab of the |
| 49 | - | // first and then another session's. Returning nil never implies a switch | 51 | + | // attached session first, then another session of this daemon, then the |
| 50 | - | // happened — it also covers "no target exists", which is routine and not an | 52 | + | // 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 nil | 78 | + | 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, ok | 148 | 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 { | |||
| 361 | 361 | notices *noticeCenter | |
| 362 | 362 | resumeParkGrace time.Duration | |
| 363 | 363 | 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 | 364 | // tempDir overrides os.TempDir() for clipboard-image-transfer writes | |
| 378 | 365 | // (see clipboard.go); empty means use os.TempDir(). | |
| 379 | 366 | tempDir string | |
| @@ -487,25 +474,6 @@ func WithRuntimeObserver(observer ports.SerializedRuntimeObserver) Option { | |||
| 487 | 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 | 477 | // WithShell overrides the command (and its args) each session spawns. The | |
| 510 | 478 | // default is $SHELL (or /bin/sh) with no arguments; tests use this to run a | |
| 511 | 479 | // deterministic program. | |
| @@ -833,11 +801,6 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||
| 833 | 801 | 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 | 804 | // Serve runs the accept loop over l, owning it for the loop's lifetime. It | |
| 842 | 805 | // returns only on ctx cancellation or an explicit daemon shutdown request | |
| 843 | 806 | // (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 | 814 | d.hardCtx, d.hardCancel = context.WithCancel(context.Background()) | |
| 852 | 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 | 817 | d.sessWg.Go(func() { | |
| 898 | 818 | d.attentionAnimator(d.serveCtx) | |
| 899 | 819 | }) | |
| @@ -1212,12 +1132,10 @@ func (d *Daemon) handleConn(tr ports.ServerConnection) { | |||
| 1212 | 1132 | if err := d.handleCommand(tr, message); err != nil { | |
| 1213 | 1133 | d.log.Warn("command handler failed", "err", err) | |
| 1214 | 1134 | } | |
| 1215 | - | case protocol.RemotePreviewRequest: | |
| 1135 | + | case protocol.RemotePreviewWatch: | |
| 1216 | 1136 | stopTransport() | |
| 1217 | 1137 | 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) | |
| 1221 | 1139 | case protocol.NavigationInventoryRequest: | |
| 1222 | 1140 | stopTransport() | |
| 1223 | 1141 | finishHandshake() | |
| @@ -361,19 +361,6 @@ type Daemon struct { | |||||
| 361 | notices *noticeCenter | 361 | notices *noticeCenter | ||
| 362 | resumeParkGrace time.Duration | 362 | resumeParkGrace time.Duration | ||
| 363 | suspendedSafetyExpiry time.Duration | 363 | 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 writes | 364 | // 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 string | 366 | 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. The | 477 | // 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 a | 478 | // 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 d | 801 | 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. It | 804 | // 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 request | 805 | // returns only on ctx cancellation or an explicit daemon shutdown request | ||
| 843 | // (KillDaemon), never when the session registry drains to empty: final | 806 | // (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 ( | |||
| 6 | 6 | "github.com/bnema/vev/internal/domain" | |
| 7 | 7 | "github.com/bnema/vev/internal/ports" | |
| 8 | 8 | "github.com/bnema/vev/internal/protocol" | |
| 9 | - | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 10 | 9 | ) | |
| 11 | 10 | ||
| 12 | 11 | // navigationInventoryEntryKey derives a stable opaque key for an unchanged | |
| @@ -41,31 +40,15 @@ func (d *Daemon) answerNavigationInventory(request protocol.NavigationInventoryR | |||
| 41 | 40 | return d.snapshotNavigationInventory(request.RequestID) | |
| 42 | 41 | } | |
| 43 | 42 | ||
| 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. | |
| 49 | 45 | 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) | |
| 61 | 47 | } | |
| 62 | 48 | ||
| 63 | 49 | // localNavigationInventorySnapshot is the prepared local-only navigation | |
| 64 | 50 | // 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. | |
| 69 | 52 | func (d *Daemon) localNavigationInventorySnapshot(requestID uint64) protocol.NavigationInventoryResponse { | |
| 70 | 53 | inv := d.captureLocalSessionInventory(viewOptions{}, false) | |
| 71 | 54 | response := protocol.NavigationInventoryResponse{ | |
| @@ -107,66 +90,6 @@ func (d *Daemon) localInventoryGroup(inv localSessionInventory) protocol.Navigat | |||
| 107 | 90 | 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 | 93 | // navigationInventoryEncodedBudget mirrors the wire export budget without | |
| 171 | 94 | // importing the codec layer. The daemon never encodes frames itself; the | |
| 172 | 95 | // estimate below upper-bounds the real payload so fit degrades early rather | |
| @@ -236,22 +159,9 @@ func (d *Daemon) resolveNavigationInventory(request protocol.NavigationInventory | |||
| 236 | 159 | response.Resolved = &resolved | |
| 237 | 160 | return response | |
| 238 | 161 | } | |
| 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 | |
| 255 | 165 | return response | |
| 256 | 166 | } | |
| 257 | 167 | ||
| @@ -291,29 +201,3 @@ func localNavigationResolveEntry(inv localSessionInventory, entryKey string) (pr | |||
| 291 | 201 | } | |
| 292 | 202 | 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 | - | } | |
| @@ -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 unchanged | 11 | // 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, then | 43 | + | // snapshotNavigationInventory answers with this daemon's own sessions only: |
| 45 | - | // deterministic host order. A source exceeding its row limit becomes an | 44 | + | // 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 navigation | 49 | // localNavigationInventorySnapshot is the prepared local-only navigation | ||
| 64 | // projection (Plan 001 P4.3): exactly one local source group, built from the | 50 | // 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 | 51 | + | // 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 group | 90 | 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 without | 93 | // navigationInventoryEncodedBudget mirrors the wire export budget without | ||
| 171 | // importing the codec layer. The daemon never encodes frames itself; the | 94 | // importing the codec layer. The daemon never encodes frames itself; the | ||
| 172 | // estimate below upper-bounds the real payload so fit degrades early rather | 95 | // 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 = &resolved | 159 | response.Resolved = &resolved | ||
| 237 | return response | 160 | 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.NavigationInventoryUnavailable | 164 | + | 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 response | 165 | return response | ||
| 256 | } | 166 | } | ||
| 257 | 167 | ||||
| @@ -291,29 +201,3 @@ func localNavigationResolveEntry(inv localSessionInventory, entryKey string) (pr | |||||
| 291 | } | 201 | } | ||
| 292 | return protocol.ExactSessionTarget{}, false | 202 | 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 ( | |||
| 7 | 7 | ||
| 8 | 8 | renderer "github.com/bnema/vev-vt" | |
| 9 | 9 | "github.com/bnema/vev/internal/domain" | |
| 10 | - | "github.com/bnema/vev/internal/ports" | |
| 11 | 10 | "github.com/bnema/vev/internal/protocol" | |
| 12 | 11 | "github.com/bnema/vev/internal/usecase/command" | |
| 13 | 12 | "github.com/bnema/vev/internal/usecase/layout" | |
| @@ -116,51 +115,57 @@ func routeEntryForRef(snapshot protocol.RecentRouteSnapshot, ref protocol.RouteR | |||
| 116 | 115 | return protocol.RecentRouteEntry{}, false | |
| 117 | 116 | } | |
| 118 | 117 | ||
| 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) | |
| 123 | 124 | if home, ok := routeEntryForRef(snapshot, snapshot.Home); ok && home.Kind == protocol.RouteKindLocal { | |
| 124 | 125 | results = append(results, palette.NewCreateSessionDestination( | |
| 125 | 126 | palette.CreateSessionOnLocalRoute, "", "", snapshot.Home, snapshot.Generation, | |
| 126 | 127 | )) | |
| 127 | 128 | } | |
| 128 | 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 | 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 { | |
| 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 | + | )) | |
| 141 | 140 | continue | |
| 142 | 141 | } | |
| 143 | 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 | 146 | return results | |
| 148 | 147 | } | |
| 149 | 148 | ||
| 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 | |
| 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 | 171 | func paletteRouteRepresentsDaemonSession(entry protocol.RecentRouteEntry, daemonDisplayOrigin string) bool { | |
| @@ -171,13 +176,13 @@ func paletteRouteRepresentsDaemonSession(entry protocol.RecentRouteEntry, daemon | |||
| 171 | 176 | paletteRemoteDisplayOrigin(entry.HostLabel) == daemonDisplayOrigin | |
| 172 | 177 | } | |
| 173 | 178 | ||
| 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. | |
| 178 | 184 | func (d *Daemon) paletteResults(current *session, commands []command.Command, routeSnapshot protocol.RecentRouteSnapshot) []palette.Result { | |
| 179 | 185 | inv := d.captureSessionInventory(viewOptions{}, false) | |
| 180 | - | hosts := inv.hosts | |
| 181 | 186 | stopped := inv.resumableStopped() | |
| 182 | 187 | represented := make(map[paletteSessionIdentity]struct{}, len(inv.live)+len(inv.stopped)+len(routeSnapshot.Entries)) | |
| 183 | 188 | for _, entry := range inv.stopped { | |
| @@ -190,7 +195,7 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro | |||
| 190 | 195 | current.mu.Unlock() | |
| 191 | 196 | } | |
| 192 | 197 | daemonDisplayOrigin := paletteDaemonDisplayOrigin(routeSnapshot, currentLifecycle) | |
| 193 | - | hybrid := len(hosts) > 0 || daemonDisplayOrigin != "" | |
| 198 | + | hybrid := len(routeSnapshot.Hosts) > 0 || daemonDisplayOrigin != "" | |
| 194 | 199 | for _, entry := range routeSnapshot.Entries { | |
| 195 | 200 | hybrid = hybrid || entry.Kind == protocol.RouteKindRemote | |
| 196 | 201 | } | |
| @@ -198,13 +203,8 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro | |||
| 198 | 203 | daemonDisplayOrigin = "local" | |
| 199 | 204 | } | |
| 200 | 205 | ||
| 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)) | |
| 208 | 208 | for _, cmd := range commands { | |
| 209 | 209 | results = append(results, palette.NewCommandResult(cmd)) | |
| 210 | 210 | } | |
| @@ -234,34 +234,6 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro | |||
| 234 | 234 | results = append(results, palette.NewStoppedSessionResultWithDisplayOrigin(target, time.Unix(0, candidate.createdAt), daemonDisplayOrigin)) | |
| 235 | 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 | 237 | for _, entry := range routeSnapshot.Entries { | |
| 266 | 238 | if entry.Name == "" { | |
| 267 | 239 | continue | |
| @@ -285,24 +257,8 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro | |||
| 285 | 257 | })) | |
| 286 | 258 | if entry.Kind == protocol.RouteKindLocal { | |
| 287 | 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 | 262 | return results | |
| 307 | 263 | } | |
| 308 | 264 | ||
| @@ -407,10 +363,6 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 407 | 363 | var cmd command.Command | |
| 408 | 364 | var sessionTarget palette.Result | |
| 409 | 365 | var hasSessionTarget bool | |
| 410 | - | var remoteTarget domain.RemoteSessionTarget | |
| 411 | - | var remoteKey domain.RemoteSessionKey | |
| 412 | - | var remoteUnavailableReason string | |
| 413 | - | var hasRemoteTarget bool | |
| 414 | 366 | var importedSourceKey, importedEntryKey string | |
| 415 | 367 | var hasImportedTarget bool | |
| 416 | 368 | var routeTarget protocol.RouteNavigationAction | |
| @@ -496,16 +448,6 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 496 | 448 | routeSnapshot = ac.overlays.paletteRouteSnapshot | |
| 497 | 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 | 451 | } else if sourceKey, entryKey, ok := selected.ImportedSessionKey(); ok { | |
| 510 | 452 | importedSourceKey = sourceKey | |
| 511 | 453 | importedEntryKey = entryKey | |
| @@ -596,29 +538,6 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 596 | 538 | 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 | 541 | if hasImportedTarget { | |
| 623 | 542 | if effect == nil { | |
| 624 | 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 | 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 | 823 | if !ok { | |
| 905 | 824 | return errCreateDestinationUnavailable | |
| 906 | 825 | } | |
| 907 | 826 | switch kind { | |
| 908 | 827 | case palette.CreateSessionOnServingDaemon: | |
| 909 | 828 | 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 | 832 | if e.ac == nil { | |
| 912 | 833 | return errCreateDestinationUnavailable | |
| 913 | 834 | } | |
| @@ -915,20 +836,19 @@ func (e paletteExec) validateCreateSessionDestination(effect *attachmentEffect, | |||
| 915 | 836 | if !ok || snapshotGeneration == 0 { | |
| 916 | 837 | return errCreateDestinationUnavailable | |
| 917 | 838 | } | |
| 839 | + | want := protocol.RouteKindLocal | |
| 840 | + | if kind == palette.CreateSessionOnRemoteHost { | |
| 841 | + | want = protocol.RouteKindRemote | |
| 842 | + | } | |
| 918 | 843 | 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 { | |
| 921 | 846 | return errCreateDestinationUnavailable | |
| 922 | 847 | } | |
| 923 | 848 | if effect == nil && snapshot.Active != route { | |
| 924 | 849 | return errCreateDestinationUnavailable | |
| 925 | 850 | } | |
| 926 | 851 | return nil | |
| 927 | - | case palette.CreateSessionOnRemoteHost: | |
| 928 | - | if effect == nil || !e.d.remoteCreateHostReady(endpoint) { | |
| 929 | - | return errCreateDestinationUnavailable | |
| 930 | - | } | |
| 931 | - | return nil | |
| 932 | 852 | default: | |
| 933 | 853 | return errCreateDestinationUnavailable | |
| 934 | 854 | } | |
| @@ -945,7 +865,7 @@ func (e paletteExec) createSessionOnValidatedDestination(effect *attachmentEffec | |||
| 945 | 865 | if err := domain.ValidateSessionName(name); err != nil { | |
| 946 | 866 | return err | |
| 947 | 867 | } | |
| 948 | - | _, kind, _, endpoint, route, _ := result.CreateSessionDestination() | |
| 868 | + | _, kind, _, _, route, _ := result.CreateSessionDestination() | |
| 949 | 869 | if kind == palette.CreateSessionOnServingDaemon || | |
| 950 | 870 | (kind == palette.CreateSessionOnLocalRoute && e.ac != nil && e.ac.routeSnapshotCopy().Active == route) { | |
| 951 | 871 | if effect == nil { | |
| @@ -961,7 +881,8 @@ func (e paletteExec) createSessionOnValidatedDestination(effect *attachmentEffec | |||
| 961 | 881 | requestID = e.d.creationRequestSeq.Add(1) | |
| 962 | 882 | } | |
| 963 | 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 | 886 | snapshotGeneration, _ := result.CreateSessionSnapshotGeneration() | |
| 966 | 887 | action := protocol.RouteCreateSessionAction{ | |
| 967 | 888 | RequestID: requestID, SnapshotGeneration: snapshotGeneration, | |
| @@ -971,35 +892,11 @@ func (e paletteExec) createSessionOnValidatedDestination(effect *attachmentEffec | |||
| 971 | 892 | return err | |
| 972 | 893 | } | |
| 973 | 894 | 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 | 895 | default: | |
| 988 | 896 | 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 | 900 | func (e paletteExec) CreateEphemeralSession() error { | |
| 1004 | 901 | if e.effect == nil { | |
| 1005 | 902 | 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{}, false | 115 | 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 | - | continue | 136 | + | 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 | continue | 140 | 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 results | 146 | return results | ||
| 148 | } | 147 | } | ||
| 149 | 148 | ||||
| 150 | - | // servingRouteEndpoint resolves the exact endpoint serving the active | 149 | + | // routeHostForRef finds one creation host of the snapshot. |
| 151 | - | // remote route by matching its session lifecycle against the directory | 150 | + | func routeHostForRef(snapshot protocol.RecentRouteSnapshot, ref protocol.RouteRef) (protocol.RouteHost, bool) { |
| 152 | - | // inventory. Display origins collide across login prefixes (vev@arch vs | 151 | + | for _, host := range snapshot.Hosts { |
| 153 | - | // arch), so only an exact lifecycle match suppresses the duplicate | 152 | + | 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) == daemonDisplayOrigin | 176 | paletteRemoteDisplayOrigin(entry.HostLabel) == daemonDisplayOrigin | ||
| 172 | } | 177 | } | ||
| 173 | 178 | ||||
| 174 | - | // paletteResults projects the shared daemon inventory for the palette. It keeps | 179 | + | // 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 shared | 181 | + | // 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.RouteKindRemote | 200 | 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 := 0 | 206 | + | 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 | continue | 239 | 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 results | 262 | return results | ||
| 307 | } | 263 | } | ||
| 308 | 264 | ||||
| @@ -407,10 +363,6 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||||
| 407 | var cmd command.Command | 363 | var cmd command.Command | ||
| 408 | var sessionTarget palette.Result | 364 | var sessionTarget palette.Result | ||
| 409 | var hasSessionTarget bool | 365 | 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 string | 366 | var importedSourceKey, importedEntryKey string | ||
| 415 | var hasImportedTarget bool | 367 | var hasImportedTarget bool | ||
| 416 | var routeTarget protocol.RouteNavigationAction | 368 | var routeTarget protocol.RouteNavigationAction | ||
| @@ -496,16 +448,6 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||||
| 496 | routeSnapshot = ac.overlays.paletteRouteSnapshot | 448 | 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 = sourceKey | 452 | importedSourceKey = sourceKey | ||
| 511 | importedEntryKey = entryKey | 453 | importedEntryKey = entryKey | ||
| @@ -596,29 +538,6 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||||
| 596 | return | 538 | 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 errCreateDestinationUnavailable | 824 | return errCreateDestinationUnavailable | ||
| 906 | } | 825 | } | ||
| 907 | switch kind { | 826 | switch kind { | ||
| 908 | case palette.CreateSessionOnServingDaemon: | 827 | case palette.CreateSessionOnServingDaemon: | ||
| 909 | return nil | 828 | 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 errCreateDestinationUnavailable | 833 | 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 errCreateDestinationUnavailable | 837 | 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 errCreateDestinationUnavailable | 846 | return errCreateDestinationUnavailable | ||
| 922 | } | 847 | } | ||
| 923 | if effect == nil && snapshot.Active != route { | 848 | if effect == nil && snapshot.Active != route { | ||
| 924 | return errCreateDestinationUnavailable | 849 | return errCreateDestinationUnavailable | ||
| 925 | } | 850 | } | ||
| 926 | return nil | 851 | 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 errCreateDestinationUnavailable | 853 | 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 err | 866 | 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 err | 892 | return err | ||
| 972 | } | 893 | } | ||
| 973 | return nil | 894 | 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 errAttachmentTransition | 896 | 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 ( | |||
| 10 | 10 | ||
| 11 | 11 | renderer "github.com/bnema/vev-vt" | |
| 12 | 12 | "github.com/bnema/vev/internal/domain" | |
| 13 | - | "github.com/bnema/vev/internal/ports" | |
| 14 | 13 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 15 | 14 | "github.com/bnema/vev/internal/protocol" | |
| 16 | - | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 17 | 15 | "github.com/bnema/vev/internal/usecase/command" | |
| 18 | 16 | "github.com/bnema/vev/internal/usecase/palette" | |
| 19 | 17 | "github.com/bnema/vev/internal/usecase/picker" | |
| @@ -46,115 +44,87 @@ func beginRecentRoutePaletteEffect(t *testing.T, d *Daemon, sess *session, ac *a | |||
| 46 | 44 | 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 | 52 | 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"}}, | |
| 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{ | |
| 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 | + | }) | |
| 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 | 120 | func TestRemoteCreateSessionDestinationRequiresAttachmentEffect(t *testing.T) { | |
| 101 | 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 | 124 | err := (paletteExec{}).validateCreateSessionDestination(nil, result) | |
| 105 | 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 | 128 | func TestCaptureOverlayLayersPreservesPaletteDescriptionSurfaceAcrossFallbacks(t *testing.T) { | |
| 159 | 129 | paletteColors := [16]renderer.RGB{} | |
| 160 | 130 | 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 effect | 44 | 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 ( | |||
| 5 | 5 | "maps" | |
| 6 | 6 | "time" | |
| 7 | 7 | ||
| 8 | - | "github.com/bnema/vev/internal/domain" | |
| 9 | 8 | "github.com/bnema/vev/internal/protocol" | |
| 10 | 9 | ) | |
| 11 | 10 | ||
| 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 { | |
| 15 | 14 | var retired []protocol.RouteRetired | |
| 16 | 15 | 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 | |
| 41 | 25 | } | |
| 42 | - | if known && !present { | |
| 26 | + | if !present { | |
| 43 | 27 | retired = append(retired, protocol.RouteRetired{Ref: target.Ref, Target: target.Target}) | |
| 44 | 28 | } | |
| 45 | 29 | } | |
| @@ -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 remote | 11 | + | // 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.RouteRetired | 14 | var retired []protocol.RouteRetired | ||
| 16 | for _, target := range subscription.Targets { | 15 | for _, target := range subscription.Targets { | ||
| 17 | - | known, present := target.SourceKey == "", false | 16 | + | if target.SourceKey != "" { |
| 18 | - | if known { | 17 | + | continue |
| 19 | - | for _, item := range inv.live { | 18 | + | } |
| 20 | - | present = present || item.view.incarnation == target.Target.LifecycleID | 19 | + | 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.LifecycleID | 22 | + | } |
| 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "time" | |
| 5 | - | ||
| 6 | - | "github.com/bnema/vev/internal/ports" | |
| 7 | 5 | ) | |
| 8 | 6 | ||
| 9 | 7 | // inventoriedLiveSession pairs a live session pointer with its immutable | |
| @@ -18,8 +16,7 @@ type inventoriedLiveSession struct { | |||
| 18 | 16 | // localSessionInventory is the remote-free capture consumed by the prepared | |
| 19 | 17 | // local projections: the local catalogue export, the local navigation source | |
| 20 | 18 | // 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. | |
| 23 | 20 | // | |
| 24 | 21 | // d.mu is released before any per-session snapshot, preserving the | |
| 25 | 22 | // established lock ordering. Copied values are coherent per exact target, | |
| @@ -31,14 +28,11 @@ type localSessionInventory struct { | |||
| 31 | 28 | } | |
| 32 | 29 | ||
| 33 | 30 | // 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). | |
| 39 | 34 | type sessionInventory struct { | |
| 40 | 35 | localSessionInventory | |
| 41 | - | hosts []ports.RemoteHostSnapshot | |
| 42 | 36 | } | |
| 43 | 37 | ||
| 44 | 38 | // captureLocalSessionInventory copies registry pointers and inactive records | |
| @@ -74,15 +68,9 @@ func (d *Daemon) captureLocalSessionInventory(opts viewOptions, refreshTitles bo | |||
| 74 | 68 | return localSessionInventory{live: live, stopped: stopped, now: d.daemonNow()} | |
| 75 | 69 | } | |
| 76 | 70 | ||
| 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. | |
| 82 | 72 | 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)} | |
| 86 | 74 | } | |
| 87 | 75 | ||
| 88 | 76 | // 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 immutable | 7 | // 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 prepared | 16 | // localSessionInventory is the remote-free capture consumed by the prepared | ||
| 19 | // local projections: the local catalogue export, the local navigation source | 17 | // local projections: the local catalogue export, the local navigation source | ||
| 20 | // group, and the local picker/control projection. It copies active and | 18 | // group, and the local picker/control projection. It copies active and | ||
| 21 | - | // inactive records and never reads the remote directory, so a local-only | 19 | + | // 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 the | 21 | // 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 the | 30 | // sessionInventory is the daemon-owned common capture consumed by the | ||
| 34 | - | // palette, picker, and catalog export projections. It composes the remote-free | 31 | + | // palette, picker, and catalog export projections. The daemon observes only |
| 35 | - | // local capture (localSessionInventory) with the current foreign | 32 | + | // its own sessions: other daemons reach the palette through the client's |
| 36 | - | // remote-directory rows. Monitoring state itself is not carried here: the | 33 | + | // 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 | localSessionInventory | 35 | localSessionInventory | ||
| 41 | - | hosts []ports.RemoteHostSnapshot | |||
| 42 | } | 36 | } | ||
| 43 | 37 | ||||
| 44 | // captureLocalSessionInventory copies registry pointers and inactive records | 38 | // 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 the | 71 | + | // 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, | ||