detached 9386b87

feat(attach): let the daemon resolve attach-by-name (#291)

* feat(attach): let the daemon resolve attach-by-name

After kill --all, vev a <name> offered to create an existing session and
then failed to attach: both the create prompt and the attach resolved the
name against the broker catalogue, which stays empty until a running
daemon is observed, and observation never starts one.

- Add the attach-named stream admission (wire code 4). The daemon resolves
  the name through its existing attach path, restoring the session if
  needed, and refuses an unknown name with ErrNoSuchSession.
- Local and remote vev a <name> now open that admission instead of
  resolving an exact lifecycle from the catalogue.
- The create prompt asks the destination daemon for its sessions with a
  list that may start it, instead of reading the catalogue.
- Remove the local catalogue-based exact attach resolver.

* feat(protocol): bump session protocol version to 61 for attach-named admission

Older daemons refuse the new attach-named admission code, so peers must
negotiate the version that carries it.

* fix(picker): let a named attach start a remote host with no daemon

* test(daemon): compare attachment membership by pointer to avoid racy deep equality
commit
9386b87235373c20d1ede9141a7e596c343b4567
author
bnema · · 2026-09-26 00:59 UTC
committer
GitHub · · 2026-09-26 00:59 UTC
parent
b3c9196
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
33 files · +547 -237
M internal/adapters/brokerwire/codec.go +6 -2
@@ -554,8 +554,8 @@ func openStreamToWire(m OpenStream) (*wire.OpenStream, error) {
554554 Env: append([]string(nil), m.Env...),
555555 Policy: policyToWire(m.Policy),
556556 // The admission taxonomy mirrors the port values (0 none, 1 exact,
557- // 2 create named, 3 create ephemeral); the name travels only for the
558- // create-named variant.
557+ // 2 create named, 3 create ephemeral, 4 attach named); the name
558+ // travels only for the two named variants.
559559 Admission: admission, Name: m.Name, StartMode: startMode,
560560 }
561561 if !m.Local {
@@ -581,6 +581,8 @@ func admissionToWire(admission ports.BrokerStreamAdmission) (uint32, error) {
581581 return 2, nil
582582 case ports.BrokerAdmissionCreateEphemeral:
583583 return 3, nil
584+ case ports.BrokerAdmissionAttachNamed:
585+ return 4, nil
584586 default:
585587 return 0, errConvertRange
586588 }
@@ -597,6 +599,8 @@ func admissionFromWire(value uint32) (ports.BrokerStreamAdmission, error) {
597599 return ports.BrokerAdmissionCreateNamed, nil
598600 case 3:
599601 return ports.BrokerAdmissionCreateEphemeral, nil
602+ case 4:
603+ return ports.BrokerAdmissionAttachNamed, nil
600604 default:
601605 return 0, errConvertRange
602606 }
@@ -554,8 +554,8 @@ func openStreamToWire(m OpenStream) (*wire.OpenStream, error) {
554 Env: append([]string(nil), m.Env...),554 Env: append([]string(nil), m.Env...),
555 Policy: policyToWire(m.Policy),555 Policy: policyToWire(m.Policy),
556 // The admission taxonomy mirrors the port values (0 none, 1 exact,556 // The admission taxonomy mirrors the port values (0 none, 1 exact,
557- // 2 create named, 3 create ephemeral); the name travels only for the557+ // 2 create named, 3 create ephemeral, 4 attach named); the name
558- // create-named variant.558+ // travels only for the two named variants.
559 Admission: admission, Name: m.Name, StartMode: startMode,559 Admission: admission, Name: m.Name, StartMode: startMode,
560 }560 }
561 if !m.Local {561 if !m.Local {
@@ -581,6 +581,8 @@ func admissionToWire(admission ports.BrokerStreamAdmission) (uint32, error) {
581 return 2, nil581 return 2, nil
582 case ports.BrokerAdmissionCreateEphemeral:582 case ports.BrokerAdmissionCreateEphemeral:
583 return 3, nil583 return 3, nil
584+ case ports.BrokerAdmissionAttachNamed:
585+ return 4, nil
584 default:586 default:
585 return 0, errConvertRange587 return 0, errConvertRange
586 }588 }
@@ -597,6 +599,8 @@ func admissionFromWire(value uint32) (ports.BrokerStreamAdmission, error) {
597 return ports.BrokerAdmissionCreateNamed, nil599 return ports.BrokerAdmissionCreateNamed, nil
598 case 3:600 case 3:
599 return ports.BrokerAdmissionCreateEphemeral, nil601 return ports.BrokerAdmissionCreateEphemeral, nil
602+ case 4:
603+ return ports.BrokerAdmissionAttachNamed, nil
600 default:604 default:
601 return 0, errConvertRange605 return 0, errConvertRange
602 }606 }
M internal/adapters/brokerwire/codec_test.go +19 -1
@@ -412,6 +412,12 @@ func TestBrokerOpenStreamAdmissionVariants(t *testing.T) {
412412 message.Admission = ports.BrokerAdmissionCreateEphemeral
413413 return message
414414 }()},
415+ {"attach named", func() OpenStream {
416+ message := base()
417+ message.Admission = ports.BrokerAdmissionAttachNamed
418+ message.Name = "work"
419+ return message
420+ }()},
415421 {"control carries none", OpenStream{
416422 Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamControl,
417423 Local: true, Policy: testPolicy(), StartMode: ports.BrokerDaemonStartIfNeeded,
@@ -452,6 +458,18 @@ func TestBrokerOpenStreamAdmissionVariants(t *testing.T) {
452458 m.Admission = ports.BrokerAdmissionCreateNamed
453459 return m
454460 }(),
461+ "attach named with target": func() OpenStream {
462+ m := base()
463+ m.Admission = ports.BrokerAdmissionAttachNamed
464+ m.Name = "work"
465+ m.Target = testTarget()
466+ return m
467+ }(),
468+ "attach named without name": func() OpenStream {
469+ m := base()
470+ m.Admission = ports.BrokerAdmissionAttachNamed
471+ return m
472+ }(),
455473 "ephemeral with name": func() OpenStream {
456474 m := base()
457475 m.Admission = ports.BrokerAdmissionCreateEphemeral
@@ -484,7 +502,7 @@ func TestBrokerOpenStreamAdmissionVariants(t *testing.T) {
484502 raw, err := EncodeClient(message, testEnvelopeCeiling, testChunkCeiling)
485503 require.NoError(t, err)
486504 _, err = DecodeClient(raw, testEnvelopeCeiling, testChunkCeiling)
487- if name == "named with target" {
505+ if name == "named with target" || name == "attach named with target" {
488506 // The encoder omits a target on creation; test the untrusted wire directly.
489507 envelope := &wire.BrokerClientEnvelope{}
490508 require.NoError(t, proto.Unmarshal(raw, envelope))
@@ -412,6 +412,12 @@ func TestBrokerOpenStreamAdmissionVariants(t *testing.T) {
412 message.Admission = ports.BrokerAdmissionCreateEphemeral412 message.Admission = ports.BrokerAdmissionCreateEphemeral
413 return message413 return message
414 }()},414 }()},
415+ {"attach named", func() OpenStream {
416+ message := base()
417+ message.Admission = ports.BrokerAdmissionAttachNamed
418+ message.Name = "work"
419+ return message
420+ }()},
415 {"control carries none", OpenStream{421 {"control carries none", OpenStream{
416 Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamControl,422 Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamControl,
417 Local: true, Policy: testPolicy(), StartMode: ports.BrokerDaemonStartIfNeeded,423 Local: true, Policy: testPolicy(), StartMode: ports.BrokerDaemonStartIfNeeded,
@@ -452,6 +458,18 @@ func TestBrokerOpenStreamAdmissionVariants(t *testing.T) {
452 m.Admission = ports.BrokerAdmissionCreateNamed458 m.Admission = ports.BrokerAdmissionCreateNamed
453 return m459 return m
454 }(),460 }(),
461+ "attach named with target": func() OpenStream {
462+ m := base()
463+ m.Admission = ports.BrokerAdmissionAttachNamed
464+ m.Name = "work"
465+ m.Target = testTarget()
466+ return m
467+ }(),
468+ "attach named without name": func() OpenStream {
469+ m := base()
470+ m.Admission = ports.BrokerAdmissionAttachNamed
471+ return m
472+ }(),
455 "ephemeral with name": func() OpenStream {473 "ephemeral with name": func() OpenStream {
456 m := base()474 m := base()
457 m.Admission = ports.BrokerAdmissionCreateEphemeral475 m.Admission = ports.BrokerAdmissionCreateEphemeral
@@ -484,7 +502,7 @@ func TestBrokerOpenStreamAdmissionVariants(t *testing.T) {
484 raw, err := EncodeClient(message, testEnvelopeCeiling, testChunkCeiling)502 raw, err := EncodeClient(message, testEnvelopeCeiling, testChunkCeiling)
485 require.NoError(t, err)503 require.NoError(t, err)
486 _, err = DecodeClient(raw, testEnvelopeCeiling, testChunkCeiling)504 _, err = DecodeClient(raw, testEnvelopeCeiling, testChunkCeiling)
487- if name == "named with target" {505+ if name == "named with target" || name == "attach named with target" {
488 // The encoder omits a target on creation; test the untrusted wire directly.506 // The encoder omits a target on creation; test the untrusted wire directly.
489 envelope := &wire.BrokerClientEnvelope{}507 envelope := &wire.BrokerClientEnvelope{}
490 require.NoError(t, proto.Unmarshal(raw, envelope))508 require.NoError(t, proto.Unmarshal(raw, envelope))
M internal/adapters/daemonmux/admission_conversion_test.go +11 -0
@@ -67,6 +67,17 @@ func TestOpenMessageCarriesAttachmentAdmission(t *testing.T) {
6767 }(),
6868 admission: ports.BrokerAdmissionCreateEphemeral,
6969 },
70+ {
71+ name: "named attach keeps its name",
72+ request: func() ports.BrokerOpenStreamRequest {
73+ r := base
74+ r.Admission = ports.BrokerAdmissionAttachNamed
75+ r.Name = "work"
76+ return r
77+ }(),
78+ admission: ports.BrokerAdmissionAttachNamed,
79+ createAs: "work",
80+ },
7081 }
7182
7283 for _, tc := range tests {
@@ -67,6 +67,17 @@ func TestOpenMessageCarriesAttachmentAdmission(t *testing.T) {
67 }(),67 }(),
68 admission: ports.BrokerAdmissionCreateEphemeral,68 admission: ports.BrokerAdmissionCreateEphemeral,
69 },69 },
70+ {
71+ name: "named attach keeps its name",
72+ request: func() ports.BrokerOpenStreamRequest {
73+ r := base
74+ r.Admission = ports.BrokerAdmissionAttachNamed
75+ r.Name = "work"
76+ return r
77+ }(),
78+ admission: ports.BrokerAdmissionAttachNamed,
79+ createAs: "work",
80+ },
70 }81 }
71 82
72 for _, tc := range tests {83 for _, tc := range tests {
M internal/adapters/daemonmux/codec_test.go +14 -0
@@ -717,6 +717,13 @@ func TestMuxAdmissionVariants(t *testing.T) {
717717 named.Target = protocol.ExactSessionTarget{}
718718 require.Equal(t, named, roundTrip(t, named))
719719 })
720+ t.Run("named attach round-trips", func(t *testing.T) {
721+ named := testOpen()
722+ named.Admission = ports.BrokerAdmissionAttachNamed
723+ named.Name = "work"
724+ named.Target = protocol.ExactSessionTarget{}
725+ require.Equal(t, named, roundTrip(t, named))
726+ })
720727 t.Run("ephemeral creation round-trips", func(t *testing.T) {
721728 ephemeral := testOpen()
722729 ephemeral.Admission = ports.BrokerAdmissionCreateEphemeral
@@ -843,6 +850,10 @@ func TestMuxAdmissionWireRange(t *testing.T) {
843850 ephemeral := testOpen()
844851 ephemeral.Admission = ports.BrokerAdmissionCreateEphemeral
845852 ephemeral.Target = protocol.ExactSessionTarget{}
853+ attachNamed := testOpen()
854+ attachNamed.Admission = ports.BrokerAdmissionAttachNamed
855+ attachNamed.Name = "work"
856+ attachNamed.Target = protocol.ExactSessionTarget{}
846857
847858 tests := []struct {
848859 name string
@@ -854,6 +865,9 @@ func TestMuxAdmissionWireRange(t *testing.T) {
854865 {"exact admission", testOpen(), uint32(ports.BrokerAdmissionExact), false},
855866 {"create-named admission", named, uint32(ports.BrokerAdmissionCreateNamed), false},
856867 {"create-ephemeral admission", ephemeral, uint32(ports.BrokerAdmissionCreateEphemeral), false},
868+ {"attach-named admission", attachNamed, uint32(ports.BrokerAdmissionAttachNamed), false},
869+ {"wire value 5 past the closed set", attachNamed, 5, true},
870+ {"wire value 260 aliasing attach-named", attachNamed, 260, true},
857871 {"wire value 257 aliasing exact", testOpen(), 257, true},
858872 {"wire value 258 aliasing create-named", named, 258, true},
859873 {"wire value 1024 aliasing no admission", control, 1024, true},
@@ -717,6 +717,13 @@ func TestMuxAdmissionVariants(t *testing.T) {
717 named.Target = protocol.ExactSessionTarget{}717 named.Target = protocol.ExactSessionTarget{}
718 require.Equal(t, named, roundTrip(t, named))718 require.Equal(t, named, roundTrip(t, named))
719 })719 })
720+ t.Run("named attach round-trips", func(t *testing.T) {
721+ named := testOpen()
722+ named.Admission = ports.BrokerAdmissionAttachNamed
723+ named.Name = "work"
724+ named.Target = protocol.ExactSessionTarget{}
725+ require.Equal(t, named, roundTrip(t, named))
726+ })
720 t.Run("ephemeral creation round-trips", func(t *testing.T) {727 t.Run("ephemeral creation round-trips", func(t *testing.T) {
721 ephemeral := testOpen()728 ephemeral := testOpen()
722 ephemeral.Admission = ports.BrokerAdmissionCreateEphemeral729 ephemeral.Admission = ports.BrokerAdmissionCreateEphemeral
@@ -843,6 +850,10 @@ func TestMuxAdmissionWireRange(t *testing.T) {
843 ephemeral := testOpen()850 ephemeral := testOpen()
844 ephemeral.Admission = ports.BrokerAdmissionCreateEphemeral851 ephemeral.Admission = ports.BrokerAdmissionCreateEphemeral
845 ephemeral.Target = protocol.ExactSessionTarget{}852 ephemeral.Target = protocol.ExactSessionTarget{}
853+ attachNamed := testOpen()
854+ attachNamed.Admission = ports.BrokerAdmissionAttachNamed
855+ attachNamed.Name = "work"
856+ attachNamed.Target = protocol.ExactSessionTarget{}
846 857
847 tests := []struct {858 tests := []struct {
848 name string859 name string
@@ -854,6 +865,9 @@ func TestMuxAdmissionWireRange(t *testing.T) {
854 {"exact admission", testOpen(), uint32(ports.BrokerAdmissionExact), false},865 {"exact admission", testOpen(), uint32(ports.BrokerAdmissionExact), false},
855 {"create-named admission", named, uint32(ports.BrokerAdmissionCreateNamed), false},866 {"create-named admission", named, uint32(ports.BrokerAdmissionCreateNamed), false},
856 {"create-ephemeral admission", ephemeral, uint32(ports.BrokerAdmissionCreateEphemeral), false},867 {"create-ephemeral admission", ephemeral, uint32(ports.BrokerAdmissionCreateEphemeral), false},
868+ {"attach-named admission", attachNamed, uint32(ports.BrokerAdmissionAttachNamed), false},
869+ {"wire value 5 past the closed set", attachNamed, 5, true},
870+ {"wire value 260 aliasing attach-named", attachNamed, 260, true},
857 {"wire value 257 aliasing exact", testOpen(), 257, true},871 {"wire value 257 aliasing exact", testOpen(), 257, true},
858 {"wire value 258 aliasing create-named", named, 258, true},872 {"wire value 258 aliasing create-named", named, 258, true},
859 {"wire value 1024 aliasing no admission", control, 1024, true},873 {"wire value 1024 aliasing no admission", control, 1024, true},
M internal/app/attach_missing_prompt.go +44 -31
@@ -7,8 +7,10 @@ import (
77 "os"
88 "sync"
99
10+ "github.com/bnema/vev/internal/adapters/clock"
1011 "github.com/bnema/vev/internal/ports"
1112 "github.com/bnema/vev/internal/protocol"
13+ "github.com/bnema/vev/internal/usecase/client"
1214 "github.com/bnema/vev/internal/usecase/confirm"
1315 "github.com/bnema/vev/pkg/rawterm"
1416 )
@@ -50,21 +52,19 @@ func (w flushingWriter) Write(data []byte) (int, error) {
5052 return written, nil
5153 }
5254
53-// resolveAttachCreationIntent decides the attach intent for one direct exact
55+// resolveAttachCreationIntent decides the attach intent for one direct
5456 // `attach <name>` target (local when remoteTarget is empty, otherwise a
5557 // configured remote host) before the client owns any console reader or raw
56-// mode. It reuses the broker's own attach resolution (the same exact-target
57-// lookup `localExactAttachResolver`/`remoteExactAttachResolver` run later) so
58-// existence is decided from one committed broker publication, never from
59-// error-text matching.
58+// mode. Existence is asked of the destination daemon itself through a list
59+// that may start it, never read from the broker catalogue: right after
60+// `kill --all` the catalogue is empty while the daemon still restores the
61+// session from disk.
6062 //
61-// When the session exists, the host is not configured, or existence cannot be
62-// determined (the broker is unreachable), it keeps IntentAttach unchanged and
63-// the resolver's own rejection, if any, surfaces unchanged later. When the
64-// session is confirmed absent on a known host, it offers creation on an
65-// interactive console and returns IntentNew on confirmation; a declined
66-// answer, an unusable console, or a non-interactive console all keep
67-// IntentAttach so the existing resolver reports its own clear refusal.
63+// When the session exists, the host is not configured, or the daemon cannot
64+// answer, it keeps IntentAttach; the named attach then lets the daemon decide
65+// and report its own refusal. Only a daemon listing without the name offers
66+// creation on an interactive console, returning IntentNew on confirmation; a
67+// declined answer or a non-interactive console keeps IntentAttach.
6868 //
6969 // The check runs before Supervisor.Run enters raw mode, so the create prompt
7070 // is always the sole console reader: no attachment worker can leave a
@@ -85,8 +85,7 @@ func resolveAttachCreationIntent(ctx context.Context, intent uint8, name, remote
8585 // cadence) decide, exactly as if this preflight had never run.
8686 return protocol.IntentAttach, nil, nil
8787 }
88- exists, hostKnown := attachTargetSessionExists(service.Snapshot(), remoteTarget, name)
89- if exists || !hostKnown {
88+ if !daemonReportsSessionMissing(ctx, service, remoteTarget, name) {
9089 return protocol.IntentAttach, service, nil
9190 }
9291 resolved, err := confirmMissingSessionCreate(ctx, name, terminal)
@@ -97,25 +96,39 @@ func resolveAttachCreationIntent(ctx context.Context, intent uint8, name, remote
9796 return resolved, service, nil
9897 }
9998
100-// attachTargetSessionExists reports whether one exact-name attach target is
101-// present in the committed publication, and whether the target's daemon (the
102-// local daemon, or the named remote host) is even a configured observation.
103-// hostKnown is false only when the daemon itself is absent from the snapshot;
104-// a known daemon with no matching session name reports exists=false,
105-// hostKnown=true.
106-func attachTargetSessionExists(snapshot ports.BrokerSnapshot, remoteTarget, name string) (exists, hostKnown bool) {
107- var observation ports.BrokerDaemonObservation
108- var ok bool
109- if remoteTarget == "" {
110- observation, ok = localBrokerObservation(snapshot)
111- } else {
112- observation, ok = snapshot.Find(remoteTarget)
99+// attachSessionLister is the daemon listing seam; production starts the
100+// destination daemon if needed so restored sessions are included.
101+var attachSessionLister = func(ctx context.Context, service ports.BrokerService, route client.BrokerOperationRoute) ([]protocol.SessionInfo, error) {
102+ operations, err := client.NewBrokerOperations(service, clock.New())
103+ if err != nil {
104+ return nil, err
105+ }
106+ return operations.ListStarting(ctx, route)
107+}
108+
109+// daemonReportsSessionMissing reports true only when the destination daemon
110+// answered a listing that does not carry name. An unconfigured host or a
111+// failed listing proves nothing, so it reports false.
112+func daemonReportsSessionMissing(ctx context.Context, service ports.BrokerService, remoteTarget, name string) bool {
113+ snapshot := service.Snapshot()
114+ route := client.BrokerOperationRoute{Epoch: snapshot.Epoch, Destination: ports.BrokerEndpointFence{Local: true}}
115+ if remoteTarget != "" {
116+ observation, ok := snapshot.Find(remoteTarget)
117+ if !ok {
118+ return false
119+ }
120+ route.Destination = ports.BrokerEndpointFence{Registration: observation.Registration}
113121 }
114- if !ok {
115- return false, false
122+ sessions, err := attachSessionLister(ctx, service, route)
123+ if err != nil {
124+ return false
125+ }
126+ for _, session := range sessions {
127+ if session.Name == name {
128+ return false
129+ }
116130 }
117- _, found := brokerObservationExactTarget(observation, name)
118- return found, true
131+ return true
119132 }
120133
121134 // confirmMissingSessionCreate prompts to create an absent session and maps
@@ -7,8 +7,10 @@ import (
7 "os"7 "os"
8 "sync"8 "sync"
9 9
10+ "github.com/bnema/vev/internal/adapters/clock"
10 "github.com/bnema/vev/internal/ports"11 "github.com/bnema/vev/internal/ports"
11 "github.com/bnema/vev/internal/protocol"12 "github.com/bnema/vev/internal/protocol"
13+ "github.com/bnema/vev/internal/usecase/client"
12 "github.com/bnema/vev/internal/usecase/confirm"14 "github.com/bnema/vev/internal/usecase/confirm"
13 "github.com/bnema/vev/pkg/rawterm"15 "github.com/bnema/vev/pkg/rawterm"
14 )16 )
@@ -50,21 +52,19 @@ func (w flushingWriter) Write(data []byte) (int, error) {
50 return written, nil52 return written, nil
51 }53 }
52 54
53-// resolveAttachCreationIntent decides the attach intent for one direct exact55+// resolveAttachCreationIntent decides the attach intent for one direct
54 // `attach <name>` target (local when remoteTarget is empty, otherwise a56 // `attach <name>` target (local when remoteTarget is empty, otherwise a
55 // configured remote host) before the client owns any console reader or raw57 // configured remote host) before the client owns any console reader or raw
56-// mode. It reuses the broker's own attach resolution (the same exact-target58+// mode. Existence is asked of the destination daemon itself through a list
57-// lookup `localExactAttachResolver`/`remoteExactAttachResolver` run later) so59+// that may start it, never read from the broker catalogue: right after
58-// existence is decided from one committed broker publication, never from60+// `kill --all` the catalogue is empty while the daemon still restores the
59-// error-text matching.61+// session from disk.
60 //62 //
61-// When the session exists, the host is not configured, or existence cannot be63+// When the session exists, the host is not configured, or the daemon cannot
62-// determined (the broker is unreachable), it keeps IntentAttach unchanged and64+// answer, it keeps IntentAttach; the named attach then lets the daemon decide
63-// the resolver's own rejection, if any, surfaces unchanged later. When the65+// and report its own refusal. Only a daemon listing without the name offers
64-// session is confirmed absent on a known host, it offers creation on an66+// creation on an interactive console, returning IntentNew on confirmation; a
65-// interactive console and returns IntentNew on confirmation; a declined67+// declined answer or a non-interactive console keeps IntentAttach.
66-// answer, an unusable console, or a non-interactive console all keep
67-// IntentAttach so the existing resolver reports its own clear refusal.
68 //68 //
69 // The check runs before Supervisor.Run enters raw mode, so the create prompt69 // The check runs before Supervisor.Run enters raw mode, so the create prompt
70 // is always the sole console reader: no attachment worker can leave a70 // is always the sole console reader: no attachment worker can leave a
@@ -85,8 +85,7 @@ func resolveAttachCreationIntent(ctx context.Context, intent uint8, name, remote
85 // cadence) decide, exactly as if this preflight had never run.85 // cadence) decide, exactly as if this preflight had never run.
86 return protocol.IntentAttach, nil, nil86 return protocol.IntentAttach, nil, nil
87 }87 }
88- exists, hostKnown := attachTargetSessionExists(service.Snapshot(), remoteTarget, name)88+ if !daemonReportsSessionMissing(ctx, service, remoteTarget, name) {
89- if exists || !hostKnown {
90 return protocol.IntentAttach, service, nil89 return protocol.IntentAttach, service, nil
91 }90 }
92 resolved, err := confirmMissingSessionCreate(ctx, name, terminal)91 resolved, err := confirmMissingSessionCreate(ctx, name, terminal)
@@ -97,25 +96,39 @@ func resolveAttachCreationIntent(ctx context.Context, intent uint8, name, remote
97 return resolved, service, nil96 return resolved, service, nil
98 }97 }
99 98
100-// attachTargetSessionExists reports whether one exact-name attach target is99+// attachSessionLister is the daemon listing seam; production starts the
101-// present in the committed publication, and whether the target's daemon (the100+// destination daemon if needed so restored sessions are included.
102-// local daemon, or the named remote host) is even a configured observation.101+var attachSessionLister = func(ctx context.Context, service ports.BrokerService, route client.BrokerOperationRoute) ([]protocol.SessionInfo, error) {
103-// hostKnown is false only when the daemon itself is absent from the snapshot;102+ operations, err := client.NewBrokerOperations(service, clock.New())
104-// a known daemon with no matching session name reports exists=false,103+ if err != nil {
105-// hostKnown=true.104+ return nil, err
106-func attachTargetSessionExists(snapshot ports.BrokerSnapshot, remoteTarget, name string) (exists, hostKnown bool) {105+ }
107- var observation ports.BrokerDaemonObservation106+ return operations.ListStarting(ctx, route)
108- var ok bool107+}
109- if remoteTarget == "" {108+
110- observation, ok = localBrokerObservation(snapshot)109+// daemonReportsSessionMissing reports true only when the destination daemon
111- } else {110+// answered a listing that does not carry name. An unconfigured host or a
112- observation, ok = snapshot.Find(remoteTarget)111+// failed listing proves nothing, so it reports false.
112+func daemonReportsSessionMissing(ctx context.Context, service ports.BrokerService, remoteTarget, name string) bool {
113+ snapshot := service.Snapshot()
114+ route := client.BrokerOperationRoute{Epoch: snapshot.Epoch, Destination: ports.BrokerEndpointFence{Local: true}}
115+ if remoteTarget != "" {
116+ observation, ok := snapshot.Find(remoteTarget)
117+ if !ok {
118+ return false
119+ }
120+ route.Destination = ports.BrokerEndpointFence{Registration: observation.Registration}
113 }121 }
114- if !ok {122+ sessions, err := attachSessionLister(ctx, service, route)
115- return false, false123+ if err != nil {
124+ return false
125+ }
126+ for _, session := range sessions {
127+ if session.Name == name {
128+ return false
129+ }
116 }130 }
117- _, found := brokerObservationExactTarget(observation, name)131+ return true
118- return found, true
119 }132 }
120 133
121 // confirmMissingSessionCreate prompts to create an absent session and maps134 // confirmMissingSessionCreate prompts to create an absent session and maps
M internal/app/attach_missing_prompt_test.go +67 -62
@@ -6,11 +6,10 @@ package app
66 //
77 // resolveAttachCreationIntent restores the `vev attach <missing>` create
88 // prompt main once ran through the direct-dial preflight, but through the
9-// broker: it peeks the first committed publication over the same
10-// connectProductionClientBroker seam the ordinary terminal composition uses,
11-// decides existence with the exact lookup the later resolver also runs
12-// (attachTargetSessionExists / brokerObservationExactTarget), and on a
13-// confirmed create hands its already-open connection back so the run still
9+// broker: it connects over the same connectProductionClientBroker seam the
10+// ordinary terminal composition uses, asks the destination daemon for its
11+// session list (daemonReportsSessionMissing, starting it if needed), and on a
12+// definite answer hands its already-open connection back so the run still
1413 // opens exactly one broker connection.
1514
1615 import (
@@ -30,6 +29,7 @@ import (
3029 "github.com/bnema/vev/internal/ports"
3130 portsmocks "github.com/bnema/vev/internal/ports/mocks"
3231 "github.com/bnema/vev/internal/protocol"
32+ "github.com/bnema/vev/internal/usecase/client"
3333 "github.com/bnema/vev/pkg/rawterm"
3434 )
3535
@@ -59,66 +59,68 @@ func withPreflightBroker(t *testing.T, connect func(context.Context) (ports.Brok
5959 t.Cleanup(func() { connectProductionClientBroker = previous })
6060 }
6161
62-func TestAttachTargetSessionExists(t *testing.T) {
62+// withDaemonSessions installs a scripted daemon listing: the destination named
63+// by the route answers with sessions, or listErr when set. The broker
64+// catalogue is deliberately not consulted, matching production.
65+func withDaemonSessions(t *testing.T, sessions map[string][]string, listErr error) *[]client.BrokerOperationRoute {
66+ t.Helper()
67+ var routes []client.BrokerOperationRoute
68+ previous := attachSessionLister
69+ attachSessionLister = func(_ context.Context, _ ports.BrokerService, route client.BrokerOperationRoute) ([]protocol.SessionInfo, error) {
70+ routes = append(routes, route)
71+ if listErr != nil {
72+ return nil, listErr
73+ }
74+ key := route.Destination.Registration.Endpoint
75+ var out []protocol.SessionInfo
76+ for _, name := range sessions[key] {
77+ out = append(out, protocol.SessionInfo{Name: name})
78+ }
79+ return out, nil
80+ }
81+ t.Cleanup(func() { attachSessionLister = previous })
82+ return &routes
83+}
84+
85+func TestDaemonReportsSessionMissing(t *testing.T) {
86+ remote := "user@example.test"
6387 tests := []struct {
64- name string
65- snapshot ports.BrokerSnapshot
66- remote string
67- target string
68- wantExists bool
69- wantHostKnow bool
88+ name string
89+ snapshot ports.BrokerSnapshot
90+ daemon map[string][]string // listing per endpoint; "" is local
91+ listErr error
92+ remote string
93+ target string
94+ wantMissing bool
95+ wantListed bool
7096 }{
71- {
72- name: "local session present",
73- snapshot: terminalCompositionSnapshot([]string{"work"}, nil),
74- target: "work",
75- wantExists: true,
76- wantHostKnow: true,
77- },
78- {
79- name: "local session absent",
80- snapshot: terminalCompositionSnapshot([]string{"work"}, nil),
81- target: "gone",
82- wantExists: false,
83- wantHostKnow: true,
84- },
85- {
86- name: "local daemon absent from snapshot",
87- snapshot: ports.BrokerSnapshot{Epoch: 1, Revision: 1},
88- target: "gone",
89- wantExists: false,
90- wantHostKnow: false,
91- },
92- {
93- name: "remote session present",
94- snapshot: terminalCompositionSnapshot(nil, map[string][]string{"user@example.test": {"work"}}),
95- remote: "user@example.test",
96- target: "work",
97- wantExists: true,
98- wantHostKnow: true,
99- },
100- {
101- name: "remote session absent on known host",
102- snapshot: terminalCompositionSnapshot(nil, map[string][]string{"user@example.test": {"work"}}),
103- remote: "user@example.test",
104- target: "gone",
105- wantExists: false,
106- wantHostKnow: true,
107- },
108- {
109- name: "remote host not configured",
110- snapshot: terminalCompositionSnapshot([]string{"work"}, nil),
111- remote: "user@elsewhere.test",
112- target: "work",
113- wantExists: false,
114- wantHostKnow: false,
115- },
97+ {name: "local session present", snapshot: terminalCompositionSnapshot(nil, nil), daemon: map[string][]string{"": {"work"}}, target: "work", wantListed: true},
98+ {name: "local session absent", snapshot: terminalCompositionSnapshot(nil, nil), daemon: map[string][]string{"": {"work"}}, target: "gone", wantMissing: true, wantListed: true},
99+ {name: "catalogue empty but daemon restored it", snapshot: terminalCompositionSnapshot(nil, nil), daemon: map[string][]string{"": {"vev"}}, target: "vev", wantListed: true},
100+ {name: "daemon listing failed proves nothing", snapshot: terminalCompositionSnapshot(nil, nil), listErr: errors.New("unreachable"), target: "gone", wantListed: true},
101+ {name: "remote session present", snapshot: terminalCompositionSnapshot(nil, map[string][]string{remote: nil}), daemon: map[string][]string{remote: {"work"}}, remote: remote, target: "work", wantListed: true},
102+ {name: "remote session absent", snapshot: terminalCompositionSnapshot(nil, map[string][]string{remote: nil}), daemon: map[string][]string{remote: {"work"}}, remote: remote, target: "gone", wantMissing: true, wantListed: true},
103+ {name: "remote host not configured", snapshot: terminalCompositionSnapshot(nil, nil), remote: "user@elsewhere.test", target: "work"},
116104 }
117105 for _, tc := range tests {
118106 t.Run(tc.name, func(t *testing.T) {
119- exists, hostKnown := attachTargetSessionExists(tc.snapshot, tc.remote, tc.target)
120- require.Equal(t, tc.wantExists, exists)
121- require.Equal(t, tc.wantHostKnow, hostKnown)
107+ routes := withDaemonSessions(t, tc.daemon, tc.listErr)
108+ service := portsmocks.NewMockBrokerService(t)
109+ service.EXPECT().Snapshot().Return(tc.snapshot).Once()
110+
111+ require.Equal(t, tc.wantMissing, daemonReportsSessionMissing(context.Background(), service, tc.remote, tc.target))
112+ if !tc.wantListed {
113+ require.Empty(t, *routes, "an unconfigured host is never listed")
114+ return
115+ }
116+ require.Len(t, *routes, 1)
117+ route := (*routes)[0]
118+ require.Equal(t, tc.snapshot.Epoch, route.Epoch)
119+ require.Equal(t, tc.remote == "", route.Destination.Local)
120+ if tc.remote != "" {
121+ observation, _ := tc.snapshot.Find(tc.remote)
122+ require.True(t, route.Destination.Registration.Equal(observation.Registration))
123+ }
122124 })
123125 }
124126 }
@@ -160,7 +162,8 @@ func TestResolveAttachCreationIntentBrokerUnavailableKeepsAttach(t *testing.T) {
160162
161163 func TestResolveAttachCreationIntentExistingSessionAttachesWithoutPrompt(t *testing.T) {
162164 service := portsmocks.NewMockBrokerService(t)
163- service.EXPECT().Snapshot().Return(terminalCompositionSnapshot([]string{"work"}, nil)).Once()
165+ service.EXPECT().Snapshot().Return(terminalCompositionSnapshot(nil, nil)).Once()
166+ withDaemonSessions(t, map[string][]string{"": {"work"}}, nil)
164167 withPreflightBroker(t, func(context.Context) (ports.BrokerService, error) { return service, nil })
165168 withAttachInteractiveConsole(t, true) // must not even be consulted
166169
@@ -209,6 +212,7 @@ func TestResolveAttachCreationIntentMissingSessionPrompt(t *testing.T) {
209212 }
210213 service := portsmocks.NewMockBrokerService(t)
211214 service.EXPECT().Snapshot().Return(snapshot).Once()
215+ withDaemonSessions(t, map[string][]string{tc.remote: {"work"}}, nil)
212216 withPreflightBroker(t, func(context.Context) (ports.BrokerService, error) { return service, nil })
213217 withAttachInteractiveConsole(t, tc.interactive)
214218
@@ -234,8 +238,9 @@ func TestResolveAttachCreationIntentMissingSessionPrompt(t *testing.T) {
234238
235239 func TestResolveAttachCreationIntentCancelledPromptClosesThePeek(t *testing.T) {
236240 service := portsmocks.NewMockBrokerService(t)
237- service.EXPECT().Snapshot().Return(terminalCompositionSnapshot([]string{"work"}, nil)).Once()
241+ service.EXPECT().Snapshot().Return(terminalCompositionSnapshot(nil, nil)).Once()
238242 service.EXPECT().Close().Return(nil).Once()
243+ withDaemonSessions(t, nil, nil)
239244 withPreflightBroker(t, func(context.Context) (ports.BrokerService, error) { return service, nil })
240245 withAttachInteractiveConsole(t, true)
241246
@@ -6,11 +6,10 @@ package app
6 //6 //
7 // resolveAttachCreationIntent restores the `vev attach <missing>` create7 // resolveAttachCreationIntent restores the `vev attach <missing>` create
8 // prompt main once ran through the direct-dial preflight, but through the8 // prompt main once ran through the direct-dial preflight, but through the
9-// broker: it peeks the first committed publication over the same9+// broker: it connects over the same connectProductionClientBroker seam the
10-// connectProductionClientBroker seam the ordinary terminal composition uses,10+// ordinary terminal composition uses, asks the destination daemon for its
11-// decides existence with the exact lookup the later resolver also runs11+// session list (daemonReportsSessionMissing, starting it if needed), and on a
12-// (attachTargetSessionExists / brokerObservationExactTarget), and on a12+// definite answer hands its already-open connection back so the run still
13-// confirmed create hands its already-open connection back so the run still
14 // opens exactly one broker connection.13 // opens exactly one broker connection.
15 14
16 import (15 import (
@@ -30,6 +29,7 @@ import (
30 "github.com/bnema/vev/internal/ports"29 "github.com/bnema/vev/internal/ports"
31 portsmocks "github.com/bnema/vev/internal/ports/mocks"30 portsmocks "github.com/bnema/vev/internal/ports/mocks"
32 "github.com/bnema/vev/internal/protocol"31 "github.com/bnema/vev/internal/protocol"
32+ "github.com/bnema/vev/internal/usecase/client"
33 "github.com/bnema/vev/pkg/rawterm"33 "github.com/bnema/vev/pkg/rawterm"
34 )34 )
35 35
@@ -59,66 +59,68 @@ func withPreflightBroker(t *testing.T, connect func(context.Context) (ports.Brok
59 t.Cleanup(func() { connectProductionClientBroker = previous })59 t.Cleanup(func() { connectProductionClientBroker = previous })
60 }60 }
61 61
62-func TestAttachTargetSessionExists(t *testing.T) {62+// withDaemonSessions installs a scripted daemon listing: the destination named
63+// by the route answers with sessions, or listErr when set. The broker
64+// catalogue is deliberately not consulted, matching production.
65+func withDaemonSessions(t *testing.T, sessions map[string][]string, listErr error) *[]client.BrokerOperationRoute {
66+ t.Helper()
67+ var routes []client.BrokerOperationRoute
68+ previous := attachSessionLister
69+ attachSessionLister = func(_ context.Context, _ ports.BrokerService, route client.BrokerOperationRoute) ([]protocol.SessionInfo, error) {
70+ routes = append(routes, route)
71+ if listErr != nil {
72+ return nil, listErr
73+ }
74+ key := route.Destination.Registration.Endpoint
75+ var out []protocol.SessionInfo
76+ for _, name := range sessions[key] {
77+ out = append(out, protocol.SessionInfo{Name: name})
78+ }
79+ return out, nil
80+ }
81+ t.Cleanup(func() { attachSessionLister = previous })
82+ return &routes
83+}
84+
85+func TestDaemonReportsSessionMissing(t *testing.T) {
86+ remote := "user@example.test"
63 tests := []struct {87 tests := []struct {
64- name string88+ name string
65- snapshot ports.BrokerSnapshot89+ snapshot ports.BrokerSnapshot
66- remote string90+ daemon map[string][]string // listing per endpoint; "" is local
67- target string91+ listErr error
68- wantExists bool92+ remote string
69- wantHostKnow bool93+ target string
94+ wantMissing bool
95+ wantListed bool
70 }{96 }{
71- {97+ {name: "local session present", snapshot: terminalCompositionSnapshot(nil, nil), daemon: map[string][]string{"": {"work"}}, target: "work", wantListed: true},
72- name: "local session present",98+ {name: "local session absent", snapshot: terminalCompositionSnapshot(nil, nil), daemon: map[string][]string{"": {"work"}}, target: "gone", wantMissing: true, wantListed: true},
73- snapshot: terminalCompositionSnapshot([]string{"work"}, nil),99+ {name: "catalogue empty but daemon restored it", snapshot: terminalCompositionSnapshot(nil, nil), daemon: map[string][]string{"": {"vev"}}, target: "vev", wantListed: true},
74- target: "work",100+ {name: "daemon listing failed proves nothing", snapshot: terminalCompositionSnapshot(nil, nil), listErr: errors.New("unreachable"), target: "gone", wantListed: true},
75- wantExists: true,101+ {name: "remote session present", snapshot: terminalCompositionSnapshot(nil, map[string][]string{remote: nil}), daemon: map[string][]string{remote: {"work"}}, remote: remote, target: "work", wantListed: true},
76- wantHostKnow: true,102+ {name: "remote session absent", snapshot: terminalCompositionSnapshot(nil, map[string][]string{remote: nil}), daemon: map[string][]string{remote: {"work"}}, remote: remote, target: "gone", wantMissing: true, wantListed: true},
77- },103+ {name: "remote host not configured", snapshot: terminalCompositionSnapshot(nil, nil), remote: "user@elsewhere.test", target: "work"},
78- {
79- name: "local session absent",
80- snapshot: terminalCompositionSnapshot([]string{"work"}, nil),
81- target: "gone",
82- wantExists: false,
83- wantHostKnow: true,
84- },
85- {
86- name: "local daemon absent from snapshot",
87- snapshot: ports.BrokerSnapshot{Epoch: 1, Revision: 1},
88- target: "gone",
89- wantExists: false,
90- wantHostKnow: false,
91- },
92- {
93- name: "remote session present",
94- snapshot: terminalCompositionSnapshot(nil, map[string][]string{"user@example.test": {"work"}}),
95- remote: "user@example.test",
96- target: "work",
97- wantExists: true,
98- wantHostKnow: true,
99- },
100- {
101- name: "remote session absent on known host",
102- snapshot: terminalCompositionSnapshot(nil, map[string][]string{"user@example.test": {"work"}}),
103- remote: "user@example.test",
104- target: "gone",
105- wantExists: false,
106- wantHostKnow: true,
107- },
108- {
109- name: "remote host not configured",
110- snapshot: terminalCompositionSnapshot([]string{"work"}, nil),
111- remote: "user@elsewhere.test",
112- target: "work",
113- wantExists: false,
114- wantHostKnow: false,
115- },
116 }104 }
117 for _, tc := range tests {105 for _, tc := range tests {
118 t.Run(tc.name, func(t *testing.T) {106 t.Run(tc.name, func(t *testing.T) {
119- exists, hostKnown := attachTargetSessionExists(tc.snapshot, tc.remote, tc.target)107+ routes := withDaemonSessions(t, tc.daemon, tc.listErr)
120- require.Equal(t, tc.wantExists, exists)108+ service := portsmocks.NewMockBrokerService(t)
121- require.Equal(t, tc.wantHostKnow, hostKnown)109+ service.EXPECT().Snapshot().Return(tc.snapshot).Once()
110+
111+ require.Equal(t, tc.wantMissing, daemonReportsSessionMissing(context.Background(), service, tc.remote, tc.target))
112+ if !tc.wantListed {
113+ require.Empty(t, *routes, "an unconfigured host is never listed")
114+ return
115+ }
116+ require.Len(t, *routes, 1)
117+ route := (*routes)[0]
118+ require.Equal(t, tc.snapshot.Epoch, route.Epoch)
119+ require.Equal(t, tc.remote == "", route.Destination.Local)
120+ if tc.remote != "" {
121+ observation, _ := tc.snapshot.Find(tc.remote)
122+ require.True(t, route.Destination.Registration.Equal(observation.Registration))
123+ }
122 })124 })
123 }125 }
124 }126 }
@@ -160,7 +162,8 @@ func TestResolveAttachCreationIntentBrokerUnavailableKeepsAttach(t *testing.T) {
160 162
161 func TestResolveAttachCreationIntentExistingSessionAttachesWithoutPrompt(t *testing.T) {163 func TestResolveAttachCreationIntentExistingSessionAttachesWithoutPrompt(t *testing.T) {
162 service := portsmocks.NewMockBrokerService(t)164 service := portsmocks.NewMockBrokerService(t)
163- service.EXPECT().Snapshot().Return(terminalCompositionSnapshot([]string{"work"}, nil)).Once()165+ service.EXPECT().Snapshot().Return(terminalCompositionSnapshot(nil, nil)).Once()
166+ withDaemonSessions(t, map[string][]string{"": {"work"}}, nil)
164 withPreflightBroker(t, func(context.Context) (ports.BrokerService, error) { return service, nil })167 withPreflightBroker(t, func(context.Context) (ports.BrokerService, error) { return service, nil })
165 withAttachInteractiveConsole(t, true) // must not even be consulted168 withAttachInteractiveConsole(t, true) // must not even be consulted
166 169
@@ -209,6 +212,7 @@ func TestResolveAttachCreationIntentMissingSessionPrompt(t *testing.T) {
209 }212 }
210 service := portsmocks.NewMockBrokerService(t)213 service := portsmocks.NewMockBrokerService(t)
211 service.EXPECT().Snapshot().Return(snapshot).Once()214 service.EXPECT().Snapshot().Return(snapshot).Once()
215+ withDaemonSessions(t, map[string][]string{tc.remote: {"work"}}, nil)
212 withPreflightBroker(t, func(context.Context) (ports.BrokerService, error) { return service, nil })216 withPreflightBroker(t, func(context.Context) (ports.BrokerService, error) { return service, nil })
213 withAttachInteractiveConsole(t, tc.interactive)217 withAttachInteractiveConsole(t, tc.interactive)
214 218
@@ -234,8 +238,9 @@ func TestResolveAttachCreationIntentMissingSessionPrompt(t *testing.T) {
234 238
235 func TestResolveAttachCreationIntentCancelledPromptClosesThePeek(t *testing.T) {239 func TestResolveAttachCreationIntentCancelledPromptClosesThePeek(t *testing.T) {
236 service := portsmocks.NewMockBrokerService(t)240 service := portsmocks.NewMockBrokerService(t)
237- service.EXPECT().Snapshot().Return(terminalCompositionSnapshot([]string{"work"}, nil)).Once()241+ service.EXPECT().Snapshot().Return(terminalCompositionSnapshot(nil, nil)).Once()
238 service.EXPECT().Close().Return(nil).Once()242 service.EXPECT().Close().Return(nil).Once()
243+ withDaemonSessions(t, nil, nil)
239 withPreflightBroker(t, func(context.Context) (ports.BrokerService, error) { return service, nil })244 withPreflightBroker(t, func(context.Context) (ports.BrokerService, error) { return service, nil })
240 withAttachInteractiveConsole(t, true)245 withAttachInteractiveConsole(t, true)
241 246
M internal/app/broker_client.go +25 -27
@@ -334,23 +334,22 @@ func localEphemeralNavigation() client.InitialNavigation {
334334 }
335335 }
336336
337-// errTerminalNavigationUnresolved reports that a CLI target could not be
338-// translated into the exact identity a snapshot carries. It is a local refusal:
337+// errTerminalNavigationUnresolved reports that a CLI target names a host the
338+// broker catalogue does not carry. It is a local refusal:
339339 // no destination was dialed, so the supervisor surfaces it as a bounded
340340 // selection-unavailable notice and returns to the picker instead of attaching a
341341 // fallback or creating implicitly.
342-var errTerminalNavigationUnresolved = errors.New("vev: attach target is not in the broker catalogue")
342+var errTerminalNavigationUnresolved = errors.New("vev: attach host is not in the broker catalogue")
343343
344344 // terminalBrokerNavigation translates the parsed terminal CLI intent into the
345345 // closed initial-navigation union. An intent that is fully determined before
346-// the connection (local creation) is a value; an intent whose exact identity
347-// only exists in a broker publication (`attach`, and any remote target) is a
348-// single-shot resolver that runs once against the first committed snapshot.
346+// the connection (local creation, local named attach) is a value; a remote
347+// target is a single-shot resolver that runs once against the first committed
348+// snapshot to fence the host registration.
349349 //
350-// The translation never invents identity: a remote destination is only ever the
351-// complete registration a snapshot carries, and an exact target is only ever
352-// the lifecycle/name pair the snapshot publishes. A target the snapshot does not
353-// carry is a refusal, never an implicit create and never a same-name fallback.
350+// Named attach never consults the catalogue for session existence: the daemon
351+// restores or refuses the name. A remote destination is only ever the complete
352+// registration a snapshot carries; an unknown host is a refusal.
354353 func terminalBrokerNavigation(intent uint8, name, remoteTarget string) (client.InitialNavigation, client.InitialNavigationResolver, error) {
355354 if remoteTarget == "" {
356355 switch intent {
@@ -369,7 +368,13 @@ func terminalBrokerNavigation(intent uint8, name, remoteTarget string) (client.I
369368 if err := domain.ValidateSessionName(name); err != nil {
370369 return client.InitialNavigation{}, nil, err
371370 }
372- return client.InitialNavigation{}, localExactAttachResolver(name), nil
371+ // The daemon resolves the name, so a catalogue that has not
372+ // observed the session yet cannot refuse it.
373+ return client.InitialNavigation{
374+ Kind: client.InitialNavigationAttachNamed,
375+ Destination: ports.BrokerEndpointFence{Local: true},
376+ Name: name,
377+ }, nil, nil
373378 default:
374379 return client.InitialNavigation{}, nil, usagef("unsupported attach intent %d", intent)
375380 }
@@ -391,32 +396,25 @@ func terminalBrokerNavigation(intent uint8, name, remoteTarget string) (client.I
391396 if err := domain.ValidateSessionName(name); err != nil {
392397 return client.InitialNavigation{}, nil, err
393398 }
394- return client.InitialNavigation{}, remoteExactAttachResolver(remoteTarget, name), nil
399+ return client.InitialNavigation{}, remoteNamedAttachResolver(remoteTarget, name), nil
395400 default:
396401 return client.InitialNavigation{}, nil, usagef("unsupported attach intent %d", intent)
397402 }
398403 }
399404
400-// localExactAttachResolver resolves one local session name to its exact
401-// lifecycle in the committed publication. `attach <name>` is a target
402-// translation only: a name the local observation does not carry is refused, so
403-// the client returns to the picker with a notice instead of creating a session
404-// implicitly.
405-func localExactAttachResolver(name string) client.InitialNavigationResolver {
405+// remoteNamedAttachResolver binds a remote named attach to the endpoint's
406+// committed registration; the remote daemon resolves the name.
407+func remoteNamedAttachResolver(endpoint, name string) client.InitialNavigationResolver {
406408 return func(snapshot ports.BrokerSnapshot) (client.InitialNavigation, error) {
407- observation, ok := localBrokerObservation(snapshot)
408- if !ok {
409- return client.InitialNavigation{}, fmt.Errorf("%w: no local daemon observation", errTerminalNavigationUnresolved)
410- }
411- target, ok := brokerObservationExactTarget(observation, name)
409+ observation, ok := snapshot.Find(endpoint)
412410 if !ok {
413- return client.InitialNavigation{}, fmt.Errorf("%w: %q", errTerminalNavigationUnresolved, name)
411+ return client.InitialNavigation{}, fmt.Errorf("%w: %q is not a configured broker host", errTerminalNavigationUnresolved, endpoint)
414412 }
415413 return client.InitialNavigation{
416- Kind: client.InitialNavigationAttachExact,
414+ Kind: client.InitialNavigationAttachNamed,
417415 Epoch: snapshot.Epoch,
418- Destination: ports.BrokerEndpointFence{Local: true},
419- Target: target,
416+ Destination: ports.BrokerEndpointFence{Registration: observation.Registration},
417+ Name: name,
420418 }, nil
421419 }
422420 }
@@ -334,23 +334,22 @@ func localEphemeralNavigation() client.InitialNavigation {
334 }334 }
335 }335 }
336 336
337-// errTerminalNavigationUnresolved reports that a CLI target could not be337+// errTerminalNavigationUnresolved reports that a CLI target names a host the
338-// translated into the exact identity a snapshot carries. It is a local refusal:338+// broker catalogue does not carry. It is a local refusal:
339 // no destination was dialed, so the supervisor surfaces it as a bounded339 // no destination was dialed, so the supervisor surfaces it as a bounded
340 // selection-unavailable notice and returns to the picker instead of attaching a340 // selection-unavailable notice and returns to the picker instead of attaching a
341 // fallback or creating implicitly.341 // fallback or creating implicitly.
342-var errTerminalNavigationUnresolved = errors.New("vev: attach target is not in the broker catalogue")342+var errTerminalNavigationUnresolved = errors.New("vev: attach host is not in the broker catalogue")
343 343
344 // terminalBrokerNavigation translates the parsed terminal CLI intent into the344 // terminalBrokerNavigation translates the parsed terminal CLI intent into the
345 // closed initial-navigation union. An intent that is fully determined before345 // closed initial-navigation union. An intent that is fully determined before
346-// the connection (local creation) is a value; an intent whose exact identity346+// the connection (local creation, local named attach) is a value; a remote
347-// only exists in a broker publication (`attach`, and any remote target) is a347+// target is a single-shot resolver that runs once against the first committed
348-// single-shot resolver that runs once against the first committed snapshot.348+// snapshot to fence the host registration.
349 //349 //
350-// The translation never invents identity: a remote destination is only ever the350+// Named attach never consults the catalogue for session existence: the daemon
351-// complete registration a snapshot carries, and an exact target is only ever351+// restores or refuses the name. A remote destination is only ever the complete
352-// the lifecycle/name pair the snapshot publishes. A target the snapshot does not352+// registration a snapshot carries; an unknown host is a refusal.
353-// carry is a refusal, never an implicit create and never a same-name fallback.
354 func terminalBrokerNavigation(intent uint8, name, remoteTarget string) (client.InitialNavigation, client.InitialNavigationResolver, error) {353 func terminalBrokerNavigation(intent uint8, name, remoteTarget string) (client.InitialNavigation, client.InitialNavigationResolver, error) {
355 if remoteTarget == "" {354 if remoteTarget == "" {
356 switch intent {355 switch intent {
@@ -369,7 +368,13 @@ func terminalBrokerNavigation(intent uint8, name, remoteTarget string) (client.I
369 if err := domain.ValidateSessionName(name); err != nil {368 if err := domain.ValidateSessionName(name); err != nil {
370 return client.InitialNavigation{}, nil, err369 return client.InitialNavigation{}, nil, err
371 }370 }
372- return client.InitialNavigation{}, localExactAttachResolver(name), nil371+ // The daemon resolves the name, so a catalogue that has not
372+ // observed the session yet cannot refuse it.
373+ return client.InitialNavigation{
374+ Kind: client.InitialNavigationAttachNamed,
375+ Destination: ports.BrokerEndpointFence{Local: true},
376+ Name: name,
377+ }, nil, nil
373 default:378 default:
374 return client.InitialNavigation{}, nil, usagef("unsupported attach intent %d", intent)379 return client.InitialNavigation{}, nil, usagef("unsupported attach intent %d", intent)
375 }380 }
@@ -391,32 +396,25 @@ func terminalBrokerNavigation(intent uint8, name, remoteTarget string) (client.I
391 if err := domain.ValidateSessionName(name); err != nil {396 if err := domain.ValidateSessionName(name); err != nil {
392 return client.InitialNavigation{}, nil, err397 return client.InitialNavigation{}, nil, err
393 }398 }
394- return client.InitialNavigation{}, remoteExactAttachResolver(remoteTarget, name), nil399+ return client.InitialNavigation{}, remoteNamedAttachResolver(remoteTarget, name), nil
395 default:400 default:
396 return client.InitialNavigation{}, nil, usagef("unsupported attach intent %d", intent)401 return client.InitialNavigation{}, nil, usagef("unsupported attach intent %d", intent)
397 }402 }
398 }403 }
399 404
400-// localExactAttachResolver resolves one local session name to its exact405+// remoteNamedAttachResolver binds a remote named attach to the endpoint's
401-// lifecycle in the committed publication. `attach <name>` is a target406+// committed registration; the remote daemon resolves the name.
402-// translation only: a name the local observation does not carry is refused, so407+func remoteNamedAttachResolver(endpoint, name string) client.InitialNavigationResolver {
403-// the client returns to the picker with a notice instead of creating a session
404-// implicitly.
405-func localExactAttachResolver(name string) client.InitialNavigationResolver {
406 return func(snapshot ports.BrokerSnapshot) (client.InitialNavigation, error) {408 return func(snapshot ports.BrokerSnapshot) (client.InitialNavigation, error) {
407- observation, ok := localBrokerObservation(snapshot)409+ observation, ok := snapshot.Find(endpoint)
408- if !ok {
409- return client.InitialNavigation{}, fmt.Errorf("%w: no local daemon observation", errTerminalNavigationUnresolved)
410- }
411- target, ok := brokerObservationExactTarget(observation, name)
412 if !ok {410 if !ok {
413- return client.InitialNavigation{}, fmt.Errorf("%w: %q", errTerminalNavigationUnresolved, name)411+ return client.InitialNavigation{}, fmt.Errorf("%w: %q is not a configured broker host", errTerminalNavigationUnresolved, endpoint)
414 }412 }
415 return client.InitialNavigation{413 return client.InitialNavigation{
416- Kind: client.InitialNavigationAttachExact,414+ Kind: client.InitialNavigationAttachNamed,
417 Epoch: snapshot.Epoch,415 Epoch: snapshot.Epoch,
418- Destination: ports.BrokerEndpointFence{Local: true},416+ Destination: ports.BrokerEndpointFence{Registration: observation.Registration},
419- Target: target,417+ Name: name,
420 }, nil418 }, nil
421 }419 }
422 }420 }
M internal/app/broker_client_test.go +135 -26
@@ -127,12 +127,15 @@ func sortStrings(values []string) {
127127 type terminalCompositionService struct {
128128 mu sync.Mutex
129129 snapshot ports.BrokerSnapshot
130- closed bool
131- next ports.BrokerStreamID
132- opened []ports.BrokerOpenStreamRequest
133- streams []*terminalCompositionStream
134- done chan struct{}
135- changed chan struct{}
130+ // daemonSessions, when set, is what the local daemon owns regardless of
131+ // what the broker catalogue has observed.
132+ daemonSessions []string
133+ closed bool
134+ next ports.BrokerStreamID
135+ opened []ports.BrokerOpenStreamRequest
136+ streams []*terminalCompositionStream
137+ done chan struct{}
138+ changed chan struct{}
136139 }
137140
138141 func newTerminalCompositionService(snapshot ports.BrokerSnapshot) *terminalCompositionService {
@@ -177,7 +180,11 @@ func (s *terminalCompositionService) OpenStream(_ context.Context, request ports
177180 return nil, ports.BrokerAdmissionClosed
178181 }
179182 s.opened = append(s.opened, request)
180- stream := newTerminalCompositionStream(request)
183+ sessions := terminalCompositionDaemonSessions(s.snapshot, request)
184+ if request.Local && s.daemonSessions != nil {
185+ sessions = terminalCompositionSessions(s.daemonSessions)
186+ }
187+ stream := newTerminalCompositionStream(request, sessions)
181188 s.streams = append(s.streams, stream)
182189 s.mu.Unlock()
183190 return stream, nil
@@ -211,10 +218,26 @@ func (s *terminalCompositionService) Close() error {
211218 return nil
212219 }
213220
221+// openedRequests returns the attachment streams opened; the preflight's
222+// control listings are reported by openedControls.
214223 func (s *terminalCompositionService) openedRequests() []ports.BrokerOpenStreamRequest {
224+ return s.openedWithPurpose(ports.BrokerStreamAttachment)
225+}
226+
227+func (s *terminalCompositionService) openedControls() []ports.BrokerOpenStreamRequest {
228+ return s.openedWithPurpose(ports.BrokerStreamControl)
229+}
230+
231+func (s *terminalCompositionService) openedWithPurpose(purpose ports.BrokerStreamPurpose) []ports.BrokerOpenStreamRequest {
215232 s.mu.Lock()
216233 defer s.mu.Unlock()
217- return append([]ports.BrokerOpenStreamRequest(nil), s.opened...)
234+ var out []ports.BrokerOpenStreamRequest
235+ for _, request := range s.opened {
236+ if request.Purpose == purpose {
237+ out = append(out, request)
238+ }
239+ }
240+ return out
218241 }
219242
220243 // streamHello returns the Hello the supervisor sent on the single admitted
@@ -223,7 +246,12 @@ func (s *terminalCompositionService) openedRequests() []ports.BrokerOpenStreamRe
223246 func (s *terminalCompositionService) streamHello(t *testing.T) protocol.Hello {
224247 t.Helper()
225248 s.mu.Lock()
226- streams := append([]*terminalCompositionStream(nil), s.streams...)
249+ var streams []*terminalCompositionStream
250+ for _, stream := range s.streams {
251+ if stream.request.Purpose == ports.BrokerStreamAttachment {
252+ streams = append(streams, stream)
253+ }
254+ }
227255 s.mu.Unlock()
228256 require.Len(t, streams, 1)
229257 for _, message := range streams[0].sentMessages() {
@@ -253,8 +281,33 @@ type terminalCompositionStream struct {
253281 done chan struct{}
254282 }
255283
256-func newTerminalCompositionStream(request ports.BrokerOpenStreamRequest) *terminalCompositionStream {
284+// terminalCompositionDaemonSessions is the session list the scripted
285+// destination daemon owns: the sessions its observation publishes.
286+func terminalCompositionDaemonSessions(snapshot ports.BrokerSnapshot, request ports.BrokerOpenStreamRequest) []catalogue.RemoteCatalogSession {
287+ for _, observation := range snapshot.Daemons {
288+ if observation.Local == request.Local && (request.Local || observation.Endpoint == request.Endpoint) {
289+ return observation.Sessions
290+ }
291+ }
292+ return nil
293+}
294+
295+func newTerminalCompositionStream(request ports.BrokerOpenStreamRequest, sessions []catalogue.RemoteCatalogSession) *terminalCompositionStream {
257296 stream := &terminalCompositionStream{request: request, done: make(chan struct{})}
297+ if request.Purpose == ports.BrokerStreamControl {
298+ // The scripted daemon answers a List with its own sessions.
299+ infos := make([]protocol.SessionInfo, 0, len(sessions))
300+ for _, session := range sessions {
301+ infos = append(infos, protocol.SessionInfo{Name: session.Name})
302+ }
303+ stream.queue = append(stream.queue, protocol.Sessions{Sessions: infos})
304+ return stream
305+ }
306+ if request.Admission == ports.BrokerAdmissionAttachNamed && !slices.ContainsFunc(sessions, func(session catalogue.RemoteCatalogSession) bool { return session.Name == request.Name }) {
307+ // Like the real daemon, an unknown name is refused, never created.
308+ stream.queue = append(stream.queue, protocol.ErrorMsg{Code: protocol.ErrNoSuchSession, Text: "no such resumable session: " + request.Name})
309+ return stream
310+ }
258311 target := terminalCompositionCommittedTarget(request)
259312 stream.queue = append(stream.queue, protocol.Welcome{
260313 SessionID: "sess", SessionName: request.Name,
@@ -465,12 +518,20 @@ func (r *terminalCompositionRun) wait(t *testing.T) error {
465518 }
466519
467520 func startTerminalComposition(t *testing.T, intent uint8, name, remoteTarget string, snapshot ports.BrokerSnapshot) *terminalCompositionRun {
521+ t.Helper()
522+ return startTerminalCompositionWithDaemon(t, intent, name, remoteTarget, snapshot, nil)
523+}
524+
525+// startTerminalCompositionWithDaemon is startTerminalComposition whose local
526+// daemon owns daemonSessions even when the catalogue has not observed them.
527+func startTerminalCompositionWithDaemon(t *testing.T, intent uint8, name, remoteTarget string, snapshot ports.BrokerSnapshot, daemonSessions []string) *terminalCompositionRun {
468528 t.Helper()
469529 // The ordinary terminal path reads the nested-session and remote-transport
470530 // environment; a test drives the broker composition with both unset.
471531 t.Setenv("VEV", "")
472532 t.Setenv(envRemoteTransport, "")
473533 service := newTerminalCompositionService(snapshot)
534+ service.daemonSessions = daemonSessions
474535 terminal := newTerminalCompositionTerminal()
475536 previousConnect := connectProductionClientBroker
476537 previousTerminal := terminalForAttach
@@ -582,8 +643,8 @@ func TestTerminalNavigationTranslationClosesTheIntent(t *testing.T) {
582643 }{
583644 {name: "no arguments creates a local ephemeral session", intent: protocol.IntentEphemeral, wantKind: client.InitialNavigationCreateEphemeral, wantLocal: true},
584645 {name: "new creates a local named session", intent: protocol.IntentNew, session: "scratch", wantKind: client.InitialNavigationCreateNamed, wantLocal: true, wantName: "scratch"},
585- {name: "attach resolves a local exact target", intent: protocol.IntentAttach, session: "work", wantResolver: true},
586- {name: "remote attach resolves an exact remote target", intent: protocol.IntentAttach, session: "work", remote: "user@example.test", wantResolver: true},
646+ {name: "attach names a local session for the daemon to resolve", intent: protocol.IntentAttach, session: "work", wantKind: client.InitialNavigationAttachNamed, wantLocal: true, wantName: "work"},
647+ {name: "remote attach resolves the remote registration", intent: protocol.IntentAttach, session: "work", remote: "user@example.test", wantResolver: true},
587648 {name: "remote new resolves a named remote creation", intent: protocol.IntentNew, session: "work", remote: "user@example.test", wantResolver: true},
588649 {name: "remote ephemeral attach resolves a remote creation", intent: protocol.IntentEphemeral, remote: "user@example.test", wantResolver: true},
589650 {name: "invalid local name is refused", intent: protocol.IntentNew, session: "bad name", wantError: true},
@@ -624,24 +685,26 @@ func TestTerminalCompositionOpensTheExactBrokerStream(t *testing.T) {
624685 return terminalCompositionSnapshot(nil, map[string][]string{"user@example.test": {"work"}})
625686 }
626687 tests := []struct {
627- name string
628- intent uint8
629- session string
630- remote string
631- snapshot ports.BrokerSnapshot
632- wantAdmission ports.BrokerStreamAdmission
633- wantLocal bool
634- wantName string
635- wantHello uint8
688+ name string
689+ intent uint8
690+ session string
691+ remote string
692+ snapshot ports.BrokerSnapshot
693+ daemonSessions []string
694+ wantAdmission ports.BrokerStreamAdmission
695+ wantLocal bool
696+ wantName string
697+ wantHello uint8
636698 }{
637699 {name: "no arguments creates a local ephemeral session", intent: protocol.IntentEphemeral, snapshot: localSnapshot(), wantAdmission: ports.BrokerAdmissionCreateEphemeral, wantLocal: true, wantHello: protocol.IntentEphemeral},
638700 {name: "new creates a local named session", intent: protocol.IntentNew, session: "scratch", snapshot: localSnapshot(), wantAdmission: ports.BrokerAdmissionCreateNamed, wantLocal: true, wantName: "scratch", wantHello: protocol.IntentNew},
639- {name: "attach resolves the local session lifecycle", intent: protocol.IntentAttach, session: "work", snapshot: localSnapshot(), wantAdmission: ports.BrokerAdmissionExact, wantLocal: true, wantHello: protocol.IntentAttach},
640- {name: "remote attach resolves the remote session lifecycle", intent: protocol.IntentAttach, session: "work", remote: "user@example.test", snapshot: remoteSnapshot(), wantAdmission: ports.BrokerAdmissionExact, wantHello: protocol.IntentAttach},
701+ {name: "attach names the local session", intent: protocol.IntentAttach, session: "work", snapshot: localSnapshot(), wantAdmission: ports.BrokerAdmissionAttachNamed, wantLocal: true, wantName: "work", wantHello: protocol.IntentAttach},
702+ {name: "remote attach names the remote session", intent: protocol.IntentAttach, session: "work", remote: "user@example.test", snapshot: remoteSnapshot(), wantAdmission: ports.BrokerAdmissionAttachNamed, wantName: "work", wantHello: protocol.IntentAttach},
703+ {name: "attach reaches a session the catalogue has not observed yet", intent: protocol.IntentAttach, session: "work", snapshot: terminalCompositionUnobservedLocal(), daemonSessions: []string{"work"}, wantAdmission: ports.BrokerAdmissionAttachNamed, wantLocal: true, wantName: "work", wantHello: protocol.IntentAttach},
641704 }
642705 for _, tc := range tests {
643706 t.Run(tc.name, func(t *testing.T) {
644- run := startTerminalComposition(t, tc.intent, tc.session, tc.remote, tc.snapshot)
707+ run := startTerminalCompositionWithDaemon(t, tc.intent, tc.session, tc.remote, tc.snapshot, tc.daemonSessions)
645708 awaitTerminalCompositionAttachment(t, run)
646709 opened := run.service.openedRequests()
647710 require.Len(t, opened, 1, "one CLI target opens exactly one broker stream")
@@ -674,10 +737,25 @@ func TestTerminalCompositionOpensTheExactBrokerStream(t *testing.T) {
674737 require.NotNil(t, hello.ExactTarget)
675738 require.Equal(t, request.Target, *hello.ExactTarget)
676739 }
740+ if tc.wantAdmission == ports.BrokerAdmissionAttachNamed {
741+ require.Nil(t, hello.ExactTarget, "a named attach leaves identity to the daemon")
742+ controls := run.service.openedControls()
743+ require.Len(t, controls, 1, "the preflight asks the daemon once")
744+ require.Equal(t, ports.BrokerDaemonStartIfNeeded, controls[0].StartMode, "the preflight may start a stopped daemon")
745+ }
677746 })
678747 }
679748 }
680749
750+// terminalCompositionUnobservedLocal is the publication a freshly started
751+// broker commits right after `kill --all`: the local daemon is known but its
752+// sessions are not observed yet, while the daemon itself restores them.
753+func terminalCompositionUnobservedLocal() ports.BrokerSnapshot {
754+ snapshot := terminalCompositionSnapshot(nil, nil)
755+ snapshot.Daemons[0].InventoryKnown = false
756+ return snapshot
757+}
758+
681759 // TestTerminalCompositionPublishesRealSessionOutput proves an admitted
682760 // attachment is a committed publication: the daemon's initial full frame reached
683761 // the terminal through a committed UI output transaction, and the supervisor
@@ -717,10 +795,8 @@ func TestTerminalCompositionRefusedTargetOpensNothingAndKeepsThePicker(t *testin
717795 remote string
718796 snapshot ports.BrokerSnapshot
719797 }{
720- {name: "missing local session", intent: protocol.IntentAttach, session: "gone", snapshot: terminalCompositionSnapshot([]string{"work"}, nil)},
721798 {name: "local daemon absent", intent: protocol.IntentEphemeral, snapshot: ports.BrokerSnapshot{Epoch: terminalCompositionEpoch, Revision: 1}},
722799 {name: "unconfigured remote host", intent: protocol.IntentAttach, session: "work", remote: "user@elsewhere.test", snapshot: terminalCompositionSnapshot([]string{"work"}, nil)},
723- {name: "missing remote session", intent: protocol.IntentAttach, session: "gone", remote: "user@example.test", snapshot: terminalCompositionSnapshot(nil, map[string][]string{"user@example.test": {"work"}})},
724800 }
725801 for _, tc := range tests {
726802 t.Run(tc.name, func(t *testing.T) {
@@ -754,6 +830,39 @@ func TestTerminalCompositionRefusedTargetOpensNothingAndKeepsThePicker(t *testin
754830 }
755831 }
756832
833+// TestTerminalCompositionDaemonRefusesMissingName pins that existence is the
834+// daemon's decision: a declined (non-interactive) create prompt keeps the
835+// named attach, the daemon answers ErrNoSuchSession, and the client reports
836+// that refusal without attaching or creating.
837+func TestTerminalCompositionDaemonRefusesMissingName(t *testing.T) {
838+ tests := []struct {
839+ name string
840+ remote string
841+ snapshot ports.BrokerSnapshot
842+ }{
843+ {name: "local", snapshot: terminalCompositionSnapshot([]string{"work"}, nil)},
844+ {name: "remote", remote: "user@example.test", snapshot: terminalCompositionSnapshot(nil, map[string][]string{"user@example.test": {"work"}})},
845+ }
846+ for _, tc := range tests {
847+ t.Run(tc.name, func(t *testing.T) {
848+ withAttachInteractiveConsole(t, false)
849+ run := startTerminalComposition(t, protocol.IntentAttach, "gone", tc.remote, tc.snapshot)
850+ require.Equal(t, client.LifecycleNoticeDestinationFailed, awaitTerminalCompositionNotice(t, run).Kind)
851+ select {
852+ case failure := <-run.failures:
853+ var protocolErr *client.ProtocolError
854+ require.ErrorAs(t, failure, &protocolErr)
855+ require.Equal(t, protocol.ErrNoSuchSession, protocolErr.Code)
856+ case <-time.After(brokerTestWait):
857+ t.Fatal("the daemon refusal was not reported")
858+ }
859+ opened := run.service.openedRequests()
860+ require.Len(t, opened, 1)
861+ require.Equal(t, ports.BrokerAdmissionAttachNamed, opened[0].Admission, "a missing name is never created implicitly")
862+ })
863+ }
864+}
865+
757866 // TestTerminalCompositionClosesRawModeOnTerminalEnd pins that the shared
758867 // composition restores the terminal exactly once when the run ends, however the
759868 // attachment settled.
@@ -127,12 +127,15 @@ func sortStrings(values []string) {
127 type terminalCompositionService struct {127 type terminalCompositionService struct {
128 mu sync.Mutex128 mu sync.Mutex
129 snapshot ports.BrokerSnapshot129 snapshot ports.BrokerSnapshot
130- closed bool130+ // daemonSessions, when set, is what the local daemon owns regardless of
131- next ports.BrokerStreamID131+ // what the broker catalogue has observed.
132- opened []ports.BrokerOpenStreamRequest132+ daemonSessions []string
133- streams []*terminalCompositionStream133+ closed bool
134- done chan struct{}134+ next ports.BrokerStreamID
135- changed chan struct{}135+ opened []ports.BrokerOpenStreamRequest
136+ streams []*terminalCompositionStream
137+ done chan struct{}
138+ changed chan struct{}
136 }139 }
137 140
138 func newTerminalCompositionService(snapshot ports.BrokerSnapshot) *terminalCompositionService {141 func newTerminalCompositionService(snapshot ports.BrokerSnapshot) *terminalCompositionService {
@@ -177,7 +180,11 @@ func (s *terminalCompositionService) OpenStream(_ context.Context, request ports
177 return nil, ports.BrokerAdmissionClosed180 return nil, ports.BrokerAdmissionClosed
178 }181 }
179 s.opened = append(s.opened, request)182 s.opened = append(s.opened, request)
180- stream := newTerminalCompositionStream(request)183+ sessions := terminalCompositionDaemonSessions(s.snapshot, request)
184+ if request.Local && s.daemonSessions != nil {
185+ sessions = terminalCompositionSessions(s.daemonSessions)
186+ }
187+ stream := newTerminalCompositionStream(request, sessions)
181 s.streams = append(s.streams, stream)188 s.streams = append(s.streams, stream)
182 s.mu.Unlock()189 s.mu.Unlock()
183 return stream, nil190 return stream, nil
@@ -211,10 +218,26 @@ func (s *terminalCompositionService) Close() error {
211 return nil218 return nil
212 }219 }
213 220
221+// openedRequests returns the attachment streams opened; the preflight's
222+// control listings are reported by openedControls.
214 func (s *terminalCompositionService) openedRequests() []ports.BrokerOpenStreamRequest {223 func (s *terminalCompositionService) openedRequests() []ports.BrokerOpenStreamRequest {
224+ return s.openedWithPurpose(ports.BrokerStreamAttachment)
225+}
226+
227+func (s *terminalCompositionService) openedControls() []ports.BrokerOpenStreamRequest {
228+ return s.openedWithPurpose(ports.BrokerStreamControl)
229+}
230+
231+func (s *terminalCompositionService) openedWithPurpose(purpose ports.BrokerStreamPurpose) []ports.BrokerOpenStreamRequest {
215 s.mu.Lock()232 s.mu.Lock()
216 defer s.mu.Unlock()233 defer s.mu.Unlock()
217- return append([]ports.BrokerOpenStreamRequest(nil), s.opened...)234+ var out []ports.BrokerOpenStreamRequest
235+ for _, request := range s.opened {
236+ if request.Purpose == purpose {
237+ out = append(out, request)
238+ }
239+ }
240+ return out
218 }241 }
219 242
220 // streamHello returns the Hello the supervisor sent on the single admitted243 // streamHello returns the Hello the supervisor sent on the single admitted
@@ -223,7 +246,12 @@ func (s *terminalCompositionService) openedRequests() []ports.BrokerOpenStreamRe
223 func (s *terminalCompositionService) streamHello(t *testing.T) protocol.Hello {246 func (s *terminalCompositionService) streamHello(t *testing.T) protocol.Hello {
224 t.Helper()247 t.Helper()
225 s.mu.Lock()248 s.mu.Lock()
226- streams := append([]*terminalCompositionStream(nil), s.streams...)249+ var streams []*terminalCompositionStream
250+ for _, stream := range s.streams {
251+ if stream.request.Purpose == ports.BrokerStreamAttachment {
252+ streams = append(streams, stream)
253+ }
254+ }
227 s.mu.Unlock()255 s.mu.Unlock()
228 require.Len(t, streams, 1)256 require.Len(t, streams, 1)
229 for _, message := range streams[0].sentMessages() {257 for _, message := range streams[0].sentMessages() {
@@ -253,8 +281,33 @@ type terminalCompositionStream struct {
253 done chan struct{}281 done chan struct{}
254 }282 }
255 283
256-func newTerminalCompositionStream(request ports.BrokerOpenStreamRequest) *terminalCompositionStream {284+// terminalCompositionDaemonSessions is the session list the scripted
285+// destination daemon owns: the sessions its observation publishes.
286+func terminalCompositionDaemonSessions(snapshot ports.BrokerSnapshot, request ports.BrokerOpenStreamRequest) []catalogue.RemoteCatalogSession {
287+ for _, observation := range snapshot.Daemons {
288+ if observation.Local == request.Local && (request.Local || observation.Endpoint == request.Endpoint) {
289+ return observation.Sessions
290+ }
291+ }
292+ return nil
293+}
294+
295+func newTerminalCompositionStream(request ports.BrokerOpenStreamRequest, sessions []catalogue.RemoteCatalogSession) *terminalCompositionStream {
257 stream := &terminalCompositionStream{request: request, done: make(chan struct{})}296 stream := &terminalCompositionStream{request: request, done: make(chan struct{})}
297+ if request.Purpose == ports.BrokerStreamControl {
298+ // The scripted daemon answers a List with its own sessions.
299+ infos := make([]protocol.SessionInfo, 0, len(sessions))
300+ for _, session := range sessions {
301+ infos = append(infos, protocol.SessionInfo{Name: session.Name})
302+ }
303+ stream.queue = append(stream.queue, protocol.Sessions{Sessions: infos})
304+ return stream
305+ }
306+ if request.Admission == ports.BrokerAdmissionAttachNamed && !slices.ContainsFunc(sessions, func(session catalogue.RemoteCatalogSession) bool { return session.Name == request.Name }) {
307+ // Like the real daemon, an unknown name is refused, never created.
308+ stream.queue = append(stream.queue, protocol.ErrorMsg{Code: protocol.ErrNoSuchSession, Text: "no such resumable session: " + request.Name})
309+ return stream
310+ }
258 target := terminalCompositionCommittedTarget(request)311 target := terminalCompositionCommittedTarget(request)
259 stream.queue = append(stream.queue, protocol.Welcome{312 stream.queue = append(stream.queue, protocol.Welcome{
260 SessionID: "sess", SessionName: request.Name,313 SessionID: "sess", SessionName: request.Name,
@@ -465,12 +518,20 @@ func (r *terminalCompositionRun) wait(t *testing.T) error {
465 }518 }
466 519
467 func startTerminalComposition(t *testing.T, intent uint8, name, remoteTarget string, snapshot ports.BrokerSnapshot) *terminalCompositionRun {520 func startTerminalComposition(t *testing.T, intent uint8, name, remoteTarget string, snapshot ports.BrokerSnapshot) *terminalCompositionRun {
521+ t.Helper()
522+ return startTerminalCompositionWithDaemon(t, intent, name, remoteTarget, snapshot, nil)
523+}
524+
525+// startTerminalCompositionWithDaemon is startTerminalComposition whose local
526+// daemon owns daemonSessions even when the catalogue has not observed them.
527+func startTerminalCompositionWithDaemon(t *testing.T, intent uint8, name, remoteTarget string, snapshot ports.BrokerSnapshot, daemonSessions []string) *terminalCompositionRun {
468 t.Helper()528 t.Helper()
469 // The ordinary terminal path reads the nested-session and remote-transport529 // The ordinary terminal path reads the nested-session and remote-transport
470 // environment; a test drives the broker composition with both unset.530 // environment; a test drives the broker composition with both unset.
471 t.Setenv("VEV", "")531 t.Setenv("VEV", "")
472 t.Setenv(envRemoteTransport, "")532 t.Setenv(envRemoteTransport, "")
473 service := newTerminalCompositionService(snapshot)533 service := newTerminalCompositionService(snapshot)
534+ service.daemonSessions = daemonSessions
474 terminal := newTerminalCompositionTerminal()535 terminal := newTerminalCompositionTerminal()
475 previousConnect := connectProductionClientBroker536 previousConnect := connectProductionClientBroker
476 previousTerminal := terminalForAttach537 previousTerminal := terminalForAttach
@@ -582,8 +643,8 @@ func TestTerminalNavigationTranslationClosesTheIntent(t *testing.T) {
582 }{643 }{
583 {name: "no arguments creates a local ephemeral session", intent: protocol.IntentEphemeral, wantKind: client.InitialNavigationCreateEphemeral, wantLocal: true},644 {name: "no arguments creates a local ephemeral session", intent: protocol.IntentEphemeral, wantKind: client.InitialNavigationCreateEphemeral, wantLocal: true},
584 {name: "new creates a local named session", intent: protocol.IntentNew, session: "scratch", wantKind: client.InitialNavigationCreateNamed, wantLocal: true, wantName: "scratch"},645 {name: "new creates a local named session", intent: protocol.IntentNew, session: "scratch", wantKind: client.InitialNavigationCreateNamed, wantLocal: true, wantName: "scratch"},
585- {name: "attach resolves a local exact target", intent: protocol.IntentAttach, session: "work", wantResolver: true},646+ {name: "attach names a local session for the daemon to resolve", intent: protocol.IntentAttach, session: "work", wantKind: client.InitialNavigationAttachNamed, wantLocal: true, wantName: "work"},
586- {name: "remote attach resolves an exact remote target", intent: protocol.IntentAttach, session: "work", remote: "user@example.test", wantResolver: true},647+ {name: "remote attach resolves the remote registration", intent: protocol.IntentAttach, session: "work", remote: "user@example.test", wantResolver: true},
587 {name: "remote new resolves a named remote creation", intent: protocol.IntentNew, session: "work", remote: "user@example.test", wantResolver: true},648 {name: "remote new resolves a named remote creation", intent: protocol.IntentNew, session: "work", remote: "user@example.test", wantResolver: true},
588 {name: "remote ephemeral attach resolves a remote creation", intent: protocol.IntentEphemeral, remote: "user@example.test", wantResolver: true},649 {name: "remote ephemeral attach resolves a remote creation", intent: protocol.IntentEphemeral, remote: "user@example.test", wantResolver: true},
589 {name: "invalid local name is refused", intent: protocol.IntentNew, session: "bad name", wantError: true},650 {name: "invalid local name is refused", intent: protocol.IntentNew, session: "bad name", wantError: true},
@@ -624,24 +685,26 @@ func TestTerminalCompositionOpensTheExactBrokerStream(t *testing.T) {
624 return terminalCompositionSnapshot(nil, map[string][]string{"user@example.test": {"work"}})685 return terminalCompositionSnapshot(nil, map[string][]string{"user@example.test": {"work"}})
625 }686 }
626 tests := []struct {687 tests := []struct {
627- name string688+ name string
628- intent uint8689+ intent uint8
629- session string690+ session string
630- remote string691+ remote string
631- snapshot ports.BrokerSnapshot692+ snapshot ports.BrokerSnapshot
632- wantAdmission ports.BrokerStreamAdmission693+ daemonSessions []string
633- wantLocal bool694+ wantAdmission ports.BrokerStreamAdmission
634- wantName string695+ wantLocal bool
635- wantHello uint8696+ wantName string
697+ wantHello uint8
636 }{698 }{
637 {name: "no arguments creates a local ephemeral session", intent: protocol.IntentEphemeral, snapshot: localSnapshot(), wantAdmission: ports.BrokerAdmissionCreateEphemeral, wantLocal: true, wantHello: protocol.IntentEphemeral},699 {name: "no arguments creates a local ephemeral session", intent: protocol.IntentEphemeral, snapshot: localSnapshot(), wantAdmission: ports.BrokerAdmissionCreateEphemeral, wantLocal: true, wantHello: protocol.IntentEphemeral},
638 {name: "new creates a local named session", intent: protocol.IntentNew, session: "scratch", snapshot: localSnapshot(), wantAdmission: ports.BrokerAdmissionCreateNamed, wantLocal: true, wantName: "scratch", wantHello: protocol.IntentNew},700 {name: "new creates a local named session", intent: protocol.IntentNew, session: "scratch", snapshot: localSnapshot(), wantAdmission: ports.BrokerAdmissionCreateNamed, wantLocal: true, wantName: "scratch", wantHello: protocol.IntentNew},
639- {name: "attach resolves the local session lifecycle", intent: protocol.IntentAttach, session: "work", snapshot: localSnapshot(), wantAdmission: ports.BrokerAdmissionExact, wantLocal: true, wantHello: protocol.IntentAttach},701+ {name: "attach names the local session", intent: protocol.IntentAttach, session: "work", snapshot: localSnapshot(), wantAdmission: ports.BrokerAdmissionAttachNamed, wantLocal: true, wantName: "work", wantHello: protocol.IntentAttach},
640- {name: "remote attach resolves the remote session lifecycle", intent: protocol.IntentAttach, session: "work", remote: "user@example.test", snapshot: remoteSnapshot(), wantAdmission: ports.BrokerAdmissionExact, wantHello: protocol.IntentAttach},702+ {name: "remote attach names the remote session", intent: protocol.IntentAttach, session: "work", remote: "user@example.test", snapshot: remoteSnapshot(), wantAdmission: ports.BrokerAdmissionAttachNamed, wantName: "work", wantHello: protocol.IntentAttach},
703+ {name: "attach reaches a session the catalogue has not observed yet", intent: protocol.IntentAttach, session: "work", snapshot: terminalCompositionUnobservedLocal(), daemonSessions: []string{"work"}, wantAdmission: ports.BrokerAdmissionAttachNamed, wantLocal: true, wantName: "work", wantHello: protocol.IntentAttach},
641 }704 }
642 for _, tc := range tests {705 for _, tc := range tests {
643 t.Run(tc.name, func(t *testing.T) {706 t.Run(tc.name, func(t *testing.T) {
644- run := startTerminalComposition(t, tc.intent, tc.session, tc.remote, tc.snapshot)707+ run := startTerminalCompositionWithDaemon(t, tc.intent, tc.session, tc.remote, tc.snapshot, tc.daemonSessions)
645 awaitTerminalCompositionAttachment(t, run)708 awaitTerminalCompositionAttachment(t, run)
646 opened := run.service.openedRequests()709 opened := run.service.openedRequests()
647 require.Len(t, opened, 1, "one CLI target opens exactly one broker stream")710 require.Len(t, opened, 1, "one CLI target opens exactly one broker stream")
@@ -674,10 +737,25 @@ func TestTerminalCompositionOpensTheExactBrokerStream(t *testing.T) {
674 require.NotNil(t, hello.ExactTarget)737 require.NotNil(t, hello.ExactTarget)
675 require.Equal(t, request.Target, *hello.ExactTarget)738 require.Equal(t, request.Target, *hello.ExactTarget)
676 }739 }
740+ if tc.wantAdmission == ports.BrokerAdmissionAttachNamed {
741+ require.Nil(t, hello.ExactTarget, "a named attach leaves identity to the daemon")
742+ controls := run.service.openedControls()
743+ require.Len(t, controls, 1, "the preflight asks the daemon once")
744+ require.Equal(t, ports.BrokerDaemonStartIfNeeded, controls[0].StartMode, "the preflight may start a stopped daemon")
745+ }
677 })746 })
678 }747 }
679 }748 }
680 749
750+// terminalCompositionUnobservedLocal is the publication a freshly started
751+// broker commits right after `kill --all`: the local daemon is known but its
752+// sessions are not observed yet, while the daemon itself restores them.
753+func terminalCompositionUnobservedLocal() ports.BrokerSnapshot {
754+ snapshot := terminalCompositionSnapshot(nil, nil)
755+ snapshot.Daemons[0].InventoryKnown = false
756+ return snapshot
757+}
758+
681 // TestTerminalCompositionPublishesRealSessionOutput proves an admitted759 // TestTerminalCompositionPublishesRealSessionOutput proves an admitted
682 // attachment is a committed publication: the daemon's initial full frame reached760 // attachment is a committed publication: the daemon's initial full frame reached
683 // the terminal through a committed UI output transaction, and the supervisor761 // the terminal through a committed UI output transaction, and the supervisor
@@ -717,10 +795,8 @@ func TestTerminalCompositionRefusedTargetOpensNothingAndKeepsThePicker(t *testin
717 remote string795 remote string
718 snapshot ports.BrokerSnapshot796 snapshot ports.BrokerSnapshot
719 }{797 }{
720- {name: "missing local session", intent: protocol.IntentAttach, session: "gone", snapshot: terminalCompositionSnapshot([]string{"work"}, nil)},
721 {name: "local daemon absent", intent: protocol.IntentEphemeral, snapshot: ports.BrokerSnapshot{Epoch: terminalCompositionEpoch, Revision: 1}},798 {name: "local daemon absent", intent: protocol.IntentEphemeral, snapshot: ports.BrokerSnapshot{Epoch: terminalCompositionEpoch, Revision: 1}},
722 {name: "unconfigured remote host", intent: protocol.IntentAttach, session: "work", remote: "user@elsewhere.test", snapshot: terminalCompositionSnapshot([]string{"work"}, nil)},799 {name: "unconfigured remote host", intent: protocol.IntentAttach, session: "work", remote: "user@elsewhere.test", snapshot: terminalCompositionSnapshot([]string{"work"}, nil)},
723- {name: "missing remote session", intent: protocol.IntentAttach, session: "gone", remote: "user@example.test", snapshot: terminalCompositionSnapshot(nil, map[string][]string{"user@example.test": {"work"}})},
724 }800 }
725 for _, tc := range tests {801 for _, tc := range tests {
726 t.Run(tc.name, func(t *testing.T) {802 t.Run(tc.name, func(t *testing.T) {
@@ -754,6 +830,39 @@ func TestTerminalCompositionRefusedTargetOpensNothingAndKeepsThePicker(t *testin
754 }830 }
755 }831 }
756 832
833+// TestTerminalCompositionDaemonRefusesMissingName pins that existence is the
834+// daemon's decision: a declined (non-interactive) create prompt keeps the
835+// named attach, the daemon answers ErrNoSuchSession, and the client reports
836+// that refusal without attaching or creating.
837+func TestTerminalCompositionDaemonRefusesMissingName(t *testing.T) {
838+ tests := []struct {
839+ name string
840+ remote string
841+ snapshot ports.BrokerSnapshot
842+ }{
843+ {name: "local", snapshot: terminalCompositionSnapshot([]string{"work"}, nil)},
844+ {name: "remote", remote: "user@example.test", snapshot: terminalCompositionSnapshot(nil, map[string][]string{"user@example.test": {"work"}})},
845+ }
846+ for _, tc := range tests {
847+ t.Run(tc.name, func(t *testing.T) {
848+ withAttachInteractiveConsole(t, false)
849+ run := startTerminalComposition(t, protocol.IntentAttach, "gone", tc.remote, tc.snapshot)
850+ require.Equal(t, client.LifecycleNoticeDestinationFailed, awaitTerminalCompositionNotice(t, run).Kind)
851+ select {
852+ case failure := <-run.failures:
853+ var protocolErr *client.ProtocolError
854+ require.ErrorAs(t, failure, &protocolErr)
855+ require.Equal(t, protocol.ErrNoSuchSession, protocolErr.Code)
856+ case <-time.After(brokerTestWait):
857+ t.Fatal("the daemon refusal was not reported")
858+ }
859+ opened := run.service.openedRequests()
860+ require.Len(t, opened, 1)
861+ require.Equal(t, ports.BrokerAdmissionAttachNamed, opened[0].Admission, "a missing name is never created implicitly")
862+ })
863+ }
864+}
865+
757 // TestTerminalCompositionClosesRawModeOnTerminalEnd pins that the shared866 // TestTerminalCompositionClosesRawModeOnTerminalEnd pins that the shared
758 // composition restores the terminal exactly once when the run ends, however the867 // composition restores the terminal exactly once when the run ends, however the
759 // attachment settled.868 // attachment settled.
M internal/app/ui_driver_acceptance_test.go +10 -10
@@ -116,16 +116,16 @@ func TestUIDriverCreationRoutes(t *testing.T) {
116116 require.Equal(t, "work", namedAttached.Context.Route.Target.SessionName)
117117 }
118118
119-// TestUIDriverExactAttachResolvesTheCommittedLifecycle pins the attach route
120-// deterministically against the scripted broker: `attach work` resolves the
121-// committed local lifecycle into exactly one exact admission whose target is
122-// that published identity, and the run dials no daemon itself.
123-func TestUIDriverExactAttachResolvesTheCommittedLifecycle(t *testing.T) {
119+// TestUIDriverNamedAttachLetsTheDaemonResolve pins the attach route
120+// deterministically against the scripted broker: `attach work` opens exactly
121+// one named admission, the daemon commits the identity, and the run dials no
122+// daemon itself.
123+func TestUIDriverNamedAttachLetsTheDaemonResolve(t *testing.T) {
124124 snapshot := terminalCompositionSnapshot([]string{"work"}, nil)
125125 navigation, resolver, err := terminalBrokerNavigation(protocol.IntentAttach, "work", "")
126126 require.NoError(t, err)
127- require.Equal(t, client.InitialNavigation{}, navigation, "a name is resolved against the committed publication")
128- require.NotNil(t, resolver)
127+ require.Nil(t, resolver, "a local name needs no catalogue resolution")
128+ require.Equal(t, client.InitialNavigationAttachNamed, navigation.Kind)
129129
130130 service := newTerminalCompositionService(snapshot)
131131 run := startScriptedUIDriverRun(t, service, navigation, resolver)
@@ -135,10 +135,10 @@ func TestUIDriverExactAttachResolvesTheCommittedLifecycle(t *testing.T) {
135135 require.Len(t, opened, 1, "attach work opens exactly one broker stream")
136136 request := opened[0]
137137 require.NoError(t, request.Validate())
138- require.Equal(t, ports.BrokerAdmissionExact, request.Admission)
138+ require.Equal(t, ports.BrokerAdmissionAttachNamed, request.Admission)
139139 require.True(t, request.Local)
140- require.Equal(t, "work", request.Target.SessionName)
141- require.Equal(t, terminalCompositionSessions([]string{"work"})[0].LifecycleID, request.Target.LifecycleID)
140+ require.Equal(t, "work", request.Name)
141+ require.Equal(t, protocol.ExactSessionTarget{}, request.Target)
142142 }
143143
144144 // TestUIDriverRefusedTargetStaysPickerAndKeepsServing pins the local-refusal
@@ -116,16 +116,16 @@ func TestUIDriverCreationRoutes(t *testing.T) {
116 require.Equal(t, "work", namedAttached.Context.Route.Target.SessionName)116 require.Equal(t, "work", namedAttached.Context.Route.Target.SessionName)
117 }117 }
118 118
119-// TestUIDriverExactAttachResolvesTheCommittedLifecycle pins the attach route119+// TestUIDriverNamedAttachLetsTheDaemonResolve pins the attach route
120-// deterministically against the scripted broker: `attach work` resolves the120+// deterministically against the scripted broker: `attach work` opens exactly
121-// committed local lifecycle into exactly one exact admission whose target is121+// one named admission, the daemon commits the identity, and the run dials no
122-// that published identity, and the run dials no daemon itself.122+// daemon itself.
123-func TestUIDriverExactAttachResolvesTheCommittedLifecycle(t *testing.T) {123+func TestUIDriverNamedAttachLetsTheDaemonResolve(t *testing.T) {
124 snapshot := terminalCompositionSnapshot([]string{"work"}, nil)124 snapshot := terminalCompositionSnapshot([]string{"work"}, nil)
125 navigation, resolver, err := terminalBrokerNavigation(protocol.IntentAttach, "work", "")125 navigation, resolver, err := terminalBrokerNavigation(protocol.IntentAttach, "work", "")
126 require.NoError(t, err)126 require.NoError(t, err)
127- require.Equal(t, client.InitialNavigation{}, navigation, "a name is resolved against the committed publication")127+ require.Nil(t, resolver, "a local name needs no catalogue resolution")
128- require.NotNil(t, resolver)128+ require.Equal(t, client.InitialNavigationAttachNamed, navigation.Kind)
129 129
130 service := newTerminalCompositionService(snapshot)130 service := newTerminalCompositionService(snapshot)
131 run := startScriptedUIDriverRun(t, service, navigation, resolver)131 run := startScriptedUIDriverRun(t, service, navigation, resolver)
@@ -135,10 +135,10 @@ func TestUIDriverExactAttachResolvesTheCommittedLifecycle(t *testing.T) {
135 require.Len(t, opened, 1, "attach work opens exactly one broker stream")135 require.Len(t, opened, 1, "attach work opens exactly one broker stream")
136 request := opened[0]136 request := opened[0]
137 require.NoError(t, request.Validate())137 require.NoError(t, request.Validate())
138- require.Equal(t, ports.BrokerAdmissionExact, request.Admission)138+ require.Equal(t, ports.BrokerAdmissionAttachNamed, request.Admission)
139 require.True(t, request.Local)139 require.True(t, request.Local)
140- require.Equal(t, "work", request.Target.SessionName)140+ require.Equal(t, "work", request.Name)
141- require.Equal(t, terminalCompositionSessions([]string{"work"})[0].LifecycleID, request.Target.LifecycleID)141+ require.Equal(t, protocol.ExactSessionTarget{}, request.Target)
142 }142 }
143 143
144 // TestUIDriverRefusedTargetStaysPickerAndKeepsServing pins the local-refusal144 // TestUIDriverRefusedTargetStaysPickerAndKeepsServing pins the local-refusal
M internal/ports/broker.go +12 -5
@@ -351,6 +351,10 @@ const (
351351 // BrokerAdmissionCreateEphemeral creates one ephemeral session when the
352352 // stream attaches.
353353 BrokerAdmissionCreateEphemeral
354+ // BrokerAdmissionAttachNamed attaches to the session the daemon knows by
355+ // this validated name, restoring it if needed. The daemon, not a broker
356+ // catalogue, decides existence and refuses an unknown name.
357+ BrokerAdmissionAttachNamed
354358 )
355359
356360 func (a BrokerStreamAdmission) String() string {
@@ -361,6 +365,8 @@ func (a BrokerStreamAdmission) String() string {
361365 return "create_named"
362366 case BrokerAdmissionCreateEphemeral:
363367 return "create_ephemeral"
368+ case BrokerAdmissionAttachNamed:
369+ return "attach_named"
364370 default:
365371 return fmt.Sprintf("invalid(%d)", uint8(a))
366372 }
@@ -368,7 +374,7 @@ func (a BrokerStreamAdmission) String() string {
368374
369375 func (a BrokerStreamAdmission) Validate() error {
370376 switch a {
371- case BrokerAdmissionExact, BrokerAdmissionCreateNamed, BrokerAdmissionCreateEphemeral:
377+ case BrokerAdmissionExact, BrokerAdmissionCreateNamed, BrokerAdmissionCreateEphemeral, BrokerAdmissionAttachNamed:
372378 return nil
373379 default:
374380 return errors.New("ports: invalid broker stream admission")
@@ -379,7 +385,8 @@ func (a BrokerStreamAdmission) Validate() error {
379385 // cancellable logical stream to the owning daemon. The daemon revalidates
380386 // the exact session identity before attachment. Admission selects the
381387 // attachment admission variant; Name is the validated session name for
382-// BrokerAdmissionCreateNamed and is empty for every other variant. StartMode
388+// BrokerAdmissionCreateNamed and BrokerAdmissionAttachNamed and is empty for
389+// every other variant. StartMode
383390 // is the explicit daemon-start authorization this request carries to the
384391 // transport; Pending acquisition keys on it so an ExistingOnly request is
385392 // never coalesced with a dial that may start the target. Env is the
@@ -442,12 +449,12 @@ func (r BrokerOpenStreamRequest) Validate() error {
442449 if r.Name != "" {
443450 return errors.New("ports: exact admission carries a creation name")
444451 }
445- case BrokerAdmissionCreateNamed:
452+ case BrokerAdmissionCreateNamed, BrokerAdmissionAttachNamed:
446453 if r.Target != (protocol.ExactSessionTarget{}) {
447- return errors.New("ports: named creation carries an exact target")
454+ return errors.New("ports: named admission carries an exact target")
448455 }
449456 if err := domain.ValidateSessionName(r.Name); err != nil {
450- return fmt.Errorf("ports: invalid creation session name: %w", err)
457+ return fmt.Errorf("ports: invalid admission session name: %w", err)
451458 }
452459 case BrokerAdmissionCreateEphemeral:
453460 if r.Target != (protocol.ExactSessionTarget{}) {
@@ -351,6 +351,10 @@ const (
351 // BrokerAdmissionCreateEphemeral creates one ephemeral session when the351 // BrokerAdmissionCreateEphemeral creates one ephemeral session when the
352 // stream attaches.352 // stream attaches.
353 BrokerAdmissionCreateEphemeral353 BrokerAdmissionCreateEphemeral
354+ // BrokerAdmissionAttachNamed attaches to the session the daemon knows by
355+ // this validated name, restoring it if needed. The daemon, not a broker
356+ // catalogue, decides existence and refuses an unknown name.
357+ BrokerAdmissionAttachNamed
354 )358 )
355 359
356 func (a BrokerStreamAdmission) String() string {360 func (a BrokerStreamAdmission) String() string {
@@ -361,6 +365,8 @@ func (a BrokerStreamAdmission) String() string {
361 return "create_named"365 return "create_named"
362 case BrokerAdmissionCreateEphemeral:366 case BrokerAdmissionCreateEphemeral:
363 return "create_ephemeral"367 return "create_ephemeral"
368+ case BrokerAdmissionAttachNamed:
369+ return "attach_named"
364 default:370 default:
365 return fmt.Sprintf("invalid(%d)", uint8(a))371 return fmt.Sprintf("invalid(%d)", uint8(a))
366 }372 }
@@ -368,7 +374,7 @@ func (a BrokerStreamAdmission) String() string {
368 374
369 func (a BrokerStreamAdmission) Validate() error {375 func (a BrokerStreamAdmission) Validate() error {
370 switch a {376 switch a {
371- case BrokerAdmissionExact, BrokerAdmissionCreateNamed, BrokerAdmissionCreateEphemeral:377+ case BrokerAdmissionExact, BrokerAdmissionCreateNamed, BrokerAdmissionCreateEphemeral, BrokerAdmissionAttachNamed:
372 return nil378 return nil
373 default:379 default:
374 return errors.New("ports: invalid broker stream admission")380 return errors.New("ports: invalid broker stream admission")
@@ -379,7 +385,8 @@ func (a BrokerStreamAdmission) Validate() error {
379 // cancellable logical stream to the owning daemon. The daemon revalidates385 // cancellable logical stream to the owning daemon. The daemon revalidates
380 // the exact session identity before attachment. Admission selects the386 // the exact session identity before attachment. Admission selects the
381 // attachment admission variant; Name is the validated session name for387 // attachment admission variant; Name is the validated session name for
382-// BrokerAdmissionCreateNamed and is empty for every other variant. StartMode388+// BrokerAdmissionCreateNamed and BrokerAdmissionAttachNamed and is empty for
389+// every other variant. StartMode
383 // is the explicit daemon-start authorization this request carries to the390 // is the explicit daemon-start authorization this request carries to the
384 // transport; Pending acquisition keys on it so an ExistingOnly request is391 // transport; Pending acquisition keys on it so an ExistingOnly request is
385 // never coalesced with a dial that may start the target. Env is the392 // never coalesced with a dial that may start the target. Env is the
@@ -442,12 +449,12 @@ func (r BrokerOpenStreamRequest) Validate() error {
442 if r.Name != "" {449 if r.Name != "" {
443 return errors.New("ports: exact admission carries a creation name")450 return errors.New("ports: exact admission carries a creation name")
444 }451 }
445- case BrokerAdmissionCreateNamed:452+ case BrokerAdmissionCreateNamed, BrokerAdmissionAttachNamed:
446 if r.Target != (protocol.ExactSessionTarget{}) {453 if r.Target != (protocol.ExactSessionTarget{}) {
447- return errors.New("ports: named creation carries an exact target")454+ return errors.New("ports: named admission carries an exact target")
448 }455 }
449 if err := domain.ValidateSessionName(r.Name); err != nil {456 if err := domain.ValidateSessionName(r.Name); err != nil {
450- return fmt.Errorf("ports: invalid creation session name: %w", err)457+ return fmt.Errorf("ports: invalid admission session name: %w", err)
451 }458 }
452 case BrokerAdmissionCreateEphemeral:459 case BrokerAdmissionCreateEphemeral:
453 if r.Target != (protocol.ExactSessionTarget{}) {460 if r.Target != (protocol.ExactSessionTarget{}) {
M internal/ports/session_admission.go +5 -5
@@ -77,8 +77,8 @@ type SessionAdmission struct {
7777 // Admission is the closed attachment-admission variant. It is nonzero only
7878 // for BrokerStreamAttachment; control and observation carry none.
7979 Admission BrokerStreamAdmission
80- // Name is the validated creation session name for
81- // BrokerAdmissionCreateNamed and is empty for every other variant.
80+ // Name is the validated session name for BrokerAdmissionCreateNamed and
81+ // BrokerAdmissionAttachNamed and is empty for every other variant.
8282 Name string
8383 // Target is the exact session lifecycle target for BrokerAdmissionExact and
8484 // is zero for every other variant.
@@ -117,12 +117,12 @@ func (a SessionAdmission) Validate() error {
117117 if a.Name != "" {
118118 return errors.New("ports: exact admission carries a creation name")
119119 }
120- case BrokerAdmissionCreateNamed:
120+ case BrokerAdmissionCreateNamed, BrokerAdmissionAttachNamed:
121121 if a.Target != (protocol.ExactSessionTarget{}) {
122- return errors.New("ports: named creation carries an exact target")
122+ return errors.New("ports: named admission carries an exact target")
123123 }
124124 if err := domain.ValidateSessionName(a.Name); err != nil {
125- return fmt.Errorf("ports: invalid creation session name: %w", err)
125+ return fmt.Errorf("ports: invalid admission session name: %w", err)
126126 }
127127 case BrokerAdmissionCreateEphemeral:
128128 if a.Target != (protocol.ExactSessionTarget{}) {
@@ -77,8 +77,8 @@ type SessionAdmission struct {
77 // Admission is the closed attachment-admission variant. It is nonzero only77 // Admission is the closed attachment-admission variant. It is nonzero only
78 // for BrokerStreamAttachment; control and observation carry none.78 // for BrokerStreamAttachment; control and observation carry none.
79 Admission BrokerStreamAdmission79 Admission BrokerStreamAdmission
80- // Name is the validated creation session name for80+ // Name is the validated session name for BrokerAdmissionCreateNamed and
81- // BrokerAdmissionCreateNamed and is empty for every other variant.81+ // BrokerAdmissionAttachNamed and is empty for every other variant.
82 Name string82 Name string
83 // Target is the exact session lifecycle target for BrokerAdmissionExact and83 // Target is the exact session lifecycle target for BrokerAdmissionExact and
84 // is zero for every other variant.84 // is zero for every other variant.
@@ -117,12 +117,12 @@ func (a SessionAdmission) Validate() error {
117 if a.Name != "" {117 if a.Name != "" {
118 return errors.New("ports: exact admission carries a creation name")118 return errors.New("ports: exact admission carries a creation name")
119 }119 }
120- case BrokerAdmissionCreateNamed:120+ case BrokerAdmissionCreateNamed, BrokerAdmissionAttachNamed:
121 if a.Target != (protocol.ExactSessionTarget{}) {121 if a.Target != (protocol.ExactSessionTarget{}) {
122- return errors.New("ports: named creation carries an exact target")122+ return errors.New("ports: named admission carries an exact target")
123 }123 }
124 if err := domain.ValidateSessionName(a.Name); err != nil {124 if err := domain.ValidateSessionName(a.Name); err != nil {
125- return fmt.Errorf("ports: invalid creation session name: %w", err)125+ return fmt.Errorf("ports: invalid admission session name: %w", err)
126 }126 }
127 case BrokerAdmissionCreateEphemeral:127 case BrokerAdmissionCreateEphemeral:
128 if a.Target != (protocol.ExactSessionTarget{}) {128 if a.Target != (protocol.ExactSessionTarget{}) {
M internal/protocol/session.go +1 -1
@@ -16,7 +16,7 @@ import (
1616 // daemonmux physical preamble conversation (multiplex.proto), whose
1717 // negotiated ceilings and accepted daemon binding ride the same wire
1818 // version as the session and broker conversations.
19-const Version uint16 = 60
19+const Version uint16 = 61
2020
2121 // HandshakeTimeout bounds every transport handshake from connect through the
2222 // first committed publication. It excludes the preceding client-local
@@ -16,7 +16,7 @@ import (
16 // daemonmux physical preamble conversation (multiplex.proto), whose16 // daemonmux physical preamble conversation (multiplex.proto), whose
17 // negotiated ceilings and accepted daemon binding ride the same wire17 // negotiated ceilings and accepted daemon binding ride the same wire
18 // version as the session and broker conversations.18 // version as the session and broker conversations.
19-const Version uint16 = 6019+const Version uint16 = 61
20 20
21 // HandshakeTimeout bounds every transport handshake from connect through the21 // HandshakeTimeout bounds every transport handshake from connect through the
22 // first committed publication. It excludes the preceding client-local22 // first committed publication. It excludes the preceding client-local
M internal/protocol/session_test.go +1 -1
@@ -10,7 +10,7 @@ import (
1010 )
1111
1212 func TestVersionRemainsWireCompatible(t *testing.T) {
13- require.Equal(t, uint16(60), Version)
13+ require.Equal(t, uint16(61), Version)
1414 require.Equal(t, (16<<20)-407, MaxOutputDataLen)
1515 }
1616
@@ -10,7 +10,7 @@ import (
10 )10 )
11 11
12 func TestVersionRemainsWireCompatible(t *testing.T) {12 func TestVersionRemainsWireCompatible(t *testing.T) {
13- require.Equal(t, uint16(60), Version)13+ require.Equal(t, uint16(61), Version)
14 require.Equal(t, (16<<20)-407, MaxOutputDataLen)14 require.Equal(t, (16<<20)-407, MaxOutputDataLen)
15 }15 }
16 16
M internal/protocol/wire/broker.pb.go +5 -3 generated file: collapsed
M internal/protocol/wire/envelope_inventory_test.go +2 -2
@@ -15,8 +15,8 @@ import (
1515 // shares a payload type except route navigation failure, and the preamble
1616 // constants agree with the schema authority.
1717 func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) {
18- if protocol.Version != 60 {
19- t.Fatalf("protocol.Version = %d, want 60", protocol.Version)
18+ if protocol.Version != 61 {
19+ t.Fatalf("protocol.Version = %d, want 61", protocol.Version)
2020 }
2121 if ProtocolEpoch != 1 {
2222 t.Fatalf("ProtocolEpoch = %d, want 1", ProtocolEpoch)
@@ -15,8 +15,8 @@ import (
15 // shares a payload type except route navigation failure, and the preamble15 // shares a payload type except route navigation failure, and the preamble
16 // constants agree with the schema authority.16 // constants agree with the schema authority.
17 func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) {17 func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) {
18- if protocol.Version != 60 {18+ if protocol.Version != 61 {
19- t.Fatalf("protocol.Version = %d, want 60", protocol.Version)19+ t.Fatalf("protocol.Version = %d, want 61", protocol.Version)
20 }20 }
21 if ProtocolEpoch != 1 {21 if ProtocolEpoch != 1 {
22 t.Fatalf("ProtocolEpoch = %d, want 1", ProtocolEpoch)22 t.Fatalf("ProtocolEpoch = %d, want 1", ProtocolEpoch)
M internal/protocol/wire/multiplex.pb.go +2 -1 generated file: collapsed
M internal/protocol/wire/schema/broker.proto +5 -3
@@ -181,7 +181,8 @@ message Reconcile {
181181 // admission is the closed attachment-admission taxonomy: an attachment
182182 // stream names exactly how the daemon must admit it, while control and
183183 // observation streams carry none (0). name is validated session-name
184-// authority for create-named and is empty for every other variant.
184+// authority for create-named and attach-named and is empty for every other
185+// variant.
185186 // start_mode is the explicit daemon-start authorization carried to the
186187 // transport: observation and daemon-stop must be existing_only, while
187188 // attach/creation and the explicit list/mutate controls may be
@@ -199,9 +200,10 @@ message OpenStream {
199200 repeated string env = 7;
200201 BrokerWirePolicy policy = 8;
201202 // Closed admission taxonomy: 0 = none (control/observation), 1 = exact
202- // attach/resume, 2 = create named, 3 = create ephemeral.
203+ // attach/resume, 2 = create named, 3 = create ephemeral, 4 = attach named
204+ // (the daemon resolves the name and refuses an unknown one).
203205 uint32 admission = 9;
204- // Validated session name for create-named; empty otherwise.
206+ // Validated session name for create-named and attach-named; empty otherwise.
205207 string name = 10;
206208 // Closed daemon-start taxonomy (ports.BrokerDaemonStartMode): 1 =
207209 // existing_only, 2 = start_if_needed. Always present; zero is refused.
@@ -181,7 +181,8 @@ message Reconcile {
181 // admission is the closed attachment-admission taxonomy: an attachment181 // admission is the closed attachment-admission taxonomy: an attachment
182 // stream names exactly how the daemon must admit it, while control and182 // stream names exactly how the daemon must admit it, while control and
183 // observation streams carry none (0). name is validated session-name183 // observation streams carry none (0). name is validated session-name
184-// authority for create-named and is empty for every other variant.184+// authority for create-named and attach-named and is empty for every other
185+// variant.
185 // start_mode is the explicit daemon-start authorization carried to the186 // start_mode is the explicit daemon-start authorization carried to the
186 // transport: observation and daemon-stop must be existing_only, while187 // transport: observation and daemon-stop must be existing_only, while
187 // attach/creation and the explicit list/mutate controls may be188 // attach/creation and the explicit list/mutate controls may be
@@ -199,9 +200,10 @@ message OpenStream {
199 repeated string env = 7;200 repeated string env = 7;
200 BrokerWirePolicy policy = 8;201 BrokerWirePolicy policy = 8;
201 // Closed admission taxonomy: 0 = none (control/observation), 1 = exact202 // Closed admission taxonomy: 0 = none (control/observation), 1 = exact
202- // attach/resume, 2 = create named, 3 = create ephemeral.203+ // attach/resume, 2 = create named, 3 = create ephemeral, 4 = attach named
204+ // (the daemon resolves the name and refuses an unknown one).
203 uint32 admission = 9;205 uint32 admission = 9;
204- // Validated session name for create-named; empty otherwise.206+ // Validated session name for create-named and attach-named; empty otherwise.
205 string name = 10;207 string name = 10;
206 // Closed daemon-start taxonomy (ports.BrokerDaemonStartMode): 1 =208 // Closed daemon-start taxonomy (ports.BrokerDaemonStartMode): 1 =
207 // existing_only, 2 = start_if_needed. Always present; zero is refused.209 // existing_only, 2 = start_if_needed. Always present; zero is refused.
M internal/protocol/wire/schema/multiplex.proto +2 -1
@@ -55,7 +55,8 @@ message MuxOpen {
5555 BrokerWirePolicy policy = 8;
5656 // Closed attachment-admission taxonomy (ports.BrokerStreamAdmission):
5757 // 0 = none (control/observation), 1 = exact attach/resume carrying
58- // target, 2 = create named carrying name, 3 = create ephemeral.
58+ // target, 2 = create named carrying name, 3 = create ephemeral, 4 = attach
59+ // named carrying name.
5960 uint32 admission = 9;
6061 string name = 10;
6162 // Closed daemon-start taxonomy (ports.BrokerDaemonStartMode): 1 =
@@ -55,7 +55,8 @@ message MuxOpen {
55 BrokerWirePolicy policy = 8;55 BrokerWirePolicy policy = 8;
56 // Closed attachment-admission taxonomy (ports.BrokerStreamAdmission):56 // Closed attachment-admission taxonomy (ports.BrokerStreamAdmission):
57 // 0 = none (control/observation), 1 = exact attach/resume carrying57 // 0 = none (control/observation), 1 = exact attach/resume carrying
58- // target, 2 = create named carrying name, 3 = create ephemeral.58+ // target, 2 = create named carrying name, 3 = create ephemeral, 4 = attach
59+ // named carrying name.
59 uint32 admission = 9;60 uint32 admission = 9;
60 string name = 10;61 string name = 10;
61 // Closed daemon-start taxonomy (ports.BrokerDaemonStartMode): 1 =62 // Closed daemon-start taxonomy (ports.BrokerDaemonStartMode): 1 =
M internal/usecase/client/attachment_session.go +6 -2
@@ -652,7 +652,7 @@ func (w *sessionAttachmentWorker) validateWelcome(welcome protocol.Welcome) erro
652652 if welcome.CommittedIdentity != nil {
653653 return w.validateIdentity("welcome", welcome.CommittedIdentity.Target)
654654 }
655- if w.cfg.Request.Admission == ports.BrokerAdmissionCreateNamed && welcome.SessionName != w.cfg.Request.Name {
655+ if (w.cfg.Request.Admission == ports.BrokerAdmissionCreateNamed || w.cfg.Request.Admission == ports.BrokerAdmissionAttachNamed) && welcome.SessionName != w.cfg.Request.Name {
656656 return &AttachmentIdentityError{Stage: "welcome", Got: protocol.ExactSessionTarget{SessionName: welcome.SessionName}, Name: w.cfg.Request.Name}
657657 }
658658 return nil
@@ -672,7 +672,7 @@ func (w *sessionAttachmentWorker) validateIdentity(stage string, got protocol.Ex
672672 if got != request.Target {
673673 return &AttachmentIdentityError{Stage: stage, Want: request.Target, Got: got}
674674 }
675- case ports.BrokerAdmissionCreateNamed:
675+ case ports.BrokerAdmissionCreateNamed, ports.BrokerAdmissionAttachNamed:
676676 if got.SessionName != request.Name {
677677 return &AttachmentIdentityError{Stage: stage, Got: got, Name: request.Name}
678678 }
@@ -740,6 +740,10 @@ func (w *sessionAttachmentWorker) hello(stream ports.BrokerLogicalConnection) pr
740740 case ports.BrokerAdmissionCreateNamed:
741741 hello.Intent = protocol.IntentNew
742742 hello.Name = request.Name
743+ case ports.BrokerAdmissionAttachNamed:
744+ // The daemon resolves the name, restoring it if needed.
745+ hello.Intent = protocol.IntentAttach
746+ hello.Name = request.Name
743747 case ports.BrokerAdmissionCreateEphemeral:
744748 hello.Intent = protocol.IntentEphemeral
745749 }
@@ -652,7 +652,7 @@ func (w *sessionAttachmentWorker) validateWelcome(welcome protocol.Welcome) erro
652 if welcome.CommittedIdentity != nil {652 if welcome.CommittedIdentity != nil {
653 return w.validateIdentity("welcome", welcome.CommittedIdentity.Target)653 return w.validateIdentity("welcome", welcome.CommittedIdentity.Target)
654 }654 }
655- if w.cfg.Request.Admission == ports.BrokerAdmissionCreateNamed && welcome.SessionName != w.cfg.Request.Name {655+ if (w.cfg.Request.Admission == ports.BrokerAdmissionCreateNamed || w.cfg.Request.Admission == ports.BrokerAdmissionAttachNamed) && welcome.SessionName != w.cfg.Request.Name {
656 return &AttachmentIdentityError{Stage: "welcome", Got: protocol.ExactSessionTarget{SessionName: welcome.SessionName}, Name: w.cfg.Request.Name}656 return &AttachmentIdentityError{Stage: "welcome", Got: protocol.ExactSessionTarget{SessionName: welcome.SessionName}, Name: w.cfg.Request.Name}
657 }657 }
658 return nil658 return nil
@@ -672,7 +672,7 @@ func (w *sessionAttachmentWorker) validateIdentity(stage string, got protocol.Ex
672 if got != request.Target {672 if got != request.Target {
673 return &AttachmentIdentityError{Stage: stage, Want: request.Target, Got: got}673 return &AttachmentIdentityError{Stage: stage, Want: request.Target, Got: got}
674 }674 }
675- case ports.BrokerAdmissionCreateNamed:675+ case ports.BrokerAdmissionCreateNamed, ports.BrokerAdmissionAttachNamed:
676 if got.SessionName != request.Name {676 if got.SessionName != request.Name {
677 return &AttachmentIdentityError{Stage: stage, Got: got, Name: request.Name}677 return &AttachmentIdentityError{Stage: stage, Got: got, Name: request.Name}
678 }678 }
@@ -740,6 +740,10 @@ func (w *sessionAttachmentWorker) hello(stream ports.BrokerLogicalConnection) pr
740 case ports.BrokerAdmissionCreateNamed:740 case ports.BrokerAdmissionCreateNamed:
741 hello.Intent = protocol.IntentNew741 hello.Intent = protocol.IntentNew
742 hello.Name = request.Name742 hello.Name = request.Name
743+ case ports.BrokerAdmissionAttachNamed:
744+ // The daemon resolves the name, restoring it if needed.
745+ hello.Intent = protocol.IntentAttach
746+ hello.Name = request.Name
743 case ports.BrokerAdmissionCreateEphemeral:747 case ports.BrokerAdmissionCreateEphemeral:
744 hello.Intent = protocol.IntentEphemeral748 hello.Intent = protocol.IntentEphemeral
745 }749 }
M internal/usecase/client/broker_operations.go +12 -1
@@ -122,7 +122,18 @@ func NewBrokerOperations(service ports.BrokerService, clock ports.Clock) (*Broke
122122 // unreachable: a refused or lost listing is an ordinary error. A new explicit
123123 // list is always safe, so the implementation never retries on its own.
124124 func (o *BrokerOperations) List(ctx context.Context, route BrokerOperationRoute) ([]protocol.SessionInfo, error) {
125- reply, err := o.exchange(ctx, route, ports.BrokerDaemonExistingOnly, protocol.List{})
125+ return o.list(ctx, route, ports.BrokerDaemonExistingOnly)
126+}
127+
128+// ListStarting is List that may start the destination daemon first, so the
129+// reply includes sessions it restores from disk. Use it when the answer
130+// decides an attach, where a stopped daemon must not read as "no sessions".
131+func (o *BrokerOperations) ListStarting(ctx context.Context, route BrokerOperationRoute) ([]protocol.SessionInfo, error) {
132+ return o.list(ctx, route, ports.BrokerDaemonStartIfNeeded)
133+}
134+
135+func (o *BrokerOperations) list(ctx context.Context, route BrokerOperationRoute, mode ports.BrokerDaemonStartMode) ([]protocol.SessionInfo, error) {
136+ reply, err := o.exchange(ctx, route, mode, protocol.List{})
126137 if err != nil {
127138 if errors.Is(err, errBrokerOperationLost) {
128139 return nil, fmt.Errorf("vev: reading session list: %w", err)
@@ -122,7 +122,18 @@ func NewBrokerOperations(service ports.BrokerService, clock ports.Clock) (*Broke
122 // unreachable: a refused or lost listing is an ordinary error. A new explicit122 // unreachable: a refused or lost listing is an ordinary error. A new explicit
123 // list is always safe, so the implementation never retries on its own.123 // list is always safe, so the implementation never retries on its own.
124 func (o *BrokerOperations) List(ctx context.Context, route BrokerOperationRoute) ([]protocol.SessionInfo, error) {124 func (o *BrokerOperations) List(ctx context.Context, route BrokerOperationRoute) ([]protocol.SessionInfo, error) {
125- reply, err := o.exchange(ctx, route, ports.BrokerDaemonExistingOnly, protocol.List{})125+ return o.list(ctx, route, ports.BrokerDaemonExistingOnly)
126+}
127+
128+// ListStarting is List that may start the destination daemon first, so the
129+// reply includes sessions it restores from disk. Use it when the answer
130+// decides an attach, where a stopped daemon must not read as "no sessions".
131+func (o *BrokerOperations) ListStarting(ctx context.Context, route BrokerOperationRoute) ([]protocol.SessionInfo, error) {
132+ return o.list(ctx, route, ports.BrokerDaemonStartIfNeeded)
133+}
134+
135+func (o *BrokerOperations) list(ctx context.Context, route BrokerOperationRoute, mode ports.BrokerDaemonStartMode) ([]protocol.SessionInfo, error) {
136+ reply, err := o.exchange(ctx, route, mode, protocol.List{})
126 if err != nil {137 if err != nil {
127 if errors.Is(err, errBrokerOperationLost) {138 if errors.Is(err, errBrokerOperationLost) {
128 return nil, fmt.Errorf("vev: reading session list: %w", err)139 return nil, fmt.Errorf("vev: reading session list: %w", err)
M internal/usecase/client/broker_operations_test.go +36 -24
@@ -464,33 +464,45 @@ func TestBrokerOperationOpenFailureIsNotSent(t *testing.T) {
464464 // control stream, sends exactly one List, and returns the daemon's own
465465 // sessions rather than a fabricated or cached value.
466466 func TestBrokerOperationsListUsesOneControlStream(t *testing.T) {
467- stream := newBrokerOpsTestStream()
468- stream.deliver(protocol.Sessions{Sessions: []protocol.SessionInfo{{Name: "alpha", State: protocol.SessionUp}}})
469- ops, service, _ := brokerOpsTestOperation(t, brokerOpsTestLocalSnapshot(), stream)
467+ tests := []struct {
468+ name string
469+ list func(*BrokerOperations, context.Context, BrokerOperationRoute) ([]protocol.SessionInfo, error)
470+ startMode ports.BrokerDaemonStartMode
471+ }{
472+ {name: "list never starts the daemon", list: (*BrokerOperations).List, startMode: ports.BrokerDaemonExistingOnly},
473+ {name: "list starting may start the daemon", list: (*BrokerOperations).ListStarting, startMode: ports.BrokerDaemonStartIfNeeded},
474+ }
475+ for _, tt := range tests {
476+ t.Run(tt.name, func(t *testing.T) {
477+ stream := newBrokerOpsTestStream()
478+ stream.deliver(protocol.Sessions{Sessions: []protocol.SessionInfo{{Name: "alpha", State: protocol.SessionUp}}})
479+ ops, service, _ := brokerOpsTestOperation(t, brokerOpsTestLocalSnapshot(), stream)
470480
471- sessions, err := ops.List(context.Background(), brokerOpsTestLocalRoute())
472- require.NoError(t, err)
473- require.Equal(t, []protocol.SessionInfo{{Name: "alpha", State: protocol.SessionUp}}, sessions)
481+ sessions, err := tt.list(ops, context.Background(), brokerOpsTestLocalRoute())
482+ require.NoError(t, err)
483+ require.Equal(t, []protocol.SessionInfo{{Name: "alpha", State: protocol.SessionUp}}, sessions)
474484
475- requests := service.openedRequests()
476- require.Len(t, requests, 1)
477- request := requests[0]
478- require.Equal(t, ports.BrokerStreamControl, request.Purpose)
479- require.Equal(t, ports.BrokerStreamAdmission(0), request.Admission)
480- require.Empty(t, request.Name)
481- require.Equal(t, protocol.ExactSessionTarget{}, request.Target)
482- require.Empty(t, request.Env)
483- require.Equal(t, ports.BrokerDaemonExistingOnly, request.StartMode)
484- require.True(t, request.Local)
485- require.Equal(t, service.ConnectionID(), request.Connection)
486- require.NotZero(t, request.Stream)
487- require.Equal(t, brokerOpsTestEpoch, request.Epoch)
485+ requests := service.openedRequests()
486+ require.Len(t, requests, 1)
487+ request := requests[0]
488+ require.Equal(t, ports.BrokerStreamControl, request.Purpose)
489+ require.Equal(t, ports.BrokerStreamAdmission(0), request.Admission)
490+ require.Empty(t, request.Name)
491+ require.Equal(t, protocol.ExactSessionTarget{}, request.Target)
492+ require.Empty(t, request.Env)
493+ require.Equal(t, tt.startMode, request.StartMode)
494+ require.True(t, request.Local)
495+ require.Equal(t, service.ConnectionID(), request.Connection)
496+ require.NotZero(t, request.Stream)
497+ require.Equal(t, brokerOpsTestEpoch, request.Epoch)
488498
489- sent := stream.messages()
490- require.Len(t, sent, 1)
491- require.Equal(t, protocol.List{}, sent[0])
492- require.Equal(t, 1, stream.closeCount(), "the owned stream is closed exactly once")
493- require.Equal(t, 0, service.closeCount(), "the borrowed service is never closed")
499+ sent := stream.messages()
500+ require.Len(t, sent, 1)
501+ require.Equal(t, protocol.List{}, sent[0])
502+ require.Equal(t, 1, stream.closeCount(), "the owned stream is closed exactly once")
503+ require.Equal(t, 0, service.closeCount(), "the borrowed service is never closed")
504+ })
505+ }
494506 }
495507
496508 // TestBrokerOperationsListLostReplyIsError proves a lost or malformed listing
@@ -464,33 +464,45 @@ func TestBrokerOperationOpenFailureIsNotSent(t *testing.T) {
464 // control stream, sends exactly one List, and returns the daemon's own464 // control stream, sends exactly one List, and returns the daemon's own
465 // sessions rather than a fabricated or cached value.465 // sessions rather than a fabricated or cached value.
466 func TestBrokerOperationsListUsesOneControlStream(t *testing.T) {466 func TestBrokerOperationsListUsesOneControlStream(t *testing.T) {
467- stream := newBrokerOpsTestStream()467+ tests := []struct {
468- stream.deliver(protocol.Sessions{Sessions: []protocol.SessionInfo{{Name: "alpha", State: protocol.SessionUp}}})468+ name string
469- ops, service, _ := brokerOpsTestOperation(t, brokerOpsTestLocalSnapshot(), stream)469+ list func(*BrokerOperations, context.Context, BrokerOperationRoute) ([]protocol.SessionInfo, error)
470+ startMode ports.BrokerDaemonStartMode
471+ }{
472+ {name: "list never starts the daemon", list: (*BrokerOperations).List, startMode: ports.BrokerDaemonExistingOnly},
473+ {name: "list starting may start the daemon", list: (*BrokerOperations).ListStarting, startMode: ports.BrokerDaemonStartIfNeeded},
474+ }
475+ for _, tt := range tests {
476+ t.Run(tt.name, func(t *testing.T) {
477+ stream := newBrokerOpsTestStream()
478+ stream.deliver(protocol.Sessions{Sessions: []protocol.SessionInfo{{Name: "alpha", State: protocol.SessionUp}}})
479+ ops, service, _ := brokerOpsTestOperation(t, brokerOpsTestLocalSnapshot(), stream)
470 480
471- sessions, err := ops.List(context.Background(), brokerOpsTestLocalRoute())481+ sessions, err := tt.list(ops, context.Background(), brokerOpsTestLocalRoute())
472- require.NoError(t, err)482+ require.NoError(t, err)
473- require.Equal(t, []protocol.SessionInfo{{Name: "alpha", State: protocol.SessionUp}}, sessions)483+ require.Equal(t, []protocol.SessionInfo{{Name: "alpha", State: protocol.SessionUp}}, sessions)
474 484
475- requests := service.openedRequests()485+ requests := service.openedRequests()
476- require.Len(t, requests, 1)486+ require.Len(t, requests, 1)
477- request := requests[0]487+ request := requests[0]
478- require.Equal(t, ports.BrokerStreamControl, request.Purpose)488+ require.Equal(t, ports.BrokerStreamControl, request.Purpose)
479- require.Equal(t, ports.BrokerStreamAdmission(0), request.Admission)489+ require.Equal(t, ports.BrokerStreamAdmission(0), request.Admission)
480- require.Empty(t, request.Name)490+ require.Empty(t, request.Name)
481- require.Equal(t, protocol.ExactSessionTarget{}, request.Target)491+ require.Equal(t, protocol.ExactSessionTarget{}, request.Target)
482- require.Empty(t, request.Env)492+ require.Empty(t, request.Env)
483- require.Equal(t, ports.BrokerDaemonExistingOnly, request.StartMode)493+ require.Equal(t, tt.startMode, request.StartMode)
484- require.True(t, request.Local)494+ require.True(t, request.Local)
485- require.Equal(t, service.ConnectionID(), request.Connection)495+ require.Equal(t, service.ConnectionID(), request.Connection)
486- require.NotZero(t, request.Stream)496+ require.NotZero(t, request.Stream)
487- require.Equal(t, brokerOpsTestEpoch, request.Epoch)497+ require.Equal(t, brokerOpsTestEpoch, request.Epoch)
488 498
489- sent := stream.messages()499+ sent := stream.messages()
490- require.Len(t, sent, 1)500+ require.Len(t, sent, 1)
491- require.Equal(t, protocol.List{}, sent[0])501+ require.Equal(t, protocol.List{}, sent[0])
492- require.Equal(t, 1, stream.closeCount(), "the owned stream is closed exactly once")502+ require.Equal(t, 1, stream.closeCount(), "the owned stream is closed exactly once")
493- require.Equal(t, 0, service.closeCount(), "the borrowed service is never closed")503+ require.Equal(t, 0, service.closeCount(), "the borrowed service is never closed")
504+ })
505+ }
494 }506 }
495 507
496 // TestBrokerOperationsListLostReplyIsError proves a lost or malformed listing508 // TestBrokerOperationsListLostReplyIsError proves a lost or malformed listing
M internal/usecase/client/initial_navigation.go +14 -4
@@ -31,6 +31,8 @@ import (
3131 // only for the named variant, and no target.
3232 // - AttachExact carries a valid endpoint fence, a nonzero epoch, no name, and
3333 // a validated protocol.ExactSessionTarget.
34+// - AttachNamed carries a valid endpoint fence, a name, and no target; the
35+// daemon resolves the name and refuses an unknown one.
3436 // - A local creation may state epoch zero, meaning "bind to the first adopted
3537 // publication". Every remote intent, and every exact attach even locally,
3638 // requires the epoch of the snapshot that resolved it, and a nonzero epoch
@@ -48,6 +50,9 @@ const (
4850 InitialNavigationCreateNamed
4951 // InitialNavigationAttachExact attaches to one exact session lifecycle.
5052 InitialNavigationAttachExact
53+ // InitialNavigationAttachNamed attaches to the session the daemon knows by
54+ // name; existence is the daemon's decision, not the broker catalogue's.
55+ InitialNavigationAttachNamed
5156 )
5257
5358 func (k InitialNavigationKind) String() string {
@@ -60,6 +65,8 @@ func (k InitialNavigationKind) String() string {
6065 return "create_named"
6166 case InitialNavigationAttachExact:
6267 return "attach_exact"
68+ case InitialNavigationAttachNamed:
69+ return "attach_named"
6370 default:
6471 return fmt.Sprintf("invalid(%d)", uint8(k))
6572 }
@@ -104,7 +111,7 @@ func (n InitialNavigation) Validate() error {
104111 return errors.New("vev: picker navigation carries an exact target")
105112 }
106113 return nil
107- case InitialNavigationCreateEphemeral, InitialNavigationCreateNamed, InitialNavigationAttachExact:
114+ case InitialNavigationCreateEphemeral, InitialNavigationCreateNamed, InitialNavigationAttachExact, InitialNavigationAttachNamed:
108115 default:
109116 return fmt.Errorf("vev: unknown initial navigation kind %d", uint8(n.Kind))
110117 }
@@ -124,12 +131,12 @@ func (n InitialNavigation) Validate() error {
124131 if n.Target != (protocol.ExactSessionTarget{}) {
125132 return errors.New("vev: ephemeral creation navigation carries an exact target")
126133 }
127- case InitialNavigationCreateNamed:
134+ case InitialNavigationCreateNamed, InitialNavigationAttachNamed:
128135 if err := domain.ValidateSessionName(n.Name); err != nil {
129136 return fmt.Errorf("vev: initial navigation session name: %w", err)
130137 }
131138 if n.Target != (protocol.ExactSessionTarget{}) {
132- return errors.New("vev: named creation navigation carries an exact target")
139+ return errors.New("vev: named navigation carries an exact target")
133140 }
134141 case InitialNavigationAttachExact:
135142 if n.Epoch == 0 {
@@ -202,6 +209,9 @@ func pickerSelectionFromNavigation(n InitialNavigation) pickerSelectionRef {
202209 ref.kind = pickerSelectionExact
203210 ref.lifecycle = n.Target.LifecycleID
204211 ref.name = n.Target.SessionName
212+ case InitialNavigationAttachNamed:
213+ ref.kind = pickerSelectionAttachNamed
214+ ref.name = n.Name
205215 }
206216 return ref
207217 }
@@ -211,7 +221,7 @@ func pickerSelectionFromNavigation(n InitialNavigation) pickerSelectionRef {
211221 // creation name keeps its own code, and every other contradiction is an
212222 // unknown-kind refusal.
213223 func initialNavigationRefusal(n InitialNavigation) pickerCatalogueErrorCode {
214- if n.Kind == InitialNavigationCreateNamed {
224+ if n.Kind == InitialNavigationCreateNamed || n.Kind == InitialNavigationAttachNamed {
215225 if err := domain.ValidateSessionName(n.Name); err != nil {
216226 return pickerCatalogueInvalidName
217227 }
@@ -31,6 +31,8 @@ import (
31 // only for the named variant, and no target.31 // only for the named variant, and no target.
32 // - AttachExact carries a valid endpoint fence, a nonzero epoch, no name, and32 // - AttachExact carries a valid endpoint fence, a nonzero epoch, no name, and
33 // a validated protocol.ExactSessionTarget.33 // a validated protocol.ExactSessionTarget.
34+// - AttachNamed carries a valid endpoint fence, a name, and no target; the
35+// daemon resolves the name and refuses an unknown one.
34 // - A local creation may state epoch zero, meaning "bind to the first adopted36 // - A local creation may state epoch zero, meaning "bind to the first adopted
35 // publication". Every remote intent, and every exact attach even locally,37 // publication". Every remote intent, and every exact attach even locally,
36 // requires the epoch of the snapshot that resolved it, and a nonzero epoch38 // requires the epoch of the snapshot that resolved it, and a nonzero epoch
@@ -48,6 +50,9 @@ const (
48 InitialNavigationCreateNamed50 InitialNavigationCreateNamed
49 // InitialNavigationAttachExact attaches to one exact session lifecycle.51 // InitialNavigationAttachExact attaches to one exact session lifecycle.
50 InitialNavigationAttachExact52 InitialNavigationAttachExact
53+ // InitialNavigationAttachNamed attaches to the session the daemon knows by
54+ // name; existence is the daemon's decision, not the broker catalogue's.
55+ InitialNavigationAttachNamed
51 )56 )
52 57
53 func (k InitialNavigationKind) String() string {58 func (k InitialNavigationKind) String() string {
@@ -60,6 +65,8 @@ func (k InitialNavigationKind) String() string {
60 return "create_named"65 return "create_named"
61 case InitialNavigationAttachExact:66 case InitialNavigationAttachExact:
62 return "attach_exact"67 return "attach_exact"
68+ case InitialNavigationAttachNamed:
69+ return "attach_named"
63 default:70 default:
64 return fmt.Sprintf("invalid(%d)", uint8(k))71 return fmt.Sprintf("invalid(%d)", uint8(k))
65 }72 }
@@ -104,7 +111,7 @@ func (n InitialNavigation) Validate() error {
104 return errors.New("vev: picker navigation carries an exact target")111 return errors.New("vev: picker navigation carries an exact target")
105 }112 }
106 return nil113 return nil
107- case InitialNavigationCreateEphemeral, InitialNavigationCreateNamed, InitialNavigationAttachExact:114+ case InitialNavigationCreateEphemeral, InitialNavigationCreateNamed, InitialNavigationAttachExact, InitialNavigationAttachNamed:
108 default:115 default:
109 return fmt.Errorf("vev: unknown initial navigation kind %d", uint8(n.Kind))116 return fmt.Errorf("vev: unknown initial navigation kind %d", uint8(n.Kind))
110 }117 }
@@ -124,12 +131,12 @@ func (n InitialNavigation) Validate() error {
124 if n.Target != (protocol.ExactSessionTarget{}) {131 if n.Target != (protocol.ExactSessionTarget{}) {
125 return errors.New("vev: ephemeral creation navigation carries an exact target")132 return errors.New("vev: ephemeral creation navigation carries an exact target")
126 }133 }
127- case InitialNavigationCreateNamed:134+ case InitialNavigationCreateNamed, InitialNavigationAttachNamed:
128 if err := domain.ValidateSessionName(n.Name); err != nil {135 if err := domain.ValidateSessionName(n.Name); err != nil {
129 return fmt.Errorf("vev: initial navigation session name: %w", err)136 return fmt.Errorf("vev: initial navigation session name: %w", err)
130 }137 }
131 if n.Target != (protocol.ExactSessionTarget{}) {138 if n.Target != (protocol.ExactSessionTarget{}) {
132- return errors.New("vev: named creation navigation carries an exact target")139+ return errors.New("vev: named navigation carries an exact target")
133 }140 }
134 case InitialNavigationAttachExact:141 case InitialNavigationAttachExact:
135 if n.Epoch == 0 {142 if n.Epoch == 0 {
@@ -202,6 +209,9 @@ func pickerSelectionFromNavigation(n InitialNavigation) pickerSelectionRef {
202 ref.kind = pickerSelectionExact209 ref.kind = pickerSelectionExact
203 ref.lifecycle = n.Target.LifecycleID210 ref.lifecycle = n.Target.LifecycleID
204 ref.name = n.Target.SessionName211 ref.name = n.Target.SessionName
212+ case InitialNavigationAttachNamed:
213+ ref.kind = pickerSelectionAttachNamed
214+ ref.name = n.Name
205 }215 }
206 return ref216 return ref
207 }217 }
@@ -211,7 +221,7 @@ func pickerSelectionFromNavigation(n InitialNavigation) pickerSelectionRef {
211 // creation name keeps its own code, and every other contradiction is an221 // creation name keeps its own code, and every other contradiction is an
212 // unknown-kind refusal.222 // unknown-kind refusal.
213 func initialNavigationRefusal(n InitialNavigation) pickerCatalogueErrorCode {223 func initialNavigationRefusal(n InitialNavigation) pickerCatalogueErrorCode {
214- if n.Kind == InitialNavigationCreateNamed {224+ if n.Kind == InitialNavigationCreateNamed || n.Kind == InitialNavigationAttachNamed {
215 if err := domain.ValidateSessionName(n.Name); err != nil {225 if err := domain.ValidateSessionName(n.Name); err != nil {
216 return pickerCatalogueInvalidName226 return pickerCatalogueInvalidName
217 }227 }
M internal/usecase/client/picker_catalogue.go +12 -1
@@ -136,6 +136,9 @@ const (
136136 pickerSelectionCreateNamed
137137 // pickerSelectionCreateEphemeral creates one ephemeral session.
138138 pickerSelectionCreateEphemeral
139+ // pickerSelectionAttachNamed attaches to a session the daemon resolves by
140+ // name. It is produced only by an initial CLI navigation, never a row.
141+ pickerSelectionAttachNamed
139142 )
140143
141144 // pickerSelectionRef is the client's exact selection identity, captured when a
@@ -599,7 +602,9 @@ func resolvePickerTarget(epoch ports.BrokerEpoch, authority pickerResolveAuthori
599602 if observation.Availability == domain.RemoteAvailabilityNoDaemon && ref.kind == pickerSelectionExact {
600603 return fail(pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "remote host has no vev daemon; choose create session"})
601604 }
602- if observation.Availability == domain.RemoteAvailabilityNoDaemon && ref.kind != pickerSelectionCreateNamed && ref.kind != pickerSelectionCreateEphemeral {
605+ if observation.Availability == domain.RemoteAvailabilityNoDaemon && ref.kind != pickerSelectionCreateNamed && ref.kind != pickerSelectionCreateEphemeral && ref.kind != pickerSelectionAttachNamed {
606+ // A named attach carries StartIfNeeded and lets the started daemon
607+ // restore or refuse the name, so a no-daemon observation is no refusal.
603608 return fail(pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "no daemon exists on this host; choose create session"})
604609 }
605610 if refuseFailing && !ref.local && pickerObservationFailing(observation) && observation.Availability != domain.RemoteAvailabilityNoDaemon {
@@ -658,6 +663,12 @@ func resolvePickerTarget(epoch ports.BrokerEpoch, authority pickerResolveAuthori
658663 }
659664 request.Admission = ports.BrokerAdmissionCreateNamed
660665 request.Name = ref.createName
666+ case pickerSelectionAttachNamed:
667+ if err := domain.ValidateSessionName(ref.name); err != nil {
668+ return fail(pickerCatalogueError{Code: pickerCatalogueInvalidName, Text: "session name is not valid"})
669+ }
670+ request.Admission = ports.BrokerAdmissionAttachNamed
671+ request.Name = ref.name
661672 case pickerSelectionCreateEphemeral:
662673 request.Admission = ports.BrokerAdmissionCreateEphemeral
663674 default:
@@ -136,6 +136,9 @@ const (
136 pickerSelectionCreateNamed136 pickerSelectionCreateNamed
137 // pickerSelectionCreateEphemeral creates one ephemeral session.137 // pickerSelectionCreateEphemeral creates one ephemeral session.
138 pickerSelectionCreateEphemeral138 pickerSelectionCreateEphemeral
139+ // pickerSelectionAttachNamed attaches to a session the daemon resolves by
140+ // name. It is produced only by an initial CLI navigation, never a row.
141+ pickerSelectionAttachNamed
139 )142 )
140 143
141 // pickerSelectionRef is the client's exact selection identity, captured when a144 // pickerSelectionRef is the client's exact selection identity, captured when a
@@ -599,7 +602,9 @@ func resolvePickerTarget(epoch ports.BrokerEpoch, authority pickerResolveAuthori
599 if observation.Availability == domain.RemoteAvailabilityNoDaemon && ref.kind == pickerSelectionExact {602 if observation.Availability == domain.RemoteAvailabilityNoDaemon && ref.kind == pickerSelectionExact {
600 return fail(pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "remote host has no vev daemon; choose create session"})603 return fail(pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "remote host has no vev daemon; choose create session"})
601 }604 }
602- if observation.Availability == domain.RemoteAvailabilityNoDaemon && ref.kind != pickerSelectionCreateNamed && ref.kind != pickerSelectionCreateEphemeral {605+ if observation.Availability == domain.RemoteAvailabilityNoDaemon && ref.kind != pickerSelectionCreateNamed && ref.kind != pickerSelectionCreateEphemeral && ref.kind != pickerSelectionAttachNamed {
606+ // A named attach carries StartIfNeeded and lets the started daemon
607+ // restore or refuse the name, so a no-daemon observation is no refusal.
603 return fail(pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "no daemon exists on this host; choose create session"})608 return fail(pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "no daemon exists on this host; choose create session"})
604 }609 }
605 if refuseFailing && !ref.local && pickerObservationFailing(observation) && observation.Availability != domain.RemoteAvailabilityNoDaemon {610 if refuseFailing && !ref.local && pickerObservationFailing(observation) && observation.Availability != domain.RemoteAvailabilityNoDaemon {
@@ -658,6 +663,12 @@ func resolvePickerTarget(epoch ports.BrokerEpoch, authority pickerResolveAuthori
658 }663 }
659 request.Admission = ports.BrokerAdmissionCreateNamed664 request.Admission = ports.BrokerAdmissionCreateNamed
660 request.Name = ref.createName665 request.Name = ref.createName
666+ case pickerSelectionAttachNamed:
667+ if err := domain.ValidateSessionName(ref.name); err != nil {
668+ return fail(pickerCatalogueError{Code: pickerCatalogueInvalidName, Text: "session name is not valid"})
669+ }
670+ request.Admission = ports.BrokerAdmissionAttachNamed
671+ request.Name = ref.name
661 case pickerSelectionCreateEphemeral:672 case pickerSelectionCreateEphemeral:
662 request.Admission = ports.BrokerAdmissionCreateEphemeral673 request.Admission = ports.BrokerAdmissionCreateEphemeral
663 default:674 default:
M internal/usecase/client/picker_catalogue_test.go +23 -0
@@ -849,6 +849,7 @@ func TestPickerCatalogueResolutionAdmission(t *testing.T) {
849849 hostRow bool
850850 create bool
851851 exact bool
852+ named bool
852853 }{
853854 {
854855 name: "unobserved unknown stays attemptable",
@@ -915,6 +916,19 @@ func TestPickerCatalogueResolutionAdmission(t *testing.T) {
915916 }(),
916917 wantErr: pickerCatalogueUnavailable,
917918 },
919+ {
920+ name: "no daemon lets the daemon decide a named attach", named: true,
921+ daemon: func() ports.BrokerDaemonObservation {
922+ o := pickerTestRemoteObservation("user@arch", 1, 1, now)
923+ o.Identity = ""
924+ o.Incarnation = ports.BrokerDaemonIncarnation{}
925+ o.ProtocolVersion = 0
926+ o.Availability = domain.RemoteAvailabilityNoDaemon
927+ o.LastSuccess = time.Time{}
928+ o.InventoryKnown = false
929+ return o
930+ }(),
931+ },
918932 {
919933 name: "reachable mismatch refuses incompatible",
920934 daemon: func() ports.BrokerDaemonObservation {
@@ -953,6 +967,15 @@ func TestPickerCatalogueResolutionAdmission(t *testing.T) {
953967 require.Equal(t, ports.BrokerDaemonStartIfNeeded, request.StartMode)
954968 return
955969 }
970+ if tt.named {
971+ ref := pickerSelectionRef{kind: pickerSelectionAttachNamed, epoch: catalogue.snapshot.Epoch, endpoint: tt.daemon.Endpoint,
972+ registration: tt.daemon.Registration, name: "restored"}
973+ request, err := resolvePickerRequest(catalogue.snapshot.Epoch, pickerAuthorityInSnapshot(catalogue.snapshot, ref), ref, pickerTestBase())
974+ require.NoError(t, err)
975+ require.Equal(t, ports.BrokerAdmissionAttachNamed, request.Admission)
976+ require.Equal(t, ports.BrokerDaemonStartIfNeeded, request.StartMode)
977+ return
978+ }
956979 if tt.exact {
957980 ref := pickerSelectionRef{kind: pickerSelectionExact, epoch: catalogue.snapshot.Epoch, endpoint: tt.daemon.Endpoint,
958981 registration: tt.daemon.Registration, lifecycle: domain.SessionLifecycleID{1}, name: "absent"}
@@ -849,6 +849,7 @@ func TestPickerCatalogueResolutionAdmission(t *testing.T) {
849 hostRow bool849 hostRow bool
850 create bool850 create bool
851 exact bool851 exact bool
852+ named bool
852 }{853 }{
853 {854 {
854 name: "unobserved unknown stays attemptable",855 name: "unobserved unknown stays attemptable",
@@ -915,6 +916,19 @@ func TestPickerCatalogueResolutionAdmission(t *testing.T) {
915 }(),916 }(),
916 wantErr: pickerCatalogueUnavailable,917 wantErr: pickerCatalogueUnavailable,
917 },918 },
919+ {
920+ name: "no daemon lets the daemon decide a named attach", named: true,
921+ daemon: func() ports.BrokerDaemonObservation {
922+ o := pickerTestRemoteObservation("user@arch", 1, 1, now)
923+ o.Identity = ""
924+ o.Incarnation = ports.BrokerDaemonIncarnation{}
925+ o.ProtocolVersion = 0
926+ o.Availability = domain.RemoteAvailabilityNoDaemon
927+ o.LastSuccess = time.Time{}
928+ o.InventoryKnown = false
929+ return o
930+ }(),
931+ },
918 {932 {
919 name: "reachable mismatch refuses incompatible",933 name: "reachable mismatch refuses incompatible",
920 daemon: func() ports.BrokerDaemonObservation {934 daemon: func() ports.BrokerDaemonObservation {
@@ -953,6 +967,15 @@ func TestPickerCatalogueResolutionAdmission(t *testing.T) {
953 require.Equal(t, ports.BrokerDaemonStartIfNeeded, request.StartMode)967 require.Equal(t, ports.BrokerDaemonStartIfNeeded, request.StartMode)
954 return968 return
955 }969 }
970+ if tt.named {
971+ ref := pickerSelectionRef{kind: pickerSelectionAttachNamed, epoch: catalogue.snapshot.Epoch, endpoint: tt.daemon.Endpoint,
972+ registration: tt.daemon.Registration, name: "restored"}
973+ request, err := resolvePickerRequest(catalogue.snapshot.Epoch, pickerAuthorityInSnapshot(catalogue.snapshot, ref), ref, pickerTestBase())
974+ require.NoError(t, err)
975+ require.Equal(t, ports.BrokerAdmissionAttachNamed, request.Admission)
976+ require.Equal(t, ports.BrokerDaemonStartIfNeeded, request.StartMode)
977+ return
978+ }
956 if tt.exact {979 if tt.exact {
957 ref := pickerSelectionRef{kind: pickerSelectionExact, epoch: catalogue.snapshot.Epoch, endpoint: tt.daemon.Endpoint,980 ref := pickerSelectionRef{kind: pickerSelectionExact, epoch: catalogue.snapshot.Epoch, endpoint: tt.daemon.Endpoint,
958 registration: tt.daemon.Registration, lifecycle: domain.SessionLifecycleID{1}, name: "absent"}981 registration: tt.daemon.Registration, lifecycle: domain.SessionLifecycleID{1}, name: "absent"}
M internal/usecase/daemon/attachment_effect_gate_test.go +3 -2
@@ -3,6 +3,7 @@ package daemon
33 import (
44 "errors"
55 "io"
6+ "slices"
67 "sync"
78 "testing"
89 "time"
@@ -523,8 +524,8 @@ func TestAttachmentEffectGateReplacementInterruptsBlockedOldRenderBeforePublicat
523524 t.Fatal("replacement publication remained blocked behind the old render send")
524525 }
525526 require.NoError(t, <-transitionErr)
526- require.Contains(t, sess.snapshotAttachments(), next)
527- require.Contains(t, sess.snapshotAttachments(), old)
527+ require.True(t, slices.Contains(sess.snapshotAttachments(), next))
528+ require.True(t, slices.Contains(sess.snapshotAttachments(), old))
528529 require.False(t, newTransport.Closed(), "new attachment transport was affected by publication")
529530
530531 // Generic attachment publication does not interrupt or retire another
@@ -3,6 +3,7 @@ package daemon
3 import (3 import (
4 "errors"4 "errors"
5 "io"5 "io"
6+ "slices"
6 "sync"7 "sync"
7 "testing"8 "testing"
8 "time"9 "time"
@@ -523,8 +524,8 @@ func TestAttachmentEffectGateReplacementInterruptsBlockedOldRenderBeforePublicat
523 t.Fatal("replacement publication remained blocked behind the old render send")524 t.Fatal("replacement publication remained blocked behind the old render send")
524 }525 }
525 require.NoError(t, <-transitionErr)526 require.NoError(t, <-transitionErr)
526- require.Contains(t, sess.snapshotAttachments(), next)527+ require.True(t, slices.Contains(sess.snapshotAttachments(), next))
527- require.Contains(t, sess.snapshotAttachments(), old)528+ require.True(t, slices.Contains(sess.snapshotAttachments(), old))
528 require.False(t, newTransport.Closed(), "new attachment transport was affected by publication")529 require.False(t, newTransport.Closed(), "new attachment transport was affected by publication")
529 530
530 // Generic attachment publication does not interrupt or retire another531 // Generic attachment publication does not interrupt or retire another
M internal/usecase/daemon/attachment_multiplex_semantics_test.go +4 -3
@@ -1,6 +1,7 @@
11 package daemon
22
33 import (
4+ "slices"
45 "testing"
56
67 "github.com/bnema/vev/internal/domain"
@@ -179,7 +180,7 @@ func TestMultiplexReconnectGenerationFencesOldEffects(t *testing.T) {
179180 t.Fatal("stale generation emitted a frame")
180181 default:
181182 }
182- require.Contains(t, sess.snapshotAttachments(), peer)
183+ require.True(t, slices.Contains(sess.snapshotAttachments(), peer))
183184 }
184185
185186 // TestMultiplexSingleStreamErrorIsolation proves one failed attachment is
@@ -197,8 +198,8 @@ func TestMultiplexSingleStreamErrorIsolation(t *testing.T) {
197198 failed.installTestAttachmentCapability(failedToken)
198199
199200 d.detachOnAttachmentSendError(failedToken, failedTransport)
200- require.NotContains(t, sess.snapshotAttachments(), failed)
201- require.Contains(t, sess.snapshotAttachments(), peer)
201+ require.False(t, slices.Contains(sess.snapshotAttachments(), failed))
202+ require.True(t, slices.Contains(sess.snapshotAttachments(), peer))
202203 require.Same(t, sess, peer.currentSession())
203204 d.mu.Lock()
204205 require.Same(t, sess, d.sessions[sess.id])
@@ -1,6 +1,7 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "slices"
4 "testing"5 "testing"
5 6
6 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
@@ -179,7 +180,7 @@ func TestMultiplexReconnectGenerationFencesOldEffects(t *testing.T) {
179 t.Fatal("stale generation emitted a frame")180 t.Fatal("stale generation emitted a frame")
180 default:181 default:
181 }182 }
182- require.Contains(t, sess.snapshotAttachments(), peer)183+ require.True(t, slices.Contains(sess.snapshotAttachments(), peer))
183 }184 }
184 185
185 // TestMultiplexSingleStreamErrorIsolation proves one failed attachment is186 // TestMultiplexSingleStreamErrorIsolation proves one failed attachment is
@@ -197,8 +198,8 @@ func TestMultiplexSingleStreamErrorIsolation(t *testing.T) {
197 failed.installTestAttachmentCapability(failedToken)198 failed.installTestAttachmentCapability(failedToken)
198 199
199 d.detachOnAttachmentSendError(failedToken, failedTransport)200 d.detachOnAttachmentSendError(failedToken, failedTransport)
200- require.NotContains(t, sess.snapshotAttachments(), failed)201+ require.False(t, slices.Contains(sess.snapshotAttachments(), failed))
201- require.Contains(t, sess.snapshotAttachments(), peer)202+ require.True(t, slices.Contains(sess.snapshotAttachments(), peer))
202 require.Same(t, sess, peer.currentSession())203 require.Same(t, sess, peer.currentSession())
203 d.mu.Lock()204 d.mu.Lock()
204 require.Same(t, sess, d.sessions[sess.id])205 require.Same(t, sess, d.sessions[sess.id])
M internal/usecase/daemon/attachment_parking_test.go +2 -1
@@ -2,6 +2,7 @@ package daemon
22
33 import (
44 "errors"
5+ "slices"
56 "testing"
67
78 "github.com/stretchr/testify/require"
@@ -42,7 +43,7 @@ func TestAttachmentLossParksOnlyThatAttachment(t *testing.T) {
4243 sess.mu.Unlock()
4344 require.False(t, firstAttached)
4445 require.True(t, secondAttached)
45- require.Contains(t, sess.snapshotAttachments(), second)
46+ require.True(t, slices.Contains(sess.snapshotAttachments(), second))
4647 require.False(t, secondTransport.Closed())
4748 }
4849
@@ -2,6 +2,7 @@ package daemon
2 2
3 import (3 import (
4 "errors"4 "errors"
5+ "slices"
5 "testing"6 "testing"
6 7
7 "github.com/stretchr/testify/require"8 "github.com/stretchr/testify/require"
@@ -42,7 +43,7 @@ func TestAttachmentLossParksOnlyThatAttachment(t *testing.T) {
42 sess.mu.Unlock()43 sess.mu.Unlock()
43 require.False(t, firstAttached)44 require.False(t, firstAttached)
44 require.True(t, secondAttached)45 require.True(t, secondAttached)
45- require.Contains(t, sess.snapshotAttachments(), second)46+ require.True(t, slices.Contains(sess.snapshotAttachments(), second))
46 require.False(t, secondTransport.Closed())47 require.False(t, secondTransport.Closed())
47 }48 }
48 49
M internal/usecase/daemon/attachment_suspension_expiry_test.go +3 -2
@@ -4,6 +4,7 @@ import (
44 "errors"
55 "io"
66 "log/slog"
7+ "slices"
78 "sync"
89 "sync/atomic"
910 "testing"
@@ -112,7 +113,7 @@ func TestSuspendedAttachmentSafetyExpiryEvictsAfterTimer(t *testing.T) {
112113 // Suspended-only presence is not observable, but membership and transport
113114 // ownership are retained until expiry.
114115 require.False(t, sess.snapshotView(viewOptions{}).attached, "suspended-only session must not be observably attached")
115- require.Contains(t, sess.snapshotAttachments(), ac, "suspension retains session membership")
116+ require.True(t, slices.Contains(sess.snapshotAttachments(), ac), "suspension retains session membership")
116117 d.mu.Lock()
117118 require.NotNil(t, d.suspended[ac], "suspension arms the daemon-owned safety expiry")
118119 d.mu.Unlock()
@@ -438,7 +439,7 @@ func TestHandleListAttachedExcludesSuspendedOnly(t *testing.T) {
438439 ac.installTestAttachmentCapability(token)
439440 require.NoError(t, d.suspendAttachment(token, protocol.SuspendAttachment{RequestID: 1}))
440441 require.False(t, listedAttached(t, d, sess.name), "a suspended-only session is not attached in the listing")
441- require.Contains(t, sess.snapshotAttachments(), ac, "suspension retains membership for activation")
442+ require.True(t, slices.Contains(sess.snapshotAttachments(), ac), "suspension retains membership for activation")
442443
443444 require.NoError(t, d.activateAttachment(ac, ac.transportSnapshot(), protocol.ActivateAttachment{
444445 RequestID: 2,
@@ -4,6 +4,7 @@ import (
4 "errors"4 "errors"
5 "io"5 "io"
6 "log/slog"6 "log/slog"
7+ "slices"
7 "sync"8 "sync"
8 "sync/atomic"9 "sync/atomic"
9 "testing"10 "testing"
@@ -112,7 +113,7 @@ func TestSuspendedAttachmentSafetyExpiryEvictsAfterTimer(t *testing.T) {
112 // Suspended-only presence is not observable, but membership and transport113 // Suspended-only presence is not observable, but membership and transport
113 // ownership are retained until expiry.114 // ownership are retained until expiry.
114 require.False(t, sess.snapshotView(viewOptions{}).attached, "suspended-only session must not be observably attached")115 require.False(t, sess.snapshotView(viewOptions{}).attached, "suspended-only session must not be observably attached")
115- require.Contains(t, sess.snapshotAttachments(), ac, "suspension retains session membership")116+ require.True(t, slices.Contains(sess.snapshotAttachments(), ac), "suspension retains session membership")
116 d.mu.Lock()117 d.mu.Lock()
117 require.NotNil(t, d.suspended[ac], "suspension arms the daemon-owned safety expiry")118 require.NotNil(t, d.suspended[ac], "suspension arms the daemon-owned safety expiry")
118 d.mu.Unlock()119 d.mu.Unlock()
@@ -438,7 +439,7 @@ func TestHandleListAttachedExcludesSuspendedOnly(t *testing.T) {
438 ac.installTestAttachmentCapability(token)439 ac.installTestAttachmentCapability(token)
439 require.NoError(t, d.suspendAttachment(token, protocol.SuspendAttachment{RequestID: 1}))440 require.NoError(t, d.suspendAttachment(token, protocol.SuspendAttachment{RequestID: 1}))
440 require.False(t, listedAttached(t, d, sess.name), "a suspended-only session is not attached in the listing")441 require.False(t, listedAttached(t, d, sess.name), "a suspended-only session is not attached in the listing")
441- require.Contains(t, sess.snapshotAttachments(), ac, "suspension retains membership for activation")442+ require.True(t, slices.Contains(sess.snapshotAttachments(), ac), "suspension retains membership for activation")
442 443
443 require.NoError(t, d.activateAttachment(ac, ac.transportSnapshot(), protocol.ActivateAttachment{444 require.NoError(t, d.activateAttachment(ac, ac.transportSnapshot(), protocol.ActivateAttachment{
444 RequestID: 2,445 RequestID: 2,
M internal/usecase/daemon/clipboard_test.go +4 -3
@@ -7,6 +7,7 @@ import (
77 "io"
88 "os"
99 "path/filepath"
10+ "slices"
1011 "strings"
1112 "sync"
1213 "testing"
@@ -366,7 +367,7 @@ func TestQueuedClipboardAfterPaneMoveDoesNotSendToFormerOwner(t *testing.T) {
366367 d.clipboardWorker(sess)
367368
368369 require.Zero(t, oldTransport.sendCount(), "clipboard queued by the former pane owner reached its client")
369- require.Contains(t, sess.snapshotAttachments(), ac, "stale clipboard send handling detached the former owner's client")
370+ require.True(t, slices.Contains(sess.snapshotAttachments(), ac), "stale clipboard send handling detached the former owner's client")
370371 }
371372
372373 type movingClipboardErrorTransport struct {
@@ -435,7 +436,7 @@ func TestQueuedClipboardRevalidatesOwnerAfterWaitingForClientSendLock(t *testing
435436 awaitTestCompletion(t, workerDone, "clipboard worker did not finish")
436437
437438 require.Zero(t, oldTransport.sendCount(), "clipboard send was not revalidated immediately before transport I/O")
438- require.Contains(t, sess.snapshotAttachments(), ac)
439+ require.True(t, slices.Contains(sess.snapshotAttachments(), ac))
439440 }
440441
441442 func TestClipboardSendErrorAfterPaneMoveDoesNotDetachFormerOwner(t *testing.T) {
@@ -464,7 +465,7 @@ func TestClipboardSendErrorAfterPaneMoveDoesNotDetachFormerOwner(t *testing.T) {
464465 close(oldTransport.release)
465466 awaitTestCompletion(t, workerDone, "clipboard worker did not finish")
466467
467- require.Contains(t, sess.snapshotAttachments(), ac, "a send error from the pane's retired owner detached its client")
468+ require.True(t, slices.Contains(sess.snapshotAttachments(), ac), "a send error from the pane's retired owner detached its client")
468469 select {
469470 case <-oldTransport.closed:
470471 t.Fatal("a send error from the pane's retired owner closed its client transport")
@@ -7,6 +7,7 @@ import (
7 "io"7 "io"
8 "os"8 "os"
9 "path/filepath"9 "path/filepath"
10+ "slices"
10 "strings"11 "strings"
11 "sync"12 "sync"
12 "testing"13 "testing"
@@ -366,7 +367,7 @@ func TestQueuedClipboardAfterPaneMoveDoesNotSendToFormerOwner(t *testing.T) {
366 d.clipboardWorker(sess)367 d.clipboardWorker(sess)
367 368
368 require.Zero(t, oldTransport.sendCount(), "clipboard queued by the former pane owner reached its client")369 require.Zero(t, oldTransport.sendCount(), "clipboard queued by the former pane owner reached its client")
369- require.Contains(t, sess.snapshotAttachments(), ac, "stale clipboard send handling detached the former owner's client")370+ require.True(t, slices.Contains(sess.snapshotAttachments(), ac), "stale clipboard send handling detached the former owner's client")
370 }371 }
371 372
372 type movingClipboardErrorTransport struct {373 type movingClipboardErrorTransport struct {
@@ -435,7 +436,7 @@ func TestQueuedClipboardRevalidatesOwnerAfterWaitingForClientSendLock(t *testing
435 awaitTestCompletion(t, workerDone, "clipboard worker did not finish")436 awaitTestCompletion(t, workerDone, "clipboard worker did not finish")
436 437
437 require.Zero(t, oldTransport.sendCount(), "clipboard send was not revalidated immediately before transport I/O")438 require.Zero(t, oldTransport.sendCount(), "clipboard send was not revalidated immediately before transport I/O")
438- require.Contains(t, sess.snapshotAttachments(), ac)439+ require.True(t, slices.Contains(sess.snapshotAttachments(), ac))
439 }440 }
440 441
441 func TestClipboardSendErrorAfterPaneMoveDoesNotDetachFormerOwner(t *testing.T) {442 func TestClipboardSendErrorAfterPaneMoveDoesNotDetachFormerOwner(t *testing.T) {
@@ -464,7 +465,7 @@ func TestClipboardSendErrorAfterPaneMoveDoesNotDetachFormerOwner(t *testing.T) {
464 close(oldTransport.release)465 close(oldTransport.release)
465 awaitTestCompletion(t, workerDone, "clipboard worker did not finish")466 awaitTestCompletion(t, workerDone, "clipboard worker did not finish")
466 467
467- require.Contains(t, sess.snapshotAttachments(), ac, "a send error from the pane's retired owner detached its client")468+ require.True(t, slices.Contains(sess.snapshotAttachments(), ac), "a send error from the pane's retired owner detached its client")
468 select {469 select {
469 case <-oldTransport.closed:470 case <-oldTransport.closed:
470 t.Fatal("a send error from the pane's retired owner closed its client transport")471 t.Fatal("a send error from the pane's retired owner closed its client transport")
M internal/usecase/daemon/copymode_test.go +2 -1
@@ -5,6 +5,7 @@ import (
55 "errors"
66 "fmt"
77 "io"
8+ "slices"
89 "strings"
910 "sync"
1011 "testing"
@@ -61,7 +62,7 @@ func TestOwnedSynchronousSendReturnsCapturedTransportAcrossReplacement(t *testin
6162 out := unmarshalTestOutput(t, (<-failed.sent).Payload)
6263 require.Equal(t, []byte("copy"), out.Data)
6364 d.detachOnSendError(sess, ac, used)
64- require.Contains(t, sess.snapshotAttachments(), ac)
65+ require.True(t, slices.Contains(sess.snapshotAttachments(), ac))
6566 require.False(t, replacement.Closed())
6667 }
6768
@@ -5,6 +5,7 @@ import (
5 "errors"5 "errors"
6 "fmt"6 "fmt"
7 "io"7 "io"
8+ "slices"
8 "strings"9 "strings"
9 "sync"10 "sync"
10 "testing"11 "testing"
@@ -61,7 +62,7 @@ func TestOwnedSynchronousSendReturnsCapturedTransportAcrossReplacement(t *testin
61 out := unmarshalTestOutput(t, (<-failed.sent).Payload)62 out := unmarshalTestOutput(t, (<-failed.sent).Payload)
62 require.Equal(t, []byte("copy"), out.Data)63 require.Equal(t, []byte("copy"), out.Data)
63 d.detachOnSendError(sess, ac, used)64 d.detachOnSendError(sess, ac, used)
64- require.Contains(t, sess.snapshotAttachments(), ac)65+ require.True(t, slices.Contains(sess.snapshotAttachments(), ac))
65 require.False(t, replacement.Closed())66 require.False(t, replacement.Closed())
66 }67 }
67 68
M internal/usecase/daemon/resume_test.go +13 -12
@@ -3,6 +3,7 @@ package daemon
33 import (
44 "context"
55 "errors"
6+ "slices"
67 "strings"
78 "sync"
89 "testing"
@@ -413,7 +414,7 @@ func TestEphemeralLinkLossParksAndResumes(t *testing.T) {
413414 require.Same(t, sess, resumedSess)
414415 require.Same(t, ac, resumedAC)
415416 require.NotEqual(t, token, resumedAC.resumeToken, "resume rotates token")
416- require.Contains(t, sess.snapshotAttachments(), resumedAC)
417+ require.True(t, slices.Contains(sess.snapshotAttachments(), resumedAC))
417418 d.firstPaint(sess, resumedAC)
418419 require.Equal(t, domain.Size{Cols: 100, Rows: 30}, peerAC.sizeSnapshot())
419420 tb.mu.Lock()
@@ -508,7 +509,7 @@ func TestResumeLiveAttachmentParkedResumeRaceFailsClosed(t *testing.T) {
508509 require.NoError(t, err)
509510 token := ac.resumeToken
510511 require.NotZero(t, token)
511- require.Contains(t, sess.snapshotAttachments(), ac)
512+ require.True(t, slices.Contains(sess.snapshotAttachments(), ac))
512513 d.mu.Lock()
513514 _, parkedAtStart := d.parked[token]
514515 sessionsBefore := len(d.sessions)
@@ -581,7 +582,7 @@ func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) {
581582 require.NoError(t, err)
582583 token := ac.resumeToken
583584 require.NotZero(t, token)
584- require.Contains(t, sess.snapshotAttachments(), ac)
585+ require.True(t, slices.Contains(sess.snapshotAttachments(), ac))
585586
586587 reachedGap := make(chan struct{})
587588 releaseGap := make(chan struct{})
@@ -647,7 +648,7 @@ func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) {
647648 require.NoError(t, got.err, "same-client token must recover across detach-before-park")
648649 require.Same(t, sess, got.sess)
649650 require.Same(t, ac, got.ac)
650- require.Contains(t, sess.snapshotAttachments(), ac)
651+ require.True(t, slices.Contains(sess.snapshotAttachments(), ac))
651652 require.Same(t, newTr, ac.transport())
652653 require.NotEqual(t, token, ac.resumeToken, "successful resume rotates the credential")
653654 require.True(t, d.commitResumeClaim(ac), "successful resume must consume its parked credential")
@@ -661,7 +662,7 @@ func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) {
661662 require.False(t, stillParking, "parking marker must be consumed after park/resume")
662663
663664 d.clientGone(sess, ac, oldTr, false)
664- require.Contains(t, sess.snapshotAttachments(), ac, "stale old-link cleanup must not detach the rebound attachment")
665+ require.True(t, slices.Contains(sess.snapshotAttachments(), ac), "stale old-link cleanup must not detach the rebound attachment")
665666 require.Same(t, newTr, ac.transport())
666667 require.False(t, newTr.Closed())
667668 }
@@ -680,7 +681,7 @@ func TestConcurrentLiveResumesWaitParkingMarkerBeforePark(t *testing.T) {
680681 require.NoError(t, err)
681682 token := ac.resumeToken
682683 require.NotZero(t, token)
683- require.Contains(t, sess.snapshotAttachments(), ac)
684+ require.True(t, slices.Contains(sess.snapshotAttachments(), ac))
684685
685686 reachedGap := make(chan struct{})
686687 releaseGap := make(chan struct{})
@@ -750,7 +751,7 @@ func TestConcurrentLiveResumesWaitParkingMarkerBeforePark(t *testing.T) {
750751
751752 require.Same(t, sess, winner.sess)
752753 require.Same(t, ac, winner.ac)
753- require.Contains(t, sess.snapshotAttachments(), ac)
754+ require.True(t, slices.Contains(sess.snapshotAttachments(), ac))
754755 require.Same(t, winnerTr, ac.transport())
755756 require.NotEqual(t, token, ac.resumeToken, "successful resume rotates the credential")
756757 require.True(t, oldTr.Closed(), "winning resume must retire the old transport")
@@ -771,7 +772,7 @@ func TestConcurrentLiveResumesWaitParkingMarkerBeforePark(t *testing.T) {
771772 require.False(t, stillParking, "parking marker must be consumed after park/resume")
772773
773774 d.clientGone(sess, ac, oldTr, false)
774- require.Contains(t, sess.snapshotAttachments(), ac, "stale old-link cleanup must not detach the rebound attachment")
775+ require.True(t, slices.Contains(sess.snapshotAttachments(), ac), "stale old-link cleanup must not detach the rebound attachment")
775776 require.Same(t, winnerTr, ac.transport())
776777 require.False(t, winnerTr.Closed())
777778 }
@@ -902,7 +903,7 @@ func TestLiveResumeRejectsLateMarkerAfterTerminalCleanupWins(t *testing.T) {
902903 require.NoError(t, err)
903904 token := ac.resumeToken
904905 require.NotZero(t, token)
905- require.Contains(t, sess.snapshotAttachments(), ac)
906+ require.True(t, slices.Contains(sess.snapshotAttachments(), ac))
906907
907908 validated := make(chan struct{})
908909 releaseMark := make(chan struct{})
@@ -1018,7 +1019,7 @@ func TestStaleClientGoneAfterTransportCheckDoesNotDetachReboundAttachment(t *tes
10181019 releaseOnce.Do(func() { close(releaseDetach) })
10191020 awaitTestCompletion(t, goneDone, "stale clientGone did not finish after resume rebound")
10201021
1021- require.Contains(t, sess.snapshotAttachments(), ac, "stale cleanup must not detach the rebound attachment")
1022+ require.True(t, slices.Contains(sess.snapshotAttachments(), ac), "stale cleanup must not detach the rebound attachment")
10221023 require.Same(t, newTr, ac.transport())
10231024 require.False(t, newTr.Closed(), "rebound transport must survive stale old-link cleanup")
10241025 require.True(t, oldTr.Closed(), "live resume retires the captured old transport")
@@ -1075,7 +1076,7 @@ func TestResumeRebindsRotatesAndDoesNotOpenPTY(t *testing.T) {
10751076 require.Same(t, sess, resumedSess)
10761077 require.Same(t, ac, resumedAC)
10771078 require.NotEqual(t, oldToken, resumedAC.resumeToken, "resume rotates token")
1078- require.Contains(t, sess.snapshotAttachments(), resumedAC)
1079+ require.True(t, slices.Contains(sess.snapshotAttachments(), resumedAC))
10791080 }
10801081
10811082 func TestOutputAckLagAloneDoesNotForceFullStateRepaint(t *testing.T) {
@@ -1523,7 +1524,7 @@ func TestStaleClientGoneDoesNotDetachOrCloseFreshTransport(t *testing.T) {
15231524
15241525 d.clientGone(sess, ac, oldTr, false)
15251526
1526- require.Contains(t, sess.snapshotAttachments(), ac, "stale connection must not detach current client")
1527+ require.True(t, slices.Contains(sess.snapshotAttachments(), ac), "stale connection must not detach current client")
15271528 require.False(t, oldTr.Closed(), "stale transport is owned by its own loop/handler")
15281529 require.False(t, freshTr.Closed(), "fresh resumed transport must not be closed by stale loop")
15291530 }
@@ -3,6 +3,7 @@ package daemon
3 import (3 import (
4 "context"4 "context"
5 "errors"5 "errors"
6+ "slices"
6 "strings"7 "strings"
7 "sync"8 "sync"
8 "testing"9 "testing"
@@ -413,7 +414,7 @@ func TestEphemeralLinkLossParksAndResumes(t *testing.T) {
413 require.Same(t, sess, resumedSess)414 require.Same(t, sess, resumedSess)
414 require.Same(t, ac, resumedAC)415 require.Same(t, ac, resumedAC)
415 require.NotEqual(t, token, resumedAC.resumeToken, "resume rotates token")416 require.NotEqual(t, token, resumedAC.resumeToken, "resume rotates token")
416- require.Contains(t, sess.snapshotAttachments(), resumedAC)417+ require.True(t, slices.Contains(sess.snapshotAttachments(), resumedAC))
417 d.firstPaint(sess, resumedAC)418 d.firstPaint(sess, resumedAC)
418 require.Equal(t, domain.Size{Cols: 100, Rows: 30}, peerAC.sizeSnapshot())419 require.Equal(t, domain.Size{Cols: 100, Rows: 30}, peerAC.sizeSnapshot())
419 tb.mu.Lock()420 tb.mu.Lock()
@@ -508,7 +509,7 @@ func TestResumeLiveAttachmentParkedResumeRaceFailsClosed(t *testing.T) {
508 require.NoError(t, err)509 require.NoError(t, err)
509 token := ac.resumeToken510 token := ac.resumeToken
510 require.NotZero(t, token)511 require.NotZero(t, token)
511- require.Contains(t, sess.snapshotAttachments(), ac)512+ require.True(t, slices.Contains(sess.snapshotAttachments(), ac))
512 d.mu.Lock()513 d.mu.Lock()
513 _, parkedAtStart := d.parked[token]514 _, parkedAtStart := d.parked[token]
514 sessionsBefore := len(d.sessions)515 sessionsBefore := len(d.sessions)
@@ -581,7 +582,7 @@ func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) {
581 require.NoError(t, err)582 require.NoError(t, err)
582 token := ac.resumeToken583 token := ac.resumeToken
583 require.NotZero(t, token)584 require.NotZero(t, token)
584- require.Contains(t, sess.snapshotAttachments(), ac)585+ require.True(t, slices.Contains(sess.snapshotAttachments(), ac))
585 586
586 reachedGap := make(chan struct{})587 reachedGap := make(chan struct{})
587 releaseGap := make(chan struct{})588 releaseGap := make(chan struct{})
@@ -647,7 +648,7 @@ func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) {
647 require.NoError(t, got.err, "same-client token must recover across detach-before-park")648 require.NoError(t, got.err, "same-client token must recover across detach-before-park")
648 require.Same(t, sess, got.sess)649 require.Same(t, sess, got.sess)
649 require.Same(t, ac, got.ac)650 require.Same(t, ac, got.ac)
650- require.Contains(t, sess.snapshotAttachments(), ac)651+ require.True(t, slices.Contains(sess.snapshotAttachments(), ac))
651 require.Same(t, newTr, ac.transport())652 require.Same(t, newTr, ac.transport())
652 require.NotEqual(t, token, ac.resumeToken, "successful resume rotates the credential")653 require.NotEqual(t, token, ac.resumeToken, "successful resume rotates the credential")
653 require.True(t, d.commitResumeClaim(ac), "successful resume must consume its parked credential")654 require.True(t, d.commitResumeClaim(ac), "successful resume must consume its parked credential")
@@ -661,7 +662,7 @@ func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) {
661 require.False(t, stillParking, "parking marker must be consumed after park/resume")662 require.False(t, stillParking, "parking marker must be consumed after park/resume")
662 663
663 d.clientGone(sess, ac, oldTr, false)664 d.clientGone(sess, ac, oldTr, false)
664- require.Contains(t, sess.snapshotAttachments(), ac, "stale old-link cleanup must not detach the rebound attachment")665+ require.True(t, slices.Contains(sess.snapshotAttachments(), ac), "stale old-link cleanup must not detach the rebound attachment")
665 require.Same(t, newTr, ac.transport())666 require.Same(t, newTr, ac.transport())
666 require.False(t, newTr.Closed())667 require.False(t, newTr.Closed())
667 }668 }
@@ -680,7 +681,7 @@ func TestConcurrentLiveResumesWaitParkingMarkerBeforePark(t *testing.T) {
680 require.NoError(t, err)681 require.NoError(t, err)
681 token := ac.resumeToken682 token := ac.resumeToken
682 require.NotZero(t, token)683 require.NotZero(t, token)
683- require.Contains(t, sess.snapshotAttachments(), ac)684+ require.True(t, slices.Contains(sess.snapshotAttachments(), ac))
684 685
685 reachedGap := make(chan struct{})686 reachedGap := make(chan struct{})
686 releaseGap := make(chan struct{})687 releaseGap := make(chan struct{})
@@ -750,7 +751,7 @@ func TestConcurrentLiveResumesWaitParkingMarkerBeforePark(t *testing.T) {
750 751
751 require.Same(t, sess, winner.sess)752 require.Same(t, sess, winner.sess)
752 require.Same(t, ac, winner.ac)753 require.Same(t, ac, winner.ac)
753- require.Contains(t, sess.snapshotAttachments(), ac)754+ require.True(t, slices.Contains(sess.snapshotAttachments(), ac))
754 require.Same(t, winnerTr, ac.transport())755 require.Same(t, winnerTr, ac.transport())
755 require.NotEqual(t, token, ac.resumeToken, "successful resume rotates the credential")756 require.NotEqual(t, token, ac.resumeToken, "successful resume rotates the credential")
756 require.True(t, oldTr.Closed(), "winning resume must retire the old transport")757 require.True(t, oldTr.Closed(), "winning resume must retire the old transport")
@@ -771,7 +772,7 @@ func TestConcurrentLiveResumesWaitParkingMarkerBeforePark(t *testing.T) {
771 require.False(t, stillParking, "parking marker must be consumed after park/resume")772 require.False(t, stillParking, "parking marker must be consumed after park/resume")
772 773
773 d.clientGone(sess, ac, oldTr, false)774 d.clientGone(sess, ac, oldTr, false)
774- require.Contains(t, sess.snapshotAttachments(), ac, "stale old-link cleanup must not detach the rebound attachment")775+ require.True(t, slices.Contains(sess.snapshotAttachments(), ac), "stale old-link cleanup must not detach the rebound attachment")
775 require.Same(t, winnerTr, ac.transport())776 require.Same(t, winnerTr, ac.transport())
776 require.False(t, winnerTr.Closed())777 require.False(t, winnerTr.Closed())
777 }778 }
@@ -902,7 +903,7 @@ func TestLiveResumeRejectsLateMarkerAfterTerminalCleanupWins(t *testing.T) {
902 require.NoError(t, err)903 require.NoError(t, err)
903 token := ac.resumeToken904 token := ac.resumeToken
904 require.NotZero(t, token)905 require.NotZero(t, token)
905- require.Contains(t, sess.snapshotAttachments(), ac)906+ require.True(t, slices.Contains(sess.snapshotAttachments(), ac))
906 907
907 validated := make(chan struct{})908 validated := make(chan struct{})
908 releaseMark := make(chan struct{})909 releaseMark := make(chan struct{})
@@ -1018,7 +1019,7 @@ func TestStaleClientGoneAfterTransportCheckDoesNotDetachReboundAttachment(t *tes
1018 releaseOnce.Do(func() { close(releaseDetach) })1019 releaseOnce.Do(func() { close(releaseDetach) })
1019 awaitTestCompletion(t, goneDone, "stale clientGone did not finish after resume rebound")1020 awaitTestCompletion(t, goneDone, "stale clientGone did not finish after resume rebound")
1020 1021
1021- require.Contains(t, sess.snapshotAttachments(), ac, "stale cleanup must not detach the rebound attachment")1022+ require.True(t, slices.Contains(sess.snapshotAttachments(), ac), "stale cleanup must not detach the rebound attachment")
1022 require.Same(t, newTr, ac.transport())1023 require.Same(t, newTr, ac.transport())
1023 require.False(t, newTr.Closed(), "rebound transport must survive stale old-link cleanup")1024 require.False(t, newTr.Closed(), "rebound transport must survive stale old-link cleanup")
1024 require.True(t, oldTr.Closed(), "live resume retires the captured old transport")1025 require.True(t, oldTr.Closed(), "live resume retires the captured old transport")
@@ -1075,7 +1076,7 @@ func TestResumeRebindsRotatesAndDoesNotOpenPTY(t *testing.T) {
1075 require.Same(t, sess, resumedSess)1076 require.Same(t, sess, resumedSess)
1076 require.Same(t, ac, resumedAC)1077 require.Same(t, ac, resumedAC)
1077 require.NotEqual(t, oldToken, resumedAC.resumeToken, "resume rotates token")1078 require.NotEqual(t, oldToken, resumedAC.resumeToken, "resume rotates token")
1078- require.Contains(t, sess.snapshotAttachments(), resumedAC)1079+ require.True(t, slices.Contains(sess.snapshotAttachments(), resumedAC))
1079 }1080 }
1080 1081
1081 func TestOutputAckLagAloneDoesNotForceFullStateRepaint(t *testing.T) {1082 func TestOutputAckLagAloneDoesNotForceFullStateRepaint(t *testing.T) {
@@ -1523,7 +1524,7 @@ func TestStaleClientGoneDoesNotDetachOrCloseFreshTransport(t *testing.T) {
1523 1524
1524 d.clientGone(sess, ac, oldTr, false)1525 d.clientGone(sess, ac, oldTr, false)
1525 1526
1526- require.Contains(t, sess.snapshotAttachments(), ac, "stale connection must not detach current client")1527+ require.True(t, slices.Contains(sess.snapshotAttachments(), ac), "stale connection must not detach current client")
1527 require.False(t, oldTr.Closed(), "stale transport is owned by its own loop/handler")1528 require.False(t, oldTr.Closed(), "stale transport is owned by its own loop/handler")
1528 require.False(t, freshTr.Closed(), "fresh resumed transport must not be closed by stale loop")1529 require.False(t, freshTr.Closed(), "fresh resumed transport must not be closed by stale loop")
1529 }1530 }
M internal/usecase/daemon/session_admission.go +10 -0
@@ -108,6 +108,16 @@ func validateSessionHelloAdmission(h protocol.Hello, admission ports.SessionAdmi
108108 if h.ExactTarget != nil || h.SessionTarget != nil || h.ResumeToken != 0 {
109109 return admissionError("named admission forbids a target or resume token")
110110 }
111+ case ports.BrokerAdmissionAttachNamed:
112+ if h.Intent != protocol.IntentAttach {
113+ return admissionError("named attach admission requires an attach hello")
114+ }
115+ if h.Name != admission.Name {
116+ return admissionError("hello name contradicts the admitted session name")
117+ }
118+ if h.ExactTarget != nil || h.SessionTarget != nil || h.ResumeToken != 0 {
119+ return admissionError("named attach admission forbids a target or resume token")
120+ }
111121 case ports.BrokerAdmissionExact:
112122 if h.ExactTarget == nil || *h.ExactTarget != admission.Target {
113123 return admissionError("hello exact target contradicts the admitted target")
@@ -108,6 +108,16 @@ func validateSessionHelloAdmission(h protocol.Hello, admission ports.SessionAdmi
108 if h.ExactTarget != nil || h.SessionTarget != nil || h.ResumeToken != 0 {108 if h.ExactTarget != nil || h.SessionTarget != nil || h.ResumeToken != 0 {
109 return admissionError("named admission forbids a target or resume token")109 return admissionError("named admission forbids a target or resume token")
110 }110 }
111+ case ports.BrokerAdmissionAttachNamed:
112+ if h.Intent != protocol.IntentAttach {
113+ return admissionError("named attach admission requires an attach hello")
114+ }
115+ if h.Name != admission.Name {
116+ return admissionError("hello name contradicts the admitted session name")
117+ }
118+ if h.ExactTarget != nil || h.SessionTarget != nil || h.ResumeToken != 0 {
119+ return admissionError("named attach admission forbids a target or resume token")
120+ }
111 case ports.BrokerAdmissionExact:121 case ports.BrokerAdmissionExact:
112 if h.ExactTarget == nil || *h.ExactTarget != admission.Target {122 if h.ExactTarget == nil || *h.ExactTarget != admission.Target {
113 return admissionError("hello exact target contradicts the admitted target")123 return admissionError("hello exact target contradicts the admitted target")
M internal/usecase/daemon/session_admission_test.go +37 -0
@@ -96,6 +96,43 @@ func TestValidateSessionHelloAdmissionMatrix(t *testing.T) {
9696 },
9797 admission: local(ports.SessionAdmission{Admission: ports.BrokerAdmissionCreateNamed, Name: "work", Env: localEnv}),
9898 },
99+ {
100+ name: "local named attach",
101+ hello: protocol.Hello{Intent: protocol.IntentAttach, Name: "work", Cwd: "/client/cwd", Env: localEnv},
102+ admission: local(ports.SessionAdmission{Admission: ports.BrokerAdmissionAttachNamed, Name: "work", Env: localEnv}),
103+ },
104+ {
105+ name: "remote named attach",
106+ hello: protocol.Hello{
107+ Intent: protocol.IntentAttach, Name: "work", Remote: true,
108+ EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
109+ },
110+ admission: remote(ports.SessionAdmission{Admission: ports.BrokerAdmissionAttachNamed, Name: "work"}),
111+ },
112+ {
113+ name: "named attach cannot create",
114+ hello: protocol.Hello{Intent: protocol.IntentNew, Name: "work"},
115+ admission: local(ports.SessionAdmission{Admission: ports.BrokerAdmissionAttachNamed, Name: "work"}),
116+ wantErr: true,
117+ },
118+ {
119+ name: "liar name on a named attach",
120+ hello: protocol.Hello{Intent: protocol.IntentAttach, Name: "other"},
121+ admission: local(ports.SessionAdmission{Admission: ports.BrokerAdmissionAttachNamed, Name: "work"}),
122+ wantErr: true,
123+ },
124+ {
125+ name: "named attach smuggles an exact target",
126+ hello: protocol.Hello{Intent: protocol.IntentAttach, Name: "work", ExactTarget: &target},
127+ admission: local(ports.SessionAdmission{Admission: ports.BrokerAdmissionAttachNamed, Name: "work"}),
128+ wantErr: true,
129+ },
130+ {
131+ name: "named attach smuggles a resume token",
132+ hello: protocol.Hello{Intent: protocol.IntentAttach, Name: "work", ResumeToken: 4},
133+ admission: local(ports.SessionAdmission{Admission: ports.BrokerAdmissionAttachNamed, Name: "work"}),
134+ wantErr: true,
135+ },
99136 {
100137 name: "local fresh exact attach",
101138 hello: protocol.Hello{Intent: protocol.IntentAttach, Name: "work", ExactTarget: &target},
@@ -96,6 +96,43 @@ func TestValidateSessionHelloAdmissionMatrix(t *testing.T) {
96 },96 },
97 admission: local(ports.SessionAdmission{Admission: ports.BrokerAdmissionCreateNamed, Name: "work", Env: localEnv}),97 admission: local(ports.SessionAdmission{Admission: ports.BrokerAdmissionCreateNamed, Name: "work", Env: localEnv}),
98 },98 },
99+ {
100+ name: "local named attach",
101+ hello: protocol.Hello{Intent: protocol.IntentAttach, Name: "work", Cwd: "/client/cwd", Env: localEnv},
102+ admission: local(ports.SessionAdmission{Admission: ports.BrokerAdmissionAttachNamed, Name: "work", Env: localEnv}),
103+ },
104+ {
105+ name: "remote named attach",
106+ hello: protocol.Hello{
107+ Intent: protocol.IntentAttach, Name: "work", Remote: true,
108+ EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
109+ },
110+ admission: remote(ports.SessionAdmission{Admission: ports.BrokerAdmissionAttachNamed, Name: "work"}),
111+ },
112+ {
113+ name: "named attach cannot create",
114+ hello: protocol.Hello{Intent: protocol.IntentNew, Name: "work"},
115+ admission: local(ports.SessionAdmission{Admission: ports.BrokerAdmissionAttachNamed, Name: "work"}),
116+ wantErr: true,
117+ },
118+ {
119+ name: "liar name on a named attach",
120+ hello: protocol.Hello{Intent: protocol.IntentAttach, Name: "other"},
121+ admission: local(ports.SessionAdmission{Admission: ports.BrokerAdmissionAttachNamed, Name: "work"}),
122+ wantErr: true,
123+ },
124+ {
125+ name: "named attach smuggles an exact target",
126+ hello: protocol.Hello{Intent: protocol.IntentAttach, Name: "work", ExactTarget: &target},
127+ admission: local(ports.SessionAdmission{Admission: ports.BrokerAdmissionAttachNamed, Name: "work"}),
128+ wantErr: true,
129+ },
130+ {
131+ name: "named attach smuggles a resume token",
132+ hello: protocol.Hello{Intent: protocol.IntentAttach, Name: "work", ResumeToken: 4},
133+ admission: local(ports.SessionAdmission{Admission: ports.BrokerAdmissionAttachNamed, Name: "work"}),
134+ wantErr: true,
135+ },
99 {136 {
100 name: "local fresh exact attach",137 name: "local fresh exact attach",
101 hello: protocol.Hello{Intent: protocol.IntentAttach, Name: "work", ExactTarget: &target},138 hello: protocol.Hello{Intent: protocol.IntentAttach, Name: "work", ExactTarget: &target},

Keyboard shortcuts

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

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

Branches 12

Tags 11

↑↓ moveenter openesc close