supervisor_routes.go RawHistoryFolder

1package client
2
3import (
4	"errors"
5
6	"github.com/bnema/vev/internal/domain"
7	"github.com/bnema/vev/internal/ports"
8	"github.com/bnema/vev/internal/protocol"
9)
10
11// Routes and daemon navigation while attached.
12//
13// The supervisor publishes the client route ledger to the serving daemon after
14// the committed initial publication, whenever the attached session changes,
15// and on every broker publication. The daemon renders the status-bar MRU,
16// other-session bells, and the palette's remote, recent-route, and
17// create-on-host rows from it; it never monitors another daemon.
18//
19// The daemon never dials either. Its navigation requests reach the
20// supervisor, which settles them exactly like a picker commit: another tab of
21// the attached session switches in place with SelectTab, anything else detaches
22// cleanly and swaps through Connecting.
23//
24//   - RouteNavigationAction and RouteCreateSessionAction name a reference of
25//     the published ledger; the supervisor resolves it against the latest
26//     broker catalogue and answers a refusal with the typed failure the daemon
27//     turns into a toast.
28//   - An endpoint-empty AttachTarget names a lifecycle on the serving daemon
29//     itself (a stopped session or a close-and-dial handoff); the same-peer
30//     offer is confirmed by the worker in place.
31//
32// Every method runs on the supervisor's run goroutine.
33
34// brokerChanged is the adopted connection's publication wake, or nil.
35func (s *Supervisor) brokerChanged() <-chan struct{} {
36	if supervisorNil(s.readySub) {
37		return nil
38	}
39	return s.readySub.Changed()
40}
41
42// publishRoutes rebuilds the ledger over the live attachment and hands a
43// changed snapshot, or the first one of this attachment, to its worker.
44func (s *Supervisor) publishRoutes(service ports.BrokerNavigator, run *attachmentRun, request ports.BrokerOpenStreamRequest) {
45	target, _, known := s.attachments.committedView()
46	if !known {
47		return
48	}
49	if s.routes == nil {
50		s.routes = newRouteLedger()
51	}
52	snapshot, changed := s.routes.build(service.Snapshot(), routeActive{known: true, authority: requestAuthority(request), target: target})
53	if snapshot.Generation == 0 || (!changed && s.routesSent == run.token) {
54		return
55	}
56	if s.attachments.publishRoutes(run.token, snapshot) {
57		s.routesSent = run.token
58	}
59}
60
61func requestAuthority(request ports.BrokerOpenStreamRequest) routeAuthority {
62	if request.Local {
63		return routeAuthority{local: true}
64	}
65	return routeAuthority{endpoint: request.Endpoint}
66}
67
68// settleDaemonNavigation carries out one daemon navigation request over the
69// live attachment.
70func (s *Supervisor) settleDaemonNavigation(service ports.BrokerNavigator, overlay *attachmentPickerOverlay, message protocol.ServerMessage) {
71	target, ok := s.resolveDaemonNavigation(service, overlay.run.token, overlay.request, message)
72	if !ok {
73		return
74	}
75	if actionID := navigationCauseActionID(message); actionID != 0 && s.cfg.UI != nil {
76		s.cfg.UI.follow(overlay.run.fg.uiGeneration, actionID)
77	}
78	if sameAttachmentTarget(overlay.request, s.attachments.committedTargetOrZero(), target.request) && target.tab.stopped == nil {
79		_, current, _ := s.attachments.committedView()
80		if target.tab.preferred != "" && target.tab.preferred != current {
81			s.attachments.requestTabSelection(overlay.run.token, target.tab.preferred)
82		}
83		return
84	}
85	if overlay.active {
86		overlay.exit()
87	}
88	s.pendingSwap = &target
89	overlay.swapping = true
90	s.attachments.requestDetach(overlay.run.token)
91}
92
93func navigationCauseActionID(message protocol.ServerMessage) uint64 {
94	switch action := message.(type) {
95	case protocol.RouteNavigationAction:
96		return action.CauseActionID
97	case protocol.RouteCreateSessionAction:
98		return action.CauseActionID
99	case protocol.AttachTarget:
100		return action.CauseActionID
101	default:
102		return 0
103	}
104}
105
106// takeSettledNavigation adopts a navigation request the worker handed over
107// just before its attachment ended, such as a close-and-dial handoff whose
108// daemon detached the source right after sending it.
109func (s *Supervisor) takeSettledNavigation(service ports.BrokerNavigator, token AttachmentToken, request ports.BrokerOpenStreamRequest) {
110	message, ok := s.attachments.takeNavigation()
111	if !ok || s.pendingSwap != nil {
112		return
113	}
114	if target, ok := s.resolveDaemonNavigation(service, token, request, message); ok {
115		if actionID := navigationCauseActionID(message); actionID != 0 && s.cfg.UI != nil {
116			s.cfg.UI.follow(s.cfg.UI.generation, actionID)
117		}
118		s.pendingSwap = &target
119	}
120}
121
122// resolveDaemonNavigation turns one daemon request into an exact attachment
123// target. A refusal is answered on the attachment with the typed failure.
124func (s *Supervisor) resolveDaemonNavigation(service ports.BrokerNavigator, token AttachmentToken, request ports.BrokerOpenStreamRequest, message protocol.ServerMessage) (pickerAttachmentTarget, bool) {
125	switch typed := message.(type) {
126	case protocol.RouteNavigationAction:
127		fail := func(code protocol.RouteFailureCode) (pickerAttachmentTarget, bool) {
128			s.attachments.replyNavigation(token, protocol.RouteNavigationFailure{Key: typed.Key, Generation: typed.Generation, Code: code})
129			return pickerAttachmentTarget{}, false
130		}
131		routed, ok := s.resolveRoute(protocol.RouteRef{Key: typed.Key, Generation: typed.Generation})
132		if !ok || routed.host {
133			return fail(protocol.RouteFailureStaleSelection)
134		}
135		target, err := s.resolveRouteSelection(service, pickerSelectionRef{
136			kind: pickerSelectionExact, local: routed.authority.local, endpoint: routed.authority.endpoint,
137			lifecycle: routed.target.LifecycleID, name: routed.target.SessionName,
138		})
139		if err != nil {
140			return fail(routeNavigationFailureCode(err))
141		}
142		return target, true
143	case protocol.RouteCreateSessionAction:
144		fail := func(code protocol.RouteFailureCode) (pickerAttachmentTarget, bool) {
145			s.attachments.replyNavigation(token, protocol.SessionCreationFailure{RequestID: typed.RequestID, Code: code})
146			return pickerAttachmentTarget{}, false
147		}
148		routed, ok := s.resolveRoute(protocol.RouteRef{Key: typed.Key, Generation: typed.Generation})
149		if !ok {
150			return fail(protocol.RouteFailureStaleSelection)
151		}
152		target, err := s.resolveRouteSelection(service, pickerSelectionRef{
153			kind: pickerSelectionCreateNamed, local: routed.authority.local, endpoint: routed.authority.endpoint,
154			createName: typed.SessionName,
155		})
156		if err != nil {
157			return fail(routeNavigationFailureCode(err))
158		}
159		return target, true
160	case protocol.AttachTarget:
161		return s.resolveServingHandoff(service, request, typed)
162	default:
163		return pickerAttachmentTarget{}, false
164	}
165}
166
167// resolveServingHandoff resolves an endpoint-empty handoff: the serving
168// daemon itself named one of its own lifecycles, so the attachment's own
169// authority is kept and only the exact target and tab change. The daemon
170// revalidates the exact identity on Hello.
171func (s *Supervisor) resolveServingHandoff(service ports.BrokerNavigator, request ports.BrokerOpenStreamRequest, handoff protocol.AttachTarget) (pickerAttachmentTarget, bool) {
172	if handoff.Endpoint != "" || handoff.SessionTarget != nil || handoff.ExactTarget == nil || handoff.Intent != protocol.IntentAttach {
173		s.notifyPicker(domain.Notification{Code: domain.NoticeSessionUnavailable, Scope: "handoff", Severity: domain.NoticeWarn, Message: "the daemon asked for a destination this client cannot open"})
174		return pickerAttachmentTarget{}, false
175	}
176	stream, err := service.NextStreamID()
177	if err != nil {
178		return pickerAttachmentTarget{}, false
179	}
180	next := request
181	next.Stream = stream
182	next.Connection = service.ConnectionID()
183	next.Admission = ports.BrokerAdmissionExact
184	next.Target = *handoff.ExactTarget
185	next.Name = ""
186	next.Purpose = ports.BrokerStreamAttachment
187	next.StartMode = ports.BrokerDaemonStartIfNeeded
188	if next.Validate() != nil {
189		return pickerAttachmentTarget{}, false
190	}
191	return pickerAttachmentTarget{request: next, tab: attachmentTab{preferred: handoff.PreferredTabID}}, true
192}
193
194func (s *Supervisor) resolveRoute(ref protocol.RouteRef) (routeLedgerTarget, bool) {
195	if s.routes == nil || ref.IsZero() {
196		return routeLedgerTarget{}, false
197	}
198	return s.routes.resolve(ref)
199}
200
201// resolveRouteSelection resolves one route through the same catalogue rules
202// as a picker commit, against the latest broker publication.
203func (s *Supervisor) resolveRouteSelection(service ports.BrokerNavigator, selection pickerSelectionRef) (pickerAttachmentTarget, error) {
204	stream, err := service.NextStreamID()
205	if err != nil {
206		return pickerAttachmentTarget{}, err
207	}
208	snapshot := service.Snapshot()
209	authority := pickerAuthorityInSnapshot(snapshot, selection)
210	if authority.found {
211		// The ledger keys a host by endpoint; the registration is the
212		// catalogue's current one.
213		selection.registration = authority.observation.Registration
214	}
215	base := pickerResolveBase{Connection: service.ConnectionID(), Stream: stream}
216	request, tab, err := resolvePickerTarget(snapshot.Epoch, authority, selection, base, true)
217	if err != nil {
218		return pickerAttachmentTarget{}, err
219	}
220	return pickerAttachmentTarget{request: request, tab: tab}, nil
221}
222
223func routeNavigationFailureCode(err error) protocol.RouteFailureCode {
224	var catalogueErr pickerCatalogueError
225	if errors.As(err, &catalogueErr) {
226		switch catalogueErr.Code {
227		case pickerCatalogueGone:
228			return protocol.RouteFailureNoSuchRoute
229		case pickerCatalogueReplaced:
230			return protocol.RouteFailureTargetChanged
231		case pickerCatalogueEpochStale:
232			return protocol.RouteFailureStaleSelection
233		}
234	}
235	return protocol.RouteFailureUnavailable
236}

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