1package protocol
2
3import "fmt"
4
5// PickerServingSourceID names the source the serving daemon owns: every
6// attachment publishes its own rows under this one source identity.
7const (
8 PickerServingSourceID = "serving"
9 PickerHomeSourceID = "home"
10)
11
12// Client-picker interaction bounds. A source publishes at most
13// PickerInteractionMaxLines structured lines; the complete encoded snapshot
14// stays below PickerInteractionMaxEncodedBytes, the same 4 MiB export budget
15// shared with navigation inventory.
16const (
17 PickerInteractionMaxLines = 4096
18 PickerInteractionMaxDisplayBytes = 256
19 PickerInteractionMaxEncodedBytes = 4 << 20
20 PickerInteractionMaxSourceBytes = 128
21 PickerInteractionMaxTitleBytes = 256
22)
23
24// PickerIntent names which destinations a picker offers and which mutation a
25// selection performs. The daemon owns the intent: it captures move sources and
26// decides which rows are eligible.
27type PickerIntent uint8
28
29const (
30 PickerIntentNavigation PickerIntent = iota + 1
31 PickerIntentMovePane
32 PickerIntentMoveTab
33)
34
35// PickerAction names the mutation one selection requests.
36type PickerAction uint8
37
38const (
39 PickerActionNavigate PickerAction = iota + 1
40 PickerActionMove
41 PickerActionKill
42)
43
44// PickerLineKind names the row shape. Section rows are group headers and
45// carry no key; presentation-only rows (host status) stay focusable but not
46// selectable.
47type PickerLineKind uint8
48
49const (
50 PickerLineSection PickerLineKind = iota + 1
51 PickerLineSession
52 PickerLineTab
53 PickerLineHost
54)
55
56// PickerLineStatus is the bounded state badge drawn on a row.
57type PickerLineStatus uint8
58
59const (
60 PickerLineStatusNone PickerLineStatus = iota
61 PickerLineStatusUp
62 PickerLineStatusStopped
63 PickerLineStatusDown
64 PickerLineStatusStale
65 PickerLineStatusVersion
66 PickerLineStatusError
67 PickerLineStatusNoDaemon
68)
69
70// PickerLineActions is the set of actions a row admits. The presenting client
71// offers only what the owning source authorised; the source revalidates the
72// action and the row lifecycle at commit.
73type PickerLineActions uint8
74
75const (
76 PickerCanNavigate PickerLineActions = 1 << iota
77 PickerCanMove
78 PickerCanKill
79)
80
81// PickerSourceStatus is the per-source outcome. A failed source never
82// invalidates another source's lines.
83type PickerSourceStatus uint8
84
85const (
86 PickerSourceOK PickerSourceStatus = iota + 1
87 PickerSourceUnavailable
88 PickerSourceTooLarge
89)
90
91// PickerLine is one structured row. Key is opaque and source-assigned; it
92// resolves to a target source-side only. Display strings carry presentation
93// facts, never endpoints, credentials, environment, CWD, or pane content.
94// Lines are published in the source's own order (recency for local sessions)
95// and Ephemeral marks sessions created for one attach, which is what the
96// presenting client needs to offer its local grouped order.
97type PickerLine struct {
98 Key string
99 Kind PickerLineKind
100 Label string
101 Detail string
102 Status PickerLineStatus
103 StatusDetail string
104 Attention bool
105 Stopped bool
106 Dim bool
107 // Focusable reports that the source authorises this row as a cursor
108 // destination for this interaction, independently of whether it admits an
109 // action. A Focusable row with no Actions is deliberately inspectable and
110 // never committable; a row that is neither Focusable nor actionable is
111 // rendered but skipped by cursor navigation.
112 Focusable bool
113 Actions PickerLineActions
114 Ephemeral bool
115}
116
117// Selectable reports whether the row admits at least one action.
118func (line PickerLine) Selectable() bool { return line.Actions != 0 }
119
120// PickerCursor restores the selected row by opaque key with its list index.
121type PickerCursor struct {
122 Key string
123 Index int
124}
125
126// PickerOffer opens one interaction. The daemon sends it for intents it
127// initiates (a session-picker command or a move request); it carries the
128// acquisition barrier the client must display before owning the terminal and
129// the opaque identity of the captured move source. RequestID correlates a
130// client-owned interaction request, and is zero for daemon-driven opens.
131type PickerOffer struct {
132 InteractionID uint64
133 RequestID uint64
134 Intent PickerIntent
135 MoveSourceKey string
136 BarrierEpoch uint64
137 BarrierState uint64
138 SizeEpoch uint64
139 Title string
140}
141
142// PickerProjection is one complete daemon-authorised presentation. Recent and
143// Grouped contain the same destination keys and actions but may differ in
144// order, sections, and display labels.
145type PickerProjection struct {
146 Lines []PickerLine
147 Cursor PickerCursor
148}
149
150// PickerSnapshot atomically publishes both presentation projections of one
151// source at one revision. The client chooses a projection but never derives
152// grouping or ordering policy from row metadata.
153type PickerSnapshot struct {
154 InteractionID uint64
155 SourceID string
156 SourceRevision uint64
157 Status PickerSourceStatus
158 StatusDetail string
159 // Lines/Cursor are retained as construction aliases inside this v0 branch;
160 // normalization copies them into both projections before encoding.
161 Lines []PickerLine
162 Cursor PickerCursor
163 Recent PickerProjection
164 Grouped PickerProjection
165}
166
167// PickerSelection commits the opaque row key displayed at SourceRevision.
168// CauseActionID correlates ui-driver input like NavigationInventorySelection.
169// RequestID correlates a client-initiated interaction, and is zero otherwise.
170type PickerSelection struct {
171 CauseActionID uint64
172 RequestID uint64
173 InteractionID uint64
174 SourceID string
175 SourceRevision uint64
176 Key string
177 Action PickerAction
178}
179
180// PickerResult reports a completed mutation that changed source state, so the
181// client can refresh without guessing. Rejections use PickerFailure.
182type PickerResult struct {
183 CauseActionID uint64
184 RequestID uint64
185 InteractionID uint64
186 SourceID string
187 Key string
188 Action PickerAction
189}
190
191// PickerFailureCode classifies client-picker selection failures.
192type PickerFailureCode uint8
193
194const (
195 PickerStaleRevision PickerFailureCode = iota + 1
196 PickerUnknownKey
197 PickerRetiredTarget
198 PickerNavigationFailed
199 PickerUnknownSource
200 PickerActionFailed
201)
202
203// PickerFailure reports a rejected selection with a bounded code.
204type PickerFailure struct {
205 CauseActionID uint64
206 RequestID uint64
207 InteractionID uint64
208 SourceID string
209 Key string
210 Action PickerAction
211 Code PickerFailureCode
212}
213
214// PickerClose asks the serving daemon to retire one interaction. It is
215// idempotent: a close for an unknown or already retired interaction is a
216// no-op, never an error.
217type PickerClose struct {
218 InteractionID uint64
219 RequestID uint64
220}
221
222// PickerClosed confirms that the serving daemon retired one interaction. The
223// barrier names the daemon output boundary the client must display before it
224// releases the terminal.
225type PickerClosed struct {
226 InteractionID uint64
227 BarrierEpoch uint64
228 BarrierState uint64
229}
230
231func validPickerIntent(intent PickerIntent) bool {
232 switch intent {
233 case PickerIntentNavigation, PickerIntentMovePane, PickerIntentMoveTab:
234 return true
235 default:
236 return false
237 }
238}
239
240func validPickerAction(action PickerAction) bool {
241 switch action {
242 case PickerActionNavigate, PickerActionMove, PickerActionKill:
243 return true
244 default:
245 return false
246 }
247}
248
249func validPickerLineKind(kind PickerLineKind) bool {
250 switch kind {
251 case PickerLineSection, PickerLineSession, PickerLineTab, PickerLineHost:
252 return true
253 default:
254 return false
255 }
256}
257
258func validPickerLineStatus(status PickerLineStatus) bool {
259 return status <= PickerLineStatusNoDaemon
260}
261
262func validPickerLineActions(actions PickerLineActions) bool {
263 return actions&^(PickerCanNavigate|PickerCanMove|PickerCanKill) == 0
264}
265
266func validPickerSourceStatus(status PickerSourceStatus) bool {
267 switch status {
268 case PickerSourceOK, PickerSourceUnavailable, PickerSourceTooLarge:
269 return true
270 default:
271 return false
272 }
273}
274
275func validPickerFailureCode(code PickerFailureCode) bool {
276 switch code {
277 case PickerStaleRevision, PickerUnknownKey, PickerRetiredTarget, PickerNavigationFailed, PickerUnknownSource, PickerActionFailed:
278 return true
279 default:
280 return false
281 }
282}
283
284func validPickerKey(value string) bool {
285 return validInventoryKey(value)
286}
287
288func validPickerDisplay(value string) bool {
289 if len(value) > PickerInteractionMaxDisplayBytes {
290 return false
291 }
292 return validInventoryDisplay(value)
293}
294
295func validPickerSourceID(value string) bool {
296 if value == "" || len(value) > PickerInteractionMaxSourceBytes {
297 return false
298 }
299 return validInventoryDisplay(value)
300}
301
302// ValidatePickerOffer enforces a nonzero interaction, a known intent, bounded
303// display strings, and a move source only on move intents.
304func ValidatePickerOffer(offer PickerOffer) error {
305 if offer.InteractionID == 0 || !validPickerIntent(offer.Intent) {
306 return ErrInvalidNavigation
307 }
308 if !validPickerDisplay(offer.Title) {
309 return ErrInvalidNavigation
310 }
311 if offer.MoveSourceKey != "" {
312 if offer.Intent == PickerIntentNavigation || !validPickerKey(offer.MoveSourceKey) {
313 return ErrInvalidNavigation
314 }
315 }
316 return nil
317}
318
319// ValidatePickerSnapshot enforces a nonzero interaction/source revision, a
320// bounded source identity, and unique opaque keys with display safety. Order
321// is preserved verbatim: lines are never re-sorted here.
322// ValidatePickerSnapshot enforces the envelope, the per-line shape, unique row
323// keys, and a cursor that names a published row. Every rejection wraps
324// ErrInvalidNavigation with the offending row, because a refused snapshot
325// otherwise leaves an operator with nothing but a silent, closed picker.
326func ValidatePickerSnapshot(snapshot PickerSnapshot) error {
327 snapshot = NormalizePickerSnapshot(snapshot)
328 if snapshot.InteractionID == 0 || snapshot.SourceRevision == 0 {
329 return fmt.Errorf("%w: snapshot envelope", ErrInvalidNavigation)
330 }
331 if !validPickerSourceID(snapshot.SourceID) || !validPickerSourceStatus(snapshot.Status) {
332 return fmt.Errorf("%w: snapshot source %q", ErrInvalidNavigation, snapshot.SourceID)
333 }
334 if !validPickerDisplay(snapshot.StatusDetail) {
335 return fmt.Errorf("%w: snapshot status detail", ErrInvalidNavigation)
336 }
337 recentKeys, err := validatePickerProjection("recent", snapshot.Recent)
338 if err != nil {
339 return err
340 }
341 groupedKeys, err := validatePickerProjection("grouped", snapshot.Grouped)
342 if err != nil {
343 return err
344 }
345 if len(recentKeys) != len(groupedKeys) {
346 return fmt.Errorf("%w: projection destination count", ErrInvalidNavigation)
347 }
348 for key, recent := range recentKeys {
349 grouped, ok := groupedKeys[key]
350 if !ok || recent != grouped {
351 return fmt.Errorf("%w: projection destination %q", ErrInvalidNavigation, key)
352 }
353 }
354 return nil
355}
356
357func NormalizePickerSnapshot(snapshot PickerSnapshot) PickerSnapshot {
358 if len(snapshot.Recent.Lines) == 0 && len(snapshot.Lines) != 0 {
359 snapshot.Recent = PickerProjection{Lines: snapshot.Lines, Cursor: snapshot.Cursor}
360 }
361 if len(snapshot.Grouped.Lines) == 0 && len(snapshot.Lines) != 0 {
362 snapshot.Grouped = PickerProjection{Lines: snapshot.Lines, Cursor: snapshot.Cursor}
363 }
364 return snapshot
365}
366
367func validatePickerProjection(name string, projection PickerProjection) (map[string]PickerLineActions, error) {
368 if len(projection.Lines) > PickerInteractionMaxLines {
369 return nil, fmt.Errorf("%w: %s has %d lines", ErrInvalidNavigation, name, len(projection.Lines))
370 }
371 seen := make(map[string]PickerLineActions, len(projection.Lines))
372 for i, line := range projection.Lines {
373 if !validPickerLineKind(line.Kind) || !validPickerLineStatus(line.Status) || !validPickerLineActions(line.Actions) {
374 return nil, fmt.Errorf("%w: %s line %d kind/status/actions", ErrInvalidNavigation, name, i)
375 }
376 if !validPickerDisplay(line.Label) || !validPickerDisplay(line.Detail) || !validPickerDisplay(line.StatusDetail) {
377 return nil, fmt.Errorf("%w: %s line %d display", ErrInvalidNavigation, name, i)
378 }
379 // Focus eligibility is published explicitly: a row that admits an
380 // action is always a cursor destination, and a section header is never
381 // one. A row that is neither is rendered and skipped.
382 if line.Actions != 0 && !line.Focusable {
383 return nil, fmt.Errorf("%w: %s line %d action without focus", ErrInvalidNavigation, name, i)
384 }
385 if line.Kind == PickerLineSection {
386 if line.Key != "" || line.Focusable || line.Actions != 0 {
387 return nil, fmt.Errorf("%w: %s line %d section key or action", ErrInvalidNavigation, name, i)
388 }
389 continue
390 }
391 if !validPickerKey(line.Key) {
392 return nil, fmt.Errorf("%w: %s line %d key %q", ErrInvalidNavigation, name, i, line.Key)
393 }
394 if _, dup := seen[line.Key]; dup {
395 return nil, fmt.Errorf("%w: %s line %d repeats key %q", ErrInvalidNavigation, name, i, line.Key)
396 }
397 seen[line.Key] = line.Actions
398 }
399 if projection.Cursor.Key != "" {
400 if !validPickerKey(projection.Cursor.Key) {
401 return nil, fmt.Errorf("%w: %s cursor key %q", ErrInvalidNavigation, name, projection.Cursor.Key)
402 }
403 if _, ok := seen[projection.Cursor.Key]; !ok {
404 return nil, fmt.Errorf("%w: %s cursor key %q is not a published row", ErrInvalidNavigation, name, projection.Cursor.Key)
405 }
406 }
407 if projection.Cursor.Index < 0 || projection.Cursor.Index > len(projection.Lines) {
408 return nil, fmt.Errorf("%w: %s cursor index %d of %d lines", ErrInvalidNavigation, name, projection.Cursor.Index, len(projection.Lines))
409 }
410 return seen, nil
411}
412
413// ValidatePickerClose requires a nonzero interaction.
414func ValidatePickerClose(close PickerClose) error {
415 if close.InteractionID == 0 {
416 return ErrInvalidNavigation
417 }
418 return nil
419}
420
421// ValidatePickerClosed requires a nonzero interaction.
422func ValidatePickerClosed(closed PickerClosed) error {
423 if closed.InteractionID == 0 {
424 return ErrInvalidNavigation
425 }
426 return nil
427}
428
429// ValidatePickerSelection enforces nonzero interaction/source revision, a
430// bounded source identity, a known action, and a known key shape. Unknown
431// keys, foreign sources, and stale revisions reject at resolve time, not here.
432func ValidatePickerSelection(selection PickerSelection) error {
433 if selection.InteractionID == 0 || selection.SourceRevision == 0 {
434 return ErrInvalidNavigation
435 }
436 if !validPickerSourceID(selection.SourceID) || !validPickerAction(selection.Action) {
437 return ErrInvalidNavigation
438 }
439 if !validPickerKey(selection.Key) {
440 return ErrInvalidNavigation
441 }
442 return nil
443}
444
445// ValidatePickerResult enforces matching identity for a completed mutation.
446func ValidatePickerResult(result PickerResult) error {
447 if result.InteractionID == 0 || !validPickerSourceID(result.SourceID) || !validPickerAction(result.Action) {
448 return ErrInvalidNavigation
449 }
450 if !validPickerKey(result.Key) {
451 return ErrInvalidNavigation
452 }
453 return nil
454}
455
456// ValidatePickerFailure enforces matching IDs and bounded codes.
457func ValidatePickerFailure(failure PickerFailure) error {
458 if failure.InteractionID == 0 || !validPickerAction(failure.Action) {
459 return ErrInvalidNavigation
460 }
461 if failure.SourceID != "" && !validPickerSourceID(failure.SourceID) {
462 return ErrInvalidNavigation
463 }
464 if failure.Key != "" && !validPickerKey(failure.Key) {
465 return ErrInvalidNavigation
466 }
467 if !validPickerFailureCode(failure.Code) {
468 return ErrInvalidNavigation
469 }
470 return nil
471}