fix(routes): stabilize remote navigation state
- commit
- 217c05acbf75956fee9fdde9f71f50389579ba85
- author
- brice · · 2026-08-10 04:20 UTC
- parent
- 17d2014
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
39 files · +905 -129
M .agent-reviews/redteam.md +46 -0
| @@ -46,3 +46,49 @@ history cap is 20, origin keys distinguish daemon endpoints, route labels reject | |||
| 46 | 46 | invalid UTF-8/control text, navigation actions carry the snapshot generation, | |
| 47 | 47 | active selection is a no-op, and failed transitions invoke the prior-route | |
| 48 | 48 | restore hook before returning failure. | |
| 49 | + | ||
| 50 | + | # Client-owned route-position red-team | |
| 51 | + | ||
| 52 | + | ## Builder decision | |
| 53 | + | ||
| 54 | + | Keep live tab/view authority on each daemon attachment, but remember the last | |
| 55 | + | stable tab ID per exact client route in the process-local route ledger. Add an | |
| 56 | + | optional preferred stable tab to Hello and a daemon-to-client route-position | |
| 57 | + | publication bound to an exact lifecycle/name target. A missing preferred tab is | |
| 58 | + | a hint miss and falls back to the daemon's normal attachment tab; it never | |
| 59 | + | changes session identity or shared session tab state. | |
| 60 | + | ||
| 61 | + | ## Critic pass | |
| 62 | + | ||
| 63 | + | - **Multiple clients:** daemon-global or session-global memory would couple | |
| 64 | + | clients. The remembered cursor stays in each Runner's private route ledger; | |
| 65 | + | the daemon applies it only to the requesting attachment. | |
| 66 | + | - **Stale/out-of-order publications:** a tab ID without session identity could | |
| 67 | + | corrupt the route selected by a concurrent transition. RoutePosition carries | |
| 68 | + | the exact lifecycle/name target, and the ledger applies it only to that route. | |
| 69 | + | - **Handshake ordering:** publishing another server frame before Welcome would | |
| 70 | + | recreate the resume-token failure found during manual testing. Initial route | |
| 71 | + | position is emitted only by post-Welcome rendering; Welcome stays first. | |
| 72 | + | - **Deleted tabs:** remembered positions are hints, not exact picker authority. | |
| 73 | + | The daemon validates stable IDs under target-session ownership and falls back | |
| 74 | + | without rejecting the attachment when the tab no longer exists. | |
| 75 | + | - **Discovery metadata:** RemoteTarget includes a point-in-time live tab and must | |
| 76 | + | not become route memory. Committed route records drop that catalog selector; | |
| 77 | + | PreferredTabID is the independent mutable cursor. | |
| 78 | + | - **Resume and identity authority:** a preferred tab cannot select a session. It | |
| 79 | + | is accepted only for attach/resume and only after resume-token or exact-target | |
| 80 | + | session resolution succeeds. | |
| 81 | + | - **Untrusted IDs:** both Hello and RoutePosition reject empty/oversized/control | |
| 82 | + | stable IDs and trailing or truncated payloads. | |
| 83 | + | - **Publication completeness:** tab selection and repair always invalidate a | |
| 84 | + | render. RoutePosition is serialized with the attachment's post-commit output | |
| 85 | + | transaction and cached per transport incarnation; output rebase forces an | |
| 86 | + | initial publication after attach/resume. | |
| 87 | + | ||
| 88 | + | ## Accepted risks | |
| 89 | + | ||
| 90 | + | - The state is intentionally process-memory only. Restarting the client loses | |
| 91 | + | remembered tabs; restarting a daemon does not make the daemon the memory | |
| 92 | + | owner, and missing/replaced stable IDs safely fall back. | |
| 93 | + | - RoutePosition is an additional small control frame only when the exact target | |
| 94 | + | or active stable tab changes, not on every output diff. | |
| @@ -46,3 +46,49 @@ history cap is 20, origin keys distinguish daemon endpoints, route labels reject | |||||
| 46 | invalid UTF-8/control text, navigation actions carry the snapshot generation, | 46 | invalid UTF-8/control text, navigation actions carry the snapshot generation, | ||
| 47 | active selection is a no-op, and failed transitions invoke the prior-route | 47 | active selection is a no-op, and failed transitions invoke the prior-route | ||
| 48 | restore hook before returning failure. | 48 | restore hook before returning failure. | ||
| 49 | + | ||||
| 50 | + | # Client-owned route-position red-team | |||
| 51 | + | ||||
| 52 | + | ## Builder decision | |||
| 53 | + | ||||
| 54 | + | Keep live tab/view authority on each daemon attachment, but remember the last | |||
| 55 | + | stable tab ID per exact client route in the process-local route ledger. Add an | |||
| 56 | + | optional preferred stable tab to Hello and a daemon-to-client route-position | |||
| 57 | + | publication bound to an exact lifecycle/name target. A missing preferred tab is | |||
| 58 | + | a hint miss and falls back to the daemon's normal attachment tab; it never | |||
| 59 | + | changes session identity or shared session tab state. | |||
| 60 | + | ||||
| 61 | + | ## Critic pass | |||
| 62 | + | ||||
| 63 | + | - **Multiple clients:** daemon-global or session-global memory would couple | |||
| 64 | + | clients. The remembered cursor stays in each Runner's private route ledger; | |||
| 65 | + | the daemon applies it only to the requesting attachment. | |||
| 66 | + | - **Stale/out-of-order publications:** a tab ID without session identity could | |||
| 67 | + | corrupt the route selected by a concurrent transition. RoutePosition carries | |||
| 68 | + | the exact lifecycle/name target, and the ledger applies it only to that route. | |||
| 69 | + | - **Handshake ordering:** publishing another server frame before Welcome would | |||
| 70 | + | recreate the resume-token failure found during manual testing. Initial route | |||
| 71 | + | position is emitted only by post-Welcome rendering; Welcome stays first. | |||
| 72 | + | - **Deleted tabs:** remembered positions are hints, not exact picker authority. | |||
| 73 | + | The daemon validates stable IDs under target-session ownership and falls back | |||
| 74 | + | without rejecting the attachment when the tab no longer exists. | |||
| 75 | + | - **Discovery metadata:** RemoteTarget includes a point-in-time live tab and must | |||
| 76 | + | not become route memory. Committed route records drop that catalog selector; | |||
| 77 | + | PreferredTabID is the independent mutable cursor. | |||
| 78 | + | - **Resume and identity authority:** a preferred tab cannot select a session. It | |||
| 79 | + | is accepted only for attach/resume and only after resume-token or exact-target | |||
| 80 | + | session resolution succeeds. | |||
| 81 | + | - **Untrusted IDs:** both Hello and RoutePosition reject empty/oversized/control | |||
| 82 | + | stable IDs and trailing or truncated payloads. | |||
| 83 | + | - **Publication completeness:** tab selection and repair always invalidate a | |||
| 84 | + | render. RoutePosition is serialized with the attachment's post-commit output | |||
| 85 | + | transaction and cached per transport incarnation; output rebase forces an | |||
| 86 | + | initial publication after attach/resume. | |||
| 87 | + | ||||
| 88 | + | ## Accepted risks | |||
| 89 | + | ||||
| 90 | + | - The state is intentionally process-memory only. Restarting the client loses | |||
| 91 | + | remembered tabs; restarting a daemon does not make the daemon the memory | |||
| 92 | + | owner, and missing/replaced stable IDs safely fall back. | |||
| 93 | + | - RoutePosition is an additional small control frame only when the exact target | |||
| 94 | + | or active stable tab changes, not on every output diff. | |||
M internal/app/cmd.go +15 -2
| @@ -128,11 +128,20 @@ type cmdDeps struct { | |||
| 128 | 128 | stdout io.Writer | |
| 129 | 129 | getenv func(string) string | |
| 130 | 130 | dial func(context.Context, string) (ports.Transport, error) | |
| 131 | + | ensure func(context.Context, string) (ports.Transport, error) | |
| 131 | 132 | clock ports.Clock | |
| 132 | 133 | } | |
| 133 | 134 | ||
| 134 | 135 | func runCmd(ctx context.Context, invocation cmdInvocation) error { | |
| 135 | - | return runCmdWithDeps(ctx, invocation, cmdDeps{stdout: os.Stdout, getenv: os.Getenv, dial: realDial, clock: clock.New()}) | |
| 136 | + | return runCmdWithDeps(ctx, invocation, cmdDeps{ | |
| 137 | + | stdout: os.Stdout, | |
| 138 | + | getenv: os.Getenv, | |
| 139 | + | dial: realDial, | |
| 140 | + | ensure: func(ctx context.Context, dir string) (ports.Transport, error) { | |
| 141 | + | return ensureDaemonWithLifecycle(ctx, dir, realDial, realSpawn, defaultBackoff) | |
| 142 | + | }, | |
| 143 | + | clock: clock.New(), | |
| 144 | + | }) | |
| 136 | 145 | } | |
| 137 | 146 | ||
| 138 | 147 | func runCmdWithDeps(ctx context.Context, invocation cmdInvocation, deps cmdDeps) error { | |
| @@ -166,7 +175,11 @@ func runCmdWithDeps(ctx context.Context, invocation cmdInvocation, deps cmdDeps) | |||
| 166 | 175 | return fmt.Errorf("encoding command: %w", err) | |
| 167 | 176 | } | |
| 168 | 177 | ||
| 169 | - | transport, err := deps.dial(ctx, ipc.SocketDir()) | |
| 178 | + | dial := deps.dial | |
| 179 | + | if invocation.slug == "remote-catalog" && deps.ensure != nil { | |
| 180 | + | dial = deps.ensure | |
| 181 | + | } | |
| 182 | + | transport, err := dial(ctx, ipc.SocketDir()) | |
| 170 | 183 | if err != nil { | |
| 171 | 184 | return &exitCoded{code: 3, err: fmt.Errorf("%w: %w", errDaemonUnreachable, err)} | |
| 172 | 185 | } | |
| @@ -128,11 +128,20 @@ type cmdDeps struct { | |||||
| 128 | stdout io.Writer | 128 | stdout io.Writer | ||
| 129 | getenv func(string) string | 129 | getenv func(string) string | ||
| 130 | dial func(context.Context, string) (ports.Transport, error) | 130 | dial func(context.Context, string) (ports.Transport, error) | ||
| 131 | + | ensure func(context.Context, string) (ports.Transport, error) | |||
| 131 | clock ports.Clock | 132 | clock ports.Clock | ||
| 132 | } | 133 | } | ||
| 133 | 134 | ||||
| 134 | func runCmd(ctx context.Context, invocation cmdInvocation) error { | 135 | func runCmd(ctx context.Context, invocation cmdInvocation) error { | ||
| 135 | - | return runCmdWithDeps(ctx, invocation, cmdDeps{stdout: os.Stdout, getenv: os.Getenv, dial: realDial, clock: clock.New()}) | 136 | + | return runCmdWithDeps(ctx, invocation, cmdDeps{ |
| 137 | + | stdout: os.Stdout, | |||
| 138 | + | getenv: os.Getenv, | |||
| 139 | + | dial: realDial, | |||
| 140 | + | ensure: func(ctx context.Context, dir string) (ports.Transport, error) { | |||
| 141 | + | return ensureDaemonWithLifecycle(ctx, dir, realDial, realSpawn, defaultBackoff) | |||
| 142 | + | }, | |||
| 143 | + | clock: clock.New(), | |||
| 144 | + | }) | |||
| 136 | } | 145 | } | ||
| 137 | 146 | ||||
| 138 | func runCmdWithDeps(ctx context.Context, invocation cmdInvocation, deps cmdDeps) error { | 147 | func runCmdWithDeps(ctx context.Context, invocation cmdInvocation, deps cmdDeps) error { | ||
| @@ -166,7 +175,11 @@ func runCmdWithDeps(ctx context.Context, invocation cmdInvocation, deps cmdDeps) | |||||
| 166 | return fmt.Errorf("encoding command: %w", err) | 175 | return fmt.Errorf("encoding command: %w", err) | ||
| 167 | } | 176 | } | ||
| 168 | 177 | ||||
| 169 | - | transport, err := deps.dial(ctx, ipc.SocketDir()) | 178 | + | dial := deps.dial |
| 179 | + | if invocation.slug == "remote-catalog" && deps.ensure != nil { | |||
| 180 | + | dial = deps.ensure | |||
| 181 | + | } | |||
| 182 | + | transport, err := dial(ctx, ipc.SocketDir()) | |||
| 170 | if err != nil { | 183 | if err != nil { | ||
| 171 | return &exitCoded{code: 3, err: fmt.Errorf("%w: %w", errDaemonUnreachable, err)} | 184 | return &exitCoded{code: 3, err: fmt.Errorf("%w: %w", errDaemonUnreachable, err)} | ||
| 172 | } | 185 | } | ||
M internal/app/cmd_test.go +19 -0
| @@ -156,6 +156,25 @@ func TestMoveCmdPreservesPositionalArguments(t *testing.T) { | |||
| 156 | 156 | } | |
| 157 | 157 | } | |
| 158 | 158 | ||
| 159 | + | func TestRemoteCatalogCommandEnsuresDaemon(t *testing.T) { | |
| 160 | + | transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{OK: true})}} | |
| 161 | + | ensureCalls := 0 | |
| 162 | + | err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "remote-catalog", jsonOut: true}, cmdDeps{ | |
| 163 | + | stdout: io.Discard, | |
| 164 | + | getenv: func(string) string { return "" }, | |
| 165 | + | dial: func(context.Context, string) (ports.Transport, error) { | |
| 166 | + | t.Fatal("remote catalog used non-starting daemon dial") | |
| 167 | + | return nil, errors.New("unexpected dial") | |
| 168 | + | }, | |
| 169 | + | ensure: func(context.Context, string) (ports.Transport, error) { | |
| 170 | + | ensureCalls++ | |
| 171 | + | return transport, nil | |
| 172 | + | }, | |
| 173 | + | }) | |
| 174 | + | require.NoError(t, err) | |
| 175 | + | require.Equal(t, 1, ensureCalls) | |
| 176 | + | } | |
| 177 | + | ||
| 159 | 178 | func TestMoveCmdInvalidArgumentResultExitsTwo(t *testing.T) { | |
| 160 | 179 | for _, invocation := range []cmdInvocation{ | |
| 161 | 180 | {slug: "move-pane"}, | |
| @@ -156,6 +156,25 @@ func TestMoveCmdPreservesPositionalArguments(t *testing.T) { | |||||
| 156 | } | 156 | } | ||
| 157 | } | 157 | } | ||
| 158 | 158 | ||||
| 159 | + | func TestRemoteCatalogCommandEnsuresDaemon(t *testing.T) { | |||
| 160 | + | transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{OK: true})}} | |||
| 161 | + | ensureCalls := 0 | |||
| 162 | + | err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "remote-catalog", jsonOut: true}, cmdDeps{ | |||
| 163 | + | stdout: io.Discard, | |||
| 164 | + | getenv: func(string) string { return "" }, | |||
| 165 | + | dial: func(context.Context, string) (ports.Transport, error) { | |||
| 166 | + | t.Fatal("remote catalog used non-starting daemon dial") | |||
| 167 | + | return nil, errors.New("unexpected dial") | |||
| 168 | + | }, | |||
| 169 | + | ensure: func(context.Context, string) (ports.Transport, error) { | |||
| 170 | + | ensureCalls++ | |||
| 171 | + | return transport, nil | |||
| 172 | + | }, | |||
| 173 | + | }) | |||
| 174 | + | require.NoError(t, err) | |||
| 175 | + | require.Equal(t, 1, ensureCalls) | |||
| 176 | + | } | |||
| 177 | + | ||||
| 159 | func TestMoveCmdInvalidArgumentResultExitsTwo(t *testing.T) { | 178 | func TestMoveCmdInvalidArgumentResultExitsTwo(t *testing.T) { | ||
| 160 | for _, invocation := range []cmdInvocation{ | 179 | for _, invocation := range []cmdInvocation{ | ||
| 161 | {slug: "move-pane"}, | 180 | {slug: "move-pane"}, | ||
M internal/app/run.go +0 -41
| @@ -835,17 +835,6 @@ func remoteDiscoveryDaemonOption(stateDir string, observer ports.SerializedRunti | |||
| 835 | 835 | }, nil | |
| 836 | 836 | } | |
| 837 | 837 | ||
| 838 | - | type attachHandoffKey struct { | |
| 839 | - | endpoint string | |
| 840 | - | session string | |
| 841 | - | intent uint8 | |
| 842 | - | lifecycle domain.SessionLifecycleID | |
| 843 | - | liveTab domain.TabStableID | |
| 844 | - | stopped domain.TabSelector | |
| 845 | - | } | |
| 846 | - | ||
| 847 | - | const maxAttachHandoffHops = 32 | |
| 848 | - | ||
| 849 | 838 | func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, log *slog.Logger, deps runAttachDeps) error { | |
| 850 | 839 | if activeSession != "" { | |
| 851 | 840 | if remoteTarget == "" && intent == ports.IntentNew { | |
| @@ -868,19 +857,6 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||
| 868 | 857 | var remoteEnvironmentPolicy ports.EnvironmentPolicy | |
| 869 | 858 | routeOrigin := ports.RouteOriginLocal | |
| 870 | 859 | routeOriginKey := "local" | |
| 871 | - | seenHandoffs := make(map[attachHandoffKey]struct{}) | |
| 872 | - | handoffHops := 0 | |
| 873 | - | admitHandoff := func(next attachHandoffKey) error { | |
| 874 | - | if _, seen := seenHandoffs[next]; seen { | |
| 875 | - | return fmt.Errorf("vev: attach handoff loop for %q/%q", next.endpoint, next.session) | |
| 876 | - | } | |
| 877 | - | if handoffHops >= maxAttachHandoffHops { | |
| 878 | - | return fmt.Errorf("vev: attach handoff exceeded maximum of %d hops", maxAttachHandoffHops) | |
| 879 | - | } | |
| 880 | - | handoffHops++ | |
| 881 | - | seenHandoffs[next] = struct{}{} | |
| 882 | - | return nil | |
| 883 | - | } | |
| 884 | 860 | if remoteTarget != "" { | |
| 885 | 861 | routeOrigin = ports.RouteOriginRemote | |
| 886 | 862 | routeOriginKey = remoteTarget | |
| @@ -890,7 +866,6 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||
| 890 | 866 | if factory == nil { | |
| 891 | 867 | factory = defaultRemoteDialerFactory() | |
| 892 | 868 | } | |
| 893 | - | seenHandoffs[attachHandoffKey{endpoint: remoteTarget, session: name, intent: intent}] = struct{}{} | |
| 894 | 869 | } | |
| 895 | 870 | pickerHandoff := remoteTarget == "" | |
| 896 | 871 | pickerEnvironmentPolicy := func(target *domain.RemoteSessionTarget, policy ports.EnvironmentPolicy) ports.EnvironmentPolicy { | |
| @@ -909,17 +884,10 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||
| 909 | 884 | if factory == nil { | |
| 910 | 885 | factory = defaultRemoteDialerFactory() | |
| 911 | 886 | } | |
| 912 | - | next := attachHandoffKey{endpoint: target.Endpoint, session: target.Session, intent: target.Intent} | |
| 913 | 887 | var selection *domain.RemoteSessionTarget | |
| 914 | 888 | if target.RemoteTarget != nil { | |
| 915 | 889 | copyTarget := *target.RemoteTarget | |
| 916 | 890 | selection = ©Target | |
| 917 | - | next.lifecycle = copyTarget.LifecycleID | |
| 918 | - | next.liveTab = copyTarget.LiveTabID | |
| 919 | - | next.stopped = copyTarget.StoppedTab | |
| 920 | - | } | |
| 921 | - | if err := admitHandoff(next); err != nil { | |
| 922 | - | return nil, client.AttachRequest{}, err | |
| 923 | 891 | } | |
| 924 | 892 | dialer, err := factory.DialerForRemote(target.Endpoint, target.Session, mode, log) | |
| 925 | 893 | if err != nil { | |
| @@ -995,15 +963,6 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||
| 995 | 963 | if err := validateRemoteAttachHandoff(handoffErr.Target); err != nil { | |
| 996 | 964 | return fmt.Errorf("vev: invalid remote attach handoff: %w", err) | |
| 997 | 965 | } | |
| 998 | - | next := attachHandoffKey{endpoint: handoffErr.Target.Endpoint, session: handoffErr.Target.Session, intent: handoffErr.Target.Intent} | |
| 999 | - | if handoffErr.Target.RemoteTarget != nil { | |
| 1000 | - | next.lifecycle = handoffErr.Target.RemoteTarget.LifecycleID | |
| 1001 | - | next.liveTab = handoffErr.Target.RemoteTarget.LiveTabID | |
| 1002 | - | next.stopped = handoffErr.Target.RemoteTarget.StoppedTab | |
| 1003 | - | } | |
| 1004 | - | if err := admitHandoff(next); err != nil { | |
| 1005 | - | return err | |
| 1006 | - | } | |
| 1007 | 966 | remoteTarget = handoffErr.Target.Endpoint | |
| 1008 | 967 | routeOrigin = ports.RouteOriginDiscovery | |
| 1009 | 968 | routeOriginKey = remoteTarget | |
| @@ -835,17 +835,6 @@ func remoteDiscoveryDaemonOption(stateDir string, observer ports.SerializedRunti | |||||
| 835 | }, nil | 835 | }, nil | ||
| 836 | } | 836 | } | ||
| 837 | 837 | ||||
| 838 | - | type attachHandoffKey struct { | |||
| 839 | - | endpoint string | |||
| 840 | - | session string | |||
| 841 | - | intent uint8 | |||
| 842 | - | lifecycle domain.SessionLifecycleID | |||
| 843 | - | liveTab domain.TabStableID | |||
| 844 | - | stopped domain.TabSelector | |||
| 845 | - | } | |||
| 846 | - | ||||
| 847 | - | const maxAttachHandoffHops = 32 | |||
| 848 | - | ||||
| 849 | func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, log *slog.Logger, deps runAttachDeps) error { | 838 | func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, log *slog.Logger, deps runAttachDeps) error { | ||
| 850 | if activeSession != "" { | 839 | if activeSession != "" { | ||
| 851 | if remoteTarget == "" && intent == ports.IntentNew { | 840 | if remoteTarget == "" && intent == ports.IntentNew { | ||
| @@ -868,19 +857,6 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||||
| 868 | var remoteEnvironmentPolicy ports.EnvironmentPolicy | 857 | var remoteEnvironmentPolicy ports.EnvironmentPolicy | ||
| 869 | routeOrigin := ports.RouteOriginLocal | 858 | routeOrigin := ports.RouteOriginLocal | ||
| 870 | routeOriginKey := "local" | 859 | routeOriginKey := "local" | ||
| 871 | - | seenHandoffs := make(map[attachHandoffKey]struct{}) | |||
| 872 | - | handoffHops := 0 | |||
| 873 | - | admitHandoff := func(next attachHandoffKey) error { | |||
| 874 | - | if _, seen := seenHandoffs[next]; seen { | |||
| 875 | - | return fmt.Errorf("vev: attach handoff loop for %q/%q", next.endpoint, next.session) | |||
| 876 | - | } | |||
| 877 | - | if handoffHops >= maxAttachHandoffHops { | |||
| 878 | - | return fmt.Errorf("vev: attach handoff exceeded maximum of %d hops", maxAttachHandoffHops) | |||
| 879 | - | } | |||
| 880 | - | handoffHops++ | |||
| 881 | - | seenHandoffs[next] = struct{}{} | |||
| 882 | - | return nil | |||
| 883 | - | } | |||
| 884 | if remoteTarget != "" { | 860 | if remoteTarget != "" { | ||
| 885 | routeOrigin = ports.RouteOriginRemote | 861 | routeOrigin = ports.RouteOriginRemote | ||
| 886 | routeOriginKey = remoteTarget | 862 | routeOriginKey = remoteTarget | ||
| @@ -890,7 +866,6 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||||
| 890 | if factory == nil { | 866 | if factory == nil { | ||
| 891 | factory = defaultRemoteDialerFactory() | 867 | factory = defaultRemoteDialerFactory() | ||
| 892 | } | 868 | } | ||
| 893 | - | seenHandoffs[attachHandoffKey{endpoint: remoteTarget, session: name, intent: intent}] = struct{}{} | |||
| 894 | } | 869 | } | ||
| 895 | pickerHandoff := remoteTarget == "" | 870 | pickerHandoff := remoteTarget == "" | ||
| 896 | pickerEnvironmentPolicy := func(target *domain.RemoteSessionTarget, policy ports.EnvironmentPolicy) ports.EnvironmentPolicy { | 871 | pickerEnvironmentPolicy := func(target *domain.RemoteSessionTarget, policy ports.EnvironmentPolicy) ports.EnvironmentPolicy { | ||
| @@ -909,17 +884,10 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||||
| 909 | if factory == nil { | 884 | if factory == nil { | ||
| 910 | factory = defaultRemoteDialerFactory() | 885 | factory = defaultRemoteDialerFactory() | ||
| 911 | } | 886 | } | ||
| 912 | - | next := attachHandoffKey{endpoint: target.Endpoint, session: target.Session, intent: target.Intent} | |||
| 913 | var selection *domain.RemoteSessionTarget | 887 | var selection *domain.RemoteSessionTarget | ||
| 914 | if target.RemoteTarget != nil { | 888 | if target.RemoteTarget != nil { | ||
| 915 | copyTarget := *target.RemoteTarget | 889 | copyTarget := *target.RemoteTarget | ||
| 916 | selection = ©Target | 890 | selection = ©Target | ||
| 917 | - | next.lifecycle = copyTarget.LifecycleID | |||
| 918 | - | next.liveTab = copyTarget.LiveTabID | |||
| 919 | - | next.stopped = copyTarget.StoppedTab | |||
| 920 | - | } | |||
| 921 | - | if err := admitHandoff(next); err != nil { | |||
| 922 | - | return nil, client.AttachRequest{}, err | |||
| 923 | } | 891 | } | ||
| 924 | dialer, err := factory.DialerForRemote(target.Endpoint, target.Session, mode, log) | 892 | dialer, err := factory.DialerForRemote(target.Endpoint, target.Session, mode, log) | ||
| 925 | if err != nil { | 893 | if err != nil { | ||
| @@ -995,15 +963,6 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||||
| 995 | if err := validateRemoteAttachHandoff(handoffErr.Target); err != nil { | 963 | if err := validateRemoteAttachHandoff(handoffErr.Target); err != nil { | ||
| 996 | return fmt.Errorf("vev: invalid remote attach handoff: %w", err) | 964 | return fmt.Errorf("vev: invalid remote attach handoff: %w", err) | ||
| 997 | } | 965 | } | ||
| 998 | - | next := attachHandoffKey{endpoint: handoffErr.Target.Endpoint, session: handoffErr.Target.Session, intent: handoffErr.Target.Intent} | |||
| 999 | - | if handoffErr.Target.RemoteTarget != nil { | |||
| 1000 | - | next.lifecycle = handoffErr.Target.RemoteTarget.LifecycleID | |||
| 1001 | - | next.liveTab = handoffErr.Target.RemoteTarget.LiveTabID | |||
| 1002 | - | next.stopped = handoffErr.Target.RemoteTarget.StoppedTab | |||
| 1003 | - | } | |||
| 1004 | - | if err := admitHandoff(next); err != nil { | |||
| 1005 | - | return err | |||
| 1006 | - | } | |||
| 1007 | remoteTarget = handoffErr.Target.Endpoint | 966 | remoteTarget = handoffErr.Target.Endpoint | ||
| 1008 | routeOrigin = ports.RouteOriginDiscovery | 967 | routeOrigin = ports.RouteOriginDiscovery | ||
| 1009 | routeOriginKey = remoteTarget | 968 | routeOriginKey = remoteTarget | ||
M internal/app/run_test.go +11 -24
| @@ -6,7 +6,6 @@ import ( | |||
| 6 | 6 | "context" | |
| 7 | 7 | "encoding/json" | |
| 8 | 8 | "errors" | |
| 9 | - | "fmt" | |
| 10 | 9 | "io" | |
| 11 | 10 | "log/slog" | |
| 12 | 11 | "net" | |
| @@ -916,36 +915,24 @@ func TestRunAttachWithDepsRemotePickerHandoffReopensDirectConnection(t *testing. | |||
| 916 | 915 | require.Equal(t, 2, calls, "a picker handoff must close the old connection and open a fresh direct connection") | |
| 917 | 916 | } | |
| 918 | 917 | ||
| 919 | - | func TestRunAttachWithDepsRejectsHandoffLoop(t *testing.T) { | |
| 918 | + | func TestRunAttachWithDepsAllowsRevisitingRemoteHandoff(t *testing.T) { | |
| 920 | 919 | factory := portsmocks.NewMockRemoteDialerFactory(t) | |
| 921 | 920 | factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once() | |
| 921 | + | factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote-revisit"}, nil).Once() | |
| 922 | 922 | ||
| 923 | - | calls := 0 | |
| 924 | 923 | err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{ | |
| 925 | 924 | remoteDialerFactory: factory, | |
| 926 | - | runClient: func(_ context.Context, _ client.Dependencies, _ client.AttachRequest) error { | |
| 927 | - | calls++ | |
| 928 | - | return &client.AttachTargetError{Target: ports.AttachTarget{Endpoint: "remote.example", Session: "work", Intent: ports.IntentAttach}} | |
| 929 | - | }, | |
| 930 | - | }) | |
| 931 | - | require.ErrorContains(t, err, "attach handoff loop") | |
| 932 | - | require.Equal(t, 1, calls) | |
| 933 | - | } | |
| 934 | - | ||
| 935 | - | func TestRunAttachWithDepsCapsUniqueHandoffHops(t *testing.T) { | |
| 936 | - | factory := portsmocks.NewMockRemoteDialerFactory(t) | |
| 937 | - | factory.EXPECT().DialerForRemote(mock.Anything, mock.Anything, ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Times(maxAttachHandoffHops + 1) | |
| 938 | - | ||
| 939 | - | calls := 0 | |
| 940 | - | err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{ | |
| 941 | - | remoteDialerFactory: factory, | |
| 942 | - | runClient: func(_ context.Context, _ client.Dependencies, _ client.AttachRequest) error { | |
| 943 | - | calls++ | |
| 944 | - | return &client.AttachTargetError{Target: ports.AttachTarget{Endpoint: fmt.Sprintf("remote-%d.example", calls), Session: "work", Intent: ports.IntentAttach}} | |
| 925 | + | runClient: func(_ context.Context, deps client.Dependencies, _ client.AttachRequest) error { | |
| 926 | + | dialer, request, err := deps.AttachHandoff(ports.AttachTarget{ | |
| 927 | + | Endpoint: "remote.example", Session: "work", Intent: ports.IntentAttach, | |
| 928 | + | }) | |
| 929 | + | require.NoError(t, err) | |
| 930 | + | require.NotNil(t, dialer) | |
| 931 | + | require.Equal(t, "work", request.SessionName) | |
| 932 | + | return nil | |
| 945 | 933 | }, | |
| 946 | 934 | }) | |
| 947 | - | require.ErrorContains(t, err, "exceeded maximum") | |
| 948 | - | require.Equal(t, maxAttachHandoffHops+1, calls) | |
| 935 | + | require.NoError(t, err) | |
| 949 | 936 | } | |
| 950 | 937 | ||
| 951 | 938 | func TestRunAttachWithDepsLocalPickerHandoffAttachesSelectedRemote(t *testing.T) { | |
| @@ -6,7 +6,6 @@ import ( | |||||
| 6 | "context" | 6 | "context" | ||
| 7 | "encoding/json" | 7 | "encoding/json" | ||
| 8 | "errors" | 8 | "errors" | ||
| 9 | - | "fmt" | |||
| 10 | "io" | 9 | "io" | ||
| 11 | "log/slog" | 10 | "log/slog" | ||
| 12 | "net" | 11 | "net" | ||
| @@ -916,36 +915,24 @@ func TestRunAttachWithDepsRemotePickerHandoffReopensDirectConnection(t *testing. | |||||
| 916 | require.Equal(t, 2, calls, "a picker handoff must close the old connection and open a fresh direct connection") | 915 | require.Equal(t, 2, calls, "a picker handoff must close the old connection and open a fresh direct connection") | ||
| 917 | } | 916 | } | ||
| 918 | 917 | ||||
| 919 | - | func TestRunAttachWithDepsRejectsHandoffLoop(t *testing.T) { | 918 | + | func TestRunAttachWithDepsAllowsRevisitingRemoteHandoff(t *testing.T) { |
| 920 | factory := portsmocks.NewMockRemoteDialerFactory(t) | 919 | factory := portsmocks.NewMockRemoteDialerFactory(t) | ||
| 921 | factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once() | 920 | factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once() | ||
| 921 | + | factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote-revisit"}, nil).Once() | |||
| 922 | 922 | ||||
| 923 | - | calls := 0 | |||
| 924 | err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{ | 923 | err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{ | ||
| 925 | remoteDialerFactory: factory, | 924 | remoteDialerFactory: factory, | ||
| 926 | - | runClient: func(_ context.Context, _ client.Dependencies, _ client.AttachRequest) error { | 925 | + | runClient: func(_ context.Context, deps client.Dependencies, _ client.AttachRequest) error { |
| 927 | - | calls++ | 926 | + | dialer, request, err := deps.AttachHandoff(ports.AttachTarget{ |
| 928 | - | return &client.AttachTargetError{Target: ports.AttachTarget{Endpoint: "remote.example", Session: "work", Intent: ports.IntentAttach}} | 927 | + | Endpoint: "remote.example", Session: "work", Intent: ports.IntentAttach, |
| 929 | - | }, | 928 | + | }) |
| 930 | - | }) | 929 | + | require.NoError(t, err) |
| 931 | - | require.ErrorContains(t, err, "attach handoff loop") | 930 | + | require.NotNil(t, dialer) |
| 932 | - | require.Equal(t, 1, calls) | 931 | + | require.Equal(t, "work", request.SessionName) |
| 933 | - | } | 932 | + | return nil |
| 934 | - | ||||
| 935 | - | func TestRunAttachWithDepsCapsUniqueHandoffHops(t *testing.T) { | |||
| 936 | - | factory := portsmocks.NewMockRemoteDialerFactory(t) | |||
| 937 | - | factory.EXPECT().DialerForRemote(mock.Anything, mock.Anything, ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Times(maxAttachHandoffHops + 1) | |||
| 938 | - | ||||
| 939 | - | calls := 0 | |||
| 940 | - | err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{ | |||
| 941 | - | remoteDialerFactory: factory, | |||
| 942 | - | runClient: func(_ context.Context, _ client.Dependencies, _ client.AttachRequest) error { | |||
| 943 | - | calls++ | |||
| 944 | - | return &client.AttachTargetError{Target: ports.AttachTarget{Endpoint: fmt.Sprintf("remote-%d.example", calls), Session: "work", Intent: ports.IntentAttach}} | |||
| 945 | }, | 933 | }, | ||
| 946 | }) | 934 | }) | ||
| 947 | - | require.ErrorContains(t, err, "exceeded maximum") | 935 | + | require.NoError(t, err) |
| 948 | - | require.Equal(t, maxAttachHandoffHops+1, calls) | |||
| 949 | } | 936 | } | ||
| 950 | 937 | ||||
| 951 | func TestRunAttachWithDepsLocalPickerHandoffAttachesSelectedRemote(t *testing.T) { | 938 | func TestRunAttachWithDepsLocalPickerHandoffAttachesSelectedRemote(t *testing.T) { | ||
M internal/ports/frame.go +5 -4
| @@ -12,7 +12,7 @@ import ( | |||
| 12 | 12 | const HandshakeTimeout = 15 * time.Second | |
| 13 | 13 | ||
| 14 | 14 | // ProtocolVersion is the current vev IPC wire protocol version. | |
| 15 | - | const ProtocolVersion uint16 = 27 | |
| 15 | + | const ProtocolVersion uint16 = 28 | |
| 16 | 16 | ||
| 17 | 17 | // MaxFrameLen is the largest permitted frame length, including the type byte | |
| 18 | 18 | // and excluding the four-byte length prefix. | |
| @@ -22,9 +22,9 @@ const MaxFrameLen = 16 << 20 | |||
| 22 | 22 | type MsgType uint8 | |
| 23 | 23 | ||
| 24 | 24 | // Frame message types. Client-originated messages occupy 1–13 and 15; | |
| 25 | - | // server-originated messages occupy 16–23 and 25–27. Values 14 and 24 remain | |
| 26 | - | // reserved for future extensions. Route snapshot/navigation messages use the | |
| 27 | - | // next available values 28–30 and remain daemon-neutral until the cutover. | |
| 25 | + | // server-originated messages occupy 16–23 and 25–31. Values 14 and 24 remain | |
| 26 | + | // reserved for future extensions. Route metadata/navigation messages use the | |
| 27 | + | // post-26 range. | |
| 28 | 28 | const ( | |
| 29 | 29 | MsgHello MsgType = 1 | |
| 30 | 30 | MsgInput MsgType = 2 | |
| @@ -57,6 +57,7 @@ const ( | |||
| 57 | 57 | MsgRecentRouteSnapshot MsgType = 28 | |
| 58 | 58 | MsgNavigateRecentRoute MsgType = 29 | |
| 59 | 59 | MsgRouteNavigationFailure MsgType = 30 | |
| 60 | + | MsgRoutePosition MsgType = 31 | |
| 60 | 61 | ) | |
| 61 | 62 | ||
| 62 | 63 | // Frame is the unit of exchange over a Transport: a typed, length-delimited | |
| @@ -12,7 +12,7 @@ import ( | |||||
| 12 | const HandshakeTimeout = 15 * time.Second | 12 | const HandshakeTimeout = 15 * time.Second | ||
| 13 | 13 | ||||
| 14 | // ProtocolVersion is the current vev IPC wire protocol version. | 14 | // ProtocolVersion is the current vev IPC wire protocol version. | ||
| 15 | - | const ProtocolVersion uint16 = 27 | 15 | + | const ProtocolVersion uint16 = 28 |
| 16 | 16 | ||||
| 17 | // MaxFrameLen is the largest permitted frame length, including the type byte | 17 | // MaxFrameLen is the largest permitted frame length, including the type byte | ||
| 18 | // and excluding the four-byte length prefix. | 18 | // and excluding the four-byte length prefix. | ||
| @@ -22,9 +22,9 @@ const MaxFrameLen = 16 << 20 | |||||
| 22 | type MsgType uint8 | 22 | type MsgType uint8 | ||
| 23 | 23 | ||||
| 24 | // Frame message types. Client-originated messages occupy 1–13 and 15; | 24 | // Frame message types. Client-originated messages occupy 1–13 and 15; | ||
| 25 | - | // server-originated messages occupy 16–23 and 25–27. Values 14 and 24 remain | 25 | + | // server-originated messages occupy 16–23 and 25–31. Values 14 and 24 remain |
| 26 | - | // reserved for future extensions. Route snapshot/navigation messages use the | 26 | + | // reserved for future extensions. Route metadata/navigation messages use the |
| 27 | - | // next available values 28–30 and remain daemon-neutral until the cutover. | 27 | + | // post-26 range. |
| 28 | const ( | 28 | const ( | ||
| 29 | MsgHello MsgType = 1 | 29 | MsgHello MsgType = 1 | ||
| 30 | MsgInput MsgType = 2 | 30 | MsgInput MsgType = 2 | ||
| @@ -57,6 +57,7 @@ const ( | |||||
| 57 | MsgRecentRouteSnapshot MsgType = 28 | 57 | MsgRecentRouteSnapshot MsgType = 28 | ||
| 58 | MsgNavigateRecentRoute MsgType = 29 | 58 | MsgNavigateRecentRoute MsgType = 29 | ||
| 59 | MsgRouteNavigationFailure MsgType = 30 | 59 | MsgRouteNavigationFailure MsgType = 30 | ||
| 60 | + | MsgRoutePosition MsgType = 31 | |||
| 60 | ) | 61 | ) | ||
| 61 | 62 | ||||
| 62 | // Frame is the unit of exchange over a Transport: a typed, length-delimited | 63 | // Frame is the unit of exchange over a Transport: a typed, length-delimited | ||
M internal/ports/frame_test.go +3 -2
| @@ -3,8 +3,8 @@ package ports | |||
| 3 | 3 | import "testing" | |
| 4 | 4 | ||
| 5 | 5 | func TestProtocolVersion(t *testing.T) { | |
| 6 | - | if ProtocolVersion != 27 { | |
| 7 | - | t.Fatalf("ProtocolVersion = %d, want 27", ProtocolVersion) | |
| 6 | + | if ProtocolVersion != 28 { | |
| 7 | + | t.Fatalf("ProtocolVersion = %d, want 28", ProtocolVersion) | |
| 8 | 8 | } | |
| 9 | 9 | } | |
| 10 | 10 | ||
| @@ -39,6 +39,7 @@ func TestMsgTypeUnique(t *testing.T) { | |||
| 39 | 39 | {"MsgRemotePreviewResponse", MsgRemotePreviewResponse}, {"MsgAttachTarget", MsgAttachTarget}, {"MsgNavigationAction", MsgNavigationAction}, | |
| 40 | 40 | {"MsgCommittedRouteIdentity", MsgCommittedRouteIdentity}, {"MsgRecentRouteSnapshot", MsgRecentRouteSnapshot}, | |
| 41 | 41 | {"MsgNavigateRecentRoute", MsgNavigateRecentRoute}, {"MsgRouteNavigationFailure", MsgRouteNavigationFailure}, | |
| 42 | + | {"MsgRoutePosition", MsgRoutePosition}, | |
| 42 | 43 | } | |
| 43 | 44 | ||
| 44 | 45 | seen := make(map[MsgType]string, len(tests)) | |
| @@ -3,8 +3,8 @@ package ports | |||||
| 3 | import "testing" | 3 | import "testing" | ||
| 4 | 4 | ||||
| 5 | func TestProtocolVersion(t *testing.T) { | 5 | func TestProtocolVersion(t *testing.T) { | ||
| 6 | - | if ProtocolVersion != 27 { | 6 | + | if ProtocolVersion != 28 { |
| 7 | - | t.Fatalf("ProtocolVersion = %d, want 27", ProtocolVersion) | 7 | + | t.Fatalf("ProtocolVersion = %d, want 28", ProtocolVersion) |
| 8 | } | 8 | } | ||
| 9 | } | 9 | } | ||
| 10 | 10 | ||||
| @@ -39,6 +39,7 @@ func TestMsgTypeUnique(t *testing.T) { | |||||
| 39 | {"MsgRemotePreviewResponse", MsgRemotePreviewResponse}, {"MsgAttachTarget", MsgAttachTarget}, {"MsgNavigationAction", MsgNavigationAction}, | 39 | {"MsgRemotePreviewResponse", MsgRemotePreviewResponse}, {"MsgAttachTarget", MsgAttachTarget}, {"MsgNavigationAction", MsgNavigationAction}, | ||
| 40 | {"MsgCommittedRouteIdentity", MsgCommittedRouteIdentity}, {"MsgRecentRouteSnapshot", MsgRecentRouteSnapshot}, | 40 | {"MsgCommittedRouteIdentity", MsgCommittedRouteIdentity}, {"MsgRecentRouteSnapshot", MsgRecentRouteSnapshot}, | ||
| 41 | {"MsgNavigateRecentRoute", MsgNavigateRecentRoute}, {"MsgRouteNavigationFailure", MsgRouteNavigationFailure}, | 41 | {"MsgNavigateRecentRoute", MsgNavigateRecentRoute}, {"MsgRouteNavigationFailure", MsgRouteNavigationFailure}, | ||
| 42 | + | {"MsgRoutePosition", MsgRoutePosition}, | |||
| 42 | } | 43 | } | ||
| 43 | 44 | ||||
| 44 | seen := make(map[MsgType]string, len(tests)) | 45 | seen := make(map[MsgType]string, len(tests)) | ||
M internal/ports/navigation_test.go +1 -1
| @@ -62,7 +62,7 @@ func TestHelloNavigationValidationTable(t *testing.T) { | |||
| 62 | 62 | {name: "client-owned home picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityHomePicker}, valid: false}, | |
| 63 | 63 | {name: "daemon-owned back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false}, | |
| 64 | 64 | {name: "remote-target back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false}, | |
| 65 | - | {name: "back with startup picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "001b02" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0201"}, | |
| 65 | + | {name: "back with startup picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "001c02" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "0201"}, | |
| 66 | 66 | {name: "new rejects navigation", hello: Hello{Version: ProtocolVersion, Intent: IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: false}, | |
| 67 | 67 | {name: "unknown capability", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false}, | |
| 68 | 68 | {name: "back without picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack}, valid: false}, | |
| @@ -62,7 +62,7 @@ func TestHelloNavigationValidationTable(t *testing.T) { | |||||
| 62 | {name: "client-owned home picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityHomePicker}, valid: false}, | 62 | {name: "client-owned home picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityHomePicker}, valid: false}, | ||
| 63 | {name: "daemon-owned back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false}, | 63 | {name: "daemon-owned back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false}, | ||
| 64 | {name: "remote-target back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false}, | 64 | {name: "remote-target back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false}, | ||
| 65 | - | {name: "back with startup picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "001b02" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0201"}, | 65 | + | {name: "back with startup picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "001c02" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "0201"}, |
| 66 | {name: "new rejects navigation", hello: Hello{Version: ProtocolVersion, Intent: IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: false}, | 66 | {name: "new rejects navigation", hello: Hello{Version: ProtocolVersion, Intent: IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: false}, | ||
| 67 | {name: "unknown capability", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false}, | 67 | {name: "unknown capability", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false}, | ||
| 68 | {name: "back without picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack}, valid: false}, | 68 | {name: "back without picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack}, valid: false}, | ||
M internal/ports/remote_target_wire_test.go +1 -1
| @@ -115,7 +115,7 @@ func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) { | |||
| 115 | 115 | }) | |
| 116 | 116 | }, | |
| 117 | 117 | unmarshal: func(payload []byte) error { _, err := UnmarshalHello(payload); return err }, | |
| 118 | - | policyOffset: 4, | |
| 118 | + | policyOffset: 6, | |
| 119 | 119 | wantErr: ErrInvalidHello, | |
| 120 | 120 | }, | |
| 121 | 121 | { | |
| @@ -115,7 +115,7 @@ func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) { | |||||
| 115 | }) | 115 | }) | ||
| 116 | }, | 116 | }, | ||
| 117 | unmarshal: func(payload []byte) error { _, err := UnmarshalHello(payload); return err }, | 117 | unmarshal: func(payload []byte) error { _, err := UnmarshalHello(payload); return err }, | ||
| 118 | - | policyOffset: 4, | 118 | + | policyOffset: 6, |
| 119 | wantErr: ErrInvalidHello, | 119 | wantErr: ErrInvalidHello, | ||
| 120 | }, | 120 | }, | ||
| 121 | { | 121 | { | ||
M internal/ports/routes.go +18 -0
| @@ -142,6 +142,24 @@ func (i CommittedRouteIdentity) Validate() error { | |||
| 142 | 142 | return nil | |
| 143 | 143 | } | |
| 144 | 144 | ||
| 145 | + | // RoutePosition is mutable per-client route state published by the daemon. | |
| 146 | + | // Target binds the tab cursor to one exact session lifecycle so delayed frames | |
| 147 | + | // cannot update another route. | |
| 148 | + | type RoutePosition struct { | |
| 149 | + | Target ExactSessionTarget | |
| 150 | + | ActiveTabID domain.TabStableID | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | func (p RoutePosition) Validate() error { | |
| 154 | + | if err := p.Target.Validate(); err != nil { | |
| 155 | + | return err | |
| 156 | + | } | |
| 157 | + | if err := domain.ValidateTabStableID(p.ActiveTabID); err != nil { | |
| 158 | + | return fmt.Errorf("invalid active tab ID: %w", err) | |
| 159 | + | } | |
| 160 | + | return nil | |
| 161 | + | } | |
| 162 | + | ||
| 145 | 163 | // RouteRef is an opaque client-ledger reference. It is intentionally not a | |
| 146 | 164 | // lifecycle identifier and is meaningful only with the matching generation. | |
| 147 | 165 | type RouteRef struct { | |
| @@ -142,6 +142,24 @@ func (i CommittedRouteIdentity) Validate() error { | |||||
| 142 | return nil | 142 | return nil | ||
| 143 | } | 143 | } | ||
| 144 | 144 | ||||
| 145 | + | // RoutePosition is mutable per-client route state published by the daemon. | |||
| 146 | + | // Target binds the tab cursor to one exact session lifecycle so delayed frames | |||
| 147 | + | // cannot update another route. | |||
| 148 | + | type RoutePosition struct { | |||
| 149 | + | Target ExactSessionTarget | |||
| 150 | + | ActiveTabID domain.TabStableID | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | func (p RoutePosition) Validate() error { | |||
| 154 | + | if err := p.Target.Validate(); err != nil { | |||
| 155 | + | return err | |||
| 156 | + | } | |||
| 157 | + | if err := domain.ValidateTabStableID(p.ActiveTabID); err != nil { | |||
| 158 | + | return fmt.Errorf("invalid active tab ID: %w", err) | |||
| 159 | + | } | |||
| 160 | + | return nil | |||
| 161 | + | } | |||
| 162 | + | ||||
| 145 | // RouteRef is an opaque client-ledger reference. It is intentionally not a | 163 | // RouteRef is an opaque client-ledger reference. It is intentionally not a | ||
| 146 | // lifecycle identifier and is meaningful only with the matching generation. | 164 | // lifecycle identifier and is meaningful only with the matching generation. | ||
| 147 | type RouteRef struct { | 165 | type RouteRef struct { | ||
M internal/ports/routes_test.go +29 -5
| @@ -32,11 +32,12 @@ func TestExactSessionTargetCodecIsStrict(t *testing.T) { | |||
| 32 | 32 | func TestHelloExactTargetRoundTrip(t *testing.T) { | |
| 33 | 33 | target := testExactTarget() | |
| 34 | 34 | msg := Hello{ | |
| 35 | - | Version: ProtocolVersion, | |
| 36 | - | Intent: IntentAttach, | |
| 37 | - | Name: target.SessionName, | |
| 38 | - | Size: domain.Size{Cols: 80, Rows: 24}, | |
| 39 | - | ExactTarget: &target, | |
| 35 | + | Version: ProtocolVersion, | |
| 36 | + | Intent: IntentAttach, | |
| 37 | + | Name: target.SessionName, | |
| 38 | + | Size: domain.Size{Cols: 80, Rows: 24}, | |
| 39 | + | ExactTarget: &target, | |
| 40 | + | PreferredTabID: "tab-2", | |
| 40 | 41 | } | |
| 41 | 42 | ||
| 42 | 43 | encoded := MarshalHello(msg) | |
| @@ -48,6 +49,29 @@ func TestHelloExactTargetRoundTrip(t *testing.T) { | |||
| 48 | 49 | invalid := msg | |
| 49 | 50 | invalid.Intent = IntentNew | |
| 50 | 51 | require.Error(t, ValidateHello(invalid)) | |
| 52 | + | ||
| 53 | + | invalid = msg | |
| 54 | + | invalid.PreferredTabID = "bad tab" | |
| 55 | + | require.Error(t, ValidateHello(invalid)) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func TestRoutePositionCodecIsStrict(t *testing.T) { | |
| 59 | + | want := RoutePosition{Target: testExactTarget(), ActiveTabID: "tab-2"} | |
| 60 | + | encoded, err := MarshalRoutePosition(want) | |
| 61 | + | require.NoError(t, err) | |
| 62 | + | require.Equal(t, "010203000000000000000000000000000004776f726b00057461622d32", hex.EncodeToString(encoded)) | |
| 63 | + | assertAllPrefixesFail(t, encoded, UnmarshalRoutePosition) | |
| 64 | + | _, err = UnmarshalRoutePosition(append(append([]byte(nil), encoded...), 0)) | |
| 65 | + | require.Error(t, err) | |
| 66 | + | ||
| 67 | + | got, err := UnmarshalRoutePosition(encoded) | |
| 68 | + | require.NoError(t, err) | |
| 69 | + | require.Equal(t, want, got) | |
| 70 | + | ||
| 71 | + | _, err = MarshalRoutePosition(RoutePosition{Target: testExactTarget()}) | |
| 72 | + | require.ErrorIs(t, err, ErrInvalidRouteWire) | |
| 73 | + | _, err = MarshalRoutePosition(RoutePosition{Target: testExactTarget(), ActiveTabID: "bad tab"}) | |
| 74 | + | require.ErrorIs(t, err, ErrInvalidRouteWire) | |
| 51 | 75 | } | |
| 52 | 76 | ||
| 53 | 77 | func TestWelcomeCarriesCommittedRouteIdentity(t *testing.T) { | |
| @@ -32,11 +32,12 @@ func TestExactSessionTargetCodecIsStrict(t *testing.T) { | |||||
| 32 | func TestHelloExactTargetRoundTrip(t *testing.T) { | 32 | func TestHelloExactTargetRoundTrip(t *testing.T) { | ||
| 33 | target := testExactTarget() | 33 | target := testExactTarget() | ||
| 34 | msg := Hello{ | 34 | msg := Hello{ | ||
| 35 | - | Version: ProtocolVersion, | 35 | + | Version: ProtocolVersion, |
| 36 | - | Intent: IntentAttach, | 36 | + | Intent: IntentAttach, |
| 37 | - | Name: target.SessionName, | 37 | + | Name: target.SessionName, |
| 38 | - | Size: domain.Size{Cols: 80, Rows: 24}, | 38 | + | Size: domain.Size{Cols: 80, Rows: 24}, |
| 39 | - | ExactTarget: &target, | 39 | + | ExactTarget: &target, |
| 40 | + | PreferredTabID: "tab-2", | |||
| 40 | } | 41 | } | ||
| 41 | 42 | ||||
| 42 | encoded := MarshalHello(msg) | 43 | encoded := MarshalHello(msg) | ||
| @@ -48,6 +49,29 @@ func TestHelloExactTargetRoundTrip(t *testing.T) { | |||||
| 48 | invalid := msg | 49 | invalid := msg | ||
| 49 | invalid.Intent = IntentNew | 50 | invalid.Intent = IntentNew | ||
| 50 | require.Error(t, ValidateHello(invalid)) | 51 | require.Error(t, ValidateHello(invalid)) | ||
| 52 | + | ||||
| 53 | + | invalid = msg | |||
| 54 | + | invalid.PreferredTabID = "bad tab" | |||
| 55 | + | require.Error(t, ValidateHello(invalid)) | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func TestRoutePositionCodecIsStrict(t *testing.T) { | |||
| 59 | + | want := RoutePosition{Target: testExactTarget(), ActiveTabID: "tab-2"} | |||
| 60 | + | encoded, err := MarshalRoutePosition(want) | |||
| 61 | + | require.NoError(t, err) | |||
| 62 | + | require.Equal(t, "010203000000000000000000000000000004776f726b00057461622d32", hex.EncodeToString(encoded)) | |||
| 63 | + | assertAllPrefixesFail(t, encoded, UnmarshalRoutePosition) | |||
| 64 | + | _, err = UnmarshalRoutePosition(append(append([]byte(nil), encoded...), 0)) | |||
| 65 | + | require.Error(t, err) | |||
| 66 | + | ||||
| 67 | + | got, err := UnmarshalRoutePosition(encoded) | |||
| 68 | + | require.NoError(t, err) | |||
| 69 | + | require.Equal(t, want, got) | |||
| 70 | + | ||||
| 71 | + | _, err = MarshalRoutePosition(RoutePosition{Target: testExactTarget()}) | |||
| 72 | + | require.ErrorIs(t, err, ErrInvalidRouteWire) | |||
| 73 | + | _, err = MarshalRoutePosition(RoutePosition{Target: testExactTarget(), ActiveTabID: "bad tab"}) | |||
| 74 | + | require.ErrorIs(t, err, ErrInvalidRouteWire) | |||
| 51 | } | 75 | } | ||
| 52 | 76 | ||||
| 53 | func TestWelcomeCarriesCommittedRouteIdentity(t *testing.T) { | 77 | func TestWelcomeCarriesCommittedRouteIdentity(t *testing.T) { | ||
M internal/ports/routes_wire.go +35 -0
| @@ -3,6 +3,8 @@ package ports | |||
| 3 | 3 | import ( | |
| 4 | 4 | "errors" | |
| 5 | 5 | "fmt" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/domain" | |
| 6 | 8 | ) | |
| 7 | 9 | ||
| 8 | 10 | var ErrInvalidRouteWire = errors.New("invalid route wire message") | |
| @@ -176,6 +178,39 @@ func UnmarshalCommittedRouteIdentity(b []byte) (CommittedRouteIdentity, error) { | |||
| 176 | 178 | return identity, nil | |
| 177 | 179 | } | |
| 178 | 180 | ||
| 181 | + | // MarshalRoutePosition encodes one exact session and its attachment-local tab | |
| 182 | + | // cursor. Route positions are mutable client memory, not attach authority. | |
| 183 | + | func MarshalRoutePosition(position RoutePosition) ([]byte, error) { | |
| 184 | + | if err := position.Validate(); err != nil { | |
| 185 | + | return nil, fmt.Errorf("%w: route position: %v", ErrInvalidRouteWire, err) | |
| 186 | + | } | |
| 187 | + | w := payloadWriter{} | |
| 188 | + | marshalExactSessionTarget(&w, position.Target) | |
| 189 | + | w.putString(string(position.ActiveTabID)) | |
| 190 | + | return w.b, nil | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | // UnmarshalRoutePosition decodes one strict route-position payload. | |
| 194 | + | func UnmarshalRoutePosition(b []byte) (RoutePosition, error) { | |
| 195 | + | r := payloadReader{b: b} | |
| 196 | + | target, err := unmarshalExactSessionTarget(&r) | |
| 197 | + | if err != nil { | |
| 198 | + | return RoutePosition{}, err | |
| 199 | + | } | |
| 200 | + | activeTabID, err := r.getString() | |
| 201 | + | if err != nil { | |
| 202 | + | return RoutePosition{}, err | |
| 203 | + | } | |
| 204 | + | if err := r.done(); err != nil { | |
| 205 | + | return RoutePosition{}, err | |
| 206 | + | } | |
| 207 | + | position := RoutePosition{Target: target, ActiveTabID: domain.TabStableID(activeTabID)} | |
| 208 | + | if err := position.Validate(); err != nil { | |
| 209 | + | return RoutePosition{}, fmt.Errorf("%w: route position: %v", ErrInvalidRouteWire, err) | |
| 210 | + | } | |
| 211 | + | return position, nil | |
| 212 | + | } | |
| 213 | + | ||
| 179 | 214 | func validateRouteRef(ref RouteRef) error { | |
| 180 | 215 | if err := ref.Validate(); err != nil { | |
| 181 | 216 | return fmt.Errorf("%w: route reference: %v", ErrInvalidRouteWire, err) | |
| @@ -3,6 +3,8 @@ package ports | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "errors" | 4 | "errors" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/internal/domain" | |||
| 6 | ) | 8 | ) | ||
| 7 | 9 | ||||
| 8 | var ErrInvalidRouteWire = errors.New("invalid route wire message") | 10 | var ErrInvalidRouteWire = errors.New("invalid route wire message") | ||
| @@ -176,6 +178,39 @@ func UnmarshalCommittedRouteIdentity(b []byte) (CommittedRouteIdentity, error) { | |||||
| 176 | return identity, nil | 178 | return identity, nil | ||
| 177 | } | 179 | } | ||
| 178 | 180 | ||||
| 181 | + | // MarshalRoutePosition encodes one exact session and its attachment-local tab | |||
| 182 | + | // cursor. Route positions are mutable client memory, not attach authority. | |||
| 183 | + | func MarshalRoutePosition(position RoutePosition) ([]byte, error) { | |||
| 184 | + | if err := position.Validate(); err != nil { | |||
| 185 | + | return nil, fmt.Errorf("%w: route position: %v", ErrInvalidRouteWire, err) | |||
| 186 | + | } | |||
| 187 | + | w := payloadWriter{} | |||
| 188 | + | marshalExactSessionTarget(&w, position.Target) | |||
| 189 | + | w.putString(string(position.ActiveTabID)) | |||
| 190 | + | return w.b, nil | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | // UnmarshalRoutePosition decodes one strict route-position payload. | |||
| 194 | + | func UnmarshalRoutePosition(b []byte) (RoutePosition, error) { | |||
| 195 | + | r := payloadReader{b: b} | |||
| 196 | + | target, err := unmarshalExactSessionTarget(&r) | |||
| 197 | + | if err != nil { | |||
| 198 | + | return RoutePosition{}, err | |||
| 199 | + | } | |||
| 200 | + | activeTabID, err := r.getString() | |||
| 201 | + | if err != nil { | |||
| 202 | + | return RoutePosition{}, err | |||
| 203 | + | } | |||
| 204 | + | if err := r.done(); err != nil { | |||
| 205 | + | return RoutePosition{}, err | |||
| 206 | + | } | |||
| 207 | + | position := RoutePosition{Target: target, ActiveTabID: domain.TabStableID(activeTabID)} | |||
| 208 | + | if err := position.Validate(); err != nil { | |||
| 209 | + | return RoutePosition{}, fmt.Errorf("%w: route position: %v", ErrInvalidRouteWire, err) | |||
| 210 | + | } | |||
| 211 | + | return position, nil | |||
| 212 | + | } | |||
| 213 | + | ||||
| 179 | func validateRouteRef(ref RouteRef) error { | 214 | func validateRouteRef(ref RouteRef) error { | ||
| 180 | if err := ref.Validate(); err != nil { | 215 | if err := ref.Validate(); err != nil { | ||
| 181 | return fmt.Errorf("%w: route reference: %v", ErrInvalidRouteWire, err) | 216 | return fmt.Errorf("%w: route reference: %v", ErrInvalidRouteWire, err) | ||
M internal/ports/wire.go +21 -1
| @@ -165,7 +165,10 @@ type Hello struct { | |||
| 165 | 165 | // ExactTarget is an optional lifecycle/name identity selected by the client | |
| 166 | 166 | // ledger. It is transport-neutral and never replaces the daemon's authority | |
| 167 | 167 | // to validate or commit the target. | |
| 168 | - | ExactTarget *ExactSessionTarget | |
| 168 | + | ExactTarget *ExactSessionTarget | |
| 169 | + | // PreferredTabID is a client-owned route cursor. The daemon treats it as an | |
| 170 | + | // attachment-local hint and falls back when the stable tab no longer exists. | |
| 171 | + | PreferredTabID domain.TabStableID | |
| 169 | 172 | EnvironmentPolicy EnvironmentPolicy | |
| 170 | 173 | NavigationCapabilities NavigationCapabilities | |
| 171 | 174 | StartupOverlay StartupOverlay | |
| @@ -725,6 +728,14 @@ func ValidateHello(h Hello) error { | |||
| 725 | 728 | return ErrInvalidHello | |
| 726 | 729 | } | |
| 727 | 730 | } | |
| 731 | + | if h.PreferredTabID != "" { | |
| 732 | + | if h.Intent != IntentAttach && h.Intent != IntentResume { | |
| 733 | + | return ErrInvalidHello | |
| 734 | + | } | |
| 735 | + | if err := domain.ValidateTabStableID(h.PreferredTabID); err != nil { | |
| 736 | + | return fmt.Errorf("%w: preferred tab: %v", ErrInvalidHello, err) | |
| 737 | + | } | |
| 738 | + | } | |
| 728 | 739 | if h.ExactTarget != nil { | |
| 729 | 740 | if h.Intent != IntentAttach && h.Intent != IntentResume { | |
| 730 | 741 | return ErrInvalidHello | |
| @@ -852,6 +863,7 @@ func MarshalHello(h Hello) []byte { | |||
| 852 | 863 | } | |
| 853 | 864 | marshalRemoteTargetSection(&w, h.RemoteTarget, h.EnvironmentPolicy) | |
| 854 | 865 | marshalExactTargetSection(&w, h.ExactTarget) | |
| 866 | + | w.putString(string(h.PreferredTabID)) | |
| 855 | 867 | w.putUint8(uint8(h.NavigationCapabilities)) | |
| 856 | 868 | w.putUint8(uint8(h.StartupOverlay)) | |
| 857 | 869 | return w.b | |
| @@ -915,6 +927,9 @@ func preflightHello(b []byte) error { | |||
| 915 | 927 | if err := skipExactTargetSection(&r); err != nil { | |
| 916 | 928 | return err | |
| 917 | 929 | } | |
| 930 | + | if err := r.skipString(); err != nil { | |
| 931 | + | return err | |
| 932 | + | } | |
| 918 | 933 | if _, err := r.getUint8(); err != nil { | |
| 919 | 934 | return err | |
| 920 | 935 | } | |
| @@ -999,6 +1014,11 @@ func UnmarshalHello(b []byte) (Hello, error) { | |||
| 999 | 1014 | if h.ExactTarget, err = unmarshalExactTargetSection(&r); err != nil { | |
| 1000 | 1015 | return Hello{}, err | |
| 1001 | 1016 | } | |
| 1017 | + | preferredTabID, err := r.getString() | |
| 1018 | + | if err != nil { | |
| 1019 | + | return Hello{}, err | |
| 1020 | + | } | |
| 1021 | + | h.PreferredTabID = domain.TabStableID(preferredTabID) | |
| 1002 | 1022 | capabilities, err := r.getUint8() | |
| 1003 | 1023 | if err != nil { | |
| 1004 | 1024 | return Hello{}, err | |
| @@ -165,7 +165,10 @@ type Hello struct { | |||||
| 165 | // ExactTarget is an optional lifecycle/name identity selected by the client | 165 | // ExactTarget is an optional lifecycle/name identity selected by the client | ||
| 166 | // ledger. It is transport-neutral and never replaces the daemon's authority | 166 | // ledger. It is transport-neutral and never replaces the daemon's authority | ||
| 167 | // to validate or commit the target. | 167 | // to validate or commit the target. | ||
| 168 | - | ExactTarget *ExactSessionTarget | 168 | + | ExactTarget *ExactSessionTarget |
| 169 | + | // PreferredTabID is a client-owned route cursor. The daemon treats it as an | |||
| 170 | + | // attachment-local hint and falls back when the stable tab no longer exists. | |||
| 171 | + | PreferredTabID domain.TabStableID | |||
| 169 | EnvironmentPolicy EnvironmentPolicy | 172 | EnvironmentPolicy EnvironmentPolicy | ||
| 170 | NavigationCapabilities NavigationCapabilities | 173 | NavigationCapabilities NavigationCapabilities | ||
| 171 | StartupOverlay StartupOverlay | 174 | StartupOverlay StartupOverlay | ||
| @@ -725,6 +728,14 @@ func ValidateHello(h Hello) error { | |||||
| 725 | return ErrInvalidHello | 728 | return ErrInvalidHello | ||
| 726 | } | 729 | } | ||
| 727 | } | 730 | } | ||
| 731 | + | if h.PreferredTabID != "" { | |||
| 732 | + | if h.Intent != IntentAttach && h.Intent != IntentResume { | |||
| 733 | + | return ErrInvalidHello | |||
| 734 | + | } | |||
| 735 | + | if err := domain.ValidateTabStableID(h.PreferredTabID); err != nil { | |||
| 736 | + | return fmt.Errorf("%w: preferred tab: %v", ErrInvalidHello, err) | |||
| 737 | + | } | |||
| 738 | + | } | |||
| 728 | if h.ExactTarget != nil { | 739 | if h.ExactTarget != nil { | ||
| 729 | if h.Intent != IntentAttach && h.Intent != IntentResume { | 740 | if h.Intent != IntentAttach && h.Intent != IntentResume { | ||
| 730 | return ErrInvalidHello | 741 | return ErrInvalidHello | ||
| @@ -852,6 +863,7 @@ func MarshalHello(h Hello) []byte { | |||||
| 852 | } | 863 | } | ||
| 853 | marshalRemoteTargetSection(&w, h.RemoteTarget, h.EnvironmentPolicy) | 864 | marshalRemoteTargetSection(&w, h.RemoteTarget, h.EnvironmentPolicy) | ||
| 854 | marshalExactTargetSection(&w, h.ExactTarget) | 865 | marshalExactTargetSection(&w, h.ExactTarget) | ||
| 866 | + | w.putString(string(h.PreferredTabID)) | |||
| 855 | w.putUint8(uint8(h.NavigationCapabilities)) | 867 | w.putUint8(uint8(h.NavigationCapabilities)) | ||
| 856 | w.putUint8(uint8(h.StartupOverlay)) | 868 | w.putUint8(uint8(h.StartupOverlay)) | ||
| 857 | return w.b | 869 | return w.b | ||
| @@ -915,6 +927,9 @@ func preflightHello(b []byte) error { | |||||
| 915 | if err := skipExactTargetSection(&r); err != nil { | 927 | if err := skipExactTargetSection(&r); err != nil { | ||
| 916 | return err | 928 | return err | ||
| 917 | } | 929 | } | ||
| 930 | + | if err := r.skipString(); err != nil { | |||
| 931 | + | return err | |||
| 932 | + | } | |||
| 918 | if _, err := r.getUint8(); err != nil { | 933 | if _, err := r.getUint8(); err != nil { | ||
| 919 | return err | 934 | return err | ||
| 920 | } | 935 | } | ||
| @@ -999,6 +1014,11 @@ func UnmarshalHello(b []byte) (Hello, error) { | |||||
| 999 | if h.ExactTarget, err = unmarshalExactTargetSection(&r); err != nil { | 1014 | if h.ExactTarget, err = unmarshalExactTargetSection(&r); err != nil { | ||
| 1000 | return Hello{}, err | 1015 | return Hello{}, err | ||
| 1001 | } | 1016 | } | ||
| 1017 | + | preferredTabID, err := r.getString() | |||
| 1018 | + | if err != nil { | |||
| 1019 | + | return Hello{}, err | |||
| 1020 | + | } | |||
| 1021 | + | h.PreferredTabID = domain.TabStableID(preferredTabID) | |||
| 1002 | capabilities, err := r.getUint8() | 1022 | capabilities, err := r.getUint8() | ||
| 1003 | if err != nil { | 1023 | if err != nil { | ||
| 1004 | return Hello{}, err | 1024 | return Hello{}, err | ||
M internal/ports/wire_final_test.go +4 -4
| @@ -11,8 +11,8 @@ import ( | |||
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | 13 | func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) { | |
| 14 | - | if ProtocolVersion != 27 { | |
| 15 | - | t.Fatalf("ProtocolVersion = %d, want 27", ProtocolVersion) | |
| 14 | + | if ProtocolVersion != 28 { | |
| 15 | + | t.Fatalf("ProtocolVersion = %d, want 28", ProtocolVersion) | |
| 16 | 16 | } | |
| 17 | 17 | payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}) | |
| 18 | 18 | if len(payload) < 2 { | |
| @@ -26,8 +26,8 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) { | |||
| 26 | 26 | ||
| 27 | 27 | func TestFinalHelloGoldenStrict(t *testing.T) { | |
| 28 | 28 | msg := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}} | |
| 29 | - | want := append([]byte{0, 27, 2}, make([]byte, 16+8)...) | |
| 30 | - | want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) | |
| 29 | + | want := append([]byte{0, 28, 2}, make([]byte, 16+8)...) | |
| 30 | + | want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) | |
| 31 | 31 | got := MarshalHello(msg) | |
| 32 | 32 | if !bytes.Equal(got, want) { | |
| 33 | 33 | t.Fatalf("Hello bytes = %x, want %x", got, want) | |
| @@ -11,8 +11,8 @@ import ( | |||||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
| 13 | func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) { | 13 | func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) { | ||
| 14 | - | if ProtocolVersion != 27 { | 14 | + | if ProtocolVersion != 28 { |
| 15 | - | t.Fatalf("ProtocolVersion = %d, want 27", ProtocolVersion) | 15 | + | t.Fatalf("ProtocolVersion = %d, want 28", ProtocolVersion) |
| 16 | } | 16 | } | ||
| 17 | payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}) | 17 | payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}) | ||
| 18 | if len(payload) < 2 { | 18 | if len(payload) < 2 { | ||
| @@ -26,8 +26,8 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) { | |||||
| 26 | 26 | ||||
| 27 | func TestFinalHelloGoldenStrict(t *testing.T) { | 27 | func TestFinalHelloGoldenStrict(t *testing.T) { | ||
| 28 | msg := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}} | 28 | msg := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}} | ||
| 29 | - | want := append([]byte{0, 27, 2}, make([]byte, 16+8)...) | 29 | + | want := append([]byte{0, 28, 2}, make([]byte, 16+8)...) |
| 30 | - | want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) | 30 | + | want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) |
| 31 | got := MarshalHello(msg) | 31 | got := MarshalHello(msg) | ||
| 32 | if !bytes.Equal(got, want) { | 32 | if !bytes.Equal(got, want) { | ||
| 33 | t.Fatalf("Hello bytes = %x, want %x", got, want) | 33 | t.Fatalf("Hello bytes = %x, want %x", got, want) | ||
M internal/ports/wire_test.go +9 -8
| @@ -54,8 +54,9 @@ func assertTrailingGarbageFails[T any](t *testing.T, full []byte, unmarshal func | |||
| 54 | 54 | } | |
| 55 | 55 | ||
| 56 | 56 | func appendNoNavigationTail(payload []byte) []byte { | |
| 57 | - | // exact-target absent, navigation capabilities none, startup overlay none | |
| 58 | - | return append(payload, 0, 0, 0) | |
| 57 | + | // exact-target absent, preferred-tab absent, navigation capabilities none, | |
| 58 | + | // startup overlay none. | |
| 59 | + | return append(payload, 0, 0, 0, 0, 0) | |
| 59 | 60 | } | |
| 60 | 61 | ||
| 61 | 62 | func TestHelloGoldenAndRoundTrip(t *testing.T) { | |
| @@ -130,7 +131,7 @@ func TestHelloEnvironmentCodec(t *testing.T) { | |||
| 130 | 131 | } | |
| 131 | 132 | got := MarshalHello(msg) | |
| 132 | 133 | want := []byte{ | |
| 133 | - | 0x00, 0x1b, 0x00, // version, intent, first client ID byte | |
| 134 | + | 0x00, 0x1c, 0x00, // version, intent, first client ID byte | |
| 134 | 135 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 135 | 136 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | |
| 136 | 137 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | |
| @@ -349,7 +350,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T) | |||
| 349 | 350 | } | |
| 350 | 351 | want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...) | |
| 351 | 352 | require.Equal(t, want, MarshalTheme(cleared)) | |
| 352 | - | require.Equal(t, uint16(27), ProtocolVersion) | |
| 353 | + | require.Equal(t, uint16(28), ProtocolVersion) | |
| 353 | 354 | } | |
| 354 | 355 | ||
| 355 | 356 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | |
| @@ -510,7 +511,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||
| 510 | 511 | name: "minimal", | |
| 511 | 512 | msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"}, | |
| 512 | 513 | want: []byte{ | |
| 513 | - | 0x00, 0x1b, | |
| 514 | + | 0x00, 0x1c, | |
| 514 | 515 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | |
| 515 | 516 | 0x00, // attached | |
| 516 | 517 | 0x00, // self | |
| @@ -533,7 +534,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||
| 533 | 534 | JSON: true, | |
| 534 | 535 | }, | |
| 535 | 536 | want: []byte{ | |
| 536 | - | 0x00, 0x1b, | |
| 537 | + | 0x00, 0x1c, | |
| 537 | 538 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | |
| 538 | 539 | 0x00, // attached | |
| 539 | 540 | 0x01, // self | |
| @@ -962,7 +963,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) { | |||
| 962 | 963 | name: "attached", | |
| 963 | 964 | msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}}, | |
| 964 | 965 | want: []byte{ | |
| 965 | - | 0x00, 0x1b, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01, | |
| 966 | + | 0x00, 0x1c, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01, | |
| 966 | 967 | 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't', | |
| 967 | 968 | 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l', | |
| 968 | 969 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| @@ -972,7 +973,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) { | |||
| 972 | 973 | name: "control", | |
| 973 | 974 | msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"}, | |
| 974 | 975 | want: []byte{ | |
| 975 | - | 0x00, 0x1b, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 976 | + | 0x00, 0x1c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 976 | 977 | 0x00, 0x02, 'l', 's', 0x00, 0x00, | |
| 977 | 978 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 978 | 979 | }, | |
| @@ -54,8 +54,9 @@ func assertTrailingGarbageFails[T any](t *testing.T, full []byte, unmarshal func | |||||
| 54 | } | 54 | } | ||
| 55 | 55 | ||||
| 56 | func appendNoNavigationTail(payload []byte) []byte { | 56 | func appendNoNavigationTail(payload []byte) []byte { | ||
| 57 | - | // exact-target absent, navigation capabilities none, startup overlay none | 57 | + | // exact-target absent, preferred-tab absent, navigation capabilities none, |
| 58 | - | return append(payload, 0, 0, 0) | 58 | + | // startup overlay none. |
| 59 | + | return append(payload, 0, 0, 0, 0, 0) | |||
| 59 | } | 60 | } | ||
| 60 | 61 | ||||
| 61 | func TestHelloGoldenAndRoundTrip(t *testing.T) { | 62 | func TestHelloGoldenAndRoundTrip(t *testing.T) { | ||
| @@ -130,7 +131,7 @@ func TestHelloEnvironmentCodec(t *testing.T) { | |||||
| 130 | } | 131 | } | ||
| 131 | got := MarshalHello(msg) | 132 | got := MarshalHello(msg) | ||
| 132 | want := []byte{ | 133 | want := []byte{ | ||
| 133 | - | 0x00, 0x1b, 0x00, // version, intent, first client ID byte | 134 | + | 0x00, 0x1c, 0x00, // version, intent, first client ID byte |
| 134 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | 135 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | ||
| 135 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | 136 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | ||
| 136 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | 137 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | ||
| @@ -349,7 +350,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T) | |||||
| 349 | } | 350 | } | ||
| 350 | want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...) | 351 | want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...) | ||
| 351 | require.Equal(t, want, MarshalTheme(cleared)) | 352 | require.Equal(t, want, MarshalTheme(cleared)) | ||
| 352 | - | require.Equal(t, uint16(27), ProtocolVersion) | 353 | + | require.Equal(t, uint16(28), ProtocolVersion) |
| 353 | } | 354 | } | ||
| 354 | 355 | ||||
| 355 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | 356 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | ||
| @@ -510,7 +511,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||||
| 510 | name: "minimal", | 511 | name: "minimal", | ||
| 511 | msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"}, | 512 | msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"}, | ||
| 512 | want: []byte{ | 513 | want: []byte{ | ||
| 513 | - | 0x00, 0x1b, | 514 | + | 0x00, 0x1c, |
| 514 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | 515 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | ||
| 515 | 0x00, // attached | 516 | 0x00, // attached | ||
| 516 | 0x00, // self | 517 | 0x00, // self | ||
| @@ -533,7 +534,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||||
| 533 | JSON: true, | 534 | JSON: true, | ||
| 534 | }, | 535 | }, | ||
| 535 | want: []byte{ | 536 | want: []byte{ | ||
| 536 | - | 0x00, 0x1b, | 537 | + | 0x00, 0x1c, |
| 537 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | 538 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | ||
| 538 | 0x00, // attached | 539 | 0x00, // attached | ||
| 539 | 0x01, // self | 540 | 0x01, // self | ||
| @@ -962,7 +963,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) { | |||||
| 962 | name: "attached", | 963 | name: "attached", | ||
| 963 | msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}}, | 964 | msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}}, | ||
| 964 | want: []byte{ | 965 | want: []byte{ | ||
| 965 | - | 0x00, 0x1b, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01, | 966 | + | 0x00, 0x1c, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01, |
| 966 | 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't', | 967 | 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't', | ||
| 967 | 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l', | 968 | 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l', | ||
| 968 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | 969 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | ||
| @@ -972,7 +973,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) { | |||||
| 972 | name: "control", | 973 | name: "control", | ||
| 973 | msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"}, | 974 | msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"}, | ||
| 974 | want: []byte{ | 975 | want: []byte{ | ||
| 975 | - | 0x00, 0x1b, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | 976 | + | 0x00, 0x1c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, |
| 976 | 0x00, 0x02, 'l', 's', 0x00, 0x00, | 977 | 0x00, 0x02, 'l', 's', 0x00, 0x00, | ||
| 977 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | 978 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | ||
| 978 | }, | 979 | }, | ||
M internal/usecase/client/client.go +35 -1
| @@ -180,6 +180,7 @@ type AttachRequest struct { | |||
| 180 | 180 | OriginKey string | |
| 181 | 181 | RemoteTarget *domain.RemoteSessionTarget | |
| 182 | 182 | ExactTarget *ports.ExactSessionTarget | |
| 183 | + | PreferredTabID domain.TabStableID | |
| 183 | 184 | EnvironmentPolicy ports.EnvironmentPolicy | |
| 184 | 185 | NavigationCapabilities ports.NavigationCapabilities | |
| 185 | 186 | StartupOverlay ports.StartupOverlay | |
| @@ -539,10 +540,19 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 539 | 540 | if result.committedIdentity != nil { | |
| 540 | 541 | identity := *result.committedIdentity | |
| 541 | 542 | if attemptRequest.ExactTarget != nil && *attemptRequest.ExactTarget != identity.Target { | |
| 543 | + | attemptRequest.PreferredTabID = "" | |
| 544 | + | } | |
| 545 | + | if remoteTarget := attemptRequest.RemoteTarget; remoteTarget != nil && | |
| 546 | + | (remoteTarget.LifecycleID != identity.Target.LifecycleID || remoteTarget.SessionName != identity.Target.SessionName) { | |
| 542 | 547 | attemptRequest.RemoteTarget = nil | |
| 543 | 548 | } | |
| 544 | 549 | attemptRequest.ExactTarget = &identity.Target | |
| 545 | 550 | } | |
| 551 | + | if result.routePosition != nil && attemptRequest.ExactTarget != nil && | |
| 552 | + | *attemptRequest.ExactTarget == result.routePosition.Target { | |
| 553 | + | attemptRequest.PreferredTabID = result.routePosition.ActiveTabID | |
| 554 | + | attemptRequest.RemoteTarget = nil | |
| 555 | + | } | |
| 546 | 556 | if result.err == nil && result.welcomed && routeNavigationPending { | |
| 547 | 557 | routeNavigationPending = false | |
| 548 | 558 | routeNavigationSelection = nil | |
| @@ -590,7 +600,11 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 590 | 600 | if result.action != 0 { | |
| 591 | 601 | switch result.action { | |
| 592 | 602 | case ports.NavigationOpenHomePicker: | |
| 593 | - | if attemptRequest.NavigationCapabilities&ports.NavigationCapabilityHomePicker == 0 || homeRoute == nil { | |
| 603 | + | // The daemon only sends this action after accepting a Hello that | |
| 604 | + | // advertised Home support. The durable client-side prerequisite is | |
| 605 | + | // the captured route itself; request metadata may have been rebased by | |
| 606 | + | // committed identity or cursor updates since that Hello. | |
| 607 | + | if homeRoute == nil { | |
| 594 | 608 | return errors.New("vev: stale home navigation action") | |
| 595 | 609 | } | |
| 596 | 610 | returnRequest := attemptRequest | |
| @@ -891,6 +905,7 @@ type attachResult struct { | |||
| 891 | 905 | resumeToken uint64 | |
| 892 | 906 | sessionName string | |
| 893 | 907 | committedIdentity *ports.CommittedRouteIdentity | |
| 908 | + | routePosition *ports.RoutePosition | |
| 894 | 909 | welcomed bool | |
| 895 | 910 | transportClosed bool | |
| 896 | 911 | target *ports.AttachTarget | |
| @@ -1024,6 +1039,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 1024 | 1039 | Env: os.Environ(), | |
| 1025 | 1040 | RemoteTarget: request.RemoteTarget, | |
| 1026 | 1041 | ExactTarget: exactTarget, | |
| 1042 | + | PreferredTabID: request.PreferredTabID, | |
| 1027 | 1043 | EnvironmentPolicy: request.EnvironmentPolicy, | |
| 1028 | 1044 | NavigationCapabilities: request.NavigationCapabilities, | |
| 1029 | 1045 | StartupOverlay: request.StartupOverlay, | |
| @@ -1045,11 +1061,13 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 1045 | 1061 | return handshakeFailure("awaiting welcome", err) | |
| 1046 | 1062 | } | |
| 1047 | 1063 | var committedIdentity *ports.CommittedRouteIdentity | |
| 1064 | + | var routePosition *ports.RoutePosition | |
| 1048 | 1065 | result := func(welcomed bool, err error) attachResult { | |
| 1049 | 1066 | return attachResult{ | |
| 1050 | 1067 | resumeToken: resumeToken, | |
| 1051 | 1068 | sessionName: name, | |
| 1052 | 1069 | committedIdentity: cloneCommittedIdentity(committedIdentity), | |
| 1070 | + | routePosition: cloneRoutePosition(routePosition), | |
| 1053 | 1071 | welcomed: welcomed, | |
| 1054 | 1072 | err: err, | |
| 1055 | 1073 | } | |
| @@ -1291,6 +1309,10 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 1291 | 1309 | return welcomedResult(err) | |
| 1292 | 1310 | } | |
| 1293 | 1311 | } | |
| 1312 | + | // Welcome and every synchronous initial publication have committed. The | |
| 1313 | + | // handshake deadline must not own the long-lived runtime transport: a quiet | |
| 1314 | + | // or unchanged screen may legitimately produce no initial Output frame. | |
| 1315 | + | endHandshake() | |
| 1294 | 1316 | senderStarted = true | |
| 1295 | 1317 | go runSender(loopCtx, cancel, transport, sendCh, ackQueue, sendErrCh, log) | |
| 1296 | 1318 | stdinDone := make(chan struct{}) | |
| @@ -1568,6 +1590,18 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 1568 | 1590 | if derr := publishRouteSnapshot(); derr != nil { | |
| 1569 | 1591 | return welcomedResult(fmt.Errorf("vev: publishing daemon-local route snapshot: %w", derr)) | |
| 1570 | 1592 | } | |
| 1593 | + | case ports.MsgRoutePosition: | |
| 1594 | + | position, derr := ports.UnmarshalRoutePosition(r.frame.Payload) | |
| 1595 | + | if derr != nil { | |
| 1596 | + | return welcomedResult(fmt.Errorf("vev: decoding route position: %w", derr)) | |
| 1597 | + | } | |
| 1598 | + | if a.runner.ledger == nil { | |
| 1599 | + | return welcomedResult(errors.New("vev: route ledger unavailable")) | |
| 1600 | + | } | |
| 1601 | + | if derr := a.runner.ledger.updateRoutePosition(position); derr != nil { | |
| 1602 | + | return welcomedResult(fmt.Errorf("vev: remembering route position: %w", derr)) | |
| 1603 | + | } | |
| 1604 | + | routePosition = cloneRoutePosition(&position) | |
| 1571 | 1605 | case ports.MsgRouteNavigationFailure: | |
| 1572 | 1606 | failure, derr := ports.UnmarshalRouteNavigationFailure(r.frame.Payload) | |
| 1573 | 1607 | if derr != nil { | |
| @@ -180,6 +180,7 @@ type AttachRequest struct { | |||||
| 180 | OriginKey string | 180 | OriginKey string | ||
| 181 | RemoteTarget *domain.RemoteSessionTarget | 181 | RemoteTarget *domain.RemoteSessionTarget | ||
| 182 | ExactTarget *ports.ExactSessionTarget | 182 | ExactTarget *ports.ExactSessionTarget | ||
| 183 | + | PreferredTabID domain.TabStableID | |||
| 183 | EnvironmentPolicy ports.EnvironmentPolicy | 184 | EnvironmentPolicy ports.EnvironmentPolicy | ||
| 184 | NavigationCapabilities ports.NavigationCapabilities | 185 | NavigationCapabilities ports.NavigationCapabilities | ||
| 185 | StartupOverlay ports.StartupOverlay | 186 | StartupOverlay ports.StartupOverlay | ||
| @@ -539,10 +540,19 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||||
| 539 | if result.committedIdentity != nil { | 540 | if result.committedIdentity != nil { | ||
| 540 | identity := *result.committedIdentity | 541 | identity := *result.committedIdentity | ||
| 541 | if attemptRequest.ExactTarget != nil && *attemptRequest.ExactTarget != identity.Target { | 542 | if attemptRequest.ExactTarget != nil && *attemptRequest.ExactTarget != identity.Target { | ||
| 543 | + | attemptRequest.PreferredTabID = "" | |||
| 544 | + | } | |||
| 545 | + | if remoteTarget := attemptRequest.RemoteTarget; remoteTarget != nil && | |||
| 546 | + | (remoteTarget.LifecycleID != identity.Target.LifecycleID || remoteTarget.SessionName != identity.Target.SessionName) { | |||
| 542 | attemptRequest.RemoteTarget = nil | 547 | attemptRequest.RemoteTarget = nil | ||
| 543 | } | 548 | } | ||
| 544 | attemptRequest.ExactTarget = &identity.Target | 549 | attemptRequest.ExactTarget = &identity.Target | ||
| 545 | } | 550 | } | ||
| 551 | + | if result.routePosition != nil && attemptRequest.ExactTarget != nil && | |||
| 552 | + | *attemptRequest.ExactTarget == result.routePosition.Target { | |||
| 553 | + | attemptRequest.PreferredTabID = result.routePosition.ActiveTabID | |||
| 554 | + | attemptRequest.RemoteTarget = nil | |||
| 555 | + | } | |||
| 546 | if result.err == nil && result.welcomed && routeNavigationPending { | 556 | if result.err == nil && result.welcomed && routeNavigationPending { | ||
| 547 | routeNavigationPending = false | 557 | routeNavigationPending = false | ||
| 548 | routeNavigationSelection = nil | 558 | routeNavigationSelection = nil | ||
| @@ -590,7 +600,11 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||||
| 590 | if result.action != 0 { | 600 | if result.action != 0 { | ||
| 591 | switch result.action { | 601 | switch result.action { | ||
| 592 | case ports.NavigationOpenHomePicker: | 602 | case ports.NavigationOpenHomePicker: | ||
| 593 | - | if attemptRequest.NavigationCapabilities&ports.NavigationCapabilityHomePicker == 0 || homeRoute == nil { | 603 | + | // The daemon only sends this action after accepting a Hello that |
| 604 | + | // advertised Home support. The durable client-side prerequisite is | |||
| 605 | + | // the captured route itself; request metadata may have been rebased by | |||
| 606 | + | // committed identity or cursor updates since that Hello. | |||
| 607 | + | if homeRoute == nil { | |||
| 594 | return errors.New("vev: stale home navigation action") | 608 | return errors.New("vev: stale home navigation action") | ||
| 595 | } | 609 | } | ||
| 596 | returnRequest := attemptRequest | 610 | returnRequest := attemptRequest | ||
| @@ -891,6 +905,7 @@ type attachResult struct { | |||||
| 891 | resumeToken uint64 | 905 | resumeToken uint64 | ||
| 892 | sessionName string | 906 | sessionName string | ||
| 893 | committedIdentity *ports.CommittedRouteIdentity | 907 | committedIdentity *ports.CommittedRouteIdentity | ||
| 908 | + | routePosition *ports.RoutePosition | |||
| 894 | welcomed bool | 909 | welcomed bool | ||
| 895 | transportClosed bool | 910 | transportClosed bool | ||
| 896 | target *ports.AttachTarget | 911 | target *ports.AttachTarget | ||
| @@ -1024,6 +1039,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||||
| 1024 | Env: os.Environ(), | 1039 | Env: os.Environ(), | ||
| 1025 | RemoteTarget: request.RemoteTarget, | 1040 | RemoteTarget: request.RemoteTarget, | ||
| 1026 | ExactTarget: exactTarget, | 1041 | ExactTarget: exactTarget, | ||
| 1042 | + | PreferredTabID: request.PreferredTabID, | |||
| 1027 | EnvironmentPolicy: request.EnvironmentPolicy, | 1043 | EnvironmentPolicy: request.EnvironmentPolicy, | ||
| 1028 | NavigationCapabilities: request.NavigationCapabilities, | 1044 | NavigationCapabilities: request.NavigationCapabilities, | ||
| 1029 | StartupOverlay: request.StartupOverlay, | 1045 | StartupOverlay: request.StartupOverlay, | ||
| @@ -1045,11 +1061,13 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||||
| 1045 | return handshakeFailure("awaiting welcome", err) | 1061 | return handshakeFailure("awaiting welcome", err) | ||
| 1046 | } | 1062 | } | ||
| 1047 | var committedIdentity *ports.CommittedRouteIdentity | 1063 | var committedIdentity *ports.CommittedRouteIdentity | ||
| 1064 | + | var routePosition *ports.RoutePosition | |||
| 1048 | result := func(welcomed bool, err error) attachResult { | 1065 | result := func(welcomed bool, err error) attachResult { | ||
| 1049 | return attachResult{ | 1066 | return attachResult{ | ||
| 1050 | resumeToken: resumeToken, | 1067 | resumeToken: resumeToken, | ||
| 1051 | sessionName: name, | 1068 | sessionName: name, | ||
| 1052 | committedIdentity: cloneCommittedIdentity(committedIdentity), | 1069 | committedIdentity: cloneCommittedIdentity(committedIdentity), | ||
| 1070 | + | routePosition: cloneRoutePosition(routePosition), | |||
| 1053 | welcomed: welcomed, | 1071 | welcomed: welcomed, | ||
| 1054 | err: err, | 1072 | err: err, | ||
| 1055 | } | 1073 | } | ||
| @@ -1291,6 +1309,10 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||||
| 1291 | return welcomedResult(err) | 1309 | return welcomedResult(err) | ||
| 1292 | } | 1310 | } | ||
| 1293 | } | 1311 | } | ||
| 1312 | + | // Welcome and every synchronous initial publication have committed. The | |||
| 1313 | + | // handshake deadline must not own the long-lived runtime transport: a quiet | |||
| 1314 | + | // or unchanged screen may legitimately produce no initial Output frame. | |||
| 1315 | + | endHandshake() | |||
| 1294 | senderStarted = true | 1316 | senderStarted = true | ||
| 1295 | go runSender(loopCtx, cancel, transport, sendCh, ackQueue, sendErrCh, log) | 1317 | go runSender(loopCtx, cancel, transport, sendCh, ackQueue, sendErrCh, log) | ||
| 1296 | stdinDone := make(chan struct{}) | 1318 | stdinDone := make(chan struct{}) | ||
| @@ -1568,6 +1590,18 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||||
| 1568 | if derr := publishRouteSnapshot(); derr != nil { | 1590 | if derr := publishRouteSnapshot(); derr != nil { | ||
| 1569 | return welcomedResult(fmt.Errorf("vev: publishing daemon-local route snapshot: %w", derr)) | 1591 | return welcomedResult(fmt.Errorf("vev: publishing daemon-local route snapshot: %w", derr)) | ||
| 1570 | } | 1592 | } | ||
| 1593 | + | case ports.MsgRoutePosition: | |||
| 1594 | + | position, derr := ports.UnmarshalRoutePosition(r.frame.Payload) | |||
| 1595 | + | if derr != nil { | |||
| 1596 | + | return welcomedResult(fmt.Errorf("vev: decoding route position: %w", derr)) | |||
| 1597 | + | } | |||
| 1598 | + | if a.runner.ledger == nil { | |||
| 1599 | + | return welcomedResult(errors.New("vev: route ledger unavailable")) | |||
| 1600 | + | } | |||
| 1601 | + | if derr := a.runner.ledger.updateRoutePosition(position); derr != nil { | |||
| 1602 | + | return welcomedResult(fmt.Errorf("vev: remembering route position: %w", derr)) | |||
| 1603 | + | } | |||
| 1604 | + | routePosition = cloneRoutePosition(&position) | |||
| 1571 | case ports.MsgRouteNavigationFailure: | 1605 | case ports.MsgRouteNavigationFailure: | ||
| 1572 | failure, derr := ports.UnmarshalRouteNavigationFailure(r.frame.Payload) | 1606 | failure, derr := ports.UnmarshalRouteNavigationFailure(r.frame.Payload) | ||
| 1573 | if derr != nil { | 1607 | if derr != nil { | ||
M internal/usecase/client/client_test.go +158 -4
| @@ -439,7 +439,7 @@ func TestAttachPublishesCommittedRouteSnapshot(t *testing.T) { | |||
| 439 | 439 | ||
| 440 | 440 | var snapshot ports.RecentRouteSnapshot | |
| 441 | 441 | found := false | |
| 442 | - | for _, frame := range tr.sends { | |
| 442 | + | for _, frame := range tr.Sends() { | |
| 443 | 443 | if frame.Type != ports.MsgRecentRouteSnapshot { | |
| 444 | 444 | continue | |
| 445 | 445 | } | |
| @@ -483,6 +483,56 @@ func TestAttachHelloPreservesCompleteAttachRequest(t *testing.T) { | |||
| 483 | 483 | require.Equal(t, request.StartupOverlay, hello.StartupOverlay) | |
| 484 | 484 | } | |
| 485 | 485 | ||
| 486 | + | func TestAcceptedHomeActionUsesCapturedRouteAfterRequestMetadataRebase(t *testing.T) { | |
| 487 | + | term := newRunTerminal() | |
| 488 | + | defer term.in.unblock() | |
| 489 | + | localLifecycle := domain.SessionLifecycleID{1} | |
| 490 | + | remoteLifecycle := domain.SessionLifecycleID{2} | |
| 491 | + | welcome := func(name string, lifecycle domain.SessionLifecycleID) ports.Frame { | |
| 492 | + | return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{ | |
| 493 | + | SessionID: name, SessionName: name, ResumeToken: 1, Capabilities: ports.CapabilityResume, | |
| 494 | + | CommittedIdentity: &ports.CommittedRouteIdentity{Target: ports.ExactSessionTarget{LifecycleID: lifecycle, SessionName: name}}, | |
| 495 | + | })) | |
| 496 | + | } | |
| 497 | + | remoteTarget := domain.RemoteSessionTarget{ | |
| 498 | + | Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: remoteLifecycle, | |
| 499 | + | SessionName: "work", LiveTabID: "tab-1", | |
| 500 | + | } | |
| 501 | + | local1 := &recordingTransport{recvs: []recvItem{ | |
| 502 | + | {f: welcome("local", localLifecycle)}, | |
| 503 | + | {f: frameOf(ports.MsgAttachTarget, ports.MarshalAttachTarget(ports.AttachTarget{ | |
| 504 | + | Endpoint: "remote", Session: "work", Intent: ports.IntentAttach, | |
| 505 | + | RemoteTarget: &remoteTarget, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned, | |
| 506 | + | }))}, | |
| 507 | + | }} | |
| 508 | + | remote := &recordingTransport{recvs: []recvItem{ | |
| 509 | + | {f: welcome("work", remoteLifecycle)}, | |
| 510 | + | {f: frameOf(ports.MsgNavigationAction, ports.MarshalNavigationAction(ports.NavigationOpenHomePicker))}, | |
| 511 | + | }} | |
| 512 | + | local2 := &recordingTransport{recvs: []recvItem{ | |
| 513 | + | {f: welcome("local", localLifecycle)}, | |
| 514 | + | {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}, | |
| 515 | + | }} | |
| 516 | + | localDialer := &sequenceDialer{trs: []ports.Transport{local1, local2}} | |
| 517 | + | remoteDialer := &sequenceDialer{trs: []ports.Transport{remote}} | |
| 518 | + | deps := testDependencies(localDialer, term, realClock{}, nil, nil) | |
| 519 | + | deps.AttachHandoff = func(ports.AttachTarget) (ports.Dialer, client.AttachRequest, error) { | |
| 520 | + | target := ports.ExactSessionTarget{LifecycleID: remoteLifecycle, SessionName: "work"} | |
| 521 | + | return remoteDialer, client.AttachRequest{ | |
| 522 | + | Intent: ports.IntentAttach, SessionName: "work", Remote: true, | |
| 523 | + | Origin: ports.RouteOriginRemote, OriginKey: "remote", ExactTarget: &target, | |
| 524 | + | EnvironmentPolicy: ports.EnvironmentPolicyClientOwned, | |
| 525 | + | // Deliberately omit the copied Home capability. The accepted server | |
| 526 | + | // action remains valid because Runner retained the concrete home route. | |
| 527 | + | }, nil | |
| 528 | + | } | |
| 529 | + | ||
| 530 | + | require.NoError(t, runTestClient(context.Background(), deps, client.AttachRequest{ | |
| 531 | + | Intent: ports.IntentAttach, SessionName: "local", Origin: ports.RouteOriginLocal, OriginKey: "local", | |
| 532 | + | })) | |
| 533 | + | require.Equal(t, ports.StartupOverlaySessionPicker, helloFromSend(t, local2).StartupOverlay) | |
| 534 | + | } | |
| 535 | + | ||
| 486 | 536 | func TestRouteNavigationPreservesRemoteHomePickerAcrossLocalReturn(t *testing.T) { | |
| 487 | 537 | term := newRunTerminal() | |
| 488 | 538 | defer term.in.unblock() | |
| @@ -538,6 +588,9 @@ func TestRouteNavigationPreservesRemoteHomePickerAcrossLocalReturn(t *testing.T) | |||
| 538 | 588 | {f: frameOf(ports.MsgCommittedRouteIdentity, mustMarshalCommittedIdentity(ports.CommittedRouteIdentity{ | |
| 539 | 589 | Target: ports.ExactSessionTarget{LifecycleID: remoteNewLifecycle, SessionName: "remote-new"}, | |
| 540 | 590 | }))}, | |
| 591 | + | {f: frameOf(ports.MsgRoutePosition, mustMarshalRoutePosition(ports.RoutePosition{ | |
| 592 | + | Target: ports.ExactSessionTarget{LifecycleID: remoteNewLifecycle, SessionName: "remote-new"}, ActiveTabID: "tab-2", | |
| 593 | + | }))}, | |
| 541 | 594 | {f: frameOf(ports.MsgNavigationAction, ports.MarshalNavigationAction(ports.NavigationOpenHomePicker))}, | |
| 542 | 595 | }} | |
| 543 | 596 | remote3 := &recordingTransport{recvs: []recvItem{ | |
| @@ -585,6 +638,14 @@ func mustMarshalRouteAction(action ports.RouteNavigationAction) []byte { | |||
| 585 | 638 | return payload | |
| 586 | 639 | } | |
| 587 | 640 | ||
| 641 | + | func mustMarshalRoutePosition(position ports.RoutePosition) []byte { | |
| 642 | + | payload, err := ports.MarshalRoutePosition(position) | |
| 643 | + | if err != nil { | |
| 644 | + | panic(err) | |
| 645 | + | } | |
| 646 | + | return payload | |
| 647 | + | } | |
| 648 | + | ||
| 588 | 649 | func TestAttachTargetHandoffReturnsValidatedTargetAndClosesTransport(t *testing.T) { | |
| 589 | 650 | var out bytes.Buffer | |
| 590 | 651 | var restoreCount atomic.Int32 | |
| @@ -1026,16 +1087,25 @@ func (d *sequenceDialer) Dial(context.Context) (ports.Transport, error) { | |||
| 1026 | 1087 | } | |
| 1027 | 1088 | ||
| 1028 | 1089 | type recordingTransport struct { | |
| 1090 | + | mu sync.Mutex | |
| 1029 | 1091 | recvs []recvItem | |
| 1030 | 1092 | sends []ports.Frame | |
| 1031 | 1093 | closed atomic.Int32 | |
| 1032 | 1094 | } | |
| 1033 | 1095 | ||
| 1034 | 1096 | func (t *recordingTransport) Send(f ports.Frame) error { | |
| 1097 | + | t.mu.Lock() | |
| 1035 | 1098 | t.sends = append(t.sends, f) | |
| 1099 | + | t.mu.Unlock() | |
| 1036 | 1100 | return nil | |
| 1037 | 1101 | } | |
| 1038 | 1102 | ||
| 1103 | + | func (t *recordingTransport) Sends() []ports.Frame { | |
| 1104 | + | t.mu.Lock() | |
| 1105 | + | defer t.mu.Unlock() | |
| 1106 | + | return append([]ports.Frame(nil), t.sends...) | |
| 1107 | + | } | |
| 1108 | + | ||
| 1039 | 1109 | func (t *recordingTransport) Recv() (ports.Frame, error) { | |
| 1040 | 1110 | if len(t.recvs) == 0 { | |
| 1041 | 1111 | return ports.Frame{}, io.EOF | |
| @@ -1074,9 +1144,10 @@ func (t *runTerminal) Flush() error { return nil } | |||
| 1074 | 1144 | ||
| 1075 | 1145 | func helloFromSend(t *testing.T, tr *recordingTransport) ports.Hello { | |
| 1076 | 1146 | t.Helper() | |
| 1077 | - | require.NotEmpty(t, tr.sends) | |
| 1078 | - | require.Equal(t, ports.MsgHello, tr.sends[0].Type) | |
| 1079 | - | h, err := ports.UnmarshalHello(tr.sends[0].Payload) | |
| 1147 | + | sends := tr.Sends() | |
| 1148 | + | require.NotEmpty(t, sends) | |
| 1149 | + | require.Equal(t, ports.MsgHello, sends[0].Type) | |
| 1150 | + | h, err := ports.UnmarshalHello(sends[0].Payload) | |
| 1080 | 1151 | require.NoError(t, err) | |
| 1081 | 1152 | return h | |
| 1082 | 1153 | } | |
| @@ -1119,6 +1190,89 @@ func TestRunReconnectsWithRotatedTokenAndSameClientID(t *testing.T) { | |||
| 1119 | 1190 | require.Contains(t, term.out.String(), "\x1b[2K") | |
| 1120 | 1191 | } | |
| 1121 | 1192 | ||
| 1193 | + | func TestReconnectRestoresLastPublishedRouteTab(t *testing.T) { | |
| 1194 | + | term := newRunTerminal() | |
| 1195 | + | defer term.in.unblock() | |
| 1196 | + | lifecycle := domain.SessionLifecycleID{7, 8, 9} | |
| 1197 | + | target := ports.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "work"} | |
| 1198 | + | welcome := func(token uint64) ports.Frame { | |
| 1199 | + | return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{ | |
| 1200 | + | SessionID: "work", SessionName: "work", ResumeToken: token, Capabilities: ports.CapabilityResume, | |
| 1201 | + | CommittedIdentity: &ports.CommittedRouteIdentity{Target: target}, | |
| 1202 | + | })) | |
| 1203 | + | } | |
| 1204 | + | tr1 := &recordingTransport{recvs: []recvItem{ | |
| 1205 | + | {f: welcome(11)}, | |
| 1206 | + | {f: frameOf(ports.MsgRoutePosition, mustMarshalRoutePosition(ports.RoutePosition{Target: target, ActiveTabID: "tab-2"}))}, | |
| 1207 | + | {err: io.EOF}, | |
| 1208 | + | }} | |
| 1209 | + | tr2 := &recordingTransport{recvs: []recvItem{ | |
| 1210 | + | {f: welcome(22)}, | |
| 1211 | + | {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}, | |
| 1212 | + | }} | |
| 1213 | + | dialer := &sequenceDialer{trs: []ports.Transport{tr1, tr2}} | |
| 1214 | + | clock := &reconnectTestClock{} | |
| 1215 | + | result := make(chan error, 1) | |
| 1216 | + | go func() { | |
| 1217 | + | result <- runTestClient(context.Background(), testDependencies(dialer, term, clock, nil, nil), client.AttachRequest{ | |
| 1218 | + | Intent: ports.IntentAttach, SessionName: "work", Origin: ports.RouteOriginLocal, OriginKey: "local", | |
| 1219 | + | }) | |
| 1220 | + | }() | |
| 1221 | + | clock.fireReconnect(t) | |
| 1222 | + | require.NoError(t, <-result) | |
| 1223 | + | ||
| 1224 | + | reconnectHello := helloFromSend(t, tr2) | |
| 1225 | + | require.Equal(t, domain.TabStableID("tab-2"), reconnectHello.PreferredTabID) | |
| 1226 | + | } | |
| 1227 | + | ||
| 1228 | + | func TestRemoteReconnectDropsStalePickerTargetAfterDaemonSessionSwitch(t *testing.T) { | |
| 1229 | + | term := newRunTerminal() | |
| 1230 | + | defer term.in.unblock() | |
| 1231 | + | oldLifecycle := domain.SessionLifecycleID{1, 2, 3} | |
| 1232 | + | newLifecycle := domain.SessionLifecycleID{4, 5, 6} | |
| 1233 | + | remoteTarget := &domain.RemoteSessionTarget{ | |
| 1234 | + | Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: oldLifecycle, | |
| 1235 | + | SessionName: "old", LiveTabID: "tab-1", | |
| 1236 | + | } | |
| 1237 | + | welcome := func(name string, lifecycle domain.SessionLifecycleID, token uint64) ports.Frame { | |
| 1238 | + | return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{ | |
| 1239 | + | SessionID: name, SessionName: name, ResumeToken: token, Capabilities: ports.CapabilityResume, | |
| 1240 | + | CommittedIdentity: &ports.CommittedRouteIdentity{ | |
| 1241 | + | Target: ports.ExactSessionTarget{LifecycleID: lifecycle, SessionName: name}, | |
| 1242 | + | }, | |
| 1243 | + | })) | |
| 1244 | + | } | |
| 1245 | + | tr1 := &recordingTransport{recvs: []recvItem{ | |
| 1246 | + | {f: welcome("old", oldLifecycle, 11)}, | |
| 1247 | + | {f: frameOf(ports.MsgCommittedRouteIdentity, mustMarshalCommittedIdentity(ports.CommittedRouteIdentity{ | |
| 1248 | + | Target: ports.ExactSessionTarget{LifecycleID: newLifecycle, SessionName: "new"}, | |
| 1249 | + | }))}, | |
| 1250 | + | {err: io.EOF}, | |
| 1251 | + | }} | |
| 1252 | + | tr2 := &recordingTransport{recvs: []recvItem{ | |
| 1253 | + | {f: welcome("new", newLifecycle, 22)}, | |
| 1254 | + | {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}, | |
| 1255 | + | }} | |
| 1256 | + | dialer := &sequenceDialer{trs: []ports.Transport{tr1, tr2}} | |
| 1257 | + | clock := &reconnectTestClock{} | |
| 1258 | + | result := make(chan error, 1) | |
| 1259 | + | go func() { | |
| 1260 | + | result <- runTestClient(context.Background(), testDependencies(dialer, term, clock, nil, nil), client.AttachRequest{ | |
| 1261 | + | Intent: ports.IntentAttach, SessionName: "old", Remote: true, | |
| 1262 | + | Origin: ports.RouteOriginDiscovery, OriginKey: "remote", | |
| 1263 | + | RemoteTarget: remoteTarget, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned, | |
| 1264 | + | }) | |
| 1265 | + | }() | |
| 1266 | + | clock.fireReconnect(t) | |
| 1267 | + | require.NoError(t, <-result) | |
| 1268 | + | ||
| 1269 | + | reconnectHello := helloFromSend(t, tr2) | |
| 1270 | + | require.Equal(t, ports.IntentResume, reconnectHello.Intent) | |
| 1271 | + | require.Equal(t, "new", reconnectHello.Name) | |
| 1272 | + | require.Equal(t, &ports.ExactSessionTarget{LifecycleID: newLifecycle, SessionName: "new"}, reconnectHello.ExactTarget) | |
| 1273 | + | require.Nil(t, reconnectHello.RemoteTarget) | |
| 1274 | + | } | |
| 1275 | + | ||
| 1122 | 1276 | func TestAttachRememberRemoteHost(t *testing.T) { | |
| 1123 | 1277 | tests := []struct { | |
| 1124 | 1278 | name string | |
| @@ -439,7 +439,7 @@ func TestAttachPublishesCommittedRouteSnapshot(t *testing.T) { | |||||
| 439 | 439 | ||||
| 440 | var snapshot ports.RecentRouteSnapshot | 440 | var snapshot ports.RecentRouteSnapshot | ||
| 441 | found := false | 441 | found := false | ||
| 442 | - | for _, frame := range tr.sends { | 442 | + | for _, frame := range tr.Sends() { |
| 443 | if frame.Type != ports.MsgRecentRouteSnapshot { | 443 | if frame.Type != ports.MsgRecentRouteSnapshot { | ||
| 444 | continue | 444 | continue | ||
| 445 | } | 445 | } | ||
| @@ -483,6 +483,56 @@ func TestAttachHelloPreservesCompleteAttachRequest(t *testing.T) { | |||||
| 483 | require.Equal(t, request.StartupOverlay, hello.StartupOverlay) | 483 | require.Equal(t, request.StartupOverlay, hello.StartupOverlay) | ||
| 484 | } | 484 | } | ||
| 485 | 485 | ||||
| 486 | + | func TestAcceptedHomeActionUsesCapturedRouteAfterRequestMetadataRebase(t *testing.T) { | |||
| 487 | + | term := newRunTerminal() | |||
| 488 | + | defer term.in.unblock() | |||
| 489 | + | localLifecycle := domain.SessionLifecycleID{1} | |||
| 490 | + | remoteLifecycle := domain.SessionLifecycleID{2} | |||
| 491 | + | welcome := func(name string, lifecycle domain.SessionLifecycleID) ports.Frame { | |||
| 492 | + | return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{ | |||
| 493 | + | SessionID: name, SessionName: name, ResumeToken: 1, Capabilities: ports.CapabilityResume, | |||
| 494 | + | CommittedIdentity: &ports.CommittedRouteIdentity{Target: ports.ExactSessionTarget{LifecycleID: lifecycle, SessionName: name}}, | |||
| 495 | + | })) | |||
| 496 | + | } | |||
| 497 | + | remoteTarget := domain.RemoteSessionTarget{ | |||
| 498 | + | Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: remoteLifecycle, | |||
| 499 | + | SessionName: "work", LiveTabID: "tab-1", | |||
| 500 | + | } | |||
| 501 | + | local1 := &recordingTransport{recvs: []recvItem{ | |||
| 502 | + | {f: welcome("local", localLifecycle)}, | |||
| 503 | + | {f: frameOf(ports.MsgAttachTarget, ports.MarshalAttachTarget(ports.AttachTarget{ | |||
| 504 | + | Endpoint: "remote", Session: "work", Intent: ports.IntentAttach, | |||
| 505 | + | RemoteTarget: &remoteTarget, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned, | |||
| 506 | + | }))}, | |||
| 507 | + | }} | |||
| 508 | + | remote := &recordingTransport{recvs: []recvItem{ | |||
| 509 | + | {f: welcome("work", remoteLifecycle)}, | |||
| 510 | + | {f: frameOf(ports.MsgNavigationAction, ports.MarshalNavigationAction(ports.NavigationOpenHomePicker))}, | |||
| 511 | + | }} | |||
| 512 | + | local2 := &recordingTransport{recvs: []recvItem{ | |||
| 513 | + | {f: welcome("local", localLifecycle)}, | |||
| 514 | + | {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}, | |||
| 515 | + | }} | |||
| 516 | + | localDialer := &sequenceDialer{trs: []ports.Transport{local1, local2}} | |||
| 517 | + | remoteDialer := &sequenceDialer{trs: []ports.Transport{remote}} | |||
| 518 | + | deps := testDependencies(localDialer, term, realClock{}, nil, nil) | |||
| 519 | + | deps.AttachHandoff = func(ports.AttachTarget) (ports.Dialer, client.AttachRequest, error) { | |||
| 520 | + | target := ports.ExactSessionTarget{LifecycleID: remoteLifecycle, SessionName: "work"} | |||
| 521 | + | return remoteDialer, client.AttachRequest{ | |||
| 522 | + | Intent: ports.IntentAttach, SessionName: "work", Remote: true, | |||
| 523 | + | Origin: ports.RouteOriginRemote, OriginKey: "remote", ExactTarget: &target, | |||
| 524 | + | EnvironmentPolicy: ports.EnvironmentPolicyClientOwned, | |||
| 525 | + | // Deliberately omit the copied Home capability. The accepted server | |||
| 526 | + | // action remains valid because Runner retained the concrete home route. | |||
| 527 | + | }, nil | |||
| 528 | + | } | |||
| 529 | + | ||||
| 530 | + | require.NoError(t, runTestClient(context.Background(), deps, client.AttachRequest{ | |||
| 531 | + | Intent: ports.IntentAttach, SessionName: "local", Origin: ports.RouteOriginLocal, OriginKey: "local", | |||
| 532 | + | })) | |||
| 533 | + | require.Equal(t, ports.StartupOverlaySessionPicker, helloFromSend(t, local2).StartupOverlay) | |||
| 534 | + | } | |||
| 535 | + | ||||
| 486 | func TestRouteNavigationPreservesRemoteHomePickerAcrossLocalReturn(t *testing.T) { | 536 | func TestRouteNavigationPreservesRemoteHomePickerAcrossLocalReturn(t *testing.T) { | ||
| 487 | term := newRunTerminal() | 537 | term := newRunTerminal() | ||
| 488 | defer term.in.unblock() | 538 | defer term.in.unblock() | ||
| @@ -538,6 +588,9 @@ func TestRouteNavigationPreservesRemoteHomePickerAcrossLocalReturn(t *testing.T) | |||||
| 538 | {f: frameOf(ports.MsgCommittedRouteIdentity, mustMarshalCommittedIdentity(ports.CommittedRouteIdentity{ | 588 | {f: frameOf(ports.MsgCommittedRouteIdentity, mustMarshalCommittedIdentity(ports.CommittedRouteIdentity{ | ||
| 539 | Target: ports.ExactSessionTarget{LifecycleID: remoteNewLifecycle, SessionName: "remote-new"}, | 589 | Target: ports.ExactSessionTarget{LifecycleID: remoteNewLifecycle, SessionName: "remote-new"}, | ||
| 540 | }))}, | 590 | }))}, | ||
| 591 | + | {f: frameOf(ports.MsgRoutePosition, mustMarshalRoutePosition(ports.RoutePosition{ | |||
| 592 | + | Target: ports.ExactSessionTarget{LifecycleID: remoteNewLifecycle, SessionName: "remote-new"}, ActiveTabID: "tab-2", | |||
| 593 | + | }))}, | |||
| 541 | {f: frameOf(ports.MsgNavigationAction, ports.MarshalNavigationAction(ports.NavigationOpenHomePicker))}, | 594 | {f: frameOf(ports.MsgNavigationAction, ports.MarshalNavigationAction(ports.NavigationOpenHomePicker))}, | ||
| 542 | }} | 595 | }} | ||
| 543 | remote3 := &recordingTransport{recvs: []recvItem{ | 596 | remote3 := &recordingTransport{recvs: []recvItem{ | ||
| @@ -585,6 +638,14 @@ func mustMarshalRouteAction(action ports.RouteNavigationAction) []byte { | |||||
| 585 | return payload | 638 | return payload | ||
| 586 | } | 639 | } | ||
| 587 | 640 | ||||
| 641 | + | func mustMarshalRoutePosition(position ports.RoutePosition) []byte { | |||
| 642 | + | payload, err := ports.MarshalRoutePosition(position) | |||
| 643 | + | if err != nil { | |||
| 644 | + | panic(err) | |||
| 645 | + | } | |||
| 646 | + | return payload | |||
| 647 | + | } | |||
| 648 | + | ||||
| 588 | func TestAttachTargetHandoffReturnsValidatedTargetAndClosesTransport(t *testing.T) { | 649 | func TestAttachTargetHandoffReturnsValidatedTargetAndClosesTransport(t *testing.T) { | ||
| 589 | var out bytes.Buffer | 650 | var out bytes.Buffer | ||
| 590 | var restoreCount atomic.Int32 | 651 | var restoreCount atomic.Int32 | ||
| @@ -1026,16 +1087,25 @@ func (d *sequenceDialer) Dial(context.Context) (ports.Transport, error) { | |||||
| 1026 | } | 1087 | } | ||
| 1027 | 1088 | ||||
| 1028 | type recordingTransport struct { | 1089 | type recordingTransport struct { | ||
| 1090 | + | mu sync.Mutex | |||
| 1029 | recvs []recvItem | 1091 | recvs []recvItem | ||
| 1030 | sends []ports.Frame | 1092 | sends []ports.Frame | ||
| 1031 | closed atomic.Int32 | 1093 | closed atomic.Int32 | ||
| 1032 | } | 1094 | } | ||
| 1033 | 1095 | ||||
| 1034 | func (t *recordingTransport) Send(f ports.Frame) error { | 1096 | func (t *recordingTransport) Send(f ports.Frame) error { | ||
| 1097 | + | t.mu.Lock() | |||
| 1035 | t.sends = append(t.sends, f) | 1098 | t.sends = append(t.sends, f) | ||
| 1099 | + | t.mu.Unlock() | |||
| 1036 | return nil | 1100 | return nil | ||
| 1037 | } | 1101 | } | ||
| 1038 | 1102 | ||||
| 1103 | + | func (t *recordingTransport) Sends() []ports.Frame { | |||
| 1104 | + | t.mu.Lock() | |||
| 1105 | + | defer t.mu.Unlock() | |||
| 1106 | + | return append([]ports.Frame(nil), t.sends...) | |||
| 1107 | + | } | |||
| 1108 | + | ||||
| 1039 | func (t *recordingTransport) Recv() (ports.Frame, error) { | 1109 | func (t *recordingTransport) Recv() (ports.Frame, error) { | ||
| 1040 | if len(t.recvs) == 0 { | 1110 | if len(t.recvs) == 0 { | ||
| 1041 | return ports.Frame{}, io.EOF | 1111 | return ports.Frame{}, io.EOF | ||
| @@ -1074,9 +1144,10 @@ func (t *runTerminal) Flush() error { return nil } | |||||
| 1074 | 1144 | ||||
| 1075 | func helloFromSend(t *testing.T, tr *recordingTransport) ports.Hello { | 1145 | func helloFromSend(t *testing.T, tr *recordingTransport) ports.Hello { | ||
| 1076 | t.Helper() | 1146 | t.Helper() | ||
| 1077 | - | require.NotEmpty(t, tr.sends) | 1147 | + | sends := tr.Sends() |
| 1078 | - | require.Equal(t, ports.MsgHello, tr.sends[0].Type) | 1148 | + | require.NotEmpty(t, sends) |
| 1079 | - | h, err := ports.UnmarshalHello(tr.sends[0].Payload) | 1149 | + | require.Equal(t, ports.MsgHello, sends[0].Type) |
| 1150 | + | h, err := ports.UnmarshalHello(sends[0].Payload) | |||
| 1080 | require.NoError(t, err) | 1151 | require.NoError(t, err) | ||
| 1081 | return h | 1152 | return h | ||
| 1082 | } | 1153 | } | ||
| @@ -1119,6 +1190,89 @@ func TestRunReconnectsWithRotatedTokenAndSameClientID(t *testing.T) { | |||||
| 1119 | require.Contains(t, term.out.String(), "\x1b[2K") | 1190 | require.Contains(t, term.out.String(), "\x1b[2K") | ||
| 1120 | } | 1191 | } | ||
| 1121 | 1192 | ||||
| 1193 | + | func TestReconnectRestoresLastPublishedRouteTab(t *testing.T) { | |||
| 1194 | + | term := newRunTerminal() | |||
| 1195 | + | defer term.in.unblock() | |||
| 1196 | + | lifecycle := domain.SessionLifecycleID{7, 8, 9} | |||
| 1197 | + | target := ports.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "work"} | |||
| 1198 | + | welcome := func(token uint64) ports.Frame { | |||
| 1199 | + | return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{ | |||
| 1200 | + | SessionID: "work", SessionName: "work", ResumeToken: token, Capabilities: ports.CapabilityResume, | |||
| 1201 | + | CommittedIdentity: &ports.CommittedRouteIdentity{Target: target}, | |||
| 1202 | + | })) | |||
| 1203 | + | } | |||
| 1204 | + | tr1 := &recordingTransport{recvs: []recvItem{ | |||
| 1205 | + | {f: welcome(11)}, | |||
| 1206 | + | {f: frameOf(ports.MsgRoutePosition, mustMarshalRoutePosition(ports.RoutePosition{Target: target, ActiveTabID: "tab-2"}))}, | |||
| 1207 | + | {err: io.EOF}, | |||
| 1208 | + | }} | |||
| 1209 | + | tr2 := &recordingTransport{recvs: []recvItem{ | |||
| 1210 | + | {f: welcome(22)}, | |||
| 1211 | + | {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}, | |||
| 1212 | + | }} | |||
| 1213 | + | dialer := &sequenceDialer{trs: []ports.Transport{tr1, tr2}} | |||
| 1214 | + | clock := &reconnectTestClock{} | |||
| 1215 | + | result := make(chan error, 1) | |||
| 1216 | + | go func() { | |||
| 1217 | + | result <- runTestClient(context.Background(), testDependencies(dialer, term, clock, nil, nil), client.AttachRequest{ | |||
| 1218 | + | Intent: ports.IntentAttach, SessionName: "work", Origin: ports.RouteOriginLocal, OriginKey: "local", | |||
| 1219 | + | }) | |||
| 1220 | + | }() | |||
| 1221 | + | clock.fireReconnect(t) | |||
| 1222 | + | require.NoError(t, <-result) | |||
| 1223 | + | ||||
| 1224 | + | reconnectHello := helloFromSend(t, tr2) | |||
| 1225 | + | require.Equal(t, domain.TabStableID("tab-2"), reconnectHello.PreferredTabID) | |||
| 1226 | + | } | |||
| 1227 | + | ||||
| 1228 | + | func TestRemoteReconnectDropsStalePickerTargetAfterDaemonSessionSwitch(t *testing.T) { | |||
| 1229 | + | term := newRunTerminal() | |||
| 1230 | + | defer term.in.unblock() | |||
| 1231 | + | oldLifecycle := domain.SessionLifecycleID{1, 2, 3} | |||
| 1232 | + | newLifecycle := domain.SessionLifecycleID{4, 5, 6} | |||
| 1233 | + | remoteTarget := &domain.RemoteSessionTarget{ | |||
| 1234 | + | Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: oldLifecycle, | |||
| 1235 | + | SessionName: "old", LiveTabID: "tab-1", | |||
| 1236 | + | } | |||
| 1237 | + | welcome := func(name string, lifecycle domain.SessionLifecycleID, token uint64) ports.Frame { | |||
| 1238 | + | return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{ | |||
| 1239 | + | SessionID: name, SessionName: name, ResumeToken: token, Capabilities: ports.CapabilityResume, | |||
| 1240 | + | CommittedIdentity: &ports.CommittedRouteIdentity{ | |||
| 1241 | + | Target: ports.ExactSessionTarget{LifecycleID: lifecycle, SessionName: name}, | |||
| 1242 | + | }, | |||
| 1243 | + | })) | |||
| 1244 | + | } | |||
| 1245 | + | tr1 := &recordingTransport{recvs: []recvItem{ | |||
| 1246 | + | {f: welcome("old", oldLifecycle, 11)}, | |||
| 1247 | + | {f: frameOf(ports.MsgCommittedRouteIdentity, mustMarshalCommittedIdentity(ports.CommittedRouteIdentity{ | |||
| 1248 | + | Target: ports.ExactSessionTarget{LifecycleID: newLifecycle, SessionName: "new"}, | |||
| 1249 | + | }))}, | |||
| 1250 | + | {err: io.EOF}, | |||
| 1251 | + | }} | |||
| 1252 | + | tr2 := &recordingTransport{recvs: []recvItem{ | |||
| 1253 | + | {f: welcome("new", newLifecycle, 22)}, | |||
| 1254 | + | {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}, | |||
| 1255 | + | }} | |||
| 1256 | + | dialer := &sequenceDialer{trs: []ports.Transport{tr1, tr2}} | |||
| 1257 | + | clock := &reconnectTestClock{} | |||
| 1258 | + | result := make(chan error, 1) | |||
| 1259 | + | go func() { | |||
| 1260 | + | result <- runTestClient(context.Background(), testDependencies(dialer, term, clock, nil, nil), client.AttachRequest{ | |||
| 1261 | + | Intent: ports.IntentAttach, SessionName: "old", Remote: true, | |||
| 1262 | + | Origin: ports.RouteOriginDiscovery, OriginKey: "remote", | |||
| 1263 | + | RemoteTarget: remoteTarget, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned, | |||
| 1264 | + | }) | |||
| 1265 | + | }() | |||
| 1266 | + | clock.fireReconnect(t) | |||
| 1267 | + | require.NoError(t, <-result) | |||
| 1268 | + | ||||
| 1269 | + | reconnectHello := helloFromSend(t, tr2) | |||
| 1270 | + | require.Equal(t, ports.IntentResume, reconnectHello.Intent) | |||
| 1271 | + | require.Equal(t, "new", reconnectHello.Name) | |||
| 1272 | + | require.Equal(t, &ports.ExactSessionTarget{LifecycleID: newLifecycle, SessionName: "new"}, reconnectHello.ExactTarget) | |||
| 1273 | + | require.Nil(t, reconnectHello.RemoteTarget) | |||
| 1274 | + | } | |||
| 1275 | + | ||||
| 1122 | func TestAttachRememberRemoteHost(t *testing.T) { | 1276 | func TestAttachRememberRemoteHost(t *testing.T) { | ||
| 1123 | tests := []struct { | 1277 | tests := []struct { | ||
| 1124 | name string | 1278 | name string | ||
M internal/usecase/client/palette_attach_generation_test.go +12 -0
| @@ -252,6 +252,18 @@ func paletteReply(foreground, background string, slot uint8, color string) strin | |||
| 252 | 252 | return "\x1b]10;" + foreground + "\a\x1b]11;" + background + "\a\x1b]4;" + string(rune('0'+slot)) + ";" + color + "\a\x1b[?2031;1$y" | |
| 253 | 253 | } | |
| 254 | 254 | ||
| 255 | + | func TestAttachHandshakeDeadlineStopsBeforeRuntimeWithoutInitialOutput(t *testing.T) { | |
| 256 | + | h := startAttachPaletteHarness(&terminalThemeState{}) | |
| 257 | + | select { | |
| 258 | + | case <-h.handshakeTimer.stopped: | |
| 259 | + | // Welcome and synchronous client publications commit the handshake. A | |
| 260 | + | // quiet runtime is allowed to produce no Output frame. | |
| 261 | + | case <-time.After(2 * time.Second): | |
| 262 | + | t.Fatal("handshake deadline still owned the long-lived runtime transport") | |
| 263 | + | } | |
| 264 | + | h.detach(t) | |
| 265 | + | } | |
| 266 | + | ||
| 255 | 267 | func TestAttachReconnectClearsRetainedPaletteInOnePublication(t *testing.T) { | |
| 256 | 268 | t.Setenv("COLORTERM", "truecolor") | |
| 257 | 269 | state := &terminalThemeState{} | |
| @@ -252,6 +252,18 @@ func paletteReply(foreground, background string, slot uint8, color string) strin | |||||
| 252 | return "\x1b]10;" + foreground + "\a\x1b]11;" + background + "\a\x1b]4;" + string(rune('0'+slot)) + ";" + color + "\a\x1b[?2031;1$y" | 252 | return "\x1b]10;" + foreground + "\a\x1b]11;" + background + "\a\x1b]4;" + string(rune('0'+slot)) + ";" + color + "\a\x1b[?2031;1$y" | ||
| 253 | } | 253 | } | ||
| 254 | 254 | ||||
| 255 | + | func TestAttachHandshakeDeadlineStopsBeforeRuntimeWithoutInitialOutput(t *testing.T) { | |||
| 256 | + | h := startAttachPaletteHarness(&terminalThemeState{}) | |||
| 257 | + | select { | |||
| 258 | + | case <-h.handshakeTimer.stopped: | |||
| 259 | + | // Welcome and synchronous client publications commit the handshake. A | |||
| 260 | + | // quiet runtime is allowed to produce no Output frame. | |||
| 261 | + | case <-time.After(2 * time.Second): | |||
| 262 | + | t.Fatal("handshake deadline still owned the long-lived runtime transport") | |||
| 263 | + | } | |||
| 264 | + | h.detach(t) | |||
| 265 | + | } | |||
| 266 | + | ||||
| 255 | func TestAttachReconnectClearsRetainedPaletteInOnePublication(t *testing.T) { | 267 | func TestAttachReconnectClearsRetainedPaletteInOnePublication(t *testing.T) { | ||
| 256 | t.Setenv("COLORTERM", "truecolor") | 268 | t.Setenv("COLORTERM", "truecolor") | ||
| 257 | state := &terminalThemeState{} | 269 | state := &terminalThemeState{} | ||
M internal/usecase/client/routes.go +40 -0
| @@ -105,6 +105,14 @@ func cloneCommittedIdentity(identity *ports.CommittedRouteIdentity) *ports.Commi | |||
| 105 | 105 | return &clone | |
| 106 | 106 | } | |
| 107 | 107 | ||
| 108 | + | func cloneRoutePosition(position *ports.RoutePosition) *ports.RoutePosition { | |
| 109 | + | if position == nil { | |
| 110 | + | return nil | |
| 111 | + | } | |
| 112 | + | clone := *position | |
| 113 | + | return &clone | |
| 114 | + | } | |
| 115 | + | ||
| 108 | 116 | func routeCandidateForAttach(request AttachRequest, identity ports.CommittedRouteIdentity, dialer ports.Dialer, resumeToken uint64) routeCandidate { | |
| 109 | 117 | origin := normalizeRouteOrigin(request.Origin, request.Remote) | |
| 110 | 118 | originKey := normalizeRouteOriginKey(request.OriginKey, origin) | |
| @@ -124,6 +132,10 @@ func routeCandidateForAttach(request AttachRequest, identity ports.CommittedRout | |||
| 124 | 132 | request.OriginKey = originKey | |
| 125 | 133 | request.SessionName = identity.Target.SessionName | |
| 126 | 134 | request.ExactTarget = &identity.Target | |
| 135 | + | // Discovery targets are point-in-time attach authority, including a live | |
| 136 | + | // tab selector. Once Welcome commits an exact route, mutable tab memory is | |
| 137 | + | // carried independently by PreferredTabID. | |
| 138 | + | request.RemoteTarget = nil | |
| 127 | 139 | return routeCandidate{ | |
| 128 | 140 | origin: origin, | |
| 129 | 141 | originKey: originKey, | |
| @@ -146,6 +158,9 @@ func routeCandidateForCommittedIdentity(active routeRecord, identity ports.Commi | |||
| 146 | 158 | request.SessionName = identity.Target.SessionName | |
| 147 | 159 | request.ExactTarget = &identity.Target | |
| 148 | 160 | request.RemoteTarget = nil | |
| 161 | + | if active.target != identity.Target { | |
| 162 | + | request.PreferredTabID = "" | |
| 163 | + | } | |
| 149 | 164 | return routeCandidate{ | |
| 150 | 165 | origin: active.origin, | |
| 151 | 166 | originKey: active.originKey, | |
| @@ -338,6 +353,31 @@ func (l *routeLedger) commitCommittedIdentity(identity ports.CommittedRouteIdent | |||
| 338 | 353 | return l.commitLocked(candidate) | |
| 339 | 354 | } | |
| 340 | 355 | ||
| 356 | + | // updateRoutePosition remembers a cursor only for the currently active exact | |
| 357 | + | // route. Delayed frames from a prior transition are valid wire messages but do | |
| 358 | + | // not mutate another route. | |
| 359 | + | func (l *routeLedger) updateRoutePosition(position ports.RoutePosition) error { | |
| 360 | + | if err := position.Validate(); err != nil { | |
| 361 | + | return err | |
| 362 | + | } | |
| 363 | + | l.mu.Lock() | |
| 364 | + | defer l.mu.Unlock() | |
| 365 | + | activeIndex := l.indexByIdentityLocked(l.active) | |
| 366 | + | if activeIndex < 0 || l.entries[activeIndex].target != position.Target { | |
| 367 | + | return nil | |
| 368 | + | } | |
| 369 | + | entry := &l.entries[activeIndex] | |
| 370 | + | if entry.request.PreferredTabID == position.ActiveTabID { | |
| 371 | + | return nil | |
| 372 | + | } | |
| 373 | + | // The tab cursor is mutable payload on an otherwise unchanged route. | |
| 374 | + | // Rotating the route generation here would invalidate navigation actions | |
| 375 | + | // captured immediately before a paint published the new cursor. | |
| 376 | + | entry.request.PreferredTabID = position.ActiveTabID | |
| 377 | + | entry.request.RemoteTarget = nil | |
| 378 | + | return nil | |
| 379 | + | } | |
| 380 | + | ||
| 341 | 381 | func (l *routeLedger) commitLocked(candidate routeCandidate) (routeIdentity, error) { | |
| 342 | 382 | identity, err := l.nextIdentityLocked() | |
| 343 | 383 | if err != nil { | |
| @@ -105,6 +105,14 @@ func cloneCommittedIdentity(identity *ports.CommittedRouteIdentity) *ports.Commi | |||||
| 105 | return &clone | 105 | return &clone | ||
| 106 | } | 106 | } | ||
| 107 | 107 | ||||
| 108 | + | func cloneRoutePosition(position *ports.RoutePosition) *ports.RoutePosition { | |||
| 109 | + | if position == nil { | |||
| 110 | + | return nil | |||
| 111 | + | } | |||
| 112 | + | clone := *position | |||
| 113 | + | return &clone | |||
| 114 | + | } | |||
| 115 | + | ||||
| 108 | func routeCandidateForAttach(request AttachRequest, identity ports.CommittedRouteIdentity, dialer ports.Dialer, resumeToken uint64) routeCandidate { | 116 | func routeCandidateForAttach(request AttachRequest, identity ports.CommittedRouteIdentity, dialer ports.Dialer, resumeToken uint64) routeCandidate { | ||
| 109 | origin := normalizeRouteOrigin(request.Origin, request.Remote) | 117 | origin := normalizeRouteOrigin(request.Origin, request.Remote) | ||
| 110 | originKey := normalizeRouteOriginKey(request.OriginKey, origin) | 118 | originKey := normalizeRouteOriginKey(request.OriginKey, origin) | ||
| @@ -124,6 +132,10 @@ func routeCandidateForAttach(request AttachRequest, identity ports.CommittedRout | |||||
| 124 | request.OriginKey = originKey | 132 | request.OriginKey = originKey | ||
| 125 | request.SessionName = identity.Target.SessionName | 133 | request.SessionName = identity.Target.SessionName | ||
| 126 | request.ExactTarget = &identity.Target | 134 | request.ExactTarget = &identity.Target | ||
| 135 | + | // Discovery targets are point-in-time attach authority, including a live | |||
| 136 | + | // tab selector. Once Welcome commits an exact route, mutable tab memory is | |||
| 137 | + | // carried independently by PreferredTabID. | |||
| 138 | + | request.RemoteTarget = nil | |||
| 127 | return routeCandidate{ | 139 | return routeCandidate{ | ||
| 128 | origin: origin, | 140 | origin: origin, | ||
| 129 | originKey: originKey, | 141 | originKey: originKey, | ||
| @@ -146,6 +158,9 @@ func routeCandidateForCommittedIdentity(active routeRecord, identity ports.Commi | |||||
| 146 | request.SessionName = identity.Target.SessionName | 158 | request.SessionName = identity.Target.SessionName | ||
| 147 | request.ExactTarget = &identity.Target | 159 | request.ExactTarget = &identity.Target | ||
| 148 | request.RemoteTarget = nil | 160 | request.RemoteTarget = nil | ||
| 161 | + | if active.target != identity.Target { | |||
| 162 | + | request.PreferredTabID = "" | |||
| 163 | + | } | |||
| 149 | return routeCandidate{ | 164 | return routeCandidate{ | ||
| 150 | origin: active.origin, | 165 | origin: active.origin, | ||
| 151 | originKey: active.originKey, | 166 | originKey: active.originKey, | ||
| @@ -338,6 +353,31 @@ func (l *routeLedger) commitCommittedIdentity(identity ports.CommittedRouteIdent | |||||
| 338 | return l.commitLocked(candidate) | 353 | return l.commitLocked(candidate) | ||
| 339 | } | 354 | } | ||
| 340 | 355 | ||||
| 356 | + | // updateRoutePosition remembers a cursor only for the currently active exact | |||
| 357 | + | // route. Delayed frames from a prior transition are valid wire messages but do | |||
| 358 | + | // not mutate another route. | |||
| 359 | + | func (l *routeLedger) updateRoutePosition(position ports.RoutePosition) error { | |||
| 360 | + | if err := position.Validate(); err != nil { | |||
| 361 | + | return err | |||
| 362 | + | } | |||
| 363 | + | l.mu.Lock() | |||
| 364 | + | defer l.mu.Unlock() | |||
| 365 | + | activeIndex := l.indexByIdentityLocked(l.active) | |||
| 366 | + | if activeIndex < 0 || l.entries[activeIndex].target != position.Target { | |||
| 367 | + | return nil | |||
| 368 | + | } | |||
| 369 | + | entry := &l.entries[activeIndex] | |||
| 370 | + | if entry.request.PreferredTabID == position.ActiveTabID { | |||
| 371 | + | return nil | |||
| 372 | + | } | |||
| 373 | + | // The tab cursor is mutable payload on an otherwise unchanged route. | |||
| 374 | + | // Rotating the route generation here would invalidate navigation actions | |||
| 375 | + | // captured immediately before a paint published the new cursor. | |||
| 376 | + | entry.request.PreferredTabID = position.ActiveTabID | |||
| 377 | + | entry.request.RemoteTarget = nil | |||
| 378 | + | return nil | |||
| 379 | + | } | |||
| 380 | + | ||||
| 341 | func (l *routeLedger) commitLocked(candidate routeCandidate) (routeIdentity, error) { | 381 | func (l *routeLedger) commitLocked(candidate routeCandidate) (routeIdentity, error) { | ||
| 342 | identity, err := l.nextIdentityLocked() | 382 | identity, err := l.nextIdentityLocked() | ||
| 343 | if err != nil { | 383 | if err != nil { | ||
M internal/usecase/client/routes_test.go +28 -0
| @@ -69,6 +69,34 @@ func TestRouteLedgerBoundsAndImmutableSnapshot(t *testing.T) { | |||
| 69 | 69 | require.Equal(t, old, ledger.previousRef(), "the old active route becomes the previous route") | |
| 70 | 70 | } | |
| 71 | 71 | ||
| 72 | + | func TestRouteLedgerRemembersTabsIndependentlyPerExactRoute(t *testing.T) { | |
| 73 | + | ledger := newRouteLedger() | |
| 74 | + | first := routeTestCandidate(1, ports.RouteOriginLocal) | |
| 75 | + | firstIdentity, err := ledger.commit(first) | |
| 76 | + | require.NoError(t, err) | |
| 77 | + | firstRef := ledger.activeRef() | |
| 78 | + | firstSnapshotGeneration := ledger.snapshot().Generation | |
| 79 | + | require.NoError(t, ledger.updateRoutePosition(ports.RoutePosition{Target: first.target, ActiveTabID: "tab-first"})) | |
| 80 | + | require.Equal(t, firstRef, ledger.activeRef(), "tab memory must not rotate route identity") | |
| 81 | + | require.Equal(t, firstSnapshotGeneration, ledger.snapshot().Generation, "tab memory must not stale navigation snapshots") | |
| 82 | + | ||
| 83 | + | second := routeTestCandidate(2, ports.RouteOriginRemote) | |
| 84 | + | secondIdentity, err := ledger.commit(second) | |
| 85 | + | require.NoError(t, err) | |
| 86 | + | secondRef := ledger.activeRef() | |
| 87 | + | secondSnapshotGeneration := ledger.snapshot().Generation | |
| 88 | + | require.NoError(t, ledger.updateRoutePosition(ports.RoutePosition{Target: second.target, ActiveTabID: "tab-second"})) | |
| 89 | + | require.Equal(t, secondRef, ledger.activeRef(), "tab memory must not rotate route identity") | |
| 90 | + | require.Equal(t, secondSnapshotGeneration, ledger.snapshot().Generation, "tab memory must not stale navigation snapshots") | |
| 91 | + | ||
| 92 | + | firstRecord, ok := ledger.lookup(firstIdentity.wire()) | |
| 93 | + | require.True(t, ok) | |
| 94 | + | require.Equal(t, domain.TabStableID("tab-first"), firstRecord.request.PreferredTabID) | |
| 95 | + | secondRecord, ok := ledger.lookup(secondIdentity.wire()) | |
| 96 | + | require.True(t, ok) | |
| 97 | + | require.Equal(t, domain.TabStableID("tab-second"), secondRecord.request.PreferredTabID) | |
| 98 | + | } | |
| 99 | + | ||
| 72 | 100 | func TestCommittedIdentityDoesNotReassignHomeRoute(t *testing.T) { | |
| 73 | 101 | ledger := newRouteLedger() | |
| 74 | 102 | home := routeTestCandidate(1, ports.RouteOriginLocal) | |
| @@ -69,6 +69,34 @@ func TestRouteLedgerBoundsAndImmutableSnapshot(t *testing.T) { | |||||
| 69 | require.Equal(t, old, ledger.previousRef(), "the old active route becomes the previous route") | 69 | require.Equal(t, old, ledger.previousRef(), "the old active route becomes the previous route") | ||
| 70 | } | 70 | } | ||
| 71 | 71 | ||||
| 72 | + | func TestRouteLedgerRemembersTabsIndependentlyPerExactRoute(t *testing.T) { | |||
| 73 | + | ledger := newRouteLedger() | |||
| 74 | + | first := routeTestCandidate(1, ports.RouteOriginLocal) | |||
| 75 | + | firstIdentity, err := ledger.commit(first) | |||
| 76 | + | require.NoError(t, err) | |||
| 77 | + | firstRef := ledger.activeRef() | |||
| 78 | + | firstSnapshotGeneration := ledger.snapshot().Generation | |||
| 79 | + | require.NoError(t, ledger.updateRoutePosition(ports.RoutePosition{Target: first.target, ActiveTabID: "tab-first"})) | |||
| 80 | + | require.Equal(t, firstRef, ledger.activeRef(), "tab memory must not rotate route identity") | |||
| 81 | + | require.Equal(t, firstSnapshotGeneration, ledger.snapshot().Generation, "tab memory must not stale navigation snapshots") | |||
| 82 | + | ||||
| 83 | + | second := routeTestCandidate(2, ports.RouteOriginRemote) | |||
| 84 | + | secondIdentity, err := ledger.commit(second) | |||
| 85 | + | require.NoError(t, err) | |||
| 86 | + | secondRef := ledger.activeRef() | |||
| 87 | + | secondSnapshotGeneration := ledger.snapshot().Generation | |||
| 88 | + | require.NoError(t, ledger.updateRoutePosition(ports.RoutePosition{Target: second.target, ActiveTabID: "tab-second"})) | |||
| 89 | + | require.Equal(t, secondRef, ledger.activeRef(), "tab memory must not rotate route identity") | |||
| 90 | + | require.Equal(t, secondSnapshotGeneration, ledger.snapshot().Generation, "tab memory must not stale navigation snapshots") | |||
| 91 | + | ||||
| 92 | + | firstRecord, ok := ledger.lookup(firstIdentity.wire()) | |||
| 93 | + | require.True(t, ok) | |||
| 94 | + | require.Equal(t, domain.TabStableID("tab-first"), firstRecord.request.PreferredTabID) | |||
| 95 | + | secondRecord, ok := ledger.lookup(secondIdentity.wire()) | |||
| 96 | + | require.True(t, ok) | |||
| 97 | + | require.Equal(t, domain.TabStableID("tab-second"), secondRecord.request.PreferredTabID) | |||
| 98 | + | } | |||
| 99 | + | ||||
| 72 | func TestCommittedIdentityDoesNotReassignHomeRoute(t *testing.T) { | 100 | func TestCommittedIdentityDoesNotReassignHomeRoute(t *testing.T) { | ||
| 73 | ledger := newRouteLedger() | 101 | ledger := newRouteLedger() | ||
| 74 | home := routeTestCandidate(1, ports.RouteOriginLocal) | 102 | home := routeTestCandidate(1, ports.RouteOriginLocal) | ||
M internal/usecase/daemon/attachment_parking_test.go +2 -0
| @@ -137,6 +137,7 @@ func TestSuccessfulResumeRejectsOldCredentialAfterWelcome(t *testing.T) { | |||
| 137 | 137 | sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport) | |
| 138 | 138 | require.NoError(t, err) | |
| 139 | 139 | oldToken := ac.resumeToken | |
| 140 | + | ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1}) | |
| 140 | 141 | d.clientGone(sess, ac, oldTransport, false) | |
| 141 | 142 | ||
| 142 | 143 | resumedTransport := &closeTrackingTransport{} | |
| @@ -150,6 +151,7 @@ func TestSuccessfulResumeRejectsOldCredentialAfterWelcome(t *testing.T) { | |||
| 150 | 151 | require.False(t, oldRetained) | |
| 151 | 152 | require.NotNil(t, newParked) | |
| 152 | 153 | require.NotEmpty(t, resumedTransport.Sends()) | |
| 154 | + | require.Equal(t, ports.MsgWelcome, resumedTransport.Sends()[0].Type, "Welcome must remain the first server frame after Hello") | |
| 153 | 155 | } | |
| 154 | 156 | ||
| 155 | 157 | func TestFailedResumeHandshakeKeepsParkedCredential(t *testing.T) { | |
| @@ -137,6 +137,7 @@ func TestSuccessfulResumeRejectsOldCredentialAfterWelcome(t *testing.T) { | |||||
| 137 | sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport) | 137 | sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport) | ||
| 138 | require.NoError(t, err) | 138 | require.NoError(t, err) | ||
| 139 | oldToken := ac.resumeToken | 139 | oldToken := ac.resumeToken | ||
| 140 | + | ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1}) | |||
| 140 | d.clientGone(sess, ac, oldTransport, false) | 141 | d.clientGone(sess, ac, oldTransport, false) | ||
| 141 | 142 | ||||
| 142 | resumedTransport := &closeTrackingTransport{} | 143 | resumedTransport := &closeTrackingTransport{} | ||
| @@ -150,6 +151,7 @@ func TestSuccessfulResumeRejectsOldCredentialAfterWelcome(t *testing.T) { | |||||
| 150 | require.False(t, oldRetained) | 151 | require.False(t, oldRetained) | ||
| 151 | require.NotNil(t, newParked) | 152 | require.NotNil(t, newParked) | ||
| 152 | require.NotEmpty(t, resumedTransport.Sends()) | 153 | require.NotEmpty(t, resumedTransport.Sends()) | ||
| 154 | + | require.Equal(t, ports.MsgWelcome, resumedTransport.Sends()[0].Type, "Welcome must remain the first server frame after Hello") | |||
| 153 | } | 155 | } | ||
| 154 | 156 | ||||
| 155 | func TestFailedResumeHandshakeKeepsParkedCredential(t *testing.T) { | 157 | func TestFailedResumeHandshakeKeepsParkedCredential(t *testing.T) { | ||
M internal/usecase/daemon/attachment_transition.go +4 -1
| @@ -177,7 +177,10 @@ func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachme | |||
| 177 | 177 | if err != nil { | |
| 178 | 178 | return result, err | |
| 179 | 179 | } | |
| 180 | - | if result.published.ac != nil && result.published.ac.routeSnapshotCopy().Generation != 0 { | |
| 180 | + | // A not-ready publication is still inside the Hello handshake. Welcome | |
| 181 | + | // carries the committed identity for those attachments and must remain the | |
| 182 | + | // first server frame. Ready transitions notify an already-running client. | |
| 183 | + | if req.ready && result.published.ac != nil && result.published.ac.routeSnapshotCopy().Generation != 0 { | |
| 181 | 184 | identityErr := errAttachmentTransition | |
| 182 | 185 | if effect, admitted := result.published.ac.beginAttachmentEffect(result.published); admitted { | |
| 183 | 186 | identityErr = d.sendCommittedRouteIdentityForAttachment(effect.connectionToken()) | |
| @@ -177,7 +177,10 @@ func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachme | |||||
| 177 | if err != nil { | 177 | if err != nil { | ||
| 178 | return result, err | 178 | return result, err | ||
| 179 | } | 179 | } | ||
| 180 | - | if result.published.ac != nil && result.published.ac.routeSnapshotCopy().Generation != 0 { | 180 | + | // A not-ready publication is still inside the Hello handshake. Welcome |
| 181 | + | // carries the committed identity for those attachments and must remain the | |||
| 182 | + | // first server frame. Ready transitions notify an already-running client. | |||
| 183 | + | if req.ready && result.published.ac != nil && result.published.ac.routeSnapshotCopy().Generation != 0 { | |||
| 181 | identityErr := errAttachmentTransition | 184 | identityErr := errAttachmentTransition | ||
| 182 | if effect, admitted := result.published.ac.beginAttachmentEffect(result.published); admitted { | 185 | if effect, admitted := result.published.ac.beginAttachmentEffect(result.published); admitted { | ||
| 183 | identityErr = d.sendCommittedRouteIdentityForAttachment(effect.connectionToken()) | 186 | identityErr = d.sendCommittedRouteIdentityForAttachment(effect.connectionToken()) | ||
M internal/usecase/daemon/client.go +15 -13
| @@ -85,19 +85,20 @@ type attachedClient struct { | |||
| 85 | 85 | // view is attachment-local navigation state. It is never inferred from a | |
| 86 | 86 | // session-wide active tab, so multiple attachments can observe different | |
| 87 | 87 | // tabs and panes without changing shared session ownership. | |
| 88 | - | viewMu sync.Mutex | |
| 89 | - | view attachmentView | |
| 90 | - | sess Guarded[*session] | |
| 91 | - | mouseScan mouse.Scanner | |
| 92 | - | themeMu sync.Mutex | |
| 93 | - | clientTheme themeui.Theme | |
| 94 | - | appliedTheme appliedTheme | |
| 95 | - | lastCursor cursorOut | |
| 96 | - | renderStages renderStageHooks // optional render and handoff observability hooks | |
| 97 | - | linkMu sync.Mutex | |
| 98 | - | sendMu sync.Mutex | |
| 99 | - | routeMu sync.RWMutex | |
| 100 | - | routeSnapshot ports.RecentRouteSnapshot | |
| 88 | + | viewMu sync.Mutex | |
| 89 | + | view attachmentView | |
| 90 | + | sess Guarded[*session] | |
| 91 | + | mouseScan mouse.Scanner | |
| 92 | + | themeMu sync.Mutex | |
| 93 | + | clientTheme themeui.Theme | |
| 94 | + | appliedTheme appliedTheme | |
| 95 | + | lastCursor cursorOut | |
| 96 | + | lastRoutePosition ports.RoutePosition | |
| 97 | + | renderStages renderStageHooks // optional render and handoff observability hooks | |
| 98 | + | linkMu sync.Mutex | |
| 99 | + | sendMu sync.Mutex | |
| 100 | + | routeMu sync.RWMutex | |
| 101 | + | routeSnapshot ports.RecentRouteSnapshot | |
| 101 | 102 | // routeCreatedSession marks a session created by this attachment's route. | |
| 102 | 103 | // A handshake that never commits Welcome must tear down that exact empty | |
| 103 | 104 | // session, while an attachment routed to an existing session must not. | |
| @@ -382,6 +383,7 @@ func (ac *attachedClient) rebaseOutput() { | |||
| 382 | 383 | if ac.output != nil { | |
| 383 | 384 | ac.output.rebase() | |
| 384 | 385 | } | |
| 386 | + | ac.lastRoutePosition = ports.RoutePosition{} | |
| 385 | 387 | } | |
| 386 | 388 | ||
| 387 | 389 | var errTransportReplaced = errors.New("client transport was replaced") | |
| @@ -85,19 +85,20 @@ type attachedClient struct { | |||||
| 85 | // view is attachment-local navigation state. It is never inferred from a | 85 | // view is attachment-local navigation state. It is never inferred from a | ||
| 86 | // session-wide active tab, so multiple attachments can observe different | 86 | // session-wide active tab, so multiple attachments can observe different | ||
| 87 | // tabs and panes without changing shared session ownership. | 87 | // tabs and panes without changing shared session ownership. | ||
| 88 | - | viewMu sync.Mutex | 88 | + | viewMu sync.Mutex |
| 89 | - | view attachmentView | 89 | + | view attachmentView |
| 90 | - | sess Guarded[*session] | 90 | + | sess Guarded[*session] |
| 91 | - | mouseScan mouse.Scanner | 91 | + | mouseScan mouse.Scanner |
| 92 | - | themeMu sync.Mutex | 92 | + | themeMu sync.Mutex |
| 93 | - | clientTheme themeui.Theme | 93 | + | clientTheme themeui.Theme |
| 94 | - | appliedTheme appliedTheme | 94 | + | appliedTheme appliedTheme |
| 95 | - | lastCursor cursorOut | 95 | + | lastCursor cursorOut |
| 96 | - | renderStages renderStageHooks // optional render and handoff observability hooks | 96 | + | lastRoutePosition ports.RoutePosition |
| 97 | - | linkMu sync.Mutex | 97 | + | renderStages renderStageHooks // optional render and handoff observability hooks |
| 98 | - | sendMu sync.Mutex | 98 | + | linkMu sync.Mutex |
| 99 | - | routeMu sync.RWMutex | 99 | + | sendMu sync.Mutex |
| 100 | - | routeSnapshot ports.RecentRouteSnapshot | 100 | + | routeMu sync.RWMutex |
| 101 | + | routeSnapshot ports.RecentRouteSnapshot | |||
| 101 | // routeCreatedSession marks a session created by this attachment's route. | 102 | // routeCreatedSession marks a session created by this attachment's route. | ||
| 102 | // A handshake that never commits Welcome must tear down that exact empty | 103 | // A handshake that never commits Welcome must tear down that exact empty | ||
| 103 | // session, while an attachment routed to an existing session must not. | 104 | // session, while an attachment routed to an existing session must not. | ||
| @@ -382,6 +383,7 @@ func (ac *attachedClient) rebaseOutput() { | |||||
| 382 | if ac.output != nil { | 383 | if ac.output != nil { | ||
| 383 | ac.output.rebase() | 384 | ac.output.rebase() | ||
| 384 | } | 385 | } | ||
| 386 | + | ac.lastRoutePosition = ports.RoutePosition{} | |||
| 385 | } | 387 | } | ||
| 386 | 388 | ||||
| 387 | var errTransportReplaced = errors.New("client transport was replaced") | 389 | var errTransportReplaced = errors.New("client transport was replaced") | ||
M internal/usecase/daemon/daemon.go +20 -0
| @@ -1266,6 +1266,12 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size, | |||
| 1266 | 1266 | return nil, &protoErr{ports.ErrNoSuchTarget, "remote tab no longer exists"} | |
| 1267 | 1267 | } | |
| 1268 | 1268 | } | |
| 1269 | + | if h.PreferredTabID != "" { | |
| 1270 | + | // Route memory is a best-effort attachment cursor, not attach authority. | |
| 1271 | + | // It overrides point-in-time picker tab metadata when still present and | |
| 1272 | + | // otherwise falls back to the session's normal first-tab repair. | |
| 1273 | + | initialTabIndex = preferredTabIndex(sess, h.PreferredTabID) | |
| 1274 | + | } | |
| 1269 | 1275 | // Session state is the sole source for future PTY children. Update it before | |
| 1270 | 1276 | // publishing the attachment; existing PTYs keep their original environment. | |
| 1271 | 1277 | // A picker handoff deliberately leaves the daemon-owned environment and CWD | |
| @@ -1307,6 +1313,20 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size, | |||
| 1307 | 1313 | return ac, nil | |
| 1308 | 1314 | } | |
| 1309 | 1315 | ||
| 1316 | + | func preferredTabIndex(sess *session, preferred domain.TabStableID) int { | |
| 1317 | + | if sess == nil || preferred == "" { | |
| 1318 | + | return -1 | |
| 1319 | + | } | |
| 1320 | + | sess.mu.Lock() | |
| 1321 | + | defer sess.mu.Unlock() | |
| 1322 | + | for i, candidate := range sess.tabs { | |
| 1323 | + | if candidate != nil && domain.TabStableID(candidate.stableID) == preferred { | |
| 1324 | + | return i | |
| 1325 | + | } | |
| 1326 | + | } | |
| 1327 | + | return -1 | |
| 1328 | + | } | |
| 1329 | + | ||
| 1310 | 1330 | func (d *Daemon) waitForTargetRestore(ctx context.Context, name string) error { | |
| 1311 | 1331 | d.mu.Lock() | |
| 1312 | 1332 | var ( | |
| @@ -1266,6 +1266,12 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size, | |||||
| 1266 | return nil, &protoErr{ports.ErrNoSuchTarget, "remote tab no longer exists"} | 1266 | return nil, &protoErr{ports.ErrNoSuchTarget, "remote tab no longer exists"} | ||
| 1267 | } | 1267 | } | ||
| 1268 | } | 1268 | } | ||
| 1269 | + | if h.PreferredTabID != "" { | |||
| 1270 | + | // Route memory is a best-effort attachment cursor, not attach authority. | |||
| 1271 | + | // It overrides point-in-time picker tab metadata when still present and | |||
| 1272 | + | // otherwise falls back to the session's normal first-tab repair. | |||
| 1273 | + | initialTabIndex = preferredTabIndex(sess, h.PreferredTabID) | |||
| 1274 | + | } | |||
| 1269 | // Session state is the sole source for future PTY children. Update it before | 1275 | // Session state is the sole source for future PTY children. Update it before | ||
| 1270 | // publishing the attachment; existing PTYs keep their original environment. | 1276 | // publishing the attachment; existing PTYs keep their original environment. | ||
| 1271 | // A picker handoff deliberately leaves the daemon-owned environment and CWD | 1277 | // A picker handoff deliberately leaves the daemon-owned environment and CWD | ||
| @@ -1307,6 +1313,20 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size, | |||||
| 1307 | return ac, nil | 1313 | return ac, nil | ||
| 1308 | } | 1314 | } | ||
| 1309 | 1315 | ||||
| 1316 | + | func preferredTabIndex(sess *session, preferred domain.TabStableID) int { | |||
| 1317 | + | if sess == nil || preferred == "" { | |||
| 1318 | + | return -1 | |||
| 1319 | + | } | |||
| 1320 | + | sess.mu.Lock() | |||
| 1321 | + | defer sess.mu.Unlock() | |||
| 1322 | + | for i, candidate := range sess.tabs { | |||
| 1323 | + | if candidate != nil && domain.TabStableID(candidate.stableID) == preferred { | |||
| 1324 | + | return i | |||
| 1325 | + | } | |||
| 1326 | + | } | |||
| 1327 | + | return -1 | |||
| 1328 | + | } | |||
| 1329 | + | ||||
| 1310 | func (d *Daemon) waitForTargetRestore(ctx context.Context, name string) error { | 1330 | func (d *Daemon) waitForTargetRestore(ctx context.Context, name string) error { | ||
| 1311 | d.mu.Lock() | 1331 | d.mu.Lock() | ||
| 1312 | var ( | 1332 | var ( | ||
M internal/usecase/daemon/palette.go +57 -5
| @@ -42,7 +42,7 @@ func (d *Daemon) enterPalette(sess *session, ac *attachedClient) { | |||
| 42 | 42 | // consults its own session history for recent-route commands. | |
| 43 | 43 | routeSnapshot := ac.routeSnapshotCopy() | |
| 44 | 44 | commands := d.paletteCommands() | |
| 45 | - | results := d.paletteResults(sess, commands) | |
| 45 | + | results := d.paletteResults(sess, commands, routeSnapshot, ac.remoteOrigin) | |
| 46 | 46 | ac.overlays.paletteMu.Lock() | |
| 47 | 47 | ac.overlays.paletteGeneration++ | |
| 48 | 48 | ac.overlays.palette = palette.New(results) | |
| @@ -56,7 +56,7 @@ func (d *Daemon) enterPalette(sess *session, ac *attachedClient) { | |||
| 56 | 56 | ||
| 57 | 57 | // paletteResults captures eligible named sessions before paletteMu so the | |
| 58 | 58 | // palette has an immutable lifecycle target without violating lock ordering. | |
| 59 | - | func (d *Daemon) paletteResults(current *session, commands []command.Command) []palette.Result { | |
| 59 | + | func (d *Daemon) paletteResults(current *session, commands []command.Command, routeSnapshot ports.RecentRouteSnapshot, currentRemoteOrigin string) []palette.Result { | |
| 60 | 60 | d.mu.Lock() | |
| 61 | 61 | sessions := d.sessionsSnapshotLocked() | |
| 62 | 62 | stopped := make([]stoppedSession, 0, len(d.stopped)) | |
| @@ -67,7 +67,7 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command) [] | |||
| 67 | 67 | } | |
| 68 | 68 | d.mu.Unlock() | |
| 69 | 69 | ||
| 70 | - | results := make([]palette.Result, 0, len(commands)+len(sessions)+len(stopped)) | |
| 70 | + | results := make([]palette.Result, 0, len(commands)+len(sessions)+len(stopped)+len(routeSnapshot.Entries)) | |
| 71 | 71 | for _, cmd := range commands { | |
| 72 | 72 | results = append(results, palette.NewCommandResult(cmd)) | |
| 73 | 73 | } | |
| @@ -88,8 +88,36 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command) [] | |||
| 88 | 88 | }) | |
| 89 | 89 | sort.Slice(stopped, func(i, j int) bool { return stopped[i].name < stopped[j].name }) | |
| 90 | 90 | results = append(results, active...) | |
| 91 | + | localNames := make(map[string]struct{}, len(active)+len(stopped)) | |
| 92 | + | for _, candidate := range active { | |
| 93 | + | name, _ := candidate.SessionName() | |
| 94 | + | localNames[name] = struct{}{} | |
| 95 | + | } | |
| 91 | 96 | for _, candidate := range stopped { | |
| 92 | 97 | results = append(results, palette.NewStoppedSessionResult(candidate.name, time.Unix(0, candidate.createdAt))) | |
| 98 | + | localNames[candidate.name] = struct{}{} | |
| 99 | + | } | |
| 100 | + | formattedRoutes := formatRecentRouteSnapshot(routeSnapshot) | |
| 101 | + | for i, entry := range routeSnapshot.Entries { | |
| 102 | + | if i >= len(formattedRoutes) { | |
| 103 | + | break | |
| 104 | + | } | |
| 105 | + | label := formattedRoutes[i].name | |
| 106 | + | if label == "" { | |
| 107 | + | continue | |
| 108 | + | } | |
| 109 | + | if _, represented := localNames[entry.Name]; represented { | |
| 110 | + | localRoute := entry.Kind == ports.RouteKindLocal | |
| 111 | + | currentRemoteRoute := entry.Kind == ports.RouteKindRemote && currentRemoteOrigin != "" && entry.HostLabel == currentRemoteOrigin | |
| 112 | + | if localRoute || currentRemoteRoute { | |
| 113 | + | continue | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | results = append(results, palette.NewRecentRouteResult(label, ports.RouteNavigationAction{ | |
| 117 | + | SnapshotGeneration: routeSnapshot.Generation, | |
| 118 | + | Key: entry.Key, | |
| 119 | + | Generation: entry.Generation, | |
| 120 | + | })) | |
| 93 | 121 | } | |
| 94 | 122 | return results | |
| 95 | 123 | } | |
| @@ -166,6 +194,8 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 166 | 194 | var cmd command.Command | |
| 167 | 195 | var sessionTarget palette.Result | |
| 168 | 196 | var hasSessionTarget bool | |
| 197 | + | var routeTarget ports.RouteNavigationAction | |
| 198 | + | var hasRouteTarget bool | |
| 169 | 199 | var args []string | |
| 170 | 200 | var routeSnapshot ports.RecentRouteSnapshot | |
| 171 | 201 | var generation uint64 | |
| @@ -241,6 +271,9 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 241 | 271 | routeSnapshot = ac.overlays.paletteRouteSnapshot | |
| 242 | 272 | routeSnapshot.Entries = append([]ports.RecentRouteEntry(nil), routeSnapshot.Entries...) | |
| 243 | 273 | } | |
| 274 | + | } else if action, ok := selected.RouteNavigationAction(); ok { | |
| 275 | + | routeTarget = action | |
| 276 | + | hasRouteTarget = true | |
| 244 | 277 | } else if _, ok := selected.SessionName(); ok { | |
| 245 | 278 | sessionTarget = selected | |
| 246 | 279 | hasSessionTarget = true | |
| @@ -281,6 +314,22 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 281 | 314 | return | |
| 282 | 315 | } | |
| 283 | 316 | ||
| 317 | + | if hasRouteTarget { | |
| 318 | + | exec := paletteExec{d: d, sess: sess, attachment: entry, ac: ac, effect: effect} | |
| 319 | + | if err := exec.NavigateRecentRoute(routeTarget); err != nil { | |
| 320 | + | if errors.Is(err, errAttachmentTransition) { | |
| 321 | + | return | |
| 322 | + | } | |
| 323 | + | ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable") | |
| 324 | + | d.invalidateRender(entry, ac, true, "palette.go") | |
| 325 | + | return | |
| 326 | + | } | |
| 327 | + | if ac.closeExecutedPalette(generation, rawQuery) { | |
| 328 | + | d.invalidateRender(entry, ac, true, "palette.go") | |
| 329 | + | } | |
| 330 | + | return | |
| 331 | + | } | |
| 332 | + | ||
| 284 | 333 | if hasSessionTarget { | |
| 285 | 334 | createdAt, _ := sessionTarget.SessionCreatedAt() | |
| 286 | 335 | expectedCreatedAt := createdAt.UnixNano() | |
| @@ -654,11 +703,14 @@ func (e paletteExec) JumpRecentSession(rank int) error { | |||
| 654 | 703 | return command.ErrInvalidArguments | |
| 655 | 704 | } | |
| 656 | 705 | entry := e.routeSnapshot.Entries[rank-1] | |
| 657 | - | action := ports.RouteNavigationAction{ | |
| 706 | + | return e.NavigateRecentRoute(ports.RouteNavigationAction{ | |
| 658 | 707 | SnapshotGeneration: e.routeSnapshot.Generation, | |
| 659 | 708 | Key: entry.Key, | |
| 660 | 709 | Generation: entry.Generation, | |
| 661 | - | } | |
| 710 | + | }) | |
| 711 | + | } | |
| 712 | + | ||
| 713 | + | func (e paletteExec) NavigateRecentRoute(action ports.RouteNavigationAction) error { | |
| 662 | 714 | if e.d.beforeRecentSessionHandoff != nil { | |
| 663 | 715 | e.d.beforeRecentSessionHandoff() | |
| 664 | 716 | } | |
| @@ -42,7 +42,7 @@ func (d *Daemon) enterPalette(sess *session, ac *attachedClient) { | |||||
| 42 | // consults its own session history for recent-route commands. | 42 | // consults its own session history for recent-route commands. | ||
| 43 | routeSnapshot := ac.routeSnapshotCopy() | 43 | routeSnapshot := ac.routeSnapshotCopy() | ||
| 44 | commands := d.paletteCommands() | 44 | commands := d.paletteCommands() | ||
| 45 | - | results := d.paletteResults(sess, commands) | 45 | + | results := d.paletteResults(sess, commands, routeSnapshot, ac.remoteOrigin) |
| 46 | ac.overlays.paletteMu.Lock() | 46 | ac.overlays.paletteMu.Lock() | ||
| 47 | ac.overlays.paletteGeneration++ | 47 | ac.overlays.paletteGeneration++ | ||
| 48 | ac.overlays.palette = palette.New(results) | 48 | ac.overlays.palette = palette.New(results) | ||
| @@ -56,7 +56,7 @@ func (d *Daemon) enterPalette(sess *session, ac *attachedClient) { | |||||
| 56 | 56 | ||||
| 57 | // paletteResults captures eligible named sessions before paletteMu so the | 57 | // paletteResults captures eligible named sessions before paletteMu so the | ||
| 58 | // palette has an immutable lifecycle target without violating lock ordering. | 58 | // palette has an immutable lifecycle target without violating lock ordering. | ||
| 59 | - | func (d *Daemon) paletteResults(current *session, commands []command.Command) []palette.Result { | 59 | + | func (d *Daemon) paletteResults(current *session, commands []command.Command, routeSnapshot ports.RecentRouteSnapshot, currentRemoteOrigin string) []palette.Result { |
| 60 | d.mu.Lock() | 60 | d.mu.Lock() | ||
| 61 | sessions := d.sessionsSnapshotLocked() | 61 | sessions := d.sessionsSnapshotLocked() | ||
| 62 | stopped := make([]stoppedSession, 0, len(d.stopped)) | 62 | stopped := make([]stoppedSession, 0, len(d.stopped)) | ||
| @@ -67,7 +67,7 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command) [] | |||||
| 67 | } | 67 | } | ||
| 68 | d.mu.Unlock() | 68 | d.mu.Unlock() | ||
| 69 | 69 | ||||
| 70 | - | results := make([]palette.Result, 0, len(commands)+len(sessions)+len(stopped)) | 70 | + | results := make([]palette.Result, 0, len(commands)+len(sessions)+len(stopped)+len(routeSnapshot.Entries)) |
| 71 | for _, cmd := range commands { | 71 | for _, cmd := range commands { | ||
| 72 | results = append(results, palette.NewCommandResult(cmd)) | 72 | results = append(results, palette.NewCommandResult(cmd)) | ||
| 73 | } | 73 | } | ||
| @@ -88,8 +88,36 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command) [] | |||||
| 88 | }) | 88 | }) | ||
| 89 | sort.Slice(stopped, func(i, j int) bool { return stopped[i].name < stopped[j].name }) | 89 | sort.Slice(stopped, func(i, j int) bool { return stopped[i].name < stopped[j].name }) | ||
| 90 | results = append(results, active...) | 90 | results = append(results, active...) | ||
| 91 | + | localNames := make(map[string]struct{}, len(active)+len(stopped)) | |||
| 92 | + | for _, candidate := range active { | |||
| 93 | + | name, _ := candidate.SessionName() | |||
| 94 | + | localNames[name] = struct{}{} | |||
| 95 | + | } | |||
| 91 | for _, candidate := range stopped { | 96 | for _, candidate := range stopped { | ||
| 92 | results = append(results, palette.NewStoppedSessionResult(candidate.name, time.Unix(0, candidate.createdAt))) | 97 | results = append(results, palette.NewStoppedSessionResult(candidate.name, time.Unix(0, candidate.createdAt))) | ||
| 98 | + | localNames[candidate.name] = struct{}{} | |||
| 99 | + | } | |||
| 100 | + | formattedRoutes := formatRecentRouteSnapshot(routeSnapshot) | |||
| 101 | + | for i, entry := range routeSnapshot.Entries { | |||
| 102 | + | if i >= len(formattedRoutes) { | |||
| 103 | + | break | |||
| 104 | + | } | |||
| 105 | + | label := formattedRoutes[i].name | |||
| 106 | + | if label == "" { | |||
| 107 | + | continue | |||
| 108 | + | } | |||
| 109 | + | if _, represented := localNames[entry.Name]; represented { | |||
| 110 | + | localRoute := entry.Kind == ports.RouteKindLocal | |||
| 111 | + | currentRemoteRoute := entry.Kind == ports.RouteKindRemote && currentRemoteOrigin != "" && entry.HostLabel == currentRemoteOrigin | |||
| 112 | + | if localRoute || currentRemoteRoute { | |||
| 113 | + | continue | |||
| 114 | + | } | |||
| 115 | + | } | |||
| 116 | + | results = append(results, palette.NewRecentRouteResult(label, ports.RouteNavigationAction{ | |||
| 117 | + | SnapshotGeneration: routeSnapshot.Generation, | |||
| 118 | + | Key: entry.Key, | |||
| 119 | + | Generation: entry.Generation, | |||
| 120 | + | })) | |||
| 93 | } | 121 | } | ||
| 94 | return results | 122 | return results | ||
| 95 | } | 123 | } | ||
| @@ -166,6 +194,8 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||||
| 166 | var cmd command.Command | 194 | var cmd command.Command | ||
| 167 | var sessionTarget palette.Result | 195 | var sessionTarget palette.Result | ||
| 168 | var hasSessionTarget bool | 196 | var hasSessionTarget bool | ||
| 197 | + | var routeTarget ports.RouteNavigationAction | |||
| 198 | + | var hasRouteTarget bool | |||
| 169 | var args []string | 199 | var args []string | ||
| 170 | var routeSnapshot ports.RecentRouteSnapshot | 200 | var routeSnapshot ports.RecentRouteSnapshot | ||
| 171 | var generation uint64 | 201 | var generation uint64 | ||
| @@ -241,6 +271,9 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||||
| 241 | routeSnapshot = ac.overlays.paletteRouteSnapshot | 271 | routeSnapshot = ac.overlays.paletteRouteSnapshot | ||
| 242 | routeSnapshot.Entries = append([]ports.RecentRouteEntry(nil), routeSnapshot.Entries...) | 272 | routeSnapshot.Entries = append([]ports.RecentRouteEntry(nil), routeSnapshot.Entries...) | ||
| 243 | } | 273 | } | ||
| 274 | + | } else if action, ok := selected.RouteNavigationAction(); ok { | |||
| 275 | + | routeTarget = action | |||
| 276 | + | hasRouteTarget = true | |||
| 244 | } else if _, ok := selected.SessionName(); ok { | 277 | } else if _, ok := selected.SessionName(); ok { | ||
| 245 | sessionTarget = selected | 278 | sessionTarget = selected | ||
| 246 | hasSessionTarget = true | 279 | hasSessionTarget = true | ||
| @@ -281,6 +314,22 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||||
| 281 | return | 314 | return | ||
| 282 | } | 315 | } | ||
| 283 | 316 | ||||
| 317 | + | if hasRouteTarget { | |||
| 318 | + | exec := paletteExec{d: d, sess: sess, attachment: entry, ac: ac, effect: effect} | |||
| 319 | + | if err := exec.NavigateRecentRoute(routeTarget); err != nil { | |||
| 320 | + | if errors.Is(err, errAttachmentTransition) { | |||
| 321 | + | return | |||
| 322 | + | } | |||
| 323 | + | ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable") | |||
| 324 | + | d.invalidateRender(entry, ac, true, "palette.go") | |||
| 325 | + | return | |||
| 326 | + | } | |||
| 327 | + | if ac.closeExecutedPalette(generation, rawQuery) { | |||
| 328 | + | d.invalidateRender(entry, ac, true, "palette.go") | |||
| 329 | + | } | |||
| 330 | + | return | |||
| 331 | + | } | |||
| 332 | + | ||||
| 284 | if hasSessionTarget { | 333 | if hasSessionTarget { | ||
| 285 | createdAt, _ := sessionTarget.SessionCreatedAt() | 334 | createdAt, _ := sessionTarget.SessionCreatedAt() | ||
| 286 | expectedCreatedAt := createdAt.UnixNano() | 335 | expectedCreatedAt := createdAt.UnixNano() | ||
| @@ -654,11 +703,14 @@ func (e paletteExec) JumpRecentSession(rank int) error { | |||||
| 654 | return command.ErrInvalidArguments | 703 | return command.ErrInvalidArguments | ||
| 655 | } | 704 | } | ||
| 656 | entry := e.routeSnapshot.Entries[rank-1] | 705 | entry := e.routeSnapshot.Entries[rank-1] | ||
| 657 | - | action := ports.RouteNavigationAction{ | 706 | + | return e.NavigateRecentRoute(ports.RouteNavigationAction{ |
| 658 | SnapshotGeneration: e.routeSnapshot.Generation, | 707 | SnapshotGeneration: e.routeSnapshot.Generation, | ||
| 659 | Key: entry.Key, | 708 | Key: entry.Key, | ||
| 660 | Generation: entry.Generation, | 709 | Generation: entry.Generation, | ||
| 661 | - | } | 710 | + | }) |
| 711 | + | } | |||
| 712 | + | ||||
| 713 | + | func (e paletteExec) NavigateRecentRoute(action ports.RouteNavigationAction) error { | |||
| 662 | if e.d.beforeRecentSessionHandoff != nil { | 714 | if e.d.beforeRecentSessionHandoff != nil { | ||
| 663 | e.d.beforeRecentSessionHandoff() | 715 | e.d.beforeRecentSessionHandoff() | ||
| 664 | } | 716 | } | ||
M internal/usecase/daemon/palette_test.go +57 -0
| @@ -419,6 +419,63 @@ func TestPaletteJRSUsesEffectiveOverrideOnly(t *testing.T) { | |||
| 419 | 419 | require.True(t, ac.overlays.paletteActive(), "literal JRS must not execute an overridden jump command") | |
| 420 | 420 | } | |
| 421 | 421 | ||
| 422 | + | func TestPaletteResultsDeduplicateRoutesLocalToCurrentRemoteDaemon(t *testing.T) { | |
| 423 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 424 | + | current := addControlSession(d, "hi", "tab-1", "pane-1") | |
| 425 | + | represented := addControlSession(d, "remote-manual", "tab-2", "pane-2") | |
| 426 | + | represented.ephemeral = false | |
| 427 | + | snapshot := ports.RecentRouteSnapshot{ | |
| 428 | + | Generation: 2, | |
| 429 | + | Active: ports.RouteRef{Key: 1, Generation: 1}, | |
| 430 | + | Entries: []ports.RecentRouteEntry{{ | |
| 431 | + | Key: 2, Generation: 1, Name: "remote-manual", HostLabel: "remote", Kind: ports.RouteKindRemote, | |
| 432 | + | }}, | |
| 433 | + | } | |
| 434 | + | ||
| 435 | + | for _, test := range []struct { | |
| 436 | + | name string | |
| 437 | + | currentOrigin string | |
| 438 | + | want []string | |
| 439 | + | }{ | |
| 440 | + | {name: "remote daemon", currentOrigin: "remote", want: []string{"Switch to session remote-manual"}}, | |
| 441 | + | {name: "home daemon keeps distinct remote", want: []string{"Switch to session remote-manual", "Switch to session remote-manual@remote"}}, | |
| 442 | + | } { | |
| 443 | + | t.Run(test.name, func(t *testing.T) { | |
| 444 | + | results := d.paletteResults(current, nil, snapshot, test.currentOrigin) | |
| 445 | + | var matching []string | |
| 446 | + | for _, result := range results { | |
| 447 | + | if result.DisplayText() == "Switch to session remote-manual" || result.DisplayText() == "Switch to session remote-manual@remote" { | |
| 448 | + | matching = append(matching, result.DisplayText()) | |
| 449 | + | } | |
| 450 | + | } | |
| 451 | + | require.Equal(t, test.want, matching) | |
| 452 | + | }) | |
| 453 | + | } | |
| 454 | + | } | |
| 455 | + | ||
| 456 | + | func TestPaletteFuzzyRemoteRecentRouteSendsExactNavigationAction(t *testing.T) { | |
| 457 | + | d, current, ac, sends, releases := newRecentNavigationTestSessions(t) | |
| 458 | + | defer releaseAll(releases) | |
| 459 | + | ac.setRouteSnapshot(ports.RecentRouteSnapshot{ | |
| 460 | + | Generation: 9, | |
| 461 | + | Active: ports.RouteRef{Key: 1, Generation: 1}, | |
| 462 | + | Entries: []ports.RecentRouteEntry{{ | |
| 463 | + | Key: 8, Generation: 4, Name: "logs", HostLabel: "edge", Kind: ports.RouteKindRemote, | |
| 464 | + | }}, | |
| 465 | + | }) | |
| 466 | + | token := beginRecentRoutePaletteEffect(t, d, current, ac) | |
| 467 | + | ||
| 468 | + | d.handleInputForAttachment(token, []byte("\x1b ")) | |
| 469 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 470 | + | d.handleInputForAttachment(token, []byte("logs@edge\r")) | |
| 471 | + | ||
| 472 | + | actionFrame := awaitFrame(t, sends, ports.MsgNavigateRecentRoute) | |
| 473 | + | action, err := ports.UnmarshalRouteNavigationAction(actionFrame.Payload) | |
| 474 | + | require.NoError(t, err) | |
| 475 | + | require.Equal(t, ports.RouteNavigationAction{SnapshotGeneration: 9, Key: 8, Generation: 4}, action) | |
| 476 | + | require.False(t, ac.overlays.paletteActive()) | |
| 477 | + | } | |
| 478 | + | ||
| 422 | 479 | func TestPaletteFuzzySelectedStaticCommandExecutes(t *testing.T) { | |
| 423 | 480 | d, sess, ac, sends, releases := newManualTabSession(t, 2) | |
| 424 | 481 | defer releases[0]() | |
| @@ -419,6 +419,63 @@ func TestPaletteJRSUsesEffectiveOverrideOnly(t *testing.T) { | |||||
| 419 | require.True(t, ac.overlays.paletteActive(), "literal JRS must not execute an overridden jump command") | 419 | require.True(t, ac.overlays.paletteActive(), "literal JRS must not execute an overridden jump command") | ||
| 420 | } | 420 | } | ||
| 421 | 421 | ||||
| 422 | + | func TestPaletteResultsDeduplicateRoutesLocalToCurrentRemoteDaemon(t *testing.T) { | |||
| 423 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 424 | + | current := addControlSession(d, "hi", "tab-1", "pane-1") | |||
| 425 | + | represented := addControlSession(d, "remote-manual", "tab-2", "pane-2") | |||
| 426 | + | represented.ephemeral = false | |||
| 427 | + | snapshot := ports.RecentRouteSnapshot{ | |||
| 428 | + | Generation: 2, | |||
| 429 | + | Active: ports.RouteRef{Key: 1, Generation: 1}, | |||
| 430 | + | Entries: []ports.RecentRouteEntry{{ | |||
| 431 | + | Key: 2, Generation: 1, Name: "remote-manual", HostLabel: "remote", Kind: ports.RouteKindRemote, | |||
| 432 | + | }}, | |||
| 433 | + | } | |||
| 434 | + | ||||
| 435 | + | for _, test := range []struct { | |||
| 436 | + | name string | |||
| 437 | + | currentOrigin string | |||
| 438 | + | want []string | |||
| 439 | + | }{ | |||
| 440 | + | {name: "remote daemon", currentOrigin: "remote", want: []string{"Switch to session remote-manual"}}, | |||
| 441 | + | {name: "home daemon keeps distinct remote", want: []string{"Switch to session remote-manual", "Switch to session remote-manual@remote"}}, | |||
| 442 | + | } { | |||
| 443 | + | t.Run(test.name, func(t *testing.T) { | |||
| 444 | + | results := d.paletteResults(current, nil, snapshot, test.currentOrigin) | |||
| 445 | + | var matching []string | |||
| 446 | + | for _, result := range results { | |||
| 447 | + | if result.DisplayText() == "Switch to session remote-manual" || result.DisplayText() == "Switch to session remote-manual@remote" { | |||
| 448 | + | matching = append(matching, result.DisplayText()) | |||
| 449 | + | } | |||
| 450 | + | } | |||
| 451 | + | require.Equal(t, test.want, matching) | |||
| 452 | + | }) | |||
| 453 | + | } | |||
| 454 | + | } | |||
| 455 | + | ||||
| 456 | + | func TestPaletteFuzzyRemoteRecentRouteSendsExactNavigationAction(t *testing.T) { | |||
| 457 | + | d, current, ac, sends, releases := newRecentNavigationTestSessions(t) | |||
| 458 | + | defer releaseAll(releases) | |||
| 459 | + | ac.setRouteSnapshot(ports.RecentRouteSnapshot{ | |||
| 460 | + | Generation: 9, | |||
| 461 | + | Active: ports.RouteRef{Key: 1, Generation: 1}, | |||
| 462 | + | Entries: []ports.RecentRouteEntry{{ | |||
| 463 | + | Key: 8, Generation: 4, Name: "logs", HostLabel: "edge", Kind: ports.RouteKindRemote, | |||
| 464 | + | }}, | |||
| 465 | + | }) | |||
| 466 | + | token := beginRecentRoutePaletteEffect(t, d, current, ac) | |||
| 467 | + | ||||
| 468 | + | d.handleInputForAttachment(token, []byte("\x1b ")) | |||
| 469 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 470 | + | d.handleInputForAttachment(token, []byte("logs@edge\r")) | |||
| 471 | + | ||||
| 472 | + | actionFrame := awaitFrame(t, sends, ports.MsgNavigateRecentRoute) | |||
| 473 | + | action, err := ports.UnmarshalRouteNavigationAction(actionFrame.Payload) | |||
| 474 | + | require.NoError(t, err) | |||
| 475 | + | require.Equal(t, ports.RouteNavigationAction{SnapshotGeneration: 9, Key: 8, Generation: 4}, action) | |||
| 476 | + | require.False(t, ac.overlays.paletteActive()) | |||
| 477 | + | } | |||
| 478 | + | ||||
| 422 | func TestPaletteFuzzySelectedStaticCommandExecutes(t *testing.T) { | 479 | func TestPaletteFuzzySelectedStaticCommandExecutes(t *testing.T) { | ||
| 423 | d, sess, ac, sends, releases := newManualTabSession(t, 2) | 480 | d, sess, ac, sends, releases := newManualTabSession(t, 2) | ||
| 424 | defer releases[0]() | 481 | defer releases[0]() | ||
M internal/usecase/daemon/picker.go +7 -0
| @@ -179,8 +179,15 @@ func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.Session | |||
| 179 | 179 | for _, item := range live { | |
| 180 | 180 | views = append(views, item.view.pickerView()) | |
| 181 | 181 | } | |
| 182 | + | seenRemoteLifecycles := make(map[domain.SessionLifecycleID]struct{}, catalogRows) | |
| 182 | 183 | for _, host := range catalog { | |
| 183 | 184 | for _, session := range host.entry.Sessions { | |
| 185 | + | if session.LifecycleID != (domain.SessionLifecycleID{}) { | |
| 186 | + | if _, duplicate := seenRemoteLifecycles[session.LifecycleID]; duplicate { | |
| 187 | + | continue | |
| 188 | + | } | |
| 189 | + | seenRemoteLifecycles[session.LifecycleID] = struct{}{} | |
| 190 | + | } | |
| 184 | 191 | key := domain.RemoteSessionKey{Host: host.entry.Host, Name: session.Name} | |
| 185 | 192 | if key.Validate() != nil { | |
| 186 | 193 | continue | |
| @@ -179,8 +179,15 @@ func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.Session | |||||
| 179 | for _, item := range live { | 179 | for _, item := range live { | ||
| 180 | views = append(views, item.view.pickerView()) | 180 | views = append(views, item.view.pickerView()) | ||
| 181 | } | 181 | } | ||
| 182 | + | seenRemoteLifecycles := make(map[domain.SessionLifecycleID]struct{}, catalogRows) | |||
| 182 | for _, host := range catalog { | 183 | for _, host := range catalog { | ||
| 183 | for _, session := range host.entry.Sessions { | 184 | for _, session := range host.entry.Sessions { | ||
| 185 | + | if session.LifecycleID != (domain.SessionLifecycleID{}) { | |||
| 186 | + | if _, duplicate := seenRemoteLifecycles[session.LifecycleID]; duplicate { | |||
| 187 | + | continue | |||
| 188 | + | } | |||
| 189 | + | seenRemoteLifecycles[session.LifecycleID] = struct{}{} | |||
| 190 | + | } | |||
| 184 | key := domain.RemoteSessionKey{Host: host.entry.Host, Name: session.Name} | 191 | key := domain.RemoteSessionKey{Host: host.entry.Host, Name: session.Name} | ||
| 185 | if key.Validate() != nil { | 192 | if key.Validate() != nil { | ||
| 186 | continue | 193 | continue | ||
M internal/usecase/daemon/remote_picker_test.go +28 -0
| @@ -82,6 +82,34 @@ func TestRemotePickerStoppedRowsUseCanonicalStateAndSafeSelection(t *testing.T) | |||
| 82 | 82 | } | |
| 83 | 83 | } | |
| 84 | 84 | ||
| 85 | + | func TestRemotePickerDeduplicatesSameLifecycleReachedThroughHostAliases(t *testing.T) { | |
| 86 | + | store := &remoteRefreshHostStore{hosts: []string{"remote", "vev@remote"}} | |
| 87 | + | d := newRemotePickerDaemon(store) | |
| 88 | + | lifecycle := remoteLifecycleForTest() | |
| 89 | + | session := ports.RemoteCatalogSession{ | |
| 90 | + | LifecycleID: lifecycle, Name: "work", State: "up", | |
| 91 | + | Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}}, | |
| 92 | + | } | |
| 93 | + | d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{ | |
| 94 | + | {Host: "remote", FetchedAt: time.Unix(100, 0), Sessions: []ports.RemoteCatalogSession{session}}, | |
| 95 | + | {Host: "vev@remote", FetchedAt: time.Unix(100, 0), Sessions: []ports.RemoteCatalogSession{session}}, | |
| 96 | + | }) | |
| 97 | + | d.remoteCatalog.mu.Lock() | |
| 98 | + | d.remoteCatalog.status["remote"] = remoteHostFresh | |
| 99 | + | d.remoteCatalog.status["vev@remote"] = remoteHostFresh | |
| 100 | + | d.remoteCatalog.mu.Unlock() | |
| 101 | + | ||
| 102 | + | views, _ := d.pickerViews(nil, nil) | |
| 103 | + | var targets []*domain.RemoteSessionTarget | |
| 104 | + | for _, view := range views { | |
| 105 | + | if view.RemoteTarget != nil { | |
| 106 | + | targets = append(targets, view.RemoteTarget) | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | require.Len(t, targets, 1) | |
| 110 | + | require.Equal(t, "remote", targets[0].Endpoint, "first pinned alias must remain the attach endpoint") | |
| 111 | + | } | |
| 112 | + | ||
| 85 | 113 | func TestRemotePickerCatalogExpiresAtAttachTTL(t *testing.T) { | |
| 86 | 114 | now := time.Unix(100, 0) | |
| 87 | 115 | clock := &remotePickerClock{now: now} | |
| @@ -82,6 +82,34 @@ func TestRemotePickerStoppedRowsUseCanonicalStateAndSafeSelection(t *testing.T) | |||||
| 82 | } | 82 | } | ||
| 83 | } | 83 | } | ||
| 84 | 84 | ||||
| 85 | + | func TestRemotePickerDeduplicatesSameLifecycleReachedThroughHostAliases(t *testing.T) { | |||
| 86 | + | store := &remoteRefreshHostStore{hosts: []string{"remote", "vev@remote"}} | |||
| 87 | + | d := newRemotePickerDaemon(store) | |||
| 88 | + | lifecycle := remoteLifecycleForTest() | |||
| 89 | + | session := ports.RemoteCatalogSession{ | |||
| 90 | + | LifecycleID: lifecycle, Name: "work", State: "up", | |||
| 91 | + | Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}}, | |||
| 92 | + | } | |||
| 93 | + | d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{ | |||
| 94 | + | {Host: "remote", FetchedAt: time.Unix(100, 0), Sessions: []ports.RemoteCatalogSession{session}}, | |||
| 95 | + | {Host: "vev@remote", FetchedAt: time.Unix(100, 0), Sessions: []ports.RemoteCatalogSession{session}}, | |||
| 96 | + | }) | |||
| 97 | + | d.remoteCatalog.mu.Lock() | |||
| 98 | + | d.remoteCatalog.status["remote"] = remoteHostFresh | |||
| 99 | + | d.remoteCatalog.status["vev@remote"] = remoteHostFresh | |||
| 100 | + | d.remoteCatalog.mu.Unlock() | |||
| 101 | + | ||||
| 102 | + | views, _ := d.pickerViews(nil, nil) | |||
| 103 | + | var targets []*domain.RemoteSessionTarget | |||
| 104 | + | for _, view := range views { | |||
| 105 | + | if view.RemoteTarget != nil { | |||
| 106 | + | targets = append(targets, view.RemoteTarget) | |||
| 107 | + | } | |||
| 108 | + | } | |||
| 109 | + | require.Len(t, targets, 1) | |||
| 110 | + | require.Equal(t, "remote", targets[0].Endpoint, "first pinned alias must remain the attach endpoint") | |||
| 111 | + | } | |||
| 112 | + | ||||
| 85 | func TestRemotePickerCatalogExpiresAtAttachTTL(t *testing.T) { | 113 | func TestRemotePickerCatalogExpiresAtAttachTTL(t *testing.T) { | ||
| 86 | now := time.Unix(100, 0) | 114 | now := time.Unix(100, 0) | ||
| 87 | clock := &remotePickerClock{now: now} | 115 | clock := &remotePickerClock{now: now} | ||
M internal/usecase/daemon/render_coordinator_test.go +10 -4
| @@ -268,10 +268,16 @@ func awaitLatestCoordinatorTimer(t *testing.T, clk *coordinatorMockClock) *coord | |||
| 268 | 268 | ||
| 269 | 269 | func requireNoCoordinatorOutputFrame(t *testing.T, sends chan ports.Frame) { | |
| 270 | 270 | t.Helper() | |
| 271 | - | select { | |
| 272 | - | case frame := <-sends: | |
| 273 | - | t.Fatalf("unexpected output frame: %+v", frame) | |
| 274 | - | default: | |
| 271 | + | for { | |
| 272 | + | select { | |
| 273 | + | case frame := <-sends: | |
| 274 | + | if frame.Type == ports.MsgRoutePosition { | |
| 275 | + | continue | |
| 276 | + | } | |
| 277 | + | t.Fatalf("unexpected output frame: %+v", frame) | |
| 278 | + | default: | |
| 279 | + | return | |
| 280 | + | } | |
| 275 | 281 | } | |
| 276 | 282 | } | |
| 277 | 283 | ||
| @@ -268,10 +268,16 @@ func awaitLatestCoordinatorTimer(t *testing.T, clk *coordinatorMockClock) *coord | |||||
| 268 | 268 | ||||
| 269 | func requireNoCoordinatorOutputFrame(t *testing.T, sends chan ports.Frame) { | 269 | func requireNoCoordinatorOutputFrame(t *testing.T, sends chan ports.Frame) { | ||
| 270 | t.Helper() | 270 | t.Helper() | ||
| 271 | - | select { | 271 | + | for { |
| 272 | - | case frame := <-sends: | 272 | + | select { |
| 273 | - | t.Fatalf("unexpected output frame: %+v", frame) | 273 | + | case frame := <-sends: |
| 274 | - | default: | 274 | + | if frame.Type == ports.MsgRoutePosition { |
| 275 | + | continue | |||
| 276 | + | } | |||
| 277 | + | t.Fatalf("unexpected output frame: %+v", frame) | |||
| 278 | + | default: | |||
| 279 | + | return | |||
| 280 | + | } | |||
| 275 | } | 281 | } | ||
| 276 | } | 282 | } | ||
| 277 | 283 | ||||
M internal/usecase/daemon/render_pipeline.go +44 -0
| @@ -448,6 +448,13 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe | |||
| 448 | 448 | } | |
| 449 | 449 | entry.core().mu.Lock() | |
| 450 | 450 | _, owned := entry.core().attachments[ac] | |
| 451 | + | position := ports.RoutePosition{ | |
| 452 | + | Target: ports.ExactSessionTarget{ | |
| 453 | + | LifecycleID: entry.core().incarnation, | |
| 454 | + | SessionName: entry.core().name, | |
| 455 | + | }, | |
| 456 | + | ActiveTabID: state.view.tabID, | |
| 457 | + | } | |
| 451 | 458 | entry.core().mu.Unlock() | |
| 452 | 459 | if !owned || state.attachment != ac || ac.currentAttachmentSession() != entry { | |
| 453 | 460 | ac.sendMu.Unlock() | |
| @@ -558,6 +565,43 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe | |||
| 558 | 565 | if ac.renderStages.emit != nil { | |
| 559 | 566 | ac.renderStages.emit() | |
| 560 | 567 | } | |
| 568 | + | ||
| 569 | + | if position.ActiveTabID != "" && position != ac.lastRoutePosition { | |
| 570 | + | payload, positionMarshalErr := ports.MarshalRoutePosition(position) | |
| 571 | + | if positionMarshalErr != nil { | |
| 572 | + | d.log.Error("marshal route position", "err", positionMarshalErr) | |
| 573 | + | } else { | |
| 574 | + | sendTransport := ac.transportSnapshot() | |
| 575 | + | sendTr = sendTransport.transport | |
| 576 | + | interruptible := false | |
| 577 | + | if marks.attachmentEffect != nil { | |
| 578 | + | interruptible = marks.attachmentEffect.beginTransportSend(sendTransport) | |
| 579 | + | if !interruptible { | |
| 580 | + | sendErr = errAttachmentTransition | |
| 581 | + | } | |
| 582 | + | } | |
| 583 | + | if sendErr == nil { | |
| 584 | + | if sendTr == nil { | |
| 585 | + | sendErr = errors.New("client transport is nil") | |
| 586 | + | } else { | |
| 587 | + | send := sendTr.Send | |
| 588 | + | if async, ok := sendTr.(ports.AsyncTransport); ok { | |
| 589 | + | send = async.SendAsync | |
| 590 | + | } | |
| 591 | + | sendErr = send(ports.Frame{Type: ports.MsgRoutePosition, Payload: payload}) | |
| 592 | + | } | |
| 593 | + | } | |
| 594 | + | if marks.attachmentEffect != nil && interruptible { | |
| 595 | + | if sendErr != nil { | |
| 596 | + | marks.attachmentEffect.reportTransportFailure(sendTransport) | |
| 597 | + | } | |
| 598 | + | marks.attachmentEffect.endTransportSend() | |
| 599 | + | } | |
| 600 | + | if sendErr == nil { | |
| 601 | + | ac.lastRoutePosition = position | |
| 602 | + | } | |
| 603 | + | } | |
| 604 | + | } | |
| 561 | 605 | } | |
| 562 | 606 | ac.sendMu.Unlock() | |
| 563 | 607 | if sendErr != nil { | |
| @@ -448,6 +448,13 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe | |||||
| 448 | } | 448 | } | ||
| 449 | entry.core().mu.Lock() | 449 | entry.core().mu.Lock() | ||
| 450 | _, owned := entry.core().attachments[ac] | 450 | _, owned := entry.core().attachments[ac] | ||
| 451 | + | position := ports.RoutePosition{ | |||
| 452 | + | Target: ports.ExactSessionTarget{ | |||
| 453 | + | LifecycleID: entry.core().incarnation, | |||
| 454 | + | SessionName: entry.core().name, | |||
| 455 | + | }, | |||
| 456 | + | ActiveTabID: state.view.tabID, | |||
| 457 | + | } | |||
| 451 | entry.core().mu.Unlock() | 458 | entry.core().mu.Unlock() | ||
| 452 | if !owned || state.attachment != ac || ac.currentAttachmentSession() != entry { | 459 | if !owned || state.attachment != ac || ac.currentAttachmentSession() != entry { | ||
| 453 | ac.sendMu.Unlock() | 460 | ac.sendMu.Unlock() | ||
| @@ -558,6 +565,43 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe | |||||
| 558 | if ac.renderStages.emit != nil { | 565 | if ac.renderStages.emit != nil { | ||
| 559 | ac.renderStages.emit() | 566 | ac.renderStages.emit() | ||
| 560 | } | 567 | } | ||
| 568 | + | ||||
| 569 | + | if position.ActiveTabID != "" && position != ac.lastRoutePosition { | |||
| 570 | + | payload, positionMarshalErr := ports.MarshalRoutePosition(position) | |||
| 571 | + | if positionMarshalErr != nil { | |||
| 572 | + | d.log.Error("marshal route position", "err", positionMarshalErr) | |||
| 573 | + | } else { | |||
| 574 | + | sendTransport := ac.transportSnapshot() | |||
| 575 | + | sendTr = sendTransport.transport | |||
| 576 | + | interruptible := false | |||
| 577 | + | if marks.attachmentEffect != nil { | |||
| 578 | + | interruptible = marks.attachmentEffect.beginTransportSend(sendTransport) | |||
| 579 | + | if !interruptible { | |||
| 580 | + | sendErr = errAttachmentTransition | |||
| 581 | + | } | |||
| 582 | + | } | |||
| 583 | + | if sendErr == nil { | |||
| 584 | + | if sendTr == nil { | |||
| 585 | + | sendErr = errors.New("client transport is nil") | |||
| 586 | + | } else { | |||
| 587 | + | send := sendTr.Send | |||
| 588 | + | if async, ok := sendTr.(ports.AsyncTransport); ok { | |||
| 589 | + | send = async.SendAsync | |||
| 590 | + | } | |||
| 591 | + | sendErr = send(ports.Frame{Type: ports.MsgRoutePosition, Payload: payload}) | |||
| 592 | + | } | |||
| 593 | + | } | |||
| 594 | + | if marks.attachmentEffect != nil && interruptible { | |||
| 595 | + | if sendErr != nil { | |||
| 596 | + | marks.attachmentEffect.reportTransportFailure(sendTransport) | |||
| 597 | + | } | |||
| 598 | + | marks.attachmentEffect.endTransportSend() | |||
| 599 | + | } | |||
| 600 | + | if sendErr == nil { | |||
| 601 | + | ac.lastRoutePosition = position | |||
| 602 | + | } | |||
| 603 | + | } | |||
| 604 | + | } | |||
| 561 | } | 605 | } | ||
| 562 | ac.sendMu.Unlock() | 606 | ac.sendMu.Unlock() | ||
| 563 | if sendErr != nil { | 607 | if sendErr != nil { | ||
M internal/usecase/daemon/resume.go +3 -0
| @@ -641,6 +641,7 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain | |||
| 641 | 641 | } | |
| 642 | 642 | sess.terminal = terminalEnv{TrueColor: h.TrueColor} | |
| 643 | 643 | sess.mu.Unlock() | |
| 644 | + | preferredTabIndex := preferredTabIndex(sess, h.PreferredTabID) | |
| 644 | 645 | // Resume preparation used sendMu -> d.mu, but freeze/drain must run with | |
| 645 | 646 | // neither held. Reacquire them before returning to preserve this helper's | |
| 646 | 647 | // locked-caller contract. | |
| @@ -651,6 +652,8 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain | |||
| 651 | 652 | next: ac, | |
| 652 | 653 | ||
| 653 | 654 | expectedTransport: ac.transportSnapshot(), | |
| 655 | + | activateTargetTab: preferredTabIndex >= 0, | |
| 656 | + | targetTabIndex: preferredTabIndex, | |
| 654 | 657 | ready: false, | |
| 655 | 658 | }) | |
| 656 | 659 | ac.sendMu.Lock() | |
| @@ -641,6 +641,7 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain | |||||
| 641 | } | 641 | } | ||
| 642 | sess.terminal = terminalEnv{TrueColor: h.TrueColor} | 642 | sess.terminal = terminalEnv{TrueColor: h.TrueColor} | ||
| 643 | sess.mu.Unlock() | 643 | sess.mu.Unlock() | ||
| 644 | + | preferredTabIndex := preferredTabIndex(sess, h.PreferredTabID) | |||
| 644 | // Resume preparation used sendMu -> d.mu, but freeze/drain must run with | 645 | // Resume preparation used sendMu -> d.mu, but freeze/drain must run with | ||
| 645 | // neither held. Reacquire them before returning to preserve this helper's | 646 | // neither held. Reacquire them before returning to preserve this helper's | ||
| 646 | // locked-caller contract. | 647 | // locked-caller contract. | ||
| @@ -651,6 +652,8 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain | |||||
| 651 | next: ac, | 652 | next: ac, | ||
| 652 | 653 | ||||
| 653 | expectedTransport: ac.transportSnapshot(), | 654 | expectedTransport: ac.transportSnapshot(), | ||
| 655 | + | activateTargetTab: preferredTabIndex >= 0, | |||
| 656 | + | targetTabIndex: preferredTabIndex, | |||
| 654 | ready: false, | 657 | ready: false, | ||
| 655 | }) | 658 | }) | ||
| 656 | ac.sendMu.Lock() | 659 | ac.sendMu.Lock() | ||
M internal/usecase/daemon/resume_test.go +3 -3
| @@ -1184,7 +1184,7 @@ func TestResumeRebasesFullOutputWindowBeforeFirstPaint(t *testing.T) { | |||
| 1184 | 1184 | require.Same(t, ac, resumedAC) | |
| 1185 | 1185 | d.paint(resumedSess, resumedAC, true, nil) | |
| 1186 | 1186 | ||
| 1187 | - | sends := newTr.Sends() | |
| 1187 | + | sends := testFramesOfType(newTr.Sends(), ports.MsgOutput) | |
| 1188 | 1188 | require.Len(t, sends, 1) | |
| 1189 | 1189 | first, err := ports.UnmarshalOutput(sends[0].Payload) | |
| 1190 | 1190 | require.NoError(t, err) | |
| @@ -1194,7 +1194,7 @@ func TestResumeRebasesFullOutputWindowBeforeFirstPaint(t *testing.T) { | |||
| 1194 | 1194 | ||
| 1195 | 1195 | resumedSess.tabs[0].focusedPane().screen.Write([]byte("A")) | |
| 1196 | 1196 | d.paint(resumedSess, resumedAC, false, nil) | |
| 1197 | - | sends = newTr.Sends() | |
| 1197 | + | sends = testFramesOfType(newTr.Sends(), ports.MsgOutput) | |
| 1198 | 1198 | require.Len(t, sends, 2) | |
| 1199 | 1199 | second, err := ports.UnmarshalOutput(sends[1].Payload) | |
| 1200 | 1200 | require.NoError(t, err) | |
| @@ -1244,7 +1244,7 @@ func TestParkingReleasesPaneCapturesBeforeHeadlessCloseAndResume(t *testing.T) { | |||
| 1244 | 1244 | require.True(t, resumedSess.renderCoordinator().markAttachmentReady(resumedSess.renderCoordinator().attachmentLease(resumedAC))) | |
| 1245 | 1245 | d.firstPaint(resumedSess, resumedAC) | |
| 1246 | 1246 | ||
| 1247 | - | sends := newTransport.Sends() | |
| 1247 | + | sends := testFramesOfType(newTransport.Sends(), ports.MsgOutput) | |
| 1248 | 1248 | require.Len(t, sends, 1) | |
| 1249 | 1249 | output, err := ports.UnmarshalOutput(sends[0].Payload) | |
| 1250 | 1250 | require.NoError(t, err) | |
| @@ -1184,7 +1184,7 @@ func TestResumeRebasesFullOutputWindowBeforeFirstPaint(t *testing.T) { | |||||
| 1184 | require.Same(t, ac, resumedAC) | 1184 | require.Same(t, ac, resumedAC) | ||
| 1185 | d.paint(resumedSess, resumedAC, true, nil) | 1185 | d.paint(resumedSess, resumedAC, true, nil) | ||
| 1186 | 1186 | ||||
| 1187 | - | sends := newTr.Sends() | 1187 | + | sends := testFramesOfType(newTr.Sends(), ports.MsgOutput) |
| 1188 | require.Len(t, sends, 1) | 1188 | require.Len(t, sends, 1) | ||
| 1189 | first, err := ports.UnmarshalOutput(sends[0].Payload) | 1189 | first, err := ports.UnmarshalOutput(sends[0].Payload) | ||
| 1190 | require.NoError(t, err) | 1190 | require.NoError(t, err) | ||
| @@ -1194,7 +1194,7 @@ func TestResumeRebasesFullOutputWindowBeforeFirstPaint(t *testing.T) { | |||||
| 1194 | 1194 | ||||
| 1195 | resumedSess.tabs[0].focusedPane().screen.Write([]byte("A")) | 1195 | resumedSess.tabs[0].focusedPane().screen.Write([]byte("A")) | ||
| 1196 | d.paint(resumedSess, resumedAC, false, nil) | 1196 | d.paint(resumedSess, resumedAC, false, nil) | ||
| 1197 | - | sends = newTr.Sends() | 1197 | + | sends = testFramesOfType(newTr.Sends(), ports.MsgOutput) |
| 1198 | require.Len(t, sends, 2) | 1198 | require.Len(t, sends, 2) | ||
| 1199 | second, err := ports.UnmarshalOutput(sends[1].Payload) | 1199 | second, err := ports.UnmarshalOutput(sends[1].Payload) | ||
| 1200 | require.NoError(t, err) | 1200 | require.NoError(t, err) | ||
| @@ -1244,7 +1244,7 @@ func TestParkingReleasesPaneCapturesBeforeHeadlessCloseAndResume(t *testing.T) { | |||||
| 1244 | require.True(t, resumedSess.renderCoordinator().markAttachmentReady(resumedSess.renderCoordinator().attachmentLease(resumedAC))) | 1244 | require.True(t, resumedSess.renderCoordinator().markAttachmentReady(resumedSess.renderCoordinator().attachmentLease(resumedAC))) | ||
| 1245 | d.firstPaint(resumedSess, resumedAC) | 1245 | d.firstPaint(resumedSess, resumedAC) | ||
| 1246 | 1246 | ||||
| 1247 | - | sends := newTransport.Sends() | 1247 | + | sends := testFramesOfType(newTransport.Sends(), ports.MsgOutput) |
| 1248 | require.Len(t, sends, 1) | 1248 | require.Len(t, sends, 1) | ||
| 1249 | output, err := ports.UnmarshalOutput(sends[0].Payload) | 1249 | output, err := ports.UnmarshalOutput(sends[0].Payload) | ||
| 1250 | require.NoError(t, err) | 1250 | require.NoError(t, err) | ||
A internal/usecase/daemon/route_position_test.go +110 -0
| @@ -0,0 +1,110 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/internal/domain" | |
| 10 | + | "github.com/bnema/vev/internal/ports" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestRouteAppliesPreferredTabPerAttachment(t *testing.T) { | |
| 14 | + | for _, test := range []struct { | |
| 15 | + | name string | |
| 16 | + | preferred domain.TabStableID | |
| 17 | + | want domain.TabStableID | |
| 18 | + | }{ | |
| 19 | + | {name: "existing preferred tab", preferred: "tab-2", want: "tab-2"}, | |
| 20 | + | {name: "missing preferred tab falls back", preferred: "missing", want: "tab-1"}, | |
| 21 | + | } { | |
| 22 | + | t.Run(test.name, func(t *testing.T) { | |
| 23 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 24 | + | sess := addControlSession(d, "work", "tab-1", "pane-1") | |
| 25 | + | sess.incarnation = domain.SessionLifecycleID{1} | |
| 26 | + | second := newTabWithStableID("tab-2", "pane-2", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |
| 27 | + | sess.mu.Lock() | |
| 28 | + | sess.tabs = append(sess.tabs, second) | |
| 29 | + | sess.mu.Unlock() | |
| 30 | + | tr := &closeTrackingTransport{} | |
| 31 | + | target := ports.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name} | |
| 32 | + | hello := ports.Hello{ | |
| 33 | + | Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: sess.name, | |
| 34 | + | Size: defaultSize, ExactTarget: &target, PreferredTabID: test.preferred, | |
| 35 | + | EnvironmentPolicy: ports.EnvironmentPolicyClientOwned, | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | _, ac, err := d.routeWithContext(context.Background(), hello, tr) | |
| 39 | + | require.NoError(t, err) | |
| 40 | + | sess.repairAttachmentView(ac) | |
| 41 | + | require.Equal(t, test.want, ac.viewSnapshot().tabID) | |
| 42 | + | d.clientGone(sess, ac, tr, false) | |
| 43 | + | }) | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func TestResumeAppliesPreferredTabToParkedAttachment(t *testing.T) { | |
| 48 | + | pty, release := newBlockingPTY(t) | |
| 49 | + | defer release() | |
| 50 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 51 | + | oldTransport := &closeTrackingTransport{} | |
| 52 | + | sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport) | |
| 53 | + | require.NoError(t, err) | |
| 54 | + | second := newTabWithStableID("tab-2", "pane-2", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |
| 55 | + | sess.mu.Lock() | |
| 56 | + | sess.tabs = append(sess.tabs, second) | |
| 57 | + | sess.mu.Unlock() | |
| 58 | + | sess.repairAttachmentView(ac) | |
| 59 | + | token := ac.resumeToken | |
| 60 | + | d.clientGone(sess, ac, oldTransport, false) | |
| 61 | + | ||
| 62 | + | hello := helloResumeCapable(ports.IntentResume, "work", token) | |
| 63 | + | hello.PreferredTabID = "tab-2" | |
| 64 | + | newTransport := &closeTrackingTransport{} | |
| 65 | + | resumedSession, resumed, err := d.route(hello, newTransport) | |
| 66 | + | require.NoError(t, err) | |
| 67 | + | require.Same(t, ac, resumed) | |
| 68 | + | require.Equal(t, domain.TabStableID("tab-2"), resumed.viewSnapshot().tabID) | |
| 69 | + | d.clientGone(resumedSession, resumed, newTransport, false) | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func TestPaintPublishesChangedAttachmentRoutePosition(t *testing.T) { | |
| 73 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 74 | + | sess := addControlSession(d, "work", "tab-1", "pane-1") | |
| 75 | + | sess.incarnation = domain.SessionLifecycleID{1} | |
| 76 | + | second := newTabWithStableID("tab-2", "pane-2", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |
| 77 | + | sess.mu.Lock() | |
| 78 | + | sess.tabs = append(sess.tabs, second) | |
| 79 | + | sess.mu.Unlock() | |
| 80 | + | tr := &closeTrackingTransport{} | |
| 81 | + | target := ports.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name} | |
| 82 | + | hello := ports.Hello{ | |
| 83 | + | Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: sess.name, | |
| 84 | + | Size: defaultSize, ExactTarget: &target, EnvironmentPolicy: ports.EnvironmentPolicyClientOwned, | |
| 85 | + | } | |
| 86 | + | _, ac, err := d.routeWithContext(context.Background(), hello, tr) | |
| 87 | + | require.NoError(t, err) | |
| 88 | + | rc := sess.renderCoordinator() | |
| 89 | + | require.True(t, rc.markAttachmentReady(rc.attachmentLease(ac))) | |
| 90 | + | sess.repairAttachmentView(ac) | |
| 91 | + | ||
| 92 | + | require.True(t, d.firstPaintForTransition(sess.attachmentToken(ac, tr))) | |
| 93 | + | sess.selectAttachmentTab(ac, "tab-2") | |
| 94 | + | d.paint(sess, ac, false, nil) | |
| 95 | + | ||
| 96 | + | var positions []ports.RoutePosition | |
| 97 | + | for _, frame := range tr.Sends() { | |
| 98 | + | if frame.Type != ports.MsgRoutePosition { | |
| 99 | + | continue | |
| 100 | + | } | |
| 101 | + | position, err := ports.UnmarshalRoutePosition(frame.Payload) | |
| 102 | + | require.NoError(t, err) | |
| 103 | + | positions = append(positions, position) | |
| 104 | + | } | |
| 105 | + | require.Equal(t, []ports.RoutePosition{ | |
| 106 | + | {Target: target, ActiveTabID: "tab-1"}, | |
| 107 | + | {Target: target, ActiveTabID: "tab-2"}, | |
| 108 | + | }, positions) | |
| 109 | + | d.clientGone(sess, ac, tr, false) | |
| 110 | + | } | |
| @@ -0,0 +1,110 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/vev/internal/domain" | |||
| 10 | + | "github.com/bnema/vev/internal/ports" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestRouteAppliesPreferredTabPerAttachment(t *testing.T) { | |||
| 14 | + | for _, test := range []struct { | |||
| 15 | + | name string | |||
| 16 | + | preferred domain.TabStableID | |||
| 17 | + | want domain.TabStableID | |||
| 18 | + | }{ | |||
| 19 | + | {name: "existing preferred tab", preferred: "tab-2", want: "tab-2"}, | |||
| 20 | + | {name: "missing preferred tab falls back", preferred: "missing", want: "tab-1"}, | |||
| 21 | + | } { | |||
| 22 | + | t.Run(test.name, func(t *testing.T) { | |||
| 23 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 24 | + | sess := addControlSession(d, "work", "tab-1", "pane-1") | |||
| 25 | + | sess.incarnation = domain.SessionLifecycleID{1} | |||
| 26 | + | second := newTabWithStableID("tab-2", "pane-2", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |||
| 27 | + | sess.mu.Lock() | |||
| 28 | + | sess.tabs = append(sess.tabs, second) | |||
| 29 | + | sess.mu.Unlock() | |||
| 30 | + | tr := &closeTrackingTransport{} | |||
| 31 | + | target := ports.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name} | |||
| 32 | + | hello := ports.Hello{ | |||
| 33 | + | Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: sess.name, | |||
| 34 | + | Size: defaultSize, ExactTarget: &target, PreferredTabID: test.preferred, | |||
| 35 | + | EnvironmentPolicy: ports.EnvironmentPolicyClientOwned, | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | _, ac, err := d.routeWithContext(context.Background(), hello, tr) | |||
| 39 | + | require.NoError(t, err) | |||
| 40 | + | sess.repairAttachmentView(ac) | |||
| 41 | + | require.Equal(t, test.want, ac.viewSnapshot().tabID) | |||
| 42 | + | d.clientGone(sess, ac, tr, false) | |||
| 43 | + | }) | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func TestResumeAppliesPreferredTabToParkedAttachment(t *testing.T) { | |||
| 48 | + | pty, release := newBlockingPTY(t) | |||
| 49 | + | defer release() | |||
| 50 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |||
| 51 | + | oldTransport := &closeTrackingTransport{} | |||
| 52 | + | sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport) | |||
| 53 | + | require.NoError(t, err) | |||
| 54 | + | second := newTabWithStableID("tab-2", "pane-2", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |||
| 55 | + | sess.mu.Lock() | |||
| 56 | + | sess.tabs = append(sess.tabs, second) | |||
| 57 | + | sess.mu.Unlock() | |||
| 58 | + | sess.repairAttachmentView(ac) | |||
| 59 | + | token := ac.resumeToken | |||
| 60 | + | d.clientGone(sess, ac, oldTransport, false) | |||
| 61 | + | ||||
| 62 | + | hello := helloResumeCapable(ports.IntentResume, "work", token) | |||
| 63 | + | hello.PreferredTabID = "tab-2" | |||
| 64 | + | newTransport := &closeTrackingTransport{} | |||
| 65 | + | resumedSession, resumed, err := d.route(hello, newTransport) | |||
| 66 | + | require.NoError(t, err) | |||
| 67 | + | require.Same(t, ac, resumed) | |||
| 68 | + | require.Equal(t, domain.TabStableID("tab-2"), resumed.viewSnapshot().tabID) | |||
| 69 | + | d.clientGone(resumedSession, resumed, newTransport, false) | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | func TestPaintPublishesChangedAttachmentRoutePosition(t *testing.T) { | |||
| 73 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 74 | + | sess := addControlSession(d, "work", "tab-1", "pane-1") | |||
| 75 | + | sess.incarnation = domain.SessionLifecycleID{1} | |||
| 76 | + | second := newTabWithStableID("tab-2", "pane-2", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |||
| 77 | + | sess.mu.Lock() | |||
| 78 | + | sess.tabs = append(sess.tabs, second) | |||
| 79 | + | sess.mu.Unlock() | |||
| 80 | + | tr := &closeTrackingTransport{} | |||
| 81 | + | target := ports.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name} | |||
| 82 | + | hello := ports.Hello{ | |||
| 83 | + | Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: sess.name, | |||
| 84 | + | Size: defaultSize, ExactTarget: &target, EnvironmentPolicy: ports.EnvironmentPolicyClientOwned, | |||
| 85 | + | } | |||
| 86 | + | _, ac, err := d.routeWithContext(context.Background(), hello, tr) | |||
| 87 | + | require.NoError(t, err) | |||
| 88 | + | rc := sess.renderCoordinator() | |||
| 89 | + | require.True(t, rc.markAttachmentReady(rc.attachmentLease(ac))) | |||
| 90 | + | sess.repairAttachmentView(ac) | |||
| 91 | + | ||||
| 92 | + | require.True(t, d.firstPaintForTransition(sess.attachmentToken(ac, tr))) | |||
| 93 | + | sess.selectAttachmentTab(ac, "tab-2") | |||
| 94 | + | d.paint(sess, ac, false, nil) | |||
| 95 | + | ||||
| 96 | + | var positions []ports.RoutePosition | |||
| 97 | + | for _, frame := range tr.Sends() { | |||
| 98 | + | if frame.Type != ports.MsgRoutePosition { | |||
| 99 | + | continue | |||
| 100 | + | } | |||
| 101 | + | position, err := ports.UnmarshalRoutePosition(frame.Payload) | |||
| 102 | + | require.NoError(t, err) | |||
| 103 | + | positions = append(positions, position) | |||
| 104 | + | } | |||
| 105 | + | require.Equal(t, []ports.RoutePosition{ | |||
| 106 | + | {Target: target, ActiveTabID: "tab-1"}, | |||
| 107 | + | {Target: target, ActiveTabID: "tab-2"}, | |||
| 108 | + | }, positions) | |||
| 109 | + | d.clientGone(sess, ac, tr, false) | |||
| 110 | + | } | |||
M internal/usecase/daemon/status.go +1 -1
| @@ -196,7 +196,7 @@ func (s *session) statusSegmentsFor(ac *attachedClient, includeTerminalTitle boo | |||
| 196 | 196 | name += "*" | |
| 197 | 197 | } | |
| 198 | 198 | if ac != nil && ac.remoteOrigin != "" { | |
| 199 | - | name += " at " + ac.remoteOrigin | |
| 199 | + | name += "@" + ac.remoteOrigin | |
| 200 | 200 | } | |
| 201 | 201 | snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))} | |
| 202 | 202 | activeIndex := 0 | |
| @@ -196,7 +196,7 @@ func (s *session) statusSegmentsFor(ac *attachedClient, includeTerminalTitle boo | |||||
| 196 | name += "*" | 196 | name += "*" | ||
| 197 | } | 197 | } | ||
| 198 | if ac != nil && ac.remoteOrigin != "" { | 198 | if ac != nil && ac.remoteOrigin != "" { | ||
| 199 | - | name += " at " + ac.remoteOrigin | 199 | + | name += "@" + ac.remoteOrigin |
| 200 | } | 200 | } | ||
| 201 | snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))} | 201 | snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))} | ||
| 202 | activeIndex := 0 | 202 | activeIndex := 0 | ||
M internal/usecase/daemon/status_test.go +1 -1
| @@ -115,7 +115,7 @@ func TestStatusSegmentsIncludesAttachmentRemoteOrigin(t *testing.T) { | |||
| 115 | 115 | remoteOrigin string | |
| 116 | 116 | wantAttachmentBar string | |
| 117 | 117 | }{ | |
| 118 | - | {name: "remote origin", remoteOrigin: "arch", wantAttachmentBar: "vive at arch"}, | |
| 118 | + | {name: "remote origin", remoteOrigin: "arch", wantAttachmentBar: "vive@arch"}, | |
| 119 | 119 | {name: "local attachment", wantAttachmentBar: "vive"}, | |
| 120 | 120 | } | |
| 121 | 121 | ||
| @@ -115,7 +115,7 @@ func TestStatusSegmentsIncludesAttachmentRemoteOrigin(t *testing.T) { | |||||
| 115 | remoteOrigin string | 115 | remoteOrigin string | ||
| 116 | wantAttachmentBar string | 116 | wantAttachmentBar string | ||
| 117 | }{ | 117 | }{ | ||
| 118 | - | {name: "remote origin", remoteOrigin: "arch", wantAttachmentBar: "vive at arch"}, | 118 | + | {name: "remote origin", remoteOrigin: "arch", wantAttachmentBar: "vive@arch"}, |
| 119 | {name: "local attachment", wantAttachmentBar: "vive"}, | 119 | {name: "local attachment", wantAttachmentBar: "vive"}, | ||
| 120 | } | 120 | } | ||
| 121 | 121 | ||||
M internal/usecase/daemon/test_wait_helpers_test.go +13 -0
| @@ -9,6 +9,16 @@ import ( | |||
| 9 | 9 | ||
| 10 | 10 | const testWaitTimeout = 2 * time.Second | |
| 11 | 11 | ||
| 12 | + | func testFramesOfType(frames []ports.Frame, typ ports.MsgType) []ports.Frame { | |
| 13 | + | matched := make([]ports.Frame, 0, len(frames)) | |
| 14 | + | for _, frame := range frames { | |
| 15 | + | if frame.Type == typ { | |
| 16 | + | matched = append(matched, frame) | |
| 17 | + | } | |
| 18 | + | } | |
| 19 | + | return matched | |
| 20 | + | } | |
| 21 | + | ||
| 12 | 22 | func awaitTestValue[T any](t *testing.T, ch <-chan T, failure string) T { | |
| 13 | 23 | t.Helper() | |
| 14 | 24 | timer := time.NewTimer(testWaitTimeout) | |
| @@ -41,6 +51,9 @@ func awaitCoordinatorOutput( | |||
| 41 | 51 | for { | |
| 42 | 52 | select { | |
| 43 | 53 | case frame := <-sends: | |
| 54 | + | if frame.Type == ports.MsgRoutePosition { | |
| 55 | + | continue | |
| 56 | + | } | |
| 44 | 57 | if frame.Type != ports.MsgOutput { | |
| 45 | 58 | t.Fatalf("unexpected frame type %d %s", frame.Type, frameContext) | |
| 46 | 59 | } | |
| @@ -9,6 +9,16 @@ import ( | |||||
| 9 | 9 | ||||
| 10 | const testWaitTimeout = 2 * time.Second | 10 | const testWaitTimeout = 2 * time.Second | ||
| 11 | 11 | ||||
| 12 | + | func testFramesOfType(frames []ports.Frame, typ ports.MsgType) []ports.Frame { | |||
| 13 | + | matched := make([]ports.Frame, 0, len(frames)) | |||
| 14 | + | for _, frame := range frames { | |||
| 15 | + | if frame.Type == typ { | |||
| 16 | + | matched = append(matched, frame) | |||
| 17 | + | } | |||
| 18 | + | } | |||
| 19 | + | return matched | |||
| 20 | + | } | |||
| 21 | + | ||||
| 12 | func awaitTestValue[T any](t *testing.T, ch <-chan T, failure string) T { | 22 | func awaitTestValue[T any](t *testing.T, ch <-chan T, failure string) T { | ||
| 13 | t.Helper() | 23 | t.Helper() | ||
| 14 | timer := time.NewTimer(testWaitTimeout) | 24 | timer := time.NewTimer(testWaitTimeout) | ||
| @@ -41,6 +51,9 @@ func awaitCoordinatorOutput( | |||||
| 41 | for { | 51 | for { | ||
| 42 | select { | 52 | select { | ||
| 43 | case frame := <-sends: | 53 | case frame := <-sends: | ||
| 54 | + | if frame.Type == ports.MsgRoutePosition { | |||
| 55 | + | continue | |||
| 56 | + | } | |||
| 44 | if frame.Type != ports.MsgOutput { | 57 | if frame.Type != ports.MsgOutput { | ||
| 45 | t.Fatalf("unexpected frame type %d %s", frame.Type, frameContext) | 58 | t.Fatalf("unexpected frame type %d %s", frame.Type, frameContext) | ||
| 46 | } | 59 | } | ||
M internal/usecase/palette/result.go +24 -2
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "time" | |
| 5 | 5 | ||
| 6 | 6 | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/bnema/vev/internal/ports" | |
| 7 | 8 | "github.com/bnema/vev/internal/usecase/command" | |
| 8 | 9 | ) | |
| 9 | 10 | ||
| @@ -14,6 +15,7 @@ const ( | |||
| 14 | 15 | ResultKindCommand ResultKind = iota | |
| 15 | 16 | ResultKindActiveSession | |
| 16 | 17 | ResultKindStoppedSession | |
| 18 | + | ResultKindRecentRoute | |
| 17 | 19 | ) | |
| 18 | 20 | ||
| 19 | 21 | // Result is an immutable palette target. Its kind is the sole discriminator | |
| @@ -22,6 +24,7 @@ type Result struct { | |||
| 22 | 24 | kind ResultKind | |
| 23 | 25 | command command.Command | |
| 24 | 26 | session sessionPayload | |
| 27 | + | route routePayload | |
| 25 | 28 | } | |
| 26 | 29 | ||
| 27 | 30 | type sessionPayload struct { | |
| @@ -30,6 +33,11 @@ type sessionPayload struct { | |||
| 30 | 33 | sessionID domain.SessionID | |
| 31 | 34 | } | |
| 32 | 35 | ||
| 36 | + | type routePayload struct { | |
| 37 | + | label string | |
| 38 | + | action ports.RouteNavigationAction | |
| 39 | + | } | |
| 40 | + | ||
| 33 | 41 | // NewCommandResult creates a static command palette target. | |
| 34 | 42 | func NewCommandResult(cmd command.Command) Result { | |
| 35 | 43 | return Result{kind: ResultKindCommand, command: cmd} | |
| @@ -51,12 +59,20 @@ func NewStoppedSessionResult(name string, createdAt time.Time) Result { | |||
| 51 | 59 | } | |
| 52 | 60 | } | |
| 53 | 61 | ||
| 62 | + | // NewRecentRouteResult creates an immutable client-ledger route target. | |
| 63 | + | func NewRecentRouteResult(label string, action ports.RouteNavigationAction) Result { | |
| 64 | + | return Result{kind: ResultKindRecentRoute, route: routePayload{label: label, action: action}} | |
| 65 | + | } | |
| 66 | + | ||
| 54 | 67 | func (r Result) Kind() ResultKind { return r.kind } | |
| 55 | 68 | ||
| 56 | 69 | func (r Result) DisplayText() string { | |
| 57 | 70 | if r.kind == ResultKindCommand { | |
| 58 | 71 | return r.command.Code | |
| 59 | 72 | } | |
| 73 | + | if r.kind == ResultKindRecentRoute { | |
| 74 | + | return activeSessionDisplayPrefix + r.route.label | |
| 75 | + | } | |
| 60 | 76 | return r.sessionDisplayPrefix() + r.session.name | |
| 61 | 77 | } | |
| 62 | 78 | ||
| @@ -78,12 +94,12 @@ func (r Result) Command() (command.Command, bool) { | |||
| 78 | 94 | ||
| 79 | 95 | // SessionName returns the session name only for session results. | |
| 80 | 96 | func (r Result) SessionName() (string, bool) { | |
| 81 | - | return r.session.name, r.kind != ResultKindCommand | |
| 97 | + | return r.session.name, r.kind == ResultKindActiveSession || r.kind == ResultKindStoppedSession | |
| 82 | 98 | } | |
| 83 | 99 | ||
| 84 | 100 | // SessionCreatedAt returns the session creation time only for session results. | |
| 85 | 101 | func (r Result) SessionCreatedAt() (time.Time, bool) { | |
| 86 | - | return r.session.createdAt, r.kind != ResultKindCommand | |
| 102 | + | return r.session.createdAt, r.kind == ResultKindActiveSession || r.kind == ResultKindStoppedSession | |
| 87 | 103 | } | |
| 88 | 104 | ||
| 89 | 105 | // SessionID returns the lifecycle identity only for active session results. | |
| @@ -91,6 +107,12 @@ func (r Result) SessionID() (domain.SessionID, bool) { | |||
| 91 | 107 | return r.session.sessionID, r.kind == ResultKindActiveSession | |
| 92 | 108 | } | |
| 93 | 109 | ||
| 110 | + | // RouteNavigationAction returns the exact client-ledger target only for a | |
| 111 | + | // recent-route result. | |
| 112 | + | func (r Result) RouteNavigationAction() (ports.RouteNavigationAction, bool) { | |
| 113 | + | return r.route.action, r.kind == ResultKindRecentRoute | |
| 114 | + | } | |
| 115 | + | ||
| 94 | 116 | const ( | |
| 95 | 117 | activeSessionDisplayPrefix = "Switch to session " | |
| 96 | 118 | stoppedSessionDisplayPrefix = "Resume session " | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "time" | 4 | "time" | ||
| 5 | 5 | ||||
| 6 | "github.com/bnema/vev/internal/domain" | 6 | "github.com/bnema/vev/internal/domain" | ||
| 7 | + | "github.com/bnema/vev/internal/ports" | |||
| 7 | "github.com/bnema/vev/internal/usecase/command" | 8 | "github.com/bnema/vev/internal/usecase/command" | ||
| 8 | ) | 9 | ) | ||
| 9 | 10 | ||||
| @@ -14,6 +15,7 @@ const ( | |||||
| 14 | ResultKindCommand ResultKind = iota | 15 | ResultKindCommand ResultKind = iota | ||
| 15 | ResultKindActiveSession | 16 | ResultKindActiveSession | ||
| 16 | ResultKindStoppedSession | 17 | ResultKindStoppedSession | ||
| 18 | + | ResultKindRecentRoute | |||
| 17 | ) | 19 | ) | ||
| 18 | 20 | ||||
| 19 | // Result is an immutable palette target. Its kind is the sole discriminator | 21 | // Result is an immutable palette target. Its kind is the sole discriminator | ||
| @@ -22,6 +24,7 @@ type Result struct { | |||||
| 22 | kind ResultKind | 24 | kind ResultKind | ||
| 23 | command command.Command | 25 | command command.Command | ||
| 24 | session sessionPayload | 26 | session sessionPayload | ||
| 27 | + | route routePayload | |||
| 25 | } | 28 | } | ||
| 26 | 29 | ||||
| 27 | type sessionPayload struct { | 30 | type sessionPayload struct { | ||
| @@ -30,6 +33,11 @@ type sessionPayload struct { | |||||
| 30 | sessionID domain.SessionID | 33 | sessionID domain.SessionID | ||
| 31 | } | 34 | } | ||
| 32 | 35 | ||||
| 36 | + | type routePayload struct { | |||
| 37 | + | label string | |||
| 38 | + | action ports.RouteNavigationAction | |||
| 39 | + | } | |||
| 40 | + | ||||
| 33 | // NewCommandResult creates a static command palette target. | 41 | // NewCommandResult creates a static command palette target. | ||
| 34 | func NewCommandResult(cmd command.Command) Result { | 42 | func NewCommandResult(cmd command.Command) Result { | ||
| 35 | return Result{kind: ResultKindCommand, command: cmd} | 43 | return Result{kind: ResultKindCommand, command: cmd} | ||
| @@ -51,12 +59,20 @@ func NewStoppedSessionResult(name string, createdAt time.Time) Result { | |||||
| 51 | } | 59 | } | ||
| 52 | } | 60 | } | ||
| 53 | 61 | ||||
| 62 | + | // NewRecentRouteResult creates an immutable client-ledger route target. | |||
| 63 | + | func NewRecentRouteResult(label string, action ports.RouteNavigationAction) Result { | |||
| 64 | + | return Result{kind: ResultKindRecentRoute, route: routePayload{label: label, action: action}} | |||
| 65 | + | } | |||
| 66 | + | ||||
| 54 | func (r Result) Kind() ResultKind { return r.kind } | 67 | func (r Result) Kind() ResultKind { return r.kind } | ||
| 55 | 68 | ||||
| 56 | func (r Result) DisplayText() string { | 69 | func (r Result) DisplayText() string { | ||
| 57 | if r.kind == ResultKindCommand { | 70 | if r.kind == ResultKindCommand { | ||
| 58 | return r.command.Code | 71 | return r.command.Code | ||
| 59 | } | 72 | } | ||
| 73 | + | if r.kind == ResultKindRecentRoute { | |||
| 74 | + | return activeSessionDisplayPrefix + r.route.label | |||
| 75 | + | } | |||
| 60 | return r.sessionDisplayPrefix() + r.session.name | 76 | return r.sessionDisplayPrefix() + r.session.name | ||
| 61 | } | 77 | } | ||
| 62 | 78 | ||||
| @@ -78,12 +94,12 @@ func (r Result) Command() (command.Command, bool) { | |||||
| 78 | 94 | ||||
| 79 | // SessionName returns the session name only for session results. | 95 | // SessionName returns the session name only for session results. | ||
| 80 | func (r Result) SessionName() (string, bool) { | 96 | func (r Result) SessionName() (string, bool) { | ||
| 81 | - | return r.session.name, r.kind != ResultKindCommand | 97 | + | return r.session.name, r.kind == ResultKindActiveSession || r.kind == ResultKindStoppedSession |
| 82 | } | 98 | } | ||
| 83 | 99 | ||||
| 84 | // SessionCreatedAt returns the session creation time only for session results. | 100 | // SessionCreatedAt returns the session creation time only for session results. | ||
| 85 | func (r Result) SessionCreatedAt() (time.Time, bool) { | 101 | func (r Result) SessionCreatedAt() (time.Time, bool) { | ||
| 86 | - | return r.session.createdAt, r.kind != ResultKindCommand | 102 | + | return r.session.createdAt, r.kind == ResultKindActiveSession || r.kind == ResultKindStoppedSession |
| 87 | } | 103 | } | ||
| 88 | 104 | ||||
| 89 | // SessionID returns the lifecycle identity only for active session results. | 105 | // SessionID returns the lifecycle identity only for active session results. | ||
| @@ -91,6 +107,12 @@ func (r Result) SessionID() (domain.SessionID, bool) { | |||||
| 91 | return r.session.sessionID, r.kind == ResultKindActiveSession | 107 | return r.session.sessionID, r.kind == ResultKindActiveSession | ||
| 92 | } | 108 | } | ||
| 93 | 109 | ||||
| 110 | + | // RouteNavigationAction returns the exact client-ledger target only for a | |||
| 111 | + | // recent-route result. | |||
| 112 | + | func (r Result) RouteNavigationAction() (ports.RouteNavigationAction, bool) { | |||
| 113 | + | return r.route.action, r.kind == ResultKindRecentRoute | |||
| 114 | + | } | |||
| 115 | + | ||||
| 94 | const ( | 116 | const ( | ||
| 95 | activeSessionDisplayPrefix = "Switch to session " | 117 | activeSessionDisplayPrefix = "Switch to session " | ||
| 96 | stoppedSessionDisplayPrefix = "Resume session " | 118 | stoppedSessionDisplayPrefix = "Resume session " | ||
M internal/usecase/palette/result_test.go +15 -0
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "time" | |
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/vev/internal/domain" | |
| 8 | + | "github.com/bnema/vev/internal/ports" | |
| 8 | 9 | "github.com/bnema/vev/internal/usecase/command" | |
| 9 | 10 | "github.com/stretchr/testify/require" | |
| 10 | 11 | ) | |
| @@ -48,6 +49,20 @@ func TestResultKindsAndSessionLifecycleTargets(t *testing.T) { | |||
| 48 | 49 | require.False(t, ok) | |
| 49 | 50 | } | |
| 50 | 51 | ||
| 52 | + | func TestRecentRouteResultCarriesExactNavigationAction(t *testing.T) { | |
| 53 | + | action := ports.RouteNavigationAction{SnapshotGeneration: 4, Key: 7, Generation: 3} | |
| 54 | + | result := NewRecentRouteResult("logs@edge", action) | |
| 55 | + | ||
| 56 | + | require.Equal(t, ResultKindRecentRoute, result.Kind()) | |
| 57 | + | require.Equal(t, "Switch to session logs@edge", result.DisplayText()) | |
| 58 | + | require.Equal(t, "Switch to session logs@edge", result.SearchText()) | |
| 59 | + | got, ok := result.RouteNavigationAction() | |
| 60 | + | require.True(t, ok) | |
| 61 | + | require.Equal(t, action, got) | |
| 62 | + | _, ok = result.SessionName() | |
| 63 | + | require.False(t, ok) | |
| 64 | + | } | |
| 65 | + | ||
| 51 | 66 | func TestSessionResultsSearchDisplayedActionText(t *testing.T) { | |
| 52 | 67 | created := time.Date(2026, time.March, 1, 2, 3, 4, 0, time.UTC) | |
| 53 | 68 | tests := []struct { | |
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "time" | 5 | "time" | ||
| 6 | 6 | ||||
| 7 | "github.com/bnema/vev/internal/domain" | 7 | "github.com/bnema/vev/internal/domain" | ||
| 8 | + | "github.com/bnema/vev/internal/ports" | |||
| 8 | "github.com/bnema/vev/internal/usecase/command" | 9 | "github.com/bnema/vev/internal/usecase/command" | ||
| 9 | "github.com/stretchr/testify/require" | 10 | "github.com/stretchr/testify/require" | ||
| 10 | ) | 11 | ) | ||
| @@ -48,6 +49,20 @@ func TestResultKindsAndSessionLifecycleTargets(t *testing.T) { | |||||
| 48 | require.False(t, ok) | 49 | require.False(t, ok) | ||
| 49 | } | 50 | } | ||
| 50 | 51 | ||||
| 52 | + | func TestRecentRouteResultCarriesExactNavigationAction(t *testing.T) { | |||
| 53 | + | action := ports.RouteNavigationAction{SnapshotGeneration: 4, Key: 7, Generation: 3} | |||
| 54 | + | result := NewRecentRouteResult("logs@edge", action) | |||
| 55 | + | ||||
| 56 | + | require.Equal(t, ResultKindRecentRoute, result.Kind()) | |||
| 57 | + | require.Equal(t, "Switch to session logs@edge", result.DisplayText()) | |||
| 58 | + | require.Equal(t, "Switch to session logs@edge", result.SearchText()) | |||
| 59 | + | got, ok := result.RouteNavigationAction() | |||
| 60 | + | require.True(t, ok) | |||
| 61 | + | require.Equal(t, action, got) | |||
| 62 | + | _, ok = result.SessionName() | |||
| 63 | + | require.False(t, ok) | |||
| 64 | + | } | |||
| 65 | + | ||||
| 51 | func TestSessionResultsSearchDisplayedActionText(t *testing.T) { | 66 | func TestSessionResultsSearchDisplayedActionText(t *testing.T) { | ||
| 52 | created := time.Date(2026, time.March, 1, 2, 3, 4, 0, time.UTC) | 67 | created := time.Date(2026, time.March, 1, 2, 3, 4, 0, time.UTC) | ||
| 53 | tests := []struct { | 68 | tests := []struct { | ||
M scripts/remote-picker-harness/main.go +1 -1
| @@ -281,7 +281,7 @@ func runLocalPickerUnitedPhase(ctx context.Context, target string, catalog ports | |||
| 281 | 281 | if err := sendRawInput(local, "\r"); err != nil { | |
| 282 | 282 | return fmt.Errorf("commit remote picker target: %w", err) | |
| 283 | 283 | } | |
| 284 | - | if err := awaitOutputContains(ctx, local, remoteSession+" at remote"); err != nil { | |
| 284 | + | if err := awaitOutputContains(ctx, local, remoteSession+"@remote"); err != nil { | |
| 285 | 285 | return fmt.Errorf("publish remote content on local transport: %w", err) | |
| 286 | 286 | } | |
| 287 | 287 | if !local.probe.contains(previewMarker) { | |
| @@ -281,7 +281,7 @@ func runLocalPickerUnitedPhase(ctx context.Context, target string, catalog ports | |||||
| 281 | if err := sendRawInput(local, "\r"); err != nil { | 281 | if err := sendRawInput(local, "\r"); err != nil { | ||
| 282 | return fmt.Errorf("commit remote picker target: %w", err) | 282 | return fmt.Errorf("commit remote picker target: %w", err) | ||
| 283 | } | 283 | } | ||
| 284 | - | if err := awaitOutputContains(ctx, local, remoteSession+" at remote"); err != nil { | 284 | + | if err := awaitOutputContains(ctx, local, remoteSession+"@remote"); err != nil { |
| 285 | return fmt.Errorf("publish remote content on local transport: %w", err) | 285 | return fmt.Errorf("publish remote content on local transport: %w", err) | ||
| 286 | } | 286 | } | ||
| 287 | if !local.probe.contains(previewMarker) { | 287 | if !local.probe.contains(previewMarker) { | ||