detached c16729e

refactor(daemon): isolate local catalogue boundaries

commit
c16729ea20c51992657a1ed62f3e3c376d1d1304
author
brice · · 2026-09-17 08:03 UTC
parent
3e0d64d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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,
114114 gated, or polled differently before that cutover, and no polling gate is
115115 introduced here (GO-001, deferred to P7).
116116
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+
117174 ## Session composition
118175
119176 ```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 is114 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 composition174 ## Session composition
118 175
119 ```text176 ```text
M internal/usecase/daemon/control.go +4 -1
@@ -728,7 +728,10 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) {
728728 if !asJSON {
729729 return "", command.ErrInvalidArguments
730730 }
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)
732735 stopped := inv.visibleStopped()
733736
734737 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.ErrInvalidArguments729 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
4949 func (d *Daemon) snapshotNavigationInventory(requestID uint64) protocol.NavigationInventoryResponse {
5050 inv := d.captureSessionInventory(viewOptions{}, false)
5151 groups := make([]protocol.NavigationInventorySourceGroup, 0, len(inv.hosts)+1)
52- groups = append(groups, d.localInventoryGroup(inv))
52+ groups = append(groups, d.localInventoryGroup(inv.localSessionInventory))
5353 for _, host := range inv.hosts {
5454 groups = append(groups, remoteInventoryGroup(host))
5555 }
@@ -60,10 +60,26 @@ func (d *Daemon) snapshotNavigationInventory(requestID uint64) protocol.Navigati
6060 return fitNavigationInventoryResponse(response)
6161 }
6262
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+
6378 // 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 {
6783 group := protocol.NavigationInventorySourceGroup{SourceKey: protocol.NavigationInventoryLocalSourceKey, Status: protocol.NavigationInventorySourceOK}
6884 for _, item := range inv.live {
6985 view := item.view
@@ -210,7 +226,7 @@ func fitNavigationInventoryResponse(response protocol.NavigationInventoryRespons
210226 func (d *Daemon) resolveNavigationInventory(request protocol.NavigationInventoryRequest) protocol.NavigationInventoryResponse {
211227 response := protocol.NavigationInventoryResponse{RequestID: request.RequestID, Operation: protocol.NavigationInventoryResolve}
212228 if request.SourceKey == protocol.NavigationInventoryLocalSourceKey {
213- target, ok := d.resolveLocalInventoryEntry(request.EntryKey)
229+ target, ok := d.localNavigationResolve(request)
214230 if !ok {
215231 response.Status = protocol.NavigationInventoryUnavailable
216232 return response
@@ -239,11 +255,24 @@ func (d *Daemon) resolveNavigationInventory(request protocol.NavigationInventory
239255 return response
240256 }
241257
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
243272 // 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) {
247276 for _, item := range inv.live {
248277 view := item.view
249278 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 stopped78 // localInventoryGroup projects live named sessions and resumable stopped
64-// sessions. Observation failures do not exist for local state; broken79+// 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.view85 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.NavigationInventoryUnavailable231 response.Status = protocol.NavigationInventoryUnavailable
216 return response232 return response
@@ -239,11 +255,24 @@ func (d *Daemon) resolveNavigationInventory(request protocol.NavigationInventory
239 return response255 return response
240 }256 }
241 257
242-// resolveLocalInventoryEntry matches the opaque key against fresh live and258+// 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 same272 // 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.view277 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
3232 return grouped, current
3333 }
3434
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 {
3663 if cur != nil && ac != nil {
3764 cur.repairAttachmentView(ac)
3865 }
3966 opts := viewOptions{tabDetails: true, focusedTitles: true, terminalTitle: d.currentTabsConfig().TerminalTitle}
40- inv := d.captureSessionInventory(opts, true)
67+ inv := d.captureLocalSessionInventory(opts, true)
4168 stopped := inv.visibleStopped()
42- hosts := inv.hosts
43- now := inv.now
44- monitored := inv.monitored
45- initialized := inv.initialized
4669
4770 // One snapshot per live session: sorting and view building read the same
4871 // 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.
5173 live := inv.live
5274 var current pickerSourceFilter
5375 for _, item := range live {
@@ -79,6 +101,42 @@ func (d *Daemon) pickerViewProjections(cur *session, ac *attachedClient) ([]pick
79101 return stopped[i].name < stopped[j].name
80102 })
81103
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()
82140 catalogRows := 0
83141 for _, host := range hosts {
84142 catalogRows += len(host.Sessions)
@@ -87,10 +145,7 @@ func (d *Daemon) pickerViewProjections(cur *session, ac *attachedClient) ([]pick
87145 }
88146 }
89147 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)
94149 for _, host := range hosts {
95150 for _, session := range host.Sessions {
96151 key := domain.RemoteSessionKey{Host: host.Endpoint, Name: session.Name}
@@ -105,19 +160,8 @@ func (d *Daemon) pickerViewProjections(cur *session, ac *attachedClient) ([]pick
105160 if checking {
106161 recent = append(recent, remotePickerCheckingView())
107162 }
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- }
112163
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)
121165 for _, host := range hosts {
122166 publishedForHost := 0
123167 for _, session := range host.Sessions {
@@ -130,13 +174,13 @@ func (d *Daemon) pickerViewProjections(cur *session, ac *attachedClient) ([]pick
130174 if publishedForHost == 0 {
131175 view.Section = "REMOTE " + host.Endpoint
132176 }
133- views = append(views, view)
177+ grouped = append(grouped, view)
134178 publishedForHost++
135179 }
136180 if len(host.Sessions) == 0 && host.Availability != domain.RemoteAvailabilityReachable {
137181 view := remotePickerHostView(host, now)
138182 view.Section = "REMOTE " + host.Endpoint
139- views = append(views, view)
183+ grouped = append(grouped, view)
140184 }
141185 }
142186 // A nil directory means remote monitoring is not installed at all:
@@ -144,24 +188,49 @@ func (d *Daemon) pickerViewProjections(cur *session, ac *attachedClient) ([]pick
144188 if checking {
145189 view := remotePickerCheckingView()
146190 view.Section = "REMOTE"
147- views = append(views, view)
191+ grouped = append(grouped, view)
148192 }
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
165234 }
166235
167236 func attentionSuffix(label string) string {
@@ -32,22 +32,44 @@ func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]pickerSessionV
32 return grouped, current32 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 same70 // One snapshot per live session: sorting and view building read the same
48 // capture, so comparators cannot observe a concurrent touchMRU or71 // capture, so comparators cannot observe a concurrent touchMRU or
49- // renameSession mid-sort. Remote-catalog locks are released before sorting72+ // renameSession mid-sort.
50- // and picker-row construction.
51 live := inv.live73 live := inv.live
52 var current pickerSourceFilter74 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].name101 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 := 0140 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 && !initialized147 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 := 0166 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.Endpoint175 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.Endpoint182 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.createdAt194+}
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, current208+}
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 @@
11 package daemon
22
33 import (
4+ "github.com/bnema/vev/internal/domain"
45 "github.com/bnema/vev/internal/ports"
56 "github.com/bnema/vev/internal/protocol"
67 "github.com/bnema/vev/internal/usecase/picker"
@@ -76,6 +77,10 @@ func (d *Daemon) observePickerRoutes(targets []protocol.ExactSessionTarget) []pr
7677 }
7778
7879 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.
7984 if target.RemoteTarget != nil && target.RemoteKey != nil {
8085 remote := *target.RemoteTarget
8186 if !d.remoteCatalogTargetReady(remote) {
@@ -83,6 +88,41 @@ func (d *Daemon) pickerControlAttachTarget(target picker.Target) (protocol.Attac
8388 }
8489 return protocol.AttachTarget{Endpoint: remote.Endpoint, Session: remote.SessionName, Intent: protocol.IntentAttach, RemoteTarget: &remote, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, true
8590 }
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+ }
86126 d.mu.Lock()
87127 defer d.mu.Unlock()
88128 sess := d.sessions[target.Session]
@@ -90,7 +130,24 @@ func (d *Daemon) pickerControlAttachTarget(target picker.Target) (protocol.Attac
90130 return protocol.AttachTarget{}, false
91131 }
92132 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+ }
93140 exact := protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}
94- sess.mu.Unlock()
95141 return protocol.AttachTarget{Session: exact.SessionName, Intent: protocol.IntentAttach, ExactTarget: &exact, PreferredTabID: target.TabID, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, true
96142 }
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 daemon1 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.RemoteTarget85 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}, true89 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{}, false130 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}, true141 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 {
2020 return d.remoteDirectory.Snapshot()
2121 }
2222
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+
2336 // sortDirectoryHosts orders hosts by registry rank, breaking ties by
2437 // endpoint. Snapshot slice order is alphabetical; Rank carries registry
2538 // (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 by36 // sortDirectoryHosts orders hosts by registry rank, breaking ties by
24 // endpoint. Snapshot slice order is alphabetical; Rank carries registry37 // 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 {
1515 view sessionView
1616 }
1717
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.
2223 //
2324 // d.mu is released before any per-session snapshot, preserving the
2425 // established lock ordering. Copied values are coherent per exact target,
2526 // 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.
2639 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
3342 }
3443
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
3746 // refreshTitles is true the focused-title refresh runs per session after the
3847 // 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 {
4151 var sessions []*session
4252 var stopped []inactiveSession
43- var monitored bool
4453 if d != nil {
4554 d.mu.Lock()
4655 sessions = d.sessionsSnapshotLocked()
@@ -48,7 +57,6 @@ func (d *Daemon) captureSessionInventory(opts viewOptions, refreshTitles bool) s
4857 for _, entry := range d.inactive {
4958 stopped = append(stopped, entry)
5059 }
51- monitored = d.remoteDirectory != nil
5260 d.mu.Unlock()
5361 }
5462 if refreshTitles && d != nil {
@@ -63,22 +71,23 @@ func (d *Daemon) captureSessionInventory(opts viewOptions, refreshTitles bool) s
6371 }
6472 live = append(live, inventoriedLiveSession{sess: sess, view: sess.snapshotView(opts)})
6573 }
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}
7786 }
7887
7988 // resumableStopped returns stopped sessions eligible for resume display,
8089 // preserving the palette's historical canResume projection.
81-func (inv sessionInventory) resumableStopped() []inactiveSession {
90+func (inv localSessionInventory) resumableStopped() []inactiveSession {
8291 out := make([]inactiveSession, 0, len(inv.stopped))
8392 for _, entry := range inv.stopped {
8493 if entry.canResume() {
@@ -90,7 +99,7 @@ func (inv sessionInventory) resumableStopped() []inactiveSession {
9099
91100 // visibleStopped returns non-purging stopped sessions, preserving the
92101 // picker and catalog export projections.
93-func (inv sessionInventory) visibleStopped() []inactiveSession {
102+func (inv localSessionInventory) visibleStopped() []inactiveSession {
94103 out := make([]inactiveSession, 0, len(inv.stopped))
95104 for _, entry := range inv.stopped {
96105 if entry.visible() {
@@ -15,32 +15,41 @@ type inventoriedLiveSession struct {
15 view sessionView15 view sessionView
16 }16 }
17 17
18-// sessionInventory is the daemon-owned common capture consumed by the18+// localSessionInventory is the remote-free capture consumed by the prepared
19-// palette, picker, and catalog export projections. It copies active/inactive19+// local projections: the local catalogue export, the local navigation source
20-// records and remote-directory values once through this module; projections20+// 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 the24 // 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 []inventoriedLiveSession40+ localSessionInventory
28- stopped []inactiveSession41+ 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 under44+// captureLocalSessionInventory copies registry pointers and inactive records
36-// d.mu, then snapshots each live session without holding that lock. When45+// 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 the46 // 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 []*session51 var sessions []*session
42 var stopped []inactiveSession52 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 the100 // 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() {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close