v0.2.0 80c61e3

remote.go RawHistoryFolder

1package ports
2
3import (
4	"context"
5	"encoding/json"
6	"errors"
7	"fmt"
8	"log/slog"
9	"math"
10	"slices"
11	"time"
12	"unicode"
13	"unicode/utf8"
14
15	"github.com/bnema/vev/internal/domain"
16)
17
18// RemoteTransportMode selects the transport used for remote attach after CLI parsing.
19type RemoteTransportMode string
20
21const (
22	RemoteTransportUDP   RemoteTransportMode = "udp"
23	RemoteTransportStdio RemoteTransportMode = "stdio"
24)
25
26// RemoteDialerFactory builds the transport-specific dialer for a remote target.
27// Implementations live in adapters; app wiring consumes this interface.
28type RemoteDialerFactory interface {
29	DialerForRemote(target string, session string, mode RemoteTransportMode, log *slog.Logger) (Dialer, error)
30}
31
32// RemoteHostStore persists pinned and learned remote host targets in one
33// versioned state file across processes.
34type RemoteHostStore interface {
35	// Hosts returns pinned hosts in stored order and learned hosts in lexical order.
36	Hosts() (pinned, learned []string, err error)
37	AddPinned(target string) error
38	RemovePinned(target string) error
39	Remember(target string) error
40	Forget(target string) error
41	// Remove deletes target from both pinned and learned lists atomically and
42	// reports whether the target existed in either list.
43	Remove(target string) (deleted bool, err error)
44}
45
46// RemoteHostLearner records the remote target after a successful attach.
47// It deliberately has no arguments because app captures the validated target
48// while constructing the client dependency.
49type RemoteHostLearner interface {
50	RememberRemoteHost() error
51}
52
53// RemoteCatalogClient fetches a versioned session catalog from a remote host.
54type RemoteCatalogClient interface {
55	List(ctx context.Context, target string) (RemoteCatalog, error)
56}
57
58// RemotePreviewClient fetches one bounded, exact-target viewport without
59// attaching to or mutating the remote session.
60type RemotePreviewClient interface {
61	Preview(ctx context.Context, target domain.RemoteSessionTarget, width, height uint16) (RemotePreview, error)
62}
63
64const (
65	// RemoteCatalogSchemaVersion is independent from the IPC protocol. A
66	// catalogue can be rejected without changing the attachment wire layout.
67	RemoteCatalogSchemaVersion uint16 = 2
68
69	RemoteCatalogMaxHosts        = 64
70	RemoteCatalogMaxSessions     = 256
71	RemoteCatalogMaxTabsPerSess  = 128
72	RemoteCatalogMaxNameBytes    = 256
73	RemoteCatalogMaxDetailBytes  = 512
74	RemoteCatalogMaxIDBytes      = 128
75	RemoteCatalogMaxCatalogBytes = 1 << 20
76)
77
78// RemoteCatalogCacheEntry is one immutable host catalog snapshot persisted for
79// immediate remote discovery at daemon startup. The legacy-shaped Sessions
80// field is retained as a source-compatible container, but cache writers strip
81// dynamic Detail and Attention fields before persistence.
82type RemoteCatalogCacheEntry struct {
83	Host      string
84	FetchedAt time.Time
85	Sessions  []RemoteCatalogSession
86}
87
88// RemoteCatalogCache persists complete remote discovery snapshots independently
89// from the remote host registry.
90type RemoteCatalogCache interface {
91	Load() ([]RemoteCatalogCacheEntry, error)
92	Store([]RemoteCatalogCacheEntry) error
93}
94
95// RemoteCatalogTab is one bounded, untrusted tab presentation hint. Detail
96// and Attention are deliberately omitted from the durable cache.
97type RemoteCatalogTab struct {
98	ID        string `json:"id"`
99	Index     uint16 `json:"index"`
100	Name      string `json:"name"`
101	Detail    string `json:"detail,omitempty"`
102	Attention bool   `json:"attention,omitempty"`
103}
104
105// RemoteCatalogSession is one session in the remote discovery catalog. State
106// is an explicit string contract (up|down|broken), not SessionState.
107//
108// Tabs is interface-typed only to keep old v0.x callers that supplied a count
109// source-compatible while the versioned schema carries []RemoteCatalogTab.
110// New code must use CatalogTabs and never infer routing identity from a count.
111type RemoteCatalogSession struct {
112	LifecycleID domain.SessionLifecycleID `json:"lifecycle_id,omitempty"`
113	Name        string                    `json:"name"`
114	State       string                    `json:"state"`
115	Ephemeral   bool                      `json:"ephemeral"`
116	Tabs        any                       `json:"tabs"`
117	Attached    bool                      `json:"attached"`
118	LastUsedSeq uint64                    `json:"last_used_seq,omitempty"`
119	ActiveTabID string                    `json:"active_tab_id,omitempty"`
120	Reason      string                    `json:"reason,omitempty"`
121}
122
123// MarshalJSON omits a zero lifecycle ID for legacy count-only catalogues.
124// Encoding/json does not apply omitempty to a zero array that implements
125// encoding.TextMarshaler, so the pointer is made explicit here.
126func (s RemoteCatalogSession) MarshalJSON() ([]byte, error) {
127	type envelope struct {
128		LifecycleID *domain.SessionLifecycleID `json:"lifecycle_id,omitempty"`
129		Name        string                     `json:"name"`
130		State       string                     `json:"state"`
131		Ephemeral   bool                       `json:"ephemeral"`
132		Tabs        any                        `json:"tabs"`
133		Attached    bool                       `json:"attached"`
134		LastUsedSeq uint64                     `json:"last_used_seq,omitempty"`
135		ActiveTabID string                     `json:"active_tab_id,omitempty"`
136		Reason      string                     `json:"reason,omitempty"`
137	}
138	var lifecycle *domain.SessionLifecycleID
139	if s.LifecycleID != (domain.SessionLifecycleID{}) {
140		id := s.LifecycleID
141		lifecycle = &id
142	}
143	return json.Marshal(envelope{LifecycleID: lifecycle, Name: s.Name, State: s.State, Ephemeral: s.Ephemeral, Tabs: s.Tabs, Attached: s.Attached, LastUsedSeq: s.LastUsedSeq, ActiveTabID: s.ActiveTabID, Reason: s.Reason})
144}
145
146// UnmarshalJSON preserves the legacy numeric tab-count representation while
147// decoding the current array representation into concrete tab values.
148func (s *RemoteCatalogSession) UnmarshalJSON(data []byte) error {
149	type envelope struct {
150		LifecycleID domain.SessionLifecycleID `json:"lifecycle_id"`
151		Name        string                    `json:"name"`
152		State       string                    `json:"state"`
153		Ephemeral   bool                      `json:"ephemeral"`
154		Tabs        json.RawMessage           `json:"tabs"`
155		Attached    bool                      `json:"attached"`
156		LastUsedSeq uint64                    `json:"last_used_seq"`
157		ActiveTabID string                    `json:"active_tab_id"`
158		Reason      string                    `json:"reason"`
159	}
160	var raw envelope
161	if err := json.Unmarshal(data, &raw); err != nil {
162		return err
163	}
164	*s = RemoteCatalogSession{LifecycleID: raw.LifecycleID, Name: raw.Name, State: raw.State, Ephemeral: raw.Ephemeral, Attached: raw.Attached, LastUsedSeq: raw.LastUsedSeq, ActiveTabID: raw.ActiveTabID, Reason: raw.Reason}
165	if len(raw.Tabs) == 0 || string(raw.Tabs) == "null" {
166		s.Tabs = 0
167		return nil
168	}
169	var tabs []RemoteCatalogTab
170	if err := json.Unmarshal(raw.Tabs, &tabs); err == nil {
171		s.Tabs = tabs
172		return nil
173	}
174	var count uint16
175	if err := json.Unmarshal(raw.Tabs, &count); err != nil {
176		return err
177	}
178	s.Tabs = int(count)
179	return nil
180}
181
182// CatalogTabs returns a copied typed tab list. Legacy count-only entries have
183// no stable tab identity and therefore return nil.
184func CatalogTabs(session RemoteCatalogSession) []RemoteCatalogTab {
185	tabs, ok := session.Tabs.([]RemoteCatalogTab)
186	if !ok {
187		return nil
188	}
189	return slices.Clone(tabs)
190}
191
192// CatalogTabCount returns the count represented by either schema form.
193func CatalogTabCount(session RemoteCatalogSession) int {
194	switch tabs := session.Tabs.(type) {
195	case []RemoteCatalogTab:
196		return len(tabs)
197	case uint16:
198		return int(tabs)
199	case uint32:
200		return int(tabs)
201	case int:
202		return tabs
203	case float64:
204		return int(tabs)
205	default:
206		return 0
207	}
208}
209
210// SaturateUint16 clamps n to the uint16 range for legacy command output only.
211func SaturateUint16(n int) uint16 {
212	if n <= 0 {
213		return 0
214	}
215	if n >= 1<<16 {
216		return math.MaxUint16
217	}
218	return uint16(n)
219}
220
221// RemoteCatalog is the independently versioned JSON envelope returned by
222// remote-catalog --json. ProtocolVersion remains for legacy peers; new peers
223// must also provide SchemaVersion.
224type RemoteCatalog struct {
225	ProtocolVersion uint16                 `json:"protocol_version"`
226	SchemaVersion   uint16                 `json:"schema_version,omitempty"`
227	Sessions        []RemoteCatalogSession `json:"sessions"`
228}
229
230// RemoteCatalogVersionMismatchError is returned when a remote catalog envelope
231// reports an incompatible protocol or catalogue schema version.
232type RemoteCatalogVersionMismatchError struct {
233	Got  uint16
234	Want uint16
235	Kind string
236}
237
238func (e *RemoteCatalogVersionMismatchError) Error() string {
239	kind := e.Kind
240	if kind == "" {
241		kind = "protocol"
242	}
243	return fmt.Sprintf("remote catalog: %s version mismatch: got %d, want %d", kind, e.Got, e.Want)
244}
245
246var (
247	ErrInvalidRemoteCatalog       = errors.New("ports: invalid remote catalog")
248	ErrRemoteCatalogTooLarge      = errors.New("ports: remote catalog exceeds size limit")
249	ErrRemoteCatalogUnknownState  = errors.New("ports: remote catalog has unknown state")
250	ErrRemoteCatalogInvalidReason = errors.New("ports: remote catalog has unknown reason")
251)
252
253// ValidateRemoteCatalog applies all limits before a catalogue is cached or
254// rendered. It is deliberately strict for the current schema; zero schema is
255// the read-only compatibility form used by pre-parity v0.x peers.
256func ValidateRemoteCatalog(c RemoteCatalog) error {
257	if c.ProtocolVersion != ProtocolVersion {
258		return &RemoteCatalogVersionMismatchError{Got: c.ProtocolVersion, Want: ProtocolVersion, Kind: "protocol"}
259	}
260	if c.SchemaVersion != 0 && c.SchemaVersion != RemoteCatalogSchemaVersion {
261		return &RemoteCatalogVersionMismatchError{Got: c.SchemaVersion, Want: RemoteCatalogSchemaVersion, Kind: "catalog"}
262	}
263	if len(c.Sessions) > RemoteCatalogMaxSessions {
264		return fmt.Errorf("%w: too many sessions", ErrRemoteCatalogTooLarge)
265	}
266	seen := make(map[domain.SessionLifecycleID]string, len(c.Sessions))
267	bytes := 0
268	for _, session := range c.Sessions {
269		if !validRemoteCatalogText(session.Name) || !validRemoteCatalogText(session.State) || !validRemoteCatalogText(session.Reason) {
270			return fmt.Errorf("%w: invalid session text", ErrInvalidRemoteCatalog)
271		}
272		if len(session.Name) > RemoteCatalogMaxNameBytes || len(session.Reason) > RemoteCatalogMaxDetailBytes {
273			return fmt.Errorf("%w: session text too long", ErrRemoteCatalogTooLarge)
274		}
275		if err := domain.ValidateSessionName(session.Name); err != nil {
276			return fmt.Errorf("%w: session name: %v", ErrInvalidRemoteCatalog, err)
277		}
278		if !validRemoteCatalogState(session.State) {
279			return fmt.Errorf("%w: %q", ErrRemoteCatalogUnknownState, session.State)
280		}
281		if session.ActiveTabID != "" {
282			if err := domain.ValidateTabStableID(domain.TabStableID(session.ActiveTabID)); err != nil {
283				return fmt.Errorf("%w: invalid active tab ID", ErrInvalidRemoteCatalog)
284			}
285		}
286		if session.Reason != "" && !validRemoteCatalogReason(session.Reason) {
287			return fmt.Errorf("%w: %q", ErrRemoteCatalogInvalidReason, session.Reason)
288		}
289		if c.SchemaVersion != 0 {
290			if session.LifecycleID == (domain.SessionLifecycleID{}) {
291				return fmt.Errorf("%w: zero lifecycle ID", ErrInvalidRemoteCatalog)
292			}
293			if prior, exists := seen[session.LifecycleID]; exists {
294				return fmt.Errorf("%w: lifecycle ID reused by %q and %q", ErrInvalidRemoteCatalog, prior, session.Name)
295			}
296			seen[session.LifecycleID] = session.Name
297		}
298		tabs := CatalogTabs(session)
299		if len(tabs) == 0 {
300			if c.SchemaVersion != 0 {
301				if session.ActiveTabID != "" {
302					return fmt.Errorf("%w: active tab is absent", ErrInvalidRemoteCatalog)
303				}
304				if session.State == "up" && session.Tabs == nil {
305					return fmt.Errorf("%w: missing tab list", ErrInvalidRemoteCatalog)
306				}
307			}
308		} else {
309			if len(tabs) > RemoteCatalogMaxTabsPerSess {
310				return fmt.Errorf("%w: too many tabs", ErrRemoteCatalogTooLarge)
311			}
312			tabIDs := make(map[string]struct{}, len(tabs))
313			for i, tab := range tabs {
314				if !validRemoteCatalogText(tab.ID) || !validRemoteCatalogText(tab.Name) || !validRemoteCatalogText(tab.Detail) {
315					return fmt.Errorf("%w: invalid tab text", ErrInvalidRemoteCatalog)
316				}
317				if len(tab.ID) > RemoteCatalogMaxIDBytes || len(tab.Name) > RemoteCatalogMaxNameBytes || len(tab.Detail) > RemoteCatalogMaxDetailBytes {
318					return fmt.Errorf("%w: tab text too long", ErrRemoteCatalogTooLarge)
319				}
320				if tab.Index != uint16(i) {
321					return fmt.Errorf("%w: tab indexes are not ordered", ErrInvalidRemoteCatalog)
322				}
323				if session.State == "up" && tab.ID == "" {
324					return fmt.Errorf("%w: up tab has zero ID", ErrInvalidRemoteCatalog)
325				}
326				if tab.ID != "" {
327					if err := domain.ValidateTabStableID(domain.TabStableID(tab.ID)); err != nil {
328						return fmt.Errorf("%w: invalid tab ID", ErrInvalidRemoteCatalog)
329					}
330					if _, duplicate := tabIDs[tab.ID]; duplicate {
331						return fmt.Errorf("%w: duplicate tab ID", ErrInvalidRemoteCatalog)
332					}
333					tabIDs[tab.ID] = struct{}{}
334				}
335				bytes += len(tab.ID) + len(tab.Name) + len(tab.Detail)
336			}
337			if session.ActiveTabID != "" {
338				if session.State != "up" {
339					return fmt.Errorf("%w: down or broken session has an active tab", ErrInvalidRemoteCatalog)
340				}
341				if _, ok := tabIDs[session.ActiveTabID]; !ok {
342					return fmt.Errorf("%w: active tab is absent", ErrInvalidRemoteCatalog)
343				}
344			}
345		}
346		bytes += len(session.Name) + len(session.State) + len(session.Reason)
347	}
348	if bytes > RemoteCatalogMaxCatalogBytes {
349		return ErrRemoteCatalogTooLarge
350	}
351	return nil
352}
353
354// ValidateRemoteCatalogCacheEntries validates the bounded cache DTO before it
355// is loaded into picker state or written to disk. A cache may contain legacy
356// count-only sessions, but any complete typed snapshot must carry lifecycle
357// identity and the same tab invariants as a live catalog.
358func ValidateRemoteCatalogCacheEntries(entries []RemoteCatalogCacheEntry) error {
359	if len(entries) > RemoteCatalogMaxHosts {
360		return fmt.Errorf("%w: too many hosts", ErrRemoteCatalogTooLarge)
361	}
362	seenHosts := make(map[string]struct{}, len(entries))
363	for _, entry := range entries {
364		if err := domain.ValidateRemoteHostTarget(entry.Host); err != nil || len(entry.Host) > RemoteCatalogMaxNameBytes {
365			return fmt.Errorf("%w: invalid host", ErrInvalidRemoteCatalog)
366		}
367		if entry.FetchedAt.IsZero() || entry.FetchedAt.UnixNano() <= 0 {
368			return fmt.Errorf("%w: invalid fetched time", ErrInvalidRemoteCatalog)
369		}
370		if _, exists := seenHosts[entry.Host]; exists {
371			return fmt.Errorf("%w: duplicate host", ErrInvalidRemoteCatalog)
372		}
373		seenHosts[entry.Host] = struct{}{}
374		complete := false
375		for _, session := range entry.Sessions {
376			if session.LifecycleID != (domain.SessionLifecycleID{}) || CatalogTabs(session) != nil {
377				complete = true
378				break
379			}
380		}
381		schema := uint16(0)
382		if complete {
383			schema = RemoteCatalogSchemaVersion
384		}
385		catalog := RemoteCatalog{ProtocolVersion: ProtocolVersion, SchemaVersion: schema, Sessions: entry.Sessions}
386		if err := ValidateRemoteCatalog(catalog); err != nil {
387			return fmt.Errorf("%w: host %q: %v", ErrInvalidRemoteCatalog, entry.Host, err)
388		}
389	}
390	return nil
391}
392
393func validRemoteCatalogText(value string) bool {
394	if !utf8.ValidString(value) {
395		return false
396	}
397	for _, r := range value {
398		if unicode.IsControl(r) {
399			return false
400		}
401	}
402	return true
403}
404
405func validRemoteCatalogState(state string) bool {
406	switch state {
407	case "up", "down", "broken":
408		return true
409	default:
410		return false
411	}
412}
413
414func validRemoteCatalogReason(reason string) bool {
415	switch reason {
416	case "", "refreshing", "catalog_stale", "host_unreachable", "version_mismatch", "session_down", "session_broken", "identity_changed", "not_found", "timeout", "malformed":
417		return true
418	default:
419		return false
420	}
421}

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