1package daemon
2
3import (
4 "encoding/hex"
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// navigationInventoryEntryKey derives a stable opaque key for an unchanged
12// private exact target (authority, lifecycle, name). Presentation-only
13// updates keep the key; a changed or removed target retires it and a
14// reappearing target gets a new key through its new lifecycle.
15func navigationInventoryEntryKey(lifecycle domain.SessionLifecycleID, name string) string {
16 return hex.EncodeToString(lifecycle[:]) + "/" + name
17}
18
19// handleNavigationInventory answers a one-shot source-control query without a
20// Hello session, attachment, startup, PTY, or geometry effect. It closes the
21// exact connection on every exit so blocked operations unwind.
22func (d *Daemon) handleNavigationInventory(tr ports.ServerConnection, request protocol.NavigationInventoryRequest) {
23 defer func() { _ = tr.Close() }()
24 response := d.answerNavigationInventory(request)
25 _ = d.boundedControlSend(tr, response)
26}
27
28// answerNavigationInventory builds the typed response for a validated or
29// rejected inventory request.
30func (d *Daemon) answerNavigationInventory(request protocol.NavigationInventoryRequest) protocol.NavigationInventoryResponse {
31 if err := protocol.ValidateNavigationInventoryRequest(request); err != nil {
32 return protocol.NavigationInventoryResponse{RequestID: request.RequestID, Operation: request.Operation, Status: protocol.NavigationInventoryInvalid}
33 }
34 if request.Version != protocol.Version {
35 return protocol.NavigationInventoryResponse{RequestID: request.RequestID, Operation: request.Operation, Status: protocol.NavigationInventoryVersionMismatch}
36 }
37 if request.Operation == protocol.NavigationInventoryResolve {
38 return d.resolveNavigationInventory(request)
39 }
40 return d.snapshotNavigationInventory(request.RequestID)
41}
42
43// snapshotNavigationInventory answers with this daemon's own sessions only:
44// other daemons are observed by the client's broker, never relayed here.
45func (d *Daemon) snapshotNavigationInventory(requestID uint64) protocol.NavigationInventoryResponse {
46 return d.localNavigationInventorySnapshot(requestID)
47}
48
49// localNavigationInventorySnapshot is the prepared local-only navigation
50// projection: exactly one local source group, built from the
51// daemon's own capture.
52func (d *Daemon) localNavigationInventorySnapshot(requestID uint64) protocol.NavigationInventoryResponse {
53 inv := d.captureLocalSessionInventory(viewOptions{}, false)
54 response := protocol.NavigationInventoryResponse{
55 RequestID: requestID, Operation: protocol.NavigationInventorySnapshot, Status: protocol.NavigationInventoryOK,
56 Groups: []protocol.NavigationInventorySourceGroup{d.localInventoryGroup(inv)},
57 }
58 return fitNavigationInventoryResponse(response)
59}
60
61// localInventoryGroup projects live named sessions and resumable stopped
62// sessions. Observation failures do not exist for local state; broken and
63// purging records are excluded here because they are never resumable. It reads
64// only the remote-free local capture.
65func (d *Daemon) localInventoryGroup(inv localSessionInventory) protocol.NavigationInventorySourceGroup {
66 group := protocol.NavigationInventorySourceGroup{SourceKey: protocol.NavigationInventoryLocalSourceKey, Status: protocol.NavigationInventorySourceOK}
67 for _, item := range inv.live {
68 view := item.view
69 if view.name == "" || view.ephemeral {
70 continue
71 }
72 group.Entries = append(group.Entries, protocol.NavigationInventoryEntry{
73 SourceKey: group.SourceKey,
74 EntryKey: navigationInventoryEntryKey(view.incarnation, view.name),
75 Name: view.name,
76 State: "up",
77 })
78 }
79 for _, entry := range inv.resumableStopped() {
80 group.Entries = append(group.Entries, protocol.NavigationInventoryEntry{
81 SourceKey: group.SourceKey,
82 EntryKey: navigationInventoryEntryKey(entry.incarnation, entry.name),
83 Name: entry.name,
84 State: "stopped",
85 })
86 }
87 if len(group.Entries) > protocol.NavigationInventoryMaxLocalEntries {
88 return protocol.NavigationInventorySourceGroup{SourceKey: group.SourceKey, Status: protocol.NavigationInventorySourceTooLarge}
89 }
90 return group
91}
92
93// navigationInventoryEncodedBudget mirrors the wire export budget without
94// importing the codec layer. The daemon never encodes frames itself; the
95// estimate below upper-bounds the real payload so fit degrades early rather
96// than emitting an oversized frame.
97const navigationInventoryEncodedBudget = 4 << 20
98
99func navigationInventorySnapshotSize(response protocol.NavigationInventoryResponse) int {
100 size := 10 + 2
101 for _, group := range response.Groups {
102 size += 2 + len(group.SourceKey) + 1 + 4
103 for _, entry := range group.Entries {
104 size += 2 + len(entry.EntryKey) + 2 + len(entry.Name) + 2 + len(entry.DisplayOrigin) + 2 + len(entry.State) + 2 + len(entry.Reason)
105 }
106 }
107 return size
108}
109
110// fitNavigationInventoryResponse degrades the largest complete group to
111// status-only until the response fits the encoded budget. It never emits a
112// silently truncated group or a claim of complete inventory.
113func fitNavigationInventoryResponse(response protocol.NavigationInventoryResponse) protocol.NavigationInventoryResponse {
114 if protocol.ValidateNavigationInventoryResponse(response) != nil {
115 response.Groups = nil
116 response.Status = protocol.NavigationInventoryInvalid
117 return response
118 }
119 for range len(response.Groups) + 1 {
120 if navigationInventorySnapshotSize(response) <= navigationInventoryEncodedBudget {
121 return response
122 }
123 largest := -1
124 for i, group := range response.Groups {
125 if group.Status != protocol.NavigationInventorySourceOK || len(group.Entries) == 0 {
126 continue
127 }
128 if largest == -1 || len(group.Entries) > len(response.Groups[largest].Entries) {
129 largest = i
130 }
131 }
132 if largest == -1 {
133 response.Groups = nil
134 response.Status = protocol.NavigationInventoryUnavailable
135 return response
136 }
137 source := response.Groups[largest].SourceKey
138 response.Groups[largest] = protocol.NavigationInventorySourceGroup{SourceKey: source, Status: protocol.NavigationInventorySourceTooLarge}
139 }
140 response.Groups = nil
141 response.Status = protocol.NavigationInventoryUnavailable
142 return response
143}
144
145// resolveNavigationInventory revalidates the selected key against a fresh
146// capture and returns a non-mutating AttachTarget. Registration is checked at
147// resolve: a removed or re-registered source rejects without attaching a
148// same-name replacement.
149func (d *Daemon) resolveNavigationInventory(request protocol.NavigationInventoryRequest) protocol.NavigationInventoryResponse {
150 response := protocol.NavigationInventoryResponse{RequestID: request.RequestID, Operation: protocol.NavigationInventoryResolve}
151 if request.SourceKey == protocol.NavigationInventoryLocalSourceKey {
152 target, ok := d.localNavigationResolve(request)
153 if !ok {
154 response.Status = protocol.NavigationInventoryUnavailable
155 return response
156 }
157 response.Status = protocol.NavigationInventoryOK
158 resolved := protocol.AttachTarget{Session: target.SessionName, Intent: protocol.IntentAttach, ExactTarget: &target}
159 response.Resolved = &resolved
160 return response
161 }
162 // Remote sources are the client broker's; this daemon resolves only its
163 // own sessions.
164 response.Status = protocol.NavigationInventoryUnavailable
165 return response
166}
167
168// localNavigationResolve is the prepared local-only navigation resolver
169// It rejects a structured foreign source key or registration
170// before any lookup, so a local resolve can never be satisfied by a remote
171// endpoint or a foreign registration even when a local session shares the
172// row's name. Matching is by the opaque lifecycle-qualified key only: a stale
173// or replaced lifecycle never falls back to a same-name session.
174func (d *Daemon) localNavigationResolve(request protocol.NavigationInventoryRequest) (protocol.ExactSessionTarget, bool) {
175 if request.SourceKey != protocol.NavigationInventoryLocalSourceKey || !request.Registration.IsZero() {
176 return protocol.ExactSessionTarget{}, false
177 }
178 return localNavigationResolveEntry(d.captureLocalSessionInventory(viewOptions{}, false), request.EntryKey)
179}
180
181// localNavigationResolveEntry matches the opaque key against fresh live and
182// resumable stopped state. A stopped target that is now live with the same
183// lifecycle and selector stays attachable; a broken or purging record is not
184// resumable and rejects; a changed lifecycle rejects.
185func localNavigationResolveEntry(inv localSessionInventory, entryKey string) (protocol.ExactSessionTarget, bool) {
186 for _, item := range inv.live {
187 view := item.view
188 if view.name == "" || view.ephemeral {
189 continue
190 }
191 if navigationInventoryEntryKey(view.incarnation, view.name) != entryKey {
192 continue
193 }
194 return protocol.ExactSessionTarget{LifecycleID: view.incarnation, SessionName: view.name}, true
195 }
196 for _, entry := range inv.resumableStopped() {
197 if navigationInventoryEntryKey(entry.incarnation, entry.name) != entryKey {
198 continue
199 }
200 return protocol.ExactSessionTarget{LifecycleID: entry.incarnation, SessionName: entry.name}, true
201 }
202 return protocol.ExactSessionTarget{}, false
203}