detached edcc67a

refactor(architecture): enforce application port boundaries

commit
edcc67a2aca6b372b5afcb867aa81d27a94ea55d
author
brice · · 2026-08-28 08:12 UTC
committer
brice · · 2026-08-28 14:56 UTC
parent
9af94c6
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
82 files · +1778 -1655
M .mockery.yml +12 -0
@@ -28,6 +28,18 @@ packages:
2828 dir: internal/ports/mocks
2929 filename: '{{.InterfaceFile | base | trimSuffix ".go"}}_mock.go'
3030 pkgname: portsmocks
31+ github.com/bnema/vev/internal/protocol/wire:
32+ config:
33+ dir: internal/protocol/wire/mocks
34+ filename: transport_mock.go
35+ pkgname: wiremocks
36+ interfaces:
37+ Transport:
38+ DatagramTransport:
39+ AsyncTransport:
40+ OwnedSynchronousTransport:
41+ Dialer:
42+ Listener:
3143 github.com/bnema/vev/internal/usecase/command:
3244 interfaces:
3345 Context:
M AGENTS.md +16 -14
@@ -28,28 +28,30 @@ go test ./pkg/renderer ./pkg/vt ./internal/adapters/ipc ./internal/usecase/daemo
2828
2929 ## Architecture rules
3030
31-Hexagonal boundaries are enforced by `boundary_test.go`.
31+The exhaustive production matrix and separate test-import policy are enforced by `boundary_test.go`; package ownership is documented in `docs/architecture.md`.
3232
33-- `pkg/` is reusable and must not import `internal/`.
34-- `internal/usecase/` must not import `internal/adapters/`.
35-- Usecases depend on `internal/ports` and `internal/domain`.
33+- `pkg/` is reusable and never imports `internal/`.
34+- Production use cases import only `internal/ports`, semantic `internal/protocol` packages, `internal/domain`, and approved sibling use cases.
35+- Production use cases never import `internal/protocol/wire`, concrete adapters, `internal/app`, `internal/persist`, or `internal/platform`.
36+- `internal/ports` owns application seams, not codecs, raw frames, environment policy, or worker implementations.
3637
3738 Layer map:
3839
39-- `main.go` → `internal/app`: CLI parsing, wiring, daemon startup, hidden subcommands.
40-- `internal/ports`: application and raw carriage interfaces.
41-- `internal/protocol`: typed transport-neutral messages and negotiated session policy.
42-- `internal/protocol/wire`: message IDs, frames, strict binary codecs, compression, and encoded bounds.
43-- `internal/usecase/daemon`: sessions, tabs, panes, VT screens, renderer shadows, daemon features.
40+- `main.go` → `internal/app`: CLI parsing, composition, daemon startup, and hidden subcommands.
41+- `internal/domain`: pure shared values and terminal capability policy.
42+- `internal/protocol`: typed session messages; `protocol/catalogue` owns remote JSON schema; `protocol/wire` owns binary encoding and raw carriage contracts.
43+- `internal/ports`: typed application connections and infrastructure-facing use-case seams.
44+- `internal/adapters/sessionwire`: typed message ↔ raw frame adaptation.
45+- `internal/usecase/daemon`: sessions, tabs, panes, VT screens, renderer shadows, and daemon features.
4446 - `internal/usecase/client`: raw-mode thin client; writes output bytes verbatim and interprets nothing.
45-- `internal/adapters`: IPC, SSH stdio, PTY, terminal, and clock implementations.
47+- `internal/adapters`: IPC, UDP, SSH stdio, PTY, terminal, clock, and observability implementations.
4648
4749 Before touching daemon teardown paths, read the lock-ordering notes at the top of `internal/usecase/daemon/client.go`.
4850
4951 ## Ports and mocks
5052
51-- Define cross-layer interfaces only in `internal/ports`.
52-- Adapters implement ports; usecases and `internal/app` consume ports.
53+- Define application cross-layer interfaces in `internal/ports`; raw frame carriage interfaces live in `internal/protocol/wire`.
54+- Adapters implement ports and raw carriage contracts; use cases consume only application ports.
5355 - Tests should prefer generated `portsmocks.MockX` with `.EXPECT()`.
5456 - Hand-written fakes are okay only when mocks make the test harder to understand.
5557 - After changing a port, run `make mocks` and commit the generated updates.
@@ -63,7 +65,7 @@ Before touching daemon teardown paths, read the lock-ordering notes at the top o
6365
6466 ## Wire protocol
6567
66-Typed messages and negotiated version live in `internal/protocol`. Message IDs, frames, strict codecs, compression, and encoded bounds live in `internal/protocol/wire`; raw carriage interfaces remain in `internal/ports`. Connection framing lives in concrete carriage adapters such as `internal/adapters/ipc`.
68+Typed messages and negotiated version live in `internal/protocol`. Remote discovery JSON lives in `internal/protocol/catalogue`. Message IDs, frames, strict codecs, compression, encoded bounds, and raw carriage interfaces live in `internal/protocol/wire`. Connection framing lives in concrete carriage adapters such as `internal/adapters/ipc`.
6769
6870 - IPC frames on a connection are 4-byte big-endian length, 1 type byte, then payload.
6971 - Client message types occupy `1–13`, `15`, `32–33`, and `35` (`MsgParkedRouteRequest`); server types occupy `16–23`, `25–31`, `34`, and `36` (`MsgParkedRouteResponse`). Types `14` and `24` remain reserved.
@@ -77,7 +79,7 @@ Typed messages and negotiated version live in `internal/protocol`. Message IDs,
7779 - Named sessions survive headless and persist across daemon restarts.
7880 - The daemon starts on first use and exits when the last session ends.
7981 - Each connection has a 15-second handshake budget from connect through the initial committed publication.
80-- Local and remote attach use the same `Hello`/`Welcome` session protocol, then pump `Input`/`Resize` and `Output` frames over a byte-only Transport.
82+- Local and remote attach use the same typed `Hello`/`Welcome` session protocol. `sessionwire` translates typed traffic to raw frames carried by IPC, UDP, or SSH stdio.
8183 - Remote attach opens a direct connection to the selected daemon over UDP by default; `VEV_REMOTE_TRANSPORT=stdio` explicitly selects an SSH-only carriage.
8284 - A session owns shared PTYs, VT state, tabs, panes, PTY content geometry selected by the latest valid attachment claim, and ordered mutations. Each attachment owns its window/view, copy and overlay state, rendering/output state, and reconnect lifecycle; when the latest claimant detaches, the most recently claimed remaining attachment becomes authoritative.
8385 - Command requests have a 10-second result deadline and are tracked per connection.
M boundary_test.go +189 -46
@@ -1,84 +1,227 @@
11 package main_test
22
33 import (
4+ "fmt"
45 "go/parser"
56 "go/token"
67 "io/fs"
78 "path/filepath"
9+ "sort"
10+ "strconv"
811 "strings"
912 "testing"
1013 )
1114
12-// boundaryRule expresses a single import-boundary constraint: no .go file
13-// under root may import a package whose path has disallowedPrefix.
14-type boundaryRule struct {
15- name string
16- root string
17- disallowedPrefix string
15+const modulePath = "github.com/bnema/vev"
16+
17+type packageLayer string
18+
19+const (
20+ layerRoot packageLayer = "root"
21+ layerCommand packageLayer = "command"
22+ layerScript packageLayer = "script"
23+ layerApp packageLayer = "app"
24+ layerAdapter packageLayer = "adapter"
25+ layerUsecase packageLayer = "usecase"
26+ layerPorts packageLayer = "ports"
27+ layerProtocol packageLayer = "protocol"
28+ layerCatalogue packageLayer = "catalogue"
29+ layerWire packageLayer = "wire"
30+ layerDomain packageLayer = "domain"
31+ layerPersist packageLayer = "persist"
32+ layerPlatform packageLayer = "platform"
33+ layerLogging packageLayer = "logging"
34+ layerPkg packageLayer = "pkg"
35+ layerTestSupport packageLayer = "test-support"
36+)
37+
38+var productionDependencies = map[packageLayer]map[packageLayer]bool{
39+ layerRoot: {layerApp: true},
40+ layerCommand: {layerPkg: true},
41+ layerScript: {layerAdapter: true, layerDomain: true, layerPorts: true, layerProtocol: true, layerCatalogue: true, layerWire: true, layerPkg: true},
42+ layerApp: {layerAdapter: true, layerDomain: true, layerPorts: true, layerProtocol: true, layerCatalogue: true, layerWire: true, layerUsecase: true, layerPersist: true, layerPlatform: true, layerLogging: true, layerPkg: true},
43+ layerAdapter: {layerAdapter: true, layerDomain: true, layerPorts: true, layerProtocol: true, layerCatalogue: true, layerWire: true, layerPlatform: true, layerPkg: true},
44+ layerUsecase: {layerUsecase: true, layerDomain: true, layerPorts: true, layerProtocol: true, layerCatalogue: true},
45+ layerPorts: {layerDomain: true, layerProtocol: true, layerCatalogue: true},
46+ layerProtocol: {layerDomain: true},
47+ layerCatalogue: {layerDomain: true, layerProtocol: true},
48+ layerWire: {layerDomain: true, layerProtocol: true},
49+ layerDomain: {layerDomain: true},
50+ layerPersist: {layerDomain: true, layerPorts: true, layerProtocol: true, layerPkg: true},
51+ layerPlatform: {},
52+ layerLogging: {layerPkg: true},
53+ layerPkg: {layerPkg: true},
1854 }
1955
2056 func TestImportBoundaries(t *testing.T) {
21- rules := []boundaryRule{
22- {
23- name: "pkg must not import internal",
24- root: "pkg",
25- disallowedPrefix: "github.com/bnema/vev/internal",
26- },
27- {
28- name: "usecase must not import adapters",
29- root: "internal/usecase",
30- disallowedPrefix: "github.com/bnema/vev/internal/adapters",
31- },
32- {
33- name: "usecase must not import platform",
34- root: "internal/usecase",
35- disallowedPrefix: "github.com/bnema/vev/internal/platform",
36- },
57+ violations, packages := inspectRepositoryDependencies(t)
58+ if len(violations) != 0 {
59+ t.Fatalf("invalid package dependencies:\n%s", strings.Join(violations, "\n"))
60+ }
61+ if len(packages) == 0 {
62+ t.Fatal("package inventory is empty")
3763 }
64+}
3865
39- for _, rule := range rules {
40- t.Run(rule.name, func(t *testing.T) {
41- violations := findImportViolations(t, rule.root, rule.disallowedPrefix)
42- if len(violations) > 0 {
43- t.Errorf("found disallowed imports of %q under %q:\n%s", rule.disallowedPrefix, rule.root, strings.Join(violations, "\n"))
66+func TestImportBoundaryNegativeFixtures(t *testing.T) {
67+ tests := []struct {
68+ name, source, target string
69+ testFile bool
70+ want bool
71+ }{
72+ {"usecase accepts semantic protocol", modulePath + "/internal/usecase/client", modulePath + "/internal/protocol", false, true},
73+ {"usecase rejects wire", modulePath + "/internal/usecase/daemon", modulePath + "/internal/protocol/wire", false, false},
74+ {"usecase rejects adapter", modulePath + "/internal/usecase/client", modulePath + "/internal/adapters/ipc", false, false},
75+ {"domain rejects ports", modulePath + "/internal/domain", modulePath + "/internal/ports", false, false},
76+ {"pkg rejects internal", modulePath + "/pkg/rawterm", modulePath + "/internal/domain", false, false},
77+ {"snapshot adapter exception", modulePath + "/internal/adapters/snapshot", modulePath + "/internal/usecase/snapshot", false, true},
78+ {"other adapter rejects usecase", modulePath + "/internal/adapters/ipc", modulePath + "/internal/usecase/client", false, false},
79+ {"usecase test may use wire fixture", modulePath + "/internal/usecase/client", modulePath + "/internal/protocol/wire", true, true},
80+ {"pkg test still rejects internal", modulePath + "/pkg/rawterm", modulePath + "/internal/domain", true, false},
81+ }
82+ for _, tt := range tests {
83+ t.Run(tt.name, func(t *testing.T) {
84+ got, err := dependencyAllowed(tt.source, tt.target, tt.testFile)
85+ if err != nil {
86+ t.Fatalf("dependencyAllowed: %v", err)
87+ }
88+ if got != tt.want {
89+ t.Fatalf("dependencyAllowed(%q, %q, test=%t) = %t, want %t", tt.source, tt.target, tt.testFile, got, tt.want)
4490 }
4591 })
4692 }
4793 }
4894
49-// findImportViolations walks root, parses each .go file's imports, and
50-// returns a description for every import whose path starts with
51-// disallowedPrefix.
52-func findImportViolations(t *testing.T, root, disallowedPrefix string) []string {
95+func inspectRepositoryDependencies(t *testing.T) ([]string, map[string]struct{}) {
5396 t.Helper()
54-
5597 var violations []string
98+ packages := make(map[string]struct{})
5699 fset := token.NewFileSet()
57-
58- err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
59- if err != nil {
60- return err
100+ err := filepath.WalkDir(".", func(path string, entry fs.DirEntry, walkErr error) error {
101+ if walkErr != nil {
102+ return walkErr
61103 }
62- if d.IsDir() || !strings.HasSuffix(path, ".go") {
104+ if entry.IsDir() {
105+ name := entry.Name()
106+ if name == ".git" || name == ".worktrees" || name == "vendor" {
107+ return filepath.SkipDir
108+ }
63109 return nil
64110 }
65-
66- f, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly)
111+ if !strings.HasSuffix(path, ".go") {
112+ return nil
113+ }
114+ source := importPathForFile(path)
115+ packages[source] = struct{}{}
116+ if _, err := classifyPackage(source); err != nil {
117+ violations = append(violations, path+": "+err.Error())
118+ return nil
119+ }
120+ file, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly)
67121 if err != nil {
68122 return err
69123 }
70-
71- for _, imp := range f.Imports {
72- importPath := strings.Trim(imp.Path.Value, `"`)
73- if strings.HasPrefix(importPath, disallowedPrefix) {
74- violations = append(violations, path+": imports "+importPath)
124+ testFile := strings.HasSuffix(path, "_test.go")
125+ for _, spec := range file.Imports {
126+ target, err := strconv.Unquote(spec.Path.Value)
127+ if err != nil || (target != modulePath && !strings.HasPrefix(target, modulePath+"/")) {
128+ continue
129+ }
130+ allowed, err := dependencyAllowed(source, target, testFile)
131+ if err != nil {
132+ violations = append(violations, fmt.Sprintf("%s: %v", path, err))
133+ continue
134+ }
135+ if !allowed {
136+ violations = append(violations, fmt.Sprintf("%s: %s may not import %s", path, source, target))
75137 }
76138 }
77139 return nil
78140 })
79141 if err != nil {
80- t.Fatalf("walking %s: %v", root, err)
142+ t.Fatalf("walking repository: %v", err)
81143 }
144+ sort.Strings(violations)
145+ return violations, packages
146+}
82147
83- return violations
148+func dependencyAllowed(source, target string, testFile bool) (bool, error) {
149+ sourceLayer, err := classifyPackage(source)
150+ if err != nil {
151+ return false, err
152+ }
153+ targetLayer, err := classifyPackage(target)
154+ if err != nil {
155+ return false, err
156+ }
157+ if testFile {
158+ return sourceLayer != layerPkg, nil
159+ }
160+ if sourceLayer == layerTestSupport {
161+ return testSupportDependencyAllowed(source, targetLayer), nil
162+ }
163+ if source == modulePath+"/internal/adapters/snapshot" && target == modulePath+"/internal/usecase/snapshot" {
164+ return true, nil
165+ }
166+ return productionDependencies[sourceLayer][targetLayer], nil
167+}
168+
169+func testSupportDependencyAllowed(source string, target packageLayer) bool {
170+ switch {
171+ case source == modulePath+"/internal/ports/mocks":
172+ return target == layerPorts || target == layerDomain || target == layerProtocol || target == layerCatalogue
173+ case source == modulePath+"/internal/protocol/wire/mocks":
174+ return target == layerWire
175+ case strings.HasPrefix(source, modulePath+"/internal/testutil"):
176+ return target == layerDomain || target == layerProtocol || target == layerWire
177+ default:
178+ return false
179+ }
180+}
181+
182+func classifyPackage(path string) (packageLayer, error) {
183+ switch {
184+ case path == modulePath:
185+ return layerRoot, nil
186+ case strings.HasPrefix(path, modulePath+"/cmd/"):
187+ return layerCommand, nil
188+ case strings.HasPrefix(path, modulePath+"/scripts/"):
189+ return layerScript, nil
190+ case path == modulePath+"/internal/app":
191+ return layerApp, nil
192+ case strings.HasPrefix(path, modulePath+"/internal/adapters/"):
193+ return layerAdapter, nil
194+ case path == modulePath+"/internal/ports/mocks", path == modulePath+"/internal/protocol/wire/mocks", strings.HasPrefix(path, modulePath+"/internal/testutil"):
195+ return layerTestSupport, nil
196+ case path == modulePath+"/internal/ports":
197+ return layerPorts, nil
198+ case path == modulePath+"/internal/protocol/wire":
199+ return layerWire, nil
200+ case path == modulePath+"/internal/protocol/catalogue":
201+ return layerCatalogue, nil
202+ case path == modulePath+"/internal/protocol":
203+ return layerProtocol, nil
204+ case path == modulePath+"/internal/domain" || strings.HasPrefix(path, modulePath+"/internal/domain/"):
205+ return layerDomain, nil
206+ case path == modulePath+"/internal/usecase" || strings.HasPrefix(path, modulePath+"/internal/usecase/"):
207+ return layerUsecase, nil
208+ case path == modulePath+"/internal/persist":
209+ return layerPersist, nil
210+ case path == modulePath+"/internal/platform":
211+ return layerPlatform, nil
212+ case path == modulePath+"/internal/logging":
213+ return layerLogging, nil
214+ case path == modulePath+"/pkg" || strings.HasPrefix(path, modulePath+"/pkg/"):
215+ return layerPkg, nil
216+ default:
217+ return "", fmt.Errorf("unclassified package %q", path)
218+ }
219+}
220+
221+func importPathForFile(path string) string {
222+ dir := filepath.ToSlash(filepath.Dir(path))
223+ if dir == "." {
224+ return modulePath
225+ }
226+ return modulePath + "/" + strings.TrimPrefix(dir, "./")
84227 }
A docs/architecture.md +51 -0
@@ -0,0 +1,51 @@
1+# Architecture
2+
3+vev uses a hexagonal core with typed session messages at the client and daemon boundary. Binary framing and carriage selection stay outside the use cases.
4+
5+## Package ownership
6+
7+- `internal/domain`: pure shared values and invariants. `domain/terminalcap` owns terminal capability values and environment detection policy.
8+- `internal/protocol`: typed, transport-neutral client/daemon messages, protocol version, semantic validation, and handshake policy.
9+- `internal/protocol/catalogue`: independently versioned remote discovery JSON schema and bounded validation.
10+- `internal/protocol/wire`: message IDs, raw frames, strict codecs, compression, encoded limits, and raw `Transport`, `Dialer`, and `Listener` contracts.
11+- `internal/ports`: application-facing interfaces and the values required by those interfaces. It contains no codecs, raw frames, environment policy, or worker implementations.
12+- `internal/usecase`: client, daemon, and supporting application behavior. Production use cases may consume semantic protocol packages but never `protocol/wire` or concrete adapters.
13+- `internal/adapters/sessionwire`: translates between typed session connections and raw wire transports, including direction checks and decode-failure classification.
14+- `internal/adapters`: IPC, UDP, SSH stdio, PTY, terminal, persistence-facing, and observability implementations.
15+- `internal/app`: CLI parsing and composition. It selects local or remote carriage, wraps raw dialers with `sessionwire`, and injects typed ports into use cases.
16+- `pkg`: reusable packages that never import `internal`.
17+
18+## Dependency direction
19+
20+```text
21+main → app → usecase → ports
22+ │ │
23+ └────────→ protocol ← catalogue
24+ ↑
25+ protocol/wire
26+ ↑
27+ carriage adapters
28+```
29+
30+`internal/app` may import every layer to compose the process. Adapters depend inward on ports, protocol, and domain. The existing `adapters/snapshot → usecase/snapshot` dependency is an explicit exception until the snapshot codec has its own owner. Production dependency rules and the separate test-import policy are executable in `boundary_test.go`.
31+
32+## Session composition
33+
34+```text
35+client or daemon use case
36+ ↕ ports.ClientConnection / ports.ServerConnection
37+adapters/sessionwire
38+ ↕ protocol/wire.Transport carrying wire.Frame
39+IPC, UDP, or SSH stdio adapter
40+```
41+
42+Use cases exchange `protocol.ClientMessage` and `protocol.ServerMessage` values. `sessionwire` alone maps those values to message IDs and payload codecs. Blind proxies may forward raw frames, and the UDP adapter may inspect bounded frame classification for QoS, but neither path exposes bytes to a use case.
43+
44+## Adding code
45+
46+- Add application behavior to a use case and define any cross-layer interface in `internal/ports`.
47+- Add transport-neutral session meaning to `internal/protocol`.
48+- Add remote catalogue schema fields and validation to `internal/protocol/catalogue`.
49+- Add message IDs, binary layouts, strict decoding, compression, or raw carriage contracts to `internal/protocol/wire`.
50+- Implement I/O, queues, workers, environment integration, or technology selection in an adapter or `internal/app`.
51+- Keep protocol version `37` unless a negotiated wire layout changes.
M internal/adapters/dgram/proxy.go +5 -5
@@ -17,8 +17,8 @@ const (
1717 )
1818
1919 type ProxyRuntime struct {
20- Client ports.Transport
21- Daemon ports.Transport
20+ Client wire.Transport
21+ Daemon wire.Transport
2222 Log *slog.Logger
2323 IdleTTL time.Duration
2424 RetryBackoff time.Duration
@@ -47,8 +47,8 @@ type proxyCopier struct {
4747 }
4848
4949 type proxyCopyDirection struct {
50- src ports.Transport
51- dst ports.Transport
50+ src wire.Transport
51+ dst wire.Transport
5252 recvKind proxyCopyErrKind
5353 sendKind proxyCopyErrKind
5454 transform func(wire.Frame) wire.Frame
@@ -191,7 +191,7 @@ func (c proxyCopier) copyFrames(dir proxyCopyDirection) {
191191 }
192192 }
193193
194-func recoverableClientLink(t ports.Transport, err error) bool {
194+func recoverableClientLink(t wire.Transport, err error) bool {
195195 if errors.Is(err, ErrLinkDead) {
196196 return false
197197 }
M internal/adapters/dgram/ssh_dialer.go +2 -1
@@ -15,6 +15,7 @@ import (
1515
1616 "github.com/bnema/vev/internal/adapters/sshstdio"
1717 "github.com/bnema/vev/internal/ports"
18+ "github.com/bnema/vev/internal/protocol/wire"
1819 pdgram "github.com/bnema/vev/pkg/dgram"
1920 )
2021
@@ -112,7 +113,7 @@ func NewRemoteDialerWithLogger(target, _ string, log *slog.Logger) RemoteDialer
112113 return RemoteDialer{Target: target, ProbeTimeout: defaultProbeTimeout, Log: log}
113114 }
114115
115-func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) {
116+func (d RemoteDialer) Dial(ctx context.Context) (wire.Transport, error) {
116117 if err := ctx.Err(); err != nil {
117118 return nil, err
118119 }
M internal/adapters/dgram/transport.go +2 -2
@@ -1,4 +1,4 @@
1-// Package dgram adapts authenticated UDP-style packet connections to ports.Transport.
1+// Package dgram adapts authenticated UDP-style packet connections to wire.Transport.
22 package dgram
33
44 import (
@@ -136,7 +136,7 @@ func normalizeOptions(opts Options) Options {
136136 return opts
137137 }
138138
139-var _ ports.DatagramTransport = (*Transport)(nil)
139+var _ wire.DatagramTransport = (*Transport)(nil)
140140
141141 type Transport struct {
142142 pc net.PacketConn
M internal/adapters/dgram/transport_observability_test.go +2 -2
@@ -28,7 +28,7 @@ func TestTransportObservabilityDgramFailedCloseEndsReceiveAndSend(t *testing.T)
2828 }
2929 receiveStarted := make(chan struct{})
3030 signalingObserver := &dgramStartObserver{RuntimeObserver: observer, receiveStarted: receiveStarted}
31- reporter := ports.NewSerializedRuntimeObserver(signalingObserver, 64)
31+ reporter := observability.NewSerialized(signalingObserver, 64)
3232 defer reporter.Close()
3333 transport, err := NewTransportWithOptions(&dgramCloseFailPC{fakePC: aPC, err: closeErr}, testAddr("b"), key(), 1, 2, Options{Clock: behaviorClock, ResendAfter: time.Hour, Heartbeat: time.Hour}, WithRuntimeObserver(reporter))
3434 if err != nil {
@@ -142,7 +142,7 @@ func TestTransportObservabilityDgramKeepsBehaviorClockSeparate(t *testing.T) {
142142 if err != nil {
143143 t.Fatalf("NewJSONL() error = %v", err)
144144 }
145- reporter := ports.NewSerializedRuntimeObserver(observer, 64)
145+ reporter := observability.NewSerialized(observer, 64)
146146 defer reporter.Close()
147147
148148 a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: behaviorClock, ResendAfter: time.Hour, Heartbeat: time.Hour}, WithRuntimeObserver(reporter))
M internal/adapters/ipc/dial.go +2 -2
@@ -6,14 +6,14 @@ import (
66 "net"
77 "path/filepath"
88
9- "github.com/bnema/vev/internal/ports"
9+ "github.com/bnema/vev/internal/protocol/wire"
1010 )
1111
1212 // DialContext connects to the daemon's socket in dir (dir/daemon.sock) and
1313 // returns a Transport speaking vev's framed protocol. A dial failure (no daemon
1414 // listening, or a stale/absent socket) is reported as a wrapped error so
1515 // callers can decide whether to spawn a daemon and retry.
16-func DialContext(ctx context.Context, dir string, opts ...Option) (ports.Transport, error) {
16+func DialContext(ctx context.Context, dir string, opts ...Option) (wire.Transport, error) {
1717 sockPath := filepath.Join(dir, socketFileName)
1818 var dialer net.Dialer
1919 conn, err := dialer.DialContext(ctx, "unix", sockPath)
M internal/adapters/ipc/dial_test.go +2 -1
@@ -6,6 +6,7 @@ import (
66 "testing"
77 "time"
88
9+ "github.com/bnema/vev/internal/adapters/observability"
910 "github.com/bnema/vev/internal/ports"
1011 )
1112
@@ -61,7 +62,7 @@ func TestDialContextPreservesOptions(t *testing.T) {
6162 }
6263 defer func() { _ = listener.Close() }()
6364
64- observer := ports.NewSerializedRuntimeObserver(dialTestObserver{}, 1)
65+ observer := observability.NewSerialized(dialTestObserver{}, 1)
6566 defer observer.Close()
6667 transport, err := DialContext(context.Background(), dir, WithRuntimeObserver(observer))
6768 if err != nil {
M internal/adapters/ipc/listener.go +5 -5
@@ -8,7 +8,7 @@ import (
88 "path/filepath"
99 "syscall"
1010
11- "github.com/bnema/vev/internal/ports"
11+ "github.com/bnema/vev/internal/protocol/wire"
1212 "github.com/bnema/vev/pkg/safedir"
1313 )
1414
@@ -20,7 +20,7 @@ const socketFileName = "daemon.sock"
2020 // bound by a live daemon (a dial-probe against it succeeded).
2121 var ErrDaemonRunning = errors.New("ipc: a daemon is already listening on this socket")
2222
23-// unixListener implements ports.Listener over an AF_UNIX SOCK_STREAM
23+// unixListener implements wire.Listener over an AF_UNIX SOCK_STREAM
2424 // listener.
2525 type unixListener struct {
2626 ln *net.UnixListener
@@ -36,7 +36,7 @@ type unixListener struct {
3636 // the previous owner died without cleaning up, so the stale socket file is
3737 // unlinked and bind is retried once. A successful dial means a live daemon
3838 // owns the socket, and Listen returns ErrDaemonRunning.
39-func Listen(dir string, opts ...Option) (ports.Listener, error) {
39+func Listen(dir string, opts ...Option) (wire.Listener, error) {
4040 if err := safedir.EnsurePrivate(dir); err != nil {
4141 return nil, fmt.Errorf("ipc: securing socket directory: %w", err)
4242 }
@@ -99,8 +99,8 @@ func probeLiveDaemon(sockPath string) error {
9999 }
100100
101101 // Accept waits for and returns the next connection, wrapped as a
102-// ports.Transport.
103-func (l *unixListener) Accept() (ports.Transport, error) {
102+// wire.Transport.
103+func (l *unixListener) Accept() (wire.Transport, error) {
104104 conn, err := l.ln.Accept()
105105 if err != nil {
106106 return nil, err
M internal/adapters/ipc/transport.go +3 -3
@@ -35,7 +35,7 @@ var errClosed = errors.New("ipc: closed")
3535 // peer into an unbounded memory commitment.
3636 const sendQueueCapacity = 8
3737
38-// unixTransport implements ports.Transport over a net.Conn (in practice an
38+// unixTransport implements wire.Transport over a net.Conn (in practice an
3939 // AF_UNIX SOCK_STREAM connection, but any net.Conn works — this also lets
4040 // tests exercise it over net.Pipe).
4141 //
@@ -79,9 +79,9 @@ type unixTransport struct {
7979 readBuf []byte
8080 }
8181
82-// NewTransport wraps conn as a ports.Transport speaking vev's framed
82+// NewTransport wraps conn as a wire.Transport speaking vev's framed
8383 // binary protocol.
84-func NewTransport(conn net.Conn, opts ...Option) ports.Transport {
84+func NewTransport(conn net.Conn, opts ...Option) wire.Transport {
8585 t := &unixTransport{
8686 conn: conn,
8787 egress: make(chan sendRequest, sendQueueCapacity),
M internal/adapters/ipc/transport_observability_test.go +2 -2
@@ -21,7 +21,7 @@ func TestTransportObservabilityIPCEOFAndCloseEndFailedSpans(t *testing.T) {
2121 if err != nil {
2222 t.Fatalf("NewJSONL() error = %v", err)
2323 }
24- reporter := ports.NewSerializedRuntimeObserver(observer, 64)
24+ reporter := observability.NewSerialized(observer, 64)
2525 defer reporter.Close()
2626
2727 // EOF from the peer and a locally closed blocked receive are distinct
@@ -145,7 +145,7 @@ func TestTransportObservabilityIPCMarksCarriageWithoutChangingBytes(t *testing.T
145145 if err != nil {
146146 t.Fatalf("NewJSONL() error = %v", err)
147147 }
148- reporter := ports.NewSerializedRuntimeObserver(observer, 64)
148+ reporter := observability.NewSerialized(observer, 64)
149149 defer reporter.Close()
150150
151151 left, right := net.Pipe()
M internal/adapters/ipc/transport_test.go +6 -7
@@ -12,7 +12,6 @@ import (
1212 "time"
1313
1414 "github.com/bnema/vev/internal/domain"
15- "github.com/bnema/vev/internal/ports"
1615 "github.com/bnema/vev/internal/protocol"
1716 "github.com/bnema/vev/internal/protocol/wire"
1817 )
@@ -253,7 +252,7 @@ func TestTransportAsyncEgressPreservesWelcomeBeforeOutput(t *testing.T) {
253252 defer closeTransport(t, server)
254253 client := NewTransport(clientConn)
255254 defer closeTransport(t, client)
256- async, ok := server.(ports.AsyncTransport)
255+ async, ok := server.(wire.AsyncTransport)
257256 if !ok {
258257 t.Fatal("IPC transport does not implement AsyncTransport")
259258 }
@@ -317,7 +316,7 @@ func TestTransportSendWaitsForEgressCapacity(t *testing.T) {
317316 }
318317 client := NewTransport(clientConn)
319318 defer closeTransport(t, client)
320- async, ok := server.(ports.AsyncTransport)
319+ async, ok := server.(wire.AsyncTransport)
321320 if !ok {
322321 t.Fatal("IPC transport does not implement AsyncTransport")
323322 }
@@ -365,7 +364,7 @@ func TestTransportCloseInterruptsSendWaitingForEgressCapacity(t *testing.T) {
365364 if !ok {
366365 t.Fatal("IPC transport is not a unixTransport")
367366 }
368- async, ok := server.(ports.AsyncTransport)
367+ async, ok := server.(wire.AsyncTransport)
369368 if !ok {
370369 t.Fatal("IPC transport does not implement AsyncTransport")
371370 }
@@ -407,7 +406,7 @@ func waitForEgressSender(t *testing.T, transport *unixTransport, timeout time.Du
407406 }
408407 }
409408
410-func closeTransport(t *testing.T, transport ports.Transport) {
409+func closeTransport(t *testing.T, transport wire.Transport) {
411410 t.Helper()
412411
413412 closed := make(chan error, 1)
@@ -422,7 +421,7 @@ func closeTransport(t *testing.T, transport ports.Transport) {
422421 }
423422 }
424423
425-func fillAsyncEgress(t *testing.T, transport *unixTransport, async ports.AsyncTransport, timeout time.Duration) []wire.Frame {
424+func fillAsyncEgress(t *testing.T, transport *unixTransport, async wire.AsyncTransport, timeout time.Duration) []wire.Frame {
426425 t.Helper()
427426
428427 first := wire.Frame{Type: wire.MsgOutput, Payload: []byte{0}}
@@ -474,7 +473,7 @@ func TestTransportAsyncEgressIsBoundedAndCloseInterruptsWorkers(t *testing.T) {
474473 if !ok {
475474 t.Fatal("IPC transport is not a unixTransport")
476475 }
477- async, ok := tr.(ports.AsyncTransport)
476+ async, ok := tr.(wire.AsyncTransport)
478477 if !ok {
479478 t.Fatal("IPC transport does not implement AsyncTransport")
480479 }
A internal/adapters/observability/serialized.go +136 -0
@@ -0,0 +1,136 @@
1+package observability
2+
3+import (
4+ "sync"
5+
6+ "github.com/bnema/vev/internal/ports"
7+)
8+
9+// NewSerialized isolates producers from a potentially slow observer with one
10+// ordered worker and a bounded queue.
11+func NewSerialized(observer ports.RuntimeObserver, capacity int) ports.SerializedRuntimeObserver {
12+ if observer == nil {
13+ return nil
14+ }
15+ if capacity < 1 {
16+ capacity = 1
17+ }
18+ o := &serializedRuntimeObserver{observer: observer, capacity: capacity, done: make(chan struct{})}
19+ o.cond = sync.NewCond(&o.mu)
20+ go o.run()
21+ return o
22+}
23+
24+// EnsureSerialized reuses an existing serialized owner or creates one.
25+func EnsureSerialized(observer ports.RuntimeObserver, capacity int) (serialized ports.SerializedRuntimeObserver, owned bool) {
26+ if observer == nil {
27+ return nil, false
28+ }
29+ if serialized, ok := observer.(ports.SerializedRuntimeObserver); ok {
30+ return serialized, false
31+ }
32+ return NewSerialized(observer, capacity), true
33+}
34+
35+type serializedRuntimeObserver struct {
36+ observer ports.RuntimeObserver
37+ capacity int
38+
39+ mu sync.Mutex
40+ cond *sync.Cond
41+ marks []ports.RuntimeMark
42+ dropped uint64
43+ closed bool
44+ active bool
45+ done chan struct{}
46+}
47+
48+func (o *serializedRuntimeObserver) ObserveRuntime(mark ports.RuntimeMark) {
49+ if o == nil {
50+ return
51+ }
52+ o.mu.Lock()
53+ defer o.mu.Unlock()
54+ if o.closed {
55+ return
56+ }
57+ if o.dropped != 0 {
58+ if len(o.marks)+2 > o.capacity {
59+ o.dropped++
60+ return
61+ }
62+ o.marks = append(o.marks, gapMark(o.dropped), mark)
63+ o.dropped = 0
64+ o.cond.Signal()
65+ return
66+ }
67+ if len(o.marks) == o.capacity {
68+ o.dropped = 1
69+ return
70+ }
71+ o.marks = append(o.marks, mark)
72+ o.cond.Signal()
73+}
74+
75+func gapMark(dropped uint64) ports.RuntimeMark {
76+ return ports.NewRuntimeMark("observer", ports.RuntimeTransportDiagnostic, dropped, false)
77+}
78+
79+func (o *serializedRuntimeObserver) Flush() {
80+ if o == nil {
81+ return
82+ }
83+ o.mu.Lock()
84+ for len(o.marks) != 0 || o.active || o.dropped != 0 {
85+ o.cond.Signal()
86+ o.cond.Wait()
87+ }
88+ closed := o.closed
89+ o.mu.Unlock()
90+ if closed {
91+ <-o.done
92+ }
93+}
94+
95+func (o *serializedRuntimeObserver) Close() {
96+ if o == nil {
97+ return
98+ }
99+ o.mu.Lock()
100+ if !o.closed {
101+ o.closed = true
102+ o.cond.Broadcast()
103+ }
104+ o.mu.Unlock()
105+ <-o.done
106+}
107+
108+func (o *serializedRuntimeObserver) run() {
109+ defer close(o.done)
110+ for {
111+ o.mu.Lock()
112+ for len(o.marks) == 0 && o.dropped == 0 && !o.closed {
113+ o.cond.Wait()
114+ }
115+ if len(o.marks) == 0 && o.dropped != 0 {
116+ o.marks = append(o.marks, gapMark(o.dropped))
117+ o.dropped = 0
118+ }
119+ if len(o.marks) == 0 && o.closed {
120+ o.cond.Broadcast()
121+ o.mu.Unlock()
122+ return
123+ }
124+ mark := o.marks[0]
125+ o.marks = o.marks[1:]
126+ o.active = true
127+ o.mu.Unlock()
128+
129+ o.observer.ObserveRuntime(mark)
130+
131+ o.mu.Lock()
132+ o.active = false
133+ o.cond.Broadcast()
134+ o.mu.Unlock()
135+ }
136+}
A internal/adapters/observability/serialized_test.go +77 -0
@@ -0,0 +1,77 @@
1+package observability
2+
3+import (
4+ "sync"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/vev/internal/ports"
9+)
10+
11+type runtimeObserverFunc func(ports.RuntimeMark)
12+
13+func (f runtimeObserverFunc) ObserveRuntime(mark ports.RuntimeMark) { f(mark) }
14+
15+func TestSerializedFlushesInOrderAndCloses(t *testing.T) {
16+ var mu sync.Mutex
17+ var got []ports.RuntimeMark
18+ observer := NewSerialized(runtimeObserverFunc(func(mark ports.RuntimeMark) {
19+ mu.Lock()
20+ got = append(got, mark)
21+ mu.Unlock()
22+ }), 2)
23+ observer.ObserveRuntime(ports.NewRuntimeMark("client", ports.RuntimeEmitStart, 1, true))
24+ observer.ObserveRuntime(ports.NewRuntimeMark("client", ports.RuntimeEmitEnd, 1, true))
25+ observer.Flush()
26+ observer.Close()
27+ observer.Close()
28+
29+ mu.Lock()
30+ defer mu.Unlock()
31+ if len(got) != 2 || got[0].Kind != ports.RuntimeEmitStart || got[1].Kind != ports.RuntimeEmitEnd {
32+ t.Fatalf("serialized marks = %#v", got)
33+ }
34+}
35+
36+func TestEnsureSerializedReusesExistingOwner(t *testing.T) {
37+ existing := NewSerialized(runtimeObserverFunc(func(ports.RuntimeMark) {}), 1)
38+ reporter, owned := EnsureSerialized(existing, 1)
39+ if owned || reporter != existing {
40+ t.Fatalf("EnsureSerialized() = (%T, %t), want existing unowned reporter", reporter, owned)
41+ }
42+ existing.Close()
43+}
44+
45+func TestSerializedReportsBoundedQueueLoss(t *testing.T) {
46+ entered := make(chan struct{})
47+ release := make(chan struct{})
48+ var mu sync.Mutex
49+ var got []ports.RuntimeMark
50+ observer := NewSerialized(runtimeObserverFunc(func(mark ports.RuntimeMark) {
51+ if mark.Kind == ports.RuntimeEmitStart {
52+ close(entered)
53+ <-release
54+ }
55+ mu.Lock()
56+ got = append(got, mark)
57+ mu.Unlock()
58+ }), 2)
59+ observer.ObserveRuntime(ports.NewRuntimeMark("client", ports.RuntimeEmitStart, 1, true))
60+ select {
61+ case <-entered:
62+ case <-time.After(time.Second):
63+ t.Fatal("observer did not start")
64+ }
65+ observer.ObserveRuntime(ports.NewRuntimeMark("client", ports.RuntimeComposeStart, 1, true))
66+ observer.ObserveRuntime(ports.NewRuntimeMark("client", ports.RuntimeComposeEnd, 1, true))
67+ observer.ObserveRuntime(ports.NewRuntimeMark("client", ports.RuntimeEmitEnd, 1, true))
68+ close(release)
69+ observer.Flush()
70+ observer.Close()
71+
72+ mu.Lock()
73+ defer mu.Unlock()
74+ if len(got) != 4 || got[0].Kind != ports.RuntimeEmitStart || got[1].Kind != ports.RuntimeComposeStart || got[2].Kind != ports.RuntimeComposeEnd || got[3].Kind != ports.RuntimeTransportDiagnostic || got[3].Bytes != 1 || got[3].Valid {
75+ t.Fatalf("bounded-loss marks = %#v", got)
76+ }
77+}
M internal/adapters/remote/catalog.go +42 -41
@@ -16,8 +16,9 @@ import (
1616
1717 "github.com/bnema/vev/internal/adapters/sshstdio"
1818 "github.com/bnema/vev/internal/domain"
19- "github.com/bnema/vev/internal/ports"
19+ appports "github.com/bnema/vev/internal/ports"
2020 "github.com/bnema/vev/internal/protocol"
21+ "github.com/bnema/vev/internal/protocol/catalogue"
2122 )
2223
2324 const (
@@ -42,10 +43,10 @@ type CatalogClient struct {
4243 timeout time.Duration
4344 }
4445
45-var _ ports.RemoteCatalogClient = (*CatalogClient)(nil)
46+var _ appports.RemoteCatalogClient = (*CatalogClient)(nil)
4647
4748 // NewCatalogClient returns a RemoteCatalogClient that shells out to ssh.
48-func NewCatalogClient() ports.RemoteCatalogClient {
49+func NewCatalogClient() appports.RemoteCatalogClient {
4950 return &CatalogClient{command: exec.CommandContext}
5051 }
5152
@@ -60,13 +61,13 @@ func (c *CatalogClient) listTimeout() time.Duration {
6061 // exactly one versioned catalog envelope from stdout. List always applies a
6162 // bounded command timeout derived from ctx so a caller with no deadline cannot
6263 // hang indefinitely, while still honoring caller cancellation.
63-func (c *CatalogClient) List(ctx context.Context, target string) (ports.RemoteCatalog, error) {
64+func (c *CatalogClient) List(ctx context.Context, target string) (catalogue.RemoteCatalog, error) {
6465 if err := ctx.Err(); err != nil {
65- return ports.RemoteCatalog{}, err
66+ return catalogue.RemoteCatalog{}, err
6667 }
6768 if err := domain.ValidateRemoteHostTarget(target); err != nil {
6869 slog.Debug("remote catalog rejected target", "target", target, "err", err)
69- return ports.RemoteCatalog{}, err
70+ return catalogue.RemoteCatalog{}, err
7071 }
7172
7273 runCtx, cancel := context.WithTimeout(ctx, c.listTimeout())
@@ -85,29 +86,29 @@ func (c *CatalogClient) List(ctx context.Context, target string) (ports.RemoteCa
8586 cmd.WaitDelay = catalogCommandWaitDelay
8687 err := cmd.Run()
8788 if ctxErr := runCtx.Err(); ctxErr != nil {
88- return ports.RemoteCatalog{}, ctxErr
89+ return catalogue.RemoteCatalog{}, ctxErr
8990 }
9091 if err != nil {
9192 if stdout.overflow || stderr.overflow {
9293 slog.Debug("remote catalog output too large", "target", target, "stdout_limit", maxCatalogBytes, "stderr_limit", maxCatalogDiagnosticBytes)
93- return ports.RemoteCatalog{}, errCatalogTooLarge
94+ return catalogue.RemoteCatalog{}, errCatalogTooLarge
9495 }
9596 stderrText := sanitizeCatalogDiagnostic(string(stderr.Bytes()))
9697 slog.Debug("remote catalog ssh failed", "target", target, "err", err, "stderr", stderrText)
9798 if stderrText != "" {
98- return ports.RemoteCatalog{}, fmt.Errorf("%w: %w: %s", errCatalogSSH, err, stderrText)
99+ return catalogue.RemoteCatalog{}, fmt.Errorf("%w: %w: %s", errCatalogSSH, err, stderrText)
99100 }
100- return ports.RemoteCatalog{}, fmt.Errorf("%w: %w", errCatalogSSH, err)
101+ return catalogue.RemoteCatalog{}, fmt.Errorf("%w: %w", errCatalogSSH, err)
101102 }
102103 if stdout.overflow || stderr.overflow {
103104 slog.Debug("remote catalog output too large", "target", target, "stdout_limit", maxCatalogBytes, "stderr_limit", maxCatalogDiagnosticBytes)
104- return ports.RemoteCatalog{}, errCatalogTooLarge
105+ return catalogue.RemoteCatalog{}, errCatalogTooLarge
105106 }
106107
107108 catalog, err := decodeRemoteCatalog(stdout.Bytes())
108109 if err != nil {
109110 slog.Debug("remote catalog decode failed", "target", target, "err", err)
110- return ports.RemoteCatalog{}, err
111+ return catalogue.RemoteCatalog{}, err
111112 }
112113 return catalog, nil
113114 }
@@ -125,15 +126,15 @@ type catalogEnvelope struct {
125126 }
126127
127128 type catalogSessionEnvelope struct {
128- LifecycleID *domain.SessionLifecycleID `json:"lifecycle_id"`
129- Name *string `json:"name"`
130- State *ports.RemoteCatalogSessionState `json:"state"`
131- Ephemeral *bool `json:"ephemeral"`
132- Tabs *[]catalogTabEnvelope `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"`
129+ LifecycleID *domain.SessionLifecycleID `json:"lifecycle_id"`
130+ Name *string `json:"name"`
131+ State *catalogue.RemoteCatalogSessionState `json:"state"`
132+ Ephemeral *bool `json:"ephemeral"`
133+ Tabs *[]catalogTabEnvelope `json:"tabs"`
134+ Attached *bool `json:"attached"`
135+ LastUsedSeq uint64 `json:"last_used_seq,omitempty"`
136+ ActiveTabID string `json:"active_tab_id,omitempty"`
137+ Reason string `json:"reason,omitempty"`
137138 }
138139
139140 type catalogTabEnvelope struct {
@@ -144,71 +145,71 @@ type catalogTabEnvelope struct {
144145 Attention bool `json:"attention,omitempty"`
145146 }
146147
147-func decodeRemoteCatalog(raw []byte) (ports.RemoteCatalog, error) {
148+func decodeRemoteCatalog(raw []byte) (catalogue.RemoteCatalog, error) {
148149 var version catalogVersionEnvelope
149150 if err := decodeCatalogEnvelope(raw, false, &version); err != nil {
150- return ports.RemoteCatalog{}, err
151+ return catalogue.RemoteCatalog{}, err
151152 }
152153 if version.ProtocolVersion == nil {
153- return ports.RemoteCatalog{}, errCatalogRequired
154+ return catalogue.RemoteCatalog{}, errCatalogRequired
154155 }
155156 if version.SchemaVersion == nil {
156- return ports.RemoteCatalog{}, &ports.RemoteCatalogVersionMismatchError{
157+ return catalogue.RemoteCatalog{}, &catalogue.RemoteCatalogVersionMismatchError{
157158 Got: 0,
158- Want: ports.RemoteCatalogSchemaVersion,
159+ Want: catalogue.RemoteCatalogSchemaVersion,
159160 Kind: "catalog",
160161 }
161162 }
162163 if *version.ProtocolVersion != protocol.Version {
163- return ports.RemoteCatalog{}, &ports.RemoteCatalogVersionMismatchError{
164+ return catalogue.RemoteCatalog{}, &catalogue.RemoteCatalogVersionMismatchError{
164165 Got: *version.ProtocolVersion,
165166 Want: protocol.Version,
166167 Kind: "protocol",
167168 }
168169 }
169- if *version.SchemaVersion != ports.RemoteCatalogSchemaVersion {
170- return ports.RemoteCatalog{}, &ports.RemoteCatalogVersionMismatchError{
170+ if *version.SchemaVersion != catalogue.RemoteCatalogSchemaVersion {
171+ return catalogue.RemoteCatalog{}, &catalogue.RemoteCatalogVersionMismatchError{
171172 Got: *version.SchemaVersion,
172- Want: ports.RemoteCatalogSchemaVersion,
173+ Want: catalogue.RemoteCatalogSchemaVersion,
173174 Kind: "catalog",
174175 }
175176 }
176177
177178 var envelope catalogEnvelope
178179 if err := decodeCatalogEnvelope(raw, true, &envelope); err != nil {
179- return ports.RemoteCatalog{}, err
180+ return catalogue.RemoteCatalog{}, err
180181 }
181182 if envelope.ProtocolVersion == nil || envelope.SchemaVersion == nil || envelope.Sessions == nil {
182- return ports.RemoteCatalog{}, errCatalogRequired
183+ return catalogue.RemoteCatalog{}, errCatalogRequired
183184 }
184- sessions := make([]ports.RemoteCatalogSession, 0, len(*envelope.Sessions))
185+ sessions := make([]catalogue.RemoteCatalogSession, 0, len(*envelope.Sessions))
185186 for _, session := range *envelope.Sessions {
186187 if session.LifecycleID == nil || session.Name == nil || session.State == nil || session.Ephemeral == nil || session.Tabs == nil || session.Attached == nil {
187- return ports.RemoteCatalog{}, errCatalogRequired
188+ return catalogue.RemoteCatalog{}, errCatalogRequired
188189 }
189- tabs := make([]ports.RemoteCatalogTab, 0, len(*session.Tabs))
190+ tabs := make([]catalogue.RemoteCatalogTab, 0, len(*session.Tabs))
190191 for _, tab := range *session.Tabs {
191192 if tab.ID == nil || tab.Index == nil || tab.Name == nil {
192- return ports.RemoteCatalog{}, errCatalogRequired
193+ return catalogue.RemoteCatalog{}, errCatalogRequired
193194 }
194- tabs = append(tabs, ports.RemoteCatalogTab{
195+ tabs = append(tabs, catalogue.RemoteCatalogTab{
195196 ID: *tab.ID, Index: *tab.Index, Name: *tab.Name,
196197 Detail: tab.Detail, Attention: tab.Attention,
197198 })
198199 }
199- sessions = append(sessions, ports.RemoteCatalogSession{
200+ sessions = append(sessions, catalogue.RemoteCatalogSession{
200201 LifecycleID: *session.LifecycleID, Name: *session.Name, State: *session.State,
201202 Ephemeral: *session.Ephemeral, Tabs: tabs, Attached: *session.Attached,
202203 LastUsedSeq: session.LastUsedSeq, ActiveTabID: session.ActiveTabID, Reason: session.Reason,
203204 })
204205 }
205- catalog := ports.RemoteCatalog{
206+ catalog := catalogue.RemoteCatalog{
206207 ProtocolVersion: *envelope.ProtocolVersion,
207208 SchemaVersion: *envelope.SchemaVersion,
208209 Sessions: sessions,
209210 }
210- if err := ports.ValidateRemoteCatalog(catalog); err != nil {
211- return ports.RemoteCatalog{}, err
211+ if err := catalogue.ValidateRemoteCatalog(catalog); err != nil {
212+ return catalogue.RemoteCatalog{}, err
212213 }
213214 return catalog, nil
214215 }
M internal/adapters/remote/catalog_cache.go +37 -36
@@ -12,7 +12,8 @@ import (
1212 "unicode/utf8"
1313
1414 "github.com/bnema/vev/internal/domain"
15- "github.com/bnema/vev/internal/ports"
15+ appports "github.com/bnema/vev/internal/ports"
16+ "github.com/bnema/vev/internal/protocol/catalogue"
1617 )
1718
1819 const (
@@ -40,14 +41,14 @@ type catalogCacheHost struct {
4041 }
4142
4243 type catalogCacheSession struct {
43- LifecycleID *domain.SessionLifecycleID `json:"lifecycle_id"`
44- Name *string `json:"name"`
45- State *ports.RemoteCatalogSessionState `json:"state"`
46- Ephemeral *bool `json:"ephemeral"`
47- LastUsedSeq *uint64 `json:"last_used_seq,omitempty"`
48- Tabs *[]ports.RemoteCatalogTab `json:"tabs"`
49- ActiveTabID *string `json:"active_tab_id,omitempty"`
50- Attached *bool `json:"attached"`
44+ LifecycleID *domain.SessionLifecycleID `json:"lifecycle_id"`
45+ Name *string `json:"name"`
46+ State *catalogue.RemoteCatalogSessionState `json:"state"`
47+ Ephemeral *bool `json:"ephemeral"`
48+ LastUsedSeq *uint64 `json:"last_used_seq,omitempty"`
49+ Tabs *[]catalogue.RemoteCatalogTab `json:"tabs"`
50+ ActiveTabID *string `json:"active_tab_id,omitempty"`
51+ Attached *bool `json:"attached"`
5152 }
5253
5354 type catalogCacheFileV2 struct {
@@ -62,15 +63,15 @@ type catalogCacheHostV2 struct {
6263 }
6364
6465 type catalogCacheSessionV2 struct {
65- LifecycleID *domain.SessionLifecycleID `json:"lifecycle_id,omitempty"`
66- Name *string `json:"name"`
67- State *ports.RemoteCatalogSessionState `json:"state"`
68- Ephemeral *bool `json:"ephemeral"`
69- LastUsedSeq *uint64 `json:"last_used_seq,omitempty"`
70- Tabs *uint16 `json:"tabs,omitempty"`
71- TabList *[]ports.RemoteCatalogTab `json:"tab_list,omitempty"`
72- ActiveTabID *string `json:"active_tab_id,omitempty"`
73- Attached *bool `json:"attached"`
66+ LifecycleID *domain.SessionLifecycleID `json:"lifecycle_id,omitempty"`
67+ Name *string `json:"name"`
68+ State *catalogue.RemoteCatalogSessionState `json:"state"`
69+ Ephemeral *bool `json:"ephemeral"`
70+ LastUsedSeq *uint64 `json:"last_used_seq,omitempty"`
71+ Tabs *uint16 `json:"tabs,omitempty"`
72+ TabList *[]catalogue.RemoteCatalogTab `json:"tab_list,omitempty"`
73+ ActiveTabID *string `json:"active_tab_id,omitempty"`
74+ Attached *bool `json:"attached"`
7475 }
7576
7677 type fileCatalogCache struct {
@@ -78,19 +79,19 @@ type fileCatalogCache struct {
7879 }
7980
8081 // NewFileCatalogCache returns a durable remote catalog cache backed by path.
81-func NewFileCatalogCache(path string) ports.RemoteCatalogCache {
82+func NewFileCatalogCache(path string) appports.RemoteCatalogCache {
8283 return &fileCatalogCache{path: path}
8384 }
8485
85-var _ ports.RemoteCatalogCache = (*fileCatalogCache)(nil)
86+var _ appports.RemoteCatalogCache = (*fileCatalogCache)(nil)
8687
8788 // Load reads and validates one complete cache snapshot. Exact v2 tab-list
8889 // snapshots are upgraded in memory; ambiguous count-only v2 data is rejected.
8990 // A missing cache is an empty snapshot, and Load never changes cache data.
90-func (c *fileCatalogCache) Load() ([]ports.RemoteCatalogCacheEntry, error) {
91+func (c *fileCatalogCache) Load() ([]catalogue.RemoteCatalogCacheEntry, error) {
9192 raw, err := os.ReadFile(c.path)
9293 if errors.Is(err, os.ErrNotExist) {
93- return []ports.RemoteCatalogCacheEntry{}, nil
94+ return []catalogue.RemoteCatalogCacheEntry{}, nil
9495 }
9596 if err != nil {
9697 return nil, err
@@ -130,7 +131,7 @@ func (c *fileCatalogCache) Load() ([]ports.RemoteCatalogCacheEntry, error) {
130131 return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing hosts")
131132 }
132133
133- entries := make([]ports.RemoteCatalogCacheEntry, 0, len(*file.Hosts))
134+ entries := make([]catalogue.RemoteCatalogCacheEntry, 0, len(*file.Hosts))
134135 for _, host := range *file.Hosts {
135136 if host.Target == nil || host.FetchedAtUnixNano == nil || host.Sessions == nil {
136137 return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing host fields")
@@ -141,10 +142,10 @@ func (c *fileCatalogCache) Load() ([]ports.RemoteCatalogCacheEntry, error) {
141142 if *host.FetchedAtUnixNano <= 0 {
142143 return nil, fmt.Errorf("remote catalog cache: malformed cache file: non-positive fetched time")
143144 }
144- entry := ports.RemoteCatalogCacheEntry{
145+ entry := catalogue.RemoteCatalogCacheEntry{
145146 Host: *host.Target,
146147 FetchedAt: time.Unix(0, *host.FetchedAtUnixNano),
147- Sessions: make([]ports.RemoteCatalogSession, 0, len(*host.Sessions)),
148+ Sessions: make([]catalogue.RemoteCatalogSession, 0, len(*host.Sessions)),
148149 }
149150 for _, session := range *host.Sessions {
150151 if session.LifecycleID == nil || session.Name == nil || session.State == nil || session.Ephemeral == nil || session.Attached == nil || session.Tabs == nil {
@@ -153,9 +154,9 @@ func (c *fileCatalogCache) Load() ([]ports.RemoteCatalogCacheEntry, error) {
153154 if !utf8.ValidString(*session.Name) || !utf8.ValidString(string(*session.State)) {
154155 return nil, fmt.Errorf("remote catalog cache: malformed cache file: invalid UTF-8")
155156 }
156- tabs := make([]ports.RemoteCatalogTab, len(*session.Tabs))
157+ tabs := make([]catalogue.RemoteCatalogTab, len(*session.Tabs))
157158 copy(tabs, *session.Tabs)
158- decoded := ports.RemoteCatalogSession{
159+ decoded := catalogue.RemoteCatalogSession{
159160 LifecycleID: *session.LifecycleID,
160161 Name: *session.Name,
161162 State: *session.State,
@@ -228,7 +229,7 @@ func migrateExactCatalogCacheV2(legacy catalogCacheFileV2) (catalogCacheFile, er
228229 }
229230
230231 // Store atomically replaces the cache with a complete normalized snapshot.
231-func (c *fileCatalogCache) Store(entries []ports.RemoteCatalogCacheEntry) error {
232+func (c *fileCatalogCache) Store(entries []catalogue.RemoteCatalogCacheEntry) error {
232233 normalized, err := normalizeCatalogCacheEntries(entries)
233234 if err != nil {
234235 return err
@@ -244,7 +245,7 @@ func (c *fileCatalogCache) Store(entries []ports.RemoteCatalogCacheEntry) error
244245 ephemeral := session.Ephemeral
245246 attached := session.Attached
246247 id := session.LifecycleID
247- tabs := make([]ports.RemoteCatalogTab, len(session.Tabs))
248+ tabs := make([]catalogue.RemoteCatalogTab, len(session.Tabs))
248249 copy(tabs, session.Tabs)
249250 row := catalogCacheSession{LifecycleID: &id, Name: &name, State: &state, Ephemeral: &ephemeral, Tabs: &tabs, Attached: &attached}
250251 if session.LastUsedSeq != 0 {
@@ -271,11 +272,11 @@ func (c *fileCatalogCache) Store(entries []ports.RemoteCatalogCacheEntry) error
271272 return atomicReplacePrivateFile(c.path, append(payload, '\n'))
272273 }
273274
274-func normalizeCatalogCacheEntries(entries []ports.RemoteCatalogCacheEntry) ([]ports.RemoteCatalogCacheEntry, error) {
275- if err := ports.ValidateRemoteCatalogCacheEntries(entries); err != nil {
275+func normalizeCatalogCacheEntries(entries []catalogue.RemoteCatalogCacheEntry) ([]catalogue.RemoteCatalogCacheEntry, error) {
276+ if err := catalogue.ValidateRemoteCatalogCacheEntries(entries); err != nil {
276277 return nil, err
277278 }
278- normalized := make([]ports.RemoteCatalogCacheEntry, 0, len(entries))
279+ normalized := make([]catalogue.RemoteCatalogCacheEntry, 0, len(entries))
279280 hosts := make(map[string]struct{}, len(entries))
280281 for _, entry := range entries {
281282 if err := validateHostTarget(entry.Host); err != nil {
@@ -289,10 +290,10 @@ func normalizeCatalogCacheEntries(entries []ports.RemoteCatalogCacheEntry) ([]po
289290 }
290291 hosts[entry.Host] = struct{}{}
291292
292- copyEntry := ports.RemoteCatalogCacheEntry{
293+ copyEntry := catalogue.RemoteCatalogCacheEntry{
293294 Host: entry.Host,
294295 FetchedAt: entry.FetchedAt,
295- Sessions: make([]ports.RemoteCatalogSession, 0, len(entry.Sessions)),
296+ Sessions: make([]catalogue.RemoteCatalogSession, 0, len(entry.Sessions)),
296297 }
297298 sessions := make(map[string]struct{}, len(entry.Sessions))
298299 for _, session := range entry.Sessions {
@@ -310,9 +311,9 @@ func normalizeCatalogCacheEntries(entries []ports.RemoteCatalogCacheEntry) ([]po
310311 }
311312 sessions[session.Name] = struct{}{}
312313 copySession := session
313- copySession.Tabs = make([]ports.RemoteCatalogTab, len(session.Tabs))
314+ copySession.Tabs = make([]catalogue.RemoteCatalogTab, len(session.Tabs))
314315 for i, tab := range session.Tabs {
315- copySession.Tabs[i] = ports.RemoteCatalogTab{ID: tab.ID, Index: tab.Index, Name: tab.Name}
316+ copySession.Tabs[i] = catalogue.RemoteCatalogTab{ID: tab.ID, Index: tab.Index, Name: tab.Name}
316317 }
317318 copyEntry.Sessions = append(copyEntry.Sessions, copySession)
318319 }
M internal/adapters/remote/catalog_cache_test.go +25 -25
@@ -9,7 +9,7 @@ import (
99 "testing"
1010 "time"
1111
12- "github.com/bnema/vev/internal/ports"
12+ "github.com/bnema/vev/internal/protocol/catalogue"
1313 "github.com/bnema/vev/pkg/safedir"
1414 )
1515
@@ -27,22 +27,22 @@ func TestCatalogCacheStoreLoad(t *testing.T) {
2727 path := filepath.Join(t.TempDir(), "vev", "remote-catalog-cache.json")
2828 cache := NewFileCatalogCache(path)
2929 fetchedAt := time.Unix(0, 1780000000000000000)
30- entries := []ports.RemoteCatalogCacheEntry{
30+ entries := []catalogue.RemoteCatalogCacheEntry{
3131 {
3232 Host: "zebra",
3333 FetchedAt: fetchedAt,
34- Sessions: []ports.RemoteCatalogSession{
34+ Sessions: []catalogue.RemoteCatalogSession{
3535 {
3636 LifecycleID: [16]byte{1}, Name: "work", State: "up", LastUsedSeq: 42, ActiveTabID: "work-2",
37- Tabs: []ports.RemoteCatalogTab{{ID: "work-1", Detail: "dynamic", Attention: true}, {ID: "work-2", Index: 1}},
37+ Tabs: []catalogue.RemoteCatalogTab{{ID: "work-1", Detail: "dynamic", Attention: true}, {ID: "work-2", Index: 1}},
3838 },
39- {LifecycleID: [16]byte{2}, Name: "alpha", State: "down", Ephemeral: true, Tabs: []ports.RemoteCatalogTab{{ID: "alpha-1"}}, Attached: true},
39+ {LifecycleID: [16]byte{2}, Name: "alpha", State: "down", Ephemeral: true, Tabs: []catalogue.RemoteCatalogTab{{ID: "alpha-1"}}, Attached: true},
4040 },
4141 },
4242 {
4343 Host: "arch",
4444 FetchedAt: fetchedAt.Add(time.Second),
45- Sessions: []ports.RemoteCatalogSession{},
45+ Sessions: []catalogue.RemoteCatalogSession{},
4646 },
4747 }
4848
@@ -73,18 +73,18 @@ func TestCatalogCacheStoreLoad(t *testing.T) {
7373 if err != nil {
7474 t.Fatalf("Load() error = %v", err)
7575 }
76- want := []ports.RemoteCatalogCacheEntry{
76+ want := []catalogue.RemoteCatalogCacheEntry{
7777 {
7878 Host: "arch",
7979 FetchedAt: fetchedAt.Add(time.Second),
80- Sessions: []ports.RemoteCatalogSession{},
80+ Sessions: []catalogue.RemoteCatalogSession{},
8181 },
8282 {
8383 Host: "zebra",
8484 FetchedAt: fetchedAt,
85- Sessions: []ports.RemoteCatalogSession{
86- {LifecycleID: [16]byte{2}, Name: "alpha", State: "down", Ephemeral: true, Tabs: []ports.RemoteCatalogTab{{ID: "alpha-1"}}, Attached: true},
87- {LifecycleID: [16]byte{1}, Name: "work", State: "up", LastUsedSeq: 42, ActiveTabID: "work-2", Tabs: []ports.RemoteCatalogTab{{ID: "work-1"}, {ID: "work-2", Index: 1}}},
85+ Sessions: []catalogue.RemoteCatalogSession{
86+ {LifecycleID: [16]byte{2}, Name: "alpha", State: "down", Ephemeral: true, Tabs: []catalogue.RemoteCatalogTab{{ID: "alpha-1"}}, Attached: true},
87+ {LifecycleID: [16]byte{1}, Name: "work", State: "up", LastUsedSeq: 42, ActiveTabID: "work-2", Tabs: []catalogue.RemoteCatalogTab{{ID: "work-1"}, {ID: "work-2", Index: 1}}},
8888 },
8989 },
9090 }
@@ -133,14 +133,14 @@ func TestCatalogCacheLoadMigratesExactV2TabList(t *testing.T) {
133133 if err != nil {
134134 t.Fatalf("Load() error = %v", err)
135135 }
136- want := []ports.RemoteCatalogCacheEntry{{
136+ want := []catalogue.RemoteCatalogCacheEntry{{
137137 Host: "user@arch",
138138 FetchedAt: time.Unix(0, 1780000000000000000),
139- Sessions: []ports.RemoteCatalogSession{
140- {LifecycleID: [16]byte{2}, Name: "dumber", State: ports.RemoteCatalogSessionDown, Tabs: []ports.RemoteCatalogTab{}},
139+ Sessions: []catalogue.RemoteCatalogSession{
140+ {LifecycleID: [16]byte{2}, Name: "dumber", State: catalogue.RemoteCatalogSessionDown, Tabs: []catalogue.RemoteCatalogTab{}},
141141 {
142- LifecycleID: [16]byte{1}, Name: "work", State: ports.RemoteCatalogSessionUp,
143- LastUsedSeq: 42, Tabs: []ports.RemoteCatalogTab{{ID: "t_work", Name: "shell"}}, ActiveTabID: "t_work",
142+ LifecycleID: [16]byte{1}, Name: "work", State: catalogue.RemoteCatalogSessionUp,
143+ LastUsedSeq: 42, Tabs: []catalogue.RemoteCatalogTab{{ID: "t_work", Name: "shell"}}, ActiveTabID: "t_work",
144144 },
145145 },
146146 }}
@@ -208,10 +208,10 @@ func TestCatalogCacheStoreFailurePreservesExistingFile(t *testing.T) {
208208
209209 path := filepath.Join(t.TempDir(), "vev", "remote-catalog-cache.json")
210210 cache := NewFileCatalogCache(path)
211- valid := []ports.RemoteCatalogCacheEntry{{
211+ valid := []catalogue.RemoteCatalogCacheEntry{{
212212 Host: "arch",
213213 FetchedAt: time.Unix(0, 1),
214- Sessions: []ports.RemoteCatalogSession{},
214+ Sessions: []catalogue.RemoteCatalogSession{},
215215 }}
216216 if err := cache.Store(valid); err != nil {
217217 t.Fatalf("initial Store() error = %v", err)
@@ -221,10 +221,10 @@ func TestCatalogCacheStoreFailurePreservesExistingFile(t *testing.T) {
221221 t.Fatalf("read initial cache: %v", err)
222222 }
223223
224- invalid := []ports.RemoteCatalogCacheEntry{{
224+ invalid := []catalogue.RemoteCatalogCacheEntry{{
225225 Host: "arch",
226226 FetchedAt: time.Time{},
227- Sessions: []ports.RemoteCatalogSession{},
227+ Sessions: []catalogue.RemoteCatalogSession{},
228228 }}
229229 if err := cache.Store(invalid); err == nil {
230230 t.Fatal("Store() error = nil, want validation error")
@@ -251,10 +251,10 @@ func TestCatalogCacheStoreRejectsNonPositiveFetchedTimes(t *testing.T) {
251251 t.Run(tc.name, func(t *testing.T) {
252252 t.Parallel()
253253 path := filepath.Join(t.TempDir(), "remote-catalog-cache.json")
254- entries := []ports.RemoteCatalogCacheEntry{{
254+ entries := []catalogue.RemoteCatalogCacheEntry{{
255255 Host: "arch",
256256 FetchedAt: tc.fetchedAt,
257- Sessions: []ports.RemoteCatalogSession{},
257+ Sessions: []catalogue.RemoteCatalogSession{},
258258 }}
259259
260260 err := NewFileCatalogCache(path).Store(entries)
@@ -281,7 +281,7 @@ func TestCatalogCacheStoreHardensExistingFilePermissions(t *testing.T) {
281281 }
282282
283283 cache := NewFileCatalogCache(path)
284- if err := cache.Store([]ports.RemoteCatalogCacheEntry{}); err != nil {
284+ if err := cache.Store([]catalogue.RemoteCatalogCacheEntry{}); err != nil {
285285 t.Fatalf("Store() error = %v", err)
286286 }
287287 info, err := os.Stat(path)
@@ -306,7 +306,7 @@ func TestCatalogCacheStoreRenameFailureCleansTemporaryFile(t *testing.T) {
306306 }
307307
308308 cache := NewFileCatalogCache(path)
309- if err := cache.Store([]ports.RemoteCatalogCacheEntry{}); err == nil {
309+ if err := cache.Store([]catalogue.RemoteCatalogCacheEntry{}); err == nil {
310310 t.Fatal("Store() error = nil, want rename error")
311311 }
312312 entries, err := os.ReadDir(dir)
@@ -320,6 +320,6 @@ func TestCatalogCacheStoreRenameFailureCleansTemporaryFile(t *testing.T) {
320320 }
321321 }
322322
323-func equalRemoteCatalogCacheEntry(a, b ports.RemoteCatalogCacheEntry) bool {
323+func equalRemoteCatalogCacheEntry(a, b catalogue.RemoteCatalogCacheEntry) bool {
324324 return a.Host == b.Host && a.FetchedAt.Equal(b.FetchedAt) && reflect.DeepEqual(a.Sessions, b.Sessions)
325325 }
M internal/adapters/remote/catalog_test.go +31 -31
@@ -12,8 +12,8 @@ import (
1212 "time"
1313
1414 "github.com/bnema/vev/internal/adapters/sshstdio"
15- "github.com/bnema/vev/internal/ports"
1615 "github.com/bnema/vev/internal/protocol"
16+ "github.com/bnema/vev/internal/protocol/catalogue"
1717 )
1818
1919 func TestRemoteCatalogClientCommandConstruction(t *testing.T) {
@@ -37,10 +37,10 @@ func TestRemoteCatalogClientCommandConstruction(t *testing.T) {
3737 command: func(ctx context.Context, name string, args ...string) *exec.Cmd {
3838 gotPath = name
3939 gotArgs = append([]string(nil), args...)
40- return stdoutCmd(ctx, mustCatalogJSON(t, ports.RemoteCatalog{
40+ return stdoutCmd(ctx, mustCatalogJSON(t, catalogue.RemoteCatalog{
4141 ProtocolVersion: protocol.Version,
42- SchemaVersion: ports.RemoteCatalogSchemaVersion,
43- Sessions: []ports.RemoteCatalogSession{},
42+ SchemaVersion: catalogue.RemoteCatalogSchemaVersion,
43+ Sessions: []catalogue.RemoteCatalogSession{},
4444 }))
4545 },
4646 }
@@ -83,11 +83,11 @@ func TestRemoteCatalogClientRejectsUnsafeTarget(t *testing.T) {
8383 func TestRemoteCatalogClientDecode(t *testing.T) {
8484 t.Parallel()
8585
86- valid := ports.RemoteCatalog{
86+ valid := catalogue.RemoteCatalog{
8787 ProtocolVersion: protocol.Version,
88- SchemaVersion: ports.RemoteCatalogSchemaVersion,
89- Sessions: []ports.RemoteCatalogSession{
90- {LifecycleID: [16]byte{1}, Name: "work", State: "up", Ephemeral: false, Tabs: []ports.RemoteCatalogTab{{ID: "tab-1"}, {ID: "tab-2", Index: 1}}, Attached: true},
88+ SchemaVersion: catalogue.RemoteCatalogSchemaVersion,
89+ Sessions: []catalogue.RemoteCatalogSession{
90+ {LifecycleID: [16]byte{1}, Name: "work", State: "up", Ephemeral: false, Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1"}, {ID: "tab-2", Index: 1}}, Attached: true},
9191 },
9292 }
9393 validJSON := mustCatalogJSON(t, valid)
@@ -97,7 +97,7 @@ func TestRemoteCatalogClientDecode(t *testing.T) {
9797 stdout string
9898 stderr string
9999 exit int
100- want ports.RemoteCatalog
100+ want catalogue.RemoteCatalog
101101 wantErr error
102102 check func(t *testing.T, err error)
103103 }{
@@ -113,10 +113,10 @@ func TestRemoteCatalogClientDecode(t *testing.T) {
113113 },
114114 {
115115 name: "mismatching protocol version",
116- stdout: mustCatalogJSON(t, ports.RemoteCatalog{ProtocolVersion: protocol.Version + 1, SchemaVersion: ports.RemoteCatalogSchemaVersion, Sessions: []ports.RemoteCatalogSession{}}),
116+ stdout: mustCatalogJSON(t, catalogue.RemoteCatalog{ProtocolVersion: protocol.Version + 1, SchemaVersion: catalogue.RemoteCatalogSchemaVersion, Sessions: []catalogue.RemoteCatalogSession{}}),
117117 check: func(t *testing.T, err error) {
118118 t.Helper()
119- var mismatch *ports.RemoteCatalogVersionMismatchError
119+ var mismatch *catalogue.RemoteCatalogVersionMismatchError
120120 if !errors.As(err, &mismatch) {
121121 t.Fatalf("error = %v, want RemoteCatalogVersionMismatchError", err)
122122 }
@@ -131,8 +131,8 @@ func TestRemoteCatalogClientDecode(t *testing.T) {
131131 protocol.Version),
132132 check: func(t *testing.T, err error) {
133133 t.Helper()
134- var mismatch *ports.RemoteCatalogVersionMismatchError
135- if !errors.As(err, &mismatch) || mismatch.Kind != "catalog" || mismatch.Got != 2 || mismatch.Want != ports.RemoteCatalogSchemaVersion {
134+ var mismatch *catalogue.RemoteCatalogVersionMismatchError
135+ if !errors.As(err, &mismatch) || mismatch.Kind != "catalog" || mismatch.Got != 2 || mismatch.Want != catalogue.RemoteCatalogSchemaVersion {
136136 t.Fatalf("error = %v, want obsolete catalogue version mismatch", err)
137137 }
138138 },
@@ -142,8 +142,8 @@ func TestRemoteCatalogClientDecode(t *testing.T) {
142142 stdout: fmt.Sprintf(`{"protocol_version":%d,"schema_version":4,"sessions":[]}`, protocol.Version),
143143 check: func(t *testing.T, err error) {
144144 t.Helper()
145- var mismatch *ports.RemoteCatalogVersionMismatchError
146- if !errors.As(err, &mismatch) || mismatch.Kind != "catalog" || mismatch.Got != 4 || mismatch.Want != ports.RemoteCatalogSchemaVersion {
145+ var mismatch *catalogue.RemoteCatalogVersionMismatchError
146+ if !errors.As(err, &mismatch) || mismatch.Kind != "catalog" || mismatch.Got != 4 || mismatch.Want != catalogue.RemoteCatalogSchemaVersion {
147147 t.Fatalf("error = %v, want unknown catalogue version mismatch", err)
148148 }
149149 },
@@ -161,19 +161,19 @@ func TestRemoteCatalogClientDecode(t *testing.T) {
161161 {
162162 name: "count-only tabs rejected",
163163 stdout: fmt.Sprintf(`{"protocol_version":%d,"schema_version":%d,"sessions":[{"lifecycle_id":"01000000000000000000000000000000","name":"work","state":"up","ephemeral":false,"tabs":1,"attached":false}]}`,
164- protocol.Version, ports.RemoteCatalogSchemaVersion),
164+ protocol.Version, catalogue.RemoteCatalogSchemaVersion),
165165 wantErr: errCatalogDecode,
166166 },
167167 {
168168 name: "unknown fields rejected",
169169 stdout: fmt.Sprintf(`{"protocol_version":%d,"schema_version":%d,"sessions":[],"future":true}`,
170- protocol.Version, ports.RemoteCatalogSchemaVersion),
170+ protocol.Version, catalogue.RemoteCatalogSchemaVersion),
171171 wantErr: errCatalogDecode,
172172 },
173173 {
174174 name: "missing required session field",
175175 stdout: fmt.Sprintf(`{"protocol_version":%d,"schema_version":%d,"sessions":[{"lifecycle_id":"01000000000000000000000000000000","name":"work","state":"up","tabs":[],"attached":false}]}`,
176- protocol.Version, ports.RemoteCatalogSchemaVersion),
176+ protocol.Version, catalogue.RemoteCatalogSchemaVersion),
177177 wantErr: errCatalogRequired,
178178 },
179179 {
@@ -191,30 +191,30 @@ func TestRemoteCatalogClientDecode(t *testing.T) {
191191 stdout: fmt.Sprintf(`{"protocol_version":%d,"sessions":[]}`+"\n", protocol.Version),
192192 check: func(t *testing.T, err error) {
193193 t.Helper()
194- var mismatch *ports.RemoteCatalogVersionMismatchError
195- if !errors.As(err, &mismatch) || mismatch.Kind != "catalog" || mismatch.Got != 0 || mismatch.Want != ports.RemoteCatalogSchemaVersion {
194+ var mismatch *catalogue.RemoteCatalogVersionMismatchError
195+ if !errors.As(err, &mismatch) || mismatch.Kind != "catalog" || mismatch.Got != 0 || mismatch.Want != catalogue.RemoteCatalogSchemaVersion {
196196 t.Fatalf("error = %v, want missing catalogue version mismatch", err)
197197 }
198198 },
199199 },
200200 {
201201 name: "missing sessions",
202- stdout: fmt.Sprintf(`{"protocol_version":%d,"schema_version":%d}`+"\n", protocol.Version, ports.RemoteCatalogSchemaVersion),
202+ stdout: fmt.Sprintf(`{"protocol_version":%d,"schema_version":%d}`+"\n", protocol.Version, catalogue.RemoteCatalogSchemaVersion),
203203 wantErr: errCatalogRequired,
204204 },
205205 {
206206 name: "invalid session name",
207- stdout: mustCatalogJSON(t, ports.RemoteCatalog{ProtocolVersion: protocol.Version, SchemaVersion: ports.RemoteCatalogSchemaVersion, Sessions: []ports.RemoteCatalogSession{{
208- LifecycleID: [16]byte{1}, Name: "bad name", State: "up", Tabs: []ports.RemoteCatalogTab{},
207+ stdout: mustCatalogJSON(t, catalogue.RemoteCatalog{ProtocolVersion: protocol.Version, SchemaVersion: catalogue.RemoteCatalogSchemaVersion, Sessions: []catalogue.RemoteCatalogSession{{
208+ LifecycleID: [16]byte{1}, Name: "bad name", State: "up", Tabs: []catalogue.RemoteCatalogTab{},
209209 }}}),
210- wantErr: ports.ErrInvalidRemoteCatalog,
210+ wantErr: catalogue.ErrInvalidRemoteCatalog,
211211 },
212212 {
213213 name: "invalid session state",
214- stdout: mustCatalogJSON(t, ports.RemoteCatalog{ProtocolVersion: protocol.Version, SchemaVersion: ports.RemoteCatalogSchemaVersion, Sessions: []ports.RemoteCatalogSession{{
215- LifecycleID: [16]byte{1}, Name: "work", State: "weird", Tabs: []ports.RemoteCatalogTab{},
214+ stdout: mustCatalogJSON(t, catalogue.RemoteCatalog{ProtocolVersion: protocol.Version, SchemaVersion: catalogue.RemoteCatalogSchemaVersion, Sessions: []catalogue.RemoteCatalogSession{{
215+ LifecycleID: [16]byte{1}, Name: "work", State: "weird", Tabs: []catalogue.RemoteCatalogTab{},
216216 }}}),
217- wantErr: ports.ErrRemoteCatalogUnknownState,
217+ wantErr: catalogue.ErrRemoteCatalogUnknownState,
218218 },
219219 {
220220 name: "remote exit with stderr",
@@ -292,10 +292,10 @@ func TestRemoteCatalogClientAppliesLifetimeAndWaitDelay(t *testing.T) {
292292 client := &CatalogClient{
293293 command: func(ctx context.Context, name string, args ...string) *exec.Cmd {
294294 commandCtx = ctx
295- cmd = stdoutCmd(ctx, mustCatalogJSON(t, ports.RemoteCatalog{
295+ cmd = stdoutCmd(ctx, mustCatalogJSON(t, catalogue.RemoteCatalog{
296296 ProtocolVersion: protocol.Version,
297- SchemaVersion: ports.RemoteCatalogSchemaVersion,
298- Sessions: []ports.RemoteCatalogSession{},
297+ SchemaVersion: catalogue.RemoteCatalogSchemaVersion,
298+ Sessions: []catalogue.RemoteCatalogSession{},
299299 }))
300300 return cmd
301301 },
@@ -549,7 +549,7 @@ func stdoutCmd(ctx context.Context, stdout string) *exec.Cmd {
549549 return cmd
550550 }
551551
552-func mustCatalogJSON(t *testing.T, catalog ports.RemoteCatalog) string {
552+func mustCatalogJSON(t *testing.T, catalog catalogue.RemoteCatalog) string {
553553 t.Helper()
554554 raw, err := json.Marshal(catalog)
555555 if err != nil {
M internal/adapters/remote/remote_factory.go +14 -9
@@ -8,33 +8,38 @@ import (
88 "github.com/bnema/vev/internal/adapters/dgram"
99 "github.com/bnema/vev/internal/adapters/sshstdio"
1010 "github.com/bnema/vev/internal/ports"
11+ "github.com/bnema/vev/internal/protocol/wire"
1112 )
1213
13-// DialerFactory selects the requested remote transport adapter.
14+// TransportMode selects a concrete remote carriage after app validation.
15+type TransportMode string
16+
17+const (
18+ TransportUDP TransportMode = "udp"
19+ TransportStdio TransportMode = "stdio"
20+)
21+
22+// DialerFactory constructs target-specific raw carriage dialers.
1423 type DialerFactory struct {
1524 observer ports.SerializedRuntimeObserver
1625 }
1726
18-var _ ports.RemoteDialerFactory = DialerFactory{}
19-
2027 func NewDialerFactory() DialerFactory { return DialerFactory{} }
2128
22-// NewDialerFactoryWithRuntimeObserver wires the one process-local sink into
23-// whichever concrete remote carriage adapter is selected.
2429 func NewDialerFactoryWithRuntimeObserver(observer ports.SerializedRuntimeObserver) DialerFactory {
2530 return DialerFactory{observer: observer}
2631 }
2732
28-func (f DialerFactory) DialerForRemote(target string, session string, mode ports.RemoteTransportMode, log *slog.Logger) (ports.Dialer, error) {
33+func (f DialerFactory) DialerForRemote(target, session string, mode TransportMode, log *slog.Logger) (wire.Dialer, error) {
2934 switch mode {
30- case ports.RemoteTransportUDP:
35+ case TransportUDP:
3136 if log != nil {
3237 log.Info("remote transport selected", "mode", mode, "target", target, "session", session)
3338 }
3439 dialer := dgram.NewRemoteDialerWithLogger(target, "", log)
3540 dialer.RuntimeObserver = f.observer
3641 return dialer, nil
37- case ports.RemoteTransportStdio:
42+ case TransportStdio:
3843 if log != nil {
3944 log.Info("remote transport selected", "mode", mode, "target", target, "session", session)
4045 }
@@ -50,6 +55,6 @@ type stdioDialer struct {
5055 observer ports.SerializedRuntimeObserver
5156 }
5257
53-func (d stdioDialer) Dial(ctx context.Context) (ports.Transport, error) {
58+func (d stdioDialer) Dial(ctx context.Context) (wire.Transport, error) {
5459 return sshstdio.DialContextWithRuntimeObserver(ctx, d.target, "", d.log, sshstdio.WithRuntimeObserver(d.observer))
5560 }
M internal/adapters/remote/remote_factory_test.go +4 -5
@@ -6,7 +6,6 @@ import (
66 "testing"
77
88 "github.com/bnema/vev/internal/adapters/dgram"
9- "github.com/bnema/vev/internal/ports"
109 )
1110
1211 func TestDialerFactorySelectsExplicitModes(t *testing.T) {
@@ -14,11 +13,11 @@ func TestDialerFactorySelectsExplicitModes(t *testing.T) {
1413
1514 tests := []struct {
1615 name string
17- mode ports.RemoteTransportMode
16+ mode TransportMode
1817 wantType any
1918 }{
20- {name: "udp", mode: ports.RemoteTransportUDP, wantType: dgram.RemoteDialer{}},
21- {name: "stdio", mode: ports.RemoteTransportStdio, wantType: stdioDialer{}},
19+ {name: "udp", mode: TransportUDP, wantType: dgram.RemoteDialer{}},
20+ {name: "stdio", mode: TransportStdio, wantType: stdioDialer{}},
2221 }
2322 for _, tt := range tests {
2423 t.Run(tt.name, func(t *testing.T) {
@@ -51,7 +50,7 @@ func TestDialerFactorySelectsExplicitModes(t *testing.T) {
5150 func TestDialerFactoryRejectsUnsupportedMode(t *testing.T) {
5251 factory := NewDialerFactory()
5352
54- dialer, err := factory.DialerForRemote("remote.example", "work", ports.RemoteTransportMode("serial"), nil)
53+ dialer, err := factory.DialerForRemote("remote.example", "work", TransportMode("serial"), nil)
5554 if err == nil {
5655 t.Fatal("DialerForRemote() error = nil, want unsupported mode error")
5756 }
M internal/adapters/sessionwire/client.go +4 -4
@@ -9,12 +9,12 @@ import (
99 "github.com/bnema/vev/internal/protocol/wire"
1010 )
1111
12-type clientConnection struct{ raw ports.Transport }
12+type clientConnection struct{ raw wire.Transport }
1313
1414 var _ ports.ClientConnection = (*clientConnection)(nil)
1515
1616 // NewClientConnection wraps one raw client connection incarnation.
17-func NewClientConnection(raw ports.Transport) ports.ClientConnection {
17+func NewClientConnection(raw wire.Transport) ports.ClientConnection {
1818 if raw == nil {
1919 return nil
2020 }
@@ -55,12 +55,12 @@ func (c *clientConnection) LinkState() ports.LinkState { return rawLinkS
5555 func (c *clientConnection) LinkEvents() <-chan ports.LinkEvent { return rawLinkEvents(c.raw) }
5656 func (c *clientConnection) Close() error { return c.raw.Close() }
5757
58-type clientDialer struct{ raw ports.Dialer }
58+type clientDialer struct{ raw wire.Dialer }
5959
6060 var _ ports.ClientDialer = (*clientDialer)(nil)
6161
6262 // NewClientDialer wraps every dialed raw connection in a stable typed adapter.
63-func NewClientDialer(raw ports.Dialer) ports.ClientDialer {
63+func NewClientDialer(raw wire.Dialer) ports.ClientDialer {
6464 if raw == nil {
6565 return nil
6666 }
M internal/adapters/sessionwire/client_test.go +2 -2
@@ -131,10 +131,10 @@ func TestClientDialerWrapsEachConnectionAndPreservesErrors(t *testing.T) {
131131 }
132132
133133 type scriptedDialer struct {
134- transport ports.Transport
134+ transport wire.Transport
135135 err error
136136 }
137137
138-func (d *scriptedDialer) Dial(context.Context) (ports.Transport, error) {
138+func (d *scriptedDialer) Dial(context.Context) (wire.Transport, error) {
139139 return d.transport, d.err
140140 }
M internal/adapters/sessionwire/server.go +14 -14
@@ -17,12 +17,12 @@ var (
1717 ErrUnsupportedSend = errors.New("sessionwire: send mode is unsupported")
1818 )
1919
20-type serverConnection struct{ raw ports.Transport }
20+type serverConnection struct{ raw wire.Transport }
2121
2222 var _ ports.ServerConnection = (*serverConnection)(nil)
2323
2424 // NewServerConnection wraps one raw connection incarnation exactly once.
25-func NewServerConnection(raw ports.Transport) ports.ServerConnection {
25+func NewServerConnection(raw wire.Transport) ports.ServerConnection {
2626 if raw == nil {
2727 return nil
2828 }
@@ -71,7 +71,7 @@ func (c *serverConnection) SendServer(message protocol.ServerMessage) error {
7171 }
7272
7373 func (c *serverConnection) SendServerAsync(message protocol.ServerMessage) error {
74- transport, ok := c.raw.(ports.AsyncTransport)
74+ transport, ok := c.raw.(wire.AsyncTransport)
7575 if !ok {
7676 return ErrUnsupportedSend
7777 }
@@ -83,7 +83,7 @@ func (c *serverConnection) SendServerAsync(message protocol.ServerMessage) error
8383 }
8484
8585 func (c *serverConnection) SendServerSynchronous(message protocol.ServerMessage) error {
86- transport, ok := c.raw.(ports.OwnedSynchronousTransport)
86+ transport, ok := c.raw.(wire.OwnedSynchronousTransport)
8787 if !ok {
8888 return ErrUnsupportedSend
8989 }
@@ -103,7 +103,7 @@ func (c *serverConnection) SendOutput(output protocol.Output) error {
103103 }
104104
105105 func (c *serverConnection) SendOutputAsync(output protocol.Output) error {
106- transport, ok := c.raw.(ports.AsyncTransport)
106+ transport, ok := c.raw.(wire.AsyncTransport)
107107 if !ok {
108108 return ErrUnsupportedSend
109109 }
@@ -115,7 +115,7 @@ func (c *serverConnection) SendOutputAsync(output protocol.Output) error {
115115 }
116116
117117 func (c *serverConnection) SendOutputSynchronous(output protocol.Output) error {
118- transport, ok := c.raw.(ports.OwnedSynchronousTransport)
118+ transport, ok := c.raw.(wire.OwnedSynchronousTransport)
119119 if !ok {
120120 return ErrUnsupportedSend
121121 }
@@ -138,10 +138,10 @@ func (c *serverConnection) Capabilities() protocol.ConnectionCapabilities {
138138 func (c *serverConnection) LinkState() ports.LinkState { return rawLinkState(c.raw) }
139139 func (c *serverConnection) LinkEvents() <-chan ports.LinkEvent { return rawLinkEvents(c.raw) }
140140
141-func rawCapabilities(raw ports.Transport) protocol.ConnectionCapabilities {
142- _, datagram := raw.(ports.DatagramTransport)
143- _, async := raw.(ports.AsyncTransport)
144- _, synchronous := raw.(ports.OwnedSynchronousTransport)
141+func rawCapabilities(raw wire.Transport) protocol.ConnectionCapabilities {
142+ _, datagram := raw.(wire.DatagramTransport)
143+ _, async := raw.(wire.AsyncTransport)
144+ _, synchronous := raw.(wire.OwnedSynchronousTransport)
145145 _, linkState := raw.(ports.LinkStateReporter)
146146 window := uint8(protocol.MaxOutputWindow)
147147 if datagram {
@@ -156,14 +156,14 @@ func rawCapabilities(raw ports.Transport) protocol.ConnectionCapabilities {
156156 }
157157 }
158158
159-func rawLinkState(raw ports.Transport) ports.LinkState {
159+func rawLinkState(raw wire.Transport) ports.LinkState {
160160 if reporter, ok := raw.(ports.LinkStateReporter); ok {
161161 return reporter.LinkState()
162162 }
163163 return ports.LinkStateConnected
164164 }
165165
166-func rawLinkEvents(raw ports.Transport) <-chan ports.LinkEvent {
166+func rawLinkEvents(raw wire.Transport) <-chan ports.LinkEvent {
167167 if reporter, ok := raw.(ports.LinkStateReporter); ok {
168168 return reporter.LinkEvents()
169169 }
@@ -172,12 +172,12 @@ func rawLinkEvents(raw ports.Transport) <-chan ports.LinkEvent {
172172
173173 func (c *serverConnection) Close() error { return c.raw.Close() }
174174
175-type serverListener struct{ raw ports.Listener }
175+type serverListener struct{ raw wire.Listener }
176176
177177 var _ ports.ServerListener = (*serverListener)(nil)
178178
179179 // NewServerListener wraps every accepted raw connection in a stable typed adapter.
180-func NewServerListener(raw ports.Listener) ports.ServerListener {
180+func NewServerListener(raw wire.Listener) ports.ServerListener {
181181 if raw == nil {
182182 return nil
183183 }
M internal/adapters/sessionwire/server_test.go +4 -4
@@ -279,8 +279,8 @@ func TestServerListenerWrapsEachAcceptedIncarnation(t *testing.T) {
279279 require.NoError(t, wrapped.Close())
280280 }
281281
282-type scriptedListener struct{ transport ports.Transport }
282+type scriptedListener struct{ transport wire.Transport }
283283
284-func (l *scriptedListener) Accept() (ports.Transport, error) { return l.transport, nil }
285-func (*scriptedListener) Close() error { return nil }
286-func (*scriptedListener) Addr() string { return "test" }
284+func (l *scriptedListener) Accept() (wire.Transport, error) { return l.transport, nil }
285+func (*scriptedListener) Close() error { return nil }
286+func (*scriptedListener) Addr() string { return "test" }
M internal/adapters/sshstdio/transport.go +5 -5
@@ -57,7 +57,7 @@ func WithRuntimeObserver(observer ports.SerializedRuntimeObserver) Option {
5757 }
5858
5959 // NewTransport wraps separate reader/writer streams as a framed Transport.
60-func NewTransport(r io.Reader, w io.Writer, closeFn closeFunc, opts ...Option) ports.Transport {
60+func NewTransport(r io.Reader, w io.Writer, closeFn closeFunc, opts ...Option) wire.Transport {
6161 t := newTransport(r, w, closeFn, nil)
6262 for _, opt := range opts {
6363 if opt != nil {
@@ -533,12 +533,12 @@ func shellQuote(s string) string {
533533 // Dial starts ssh target vev _stdio and returns a Transport over the child
534534 // process' stdio. Session selection is carried in Hello. The subprocess is
535535 // started with exec.Command argv, never through a shell.
536-func Dial(target, session string) (ports.Transport, error) {
536+func Dial(target, session string) (wire.Transport, error) {
537537 return DialContext(context.Background(), target, session)
538538 }
539539
540540 // DialContextWithRuntimeObserver is the option-bearing production constructor.
541-func DialContextWithRuntimeObserver(ctx context.Context, target, session string, logger *slog.Logger, opts ...Option) (ports.Transport, error) {
541+func DialContextWithRuntimeObserver(ctx context.Context, target, session string, logger *slog.Logger, opts ...Option) (wire.Transport, error) {
542542 return dialContext(ctx, target, session, logger, opts...)
543543 }
544544
@@ -547,7 +547,7 @@ func DialContextWithRuntimeObserver(ctx context.Context, target, session string,
547547 // handshake context must not kill an already-established carriage. Callers may
548548 // pass a logger to record ssh start failures and non-clean exits without logging
549549 // the generated command line.
550-func DialContext(ctx context.Context, target, session string, logger ...*slog.Logger) (ports.Transport, error) {
550+func DialContext(ctx context.Context, target, session string, logger ...*slog.Logger) (wire.Transport, error) {
551551 var log *slog.Logger
552552 if len(logger) > 0 {
553553 log = logger[0]
@@ -555,7 +555,7 @@ func DialContext(ctx context.Context, target, session string, logger ...*slog.Lo
555555 return dialContext(ctx, target, session, log)
556556 }
557557
558-func dialContext(ctx context.Context, target, session string, log *slog.Logger, opts ...Option) (ports.Transport, error) {
558+func dialContext(ctx context.Context, target, session string, log *slog.Logger, opts ...Option) (wire.Transport, error) {
559559 if err := ctx.Err(); err != nil {
560560 return nil, err
561561 }
M internal/adapters/sshstdio/transport_observability_test.go +4 -4
@@ -22,7 +22,7 @@ func TestTransportObservabilitySSHStdioEOFEndsReceive(t *testing.T) {
2222 if err != nil {
2323 t.Fatalf("NewJSONL() error = %v", err)
2424 }
25- reporter := ports.NewSerializedRuntimeObserver(observer, 64)
25+ reporter := observability.NewSerialized(observer, 64)
2626 defer reporter.Close()
2727 if _, err := NewTransport(bytes.NewReader(nil), &bytes.Buffer{}, nil, WithRuntimeObserver(reporter)).Recv(); err == nil {
2828 t.Fatal("Recv() error = nil at EOF")
@@ -40,7 +40,7 @@ func TestTransportObservabilitySSHStdioCloseEndsBlockedReceive(t *testing.T) {
4040 if err != nil {
4141 t.Fatalf("NewJSONL() error = %v", err)
4242 }
43- reporter := ports.NewSerializedRuntimeObserver(observer, 64)
43+ reporter := observability.NewSerialized(observer, 64)
4444 defer reporter.Close()
4545 reader := &sshShutdownReader{entered: make(chan struct{}), done: make(chan struct{})}
4646 shutdownErr := errors.New("ssh shutdown failed")
@@ -152,7 +152,7 @@ func TestTransportObservabilitySSHStdioCloseDoesNotWaitForUnownedReceive(t *test
152152 if err != nil {
153153 t.Fatalf("NewJSONL() error = %v", err)
154154 }
155- reporter := ports.NewSerializedRuntimeObserver(observer, 64)
155+ reporter := observability.NewSerialized(observer, 64)
156156 defer reporter.Close()
157157 reader := &sshUnownedBlockedReader{entered: make(chan struct{}), release: make(chan struct{})}
158158 t.Cleanup(reader.unblock)
@@ -210,7 +210,7 @@ func TestTransportObservabilitySSHStdioPreservesCarriage(t *testing.T) {
210210 if err != nil {
211211 t.Fatalf("NewJSONL() error = %v", err)
212212 }
213- reporter := ports.NewSerializedRuntimeObserver(observer, 64)
213+ reporter := observability.NewSerialized(observer, 64)
214214 defer reporter.Close()
215215
216216 frame := wire.Frame{Type: wire.MsgOutput, Payload: []byte("stdio bytes stay exact")}
M internal/app/cmd.go +3 -3
@@ -129,8 +129,8 @@ var errDaemonUnreachable = errors.New("daemon not running")
129129 type cmdDeps struct {
130130 stdout io.Writer
131131 getenv func(string) string
132- dial func(context.Context, string) (ports.Transport, error)
133- ensure func(context.Context, string) (ports.Transport, error)
132+ dial func(context.Context, string) (wire.Transport, error)
133+ ensure func(context.Context, string) (wire.Transport, error)
134134 clock ports.Clock
135135 }
136136
@@ -139,7 +139,7 @@ func runCmd(ctx context.Context, invocation cmdInvocation) error {
139139 stdout: os.Stdout,
140140 getenv: os.Getenv,
141141 dial: realDial,
142- ensure: func(ctx context.Context, dir string) (ports.Transport, error) {
142+ ensure: func(ctx context.Context, dir string) (wire.Transport, error) {
143143 return ensureDaemonWithLifecycle(ctx, dir, realDial, realSpawn, defaultBackoff)
144144 },
145145 clock: clock.New(),
M internal/app/cmd_test.go +14 -15
@@ -11,7 +11,6 @@ import (
1111 "testing"
1212 "time"
1313
14- "github.com/bnema/vev/internal/ports"
1514 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1615 "github.com/bnema/vev/internal/protocol"
1716 "github.com/bnema/vev/internal/protocol/wire"
@@ -91,7 +90,7 @@ func TestCmdHelpUsesRegistryWithoutDialing(t *testing.T) {
9190 err := runCmdWithDeps(context.Background(), cmdInvocation{help: true}, cmdDeps{
9291 stdout: out,
9392 getenv: func(string) string { return "" },
94- dial: func(context.Context, string) (ports.Transport, error) {
93+ dial: func(context.Context, string) (wire.Transport, error) {
9594 called = true
9695 return nil, errors.New("must not dial")
9796 },
@@ -146,7 +145,7 @@ func TestMoveCmdPreservesPositionalArguments(t *testing.T) {
146145 require.NoError(t, runCmdWithDeps(context.Background(), parsed.cmd, cmdDeps{
147146 stdout: io.Discard,
148147 getenv: func(string) string { return "" },
149- dial: func(context.Context, string) (ports.Transport, error) { return transport, nil },
148+ dial: func(context.Context, string) (wire.Transport, error) { return transport, nil },
150149 }))
151150 require.Len(t, transport.sent, 1)
152151 request, err := wire.UnmarshalCommandRequest(transport.sent[0].Payload)
@@ -164,11 +163,11 @@ func TestRemoteCatalogCommandEnsuresDaemon(t *testing.T) {
164163 err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "remote-catalog", jsonOut: true}, cmdDeps{
165164 stdout: io.Discard,
166165 getenv: func(string) string { return "" },
167- dial: func(context.Context, string) (ports.Transport, error) {
166+ dial: func(context.Context, string) (wire.Transport, error) {
168167 t.Fatal("remote catalog used non-starting daemon dial")
169168 return nil, errors.New("unexpected dial")
170169 },
171- ensure: func(context.Context, string) (ports.Transport, error) {
170+ ensure: func(context.Context, string) (wire.Transport, error) {
172171 ensureCalls++
173172 return transport, nil
174173 },
@@ -191,7 +190,7 @@ func TestMoveCmdInvalidArgumentResultExitsTwo(t *testing.T) {
191190 err := runCmdWithDeps(context.Background(), invocation, cmdDeps{
192191 stdout: io.Discard,
193192 getenv: func(string) string { return "" },
194- dial: func(context.Context, string) (ports.Transport, error) { return transport, nil },
193+ dial: func(context.Context, string) (wire.Transport, error) { return transport, nil },
195194 })
196195 if ExitCode(err) != 2 {
197196 t.Fatalf("ExitCode(%v) = %d, want 2", err, ExitCode(err))
@@ -212,7 +211,7 @@ func TestTargetPaneCmdsParseAndUseSelfTarget(t *testing.T) {
212211 require.NoError(t, runCmdWithDeps(context.Background(), invocation.cmd, cmdDeps{
213212 stdout: io.Discard,
214213 getenv: func(string) string { return "session=work,tab=t_abc,pane=p_def" },
215- dial: func(context.Context, string) (ports.Transport, error) { return transport, nil },
214+ dial: func(context.Context, string) (wire.Transport, error) { return transport, nil },
216215 }))
217216 require.Len(t, transport.sent, 1)
218217 request, err := wire.UnmarshalCommandRequest(transport.sent[0].Payload)
@@ -232,7 +231,7 @@ func TestRunCmdBuildsOneShotTargetedRequest(t *testing.T) {
232231 err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right", self: true}, cmdDeps{
233232 stdout: out,
234233 getenv: func(string) string { return "session=old,tab=t_abc,pane=p_def" },
235- dial: func(context.Context, string) (ports.Transport, error) { return transport, nil },
234+ dial: func(context.Context, string) (wire.Transport, error) { return transport, nil },
236235 })
237236 if err != nil {
238237 t.Fatalf("runCmdWithDeps: %v", err)
@@ -257,7 +256,7 @@ func TestRunCmdVEVWithoutSelfUsesIDsOnlyAsSessionLocator(t *testing.T) {
257256 err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{
258257 stdout: io.Discard,
259258 getenv: func(string) string { return "session=old,tab=t_abc,pane=p_def" },
260- dial: func(context.Context, string) (ports.Transport, error) { return transport, nil },
259+ dial: func(context.Context, string) (wire.Transport, error) { return transport, nil },
261260 })
262261 if err != nil {
263262 t.Fatalf("runCmdWithDeps: %v", err)
@@ -276,7 +275,7 @@ func TestRunCmdExplicitSessionDoesNotUseEnvIDsWithoutSelf(t *testing.T) {
276275 err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "new-tab", session: "current"}, cmdDeps{
277276 stdout: io.Discard,
278277 getenv: func(string) string { return "session=old,tab=t_abc,pane=p_def" },
279- dial: func(context.Context, string) (ports.Transport, error) { return transport, nil },
278+ dial: func(context.Context, string) (wire.Transport, error) { return transport, nil },
280279 })
281280 if err != nil {
282281 t.Fatalf("runCmdWithDeps: %v", err)
@@ -298,7 +297,7 @@ func TestRunCmdRejectsTooManyArgumentsBeforeDialing(t *testing.T) {
298297 }, cmdDeps{
299298 stdout: io.Discard,
300299 getenv: func(string) string { return "" },
301- dial: func(context.Context, string) (ports.Transport, error) {
300+ dial: func(context.Context, string) (wire.Transport, error) {
302301 dialed = true
303302 return nil, errors.New("must not dial")
304303 },
@@ -330,7 +329,7 @@ func TestRunCmdTimesOutPendingRequest(t *testing.T) {
330329 done <- runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{
331330 stdout: io.Discard,
332331 getenv: func(string) string { return "" },
333- dial: func(context.Context, string) (ports.Transport, error) { return transport, nil },
332+ dial: func(context.Context, string) (wire.Transport, error) { return transport, nil },
334333 clock: clock,
335334 })
336335 }()
@@ -358,7 +357,7 @@ func TestRunCmdZeroRequestIDReplyReturnsDaemonError(t *testing.T) {
358357 err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{
359358 stdout: io.Discard,
360359 getenv: func(string) string { return "" },
361- dial: func(context.Context, string) (ports.Transport, error) { return transport, nil },
360+ dial: func(context.Context, string) (wire.Transport, error) { return transport, nil },
362361 })
363362 if err == nil || err.Error() != want {
364363 t.Fatalf("error = %v, want daemon error %q", err, want)
@@ -374,7 +373,7 @@ func TestRunCmdDoesNotAutostartAndClassifiesDialFailure(t *testing.T) {
374373 err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{
375374 stdout: io.Discard,
376375 getenv: func(string) string { return "" },
377- dial: func(context.Context, string) (ports.Transport, error) {
376+ dial: func(context.Context, string) (wire.Transport, error) {
378377 dials++
379378 return nil, dialErr
380379 },
@@ -420,7 +419,7 @@ func TestRunCmdClassifiesDaemonCommandErrors(t *testing.T) {
420419 err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{
421420 stdout: io.Discard,
422421 getenv: func(string) string { return "" },
423- dial: func(context.Context, string) (ports.Transport, error) { return transport, nil },
422+ dial: func(context.Context, string) (wire.Transport, error) { return transport, nil },
424423 })
425424 if err == nil || ExitCode(err) != tt.wantCode || err.Error() != tt.result.Text {
426425 t.Fatalf("error=%v code=%d, want message %q and code %d", err, ExitCode(err), tt.result.Text, tt.wantCode)
M internal/app/daemon_integration_test.go +8 -8
@@ -66,10 +66,10 @@ func startDaemonInDir(t *testing.T, dir string, opts ...daemon.Option) (string,
6666 }
6767
6868 // pump wires a background Recv pump to a transport so tests can await frames
69-// with a timeout (ports.Transport has no read deadline).
69+// with a timeout (wire.Transport has no read deadline).
7070 type pump struct{ ch chan wire.Frame }
7171
72-func recvPump(tr ports.Transport) *pump {
72+func recvPump(tr wire.Transport) *pump {
7373 p := &pump{ch: make(chan wire.Frame, 128)}
7474 go func() {
7575 for {
@@ -85,12 +85,12 @@ func recvPump(tr ports.Transport) *pump {
8585 }
8686
8787 // attach dials, handshakes, and returns the transport plus its frame pump.
88-func attach(t *testing.T, dir string, intent uint8, name string, sz domain.Size) (ports.Transport, *pump) {
88+func attach(t *testing.T, dir string, intent uint8, name string, sz domain.Size) (wire.Transport, *pump) {
8989 t.Helper()
9090 return attachWithEnvironment(t, dir, intent, name, sz, nil)
9191 }
9292
93-func attachWithEnvironment(t *testing.T, dir string, intent uint8, name string, sz domain.Size, env []string) (ports.Transport, *pump) {
93+func attachWithEnvironment(t *testing.T, dir string, intent uint8, name string, sz domain.Size, env []string) (wire.Transport, *pump) {
9494 t.Helper()
9595 tr, err := ipc.DialContext(context.Background(), dir)
9696 require.NoError(t, err)
@@ -263,7 +263,7 @@ func shellFixture(t *testing.T, label string) string {
263263 return path
264264 }
265265
266-func assertChildEnvironment(t *testing.T, tr ports.Transport, p *pump, sz domain.Size, wantTestEnv, wantShell, wantRuntimeDir, wantWayland string) {
266+func assertChildEnvironment(t *testing.T, tr wire.Transport, p *pump, sz domain.Size, wantTestEnv, wantShell, wantRuntimeDir, wantWayland string) {
267267 t.Helper()
268268 command := "printf '\\033[2J\\033[H'; printf 'VEV_TEST_ENV=%s SHELL=%s XDG_RUNTIME_DIR=%s WAYLAND_DISPLAY=%s TERM=%s COLORTERM=%s TERM_PROGRAM=%s VEV_PREFIX=%.24s\\n' \"$VEV_TEST_ENV\" \"${SHELL##*/}\" \"$XDG_RUNTIME_DIR\" \"$WAYLAND_DISPLAY\" \"$TERM\" \"$COLORTERM\" \"$TERM_PROGRAM\" \"$VEV\"\n"
269269 require.NoError(t, tr.Send(wire.Frame{Type: wire.MsgInput, Payload: wire.MarshalInput(protocol.Input{Data: []byte(command)})}))
@@ -531,7 +531,7 @@ func TestMultipleClientsOneLifecycleOwner(t *testing.T) {
531531
532532 ready := make(chan struct{})
533533 transport := &integrationTransport{}
534- dial := func(context.Context, string) (ports.Transport, error) {
534+ dial := func(context.Context, string) (wire.Transport, error) {
535535 select {
536536 case <-ready:
537537 return transport, nil
@@ -1075,7 +1075,7 @@ func TestLifecycleManualTimerRepeatedFireIsNonBlocking(t *testing.T) {
10751075 }
10761076
10771077 type lifecycleObservedListener struct {
1078- ports.Listener
1078+ wire.Listener
10791079 closed chan struct{}
10801080 once sync.Once
10811081 }
@@ -1249,7 +1249,7 @@ func assertNewDaemonStartupPending(t *testing.T, durableOpen, listen <-chan stru
12491249 }
12501250
12511251 type lifecycleControlledListener struct {
1252- ports.Listener
1252+ wire.Listener
12531253 entered chan struct{}
12541254 proceed chan struct{}
12551255 closed chan struct{}
M internal/app/final_acceptance_test.go +2 -3
@@ -7,14 +7,13 @@ import (
77 "time"
88
99 "github.com/bnema/vev/internal/domain"
10- "github.com/bnema/vev/internal/ports"
1110 "github.com/bnema/vev/internal/protocol"
1211 "github.com/bnema/vev/internal/protocol/wire"
1312 "github.com/bnema/vev/internal/usecase/daemon"
1413 "github.com/stretchr/testify/require"
1514 )
1615
17-func sendAcceptanceInput(t *testing.T, tr ports.Transport, data string) {
16+func sendAcceptanceInput(t *testing.T, tr wire.Transport, data string) {
1817 t.Helper()
1918 require.NoError(t, tr.Send(wire.Frame{Type: wire.MsgInput, Payload: wire.MarshalInput(protocol.Input{Data: []byte(data)})}))
2019 }
@@ -60,7 +59,7 @@ func awaitAcceptanceOutput(t *testing.T, p *pump, predicate func(protocol.Output
6059 return output
6160 }
6261
63-func awaitAcceptanceCommandResult(t *testing.T, tr ports.Transport, p *pump, requestID uint64, slug string) protocol.CommandResult {
62+func awaitAcceptanceCommandResult(t *testing.T, tr wire.Transport, p *pump, requestID uint64, slug string) protocol.CommandResult {
6463 t.Helper()
6564 payload, err := wire.MarshalCommandRequest(protocol.CommandRequest{
6665 Version: protocol.Version, RequestID: requestID, Attached: true, Slug: slug,
A internal/app/remote_dialer_factory_test.go +78 -0
@@ -0,0 +1,78 @@
1+package app
2+
3+import (
4+ "fmt"
5+ "log/slog"
6+ "sync"
7+ "testing"
8+
9+ remoteadapter "github.com/bnema/vev/internal/adapters/remote"
10+ "github.com/bnema/vev/internal/protocol/wire"
11+)
12+
13+type remoteDialerFactoryMock struct {
14+ mu sync.Mutex
15+ calls []*remoteDialerFactoryCall
16+}
17+
18+type remoteDialerFactoryExpecter struct{ mock *remoteDialerFactoryMock }
19+
20+type remoteDialerFactoryCall struct {
21+ target, session string
22+ mode remoteadapter.TransportMode
23+ dialer wire.Dialer
24+ err error
25+ remaining int
26+}
27+
28+func newRemoteDialerFactoryMock(t *testing.T) *remoteDialerFactoryMock {
29+ t.Helper()
30+ m := &remoteDialerFactoryMock{}
31+ t.Cleanup(func() {
32+ m.mu.Lock()
33+ defer m.mu.Unlock()
34+ for _, call := range m.calls {
35+ if call.remaining != 0 {
36+ t.Errorf("unmet DialerForRemote(%q, %q, %q) calls: %d", call.target, call.session, call.mode, call.remaining)
37+ }
38+ }
39+ })
40+ return m
41+}
42+
43+func (m *remoteDialerFactoryMock) EXPECT() *remoteDialerFactoryExpecter {
44+ return &remoteDialerFactoryExpecter{mock: m}
45+}
46+
47+func (e *remoteDialerFactoryExpecter) DialerForRemote(target, session string, mode remoteadapter.TransportMode, _ any) *remoteDialerFactoryCall {
48+ call := &remoteDialerFactoryCall{target: target, session: session, mode: mode, remaining: 1}
49+ e.mock.mu.Lock()
50+ e.mock.calls = append(e.mock.calls, call)
51+ e.mock.mu.Unlock()
52+ return call
53+}
54+
55+func (c *remoteDialerFactoryCall) Return(dialer wire.Dialer, err error) *remoteDialerFactoryCall {
56+ c.dialer, c.err = dialer, err
57+ return c
58+}
59+
60+func (c *remoteDialerFactoryCall) Once() *remoteDialerFactoryCall { return c.Times(1) }
61+
62+func (c *remoteDialerFactoryCall) Times(n int) *remoteDialerFactoryCall {
63+ c.remaining = n
64+ return c
65+}
66+
67+func (m *remoteDialerFactoryMock) DialerForRemote(target, session string, mode remoteadapter.TransportMode, _ *slog.Logger) (wire.Dialer, error) {
68+ m.mu.Lock()
69+ defer m.mu.Unlock()
70+ for _, call := range m.calls {
71+ if call.remaining == 0 || call.target != target || call.session != session || call.mode != mode {
72+ continue
73+ }
74+ call.remaining--
75+ return call.dialer, call.err
76+ }
77+ return nil, fmt.Errorf("unexpected DialerForRemote(%q, %q, %q)", target, session, mode)
78+}
M internal/app/remote_final_acceptance_test.go +7 -7
@@ -11,9 +11,9 @@ import (
1111 "sync"
1212 "testing"
1313
14+ remoteadapter "github.com/bnema/vev/internal/adapters/remote"
1415 "github.com/bnema/vev/internal/adapters/sshstdio"
1516 "github.com/bnema/vev/internal/domain"
16- "github.com/bnema/vev/internal/ports"
1717 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1818 "github.com/bnema/vev/internal/protocol"
1919 "github.com/bnema/vev/internal/protocol/wire"
@@ -28,7 +28,7 @@ type acceptanceRemoteFactory struct {
2828 outputs chan string
2929 }
3030
31-func (f *acceptanceRemoteFactory) DialerForRemote(target string, _ string, _ ports.RemoteTransportMode, _ *slog.Logger) (ports.Dialer, error) {
31+func (f *acceptanceRemoteFactory) DialerForRemote(target string, _ string, _ remoteadapter.TransportMode, _ *slog.Logger) (wire.Dialer, error) {
3232 f.mu.Lock()
3333 f.calls = append(f.calls, target)
3434 targetHandoff := f.handoff[target]
@@ -42,7 +42,7 @@ type acceptanceRemoteDialer struct {
4242 outputs chan<- string
4343 }
4444
45-func (d acceptanceRemoteDialer) Dial(context.Context) (ports.Transport, error) {
45+func (d acceptanceRemoteDialer) Dial(context.Context) (wire.Transport, error) {
4646 clientConn, serverConn := net.Pipe()
4747 clientTransport := sshstdio.NewTransport(clientConn, clientConn, clientConn.Close)
4848 serverTransport := sshstdio.NewTransport(serverConn, serverConn, serverConn.Close)
@@ -50,7 +50,7 @@ func (d acceptanceRemoteDialer) Dial(context.Context) (ports.Transport, error) {
5050 return clientTransport, nil
5151 }
5252
53-func serveAcceptanceRemote(tr ports.Transport, target string, handoff protocol.AttachTarget, outputs chan<- string) {
53+func serveAcceptanceRemote(tr wire.Transport, target string, handoff protocol.AttachTarget, outputs chan<- string) {
5454 defer func() { _ = tr.Close() }()
5555 welcomed := false
5656 for {
@@ -125,12 +125,12 @@ func TestAcceptanceRemoteDirectAndPickerUseOnlyRemoteTransports(t *testing.T) {
125125 terminals := make([]*acceptanceRemoteTerminal, 0, 3)
126126 deps := runAttachDeps{
127127 hostStore: hostStore,
128- localDialer: func() ports.Dialer {
128+ localDialer: func() wire.Dialer {
129129 localCalls++
130130 return acceptanceRemoteDialer{}
131131 },
132- remoteDialerFactory: factory,
133- selectedRemoteTransport: string(ports.RemoteTransportStdio),
132+ remoteDialerFactory: factory.DialerForRemote,
133+ selectedRemoteTransport: string(remoteadapter.TransportStdio),
134134 runClient: func(ctx context.Context, deps client.Dependencies, request client.AttachRequest) error {
135135 term := &acceptanceRemoteTerminal{in: bytes.NewReader(nil)}
136136 terminals = append(terminals, term)
M internal/app/remote_hosts.go +6 -5
@@ -15,6 +15,7 @@ import (
1515 "github.com/bnema/vev/internal/platform"
1616 "github.com/bnema/vev/internal/ports"
1717 "github.com/bnema/vev/internal/protocol"
18+ "github.com/bnema/vev/internal/protocol/catalogue"
1819 )
1920
2021 const (
@@ -196,21 +197,21 @@ func listAllSessions(ctx context.Context, deps remoteHostDeps, hosts []domain.Re
196197 return nil
197198 }
198199
199-func catalogSessionsAsInfo(host string, sessions []ports.RemoteCatalogSession) []protocol.SessionInfo {
200+func catalogSessionsAsInfo(host string, sessions []catalogue.RemoteCatalogSession) []protocol.SessionInfo {
200201 out := make([]protocol.SessionInfo, 0, len(sessions))
201202 for _, session := range sessions {
202203 info := protocol.SessionInfo{
203204 Name: domain.RemoteSessionDisplay(session.Name, host),
204205 Ephemeral: session.Ephemeral,
205- Tabs: ports.SaturateUint16(ports.CatalogTabCount(session)),
206+ Tabs: catalogue.SaturateUint16(catalogue.CatalogTabCount(session)),
206207 Attached: session.Attached,
207208 }
208209 switch session.State {
209- case ports.RemoteCatalogSessionUp:
210+ case catalogue.RemoteCatalogSessionUp:
210211 info.State = protocol.SessionUp
211- case ports.RemoteCatalogSessionDown:
212+ case catalogue.RemoteCatalogSessionDown:
212213 info.State = protocol.SessionDown
213- case ports.RemoteCatalogSessionBroken:
214+ case catalogue.RemoteCatalogSessionBroken:
214215 info.State = protocol.SessionBroken
215216 default:
216217 slog.Debug("remote catalog session has unknown state", "host", host, "session", session.Name, "state", session.State)
M internal/app/remote_test.go +19 -17
@@ -8,10 +8,12 @@ import (
88 "strings"
99 "testing"
1010
11+ remoteadapter "github.com/bnema/vev/internal/adapters/remote"
1112 "github.com/bnema/vev/internal/domain"
1213 "github.com/bnema/vev/internal/ports"
1314 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1415 "github.com/bnema/vev/internal/protocol"
16+ "github.com/bnema/vev/internal/protocol/catalogue"
1517 "github.com/bnema/vev/internal/protocol/wire"
1618 "github.com/bnema/vev/internal/usecase/client"
1719 "github.com/stretchr/testify/mock"
@@ -173,7 +175,7 @@ func TestRemoteHostListingUsesUnifiedStore(t *testing.T) {
173175 store := portsmocks.NewMockRemoteHostStore(t)
174176 store.EXPECT().Hosts().Return([]string{"arch"}, []string{"beta"}, nil).Once()
175177 catalog := portsmocks.NewMockRemoteCatalogClient(t)
176- catalog.EXPECT().List(mock.Anything, "arch").Return(ports.RemoteCatalog{Sessions: []ports.RemoteCatalogSession{{Name: "build", State: "up"}}}, nil).Once()
178+ catalog.EXPECT().List(mock.Anything, "arch").Return(catalogue.RemoteCatalog{Sessions: []catalogue.RemoteCatalogSession{{Name: "build", State: "up"}}}, nil).Once()
177179 var out bytes.Buffer
178180 err := runRemoteList(context.Background(), command{listHost: "arch"}, remoteHostDeps{
179181 store: store,
@@ -210,9 +212,9 @@ func TestListAllSessionsInvariants(t *testing.T) {
210212 ctx, cancel := context.WithCancel(context.Background())
211213 defer cancel()
212214 catalog := portsmocks.NewMockRemoteCatalogClient(t)
213- catalog.EXPECT().List(mock.Anything, "arch").RunAndReturn(func(context.Context, string) (ports.RemoteCatalog, error) {
215+ catalog.EXPECT().List(mock.Anything, "arch").RunAndReturn(func(context.Context, string) (catalogue.RemoteCatalog, error) {
214216 cancel()
215- return ports.RemoteCatalog{Sessions: []ports.RemoteCatalogSession{{Name: "dev", State: "up"}}}, nil
217+ return catalogue.RemoteCatalog{Sessions: []catalogue.RemoteCatalogSession{{Name: "dev", State: "up"}}}, nil
216218 }).Once()
217219 var out bytes.Buffer
218220 err := listAllSessions(ctx, remoteHostDeps{
@@ -235,10 +237,10 @@ func TestListAllSessionsInvariants(t *testing.T) {
235237 var calls []string
236238 catalog.EXPECT().List(mock.Anything, "arch").Run(func(_ context.Context, target string) {
237239 calls = append(calls, target)
238- }).Return(ports.RemoteCatalog{Sessions: []ports.RemoteCatalogSession{{Name: "dev", State: "up"}}}, nil).Once()
240+ }).Return(catalogue.RemoteCatalog{Sessions: []catalogue.RemoteCatalogSession{{Name: "dev", State: "up"}}}, nil).Once()
239241 catalog.EXPECT().List(mock.Anything, "mule").Run(func(_ context.Context, target string) {
240242 calls = append(calls, target)
241- }).Return(ports.RemoteCatalog{Sessions: []ports.RemoteCatalogSession{{Name: "ops", State: "up"}}}, nil).Once()
243+ }).Return(catalogue.RemoteCatalog{Sessions: []catalogue.RemoteCatalogSession{{Name: "ops", State: "up"}}}, nil).Once()
242244 var out bytes.Buffer
243245 err := listAllSessions(context.Background(), remoteHostDeps{
244246 catalog: catalog,
@@ -263,18 +265,18 @@ func TestListAllSessionsInvariants(t *testing.T) {
263265 func TestCatalogSessionsAsInfoInvariants(t *testing.T) {
264266 tests := []struct {
265267 name string
266- session ports.RemoteCatalogSession
268+ session catalogue.RemoteCatalogSession
267269 wantState protocol.SessionState
268270 wantTabs uint16
269271 }{
270- {name: "up", session: ports.RemoteCatalogSession{Name: "dev", State: "up", Tabs: []ports.RemoteCatalogTab{{}, {}}}, wantState: protocol.SessionUp, wantTabs: 2},
271- {name: "down", session: ports.RemoteCatalogSession{Name: "dev", State: "down", Tabs: []ports.RemoteCatalogTab{{}, {}}}, wantState: protocol.SessionDown, wantTabs: 2},
272- {name: "broken", session: ports.RemoteCatalogSession{Name: "dev", State: "broken", Tabs: []ports.RemoteCatalogTab{{}, {}}}, wantState: protocol.SessionBroken, wantTabs: 2},
273- {name: "unknown fails closed", session: ports.RemoteCatalogSession{Name: "dev", State: "unknown", Tabs: []ports.RemoteCatalogTab{{}, {}}}, wantState: protocol.SessionBroken, wantTabs: 2},
272+ {name: "up", session: catalogue.RemoteCatalogSession{Name: "dev", State: "up", Tabs: []catalogue.RemoteCatalogTab{{}, {}}}, wantState: protocol.SessionUp, wantTabs: 2},
273+ {name: "down", session: catalogue.RemoteCatalogSession{Name: "dev", State: "down", Tabs: []catalogue.RemoteCatalogTab{{}, {}}}, wantState: protocol.SessionDown, wantTabs: 2},
274+ {name: "broken", session: catalogue.RemoteCatalogSession{Name: "dev", State: "broken", Tabs: []catalogue.RemoteCatalogTab{{}, {}}}, wantState: protocol.SessionBroken, wantTabs: 2},
275+ {name: "unknown fails closed", session: catalogue.RemoteCatalogSession{Name: "dev", State: "unknown", Tabs: []catalogue.RemoteCatalogTab{{}, {}}}, wantState: protocol.SessionBroken, wantTabs: 2},
274276 }
275277 for _, tt := range tests {
276278 t.Run(tt.name, func(t *testing.T) {
277- infos := catalogSessionsAsInfo("user@arch", []ports.RemoteCatalogSession{tt.session})
279+ infos := catalogSessionsAsInfo("user@arch", []catalogue.RemoteCatalogSession{tt.session})
278280 require.Len(t, infos, 1)
279281 require.Equal(t, "dev@arch", infos[0].Name)
280282 require.Equal(t, tt.wantState, infos[0].State)
@@ -286,12 +288,12 @@ func TestCatalogSessionsAsInfoInvariants(t *testing.T) {
286288 func TestRunAttachWithDepsRemoteLearning(t *testing.T) {
287289 store := portsmocks.NewMockRemoteHostStore(t)
288290 store.EXPECT().Remember("build@mule").Return(nil).Once()
289- factory := portsmocks.NewMockRemoteDialerFactory(t)
290- factory.EXPECT().DialerForRemote("build@mule", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()
291+ factory := newRemoteDialerFactoryMock(t)
292+ factory.EXPECT().DialerForRemote("build@mule", "work", remoteadapter.TransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()
291293
292294 var learner ports.RemoteHostLearner
293295 err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "build@mule", "", nil, runAttachDeps{
294- remoteDialerFactory: factory,
296+ remoteDialerFactory: factory.DialerForRemote,
295297 hostStore: store,
296298 runClient: func(_ context.Context, deps client.Dependencies, _ client.AttachRequest) error {
297299 learner = deps.RemoteHostLearner
@@ -306,11 +308,11 @@ func TestRunAttachWithDepsRemoteLearning(t *testing.T) {
306308 func TestRunAttachWithDepsAlwaysLearnsRemoteHost(t *testing.T) {
307309 store := portsmocks.NewMockRemoteHostStore(t)
308310 store.EXPECT().Remember("arch").Return(nil).Once()
309- factory := portsmocks.NewMockRemoteDialerFactory(t)
310- factory.EXPECT().DialerForRemote("arch", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()
311+ factory := newRemoteDialerFactoryMock(t)
312+ factory.EXPECT().DialerForRemote("arch", "work", remoteadapter.TransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()
311313 var learner ports.RemoteHostLearner
312314 err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "arch", "", nil, runAttachDeps{
313- remoteDialerFactory: factory,
315+ remoteDialerFactory: factory.DialerForRemote,
314316 hostStore: store,
315317 runClient: func(_ context.Context, deps client.Dependencies, _ client.AttachRequest) error {
316318 learner = deps.RemoteHostLearner
M internal/app/run.go +33 -33
@@ -132,7 +132,7 @@ var (
132132 date = "unknown"
133133
134134 openCatalogue = persist.OpenOrCreate
135- listenDaemon = func(dir string, observer ports.SerializedRuntimeObserver) (ports.Listener, error) {
135+ listenDaemon = func(dir string, observer ports.SerializedRuntimeObserver) (wire.Listener, error) {
136136 return ipc.Listen(dir, ipc.WithRuntimeObserver(observer))
137137 }
138138 )
@@ -343,6 +343,8 @@ func parseHostArgs(args []string) (command, error) {
343343 }
344344 }
345345
346+type remoteDialerForTarget func(target, session string, mode remoteadapter.TransportMode, log *slog.Logger) (wire.Dialer, error)
347+
346348 // performanceTrace creates one serialized timestamp owner for this process.
347349 // An empty trace environment leaves all production behavior and wire bytes
348350 // unchanged.
@@ -354,8 +356,8 @@ var (
354356 newRemoteCatalogCache = remoteadapter.NewFileCatalogCache
355357 newRemoteCatalogClient = remoteadapter.NewCatalogClient
356358 newRemotePreviewClient = remoteadapter.NewPreviewClient
357- newRemoteDialerFactoryWithRuntimeObserver = func(observer ports.SerializedRuntimeObserver) ports.RemoteDialerFactory {
358- return remoteadapter.NewDialerFactoryWithRuntimeObserver(observer)
359+ newRemoteDialerFactoryWithRuntimeObserver = func(observer ports.SerializedRuntimeObserver) remoteDialerForTarget {
360+ return remoteadapter.NewDialerFactoryWithRuntimeObserver(observer).DialerForRemote
359361 }
360362 runClientWithDeps runClientFunc = func(
361363 ctx context.Context,
@@ -408,7 +410,7 @@ func performanceTraceWithFactories(
408410 setupErr := fmt.Errorf("configure runtime trace correlation: %w", err)
409411 return nil, nil, closeTraceAfterSetupFailure(closer, setupErr)
410412 }
411- reporter := ports.NewSerializedRuntimeObserver(observer, runtimeTraceQueueDepth)
413+ reporter := observability.NewSerialized(observer, runtimeTraceQueueDepth)
412414 return reporter, &runtimeTraceCloser{reporter: reporter, closer: closer}, nil
413415 }
414416
@@ -620,8 +622,8 @@ func logStartupRecoveryCounts(log *slog.Logger, records []domain.CatalogueRecord
620622 func constructDaemonBeforeSocketPublication(
621623 construct func() *daemon.Daemon,
622624 prepare func(*daemon.Daemon) error,
623- listen func() (ports.Listener, error),
624-) (*daemon.Daemon, ports.Listener, error) {
625+ listen func() (wire.Listener, error),
626+) (*daemon.Daemon, wire.Listener, error) {
625627 d := construct()
626628 if err := prepare(d); err != nil {
627629 return d, nil, err
@@ -648,7 +650,7 @@ func runDaemonOwnedWithLogger(ctx context.Context, log *slog.Logger) (retErr err
648650 if observerCloser != nil {
649651 defer func() { retErr = errors.Join(retErr, observerCloser.Close()) }()
650652 }
651- remoteDiscoveryOpt, err := remoteDiscoveryDaemonOption(platform.StateDir(), observer, os.Getenv(envRemoteTransport))
653+ remoteDiscoveryOpt, err := remoteDiscoveryDaemonOption(platform.StateDir(), os.Getenv(envRemoteTransport))
652654 if err != nil {
653655 return err
654656 }
@@ -723,7 +725,7 @@ func runDaemonOwnedWithLogger(ctx context.Context, log *slog.Logger) (retErr err
723725 }
724726 return nil
725727 },
726- func() (ports.Listener, error) { return listenDaemon(ipc.SocketDir(), observer) },
728+ func() (wire.Listener, error) { return listenDaemon(ipc.SocketDir(), observer) },
727729 )
728730 if err != nil {
729731 closeErr := opened.Catalogue.Close()
@@ -779,7 +781,7 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) (re
779781 defer func() { retErr = errors.Join(retErr, observerCloser.Close()) }()
780782 }
781783 return runAttachWithDeps(ctx, intent, name, remoteTarget, os.Getenv("VEV"), log, runAttachDeps{
782- localDialer: func() ports.Dialer {
784+ localDialer: func() wire.Dialer {
783785 return localDaemonDialer{dir: ipc.SocketDir(), observer: observer}
784786 },
785787 remoteDialerFactory: newRemoteDialerFactoryWithRuntimeObserver(observer),
@@ -794,17 +796,17 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) (re
794796
795797 const envRemoteTransport = "VEV_REMOTE_TRANSPORT"
796798
797-func defaultLocalDialer() ports.Dialer { return localDaemonDialer{dir: ipc.SocketDir()} }
799+func defaultLocalDialer() wire.Dialer { return localDaemonDialer{dir: ipc.SocketDir()} }
798800
799-func defaultRemoteDialerFactory() ports.RemoteDialerFactory {
800- return remoteadapter.NewDialerFactory()
801+func defaultRemoteDialerFactory() remoteDialerForTarget {
802+ return remoteadapter.NewDialerFactory().DialerForRemote
801803 }
802804
803805 type runClientFunc func(context.Context, client.Dependencies, client.AttachRequest) error
804806
805807 type runAttachDeps struct {
806- localDialer func() ports.Dialer
807- remoteDialerFactory ports.RemoteDialerFactory
808+ localDialer func() wire.Dialer
809+ remoteDialerFactory remoteDialerForTarget
808810 selectedRemoteTransport string
809811 runClient runClientFunc
810812 createDetached func(context.Context, string) error
@@ -819,14 +821,14 @@ type runAttachDeps struct {
819821 hostStore ports.RemoteHostStore
820822 }
821823
822-func remoteTransportModeFromEnv(value string) (ports.RemoteTransportMode, error) {
824+func remoteTransportModeFromEnv(value string) (remoteadapter.TransportMode, error) {
823825 switch value {
824- case "", string(ports.RemoteTransportUDP):
825- return ports.RemoteTransportUDP, nil
826- case string(ports.RemoteTransportStdio):
827- return ports.RemoteTransportStdio, nil
826+ case "", string(remoteadapter.TransportUDP):
827+ return remoteadapter.TransportUDP, nil
828+ case string(remoteadapter.TransportStdio):
829+ return remoteadapter.TransportStdio, nil
828830 default:
829- return "", fmt.Errorf("vev: invalid remote transport %q (want %q or %q)", value, ports.RemoteTransportUDP, ports.RemoteTransportStdio)
831+ return "", fmt.Errorf("vev: invalid remote transport %q (want %q or %q)", value, remoteadapter.TransportUDP, remoteadapter.TransportStdio)
830832 }
831833 }
832834
@@ -853,8 +855,8 @@ func validateRemoteAttachHandoff(target protocol.AttachTarget) error {
853855
854856 // remoteDiscoveryDaemonOption constructs the daemon-owned discovery ports from
855857 // the same validated transport selection used by direct remote attach.
856-func remoteDiscoveryDaemonOption(stateDir string, observer ports.SerializedRuntimeObserver, transport string) (daemon.Option, error) {
857- mode, err := remoteTransportModeFromEnv(transport)
858+func remoteDiscoveryDaemonOption(stateDir, transport string) (daemon.Option, error) {
859+ _, err := remoteTransportModeFromEnv(transport)
858860 if err != nil {
859861 return nil, err
860862 }
@@ -862,8 +864,6 @@ func remoteDiscoveryDaemonOption(stateDir string, observer ports.SerializedRunti
862864 newRemoteHostStore(remoteadapter.HostStorePath(stateDir)),
863865 newRemoteCatalogClient(),
864866 newRemoteCatalogCache(remoteadapter.CatalogCachePath(stateDir)),
865- newRemoteDialerFactoryWithRuntimeObserver(observer),
866- mode,
867867 )
868868 preview := daemon.WithRemotePreview(newRemotePreviewClient())
869869 return func(d *daemon.Daemon) {
@@ -929,7 +929,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
929929 copyTarget := *target.RemoteTarget
930930 selection = &copyTarget
931931 }
932- dialer, err := factory.DialerForRemote(target.Endpoint, target.Session, mode, log)
932+ dialer, err := factory(target.Endpoint, target.Session, mode, log)
933933 if err != nil {
934934 return nil, client.AttachRequest{}, err
935935 }
@@ -957,7 +957,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
957957 if log != nil {
958958 log.Info("attaching to remote session", "target", remoteTarget, "name", name, "transport", string(mode))
959959 }
960- dialer, dialErr := factory.DialerForRemote(remoteTarget, name, mode, log)
960+ dialer, dialErr := factory(remoteTarget, name, mode, log)
961961 if dialErr != nil {
962962 return dialErr
963963 }
@@ -1043,10 +1043,10 @@ type localDaemonDialer struct {
10431043 observer ports.SerializedRuntimeObserver
10441044 }
10451045
1046-func (d localDaemonDialer) Dial(ctx context.Context) (ports.Transport, error) {
1046+func (d localDaemonDialer) Dial(ctx context.Context) (wire.Transport, error) {
10471047 dial := realDial
10481048 if d.observer != nil {
1049- dial = func(ctx context.Context, dir string) (ports.Transport, error) {
1049+ dial = func(ctx context.Context, dir string) (wire.Transport, error) {
10501050 return ipc.DialContext(ctx, dir, ipc.WithRuntimeObserver(d.observer))
10511051 }
10521052 }
@@ -1161,7 +1161,7 @@ func runStdio(ctx context.Context) (retErr error) {
11611161 if observerCloser != nil {
11621162 defer func() { retErr = errors.Join(retErr, observerCloser.Close()) }()
11631163 }
1164- transport, err := ensureDaemonWithLifecycle(ctx, ipc.SocketDir(), func(ctx context.Context, dir string) (ports.Transport, error) {
1164+ transport, err := ensureDaemonWithLifecycle(ctx, ipc.SocketDir(), func(ctx context.Context, dir string) (wire.Transport, error) {
11651165 return ipc.DialContext(ctx, dir, ipc.WithRuntimeObserver(observer))
11661166 }, realSpawn, defaultBackoff)
11671167 if err != nil {
@@ -1293,7 +1293,7 @@ func runUDPProxy(ctx context.Context, ready io.Writer) (retErr error) {
12931293 return err
12941294 }
12951295 defer func() { _ = conn.Close() }()
1296- daemonTr, err := ensureDaemonWithLifecycle(ctx, ipc.SocketDir(), func(ctx context.Context, dir string) (ports.Transport, error) {
1296+ daemonTr, err := ensureDaemonWithLifecycle(ctx, ipc.SocketDir(), func(ctx context.Context, dir string) (wire.Transport, error) {
12971297 return ipc.DialContext(ctx, dir, ipc.WithRuntimeObserver(observer))
12981298 }, realSpawn, defaultBackoff)
12991299 if err != nil {
@@ -1429,15 +1429,15 @@ func listenUDPInRange(ctx context.Context, r udpPortRange) (net.PacketConn, erro
14291429
14301430 // runStdioProxy forwards the framed transport without interpreting protocol
14311431 // messages. Session selection remains in the Hello sent by the thin client.
1432-func runStdioProxy(ctx context.Context, client, daemon ports.Transport, log *slog.Logger) error {
1432+func runStdioProxy(ctx context.Context, client, daemon wire.Transport, log *slog.Logger) error {
14331433 return proxyTransports(ctx, client, daemon, log)
14341434 }
14351435
1436-func runUDPProxyRuntime(ctx context.Context, client, daemon ports.Transport, log *slog.Logger) error {
1436+func runUDPProxyRuntime(ctx context.Context, client, daemon wire.Transport, log *slog.Logger) error {
14371437 return dgram.ProxyRuntime{Client: client, Daemon: daemon, Log: log, IdleTTL: udpProxyIdleTTL}.Run(ctx)
14381438 }
14391439
1440-func proxyTransports(ctx context.Context, a, b ports.Transport, log *slog.Logger) error {
1440+func proxyTransports(ctx context.Context, a, b wire.Transport, log *slog.Logger) error {
14411441 return dgram.ProxyRuntime{Client: a, Daemon: b, Log: log}.Run(ctx)
14421442 }
14431443
M internal/app/run_test.go +47 -45
@@ -23,8 +23,10 @@ import (
2323 "github.com/bnema/vev/internal/domain"
2424 "github.com/bnema/vev/internal/persist"
2525 "github.com/bnema/vev/internal/ports"
26- portsmocks "github.com/bnema/vev/internal/ports/mocks"
2726 "github.com/bnema/vev/internal/protocol"
27+ "github.com/bnema/vev/internal/protocol/catalogue"
28+ "github.com/bnema/vev/internal/protocol/wire"
29+ wiremocks "github.com/bnema/vev/internal/protocol/wire/mocks"
2830 "github.com/bnema/vev/internal/usecase/client"
2931 "github.com/bnema/vev/internal/usecase/daemon"
3032 "github.com/bnema/vev/pkg/kv"
@@ -220,7 +222,7 @@ func TestCatalogueFailureDoesNotListen(t *testing.T) {
220222 require.Equal(t, stateDir, gotStateDir)
221223 return persist.OpenResult{}, catalogueErr
222224 }
223- listenDaemon = func(string, ports.SerializedRuntimeObserver) (ports.Listener, error) {
225+ listenDaemon = func(string, ports.SerializedRuntimeObserver) (wire.Listener, error) {
224226 listenCalls++
225227 return nil, errors.New("IPC listen must not run after catalogue failure")
226228 }
@@ -251,7 +253,7 @@ func TestCatalogueRegistryConstructionPrecedesSocketPublication(t *testing.T) {
251253 events = append(events, "startup-garbage-collection")
252254 return nil
253255 },
254- func() (ports.Listener, error) {
256+ func() (wire.Listener, error) {
255257 require.Equal(t, []string{"catalogue-registry", "startup-garbage-collection"}, events)
256258 events = append(events, "socket-publication")
257259 return nil, nil
@@ -577,7 +579,7 @@ func TestAttachTargetsCreateCommonSessionRequest(t *testing.T) {
577579 t.Run(tt.name, func(t *testing.T) {
578580 cmd, err := parseArgs([]string{"attach", tt.arg})
579581 require.NoError(t, err)
580- connection, err := client.NewSessionConnection(sessionwire.NewClientConnection(portsmocks.NewMockTransport(t)), client.SessionTarget{
582+ connection, err := client.NewSessionConnection(sessionwire.NewClientConnection(wiremocks.NewMockTransport(t)), client.SessionTarget{
581583 Intent: cmd.intent,
582584 SessionName: cmd.name,
583585 })
@@ -808,7 +810,7 @@ func TestDetachedLocalHelloIncludesTrueColor(t *testing.T) {
808810
809811 type namedDialer struct{ name string }
810812
811-func (d namedDialer) Dial(context.Context) (ports.Transport, error) {
813+func (d namedDialer) Dial(context.Context) (wire.Transport, error) {
812814 return nil, fmt.Errorf("not used: %s", d.name)
813815 }
814816
@@ -827,10 +829,10 @@ func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) {
827829 tests := []struct {
828830 name string
829831 selectedTransport string
830- wantMode ports.RemoteTransportMode
832+ wantMode remoteadapter.TransportMode
831833 }{
832- {name: "default remote mode is udp", selectedTransport: "", wantMode: ports.RemoteTransportUDP},
833- {name: "explicit stdio mode", selectedTransport: "stdio", wantMode: ports.RemoteTransportStdio},
834+ {name: "default remote mode is udp", selectedTransport: "", wantMode: remoteadapter.TransportUDP},
835+ {name: "explicit stdio mode", selectedTransport: "stdio", wantMode: remoteadapter.TransportStdio},
834836 }
835837
836838 for _, tt := range tests {
@@ -839,11 +841,11 @@ func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) {
839841 var gotRemote bool
840842 var gotClipboard ports.ClipboardReader
841843 clip := &fakeClipboardReader{}
842- factory := portsmocks.NewMockRemoteDialerFactory(t)
844+ factory := newRemoteDialerFactoryMock(t)
843845 factory.EXPECT().DialerForRemote("remote.example", "work", tt.wantMode, mock.Anything).Return(namedDialer{name: "remote"}, nil)
844846
845847 err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
846- remoteDialerFactory: factory,
848+ remoteDialerFactory: factory.DialerForRemote,
847849 selectedRemoteTransport: tt.selectedTransport,
848850 clipboard: clip,
849851 runClient: func(ctx context.Context, deps client.Dependencies, request client.AttachRequest) error {
@@ -877,12 +879,12 @@ func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) {
877879 }
878880
879881 func TestRunAttachWithDepsDirectLegacyCallbackPreservesClientOwnership(t *testing.T) {
880- factory := portsmocks.NewMockRemoteDialerFactory(t)
881- factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote-1"}, nil).Once()
882- factory.EXPECT().DialerForRemote("selected.example", "picked", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote-2"}, nil).Once()
882+ factory := newRemoteDialerFactoryMock(t)
883+ factory.EXPECT().DialerForRemote("remote.example", "work", remoteadapter.TransportUDP, mock.Anything).Return(namedDialer{name: "remote-1"}, nil).Once()
884+ factory.EXPECT().DialerForRemote("selected.example", "picked", remoteadapter.TransportUDP, mock.Anything).Return(namedDialer{name: "remote-2"}, nil).Once()
883885
884886 err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
885- remoteDialerFactory: factory,
887+ remoteDialerFactory: factory.DialerForRemote,
886888 runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error {
887889 require.True(t, request.Remote)
888890 require.Equal(t, protocol.EnvironmentPolicyClientOwned, request.EnvironmentPolicy)
@@ -900,12 +902,12 @@ func TestRunAttachWithDepsDirectLegacyCallbackPreservesClientOwnership(t *testin
900902 }
901903
902904 func TestRunAttachWithDepsRemotePickerHandoffReopensDirectConnection(t *testing.T) {
903- factory := portsmocks.NewMockRemoteDialerFactory(t)
904- factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote-1"}, nil).Once()
905- factory.EXPECT().DialerForRemote("selected.example", "picked", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote-2"}, nil).Once()
905+ factory := newRemoteDialerFactoryMock(t)
906+ factory.EXPECT().DialerForRemote("remote.example", "work", remoteadapter.TransportUDP, mock.Anything).Return(namedDialer{name: "remote-1"}, nil).Once()
907+ factory.EXPECT().DialerForRemote("selected.example", "picked", remoteadapter.TransportUDP, mock.Anything).Return(namedDialer{name: "remote-2"}, nil).Once()
906908 var calls int
907909 err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
908- remoteDialerFactory: factory,
910+ remoteDialerFactory: factory.DialerForRemote,
909911 runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error {
910912 calls++
911913 require.NotNil(t, deps.Dialer)
@@ -922,12 +924,12 @@ func TestRunAttachWithDepsRemotePickerHandoffReopensDirectConnection(t *testing.
922924 }
923925
924926 func TestRunAttachWithDepsAllowsRevisitingRemoteHandoff(t *testing.T) {
925- factory := portsmocks.NewMockRemoteDialerFactory(t)
926- factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()
927- factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote-revisit"}, nil).Once()
927+ factory := newRemoteDialerFactoryMock(t)
928+ factory.EXPECT().DialerForRemote("remote.example", "work", remoteadapter.TransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()
929+ factory.EXPECT().DialerForRemote("remote.example", "work", remoteadapter.TransportUDP, mock.Anything).Return(namedDialer{name: "remote-revisit"}, nil).Once()
928930
929931 err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
930- remoteDialerFactory: factory,
932+ remoteDialerFactory: factory.DialerForRemote,
931933 runClient: func(_ context.Context, deps client.Dependencies, _ client.AttachRequest) error {
932934 dialer, request, err := deps.AttachHandoff(protocol.AttachTarget{
933935 Endpoint: "remote.example", Session: "work", Intent: protocol.IntentAttach,
@@ -942,12 +944,12 @@ func TestRunAttachWithDepsAllowsRevisitingRemoteHandoff(t *testing.T) {
942944 }
943945
944946 func TestRunAttachWithDepsBoundsRepeatedAttachTargetHandoffs(t *testing.T) {
945- factory := portsmocks.NewMockRemoteDialerFactory(t)
946- factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Times(maxAttachTargetHandoffs + 1)
947+ factory := newRemoteDialerFactoryMock(t)
948+ factory.EXPECT().DialerForRemote("remote.example", "work", remoteadapter.TransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Times(maxAttachTargetHandoffs + 1)
947949
948950 calls := 0
949951 err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
950- remoteDialerFactory: factory,
952+ remoteDialerFactory: factory.DialerForRemote,
951953 runClient: func(_ context.Context, _ client.Dependencies, _ client.AttachRequest) error {
952954 calls++
953955 return &client.AttachTargetError{Target: protocol.AttachTarget{Endpoint: "remote.example", Session: "work", Intent: protocol.IntentAttach}}
@@ -958,13 +960,13 @@ func TestRunAttachWithDepsBoundsRepeatedAttachTargetHandoffs(t *testing.T) {
958960 }
959961
960962 func TestRunAttachWithDepsLocalPickerHandoffAttachesSelectedRemote(t *testing.T) {
961- factory := portsmocks.NewMockRemoteDialerFactory(t)
962- factory.EXPECT().DialerForRemote("selected.example", "picked", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()
963+ factory := newRemoteDialerFactoryMock(t)
964+ factory.EXPECT().DialerForRemote("selected.example", "picked", remoteadapter.TransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()
963965
964966 localCalls, remoteCalls := 0, 0
965967 err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "", "", nil, runAttachDeps{
966- localDialer: func() ports.Dialer { return namedDialer{name: "local"} },
967- remoteDialerFactory: factory,
968+ localDialer: func() wire.Dialer { return namedDialer{name: "local"} },
969+ remoteDialerFactory: factory.DialerForRemote,
968970 runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error {
969971 if deps.Remote {
970972 remoteCalls++
@@ -1003,11 +1005,11 @@ func TestRunAttachWithDepsRejectsInvalidHandoffBeforeDialing(t *testing.T) {
10031005 }
10041006 for _, tt := range tests {
10051007 t.Run(tt.name, func(t *testing.T) {
1006- factory := portsmocks.NewMockRemoteDialerFactory(t)
1007- factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()
1008+ factory := newRemoteDialerFactoryMock(t)
1009+ factory.EXPECT().DialerForRemote("remote.example", "work", remoteadapter.TransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()
10081010 calls := 0
10091011 err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
1010- remoteDialerFactory: factory,
1012+ remoteDialerFactory: factory.DialerForRemote,
10111013 runClient: func(_ context.Context, _ client.Dependencies, _ client.AttachRequest) error {
10121014 calls++
10131015 return &client.AttachTargetError{Target: tt.target}
@@ -1020,11 +1022,11 @@ func TestRunAttachWithDepsRejectsInvalidHandoffBeforeDialing(t *testing.T) {
10201022 }
10211023
10221024 func TestRunAttachWithDepsRejectsInvalidRemoteTransportBeforeDialing(t *testing.T) {
1023- factory := portsmocks.NewMockRemoteDialerFactory(t)
1025+ factory := newRemoteDialerFactoryMock(t)
10241026 runClientCalled := false
10251027
10261028 err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
1027- remoteDialerFactory: factory,
1029+ remoteDialerFactory: factory.DialerForRemote,
10281030 selectedRemoteTransport: "serial",
10291031 runClient: func(context.Context, client.Dependencies, client.AttachRequest) error {
10301032 runClientCalled = true
@@ -1041,12 +1043,12 @@ func TestRunAttachWithDepsRejectsInvalidRemoteTransportBeforeDialing(t *testing.
10411043
10421044 func TestRunAttachWithDepsReturnsFactoryErrorBeforeRunClient(t *testing.T) {
10431045 factoryErr := errors.New("factory failed")
1044- factory := portsmocks.NewMockRemoteDialerFactory(t)
1045- factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(nil, factoryErr)
1046+ factory := newRemoteDialerFactoryMock(t)
1047+ factory.EXPECT().DialerForRemote("remote.example", "work", remoteadapter.TransportUDP, mock.Anything).Return(nil, factoryErr)
10461048 runClientCalled := false
10471049
10481050 err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
1049- remoteDialerFactory: factory,
1051+ remoteDialerFactory: factory.DialerForRemote,
10501052 runClient: func(context.Context, client.Dependencies, client.AttachRequest) error {
10511053 runClientCalled = true
10521054 return nil
@@ -1064,10 +1066,10 @@ func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) {
10641066 var gotDialer string
10651067 gotRemote := true
10661068 gotClipboard := ports.ClipboardReader(&fakeClipboardReader{})
1067- factory := portsmocks.NewMockRemoteDialerFactory(t)
1069+ factory := newRemoteDialerFactoryMock(t)
10681070 err := runAttachWithDeps(context.Background(), protocol.IntentEphemeral, "", "", "", nil, runAttachDeps{
1069- localDialer: func() ports.Dialer { return namedDialer{name: "local"} },
1070- remoteDialerFactory: factory,
1071+ localDialer: func() wire.Dialer { return namedDialer{name: "local"} },
1072+ remoteDialerFactory: factory.DialerForRemote,
10711073 clipboard: &fakeClipboardReader{}, // must NOT reach runClient for a local attach
10721074 runClient: func(ctx context.Context, deps client.Dependencies, request client.AttachRequest) error {
10731075 requireNamedClientDialer(t, ctx, deps.Dialer, "local")
@@ -1287,12 +1289,12 @@ type appCatalogCacheStub struct {
12871289 loads int
12881290 }
12891291
1290-func (s *appCatalogCacheStub) Load() ([]ports.RemoteCatalogCacheEntry, error) {
1292+func (s *appCatalogCacheStub) Load() ([]catalogue.RemoteCatalogCacheEntry, error) {
12911293 s.loads++
12921294 return nil, nil
12931295 }
12941296
1295-func (s *appCatalogCacheStub) Store([]ports.RemoteCatalogCacheEntry) error { return nil }
1297+func (s *appCatalogCacheStub) Store([]catalogue.RemoteCatalogCacheEntry) error { return nil }
12961298
12971299 func TestRemoteDiscoveryDaemonOptionWiresProductionPorts(t *testing.T) {
12981300 oldHostStore := newRemoteHostStore
@@ -1318,9 +1320,9 @@ func TestRemoteDiscoveryDaemonOptionWiresProductionPorts(t *testing.T) {
13181320 return cache
13191321 }
13201322 newRemoteCatalogClient = func() ports.RemoteCatalogClient { return nil }
1321- newRemoteDialerFactoryWithRuntimeObserver = func(ports.SerializedRuntimeObserver) ports.RemoteDialerFactory { return nil }
1323+ newRemoteDialerFactoryWithRuntimeObserver = func(ports.SerializedRuntimeObserver) remoteDialerForTarget { return nil }
13221324
1323- option, err := remoteDiscoveryDaemonOption(stateDir, nil, "stdio")
1325+ option, err := remoteDiscoveryDaemonOption(stateDir, "stdio")
13241326 require.NoError(t, err)
13251327 _ = daemon.New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)), option)
13261328 require.Equal(t, remoteadapter.HostStorePath(stateDir), hostPath)
@@ -1329,6 +1331,6 @@ func TestRemoteDiscoveryDaemonOptionWiresProductionPorts(t *testing.T) {
13291331 }
13301332
13311333 func TestRemoteDiscoveryDaemonOptionRejectsInvalidTransport(t *testing.T) {
1332- _, err := remoteDiscoveryDaemonOption(t.TempDir(), nil, "serial")
1334+ _, err := remoteDiscoveryDaemonOption(t.TempDir(), "serial")
13331335 require.EqualError(t, err, `vev: invalid remote transport "serial" (want "udp" or "stdio")`)
13341336 }
M internal/app/spawn.go +9 -9
@@ -16,7 +16,7 @@ import (
1616 "github.com/bnema/vev/internal/adapters/ipc"
1717 "github.com/bnema/vev/internal/adapters/lifecycle"
1818 "github.com/bnema/vev/internal/platform"
19- "github.com/bnema/vev/internal/ports"
19+ "github.com/bnema/vev/internal/protocol/wire"
2020 "github.com/bnema/vev/pkg/safedir"
2121 )
2222
@@ -35,7 +35,7 @@ const staleLockAge = 10 * time.Second
3535
3636 // dialFunc dials the daemon socket in dir. Injected so tests can drive the
3737 // spawn/backoff logic without a real socket.
38-type dialFunc func(ctx context.Context, dir string) (ports.Transport, error)
38+type dialFunc func(ctx context.Context, dir string) (wire.Transport, error)
3939
4040 // spawnFunc launches a detached daemon process. Injected so tests never
4141 // re-exec a real binary.
@@ -115,11 +115,11 @@ func waitForLifecycleAvailability(ctx context.Context, runtimeDir string, cfg ba
115115 }
116116
117117 type daemonOrLifecycle struct {
118- transport ports.Transport
118+ transport wire.Transport
119119 owner lifecycleOwnership
120120 }
121121
122-func waitForDaemonOrLifecycle(ctx context.Context, dir string, dial dialFunc, cfg backoffConfig) (ports.Transport, lifecycleOwnership, error) {
122+func waitForDaemonOrLifecycle(ctx context.Context, dir string, dial dialFunc, cfg backoffConfig) (wire.Transport, lifecycleOwnership, error) {
123123 result, err := retryAttempts(ctx, cfg, func() (daemonOrLifecycle, bool, error) {
124124 if transport, err := dial(ctx, dir); err == nil {
125125 return daemonOrLifecycle{transport: transport}, true, nil
@@ -149,7 +149,7 @@ func waitBackoff(ctx context.Context, duration time.Duration) error {
149149
150150 // ensureDaemonWithLifecycle never elects a spawner while another lifecycle
151151 // owner may still be initializing or tearing down durable state.
152-func ensureDaemonWithLifecycle(ctx context.Context, dir string, dial dialFunc, spawn spawnFunc, cfg backoffConfig) (ports.Transport, error) {
152+func ensureDaemonWithLifecycle(ctx context.Context, dir string, dial dialFunc, spawn spawnFunc, cfg backoffConfig) (wire.Transport, error) {
153153 if err := ctx.Err(); err != nil {
154154 return nil, err
155155 }
@@ -172,7 +172,7 @@ func ensureDaemonWithLifecycle(ctx context.Context, dir string, dial dialFunc, s
172172 // ensureDaemon returns a transport to a running daemon, spawning one if
173173 // necessary after ensureDaemonWithLifecycle has established lifecycle
174174 // availability. Tests also exercise this lower-level spawn election directly.
175-func ensureDaemon(ctx context.Context, dir string, dial dialFunc, spawn spawnFunc, cfg backoffConfig) (ports.Transport, error) {
175+func ensureDaemon(ctx context.Context, dir string, dial dialFunc, spawn spawnFunc, cfg backoffConfig) (wire.Transport, error) {
176176 if err := ctx.Err(); err != nil {
177177 return nil, err
178178 }
@@ -237,8 +237,8 @@ func acquireSpawnLock(dir string) (release func(), acquired bool, err error) {
237237
238238 // retryDial dials repeatedly with exponential backoff until the daemon
239239 // answers, the context is cancelled, or the total budget is exhausted.
240-func retryDial(ctx context.Context, dir string, dial dialFunc, cfg backoffConfig) (ports.Transport, error) {
241- transport, err := retryAttempts(ctx, cfg, func() (ports.Transport, bool, error) {
240+func retryDial(ctx context.Context, dir string, dial dialFunc, cfg backoffConfig) (wire.Transport, error) {
241+ transport, err := retryAttempts(ctx, cfg, func() (wire.Transport, bool, error) {
242242 transport, err := dial(ctx, dir)
243243 return transport, err == nil, nil
244244 })
@@ -249,7 +249,7 @@ func retryDial(ctx context.Context, dir string, dial dialFunc, cfg backoffConfig
249249 }
250250
251251 // realDial is the production dialer.
252-func realDial(ctx context.Context, dir string) (ports.Transport, error) {
252+func realDial(ctx context.Context, dir string) (wire.Transport, error) {
253253 return ipc.DialContext(ctx, dir)
254254 }
255255
M internal/app/spawn_test.go +18 -13
@@ -13,12 +13,17 @@ import (
1313 "testing"
1414 "time"
1515
16- "github.com/bnema/vev/internal/ports"
17- portsmocks "github.com/bnema/vev/internal/ports/mocks"
16+ "github.com/bnema/vev/internal/protocol/wire"
1817 )
1918
2019 var errDialFailed = errors.New("dial failed")
2120
21+type spawnTestTransport struct{}
22+
23+func (spawnTestTransport) Send(wire.Frame) error { return nil }
24+func (spawnTestTransport) Recv() (wire.Frame, error) { return wire.Frame{}, nil }
25+func (spawnTestTransport) Close() error { return nil }
26+
2227 const (
2328 spawnTestChildFileEnv = "VEV_SPAWN_TEST_CHILD_FILE"
2429 spawnTestLauncherFileEnv = "VEV_SPAWN_TEST_LAUNCHER_FILE"
@@ -305,9 +310,9 @@ func TestRetryAttemptsClampsNonPositiveInitialDelay(t *testing.T) {
305310 }
306311
307312 func TestRetryDialSucceedsAfterTransientFailures(t *testing.T) {
308- want := portsmocks.NewMockTransport(t)
313+ want := spawnTestTransport{}
309314 var calls atomic.Int32
310- dial := func(context.Context, string) (ports.Transport, error) {
315+ dial := func(context.Context, string) (wire.Transport, error) {
311316 if calls.Add(1) <= 3 {
312317 return nil, errDialFailed
313318 }
@@ -327,7 +332,7 @@ func TestRetryDialSucceedsAfterTransientFailures(t *testing.T) {
327332 }
328333
329334 func TestRetryDialPermanentFailure(t *testing.T) {
330- dial := func(context.Context, string) (ports.Transport, error) { return nil, errDialFailed }
335+ dial := func(context.Context, string) (wire.Transport, error) { return nil, errDialFailed }
331336 _, err := retryDial(context.Background(), t.TempDir(), dial, fastBackoff)
332337 if !errors.Is(err, ErrDaemonUnreachable) {
333338 t.Fatalf("retryDial error = %v, want ErrDaemonUnreachable", err)
@@ -338,7 +343,7 @@ func TestRetryDialContextCancelled(t *testing.T) {
338343 ctx, cancel := context.WithCancel(context.Background())
339344 cancel()
340345 var calls atomic.Int32
341- dial := func(context.Context, string) (ports.Transport, error) {
346+ dial := func(context.Context, string) (wire.Transport, error) {
342347 calls.Add(1)
343348 return nil, errDialFailed
344349 }
@@ -354,7 +359,7 @@ func TestRetryDialContextCancelled(t *testing.T) {
354359 func TestEnsureDaemonContextCancelledDoesNotDialOrSpawn(t *testing.T) {
355360 ctx, cancel := context.WithCancel(context.Background())
356361 cancel()
357- dial := func(context.Context, string) (ports.Transport, error) {
362+ dial := func(context.Context, string) (wire.Transport, error) {
358363 t.Fatal("ensureDaemon dialed with a canceled context")
359364 return nil, nil
360365 }
@@ -373,7 +378,7 @@ func TestEnsureDaemonDoesNotSpawnWhenContextCancelsAfterFailedDial(t *testing.T)
373378 ctx, cancel := context.WithCancel(context.Background())
374379 defer cancel()
375380 var spawnCalls atomic.Int32
376- dial := func(context.Context, string) (ports.Transport, error) {
381+ dial := func(context.Context, string) (wire.Transport, error) {
377382 cancel()
378383 return nil, errDialFailed
379384 }
@@ -393,9 +398,9 @@ func TestEnsureDaemonDoesNotSpawnWhenContextCancelsAfterFailedDial(t *testing.T)
393398
394399 func TestEnsureDaemonSpawnsThenDials(t *testing.T) {
395400 dir := filepath.Join(t.TempDir(), "vev")
396- want := portsmocks.NewMockTransport(t)
401+ want := spawnTestTransport{}
397402 var dialCalls, spawnCalls atomic.Int32
398- dial := func(context.Context, string) (ports.Transport, error) {
403+ dial := func(context.Context, string) (wire.Transport, error) {
399404 // First (pre-spawn) dial fails; the post-spawn retry succeeds.
400405 if dialCalls.Add(1) == 1 {
401406 return nil, errDialFailed
@@ -424,9 +429,9 @@ func TestEnsureDaemonSpawnsThenDials(t *testing.T) {
424429 }
425430
426431 func TestEnsureDaemonReturnsExistingDaemon(t *testing.T) {
427- want := portsmocks.NewMockTransport(t)
432+ want := spawnTestTransport{}
428433 var spawnCalls atomic.Int32
429- dial := func(context.Context, string) (ports.Transport, error) { return want, nil }
434+ dial := func(context.Context, string) (wire.Transport, error) { return want, nil }
430435 spawn := func() error { spawnCalls.Add(1); return nil }
431436
432437 got, err := ensureDaemon(context.Background(), t.TempDir(), dial, spawn, fastBackoff)
@@ -443,7 +448,7 @@ func TestEnsureDaemonReturnsExistingDaemon(t *testing.T) {
443448
444449 func TestEnsureDaemonSpawnFailure(t *testing.T) {
445450 spawnErr := errors.New("exec boom")
446- dial := func(context.Context, string) (ports.Transport, error) { return nil, errDialFailed }
451+ dial := func(context.Context, string) (wire.Transport, error) { return nil, errDialFailed }
447452 spawn := func() error { return spawnErr }
448453
449454 _, err := ensureDaemon(context.Background(), filepath.Join(t.TempDir(), "vev"), dial, spawn, fastBackoff)
M internal/app/transport_observability_test.go +9 -7
@@ -13,6 +13,8 @@ import (
1313 "testing"
1414 "time"
1515
16+ "github.com/bnema/vev/internal/adapters/observability"
17+ remoteadapter "github.com/bnema/vev/internal/adapters/remote"
1618 "github.com/bnema/vev/internal/ports"
1719 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1820 "github.com/bnema/vev/internal/protocol"
@@ -182,7 +184,7 @@ func TestRunAttachJoinsTraceCloseError(t *testing.T) {
182184 traceCloseErr := errors.New("trace close failed")
183185 original := newPerformanceTrace
184186 newPerformanceTrace = func(ports.Clock) (ports.SerializedRuntimeObserver, io.Closer, error) {
185- return ports.NewSerializedRuntimeObserver(runtimeObserverFunc(func(ports.RuntimeMark) {}), 1), errorCloser{err: traceCloseErr}, nil
187+ return observability.NewSerialized(runtimeObserverFunc(func(ports.RuntimeMark) {}), 1), errorCloser{err: traceCloseErr}, nil
186188 }
187189 t.Cleanup(func() { newPerformanceTrace = original })
188190 t.Setenv("VEV", "")
@@ -211,9 +213,9 @@ func TestRunAttachPropagatesOneObserverToRemoteTransportFactory(t *testing.T) {
211213 t.Setenv("VEV", "")
212214 t.Setenv("XDG_STATE_HOME", t.TempDir())
213215 t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
214- t.Setenv(envRemoteTransport, string(ports.RemoteTransportStdio))
216+ t.Setenv(envRemoteTransport, string(remoteadapter.TransportStdio))
215217
216- observer := ports.NewSerializedRuntimeObserver(runtimeObserverFunc(func(ports.RuntimeMark) {}), 1)
218+ observer := observability.NewSerialized(runtimeObserverFunc(func(ports.RuntimeMark) {}), 1)
217219 originalTrace := newPerformanceTrace
218220 traceCalls := 0
219221 newPerformanceTrace = func(ports.Clock) (ports.SerializedRuntimeObserver, io.Closer, error) {
@@ -222,16 +224,16 @@ func TestRunAttachPropagatesOneObserverToRemoteTransportFactory(t *testing.T) {
222224 }
223225 t.Cleanup(func() { newPerformanceTrace = originalTrace })
224226
225- factory := portsmocks.NewMockRemoteDialerFactory(t)
226- factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportStdio, mock.Anything).Return(namedDialer{name: "remote"}, nil)
227+ factory := newRemoteDialerFactoryMock(t)
228+ factory.EXPECT().DialerForRemote("remote.example", "work", remoteadapter.TransportStdio, mock.Anything).Return(namedDialer{name: "remote"}, nil)
227229 originalFactory := newRemoteDialerFactoryWithRuntimeObserver
228230 factoryCalls := 0
229- newRemoteDialerFactoryWithRuntimeObserver = func(got ports.SerializedRuntimeObserver) ports.RemoteDialerFactory {
231+ newRemoteDialerFactoryWithRuntimeObserver = func(got ports.SerializedRuntimeObserver) remoteDialerForTarget {
230232 factoryCalls++
231233 if got != observer {
232234 t.Fatalf("remote transport observer = %v, want process observer %v", got, observer)
233235 }
234- return factory
236+ return factory.DialerForRemote
235237 }
236238 t.Cleanup(func() { newRemoteDialerFactoryWithRuntimeObserver = originalFactory })
237239
A internal/domain/terminalcap/capabilities.go +100 -0
@@ -0,0 +1,100 @@
1+// Package terminalcap owns pure terminal capability values and detection policy.
2+package terminalcap
3+
4+import "strings"
5+
6+// ColorMode is the color output mode selected for one attachment.
7+type ColorMode uint8
8+
9+const (
10+ // TrueColor is the zero value so manually constructed attachments retain the
11+ // historical renderer behavior; live Hello paths always use the detector.
12+ TrueColor ColorMode = iota
13+ Indexed256
14+)
15+
16+// Source records how confidently a terminal capability was selected.
17+type Source uint8
18+
19+const (
20+ SourceUnknown Source = iota
21+ SourceHeuristic
22+ SourceDeclared
23+)
24+
25+// Application identifies a known terminal application when its environment
26+// provides a trustworthy origin signal.
27+type Application uint8
28+
29+const (
30+ ApplicationUnknown Application = iota
31+ ApplicationKitty
32+)
33+
34+// Capabilities describes the output features selected for one client attachment.
35+type Capabilities struct {
36+ ColorMode ColorMode
37+ ColorSource Source
38+ Application Application
39+ KittyGraphics bool
40+}
41+
42+// SupportsKittyGraphics reports whether the active outer-terminal probe
43+// accepted the Kitty graphics protocol. Environment detection never sets it.
44+func (c Capabilities) SupportsKittyGraphics() bool { return c.KittyGraphics }
45+
46+// TrueColor reports whether this attachment can receive RGB ANSI output.
47+func (c Capabilities) TrueColor() bool { return c.ColorMode == TrueColor }
48+
49+// Detect derives conservative attachment capabilities from a client environment.
50+func Detect(env []string) Capabilities {
51+ values := environmentValues(env)
52+ term := strings.ToLower(strings.TrimSpace(values["TERM"]))
53+ colorTerm := strings.ToLower(strings.TrimSpace(values["COLORTERM"]))
54+ caps := Capabilities{ColorMode: Indexed256}
55+
56+ if values["KITTY_WINDOW_ID"] != "" || values["KITTY_PID"] != "" || values["KITTY_LISTEN_ON"] != "" {
57+ caps.Application = ApplicationKitty
58+ }
59+
60+ kittyIdentity := term == "xterm-kitty" && caps.Application == ApplicationKitty
61+ switch colorTerm {
62+ case "truecolor", "24bit":
63+ caps.ColorMode = TrueColor
64+ caps.ColorSource = SourceDeclared
65+ }
66+ if term == "xterm-direct" || strings.HasSuffix(term, "-direct") {
67+ caps.ColorMode = TrueColor
68+ caps.ColorSource = SourceDeclared
69+ }
70+ if kittyIdentity {
71+ if caps.ColorSource == SourceUnknown {
72+ caps.ColorSource = SourceHeuristic
73+ }
74+ caps.ColorMode = TrueColor
75+ return caps
76+ }
77+ if caps.ColorSource == SourceDeclared {
78+ return caps
79+ }
80+ if strings.Contains(term, "256color") || term == "dumb" {
81+ caps.ColorSource = SourceDeclared
82+ }
83+ return caps
84+}
85+
86+func environmentValues(env []string) map[string]string {
87+ values := make(map[string]string, len(env))
88+ for _, entry := range env {
89+ key, value, ok := strings.Cut(entry, "=")
90+ if ok {
91+ values[key] = value
92+ }
93+ }
94+ return values
95+}
96+
97+// DetectTrueColor reports whether TERM/COLORTERM advertise direct color support.
98+func DetectTrueColor(termEnv, colorTerm string) bool {
99+ return Detect([]string{"TERM=" + termEnv, "COLORTERM=" + colorTerm}).TrueColor()
100+}
A internal/domain/terminalcap/capabilities_test.go +35 -0
@@ -0,0 +1,35 @@
1+package terminalcap
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestDetect(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ env []string
13+ wantMode ColorMode
14+ wantSource Source
15+ wantApp Application
16+ wantGraphics bool
17+ }{
18+ {name: "declared truecolor", env: []string{"TERM=xterm-256color", "COLORTERM=truecolor"}, wantMode: TrueColor, wantSource: SourceDeclared},
19+ {name: "kitty signals infer truecolor", env: []string{"TERM=xterm-kitty", "KITTY_WINDOW_ID=1"}, wantMode: TrueColor, wantSource: SourceHeuristic, wantApp: ApplicationKitty},
20+ {name: "kitty environment with declared truecolor remains color-only", env: []string{"TERM=xterm-kitty", "COLORTERM=truecolor", "KITTY_WINDOW_ID=1"}, wantMode: TrueColor, wantSource: SourceDeclared, wantApp: ApplicationKitty},
21+ {name: "256 color terminal remains a constrained attachment", env: []string{"TERM=xterm-256color"}, wantMode: Indexed256, wantSource: SourceDeclared},
22+ {name: "unknown terminal is conservatively indexed", env: []string{"TERM=unknown"}, wantMode: Indexed256, wantSource: SourceUnknown},
23+ {name: "kitty environment behind a multiplexer is not graphics evidence", env: []string{"TERM=tmux-256color", "KITTY_WINDOW_ID=1"}, wantMode: Indexed256, wantSource: SourceDeclared, wantApp: ApplicationKitty},
24+ }
25+
26+ for _, tt := range tests {
27+ t.Run(tt.name, func(t *testing.T) {
28+ got := Detect(tt.env)
29+ require.Equal(t, tt.wantMode, got.ColorMode)
30+ require.Equal(t, tt.wantSource, got.ColorSource)
31+ require.Equal(t, tt.wantApp, got.Application)
32+ require.Equal(t, tt.wantGraphics, got.SupportsKittyGraphics())
33+ })
34+ }
35+}
M internal/ports/mocks/remote_mock.go +22 -130
@@ -6,121 +6,13 @@ package portsmocks
66
77 import (
88 "context"
9- "log/slog"
109
1110 "github.com/bnema/vev/internal/domain"
12- "github.com/bnema/vev/internal/ports"
1311 "github.com/bnema/vev/internal/protocol"
12+ "github.com/bnema/vev/internal/protocol/catalogue"
1413 mock "github.com/stretchr/testify/mock"
1514 )
1615
17-// NewMockRemoteDialerFactory creates a new instance of MockRemoteDialerFactory. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
18-// The first argument is typically a *testing.T value.
19-func NewMockRemoteDialerFactory(t interface {
20- mock.TestingT
21- Cleanup(func())
22-}) *MockRemoteDialerFactory {
23- mock := &MockRemoteDialerFactory{}
24- mock.Mock.Test(t)
25-
26- t.Cleanup(func() { mock.AssertExpectations(t) })
27-
28- return mock
29-}
30-
31-// MockRemoteDialerFactory is an autogenerated mock type for the RemoteDialerFactory type
32-type MockRemoteDialerFactory struct {
33- mock.Mock
34-}
35-
36-type MockRemoteDialerFactory_Expecter struct {
37- mock *mock.Mock
38-}
39-
40-func (_m *MockRemoteDialerFactory) EXPECT() *MockRemoteDialerFactory_Expecter {
41- return &MockRemoteDialerFactory_Expecter{mock: &_m.Mock}
42-}
43-
44-// DialerForRemote provides a mock function for the type MockRemoteDialerFactory
45-func (_mock *MockRemoteDialerFactory) DialerForRemote(target string, session string, mode ports.RemoteTransportMode, log *slog.Logger) (ports.Dialer, error) {
46- ret := _mock.Called(target, session, mode, log)
47-
48- if len(ret) == 0 {
49- panic("no return value specified for DialerForRemote")
50- }
51-
52- var r0 ports.Dialer
53- var r1 error
54- if returnFunc, ok := ret.Get(0).(func(string, string, ports.RemoteTransportMode, *slog.Logger) (ports.Dialer, error)); ok {
55- return returnFunc(target, session, mode, log)
56- }
57- if returnFunc, ok := ret.Get(0).(func(string, string, ports.RemoteTransportMode, *slog.Logger) ports.Dialer); ok {
58- r0 = returnFunc(target, session, mode, log)
59- } else {
60- if ret.Get(0) != nil {
61- r0 = ret.Get(0).(ports.Dialer)
62- }
63- }
64- if returnFunc, ok := ret.Get(1).(func(string, string, ports.RemoteTransportMode, *slog.Logger) error); ok {
65- r1 = returnFunc(target, session, mode, log)
66- } else {
67- r1 = ret.Error(1)
68- }
69- return r0, r1
70-}
71-
72-// MockRemoteDialerFactory_DialerForRemote_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DialerForRemote'
73-type MockRemoteDialerFactory_DialerForRemote_Call struct {
74- *mock.Call
75-}
76-
77-// DialerForRemote is a helper method to define mock.On call
78-// - target string
79-// - session string
80-// - mode ports.RemoteTransportMode
81-// - log *slog.Logger
82-func (_e *MockRemoteDialerFactory_Expecter) DialerForRemote(target any, session any, mode any, log any) *MockRemoteDialerFactory_DialerForRemote_Call {
83- return &MockRemoteDialerFactory_DialerForRemote_Call{Call: _e.mock.On("DialerForRemote", target, session, mode, log)}
84-}
85-
86-func (_c *MockRemoteDialerFactory_DialerForRemote_Call) Run(run func(target string, session string, mode ports.RemoteTransportMode, log *slog.Logger)) *MockRemoteDialerFactory_DialerForRemote_Call {
87- _c.Call.Run(func(args mock.Arguments) {
88- var arg0 string
89- if args[0] != nil {
90- arg0 = args[0].(string)
91- }
92- var arg1 string
93- if args[1] != nil {
94- arg1 = args[1].(string)
95- }
96- var arg2 ports.RemoteTransportMode
97- if args[2] != nil {
98- arg2 = args[2].(ports.RemoteTransportMode)
99- }
100- var arg3 *slog.Logger
101- if args[3] != nil {
102- arg3 = args[3].(*slog.Logger)
103- }
104- run(
105- arg0,
106- arg1,
107- arg2,
108- arg3,
109- )
110- })
111- return _c
112-}
113-
114-func (_c *MockRemoteDialerFactory_DialerForRemote_Call) Return(dialer ports.Dialer, err error) *MockRemoteDialerFactory_DialerForRemote_Call {
115- _c.Call.Return(dialer, err)
116- return _c
117-}
118-
119-func (_c *MockRemoteDialerFactory_DialerForRemote_Call) RunAndReturn(run func(target string, session string, mode ports.RemoteTransportMode, log *slog.Logger) (ports.Dialer, error)) *MockRemoteDialerFactory_DialerForRemote_Call {
120- _c.Call.Return(run)
121- return _c
122-}
123-
12416 // NewMockRemoteHostStore creates a new instance of MockRemoteHostStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12517 // The first argument is typically a *testing.T value.
12618 func NewMockRemoteHostStore(t interface {
@@ -574,22 +466,22 @@ func (_m *MockRemoteCatalogClient) EXPECT() *MockRemoteCatalogClient_Expecter {
574466 }
575467
576468 // List provides a mock function for the type MockRemoteCatalogClient
577-func (_mock *MockRemoteCatalogClient) List(ctx context.Context, target string) (ports.RemoteCatalog, error) {
469+func (_mock *MockRemoteCatalogClient) List(ctx context.Context, target string) (catalogue.RemoteCatalog, error) {
578470 ret := _mock.Called(ctx, target)
579471
580472 if len(ret) == 0 {
581473 panic("no return value specified for List")
582474 }
583475
584- var r0 ports.RemoteCatalog
476+ var r0 catalogue.RemoteCatalog
585477 var r1 error
586- if returnFunc, ok := ret.Get(0).(func(context.Context, string) (ports.RemoteCatalog, error)); ok {
478+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (catalogue.RemoteCatalog, error)); ok {
587479 return returnFunc(ctx, target)
588480 }
589- if returnFunc, ok := ret.Get(0).(func(context.Context, string) ports.RemoteCatalog); ok {
481+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) catalogue.RemoteCatalog); ok {
590482 r0 = returnFunc(ctx, target)
591483 } else {
592- r0 = ret.Get(0).(ports.RemoteCatalog)
484+ r0 = ret.Get(0).(catalogue.RemoteCatalog)
593485 }
594486 if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
595487 r1 = returnFunc(ctx, target)
@@ -629,12 +521,12 @@ func (_c *MockRemoteCatalogClient_List_Call) Run(run func(ctx context.Context, t
629521 return _c
630522 }
631523
632-func (_c *MockRemoteCatalogClient_List_Call) Return(remoteCatalog ports.RemoteCatalog, err error) *MockRemoteCatalogClient_List_Call {
524+func (_c *MockRemoteCatalogClient_List_Call) Return(remoteCatalog catalogue.RemoteCatalog, err error) *MockRemoteCatalogClient_List_Call {
633525 _c.Call.Return(remoteCatalog, err)
634526 return _c
635527 }
636528
637-func (_c *MockRemoteCatalogClient_List_Call) RunAndReturn(run func(ctx context.Context, target string) (ports.RemoteCatalog, error)) *MockRemoteCatalogClient_List_Call {
529+func (_c *MockRemoteCatalogClient_List_Call) RunAndReturn(run func(ctx context.Context, target string) (catalogue.RemoteCatalog, error)) *MockRemoteCatalogClient_List_Call {
638530 _c.Call.Return(run)
639531 return _c
640532 }
@@ -772,23 +664,23 @@ func (_m *MockRemoteCatalogCache) EXPECT() *MockRemoteCatalogCache_Expecter {
772664 }
773665
774666 // Load provides a mock function for the type MockRemoteCatalogCache
775-func (_mock *MockRemoteCatalogCache) Load() ([]ports.RemoteCatalogCacheEntry, error) {
667+func (_mock *MockRemoteCatalogCache) Load() ([]catalogue.RemoteCatalogCacheEntry, error) {
776668 ret := _mock.Called()
777669
778670 if len(ret) == 0 {
779671 panic("no return value specified for Load")
780672 }
781673
782- var r0 []ports.RemoteCatalogCacheEntry
674+ var r0 []catalogue.RemoteCatalogCacheEntry
783675 var r1 error
784- if returnFunc, ok := ret.Get(0).(func() ([]ports.RemoteCatalogCacheEntry, error)); ok {
676+ if returnFunc, ok := ret.Get(0).(func() ([]catalogue.RemoteCatalogCacheEntry, error)); ok {
785677 return returnFunc()
786678 }
787- if returnFunc, ok := ret.Get(0).(func() []ports.RemoteCatalogCacheEntry); ok {
679+ if returnFunc, ok := ret.Get(0).(func() []catalogue.RemoteCatalogCacheEntry); ok {
788680 r0 = returnFunc()
789681 } else {
790682 if ret.Get(0) != nil {
791- r0 = ret.Get(0).([]ports.RemoteCatalogCacheEntry)
683+ r0 = ret.Get(0).([]catalogue.RemoteCatalogCacheEntry)
792684 }
793685 }
794686 if returnFunc, ok := ret.Get(1).(func() error); ok {
@@ -816,18 +708,18 @@ func (_c *MockRemoteCatalogCache_Load_Call) Run(run func()) *MockRemoteCatalogCa
816708 return _c
817709 }
818710
819-func (_c *MockRemoteCatalogCache_Load_Call) Return(remoteCatalogCacheEntrys []ports.RemoteCatalogCacheEntry, err error) *MockRemoteCatalogCache_Load_Call {
711+func (_c *MockRemoteCatalogCache_Load_Call) Return(remoteCatalogCacheEntrys []catalogue.RemoteCatalogCacheEntry, err error) *MockRemoteCatalogCache_Load_Call {
820712 _c.Call.Return(remoteCatalogCacheEntrys, err)
821713 return _c
822714 }
823715
824-func (_c *MockRemoteCatalogCache_Load_Call) RunAndReturn(run func() ([]ports.RemoteCatalogCacheEntry, error)) *MockRemoteCatalogCache_Load_Call {
716+func (_c *MockRemoteCatalogCache_Load_Call) RunAndReturn(run func() ([]catalogue.RemoteCatalogCacheEntry, error)) *MockRemoteCatalogCache_Load_Call {
825717 _c.Call.Return(run)
826718 return _c
827719 }
828720
829721 // Store provides a mock function for the type MockRemoteCatalogCache
830-func (_mock *MockRemoteCatalogCache) Store(remoteCatalogCacheEntrys []ports.RemoteCatalogCacheEntry) error {
722+func (_mock *MockRemoteCatalogCache) Store(remoteCatalogCacheEntrys []catalogue.RemoteCatalogCacheEntry) error {
831723 ret := _mock.Called(remoteCatalogCacheEntrys)
832724
833725 if len(ret) == 0 {
@@ -835,7 +727,7 @@ func (_mock *MockRemoteCatalogCache) Store(remoteCatalogCacheEntrys []ports.Remo
835727 }
836728
837729 var r0 error
838- if returnFunc, ok := ret.Get(0).(func([]ports.RemoteCatalogCacheEntry) error); ok {
730+ if returnFunc, ok := ret.Get(0).(func([]catalogue.RemoteCatalogCacheEntry) error); ok {
839731 r0 = returnFunc(remoteCatalogCacheEntrys)
840732 } else {
841733 r0 = ret.Error(0)
@@ -849,16 +741,16 @@ type MockRemoteCatalogCache_Store_Call struct {
849741 }
850742
851743 // Store is a helper method to define mock.On call
852-// - remoteCatalogCacheEntrys []ports.RemoteCatalogCacheEntry
744+// - remoteCatalogCacheEntrys []catalogue.RemoteCatalogCacheEntry
853745 func (_e *MockRemoteCatalogCache_Expecter) Store(remoteCatalogCacheEntrys any) *MockRemoteCatalogCache_Store_Call {
854746 return &MockRemoteCatalogCache_Store_Call{Call: _e.mock.On("Store", remoteCatalogCacheEntrys)}
855747 }
856748
857-func (_c *MockRemoteCatalogCache_Store_Call) Run(run func(remoteCatalogCacheEntrys []ports.RemoteCatalogCacheEntry)) *MockRemoteCatalogCache_Store_Call {
749+func (_c *MockRemoteCatalogCache_Store_Call) Run(run func(remoteCatalogCacheEntrys []catalogue.RemoteCatalogCacheEntry)) *MockRemoteCatalogCache_Store_Call {
858750 _c.Call.Run(func(args mock.Arguments) {
859- var arg0 []ports.RemoteCatalogCacheEntry
751+ var arg0 []catalogue.RemoteCatalogCacheEntry
860752 if args[0] != nil {
861- arg0 = args[0].([]ports.RemoteCatalogCacheEntry)
753+ arg0 = args[0].([]catalogue.RemoteCatalogCacheEntry)
862754 }
863755 run(
864756 arg0,
@@ -872,7 +764,7 @@ func (_c *MockRemoteCatalogCache_Store_Call) Return(err error) *MockRemoteCatalo
872764 return _c
873765 }
874766
875-func (_c *MockRemoteCatalogCache_Store_Call) RunAndReturn(run func(remoteCatalogCacheEntrys []ports.RemoteCatalogCacheEntry) error) *MockRemoteCatalogCache_Store_Call {
767+func (_c *MockRemoteCatalogCache_Store_Call) RunAndReturn(run func(remoteCatalogCacheEntrys []catalogue.RemoteCatalogCacheEntry) error) *MockRemoteCatalogCache_Store_Call {
876768 _c.Call.Return(run)
877769 return _c
878770 }
M internal/ports/remote.go +8 -306
@@ -2,338 +2,40 @@ package ports
22
33 import (
44 "context"
5- "errors"
6- "fmt"
7- "log/slog"
8- "math"
9- "slices"
10- "time"
11- "unicode"
12- "unicode/utf8"
135
146 "github.com/bnema/vev/internal/domain"
157 "github.com/bnema/vev/internal/protocol"
8+ "github.com/bnema/vev/internal/protocol/catalogue"
169 )
1710
18-// RemoteTransportMode selects the transport used for remote attach after CLI parsing.
19-type RemoteTransportMode string
20-
21-const (
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.
28-type 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.
11+// RemoteHostStore persists pinned and learned remote host targets.
3412 type RemoteHostStore interface {
35- // Hosts returns pinned hosts in stored order and learned hosts in lexical order.
3613 Hosts() (pinned, learned []string, err error)
3714 AddPinned(target string) error
3815 RemovePinned(target string) error
3916 Remember(target string) error
4017 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.
4318 Remove(target string) (deleted bool, err error)
4419 }
4520
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.
21+// RemoteHostLearner records the validated remote target after an attach.
4922 type RemoteHostLearner interface {
5023 RememberRemoteHost() error
5124 }
5225
53-// RemoteCatalogClient fetches a versioned session catalog from a remote host.
26+// RemoteCatalogClient fetches a versioned session catalogue from a remote host.
5427 type RemoteCatalogClient interface {
55- List(ctx context.Context, target string) (RemoteCatalog, error)
28+ List(ctx context.Context, target string) (catalogue.RemoteCatalog, error)
5629 }
5730
58-// RemotePreviewClient fetches one bounded, exact-target viewport without
59-// attaching to or mutating the remote session.
31+// RemotePreviewClient fetches one bounded, exact-target viewport.
6032 type RemotePreviewClient interface {
6133 Preview(ctx context.Context, target domain.RemoteSessionTarget, width, height uint16) (protocol.RemotePreview, error)
6234 }
6335
64-const (
65- // RemoteCatalogSchemaVersion is independent from the IPC protocol. A
66- // catalogue can be rejected without changing the attachment wire layout.
67- RemoteCatalogSchemaVersion uint16 = 3
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 exact-schema host snapshot persisted
79-// for immediate remote discovery at daemon startup. Cache writers strip dynamic
80-// Detail and Attention fields before persistence.
81-type RemoteCatalogCacheEntry struct {
82- Host string
83- FetchedAt time.Time
84- Sessions []RemoteCatalogSession
85-}
86-
8736 // RemoteCatalogCache persists complete remote discovery snapshots independently
8837 // from the remote host registry.
8938 type RemoteCatalogCache interface {
90- Load() ([]RemoteCatalogCacheEntry, error)
91- Store([]RemoteCatalogCacheEntry) error
92-}
93-
94-// RemoteCatalogTab is one bounded, untrusted tab presentation hint. Detail
95-// and Attention are deliberately omitted from the durable cache.
96-type RemoteCatalogTab struct {
97- ID string `json:"id"`
98- Index uint16 `json:"index"`
99- Name string `json:"name"`
100- Detail string `json:"detail,omitempty"`
101- Attention bool `json:"attention,omitempty"`
102-}
103-
104-// RemoteCatalogSessionState is the JSON catalogue lifecycle contract. It is
105-// deliberately independent from SessionState, whose numeric values are part of
106-// the binary client/daemon protocol.
107-type RemoteCatalogSessionState string
108-
109-const (
110- RemoteCatalogSessionUp RemoteCatalogSessionState = "up"
111- RemoteCatalogSessionDown RemoteCatalogSessionState = "down"
112- RemoteCatalogSessionBroken RemoteCatalogSessionState = "broken"
113-)
114-
115-// Valid reports whether the state is one of the bounded catalogue values.
116-func (s RemoteCatalogSessionState) Valid() bool {
117- switch s {
118- case RemoteCatalogSessionUp, RemoteCatalogSessionDown, RemoteCatalogSessionBroken:
119- return true
120- default:
121- return false
122- }
123-}
124-
125-// RemoteCatalogSession is one exact-identity session in the remote discovery
126-// catalogue. Tabs is always an ordered typed snapshot; older count-only peers
127-// are rejected at the schema seam.
128-type RemoteCatalogSession struct {
129- LifecycleID domain.SessionLifecycleID `json:"lifecycle_id"`
130- Name string `json:"name"`
131- State RemoteCatalogSessionState `json:"state"`
132- Ephemeral bool `json:"ephemeral"`
133- Tabs []RemoteCatalogTab `json:"tabs"`
134- Attached bool `json:"attached"`
135- LastUsedSeq uint64 `json:"last_used_seq,omitempty"`
136- ActiveTabID string `json:"active_tab_id,omitempty"`
137- Reason string `json:"reason,omitempty"`
138-}
139-
140-// CatalogTabs returns an independent copy of the ordered tab snapshot.
141-func CatalogTabs(session RemoteCatalogSession) []RemoteCatalogTab {
142- return slices.Clone(session.Tabs)
143-}
144-
145-// CatalogTabCount returns the typed tab count.
146-func CatalogTabCount(session RemoteCatalogSession) int {
147- return len(session.Tabs)
148-}
149-
150-// SaturateUint16 clamps a catalogue tab count to the list protocol range.
151-func SaturateUint16(n int) uint16 {
152- if n <= 0 {
153- return 0
154- }
155- if n >= 1<<16 {
156- return math.MaxUint16
157- }
158- return uint16(n)
159-}
160-
161-// RemoteCatalog is the independently versioned JSON envelope returned by
162-// remote-catalog --json. Both protocol and catalogue schema versions are
163-// mandatory and must match exactly.
164-type RemoteCatalog struct {
165- ProtocolVersion uint16 `json:"protocol_version"`
166- SchemaVersion uint16 `json:"schema_version"`
167- Sessions []RemoteCatalogSession `json:"sessions"`
168-}
169-
170-// RemoteCatalogVersionMismatchError is returned when a remote catalog envelope
171-// reports an incompatible protocol or catalogue schema version.
172-type RemoteCatalogVersionMismatchError struct {
173- Got uint16
174- Want uint16
175- Kind string
176-}
177-
178-func (e *RemoteCatalogVersionMismatchError) Error() string {
179- kind := e.Kind
180- if kind == "" {
181- kind = "protocol"
182- }
183- return fmt.Sprintf("remote catalog: %s version mismatch: got %d, want %d", kind, e.Got, e.Want)
184-}
185-
186-var (
187- ErrInvalidRemoteCatalog = errors.New("ports: invalid remote catalog")
188- ErrRemoteCatalogTooLarge = errors.New("ports: remote catalog exceeds size limit")
189- ErrRemoteCatalogUnknownState = errors.New("ports: remote catalog has unknown state")
190- ErrRemoteCatalogInvalidReason = errors.New("ports: remote catalog has unknown reason")
191-)
192-
193-// ValidateRemoteCatalog applies the exact current schema and all bounds before
194-// a catalogue is cached or rendered.
195-func ValidateRemoteCatalog(c RemoteCatalog) error {
196- if c.ProtocolVersion != protocol.Version {
197- return &RemoteCatalogVersionMismatchError{Got: c.ProtocolVersion, Want: protocol.Version, Kind: "protocol"}
198- }
199- if c.SchemaVersion != RemoteCatalogSchemaVersion {
200- return &RemoteCatalogVersionMismatchError{Got: c.SchemaVersion, Want: RemoteCatalogSchemaVersion, Kind: "catalog"}
201- }
202- if len(c.Sessions) > RemoteCatalogMaxSessions {
203- return fmt.Errorf("%w: too many sessions", ErrRemoteCatalogTooLarge)
204- }
205- seenLifecycles := make(map[domain.SessionLifecycleID]string, len(c.Sessions))
206- seenNames := make(map[string]struct{}, len(c.Sessions))
207- bytes := 0
208- for _, session := range c.Sessions {
209- if !validRemoteCatalogText(session.Name) || !validRemoteCatalogText(string(session.State)) || !validRemoteCatalogText(session.Reason) {
210- return fmt.Errorf("%w: invalid session text", ErrInvalidRemoteCatalog)
211- }
212- if len(session.Name) > RemoteCatalogMaxNameBytes || len(session.Reason) > RemoteCatalogMaxDetailBytes {
213- return fmt.Errorf("%w: session text too long", ErrRemoteCatalogTooLarge)
214- }
215- if err := domain.ValidateSessionName(session.Name); err != nil {
216- return fmt.Errorf("%w: session name: %v", ErrInvalidRemoteCatalog, err)
217- }
218- if !session.State.Valid() {
219- return fmt.Errorf("%w: %q", ErrRemoteCatalogUnknownState, session.State)
220- }
221- if session.ActiveTabID != "" {
222- if err := domain.ValidateTabStableID(domain.TabStableID(session.ActiveTabID)); err != nil {
223- return fmt.Errorf("%w: invalid active tab ID", ErrInvalidRemoteCatalog)
224- }
225- }
226- if session.Reason != "" && !validRemoteCatalogReason(session.Reason) {
227- return fmt.Errorf("%w: %q", ErrRemoteCatalogInvalidReason, session.Reason)
228- }
229- if session.LifecycleID == (domain.SessionLifecycleID{}) {
230- return fmt.Errorf("%w: zero lifecycle ID", ErrInvalidRemoteCatalog)
231- }
232- if _, exists := seenNames[session.Name]; exists {
233- return fmt.Errorf("%w: duplicate session name %q", ErrInvalidRemoteCatalog, session.Name)
234- }
235- seenNames[session.Name] = struct{}{}
236- if prior, exists := seenLifecycles[session.LifecycleID]; exists {
237- return fmt.Errorf("%w: lifecycle ID reused by %q and %q", ErrInvalidRemoteCatalog, prior, session.Name)
238- }
239- seenLifecycles[session.LifecycleID] = session.Name
240- if session.Tabs == nil {
241- return fmt.Errorf("%w: missing tab list", ErrInvalidRemoteCatalog)
242- }
243- tabs := CatalogTabs(session)
244- if len(tabs) == 0 {
245- if session.ActiveTabID != "" {
246- return fmt.Errorf("%w: active tab is absent", ErrInvalidRemoteCatalog)
247- }
248- } else {
249- if len(tabs) > RemoteCatalogMaxTabsPerSess {
250- return fmt.Errorf("%w: too many tabs", ErrRemoteCatalogTooLarge)
251- }
252- tabIDs := make(map[string]struct{}, len(tabs))
253- for i, tab := range tabs {
254- if !validRemoteCatalogText(tab.ID) || !validRemoteCatalogText(tab.Name) || !validRemoteCatalogText(tab.Detail) {
255- return fmt.Errorf("%w: invalid tab text", ErrInvalidRemoteCatalog)
256- }
257- if len(tab.ID) > RemoteCatalogMaxIDBytes || len(tab.Name) > RemoteCatalogMaxNameBytes || len(tab.Detail) > RemoteCatalogMaxDetailBytes {
258- return fmt.Errorf("%w: tab text too long", ErrRemoteCatalogTooLarge)
259- }
260- if tab.Index != uint16(i) {
261- return fmt.Errorf("%w: tab indexes are not ordered", ErrInvalidRemoteCatalog)
262- }
263- if session.State == RemoteCatalogSessionUp && tab.ID == "" {
264- return fmt.Errorf("%w: up tab has zero ID", ErrInvalidRemoteCatalog)
265- }
266- if tab.ID != "" {
267- if err := domain.ValidateTabStableID(domain.TabStableID(tab.ID)); err != nil {
268- return fmt.Errorf("%w: invalid tab ID", ErrInvalidRemoteCatalog)
269- }
270- if _, duplicate := tabIDs[tab.ID]; duplicate {
271- return fmt.Errorf("%w: duplicate tab ID", ErrInvalidRemoteCatalog)
272- }
273- tabIDs[tab.ID] = struct{}{}
274- }
275- bytes += len(tab.ID) + len(tab.Name) + len(tab.Detail)
276- }
277- if session.ActiveTabID != "" {
278- if session.State != RemoteCatalogSessionUp {
279- return fmt.Errorf("%w: down or broken session has an active tab", ErrInvalidRemoteCatalog)
280- }
281- if _, ok := tabIDs[session.ActiveTabID]; !ok {
282- return fmt.Errorf("%w: active tab is absent", ErrInvalidRemoteCatalog)
283- }
284- }
285- }
286- bytes += len(session.Name) + len(session.State) + len(session.Reason)
287- }
288- if bytes > RemoteCatalogMaxCatalogBytes {
289- return ErrRemoteCatalogTooLarge
290- }
291- return nil
292-}
293-
294-// ValidateRemoteCatalogCacheEntries validates the bounded exact-schema cache
295-// DTO before it is loaded into picker state or written to disk.
296-func ValidateRemoteCatalogCacheEntries(entries []RemoteCatalogCacheEntry) error {
297- if len(entries) > RemoteCatalogMaxHosts {
298- return fmt.Errorf("%w: too many hosts", ErrRemoteCatalogTooLarge)
299- }
300- seenHosts := make(map[string]struct{}, len(entries))
301- for _, entry := range entries {
302- if err := domain.ValidateRemoteHostTarget(entry.Host); err != nil || len(entry.Host) > RemoteCatalogMaxNameBytes {
303- return fmt.Errorf("%w: invalid host", ErrInvalidRemoteCatalog)
304- }
305- if entry.FetchedAt.IsZero() || entry.FetchedAt.UnixNano() <= 0 {
306- return fmt.Errorf("%w: invalid fetched time", ErrInvalidRemoteCatalog)
307- }
308- if _, exists := seenHosts[entry.Host]; exists {
309- return fmt.Errorf("%w: duplicate host", ErrInvalidRemoteCatalog)
310- }
311- seenHosts[entry.Host] = struct{}{}
312- catalog := RemoteCatalog{ProtocolVersion: protocol.Version, SchemaVersion: RemoteCatalogSchemaVersion, Sessions: entry.Sessions}
313- if err := ValidateRemoteCatalog(catalog); err != nil {
314- return fmt.Errorf("%w: host %q: %v", ErrInvalidRemoteCatalog, entry.Host, err)
315- }
316- }
317- return nil
318-}
319-
320-func validRemoteCatalogText(value string) bool {
321- if !utf8.ValidString(value) {
322- return false
323- }
324- for _, r := range value {
325- if unicode.IsControl(r) {
326- return false
327- }
328- }
329- return true
330-}
331-
332-func validRemoteCatalogReason(reason string) bool {
333- switch reason {
334- case "", "refreshing", "catalog_stale", "host_unreachable", "version_mismatch", "session_down", "session_broken", "identity_changed", "not_found", "timeout", "malformed":
335- return true
336- default:
337- return false
338- }
39+ Load() ([]catalogue.RemoteCatalogCacheEntry, error)
40+ Store([]catalogue.RemoteCatalogCacheEntry) error
33941 }
M internal/ports/runtime_observer.go +0 -138
@@ -2,7 +2,6 @@ package ports
22
33 import (
44 "errors"
5- "sync"
65 "sync/atomic"
76 )
87
@@ -75,143 +74,6 @@ type SerializedRuntimeObserver interface {
7574 Close()
7675 }
7776
78-// NewSerializedRuntimeObserver isolates producers from a potentially slow
79-// observer with one ordered worker. capacity bounds queued marks. If the queue
80-// fills, dropped marks are represented by one ordered diagnostic mark before
81-// reporting resumes, so a trace can never silently claim completeness.
82-func NewSerializedRuntimeObserver(observer RuntimeObserver, capacity int) SerializedRuntimeObserver {
83- if observer == nil {
84- return nil
85- }
86- if capacity < 1 {
87- capacity = 1
88- }
89- o := &serializedRuntimeObserver{observer: observer, capacity: capacity, done: make(chan struct{})}
90- o.cond = sync.NewCond(&o.mu)
91- go o.run()
92- return o
93-}
94-
95-// EnsureSerializedRuntimeObserver returns observer's existing serialized
96-// owner when it has one. Otherwise it creates an owned worker. Callers close
97-// the result only when owned is true, preventing nested workers and duplicate
98-// lifecycle ownership when application wiring already serialized the process
99-// observer.
100-func EnsureSerializedRuntimeObserver(observer RuntimeObserver, capacity int) (serialized SerializedRuntimeObserver, owned bool) {
101- if observer == nil {
102- return nil, false
103- }
104- if serialized, ok := observer.(SerializedRuntimeObserver); ok {
105- return serialized, false
106- }
107- return NewSerializedRuntimeObserver(observer, capacity), true
108-}
109-
110-type serializedRuntimeObserver struct {
111- observer RuntimeObserver
112- capacity int
113-
114- mu sync.Mutex
115- cond *sync.Cond
116- marks []RuntimeMark
117- dropped uint64
118- closed bool
119- active bool
120- done chan struct{}
121-}
122-
123-func (o *serializedRuntimeObserver) ObserveRuntime(mark RuntimeMark) {
124- if o == nil {
125- return
126- }
127- o.mu.Lock()
128- defer o.mu.Unlock()
129- if o.closed {
130- return
131- }
132- if o.dropped != 0 {
133- // Reserve space for both the gap marker and the first post-gap mark.
134- // This preserves the producer order around a bounded-queue loss.
135- if len(o.marks)+2 > o.capacity {
136- o.dropped++
137- return
138- }
139- o.marks = append(o.marks, runtimeObserverGapMark(o.dropped), mark)
140- o.dropped = 0
141- o.cond.Signal()
142- return
143- }
144- if len(o.marks) == o.capacity {
145- o.dropped = 1
146- return
147- }
148- o.marks = append(o.marks, mark)
149- o.cond.Signal()
150-}
151-
152-func runtimeObserverGapMark(dropped uint64) RuntimeMark {
153- return NewRuntimeMark("observer", RuntimeTransportDiagnostic, dropped, false)
154-}
155-
156-func (o *serializedRuntimeObserver) Flush() {
157- if o == nil {
158- return
159- }
160- o.mu.Lock()
161- for len(o.marks) != 0 || o.active || o.dropped != 0 {
162- o.cond.Signal()
163- o.cond.Wait()
164- }
165- closed := o.closed
166- o.mu.Unlock()
167- if closed {
168- <-o.done
169- }
170-}
171-
172-func (o *serializedRuntimeObserver) Close() {
173- if o == nil {
174- return
175- }
176- o.mu.Lock()
177- if !o.closed {
178- o.closed = true
179- o.cond.Broadcast()
180- }
181- o.mu.Unlock()
182- <-o.done
183-}
184-
185-func (o *serializedRuntimeObserver) run() {
186- defer close(o.done)
187- for {
188- o.mu.Lock()
189- for len(o.marks) == 0 && o.dropped == 0 && !o.closed {
190- o.cond.Wait()
191- }
192- if len(o.marks) == 0 && o.dropped != 0 {
193- o.marks = append(o.marks, runtimeObserverGapMark(o.dropped))
194- o.dropped = 0
195- }
196- if len(o.marks) == 0 && o.closed {
197- o.cond.Broadcast()
198- o.mu.Unlock()
199- return
200- }
201- mark := o.marks[0]
202- o.marks = o.marks[1:]
203- o.active = true
204- o.mu.Unlock()
205-
206- o.observer.ObserveRuntime(mark)
207-
208- o.mu.Lock()
209- o.active = false
210- o.cond.Broadcast()
211- o.mu.Unlock()
212- }
213-}
214-
21577 // RuntimeMarkValid reports whether a mark has all correlation fields required
21678 // by the JSONL schema. Tick is checked separately by the timestamp owner.
21779 func RuntimeMarkValid(mark RuntimeMark) bool {
M internal/ports/runtime_observer_test.go +0 -66
@@ -2,9 +2,7 @@ package ports
22
33 import (
44 "reflect"
5- "sync"
65 "testing"
7- "time"
86 )
97
108 func TestRuntimeObserverContract(t *testing.T) {
@@ -44,70 +42,6 @@ func TestRuntimeObserverContract(t *testing.T) {
4442 var _ RuntimeObserver = runtimeObserverFunc(func(RuntimeMark) {})
4543 }
4644
47-func TestSerializedRuntimeObserverFlushesInOrderAndCloses(t *testing.T) {
48- var mu sync.Mutex
49- var got []RuntimeMark
50- observer := NewSerializedRuntimeObserver(runtimeObserverFunc(func(mark RuntimeMark) {
51- mu.Lock()
52- got = append(got, mark)
53- mu.Unlock()
54- }), 2)
55- observer.ObserveRuntime(NewRuntimeMark("client", RuntimeEmitStart, 1, true))
56- observer.ObserveRuntime(NewRuntimeMark("client", RuntimeEmitEnd, 1, true))
57- observer.Flush()
58- observer.Close()
59- observer.Close()
60-
61- mu.Lock()
62- defer mu.Unlock()
63- if len(got) != 2 || got[0].Kind != RuntimeEmitStart || got[1].Kind != RuntimeEmitEnd {
64- t.Fatalf("serialized marks = %#v", got)
65- }
66-}
67-
68-func TestEnsureSerializedRuntimeObserverReusesExistingOwner(t *testing.T) {
69- existing := NewSerializedRuntimeObserver(runtimeObserverFunc(func(RuntimeMark) {}), 1)
70- reporter, owned := EnsureSerializedRuntimeObserver(existing, 1)
71- if owned || reporter != existing {
72- t.Fatalf("EnsureSerializedRuntimeObserver() = (%T, %t), want existing unowned reporter", reporter, owned)
73- }
74- existing.Close()
75-}
76-
77-func TestSerializedRuntimeObserverReportsBoundedQueueLoss(t *testing.T) {
78- entered := make(chan struct{})
79- release := make(chan struct{})
80- var mu sync.Mutex
81- var got []RuntimeMark
82- observer := NewSerializedRuntimeObserver(runtimeObserverFunc(func(mark RuntimeMark) {
83- if mark.Kind == RuntimeEmitStart {
84- close(entered)
85- <-release
86- }
87- mu.Lock()
88- got = append(got, mark)
89- mu.Unlock()
90- }), 2)
91- observer.ObserveRuntime(NewRuntimeMark("client", RuntimeEmitStart, 1, true))
92- select {
93- case <-entered:
94- case <-time.After(time.Second):
95- t.Fatal("observer did not start")
96- }
97- observer.ObserveRuntime(NewRuntimeMark("client", RuntimeComposeStart, 1, true))
98- observer.ObserveRuntime(NewRuntimeMark("client", RuntimeComposeEnd, 1, true))
99- observer.ObserveRuntime(NewRuntimeMark("client", RuntimeEmitEnd, 1, true)) // bounded loss
100- close(release)
101- observer.Flush()
102- observer.Close()
103-
104- mu.Lock()
105- defer mu.Unlock()
106- if len(got) != 4 || got[0].Kind != RuntimeEmitStart || got[1].Kind != RuntimeComposeStart || got[2].Kind != RuntimeComposeEnd || got[3].Kind != RuntimeTransportDiagnostic || got[3].Bytes != 1 || got[3].Valid {
107- t.Fatalf("bounded-loss marks = %#v", got)
108- }
109-}
110-
11145 func TestP4RuntimeScreenMarkKindsValidateSchema1(t *testing.T) {
11246 if RuntimeMarkSchema != 1 {
11347 t.Fatalf("RuntimeMarkSchema = %d, want 1", RuntimeMarkSchema)
M internal/ports/terminal.go +1 -110
@@ -2,12 +2,11 @@ package ports
22
33 import (
44 "io"
5- "strings"
65
76 "github.com/bnema/vev/internal/domain"
87 )
98
10-// Terminal is the CLIENT-side controlling terminal.
9+// Terminal is the client-side controlling terminal.
1110 type Terminal interface {
1211 EnterRaw() (restore func() error, err error)
1312 Geometry() (domain.Geometry, error)
@@ -16,111 +15,3 @@ type Terminal interface {
1615 Out() io.Writer
1716 Flush() error
1817 }
19-
20-// TerminalColorMode is the color output mode selected for one attachment.
21-type TerminalColorMode uint8
22-
23-const (
24- // TrueColor is the zero value so manually constructed attachments retain the
25- // historical renderer behavior; live Hello paths always use the detector.
26- TerminalColorTrueColor TerminalColorMode = iota
27- TerminalColorIndexed256
28-)
29-
30-// TerminalCapabilitySource records how confidently a terminal capability was
31-// selected. Environment observations are advisory because nested multiplexers
32-// can retain values from an outer terminal.
33-type TerminalCapabilitySource uint8
34-
35-const (
36- TerminalCapabilityUnknown TerminalCapabilitySource = iota
37- TerminalCapabilityHeuristic
38- TerminalCapabilityDeclared
39-)
40-
41-// TerminalApplication identifies a known terminal application when its
42-// environment provides a trustworthy origin signal. It does not imply support
43-// for any application-specific protocol on the current output path.
44-type TerminalApplication uint8
45-
46-const (
47- TerminalApplicationUnknown TerminalApplication = iota
48- TerminalApplicationKitty
49-)
50-
51-// TerminalCapabilities describes the output features selected for one client
52-// attachment. Pane processes must not derive their terminal environment from
53-// these capabilities.
54-type TerminalCapabilities struct {
55- ColorMode TerminalColorMode
56- ColorSource TerminalCapabilitySource
57- Application TerminalApplication
58- KittyGraphics bool
59-}
60-
61-// SupportsKittyGraphics reports whether this attachment's active outer-terminal
62-// probe accepted the Kitty graphics protocol. Environment detection never sets it.
63-func (c TerminalCapabilities) SupportsKittyGraphics() bool {
64- return c.KittyGraphics
65-}
66-
67-// TrueColor reports whether this attachment can receive RGB ANSI output.
68-func (c TerminalCapabilities) TrueColor() bool {
69- return c.ColorMode == TerminalColorTrueColor
70-}
71-
72-// DetectTerminalCapabilities derives conservative attachment capabilities from
73-// a client environment. Missing evidence selects indexed color; it never proves
74-// the physical terminal cannot display direct color.
75-func DetectTerminalCapabilities(env []string) TerminalCapabilities {
76- values := environmentValues(env)
77- term := strings.ToLower(strings.TrimSpace(values["TERM"]))
78- colorTerm := strings.ToLower(strings.TrimSpace(values["COLORTERM"]))
79- caps := TerminalCapabilities{ColorMode: TerminalColorIndexed256}
80-
81- if values["KITTY_WINDOW_ID"] != "" || values["KITTY_PID"] != "" || values["KITTY_LISTEN_ON"] != "" {
82- caps.Application = TerminalApplicationKitty
83- }
84-
85- kittyIdentity := term == "xterm-kitty" && caps.Application == TerminalApplicationKitty
86- switch colorTerm {
87- case "truecolor", "24bit":
88- caps.ColorMode = TerminalColorTrueColor
89- caps.ColorSource = TerminalCapabilityDeclared
90- }
91- if term == "xterm-direct" || strings.HasSuffix(term, "-direct") {
92- caps.ColorMode = TerminalColorTrueColor
93- caps.ColorSource = TerminalCapabilityDeclared
94- }
95- if kittyIdentity {
96- if caps.ColorSource == TerminalCapabilityUnknown {
97- caps.ColorSource = TerminalCapabilityHeuristic
98- }
99- caps.ColorMode = TerminalColorTrueColor
100- return caps
101- }
102- if caps.ColorSource == TerminalCapabilityDeclared {
103- return caps
104- }
105- if strings.Contains(term, "256color") || term == "dumb" {
106- caps.ColorSource = TerminalCapabilityDeclared
107- }
108- return caps
109-}
110-
111-func environmentValues(env []string) map[string]string {
112- values := make(map[string]string, len(env))
113- for _, entry := range env {
114- key, value, ok := strings.Cut(entry, "=")
115- if !ok {
116- continue
117- }
118- values[key] = value
119- }
120- return values
121-}
122-
123-// DetectTrueColor reports whether TERM/COLORTERM advertise direct color support.
124-func DetectTrueColor(termEnv, colorTerm string) bool {
125- return DetectTerminalCapabilities([]string{"TERM=" + termEnv, "COLORTERM=" + colorTerm}).TrueColor()
126-}
D internal/ports/terminal_test.go +0 -68
@@ -1,68 +0,0 @@
1-package ports
2-
3-import (
4- "testing"
5-
6- "github.com/stretchr/testify/require"
7-)
8-
9-func TestDetectTerminalCapabilities(t *testing.T) {
10- tests := []struct {
11- name string
12- env []string
13- wantMode TerminalColorMode
14- wantSource TerminalCapabilitySource
15- wantApp TerminalApplication
16- wantGraphics bool
17- }{
18- {
19- name: "declared truecolor",
20- env: []string{"TERM=xterm-256color", "COLORTERM=truecolor"},
21- wantMode: TerminalColorTrueColor,
22- wantSource: TerminalCapabilityDeclared,
23- },
24- {
25- name: "kitty signals infer truecolor",
26- env: []string{"TERM=xterm-kitty", "KITTY_WINDOW_ID=1"},
27- wantMode: TerminalColorTrueColor,
28- wantSource: TerminalCapabilityHeuristic,
29- wantApp: TerminalApplicationKitty,
30- },
31- {
32- name: "kitty environment with declared truecolor remains color-only",
33- env: []string{"TERM=xterm-kitty", "COLORTERM=truecolor", "KITTY_WINDOW_ID=1"},
34- wantMode: TerminalColorTrueColor,
35- wantSource: TerminalCapabilityDeclared,
36- wantApp: TerminalApplicationKitty,
37- },
38- {
39- name: "256 color terminal remains a constrained attachment",
40- env: []string{"TERM=xterm-256color"},
41- wantMode: TerminalColorIndexed256,
42- wantSource: TerminalCapabilityDeclared,
43- },
44- {
45- name: "unknown terminal is conservatively indexed",
46- env: []string{"TERM=unknown"},
47- wantMode: TerminalColorIndexed256,
48- wantSource: TerminalCapabilityUnknown,
49- },
50- {
51- name: "kitty environment behind a multiplexer is not graphics evidence",
52- env: []string{"TERM=tmux-256color", "KITTY_WINDOW_ID=1"},
53- wantMode: TerminalColorIndexed256,
54- wantSource: TerminalCapabilityDeclared,
55- wantApp: TerminalApplicationKitty,
56- },
57- }
58-
59- for _, tt := range tests {
60- t.Run(tt.name, func(t *testing.T) {
61- got := DetectTerminalCapabilities(tt.env)
62- require.Equal(t, tt.wantMode, got.ColorMode)
63- require.Equal(t, tt.wantSource, got.ColorSource)
64- require.Equal(t, tt.wantApp, got.Application)
65- require.Equal(t, tt.wantGraphics, got.SupportsKittyGraphics())
66- })
67- }
68-}
A internal/protocol/catalogue/catalogue.go +263 -0
@@ -0,0 +1,263 @@
1+// Package catalogue owns the independently versioned remote discovery schema.
2+package catalogue
3+
4+import (
5+ "errors"
6+ "fmt"
7+ "math"
8+ "slices"
9+ "time"
10+ "unicode"
11+ "unicode/utf8"
12+
13+ "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/protocol"
15+)
16+
17+const (
18+ // RemoteCatalogSchemaVersion is independent from the binary session protocol.
19+ RemoteCatalogSchemaVersion uint16 = 3
20+
21+ RemoteCatalogMaxHosts = 64
22+ RemoteCatalogMaxSessions = 256
23+ RemoteCatalogMaxTabsPerSess = 128
24+ RemoteCatalogMaxNameBytes = 256
25+ RemoteCatalogMaxDetailBytes = 512
26+ RemoteCatalogMaxIDBytes = 128
27+ RemoteCatalogMaxCatalogBytes = 1 << 20
28+)
29+
30+// RemoteCatalogCacheEntry is one immutable exact-schema host snapshot.
31+type RemoteCatalogCacheEntry struct {
32+ Host string
33+ FetchedAt time.Time
34+ Sessions []RemoteCatalogSession
35+}
36+
37+// RemoteCatalogTab is one bounded, untrusted tab presentation hint.
38+type RemoteCatalogTab struct {
39+ ID string `json:"id"`
40+ Index uint16 `json:"index"`
41+ Name string `json:"name"`
42+ Detail string `json:"detail,omitempty"`
43+ Attention bool `json:"attention,omitempty"`
44+}
45+
46+// RemoteCatalogSessionState is the JSON catalogue lifecycle contract.
47+type RemoteCatalogSessionState string
48+
49+const (
50+ RemoteCatalogSessionUp RemoteCatalogSessionState = "up"
51+ RemoteCatalogSessionDown RemoteCatalogSessionState = "down"
52+ RemoteCatalogSessionBroken RemoteCatalogSessionState = "broken"
53+)
54+
55+func (s RemoteCatalogSessionState) Valid() bool {
56+ switch s {
57+ case RemoteCatalogSessionUp, RemoteCatalogSessionDown, RemoteCatalogSessionBroken:
58+ return true
59+ default:
60+ return false
61+ }
62+}
63+
64+// RemoteCatalogSession is one exact-identity session in the remote catalogue.
65+type RemoteCatalogSession struct {
66+ LifecycleID domain.SessionLifecycleID `json:"lifecycle_id"`
67+ Name string `json:"name"`
68+ State RemoteCatalogSessionState `json:"state"`
69+ Ephemeral bool `json:"ephemeral"`
70+ Tabs []RemoteCatalogTab `json:"tabs"`
71+ Attached bool `json:"attached"`
72+ LastUsedSeq uint64 `json:"last_used_seq,omitempty"`
73+ ActiveTabID string `json:"active_tab_id,omitempty"`
74+ Reason string `json:"reason,omitempty"`
75+}
76+
77+func CatalogTabs(session RemoteCatalogSession) []RemoteCatalogTab { return slices.Clone(session.Tabs) }
78+func CatalogTabCount(session RemoteCatalogSession) int { return len(session.Tabs) }
79+
80+func SaturateUint16(n int) uint16 {
81+ if n <= 0 {
82+ return 0
83+ }
84+ if n >= 1<<16 {
85+ return math.MaxUint16
86+ }
87+ return uint16(n)
88+}
89+
90+// RemoteCatalog is the versioned JSON envelope returned by remote-catalog.
91+type RemoteCatalog struct {
92+ ProtocolVersion uint16 `json:"protocol_version"`
93+ SchemaVersion uint16 `json:"schema_version"`
94+ Sessions []RemoteCatalogSession `json:"sessions"`
95+}
96+
97+// RemoteCatalogVersionMismatchError reports an incompatible protocol or schema version.
98+type RemoteCatalogVersionMismatchError struct {
99+ Got uint16
100+ Want uint16
101+ Kind string
102+}
103+
104+func (e *RemoteCatalogVersionMismatchError) Error() string {
105+ kind := e.Kind
106+ if kind == "" {
107+ kind = "protocol"
108+ }
109+ return fmt.Sprintf("remote catalog: %s version mismatch: got %d, want %d", kind, e.Got, e.Want)
110+}
111+
112+var (
113+ ErrInvalidRemoteCatalog = errors.New("ports: invalid remote catalog")
114+ ErrRemoteCatalogTooLarge = errors.New("ports: remote catalog exceeds size limit")
115+ ErrRemoteCatalogUnknownState = errors.New("ports: remote catalog has unknown state")
116+ ErrRemoteCatalogInvalidReason = errors.New("ports: remote catalog has unknown reason")
117+)
118+
119+// ValidateRemoteCatalog applies the exact current schema and all bounds before use.
120+func ValidateRemoteCatalog(c RemoteCatalog) error {
121+ if c.ProtocolVersion != protocol.Version {
122+ return &RemoteCatalogVersionMismatchError{Got: c.ProtocolVersion, Want: protocol.Version, Kind: "protocol"}
123+ }
124+ if c.SchemaVersion != RemoteCatalogSchemaVersion {
125+ return &RemoteCatalogVersionMismatchError{Got: c.SchemaVersion, Want: RemoteCatalogSchemaVersion, Kind: "catalog"}
126+ }
127+ if len(c.Sessions) > RemoteCatalogMaxSessions {
128+ return fmt.Errorf("%w: too many sessions", ErrRemoteCatalogTooLarge)
129+ }
130+ seenLifecycles := make(map[domain.SessionLifecycleID]string, len(c.Sessions))
131+ seenNames := make(map[string]struct{}, len(c.Sessions))
132+ bytes := 0
133+ for _, session := range c.Sessions {
134+ if !validText(session.Name) || !validText(string(session.State)) || !validText(session.Reason) {
135+ return fmt.Errorf("%w: invalid session text", ErrInvalidRemoteCatalog)
136+ }
137+ if len(session.Name) > RemoteCatalogMaxNameBytes || len(session.Reason) > RemoteCatalogMaxDetailBytes {
138+ return fmt.Errorf("%w: session text too long", ErrRemoteCatalogTooLarge)
139+ }
140+ if err := domain.ValidateSessionName(session.Name); err != nil {
141+ return fmt.Errorf("%w: session name: %v", ErrInvalidRemoteCatalog, err)
142+ }
143+ if !session.State.Valid() {
144+ return fmt.Errorf("%w: %q", ErrRemoteCatalogUnknownState, session.State)
145+ }
146+ if session.ActiveTabID != "" {
147+ if err := domain.ValidateTabStableID(domain.TabStableID(session.ActiveTabID)); err != nil {
148+ return fmt.Errorf("%w: invalid active tab ID", ErrInvalidRemoteCatalog)
149+ }
150+ }
151+ if session.Reason != "" && !validReason(session.Reason) {
152+ return fmt.Errorf("%w: %q", ErrRemoteCatalogInvalidReason, session.Reason)
153+ }
154+ if session.LifecycleID == (domain.SessionLifecycleID{}) {
155+ return fmt.Errorf("%w: zero lifecycle ID", ErrInvalidRemoteCatalog)
156+ }
157+ if _, exists := seenNames[session.Name]; exists {
158+ return fmt.Errorf("%w: duplicate session name %q", ErrInvalidRemoteCatalog, session.Name)
159+ }
160+ seenNames[session.Name] = struct{}{}
161+ if prior, exists := seenLifecycles[session.LifecycleID]; exists {
162+ return fmt.Errorf("%w: lifecycle ID reused by %q and %q", ErrInvalidRemoteCatalog, prior, session.Name)
163+ }
164+ seenLifecycles[session.LifecycleID] = session.Name
165+ if session.Tabs == nil {
166+ return fmt.Errorf("%w: missing tab list", ErrInvalidRemoteCatalog)
167+ }
168+ tabs := CatalogTabs(session)
169+ if len(tabs) == 0 {
170+ if session.ActiveTabID != "" {
171+ return fmt.Errorf("%w: active tab is absent", ErrInvalidRemoteCatalog)
172+ }
173+ } else {
174+ if len(tabs) > RemoteCatalogMaxTabsPerSess {
175+ return fmt.Errorf("%w: too many tabs", ErrRemoteCatalogTooLarge)
176+ }
177+ tabIDs := make(map[string]struct{}, len(tabs))
178+ for i, tab := range tabs {
179+ if !validText(tab.ID) || !validText(tab.Name) || !validText(tab.Detail) {
180+ return fmt.Errorf("%w: invalid tab text", ErrInvalidRemoteCatalog)
181+ }
182+ if len(tab.ID) > RemoteCatalogMaxIDBytes || len(tab.Name) > RemoteCatalogMaxNameBytes || len(tab.Detail) > RemoteCatalogMaxDetailBytes {
183+ return fmt.Errorf("%w: tab text too long", ErrRemoteCatalogTooLarge)
184+ }
185+ if tab.Index != uint16(i) {
186+ return fmt.Errorf("%w: tab indexes are not ordered", ErrInvalidRemoteCatalog)
187+ }
188+ if session.State == RemoteCatalogSessionUp && tab.ID == "" {
189+ return fmt.Errorf("%w: up tab has zero ID", ErrInvalidRemoteCatalog)
190+ }
191+ if tab.ID != "" {
192+ if err := domain.ValidateTabStableID(domain.TabStableID(tab.ID)); err != nil {
193+ return fmt.Errorf("%w: invalid tab ID", ErrInvalidRemoteCatalog)
194+ }
195+ if _, duplicate := tabIDs[tab.ID]; duplicate {
196+ return fmt.Errorf("%w: duplicate tab ID", ErrInvalidRemoteCatalog)
197+ }
198+ tabIDs[tab.ID] = struct{}{}
199+ }
200+ bytes += len(tab.ID) + len(tab.Name) + len(tab.Detail)
201+ }
202+ if session.ActiveTabID != "" {
203+ if session.State != RemoteCatalogSessionUp {
204+ return fmt.Errorf("%w: down or broken session has an active tab", ErrInvalidRemoteCatalog)
205+ }
206+ if _, ok := tabIDs[session.ActiveTabID]; !ok {
207+ return fmt.Errorf("%w: active tab is absent", ErrInvalidRemoteCatalog)
208+ }
209+ }
210+ }
211+ bytes += len(session.Name) + len(session.State) + len(session.Reason)
212+ }
213+ if bytes > RemoteCatalogMaxCatalogBytes {
214+ return ErrRemoteCatalogTooLarge
215+ }
216+ return nil
217+}
218+
219+// ValidateRemoteCatalogCacheEntries validates the bounded exact-schema cache DTO.
220+func ValidateRemoteCatalogCacheEntries(entries []RemoteCatalogCacheEntry) error {
221+ if len(entries) > RemoteCatalogMaxHosts {
222+ return fmt.Errorf("%w: too many hosts", ErrRemoteCatalogTooLarge)
223+ }
224+ seenHosts := make(map[string]struct{}, len(entries))
225+ for _, entry := range entries {
226+ if err := domain.ValidateRemoteHostTarget(entry.Host); err != nil || len(entry.Host) > RemoteCatalogMaxNameBytes {
227+ return fmt.Errorf("%w: invalid host", ErrInvalidRemoteCatalog)
228+ }
229+ if entry.FetchedAt.IsZero() || entry.FetchedAt.UnixNano() <= 0 {
230+ return fmt.Errorf("%w: invalid fetched time", ErrInvalidRemoteCatalog)
231+ }
232+ if _, exists := seenHosts[entry.Host]; exists {
233+ return fmt.Errorf("%w: duplicate host", ErrInvalidRemoteCatalog)
234+ }
235+ seenHosts[entry.Host] = struct{}{}
236+ catalog := RemoteCatalog{ProtocolVersion: protocol.Version, SchemaVersion: RemoteCatalogSchemaVersion, Sessions: entry.Sessions}
237+ if err := ValidateRemoteCatalog(catalog); err != nil {
238+ return fmt.Errorf("%w: host %q: %v", ErrInvalidRemoteCatalog, entry.Host, err)
239+ }
240+ }
241+ return nil
242+}
243+
244+func validText(value string) bool {
245+ if !utf8.ValidString(value) {
246+ return false
247+ }
248+ for _, r := range value {
249+ if unicode.IsControl(r) {
250+ return false
251+ }
252+ }
253+ return true
254+}
255+
256+func validReason(reason string) bool {
257+ switch reason {
258+ case "", "refreshing", "catalog_stale", "host_unreachable", "version_mismatch", "session_down", "session_broken", "identity_changed", "not_found", "timeout", "malformed":
259+ return true
260+ default:
261+ return false
262+ }
263+}
R internal/ports/remote_state_test.go → internal/protocol/catalogue/catalogue_test.go +1 -1 renamed, 99% similar
@@ -1,4 +1,4 @@
1-package ports
1+package catalogue
22
33 import (
44 "testing"
R internal/ports/mocks/transport_mock.go → internal/protocol/wire/mocks/transport_mock.go +23 -24 renamed, 93% similar
@@ -2,12 +2,11 @@
22 // github.com/vektra/mockery
33 // template: testify
44
5-package portsmocks
5+package wiremocks
66
77 import (
88 "context"
99
10- "github.com/bnema/vev/internal/ports"
1110 "github.com/bnema/vev/internal/protocol/wire"
1211 mock "github.com/stretchr/testify/mock"
1312 )
@@ -579,27 +578,27 @@ func (_m *MockDialer) EXPECT() *MockDialer_Expecter {
579578 }
580579
581580 // Dial provides a mock function for the type MockDialer
582-func (_mock *MockDialer) Dial(ctx context.Context) (ports.Transport, error) {
583- ret := _mock.Called(ctx)
581+func (_mock *MockDialer) Dial(context1 context.Context) (wire.Transport, error) {
582+ ret := _mock.Called(context1)
584583
585584 if len(ret) == 0 {
586585 panic("no return value specified for Dial")
587586 }
588587
589- var r0 ports.Transport
588+ var r0 wire.Transport
590589 var r1 error
591- if returnFunc, ok := ret.Get(0).(func(context.Context) (ports.Transport, error)); ok {
592- return returnFunc(ctx)
590+ if returnFunc, ok := ret.Get(0).(func(context.Context) (wire.Transport, error)); ok {
591+ return returnFunc(context1)
593592 }
594- if returnFunc, ok := ret.Get(0).(func(context.Context) ports.Transport); ok {
595- r0 = returnFunc(ctx)
593+ if returnFunc, ok := ret.Get(0).(func(context.Context) wire.Transport); ok {
594+ r0 = returnFunc(context1)
596595 } else {
597596 if ret.Get(0) != nil {
598- r0 = ret.Get(0).(ports.Transport)
597+ r0 = ret.Get(0).(wire.Transport)
599598 }
600599 }
601600 if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
602- r1 = returnFunc(ctx)
601+ r1 = returnFunc(context1)
603602 } else {
604603 r1 = ret.Error(1)
605604 }
@@ -612,12 +611,12 @@ type MockDialer_Dial_Call struct {
612611 }
613612
614613 // Dial is a helper method to define mock.On call
615-// - ctx context.Context
616-func (_e *MockDialer_Expecter) Dial(ctx any) *MockDialer_Dial_Call {
617- return &MockDialer_Dial_Call{Call: _e.mock.On("Dial", ctx)}
614+// - context1 context.Context
615+func (_e *MockDialer_Expecter) Dial(context1 any) *MockDialer_Dial_Call {
616+ return &MockDialer_Dial_Call{Call: _e.mock.On("Dial", context1)}
618617 }
619618
620-func (_c *MockDialer_Dial_Call) Run(run func(ctx context.Context)) *MockDialer_Dial_Call {
619+func (_c *MockDialer_Dial_Call) Run(run func(context1 context.Context)) *MockDialer_Dial_Call {
621620 _c.Call.Run(func(args mock.Arguments) {
622621 var arg0 context.Context
623622 if args[0] != nil {
@@ -630,12 +629,12 @@ func (_c *MockDialer_Dial_Call) Run(run func(ctx context.Context)) *MockDialer_D
630629 return _c
631630 }
632631
633-func (_c *MockDialer_Dial_Call) Return(transport ports.Transport, err error) *MockDialer_Dial_Call {
632+func (_c *MockDialer_Dial_Call) Return(transport wire.Transport, err error) *MockDialer_Dial_Call {
634633 _c.Call.Return(transport, err)
635634 return _c
636635 }
637636
638-func (_c *MockDialer_Dial_Call) RunAndReturn(run func(ctx context.Context) (ports.Transport, error)) *MockDialer_Dial_Call {
637+func (_c *MockDialer_Dial_Call) RunAndReturn(run func(context1 context.Context) (wire.Transport, error)) *MockDialer_Dial_Call {
639638 _c.Call.Return(run)
640639 return _c
641640 }
@@ -668,23 +667,23 @@ func (_m *MockListener) EXPECT() *MockListener_Expecter {
668667 }
669668
670669 // Accept provides a mock function for the type MockListener
671-func (_mock *MockListener) Accept() (ports.Transport, error) {
670+func (_mock *MockListener) Accept() (wire.Transport, error) {
672671 ret := _mock.Called()
673672
674673 if len(ret) == 0 {
675674 panic("no return value specified for Accept")
676675 }
677676
678- var r0 ports.Transport
677+ var r0 wire.Transport
679678 var r1 error
680- if returnFunc, ok := ret.Get(0).(func() (ports.Transport, error)); ok {
679+ if returnFunc, ok := ret.Get(0).(func() (wire.Transport, error)); ok {
681680 return returnFunc()
682681 }
683- if returnFunc, ok := ret.Get(0).(func() ports.Transport); ok {
682+ if returnFunc, ok := ret.Get(0).(func() wire.Transport); ok {
684683 r0 = returnFunc()
685684 } else {
686685 if ret.Get(0) != nil {
687- r0 = ret.Get(0).(ports.Transport)
686+ r0 = ret.Get(0).(wire.Transport)
688687 }
689688 }
690689 if returnFunc, ok := ret.Get(1).(func() error); ok {
@@ -712,12 +711,12 @@ func (_c *MockListener_Accept_Call) Run(run func()) *MockListener_Accept_Call {
712711 return _c
713712 }
714713
715-func (_c *MockListener_Accept_Call) Return(transport ports.Transport, err error) *MockListener_Accept_Call {
714+func (_c *MockListener_Accept_Call) Return(transport wire.Transport, err error) *MockListener_Accept_Call {
716715 _c.Call.Return(transport, err)
717716 return _c
718717 }
719718
720-func (_c *MockListener_Accept_Call) RunAndReturn(run func() (ports.Transport, error)) *MockListener_Accept_Call {
719+func (_c *MockListener_Accept_Call) RunAndReturn(run func() (wire.Transport, error)) *MockListener_Accept_Call {
721720 _c.Call.Return(run)
722721 return _c
723722 }
R internal/ports/transport.go → internal/protocol/wire/transport.go +11 -15 renamed, 66% similar
@@ -1,22 +1,18 @@
1-package ports
1+package wire
22
3-import (
4- "context"
5-
6- "github.com/bnema/vev/internal/protocol/wire"
7-)
3+import "context"
84
95 // Transport is a framed message channel over a single connection. Close must
106 // be safe to call concurrently with Send and Recv, and must unblock active
117 // Send and Recv calls.
128 type Transport interface {
13- Send(wire.Frame) error
14- Recv() (wire.Frame, error) // blocking; io.EOF on close
9+ Send(Frame) error
10+ Recv() (Frame, error) // blocking; io.EOF on close
1511 Close() error
1612 }
1713
18-// DatagramTransport marks transports backed by a datagram link. It lets
19-// usecases negotiate a conservative output window without importing adapters.
14+// DatagramTransport marks transports backed by a datagram link. Typed session
15+// adapters translate this carriage detail into semantic capabilities.
2016 type DatagramTransport interface {
2117 Transport
2218 DatagramTransport()
@@ -26,21 +22,21 @@ type DatagramTransport interface {
2622 // returns once the adapter owns the frame; Send retains its synchronous wire-
2723 // attempt contract. Daemon paint output may use this capability to pipeline.
2824 type AsyncTransport interface {
29- SendAsync(wire.Frame) error
25+ SendAsync(Frame) error
3026 }
3127
3228 // OwnedSynchronousTransport owns the complete bounded synchronous operation,
3329 // including adapter queues, pacing, write deadlines, and close cancellation.
3430 type OwnedSynchronousTransport interface {
35- SendSynchronous(wire.Frame) error
31+ SendSynchronous(Frame) error
3632 }
3733
38-// Dialer establishes outbound Transport connections.
34+// Dialer establishes outbound raw carriage connections.
3935 type Dialer interface {
40- Dial(ctx context.Context) (Transport, error)
36+ Dial(context.Context) (Transport, error)
4137 }
4238
43-// Listener accepts incoming Transport connections.
39+// Listener accepts incoming raw carriage connections.
4440 type Listener interface {
4541 Accept() (Transport, error)
4642 Close() error
M internal/usecase/client/client.go +2 -1
@@ -22,6 +22,7 @@ import (
2222 renderer "github.com/bnema/vev-vt"
2323 "github.com/bnema/vev-vt/protocol/terminalquery"
2424 "github.com/bnema/vev/internal/domain"
25+ "github.com/bnema/vev/internal/domain/terminalcap"
2526 "github.com/bnema/vev/internal/ports"
2627 "github.com/bnema/vev/internal/protocol"
2728 "github.com/bnema/vev/internal/usecase/theme"
@@ -1121,7 +1122,7 @@ func paletteSlot(slot int) (uint8, bool) {
11211122
11221123 // DetectTrueColor reports whether TERM/COLORTERM advertise direct color support.
11231124 func DetectTrueColor(termEnv, colorTerm string) bool {
1124- return ports.DetectTrueColor(termEnv, colorTerm)
1125+ return terminalcap.DetectTrueColor(termEnv, colorTerm)
11251126 }
11261127
11271128 func requestedOutputWindow(connection ports.ClientConnection) uint8 {
M internal/usecase/client/client_test.go +27 -27
@@ -123,7 +123,7 @@ type recvItem struct {
123123 wait <-chan struct{}
124124 }
125125
126-type markedDatagramTransport struct{ ports.Transport }
126+type markedDatagramTransport struct{ wire.Transport }
127127
128128 func (markedDatagramTransport) DatagramTransport() {}
129129
@@ -240,7 +240,7 @@ func isType(typ wire.MsgType) any {
240240 }
241241
242242 // transportDialer adapts one already-open test transport to the Runner API.
243-type transportDialer struct{ transport ports.Transport }
243+type transportDialer struct{ transport wire.Transport }
244244
245245 func (d transportDialer) Dial(context.Context) (ports.ClientConnection, error) {
246246 return &rawClientConnection{raw: d.transport}, nil
@@ -262,7 +262,7 @@ func testDependencies(dialer ports.ClientDialer, terminal ports.Terminal, clock
262262 }
263263 }
264264
265-func attachTestDependencies(transport ports.Transport, terminal ports.Terminal, clock ports.Clock) client.Dependencies {
265+func attachTestDependencies(transport wire.Transport, terminal ports.Terminal, clock ports.Clock) client.Dependencies {
266266 return testDependencies(transportDialer{transport: transport}, terminal, clock, nil, nil)
267267 }
268268
@@ -287,7 +287,7 @@ func TestRunBoundsBlockedDial(t *testing.T) {
287287 clk, createdTimers := newHandshakeClock(t, 1)
288288 started := make(chan struct{})
289289 dialer := newMockClientDialer(t)
290- dialer.EXPECT().Dial(mock.Anything).RunAndReturn(func(ctx context.Context) (ports.Transport, error) {
290+ dialer.EXPECT().Dial(mock.Anything).RunAndReturn(func(ctx context.Context) (wire.Transport, error) {
291291 close(started)
292292 <-ctx.Done()
293293 return nil, ctx.Err()
@@ -503,7 +503,7 @@ func TestLocalOnlyHandoffBetweenLocalSessionsKeepsClientRunning(t *testing.T) {
503503 {f: frameOf(wire.MsgCommittedRouteIdentity, mustMarshalCommittedIdentity(protocol.CommittedRouteIdentity{Target: secondTarget}))},
504504 {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))},
505505 }}
506- dialer := &sequenceDialer{trs: []ports.Transport{first}}
506+ dialer := &sequenceDialer{trs: []wire.Transport{first}}
507507
508508 err := runTestClient(context.Background(), testDependencies(dialer, term, realClock{}, nil, nil), client.AttachRequest{
509509 Intent: protocol.IntentAttach, SessionName: "first",
@@ -570,7 +570,7 @@ func TestStoppedLocalHandoffDialsReplacementTransport(t *testing.T) {
570570 {f: welcome("stopped", targetLifecycle)},
571571 {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))},
572572 }}
573- dialer := &sequenceDialer{trs: []ports.Transport{first, second}}
573+ dialer := &sequenceDialer{trs: []wire.Transport{first, second}}
574574
575575 err := runTestClient(context.Background(), testDependencies(dialer, term, realClock{}, nil, nil), client.AttachRequest{
576576 Intent: protocol.IntentAttach, SessionName: "source",
@@ -613,8 +613,8 @@ func TestAcceptedHomeActionUsesCapturedRouteAfterRequestMetadataRebase(t *testin
613613 {f: welcome("local", localLifecycle)},
614614 {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))},
615615 }}
616- localDialer := &sequenceDialer{trs: []ports.Transport{local1, local2}}
617- remoteDialer := &sequenceDialer{trs: []ports.Transport{remote}}
616+ localDialer := &sequenceDialer{trs: []wire.Transport{local1, local2}}
617+ remoteDialer := &sequenceDialer{trs: []wire.Transport{remote}}
618618 deps := testDependencies(localDialer, term, realClock{}, nil, nil)
619619 deps.AttachHandoff = func(protocol.AttachTarget) (ports.ClientDialer, client.AttachRequest, error) {
620620 target := protocol.ExactSessionTarget{LifecycleID: remoteLifecycle, SessionName: "work"}
@@ -741,8 +741,8 @@ func TestHybridPickerSameHostSwitchReusesRemoteTransport(t *testing.T) {
741741 resizeOnce.Do(func() { term.setSize(domain.Size{Cols: 100, Rows: 40}) })
742742 }
743743 }
744- localDialer := &sequenceDialer{trs: []ports.Transport{localInitial, localPicker}}
745- remoteDialer := &sequenceDialer{trs: []ports.Transport{markedDatagramTransport{Transport: remote}}}
744+ localDialer := &sequenceDialer{trs: []wire.Transport{localInitial, localPicker}}
745+ remoteDialer := &sequenceDialer{trs: []wire.Transport{markedDatagramTransport{Transport: remote}}}
746746 deps := hybridPickerDependencies(localDialer, term, realClock{}, map[string]ports.ClientDialer{"remote": remoteDialer})
747747
748748 err := runTestClient(context.Background(), deps, client.AttachRequest{
@@ -822,8 +822,8 @@ func TestHybridPickerExpiredSwitchFallsBackToNewDial(t *testing.T) {
822822 {f: hybridWelcomeFrame("target", targetLifecycle)},
823823 {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))},
824824 }}
825- localDialer := &sequenceDialer{trs: []ports.Transport{localInitial, localPicker}}
826- remoteDialer := &sequenceDialer{trs: []ports.Transport{
825+ localDialer := &sequenceDialer{trs: []wire.Transport{localInitial, localPicker}}
826+ remoteDialer := &sequenceDialer{trs: []wire.Transport{
827827 markedDatagramTransport{Transport: sourceRemote}, targetRemote,
828828 }}
829829 deps := hybridPickerDependencies(localDialer, term, realClock{}, map[string]ports.ClientDialer{"remote": remoteDialer})
@@ -888,8 +888,8 @@ func TestHybridPickerPrepareResponseTimeoutClosesRetainedTransport(t *testing.T)
888888 protocol.ParkedRoutePrepare: prepareSent,
889889 })
890890
891- localDialer := &sequenceDialer{trs: []ports.Transport{localInitial}}
892- remoteDialer := &sequenceDialer{trs: []ports.Transport{markedDatagramTransport{Transport: remote}}}
891+ localDialer := &sequenceDialer{trs: []wire.Transport{localInitial}}
892+ remoteDialer := &sequenceDialer{trs: []wire.Transport{markedDatagramTransport{Transport: remote}}}
893893 deps := hybridPickerDependencies(localDialer, term, clock, map[string]ports.ClientDialer{"remote": remoteDialer})
894894
895895 ctx, cancel := context.WithCancel(context.Background())
@@ -965,8 +965,8 @@ func TestHybridPickerBackResumesRetainedRemoteTransport(t *testing.T) {
965965 {f: hybridWelcomeFrame("local", localLifecycle)},
966966 {f: navigationDirectiveFrame(protocol.NavigationBack)},
967967 }}
968- localDialer := &sequenceDialer{trs: []ports.Transport{localInitial, localPicker}}
969- remoteDialer := &sequenceDialer{trs: []ports.Transport{markedDatagramTransport{Transport: remote}}}
968+ localDialer := &sequenceDialer{trs: []wire.Transport{localInitial, localPicker}}
969+ remoteDialer := &sequenceDialer{trs: []wire.Transport{markedDatagramTransport{Transport: remote}}}
970970 deps := hybridPickerDependencies(localDialer, term, realClock{}, map[string]ports.ClientDialer{"remote": remoteDialer})
971971
972972 require.NoError(t, runTestClient(context.Background(), deps, client.AttachRequest{
@@ -1020,9 +1020,9 @@ func TestHybridPickerDifferentHostFallsBackToNewRemoteDial(t *testing.T) {
10201020 {f: hybridWelcomeFrame("target", targetLifecycle)},
10211021 {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))},
10221022 }}
1023- localDialer := &sequenceDialer{trs: []ports.Transport{localInitial, localPicker}}
1024- sourceDialer := &sequenceDialer{trs: []ports.Transport{markedDatagramTransport{Transport: sourceRemote}}}
1025- targetDialer := &sequenceDialer{trs: []ports.Transport{targetRemote}}
1023+ localDialer := &sequenceDialer{trs: []wire.Transport{localInitial, localPicker}}
1024+ sourceDialer := &sequenceDialer{trs: []wire.Transport{markedDatagramTransport{Transport: sourceRemote}}}
1025+ targetDialer := &sequenceDialer{trs: []wire.Transport{targetRemote}}
10261026 deps := hybridPickerDependencies(localDialer, term, realClock{}, map[string]ports.ClientDialer{
10271027 sourceTarget.Endpoint: sourceDialer,
10281028 targetTarget.Endpoint: targetDialer,
@@ -1105,8 +1105,8 @@ func TestRouteNavigationPreservesRemoteHomePickerAcrossLocalReturn(t *testing.T)
11051105 {f: welcome("remote-new", remoteNewLifecycle, 22)},
11061106 {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))},
11071107 }}
1108- localDialer := &sequenceDialer{trs: []ports.Transport{local1, local2, local3}}
1109- remoteDialer := &sequenceDialer{trs: []ports.Transport{remote1, remote2, remote3}}
1108+ localDialer := &sequenceDialer{trs: []wire.Transport{local1, local2, local3}}
1109+ remoteDialer := &sequenceDialer{trs: []wire.Transport{remote1, remote2, remote3}}
11101110
11111111 deps := testDependencies(localDialer, term, realClock{}, nil, nil)
11121112 deps.AttachHandoff = func(target protocol.AttachTarget) (ports.ClientDialer, client.AttachRequest, error) {
@@ -1586,7 +1586,7 @@ func TestRunPhaseASingleAttempt(t *testing.T) {
15861586 }
15871587
15881588 type sequenceDialer struct {
1589- trs []ports.Transport
1589+ trs []wire.Transport
15901590 errs []error
15911591 calls atomic.Int32
15921592 }
@@ -1709,7 +1709,7 @@ func TestRunReconnectsWithRotatedTokenAndSameClientID(t *testing.T) {
17091709 tr1 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {err: io.EOF}}}
17101710 tr2 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(22)}, {err: io.EOF}}}
17111711 tr3 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(33)}, {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))}}}
1712- d := &sequenceDialer{trs: []ports.Transport{tr1, tr2, tr3}}
1712+ d := &sequenceDialer{trs: []wire.Transport{tr1, tr2, tr3}}
17131713 clock := &reconnectTestClock{}
17141714 result := make(chan error, 1)
17151715 go func() {
@@ -1757,7 +1757,7 @@ func TestReconnectRestoresLastPublishedRouteTab(t *testing.T) {
17571757 {f: welcome(22)},
17581758 {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))},
17591759 }}
1760- dialer := &sequenceDialer{trs: []ports.Transport{tr1, tr2}}
1760+ dialer := &sequenceDialer{trs: []wire.Transport{tr1, tr2}}
17611761 clock := &reconnectTestClock{}
17621762 result := make(chan error, 1)
17631763 go func() {
@@ -1800,7 +1800,7 @@ func TestRemoteReconnectDropsStalePickerTargetAfterDaemonSessionSwitch(t *testin
18001800 {f: welcome("new", newLifecycle, 22)},
18011801 {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))},
18021802 }}
1803- dialer := &sequenceDialer{trs: []ports.Transport{tr1, tr2}}
1803+ dialer := &sequenceDialer{trs: []wire.Transport{tr1, tr2}}
18041804 clock := &reconnectTestClock{}
18051805 result := make(chan error, 1)
18061806 go func() {
@@ -1986,7 +1986,7 @@ func TestRunRememberRemoteHostAtMostOnceAcrossReconnects(t *testing.T) {
19861986
19871987 tr1 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {err: io.EOF}}}
19881988 tr2 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(22)}, {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))}}}
1989- d := &sequenceDialer{trs: []ports.Transport{tr1, tr2}}
1989+ d := &sequenceDialer{trs: []wire.Transport{tr1, tr2}}
19901990
19911991 deps := testDependencies(d, term, realClock{}, nil, nil)
19921992 learner := portsmocks.NewMockRemoteHostLearner(t)
@@ -2269,7 +2269,7 @@ func TestRunDoesNotRetryTerminalDetachedError(t *testing.T) {
22692269 term := newRunTerminal()
22702270 defer term.in.unblock()
22712271 tr := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonSessionKilled}))}}}
2272- d := &sequenceDialer{trs: []ports.Transport{tr}}
2272+ d := &sequenceDialer{trs: []wire.Transport{tr}}
22732273
22742274 err := runTestClient(context.Background(), testDependencies(d, term, realClock{}, nil, nil), client.AttachRequest{Intent: protocol.IntentAttach, SessionName: "main", Remote: false})
22752275 require.Error(t, err)
M internal/usecase/client/clipboard_intercept_test.go +1 -1
@@ -155,7 +155,7 @@ func (t *clipboardToastLifecycleTransport) Send(f wire.Frame) error {
155155 func (t *clipboardToastLifecycleTransport) Recv() (wire.Frame, error) { return <-t.recv, nil }
156156 func (*clipboardToastLifecycleTransport) Close() error { return nil }
157157
158-type clipboardToastLifecycleDialer struct{ transport ports.Transport }
158+type clipboardToastLifecycleDialer struct{ transport wire.Transport }
159159
160160 func (d clipboardToastLifecycleDialer) Dial(context.Context) (ports.ClientConnection, error) {
161161 return &rawClientConnection{raw: d.transport}, nil
M internal/usecase/client/reconnect_toast_test.go +8 -8
@@ -148,7 +148,7 @@ type reconnectToastRecv struct {
148148 }
149149
150150 type reconnectToastSequenceDialer struct {
151- transports []ports.Transport
151+ transports []wire.Transport
152152 calls int
153153 }
154154
@@ -1375,7 +1375,7 @@ func TestReconnectRetryFailuresPreserveAttachError(t *testing.T) {
13751375 reconnectToastRecv{frame: reconnectToastWelcome(44)},
13761376 reconnectToastRecv{err: attachErr},
13771377 )
1378- dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr}}
1378+ dialer := &reconnectToastSequenceDialer{transports: []wire.Transport{tr}}
13791379
13801380 err := NewRunner(Dependencies{Dialer: dialer, Terminal: term.term, Clock: newReconnectHandshakeClock(t), DisableCapabilityProbe: true, Logger: slog.New(slog.DiscardHandler)}).Run(context.Background(), AttachRequest{Intent: protocol.IntentAttach, SessionName: "main", Remote: true})
13811381 require.ErrorIs(t, err, attachErr)
@@ -1462,7 +1462,7 @@ func TestRemoteReconnectToastFailedDrawDoesNotBlankBounds(t *testing.T) {
14621462 {frame: reconnectToastWelcome(44)},
14631463 {err: io.EOF},
14641464 }}
1465- dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr}}
1465+ dialer := &reconnectToastSequenceDialer{transports: []wire.Transport{tr}}
14661466
14671467 err := NewRunner(Dependencies{Dialer: dialer, Terminal: term.term, Clock: newReconnectHandshakeClock(t), DisableCapabilityProbe: true, Logger: slog.New(slog.DiscardHandler)}).Run(ctx, AttachRequest{Intent: protocol.IntentAttach, SessionName: "main", Remote: true})
14681468 require.ErrorContains(t, err, "reconnect toast write failed")
@@ -1484,12 +1484,12 @@ func (w *reconnectStatusClearFailWriter) Write(p []byte) (int, error) {
14841484 func TestReconnectCancellationPreservesContextWhenStatusClearFails(t *testing.T) {
14851485 tests := []struct {
14861486 name string
1487- configure func(*mockClientDialer, ports.Transport)
1487+ configure func(*mockClientDialer, wire.Transport)
14881488 sleep func(context.CancelFunc) bool
14891489 }{
14901490 {
14911491 name: "after attach failure",
1492- configure: func(dialer *mockClientDialer, tr ports.Transport) {
1492+ configure: func(dialer *mockClientDialer, tr wire.Transport) {
14931493 dialer.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
14941494 },
14951495 sleep: func(cancel context.CancelFunc) bool {
@@ -1499,7 +1499,7 @@ func TestReconnectCancellationPreservesContextWhenStatusClearFails(t *testing.T)
14991499 },
15001500 {
15011501 name: "after reconnect dial failure",
1502- configure: func(dialer *mockClientDialer, tr ports.Transport) {
1502+ configure: func(dialer *mockClientDialer, tr wire.Transport) {
15031503 dialer.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
15041504 dialer.EXPECT().Dial(mock.Anything).Return(nil, errors.New("redial failed")).Once()
15051505 },
@@ -1575,7 +1575,7 @@ func TestRemoteReconnectToastLifecycleWithWrappedTransportError(t *testing.T) {
15751575 {frame: reconnectToastWelcome(55)},
15761576 {frame: reconnectToastDetach(protocol.ReasonDetach)},
15771577 }}
1578- dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}}
1578+ dialer := &reconnectToastSequenceDialer{transports: []wire.Transport{tr1, tr2}}
15791579
15801580 err := NewRunner(Dependencies{Dialer: dialer, Terminal: term.term, Clock: newReconnectHandshakeClock(t), DisableCapabilityProbe: true, Logger: slog.New(slog.DiscardHandler)}).Run(context.Background(), AttachRequest{Intent: protocol.IntentAttach, SessionName: "main", Remote: true})
15811581 require.NoError(t, err)
@@ -1619,7 +1619,7 @@ func TestRemoteEphemeralReconnectUsesAssignedSessionName(t *testing.T) {
16191619 {frame: reconnectToastWelcomeNamed("0", 55)},
16201620 {frame: reconnectToastDetach(protocol.ReasonDetach)},
16211621 }}
1622- dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}}
1622+ dialer := &reconnectToastSequenceDialer{transports: []wire.Transport{tr1, tr2}}
16231623
16241624 err := NewRunner(Dependencies{Dialer: dialer, Terminal: term.term, Clock: newReconnectHandshakeClock(t), DisableCapabilityProbe: true, Logger: slog.New(slog.DiscardHandler)}).Run(context.Background(), AttachRequest{Intent: protocol.IntentEphemeral, SessionName: "", Remote: true})
16251625 require.NoError(t, err)
M internal/usecase/client/transport_observability_test.go +3 -2
@@ -11,6 +11,7 @@ import (
1111
1212 "github.com/stretchr/testify/require"
1313
14+ "github.com/bnema/vev/internal/adapters/observability"
1415 "github.com/bnema/vev/internal/domain"
1516 "github.com/bnema/vev/internal/ports"
1617 portsmocks "github.com/bnema/vev/internal/ports/mocks"
@@ -43,7 +44,7 @@ func TestBlockingRuntimeObserverDoesNotDelayTerminalFlushOrACK(t *testing.T) {
4344 term.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
4445
4546 observer := &blockingClientRuntimeObserver{entered: make(chan struct{}), release: make(chan struct{})}
46- reporter := ports.NewSerializedRuntimeObserver(observer, 1)
47+ reporter := observability.NewSerialized(observer, 1)
4748 defer reporter.Close()
4849 acked := make(chan struct{})
4950 transport := newMockClientConnection(t)
@@ -85,7 +86,7 @@ func TestTerminalFlushBoundaryTransportObservability(t *testing.T) {
8586 defer in.unblock()
8687
8788 observer := &clientRuntimeObserver{}
88- reporter := ports.NewSerializedRuntimeObserver(observer, 64)
89+ reporter := observability.NewSerialized(observer, 64)
8990 defer reporter.Close()
9091 transport := newMockClientConnection(t)
9192 transport.EXPECT().Send(isType(wire.MsgHello)).Return(nil).Once()
M internal/usecase/client/typed_connection_external_test.go +8 -8
@@ -7,9 +7,9 @@ import (
77 "github.com/stretchr/testify/mock"
88
99 "github.com/bnema/vev/internal/ports"
10- portsmocks "github.com/bnema/vev/internal/ports/mocks"
1110 "github.com/bnema/vev/internal/protocol"
1211 "github.com/bnema/vev/internal/protocol/wire"
12+ wiremocks "github.com/bnema/vev/internal/protocol/wire/mocks"
1313 )
1414
1515 type mockTestingT interface {
@@ -17,10 +17,10 @@ type mockTestingT interface {
1717 Cleanup(func())
1818 }
1919
20-type mockClientConnection struct{ *portsmocks.MockTransport }
20+type mockClientConnection struct{ *wiremocks.MockTransport }
2121
2222 func newMockClientConnection(t mockTestingT) *mockClientConnection {
23- return &mockClientConnection{MockTransport: portsmocks.NewMockTransport(t)}
23+ return &mockClientConnection{MockTransport: wiremocks.NewMockTransport(t)}
2424 }
2525 func (c *mockClientConnection) SendClient(m protocol.ClientMessage) error {
2626 f, e := externalClientFrame(m)
@@ -42,7 +42,7 @@ func (c *mockClientConnection) Capabilities() protocol.ConnectionCapabilities {
4242 func (c *mockClientConnection) LinkState() ports.LinkState { return ports.LinkStateConnected }
4343 func (c *mockClientConnection) LinkEvents() <-chan ports.LinkEvent { return nil }
4444
45-type rawClientConnection struct{ raw ports.Transport }
45+type rawClientConnection struct{ raw wire.Transport }
4646
4747 func (c *rawClientConnection) SendClient(m protocol.ClientMessage) error {
4848 f, e := externalClientFrame(m)
@@ -75,10 +75,10 @@ func (c *rawClientConnection) LinkEvents() <-chan ports.LinkEvent {
7575 }
7676 func (c *rawClientConnection) Close() error { return c.raw.Close() }
7777
78-type mockClientDialer struct{ *portsmocks.MockDialer }
78+type mockClientDialer struct{ *wiremocks.MockDialer }
7979
8080 func newMockClientDialer(t mockTestingT) *mockClientDialer {
81- return &mockClientDialer{MockDialer: portsmocks.NewMockDialer(t)}
81+ return &mockClientDialer{MockDialer: wiremocks.NewMockDialer(t)}
8282 }
8383 func (d *mockClientDialer) Dial(ctx context.Context) (ports.ClientConnection, error) {
8484 raw, e := d.MockDialer.Dial(ctx)
@@ -88,8 +88,8 @@ func (d *mockClientDialer) Dial(ctx context.Context) (ports.ClientConnection, er
8888 return &rawClientConnection{raw: raw}, nil
8989 }
9090
91-func externalCapabilities(raw ports.Transport) protocol.ConnectionCapabilities {
92- _, dgram := raw.(ports.DatagramTransport)
91+func externalCapabilities(raw wire.Transport) protocol.ConnectionCapabilities {
92+ _, dgram := raw.(wire.DatagramTransport)
9393 _, link := raw.(ports.LinkStateReporter)
9494 window := uint8(protocol.MaxOutputWindow)
9595 if dgram {
M internal/usecase/client/typed_connection_test.go +10 -9
@@ -15,6 +15,7 @@ import (
1515 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1616 "github.com/bnema/vev/internal/protocol"
1717 "github.com/bnema/vev/internal/protocol/wire"
18+ wiremocks "github.com/bnema/vev/internal/protocol/wire/mocks"
1819 )
1920
2021 type mockTestingT interface {
@@ -22,10 +23,10 @@ type mockTestingT interface {
2223 Cleanup(func())
2324 }
2425
25-type mockClientConnection struct{ *portsmocks.MockTransport }
26+type mockClientConnection struct{ *wiremocks.MockTransport }
2627
2728 func newMockClientConnection(t mockTestingT) *mockClientConnection {
28- return &mockClientConnection{MockTransport: portsmocks.NewMockTransport(t)}
29+ return &mockClientConnection{MockTransport: wiremocks.NewMockTransport(t)}
2930 }
3031
3132 func (c *mockClientConnection) SendClient(message protocol.ClientMessage) error {
@@ -52,7 +53,7 @@ func (c *mockClientConnection) LinkEvents() <-chan ports.LinkEvent {
5253 return testClientLinkEvents(c.MockTransport)
5354 }
5455
55-type rawClientConnection struct{ raw ports.Transport }
56+type rawClientConnection struct{ raw wire.Transport }
5657
5758 func (c *rawClientConnection) SendClient(message protocol.ClientMessage) error {
5859 frame, err := testClientFrame(message)
@@ -75,10 +76,10 @@ func (c *rawClientConnection) LinkState() ports.LinkState { return testC
7576 func (c *rawClientConnection) LinkEvents() <-chan ports.LinkEvent { return testClientLinkEvents(c.raw) }
7677 func (c *rawClientConnection) Close() error { return c.raw.Close() }
7778
78-type mockClientDialer struct{ *portsmocks.MockDialer }
79+type mockClientDialer struct{ *wiremocks.MockDialer }
7980
8081 func newMockClientDialer(t mockTestingT) *mockClientDialer {
81- return &mockClientDialer{MockDialer: portsmocks.NewMockDialer(t)}
82+ return &mockClientDialer{MockDialer: wiremocks.NewMockDialer(t)}
8283 }
8384 func (d *mockClientDialer) Dial(ctx context.Context) (ports.ClientConnection, error) {
8485 raw, err := d.MockDialer.Dial(ctx)
@@ -179,8 +180,8 @@ func testServerMessage(frame wire.Frame) (protocol.ServerMessage, error) {
179180 }
180181 }
181182
182-func testClientCapabilities(raw ports.Transport) protocol.ConnectionCapabilities {
183- _, dgram := raw.(ports.DatagramTransport)
183+func testClientCapabilities(raw wire.Transport) protocol.ConnectionCapabilities {
184+ _, dgram := raw.(wire.DatagramTransport)
184185 _, link := raw.(ports.LinkStateReporter)
185186 window := uint8(protocol.MaxOutputWindow)
186187 if dgram {
@@ -188,13 +189,13 @@ func testClientCapabilities(raw ports.Transport) protocol.ConnectionCapabilities
188189 }
189190 return protocol.ConnectionCapabilities{OutputDataLimit: protocol.MaxOutputDataLen, PreferredOutputWindow: window, LinkState: link}
190191 }
191-func testClientLinkState(raw ports.Transport) ports.LinkState {
192+func testClientLinkState(raw wire.Transport) ports.LinkState {
192193 if r, ok := raw.(ports.LinkStateReporter); ok {
193194 return r.LinkState()
194195 }
195196 return ports.LinkStateConnected
196197 }
197-func testClientLinkEvents(raw ports.Transport) <-chan ports.LinkEvent {
198+func testClientLinkEvents(raw wire.Transport) <-chan ports.LinkEvent {
198199 if r, ok := raw.(ports.LinkStateReporter); ok {
199200 return r.LinkEvents()
200201 }
M internal/usecase/daemon/attachment_output.go +2 -2
@@ -8,7 +8,7 @@ import (
88
99 renderer "github.com/bnema/vev-vt/ansi"
1010 "github.com/bnema/vev/internal/domain"
11- "github.com/bnema/vev/internal/ports"
11+ "github.com/bnema/vev/internal/domain/terminalcap"
1212 "github.com/bnema/vev/internal/protocol"
1313 )
1414
@@ -95,7 +95,7 @@ func newOutputStateStream(windowSize ...uint8) *attachmentOutput {
9595 return newOutputStateStreamForProfile(renderer.ColorProfileTrueColor, windowSize...)
9696 }
9797
98-func newOutputStateStreamForCapabilities(capabilities ports.TerminalCapabilities, windowSize ...uint8) *attachmentOutput {
98+func newOutputStateStreamForCapabilities(capabilities terminalcap.Capabilities, windowSize ...uint8) *attachmentOutput {
9999 profile := renderer.ColorProfileANSI256
100100 if capabilities.TrueColor() {
101101 profile = renderer.ColorProfileTrueColor
M internal/usecase/daemon/attachment_output_test.go +5 -5
@@ -9,7 +9,7 @@ import (
99 renderer "github.com/bnema/vev-vt/ansi"
1010 "github.com/bnema/vev-vt/graphics"
1111 "github.com/bnema/vev/internal/domain"
12- "github.com/bnema/vev/internal/ports"
12+ "github.com/bnema/vev/internal/domain/terminalcap"
1313 "github.com/bnema/vev/internal/protocol"
1414 "github.com/bnema/vev/internal/protocol/wire"
1515 "github.com/bnema/vev/internal/usecase/layout"
@@ -56,17 +56,17 @@ func TestAttachmentOutputUsesTerminalColorProfile(t *testing.T) {
5656
5757 cases := []struct {
5858 name string
59- capabilities ports.TerminalCapabilities
59+ capabilities terminalcap.Capabilities
6060 want string
6161 }{
6262 {
6363 name: "truecolor",
64- capabilities: ports.TerminalCapabilities{ColorMode: ports.TerminalColorTrueColor},
64+ capabilities: terminalcap.Capabilities{ColorMode: terminalcap.TrueColor},
6565 want: "\x1b[1;1H\x1b[0;38;2;255;0;0;48;2;0;0;255;58;2;0;255;0mX\x1b[0m",
6666 },
6767 {
6868 name: "indexed 256",
69- capabilities: ports.TerminalCapabilities{ColorMode: ports.TerminalColorIndexed256},
69+ capabilities: terminalcap.Capabilities{ColorMode: terminalcap.Indexed256},
7070 want: "\x1b[1;1H\x1b[0;38;5;196;48;5;21;58;5;46mX\x1b[0m",
7171 },
7272 }
@@ -451,7 +451,7 @@ func TestAttachmentOutputFailedSendKeepsTextCursorAndGraphicsSpeculative(t *test
451451 graphicsState := newGraphicsOutputState()
452452 output := attachmentOutputWithGraphics(graphicsState)
453453 output.lastCursor = cursorOut{valid: true, row: 1, col: 1}
454- ac := &attachedClient{output: output, terminalCapabilities: ports.TerminalCapabilities{KittyGraphics: true}}
454+ ac := &attachedClient{output: output, terminalCapabilities: terminalcap.Capabilities{KittyGraphics: true}}
455455 output.attachment = ac
456456 frame := renderer.NewFrame(1, 1)
457457 prepared, err := output.prepareFrame(nil, state, frame, []renderer.Damage{renderer.FullRedraw()}, true, cursorOut{row: 2, col: 3})
M internal/usecase/daemon/client.go +5 -4
@@ -12,6 +12,7 @@ import (
1212 "time"
1313
1414 "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/domain/terminalcap"
1516 "github.com/bnema/vev/internal/ports"
1617 "github.com/bnema/vev/internal/protocol"
1718 "github.com/bnema/vev/internal/usecase/keys"
@@ -40,7 +41,7 @@ type attachedClient struct {
4041 overlays *overlayRuntime
4142 overlayOnce sync.Once
4243 clientID [16]byte
43- terminalCapabilities ports.TerminalCapabilities
44+ terminalCapabilities terminalcap.Capabilities
4445 navigationCapabilities protocol.NavigationCapabilities
4546 startupOverlay protocol.StartupOverlay
4647 // lifecycle is the sole authority for attachment capability publication,
@@ -612,7 +613,7 @@ type attachClientOptions struct {
612613 clientID [16]byte
613614 resumeCapable bool
614615 maxOutputInFlight uint8
615- terminalCapabilities ports.TerminalCapabilities
616+ terminalCapabilities terminalcap.Capabilities
616617 capabilitiesSet bool
617618 navigationCapabilities protocol.NavigationCapabilities
618619 startupOverlay protocol.StartupOverlay
@@ -644,7 +645,7 @@ func (d *Daemon) attachClient(sess *session, tr ports.ServerConnection, sz domai
644645 func (d *Daemon) finishAttachedClient(sess *session, ac *attachedClient, opts attachClientOptions) {
645646 d.touchMRU(sess)
646647 d.log.Info("client attached", "session", sess.name, "resume", opts.resumeCapable)
647- if ac.terminalCapabilities.ColorSource == ports.TerminalCapabilityDeclared && !ac.terminalCapabilities.TrueColor() {
648+ if ac.terminalCapabilities.ColorSource == terminalcap.SourceDeclared && !ac.terminalCapabilities.TrueColor() {
648649 d.publishToast(ac, domain.Notification{
649650 Code: domain.NoticeUser,
650651 Severity: domain.NoticeWarn,
@@ -669,7 +670,7 @@ func (d *Daemon) prepareAttachedClientLocked(sess *session, tr ports.ServerConne
669670 resumeToken = d.nextResumeTokenLocked()
670671 }
671672 if !opts.capabilitiesSet {
672- opts.terminalCapabilities = ports.TerminalCapabilities{ColorMode: ports.TerminalColorTrueColor}
673+ opts.terminalCapabilities = terminalcap.Capabilities{ColorMode: terminalcap.TrueColor}
673674 }
674675 output := newOutputStateStreamForCapabilities(opts.terminalCapabilities, opts.maxOutputInFlight)
675676 geometry = geometry.NormalizePixels()
M internal/usecase/daemon/control.go +18 -17
@@ -13,6 +13,7 @@ import (
1313 "github.com/bnema/vev/internal/domain"
1414 "github.com/bnema/vev/internal/ports"
1515 "github.com/bnema/vev/internal/protocol"
16+ "github.com/bnema/vev/internal/protocol/catalogue"
1617 "github.com/bnema/vev/internal/usecase/command"
1718 "github.com/bnema/vev/internal/usecase/layout"
1819 )
@@ -711,7 +712,7 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) {
711712 }
712713 e.d.mu.Unlock()
713714
714- rows := make([]ports.RemoteCatalogSession, 0, len(sessions)+len(stopped))
715+ rows := make([]catalogue.RemoteCatalogSession, 0, len(sessions)+len(stopped))
715716 liveNames := make(map[string]struct{}, len(sessions))
716717 for _, sess := range sessions {
717718 e.d.refreshSessionFocusedTitles(sess)
@@ -720,10 +721,10 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) {
720721 if err != nil {
721722 return "", err
722723 }
723- row := ports.RemoteCatalogSession{
724+ row := catalogue.RemoteCatalogSession{
724725 LifecycleID: snap.incarnation,
725726 Name: snap.name,
726- State: ports.RemoteCatalogSessionUp,
727+ State: catalogue.RemoteCatalogSessionUp,
727728 Ephemeral: snap.ephemeral,
728729 Tabs: tabs,
729730 Attached: snap.attached,
@@ -743,13 +744,13 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) {
743744 if err != nil {
744745 return "", err
745746 }
746- state := ports.RemoteCatalogSessionDown
747+ state := catalogue.RemoteCatalogSessionDown
747748 reason := ""
748749 if entry.broken() {
749- state = ports.RemoteCatalogSessionBroken
750+ state = catalogue.RemoteCatalogSessionBroken
750751 reason = "session_broken"
751752 }
752- rows = append(rows, ports.RemoteCatalogSession{
753+ rows = append(rows, catalogue.RemoteCatalogSession{
753754 LifecycleID: entry.incarnation,
754755 Name: entry.name,
755756 State: state,
@@ -765,24 +766,24 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) {
765766 }
766767 return rows[i].LifecycleID.String() < rows[j].LifecycleID.String()
767768 })
768- catalog := ports.RemoteCatalog{
769+ catalog := catalogue.RemoteCatalog{
769770 ProtocolVersion: protocol.Version,
770- SchemaVersion: ports.RemoteCatalogSchemaVersion,
771+ SchemaVersion: catalogue.RemoteCatalogSchemaVersion,
771772 Sessions: rows,
772773 }
773- if err := ports.ValidateRemoteCatalog(catalog); err != nil {
774+ if err := catalogue.ValidateRemoteCatalog(catalog); err != nil {
774775 return "", err
775776 }
776777 return marshalListing(catalog)
777778 }
778779
779-func remoteCatalogTabs(view sessionView) ([]ports.RemoteCatalogTab, error) {
780- if len(view.tabs) > ports.RemoteCatalogMaxTabsPerSess {
780+func remoteCatalogTabs(view sessionView) ([]catalogue.RemoteCatalogTab, error) {
781+ if len(view.tabs) > catalogue.RemoteCatalogMaxTabsPerSess {
781782 return nil, fmt.Errorf("remote catalog: session %q has too many tabs", view.name)
782783 }
783- tabs := make([]ports.RemoteCatalogTab, 0, len(view.tabs))
784+ tabs := make([]catalogue.RemoteCatalogTab, 0, len(view.tabs))
784785 for i, tab := range view.tabs {
785- tabs = append(tabs, ports.RemoteCatalogTab{
786+ tabs = append(tabs, catalogue.RemoteCatalogTab{
786787 ID: string(tab.id),
787788 Index: uint16(i),
788789 Name: tab.name,
@@ -793,7 +794,7 @@ func remoteCatalogTabs(view sessionView) ([]ports.RemoteCatalogTab, error) {
793794 return tabs, nil
794795 }
795796
796-func remoteCatalogStoppedTabs(entry inactiveSession) ([]ports.RemoteCatalogTab, error) {
797+func remoteCatalogStoppedTabs(entry inactiveSession) ([]catalogue.RemoteCatalogTab, error) {
797798 records := entry.tabRecords
798799 if len(records) == 0 && len(entry.tabNames) != 0 {
799800 records = make([]domain.CatalogueTabRecord, len(entry.tabNames))
@@ -801,12 +802,12 @@ func remoteCatalogStoppedTabs(entry inactiveSession) ([]ports.RemoteCatalogTab,
801802 records[i].Name = name
802803 }
803804 }
804- if len(records) > ports.RemoteCatalogMaxTabsPerSess {
805+ if len(records) > catalogue.RemoteCatalogMaxTabsPerSess {
805806 return nil, fmt.Errorf("remote catalog: session %q has too many tabs", entry.name)
806807 }
807- tabs := make([]ports.RemoteCatalogTab, 0, len(records))
808+ tabs := make([]catalogue.RemoteCatalogTab, 0, len(records))
808809 for i, record := range records {
809- tabs = append(tabs, ports.RemoteCatalogTab{ID: string(record.StableID), Index: uint16(i), Name: record.Name})
810+ tabs = append(tabs, catalogue.RemoteCatalogTab{ID: string(record.StableID), Index: uint16(i), Name: record.Name})
810811 }
811812 return tabs, nil
812813 }
M internal/usecase/daemon/control_test.go +13 -12
@@ -14,9 +14,10 @@ import (
1414 "time"
1515
1616 "github.com/bnema/vev/internal/domain"
17- "github.com/bnema/vev/internal/ports"
17+ appports "github.com/bnema/vev/internal/ports"
1818 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1919 "github.com/bnema/vev/internal/protocol"
20+ "github.com/bnema/vev/internal/protocol/catalogue"
2021 "github.com/bnema/vev/internal/protocol/wire"
2122 "github.com/bnema/vev/internal/usecase/command"
2223 "github.com/bnema/vev/internal/usecase/layout"
@@ -774,7 +775,7 @@ func TestRemoteCatalogJSONOutput(t *testing.T) {
774775 require.True(t, result.OK, result.Text)
775776 require.True(t, strings.HasSuffix(result.Output, "\n"), "catalog output must be newline-terminated")
776777
777- var catalog ports.RemoteCatalog
778+ var catalog catalogue.RemoteCatalog
778779 require.NoError(t, json.Unmarshal([]byte(result.Output), &catalog))
779780 require.Equal(t, protocol.Version, catalog.ProtocolVersion)
780781
@@ -786,13 +787,13 @@ func TestRemoteCatalogJSONOutput(t *testing.T) {
786787 _, hasSessions := raw["sessions"]
787788 require.True(t, hasProtocol && hasSchema && hasSessions)
788789
789- require.Equal(t, []ports.RemoteCatalogSession{
790- {LifecycleID: build.incarnation, Name: "build", State: "up", Ephemeral: true, Tabs: []ports.RemoteCatalogTab{{ID: "t_build", Name: "1", Detail: " (sh)"}}, ActiveTabID: "t_build", LastUsedSeq: 1},
791- {LifecycleID: domain.IncarnationID{3}, Name: "old", State: "down", Tabs: []ports.RemoteCatalogTab{}},
792- {LifecycleID: work.incarnation, Name: "work", State: "up", Tabs: []ports.RemoteCatalogTab{{ID: "t_work", Name: "1", Detail: " (sh)"}, {ID: "t_work_2", Index: 1, Name: "2", Detail: " (sh)"}}, Attached: true, ActiveTabID: "t_work", LastUsedSeq: 2},
790+ require.Equal(t, []catalogue.RemoteCatalogSession{
791+ {LifecycleID: build.incarnation, Name: "build", State: "up", Ephemeral: true, Tabs: []catalogue.RemoteCatalogTab{{ID: "t_build", Name: "1", Detail: " (sh)"}}, ActiveTabID: "t_build", LastUsedSeq: 1},
792+ {LifecycleID: domain.IncarnationID{3}, Name: "old", State: "down", Tabs: []catalogue.RemoteCatalogTab{}},
793+ {LifecycleID: work.incarnation, Name: "work", State: "up", Tabs: []catalogue.RemoteCatalogTab{{ID: "t_work", Name: "1", Detail: " (sh)"}, {ID: "t_work_2", Index: 1, Name: "2", Detail: " (sh)"}}, Attached: true, ActiveTabID: "t_work", LastUsedSeq: 2},
793794 }, catalog.Sessions)
794795
795- require.Equal(t, ports.RemoteCatalogSessionDown, catalog.Sessions[1].State)
796+ require.Equal(t, catalogue.RemoteCatalogSessionDown, catalog.Sessions[1].State)
796797
797798 listAfter := sendCommand(t, d, protocol.CommandRequest{Slug: "list-sessions"})
798799 require.True(t, listAfter.OK, listAfter.Text)
@@ -818,14 +819,14 @@ func TestRemoteCatalogRefreshesFocusedTabTitle(t *testing.T) {
818819
819820 result := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog", JSON: true})
820821 require.True(t, result.OK, result.Text)
821- var catalog ports.RemoteCatalog
822+ var catalog catalogue.RemoteCatalog
822823 require.NoError(t, json.Unmarshal([]byte(result.Output), &catalog))
823- require.Equal(t, []ports.RemoteCatalogSession{{
824+ require.Equal(t, []catalogue.RemoteCatalogSession{{
824825 LifecycleID: sess.incarnation,
825826 Name: "work",
826827 State: "up",
827828 Ephemeral: true,
828- Tabs: []ports.RemoteCatalogTab{{
829+ Tabs: []catalogue.RemoteCatalogTab{{
829830 ID: "t_work",
830831 Index: 0,
831832 Name: "shell",
@@ -840,7 +841,7 @@ func TestRemoteCatalogRejectsTooManyTabs(t *testing.T) {
840841 d := newTestDaemon(t, nil, stubClock{})
841842 sess := addControlSession(d, "work", "t_work", "p_work")
842843 sess.mu.Lock()
843- sess.tabs = make([]*tab, ports.RemoteCatalogMaxTabsPerSess+1)
844+ sess.tabs = make([]*tab, catalogue.RemoteCatalogMaxTabsPerSess+1)
844845 for i := range sess.tabs {
845846 sess.tabs[i] = newTabWithStableID(
846847 fmt.Sprintf("tab-%d", i), fmt.Sprintf("pane-%d", i), newQuietPTY(), domain.Size{Cols: 80, Rows: 22},
@@ -1345,7 +1346,7 @@ type controlPTYFactory struct {
13451346 once sync.Once
13461347 }
13471348
1348-func (f *controlPTYFactory) Open(_ context.Context, _ string, _ []string, _ []string, _ string, _ domain.Geometry) (ports.PTY, error) {
1349+func (f *controlPTYFactory) Open(_ context.Context, _ string, _ []string, _ []string, _ string, _ domain.Geometry) (appports.PTY, error) {
13491350 f.once.Do(func() {
13501351 if f.entered != nil {
13511352 close(f.entered)
M internal/usecase/daemon/daemon.go +5 -9
@@ -16,6 +16,7 @@ import (
1616 "time"
1717
1818 "github.com/bnema/vev/internal/domain"
19+ "github.com/bnema/vev/internal/domain/terminalcap"
1920 "github.com/bnema/vev/internal/ports"
2021 "github.com/bnema/vev/internal/protocol"
2122 "github.com/bnema/vev/internal/usecase/keys"
@@ -304,8 +305,6 @@ type Daemon struct {
304305 remoteCatalogClient ports.RemoteCatalogClient
305306 remoteCatalogCache ports.RemoteCatalogCache
306307 remotePreviewClient ports.RemotePreviewClient
307- remoteDialerFactory ports.RemoteDialerFactory
308- remoteTransportMode ports.RemoteTransportMode
309308 // tempDir overrides os.TempDir() for clipboard-image-transfer writes
310309 // (see clipboard.go); empty means use os.TempDir().
311310 tempDir string
@@ -416,14 +415,11 @@ func WithRuntimeObserver(observer ports.SerializedRuntimeObserver) Option {
416415 }
417416
418417 // WithRemoteDiscovery installs the remote discovery ports used by the daemon.
419-// The composition root validates mode before constructing the daemon.
420-func WithRemoteDiscovery(store ports.RemoteHostStore, catalog ports.RemoteCatalogClient, cache ports.RemoteCatalogCache, dialer ports.RemoteDialerFactory, mode ports.RemoteTransportMode) Option {
418+func WithRemoteDiscovery(store ports.RemoteHostStore, catalog ports.RemoteCatalogClient, cache ports.RemoteCatalogCache) Option {
421419 return func(d *Daemon) {
422420 d.remoteHostStore = store
423421 d.remoteCatalogClient = catalog
424422 d.remoteCatalogCache = cache
425- d.remoteDialerFactory = dialer
426- d.remoteTransportMode = mode
427423 }
428424 }
429425
@@ -1351,14 +1347,14 @@ func (d *Daemon) finishAttach(sess *session, tr ports.ServerConnection, sz domai
13511347 sess.env = copyEnvironment(h.Env)
13521348 }
13531349 sess.mu.Unlock()
1354- terminalCapabilities := ports.DetectTerminalCapabilities(h.Env)
1350+ terminalCapabilities := terminalcap.Detect(h.Env)
13551351 // Kitty graphics are enabled only by the explicit direct-terminal
13561352 // declaration in Hello. Environment values remain useful for color and
13571353 // diagnostics, but cannot authorize terminal-global graphics side effects.
13581354 terminalCapabilities.KittyGraphics = h.KittyDirectGraphics
13591355 if h.TrueColor && !terminalCapabilities.TrueColor() {
1360- terminalCapabilities.ColorMode = ports.TerminalColorTrueColor
1361- terminalCapabilities.ColorSource = ports.TerminalCapabilityDeclared
1356+ terminalCapabilities.ColorMode = terminalcap.TrueColor
1357+ terminalCapabilities.ColorSource = terminalcap.SourceDeclared
13621358 }
13631359 opts := attachClientOptions{
13641360 clientID: h.ClientID,
M internal/usecase/daemon/graphics_output_test.go +6 -6
@@ -13,7 +13,7 @@ import (
1313 vt "github.com/bnema/vev-vt"
1414 "github.com/bnema/vev-vt/graphics"
1515 "github.com/bnema/vev/internal/domain"
16- "github.com/bnema/vev/internal/ports"
16+ "github.com/bnema/vev/internal/domain/terminalcap"
1717 "github.com/bnema/vev/internal/protocol"
1818 "github.com/bnema/vev/internal/protocol/wire"
1919 "github.com/bnema/vev/internal/usecase/layout"
@@ -295,7 +295,7 @@ func TestGraphicsOutputDefersCleanupThatDoesNotFitFrameBudget(t *testing.T) {
295295 seed.commit()
296296 ac := &attachedClient{
297297 output: attachmentOutputWithGraphics(graphicsState),
298- terminalCapabilities: ports.TerminalCapabilities{KittyGraphics: true},
298+ terminalCapabilities: terminalcap.Capabilities{KittyGraphics: true},
299299 }
300300
301301 deferred, err := graphicsOutputDataWithDaemonLimit(nil, &capturedRenderState{}, ac, false, 0)
@@ -512,7 +512,7 @@ func TestGraphicsNamespacePoolExhaustionFallsBackToText(t *testing.T) {
512512 domain.Geometry{Size: defaultSize},
513513 attachClientOptions{
514514 capabilitiesSet: true,
515- terminalCapabilities: ports.TerminalCapabilities{KittyGraphics: true, ColorMode: ports.TerminalColorTrueColor},
515+ terminalCapabilities: terminalcap.Capabilities{KittyGraphics: true, ColorMode: terminalcap.TrueColor},
516516 },
517517 )
518518 d.mu.Unlock()
@@ -624,7 +624,7 @@ func TestGraphicsOutputOversizeSuppressesGraphicsWithoutCompositionError(t *test
624624 }}}
625625 ac := &attachedClient{
626626 output: attachmentOutputWithGraphics(newGraphicsOutputState()),
627- terminalCapabilities: ports.TerminalCapabilities{KittyGraphics: true},
627+ terminalCapabilities: terminalcap.Capabilities{KittyGraphics: true},
628628 }
629629 prepared, err := graphicsOutputData(state, ac, true)
630630 require.NoError(t, err, "ANSI composition must continue when optional graphics exceed the bound")
@@ -644,7 +644,7 @@ func TestGraphicsOutputClipsPlacementToPaneContent(t *testing.T) {
644644 }}}
645645 ac := &attachedClient{
646646 output: attachmentOutputWithGraphics(newGraphicsOutputState()),
647- terminalCapabilities: ports.TerminalCapabilities{KittyGraphics: true},
647+ terminalCapabilities: terminalcap.Capabilities{KittyGraphics: true},
648648 }
649649 prepared, err := graphicsOutputData(state, ac, true)
650650 require.NoError(t, err)
@@ -825,7 +825,7 @@ func graphicsPaneTestState(tab domain.TabStableID, panes ...capturedPaneRenderSt
825825 func graphicsPaneTestClient() *attachedClient {
826826 return &attachedClient{
827827 output: attachmentOutputWithGraphics(newGraphicsOutputState()),
828- terminalCapabilities: ports.TerminalCapabilities{KittyGraphics: true},
828+ terminalCapabilities: terminalcap.Capabilities{KittyGraphics: true},
829829 }
830830 }
831831
M internal/usecase/daemon/palette.go +3 -3
@@ -7,8 +7,8 @@ import (
77
88 renderer "github.com/bnema/vev-vt"
99 "github.com/bnema/vev/internal/domain"
10- "github.com/bnema/vev/internal/ports"
1110 "github.com/bnema/vev/internal/protocol"
11+ "github.com/bnema/vev/internal/protocol/catalogue"
1212 "github.com/bnema/vev/internal/usecase/command"
1313 "github.com/bnema/vev/internal/usecase/layout"
1414 "github.com/bnema/vev/internal/usecase/palette"
@@ -105,8 +105,8 @@ func paletteRouteRepresentsDaemonSession(entry protocol.RecentRouteEntry, daemon
105105 paletteRemoteDisplayOrigin(entry.HostLabel) == daemonDisplayOrigin
106106 }
107107
108-func remotePaletteUnavailableReason(status remoteHostStatus, session ports.RemoteCatalogSession) string {
109- if session.State == ports.RemoteCatalogSessionBroken {
108+func remotePaletteUnavailableReason(status remoteHostStatus, session catalogue.RemoteCatalogSession) string {
109+ if session.State == catalogue.RemoteCatalogSessionBroken {
110110 return "session_broken"
111111 }
112112 return remoteReasonForStatus(status)
M internal/usecase/daemon/palette_test.go +20 -20
@@ -11,9 +11,9 @@ import (
1111
1212 renderer "github.com/bnema/vev-vt"
1313 "github.com/bnema/vev/internal/domain"
14- "github.com/bnema/vev/internal/ports"
1514 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1615 "github.com/bnema/vev/internal/protocol"
16+ "github.com/bnema/vev/internal/protocol/catalogue"
1717 "github.com/bnema/vev/internal/protocol/wire"
1818 "github.com/bnema/vev/internal/usecase/command"
1919 "github.com/bnema/vev/internal/usecase/palette"
@@ -439,11 +439,11 @@ func TestPaletteIncludesExactRemoteCatalogTargetBesideSameNameLocalSession(t *te
439439 local.ephemeral = false
440440 remoteLifecycle := domain.SessionLifecycleID{21}
441441 d.remoteCatalog.mu.Lock()
442- d.remoteCatalog.cache["user@arch"] = ports.RemoteCatalogCacheEntry{
442+ d.remoteCatalog.cache["user@arch"] = catalogue.RemoteCatalogCacheEntry{
443443 Host: "user@arch", FetchedAt: now,
444- Sessions: []ports.RemoteCatalogSession{{
445- LifecycleID: remoteLifecycle, Name: "vev", State: ports.RemoteCatalogSessionUp,
446- Tabs: []ports.RemoteCatalogTab{{ID: "tab-remote", Index: 0, Name: "shell"}}, ActiveTabID: "tab-remote",
444+ Sessions: []catalogue.RemoteCatalogSession{{
445+ LifecycleID: remoteLifecycle, Name: "vev", State: catalogue.RemoteCatalogSessionUp,
446+ Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-remote", Index: 0, Name: "shell"}}, ActiveTabID: "tab-remote",
447447 }},
448448 }
449449 d.remoteCatalog.status["user@arch"] = remoteHostFresh
@@ -527,11 +527,11 @@ func TestPaletteMatchesRecentRemoteRouteToCatalog(t *testing.T) {
527527 lifecycle := domain.SessionLifecycleID{22}
528528 d.remoteCatalog.mu.Lock()
529529 for _, endpoint := range test.catalogEndpoints {
530- d.remoteCatalog.cache[endpoint] = ports.RemoteCatalogCacheEntry{
530+ d.remoteCatalog.cache[endpoint] = catalogue.RemoteCatalogCacheEntry{
531531 Host: endpoint, FetchedAt: now,
532- Sessions: []ports.RemoteCatalogSession{{
533- LifecycleID: lifecycle, Name: "vev", State: ports.RemoteCatalogSessionUp,
534- Tabs: []ports.RemoteCatalogTab{{ID: "tab-vev", Index: 0}}, ActiveTabID: "tab-vev",
532+ Sessions: []catalogue.RemoteCatalogSession{{
533+ LifecycleID: lifecycle, Name: "vev", State: catalogue.RemoteCatalogSessionUp,
534+ Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-vev", Index: 0}}, ActiveTabID: "tab-vev",
535535 }},
536536 }
537537 d.remoteCatalog.status[endpoint] = remoteHostFresh
@@ -577,11 +577,11 @@ func TestPaletteRemoteCatalogSelectionSendsExactAttachTarget(t *testing.T) {
577577 d.clock = fixedRemoteRefreshClock{now: now}
578578 remoteLifecycle := domain.SessionLifecycleID{31}
579579 d.remoteCatalog.mu.Lock()
580- d.remoteCatalog.cache["user@arch"] = ports.RemoteCatalogCacheEntry{
580+ d.remoteCatalog.cache["user@arch"] = catalogue.RemoteCatalogCacheEntry{
581581 Host: "user@arch", FetchedAt: now,
582- Sessions: []ports.RemoteCatalogSession{{
583- LifecycleID: remoteLifecycle, Name: "work", State: ports.RemoteCatalogSessionUp,
584- Tabs: []ports.RemoteCatalogTab{{ID: "tab-work", Index: 0, Name: "shell"}}, ActiveTabID: "tab-work",
582+ Sessions: []catalogue.RemoteCatalogSession{{
583+ LifecycleID: remoteLifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp,
584+ Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-work", Index: 0, Name: "shell"}}, ActiveTabID: "tab-work",
585585 }},
586586 }
587587 d.remoteCatalog.status["user@arch"] = remoteHostFresh
@@ -616,11 +616,11 @@ func TestPaletteCachedRemoteSelectionFailsClosed(t *testing.T) {
616616 now := time.Unix(1_000, 0)
617617 d.clock = fixedRemoteRefreshClock{now: now}
618618 d.remoteCatalog.mu.Lock()
619- d.remoteCatalog.cache["arch"] = ports.RemoteCatalogCacheEntry{
619+ d.remoteCatalog.cache["arch"] = catalogue.RemoteCatalogCacheEntry{
620620 Host: "arch", FetchedAt: now,
621- Sessions: []ports.RemoteCatalogSession{{
622- LifecycleID: domain.SessionLifecycleID{32}, Name: "cached", State: ports.RemoteCatalogSessionUp,
623- Tabs: []ports.RemoteCatalogTab{{ID: "tab-cached", Index: 0}}, ActiveTabID: "tab-cached",
621+ Sessions: []catalogue.RemoteCatalogSession{{
622+ LifecycleID: domain.SessionLifecycleID{32}, Name: "cached", State: catalogue.RemoteCatalogSessionUp,
623+ Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-cached", Index: 0}}, ActiveTabID: "tab-cached",
624624 }},
625625 }
626626 d.remoteCatalog.status["arch"] = remoteHostCached
@@ -660,9 +660,9 @@ func TestRemoteRefreshUpdatesOpenPaletteAndPreservesQuery(t *testing.T) {
660660 model.Insert(r)
661661 }
662662 ac.overlays.paletteMu.Unlock()
663- request.result <- remoteRefreshResult{catalog: remoteCatalogForTest(ports.RemoteCatalogSession{
664- LifecycleID: domain.SessionLifecycleID{41}, Name: "vev", State: ports.RemoteCatalogSessionUp,
665- Tabs: []ports.RemoteCatalogTab{{ID: "tab-vev", Index: 0}}, ActiveTabID: "tab-vev",
663+ request.result <- remoteRefreshResult{catalog: remoteCatalogForTest(catalogue.RemoteCatalogSession{
664+ LifecycleID: domain.SessionLifecycleID{41}, Name: "vev", State: catalogue.RemoteCatalogSessionUp,
665+ Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-vev", Index: 0}}, ActiveTabID: "tab-vev",
666666 })}
667667 receiveRemotePicker(t, cache.stores, "palette catalog cache store")
668668 d.sessWg.Wait()
M internal/usecase/daemon/picker.go +6 -6
@@ -9,8 +9,8 @@ import (
99
1010 renderer "github.com/bnema/vev-vt"
1111 "github.com/bnema/vev/internal/domain"
12- "github.com/bnema/vev/internal/ports"
1312 "github.com/bnema/vev/internal/protocol"
13+ "github.com/bnema/vev/internal/protocol/catalogue"
1414 "github.com/bnema/vev/internal/usecase/layout"
1515 "github.com/bnema/vev/internal/usecase/picker"
1616 "github.com/bnema/vev/internal/usecase/ui"
@@ -1005,14 +1005,14 @@ func (d *Daemon) sendRemoteAttachTargetForAttachment(effect *attachmentEffect, t
10051005 return nil
10061006 }
10071007
1008-func (d *Daemon) remoteCatalogEntryLocked(host string) (ports.RemoteCatalogCacheEntry, bool) {
1008+func (d *Daemon) remoteCatalogEntryLocked(host string) (catalogue.RemoteCatalogCacheEntry, bool) {
10091009 if d.remoteCatalog.status[host] != remoteHostFresh {
1010- return ports.RemoteCatalogCacheEntry{}, false
1010+ return catalogue.RemoteCatalogCacheEntry{}, false
10111011 }
10121012 entry, ok := d.remoteCatalog.cache[host]
10131013 if !ok || remoteCatalogExpired(entry.FetchedAt, d.clock.Now()) {
10141014 d.remoteCatalog.status[host] = remoteHostStale
1015- return ports.RemoteCatalogCacheEntry{}, false
1015+ return catalogue.RemoteCatalogCacheEntry{}, false
10161016 }
10171017 return entry, true
10181018 }
@@ -1031,10 +1031,10 @@ func (d *Daemon) remoteCatalogTargetReady(target domain.RemoteSessionTarget) boo
10311031 if session.Name != target.SessionName || session.LifecycleID != target.LifecycleID {
10321032 continue
10331033 }
1034- if target.Stopped != remoteSessionStateStopped(session.State) || session.State == ports.RemoteCatalogSessionBroken {
1034+ if target.Stopped != remoteSessionStateStopped(session.State) || session.State == catalogue.RemoteCatalogSessionBroken {
10351035 return false
10361036 }
1037- tabs := ports.CatalogTabs(session)
1037+ tabs := catalogue.CatalogTabs(session)
10381038 metadata := make([]domain.TabSelectorTab, 0, len(tabs))
10391039 for _, tab := range tabs {
10401040 metadata = append(metadata, domain.TabSelectorTab{ID: domain.TabStableID(tab.ID), Name: tab.Name})
M internal/usecase/daemon/remote_attach_test.go +11 -11
@@ -7,8 +7,8 @@ import (
77 "github.com/stretchr/testify/require"
88
99 "github.com/bnema/vev/internal/domain"
10- "github.com/bnema/vev/internal/ports"
1110 "github.com/bnema/vev/internal/protocol"
11+ "github.com/bnema/vev/internal/protocol/catalogue"
1212 "github.com/bnema/vev/internal/protocol/wire"
1313 "github.com/bnema/vev/internal/usecase/picker"
1414 )
@@ -76,9 +76,9 @@ func TestStoppedLocalPickerHandoffWaitsForClientClose(t *testing.T) {
7676 func TestRemotePickerRichHandoffCarriesLifecycleTabAndPolicy(t *testing.T) {
7777 d := newRemotePickerDaemon(nil)
7878 lifecycle := remoteLifecycleForTest()
79- d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
80- Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{
81- LifecycleID: lifecycle, Name: "work", State: "up", Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
79+ d.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{{
80+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []catalogue.RemoteCatalogSession{{
81+ LifecycleID: lifecycle, Name: "work", State: "up", Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
8282 }},
8383 }})
8484 d.remoteCatalog.mu.Lock()
@@ -117,10 +117,10 @@ func TestRemotePickerRichHandoffRejectsMismatchedRouteKey(t *testing.T) {
117117 t.Run(test.name, func(t *testing.T) {
118118 d := newRemotePickerDaemon(nil)
119119 lifecycle := remoteLifecycleForTest()
120- d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
121- Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{
122- LifecycleID: lifecycle, Name: "work", State: ports.RemoteCatalogSessionUp,
123- Tabs: []ports.RemoteCatalogTab{{ID: "tab-1"}},
120+ d.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{{
121+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []catalogue.RemoteCatalogSession{{
122+ LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp,
123+ Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1"}},
124124 }},
125125 }})
126126 d.remoteCatalog.mu.Lock()
@@ -155,9 +155,9 @@ func TestRemotePickerRichHandoffRejectsReplacedLifecycle(t *testing.T) {
155155 lifecycle := remoteLifecycleForTest()
156156 cachedLifecycle := lifecycle
157157 cachedLifecycle[0]++
158- d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
159- Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{
160- LifecycleID: cachedLifecycle, Name: "work", State: "up", Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
158+ d.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{{
159+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []catalogue.RemoteCatalogSession{{
160+ LifecycleID: cachedLifecycle, Name: "work", State: "up", Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
161161 }},
162162 }})
163163 d.remoteCatalog.mu.Lock()
M internal/usecase/daemon/remote_catalog_state.go +13 -16
@@ -7,7 +7,7 @@ import (
77 "sync"
88 "time"
99
10- "github.com/bnema/vev/internal/ports"
10+ "github.com/bnema/vev/internal/protocol/catalogue"
1111 )
1212
1313 type remoteHostStatus uint8
@@ -22,8 +22,6 @@ const (
2222 remoteHostMalformed
2323 )
2424
25-// remoteCatalogState owns the cache-derived discovery state. Its mutex never
26-// covers cache or transport I/O; writeMu only serializes complete cache writes.
2725 type remoteDiscoveryConsumerKind uint8
2826
2927 const (
@@ -31,9 +29,11 @@ const (
3129 remoteDiscoveryPalette
3230 )
3331
32+// remoteCatalogState owns cache-derived discovery state. Its mutex never
33+// covers cache or transport I/O; writeMu serializes complete cache writes.
3434 type remoteCatalogState struct {
3535 mu sync.Mutex
36- cache map[string]ports.RemoteCatalogCacheEntry
36+ cache map[string]catalogue.RemoteCatalogCacheEntry
3737 status map[string]remoteHostStatus
3838 failure map[string]time.Time
3939 refresh uint64
@@ -44,35 +44,35 @@ type remoteCatalogState struct {
4444
4545 func newRemoteCatalogState() remoteCatalogState {
4646 return remoteCatalogState{
47- cache: make(map[string]ports.RemoteCatalogCacheEntry),
47+ cache: make(map[string]catalogue.RemoteCatalogCacheEntry),
4848 status: make(map[string]remoteHostStatus),
4949 failure: make(map[string]time.Time),
5050 consumers: make(map[*attachedClient]remoteDiscoveryConsumerKind),
5151 }
5252 }
5353
54-func cloneRemoteCatalogEntry(entry ports.RemoteCatalogCacheEntry) ports.RemoteCatalogCacheEntry {
55- sessions := make([]ports.RemoteCatalogSession, len(entry.Sessions))
54+func cloneRemoteCatalogEntry(entry catalogue.RemoteCatalogCacheEntry) catalogue.RemoteCatalogCacheEntry {
55+ sessions := make([]catalogue.RemoteCatalogSession, len(entry.Sessions))
5656 for i, session := range entry.Sessions {
5757 sessions[i] = session
58- if tabs := ports.CatalogTabs(session); tabs != nil {
58+ if tabs := catalogue.CatalogTabs(session); tabs != nil {
5959 sessions[i].Tabs = slices.Clone(tabs)
6060 }
6161 }
62- return ports.RemoteCatalogCacheEntry{
62+ return catalogue.RemoteCatalogCacheEntry{
6363 Host: entry.Host,
6464 FetchedAt: entry.FetchedAt,
6565 Sessions: sessions,
6666 }
6767 }
6868
69-func (s *remoteCatalogState) replaceCache(entries []ports.RemoteCatalogCacheEntry) {
69+func (s *remoteCatalogState) replaceCache(entries []catalogue.RemoteCatalogCacheEntry) {
7070 if s == nil {
7171 return
7272 }
7373 s.mu.Lock()
7474 defer s.mu.Unlock()
75- s.cache = make(map[string]ports.RemoteCatalogCacheEntry, len(entries))
75+ s.cache = make(map[string]catalogue.RemoteCatalogCacheEntry, len(entries))
7676 s.status = make(map[string]remoteHostStatus, len(entries))
7777 s.failure = make(map[string]time.Time)
7878 for _, entry := range entries {
@@ -81,13 +81,13 @@ func (s *remoteCatalogState) replaceCache(entries []ports.RemoteCatalogCacheEntr
8181 }
8282 }
8383
84-func (s *remoteCatalogState) cacheSnapshot() []ports.RemoteCatalogCacheEntry {
84+func (s *remoteCatalogState) cacheSnapshot() []catalogue.RemoteCatalogCacheEntry {
8585 if s == nil {
8686 return nil
8787 }
8888 s.mu.Lock()
8989 defer s.mu.Unlock()
90- entries := make([]ports.RemoteCatalogCacheEntry, 0, len(s.cache))
90+ entries := make([]catalogue.RemoteCatalogCacheEntry, 0, len(s.cache))
9191 for _, entry := range s.cache {
9292 entries = append(entries, cloneRemoteCatalogEntry(entry))
9393 }
@@ -107,9 +107,6 @@ func (d *Daemon) loadRemoteCatalogCache() {
107107 d.remoteCatalog.replaceCache(entries)
108108 }
109109
110-// persistRemoteCatalogCache writes the newest complete cache snapshot. Cache
111-// I/O happens after releasing the state mutex and remains serialized with
112-// other writes so an older snapshot cannot win a later atomic replacement.
113110 func (d *Daemon) persistRemoteCatalogCache() {
114111 if d == nil || d.remoteCatalogCache == nil {
115112 return
M internal/usecase/daemon/remote_catalog_state_test.go +17 -17
@@ -8,23 +8,23 @@ import (
88 "testing"
99 "time"
1010
11- "github.com/bnema/vev/internal/ports"
11+ "github.com/bnema/vev/internal/protocol/catalogue"
1212 "github.com/stretchr/testify/require"
1313 )
1414
1515 type catalogCacheStub struct {
16- load func() ([]ports.RemoteCatalogCacheEntry, error)
17- store func([]ports.RemoteCatalogCacheEntry) error
16+ load func() ([]catalogue.RemoteCatalogCacheEntry, error)
17+ store func([]catalogue.RemoteCatalogCacheEntry) error
1818 }
1919
20-func (s catalogCacheStub) Load() ([]ports.RemoteCatalogCacheEntry, error) {
20+func (s catalogCacheStub) Load() ([]catalogue.RemoteCatalogCacheEntry, error) {
2121 if s.load == nil {
2222 return nil, nil
2323 }
2424 return s.load()
2525 }
2626
27-func (s catalogCacheStub) Store(entries []ports.RemoteCatalogCacheEntry) error {
27+func (s catalogCacheStub) Store(entries []catalogue.RemoteCatalogCacheEntry) error {
2828 if s.store == nil {
2929 return nil
3030 }
@@ -35,31 +35,31 @@ func TestRemoteCatalogCacheStartup(t *testing.T) {
3535 now := time.Unix(0, 42)
3636 tests := []struct {
3737 name string
38- load func() ([]ports.RemoteCatalogCacheEntry, error)
39- wantCache map[string]ports.RemoteCatalogCacheEntry
38+ load func() ([]catalogue.RemoteCatalogCacheEntry, error)
39+ wantCache map[string]catalogue.RemoteCatalogCacheEntry
4040 wantStatus map[string]remoteHostStatus
4141 }{
4242 {
4343 name: "loads cached entries",
44- load: func() ([]ports.RemoteCatalogCacheEntry, error) {
45- return []ports.RemoteCatalogCacheEntry{{Host: "arch", FetchedAt: now, Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "up"}}}}, nil
44+ load: func() ([]catalogue.RemoteCatalogCacheEntry, error) {
45+ return []catalogue.RemoteCatalogCacheEntry{{Host: "arch", FetchedAt: now, Sessions: []catalogue.RemoteCatalogSession{{Name: "work", State: "up"}}}}, nil
4646 },
47- wantCache: map[string]ports.RemoteCatalogCacheEntry{"arch": {Host: "arch", FetchedAt: now, Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "up"}}}},
47+ wantCache: map[string]catalogue.RemoteCatalogCacheEntry{"arch": {Host: "arch", FetchedAt: now, Sessions: []catalogue.RemoteCatalogSession{{Name: "work", State: "up"}}}},
4848 wantStatus: map[string]remoteHostStatus{"arch": remoteHostCached},
4949 },
5050 {
5151 name: "invalid cache starts empty",
52- load: func() ([]ports.RemoteCatalogCacheEntry, error) {
52+ load: func() ([]catalogue.RemoteCatalogCacheEntry, error) {
5353 return nil, errors.New("invalid cache")
5454 },
55- wantCache: map[string]ports.RemoteCatalogCacheEntry{},
55+ wantCache: map[string]catalogue.RemoteCatalogCacheEntry{},
5656 wantStatus: map[string]remoteHostStatus{},
5757 },
5858 }
5959
6060 for _, tt := range tests {
6161 t.Run(tt.name, func(t *testing.T) {
62- d := New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)), WithRemoteDiscovery(nil, nil, catalogCacheStub{load: tt.load}, nil, ports.RemoteTransportUDP))
62+ d := New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)), WithRemoteDiscovery(nil, nil, catalogCacheStub{load: tt.load}))
6363
6464 d.remoteCatalog.mu.Lock()
6565 defer d.remoteCatalog.mu.Unlock()
@@ -76,7 +76,7 @@ func TestRemoteCatalogCacheStoreSerializesLockFreeIO(t *testing.T) {
7676 var active, maxActive int
7777 var activeMu sync.Mutex
7878
79- cache := catalogCacheStub{store: func(entries []ports.RemoteCatalogCacheEntry) error {
79+ cache := catalogCacheStub{store: func(entries []catalogue.RemoteCatalogCacheEntry) error {
8080 if !d.mu.TryLock() {
8181 return errors.New("daemon lock held during cache I/O")
8282 }
@@ -99,16 +99,16 @@ func TestRemoteCatalogCacheStoreSerializesLockFreeIO(t *testing.T) {
9999 activeMu.Unlock()
100100 return nil
101101 }}
102- d = New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)), WithRemoteDiscovery(nil, nil, cache, nil, ports.RemoteTransportUDP))
102+ d = New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)), WithRemoteDiscovery(nil, nil, cache))
103103 d.remoteCatalog.mu.Lock()
104- d.remoteCatalog.cache["arch"] = ports.RemoteCatalogCacheEntry{Host: "arch", FetchedAt: time.Unix(0, 1), Sessions: []ports.RemoteCatalogSession{{Name: "one", State: "up"}}}
104+ d.remoteCatalog.cache["arch"] = catalogue.RemoteCatalogCacheEntry{Host: "arch", FetchedAt: time.Unix(0, 1), Sessions: []catalogue.RemoteCatalogSession{{Name: "one", State: "up"}}}
105105 d.remoteCatalog.mu.Unlock()
106106
107107 done := make(chan struct{}, 2)
108108 go func() { d.persistRemoteCatalogCache(); done <- struct{}{} }()
109109 <-entered
110110 d.remoteCatalog.mu.Lock()
111- d.remoteCatalog.cache["arch"] = ports.RemoteCatalogCacheEntry{Host: "arch", FetchedAt: time.Unix(0, 2), Sessions: []ports.RemoteCatalogSession{{Name: "two", State: "up"}}}
111+ d.remoteCatalog.cache["arch"] = catalogue.RemoteCatalogCacheEntry{Host: "arch", FetchedAt: time.Unix(0, 2), Sessions: []catalogue.RemoteCatalogSession{{Name: "two", State: "up"}}}
112112 d.remoteCatalog.mu.Unlock()
113113 go func() { d.persistRemoteCatalogCache(); done <- struct{}{} }()
114114
M internal/usecase/daemon/remote_picker.go +19 -19
@@ -10,13 +10,13 @@ import (
1010 "time"
1111
1212 "github.com/bnema/vev/internal/domain"
13- "github.com/bnema/vev/internal/ports"
13+ "github.com/bnema/vev/internal/protocol/catalogue"
1414 "github.com/bnema/vev/internal/usecase/palette"
1515 "github.com/bnema/vev/internal/usecase/picker"
1616 )
1717
1818 type remoteCatalogPresentationEntry struct {
19- entry ports.RemoteCatalogCacheEntry
19+ entry catalogue.RemoteCatalogCacheEntry
2020 status remoteHostStatus
2121 }
2222
@@ -72,7 +72,7 @@ func (d *Daemon) remoteCatalogSnapshot() []remoteCatalogPresentationEntry {
7272 continue
7373 }
7474 entries = append(entries, remoteCatalogPresentationEntry{
75- entry: ports.RemoteCatalogCacheEntry{Host: host},
75+ entry: catalogue.RemoteCatalogCacheEntry{Host: host},
7676 status: status,
7777 })
7878 }
@@ -138,11 +138,11 @@ func remotePickerStatusDetail(status remoteHostStatus, fetchedAt time.Time) stri
138138 }
139139 }
140140
141-func remoteSessionStateStopped(state ports.RemoteCatalogSessionState) bool {
142- return state == ports.RemoteCatalogSessionDown
141+func remoteSessionStateStopped(state catalogue.RemoteCatalogSessionState) bool {
142+ return state == catalogue.RemoteCatalogSessionDown
143143 }
144144
145-func remoteCatalogSessionTarget(key domain.RemoteSessionKey, session ports.RemoteCatalogSession) (domain.RemoteSessionKey, domain.RemoteSessionTarget) {
145+func remoteCatalogSessionTarget(key domain.RemoteSessionKey, session catalogue.RemoteCatalogSession) (domain.RemoteSessionKey, domain.RemoteSessionTarget) {
146146 key.LifecycleID = session.LifecycleID
147147 key.DisplayOrigin = domain.RemoteDisplayOrigin(key.Host)
148148 target := domain.RemoteSessionTarget{
@@ -152,7 +152,7 @@ func remoteCatalogSessionTarget(key domain.RemoteSessionKey, session ports.Remot
152152 SessionName: session.Name,
153153 Stopped: remoteSessionStateStopped(session.State),
154154 }
155- tabs := ports.CatalogTabs(session)
155+ tabs := catalogue.CatalogTabs(session)
156156 if len(tabs) == 0 {
157157 return key, target
158158 }
@@ -177,12 +177,12 @@ func remoteCatalogSessionTarget(key domain.RemoteSessionKey, session ports.Remot
177177 return key, target
178178 }
179179
180-func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSession, status remoteHostStatus, fetchedAt time.Time) picker.SessionView {
180+func remotePickerView(key domain.RemoteSessionKey, session catalogue.RemoteCatalogSession, status remoteHostStatus, fetchedAt time.Time) picker.SessionView {
181181 key, target := remoteCatalogSessionTarget(key, session)
182182 availability := remotePickerAvailability(status)
183183 stopped := remoteSessionStateStopped(session.State)
184- broken := session.State == ports.RemoteCatalogSessionBroken
185- tabs := ports.CatalogTabs(session)
184+ broken := session.State == catalogue.RemoteCatalogSessionBroken
185+ tabs := catalogue.CatalogTabs(session)
186186 viewTabs := make([]picker.TabEntry, 0, len(tabs))
187187 active := 0
188188 for i, tab := range tabs {
@@ -236,7 +236,7 @@ func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSe
236236 detail = "identity changed"
237237 case activation == picker.RemoteRestart:
238238 detail = "stopped — Enter to restart"
239- case session.State == ports.RemoteCatalogSessionUp:
239+ case session.State == catalogue.RemoteCatalogSessionUp:
240240 detail = "up"
241241 case stopped:
242242 detail = "stopped"
@@ -573,7 +573,7 @@ func containsRemoteDiscoveryHost(hosts []string, target string) bool {
573573 // applyRemoteRefreshResult publishes one host completion only if both its
574574 // refresh generation and current host-registry membership remain authoritative.
575575 // Its return reports whether durable cache state changed.
576-func (d *Daemon) applyRemoteRefreshResult(generation uint64, host string, catalog ports.RemoteCatalog, listErr error) bool {
576+func (d *Daemon) applyRemoteRefreshResult(generation uint64, host string, catalog catalogue.RemoteCatalog, listErr error) bool {
577577 if d == nil {
578578 return false
579579 }
@@ -592,7 +592,7 @@ func (d *Daemon) applyRemoteRefreshResult(generation uint64, host string, catalo
592592 stillKnown := containsRemoteDiscoveryHost(hosts, host)
593593 now := d.clock.Now()
594594 if listErr == nil {
595- if err := ports.ValidateRemoteCatalog(catalog); err != nil {
595+ if err := catalogue.ValidateRemoteCatalog(catalog); err != nil {
596596 listErr = err
597597 }
598598 }
@@ -616,13 +616,13 @@ func (d *Daemon) applyRemoteRefreshResult(generation uint64, host string, catalo
616616 }
617617 if listErr != nil {
618618 status := remoteHostMalformed
619- var mismatch *ports.RemoteCatalogVersionMismatchError
619+ var mismatch *catalogue.RemoteCatalogVersionMismatchError
620620 if errors.As(listErr, &mismatch) {
621621 status = remoteHostVersionMismatch
622- } else if !errors.Is(listErr, ports.ErrInvalidRemoteCatalog) &&
623- !errors.Is(listErr, ports.ErrRemoteCatalogTooLarge) &&
624- !errors.Is(listErr, ports.ErrRemoteCatalogUnknownState) &&
625- !errors.Is(listErr, ports.ErrRemoteCatalogInvalidReason) {
622+ } else if !errors.Is(listErr, catalogue.ErrInvalidRemoteCatalog) &&
623+ !errors.Is(listErr, catalogue.ErrRemoteCatalogTooLarge) &&
624+ !errors.Is(listErr, catalogue.ErrRemoteCatalogUnknownState) &&
625+ !errors.Is(listErr, catalogue.ErrRemoteCatalogInvalidReason) {
626626 status = remoteHostUnreachable
627627 }
628628 d.remoteCatalog.status[host] = status
@@ -631,7 +631,7 @@ func (d *Daemon) applyRemoteRefreshResult(generation uint64, host string, catalo
631631 d.refreshRemoteDiscoveryConsumers()
632632 return false
633633 }
634- d.remoteCatalog.cache[host] = cloneRemoteCatalogEntry(ports.RemoteCatalogCacheEntry{
634+ d.remoteCatalog.cache[host] = cloneRemoteCatalogEntry(catalogue.RemoteCatalogCacheEntry{
635635 Host: host,
636636 FetchedAt: now,
637637 Sessions: catalog.Sessions,
M internal/usecase/daemon/remote_picker_test.go +100 -99
@@ -15,9 +15,10 @@ import (
1515 "github.com/stretchr/testify/require"
1616
1717 "github.com/bnema/vev/internal/domain"
18- "github.com/bnema/vev/internal/ports"
18+ appports "github.com/bnema/vev/internal/ports"
1919 portsmocks "github.com/bnema/vev/internal/ports/mocks"
2020 "github.com/bnema/vev/internal/protocol"
21+ "github.com/bnema/vev/internal/protocol/catalogue"
2122 "github.com/bnema/vev/internal/protocol/wire"
2223 "github.com/bnema/vev/internal/usecase/picker"
2324 )
@@ -56,16 +57,16 @@ func receiveRemotePickerClose(t *testing.T, ch <-chan struct{}, what string) {
5657 require.NoError(t, waitRemotePickerClose(ch, what))
5758 }
5859
59-func newRemotePickerDaemon(store ports.RemoteHostStore) *Daemon {
60- return New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithRemoteDiscovery(store, nil, nil, nil, ports.RemoteTransportUDP))
60+func newRemotePickerDaemon(store appports.RemoteHostStore) *Daemon {
61+ return New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithRemoteDiscovery(store, nil, nil))
6162 }
6263
6364 func TestRemotePickerStoppedRowsUseCanonicalStateAndSafeSelection(t *testing.T) {
6465 lifecycle := remoteLifecycleForTest()
6566 key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle}
66- session := ports.RemoteCatalogSession{
67- LifecycleID: lifecycle, Name: "work", State: ports.RemoteCatalogSessionDown,
68- Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
67+ session := catalogue.RemoteCatalogSession{
68+ LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionDown,
69+ Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
6970 }
7071 view := remotePickerView(key, session, remoteHostFresh, time.Unix(100, 0))
7172 require.True(t, view.Stopped)
@@ -77,9 +78,9 @@ func TestRemotePickerStoppedRowsUseCanonicalStateAndSafeSelection(t *testing.T)
7778 }
7879
7980 func TestRemotePickerClampsStoppedOrdinalTabCount(t *testing.T) {
80- tabs := make([]ports.RemoteCatalogTab, math.MaxUint16+1)
81- tabs[0] = ports.RemoteCatalogTab{Name: "main"}
82- view := remotePickerView(domain.RemoteSessionKey{Host: "arch", Name: "work"}, ports.RemoteCatalogSession{
81+ tabs := make([]catalogue.RemoteCatalogTab, math.MaxUint16+1)
82+ tabs[0] = catalogue.RemoteCatalogTab{Name: "main"}
83+ view := remotePickerView(domain.RemoteSessionKey{Host: "arch", Name: "work"}, catalogue.RemoteCatalogSession{
8384 LifecycleID: remoteLifecycleForTest(), Name: "work", State: "down", Tabs: tabs,
8485 }, remoteHostFresh, time.Unix(100, 0))
8586
@@ -89,9 +90,9 @@ func TestRemotePickerClampsStoppedOrdinalTabCount(t *testing.T) {
8990
9091 func TestRemotePickerUsesCompactUpDetailRegardlessOfAttachment(t *testing.T) {
9192 for _, attached := range []bool{false, true} {
92- view := remotePickerView(domain.RemoteSessionKey{Host: "remote", Name: "work"}, ports.RemoteCatalogSession{
93+ view := remotePickerView(domain.RemoteSessionKey{Host: "remote", Name: "work"}, catalogue.RemoteCatalogSession{
9394 LifecycleID: remoteLifecycleForTest(), Name: "work", State: "up", Attached: attached,
94- Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0}},
95+ Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Index: 0}},
9596 }, remoteHostFresh, time.Now())
9697 require.Equal(t, "up", view.RemoteDetail)
9798 }
@@ -101,13 +102,13 @@ func TestRemotePickerScopesSameLifecycleBytesToEachEndpoint(t *testing.T) {
101102 store := &remoteRefreshHostStore{hosts: []string{"remote", "vev@remote"}}
102103 d := newRemotePickerDaemon(store)
103104 lifecycle := remoteLifecycleForTest()
104- session := ports.RemoteCatalogSession{
105+ session := catalogue.RemoteCatalogSession{
105106 LifecycleID: lifecycle, Name: "work", State: "up",
106- Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
107+ Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
107108 }
108- d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{
109- {Host: "remote", FetchedAt: time.Unix(100, 0), Sessions: []ports.RemoteCatalogSession{session}},
110- {Host: "vev@remote", FetchedAt: time.Unix(100, 0), Sessions: []ports.RemoteCatalogSession{session}},
109+ d.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{
110+ {Host: "remote", FetchedAt: time.Unix(100, 0), Sessions: []catalogue.RemoteCatalogSession{session}},
111+ {Host: "vev@remote", FetchedAt: time.Unix(100, 0), Sessions: []catalogue.RemoteCatalogSession{session}},
111112 })
112113 d.remoteCatalog.mu.Lock()
113114 d.remoteCatalog.status["remote"] = remoteHostFresh
@@ -131,9 +132,9 @@ func TestRemotePickerCatalogExpiresAtAttachTTL(t *testing.T) {
131132 clock := &remotePickerClock{now: now}
132133 d := newTestDaemon(t, nil, clock)
133134 lifecycle := remoteLifecycleForTest()
134- d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
135- Host: "arch", FetchedAt: now.Add(-remoteCatalogAttachTTL), Sessions: []ports.RemoteCatalogSession{{
136- LifecycleID: lifecycle, Name: "work", State: "up", Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
135+ d.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{{
136+ Host: "arch", FetchedAt: now.Add(-remoteCatalogAttachTTL), Sessions: []catalogue.RemoteCatalogSession{{
137+ LifecycleID: lifecycle, Name: "work", State: "up", Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
137138 }},
138139 }})
139140 d.remoteCatalog.mu.Lock()
@@ -152,9 +153,9 @@ func TestRemotePickerTargetReadyExpiresAfterPickerBuildWithoutSnapshot(t *testin
152153 clock := &remotePickerClock{now: now}
153154 d := newTestDaemon(t, nil, clock)
154155 lifecycle := remoteLifecycleForTest()
155- d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
156- Host: "arch", FetchedAt: now, Sessions: []ports.RemoteCatalogSession{{
157- LifecycleID: lifecycle, Name: "work", State: "up", Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
156+ d.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{{
157+ Host: "arch", FetchedAt: now, Sessions: []catalogue.RemoteCatalogSession{{
158+ LifecycleID: lifecycle, Name: "work", State: "up", Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
158159 }},
159160 }})
160161 d.remoteCatalog.mu.Lock()
@@ -177,14 +178,14 @@ func TestRemotePickerTargetReadyExpiresAfterPickerBuildWithoutSnapshot(t *testin
177178 func TestRemotePickerSelectsCatalogActiveTab(t *testing.T) {
178179 tests := []struct {
179180 name string
180- tabs []ports.RemoteCatalogTab
181+ tabs []catalogue.RemoteCatalogTab
181182 activeTabID string
182183 wantActive int
183184 wantTabID domain.TabStableID
184185 }{
185186 {
186187 name: "active first",
187- tabs: []ports.RemoteCatalogTab{
188+ tabs: []catalogue.RemoteCatalogTab{
188189 {ID: "tab-active", Index: 0, Name: "active"},
189190 {ID: "tab-last", Index: 1, Name: "last"},
190191 },
@@ -194,7 +195,7 @@ func TestRemotePickerSelectsCatalogActiveTab(t *testing.T) {
194195 },
195196 {
196197 name: "active last",
197- tabs: []ports.RemoteCatalogTab{
198+ tabs: []catalogue.RemoteCatalogTab{
198199 {ID: "tab-first", Index: 0, Name: "first"},
199200 {ID: "tab-active", Index: 1, Name: "active"},
200201 },
@@ -204,7 +205,7 @@ func TestRemotePickerSelectsCatalogActiveTab(t *testing.T) {
204205 },
205206 {
206207 name: "empty active tab ID falls back to first stable tab",
207- tabs: []ports.RemoteCatalogTab{
208+ tabs: []catalogue.RemoteCatalogTab{
208209 {ID: "tab-first", Index: 0, Name: "first"},
209210 {ID: "", Index: 1, Name: "malformed"},
210211 },
@@ -217,8 +218,8 @@ func TestRemotePickerSelectsCatalogActiveTab(t *testing.T) {
217218 t.Run(test.name, func(t *testing.T) {
218219 d := newRemotePickerDaemon(nil)
219220 lifecycle := remoteLifecycleForTest()
220- d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
221- Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{
221+ d.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{{
222+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []catalogue.RemoteCatalogSession{{
222223 LifecycleID: lifecycle,
223224 Name: "vive",
224225 State: "up",
@@ -259,8 +260,8 @@ func TestRemotePickerAvailabilityUsesCachedRowsForFailures(t *testing.T) {
259260 for _, test := range tests {
260261 t.Run(test.name, func(t *testing.T) {
261262 d := newRemotePickerDaemon(nil)
262- d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
263- Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "up"}},
263+ d.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{{
264+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []catalogue.RemoteCatalogSession{{Name: "work", State: "up"}},
264265 }})
265266 d.remoteCatalog.mu.Lock()
266267 d.remoteCatalog.status["arch"] = test.status
@@ -278,8 +279,8 @@ func TestRemotePickerStaleDetailUsesLastSuccessfulFetch(t *testing.T) {
278279 d := newRemotePickerDaemon(nil)
279280 fetchedAt := time.Date(2025, time.January, 2, 3, 4, 5, 0, time.UTC)
280281 failedAt := fetchedAt.Add(6 * time.Hour)
281- d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
282- Host: "arch", FetchedAt: fetchedAt, Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "up"}},
282+ d.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{{
283+ Host: "arch", FetchedAt: fetchedAt, Sessions: []catalogue.RemoteCatalogSession{{Name: "work", State: "up"}},
283284 }})
284285 d.remoteCatalog.mu.Lock()
285286 d.remoteCatalog.status["arch"] = remoteHostUnreachable
@@ -355,7 +356,7 @@ type remoteRefreshRequest struct {
355356 }
356357
357358 type remoteRefreshResult struct {
358- catalog ports.RemoteCatalog
359+ catalog catalogue.RemoteCatalog
359360 err error
360361 }
361362
@@ -367,7 +368,7 @@ type channelRemoteCatalog struct {
367368 violations []string
368369 }
369370
370-func (c *channelRemoteCatalog) List(ctx context.Context, host string) (ports.RemoteCatalog, error) {
371+func (c *channelRemoteCatalog) List(ctx context.Context, host string) (catalogue.RemoteCatalog, error) {
371372 c.probeMu.Lock()
372373 if c.probes && !c.d.mu.TryLock() {
373374 c.violations = append(c.violations, "daemon lock held during remote catalog I/O")
@@ -386,21 +387,21 @@ func (c *channelRemoteCatalog) List(ctx context.Context, host string) (ports.Rem
386387 case result := <-request.result:
387388 return result.catalog, result.err
388389 case <-ctx.Done():
389- return ports.RemoteCatalog{}, ctx.Err()
390+ return catalogue.RemoteCatalog{}, ctx.Err()
390391 }
391392 }
392393
393394 type recordingRemoteCache struct {
394395 d *Daemon
395- stores chan []ports.RemoteCatalogCacheEntry
396+ stores chan []catalogue.RemoteCatalogCacheEntry
396397 probeMu sync.Mutex
397398 probes bool
398399 violations []string
399400 }
400401
401-func (*recordingRemoteCache) Load() ([]ports.RemoteCatalogCacheEntry, error) { return nil, nil }
402+func (*recordingRemoteCache) Load() ([]catalogue.RemoteCatalogCacheEntry, error) { return nil, nil }
402403
403-func (c *recordingRemoteCache) Store(entries []ports.RemoteCatalogCacheEntry) error {
404+func (c *recordingRemoteCache) Store(entries []catalogue.RemoteCatalogCacheEntry) error {
404405 c.probeMu.Lock()
405406 if c.probes && !c.d.mu.TryLock() {
406407 c.violations = append(c.violations, "daemon lock held during remote cache write")
@@ -413,7 +414,7 @@ func (c *recordingRemoteCache) Store(entries []ports.RemoteCatalogCacheEntry) er
413414 c.d.remoteCatalog.mu.Unlock()
414415 }
415416 c.probeMu.Unlock()
416- copyEntries := make([]ports.RemoteCatalogCacheEntry, len(entries))
417+ copyEntries := make([]catalogue.RemoteCatalogCacheEntry, len(entries))
417418 for i, entry := range entries {
418419 copyEntries[i] = cloneRemoteCatalogEntry(entry)
419420 }
@@ -423,8 +424,8 @@ func (c *recordingRemoteCache) Store(entries []ports.RemoteCatalogCacheEntry) er
423424
424425 type fixedRemoteRefreshClock struct{ now time.Time }
425426
426-func (c fixedRemoteRefreshClock) Now() time.Time { return c.now }
427-func (fixedRemoteRefreshClock) NewTimer(time.Duration) ports.Timer { return stubTimer{} }
427+func (c fixedRemoteRefreshClock) Now() time.Time { return c.now }
428+func (fixedRemoteRefreshClock) NewTimer(time.Duration) appports.Timer { return stubTimer{} }
428429
429430 type remotePickerClock struct {
430431 mu sync.Mutex
@@ -436,7 +437,7 @@ func (c *remotePickerClock) Now() time.Time {
436437 defer c.mu.Unlock()
437438 return c.now
438439 }
439-func (*remotePickerClock) NewTimer(time.Duration) ports.Timer { return stubTimer{} }
440+func (*remotePickerClock) NewTimer(time.Duration) appports.Timer { return stubTimer{} }
440441 func (c *remotePickerClock) Advance(d time.Duration) {
441442 c.mu.Lock()
442443 c.now = c.now.Add(d)
@@ -457,19 +458,19 @@ func requireNoRemoteLockViolations(t *testing.T, catalog *channelRemoteCatalog,
457458 }
458459 }
459460
460-func remoteCatalogForTest(sessions ...ports.RemoteCatalogSession) ports.RemoteCatalog {
461- return ports.RemoteCatalog{
461+func remoteCatalogForTest(sessions ...catalogue.RemoteCatalogSession) catalogue.RemoteCatalog {
462+ return catalogue.RemoteCatalog{
462463 ProtocolVersion: protocol.Version,
463- SchemaVersion: ports.RemoteCatalogSchemaVersion,
464- Sessions: append([]ports.RemoteCatalogSession{}, sessions...),
464+ SchemaVersion: catalogue.RemoteCatalogSchemaVersion,
465+ Sessions: append([]catalogue.RemoteCatalogSession{}, sessions...),
465466 }
466467 }
467468
468469 func newRemoteRefreshDaemon(t *testing.T, hosts *remoteRefreshHostStore, now time.Time) (*Daemon, *channelRemoteCatalog, *recordingRemoteCache) {
469470 t.Helper()
470471 catalog := &channelRemoteCatalog{requests: make(chan remoteRefreshRequest, 8)}
471- cache := &recordingRemoteCache{stores: make(chan []ports.RemoteCatalogCacheEntry, 8)}
472- d := New(nil, fixedRemoteRefreshClock{now: now}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithRemoteDiscovery(hosts, catalog, cache, nil, ports.RemoteTransportUDP))
472+ cache := &recordingRemoteCache{stores: make(chan []catalogue.RemoteCatalogCacheEntry, 8)}
473+ d := New(nil, fixedRemoteRefreshClock{now: now}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithRemoteDiscovery(hosts, catalog, cache))
473474 catalog.d, cache.d = d, d
474475 return d, catalog, cache
475476 }
@@ -487,10 +488,10 @@ func TestRemoteRefreshStartsPerHostAndCancelsSupersededGeneration(t *testing.T)
487488 ac.overlays.pickerMu.Unlock()
488489 require.True(t, d.registerRemoteDiscoveryConsumer(instance.discoveryInstance()))
489490 lifecycle := remoteLifecycleForTest()
490- d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
491- Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{
491+ d.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{{
492+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []catalogue.RemoteCatalogSession{{
492493 LifecycleID: lifecycle, Name: "cached", State: "up",
493- Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
494+ Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
494495 }},
495496 }})
496497
@@ -504,9 +505,9 @@ func TestRemoteRefreshStartsPerHostAndCancelsSupersededGeneration(t *testing.T)
504505 require.Contains(t, first, "mule")
505506 requireNoRemoteLockViolations(t, catalog, nil)
506507 d.remoteCatalog.mu.Lock()
507- require.Equal(t, []ports.RemoteCatalogSession{{
508+ require.Equal(t, []catalogue.RemoteCatalogSession{{
508509 LifecycleID: lifecycle, Name: "cached", State: "up",
509- Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
510+ Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
510511 }}, d.remoteCatalog.cache["arch"].Sessions, "cached rows publish before any remote completion")
511512 require.Equal(t, remoteHostRefreshing, d.remoteCatalog.status["arch"])
512513 d.remoteCatalog.mu.Unlock()
@@ -581,26 +582,26 @@ func TestRemoteRefreshResultPreservesFailuresAndEvictsSuccessfulOmissions(t *tes
581582 now := time.Unix(200, 0)
582583 hosts := &remoteRefreshHostStore{hosts: []string{"arch", "mule"}}
583584 d, _, cache := newRemoteRefreshDaemon(t, hosts, now)
584- d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{
585- {Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "old", State: "up"}}},
586- {Host: "mule", FetchedAt: time.Unix(20, 0), Sessions: []ports.RemoteCatalogSession{{Name: "stale", State: "up"}}},
585+ d.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{
586+ {Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []catalogue.RemoteCatalogSession{{Name: "old", State: "up"}}},
587+ {Host: "mule", FetchedAt: time.Unix(20, 0), Sessions: []catalogue.RemoteCatalogSession{{Name: "stale", State: "up"}}},
587588 })
588589 d.remoteCatalog.mu.Lock()
589590 d.remoteCatalog.refresh = 7
590591 d.remoteCatalog.mu.Unlock()
591592
592- require.False(t, d.applyRemoteRefreshResult(7, "mule", ports.RemoteCatalog{}, errors.New("offline")))
593+ require.False(t, d.applyRemoteRefreshResult(7, "mule", catalogue.RemoteCatalog{}, errors.New("offline")))
593594 d.remoteCatalog.mu.Lock()
594- require.Equal(t, []ports.RemoteCatalogSession{{Name: "stale", State: "up"}}, d.remoteCatalog.cache["mule"].Sessions)
595+ require.Equal(t, []catalogue.RemoteCatalogSession{{Name: "stale", State: "up"}}, d.remoteCatalog.cache["mule"].Sessions)
595596 require.Equal(t, time.Unix(20, 0), d.remoteCatalog.cache["mule"].FetchedAt)
596597 require.Equal(t, remoteHostUnreachable, d.remoteCatalog.status["mule"])
597598 d.remoteCatalog.mu.Unlock()
598599
599600 require.True(t, d.applyRemoteRefreshResult(7, "arch", remoteCatalogForTest(), nil))
600601 stored := receiveRemotePicker(t, cache.stores, "remote cache store")
601- require.Equal(t, []ports.RemoteCatalogCacheEntry{
602- {Host: "arch", FetchedAt: now, Sessions: []ports.RemoteCatalogSession{}},
603- {Host: "mule", FetchedAt: time.Unix(20, 0), Sessions: []ports.RemoteCatalogSession{{Name: "stale", State: "up"}}},
602+ require.Equal(t, []catalogue.RemoteCatalogCacheEntry{
603+ {Host: "arch", FetchedAt: now, Sessions: []catalogue.RemoteCatalogSession{}},
604+ {Host: "mule", FetchedAt: time.Unix(20, 0), Sessions: []catalogue.RemoteCatalogSession{{Name: "stale", State: "up"}}},
604605 }, stored, "every successful write persists the newest full cache atomically")
605606 requireNoRemoteLockViolations(t, nil, cache)
606607 d.remoteCatalog.mu.Lock()
@@ -612,21 +613,21 @@ func TestRemoteRefreshResultPreservesFailuresAndEvictsSuccessfulOmissions(t *tes
612613 func TestRemoteRefreshRejectsRemovedHostAndLateGeneration(t *testing.T) {
613614 hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
614615 d, _, cache := newRemoteRefreshDaemon(t, hosts, time.Unix(300, 0))
615- d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
616- Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "old", State: "up"}},
616+ d.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{{
617+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []catalogue.RemoteCatalogSession{{Name: "old", State: "up"}},
617618 }})
618619 d.remoteCatalog.mu.Lock()
619620 d.remoteCatalog.refresh = 9
620621 d.remoteCatalog.mu.Unlock()
621622
622- require.False(t, d.applyRemoteRefreshResult(8, "arch", remoteCatalogForTest(ports.RemoteCatalogSession{
623- LifecycleID: remoteLifecycleForTest(), Name: "late", State: ports.RemoteCatalogSessionUp,
624- Tabs: []ports.RemoteCatalogTab{{ID: "tab-1"}},
623+ require.False(t, d.applyRemoteRefreshResult(8, "arch", remoteCatalogForTest(catalogue.RemoteCatalogSession{
624+ LifecycleID: remoteLifecycleForTest(), Name: "late", State: catalogue.RemoteCatalogSessionUp,
625+ Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1"}},
625626 }), nil))
626627 hosts.set()
627- require.True(t, d.applyRemoteRefreshResult(9, "arch", remoteCatalogForTest(ports.RemoteCatalogSession{
628- LifecycleID: remoteLifecycleForTest(), Name: "removed", State: ports.RemoteCatalogSessionUp,
629- Tabs: []ports.RemoteCatalogTab{{ID: "tab-1"}},
628+ require.True(t, d.applyRemoteRefreshResult(9, "arch", remoteCatalogForTest(catalogue.RemoteCatalogSession{
629+ LifecycleID: remoteLifecycleForTest(), Name: "removed", State: catalogue.RemoteCatalogSessionUp,
630+ Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1"}},
630631 }), nil))
631632 require.Empty(t, receiveRemotePicker(t, cache.stores, "remote cache store"))
632633 requireNoRemoteLockViolations(t, nil, cache)
@@ -640,16 +641,16 @@ func TestRemoteRefreshValidationSentinelsRemainMalformed(t *testing.T) {
640641 lifecycle := remoteLifecycleForTest()
641642 for _, test := range []struct {
642643 name string
643- session ports.RemoteCatalogSession
644+ session catalogue.RemoteCatalogSession
644645 }{
645- {name: "unknown state", session: ports.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: "unknown"}},
646- {name: "invalid reason", session: ports.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: "up", Reason: "bogus"}},
646+ {name: "unknown state", session: catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: "unknown"}},
647+ {name: "invalid reason", session: catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: "up", Reason: "bogus"}},
647648 } {
648649 t.Run(test.name, func(t *testing.T) {
649650 hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
650651 d, _, _ := newRemoteRefreshDaemon(t, hosts, time.Unix(400, 0))
651652 d.remoteCatalog.refresh = 1
652- catalog := ports.RemoteCatalog{ProtocolVersion: protocol.Version, SchemaVersion: ports.RemoteCatalogSchemaVersion, Sessions: []ports.RemoteCatalogSession{test.session}}
653+ catalog := catalogue.RemoteCatalog{ProtocolVersion: protocol.Version, SchemaVersion: catalogue.RemoteCatalogSchemaVersion, Sessions: []catalogue.RemoteCatalogSession{test.session}}
653654 require.False(t, d.applyRemoteRefreshResult(1, "arch", catalog, nil))
654655 d.remoteCatalog.mu.Lock()
655656 require.Equal(t, remoteHostMalformed, d.remoteCatalog.status["arch"])
@@ -661,13 +662,13 @@ func TestRemoteRefreshValidationSentinelsRemainMalformed(t *testing.T) {
661662 func TestRemoteRefreshVersionMismatchPreservesStaleRows(t *testing.T) {
662663 hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
663664 d, _, _ := newRemoteRefreshDaemon(t, hosts, time.Unix(400, 0))
664- old := ports.RemoteCatalogCacheEntry{Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "up"}}}
665- d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{old})
665+ old := catalogue.RemoteCatalogCacheEntry{Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []catalogue.RemoteCatalogSession{{Name: "work", State: "up"}}}
666+ d.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{old})
666667 d.remoteCatalog.mu.Lock()
667668 d.remoteCatalog.refresh = 3
668669 d.remoteCatalog.mu.Unlock()
669670
670- require.False(t, d.applyRemoteRefreshResult(3, "arch", ports.RemoteCatalog{}, &ports.RemoteCatalogVersionMismatchError{Got: 19, Want: protocol.Version}))
671+ require.False(t, d.applyRemoteRefreshResult(3, "arch", catalogue.RemoteCatalog{}, &catalogue.RemoteCatalogVersionMismatchError{Got: 19, Want: protocol.Version}))
671672 d.remoteCatalog.mu.Lock()
672673 require.Equal(t, old, d.remoteCatalog.cache["arch"])
673674 require.Equal(t, remoteHostVersionMismatch, d.remoteCatalog.status["arch"])
@@ -676,13 +677,13 @@ func TestRemoteRefreshVersionMismatchPreservesStaleRows(t *testing.T) {
676677
677678 func TestRemotePickerHandoffSendsTargetAndLeavesNoShadowSession(t *testing.T) {
678679 lifecycle := remoteLifecycleForTest()
679- remoteSession := ports.RemoteCatalogSession{
680- LifecycleID: lifecycle, Name: "work", State: ports.RemoteCatalogSessionUp,
681- Tabs: []ports.RemoteCatalogTab{{ID: "tab-1"}},
680+ remoteSession := catalogue.RemoteCatalogSession{
681+ LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp,
682+ Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1"}},
682683 }
683684 d := newRemotePickerDaemon(nil)
684- d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
685- Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{remoteSession},
685+ d.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{{
686+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []catalogue.RemoteCatalogSession{remoteSession},
686687 }})
687688 d.remoteCatalog.mu.Lock()
688689 d.remoteCatalog.status["arch"] = remoteHostFresh
@@ -709,11 +710,11 @@ func TestRemotePickerHandoffSendsTargetAndLeavesNoShadowSession(t *testing.T) {
709710
710711 func TestRemotePickerSelectsStoppedRemoteTabAndRestoresIt(t *testing.T) {
711712 lifecycle := remoteLifecycleForTest()
712- remoteSession := ports.RemoteCatalogSession{
713+ remoteSession := catalogue.RemoteCatalogSession{
713714 LifecycleID: lifecycle,
714715 Name: "work",
715716 State: "down",
716- Tabs: []ports.RemoteCatalogTab{
717+ Tabs: []catalogue.RemoteCatalogTab{
717718 {ID: "tab-a", Name: "alpha", Index: 0},
718719 {ID: "tab-b", Name: "beta", Index: 1},
719720 },
@@ -738,7 +739,7 @@ func TestRemotePickerSelectsStoppedRemoteTabAndRestoresIt(t *testing.T) {
738739 require.Equal(t, domain.NewStableTabSelector("tab-b"), selected.RemoteTarget.StoppedTab)
739740
740741 local := newRemotePickerDaemon(nil)
741- local.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{remoteSession}}})
742+ local.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{{Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []catalogue.RemoteCatalogSession{remoteSession}}})
742743 local.remoteCatalog.mu.Lock()
743744 local.remoteCatalog.status["arch"] = remoteHostFresh
744745 local.remoteCatalog.mu.Unlock()
@@ -779,11 +780,11 @@ func TestRemotePickerSelectsStoppedRemoteTabAndRestoresIt(t *testing.T) {
779780
780781 func TestRemotePickerResurrectsStoppedRemoteSessionWithoutTabMetadata(t *testing.T) {
781782 lifecycle := remoteLifecycleForTest()
782- remoteSession := ports.RemoteCatalogSession{
783+ remoteSession := catalogue.RemoteCatalogSession{
783784 LifecycleID: lifecycle,
784785 Name: "work",
785786 State: "down",
786- Tabs: []ports.RemoteCatalogTab{},
787+ Tabs: []catalogue.RemoteCatalogTab{},
787788 }
788789 key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}
789790 remoteView := remotePickerView(key, remoteSession, remoteHostFresh, time.Unix(10, 0))
@@ -807,8 +808,8 @@ func TestRemotePickerResurrectsStoppedRemoteSessionWithoutTabMetadata(t *testing
807808 require.Equal(t, domain.TabSelector{}, selected.RemoteTarget.StoppedTab)
808809
809810 local := newRemotePickerDaemon(nil)
810- local.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
811- Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{remoteSession},
811+ local.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{{
812+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []catalogue.RemoteCatalogSession{remoteSession},
812813 }})
813814 local.remoteCatalog.mu.Lock()
814815 local.remoteCatalog.status["arch"] = remoteHostFresh
@@ -878,13 +879,13 @@ func TestNavigationActionHandoffSendsBoundedAction(t *testing.T) {
878879
879880 func TestRemotePickerHandoffSendFailureKeepsPickerOpen(t *testing.T) {
880881 lifecycle := remoteLifecycleForTest()
881- remoteSession := ports.RemoteCatalogSession{
882- LifecycleID: lifecycle, Name: "work", State: ports.RemoteCatalogSessionUp,
883- Tabs: []ports.RemoteCatalogTab{{ID: "tab-1"}},
882+ remoteSession := catalogue.RemoteCatalogSession{
883+ LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp,
884+ Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1"}},
884885 }
885886 d := newRemotePickerDaemon(nil)
886- d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
887- Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{remoteSession},
887+ d.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{{
888+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []catalogue.RemoteCatalogSession{remoteSession},
888889 }})
889890 d.remoteCatalog.mu.Lock()
890891 d.remoteCatalog.status["arch"] = remoteHostFresh
@@ -921,9 +922,9 @@ func TestRemotePickerHandoffSendFailureKeepsPickerOpen(t *testing.T) {
921922 require.Same(t, sess, ac.currentAttachmentSession())
922923 }
923924
924-func addRemoteRefreshPickerOwner(t *testing.T, d *Daemon, id domain.SessionID, transports ...ports.ServerConnection) (*session, *attachedClient, chan wire.Frame) {
925+func addRemoteRefreshPickerOwner(t *testing.T, d *Daemon, id domain.SessionID, transports ...appports.ServerConnection) (*session, *attachedClient, chan wire.Frame) {
925926 t.Helper()
926- var tr ports.ServerConnection
927+ var tr appports.ServerConnection
927928 var sends chan wire.Frame
928929 if len(transports) != 0 {
929930 tr = transports[0]
@@ -962,12 +963,12 @@ func TestRemoteRefreshUpdatesAllOpenPickersPreservingSelection(t *testing.T) {
962963 d.remoteCatalog.refresh = 4
963964 d.remoteCatalog.mu.Unlock()
964965
965- require.True(t, d.applyRemoteRefreshResult(4, "arch", ports.RemoteCatalog{
966+ require.True(t, d.applyRemoteRefreshResult(4, "arch", catalogue.RemoteCatalog{
966967 ProtocolVersion: protocol.Version,
967- SchemaVersion: ports.RemoteCatalogSchemaVersion,
968- Sessions: []ports.RemoteCatalogSession{{
968+ SchemaVersion: catalogue.RemoteCatalogSchemaVersion,
969+ Sessions: []catalogue.RemoteCatalogSession{{
969970 LifecycleID: remoteLifecycleForTest(), Name: "work", State: "up",
970- Tabs: []ports.RemoteCatalogTab{{ID: "tab-1"}, {ID: "tab-2", Index: 1}},
971+ Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1"}, {ID: "tab-2", Index: 1}},
971972 }},
972973 }, nil))
973974 receiveRemotePicker(t, cache.stores, "remote cache store")
M internal/usecase/daemon/render_pipeline_test.go +1 -1
@@ -28,7 +28,7 @@ import (
2828 "github.com/stretchr/testify/require"
2929 )
3030
31-// scriptedReplayTransport is intentionally a ports.Transport only: replay
31+// scriptedReplayTransport is intentionally a wire.Transport only: replay
3232 // assertions must not depend on any adapter's framing implementation.
3333 type scriptedReplayTransport struct {
3434 t *testing.T
M internal/usecase/daemon/session_test.go +6 -6
@@ -399,11 +399,11 @@ func TestServeReturnsWhenLastSessionExits(t *testing.T) {
399399 tr, sends, _ := newConn(t, mustHello(protocol.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24}))
400400
401401 l := newMockServerListener(t)
402- connCh := make(chan ports.Transport, 1)
402+ connCh := make(chan wire.Transport, 1)
403403 connCh <- tr
404404 closed := make(chan struct{})
405405 var once sync.Once
406- l.EXPECT().Accept().RunAndReturn(func() (ports.Transport, error) {
406+ l.EXPECT().Accept().RunAndReturn(func() (wire.Transport, error) {
407407 select {
408408 case c := <-connCh:
409409 return c, nil
@@ -439,11 +439,11 @@ func TestServeGracefulShutdownOnContextCancel(t *testing.T) {
439439 tr, sends, _ := newConn(t, mustHello(protocol.IntentNew, "long", domain.Size{Cols: 80, Rows: 24}))
440440
441441 l := newMockServerListener(t)
442- connCh := make(chan ports.Transport, 1)
442+ connCh := make(chan wire.Transport, 1)
443443 connCh <- tr
444444 closed := make(chan struct{})
445445 var once sync.Once
446- l.EXPECT().Accept().RunAndReturn(func() (ports.Transport, error) {
446+ l.EXPECT().Accept().RunAndReturn(func() (wire.Transport, error) {
447447 select {
448448 case c := <-connCh:
449449 return c, nil
@@ -601,11 +601,11 @@ func TestServeReturnsDespiteWedgedClientOnShutdown(t *testing.T) {
601601 tr.EXPECT().Close().RunAndReturn(func() error { closeConn(); return nil }).Maybe()
602602
603603 l := newMockServerListener(t)
604- connCh := make(chan ports.Transport, 1)
604+ connCh := make(chan wire.Transport, 1)
605605 connCh <- tr
606606 lnClosed := make(chan struct{})
607607 var lnOnce sync.Once
608- l.EXPECT().Accept().RunAndReturn(func() (ports.Transport, error) {
608+ l.EXPECT().Accept().RunAndReturn(func() (wire.Transport, error) {
609609 select {
610610 case c := <-connCh:
611611 return c, nil
M internal/usecase/daemon/snapshot_scheduler_test.go +3 -3
@@ -613,14 +613,14 @@ func (c *serveShutdownClock) nextFinalTimer(t *testing.T) *snapshotDeadlineTimer
613613 }
614614 }
615615
616-func serveSnapshotListener(t *testing.T, tr ports.Transport) *mockServerListener {
616+func serveSnapshotListener(t *testing.T, tr wire.Transport) *mockServerListener {
617617 t.Helper()
618618 l := newMockServerListener(t)
619- connections := make(chan ports.Transport, 1)
619+ connections := make(chan wire.Transport, 1)
620620 connections <- tr
621621 closed := make(chan struct{})
622622 var once sync.Once
623- l.EXPECT().Accept().RunAndReturn(func() (ports.Transport, error) {
623+ l.EXPECT().Accept().RunAndReturn(func() (wire.Transport, error) {
624624 select {
625625 case conn := <-connections:
626626 return conn, nil
M internal/usecase/daemon/transport_observability_test.go +2 -1
@@ -7,6 +7,7 @@ import (
77 "testing"
88 "time"
99
10+ "github.com/bnema/vev/internal/adapters/observability"
1011 "github.com/bnema/vev/internal/domain"
1112 "github.com/bnema/vev/internal/ports"
1213 "github.com/bnema/vev/internal/protocol/wire"
@@ -233,7 +234,7 @@ func TestTransportObservabilityBlockedACKEndDoesNotDelayNotifyAck(t *testing.T)
233234 endEntered: make(chan struct{}),
234235 releaseEnd: make(chan struct{}),
235236 }
236- reporter := ports.NewSerializedRuntimeObserver(observer, 64)
237+ reporter := observability.NewSerialized(observer, 64)
237238 defer reporter.Close()
238239 d := New(nil, nil, nil, WithRuntimeObserver(reporter))
239240 wakes := make(chan renderWake, 1)
M internal/usecase/daemon/typed_connection_test.go +23 -23
@@ -7,9 +7,9 @@ import (
77 "github.com/stretchr/testify/mock"
88
99 "github.com/bnema/vev/internal/ports"
10- portsmocks "github.com/bnema/vev/internal/ports/mocks"
1110 "github.com/bnema/vev/internal/protocol"
1211 "github.com/bnema/vev/internal/protocol/wire"
12+ wiremocks "github.com/bnema/vev/internal/protocol/wire/mocks"
1313 )
1414
1515 type mockTestingT interface {
@@ -17,10 +17,10 @@ type mockTestingT interface {
1717 Cleanup(func())
1818 }
1919
20-type mockServerConnection struct{ *portsmocks.MockTransport }
20+type mockServerConnection struct{ *wiremocks.MockTransport }
2121
2222 func newMockServerConnection(t mockTestingT) *mockServerConnection {
23- return &mockServerConnection{MockTransport: portsmocks.NewMockTransport(t)}
23+ return &mockServerConnection{MockTransport: wiremocks.NewMockTransport(t)}
2424 }
2525
2626 func (c *mockServerConnection) ReceiveClient() (protocol.ClientMessage, error) {
@@ -52,7 +52,7 @@ func (c *mockServerConnection) LinkEvents() <-chan ports.LinkEvent {
5252 return testLinkEvents(c.MockTransport)
5353 }
5454
55-type rawServerConnection struct{ raw ports.Transport }
55+type rawServerConnection struct{ raw wire.Transport }
5656
5757 func (c *rawServerConnection) ReceiveClient() (protocol.ClientMessage, error) {
5858 return testReceiveClient(c.raw)
@@ -82,10 +82,10 @@ func (c *rawServerConnection) LinkState() ports.LinkState { return testL
8282 func (c *rawServerConnection) LinkEvents() <-chan ports.LinkEvent { return testLinkEvents(c.raw) }
8383 func (c *rawServerConnection) Close() error { return c.raw.Close() }
8484
85-type mockServerListener struct{ *portsmocks.MockListener }
85+type mockServerListener struct{ *wiremocks.MockListener }
8686
8787 func newMockServerListener(t mockTestingT) *mockServerListener {
88- return &mockServerListener{MockListener: portsmocks.NewMockListener(t)}
88+ return &mockServerListener{MockListener: wiremocks.NewMockListener(t)}
8989 }
9090
9191 func (l *mockServerListener) Accept() (ports.ServerConnection, error) {
@@ -96,7 +96,7 @@ func (l *mockServerListener) Accept() (ports.ServerConnection, error) {
9696 return &rawServerConnection{raw: raw}, nil
9797 }
9898
99-func testReceiveClient(raw ports.Transport) (protocol.ClientMessage, error) {
99+func testReceiveClient(raw wire.Transport) (protocol.ClientMessage, error) {
100100 frame, err := raw.Recv()
101101 if err != nil {
102102 return nil, err
@@ -166,7 +166,7 @@ func testDecodeClientFrame(frame wire.Frame) (protocol.ClientMessage, error) {
166166 }
167167 }
168168
169-func testSendServer(raw ports.Transport, message protocol.ServerMessage) error {
169+func testSendServer(raw wire.Transport, message protocol.ServerMessage) error {
170170 frame, err := testServerFrame(message)
171171 if err != nil {
172172 return err
@@ -174,8 +174,8 @@ func testSendServer(raw ports.Transport, message protocol.ServerMessage) error {
174174 return raw.Send(frame)
175175 }
176176
177-func testSendServerAsync(raw ports.Transport, message protocol.ServerMessage) error {
178- transport, ok := raw.(ports.AsyncTransport)
177+func testSendServerAsync(raw wire.Transport, message protocol.ServerMessage) error {
178+ transport, ok := raw.(wire.AsyncTransport)
179179 if !ok {
180180 return errors.New("test server connection: async unsupported")
181181 }
@@ -186,8 +186,8 @@ func testSendServerAsync(raw ports.Transport, message protocol.ServerMessage) er
186186 return transport.SendAsync(frame)
187187 }
188188
189-func testSendServerSynchronous(raw ports.Transport, message protocol.ServerMessage) error {
190- transport, ok := raw.(ports.OwnedSynchronousTransport)
189+func testSendServerSynchronous(raw wire.Transport, message protocol.ServerMessage) error {
190+ transport, ok := raw.(wire.OwnedSynchronousTransport)
191191 if !ok {
192192 return errors.New("test server connection: synchronous unsupported")
193193 }
@@ -198,7 +198,7 @@ func testSendServerSynchronous(raw ports.Transport, message protocol.ServerMessa
198198 return transport.SendSynchronous(frame)
199199 }
200200
201-func testSendOutput(raw ports.Transport, output protocol.Output) error {
201+func testSendOutput(raw wire.Transport, output protocol.Output) error {
202202 frame, err := testServerFrame(output)
203203 if err != nil {
204204 return err
@@ -206,8 +206,8 @@ func testSendOutput(raw ports.Transport, output protocol.Output) error {
206206 return raw.Send(frame)
207207 }
208208
209-func testSendOutputAsync(raw ports.Transport, output protocol.Output) error {
210- transport, ok := raw.(ports.AsyncTransport)
209+func testSendOutputAsync(raw wire.Transport, output protocol.Output) error {
210+ transport, ok := raw.(wire.AsyncTransport)
211211 if !ok {
212212 return errors.New("test server connection: async unsupported")
213213 }
@@ -218,8 +218,8 @@ func testSendOutputAsync(raw ports.Transport, output protocol.Output) error {
218218 return transport.SendAsync(frame)
219219 }
220220
221-func testSendOutputSynchronous(raw ports.Transport, output protocol.Output) error {
222- transport, ok := raw.(ports.OwnedSynchronousTransport)
221+func testSendOutputSynchronous(raw wire.Transport, output protocol.Output) error {
222+ transport, ok := raw.(wire.OwnedSynchronousTransport)
223223 if !ok {
224224 return errors.New("test server connection: synchronous unsupported")
225225 }
@@ -230,10 +230,10 @@ func testSendOutputSynchronous(raw ports.Transport, output protocol.Output) erro
230230 return transport.SendSynchronous(frame)
231231 }
232232
233-func testServerCapabilities(raw ports.Transport) protocol.ConnectionCapabilities {
234- _, datagram := raw.(ports.DatagramTransport)
235- _, async := raw.(ports.AsyncTransport)
236- _, synchronous := raw.(ports.OwnedSynchronousTransport)
233+func testServerCapabilities(raw wire.Transport) protocol.ConnectionCapabilities {
234+ _, datagram := raw.(wire.DatagramTransport)
235+ _, async := raw.(wire.AsyncTransport)
236+ _, synchronous := raw.(wire.OwnedSynchronousTransport)
237237 _, link := raw.(ports.LinkStateReporter)
238238 window := uint8(protocol.MaxOutputWindow)
239239 if datagram {
@@ -242,14 +242,14 @@ func testServerCapabilities(raw ports.Transport) protocol.ConnectionCapabilities
242242 return protocol.ConnectionCapabilities{OutputDataLimit: protocol.MaxOutputDataLen, PreferredOutputWindow: window, AsyncSend: async, OwnedSynchronousSend: synchronous, LinkState: link}
243243 }
244244
245-func testLinkState(raw ports.Transport) ports.LinkState {
245+func testLinkState(raw wire.Transport) ports.LinkState {
246246 if reporter, ok := raw.(ports.LinkStateReporter); ok {
247247 return reporter.LinkState()
248248 }
249249 return ports.LinkStateConnected
250250 }
251251
252-func testLinkEvents(raw ports.Transport) <-chan ports.LinkEvent {
252+func testLinkEvents(raw wire.Transport) <-chan ports.LinkEvent {
253253 if reporter, ok := raw.(ports.LinkStateReporter); ok {
254254 return reporter.LinkEvents()
255255 }
M scripts/remote-picker-harness/main.go +23 -22
@@ -21,6 +21,7 @@ import (
2121 "github.com/bnema/vev/internal/domain"
2222 "github.com/bnema/vev/internal/ports"
2323 "github.com/bnema/vev/internal/protocol"
24+ "github.com/bnema/vev/internal/protocol/catalogue"
2425 "github.com/bnema/vev/internal/protocol/wire"
2526 )
2627
@@ -82,7 +83,7 @@ func run() (runErr error) {
8283 }
8384
8485 func runLiveStdioPhase(ctx context.Context, target string, catalog ports.RemoteCatalogClient, preview ports.RemotePreviewClient) error {
85- stdio, err := openTransport(ctx, ports.RemoteTransportStdio, target)
86+ stdio, err := openTransport(ctx, remoteadapter.TransportStdio, target)
8687 if err != nil {
8788 return fmt.Errorf("open stdio transport: %w", err)
8889 }
@@ -114,7 +115,7 @@ func runLiveStdioPhase(ctx context.Context, target string, catalog ports.RemoteC
114115 if err != nil {
115116 return fmt.Errorf("multi-tab catalog: %w", err)
116117 }
117- beforeTabs := ports.CatalogTabs(liveWithTabs)
118+ beforeTabs := catalogue.CatalogTabs(liveWithTabs)
118119 if err := sendCommand(ctx, stdio, "close-tab"); err != nil {
119120 return fmt.Errorf("remove tab: %w", err)
120121 }
@@ -122,7 +123,7 @@ func runLiveStdioPhase(ctx context.Context, target string, catalog ports.RemoteC
122123 if err != nil {
123124 return fmt.Errorf("removed-tab catalog: %w", err)
124125 }
125- removedID, ok := removedCatalogTabID(beforeTabs, ports.CatalogTabs(after))
126+ removedID, ok := removedCatalogTabID(beforeTabs, catalogue.CatalogTabs(after))
126127 if !ok {
127128 return errors.New("removed-tab catalog did not identify exactly one removed tab")
128129 }
@@ -161,7 +162,7 @@ func runLocalPickerUnitedPhase(ctx context.Context, target string, catalog ports
161162 inputMarker = "VEV_REMOTE_PICKER_INPUT_OK"
162163 )
163164
164- remote, err := openTransport(ctx, ports.RemoteTransportStdio, target)
165+ remote, err := openTransport(ctx, remoteadapter.TransportStdio, target)
165166 if err != nil {
166167 return fmt.Errorf("open remote picker source: %w", err)
167168 }
@@ -201,7 +202,7 @@ func runLocalPickerUnitedPhase(ctx context.Context, target string, catalog ports
201202 if remoteCatalog.ActiveTabID == "" {
202203 return errors.New("remote picker catalog omitted active tab")
203204 }
204- tabs := ports.CatalogTabs(remoteCatalog)
205+ tabs := catalogue.CatalogTabs(remoteCatalog)
205206 if len(tabs) != 2 {
206207 return errors.New("remote picker catalog did not retain both tabs")
207208 }
@@ -347,7 +348,7 @@ func runRestartResumePhase(ctx context.Context, target string, catalog ports.Rem
347348 if err != nil {
348349 return domain.RemoteSessionTarget{}, fmt.Errorf("resumed target: %w", err)
349350 }
350- resumed, err := openTransport(ctx, ports.RemoteTransportStdio, target)
351+ resumed, err := openTransport(ctx, remoteadapter.TransportStdio, target)
351352 if err != nil {
352353 return domain.RemoteSessionTarget{}, fmt.Errorf("open restart stdio transport: %w", err)
353354 }
@@ -381,7 +382,7 @@ func runRestartResumePhase(ctx context.Context, target string, catalog ports.Rem
381382 }
382383
383384 func runUDPPhase(ctx context.Context, target string) error {
384- udp, err := openTransport(ctx, ports.RemoteTransportUDP, target)
385+ udp, err := openTransport(ctx, remoteadapter.TransportUDP, target)
385386 if err != nil {
386387 return fmt.Errorf("open UDP transport: %w", err)
387388 }
@@ -418,7 +419,7 @@ func runStaleTargetPhase(ctx context.Context, target string, catalog ports.Remot
418419 if err := waitForMissingSession(ctx, catalog, target, sessionName); err != nil {
419420 return fmt.Errorf("wait for destroyed session: %w", err)
420421 }
421- stale, err := openTransport(ctx, ports.RemoteTransportStdio, target)
422+ stale, err := openTransport(ctx, remoteadapter.TransportStdio, target)
422423 if err != nil {
423424 return fmt.Errorf("open stale-target transport: %w", err)
424425 }
@@ -429,7 +430,7 @@ func runStaleTargetPhase(ctx context.Context, target string, catalog ports.Remot
429430 return nil
430431 }
431432
432-func removedCatalogTabID(before, after []ports.RemoteCatalogTab) (string, bool) {
433+func removedCatalogTabID(before, after []catalogue.RemoteCatalogTab) (string, bool) {
433434 remaining := make(map[string]struct{}, len(after))
434435 for _, tab := range after {
435436 remaining[tab.ID] = struct{}{}
@@ -464,7 +465,7 @@ type harnessFrame struct {
464465 }
465466
466467 type harnessTransport struct {
467- ports.Transport
468+ wire.Transport
468469 probe *visualProbe
469470 frames chan harnessFrame
470471 queued []harnessFrame
@@ -473,7 +474,7 @@ type harnessTransport struct {
473474 closeErr error
474475 }
475476
476-func newHarnessTransport(transport ports.Transport) *harnessTransport {
477+func newHarnessTransport(transport wire.Transport) *harnessTransport {
477478 h := &harnessTransport{
478479 Transport: transport,
479480 probe: newVisualProbe(domain.Size{Cols: defaultProbeCols, Rows: defaultProbeRows}),
@@ -506,13 +507,13 @@ func (h *harnessTransport) Close() error {
506507 return h.closeErr
507508 }
508509
509-func openTransport(ctx context.Context, mode ports.RemoteTransportMode, target string) (*harnessTransport, error) {
510- var transport ports.Transport
510+func openTransport(ctx context.Context, mode remoteadapter.TransportMode, target string) (*harnessTransport, error) {
511+ var transport wire.Transport
511512 var err error
512513 switch mode {
513- case ports.RemoteTransportStdio:
514+ case remoteadapter.TransportStdio:
514515 transport, err = sshstdio.DialContext(ctx, target, "", slog.New(slog.DiscardHandler))
515- case ports.RemoteTransportUDP:
516+ case remoteadapter.TransportUDP:
516517 dialer := dgram.NewRemoteDialerWithLogger(target, "", slog.New(slog.DiscardHandler))
517518 dialer.BootstrapTimeout = 8 * time.Second
518519 dialer.ProbeTimeout = 3 * time.Second
@@ -893,8 +894,8 @@ func expectNoSuchTarget(ctx context.Context, tr *harnessTransport, target domain
893894 return nil
894895 }
895896
896-func targetForSession(endpoint string, session ports.RemoteCatalogSession, stopped bool) (domain.RemoteSessionTarget, error) {
897- tabs := ports.CatalogTabs(session)
897+func targetForSession(endpoint string, session catalogue.RemoteCatalogSession, stopped bool) (domain.RemoteSessionTarget, error) {
898+ tabs := catalogue.CatalogTabs(session)
898899 if len(tabs) == 0 {
899900 return domain.RemoteSessionTarget{}, errors.New("catalog session has no tabs")
900901 }
@@ -932,18 +933,18 @@ func targetEndpoint(endpoint string) string {
932933 return endpoint
933934 }
934935
935-func waitForSession(ctx context.Context, catalog ports.RemoteCatalogClient, endpoint, name string, state ports.RemoteCatalogSessionState) (ports.RemoteCatalogSession, error) {
936+func waitForSession(ctx context.Context, catalog ports.RemoteCatalogClient, endpoint, name string, state catalogue.RemoteCatalogSessionState) (catalogue.RemoteCatalogSession, error) {
936937 return waitForSessionWithTabCount(ctx, catalog, endpoint, name, state, 0)
937938 }
938939
939-func waitForSessionWithTabCount(ctx context.Context, catalog ports.RemoteCatalogClient, endpoint, name string, state ports.RemoteCatalogSessionState, tabs int) (ports.RemoteCatalogSession, error) {
940+func waitForSessionWithTabCount(ctx context.Context, catalog ports.RemoteCatalogClient, endpoint, name string, state catalogue.RemoteCatalogSessionState, tabs int) (catalogue.RemoteCatalogSession, error) {
940941 deadline, cancel := context.WithTimeout(ctx, 10*time.Second)
941942 defer cancel()
942943 for {
943944 result, err := catalog.List(deadline, endpoint)
944945 if err == nil {
945946 for _, session := range result.Sessions {
946- if session.Name == name && session.State == state && (tabs == 0 || len(ports.CatalogTabs(session)) == tabs) {
947+ if session.Name == name && session.State == state && (tabs == 0 || len(catalogue.CatalogTabs(session)) == tabs) {
947948 return session, nil
948949 }
949950 }
@@ -951,9 +952,9 @@ func waitForSessionWithTabCount(ctx context.Context, catalog ports.RemoteCatalog
951952 select {
952953 case <-deadline.Done():
953954 if err != nil {
954- return ports.RemoteCatalogSession{}, err
955+ return catalogue.RemoteCatalogSession{}, err
955956 }
956- return ports.RemoteCatalogSession{}, fmt.Errorf("session %q did not reach state %q", name, state)
957+ return catalogue.RemoteCatalogSession{}, fmt.Errorf("session %q did not reach state %q", name, state)
957958 case <-time.After(100 * time.Millisecond):
958959 }
959960 }
M transport_replay_integration_test.go +3 -4
@@ -10,7 +10,6 @@ import (
1010 renderer "github.com/bnema/vev-vt/ansi"
1111 "github.com/bnema/vev/internal/adapters/ipc"
1212 "github.com/bnema/vev/internal/adapters/sshstdio"
13- "github.com/bnema/vev/internal/ports"
1413 "github.com/bnema/vev/internal/protocol"
1514 "github.com/bnema/vev/internal/protocol/wire"
1615 "github.com/bnema/vev/internal/testutil/replaytest"
@@ -68,11 +67,11 @@ func TestThemeGenerationTransportSequences(t *testing.T) {
6867
6968 tests := []struct {
7069 name string
71- pair func(*testing.T) (ports.Transport, ports.Transport)
70+ pair func(*testing.T) (wire.Transport, wire.Transport)
7271 }{
7372 {
7473 name: "local ipc socket",
75- pair: func(t *testing.T) (ports.Transport, ports.Transport) {
74+ pair: func(t *testing.T) (wire.Transport, wire.Transport) {
7675 left, right := net.Pipe()
7776 t.Cleanup(func() { _ = left.Close(); _ = right.Close() })
7877 return ipc.NewTransport(left), ipc.NewTransport(right)
@@ -80,7 +79,7 @@ func TestThemeGenerationTransportSequences(t *testing.T) {
8079 },
8180 {
8281 name: "remote ssh stdio",
83- pair: func(t *testing.T) (ports.Transport, ports.Transport) {
82+ pair: func(t *testing.T) (wire.Transport, wire.Transport) {
8483 clientRead, serverWrite := io.Pipe()
8584 serverRead, clientWrite := io.Pipe()
8685 closeClient := func() error {

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