refactor(daemon): isolate local catalogue boundaries
- commit
- c16729ea20c51992657a1ed62f3e3c376d1d1304
- author
- brice · · 2026-09-17 08:03 UTC
- parent
- 3e0d64d
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
8 files · +926 -83
M docs/architecture.md +57 -0
| @@ -114,6 +114,63 @@ feeds it) belongs to the coordinated P7 removal set; it is not removed, | |||
| 114 | 114 | gated, or polled differently before that cutover, and no polling gate is | |
| 115 | 115 | introduced here (GO-001, deferred to P7). | |
| 116 | 116 | ||
| 117 | + | ## Prepared local inventory boundary (Plan 001 P4.3, preparation only) | |
| 118 | + | ||
| 119 | + | The daemon separates local registry state from foreign remote-directory rows. | |
| 120 | + | `captureLocalSessionInventory` (returning `localSessionInventory`) copies live | |
| 121 | + | sessions and inactive records and never reads the remote directory; | |
| 122 | + | `captureSessionInventory` composes that local capture with the foreign rows | |
| 123 | + | from `currentRemoteDirectory`. The local catalogue export | |
| 124 | + | (`controlExec.RemoteCatalog`), the prepared navigation projection | |
| 125 | + | (`localNavigationInventorySnapshot` / `localInventoryGroup`), the prepared | |
| 126 | + | navigation resolver (`localNavigationResolve`), and the prepared picker | |
| 127 | + | projection (`localPickerViews` / `localPickerViewProjections`) read only the | |
| 128 | + | local capture, so they cannot observe or depend on remote monitoring. The | |
| 129 | + | prepared control resolver (`localPickerControlAttachTarget`) is not an | |
| 130 | + | inventory projection: it reads the live session registry directly under | |
| 131 | + | `d.mu` and never the remote directory, the `sessionInventory` composite, or a | |
| 132 | + | stopped record — only live registry sessions are eligible there. | |
| 133 | + | ||
| 134 | + | The current hybrid projections keep their foreign behavior by composing the | |
| 135 | + | prepared local output with the foreign rows: `snapshotNavigationInventory` | |
| 136 | + | appends `remoteInventoryGroup` per host, `pickerViewProjections` interleaves | |
| 137 | + | `foreignPickerViews` between the local live and stopped groups, and | |
| 138 | + | `pickerControlAttachTarget` delegates to `localPickerControlAttachTarget` (via | |
| 139 | + | `pickerControlAttachTargetLocal`) after its remote branch. Prepared local | |
| 140 | + | resolution is exact: a structured foreign source key, registration, remote | |
| 141 | + | target, or remote key is rejected before any lookup; broken and purging records | |
| 142 | + | are never resumable; a stopped target that becomes live with the same lifecycle | |
| 143 | + | resolves; and a stale lifecycle or a same-name replacement never resolves by | |
| 144 | + | name. The prepared local resolver also rejects a stale preferred tab, but the | |
| 145 | + | hybrid wrapper preserves the live behavior on top of it: when the exact local | |
| 146 | + | lifecycle is still current and only the tab vanished between snapshot and | |
| 147 | + | resolve, it retries with an empty `PreferredTabID`, matching the client's own | |
| 148 | + | missing-tab fallback instead of failing the attach. | |
| 149 | + | ||
| 150 | + | Current ownership: `internal/usecase/daemon` owns the local capture, the | |
| 151 | + | prepared projections and resolvers, and the hybrid wrappers; `internal/app` | |
| 152 | + | composes the daemon-side monitor; `internal/adapters/remote` plus the | |
| 153 | + | runner-scoped `remotes.HostRegistry` own discovery. This is a preparation pass: | |
| 154 | + | no production composition is cut over, no monitor is gated or removed, no | |
| 155 | + | public protocol, schema, or version changes, and the signed purge machinery is | |
| 156 | + | untouched. | |
| 157 | + | ||
| 158 | + | P7 removal set: `WithRemoteMonitor` and the `remoteDirectory` / | |
| 159 | + | `remoteMonitorRun` state; `remoteDirectorySnapshot` and `currentRemoteDirectory`; | |
| 160 | + | `watchRemoteDirectory`, `refreshRemoteDirectoryViews`, and | |
| 161 | + | `notifyNewRemoteFailures`; the foreign picker composition (`foreignPickerViews`, | |
| 162 | + | `remoteInventoryGroup` and the remote groups in `snapshotNavigationInventory`, | |
| 163 | + | the remote branch of `pickerControlAttachTarget`, `resolveRemoteInventoryEntry`); | |
| 164 | + | the hybrid wrappers themselves (`captureSessionInventory`, the | |
| 165 | + | `sessionInventory` composite, `pickerViewProjections`, `pickerControlAttachTarget` | |
| 166 | + | and its `pickerControlAttachTargetLocal` vanished-tab fallback) once the local | |
| 167 | + | projections read `localSessionInventory` directly; the remote picker, palette, | |
| 168 | + | preview, attach, and route projections that read the directory; and the | |
| 169 | + | daemon-side `ports.RemoteDirectory` / `ports.RemotePreviewClient` composition. | |
| 170 | + | Once those foreign inputs are deleted, the prepared local functions are the | |
| 171 | + | whole projection. The daemon-side monitor and host registry stay active until | |
| 172 | + | then (GO-001, deferred to P7). | |
| 173 | + | ||
| 117 | 174 | ## Session composition | |
| 118 | 175 | ||
| 119 | 176 | ```text | |
| @@ -114,6 +114,63 @@ feeds it) belongs to the coordinated P7 removal set; it is not removed, | |||||
| 114 | gated, or polled differently before that cutover, and no polling gate is | 114 | gated, or polled differently before that cutover, and no polling gate is | ||
| 115 | introduced here (GO-001, deferred to P7). | 115 | introduced here (GO-001, deferred to P7). | ||
| 116 | 116 | ||||
| 117 | + | ## Prepared local inventory boundary (Plan 001 P4.3, preparation only) | |||
| 118 | + | ||||
| 119 | + | The daemon separates local registry state from foreign remote-directory rows. | |||
| 120 | + | `captureLocalSessionInventory` (returning `localSessionInventory`) copies live | |||
| 121 | + | sessions and inactive records and never reads the remote directory; | |||
| 122 | + | `captureSessionInventory` composes that local capture with the foreign rows | |||
| 123 | + | from `currentRemoteDirectory`. The local catalogue export | |||
| 124 | + | (`controlExec.RemoteCatalog`), the prepared navigation projection | |||
| 125 | + | (`localNavigationInventorySnapshot` / `localInventoryGroup`), the prepared | |||
| 126 | + | navigation resolver (`localNavigationResolve`), and the prepared picker | |||
| 127 | + | projection (`localPickerViews` / `localPickerViewProjections`) read only the | |||
| 128 | + | local capture, so they cannot observe or depend on remote monitoring. The | |||
| 129 | + | prepared control resolver (`localPickerControlAttachTarget`) is not an | |||
| 130 | + | inventory projection: it reads the live session registry directly under | |||
| 131 | + | `d.mu` and never the remote directory, the `sessionInventory` composite, or a | |||
| 132 | + | stopped record — only live registry sessions are eligible there. | |||
| 133 | + | ||||
| 134 | + | The current hybrid projections keep their foreign behavior by composing the | |||
| 135 | + | prepared local output with the foreign rows: `snapshotNavigationInventory` | |||
| 136 | + | appends `remoteInventoryGroup` per host, `pickerViewProjections` interleaves | |||
| 137 | + | `foreignPickerViews` between the local live and stopped groups, and | |||
| 138 | + | `pickerControlAttachTarget` delegates to `localPickerControlAttachTarget` (via | |||
| 139 | + | `pickerControlAttachTargetLocal`) after its remote branch. Prepared local | |||
| 140 | + | resolution is exact: a structured foreign source key, registration, remote | |||
| 141 | + | target, or remote key is rejected before any lookup; broken and purging records | |||
| 142 | + | are never resumable; a stopped target that becomes live with the same lifecycle | |||
| 143 | + | resolves; and a stale lifecycle or a same-name replacement never resolves by | |||
| 144 | + | name. The prepared local resolver also rejects a stale preferred tab, but the | |||
| 145 | + | hybrid wrapper preserves the live behavior on top of it: when the exact local | |||
| 146 | + | lifecycle is still current and only the tab vanished between snapshot and | |||
| 147 | + | resolve, it retries with an empty `PreferredTabID`, matching the client's own | |||
| 148 | + | missing-tab fallback instead of failing the attach. | |||
| 149 | + | ||||
| 150 | + | Current ownership: `internal/usecase/daemon` owns the local capture, the | |||
| 151 | + | prepared projections and resolvers, and the hybrid wrappers; `internal/app` | |||
| 152 | + | composes the daemon-side monitor; `internal/adapters/remote` plus the | |||
| 153 | + | runner-scoped `remotes.HostRegistry` own discovery. This is a preparation pass: | |||
| 154 | + | no production composition is cut over, no monitor is gated or removed, no | |||
| 155 | + | public protocol, schema, or version changes, and the signed purge machinery is | |||
| 156 | + | untouched. | |||
| 157 | + | ||||
| 158 | + | P7 removal set: `WithRemoteMonitor` and the `remoteDirectory` / | |||
| 159 | + | `remoteMonitorRun` state; `remoteDirectorySnapshot` and `currentRemoteDirectory`; | |||
| 160 | + | `watchRemoteDirectory`, `refreshRemoteDirectoryViews`, and | |||
| 161 | + | `notifyNewRemoteFailures`; the foreign picker composition (`foreignPickerViews`, | |||
| 162 | + | `remoteInventoryGroup` and the remote groups in `snapshotNavigationInventory`, | |||
| 163 | + | the remote branch of `pickerControlAttachTarget`, `resolveRemoteInventoryEntry`); | |||
| 164 | + | the hybrid wrappers themselves (`captureSessionInventory`, the | |||
| 165 | + | `sessionInventory` composite, `pickerViewProjections`, `pickerControlAttachTarget` | |||
| 166 | + | and its `pickerControlAttachTargetLocal` vanished-tab fallback) once the local | |||
| 167 | + | projections read `localSessionInventory` directly; the remote picker, palette, | |||
| 168 | + | preview, attach, and route projections that read the directory; and the | |||
| 169 | + | daemon-side `ports.RemoteDirectory` / `ports.RemotePreviewClient` composition. | |||
| 170 | + | Once those foreign inputs are deleted, the prepared local functions are the | |||
| 171 | + | whole projection. The daemon-side monitor and host registry stay active until | |||
| 172 | + | then (GO-001, deferred to P7). | |||
| 173 | + | ||||
| 117 | ## Session composition | 174 | ## Session composition | ||
| 118 | 175 | ||||
| 119 | ```text | 176 | ```text | ||
M internal/usecase/daemon/control.go +4 -1
| @@ -728,7 +728,10 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) { | |||
| 728 | 728 | if !asJSON { | |
| 729 | 729 | return "", command.ErrInvalidArguments | |
| 730 | 730 | } | |
| 731 | - | inv := e.d.captureSessionInventory(viewOptions{tabDetails: true, focusedTitles: true}, true) | |
| 731 | + | // The catalogue export is a local-only projection (Plan 001 P4.3): it | |
| 732 | + | // reads the remote-free capture and never the remote directory, so a | |
| 733 | + | // serving daemon's own catalogue is independent of remote monitoring. | |
| 734 | + | inv := e.d.captureLocalSessionInventory(viewOptions{tabDetails: true, focusedTitles: true}, true) | |
| 732 | 735 | stopped := inv.visibleStopped() | |
| 733 | 736 | ||
| 734 | 737 | rows := make([]catalogue.RemoteCatalogSession, 0, len(inv.live)+len(stopped)) | |
| @@ -728,7 +728,10 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) { | |||||
| 728 | if !asJSON { | 728 | if !asJSON { | ||
| 729 | return "", command.ErrInvalidArguments | 729 | return "", command.ErrInvalidArguments | ||
| 730 | } | 730 | } | ||
| 731 | - | inv := e.d.captureSessionInventory(viewOptions{tabDetails: true, focusedTitles: true}, true) | 731 | + | // The catalogue export is a local-only projection (Plan 001 P4.3): it |
| 732 | + | // reads the remote-free capture and never the remote directory, so a | |||
| 733 | + | // serving daemon's own catalogue is independent of remote monitoring. | |||
| 734 | + | inv := e.d.captureLocalSessionInventory(viewOptions{tabDetails: true, focusedTitles: true}, true) | |||
| 732 | stopped := inv.visibleStopped() | 735 | stopped := inv.visibleStopped() | ||
| 733 | 736 | ||||
| 734 | rows := make([]catalogue.RemoteCatalogSession, 0, len(inv.live)+len(stopped)) | 737 | rows := make([]catalogue.RemoteCatalogSession, 0, len(inv.live)+len(stopped)) | ||
A internal/usecase/daemon/local_inventory_boundary_test.go +606 -0
| @@ -0,0 +1,606 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/vev/internal/domain" | |
| 13 | + | "github.com/bnema/vev/internal/ports" | |
| 14 | + | "github.com/bnema/vev/internal/protocol" | |
| 15 | + | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 16 | + | "github.com/bnema/vev/internal/usecase/picker" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // failPTYFactory fails the test if a local projection ever opens a PTY. | |
| 20 | + | type failPTYFactory struct{ t *testing.T } | |
| 21 | + | ||
| 22 | + | func (f failPTYFactory) Open(context.Context, string, []string, []string, string, domain.Geometry) (ports.PTY, error) { | |
| 23 | + | f.t.Fatal("local projection must not open a PTY") | |
| 24 | + | return nil, nil | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func localNavigationRequest(entryKey string) protocol.NavigationInventoryRequest { | |
| 28 | + | return protocol.NavigationInventoryRequest{ | |
| 29 | + | Version: protocol.Version, RequestID: 1, Operation: protocol.NavigationInventoryResolve, | |
| 30 | + | SourceKey: protocol.NavigationInventoryLocalSourceKey, EntryKey: entryKey, | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func seedLocalNamedSession(d *Daemon, name string, incarnation domain.SessionLifecycleID) *session { | |
| 35 | + | sess := addControlSession(d, name, "tab-"+name, "pane-"+name) | |
| 36 | + | sess.ephemeral = false | |
| 37 | + | sess.incarnation = incarnation | |
| 38 | + | return sess | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // TestRemoteCatalogLocalCaptureIgnoresRemoteDirectory proves the catalogue | |
| 42 | + | // export is a local-only projection: a populated and a failing remote | |
| 43 | + | // directory both leave the exported document byte-identical to a daemon with | |
| 44 | + | // no remote directory, and neither is ever read. | |
| 45 | + | func TestRemoteCatalogLocalCaptureIgnoresRemoteDirectory(t *testing.T) { | |
| 46 | + | now := time.Unix(1_000, 0) | |
| 47 | + | populateLocal := func(d *Daemon) { | |
| 48 | + | seedLocalNamedSession(d, "named", domain.SessionLifecycleID{0x01}) | |
| 49 | + | ephemeral := addControlSession(d, "ephemeral", "tab-eph", "pane-eph") | |
| 50 | + | ephemeral.ephemeral = true | |
| 51 | + | ephemeral.incarnation = domain.SessionLifecycleID{0x02} | |
| 52 | + | d.mu.Lock() | |
| 53 | + | d.inactive["stopped"] = inactiveSession{name: "stopped", cwd: "/tmp/stopped", createdAt: 7, incarnation: domain.SessionLifecycleID{0x03}, state: protocol.SessionDown} | |
| 54 | + | d.mu.Unlock() | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | reference := newTestDaemon(t, nil, fixedRemoteRefreshClock{now: now}) | |
| 58 | + | populateLocal(reference) | |
| 59 | + | want, err := controlExec{d: reference}.RemoteCatalog(true) | |
| 60 | + | require.NoError(t, err) | |
| 61 | + | require.Contains(t, want, `"named"`) | |
| 62 | + | ||
| 63 | + | cases := []struct { | |
| 64 | + | name string | |
| 65 | + | directory func() *countingRemoteDirectory | |
| 66 | + | }{ | |
| 67 | + | { | |
| 68 | + | name: "populated", | |
| 69 | + | directory: func() *countingRemoteDirectory { | |
| 70 | + | return &countingRemoteDirectory{snapshot: ports.RemoteDirectorySnapshot{ | |
| 71 | + | Revision: 3, Initialized: true, | |
| 72 | + | Hosts: []ports.RemoteHostSnapshot{reachableDirectoryHost("user@arch", now, catalogue.RemoteCatalogSession{ | |
| 73 | + | LifecycleID: domain.SessionLifecycleID{0x40}, Name: "remote-only", State: catalogue.RemoteCatalogSessionUp, | |
| 74 | + | Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-remote", Index: 0, Name: "main"}}, | |
| 75 | + | })}, | |
| 76 | + | }} | |
| 77 | + | }, | |
| 78 | + | }, | |
| 79 | + | { | |
| 80 | + | name: "failing", | |
| 81 | + | directory: func() *countingRemoteDirectory { | |
| 82 | + | return &countingRemoteDirectory{snapshot: ports.RemoteDirectorySnapshot{ | |
| 83 | + | Revision: 4, Initialized: true, | |
| 84 | + | Hosts: []ports.RemoteHostSnapshot{{ | |
| 85 | + | Endpoint: "user@gone", Availability: domain.RemoteAvailabilityUnreachable, | |
| 86 | + | LastFailure: domain.RemoteFailure{Kind: domain.RemoteFailureAuthentication, Err: errors.New("ssh refused")}, | |
| 87 | + | ConsecutiveFailures: 2, FailureEpisode: 5, | |
| 88 | + | }}, | |
| 89 | + | }} | |
| 90 | + | }, | |
| 91 | + | }, | |
| 92 | + | } | |
| 93 | + | for _, tc := range cases { | |
| 94 | + | t.Run(tc.name, func(t *testing.T) { | |
| 95 | + | d := newTestDaemon(t, nil, fixedRemoteRefreshClock{now: now}) | |
| 96 | + | populateLocal(d) | |
| 97 | + | directory := tc.directory() | |
| 98 | + | d.remoteDirectory = directory | |
| 99 | + | ||
| 100 | + | got, err := controlExec{d: d}.RemoteCatalog(true) | |
| 101 | + | require.NoError(t, err) | |
| 102 | + | require.Equal(t, want, got, "local catalogue must not depend on remote monitoring") | |
| 103 | + | require.Equal(t, 0, directory.snapshots, "local catalogue must never read the remote directory") | |
| 104 | + | require.NotContains(t, got, "remote-only") | |
| 105 | + | require.NotContains(t, got, "user@gone") | |
| 106 | + | }) | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | // TestLocalNavigationInventorySnapshotIsRemoteFree proves the prepared local | |
| 111 | + | // navigation projection publishes exactly one local group with named live | |
| 112 | + | // sessions and resumable stopped sessions only, while the hybrid snapshot still | |
| 113 | + | // composes the foreign group. | |
| 114 | + | func TestLocalNavigationInventorySnapshotIsRemoteFree(t *testing.T) { | |
| 115 | + | now := time.Unix(1_000, 0) | |
| 116 | + | d := newTestDaemon(t, nil, fixedRemoteRefreshClock{now: now}) | |
| 117 | + | seedLocalNamedSession(d, "named", domain.SessionLifecycleID{0x10}) | |
| 118 | + | ephemeral := addControlSession(d, "ephemeral", "tab-eph", "pane-eph") | |
| 119 | + | ephemeral.ephemeral = true | |
| 120 | + | ephemeral.incarnation = domain.SessionLifecycleID{0x11} | |
| 121 | + | d.mu.Lock() | |
| 122 | + | d.inactive["down"] = inactiveSession{name: "down", cwd: "/tmp/down", createdAt: 1, incarnation: domain.SessionLifecycleID{0x20}, state: protocol.SessionDown} | |
| 123 | + | d.inactive["broken"] = inactiveSession{name: "broken", cwd: "/tmp/broken", createdAt: 2, incarnation: domain.SessionLifecycleID{0x21}, state: protocol.SessionBroken} | |
| 124 | + | d.inactive["purging"] = inactiveSession{name: "purging", cwd: "/tmp/purging", createdAt: 3, incarnation: domain.SessionLifecycleID{0x22}, state: protocol.SessionDown, purging: true} | |
| 125 | + | d.mu.Unlock() | |
| 126 | + | counting := &countingRemoteDirectory{snapshot: ports.RemoteDirectorySnapshot{ | |
| 127 | + | Revision: 2, Initialized: true, | |
| 128 | + | Hosts: []ports.RemoteHostSnapshot{reachableDirectoryHost("user@arch", now, catalogue.RemoteCatalogSession{ | |
| 129 | + | LifecycleID: domain.SessionLifecycleID{0x40}, Name: "remote", State: catalogue.RemoteCatalogSessionUp, | |
| 130 | + | Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-remote", Index: 0, Name: "main"}}, | |
| 131 | + | })}, | |
| 132 | + | }} | |
| 133 | + | d.remoteDirectory = counting | |
| 134 | + | ||
| 135 | + | response := d.localNavigationInventorySnapshot(1) | |
| 136 | + | require.NoError(t, protocol.ValidateNavigationInventoryResponse(response)) | |
| 137 | + | require.Len(t, response.Groups, 1, "prepared local snapshot carries exactly one source group") | |
| 138 | + | require.Equal(t, 0, counting.snapshots, "prepared local snapshot must never read the remote directory") | |
| 139 | + | group := response.Groups[0] | |
| 140 | + | require.Equal(t, protocol.NavigationInventoryLocalSourceKey, group.SourceKey) | |
| 141 | + | require.Equal(t, protocol.NavigationInventorySourceOK, group.Status) | |
| 142 | + | ||
| 143 | + | names := map[string]int{} | |
| 144 | + | for _, entry := range group.Entries { | |
| 145 | + | names[entry.Name]++ | |
| 146 | + | require.NotEqual(t, "remote", entry.Name, "foreign rows never enter the local group") | |
| 147 | + | } | |
| 148 | + | require.Equal(t, 1, names["named"], "named live sessions are exported") | |
| 149 | + | require.NotContains(t, names, "ephemeral", "ephemeral live sessions are never exported") | |
| 150 | + | require.Equal(t, 1, names["down"], "resumable stopped sessions are exported") | |
| 151 | + | require.NotContains(t, names, "broken", "broken records are not resumable") | |
| 152 | + | require.NotContains(t, names, "purging", "purging records are not visible") | |
| 153 | + | ||
| 154 | + | // The hybrid snapshot still composes local first then the foreign source. | |
| 155 | + | hybrid := d.snapshotNavigationInventory(2) | |
| 156 | + | require.NoError(t, protocol.ValidateNavigationInventoryResponse(hybrid)) | |
| 157 | + | require.Len(t, hybrid.Groups, 2) | |
| 158 | + | require.Equal(t, protocol.NavigationInventoryLocalSourceKey, hybrid.Groups[0].SourceKey) | |
| 159 | + | require.Equal(t, remoteInventorySourceKey("user@arch"), hybrid.Groups[1].SourceKey) | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | // TestLocalInventoryOverlapIsKeyedByLifecycle proves an overlapping live and | |
| 163 | + | // stopped record stay distinct by lifecycle-qualified key (never by name), the | |
| 164 | + | // stopped key resolves to the stopped representation while it exists and the | |
| 165 | + | // live key to the live one, and the catalogue export deduplicates the | |
| 166 | + | // overlapping stopped record by name. | |
| 167 | + | func TestLocalInventoryOverlapIsKeyedByLifecycle(t *testing.T) { | |
| 168 | + | now := time.Unix(1_000, 0) | |
| 169 | + | d := newTestDaemon(t, nil, fixedRemoteRefreshClock{now: now}) | |
| 170 | + | live := seedLocalNamedSession(d, "work", domain.SessionLifecycleID{0x41}) | |
| 171 | + | d.mu.Lock() | |
| 172 | + | d.inactive["work"] = inactiveSession{name: "work", cwd: "/tmp/work", createdAt: 5, incarnation: domain.SessionLifecycleID{0x42}, state: protocol.SessionDown} | |
| 173 | + | d.mu.Unlock() | |
| 174 | + | ||
| 175 | + | response := d.localNavigationInventorySnapshot(1) | |
| 176 | + | require.NoError(t, protocol.ValidateNavigationInventoryResponse(response)) | |
| 177 | + | var liveKey, stoppedKey string | |
| 178 | + | for _, entry := range response.Groups[0].Entries { | |
| 179 | + | if entry.Name != "work" { | |
| 180 | + | continue | |
| 181 | + | } | |
| 182 | + | switch entry.State { | |
| 183 | + | case "up": | |
| 184 | + | liveKey = entry.EntryKey | |
| 185 | + | case "stopped": | |
| 186 | + | stoppedKey = entry.EntryKey | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | require.NotEmpty(t, liveKey) | |
| 190 | + | require.NotEmpty(t, stoppedKey) | |
| 191 | + | require.NotEqual(t, liveKey, stoppedKey, "overlapping representations never share a key") | |
| 192 | + | ||
| 193 | + | liveTarget, ok := d.localNavigationResolve(localNavigationRequest(liveKey)) | |
| 194 | + | require.True(t, ok) | |
| 195 | + | require.Equal(t, live.incarnation, liveTarget.LifecycleID) | |
| 196 | + | stoppedTarget, ok := d.localNavigationResolve(localNavigationRequest(stoppedKey)) | |
| 197 | + | require.True(t, ok) | |
| 198 | + | require.Equal(t, domain.SessionLifecycleID{0x42}, stoppedTarget.LifecycleID) | |
| 199 | + | ||
| 200 | + | out, err := controlExec{d: d}.RemoteCatalog(true) | |
| 201 | + | require.NoError(t, err) | |
| 202 | + | require.Equal(t, 1, strings.Count(out, `"work"`), "catalogue export deduplicates an overlapping stopped record by name") | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | // TestLocalNavigationResolveExactLifecycle covers the prepared resolver's | |
| 206 | + | // lifecycle discipline: broken and purging records reject, a stopped target | |
| 207 | + | // that becomes live with the same lifecycle stays resolvable, and a replaced | |
| 208 | + | // lifecycle never falls back to a same-name session. | |
| 209 | + | func TestLocalNavigationResolveExactLifecycle(t *testing.T) { | |
| 210 | + | d := newTestDaemon(t, nil, fixedRemoteRefreshClock{now: time.Unix(1_000, 0)}) | |
| 211 | + | // Broken and purging records are never resumable. | |
| 212 | + | d.mu.Lock() | |
| 213 | + | d.inactive["broken"] = inactiveSession{name: "broken", cwd: "/tmp/broken", createdAt: 1, incarnation: domain.SessionLifecycleID{0x61}, state: protocol.SessionBroken} | |
| 214 | + | d.inactive["purging"] = inactiveSession{name: "purging", cwd: "/tmp/purging", createdAt: 2, incarnation: domain.SessionLifecycleID{0x62}, state: protocol.SessionDown, purging: true} | |
| 215 | + | // A stale key that will be replaced by a fresh lifecycle. | |
| 216 | + | d.inactive["gone"] = inactiveSession{name: "gone", cwd: "/tmp/gone", createdAt: 3, incarnation: domain.SessionLifecycleID{0x51}, state: protocol.SessionDown} | |
| 217 | + | d.mu.Unlock() | |
| 218 | + | ||
| 219 | + | _, ok := d.localNavigationResolve(localNavigationRequest(navigationInventoryEntryKey(domain.SessionLifecycleID{0x61}, "broken"))) | |
| 220 | + | require.False(t, ok, "broken records never resolve") | |
| 221 | + | _, ok = d.localNavigationResolve(localNavigationRequest(navigationInventoryEntryKey(domain.SessionLifecycleID{0x62}, "purging"))) | |
| 222 | + | require.False(t, ok, "purging records never resolve") | |
| 223 | + | ||
| 224 | + | // Stop-to-live in place: the same lifecycle stays addressable by its key. | |
| 225 | + | d.mu.Lock() | |
| 226 | + | d.inactive["live"] = inactiveSession{name: "live", cwd: "/tmp/live", createdAt: 4, incarnation: domain.SessionLifecycleID{0x53}, state: protocol.SessionDown} | |
| 227 | + | d.mu.Unlock() | |
| 228 | + | liveKey := navigationInventoryEntryKey(domain.SessionLifecycleID{0x53}, "live") | |
| 229 | + | stoppedTarget, ok := d.localNavigationResolve(localNavigationRequest(liveKey)) | |
| 230 | + | require.True(t, ok) | |
| 231 | + | require.Equal(t, domain.SessionLifecycleID{0x53}, stoppedTarget.LifecycleID) | |
| 232 | + | addInventorySession(d, "live", domain.SessionLifecycleID{0x53}, false) | |
| 233 | + | d.mu.Lock() | |
| 234 | + | delete(d.inactive, "live") | |
| 235 | + | d.mu.Unlock() | |
| 236 | + | resumedTarget, ok := d.localNavigationResolve(localNavigationRequest(liveKey)) | |
| 237 | + | require.True(t, ok, "a stopped target that becomes live with the same lifecycle stays resolvable") | |
| 238 | + | require.Equal(t, domain.SessionLifecycleID{0x53}, resumedTarget.LifecycleID) | |
| 239 | + | ||
| 240 | + | // Replacement: the old lifecycle key must not match the new incarnation. | |
| 241 | + | oldKey := navigationInventoryEntryKey(domain.SessionLifecycleID{0x51}, "gone") | |
| 242 | + | _, ok = d.localNavigationResolve(localNavigationRequest(oldKey)) | |
| 243 | + | require.True(t, ok) | |
| 244 | + | d.mu.Lock() | |
| 245 | + | delete(d.inactive, "gone") | |
| 246 | + | d.mu.Unlock() | |
| 247 | + | addInventorySession(d, "gone", domain.SessionLifecycleID{0x52}, false) | |
| 248 | + | _, ok = d.localNavigationResolve(localNavigationRequest(oldKey)) | |
| 249 | + | require.False(t, ok, "a stale lifecycle never resolves to a same-name replacement") | |
| 250 | + | replacement, ok := d.localNavigationResolve(localNavigationRequest(navigationInventoryEntryKey(domain.SessionLifecycleID{0x52}, "gone"))) | |
| 251 | + | require.True(t, ok) | |
| 252 | + | require.Equal(t, domain.SessionLifecycleID{0x52}, replacement.LifecycleID) | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | // TestLocalNavigationResolveRejectsForeignBeforeNameLookup proves a foreign | |
| 256 | + | // source key or a structured foreign registration is rejected even when a | |
| 257 | + | // local session shares the row's name. | |
| 258 | + | func TestLocalNavigationResolveRejectsForeignBeforeNameLookup(t *testing.T) { | |
| 259 | + | d := newTestDaemon(t, nil, fixedRemoteRefreshClock{now: time.Unix(1_000, 0)}) | |
| 260 | + | addInventorySession(d, "shared", domain.SessionLifecycleID{0x71}, false) | |
| 261 | + | key := navigationInventoryEntryKey(domain.SessionLifecycleID{0x71}, "shared") | |
| 262 | + | ||
| 263 | + | _, ok := d.localNavigationResolve(localNavigationRequest(key)) | |
| 264 | + | require.True(t, ok, "the exact local key resolves") | |
| 265 | + | ||
| 266 | + | foreignSource := localNavigationRequest(key) | |
| 267 | + | foreignSource.SourceKey = remoteInventorySourceKey("user@arch") | |
| 268 | + | _, ok = d.localNavigationResolve(foreignSource) | |
| 269 | + | require.False(t, ok, "a foreign source key is never a local resolve") | |
| 270 | + | ||
| 271 | + | registration, err := domain.NewRemoteRegistration("user@arch", [16]byte{7}) | |
| 272 | + | require.NoError(t, err) | |
| 273 | + | foreignRegistration := localNavigationRequest(key) | |
| 274 | + | foreignRegistration.Registration = registration | |
| 275 | + | _, ok = d.localNavigationResolve(foreignRegistration) | |
| 276 | + | require.False(t, ok, "a structured foreign registration is rejected even with a same-name local session") | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | // TestLocalPickerControlResolveExactness covers the prepared local control | |
| 280 | + | // resolver: exact lifecycle, stale tab rejection, and foreign target | |
| 281 | + | // rejection, with no name fallback. | |
| 282 | + | func TestLocalPickerControlResolveExactness(t *testing.T) { | |
| 283 | + | d := newTestDaemon(t, nil, fixedRemoteRefreshClock{now: time.Unix(1_000, 0)}) | |
| 284 | + | sess := seedLocalNamedSession(d, "work", domain.SessionLifecycleID{0x81}) | |
| 285 | + | ||
| 286 | + | resolved, ok := d.localPickerControlAttachTarget(picker.Target{Session: sess.id, Incarnation: sess.incarnation, TabID: "tab-work"}) | |
| 287 | + | require.True(t, ok, "the current exact lifecycle and tab resolve") | |
| 288 | + | require.Equal(t, "work", resolved.Session) | |
| 289 | + | require.NotNil(t, resolved.ExactTarget) | |
| 290 | + | require.Equal(t, domain.SessionLifecycleID{0x81}, resolved.ExactTarget.LifecycleID) | |
| 291 | + | require.Equal(t, domain.TabStableID("tab-work"), resolved.PreferredTabID) | |
| 292 | + | ||
| 293 | + | _, ok = d.localPickerControlAttachTarget(picker.Target{Session: sess.id, Incarnation: sess.incarnation}) | |
| 294 | + | require.True(t, ok, "a session header with no preferred tab resolves") | |
| 295 | + | ||
| 296 | + | _, ok = d.localPickerControlAttachTarget(picker.Target{Session: sess.id, Incarnation: sess.incarnation, TabID: "tab-stale"}) | |
| 297 | + | require.False(t, ok, "a stale tab rejects instead of forwarding a dangling tab ID") | |
| 298 | + | ||
| 299 | + | _, ok = d.localPickerControlAttachTarget(picker.Target{Session: sess.id, Incarnation: domain.IncarnationID{0x82}, TabID: "tab-work"}) | |
| 300 | + | require.False(t, ok, "a replaced lifecycle never resolves by name") | |
| 301 | + | ||
| 302 | + | _, ok = d.localPickerControlAttachTarget(picker.Target{Session: sess.id, Incarnation: sess.incarnation, RemoteTarget: &domain.RemoteSessionTarget{}}) | |
| 303 | + | require.False(t, ok, "a structured foreign target is rejected before any lookup") | |
| 304 | + | _, ok = d.localPickerControlAttachTarget(picker.Target{Session: sess.id, Incarnation: sess.incarnation, RemoteKey: &domain.RemoteSessionKey{Host: "user@arch", Name: "work"}}) | |
| 305 | + | require.False(t, ok, "a foreign session key is rejected before any lookup") | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | // TestLocalProjectionsPreserveInventoryCounts proves the prepared local | |
| 309 | + | // projections and resolvers, driven with no presenting attachment, leave the | |
| 310 | + | // live session registry, the stopped records, and the seeded session's | |
| 311 | + | // attachment and tab slices unchanged, and never open a PTY. The attached | |
| 312 | + | // picker path (non-nil cur/ac) is deliberately not exercised here: | |
| 313 | + | // localPickerViews may repair that attachment's own view, which is | |
| 314 | + | // presentation state rather than shared inventory. | |
| 315 | + | func TestLocalProjectionsPreserveInventoryCounts(t *testing.T) { | |
| 316 | + | d := newTestDaemon(t, failPTYFactory{t}, stubClock{}) | |
| 317 | + | sess := seedLocalNamedSession(d, "work", domain.SessionLifecycleID{0x91}) | |
| 318 | + | d.mu.Lock() | |
| 319 | + | d.inactive["stopped"] = inactiveSession{name: "stopped", cwd: "/tmp/stopped", createdAt: 1, incarnation: domain.SessionLifecycleID{0x92}, state: protocol.SessionDown} | |
| 320 | + | sessionsBefore := len(d.sessions) | |
| 321 | + | stoppedBefore := len(d.inactive) | |
| 322 | + | d.mu.Unlock() | |
| 323 | + | sess.mu.Lock() | |
| 324 | + | attachmentsBefore := len(sess.attachments) | |
| 325 | + | tabsBefore := len(sess.tabs) | |
| 326 | + | sess.mu.Unlock() | |
| 327 | + | ||
| 328 | + | response := d.localNavigationInventorySnapshot(1) | |
| 329 | + | require.NoError(t, protocol.ValidateNavigationInventoryResponse(response)) | |
| 330 | + | _, ok := d.localNavigationResolve(localNavigationRequest(navigationInventoryEntryKey(domain.SessionLifecycleID{0x91}, "work"))) | |
| 331 | + | require.True(t, ok) | |
| 332 | + | _, _, _ = d.localPickerViewProjections(nil, nil) | |
| 333 | + | _, ok = d.localPickerControlAttachTarget(picker.Target{Session: sess.id, Incarnation: sess.incarnation, TabID: "tab-work"}) | |
| 334 | + | require.True(t, ok) | |
| 335 | + | ||
| 336 | + | d.mu.Lock() | |
| 337 | + | require.Equal(t, sessionsBefore, len(d.sessions), "local projections never create or remove sessions") | |
| 338 | + | require.Equal(t, stoppedBefore, len(d.inactive), "local projections never restore or purge stopped records") | |
| 339 | + | d.mu.Unlock() | |
| 340 | + | sess.mu.Lock() | |
| 341 | + | require.Equal(t, attachmentsBefore, len(sess.attachments), "local projections never attach") | |
| 342 | + | require.Equal(t, tabsBefore, len(sess.tabs), "local projections never reshape geometry") | |
| 343 | + | sess.mu.Unlock() | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | // TestLocalProjectionMatchesHybridWithoutRemote proves the prepared local | |
| 347 | + | // picker projection is exactly what the hybrid projection produces when no | |
| 348 | + | // remote directory is installed, so the P7 cutover can drop the foreign rows | |
| 349 | + | // without changing local presentation. | |
| 350 | + | func TestLocalProjectionMatchesHybridWithoutRemote(t *testing.T) { | |
| 351 | + | d := newTestDaemon(t, nil, fixedRemoteRefreshClock{now: time.Unix(1_000, 0)}) | |
| 352 | + | seedLocalNamedSession(d, "work", domain.SessionLifecycleID{0xA1}) | |
| 353 | + | other := seedLocalNamedSession(d, "other", domain.SessionLifecycleID{0xA2}) | |
| 354 | + | other.mruAt.Store(2) | |
| 355 | + | d.mu.Lock() | |
| 356 | + | d.inactive["stopped"] = inactiveSession{name: "stopped", cwd: "/tmp/stopped", createdAt: 1, incarnation: domain.SessionLifecycleID{0xA3}, state: protocol.SessionDown} | |
| 357 | + | d.mu.Unlock() | |
| 358 | + | require.Nil(t, d.remoteDirectory) | |
| 359 | + | ||
| 360 | + | localRecent, localGrouped, localCurrent := d.localPickerViewProjections(nil, nil) | |
| 361 | + | hybridRecent, hybridGrouped, hybridCurrent := d.pickerViewProjections(nil, nil) | |
| 362 | + | require.Equal(t, hybridRecent, localRecent) | |
| 363 | + | require.Equal(t, hybridGrouped, localGrouped) | |
| 364 | + | require.Equal(t, hybridCurrent, localCurrent) | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | // TestLocalProjectionIgnoresDirectoryAndMatchesFilteredHybrid proves the | |
| 368 | + | // prepared local picker projection is independent of remote monitoring and is | |
| 369 | + | // exactly the hybrid projection with its foreign rows removed. The local call | |
| 370 | + | // must never read the directory (zero snapshots), must not change when the | |
| 371 | + | // directory is installed or removed, and — compared row-for-row after dropping | |
| 372 | + | // exactly the foreign rows by ID — must preserve the hybrid's local ordering. | |
| 373 | + | func TestLocalProjectionIgnoresDirectoryAndMatchesFilteredHybrid(t *testing.T) { | |
| 374 | + | now := time.Unix(1_000, 0) | |
| 375 | + | d := newTestDaemon(t, nil, fixedRemoteRefreshClock{now: now}) | |
| 376 | + | seedLocalNamedSession(d, "alpha", domain.SessionLifecycleID{0xC1}) | |
| 377 | + | d.mu.Lock() | |
| 378 | + | d.inactive["zulu"] = inactiveSession{name: "zulu", cwd: "/tmp/zulu", createdAt: 5, incarnation: domain.SessionLifecycleID{0xC2}, state: protocol.SessionDown} | |
| 379 | + | d.mu.Unlock() | |
| 380 | + | directory := &countingRemoteDirectory{snapshot: ports.RemoteDirectorySnapshot{Revision: 1, Initialized: true, | |
| 381 | + | Hosts: []ports.RemoteHostSnapshot{reachableDirectoryHost("user@arch", now, catalogue.RemoteCatalogSession{ | |
| 382 | + | LifecycleID: domain.SessionLifecycleID{0xC3}, Name: "delta", State: catalogue.RemoteCatalogSessionUp, | |
| 383 | + | Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-delta", Index: 0, Name: "main"}}, ActiveTabID: "tab-delta", | |
| 384 | + | })}}} | |
| 385 | + | d.remoteDirectory = directory | |
| 386 | + | ||
| 387 | + | localRecent, localGrouped, localCurrent := d.localPickerViewProjections(nil, nil) | |
| 388 | + | require.Zero(t, directory.snapshots, "the prepared local projection must never read the remote directory") | |
| 389 | + | ||
| 390 | + | // Dependency: an installed directory cannot change the local projection. | |
| 391 | + | d.remoteDirectory = nil | |
| 392 | + | bareRecent, bareGrouped, bareCurrent := d.localPickerViewProjections(nil, nil) | |
| 393 | + | require.Equal(t, localRecent, bareRecent, "local rows never depend on the directory") | |
| 394 | + | require.Equal(t, localGrouped, bareGrouped, "local sections never depend on the directory") | |
| 395 | + | require.Equal(t, localCurrent, bareCurrent) | |
| 396 | + | ||
| 397 | + | // The hybrid projection composes the same local rows with the foreign ones. | |
| 398 | + | d.remoteDirectory = directory | |
| 399 | + | hybridRecent, hybridGrouped, hybridCurrent := d.pickerViewProjections(nil, nil) | |
| 400 | + | require.Positive(t, directory.snapshots, "the hybrid projection does read the installed directory") | |
| 401 | + | require.Equal(t, localCurrent, hybridCurrent) | |
| 402 | + | ||
| 403 | + | foreign := d.foreignPickerViews(now) | |
| 404 | + | foreignIDs := map[domain.SessionID]struct{}{} | |
| 405 | + | for _, row := range foreign.recent { | |
| 406 | + | foreignIDs[row.ID] = struct{}{} | |
| 407 | + | } | |
| 408 | + | for _, row := range foreign.grouped { | |
| 409 | + | foreignIDs[row.ID] = struct{}{} | |
| 410 | + | } | |
| 411 | + | require.NotEmpty(t, foreignIDs) | |
| 412 | + | // Drop exactly the foreign rows by ID — never by name or section — so a | |
| 413 | + | // local row cannot be hidden and the surviving order is compared in full. | |
| 414 | + | require.Equal(t, localRecent, dropPickerRows(hybridRecent, foreignIDs), "filtered recent order matches the local projection") | |
| 415 | + | require.Equal(t, localGrouped, dropPickerRows(hybridGrouped, foreignIDs), "filtered grouped order matches the local projection") | |
| 416 | + | } | |
| 417 | + | ||
| 418 | + | // dropPickerRows returns rows with none of ids, preserving order. | |
| 419 | + | func dropPickerRows(rows []pickerSessionView, ids map[domain.SessionID]struct{}) []pickerSessionView { | |
| 420 | + | kept := make([]pickerSessionView, 0, len(rows)) | |
| 421 | + | for _, row := range rows { | |
| 422 | + | if _, ok := ids[row.ID]; ok { | |
| 423 | + | continue | |
| 424 | + | } | |
| 425 | + | kept = append(kept, row) | |
| 426 | + | } | |
| 427 | + | return kept | |
| 428 | + | } | |
| 429 | + | ||
| 430 | + | // pickerRowShape is the exact slice element the hybrid ordering tests compare: | |
| 431 | + | // the rendered row name, its section label, and whether it is a stopped row. | |
| 432 | + | type pickerRowShape struct { | |
| 433 | + | Name string | |
| 434 | + | Section string | |
| 435 | + | Stopped bool | |
| 436 | + | } | |
| 437 | + | ||
| 438 | + | func pickerRowShapes(views []pickerSessionView) []pickerRowShape { | |
| 439 | + | shapes := make([]pickerRowShape, 0, len(views)) | |
| 440 | + | for _, view := range views { | |
| 441 | + | shapes = append(shapes, pickerRowShape{Name: view.Name, Section: view.Section, Stopped: view.Stopped}) | |
| 442 | + | } | |
| 443 | + | return shapes | |
| 444 | + | } | |
| 445 | + | ||
| 446 | + | // TestHybridPickerOrderingAcrossRemoteDirectoryStates pins the exact Recent and | |
| 447 | + | // Grouped hybrid projections across the remote-directory states: a populated | |
| 448 | + | // directory, an installed-but-never-initialized directory that publishes the | |
| 449 | + | // checking placeholder, and no installed directory at all. The foreign rows | |
| 450 | + | // interleave between the local live and stopped groups, the first published row | |
| 451 | + | // of each remote host carries the "REMOTE <endpoint>" label, the checking | |
| 452 | + | // placeholder carries "REMOTE", and any raw catalogue row — including an | |
| 453 | + | // invalid-key one that publishes no row of its own — suppresses the LOCAL | |
| 454 | + | // label on a trailing stopped row (the `catalogRows > 0 || checking` rule the | |
| 455 | + | // refactor re-derived). With no installed directory a stopped-only row still | |
| 456 | + | // opens the grouped LOCAL section while the recent projection stays | |
| 457 | + | // section-free. The prepared local projection is this same shape with the | |
| 458 | + | // foreign rows removed, so these cases fix the row-for-row contract the P7 | |
| 459 | + | // cutover must preserve. | |
| 460 | + | func TestHybridPickerOrderingAcrossRemoteDirectoryStates(t *testing.T) { | |
| 461 | + | now := time.Unix(1_000, 0) | |
| 462 | + | remoteSession := catalogue.RemoteCatalogSession{ | |
| 463 | + | LifecycleID: domain.SessionLifecycleID{0xD1}, Name: "delta", State: catalogue.RemoteCatalogSessionUp, | |
| 464 | + | Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-delta", Index: 0, Name: "main"}}, | |
| 465 | + | ActiveTabID: "tab-delta", | |
| 466 | + | } | |
| 467 | + | // A catalogue session whose name cannot form a valid remote key: the row is | |
| 468 | + | // never published, but the raw entry still counts toward catalogRows. | |
| 469 | + | invalidKeySession := catalogue.RemoteCatalogSession{ | |
| 470 | + | LifecycleID: domain.SessionLifecycleID{0xD2}, Name: "", State: catalogue.RemoteCatalogSessionUp, | |
| 471 | + | } | |
| 472 | + | seedStoppedZulu := func(d *Daemon) { | |
| 473 | + | d.mu.Lock() | |
| 474 | + | d.inactive["zulu"] = inactiveSession{name: "zulu", cwd: "/tmp/zulu", createdAt: 5, incarnation: domain.SessionLifecycleID{0xB2}, state: protocol.SessionDown} | |
| 475 | + | d.mu.Unlock() | |
| 476 | + | } | |
| 477 | + | ||
| 478 | + | cases := []struct { | |
| 479 | + | name string | |
| 480 | + | seed func(d *Daemon) | |
| 481 | + | directory func() ports.RemoteDirectory | |
| 482 | + | wantRecent []pickerRowShape | |
| 483 | + | wantGrouped []pickerRowShape | |
| 484 | + | }{ | |
| 485 | + | { | |
| 486 | + | name: "live+remote+stopped", | |
| 487 | + | seed: func(d *Daemon) { | |
| 488 | + | seedLocalNamedSession(d, "alpha", domain.SessionLifecycleID{0xB1}) | |
| 489 | + | seedStoppedZulu(d) | |
| 490 | + | }, | |
| 491 | + | directory: func() ports.RemoteDirectory { | |
| 492 | + | return &countingRemoteDirectory{snapshot: ports.RemoteDirectorySnapshot{Revision: 1, Initialized: true, | |
| 493 | + | Hosts: []ports.RemoteHostSnapshot{reachableDirectoryHost("user@arch", now, remoteSession)}}} | |
| 494 | + | }, | |
| 495 | + | wantRecent: []pickerRowShape{{Name: "alpha"}, {Name: "delta@arch"}, {Name: "zulu", Stopped: true}}, | |
| 496 | + | wantGrouped: []pickerRowShape{{Name: "alpha", Section: "LOCAL"}, {Name: "delta@arch", Section: "REMOTE user@arch"}, {Name: "zulu", Stopped: true}}, | |
| 497 | + | }, | |
| 498 | + | { | |
| 499 | + | name: "remote-only", | |
| 500 | + | seed: func(*Daemon) {}, | |
| 501 | + | directory: func() ports.RemoteDirectory { | |
| 502 | + | return &countingRemoteDirectory{snapshot: ports.RemoteDirectorySnapshot{Revision: 2, Initialized: true, | |
| 503 | + | Hosts: []ports.RemoteHostSnapshot{reachableDirectoryHost("user@arch", now, remoteSession)}}} | |
| 504 | + | }, | |
| 505 | + | wantRecent: []pickerRowShape{{Name: "delta@arch"}}, | |
| 506 | + | wantGrouped: []pickerRowShape{{Name: "delta@arch", Section: "REMOTE user@arch"}}, | |
| 507 | + | }, | |
| 508 | + | { | |
| 509 | + | name: "checking-only", | |
| 510 | + | seed: func(*Daemon) {}, | |
| 511 | + | directory: func() ports.RemoteDirectory { | |
| 512 | + | return &countingRemoteDirectory{snapshot: ports.RemoteDirectorySnapshot{Revision: 3}} | |
| 513 | + | }, | |
| 514 | + | wantRecent: []pickerRowShape{{Name: "checking remotes\u2026"}}, | |
| 515 | + | wantGrouped: []pickerRowShape{{Name: "checking remotes\u2026", Section: "REMOTE"}}, | |
| 516 | + | }, | |
| 517 | + | { | |
| 518 | + | name: "stopped-only, no directory", | |
| 519 | + | seed: seedStoppedZulu, | |
| 520 | + | directory: func() ports.RemoteDirectory { | |
| 521 | + | return nil | |
| 522 | + | }, | |
| 523 | + | // With no installed directory the stopped row opens the grouped | |
| 524 | + | // LOCAL section, and the recent projection stays section-free. | |
| 525 | + | wantRecent: []pickerRowShape{{Name: "zulu", Stopped: true}}, | |
| 526 | + | wantGrouped: []pickerRowShape{{Name: "zulu", Section: "LOCAL", Stopped: true}}, | |
| 527 | + | }, | |
| 528 | + | { | |
| 529 | + | name: "checking+stopped", | |
| 530 | + | seed: seedStoppedZulu, | |
| 531 | + | directory: func() ports.RemoteDirectory { | |
| 532 | + | return &countingRemoteDirectory{snapshot: ports.RemoteDirectorySnapshot{Revision: 5}} | |
| 533 | + | }, | |
| 534 | + | // The checking placeholder precedes the stopped row, and its raw | |
| 535 | + | // state suppresses the LOCAL label, so the stopped row keeps no | |
| 536 | + | // section. | |
| 537 | + | wantRecent: []pickerRowShape{{Name: "checking remotes\u2026"}, {Name: "zulu", Stopped: true}}, | |
| 538 | + | wantGrouped: []pickerRowShape{{Name: "checking remotes\u2026", Section: "REMOTE"}, {Name: "zulu", Stopped: true}}, | |
| 539 | + | }, | |
| 540 | + | { | |
| 541 | + | name: "invalid-key host", | |
| 542 | + | seed: seedStoppedZulu, | |
| 543 | + | directory: func() ports.RemoteDirectory { | |
| 544 | + | return &countingRemoteDirectory{snapshot: ports.RemoteDirectorySnapshot{Revision: 4, Initialized: true, | |
| 545 | + | Hosts: []ports.RemoteHostSnapshot{reachableDirectoryHost("user@arch", now, invalidKeySession)}}} | |
| 546 | + | }, | |
| 547 | + | // The invalid-key host publishes no remote row, but its raw catalogue | |
| 548 | + | // entry still counts, so the trailing stopped row loses its LOCAL | |
| 549 | + | // label exactly as the pre-refactor projection did. | |
| 550 | + | wantRecent: []pickerRowShape{{Name: "zulu", Stopped: true}}, | |
| 551 | + | wantGrouped: []pickerRowShape{{Name: "zulu", Stopped: true}}, | |
| 552 | + | }, | |
| 553 | + | } | |
| 554 | + | ||
| 555 | + | for _, tc := range cases { | |
| 556 | + | t.Run(tc.name, func(t *testing.T) { | |
| 557 | + | d := newTestDaemon(t, nil, fixedRemoteRefreshClock{now: now}) | |
| 558 | + | tc.seed(d) | |
| 559 | + | d.remoteDirectory = tc.directory() | |
| 560 | + | ||
| 561 | + | recent, grouped, _ := d.pickerViewProjections(nil, nil) | |
| 562 | + | require.Equal(t, tc.wantRecent, pickerRowShapes(recent), "recent order and sections") | |
| 563 | + | require.Equal(t, tc.wantGrouped, pickerRowShapes(grouped), "grouped order and sections") | |
| 564 | + | }) | |
| 565 | + | } | |
| 566 | + | } | |
| 567 | + | ||
| 568 | + | // TestHybridPickerControlAttachTargetFallsBackOnVanishedTab is the GO-001 | |
| 569 | + | // TOCTOU case: the picker snapshot named a tab that is still present, the tab | |
| 570 | + | // closes before the resolve commits, and the hybrid wrapper must still attach | |
| 571 | + | // the exact local lifecycle with no preferred tab so the daemon performs the | |
| 572 | + | // same first-tab repair the client applies to a dangling id. The prepared | |
| 573 | + | // strict resolver keeps rejecting the stale tab, and a replaced lifecycle | |
| 574 | + | // still never falls back by name. | |
| 575 | + | func TestHybridPickerControlAttachTargetFallsBackOnVanishedTab(t *testing.T) { | |
| 576 | + | d := newTestDaemon(t, nil, fixedRemoteRefreshClock{now: time.Unix(1_000, 0)}) | |
| 577 | + | sess := seedLocalNamedSession(d, "work", domain.SessionLifecycleID{0x85}) | |
| 578 | + | target := picker.Target{Session: sess.id, Incarnation: sess.incarnation, TabID: "tab-work"} | |
| 579 | + | ||
| 580 | + | // Snapshot time: both paths forward the live tab. | |
| 581 | + | strict, ok := d.localPickerControlAttachTarget(target) | |
| 582 | + | require.True(t, ok) | |
| 583 | + | require.Equal(t, domain.TabStableID("tab-work"), strict.PreferredTabID) | |
| 584 | + | hybrid, ok := d.pickerControlAttachTarget(target) | |
| 585 | + | require.True(t, ok) | |
| 586 | + | require.Equal(t, domain.TabStableID("tab-work"), hybrid.PreferredTabID) | |
| 587 | + | ||
| 588 | + | // The tab closes between the snapshot and the resolve. | |
| 589 | + | sess.mu.Lock() | |
| 590 | + | sess.tabs = nil | |
| 591 | + | sess.mu.Unlock() | |
| 592 | + | ||
| 593 | + | _, ok = d.localPickerControlAttachTarget(target) | |
| 594 | + | require.False(t, ok, "the prepared strict resolver rejects the vanished tab") | |
| 595 | + | ||
| 596 | + | hybrid, ok = d.pickerControlAttachTarget(target) | |
| 597 | + | require.True(t, ok, "the hybrid wrapper preserves the live attach") | |
| 598 | + | require.Empty(t, hybrid.PreferredTabID, "the vanished tab falls back to the client's missing-tab repair") | |
| 599 | + | require.NotNil(t, hybrid.ExactTarget) | |
| 600 | + | require.Equal(t, domain.SessionLifecycleID{0x85}, hybrid.ExactTarget.LifecycleID) | |
| 601 | + | require.Equal(t, "work", hybrid.Session) | |
| 602 | + | ||
| 603 | + | // The fallback still never resolves a same-name replacement by name. | |
| 604 | + | _, ok = d.pickerControlAttachTarget(picker.Target{Session: sess.id, Incarnation: domain.IncarnationID{0x86}, TabID: "tab-work"}) | |
| 605 | + | require.False(t, ok, "a replaced lifecycle rejects even with the vanished-tab fallback") | |
| 606 | + | } | |
| @@ -0,0 +1,606 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/vev/internal/domain" | |||
| 13 | + | "github.com/bnema/vev/internal/ports" | |||
| 14 | + | "github.com/bnema/vev/internal/protocol" | |||
| 15 | + | "github.com/bnema/vev/internal/protocol/catalogue" | |||
| 16 | + | "github.com/bnema/vev/internal/usecase/picker" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | // failPTYFactory fails the test if a local projection ever opens a PTY. | |||
| 20 | + | type failPTYFactory struct{ t *testing.T } | |||
| 21 | + | ||||
| 22 | + | func (f failPTYFactory) Open(context.Context, string, []string, []string, string, domain.Geometry) (ports.PTY, error) { | |||
| 23 | + | f.t.Fatal("local projection must not open a PTY") | |||
| 24 | + | return nil, nil | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func localNavigationRequest(entryKey string) protocol.NavigationInventoryRequest { | |||
| 28 | + | return protocol.NavigationInventoryRequest{ | |||
| 29 | + | Version: protocol.Version, RequestID: 1, Operation: protocol.NavigationInventoryResolve, | |||
| 30 | + | SourceKey: protocol.NavigationInventoryLocalSourceKey, EntryKey: entryKey, | |||
| 31 | + | } | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func seedLocalNamedSession(d *Daemon, name string, incarnation domain.SessionLifecycleID) *session { | |||
| 35 | + | sess := addControlSession(d, name, "tab-"+name, "pane-"+name) | |||
| 36 | + | sess.ephemeral = false | |||
| 37 | + | sess.incarnation = incarnation | |||
| 38 | + | return sess | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // TestRemoteCatalogLocalCaptureIgnoresRemoteDirectory proves the catalogue | |||
| 42 | + | // export is a local-only projection: a populated and a failing remote | |||
| 43 | + | // directory both leave the exported document byte-identical to a daemon with | |||
| 44 | + | // no remote directory, and neither is ever read. | |||
| 45 | + | func TestRemoteCatalogLocalCaptureIgnoresRemoteDirectory(t *testing.T) { | |||
| 46 | + | now := time.Unix(1_000, 0) | |||
| 47 | + | populateLocal := func(d *Daemon) { | |||
| 48 | + | seedLocalNamedSession(d, "named", domain.SessionLifecycleID{0x01}) | |||
| 49 | + | ephemeral := addControlSession(d, "ephemeral", "tab-eph", "pane-eph") | |||
| 50 | + | ephemeral.ephemeral = true | |||
| 51 | + | ephemeral.incarnation = domain.SessionLifecycleID{0x02} | |||
| 52 | + | d.mu.Lock() | |||
| 53 | + | d.inactive["stopped"] = inactiveSession{name: "stopped", cwd: "/tmp/stopped", createdAt: 7, incarnation: domain.SessionLifecycleID{0x03}, state: protocol.SessionDown} | |||
| 54 | + | d.mu.Unlock() | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | reference := newTestDaemon(t, nil, fixedRemoteRefreshClock{now: now}) | |||
| 58 | + | populateLocal(reference) | |||
| 59 | + | want, err := controlExec{d: reference}.RemoteCatalog(true) | |||
| 60 | + | require.NoError(t, err) | |||
| 61 | + | require.Contains(t, want, `"named"`) | |||
| 62 | + | ||||
| 63 | + | cases := []struct { | |||
| 64 | + | name string | |||
| 65 | + | directory func() *countingRemoteDirectory | |||
| 66 | + | }{ | |||
| 67 | + | { | |||
| 68 | + | name: "populated", | |||
| 69 | + | directory: func() *countingRemoteDirectory { | |||
| 70 | + | return &countingRemoteDirectory{snapshot: ports.RemoteDirectorySnapshot{ | |||
| 71 | + | Revision: 3, Initialized: true, | |||
| 72 | + | Hosts: []ports.RemoteHostSnapshot{reachableDirectoryHost("user@arch", now, catalogue.RemoteCatalogSession{ | |||
| 73 | + | LifecycleID: domain.SessionLifecycleID{0x40}, Name: "remote-only", State: catalogue.RemoteCatalogSessionUp, | |||
| 74 | + | Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-remote", Index: 0, Name: "main"}}, | |||
| 75 | + | })}, | |||
| 76 | + | }} | |||
| 77 | + | }, | |||
| 78 | + | }, | |||
| 79 | + | { | |||
| 80 | + | name: "failing", | |||
| 81 | + | directory: func() *countingRemoteDirectory { | |||
| 82 | + | return &countingRemoteDirectory{snapshot: ports.RemoteDirectorySnapshot{ | |||
| 83 | + | Revision: 4, Initialized: true, | |||
| 84 | + | Hosts: []ports.RemoteHostSnapshot{{ | |||
| 85 | + | Endpoint: "user@gone", Availability: domain.RemoteAvailabilityUnreachable, | |||
| 86 | + | LastFailure: domain.RemoteFailure{Kind: domain.RemoteFailureAuthentication, Err: errors.New("ssh refused")}, | |||
| 87 | + | ConsecutiveFailures: 2, FailureEpisode: 5, | |||
| 88 | + | }}, | |||
| 89 | + | }} | |||
| 90 | + | }, | |||
| 91 | + | }, | |||
| 92 | + | } | |||
| 93 | + | for _, tc := range cases { | |||
| 94 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 95 | + | d := newTestDaemon(t, nil, fixedRemoteRefreshClock{now: now}) | |||
| 96 | + | populateLocal(d) | |||
| 97 | + | directory := tc.directory() | |||
| 98 | + | d.remoteDirectory = directory | |||
| 99 | + | ||||
| 100 | + | got, err := controlExec{d: d}.RemoteCatalog(true) | |||
| 101 | + | require.NoError(t, err) | |||
| 102 | + | require.Equal(t, want, got, "local catalogue must not depend on remote monitoring") | |||
| 103 | + | require.Equal(t, 0, directory.snapshots, "local catalogue must never read the remote directory") | |||
| 104 | + | require.NotContains(t, got, "remote-only") | |||
| 105 | + | require.NotContains(t, got, "user@gone") | |||
| 106 | + | }) | |||
| 107 | + | } | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | // TestLocalNavigationInventorySnapshotIsRemoteFree proves the prepared local | |||
| 111 | + | // navigation projection publishes exactly one local group with named live | |||
| 112 | + | // sessions and resumable stopped sessions only, while the hybrid snapshot still | |||
| 113 | + | // composes the foreign group. | |||
| 114 | + | func TestLocalNavigationInventorySnapshotIsRemoteFree(t *testing.T) { | |||
| 115 | + | now := time.Unix(1_000, 0) | |||
| 116 | + | d := newTestDaemon(t, nil, fixedRemoteRefreshClock{now: now}) | |||
| 117 | + | seedLocalNamedSession(d, "named", domain.SessionLifecycleID{0x10}) | |||
| 118 | + | ephemeral := addControlSession(d, "ephemeral", "tab-eph", "pane-eph") | |||
| 119 | + | ephemeral.ephemeral = true | |||
| 120 | + | ephemeral.incarnation = domain.SessionLifecycleID{0x11} | |||
| 121 | + | d.mu.Lock() | |||
| 122 | + | d.inactive["down"] = inactiveSession{name: "down", cwd: "/tmp/down", createdAt: 1, incarnation: domain.SessionLifecycleID{0x20}, state: protocol.SessionDown} | |||
| 123 | + | d.inactive["broken"] = inactiveSession{name: "broken", cwd: "/tmp/broken", createdAt: 2, incarnation: domain.SessionLifecycleID{0x21}, state: protocol.SessionBroken} | |||
| 124 | + | d.inactive["purging"] = inactiveSession{name: "purging", cwd: "/tmp/purging", createdAt: 3, incarnation: domain.SessionLifecycleID{0x22}, state: protocol.SessionDown, purging: true} | |||
| 125 | + | d.mu.Unlock() | |||
| 126 | + | counting := &countingRemoteDirectory{snapshot: ports.RemoteDirectorySnapshot{ | |||
| 127 | + | Revision: 2, Initialized: true, | |||
| 128 | + | Hosts: []ports.RemoteHostSnapshot{reachableDirectoryHost("user@arch", now, catalogue.RemoteCatalogSession{ | |||
| 129 | + | LifecycleID: domain.SessionLifecycleID{0x40}, Name: "remote", State: catalogue.RemoteCatalogSessionUp, | |||
| 130 | + | Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-remote", Index: 0, Name: "main"}}, | |||
| 131 | + | })}, | |||
| 132 | + | }} | |||
| 133 | + | d.remoteDirectory = counting | |||
| 134 | + | ||||
| 135 | + | response := d.localNavigationInventorySnapshot(1) | |||
| 136 | + | require.NoError(t, protocol.ValidateNavigationInventoryResponse(response)) | |||
| 137 | + | require.Len(t, response.Groups, 1, "prepared local snapshot carries exactly one source group") | |||
| 138 | + | require.Equal(t, 0, counting.snapshots, "prepared local snapshot must never read the remote directory") | |||
| 139 | + | group := response.Groups[0] | |||
| 140 | + | require.Equal(t, protocol.NavigationInventoryLocalSourceKey, group.SourceKey) | |||
| 141 | + | require.Equal(t, protocol.NavigationInventorySourceOK, group.Status) | |||
| 142 | + | ||||
| 143 | + | names := map[string]int{} | |||
| 144 | + | for _, entry := range group.Entries { | |||
| 145 | + | names[entry.Name]++ | |||
| 146 | + | require.NotEqual(t, "remote", entry.Name, "foreign rows never enter the local group") | |||
| 147 | + | } | |||
| 148 | + | require.Equal(t, 1, names["named"], "named live sessions are exported") | |||
| 149 | + | require.NotContains(t, names, "ephemeral", "ephemeral live sessions are never exported") | |||
| 150 | + | require.Equal(t, 1, names["down"], "resumable stopped sessions are exported") | |||
| 151 | + | require.NotContains(t, names, "broken", "broken records are not resumable") | |||
| 152 | + | require.NotContains(t, names, "purging", "purging records are not visible") | |||
| 153 | + | ||||
| 154 | + | // The hybrid snapshot still composes local first then the foreign source. | |||
| 155 | + | hybrid := d.snapshotNavigationInventory(2) | |||
| 156 | + | require.NoError(t, protocol.ValidateNavigationInventoryResponse(hybrid)) | |||
| 157 | + | require.Len(t, hybrid.Groups, 2) | |||
| 158 | + | require.Equal(t, protocol.NavigationInventoryLocalSourceKey, hybrid.Groups[0].SourceKey) | |||
| 159 | + | require.Equal(t, remoteInventorySourceKey("user@arch"), hybrid.Groups[1].SourceKey) | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | // TestLocalInventoryOverlapIsKeyedByLifecycle proves an overlapping live and | |||
| 163 | + | // stopped record stay distinct by lifecycle-qualified key (never by name), the | |||
| 164 | + | // stopped key resolves to the stopped representation while it exists and the | |||
| 165 | + | // live key to the live one, and the catalogue export deduplicates the | |||
| 166 | + | // overlapping stopped record by name. | |||
| 167 | + | func TestLocalInventoryOverlapIsKeyedByLifecycle(t *testing.T) { | |||
| 168 | + | now := time.Unix(1_000, 0) | |||
| 169 | + | d := newTestDaemon(t, nil, fixedRemoteRefreshClock{now: now}) | |||
| 170 | + | live := seedLocalNamedSession(d, "work", domain.SessionLifecycleID{0x41}) | |||
| 171 | + | d.mu.Lock() | |||
| 172 | + | d.inactive["work"] = inactiveSession{name: "work", cwd: "/tmp/work", createdAt: 5, incarnation: domain.SessionLifecycleID{0x42}, state: protocol.SessionDown} | |||
| 173 | + | d.mu.Unlock() | |||
| 174 | + | ||||
| 175 | + | response := d.localNavigationInventorySnapshot(1) | |||
| 176 | + | require.NoError(t, protocol.ValidateNavigationInventoryResponse(response)) | |||
| 177 | + | var liveKey, stoppedKey string | |||
| 178 | + | for _, entry := range response.Groups[0].Entries { | |||
| 179 | + | if entry.Name != "work" { | |||
| 180 | + | continue | |||
| 181 | + | } | |||
| 182 | + | switch entry.State { | |||
| 183 | + | case "up": | |||
| 184 | + | liveKey = entry.EntryKey | |||
| 185 | + | case "stopped": | |||
| 186 | + | stoppedKey = entry.EntryKey | |||
| 187 | + | } | |||
| 188 | + | } | |||
| 189 | + | require.NotEmpty(t, liveKey) | |||
| 190 | + | require.NotEmpty(t, stoppedKey) | |||
| 191 | + | require.NotEqual(t, liveKey, stoppedKey, "overlapping representations never share a key") | |||
| 192 | + | ||||
| 193 | + | liveTarget, ok := d.localNavigationResolve(localNavigationRequest(liveKey)) | |||
| 194 | + | require.True(t, ok) | |||
| 195 | + | require.Equal(t, live.incarnation, liveTarget.LifecycleID) | |||
| 196 | + | stoppedTarget, ok := d.localNavigationResolve(localNavigationRequest(stoppedKey)) | |||
| 197 | + | require.True(t, ok) | |||
| 198 | + | require.Equal(t, domain.SessionLifecycleID{0x42}, stoppedTarget.LifecycleID) | |||
| 199 | + | ||||
| 200 | + | out, err := controlExec{d: d}.RemoteCatalog(true) | |||
| 201 | + | require.NoError(t, err) | |||
| 202 | + | require.Equal(t, 1, strings.Count(out, `"work"`), "catalogue export deduplicates an overlapping stopped record by name") | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | // TestLocalNavigationResolveExactLifecycle covers the prepared resolver's | |||
| 206 | + | // lifecycle discipline: broken and purging records reject, a stopped target | |||
| 207 | + | // that becomes live with the same lifecycle stays resolvable, and a replaced | |||
| 208 | + | // lifecycle never falls back to a same-name session. | |||
| 209 | + | func TestLocalNavigationResolveExactLifecycle(t *testing.T) { | |||
| 210 | + | d := newTestDaemon(t, nil, fixedRemoteRefreshClock{now: time.Unix(1_000, 0)}) | |||
| 211 | + | // Broken and purging records are never resumable. | |||
| 212 | + | d.mu.Lock() | |||
| 213 | + | d.inactive["broken"] = inactiveSession{name: "broken", cwd: "/tmp/broken", createdAt: 1, incarnation: domain.SessionLifecycleID{0x61}, state: protocol.SessionBroken} | |||
| 214 | + | d.inactive["purging"] = inactiveSession{name: "purging", cwd: "/tmp/purging", createdAt: 2, incarnation: domain.SessionLifecycleID{0x62}, state: protocol.SessionDown, purging: true} | |||
| 215 | + | // A stale key that will be replaced by a fresh lifecycle. | |||
| 216 | + | d.inactive["gone"] = inactiveSession{name: "gone", cwd: "/tmp/gone", createdAt: 3, incarnation: domain.SessionLifecycleID{0x51}, state: protocol.SessionDown} | |||
| 217 | + | d.mu.Unlock() | |||
| 218 | + | ||||
| 219 | + | _, ok := d.localNavigationResolve(localNavigationRequest(navigationInventoryEntryKey(domain.SessionLifecycleID{0x61}, "broken"))) | |||
| 220 | + | require.False(t, ok, "broken records never resolve") | |||
| 221 | + | _, ok = d.localNavigationResolve(localNavigationRequest(navigationInventoryEntryKey(domain.SessionLifecycleID{0x62}, "purging"))) | |||
| 222 | + | require.False(t, ok, "purging records never resolve") | |||
| 223 | + | ||||
| 224 | + | // Stop-to-live in place: the same lifecycle stays addressable by its key. | |||
| 225 | + | d.mu.Lock() | |||
| 226 | + | d.inactive["live"] = inactiveSession{name: "live", cwd: "/tmp/live", createdAt: 4, incarnation: domain.SessionLifecycleID{0x53}, state: protocol.SessionDown} | |||
| 227 | + | d.mu.Unlock() | |||
| 228 | + | liveKey := navigationInventoryEntryKey(domain.SessionLifecycleID{0x53}, "live") | |||
| 229 | + | stoppedTarget, ok := d.localNavigationResolve(localNavigationRequest(liveKey)) | |||
| 230 | + | require.True(t, ok) | |||
| 231 | + | require.Equal(t, domain.SessionLifecycleID{0x53}, stoppedTarget.LifecycleID) | |||
| 232 | + | addInventorySession(d, "live", domain.SessionLifecycleID{0x53}, false) | |||
| 233 | + | d.mu.Lock() | |||
| 234 | + | delete(d.inactive, "live") | |||
| 235 | + | d.mu.Unlock() | |||
| 236 | + | resumedTarget, ok := d.localNavigationResolve(localNavigationRequest(liveKey)) | |||
| 237 | + | require.True(t, ok, "a stopped target that becomes live with the same lifecycle stays resolvable") | |||
| 238 | + | require.Equal(t, domain.SessionLifecycleID{0x53}, resumedTarget.LifecycleID) | |||
| 239 | + | ||||
| 240 | + | // Replacement: the old lifecycle key must not match the new incarnation. | |||
| 241 | + | oldKey := navigationInventoryEntryKey(domain.SessionLifecycleID{0x51}, "gone") | |||
| 242 | + | _, ok = d.localNavigationResolve(localNavigationRequest(oldKey)) | |||
| 243 | + | require.True(t, ok) | |||
| 244 | + | d.mu.Lock() | |||
| 245 | + | delete(d.inactive, "gone") | |||
| 246 | + | d.mu.Unlock() | |||
| 247 | + | addInventorySession(d, "gone", domain.SessionLifecycleID{0x52}, false) | |||
| 248 | + | _, ok = d.localNavigationResolve(localNavigationRequest(oldKey)) | |||
| 249 | + | require.False(t, ok, "a stale lifecycle never resolves to a same-name replacement") | |||
| 250 | + | replacement, ok := d.localNavigationResolve(localNavigationRequest(navigationInventoryEntryKey(domain.SessionLifecycleID{0x52}, "gone"))) | |||
| 251 | + | require.True(t, ok) | |||
| 252 | + | require.Equal(t, domain.SessionLifecycleID{0x52}, replacement.LifecycleID) | |||
| 253 | + | } | |||
| 254 | + | ||||
| 255 | + | // TestLocalNavigationResolveRejectsForeignBeforeNameLookup proves a foreign | |||
| 256 | + | // source key or a structured foreign registration is rejected even when a | |||
| 257 | + | // local session shares the row's name. | |||
| 258 | + | func TestLocalNavigationResolveRejectsForeignBeforeNameLookup(t *testing.T) { | |||
| 259 | + | d := newTestDaemon(t, nil, fixedRemoteRefreshClock{now: time.Unix(1_000, 0)}) | |||
| 260 | + | addInventorySession(d, "shared", domain.SessionLifecycleID{0x71}, false) | |||
| 261 | + | key := navigationInventoryEntryKey(domain.SessionLifecycleID{0x71}, "shared") | |||
| 262 | + | ||||
| 263 | + | _, ok := d.localNavigationResolve(localNavigationRequest(key)) | |||
| 264 | + | require.True(t, ok, "the exact local key resolves") | |||
| 265 | + | ||||
| 266 | + | foreignSource := localNavigationRequest(key) | |||
| 267 | + | foreignSource.SourceKey = remoteInventorySourceKey("user@arch") | |||
| 268 | + | _, ok = d.localNavigationResolve(foreignSource) | |||
| 269 | + | require.False(t, ok, "a foreign source key is never a local resolve") | |||
| 270 | + | ||||
| 271 | + | registration, err := domain.NewRemoteRegistration("user@arch", [16]byte{7}) | |||
| 272 | + | require.NoError(t, err) | |||
| 273 | + | foreignRegistration := localNavigationRequest(key) | |||
| 274 | + | foreignRegistration.Registration = registration | |||
| 275 | + | _, ok = d.localNavigationResolve(foreignRegistration) | |||
| 276 | + | require.False(t, ok, "a structured foreign registration is rejected even with a same-name local session") | |||
| 277 | + | } | |||
| 278 | + | ||||
| 279 | + | // TestLocalPickerControlResolveExactness covers the prepared local control | |||
| 280 | + | // resolver: exact lifecycle, stale tab rejection, and foreign target | |||
| 281 | + | // rejection, with no name fallback. | |||
| 282 | + | func TestLocalPickerControlResolveExactness(t *testing.T) { | |||
| 283 | + | d := newTestDaemon(t, nil, fixedRemoteRefreshClock{now: time.Unix(1_000, 0)}) | |||
| 284 | + | sess := seedLocalNamedSession(d, "work", domain.SessionLifecycleID{0x81}) | |||
| 285 | + | ||||
| 286 | + | resolved, ok := d.localPickerControlAttachTarget(picker.Target{Session: sess.id, Incarnation: sess.incarnation, TabID: "tab-work"}) | |||
| 287 | + | require.True(t, ok, "the current exact lifecycle and tab resolve") | |||
| 288 | + | require.Equal(t, "work", resolved.Session) | |||
| 289 | + | require.NotNil(t, resolved.ExactTarget) | |||
| 290 | + | require.Equal(t, domain.SessionLifecycleID{0x81}, resolved.ExactTarget.LifecycleID) | |||
| 291 | + | require.Equal(t, domain.TabStableID("tab-work"), resolved.PreferredTabID) | |||
| 292 | + | ||||
| 293 | + | _, ok = d.localPickerControlAttachTarget(picker.Target{Session: sess.id, Incarnation: sess.incarnation}) | |||
| 294 | + | require.True(t, ok, "a session header with no preferred tab resolves") | |||
| 295 | + | ||||
| 296 | + | _, ok = d.localPickerControlAttachTarget(picker.Target{Session: sess.id, Incarnation: sess.incarnation, TabID: "tab-stale"}) | |||
| 297 | + | require.False(t, ok, "a stale tab rejects instead of forwarding a dangling tab ID") | |||
| 298 | + | ||||
| 299 | + | _, ok = d.localPickerControlAttachTarget(picker.Target{Session: sess.id, Incarnation: domain.IncarnationID{0x82}, TabID: "tab-work"}) | |||
| 300 | + | require.False(t, ok, "a replaced lifecycle never resolves by name") | |||
| 301 | + | ||||
| 302 | + | _, ok = d.localPickerControlAttachTarget(picker.Target{Session: sess.id, Incarnation: sess.incarnation, RemoteTarget: &domain.RemoteSessionTarget{}}) | |||
| 303 | + | require.False(t, ok, "a structured foreign target is rejected before any lookup") | |||
| 304 | + | _, ok = d.localPickerControlAttachTarget(picker.Target{Session: sess.id, Incarnation: sess.incarnation, RemoteKey: &domain.RemoteSessionKey{Host: "user@arch", Name: "work"}}) | |||
| 305 | + | require.False(t, ok, "a foreign session key is rejected before any lookup") | |||
| 306 | + | } | |||
| 307 | + | ||||
| 308 | + | // TestLocalProjectionsPreserveInventoryCounts proves the prepared local | |||
| 309 | + | // projections and resolvers, driven with no presenting attachment, leave the | |||
| 310 | + | // live session registry, the stopped records, and the seeded session's | |||
| 311 | + | // attachment and tab slices unchanged, and never open a PTY. The attached | |||
| 312 | + | // picker path (non-nil cur/ac) is deliberately not exercised here: | |||
| 313 | + | // localPickerViews may repair that attachment's own view, which is | |||
| 314 | + | // presentation state rather than shared inventory. | |||
| 315 | + | func TestLocalProjectionsPreserveInventoryCounts(t *testing.T) { | |||
| 316 | + | d := newTestDaemon(t, failPTYFactory{t}, stubClock{}) | |||
| 317 | + | sess := seedLocalNamedSession(d, "work", domain.SessionLifecycleID{0x91}) | |||
| 318 | + | d.mu.Lock() | |||
| 319 | + | d.inactive["stopped"] = inactiveSession{name: "stopped", cwd: "/tmp/stopped", createdAt: 1, incarnation: domain.SessionLifecycleID{0x92}, state: protocol.SessionDown} | |||
| 320 | + | sessionsBefore := len(d.sessions) | |||
| 321 | + | stoppedBefore := len(d.inactive) | |||
| 322 | + | d.mu.Unlock() | |||
| 323 | + | sess.mu.Lock() | |||
| 324 | + | attachmentsBefore := len(sess.attachments) | |||
| 325 | + | tabsBefore := len(sess.tabs) | |||
| 326 | + | sess.mu.Unlock() | |||
| 327 | + | ||||
| 328 | + | response := d.localNavigationInventorySnapshot(1) | |||
| 329 | + | require.NoError(t, protocol.ValidateNavigationInventoryResponse(response)) | |||
| 330 | + | _, ok := d.localNavigationResolve(localNavigationRequest(navigationInventoryEntryKey(domain.SessionLifecycleID{0x91}, "work"))) | |||
| 331 | + | require.True(t, ok) | |||
| 332 | + | _, _, _ = d.localPickerViewProjections(nil, nil) | |||
| 333 | + | _, ok = d.localPickerControlAttachTarget(picker.Target{Session: sess.id, Incarnation: sess.incarnation, TabID: "tab-work"}) | |||
| 334 | + | require.True(t, ok) | |||
| 335 | + | ||||
| 336 | + | d.mu.Lock() | |||
| 337 | + | require.Equal(t, sessionsBefore, len(d.sessions), "local projections never create or remove sessions") | |||
| 338 | + | require.Equal(t, stoppedBefore, len(d.inactive), "local projections never restore or purge stopped records") | |||
| 339 | + | d.mu.Unlock() | |||
| 340 | + | sess.mu.Lock() | |||
| 341 | + | require.Equal(t, attachmentsBefore, len(sess.attachments), "local projections never attach") | |||
| 342 | + | require.Equal(t, tabsBefore, len(sess.tabs), "local projections never reshape geometry") | |||
| 343 | + | sess.mu.Unlock() | |||
| 344 | + | } | |||
| 345 | + | ||||
| 346 | + | // TestLocalProjectionMatchesHybridWithoutRemote proves the prepared local | |||
| 347 | + | // picker projection is exactly what the hybrid projection produces when no | |||
| 348 | + | // remote directory is installed, so the P7 cutover can drop the foreign rows | |||
| 349 | + | // without changing local presentation. | |||
| 350 | + | func TestLocalProjectionMatchesHybridWithoutRemote(t *testing.T) { | |||
| 351 | + | d := newTestDaemon(t, nil, fixedRemoteRefreshClock{now: time.Unix(1_000, 0)}) | |||
| 352 | + | seedLocalNamedSession(d, "work", domain.SessionLifecycleID{0xA1}) | |||
| 353 | + | other := seedLocalNamedSession(d, "other", domain.SessionLifecycleID{0xA2}) | |||
| 354 | + | other.mruAt.Store(2) | |||
| 355 | + | d.mu.Lock() | |||
| 356 | + | d.inactive["stopped"] = inactiveSession{name: "stopped", cwd: "/tmp/stopped", createdAt: 1, incarnation: domain.SessionLifecycleID{0xA3}, state: protocol.SessionDown} | |||
| 357 | + | d.mu.Unlock() | |||
| 358 | + | require.Nil(t, d.remoteDirectory) | |||
| 359 | + | ||||
| 360 | + | localRecent, localGrouped, localCurrent := d.localPickerViewProjections(nil, nil) | |||
| 361 | + | hybridRecent, hybridGrouped, hybridCurrent := d.pickerViewProjections(nil, nil) | |||
| 362 | + | require.Equal(t, hybridRecent, localRecent) | |||
| 363 | + | require.Equal(t, hybridGrouped, localGrouped) | |||
| 364 | + | require.Equal(t, hybridCurrent, localCurrent) | |||
| 365 | + | } | |||
| 366 | + | ||||
| 367 | + | // TestLocalProjectionIgnoresDirectoryAndMatchesFilteredHybrid proves the | |||
| 368 | + | // prepared local picker projection is independent of remote monitoring and is | |||
| 369 | + | // exactly the hybrid projection with its foreign rows removed. The local call | |||
| 370 | + | // must never read the directory (zero snapshots), must not change when the | |||
| 371 | + | // directory is installed or removed, and — compared row-for-row after dropping | |||
| 372 | + | // exactly the foreign rows by ID — must preserve the hybrid's local ordering. | |||
| 373 | + | func TestLocalProjectionIgnoresDirectoryAndMatchesFilteredHybrid(t *testing.T) { | |||
| 374 | + | now := time.Unix(1_000, 0) | |||
| 375 | + | d := newTestDaemon(t, nil, fixedRemoteRefreshClock{now: now}) | |||
| 376 | + | seedLocalNamedSession(d, "alpha", domain.SessionLifecycleID{0xC1}) | |||
| 377 | + | d.mu.Lock() | |||
| 378 | + | d.inactive["zulu"] = inactiveSession{name: "zulu", cwd: "/tmp/zulu", createdAt: 5, incarnation: domain.SessionLifecycleID{0xC2}, state: protocol.SessionDown} | |||
| 379 | + | d.mu.Unlock() | |||
| 380 | + | directory := &countingRemoteDirectory{snapshot: ports.RemoteDirectorySnapshot{Revision: 1, Initialized: true, | |||
| 381 | + | Hosts: []ports.RemoteHostSnapshot{reachableDirectoryHost("user@arch", now, catalogue.RemoteCatalogSession{ | |||
| 382 | + | LifecycleID: domain.SessionLifecycleID{0xC3}, Name: "delta", State: catalogue.RemoteCatalogSessionUp, | |||
| 383 | + | Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-delta", Index: 0, Name: "main"}}, ActiveTabID: "tab-delta", | |||
| 384 | + | })}}} | |||
| 385 | + | d.remoteDirectory = directory | |||
| 386 | + | ||||
| 387 | + | localRecent, localGrouped, localCurrent := d.localPickerViewProjections(nil, nil) | |||
| 388 | + | require.Zero(t, directory.snapshots, "the prepared local projection must never read the remote directory") | |||
| 389 | + | ||||
| 390 | + | // Dependency: an installed directory cannot change the local projection. | |||
| 391 | + | d.remoteDirectory = nil | |||
| 392 | + | bareRecent, bareGrouped, bareCurrent := d.localPickerViewProjections(nil, nil) | |||
| 393 | + | require.Equal(t, localRecent, bareRecent, "local rows never depend on the directory") | |||
| 394 | + | require.Equal(t, localGrouped, bareGrouped, "local sections never depend on the directory") | |||
| 395 | + | require.Equal(t, localCurrent, bareCurrent) | |||
| 396 | + | ||||
| 397 | + | // The hybrid projection composes the same local rows with the foreign ones. | |||
| 398 | + | d.remoteDirectory = directory | |||
| 399 | + | hybridRecent, hybridGrouped, hybridCurrent := d.pickerViewProjections(nil, nil) | |||
| 400 | + | require.Positive(t, directory.snapshots, "the hybrid projection does read the installed directory") | |||
| 401 | + | require.Equal(t, localCurrent, hybridCurrent) | |||
| 402 | + | ||||
| 403 | + | foreign := d.foreignPickerViews(now) | |||
| 404 | + | foreignIDs := map[domain.SessionID]struct{}{} | |||
| 405 | + | for _, row := range foreign.recent { | |||
| 406 | + | foreignIDs[row.ID] = struct{}{} | |||
| 407 | + | } | |||
| 408 | + | for _, row := range foreign.grouped { | |||
| 409 | + | foreignIDs[row.ID] = struct{}{} | |||
| 410 | + | } | |||
| 411 | + | require.NotEmpty(t, foreignIDs) | |||
| 412 | + | // Drop exactly the foreign rows by ID — never by name or section — so a | |||
| 413 | + | // local row cannot be hidden and the surviving order is compared in full. | |||
| 414 | + | require.Equal(t, localRecent, dropPickerRows(hybridRecent, foreignIDs), "filtered recent order matches the local projection") | |||
| 415 | + | require.Equal(t, localGrouped, dropPickerRows(hybridGrouped, foreignIDs), "filtered grouped order matches the local projection") | |||
| 416 | + | } | |||
| 417 | + | ||||
| 418 | + | // dropPickerRows returns rows with none of ids, preserving order. | |||
| 419 | + | func dropPickerRows(rows []pickerSessionView, ids map[domain.SessionID]struct{}) []pickerSessionView { | |||
| 420 | + | kept := make([]pickerSessionView, 0, len(rows)) | |||
| 421 | + | for _, row := range rows { | |||
| 422 | + | if _, ok := ids[row.ID]; ok { | |||
| 423 | + | continue | |||
| 424 | + | } | |||
| 425 | + | kept = append(kept, row) | |||
| 426 | + | } | |||
| 427 | + | return kept | |||
| 428 | + | } | |||
| 429 | + | ||||
| 430 | + | // pickerRowShape is the exact slice element the hybrid ordering tests compare: | |||
| 431 | + | // the rendered row name, its section label, and whether it is a stopped row. | |||
| 432 | + | type pickerRowShape struct { | |||
| 433 | + | Name string | |||
| 434 | + | Section string | |||
| 435 | + | Stopped bool | |||
| 436 | + | } | |||
| 437 | + | ||||
| 438 | + | func pickerRowShapes(views []pickerSessionView) []pickerRowShape { | |||
| 439 | + | shapes := make([]pickerRowShape, 0, len(views)) | |||
| 440 | + | for _, view := range views { | |||
| 441 | + | shapes = append(shapes, pickerRowShape{Name: view.Name, Section: view.Section, Stopped: view.Stopped}) | |||
| 442 | + | } | |||
| 443 | + | return shapes | |||
| 444 | + | } | |||
| 445 | + | ||||
| 446 | + | // TestHybridPickerOrderingAcrossRemoteDirectoryStates pins the exact Recent and | |||
| 447 | + | // Grouped hybrid projections across the remote-directory states: a populated | |||
| 448 | + | // directory, an installed-but-never-initialized directory that publishes the | |||
| 449 | + | // checking placeholder, and no installed directory at all. The foreign rows | |||
| 450 | + | // interleave between the local live and stopped groups, the first published row | |||
| 451 | + | // of each remote host carries the "REMOTE <endpoint>" label, the checking | |||
| 452 | + | // placeholder carries "REMOTE", and any raw catalogue row — including an | |||
| 453 | + | // invalid-key one that publishes no row of its own — suppresses the LOCAL | |||
| 454 | + | // label on a trailing stopped row (the `catalogRows > 0 || checking` rule the | |||
| 455 | + | // refactor re-derived). With no installed directory a stopped-only row still | |||
| 456 | + | // opens the grouped LOCAL section while the recent projection stays | |||
| 457 | + | // section-free. The prepared local projection is this same shape with the | |||
| 458 | + | // foreign rows removed, so these cases fix the row-for-row contract the P7 | |||
| 459 | + | // cutover must preserve. | |||
| 460 | + | func TestHybridPickerOrderingAcrossRemoteDirectoryStates(t *testing.T) { | |||
| 461 | + | now := time.Unix(1_000, 0) | |||
| 462 | + | remoteSession := catalogue.RemoteCatalogSession{ | |||
| 463 | + | LifecycleID: domain.SessionLifecycleID{0xD1}, Name: "delta", State: catalogue.RemoteCatalogSessionUp, | |||
| 464 | + | Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-delta", Index: 0, Name: "main"}}, | |||
| 465 | + | ActiveTabID: "tab-delta", | |||
| 466 | + | } | |||
| 467 | + | // A catalogue session whose name cannot form a valid remote key: the row is | |||
| 468 | + | // never published, but the raw entry still counts toward catalogRows. | |||
| 469 | + | invalidKeySession := catalogue.RemoteCatalogSession{ | |||
| 470 | + | LifecycleID: domain.SessionLifecycleID{0xD2}, Name: "", State: catalogue.RemoteCatalogSessionUp, | |||
| 471 | + | } | |||
| 472 | + | seedStoppedZulu := func(d *Daemon) { | |||
| 473 | + | d.mu.Lock() | |||
| 474 | + | d.inactive["zulu"] = inactiveSession{name: "zulu", cwd: "/tmp/zulu", createdAt: 5, incarnation: domain.SessionLifecycleID{0xB2}, state: protocol.SessionDown} | |||
| 475 | + | d.mu.Unlock() | |||
| 476 | + | } | |||
| 477 | + | ||||
| 478 | + | cases := []struct { | |||
| 479 | + | name string | |||
| 480 | + | seed func(d *Daemon) | |||
| 481 | + | directory func() ports.RemoteDirectory | |||
| 482 | + | wantRecent []pickerRowShape | |||
| 483 | + | wantGrouped []pickerRowShape | |||
| 484 | + | }{ | |||
| 485 | + | { | |||
| 486 | + | name: "live+remote+stopped", | |||
| 487 | + | seed: func(d *Daemon) { | |||
| 488 | + | seedLocalNamedSession(d, "alpha", domain.SessionLifecycleID{0xB1}) | |||
| 489 | + | seedStoppedZulu(d) | |||
| 490 | + | }, | |||
| 491 | + | directory: func() ports.RemoteDirectory { | |||
| 492 | + | return &countingRemoteDirectory{snapshot: ports.RemoteDirectorySnapshot{Revision: 1, Initialized: true, | |||
| 493 | + | Hosts: []ports.RemoteHostSnapshot{reachableDirectoryHost("user@arch", now, remoteSession)}}} | |||
| 494 | + | }, | |||
| 495 | + | wantRecent: []pickerRowShape{{Name: "alpha"}, {Name: "delta@arch"}, {Name: "zulu", Stopped: true}}, | |||
| 496 | + | wantGrouped: []pickerRowShape{{Name: "alpha", Section: "LOCAL"}, {Name: "delta@arch", Section: "REMOTE user@arch"}, {Name: "zulu", Stopped: true}}, | |||
| 497 | + | }, | |||
| 498 | + | { | |||
| 499 | + | name: "remote-only", | |||
| 500 | + | seed: func(*Daemon) {}, | |||
| 501 | + | directory: func() ports.RemoteDirectory { | |||
| 502 | + | return &countingRemoteDirectory{snapshot: ports.RemoteDirectorySnapshot{Revision: 2, Initialized: true, | |||
| 503 | + | Hosts: []ports.RemoteHostSnapshot{reachableDirectoryHost("user@arch", now, remoteSession)}}} | |||
| 504 | + | }, | |||
| 505 | + | wantRecent: []pickerRowShape{{Name: "delta@arch"}}, | |||
| 506 | + | wantGrouped: []pickerRowShape{{Name: "delta@arch", Section: "REMOTE user@arch"}}, | |||
| 507 | + | }, | |||
| 508 | + | { | |||
| 509 | + | name: "checking-only", | |||
| 510 | + | seed: func(*Daemon) {}, | |||
| 511 | + | directory: func() ports.RemoteDirectory { | |||
| 512 | + | return &countingRemoteDirectory{snapshot: ports.RemoteDirectorySnapshot{Revision: 3}} | |||
| 513 | + | }, | |||
| 514 | + | wantRecent: []pickerRowShape{{Name: "checking remotes\u2026"}}, | |||
| 515 | + | wantGrouped: []pickerRowShape{{Name: "checking remotes\u2026", Section: "REMOTE"}}, | |||
| 516 | + | }, | |||
| 517 | + | { | |||
| 518 | + | name: "stopped-only, no directory", | |||
| 519 | + | seed: seedStoppedZulu, | |||
| 520 | + | directory: func() ports.RemoteDirectory { | |||
| 521 | + | return nil | |||
| 522 | + | }, | |||
| 523 | + | // With no installed directory the stopped row opens the grouped | |||
| 524 | + | // LOCAL section, and the recent projection stays section-free. | |||
| 525 | + | wantRecent: []pickerRowShape{{Name: "zulu", Stopped: true}}, | |||
| 526 | + | wantGrouped: []pickerRowShape{{Name: "zulu", Section: "LOCAL", Stopped: true}}, | |||
| 527 | + | }, | |||
| 528 | + | { | |||
| 529 | + | name: "checking+stopped", | |||
| 530 | + | seed: seedStoppedZulu, | |||
| 531 | + | directory: func() ports.RemoteDirectory { | |||
| 532 | + | return &countingRemoteDirectory{snapshot: ports.RemoteDirectorySnapshot{Revision: 5}} | |||
| 533 | + | }, | |||
| 534 | + | // The checking placeholder precedes the stopped row, and its raw | |||
| 535 | + | // state suppresses the LOCAL label, so the stopped row keeps no | |||
| 536 | + | // section. | |||
| 537 | + | wantRecent: []pickerRowShape{{Name: "checking remotes\u2026"}, {Name: "zulu", Stopped: true}}, | |||
| 538 | + | wantGrouped: []pickerRowShape{{Name: "checking remotes\u2026", Section: "REMOTE"}, {Name: "zulu", Stopped: true}}, | |||
| 539 | + | }, | |||
| 540 | + | { | |||
| 541 | + | name: "invalid-key host", | |||
| 542 | + | seed: seedStoppedZulu, | |||
| 543 | + | directory: func() ports.RemoteDirectory { | |||
| 544 | + | return &countingRemoteDirectory{snapshot: ports.RemoteDirectorySnapshot{Revision: 4, Initialized: true, | |||
| 545 | + | Hosts: []ports.RemoteHostSnapshot{reachableDirectoryHost("user@arch", now, invalidKeySession)}}} | |||
| 546 | + | }, | |||
| 547 | + | // The invalid-key host publishes no remote row, but its raw catalogue | |||
| 548 | + | // entry still counts, so the trailing stopped row loses its LOCAL | |||
| 549 | + | // label exactly as the pre-refactor projection did. | |||
| 550 | + | wantRecent: []pickerRowShape{{Name: "zulu", Stopped: true}}, | |||
| 551 | + | wantGrouped: []pickerRowShape{{Name: "zulu", Stopped: true}}, | |||
| 552 | + | }, | |||
| 553 | + | } | |||
| 554 | + | ||||
| 555 | + | for _, tc := range cases { | |||
| 556 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 557 | + | d := newTestDaemon(t, nil, fixedRemoteRefreshClock{now: now}) | |||
| 558 | + | tc.seed(d) | |||
| 559 | + | d.remoteDirectory = tc.directory() | |||
| 560 | + | ||||
| 561 | + | recent, grouped, _ := d.pickerViewProjections(nil, nil) | |||
| 562 | + | require.Equal(t, tc.wantRecent, pickerRowShapes(recent), "recent order and sections") | |||
| 563 | + | require.Equal(t, tc.wantGrouped, pickerRowShapes(grouped), "grouped order and sections") | |||
| 564 | + | }) | |||
| 565 | + | } | |||
| 566 | + | } | |||
| 567 | + | ||||
| 568 | + | // TestHybridPickerControlAttachTargetFallsBackOnVanishedTab is the GO-001 | |||
| 569 | + | // TOCTOU case: the picker snapshot named a tab that is still present, the tab | |||
| 570 | + | // closes before the resolve commits, and the hybrid wrapper must still attach | |||
| 571 | + | // the exact local lifecycle with no preferred tab so the daemon performs the | |||
| 572 | + | // same first-tab repair the client applies to a dangling id. The prepared | |||
| 573 | + | // strict resolver keeps rejecting the stale tab, and a replaced lifecycle | |||
| 574 | + | // still never falls back by name. | |||
| 575 | + | func TestHybridPickerControlAttachTargetFallsBackOnVanishedTab(t *testing.T) { | |||
| 576 | + | d := newTestDaemon(t, nil, fixedRemoteRefreshClock{now: time.Unix(1_000, 0)}) | |||
| 577 | + | sess := seedLocalNamedSession(d, "work", domain.SessionLifecycleID{0x85}) | |||
| 578 | + | target := picker.Target{Session: sess.id, Incarnation: sess.incarnation, TabID: "tab-work"} | |||
| 579 | + | ||||
| 580 | + | // Snapshot time: both paths forward the live tab. | |||
| 581 | + | strict, ok := d.localPickerControlAttachTarget(target) | |||
| 582 | + | require.True(t, ok) | |||
| 583 | + | require.Equal(t, domain.TabStableID("tab-work"), strict.PreferredTabID) | |||
| 584 | + | hybrid, ok := d.pickerControlAttachTarget(target) | |||
| 585 | + | require.True(t, ok) | |||
| 586 | + | require.Equal(t, domain.TabStableID("tab-work"), hybrid.PreferredTabID) | |||
| 587 | + | ||||
| 588 | + | // The tab closes between the snapshot and the resolve. | |||
| 589 | + | sess.mu.Lock() | |||
| 590 | + | sess.tabs = nil | |||
| 591 | + | sess.mu.Unlock() | |||
| 592 | + | ||||
| 593 | + | _, ok = d.localPickerControlAttachTarget(target) | |||
| 594 | + | require.False(t, ok, "the prepared strict resolver rejects the vanished tab") | |||
| 595 | + | ||||
| 596 | + | hybrid, ok = d.pickerControlAttachTarget(target) | |||
| 597 | + | require.True(t, ok, "the hybrid wrapper preserves the live attach") | |||
| 598 | + | require.Empty(t, hybrid.PreferredTabID, "the vanished tab falls back to the client's missing-tab repair") | |||
| 599 | + | require.NotNil(t, hybrid.ExactTarget) | |||
| 600 | + | require.Equal(t, domain.SessionLifecycleID{0x85}, hybrid.ExactTarget.LifecycleID) | |||
| 601 | + | require.Equal(t, "work", hybrid.Session) | |||
| 602 | + | ||||
| 603 | + | // The fallback still never resolves a same-name replacement by name. | |||
| 604 | + | _, ok = d.pickerControlAttachTarget(picker.Target{Session: sess.id, Incarnation: domain.IncarnationID{0x86}, TabID: "tab-work"}) | |||
| 605 | + | require.False(t, ok, "a replaced lifecycle rejects even with the vanished-tab fallback") | |||
| 606 | + | } | |||
M internal/usecase/daemon/navigation_inventory.go +38 -9
| @@ -49,7 +49,7 @@ func (d *Daemon) answerNavigationInventory(request protocol.NavigationInventoryR | |||
| 49 | 49 | func (d *Daemon) snapshotNavigationInventory(requestID uint64) protocol.NavigationInventoryResponse { | |
| 50 | 50 | inv := d.captureSessionInventory(viewOptions{}, false) | |
| 51 | 51 | groups := make([]protocol.NavigationInventorySourceGroup, 0, len(inv.hosts)+1) | |
| 52 | - | groups = append(groups, d.localInventoryGroup(inv)) | |
| 52 | + | groups = append(groups, d.localInventoryGroup(inv.localSessionInventory)) | |
| 53 | 53 | for _, host := range inv.hosts { | |
| 54 | 54 | groups = append(groups, remoteInventoryGroup(host)) | |
| 55 | 55 | } | |
| @@ -60,10 +60,26 @@ func (d *Daemon) snapshotNavigationInventory(requestID uint64) protocol.Navigati | |||
| 60 | 60 | return fitNavigationInventoryResponse(response) | |
| 61 | 61 | } | |
| 62 | 62 | ||
| 63 | + | // localNavigationInventorySnapshot is the prepared local-only navigation | |
| 64 | + | // 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. | |
| 69 | + | func (d *Daemon) localNavigationInventorySnapshot(requestID uint64) protocol.NavigationInventoryResponse { | |
| 70 | + | inv := d.captureLocalSessionInventory(viewOptions{}, false) | |
| 71 | + | response := protocol.NavigationInventoryResponse{ | |
| 72 | + | RequestID: requestID, Operation: protocol.NavigationInventorySnapshot, Status: protocol.NavigationInventoryOK, | |
| 73 | + | Groups: []protocol.NavigationInventorySourceGroup{d.localInventoryGroup(inv)}, | |
| 74 | + | } | |
| 75 | + | return fitNavigationInventoryResponse(response) | |
| 76 | + | } | |
| 77 | + | ||
| 63 | 78 | // localInventoryGroup projects live named sessions and resumable stopped | |
| 64 | - | // sessions. Observation failures do not exist for local state; broken | |
| 65 | - | // records are excluded here because they are never resumable. | |
| 66 | - | func (d *Daemon) localInventoryGroup(inv sessionInventory) protocol.NavigationInventorySourceGroup { | |
| 79 | + | // sessions. Observation failures do not exist for local state; broken and | |
| 80 | + | // purging records are excluded here because they are never resumable. It reads | |
| 81 | + | // only the remote-free local capture. | |
| 82 | + | func (d *Daemon) localInventoryGroup(inv localSessionInventory) protocol.NavigationInventorySourceGroup { | |
| 67 | 83 | group := protocol.NavigationInventorySourceGroup{SourceKey: protocol.NavigationInventoryLocalSourceKey, Status: protocol.NavigationInventorySourceOK} | |
| 68 | 84 | for _, item := range inv.live { | |
| 69 | 85 | view := item.view | |
| @@ -210,7 +226,7 @@ func fitNavigationInventoryResponse(response protocol.NavigationInventoryRespons | |||
| 210 | 226 | func (d *Daemon) resolveNavigationInventory(request protocol.NavigationInventoryRequest) protocol.NavigationInventoryResponse { | |
| 211 | 227 | response := protocol.NavigationInventoryResponse{RequestID: request.RequestID, Operation: protocol.NavigationInventoryResolve} | |
| 212 | 228 | if request.SourceKey == protocol.NavigationInventoryLocalSourceKey { | |
| 213 | - | target, ok := d.resolveLocalInventoryEntry(request.EntryKey) | |
| 229 | + | target, ok := d.localNavigationResolve(request) | |
| 214 | 230 | if !ok { | |
| 215 | 231 | response.Status = protocol.NavigationInventoryUnavailable | |
| 216 | 232 | return response | |
| @@ -239,11 +255,24 @@ func (d *Daemon) resolveNavigationInventory(request protocol.NavigationInventory | |||
| 239 | 255 | return response | |
| 240 | 256 | } | |
| 241 | 257 | ||
| 242 | - | // resolveLocalInventoryEntry matches the opaque key against fresh live and | |
| 258 | + | // localNavigationResolve is the prepared local-only navigation resolver | |
| 259 | + | // (Plan 001 P4.3). It rejects a structured foreign source key or registration | |
| 260 | + | // before any lookup, so a local resolve can never be satisfied by a remote | |
| 261 | + | // endpoint or a foreign registration even when a local session shares the | |
| 262 | + | // row's name. Matching is by the opaque lifecycle-qualified key only: a stale | |
| 263 | + | // or replaced lifecycle never falls back to a same-name session. | |
| 264 | + | func (d *Daemon) localNavigationResolve(request protocol.NavigationInventoryRequest) (protocol.ExactSessionTarget, bool) { | |
| 265 | + | if request.SourceKey != protocol.NavigationInventoryLocalSourceKey || !request.Registration.IsZero() { | |
| 266 | + | return protocol.ExactSessionTarget{}, false | |
| 267 | + | } | |
| 268 | + | return localNavigationResolveEntry(d.captureLocalSessionInventory(viewOptions{}, false), request.EntryKey) | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | // localNavigationResolveEntry matches the opaque key against fresh live and | |
| 243 | 272 | // resumable stopped state. A stopped target that is now live with the same | |
| 244 | - | // lifecycle and selector stays attachable; a changed lifecycle rejects. | |
| 245 | - | func (d *Daemon) resolveLocalInventoryEntry(entryKey string) (protocol.ExactSessionTarget, bool) { | |
| 246 | - | inv := d.captureSessionInventory(viewOptions{}, false) | |
| 273 | + | // lifecycle and selector stays attachable; a broken or purging record is not | |
| 274 | + | // resumable and rejects; a changed lifecycle rejects. | |
| 275 | + | func localNavigationResolveEntry(inv localSessionInventory, entryKey string) (protocol.ExactSessionTarget, bool) { | |
| 247 | 276 | for _, item := range inv.live { | |
| 248 | 277 | view := item.view | |
| 249 | 278 | if view.name == "" || view.ephemeral { | |
| @@ -49,7 +49,7 @@ func (d *Daemon) answerNavigationInventory(request protocol.NavigationInventoryR | |||||
| 49 | func (d *Daemon) snapshotNavigationInventory(requestID uint64) protocol.NavigationInventoryResponse { | 49 | func (d *Daemon) snapshotNavigationInventory(requestID uint64) protocol.NavigationInventoryResponse { | ||
| 50 | inv := d.captureSessionInventory(viewOptions{}, false) | 50 | inv := d.captureSessionInventory(viewOptions{}, false) | ||
| 51 | groups := make([]protocol.NavigationInventorySourceGroup, 0, len(inv.hosts)+1) | 51 | groups := make([]protocol.NavigationInventorySourceGroup, 0, len(inv.hosts)+1) | ||
| 52 | - | groups = append(groups, d.localInventoryGroup(inv)) | 52 | + | groups = append(groups, d.localInventoryGroup(inv.localSessionInventory)) |
| 53 | for _, host := range inv.hosts { | 53 | for _, host := range inv.hosts { | ||
| 54 | groups = append(groups, remoteInventoryGroup(host)) | 54 | groups = append(groups, remoteInventoryGroup(host)) | ||
| 55 | } | 55 | } | ||
| @@ -60,10 +60,26 @@ func (d *Daemon) snapshotNavigationInventory(requestID uint64) protocol.Navigati | |||||
| 60 | return fitNavigationInventoryResponse(response) | 60 | return fitNavigationInventoryResponse(response) | ||
| 61 | } | 61 | } | ||
| 62 | 62 | ||||
| 63 | + | // localNavigationInventorySnapshot is the prepared local-only navigation | |||
| 64 | + | // 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. | |||
| 69 | + | func (d *Daemon) localNavigationInventorySnapshot(requestID uint64) protocol.NavigationInventoryResponse { | |||
| 70 | + | inv := d.captureLocalSessionInventory(viewOptions{}, false) | |||
| 71 | + | response := protocol.NavigationInventoryResponse{ | |||
| 72 | + | RequestID: requestID, Operation: protocol.NavigationInventorySnapshot, Status: protocol.NavigationInventoryOK, | |||
| 73 | + | Groups: []protocol.NavigationInventorySourceGroup{d.localInventoryGroup(inv)}, | |||
| 74 | + | } | |||
| 75 | + | return fitNavigationInventoryResponse(response) | |||
| 76 | + | } | |||
| 77 | + | ||||
| 63 | // localInventoryGroup projects live named sessions and resumable stopped | 78 | // localInventoryGroup projects live named sessions and resumable stopped | ||
| 64 | - | // sessions. Observation failures do not exist for local state; broken | 79 | + | // sessions. Observation failures do not exist for local state; broken and |
| 65 | - | // records are excluded here because they are never resumable. | 80 | + | // purging records are excluded here because they are never resumable. It reads |
| 66 | - | func (d *Daemon) localInventoryGroup(inv sessionInventory) protocol.NavigationInventorySourceGroup { | 81 | + | // only the remote-free local capture. |
| 82 | + | func (d *Daemon) localInventoryGroup(inv localSessionInventory) protocol.NavigationInventorySourceGroup { | |||
| 67 | group := protocol.NavigationInventorySourceGroup{SourceKey: protocol.NavigationInventoryLocalSourceKey, Status: protocol.NavigationInventorySourceOK} | 83 | group := protocol.NavigationInventorySourceGroup{SourceKey: protocol.NavigationInventoryLocalSourceKey, Status: protocol.NavigationInventorySourceOK} | ||
| 68 | for _, item := range inv.live { | 84 | for _, item := range inv.live { | ||
| 69 | view := item.view | 85 | view := item.view | ||
| @@ -210,7 +226,7 @@ func fitNavigationInventoryResponse(response protocol.NavigationInventoryRespons | |||||
| 210 | func (d *Daemon) resolveNavigationInventory(request protocol.NavigationInventoryRequest) protocol.NavigationInventoryResponse { | 226 | func (d *Daemon) resolveNavigationInventory(request protocol.NavigationInventoryRequest) protocol.NavigationInventoryResponse { | ||
| 211 | response := protocol.NavigationInventoryResponse{RequestID: request.RequestID, Operation: protocol.NavigationInventoryResolve} | 227 | response := protocol.NavigationInventoryResponse{RequestID: request.RequestID, Operation: protocol.NavigationInventoryResolve} | ||
| 212 | if request.SourceKey == protocol.NavigationInventoryLocalSourceKey { | 228 | if request.SourceKey == protocol.NavigationInventoryLocalSourceKey { | ||
| 213 | - | target, ok := d.resolveLocalInventoryEntry(request.EntryKey) | 229 | + | target, ok := d.localNavigationResolve(request) |
| 214 | if !ok { | 230 | if !ok { | ||
| 215 | response.Status = protocol.NavigationInventoryUnavailable | 231 | response.Status = protocol.NavigationInventoryUnavailable | ||
| 216 | return response | 232 | return response | ||
| @@ -239,11 +255,24 @@ func (d *Daemon) resolveNavigationInventory(request protocol.NavigationInventory | |||||
| 239 | return response | 255 | return response | ||
| 240 | } | 256 | } | ||
| 241 | 257 | ||||
| 242 | - | // resolveLocalInventoryEntry matches the opaque key against fresh live and | 258 | + | // localNavigationResolve is the prepared local-only navigation resolver |
| 259 | + | // (Plan 001 P4.3). It rejects a structured foreign source key or registration | |||
| 260 | + | // before any lookup, so a local resolve can never be satisfied by a remote | |||
| 261 | + | // endpoint or a foreign registration even when a local session shares the | |||
| 262 | + | // row's name. Matching is by the opaque lifecycle-qualified key only: a stale | |||
| 263 | + | // or replaced lifecycle never falls back to a same-name session. | |||
| 264 | + | func (d *Daemon) localNavigationResolve(request protocol.NavigationInventoryRequest) (protocol.ExactSessionTarget, bool) { | |||
| 265 | + | if request.SourceKey != protocol.NavigationInventoryLocalSourceKey || !request.Registration.IsZero() { | |||
| 266 | + | return protocol.ExactSessionTarget{}, false | |||
| 267 | + | } | |||
| 268 | + | return localNavigationResolveEntry(d.captureLocalSessionInventory(viewOptions{}, false), request.EntryKey) | |||
| 269 | + | } | |||
| 270 | + | ||||
| 271 | + | // localNavigationResolveEntry matches the opaque key against fresh live and | |||
| 243 | // resumable stopped state. A stopped target that is now live with the same | 272 | // resumable stopped state. A stopped target that is now live with the same | ||
| 244 | - | // lifecycle and selector stays attachable; a changed lifecycle rejects. | 273 | + | // lifecycle and selector stays attachable; a broken or purging record is not |
| 245 | - | func (d *Daemon) resolveLocalInventoryEntry(entryKey string) (protocol.ExactSessionTarget, bool) { | 274 | + | // resumable and rejects; a changed lifecycle rejects. |
| 246 | - | inv := d.captureSessionInventory(viewOptions{}, false) | 275 | + | func localNavigationResolveEntry(inv localSessionInventory, entryKey string) (protocol.ExactSessionTarget, bool) { |
| 247 | for _, item := range inv.live { | 276 | for _, item := range inv.live { | ||
| 248 | view := item.view | 277 | view := item.view | ||
| 249 | if view.name == "" || view.ephemeral { | 278 | if view.name == "" || view.ephemeral { | ||
M internal/usecase/daemon/picker.go +112 -43
| @@ -32,22 +32,44 @@ func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]pickerSessionV | |||
| 32 | 32 | return grouped, current | |
| 33 | 33 | } | |
| 34 | 34 | ||
| 35 | - | func (d *Daemon) pickerViewProjections(cur *session, ac *attachedClient) ([]pickerSessionView, []pickerSessionView, pickerSourceFilter) { | |
| 35 | + | // pickerLocalViews is the remote-free picker projection. The hybrid | |
| 36 | + | // projection interleaves foreign rows between the live and stopped rows, so | |
| 37 | + | // the two groups are returned separately and the stopped rows are returned | |
| 38 | + | // without a LOCAL section label. The assembler applies that label once it | |
| 39 | + | // knows whether any row precedes them. | |
| 40 | + | type pickerLocalViews struct { | |
| 41 | + | current pickerSourceFilter | |
| 42 | + | now time.Time | |
| 43 | + | recentLive []pickerSessionView | |
| 44 | + | groupedLive []pickerSessionView | |
| 45 | + | recentStopped []pickerSessionView | |
| 46 | + | groupedStopped []pickerSessionView | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // pickerForeignViews is the projected foreign (remote-directory) portion of | |
| 50 | + | // the hybrid picker projection plus whether it suppresses the LOCAL label on a | |
| 51 | + | // preceding stopped row. | |
| 52 | + | type pickerForeignViews struct { | |
| 53 | + | recent []pickerSessionView | |
| 54 | + | grouped []pickerSessionView | |
| 55 | + | blockLocalStopped bool | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // localPickerViews is the prepared local-only picker projection (Plan 001 | |
| 59 | + | // P4.3). It captures live and stopped local rows, orders them exactly like the | |
| 60 | + | // hybrid projection does when no foreign source is present, and never reads | |
| 61 | + | // the remote directory. | |
| 62 | + | func (d *Daemon) localPickerViews(cur *session, ac *attachedClient) pickerLocalViews { | |
| 36 | 63 | if cur != nil && ac != nil { | |
| 37 | 64 | cur.repairAttachmentView(ac) | |
| 38 | 65 | } | |
| 39 | 66 | opts := viewOptions{tabDetails: true, focusedTitles: true, terminalTitle: d.currentTabsConfig().TerminalTitle} | |
| 40 | - | inv := d.captureSessionInventory(opts, true) | |
| 67 | + | inv := d.captureLocalSessionInventory(opts, true) | |
| 41 | 68 | stopped := inv.visibleStopped() | |
| 42 | - | hosts := inv.hosts | |
| 43 | - | now := inv.now | |
| 44 | - | monitored := inv.monitored | |
| 45 | - | initialized := inv.initialized | |
| 46 | 69 | ||
| 47 | 70 | // One snapshot per live session: sorting and view building read the same | |
| 48 | 71 | // capture, so comparators cannot observe a concurrent touchMRU or | |
| 49 | - | // renameSession mid-sort. Remote-catalog locks are released before sorting | |
| 50 | - | // and picker-row construction. | |
| 72 | + | // renameSession mid-sort. | |
| 51 | 73 | live := inv.live | |
| 52 | 74 | var current pickerSourceFilter | |
| 53 | 75 | for _, item := range live { | |
| @@ -79,6 +101,42 @@ func (d *Daemon) pickerViewProjections(cur *session, ac *attachedClient) ([]pick | |||
| 79 | 101 | return stopped[i].name < stopped[j].name | |
| 80 | 102 | }) | |
| 81 | 103 | ||
| 104 | + | recentLive := make([]pickerSessionView, 0, len(live)) | |
| 105 | + | groupedLive := make([]pickerSessionView, 0, len(live)) | |
| 106 | + | for i, item := range live { | |
| 107 | + | recentLive = append(recentLive, item.view.pickerView()) | |
| 108 | + | view := item.view.pickerView() | |
| 109 | + | if i == 0 { | |
| 110 | + | view.Section = "LOCAL" | |
| 111 | + | } | |
| 112 | + | groupedLive = append(groupedLive, view) | |
| 113 | + | } | |
| 114 | + | stoppedRows := stoppedPickerRows(stopped) | |
| 115 | + | // The grouped projection labels its first stopped row in place, so the | |
| 116 | + | // recent projection keeps its own copy. | |
| 117 | + | recentStopped := append([]pickerSessionView(nil), stoppedRows...) | |
| 118 | + | groupedStopped := stoppedRows | |
| 119 | + | return pickerLocalViews{current: current, now: inv.now, recentLive: recentLive, groupedLive: groupedLive, recentStopped: recentStopped, groupedStopped: groupedStopped} | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | // stoppedPickerRows projects visible inactive records into picker rows. The | |
| 123 | + | // local and hybrid projections share this single row shape, and the callers | |
| 124 | + | // own whether a LOCAL section label is applied. | |
| 125 | + | func stoppedPickerRows(stopped []inactiveSession) []pickerSessionView { | |
| 126 | + | rows := make([]pickerSessionView, 0, len(stopped)) | |
| 127 | + | for _, s := range stopped { | |
| 128 | + | createdAt := s.createdAt | |
| 129 | + | rows = append(rows, pickerSessionView{ID: domain.SessionID("stopped:" + s.name), Incarnation: s.incarnation, Name: s.name, TargetName: s.name, Stopped: true, ExpectedCreatedAt: &createdAt}) | |
| 130 | + | } | |
| 131 | + | return rows | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | // foreignPickerViews projects the current remote directory for the picker. It | |
| 135 | + | // reads the remote directory; a nil directory yields no rows and cannot | |
| 136 | + | // suppress the local stopped section label. The daemon-side monitor stays | |
| 137 | + | // intentionally active in this state (coordinated P7 removal set). | |
| 138 | + | func (d *Daemon) foreignPickerViews(now time.Time) pickerForeignViews { | |
| 139 | + | hosts, monitored, initialized := d.currentRemoteDirectory() | |
| 82 | 140 | catalogRows := 0 | |
| 83 | 141 | for _, host := range hosts { | |
| 84 | 142 | catalogRows += len(host.Sessions) | |
| @@ -87,10 +145,7 @@ func (d *Daemon) pickerViewProjections(cur *session, ac *attachedClient) ([]pick | |||
| 87 | 145 | } | |
| 88 | 146 | } | |
| 89 | 147 | checking := monitored && !initialized | |
| 90 | - | recent := make([]pickerSessionView, 0, len(live)+len(stopped)+catalogRows) | |
| 91 | - | for _, item := range live { | |
| 92 | - | recent = append(recent, item.view.pickerView()) | |
| 93 | - | } | |
| 148 | + | recent := make([]pickerSessionView, 0, catalogRows) | |
| 94 | 149 | for _, host := range hosts { | |
| 95 | 150 | for _, session := range host.Sessions { | |
| 96 | 151 | key := domain.RemoteSessionKey{Host: host.Endpoint, Name: session.Name} | |
| @@ -105,19 +160,8 @@ func (d *Daemon) pickerViewProjections(cur *session, ac *attachedClient) ([]pick | |||
| 105 | 160 | if checking { | |
| 106 | 161 | recent = append(recent, remotePickerCheckingView()) | |
| 107 | 162 | } | |
| 108 | - | for _, s := range stopped { | |
| 109 | - | createdAt := s.createdAt | |
| 110 | - | recent = append(recent, pickerSessionView{ID: domain.SessionID("stopped:" + s.name), Incarnation: s.incarnation, Name: s.name, TargetName: s.name, Stopped: true, ExpectedCreatedAt: &createdAt}) | |
| 111 | - | } | |
| 112 | 163 | ||
| 113 | - | views := make([]pickerSessionView, 0, len(live)+len(stopped)+catalogRows) | |
| 114 | - | for i, item := range live { | |
| 115 | - | view := item.view.pickerView() | |
| 116 | - | if i == 0 { | |
| 117 | - | view.Section = "LOCAL" | |
| 118 | - | } | |
| 119 | - | views = append(views, view) | |
| 120 | - | } | |
| 164 | + | grouped := make([]pickerSessionView, 0, catalogRows) | |
| 121 | 165 | for _, host := range hosts { | |
| 122 | 166 | publishedForHost := 0 | |
| 123 | 167 | for _, session := range host.Sessions { | |
| @@ -130,13 +174,13 @@ func (d *Daemon) pickerViewProjections(cur *session, ac *attachedClient) ([]pick | |||
| 130 | 174 | if publishedForHost == 0 { | |
| 131 | 175 | view.Section = "REMOTE " + host.Endpoint | |
| 132 | 176 | } | |
| 133 | - | views = append(views, view) | |
| 177 | + | grouped = append(grouped, view) | |
| 134 | 178 | publishedForHost++ | |
| 135 | 179 | } | |
| 136 | 180 | if len(host.Sessions) == 0 && host.Availability != domain.RemoteAvailabilityReachable { | |
| 137 | 181 | view := remotePickerHostView(host, now) | |
| 138 | 182 | view.Section = "REMOTE " + host.Endpoint | |
| 139 | - | views = append(views, view) | |
| 183 | + | grouped = append(grouped, view) | |
| 140 | 184 | } | |
| 141 | 185 | } | |
| 142 | 186 | // A nil directory means remote monitoring is not installed at all: | |
| @@ -144,24 +188,49 @@ func (d *Daemon) pickerViewProjections(cur *session, ac *attachedClient) ([]pick | |||
| 144 | 188 | if checking { | |
| 145 | 189 | view := remotePickerCheckingView() | |
| 146 | 190 | view.Section = "REMOTE" | |
| 147 | - | views = append(views, view) | |
| 191 | + | grouped = append(grouped, view) | |
| 148 | 192 | } | |
| 149 | - | for i, s := range stopped { | |
| 150 | - | createdAt := s.createdAt | |
| 151 | - | view := pickerSessionView{ | |
| 152 | - | ID: domain.SessionID("stopped:" + s.name), | |
| 153 | - | Incarnation: s.incarnation, | |
| 154 | - | Name: s.name, | |
| 155 | - | TargetName: s.name, | |
| 156 | - | Stopped: true, | |
| 157 | - | ExpectedCreatedAt: &createdAt, | |
| 158 | - | } | |
| 159 | - | if i == 0 && len(live) == 0 && catalogRows == 0 && !checking { | |
| 160 | - | view.Section = "LOCAL" | |
| 161 | - | } | |
| 162 | - | views = append(views, view) | |
| 163 | - | } | |
| 164 | - | return recent, views, current | |
| 193 | + | return pickerForeignViews{recent: recent, grouped: grouped, blockLocalStopped: catalogRows > 0 || checking} | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | // localPickerViewProjections is the complete remote-free picker projection. | |
| 197 | + | // It appends the local stopped rows directly and applies the LOCAL section | |
| 198 | + | // label when no live row opened the group. | |
| 199 | + | func (d *Daemon) localPickerViewProjections(cur *session, ac *attachedClient) ([]pickerSessionView, []pickerSessionView, pickerSourceFilter) { | |
| 200 | + | local := d.localPickerViews(cur, ac) | |
| 201 | + | recent := make([]pickerSessionView, 0, len(local.recentLive)+len(local.recentStopped)) | |
| 202 | + | recent = append(recent, local.recentLive...) | |
| 203 | + | recent = append(recent, local.recentStopped...) | |
| 204 | + | grouped := make([]pickerSessionView, 0, len(local.groupedLive)+len(local.groupedStopped)) | |
| 205 | + | grouped = append(grouped, local.groupedLive...) | |
| 206 | + | grouped = append(grouped, withLocalStoppedSection(local.groupedStopped, len(local.groupedLive) == 0)...) | |
| 207 | + | return recent, grouped, local.current | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | func (d *Daemon) pickerViewProjections(cur *session, ac *attachedClient) ([]pickerSessionView, []pickerSessionView, pickerSourceFilter) { | |
| 211 | + | // The hybrid projection reuses the prepared local projection for its | |
| 212 | + | // local rows and interleaves the current foreign remote rows between the | |
| 213 | + | // live and stopped groups. | |
| 214 | + | local := d.localPickerViews(cur, ac) | |
| 215 | + | foreign := d.foreignPickerViews(local.now) | |
| 216 | + | recent := make([]pickerSessionView, 0, len(local.recentLive)+len(foreign.recent)+len(local.recentStopped)) | |
| 217 | + | recent = append(recent, local.recentLive...) | |
| 218 | + | recent = append(recent, foreign.recent...) | |
| 219 | + | recent = append(recent, local.recentStopped...) | |
| 220 | + | grouped := make([]pickerSessionView, 0, len(local.groupedLive)+len(foreign.grouped)+len(local.groupedStopped)) | |
| 221 | + | grouped = append(grouped, local.groupedLive...) | |
| 222 | + | grouped = append(grouped, foreign.grouped...) | |
| 223 | + | grouped = append(grouped, withLocalStoppedSection(local.groupedStopped, len(local.groupedLive) == 0 && !foreign.blockLocalStopped)...) | |
| 224 | + | return recent, grouped, local.current | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | // withLocalStoppedSection marks the first stopped row as the LOCAL section | |
| 228 | + | // when no preceding row already opened a local group. | |
| 229 | + | func withLocalStoppedSection(stopped []pickerSessionView, local bool) []pickerSessionView { | |
| 230 | + | if local && len(stopped) > 0 { | |
| 231 | + | stopped[0].Section = "LOCAL" | |
| 232 | + | } | |
| 233 | + | return stopped | |
| 165 | 234 | } | |
| 166 | 235 | ||
| 167 | 236 | func attentionSuffix(label string) string { | |
| @@ -32,22 +32,44 @@ func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]pickerSessionV | |||||
| 32 | return grouped, current | 32 | return grouped, current | ||
| 33 | } | 33 | } | ||
| 34 | 34 | ||||
| 35 | - | func (d *Daemon) pickerViewProjections(cur *session, ac *attachedClient) ([]pickerSessionView, []pickerSessionView, pickerSourceFilter) { | 35 | + | // pickerLocalViews is the remote-free picker projection. The hybrid |
| 36 | + | // projection interleaves foreign rows between the live and stopped rows, so | |||
| 37 | + | // the two groups are returned separately and the stopped rows are returned | |||
| 38 | + | // without a LOCAL section label. The assembler applies that label once it | |||
| 39 | + | // knows whether any row precedes them. | |||
| 40 | + | type pickerLocalViews struct { | |||
| 41 | + | current pickerSourceFilter | |||
| 42 | + | now time.Time | |||
| 43 | + | recentLive []pickerSessionView | |||
| 44 | + | groupedLive []pickerSessionView | |||
| 45 | + | recentStopped []pickerSessionView | |||
| 46 | + | groupedStopped []pickerSessionView | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | // pickerForeignViews is the projected foreign (remote-directory) portion of | |||
| 50 | + | // the hybrid picker projection plus whether it suppresses the LOCAL label on a | |||
| 51 | + | // preceding stopped row. | |||
| 52 | + | type pickerForeignViews struct { | |||
| 53 | + | recent []pickerSessionView | |||
| 54 | + | grouped []pickerSessionView | |||
| 55 | + | blockLocalStopped bool | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | // localPickerViews is the prepared local-only picker projection (Plan 001 | |||
| 59 | + | // P4.3). It captures live and stopped local rows, orders them exactly like the | |||
| 60 | + | // hybrid projection does when no foreign source is present, and never reads | |||
| 61 | + | // the remote directory. | |||
| 62 | + | func (d *Daemon) localPickerViews(cur *session, ac *attachedClient) pickerLocalViews { | |||
| 36 | if cur != nil && ac != nil { | 63 | if cur != nil && ac != nil { | ||
| 37 | cur.repairAttachmentView(ac) | 64 | cur.repairAttachmentView(ac) | ||
| 38 | } | 65 | } | ||
| 39 | opts := viewOptions{tabDetails: true, focusedTitles: true, terminalTitle: d.currentTabsConfig().TerminalTitle} | 66 | opts := viewOptions{tabDetails: true, focusedTitles: true, terminalTitle: d.currentTabsConfig().TerminalTitle} | ||
| 40 | - | inv := d.captureSessionInventory(opts, true) | 67 | + | inv := d.captureLocalSessionInventory(opts, true) |
| 41 | stopped := inv.visibleStopped() | 68 | stopped := inv.visibleStopped() | ||
| 42 | - | hosts := inv.hosts | |||
| 43 | - | now := inv.now | |||
| 44 | - | monitored := inv.monitored | |||
| 45 | - | initialized := inv.initialized | |||
| 46 | 69 | ||||
| 47 | // One snapshot per live session: sorting and view building read the same | 70 | // One snapshot per live session: sorting and view building read the same | ||
| 48 | // capture, so comparators cannot observe a concurrent touchMRU or | 71 | // capture, so comparators cannot observe a concurrent touchMRU or | ||
| 49 | - | // renameSession mid-sort. Remote-catalog locks are released before sorting | 72 | + | // renameSession mid-sort. |
| 50 | - | // and picker-row construction. | |||
| 51 | live := inv.live | 73 | live := inv.live | ||
| 52 | var current pickerSourceFilter | 74 | var current pickerSourceFilter | ||
| 53 | for _, item := range live { | 75 | for _, item := range live { | ||
| @@ -79,6 +101,42 @@ func (d *Daemon) pickerViewProjections(cur *session, ac *attachedClient) ([]pick | |||||
| 79 | return stopped[i].name < stopped[j].name | 101 | return stopped[i].name < stopped[j].name | ||
| 80 | }) | 102 | }) | ||
| 81 | 103 | ||||
| 104 | + | recentLive := make([]pickerSessionView, 0, len(live)) | |||
| 105 | + | groupedLive := make([]pickerSessionView, 0, len(live)) | |||
| 106 | + | for i, item := range live { | |||
| 107 | + | recentLive = append(recentLive, item.view.pickerView()) | |||
| 108 | + | view := item.view.pickerView() | |||
| 109 | + | if i == 0 { | |||
| 110 | + | view.Section = "LOCAL" | |||
| 111 | + | } | |||
| 112 | + | groupedLive = append(groupedLive, view) | |||
| 113 | + | } | |||
| 114 | + | stoppedRows := stoppedPickerRows(stopped) | |||
| 115 | + | // The grouped projection labels its first stopped row in place, so the | |||
| 116 | + | // recent projection keeps its own copy. | |||
| 117 | + | recentStopped := append([]pickerSessionView(nil), stoppedRows...) | |||
| 118 | + | groupedStopped := stoppedRows | |||
| 119 | + | return pickerLocalViews{current: current, now: inv.now, recentLive: recentLive, groupedLive: groupedLive, recentStopped: recentStopped, groupedStopped: groupedStopped} | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | // stoppedPickerRows projects visible inactive records into picker rows. The | |||
| 123 | + | // local and hybrid projections share this single row shape, and the callers | |||
| 124 | + | // own whether a LOCAL section label is applied. | |||
| 125 | + | func stoppedPickerRows(stopped []inactiveSession) []pickerSessionView { | |||
| 126 | + | rows := make([]pickerSessionView, 0, len(stopped)) | |||
| 127 | + | for _, s := range stopped { | |||
| 128 | + | createdAt := s.createdAt | |||
| 129 | + | rows = append(rows, pickerSessionView{ID: domain.SessionID("stopped:" + s.name), Incarnation: s.incarnation, Name: s.name, TargetName: s.name, Stopped: true, ExpectedCreatedAt: &createdAt}) | |||
| 130 | + | } | |||
| 131 | + | return rows | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | // foreignPickerViews projects the current remote directory for the picker. It | |||
| 135 | + | // reads the remote directory; a nil directory yields no rows and cannot | |||
| 136 | + | // suppress the local stopped section label. The daemon-side monitor stays | |||
| 137 | + | // intentionally active in this state (coordinated P7 removal set). | |||
| 138 | + | func (d *Daemon) foreignPickerViews(now time.Time) pickerForeignViews { | |||
| 139 | + | hosts, monitored, initialized := d.currentRemoteDirectory() | |||
| 82 | catalogRows := 0 | 140 | catalogRows := 0 | ||
| 83 | for _, host := range hosts { | 141 | for _, host := range hosts { | ||
| 84 | catalogRows += len(host.Sessions) | 142 | catalogRows += len(host.Sessions) | ||
| @@ -87,10 +145,7 @@ func (d *Daemon) pickerViewProjections(cur *session, ac *attachedClient) ([]pick | |||||
| 87 | } | 145 | } | ||
| 88 | } | 146 | } | ||
| 89 | checking := monitored && !initialized | 147 | checking := monitored && !initialized | ||
| 90 | - | recent := make([]pickerSessionView, 0, len(live)+len(stopped)+catalogRows) | 148 | + | recent := make([]pickerSessionView, 0, catalogRows) |
| 91 | - | for _, item := range live { | |||
| 92 | - | recent = append(recent, item.view.pickerView()) | |||
| 93 | - | } | |||
| 94 | for _, host := range hosts { | 149 | for _, host := range hosts { | ||
| 95 | for _, session := range host.Sessions { | 150 | for _, session := range host.Sessions { | ||
| 96 | key := domain.RemoteSessionKey{Host: host.Endpoint, Name: session.Name} | 151 | key := domain.RemoteSessionKey{Host: host.Endpoint, Name: session.Name} | ||
| @@ -105,19 +160,8 @@ func (d *Daemon) pickerViewProjections(cur *session, ac *attachedClient) ([]pick | |||||
| 105 | if checking { | 160 | if checking { | ||
| 106 | recent = append(recent, remotePickerCheckingView()) | 161 | recent = append(recent, remotePickerCheckingView()) | ||
| 107 | } | 162 | } | ||
| 108 | - | for _, s := range stopped { | |||
| 109 | - | createdAt := s.createdAt | |||
| 110 | - | recent = append(recent, pickerSessionView{ID: domain.SessionID("stopped:" + s.name), Incarnation: s.incarnation, Name: s.name, TargetName: s.name, Stopped: true, ExpectedCreatedAt: &createdAt}) | |||
| 111 | - | } | |||
| 112 | 163 | ||||
| 113 | - | views := make([]pickerSessionView, 0, len(live)+len(stopped)+catalogRows) | 164 | + | grouped := make([]pickerSessionView, 0, catalogRows) |
| 114 | - | for i, item := range live { | |||
| 115 | - | view := item.view.pickerView() | |||
| 116 | - | if i == 0 { | |||
| 117 | - | view.Section = "LOCAL" | |||
| 118 | - | } | |||
| 119 | - | views = append(views, view) | |||
| 120 | - | } | |||
| 121 | for _, host := range hosts { | 165 | for _, host := range hosts { | ||
| 122 | publishedForHost := 0 | 166 | publishedForHost := 0 | ||
| 123 | for _, session := range host.Sessions { | 167 | for _, session := range host.Sessions { | ||
| @@ -130,13 +174,13 @@ func (d *Daemon) pickerViewProjections(cur *session, ac *attachedClient) ([]pick | |||||
| 130 | if publishedForHost == 0 { | 174 | if publishedForHost == 0 { | ||
| 131 | view.Section = "REMOTE " + host.Endpoint | 175 | view.Section = "REMOTE " + host.Endpoint | ||
| 132 | } | 176 | } | ||
| 133 | - | views = append(views, view) | 177 | + | grouped = append(grouped, view) |
| 134 | publishedForHost++ | 178 | publishedForHost++ | ||
| 135 | } | 179 | } | ||
| 136 | if len(host.Sessions) == 0 && host.Availability != domain.RemoteAvailabilityReachable { | 180 | if len(host.Sessions) == 0 && host.Availability != domain.RemoteAvailabilityReachable { | ||
| 137 | view := remotePickerHostView(host, now) | 181 | view := remotePickerHostView(host, now) | ||
| 138 | view.Section = "REMOTE " + host.Endpoint | 182 | view.Section = "REMOTE " + host.Endpoint | ||
| 139 | - | views = append(views, view) | 183 | + | grouped = append(grouped, view) |
| 140 | } | 184 | } | ||
| 141 | } | 185 | } | ||
| 142 | // A nil directory means remote monitoring is not installed at all: | 186 | // A nil directory means remote monitoring is not installed at all: | ||
| @@ -144,24 +188,49 @@ func (d *Daemon) pickerViewProjections(cur *session, ac *attachedClient) ([]pick | |||||
| 144 | if checking { | 188 | if checking { | ||
| 145 | view := remotePickerCheckingView() | 189 | view := remotePickerCheckingView() | ||
| 146 | view.Section = "REMOTE" | 190 | view.Section = "REMOTE" | ||
| 147 | - | views = append(views, view) | 191 | + | grouped = append(grouped, view) |
| 148 | } | 192 | } | ||
| 149 | - | for i, s := range stopped { | 193 | + | return pickerForeignViews{recent: recent, grouped: grouped, blockLocalStopped: catalogRows > 0 || checking} |
| 150 | - | createdAt := s.createdAt | 194 | + | } |
| 151 | - | view := pickerSessionView{ | 195 | + | |
| 152 | - | ID: domain.SessionID("stopped:" + s.name), | 196 | + | // localPickerViewProjections is the complete remote-free picker projection. |
| 153 | - | Incarnation: s.incarnation, | 197 | + | // It appends the local stopped rows directly and applies the LOCAL section |
| 154 | - | Name: s.name, | 198 | + | // label when no live row opened the group. |
| 155 | - | TargetName: s.name, | 199 | + | func (d *Daemon) localPickerViewProjections(cur *session, ac *attachedClient) ([]pickerSessionView, []pickerSessionView, pickerSourceFilter) { |
| 156 | - | Stopped: true, | 200 | + | local := d.localPickerViews(cur, ac) |
| 157 | - | ExpectedCreatedAt: &createdAt, | 201 | + | recent := make([]pickerSessionView, 0, len(local.recentLive)+len(local.recentStopped)) |
| 158 | - | } | 202 | + | recent = append(recent, local.recentLive...) |
| 159 | - | if i == 0 && len(live) == 0 && catalogRows == 0 && !checking { | 203 | + | recent = append(recent, local.recentStopped...) |
| 160 | - | view.Section = "LOCAL" | 204 | + | grouped := make([]pickerSessionView, 0, len(local.groupedLive)+len(local.groupedStopped)) |
| 161 | - | } | 205 | + | grouped = append(grouped, local.groupedLive...) |
| 162 | - | views = append(views, view) | 206 | + | grouped = append(grouped, withLocalStoppedSection(local.groupedStopped, len(local.groupedLive) == 0)...) |
| 163 | - | } | 207 | + | return recent, grouped, local.current |
| 164 | - | return recent, views, current | 208 | + | } |
| 209 | + | ||||
| 210 | + | func (d *Daemon) pickerViewProjections(cur *session, ac *attachedClient) ([]pickerSessionView, []pickerSessionView, pickerSourceFilter) { | |||
| 211 | + | // The hybrid projection reuses the prepared local projection for its | |||
| 212 | + | // local rows and interleaves the current foreign remote rows between the | |||
| 213 | + | // live and stopped groups. | |||
| 214 | + | local := d.localPickerViews(cur, ac) | |||
| 215 | + | foreign := d.foreignPickerViews(local.now) | |||
| 216 | + | recent := make([]pickerSessionView, 0, len(local.recentLive)+len(foreign.recent)+len(local.recentStopped)) | |||
| 217 | + | recent = append(recent, local.recentLive...) | |||
| 218 | + | recent = append(recent, foreign.recent...) | |||
| 219 | + | recent = append(recent, local.recentStopped...) | |||
| 220 | + | grouped := make([]pickerSessionView, 0, len(local.groupedLive)+len(foreign.grouped)+len(local.groupedStopped)) | |||
| 221 | + | grouped = append(grouped, local.groupedLive...) | |||
| 222 | + | grouped = append(grouped, foreign.grouped...) | |||
| 223 | + | grouped = append(grouped, withLocalStoppedSection(local.groupedStopped, len(local.groupedLive) == 0 && !foreign.blockLocalStopped)...) | |||
| 224 | + | return recent, grouped, local.current | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | // withLocalStoppedSection marks the first stopped row as the LOCAL section | |||
| 228 | + | // when no preceding row already opened a local group. | |||
| 229 | + | func withLocalStoppedSection(stopped []pickerSessionView, local bool) []pickerSessionView { | |||
| 230 | + | if local && len(stopped) > 0 { | |||
| 231 | + | stopped[0].Section = "LOCAL" | |||
| 232 | + | } | |||
| 233 | + | return stopped | |||
| 165 | } | 234 | } | ||
| 166 | 235 | ||||
| 167 | func attentionSuffix(label string) string { | 236 | func attentionSuffix(label string) string { | ||
M internal/usecase/daemon/picker_control.go +58 -1
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "github.com/bnema/vev/internal/domain" | |
| 4 | 5 | "github.com/bnema/vev/internal/ports" | |
| 5 | 6 | "github.com/bnema/vev/internal/protocol" | |
| 6 | 7 | "github.com/bnema/vev/internal/usecase/picker" | |
| @@ -76,6 +77,10 @@ func (d *Daemon) observePickerRoutes(targets []protocol.ExactSessionTarget) []pr | |||
| 76 | 77 | } | |
| 77 | 78 | ||
| 78 | 79 | func (d *Daemon) pickerControlAttachTarget(target picker.Target) (protocol.AttachTarget, bool) { | |
| 80 | + | // A remote target names an endpoint-qualified remote session, not a local | |
| 81 | + | // one. The structured route and lifecycle are revalidated against the | |
| 82 | + | // remote catalogue by remoteCatalogTargetReady; the local resolver never | |
| 83 | + | // sees a foreign target. | |
| 79 | 84 | if target.RemoteTarget != nil && target.RemoteKey != nil { | |
| 80 | 85 | remote := *target.RemoteTarget | |
| 81 | 86 | if !d.remoteCatalogTargetReady(remote) { | |
| @@ -83,6 +88,41 @@ func (d *Daemon) pickerControlAttachTarget(target picker.Target) (protocol.Attac | |||
| 83 | 88 | } | |
| 84 | 89 | return protocol.AttachTarget{Endpoint: remote.Endpoint, Session: remote.SessionName, Intent: protocol.IntentAttach, RemoteTarget: &remote, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, true | |
| 85 | 90 | } | |
| 91 | + | return d.pickerControlAttachTargetLocal(target) | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | // pickerControlAttachTargetLocal preserves the hybrid live behavior for a | |
| 95 | + | // local target while keeping the prepared resolver strict. The picker | |
| 96 | + | // snapshot's tab is a best-effort cursor, so a tab that vanished between the | |
| 97 | + | // snapshot and the resolve must not fail the whole attach: the client's own | |
| 98 | + | // missing-tab fallback would have attached the session's first tab anyway. | |
| 99 | + | // The wrapper therefore retries once without a preferred tab, which can only | |
| 100 | + | // succeed when the exact local lifecycle is still current, so a replaced or | |
| 101 | + | // renamed session and a foreign target still reject. | |
| 102 | + | func (d *Daemon) pickerControlAttachTargetLocal(target picker.Target) (protocol.AttachTarget, bool) { | |
| 103 | + | resolved, ok := d.localPickerControlAttachTarget(target) | |
| 104 | + | if ok || target.TabID == "" { | |
| 105 | + | return resolved, ok | |
| 106 | + | } | |
| 107 | + | target.TabID = "" | |
| 108 | + | return d.localPickerControlAttachTarget(target) | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | // localPickerControlAttachTarget is the prepared local-only control resolver | |
| 112 | + | // (Plan 001 P4.3). It rejects a structured foreign target (remote route or | |
| 113 | + | // remote session key) before any lookup, so a local resolve can never be | |
| 114 | + | // satisfied by a foreign row. It then matches the exact session identity and | |
| 115 | + | // lifecycle; a replaced lifecycle or a renamed session never falls back to a | |
| 116 | + | // same-name session. A non-empty preferred tab must still exist in the current | |
| 117 | + | // session, so a stale tab rejects instead of forwarding a dangling tab ID; the | |
| 118 | + | // hybrid pickerControlAttachTarget retains its vanished-tab fallback on top of | |
| 119 | + | // this strict check. | |
| 120 | + | // Broken and purging stopped records are unreachable here: only live registry | |
| 121 | + | // sessions are eligible. | |
| 122 | + | func (d *Daemon) localPickerControlAttachTarget(target picker.Target) (protocol.AttachTarget, bool) { | |
| 123 | + | if target.RemoteTarget != nil || target.RemoteKey != nil { | |
| 124 | + | return protocol.AttachTarget{}, false | |
| 125 | + | } | |
| 86 | 126 | d.mu.Lock() | |
| 87 | 127 | defer d.mu.Unlock() | |
| 88 | 128 | sess := d.sessions[target.Session] | |
| @@ -90,7 +130,24 @@ func (d *Daemon) pickerControlAttachTarget(target picker.Target) (protocol.Attac | |||
| 90 | 130 | return protocol.AttachTarget{}, false | |
| 91 | 131 | } | |
| 92 | 132 | sess.mu.Lock() | |
| 133 | + | defer sess.mu.Unlock() | |
| 134 | + | if !targetMatchesLifecycle(target, sess.name, sess.createdAt, sess.incarnation) { | |
| 135 | + | return protocol.AttachTarget{}, false | |
| 136 | + | } | |
| 137 | + | if target.TabID != "" && !sessionHasStableTabLocked(sess, target.TabID) { | |
| 138 | + | return protocol.AttachTarget{}, false | |
| 139 | + | } | |
| 93 | 140 | exact := protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name} | |
| 94 | - | sess.mu.Unlock() | |
| 95 | 141 | return protocol.AttachTarget{Session: exact.SessionName, Intent: protocol.IntentAttach, ExactTarget: &exact, PreferredTabID: target.TabID, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, true | |
| 96 | 142 | } | |
| 143 | + | ||
| 144 | + | // sessionHasStableTabLocked reports whether an exact stable tab identity is | |
| 145 | + | // still present on a session. Caller holds the session lock. | |
| 146 | + | func sessionHasStableTabLocked(sess *session, tabID domain.TabStableID) bool { | |
| 147 | + | for _, tab := range sess.tabs { | |
| 148 | + | if tab != nil && tab.stableID == string(tabID) { | |
| 149 | + | return true | |
| 150 | + | } | |
| 151 | + | } | |
| 152 | + | return false | |
| 153 | + | } | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "github.com/bnema/vev/internal/domain" | |||
| 4 | "github.com/bnema/vev/internal/ports" | 5 | "github.com/bnema/vev/internal/ports" | ||
| 5 | "github.com/bnema/vev/internal/protocol" | 6 | "github.com/bnema/vev/internal/protocol" | ||
| 6 | "github.com/bnema/vev/internal/usecase/picker" | 7 | "github.com/bnema/vev/internal/usecase/picker" | ||
| @@ -76,6 +77,10 @@ func (d *Daemon) observePickerRoutes(targets []protocol.ExactSessionTarget) []pr | |||||
| 76 | } | 77 | } | ||
| 77 | 78 | ||||
| 78 | func (d *Daemon) pickerControlAttachTarget(target picker.Target) (protocol.AttachTarget, bool) { | 79 | func (d *Daemon) pickerControlAttachTarget(target picker.Target) (protocol.AttachTarget, bool) { | ||
| 80 | + | // A remote target names an endpoint-qualified remote session, not a local | |||
| 81 | + | // one. The structured route and lifecycle are revalidated against the | |||
| 82 | + | // remote catalogue by remoteCatalogTargetReady; the local resolver never | |||
| 83 | + | // sees a foreign target. | |||
| 79 | if target.RemoteTarget != nil && target.RemoteKey != nil { | 84 | if target.RemoteTarget != nil && target.RemoteKey != nil { | ||
| 80 | remote := *target.RemoteTarget | 85 | remote := *target.RemoteTarget | ||
| 81 | if !d.remoteCatalogTargetReady(remote) { | 86 | if !d.remoteCatalogTargetReady(remote) { | ||
| @@ -83,6 +88,41 @@ func (d *Daemon) pickerControlAttachTarget(target picker.Target) (protocol.Attac | |||||
| 83 | } | 88 | } | ||
| 84 | return protocol.AttachTarget{Endpoint: remote.Endpoint, Session: remote.SessionName, Intent: protocol.IntentAttach, RemoteTarget: &remote, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, true | 89 | return protocol.AttachTarget{Endpoint: remote.Endpoint, Session: remote.SessionName, Intent: protocol.IntentAttach, RemoteTarget: &remote, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, true | ||
| 85 | } | 90 | } | ||
| 91 | + | return d.pickerControlAttachTargetLocal(target) | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | // pickerControlAttachTargetLocal preserves the hybrid live behavior for a | |||
| 95 | + | // local target while keeping the prepared resolver strict. The picker | |||
| 96 | + | // snapshot's tab is a best-effort cursor, so a tab that vanished between the | |||
| 97 | + | // snapshot and the resolve must not fail the whole attach: the client's own | |||
| 98 | + | // missing-tab fallback would have attached the session's first tab anyway. | |||
| 99 | + | // The wrapper therefore retries once without a preferred tab, which can only | |||
| 100 | + | // succeed when the exact local lifecycle is still current, so a replaced or | |||
| 101 | + | // renamed session and a foreign target still reject. | |||
| 102 | + | func (d *Daemon) pickerControlAttachTargetLocal(target picker.Target) (protocol.AttachTarget, bool) { | |||
| 103 | + | resolved, ok := d.localPickerControlAttachTarget(target) | |||
| 104 | + | if ok || target.TabID == "" { | |||
| 105 | + | return resolved, ok | |||
| 106 | + | } | |||
| 107 | + | target.TabID = "" | |||
| 108 | + | return d.localPickerControlAttachTarget(target) | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | // localPickerControlAttachTarget is the prepared local-only control resolver | |||
| 112 | + | // (Plan 001 P4.3). It rejects a structured foreign target (remote route or | |||
| 113 | + | // remote session key) before any lookup, so a local resolve can never be | |||
| 114 | + | // satisfied by a foreign row. It then matches the exact session identity and | |||
| 115 | + | // lifecycle; a replaced lifecycle or a renamed session never falls back to a | |||
| 116 | + | // same-name session. A non-empty preferred tab must still exist in the current | |||
| 117 | + | // session, so a stale tab rejects instead of forwarding a dangling tab ID; the | |||
| 118 | + | // hybrid pickerControlAttachTarget retains its vanished-tab fallback on top of | |||
| 119 | + | // this strict check. | |||
| 120 | + | // Broken and purging stopped records are unreachable here: only live registry | |||
| 121 | + | // sessions are eligible. | |||
| 122 | + | func (d *Daemon) localPickerControlAttachTarget(target picker.Target) (protocol.AttachTarget, bool) { | |||
| 123 | + | if target.RemoteTarget != nil || target.RemoteKey != nil { | |||
| 124 | + | return protocol.AttachTarget{}, false | |||
| 125 | + | } | |||
| 86 | d.mu.Lock() | 126 | d.mu.Lock() | ||
| 87 | defer d.mu.Unlock() | 127 | defer d.mu.Unlock() | ||
| 88 | sess := d.sessions[target.Session] | 128 | sess := d.sessions[target.Session] | ||
| @@ -90,7 +130,24 @@ func (d *Daemon) pickerControlAttachTarget(target picker.Target) (protocol.Attac | |||||
| 90 | return protocol.AttachTarget{}, false | 130 | return protocol.AttachTarget{}, false | ||
| 91 | } | 131 | } | ||
| 92 | sess.mu.Lock() | 132 | sess.mu.Lock() | ||
| 133 | + | defer sess.mu.Unlock() | |||
| 134 | + | if !targetMatchesLifecycle(target, sess.name, sess.createdAt, sess.incarnation) { | |||
| 135 | + | return protocol.AttachTarget{}, false | |||
| 136 | + | } | |||
| 137 | + | if target.TabID != "" && !sessionHasStableTabLocked(sess, target.TabID) { | |||
| 138 | + | return protocol.AttachTarget{}, false | |||
| 139 | + | } | |||
| 93 | exact := protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name} | 140 | exact := protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name} | ||
| 94 | - | sess.mu.Unlock() | |||
| 95 | return protocol.AttachTarget{Session: exact.SessionName, Intent: protocol.IntentAttach, ExactTarget: &exact, PreferredTabID: target.TabID, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, true | 141 | return protocol.AttachTarget{Session: exact.SessionName, Intent: protocol.IntentAttach, ExactTarget: &exact, PreferredTabID: target.TabID, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, true | ||
| 96 | } | 142 | } | ||
| 143 | + | ||||
| 144 | + | // sessionHasStableTabLocked reports whether an exact stable tab identity is | |||
| 145 | + | // still present on a session. Caller holds the session lock. | |||
| 146 | + | func sessionHasStableTabLocked(sess *session, tabID domain.TabStableID) bool { | |||
| 147 | + | for _, tab := range sess.tabs { | |||
| 148 | + | if tab != nil && tab.stableID == string(tabID) { | |||
| 149 | + | return true | |||
| 150 | + | } | |||
| 151 | + | } | |||
| 152 | + | return false | |||
| 153 | + | } | |||
M internal/usecase/daemon/remote_directory_view.go +13 -0
| @@ -20,6 +20,19 @@ func (d *Daemon) remoteDirectorySnapshot() ports.RemoteDirectorySnapshot { | |||
| 20 | 20 | return d.remoteDirectory.Snapshot() | |
| 21 | 21 | } | |
| 22 | 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 | + | ||
| 23 | 36 | // sortDirectoryHosts orders hosts by registry rank, breaking ties by | |
| 24 | 37 | // endpoint. Snapshot slice order is alphabetical; Rank carries registry | |
| 25 | 38 | // (pinned-then-learned) order for presentation. | |
| @@ -20,6 +20,19 @@ func (d *Daemon) remoteDirectorySnapshot() ports.RemoteDirectorySnapshot { | |||||
| 20 | return d.remoteDirectory.Snapshot() | 20 | return d.remoteDirectory.Snapshot() | ||
| 21 | } | 21 | } | ||
| 22 | 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 | + | ||||
| 23 | // sortDirectoryHosts orders hosts by registry rank, breaking ties by | 36 | // sortDirectoryHosts orders hosts by registry rank, breaking ties by | ||
| 24 | // endpoint. Snapshot slice order is alphabetical; Rank carries registry | 37 | // endpoint. Snapshot slice order is alphabetical; Rank carries registry | ||
| 25 | // (pinned-then-learned) order for presentation. | 38 | // (pinned-then-learned) order for presentation. | ||
M internal/usecase/daemon/session_inventory.go +38 -29
| @@ -15,32 +15,41 @@ type inventoriedLiveSession struct { | |||
| 15 | 15 | view sessionView | |
| 16 | 16 | } | |
| 17 | 17 | ||
| 18 | - | // sessionInventory is the daemon-owned common capture consumed by the | |
| 19 | - | // palette, picker, and catalog export projections. It copies active/inactive | |
| 20 | - | // records and remote-directory values once through this module; projections | |
| 21 | - | // keep only UI-specific filtering, sorting, and labeling. | |
| 18 | + | // localSessionInventory is the remote-free capture consumed by the prepared | |
| 19 | + | // local projections: the local catalogue export, the local navigation source | |
| 20 | + | // 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. | |
| 22 | 23 | // | |
| 23 | 24 | // d.mu is released before any per-session snapshot, preserving the | |
| 24 | 25 | // established lock ordering. Copied values are coherent per exact target, | |
| 25 | 26 | // not a distributed atomic transaction; callers revalidate at selection. | |
| 27 | + | type localSessionInventory struct { | |
| 28 | + | live []inventoriedLiveSession | |
| 29 | + | stopped []inactiveSession | |
| 30 | + | now time.Time | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // 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. | |
| 26 | 39 | type sessionInventory struct { | |
| 27 | - | live []inventoriedLiveSession | |
| 28 | - | stopped []inactiveSession | |
| 29 | - | hosts []ports.RemoteHostSnapshot | |
| 30 | - | monitored bool | |
| 31 | - | initialized bool | |
| 32 | - | now time.Time | |
| 40 | + | localSessionInventory | |
| 41 | + | hosts []ports.RemoteHostSnapshot | |
| 33 | 42 | } | |
| 34 | 43 | ||
| 35 | - | // captureSessionInventory copies registry pointers and inactive records under | |
| 36 | - | // d.mu, then snapshots each live session without holding that lock. When | |
| 44 | + | // captureLocalSessionInventory copies registry pointers and inactive records | |
| 45 | + | // under d.mu, then snapshots each live session without holding that lock. When | |
| 37 | 46 | // refreshTitles is true the focused-title refresh runs per session after the | |
| 38 | 47 | // registry lock is released, matching the historical picker/catalog cost; | |
| 39 | - | // palette passes false to avoid rich tab-title work it never displays. | |
| 40 | - | func (d *Daemon) captureSessionInventory(opts viewOptions, refreshTitles bool) sessionInventory { | |
| 48 | + | // palette passes false to avoid rich tab-title work it never displays. It | |
| 49 | + | // performs no remote-directory read. | |
| 50 | + | func (d *Daemon) captureLocalSessionInventory(opts viewOptions, refreshTitles bool) localSessionInventory { | |
| 41 | 51 | var sessions []*session | |
| 42 | 52 | var stopped []inactiveSession | |
| 43 | - | var monitored bool | |
| 44 | 53 | if d != nil { | |
| 45 | 54 | d.mu.Lock() | |
| 46 | 55 | sessions = d.sessionsSnapshotLocked() | |
| @@ -48,7 +57,6 @@ func (d *Daemon) captureSessionInventory(opts viewOptions, refreshTitles bool) s | |||
| 48 | 57 | for _, entry := range d.inactive { | |
| 49 | 58 | stopped = append(stopped, entry) | |
| 50 | 59 | } | |
| 51 | - | monitored = d.remoteDirectory != nil | |
| 52 | 60 | d.mu.Unlock() | |
| 53 | 61 | } | |
| 54 | 62 | if refreshTitles && d != nil { | |
| @@ -63,22 +71,23 @@ func (d *Daemon) captureSessionInventory(opts viewOptions, refreshTitles bool) s | |||
| 63 | 71 | } | |
| 64 | 72 | live = append(live, inventoriedLiveSession{sess: sess, view: sess.snapshotView(opts)}) | |
| 65 | 73 | } | |
| 66 | - | directory := d.remoteDirectorySnapshot() | |
| 67 | - | hosts := append([]ports.RemoteHostSnapshot(nil), directory.Hosts...) | |
| 68 | - | sortDirectoryHosts(hosts) | |
| 69 | - | return sessionInventory{ | |
| 70 | - | live: live, | |
| 71 | - | stopped: stopped, | |
| 72 | - | hosts: hosts, | |
| 73 | - | monitored: monitored, | |
| 74 | - | initialized: directory.Initialized, | |
| 75 | - | now: d.daemonNow(), | |
| 76 | - | } | |
| 74 | + | return localSessionInventory{live: live, stopped: stopped, now: d.daemonNow()} | |
| 75 | + | } | |
| 76 | + | ||
| 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. | |
| 82 | + | 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} | |
| 77 | 86 | } | |
| 78 | 87 | ||
| 79 | 88 | // resumableStopped returns stopped sessions eligible for resume display, | |
| 80 | 89 | // preserving the palette's historical canResume projection. | |
| 81 | - | func (inv sessionInventory) resumableStopped() []inactiveSession { | |
| 90 | + | func (inv localSessionInventory) resumableStopped() []inactiveSession { | |
| 82 | 91 | out := make([]inactiveSession, 0, len(inv.stopped)) | |
| 83 | 92 | for _, entry := range inv.stopped { | |
| 84 | 93 | if entry.canResume() { | |
| @@ -90,7 +99,7 @@ func (inv sessionInventory) resumableStopped() []inactiveSession { | |||
| 90 | 99 | ||
| 91 | 100 | // visibleStopped returns non-purging stopped sessions, preserving the | |
| 92 | 101 | // picker and catalog export projections. | |
| 93 | - | func (inv sessionInventory) visibleStopped() []inactiveSession { | |
| 102 | + | func (inv localSessionInventory) visibleStopped() []inactiveSession { | |
| 94 | 103 | out := make([]inactiveSession, 0, len(inv.stopped)) | |
| 95 | 104 | for _, entry := range inv.stopped { | |
| 96 | 105 | if entry.visible() { | |
| @@ -15,32 +15,41 @@ type inventoriedLiveSession struct { | |||||
| 15 | view sessionView | 15 | view sessionView | ||
| 16 | } | 16 | } | ||
| 17 | 17 | ||||
| 18 | - | // sessionInventory is the daemon-owned common capture consumed by the | 18 | + | // localSessionInventory is the remote-free capture consumed by the prepared |
| 19 | - | // palette, picker, and catalog export projections. It copies active/inactive | 19 | + | // local projections: the local catalogue export, the local navigation source |
| 20 | - | // records and remote-directory values once through this module; projections | 20 | + | // group, and the local picker/control projection. It copies active and |
| 21 | - | // keep only UI-specific filtering, sorting, and labeling. | 21 | + | // inactive records and never reads the remote directory, so a local-only |
| 22 | + | // projection cannot observe or depend on remote monitoring. | |||
| 22 | // | 23 | // | ||
| 23 | // d.mu is released before any per-session snapshot, preserving the | 24 | // d.mu is released before any per-session snapshot, preserving the | ||
| 24 | // established lock ordering. Copied values are coherent per exact target, | 25 | // established lock ordering. Copied values are coherent per exact target, | ||
| 25 | // not a distributed atomic transaction; callers revalidate at selection. | 26 | // not a distributed atomic transaction; callers revalidate at selection. | ||
| 27 | + | type localSessionInventory struct { | |||
| 28 | + | live []inventoriedLiveSession | |||
| 29 | + | stopped []inactiveSession | |||
| 30 | + | now time.Time | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | // 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. | |||
| 26 | type sessionInventory struct { | 39 | type sessionInventory struct { | ||
| 27 | - | live []inventoriedLiveSession | 40 | + | localSessionInventory |
| 28 | - | stopped []inactiveSession | 41 | + | hosts []ports.RemoteHostSnapshot |
| 29 | - | hosts []ports.RemoteHostSnapshot | |||
| 30 | - | monitored bool | |||
| 31 | - | initialized bool | |||
| 32 | - | now time.Time | |||
| 33 | } | 42 | } | ||
| 34 | 43 | ||||
| 35 | - | // captureSessionInventory copies registry pointers and inactive records under | 44 | + | // captureLocalSessionInventory copies registry pointers and inactive records |
| 36 | - | // d.mu, then snapshots each live session without holding that lock. When | 45 | + | // under d.mu, then snapshots each live session without holding that lock. When |
| 37 | // refreshTitles is true the focused-title refresh runs per session after the | 46 | // refreshTitles is true the focused-title refresh runs per session after the | ||
| 38 | // registry lock is released, matching the historical picker/catalog cost; | 47 | // registry lock is released, matching the historical picker/catalog cost; | ||
| 39 | - | // palette passes false to avoid rich tab-title work it never displays. | 48 | + | // palette passes false to avoid rich tab-title work it never displays. It |
| 40 | - | func (d *Daemon) captureSessionInventory(opts viewOptions, refreshTitles bool) sessionInventory { | 49 | + | // performs no remote-directory read. |
| 50 | + | func (d *Daemon) captureLocalSessionInventory(opts viewOptions, refreshTitles bool) localSessionInventory { | |||
| 41 | var sessions []*session | 51 | var sessions []*session | ||
| 42 | var stopped []inactiveSession | 52 | var stopped []inactiveSession | ||
| 43 | - | var monitored bool | |||
| 44 | if d != nil { | 53 | if d != nil { | ||
| 45 | d.mu.Lock() | 54 | d.mu.Lock() | ||
| 46 | sessions = d.sessionsSnapshotLocked() | 55 | sessions = d.sessionsSnapshotLocked() | ||
| @@ -48,7 +57,6 @@ func (d *Daemon) captureSessionInventory(opts viewOptions, refreshTitles bool) s | |||||
| 48 | for _, entry := range d.inactive { | 57 | for _, entry := range d.inactive { | ||
| 49 | stopped = append(stopped, entry) | 58 | stopped = append(stopped, entry) | ||
| 50 | } | 59 | } | ||
| 51 | - | monitored = d.remoteDirectory != nil | |||
| 52 | d.mu.Unlock() | 60 | d.mu.Unlock() | ||
| 53 | } | 61 | } | ||
| 54 | if refreshTitles && d != nil { | 62 | if refreshTitles && d != nil { | ||
| @@ -63,22 +71,23 @@ func (d *Daemon) captureSessionInventory(opts viewOptions, refreshTitles bool) s | |||||
| 63 | } | 71 | } | ||
| 64 | live = append(live, inventoriedLiveSession{sess: sess, view: sess.snapshotView(opts)}) | 72 | live = append(live, inventoriedLiveSession{sess: sess, view: sess.snapshotView(opts)}) | ||
| 65 | } | 73 | } | ||
| 66 | - | directory := d.remoteDirectorySnapshot() | 74 | + | return localSessionInventory{live: live, stopped: stopped, now: d.daemonNow()} |
| 67 | - | hosts := append([]ports.RemoteHostSnapshot(nil), directory.Hosts...) | 75 | + | } |
| 68 | - | sortDirectoryHosts(hosts) | 76 | + | |
| 69 | - | return sessionInventory{ | 77 | + | // captureSessionInventory composes the remote-free local capture with the |
| 70 | - | live: live, | 78 | + | // current foreign remote-directory rows. The daemon-side monitor stays |
| 71 | - | stopped: stopped, | 79 | + | // intentionally active in this state (coordinated P7 removal set), so hybrid |
| 72 | - | hosts: hosts, | 80 | + | // projections keep their foreign rows; a nil directory yields an empty, |
| 73 | - | monitored: monitored, | 81 | + | // uninitialized publication and never gates local behavior. |
| 74 | - | initialized: directory.Initialized, | 82 | + | func (d *Daemon) captureSessionInventory(opts viewOptions, refreshTitles bool) sessionInventory { |
| 75 | - | now: d.daemonNow(), | 83 | + | local := d.captureLocalSessionInventory(opts, refreshTitles) |
| 76 | - | } | 84 | + | hosts, _, _ := d.currentRemoteDirectory() |
| 85 | + | return sessionInventory{localSessionInventory: local, hosts: hosts} | |||
| 77 | } | 86 | } | ||
| 78 | 87 | ||||
| 79 | // resumableStopped returns stopped sessions eligible for resume display, | 88 | // resumableStopped returns stopped sessions eligible for resume display, | ||
| 80 | // preserving the palette's historical canResume projection. | 89 | // preserving the palette's historical canResume projection. | ||
| 81 | - | func (inv sessionInventory) resumableStopped() []inactiveSession { | 90 | + | func (inv localSessionInventory) resumableStopped() []inactiveSession { |
| 82 | out := make([]inactiveSession, 0, len(inv.stopped)) | 91 | out := make([]inactiveSession, 0, len(inv.stopped)) | ||
| 83 | for _, entry := range inv.stopped { | 92 | for _, entry := range inv.stopped { | ||
| 84 | if entry.canResume() { | 93 | if entry.canResume() { | ||
| @@ -90,7 +99,7 @@ func (inv sessionInventory) resumableStopped() []inactiveSession { | |||||
| 90 | 99 | ||||
| 91 | // visibleStopped returns non-purging stopped sessions, preserving the | 100 | // visibleStopped returns non-purging stopped sessions, preserving the | ||
| 92 | // picker and catalog export projections. | 101 | // picker and catalog export projections. | ||
| 93 | - | func (inv sessionInventory) visibleStopped() []inactiveSession { | 102 | + | func (inv localSessionInventory) visibleStopped() []inactiveSession { |
| 94 | out := make([]inactiveSession, 0, len(inv.stopped)) | 103 | out := make([]inactiveSession, 0, len(inv.stopped)) | ||
| 95 | for _, entry := range inv.stopped { | 104 | for _, entry := range inv.stopped { | ||
| 96 | if entry.visible() { | 105 | if entry.visible() { | ||