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:
28 dir: internal/ports/mocks28 dir: internal/ports/mocks
29 filename: '{{.InterfaceFile | base | trimSuffix ".go"}}_mock.go'29 filename: '{{.InterfaceFile | base | trimSuffix ".go"}}_mock.go'
30 pkgname: portsmocks30 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:
31 github.com/bnema/vev/internal/usecase/command:43 github.com/bnema/vev/internal/usecase/command:
32 interfaces:44 interfaces:
33 Context:45 Context:
M AGENTS.md +16 -14
@@ -28,28 +28,30 @@ go test ./pkg/renderer ./pkg/vt ./internal/adapters/ipc ./internal/usecase/daemo
28 28
29 ## Architecture rules29 ## Architecture rules
30 30
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`.
32 32
33-- `pkg/` is reusable and must not import `internal/`.33+- `pkg/` is reusable and never imports `internal/`.
34-- `internal/usecase/` must not import `internal/adapters/`.34+- Production use cases import only `internal/ports`, semantic `internal/protocol` packages, `internal/domain`, and approved sibling use cases.
35-- Usecases depend on `internal/ports` and `internal/domain`.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.
36 37
37 Layer map:38 Layer map:
38 39
39-- `main.go` → `internal/app`: CLI parsing, wiring, daemon startup, hidden subcommands.40+- `main.go` → `internal/app`: CLI parsing, composition, daemon startup, and hidden subcommands.
40-- `internal/ports`: application and raw carriage interfaces.41+- `internal/domain`: pure shared values and terminal capability policy.
41-- `internal/protocol`: typed transport-neutral messages and negotiated session policy.42+- `internal/protocol`: typed session messages; `protocol/catalogue` owns remote JSON schema; `protocol/wire` owns binary encoding and raw carriage contracts.
42-- `internal/protocol/wire`: message IDs, frames, strict binary codecs, compression, and encoded bounds.43+- `internal/ports`: typed application connections and infrastructure-facing use-case seams.
43-- `internal/usecase/daemon`: sessions, tabs, panes, VT screens, renderer shadows, daemon features.44+- `internal/adapters/sessionwire`: typed message ↔ raw frame adaptation.
45+- `internal/usecase/daemon`: sessions, tabs, panes, VT screens, renderer shadows, and daemon features.
44 - `internal/usecase/client`: raw-mode thin client; writes output bytes verbatim and interprets nothing.46 - `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.
46 48
47 Before touching daemon teardown paths, read the lock-ordering notes at the top of `internal/usecase/daemon/client.go`.49 Before touching daemon teardown paths, read the lock-ordering notes at the top of `internal/usecase/daemon/client.go`.
48 50
49 ## Ports and mocks51 ## Ports and mocks
50 52
51-- Define cross-layer interfaces only in `internal/ports`.53+- Define application cross-layer interfaces in `internal/ports`; raw frame carriage interfaces live in `internal/protocol/wire`.
52-- Adapters implement ports; usecases and `internal/app` consume ports.54+- Adapters implement ports and raw carriage contracts; use cases consume only application ports.
53 - Tests should prefer generated `portsmocks.MockX` with `.EXPECT()`.55 - Tests should prefer generated `portsmocks.MockX` with `.EXPECT()`.
54 - Hand-written fakes are okay only when mocks make the test harder to understand.56 - Hand-written fakes are okay only when mocks make the test harder to understand.
55 - After changing a port, run `make mocks` and commit the generated updates.57 - 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
63 65
64 ## Wire protocol66 ## Wire protocol
65 67
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`.
67 69
68 - IPC frames on a connection are 4-byte big-endian length, 1 type byte, then payload.70 - IPC frames on a connection are 4-byte big-endian length, 1 type byte, then payload.
69 - 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.71 - 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,
77 - Named sessions survive headless and persist across daemon restarts.79 - Named sessions survive headless and persist across daemon restarts.
78 - The daemon starts on first use and exits when the last session ends.80 - The daemon starts on first use and exits when the last session ends.
79 - Each connection has a 15-second handshake budget from connect through the initial committed publication.81 - 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.
81 - Remote attach opens a direct connection to the selected daemon over UDP by default; `VEV_REMOTE_TRANSPORT=stdio` explicitly selects an SSH-only carriage.83 - Remote attach opens a direct connection to the selected daemon over UDP by default; `VEV_REMOTE_TRANSPORT=stdio` explicitly selects an SSH-only carriage.
82 - 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.84 - 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.
83 - Command requests have a 10-second result deadline and are tracked per connection.85 - Command requests have a 10-second result deadline and are tracked per connection.
M boundary_test.go +189 -46
@@ -1,84 +1,227 @@
1 package main_test1 package main_test
2 2
3 import (3 import (
4+ "fmt"
4 "go/parser"5 "go/parser"
5 "go/token"6 "go/token"
6 "io/fs"7 "io/fs"
7 "path/filepath"8 "path/filepath"
9+ "sort"
10+ "strconv"
8 "strings"11 "strings"
9 "testing"12 "testing"
10 )13 )
11 14
12-// boundaryRule expresses a single import-boundary constraint: no .go file15+const modulePath = "github.com/bnema/vev"
13-// under root may import a package whose path has disallowedPrefix.16+
14-type boundaryRule struct {17+type packageLayer string
15- name string18+
16- root string19+const (
17- disallowedPrefix string20+ 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},
18 }54 }
19 55
20 func TestImportBoundaries(t *testing.T) {56 func TestImportBoundaries(t *testing.T) {
21- rules := []boundaryRule{57+ violations, packages := inspectRepositoryDependencies(t)
22- {58+ if len(violations) != 0 {
23- name: "pkg must not import internal",59+ t.Fatalf("invalid package dependencies:\n%s", strings.Join(violations, "\n"))
24- root: "pkg",60+ }
25- disallowedPrefix: "github.com/bnema/vev/internal",61+ if len(packages) == 0 {
26- },62+ t.Fatal("package inventory is empty")
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- },
37 }63 }
64+}
38 65
39- for _, rule := range rules {66+func TestImportBoundaryNegativeFixtures(t *testing.T) {
40- t.Run(rule.name, func(t *testing.T) {67+ tests := []struct {
41- violations := findImportViolations(t, rule.root, rule.disallowedPrefix)68+ name, source, target string
42- if len(violations) > 0 {69+ testFile bool
43- t.Errorf("found disallowed imports of %q under %q:\n%s", rule.disallowedPrefix, rule.root, strings.Join(violations, "\n"))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)
44 }90 }
45 })91 })
46 }92 }
47 }93 }
48 94
49-// findImportViolations walks root, parses each .go file's imports, and95+func inspectRepositoryDependencies(t *testing.T) ([]string, map[string]struct{}) {
50-// returns a description for every import whose path starts with
51-// disallowedPrefix.
52-func findImportViolations(t *testing.T, root, disallowedPrefix string) []string {
53 t.Helper()96 t.Helper()
54-
55 var violations []string97 var violations []string
98+ packages := make(map[string]struct{})
56 fset := token.NewFileSet()99 fset := token.NewFileSet()
57-100+ err := filepath.WalkDir(".", func(path string, entry fs.DirEntry, walkErr error) error {
58- err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {101+ if walkErr != nil {
59- if err != nil {102+ return walkErr
60- return err
61 }103 }
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+ }
63 return nil109 return nil
64 }110 }
65-111+ if !strings.HasSuffix(path, ".go") {
66- f, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly)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)
67 if err != nil {121 if err != nil {
68 return err122 return err
69 }123 }
70-124+ testFile := strings.HasSuffix(path, "_test.go")
71- for _, imp := range f.Imports {125+ for _, spec := range file.Imports {
72- importPath := strings.Trim(imp.Path.Value, `"`)126+ target, err := strconv.Unquote(spec.Path.Value)
73- if strings.HasPrefix(importPath, disallowedPrefix) {127+ if err != nil || (target != modulePath && !strings.HasPrefix(target, modulePath+"/")) {
74- violations = append(violations, path+": imports "+importPath)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))
75 }137 }
76 }138 }
77 return nil139 return nil
78 })140 })
79 if err != nil {141 if err != nil {
80- t.Fatalf("walking %s: %v", root, err)142+ t.Fatalf("walking repository: %v", err)
81 }143 }
144+ sort.Strings(violations)
145+ return violations, packages
146+}
82 147
83- return violations148+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, "./")
84 }227 }
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 (
17 )17 )
18 18
19 type ProxyRuntime struct {19 type ProxyRuntime struct {
20- Client ports.Transport20+ Client wire.Transport
21- Daemon ports.Transport21+ Daemon wire.Transport
22 Log *slog.Logger22 Log *slog.Logger
23 IdleTTL time.Duration23 IdleTTL time.Duration
24 RetryBackoff time.Duration24 RetryBackoff time.Duration
@@ -47,8 +47,8 @@ type proxyCopier struct {
47 }47 }
48 48
49 type proxyCopyDirection struct {49 type proxyCopyDirection struct {
50- src ports.Transport50+ src wire.Transport
51- dst ports.Transport51+ dst wire.Transport
52 recvKind proxyCopyErrKind52 recvKind proxyCopyErrKind
53 sendKind proxyCopyErrKind53 sendKind proxyCopyErrKind
54 transform func(wire.Frame) wire.Frame54 transform func(wire.Frame) wire.Frame
@@ -191,7 +191,7 @@ func (c proxyCopier) copyFrames(dir proxyCopyDirection) {
191 }191 }
192 }192 }
193 193
194-func recoverableClientLink(t ports.Transport, err error) bool {194+func recoverableClientLink(t wire.Transport, err error) bool {
195 if errors.Is(err, ErrLinkDead) {195 if errors.Is(err, ErrLinkDead) {
196 return false196 return false
197 }197 }
M internal/adapters/dgram/ssh_dialer.go +2 -1
@@ -15,6 +15,7 @@ import (
15 15
16 "github.com/bnema/vev/internal/adapters/sshstdio"16 "github.com/bnema/vev/internal/adapters/sshstdio"
17 "github.com/bnema/vev/internal/ports"17 "github.com/bnema/vev/internal/ports"
18+ "github.com/bnema/vev/internal/protocol/wire"
18 pdgram "github.com/bnema/vev/pkg/dgram"19 pdgram "github.com/bnema/vev/pkg/dgram"
19 )20 )
20 21
@@ -112,7 +113,7 @@ func NewRemoteDialerWithLogger(target, _ string, log *slog.Logger) RemoteDialer
112 return RemoteDialer{Target: target, ProbeTimeout: defaultProbeTimeout, Log: log}113 return RemoteDialer{Target: target, ProbeTimeout: defaultProbeTimeout, Log: log}
113 }114 }
114 115
115-func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) {116+func (d RemoteDialer) Dial(ctx context.Context) (wire.Transport, error) {
116 if err := ctx.Err(); err != nil {117 if err := ctx.Err(); err != nil {
117 return nil, err118 return nil, err
118 }119 }
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.
2 package dgram2 package dgram
3 3
4 import (4 import (
@@ -136,7 +136,7 @@ func normalizeOptions(opts Options) Options {
136 return opts136 return opts
137 }137 }
138 138
139-var _ ports.DatagramTransport = (*Transport)(nil)139+var _ wire.DatagramTransport = (*Transport)(nil)
140 140
141 type Transport struct {141 type Transport struct {
142 pc net.PacketConn142 pc net.PacketConn
M internal/adapters/dgram/transport_observability_test.go +2 -2
@@ -28,7 +28,7 @@ func TestTransportObservabilityDgramFailedCloseEndsReceiveAndSend(t *testing.T)
28 }28 }
29 receiveStarted := make(chan struct{})29 receiveStarted := make(chan struct{})
30 signalingObserver := &dgramStartObserver{RuntimeObserver: observer, receiveStarted: receiveStarted}30 signalingObserver := &dgramStartObserver{RuntimeObserver: observer, receiveStarted: receiveStarted}
31- reporter := ports.NewSerializedRuntimeObserver(signalingObserver, 64)31+ reporter := observability.NewSerialized(signalingObserver, 64)
32 defer reporter.Close()32 defer reporter.Close()
33 transport, err := NewTransportWithOptions(&dgramCloseFailPC{fakePC: aPC, err: closeErr}, testAddr("b"), key(), 1, 2, Options{Clock: behaviorClock, ResendAfter: time.Hour, Heartbeat: time.Hour}, WithRuntimeObserver(reporter))33 transport, err := NewTransportWithOptions(&dgramCloseFailPC{fakePC: aPC, err: closeErr}, testAddr("b"), key(), 1, 2, Options{Clock: behaviorClock, ResendAfter: time.Hour, Heartbeat: time.Hour}, WithRuntimeObserver(reporter))
34 if err != nil {34 if err != nil {
@@ -142,7 +142,7 @@ func TestTransportObservabilityDgramKeepsBehaviorClockSeparate(t *testing.T) {
142 if err != nil {142 if err != nil {
143 t.Fatalf("NewJSONL() error = %v", err)143 t.Fatalf("NewJSONL() error = %v", err)
144 }144 }
145- reporter := ports.NewSerializedRuntimeObserver(observer, 64)145+ reporter := observability.NewSerialized(observer, 64)
146 defer reporter.Close()146 defer reporter.Close()
147 147
148 a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: behaviorClock, ResendAfter: time.Hour, Heartbeat: time.Hour}, WithRuntimeObserver(reporter))148 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 (
6 "net"6 "net"
7 "path/filepath"7 "path/filepath"
8 8
9- "github.com/bnema/vev/internal/ports"9+ "github.com/bnema/vev/internal/protocol/wire"
10 )10 )
11 11
12 // DialContext connects to the daemon's socket in dir (dir/daemon.sock) and12 // DialContext connects to the daemon's socket in dir (dir/daemon.sock) and
13 // returns a Transport speaking vev's framed protocol. A dial failure (no daemon13 // returns a Transport speaking vev's framed protocol. A dial failure (no daemon
14 // listening, or a stale/absent socket) is reported as a wrapped error so14 // listening, or a stale/absent socket) is reported as a wrapped error so
15 // callers can decide whether to spawn a daemon and retry.15 // 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) {
17 sockPath := filepath.Join(dir, socketFileName)17 sockPath := filepath.Join(dir, socketFileName)
18 var dialer net.Dialer18 var dialer net.Dialer
19 conn, err := dialer.DialContext(ctx, "unix", sockPath)19 conn, err := dialer.DialContext(ctx, "unix", sockPath)
M internal/adapters/ipc/dial_test.go +2 -1
@@ -6,6 +6,7 @@ import (
6 "testing"6 "testing"
7 "time"7 "time"
8 8
9+ "github.com/bnema/vev/internal/adapters/observability"
9 "github.com/bnema/vev/internal/ports"10 "github.com/bnema/vev/internal/ports"
10 )11 )
11 12
@@ -61,7 +62,7 @@ func TestDialContextPreservesOptions(t *testing.T) {
61 }62 }
62 defer func() { _ = listener.Close() }()63 defer func() { _ = listener.Close() }()
63 64
64- observer := ports.NewSerializedRuntimeObserver(dialTestObserver{}, 1)65+ observer := observability.NewSerialized(dialTestObserver{}, 1)
65 defer observer.Close()66 defer observer.Close()
66 transport, err := DialContext(context.Background(), dir, WithRuntimeObserver(observer))67 transport, err := DialContext(context.Background(), dir, WithRuntimeObserver(observer))
67 if err != nil {68 if err != nil {
M internal/adapters/ipc/listener.go +5 -5
@@ -8,7 +8,7 @@ import (
8 "path/filepath"8 "path/filepath"
9 "syscall"9 "syscall"
10 10
11- "github.com/bnema/vev/internal/ports"11+ "github.com/bnema/vev/internal/protocol/wire"
12 "github.com/bnema/vev/pkg/safedir"12 "github.com/bnema/vev/pkg/safedir"
13 )13 )
14 14
@@ -20,7 +20,7 @@ const socketFileName = "daemon.sock"
20 // bound by a live daemon (a dial-probe against it succeeded).20 // bound by a live daemon (a dial-probe against it succeeded).
21 var ErrDaemonRunning = errors.New("ipc: a daemon is already listening on this socket")21 var ErrDaemonRunning = errors.New("ipc: a daemon is already listening on this socket")
22 22
23-// unixListener implements ports.Listener over an AF_UNIX SOCK_STREAM23+// unixListener implements wire.Listener over an AF_UNIX SOCK_STREAM
24 // listener.24 // listener.
25 type unixListener struct {25 type unixListener struct {
26 ln *net.UnixListener26 ln *net.UnixListener
@@ -36,7 +36,7 @@ type unixListener struct {
36 // the previous owner died without cleaning up, so the stale socket file is36 // the previous owner died without cleaning up, so the stale socket file is
37 // unlinked and bind is retried once. A successful dial means a live daemon37 // unlinked and bind is retried once. A successful dial means a live daemon
38 // owns the socket, and Listen returns ErrDaemonRunning.38 // 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) {
40 if err := safedir.EnsurePrivate(dir); err != nil {40 if err := safedir.EnsurePrivate(dir); err != nil {
41 return nil, fmt.Errorf("ipc: securing socket directory: %w", err)41 return nil, fmt.Errorf("ipc: securing socket directory: %w", err)
42 }42 }
@@ -99,8 +99,8 @@ func probeLiveDaemon(sockPath string) error {
99 }99 }
100 100
101 // Accept waits for and returns the next connection, wrapped as a101 // Accept waits for and returns the next connection, wrapped as a
102-// ports.Transport.102+// wire.Transport.
103-func (l *unixListener) Accept() (ports.Transport, error) {103+func (l *unixListener) Accept() (wire.Transport, error) {
104 conn, err := l.ln.Accept()104 conn, err := l.ln.Accept()
105 if err != nil {105 if err != nil {
106 return nil, err106 return nil, err
M internal/adapters/ipc/transport.go +3 -3
@@ -35,7 +35,7 @@ var errClosed = errors.New("ipc: closed")
35 // peer into an unbounded memory commitment.35 // peer into an unbounded memory commitment.
36 const sendQueueCapacity = 836 const sendQueueCapacity = 8
37 37
38-// unixTransport implements ports.Transport over a net.Conn (in practice an38+// unixTransport implements wire.Transport over a net.Conn (in practice an
39 // AF_UNIX SOCK_STREAM connection, but any net.Conn works — this also lets39 // AF_UNIX SOCK_STREAM connection, but any net.Conn works — this also lets
40 // tests exercise it over net.Pipe).40 // tests exercise it over net.Pipe).
41 //41 //
@@ -79,9 +79,9 @@ type unixTransport struct {
79 readBuf []byte79 readBuf []byte
80 }80 }
81 81
82-// NewTransport wraps conn as a ports.Transport speaking vev's framed82+// NewTransport wraps conn as a wire.Transport speaking vev's framed
83 // binary protocol.83 // binary protocol.
84-func NewTransport(conn net.Conn, opts ...Option) ports.Transport {84+func NewTransport(conn net.Conn, opts ...Option) wire.Transport {
85 t := &unixTransport{85 t := &unixTransport{
86 conn: conn,86 conn: conn,
87 egress: make(chan sendRequest, sendQueueCapacity),87 egress: make(chan sendRequest, sendQueueCapacity),
M internal/adapters/ipc/transport_observability_test.go +2 -2
@@ -21,7 +21,7 @@ func TestTransportObservabilityIPCEOFAndCloseEndFailedSpans(t *testing.T) {
21 if err != nil {21 if err != nil {
22 t.Fatalf("NewJSONL() error = %v", err)22 t.Fatalf("NewJSONL() error = %v", err)
23 }23 }
24- reporter := ports.NewSerializedRuntimeObserver(observer, 64)24+ reporter := observability.NewSerialized(observer, 64)
25 defer reporter.Close()25 defer reporter.Close()
26 26
27 // EOF from the peer and a locally closed blocked receive are distinct27 // EOF from the peer and a locally closed blocked receive are distinct
@@ -145,7 +145,7 @@ func TestTransportObservabilityIPCMarksCarriageWithoutChangingBytes(t *testing.T
145 if err != nil {145 if err != nil {
146 t.Fatalf("NewJSONL() error = %v", err)146 t.Fatalf("NewJSONL() error = %v", err)
147 }147 }
148- reporter := ports.NewSerializedRuntimeObserver(observer, 64)148+ reporter := observability.NewSerialized(observer, 64)
149 defer reporter.Close()149 defer reporter.Close()
150 150
151 left, right := net.Pipe()151 left, right := net.Pipe()
M internal/adapters/ipc/transport_test.go +6 -7
@@ -12,7 +12,6 @@ import (
12 "time"12 "time"
13 13
14 "github.com/bnema/vev/internal/domain"14 "github.com/bnema/vev/internal/domain"
15- "github.com/bnema/vev/internal/ports"
16 "github.com/bnema/vev/internal/protocol"15 "github.com/bnema/vev/internal/protocol"
17 "github.com/bnema/vev/internal/protocol/wire"16 "github.com/bnema/vev/internal/protocol/wire"
18 )17 )
@@ -253,7 +252,7 @@ func TestTransportAsyncEgressPreservesWelcomeBeforeOutput(t *testing.T) {
253 defer closeTransport(t, server)252 defer closeTransport(t, server)
254 client := NewTransport(clientConn)253 client := NewTransport(clientConn)
255 defer closeTransport(t, client)254 defer closeTransport(t, client)
256- async, ok := server.(ports.AsyncTransport)255+ async, ok := server.(wire.AsyncTransport)
257 if !ok {256 if !ok {
258 t.Fatal("IPC transport does not implement AsyncTransport")257 t.Fatal("IPC transport does not implement AsyncTransport")
259 }258 }
@@ -317,7 +316,7 @@ func TestTransportSendWaitsForEgressCapacity(t *testing.T) {
317 }316 }
318 client := NewTransport(clientConn)317 client := NewTransport(clientConn)
319 defer closeTransport(t, client)318 defer closeTransport(t, client)
320- async, ok := server.(ports.AsyncTransport)319+ async, ok := server.(wire.AsyncTransport)
321 if !ok {320 if !ok {
322 t.Fatal("IPC transport does not implement AsyncTransport")321 t.Fatal("IPC transport does not implement AsyncTransport")
323 }322 }
@@ -365,7 +364,7 @@ func TestTransportCloseInterruptsSendWaitingForEgressCapacity(t *testing.T) {
365 if !ok {364 if !ok {
366 t.Fatal("IPC transport is not a unixTransport")365 t.Fatal("IPC transport is not a unixTransport")
367 }366 }
368- async, ok := server.(ports.AsyncTransport)367+ async, ok := server.(wire.AsyncTransport)
369 if !ok {368 if !ok {
370 t.Fatal("IPC transport does not implement AsyncTransport")369 t.Fatal("IPC transport does not implement AsyncTransport")
371 }370 }
@@ -407,7 +406,7 @@ func waitForEgressSender(t *testing.T, transport *unixTransport, timeout time.Du
407 }406 }
408 }407 }
409 408
410-func closeTransport(t *testing.T, transport ports.Transport) {409+func closeTransport(t *testing.T, transport wire.Transport) {
411 t.Helper()410 t.Helper()
412 411
413 closed := make(chan error, 1)412 closed := make(chan error, 1)
@@ -422,7 +421,7 @@ func closeTransport(t *testing.T, transport ports.Transport) {
422 }421 }
423 }422 }
424 423
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 {
426 t.Helper()425 t.Helper()
427 426
428 first := wire.Frame{Type: wire.MsgOutput, Payload: []byte{0}}427 first := wire.Frame{Type: wire.MsgOutput, Payload: []byte{0}}
@@ -474,7 +473,7 @@ func TestTransportAsyncEgressIsBoundedAndCloseInterruptsWorkers(t *testing.T) {
474 if !ok {473 if !ok {
475 t.Fatal("IPC transport is not a unixTransport")474 t.Fatal("IPC transport is not a unixTransport")
476 }475 }
477- async, ok := tr.(ports.AsyncTransport)476+ async, ok := tr.(wire.AsyncTransport)
478 if !ok {477 if !ok {
479 t.Fatal("IPC transport does not implement AsyncTransport")478 t.Fatal("IPC transport does not implement AsyncTransport")
480 }479 }
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 (
16 16
17 "github.com/bnema/vev/internal/adapters/sshstdio"17 "github.com/bnema/vev/internal/adapters/sshstdio"
18 "github.com/bnema/vev/internal/domain"18 "github.com/bnema/vev/internal/domain"
19- "github.com/bnema/vev/internal/ports"19+ appports "github.com/bnema/vev/internal/ports"
20 "github.com/bnema/vev/internal/protocol"20 "github.com/bnema/vev/internal/protocol"
21+ "github.com/bnema/vev/internal/protocol/catalogue"
21 )22 )
22 23
23 const (24 const (
@@ -42,10 +43,10 @@ type CatalogClient struct {
42 timeout time.Duration43 timeout time.Duration
43 }44 }
44 45
45-var _ ports.RemoteCatalogClient = (*CatalogClient)(nil)46+var _ appports.RemoteCatalogClient = (*CatalogClient)(nil)
46 47
47 // NewCatalogClient returns a RemoteCatalogClient that shells out to ssh.48 // NewCatalogClient returns a RemoteCatalogClient that shells out to ssh.
48-func NewCatalogClient() ports.RemoteCatalogClient {49+func NewCatalogClient() appports.RemoteCatalogClient {
49 return &CatalogClient{command: exec.CommandContext}50 return &CatalogClient{command: exec.CommandContext}
50 }51 }
51 52
@@ -60,13 +61,13 @@ func (c *CatalogClient) listTimeout() time.Duration {
60 // exactly one versioned catalog envelope from stdout. List always applies a61 // exactly one versioned catalog envelope from stdout. List always applies a
61 // bounded command timeout derived from ctx so a caller with no deadline cannot62 // bounded command timeout derived from ctx so a caller with no deadline cannot
62 // hang indefinitely, while still honoring caller cancellation.63 // 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) {
64 if err := ctx.Err(); err != nil {65 if err := ctx.Err(); err != nil {
65- return ports.RemoteCatalog{}, err66+ return catalogue.RemoteCatalog{}, err
66 }67 }
67 if err := domain.ValidateRemoteHostTarget(target); err != nil {68 if err := domain.ValidateRemoteHostTarget(target); err != nil {
68 slog.Debug("remote catalog rejected target", "target", target, "err", err)69 slog.Debug("remote catalog rejected target", "target", target, "err", err)
69- return ports.RemoteCatalog{}, err70+ return catalogue.RemoteCatalog{}, err
70 }71 }
71 72
72 runCtx, cancel := context.WithTimeout(ctx, c.listTimeout())73 runCtx, cancel := context.WithTimeout(ctx, c.listTimeout())
@@ -85,29 +86,29 @@ func (c *CatalogClient) List(ctx context.Context, target string) (ports.RemoteCa
85 cmd.WaitDelay = catalogCommandWaitDelay86 cmd.WaitDelay = catalogCommandWaitDelay
86 err := cmd.Run()87 err := cmd.Run()
87 if ctxErr := runCtx.Err(); ctxErr != nil {88 if ctxErr := runCtx.Err(); ctxErr != nil {
88- return ports.RemoteCatalog{}, ctxErr89+ return catalogue.RemoteCatalog{}, ctxErr
89 }90 }
90 if err != nil {91 if err != nil {
91 if stdout.overflow || stderr.overflow {92 if stdout.overflow || stderr.overflow {
92 slog.Debug("remote catalog output too large", "target", target, "stdout_limit", maxCatalogBytes, "stderr_limit", maxCatalogDiagnosticBytes)93 slog.Debug("remote catalog output too large", "target", target, "stdout_limit", maxCatalogBytes, "stderr_limit", maxCatalogDiagnosticBytes)
93- return ports.RemoteCatalog{}, errCatalogTooLarge94+ return catalogue.RemoteCatalog{}, errCatalogTooLarge
94 }95 }
95 stderrText := sanitizeCatalogDiagnostic(string(stderr.Bytes()))96 stderrText := sanitizeCatalogDiagnostic(string(stderr.Bytes()))
96 slog.Debug("remote catalog ssh failed", "target", target, "err", err, "stderr", stderrText)97 slog.Debug("remote catalog ssh failed", "target", target, "err", err, "stderr", stderrText)
97 if stderrText != "" {98 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)
99 }100 }
100- return ports.RemoteCatalog{}, fmt.Errorf("%w: %w", errCatalogSSH, err)101+ return catalogue.RemoteCatalog{}, fmt.Errorf("%w: %w", errCatalogSSH, err)
101 }102 }
102 if stdout.overflow || stderr.overflow {103 if stdout.overflow || stderr.overflow {
103 slog.Debug("remote catalog output too large", "target", target, "stdout_limit", maxCatalogBytes, "stderr_limit", maxCatalogDiagnosticBytes)104 slog.Debug("remote catalog output too large", "target", target, "stdout_limit", maxCatalogBytes, "stderr_limit", maxCatalogDiagnosticBytes)
104- return ports.RemoteCatalog{}, errCatalogTooLarge105+ return catalogue.RemoteCatalog{}, errCatalogTooLarge
105 }106 }
106 107
107 catalog, err := decodeRemoteCatalog(stdout.Bytes())108 catalog, err := decodeRemoteCatalog(stdout.Bytes())
108 if err != nil {109 if err != nil {
109 slog.Debug("remote catalog decode failed", "target", target, "err", err)110 slog.Debug("remote catalog decode failed", "target", target, "err", err)
110- return ports.RemoteCatalog{}, err111+ return catalogue.RemoteCatalog{}, err
111 }112 }
112 return catalog, nil113 return catalog, nil
113 }114 }
@@ -125,15 +126,15 @@ type catalogEnvelope struct {
125 }126 }
126 127
127 type catalogSessionEnvelope struct {128 type catalogSessionEnvelope struct {
128- LifecycleID *domain.SessionLifecycleID `json:"lifecycle_id"`129+ LifecycleID *domain.SessionLifecycleID `json:"lifecycle_id"`
129- Name *string `json:"name"`130+ Name *string `json:"name"`
130- State *ports.RemoteCatalogSessionState `json:"state"`131+ State *catalogue.RemoteCatalogSessionState `json:"state"`
131- Ephemeral *bool `json:"ephemeral"`132+ Ephemeral *bool `json:"ephemeral"`
132- Tabs *[]catalogTabEnvelope `json:"tabs"`133+ Tabs *[]catalogTabEnvelope `json:"tabs"`
133- Attached *bool `json:"attached"`134+ Attached *bool `json:"attached"`
134- LastUsedSeq uint64 `json:"last_used_seq,omitempty"`135+ LastUsedSeq uint64 `json:"last_used_seq,omitempty"`
135- ActiveTabID string `json:"active_tab_id,omitempty"`136+ ActiveTabID string `json:"active_tab_id,omitempty"`
136- Reason string `json:"reason,omitempty"`137+ Reason string `json:"reason,omitempty"`
137 }138 }
138 139
139 type catalogTabEnvelope struct {140 type catalogTabEnvelope struct {
@@ -144,71 +145,71 @@ type catalogTabEnvelope struct {
144 Attention bool `json:"attention,omitempty"`145 Attention bool `json:"attention,omitempty"`
145 }146 }
146 147
147-func decodeRemoteCatalog(raw []byte) (ports.RemoteCatalog, error) {148+func decodeRemoteCatalog(raw []byte) (catalogue.RemoteCatalog, error) {
148 var version catalogVersionEnvelope149 var version catalogVersionEnvelope
149 if err := decodeCatalogEnvelope(raw, false, &version); err != nil {150 if err := decodeCatalogEnvelope(raw, false, &version); err != nil {
150- return ports.RemoteCatalog{}, err151+ return catalogue.RemoteCatalog{}, err
151 }152 }
152 if version.ProtocolVersion == nil {153 if version.ProtocolVersion == nil {
153- return ports.RemoteCatalog{}, errCatalogRequired154+ return catalogue.RemoteCatalog{}, errCatalogRequired
154 }155 }
155 if version.SchemaVersion == nil {156 if version.SchemaVersion == nil {
156- return ports.RemoteCatalog{}, &ports.RemoteCatalogVersionMismatchError{157+ return catalogue.RemoteCatalog{}, &catalogue.RemoteCatalogVersionMismatchError{
157 Got: 0,158 Got: 0,
158- Want: ports.RemoteCatalogSchemaVersion,159+ Want: catalogue.RemoteCatalogSchemaVersion,
159 Kind: "catalog",160 Kind: "catalog",
160 }161 }
161 }162 }
162 if *version.ProtocolVersion != protocol.Version {163 if *version.ProtocolVersion != protocol.Version {
163- return ports.RemoteCatalog{}, &ports.RemoteCatalogVersionMismatchError{164+ return catalogue.RemoteCatalog{}, &catalogue.RemoteCatalogVersionMismatchError{
164 Got: *version.ProtocolVersion,165 Got: *version.ProtocolVersion,
165 Want: protocol.Version,166 Want: protocol.Version,
166 Kind: "protocol",167 Kind: "protocol",
167 }168 }
168 }169 }
169- if *version.SchemaVersion != ports.RemoteCatalogSchemaVersion {170+ if *version.SchemaVersion != catalogue.RemoteCatalogSchemaVersion {
170- return ports.RemoteCatalog{}, &ports.RemoteCatalogVersionMismatchError{171+ return catalogue.RemoteCatalog{}, &catalogue.RemoteCatalogVersionMismatchError{
171 Got: *version.SchemaVersion,172 Got: *version.SchemaVersion,
172- Want: ports.RemoteCatalogSchemaVersion,173+ Want: catalogue.RemoteCatalogSchemaVersion,
173 Kind: "catalog",174 Kind: "catalog",
174 }175 }
175 }176 }
176 177
177 var envelope catalogEnvelope178 var envelope catalogEnvelope
178 if err := decodeCatalogEnvelope(raw, true, &envelope); err != nil {179 if err := decodeCatalogEnvelope(raw, true, &envelope); err != nil {
179- return ports.RemoteCatalog{}, err180+ return catalogue.RemoteCatalog{}, err
180 }181 }
181 if envelope.ProtocolVersion == nil || envelope.SchemaVersion == nil || envelope.Sessions == nil {182 if envelope.ProtocolVersion == nil || envelope.SchemaVersion == nil || envelope.Sessions == nil {
182- return ports.RemoteCatalog{}, errCatalogRequired183+ return catalogue.RemoteCatalog{}, errCatalogRequired
183 }184 }
184- sessions := make([]ports.RemoteCatalogSession, 0, len(*envelope.Sessions))185+ sessions := make([]catalogue.RemoteCatalogSession, 0, len(*envelope.Sessions))
185 for _, session := range *envelope.Sessions {186 for _, session := range *envelope.Sessions {
186 if session.LifecycleID == nil || session.Name == nil || session.State == nil || session.Ephemeral == nil || session.Tabs == nil || session.Attached == nil {187 if session.LifecycleID == nil || session.Name == nil || session.State == nil || session.Ephemeral == nil || session.Tabs == nil || session.Attached == nil {
187- return ports.RemoteCatalog{}, errCatalogRequired188+ return catalogue.RemoteCatalog{}, errCatalogRequired
188 }189 }
189- tabs := make([]ports.RemoteCatalogTab, 0, len(*session.Tabs))190+ tabs := make([]catalogue.RemoteCatalogTab, 0, len(*session.Tabs))
190 for _, tab := range *session.Tabs {191 for _, tab := range *session.Tabs {
191 if tab.ID == nil || tab.Index == nil || tab.Name == nil {192 if tab.ID == nil || tab.Index == nil || tab.Name == nil {
192- return ports.RemoteCatalog{}, errCatalogRequired193+ return catalogue.RemoteCatalog{}, errCatalogRequired
193 }194 }
194- tabs = append(tabs, ports.RemoteCatalogTab{195+ tabs = append(tabs, catalogue.RemoteCatalogTab{
195 ID: *tab.ID, Index: *tab.Index, Name: *tab.Name,196 ID: *tab.ID, Index: *tab.Index, Name: *tab.Name,
196 Detail: tab.Detail, Attention: tab.Attention,197 Detail: tab.Detail, Attention: tab.Attention,
197 })198 })
198 }199 }
199- sessions = append(sessions, ports.RemoteCatalogSession{200+ sessions = append(sessions, catalogue.RemoteCatalogSession{
200 LifecycleID: *session.LifecycleID, Name: *session.Name, State: *session.State,201 LifecycleID: *session.LifecycleID, Name: *session.Name, State: *session.State,
201 Ephemeral: *session.Ephemeral, Tabs: tabs, Attached: *session.Attached,202 Ephemeral: *session.Ephemeral, Tabs: tabs, Attached: *session.Attached,
202 LastUsedSeq: session.LastUsedSeq, ActiveTabID: session.ActiveTabID, Reason: session.Reason,203 LastUsedSeq: session.LastUsedSeq, ActiveTabID: session.ActiveTabID, Reason: session.Reason,
203 })204 })
204 }205 }
205- catalog := ports.RemoteCatalog{206+ catalog := catalogue.RemoteCatalog{
206 ProtocolVersion: *envelope.ProtocolVersion,207 ProtocolVersion: *envelope.ProtocolVersion,
207 SchemaVersion: *envelope.SchemaVersion,208 SchemaVersion: *envelope.SchemaVersion,
208 Sessions: sessions,209 Sessions: sessions,
209 }210 }
210- if err := ports.ValidateRemoteCatalog(catalog); err != nil {211+ if err := catalogue.ValidateRemoteCatalog(catalog); err != nil {
211- return ports.RemoteCatalog{}, err212+ return catalogue.RemoteCatalog{}, err
212 }213 }
213 return catalog, nil214 return catalog, nil
214 }215 }
M internal/adapters/remote/catalog_cache.go +37 -36
@@ -12,7 +12,8 @@ import (
12 "unicode/utf8"12 "unicode/utf8"
13 13
14 "github.com/bnema/vev/internal/domain"14 "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"
16 )17 )
17 18
18 const (19 const (
@@ -40,14 +41,14 @@ type catalogCacheHost struct {
40 }41 }
41 42
42 type catalogCacheSession struct {43 type catalogCacheSession struct {
43- LifecycleID *domain.SessionLifecycleID `json:"lifecycle_id"`44+ LifecycleID *domain.SessionLifecycleID `json:"lifecycle_id"`
44- Name *string `json:"name"`45+ Name *string `json:"name"`
45- State *ports.RemoteCatalogSessionState `json:"state"`46+ State *catalogue.RemoteCatalogSessionState `json:"state"`
46- Ephemeral *bool `json:"ephemeral"`47+ Ephemeral *bool `json:"ephemeral"`
47- LastUsedSeq *uint64 `json:"last_used_seq,omitempty"`48+ LastUsedSeq *uint64 `json:"last_used_seq,omitempty"`
48- Tabs *[]ports.RemoteCatalogTab `json:"tabs"`49+ Tabs *[]catalogue.RemoteCatalogTab `json:"tabs"`
49- ActiveTabID *string `json:"active_tab_id,omitempty"`50+ ActiveTabID *string `json:"active_tab_id,omitempty"`
50- Attached *bool `json:"attached"`51+ Attached *bool `json:"attached"`
51 }52 }
52 53
53 type catalogCacheFileV2 struct {54 type catalogCacheFileV2 struct {
@@ -62,15 +63,15 @@ type catalogCacheHostV2 struct {
62 }63 }
63 64
64 type catalogCacheSessionV2 struct {65 type catalogCacheSessionV2 struct {
65- LifecycleID *domain.SessionLifecycleID `json:"lifecycle_id,omitempty"`66+ LifecycleID *domain.SessionLifecycleID `json:"lifecycle_id,omitempty"`
66- Name *string `json:"name"`67+ Name *string `json:"name"`
67- State *ports.RemoteCatalogSessionState `json:"state"`68+ State *catalogue.RemoteCatalogSessionState `json:"state"`
68- Ephemeral *bool `json:"ephemeral"`69+ Ephemeral *bool `json:"ephemeral"`
69- LastUsedSeq *uint64 `json:"last_used_seq,omitempty"`70+ LastUsedSeq *uint64 `json:"last_used_seq,omitempty"`
70- Tabs *uint16 `json:"tabs,omitempty"`71+ Tabs *uint16 `json:"tabs,omitempty"`
71- TabList *[]ports.RemoteCatalogTab `json:"tab_list,omitempty"`72+ TabList *[]catalogue.RemoteCatalogTab `json:"tab_list,omitempty"`
72- ActiveTabID *string `json:"active_tab_id,omitempty"`73+ ActiveTabID *string `json:"active_tab_id,omitempty"`
73- Attached *bool `json:"attached"`74+ Attached *bool `json:"attached"`
74 }75 }
75 76
76 type fileCatalogCache struct {77 type fileCatalogCache struct {
@@ -78,19 +79,19 @@ type fileCatalogCache struct {
78 }79 }
79 80
80 // NewFileCatalogCache returns a durable remote catalog cache backed by path.81 // NewFileCatalogCache returns a durable remote catalog cache backed by path.
81-func NewFileCatalogCache(path string) ports.RemoteCatalogCache {82+func NewFileCatalogCache(path string) appports.RemoteCatalogCache {
82 return &fileCatalogCache{path: path}83 return &fileCatalogCache{path: path}
83 }84 }
84 85
85-var _ ports.RemoteCatalogCache = (*fileCatalogCache)(nil)86+var _ appports.RemoteCatalogCache = (*fileCatalogCache)(nil)
86 87
87 // Load reads and validates one complete cache snapshot. Exact v2 tab-list88 // Load reads and validates one complete cache snapshot. Exact v2 tab-list
88 // snapshots are upgraded in memory; ambiguous count-only v2 data is rejected.89 // snapshots are upgraded in memory; ambiguous count-only v2 data is rejected.
89 // A missing cache is an empty snapshot, and Load never changes cache data.90 // 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) {
91 raw, err := os.ReadFile(c.path)92 raw, err := os.ReadFile(c.path)
92 if errors.Is(err, os.ErrNotExist) {93 if errors.Is(err, os.ErrNotExist) {
93- return []ports.RemoteCatalogCacheEntry{}, nil94+ return []catalogue.RemoteCatalogCacheEntry{}, nil
94 }95 }
95 if err != nil {96 if err != nil {
96 return nil, err97 return nil, err
@@ -130,7 +131,7 @@ func (c *fileCatalogCache) Load() ([]ports.RemoteCatalogCacheEntry, error) {
130 return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing hosts")131 return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing hosts")
131 }132 }
132 133
133- entries := make([]ports.RemoteCatalogCacheEntry, 0, len(*file.Hosts))134+ entries := make([]catalogue.RemoteCatalogCacheEntry, 0, len(*file.Hosts))
134 for _, host := range *file.Hosts {135 for _, host := range *file.Hosts {
135 if host.Target == nil || host.FetchedAtUnixNano == nil || host.Sessions == nil {136 if host.Target == nil || host.FetchedAtUnixNano == nil || host.Sessions == nil {
136 return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing host fields")137 return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing host fields")
@@ -141,10 +142,10 @@ func (c *fileCatalogCache) Load() ([]ports.RemoteCatalogCacheEntry, error) {
141 if *host.FetchedAtUnixNano <= 0 {142 if *host.FetchedAtUnixNano <= 0 {
142 return nil, fmt.Errorf("remote catalog cache: malformed cache file: non-positive fetched time")143 return nil, fmt.Errorf("remote catalog cache: malformed cache file: non-positive fetched time")
143 }144 }
144- entry := ports.RemoteCatalogCacheEntry{145+ entry := catalogue.RemoteCatalogCacheEntry{
145 Host: *host.Target,146 Host: *host.Target,
146 FetchedAt: time.Unix(0, *host.FetchedAtUnixNano),147 FetchedAt: time.Unix(0, *host.FetchedAtUnixNano),
147- Sessions: make([]ports.RemoteCatalogSession, 0, len(*host.Sessions)),148+ Sessions: make([]catalogue.RemoteCatalogSession, 0, len(*host.Sessions)),
148 }149 }
149 for _, session := range *host.Sessions {150 for _, session := range *host.Sessions {
150 if session.LifecycleID == nil || session.Name == nil || session.State == nil || session.Ephemeral == nil || session.Attached == nil || session.Tabs == nil {151 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) {
153 if !utf8.ValidString(*session.Name) || !utf8.ValidString(string(*session.State)) {154 if !utf8.ValidString(*session.Name) || !utf8.ValidString(string(*session.State)) {
154 return nil, fmt.Errorf("remote catalog cache: malformed cache file: invalid UTF-8")155 return nil, fmt.Errorf("remote catalog cache: malformed cache file: invalid UTF-8")
155 }156 }
156- tabs := make([]ports.RemoteCatalogTab, len(*session.Tabs))157+ tabs := make([]catalogue.RemoteCatalogTab, len(*session.Tabs))
157 copy(tabs, *session.Tabs)158 copy(tabs, *session.Tabs)
158- decoded := ports.RemoteCatalogSession{159+ decoded := catalogue.RemoteCatalogSession{
159 LifecycleID: *session.LifecycleID,160 LifecycleID: *session.LifecycleID,
160 Name: *session.Name,161 Name: *session.Name,
161 State: *session.State,162 State: *session.State,
@@ -228,7 +229,7 @@ func migrateExactCatalogCacheV2(legacy catalogCacheFileV2) (catalogCacheFile, er
228 }229 }
229 230
230 // Store atomically replaces the cache with a complete normalized snapshot.231 // 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 {
232 normalized, err := normalizeCatalogCacheEntries(entries)233 normalized, err := normalizeCatalogCacheEntries(entries)
233 if err != nil {234 if err != nil {
234 return err235 return err
@@ -244,7 +245,7 @@ func (c *fileCatalogCache) Store(entries []ports.RemoteCatalogCacheEntry) error
244 ephemeral := session.Ephemeral245 ephemeral := session.Ephemeral
245 attached := session.Attached246 attached := session.Attached
246 id := session.LifecycleID247 id := session.LifecycleID
247- tabs := make([]ports.RemoteCatalogTab, len(session.Tabs))248+ tabs := make([]catalogue.RemoteCatalogTab, len(session.Tabs))
248 copy(tabs, session.Tabs)249 copy(tabs, session.Tabs)
249 row := catalogCacheSession{LifecycleID: &id, Name: &name, State: &state, Ephemeral: &ephemeral, Tabs: &tabs, Attached: &attached}250 row := catalogCacheSession{LifecycleID: &id, Name: &name, State: &state, Ephemeral: &ephemeral, Tabs: &tabs, Attached: &attached}
250 if session.LastUsedSeq != 0 {251 if session.LastUsedSeq != 0 {
@@ -271,11 +272,11 @@ func (c *fileCatalogCache) Store(entries []ports.RemoteCatalogCacheEntry) error
271 return atomicReplacePrivateFile(c.path, append(payload, '\n'))272 return atomicReplacePrivateFile(c.path, append(payload, '\n'))
272 }273 }
273 274
274-func normalizeCatalogCacheEntries(entries []ports.RemoteCatalogCacheEntry) ([]ports.RemoteCatalogCacheEntry, error) {275+func normalizeCatalogCacheEntries(entries []catalogue.RemoteCatalogCacheEntry) ([]catalogue.RemoteCatalogCacheEntry, error) {
275- if err := ports.ValidateRemoteCatalogCacheEntries(entries); err != nil {276+ if err := catalogue.ValidateRemoteCatalogCacheEntries(entries); err != nil {
276 return nil, err277 return nil, err
277 }278 }
278- normalized := make([]ports.RemoteCatalogCacheEntry, 0, len(entries))279+ normalized := make([]catalogue.RemoteCatalogCacheEntry, 0, len(entries))
279 hosts := make(map[string]struct{}, len(entries))280 hosts := make(map[string]struct{}, len(entries))
280 for _, entry := range entries {281 for _, entry := range entries {
281 if err := validateHostTarget(entry.Host); err != nil {282 if err := validateHostTarget(entry.Host); err != nil {
@@ -289,10 +290,10 @@ func normalizeCatalogCacheEntries(entries []ports.RemoteCatalogCacheEntry) ([]po
289 }290 }
290 hosts[entry.Host] = struct{}{}291 hosts[entry.Host] = struct{}{}
291 292
292- copyEntry := ports.RemoteCatalogCacheEntry{293+ copyEntry := catalogue.RemoteCatalogCacheEntry{
293 Host: entry.Host,294 Host: entry.Host,
294 FetchedAt: entry.FetchedAt,295 FetchedAt: entry.FetchedAt,
295- Sessions: make([]ports.RemoteCatalogSession, 0, len(entry.Sessions)),296+ Sessions: make([]catalogue.RemoteCatalogSession, 0, len(entry.Sessions)),
296 }297 }
297 sessions := make(map[string]struct{}, len(entry.Sessions))298 sessions := make(map[string]struct{}, len(entry.Sessions))
298 for _, session := range entry.Sessions {299 for _, session := range entry.Sessions {
@@ -310,9 +311,9 @@ func normalizeCatalogCacheEntries(entries []ports.RemoteCatalogCacheEntry) ([]po
310 }311 }
311 sessions[session.Name] = struct{}{}312 sessions[session.Name] = struct{}{}
312 copySession := session313 copySession := session
313- copySession.Tabs = make([]ports.RemoteCatalogTab, len(session.Tabs))314+ copySession.Tabs = make([]catalogue.RemoteCatalogTab, len(session.Tabs))
314 for i, tab := range session.Tabs {315 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}
316 }317 }
317 copyEntry.Sessions = append(copyEntry.Sessions, copySession)318 copyEntry.Sessions = append(copyEntry.Sessions, copySession)
318 }319 }
M internal/adapters/remote/catalog_cache_test.go +25 -25
@@ -9,7 +9,7 @@ import (
9 "testing"9 "testing"
10 "time"10 "time"
11 11
12- "github.com/bnema/vev/internal/ports"12+ "github.com/bnema/vev/internal/protocol/catalogue"
13 "github.com/bnema/vev/pkg/safedir"13 "github.com/bnema/vev/pkg/safedir"
14 )14 )
15 15
@@ -27,22 +27,22 @@ func TestCatalogCacheStoreLoad(t *testing.T) {
27 path := filepath.Join(t.TempDir(), "vev", "remote-catalog-cache.json")27 path := filepath.Join(t.TempDir(), "vev", "remote-catalog-cache.json")
28 cache := NewFileCatalogCache(path)28 cache := NewFileCatalogCache(path)
29 fetchedAt := time.Unix(0, 1780000000000000000)29 fetchedAt := time.Unix(0, 1780000000000000000)
30- entries := []ports.RemoteCatalogCacheEntry{30+ entries := []catalogue.RemoteCatalogCacheEntry{
31 {31 {
32 Host: "zebra",32 Host: "zebra",
33 FetchedAt: fetchedAt,33 FetchedAt: fetchedAt,
34- Sessions: []ports.RemoteCatalogSession{34+ Sessions: []catalogue.RemoteCatalogSession{
35 {35 {
36 LifecycleID: [16]byte{1}, Name: "work", State: "up", LastUsedSeq: 42, ActiveTabID: "work-2",36 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}},
38 },38 },
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},
40 },40 },
41 },41 },
42 {42 {
43 Host: "arch",43 Host: "arch",
44 FetchedAt: fetchedAt.Add(time.Second),44 FetchedAt: fetchedAt.Add(time.Second),
45- Sessions: []ports.RemoteCatalogSession{},45+ Sessions: []catalogue.RemoteCatalogSession{},
46 },46 },
47 }47 }
48 48
@@ -73,18 +73,18 @@ func TestCatalogCacheStoreLoad(t *testing.T) {
73 if err != nil {73 if err != nil {
74 t.Fatalf("Load() error = %v", err)74 t.Fatalf("Load() error = %v", err)
75 }75 }
76- want := []ports.RemoteCatalogCacheEntry{76+ want := []catalogue.RemoteCatalogCacheEntry{
77 {77 {
78 Host: "arch",78 Host: "arch",
79 FetchedAt: fetchedAt.Add(time.Second),79 FetchedAt: fetchedAt.Add(time.Second),
80- Sessions: []ports.RemoteCatalogSession{},80+ Sessions: []catalogue.RemoteCatalogSession{},
81 },81 },
82 {82 {
83 Host: "zebra",83 Host: "zebra",
84 FetchedAt: fetchedAt,84 FetchedAt: fetchedAt,
85- Sessions: []ports.RemoteCatalogSession{85+ Sessions: []catalogue.RemoteCatalogSession{
86- {LifecycleID: [16]byte{2}, Name: "alpha", State: "down", Ephemeral: true, Tabs: []ports.RemoteCatalogTab{{ID: "alpha-1"}}, Attached: true},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: []ports.RemoteCatalogTab{{ID: "work-1"}, {ID: "work-2", Index: 1}}},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}}},
88 },88 },
89 },89 },
90 }90 }
@@ -133,14 +133,14 @@ func TestCatalogCacheLoadMigratesExactV2TabList(t *testing.T) {
133 if err != nil {133 if err != nil {
134 t.Fatalf("Load() error = %v", err)134 t.Fatalf("Load() error = %v", err)
135 }135 }
136- want := []ports.RemoteCatalogCacheEntry{{136+ want := []catalogue.RemoteCatalogCacheEntry{{
137 Host: "user@arch",137 Host: "user@arch",
138 FetchedAt: time.Unix(0, 1780000000000000000),138 FetchedAt: time.Unix(0, 1780000000000000000),
139- Sessions: []ports.RemoteCatalogSession{139+ Sessions: []catalogue.RemoteCatalogSession{
140- {LifecycleID: [16]byte{2}, Name: "dumber", State: ports.RemoteCatalogSessionDown, Tabs: []ports.RemoteCatalogTab{}},140+ {LifecycleID: [16]byte{2}, Name: "dumber", State: catalogue.RemoteCatalogSessionDown, Tabs: []catalogue.RemoteCatalogTab{}},
141 {141 {
142- LifecycleID: [16]byte{1}, Name: "work", State: ports.RemoteCatalogSessionUp,142+ LifecycleID: [16]byte{1}, Name: "work", State: catalogue.RemoteCatalogSessionUp,
143- LastUsedSeq: 42, Tabs: []ports.RemoteCatalogTab{{ID: "t_work", Name: "shell"}}, ActiveTabID: "t_work",143+ LastUsedSeq: 42, Tabs: []catalogue.RemoteCatalogTab{{ID: "t_work", Name: "shell"}}, ActiveTabID: "t_work",
144 },144 },
145 },145 },
146 }}146 }}
@@ -208,10 +208,10 @@ func TestCatalogCacheStoreFailurePreservesExistingFile(t *testing.T) {
208 208
209 path := filepath.Join(t.TempDir(), "vev", "remote-catalog-cache.json")209 path := filepath.Join(t.TempDir(), "vev", "remote-catalog-cache.json")
210 cache := NewFileCatalogCache(path)210 cache := NewFileCatalogCache(path)
211- valid := []ports.RemoteCatalogCacheEntry{{211+ valid := []catalogue.RemoteCatalogCacheEntry{{
212 Host: "arch",212 Host: "arch",
213 FetchedAt: time.Unix(0, 1),213 FetchedAt: time.Unix(0, 1),
214- Sessions: []ports.RemoteCatalogSession{},214+ Sessions: []catalogue.RemoteCatalogSession{},
215 }}215 }}
216 if err := cache.Store(valid); err != nil {216 if err := cache.Store(valid); err != nil {
217 t.Fatalf("initial Store() error = %v", err)217 t.Fatalf("initial Store() error = %v", err)
@@ -221,10 +221,10 @@ func TestCatalogCacheStoreFailurePreservesExistingFile(t *testing.T) {
221 t.Fatalf("read initial cache: %v", err)221 t.Fatalf("read initial cache: %v", err)
222 }222 }
223 223
224- invalid := []ports.RemoteCatalogCacheEntry{{224+ invalid := []catalogue.RemoteCatalogCacheEntry{{
225 Host: "arch",225 Host: "arch",
226 FetchedAt: time.Time{},226 FetchedAt: time.Time{},
227- Sessions: []ports.RemoteCatalogSession{},227+ Sessions: []catalogue.RemoteCatalogSession{},
228 }}228 }}
229 if err := cache.Store(invalid); err == nil {229 if err := cache.Store(invalid); err == nil {
230 t.Fatal("Store() error = nil, want validation error")230 t.Fatal("Store() error = nil, want validation error")
@@ -251,10 +251,10 @@ func TestCatalogCacheStoreRejectsNonPositiveFetchedTimes(t *testing.T) {
251 t.Run(tc.name, func(t *testing.T) {251 t.Run(tc.name, func(t *testing.T) {
252 t.Parallel()252 t.Parallel()
253 path := filepath.Join(t.TempDir(), "remote-catalog-cache.json")253 path := filepath.Join(t.TempDir(), "remote-catalog-cache.json")
254- entries := []ports.RemoteCatalogCacheEntry{{254+ entries := []catalogue.RemoteCatalogCacheEntry{{
255 Host: "arch",255 Host: "arch",
256 FetchedAt: tc.fetchedAt,256 FetchedAt: tc.fetchedAt,
257- Sessions: []ports.RemoteCatalogSession{},257+ Sessions: []catalogue.RemoteCatalogSession{},
258 }}258 }}
259 259
260 err := NewFileCatalogCache(path).Store(entries)260 err := NewFileCatalogCache(path).Store(entries)
@@ -281,7 +281,7 @@ func TestCatalogCacheStoreHardensExistingFilePermissions(t *testing.T) {
281 }281 }
282 282
283 cache := NewFileCatalogCache(path)283 cache := NewFileCatalogCache(path)
284- if err := cache.Store([]ports.RemoteCatalogCacheEntry{}); err != nil {284+ if err := cache.Store([]catalogue.RemoteCatalogCacheEntry{}); err != nil {
285 t.Fatalf("Store() error = %v", err)285 t.Fatalf("Store() error = %v", err)
286 }286 }
287 info, err := os.Stat(path)287 info, err := os.Stat(path)
@@ -306,7 +306,7 @@ func TestCatalogCacheStoreRenameFailureCleansTemporaryFile(t *testing.T) {
306 }306 }
307 307
308 cache := NewFileCatalogCache(path)308 cache := NewFileCatalogCache(path)
309- if err := cache.Store([]ports.RemoteCatalogCacheEntry{}); err == nil {309+ if err := cache.Store([]catalogue.RemoteCatalogCacheEntry{}); err == nil {
310 t.Fatal("Store() error = nil, want rename error")310 t.Fatal("Store() error = nil, want rename error")
311 }311 }
312 entries, err := os.ReadDir(dir)312 entries, err := os.ReadDir(dir)
@@ -320,6 +320,6 @@ func TestCatalogCacheStoreRenameFailureCleansTemporaryFile(t *testing.T) {
320 }320 }
321 }321 }
322 322
323-func equalRemoteCatalogCacheEntry(a, b ports.RemoteCatalogCacheEntry) bool {323+func equalRemoteCatalogCacheEntry(a, b catalogue.RemoteCatalogCacheEntry) bool {
324 return a.Host == b.Host && a.FetchedAt.Equal(b.FetchedAt) && reflect.DeepEqual(a.Sessions, b.Sessions)324 return a.Host == b.Host && a.FetchedAt.Equal(b.FetchedAt) && reflect.DeepEqual(a.Sessions, b.Sessions)
325 }325 }
M internal/adapters/remote/catalog_test.go +31 -31
@@ -12,8 +12,8 @@ import (
12 "time"12 "time"
13 13
14 "github.com/bnema/vev/internal/adapters/sshstdio"14 "github.com/bnema/vev/internal/adapters/sshstdio"
15- "github.com/bnema/vev/internal/ports"
16 "github.com/bnema/vev/internal/protocol"15 "github.com/bnema/vev/internal/protocol"
16+ "github.com/bnema/vev/internal/protocol/catalogue"
17 )17 )
18 18
19 func TestRemoteCatalogClientCommandConstruction(t *testing.T) {19 func TestRemoteCatalogClientCommandConstruction(t *testing.T) {
@@ -37,10 +37,10 @@ func TestRemoteCatalogClientCommandConstruction(t *testing.T) {
37 command: func(ctx context.Context, name string, args ...string) *exec.Cmd {37 command: func(ctx context.Context, name string, args ...string) *exec.Cmd {
38 gotPath = name38 gotPath = name
39 gotArgs = append([]string(nil), args...)39 gotArgs = append([]string(nil), args...)
40- return stdoutCmd(ctx, mustCatalogJSON(t, ports.RemoteCatalog{40+ return stdoutCmd(ctx, mustCatalogJSON(t, catalogue.RemoteCatalog{
41 ProtocolVersion: protocol.Version,41 ProtocolVersion: protocol.Version,
42- SchemaVersion: ports.RemoteCatalogSchemaVersion,42+ SchemaVersion: catalogue.RemoteCatalogSchemaVersion,
43- Sessions: []ports.RemoteCatalogSession{},43+ Sessions: []catalogue.RemoteCatalogSession{},
44 }))44 }))
45 },45 },
46 }46 }
@@ -83,11 +83,11 @@ func TestRemoteCatalogClientRejectsUnsafeTarget(t *testing.T) {
83 func TestRemoteCatalogClientDecode(t *testing.T) {83 func TestRemoteCatalogClientDecode(t *testing.T) {
84 t.Parallel()84 t.Parallel()
85 85
86- valid := ports.RemoteCatalog{86+ valid := catalogue.RemoteCatalog{
87 ProtocolVersion: protocol.Version,87 ProtocolVersion: protocol.Version,
88- SchemaVersion: ports.RemoteCatalogSchemaVersion,88+ SchemaVersion: catalogue.RemoteCatalogSchemaVersion,
89- Sessions: []ports.RemoteCatalogSession{89+ Sessions: []catalogue.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},90+ {LifecycleID: [16]byte{1}, Name: "work", State: "up", Ephemeral: false, Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1"}, {ID: "tab-2", Index: 1}}, Attached: true},
91 },91 },
92 }92 }
93 validJSON := mustCatalogJSON(t, valid)93 validJSON := mustCatalogJSON(t, valid)
@@ -97,7 +97,7 @@ func TestRemoteCatalogClientDecode(t *testing.T) {
97 stdout string97 stdout string
98 stderr string98 stderr string
99 exit int99 exit int
100- want ports.RemoteCatalog100+ want catalogue.RemoteCatalog
101 wantErr error101 wantErr error
102 check func(t *testing.T, err error)102 check func(t *testing.T, err error)
103 }{103 }{
@@ -113,10 +113,10 @@ func TestRemoteCatalogClientDecode(t *testing.T) {
113 },113 },
114 {114 {
115 name: "mismatching protocol version",115 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{}}),
117 check: func(t *testing.T, err error) {117 check: func(t *testing.T, err error) {
118 t.Helper()118 t.Helper()
119- var mismatch *ports.RemoteCatalogVersionMismatchError119+ var mismatch *catalogue.RemoteCatalogVersionMismatchError
120 if !errors.As(err, &mismatch) {120 if !errors.As(err, &mismatch) {
121 t.Fatalf("error = %v, want RemoteCatalogVersionMismatchError", err)121 t.Fatalf("error = %v, want RemoteCatalogVersionMismatchError", err)
122 }122 }
@@ -131,8 +131,8 @@ func TestRemoteCatalogClientDecode(t *testing.T) {
131 protocol.Version),131 protocol.Version),
132 check: func(t *testing.T, err error) {132 check: func(t *testing.T, err error) {
133 t.Helper()133 t.Helper()
134- var mismatch *ports.RemoteCatalogVersionMismatchError134+ var mismatch *catalogue.RemoteCatalogVersionMismatchError
135- if !errors.As(err, &mismatch) || mismatch.Kind != "catalog" || mismatch.Got != 2 || mismatch.Want != ports.RemoteCatalogSchemaVersion {135+ if !errors.As(err, &mismatch) || mismatch.Kind != "catalog" || mismatch.Got != 2 || mismatch.Want != catalogue.RemoteCatalogSchemaVersion {
136 t.Fatalf("error = %v, want obsolete catalogue version mismatch", err)136 t.Fatalf("error = %v, want obsolete catalogue version mismatch", err)
137 }137 }
138 },138 },
@@ -142,8 +142,8 @@ func TestRemoteCatalogClientDecode(t *testing.T) {
142 stdout: fmt.Sprintf(`{"protocol_version":%d,"schema_version":4,"sessions":[]}`, protocol.Version),142 stdout: fmt.Sprintf(`{"protocol_version":%d,"schema_version":4,"sessions":[]}`, protocol.Version),
143 check: func(t *testing.T, err error) {143 check: func(t *testing.T, err error) {
144 t.Helper()144 t.Helper()
145- var mismatch *ports.RemoteCatalogVersionMismatchError145+ var mismatch *catalogue.RemoteCatalogVersionMismatchError
146- if !errors.As(err, &mismatch) || mismatch.Kind != "catalog" || mismatch.Got != 4 || mismatch.Want != ports.RemoteCatalogSchemaVersion {146+ if !errors.As(err, &mismatch) || mismatch.Kind != "catalog" || mismatch.Got != 4 || mismatch.Want != catalogue.RemoteCatalogSchemaVersion {
147 t.Fatalf("error = %v, want unknown catalogue version mismatch", err)147 t.Fatalf("error = %v, want unknown catalogue version mismatch", err)
148 }148 }
149 },149 },
@@ -161,19 +161,19 @@ func TestRemoteCatalogClientDecode(t *testing.T) {
161 {161 {
162 name: "count-only tabs rejected",162 name: "count-only tabs rejected",
163 stdout: fmt.Sprintf(`{"protocol_version":%d,"schema_version":%d,"sessions":[{"lifecycle_id":"01000000000000000000000000000000","name":"work","state":"up","ephemeral":false,"tabs":1,"attached":false}]}`,163 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),
165 wantErr: errCatalogDecode,165 wantErr: errCatalogDecode,
166 },166 },
167 {167 {
168 name: "unknown fields rejected",168 name: "unknown fields rejected",
169 stdout: fmt.Sprintf(`{"protocol_version":%d,"schema_version":%d,"sessions":[],"future":true}`,169 stdout: fmt.Sprintf(`{"protocol_version":%d,"schema_version":%d,"sessions":[],"future":true}`,
170- protocol.Version, ports.RemoteCatalogSchemaVersion),170+ protocol.Version, catalogue.RemoteCatalogSchemaVersion),
171 wantErr: errCatalogDecode,171 wantErr: errCatalogDecode,
172 },172 },
173 {173 {
174 name: "missing required session field",174 name: "missing required session field",
175 stdout: fmt.Sprintf(`{"protocol_version":%d,"schema_version":%d,"sessions":[{"lifecycle_id":"01000000000000000000000000000000","name":"work","state":"up","tabs":[],"attached":false}]}`,175 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),
177 wantErr: errCatalogRequired,177 wantErr: errCatalogRequired,
178 },178 },
179 {179 {
@@ -191,30 +191,30 @@ func TestRemoteCatalogClientDecode(t *testing.T) {
191 stdout: fmt.Sprintf(`{"protocol_version":%d,"sessions":[]}`+"\n", protocol.Version),191 stdout: fmt.Sprintf(`{"protocol_version":%d,"sessions":[]}`+"\n", protocol.Version),
192 check: func(t *testing.T, err error) {192 check: func(t *testing.T, err error) {
193 t.Helper()193 t.Helper()
194- var mismatch *ports.RemoteCatalogVersionMismatchError194+ var mismatch *catalogue.RemoteCatalogVersionMismatchError
195- if !errors.As(err, &mismatch) || mismatch.Kind != "catalog" || mismatch.Got != 0 || mismatch.Want != ports.RemoteCatalogSchemaVersion {195+ if !errors.As(err, &mismatch) || mismatch.Kind != "catalog" || mismatch.Got != 0 || mismatch.Want != catalogue.RemoteCatalogSchemaVersion {
196 t.Fatalf("error = %v, want missing catalogue version mismatch", err)196 t.Fatalf("error = %v, want missing catalogue version mismatch", err)
197 }197 }
198 },198 },
199 },199 },
200 {200 {
201 name: "missing sessions",201 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),
203 wantErr: errCatalogRequired,203 wantErr: errCatalogRequired,
204 },204 },
205 {205 {
206 name: "invalid session name",206 name: "invalid session name",
207- stdout: mustCatalogJSON(t, ports.RemoteCatalog{ProtocolVersion: protocol.Version, SchemaVersion: ports.RemoteCatalogSchemaVersion, Sessions: []ports.RemoteCatalogSession{{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: []ports.RemoteCatalogTab{},208+ LifecycleID: [16]byte{1}, Name: "bad name", State: "up", Tabs: []catalogue.RemoteCatalogTab{},
209 }}}),209 }}}),
210- wantErr: ports.ErrInvalidRemoteCatalog,210+ wantErr: catalogue.ErrInvalidRemoteCatalog,
211 },211 },
212 {212 {
213 name: "invalid session state",213 name: "invalid session state",
214- stdout: mustCatalogJSON(t, ports.RemoteCatalog{ProtocolVersion: protocol.Version, SchemaVersion: ports.RemoteCatalogSchemaVersion, Sessions: []ports.RemoteCatalogSession{{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: []ports.RemoteCatalogTab{},215+ LifecycleID: [16]byte{1}, Name: "work", State: "weird", Tabs: []catalogue.RemoteCatalogTab{},
216 }}}),216 }}}),
217- wantErr: ports.ErrRemoteCatalogUnknownState,217+ wantErr: catalogue.ErrRemoteCatalogUnknownState,
218 },218 },
219 {219 {
220 name: "remote exit with stderr",220 name: "remote exit with stderr",
@@ -292,10 +292,10 @@ func TestRemoteCatalogClientAppliesLifetimeAndWaitDelay(t *testing.T) {
292 client := &CatalogClient{292 client := &CatalogClient{
293 command: func(ctx context.Context, name string, args ...string) *exec.Cmd {293 command: func(ctx context.Context, name string, args ...string) *exec.Cmd {
294 commandCtx = ctx294 commandCtx = ctx
295- cmd = stdoutCmd(ctx, mustCatalogJSON(t, ports.RemoteCatalog{295+ cmd = stdoutCmd(ctx, mustCatalogJSON(t, catalogue.RemoteCatalog{
296 ProtocolVersion: protocol.Version,296 ProtocolVersion: protocol.Version,
297- SchemaVersion: ports.RemoteCatalogSchemaVersion,297+ SchemaVersion: catalogue.RemoteCatalogSchemaVersion,
298- Sessions: []ports.RemoteCatalogSession{},298+ Sessions: []catalogue.RemoteCatalogSession{},
299 }))299 }))
300 return cmd300 return cmd
301 },301 },
@@ -549,7 +549,7 @@ func stdoutCmd(ctx context.Context, stdout string) *exec.Cmd {
549 return cmd549 return cmd
550 }550 }
551 551
552-func mustCatalogJSON(t *testing.T, catalog ports.RemoteCatalog) string {552+func mustCatalogJSON(t *testing.T, catalog catalogue.RemoteCatalog) string {
553 t.Helper()553 t.Helper()
554 raw, err := json.Marshal(catalog)554 raw, err := json.Marshal(catalog)
555 if err != nil {555 if err != nil {
M internal/adapters/remote/remote_factory.go +14 -9
@@ -8,33 +8,38 @@ import (
8 "github.com/bnema/vev/internal/adapters/dgram"8 "github.com/bnema/vev/internal/adapters/dgram"
9 "github.com/bnema/vev/internal/adapters/sshstdio"9 "github.com/bnema/vev/internal/adapters/sshstdio"
10 "github.com/bnema/vev/internal/ports"10 "github.com/bnema/vev/internal/ports"
11+ "github.com/bnema/vev/internal/protocol/wire"
11 )12 )
12 13
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.
14 type DialerFactory struct {23 type DialerFactory struct {
15 observer ports.SerializedRuntimeObserver24 observer ports.SerializedRuntimeObserver
16 }25 }
17 26
18-var _ ports.RemoteDialerFactory = DialerFactory{}
19-
20 func NewDialerFactory() DialerFactory { return DialerFactory{} }27 func NewDialerFactory() DialerFactory { return DialerFactory{} }
21 28
22-// NewDialerFactoryWithRuntimeObserver wires the one process-local sink into
23-// whichever concrete remote carriage adapter is selected.
24 func NewDialerFactoryWithRuntimeObserver(observer ports.SerializedRuntimeObserver) DialerFactory {29 func NewDialerFactoryWithRuntimeObserver(observer ports.SerializedRuntimeObserver) DialerFactory {
25 return DialerFactory{observer: observer}30 return DialerFactory{observer: observer}
26 }31 }
27 32
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) {
29 switch mode {34 switch mode {
30- case ports.RemoteTransportUDP:35+ case TransportUDP:
31 if log != nil {36 if log != nil {
32 log.Info("remote transport selected", "mode", mode, "target", target, "session", session)37 log.Info("remote transport selected", "mode", mode, "target", target, "session", session)
33 }38 }
34 dialer := dgram.NewRemoteDialerWithLogger(target, "", log)39 dialer := dgram.NewRemoteDialerWithLogger(target, "", log)
35 dialer.RuntimeObserver = f.observer40 dialer.RuntimeObserver = f.observer
36 return dialer, nil41 return dialer, nil
37- case ports.RemoteTransportStdio:42+ case TransportStdio:
38 if log != nil {43 if log != nil {
39 log.Info("remote transport selected", "mode", mode, "target", target, "session", session)44 log.Info("remote transport selected", "mode", mode, "target", target, "session", session)
40 }45 }
@@ -50,6 +55,6 @@ type stdioDialer struct {
50 observer ports.SerializedRuntimeObserver55 observer ports.SerializedRuntimeObserver
51 }56 }
52 57
53-func (d stdioDialer) Dial(ctx context.Context) (ports.Transport, error) {58+func (d stdioDialer) Dial(ctx context.Context) (wire.Transport, error) {
54 return sshstdio.DialContextWithRuntimeObserver(ctx, d.target, "", d.log, sshstdio.WithRuntimeObserver(d.observer))59 return sshstdio.DialContextWithRuntimeObserver(ctx, d.target, "", d.log, sshstdio.WithRuntimeObserver(d.observer))
55 }60 }
M internal/adapters/remote/remote_factory_test.go +4 -5
@@ -6,7 +6,6 @@ import (
6 "testing"6 "testing"
7 7
8 "github.com/bnema/vev/internal/adapters/dgram"8 "github.com/bnema/vev/internal/adapters/dgram"
9- "github.com/bnema/vev/internal/ports"
10 )9 )
11 10
12 func TestDialerFactorySelectsExplicitModes(t *testing.T) {11 func TestDialerFactorySelectsExplicitModes(t *testing.T) {
@@ -14,11 +13,11 @@ func TestDialerFactorySelectsExplicitModes(t *testing.T) {
14 13
15 tests := []struct {14 tests := []struct {
16 name string15 name string
17- mode ports.RemoteTransportMode16+ mode TransportMode
18 wantType any17 wantType any
19 }{18 }{
20- {name: "udp", mode: ports.RemoteTransportUDP, wantType: dgram.RemoteDialer{}},19+ {name: "udp", mode: TransportUDP, wantType: dgram.RemoteDialer{}},
21- {name: "stdio", mode: ports.RemoteTransportStdio, wantType: stdioDialer{}},20+ {name: "stdio", mode: TransportStdio, wantType: stdioDialer{}},
22 }21 }
23 for _, tt := range tests {22 for _, tt := range tests {
24 t.Run(tt.name, func(t *testing.T) {23 t.Run(tt.name, func(t *testing.T) {
@@ -51,7 +50,7 @@ func TestDialerFactorySelectsExplicitModes(t *testing.T) {
51 func TestDialerFactoryRejectsUnsupportedMode(t *testing.T) {50 func TestDialerFactoryRejectsUnsupportedMode(t *testing.T) {
52 factory := NewDialerFactory()51 factory := NewDialerFactory()
53 52
54- dialer, err := factory.DialerForRemote("remote.example", "work", ports.RemoteTransportMode("serial"), nil)53+ dialer, err := factory.DialerForRemote("remote.example", "work", TransportMode("serial"), nil)
55 if err == nil {54 if err == nil {
56 t.Fatal("DialerForRemote() error = nil, want unsupported mode error")55 t.Fatal("DialerForRemote() error = nil, want unsupported mode error")
57 }56 }
M internal/adapters/sessionwire/client.go +4 -4
@@ -9,12 +9,12 @@ import (
9 "github.com/bnema/vev/internal/protocol/wire"9 "github.com/bnema/vev/internal/protocol/wire"
10 )10 )
11 11
12-type clientConnection struct{ raw ports.Transport }12+type clientConnection struct{ raw wire.Transport }
13 13
14 var _ ports.ClientConnection = (*clientConnection)(nil)14 var _ ports.ClientConnection = (*clientConnection)(nil)
15 15
16 // NewClientConnection wraps one raw client connection incarnation.16 // NewClientConnection wraps one raw client connection incarnation.
17-func NewClientConnection(raw ports.Transport) ports.ClientConnection {17+func NewClientConnection(raw wire.Transport) ports.ClientConnection {
18 if raw == nil {18 if raw == nil {
19 return nil19 return nil
20 }20 }
@@ -55,12 +55,12 @@ func (c *clientConnection) LinkState() ports.LinkState { return rawLinkS
55 func (c *clientConnection) LinkEvents() <-chan ports.LinkEvent { return rawLinkEvents(c.raw) }55 func (c *clientConnection) LinkEvents() <-chan ports.LinkEvent { return rawLinkEvents(c.raw) }
56 func (c *clientConnection) Close() error { return c.raw.Close() }56 func (c *clientConnection) Close() error { return c.raw.Close() }
57 57
58-type clientDialer struct{ raw ports.Dialer }58+type clientDialer struct{ raw wire.Dialer }
59 59
60 var _ ports.ClientDialer = (*clientDialer)(nil)60 var _ ports.ClientDialer = (*clientDialer)(nil)
61 61
62 // NewClientDialer wraps every dialed raw connection in a stable typed adapter.62 // 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 {
64 if raw == nil {64 if raw == nil {
65 return nil65 return nil
66 }66 }
M internal/adapters/sessionwire/client_test.go +2 -2
@@ -131,10 +131,10 @@ func TestClientDialerWrapsEachConnectionAndPreservesErrors(t *testing.T) {
131 }131 }
132 132
133 type scriptedDialer struct {133 type scriptedDialer struct {
134- transport ports.Transport134+ transport wire.Transport
135 err error135 err error
136 }136 }
137 137
138-func (d *scriptedDialer) Dial(context.Context) (ports.Transport, error) {138+func (d *scriptedDialer) Dial(context.Context) (wire.Transport, error) {
139 return d.transport, d.err139 return d.transport, d.err
140 }140 }
M internal/adapters/sessionwire/server.go +14 -14
@@ -17,12 +17,12 @@ var (
17 ErrUnsupportedSend = errors.New("sessionwire: send mode is unsupported")17 ErrUnsupportedSend = errors.New("sessionwire: send mode is unsupported")
18 )18 )
19 19
20-type serverConnection struct{ raw ports.Transport }20+type serverConnection struct{ raw wire.Transport }
21 21
22 var _ ports.ServerConnection = (*serverConnection)(nil)22 var _ ports.ServerConnection = (*serverConnection)(nil)
23 23
24 // NewServerConnection wraps one raw connection incarnation exactly once.24 // NewServerConnection wraps one raw connection incarnation exactly once.
25-func NewServerConnection(raw ports.Transport) ports.ServerConnection {25+func NewServerConnection(raw wire.Transport) ports.ServerConnection {
26 if raw == nil {26 if raw == nil {
27 return nil27 return nil
28 }28 }
@@ -71,7 +71,7 @@ func (c *serverConnection) SendServer(message protocol.ServerMessage) error {
71 }71 }
72 72
73 func (c *serverConnection) SendServerAsync(message protocol.ServerMessage) error {73 func (c *serverConnection) SendServerAsync(message protocol.ServerMessage) error {
74- transport, ok := c.raw.(ports.AsyncTransport)74+ transport, ok := c.raw.(wire.AsyncTransport)
75 if !ok {75 if !ok {
76 return ErrUnsupportedSend76 return ErrUnsupportedSend
77 }77 }
@@ -83,7 +83,7 @@ func (c *serverConnection) SendServerAsync(message protocol.ServerMessage) error
83 }83 }
84 84
85 func (c *serverConnection) SendServerSynchronous(message protocol.ServerMessage) error {85 func (c *serverConnection) SendServerSynchronous(message protocol.ServerMessage) error {
86- transport, ok := c.raw.(ports.OwnedSynchronousTransport)86+ transport, ok := c.raw.(wire.OwnedSynchronousTransport)
87 if !ok {87 if !ok {
88 return ErrUnsupportedSend88 return ErrUnsupportedSend
89 }89 }
@@ -103,7 +103,7 @@ func (c *serverConnection) SendOutput(output protocol.Output) error {
103 }103 }
104 104
105 func (c *serverConnection) SendOutputAsync(output protocol.Output) error {105 func (c *serverConnection) SendOutputAsync(output protocol.Output) error {
106- transport, ok := c.raw.(ports.AsyncTransport)106+ transport, ok := c.raw.(wire.AsyncTransport)
107 if !ok {107 if !ok {
108 return ErrUnsupportedSend108 return ErrUnsupportedSend
109 }109 }
@@ -115,7 +115,7 @@ func (c *serverConnection) SendOutputAsync(output protocol.Output) error {
115 }115 }
116 116
117 func (c *serverConnection) SendOutputSynchronous(output protocol.Output) error {117 func (c *serverConnection) SendOutputSynchronous(output protocol.Output) error {
118- transport, ok := c.raw.(ports.OwnedSynchronousTransport)118+ transport, ok := c.raw.(wire.OwnedSynchronousTransport)
119 if !ok {119 if !ok {
120 return ErrUnsupportedSend120 return ErrUnsupportedSend
121 }121 }
@@ -138,10 +138,10 @@ func (c *serverConnection) Capabilities() protocol.ConnectionCapabilities {
138 func (c *serverConnection) LinkState() ports.LinkState { return rawLinkState(c.raw) }138 func (c *serverConnection) LinkState() ports.LinkState { return rawLinkState(c.raw) }
139 func (c *serverConnection) LinkEvents() <-chan ports.LinkEvent { return rawLinkEvents(c.raw) }139 func (c *serverConnection) LinkEvents() <-chan ports.LinkEvent { return rawLinkEvents(c.raw) }
140 140
141-func rawCapabilities(raw ports.Transport) protocol.ConnectionCapabilities {141+func rawCapabilities(raw wire.Transport) protocol.ConnectionCapabilities {
142- _, datagram := raw.(ports.DatagramTransport)142+ _, datagram := raw.(wire.DatagramTransport)
143- _, async := raw.(ports.AsyncTransport)143+ _, async := raw.(wire.AsyncTransport)
144- _, synchronous := raw.(ports.OwnedSynchronousTransport)144+ _, synchronous := raw.(wire.OwnedSynchronousTransport)
145 _, linkState := raw.(ports.LinkStateReporter)145 _, linkState := raw.(ports.LinkStateReporter)
146 window := uint8(protocol.MaxOutputWindow)146 window := uint8(protocol.MaxOutputWindow)
147 if datagram {147 if datagram {
@@ -156,14 +156,14 @@ func rawCapabilities(raw ports.Transport) protocol.ConnectionCapabilities {
156 }156 }
157 }157 }
158 158
159-func rawLinkState(raw ports.Transport) ports.LinkState {159+func rawLinkState(raw wire.Transport) ports.LinkState {
160 if reporter, ok := raw.(ports.LinkStateReporter); ok {160 if reporter, ok := raw.(ports.LinkStateReporter); ok {
161 return reporter.LinkState()161 return reporter.LinkState()
162 }162 }
163 return ports.LinkStateConnected163 return ports.LinkStateConnected
164 }164 }
165 165
166-func rawLinkEvents(raw ports.Transport) <-chan ports.LinkEvent {166+func rawLinkEvents(raw wire.Transport) <-chan ports.LinkEvent {
167 if reporter, ok := raw.(ports.LinkStateReporter); ok {167 if reporter, ok := raw.(ports.LinkStateReporter); ok {
168 return reporter.LinkEvents()168 return reporter.LinkEvents()
169 }169 }
@@ -172,12 +172,12 @@ func rawLinkEvents(raw ports.Transport) <-chan ports.LinkEvent {
172 172
173 func (c *serverConnection) Close() error { return c.raw.Close() }173 func (c *serverConnection) Close() error { return c.raw.Close() }
174 174
175-type serverListener struct{ raw ports.Listener }175+type serverListener struct{ raw wire.Listener }
176 176
177 var _ ports.ServerListener = (*serverListener)(nil)177 var _ ports.ServerListener = (*serverListener)(nil)
178 178
179 // NewServerListener wraps every accepted raw connection in a stable typed adapter.179 // 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 {
181 if raw == nil {181 if raw == nil {
182 return nil182 return nil
183 }183 }
M internal/adapters/sessionwire/server_test.go +4 -4
@@ -279,8 +279,8 @@ func TestServerListenerWrapsEachAcceptedIncarnation(t *testing.T) {
279 require.NoError(t, wrapped.Close())279 require.NoError(t, wrapped.Close())
280 }280 }
281 281
282-type scriptedListener struct{ transport ports.Transport }282+type scriptedListener struct{ transport wire.Transport }
283 283
284-func (l *scriptedListener) Accept() (ports.Transport, error) { return l.transport, nil }284+func (l *scriptedListener) Accept() (wire.Transport, error) { return l.transport, nil }
285-func (*scriptedListener) Close() error { return nil }285+func (*scriptedListener) Close() error { return nil }
286-func (*scriptedListener) Addr() string { return "test" }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 {
57 }57 }
58 58
59 // NewTransport wraps separate reader/writer streams as a framed Transport.59 // 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 {
61 t := newTransport(r, w, closeFn, nil)61 t := newTransport(r, w, closeFn, nil)
62 for _, opt := range opts {62 for _, opt := range opts {
63 if opt != nil {63 if opt != nil {
@@ -533,12 +533,12 @@ func shellQuote(s string) string {
533 // Dial starts ssh target vev _stdio and returns a Transport over the child533 // Dial starts ssh target vev _stdio and returns a Transport over the child
534 // process' stdio. Session selection is carried in Hello. The subprocess is534 // process' stdio. Session selection is carried in Hello. The subprocess is
535 // started with exec.Command argv, never through a shell.535 // 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) {
537 return DialContext(context.Background(), target, session)537 return DialContext(context.Background(), target, session)
538 }538 }
539 539
540 // DialContextWithRuntimeObserver is the option-bearing production constructor.540 // 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) {
542 return dialContext(ctx, target, session, logger, opts...)542 return dialContext(ctx, target, session, logger, opts...)
543 }543 }
544 544
@@ -547,7 +547,7 @@ func DialContextWithRuntimeObserver(ctx context.Context, target, session string,
547 // handshake context must not kill an already-established carriage. Callers may547 // handshake context must not kill an already-established carriage. Callers may
548 // pass a logger to record ssh start failures and non-clean exits without logging548 // pass a logger to record ssh start failures and non-clean exits without logging
549 // the generated command line.549 // 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) {
551 var log *slog.Logger551 var log *slog.Logger
552 if len(logger) > 0 {552 if len(logger) > 0 {
553 log = logger[0]553 log = logger[0]
@@ -555,7 +555,7 @@ func DialContext(ctx context.Context, target, session string, logger ...*slog.Lo
555 return dialContext(ctx, target, session, log)555 return dialContext(ctx, target, session, log)
556 }556 }
557 557
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) {
559 if err := ctx.Err(); err != nil {559 if err := ctx.Err(); err != nil {
560 return nil, err560 return nil, err
561 }561 }
M internal/adapters/sshstdio/transport_observability_test.go +4 -4
@@ -22,7 +22,7 @@ func TestTransportObservabilitySSHStdioEOFEndsReceive(t *testing.T) {
22 if err != nil {22 if err != nil {
23 t.Fatalf("NewJSONL() error = %v", err)23 t.Fatalf("NewJSONL() error = %v", err)
24 }24 }
25- reporter := ports.NewSerializedRuntimeObserver(observer, 64)25+ reporter := observability.NewSerialized(observer, 64)
26 defer reporter.Close()26 defer reporter.Close()
27 if _, err := NewTransport(bytes.NewReader(nil), &bytes.Buffer{}, nil, WithRuntimeObserver(reporter)).Recv(); err == nil {27 if _, err := NewTransport(bytes.NewReader(nil), &bytes.Buffer{}, nil, WithRuntimeObserver(reporter)).Recv(); err == nil {
28 t.Fatal("Recv() error = nil at EOF")28 t.Fatal("Recv() error = nil at EOF")
@@ -40,7 +40,7 @@ func TestTransportObservabilitySSHStdioCloseEndsBlockedReceive(t *testing.T) {
40 if err != nil {40 if err != nil {
41 t.Fatalf("NewJSONL() error = %v", err)41 t.Fatalf("NewJSONL() error = %v", err)
42 }42 }
43- reporter := ports.NewSerializedRuntimeObserver(observer, 64)43+ reporter := observability.NewSerialized(observer, 64)
44 defer reporter.Close()44 defer reporter.Close()
45 reader := &sshShutdownReader{entered: make(chan struct{}), done: make(chan struct{})}45 reader := &sshShutdownReader{entered: make(chan struct{}), done: make(chan struct{})}
46 shutdownErr := errors.New("ssh shutdown failed")46 shutdownErr := errors.New("ssh shutdown failed")
@@ -152,7 +152,7 @@ func TestTransportObservabilitySSHStdioCloseDoesNotWaitForUnownedReceive(t *test
152 if err != nil {152 if err != nil {
153 t.Fatalf("NewJSONL() error = %v", err)153 t.Fatalf("NewJSONL() error = %v", err)
154 }154 }
155- reporter := ports.NewSerializedRuntimeObserver(observer, 64)155+ reporter := observability.NewSerialized(observer, 64)
156 defer reporter.Close()156 defer reporter.Close()
157 reader := &sshUnownedBlockedReader{entered: make(chan struct{}), release: make(chan struct{})}157 reader := &sshUnownedBlockedReader{entered: make(chan struct{}), release: make(chan struct{})}
158 t.Cleanup(reader.unblock)158 t.Cleanup(reader.unblock)
@@ -210,7 +210,7 @@ func TestTransportObservabilitySSHStdioPreservesCarriage(t *testing.T) {
210 if err != nil {210 if err != nil {
211 t.Fatalf("NewJSONL() error = %v", err)211 t.Fatalf("NewJSONL() error = %v", err)
212 }212 }
213- reporter := ports.NewSerializedRuntimeObserver(observer, 64)213+ reporter := observability.NewSerialized(observer, 64)
214 defer reporter.Close()214 defer reporter.Close()
215 215
216 frame := wire.Frame{Type: wire.MsgOutput, Payload: []byte("stdio bytes stay exact")}216 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")
129 type cmdDeps struct {129 type cmdDeps struct {
130 stdout io.Writer130 stdout io.Writer
131 getenv func(string) string131 getenv func(string) string
132- dial func(context.Context, string) (ports.Transport, error)132+ dial func(context.Context, string) (wire.Transport, error)
133- ensure func(context.Context, string) (ports.Transport, error)133+ ensure func(context.Context, string) (wire.Transport, error)
134 clock ports.Clock134 clock ports.Clock
135 }135 }
136 136
@@ -139,7 +139,7 @@ func runCmd(ctx context.Context, invocation cmdInvocation) error {
139 stdout: os.Stdout,139 stdout: os.Stdout,
140 getenv: os.Getenv,140 getenv: os.Getenv,
141 dial: realDial,141 dial: realDial,
142- ensure: func(ctx context.Context, dir string) (ports.Transport, error) {142+ ensure: func(ctx context.Context, dir string) (wire.Transport, error) {
143 return ensureDaemonWithLifecycle(ctx, dir, realDial, realSpawn, defaultBackoff)143 return ensureDaemonWithLifecycle(ctx, dir, realDial, realSpawn, defaultBackoff)
144 },144 },
145 clock: clock.New(),145 clock: clock.New(),
M internal/app/cmd_test.go +14 -15
@@ -11,7 +11,6 @@ import (
11 "testing"11 "testing"
12 "time"12 "time"
13 13
14- "github.com/bnema/vev/internal/ports"
15 portsmocks "github.com/bnema/vev/internal/ports/mocks"14 portsmocks "github.com/bnema/vev/internal/ports/mocks"
16 "github.com/bnema/vev/internal/protocol"15 "github.com/bnema/vev/internal/protocol"
17 "github.com/bnema/vev/internal/protocol/wire"16 "github.com/bnema/vev/internal/protocol/wire"
@@ -91,7 +90,7 @@ func TestCmdHelpUsesRegistryWithoutDialing(t *testing.T) {
91 err := runCmdWithDeps(context.Background(), cmdInvocation{help: true}, cmdDeps{90 err := runCmdWithDeps(context.Background(), cmdInvocation{help: true}, cmdDeps{
92 stdout: out,91 stdout: out,
93 getenv: func(string) string { return "" },92 getenv: func(string) string { return "" },
94- dial: func(context.Context, string) (ports.Transport, error) {93+ dial: func(context.Context, string) (wire.Transport, error) {
95 called = true94 called = true
96 return nil, errors.New("must not dial")95 return nil, errors.New("must not dial")
97 },96 },
@@ -146,7 +145,7 @@ func TestMoveCmdPreservesPositionalArguments(t *testing.T) {
146 require.NoError(t, runCmdWithDeps(context.Background(), parsed.cmd, cmdDeps{145 require.NoError(t, runCmdWithDeps(context.Background(), parsed.cmd, cmdDeps{
147 stdout: io.Discard,146 stdout: io.Discard,
148 getenv: func(string) string { return "" },147 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 },
150 }))149 }))
151 require.Len(t, transport.sent, 1)150 require.Len(t, transport.sent, 1)
152 request, err := wire.UnmarshalCommandRequest(transport.sent[0].Payload)151 request, err := wire.UnmarshalCommandRequest(transport.sent[0].Payload)
@@ -164,11 +163,11 @@ func TestRemoteCatalogCommandEnsuresDaemon(t *testing.T) {
164 err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "remote-catalog", jsonOut: true}, cmdDeps{163 err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "remote-catalog", jsonOut: true}, cmdDeps{
165 stdout: io.Discard,164 stdout: io.Discard,
166 getenv: func(string) string { return "" },165 getenv: func(string) string { return "" },
167- dial: func(context.Context, string) (ports.Transport, error) {166+ dial: func(context.Context, string) (wire.Transport, error) {
168 t.Fatal("remote catalog used non-starting daemon dial")167 t.Fatal("remote catalog used non-starting daemon dial")
169 return nil, errors.New("unexpected dial")168 return nil, errors.New("unexpected dial")
170 },169 },
171- ensure: func(context.Context, string) (ports.Transport, error) {170+ ensure: func(context.Context, string) (wire.Transport, error) {
172 ensureCalls++171 ensureCalls++
173 return transport, nil172 return transport, nil
174 },173 },
@@ -191,7 +190,7 @@ func TestMoveCmdInvalidArgumentResultExitsTwo(t *testing.T) {
191 err := runCmdWithDeps(context.Background(), invocation, cmdDeps{190 err := runCmdWithDeps(context.Background(), invocation, cmdDeps{
192 stdout: io.Discard,191 stdout: io.Discard,
193 getenv: func(string) string { return "" },192 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 },
195 })194 })
196 if ExitCode(err) != 2 {195 if ExitCode(err) != 2 {
197 t.Fatalf("ExitCode(%v) = %d, want 2", err, ExitCode(err))196 t.Fatalf("ExitCode(%v) = %d, want 2", err, ExitCode(err))
@@ -212,7 +211,7 @@ func TestTargetPaneCmdsParseAndUseSelfTarget(t *testing.T) {
212 require.NoError(t, runCmdWithDeps(context.Background(), invocation.cmd, cmdDeps{211 require.NoError(t, runCmdWithDeps(context.Background(), invocation.cmd, cmdDeps{
213 stdout: io.Discard,212 stdout: io.Discard,
214 getenv: func(string) string { return "session=work,tab=t_abc,pane=p_def" },213 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 },
216 }))215 }))
217 require.Len(t, transport.sent, 1)216 require.Len(t, transport.sent, 1)
218 request, err := wire.UnmarshalCommandRequest(transport.sent[0].Payload)217 request, err := wire.UnmarshalCommandRequest(transport.sent[0].Payload)
@@ -232,7 +231,7 @@ func TestRunCmdBuildsOneShotTargetedRequest(t *testing.T) {
232 err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right", self: true}, cmdDeps{231 err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right", self: true}, cmdDeps{
233 stdout: out,232 stdout: out,
234 getenv: func(string) string { return "session=old,tab=t_abc,pane=p_def" },233 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 },
236 })235 })
237 if err != nil {236 if err != nil {
238 t.Fatalf("runCmdWithDeps: %v", err)237 t.Fatalf("runCmdWithDeps: %v", err)
@@ -257,7 +256,7 @@ func TestRunCmdVEVWithoutSelfUsesIDsOnlyAsSessionLocator(t *testing.T) {
257 err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{256 err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{
258 stdout: io.Discard,257 stdout: io.Discard,
259 getenv: func(string) string { return "session=old,tab=t_abc,pane=p_def" },258 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 },
261 })260 })
262 if err != nil {261 if err != nil {
263 t.Fatalf("runCmdWithDeps: %v", err)262 t.Fatalf("runCmdWithDeps: %v", err)
@@ -276,7 +275,7 @@ func TestRunCmdExplicitSessionDoesNotUseEnvIDsWithoutSelf(t *testing.T) {
276 err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "new-tab", session: "current"}, cmdDeps{275 err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "new-tab", session: "current"}, cmdDeps{
277 stdout: io.Discard,276 stdout: io.Discard,
278 getenv: func(string) string { return "session=old,tab=t_abc,pane=p_def" },277 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 },
280 })279 })
281 if err != nil {280 if err != nil {
282 t.Fatalf("runCmdWithDeps: %v", err)281 t.Fatalf("runCmdWithDeps: %v", err)
@@ -298,7 +297,7 @@ func TestRunCmdRejectsTooManyArgumentsBeforeDialing(t *testing.T) {
298 }, cmdDeps{297 }, cmdDeps{
299 stdout: io.Discard,298 stdout: io.Discard,
300 getenv: func(string) string { return "" },299 getenv: func(string) string { return "" },
301- dial: func(context.Context, string) (ports.Transport, error) {300+ dial: func(context.Context, string) (wire.Transport, error) {
302 dialed = true301 dialed = true
303 return nil, errors.New("must not dial")302 return nil, errors.New("must not dial")
304 },303 },
@@ -330,7 +329,7 @@ func TestRunCmdTimesOutPendingRequest(t *testing.T) {
330 done <- runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{329 done <- runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{
331 stdout: io.Discard,330 stdout: io.Discard,
332 getenv: func(string) string { return "" },331 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 },
334 clock: clock,333 clock: clock,
335 })334 })
336 }()335 }()
@@ -358,7 +357,7 @@ func TestRunCmdZeroRequestIDReplyReturnsDaemonError(t *testing.T) {
358 err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{357 err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{
359 stdout: io.Discard,358 stdout: io.Discard,
360 getenv: func(string) string { return "" },359 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 },
362 })361 })
363 if err == nil || err.Error() != want {362 if err == nil || err.Error() != want {
364 t.Fatalf("error = %v, want daemon error %q", err, want)363 t.Fatalf("error = %v, want daemon error %q", err, want)
@@ -374,7 +373,7 @@ func TestRunCmdDoesNotAutostartAndClassifiesDialFailure(t *testing.T) {
374 err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{373 err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{
375 stdout: io.Discard,374 stdout: io.Discard,
376 getenv: func(string) string { return "" },375 getenv: func(string) string { return "" },
377- dial: func(context.Context, string) (ports.Transport, error) {376+ dial: func(context.Context, string) (wire.Transport, error) {
378 dials++377 dials++
379 return nil, dialErr378 return nil, dialErr
380 },379 },
@@ -420,7 +419,7 @@ func TestRunCmdClassifiesDaemonCommandErrors(t *testing.T) {
420 err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{419 err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{
421 stdout: io.Discard,420 stdout: io.Discard,
422 getenv: func(string) string { return "" },421 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 },
424 })423 })
425 if err == nil || ExitCode(err) != tt.wantCode || err.Error() != tt.result.Text {424 if err == nil || ExitCode(err) != tt.wantCode || err.Error() != tt.result.Text {
426 t.Fatalf("error=%v code=%d, want message %q and code %d", err, ExitCode(err), tt.result.Text, tt.wantCode)425 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,
66 }66 }
67 67
68 // pump wires a background Recv pump to a transport so tests can await frames68 // 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).
70 type pump struct{ ch chan wire.Frame }70 type pump struct{ ch chan wire.Frame }
71 71
72-func recvPump(tr ports.Transport) *pump {72+func recvPump(tr wire.Transport) *pump {
73 p := &pump{ch: make(chan wire.Frame, 128)}73 p := &pump{ch: make(chan wire.Frame, 128)}
74 go func() {74 go func() {
75 for {75 for {
@@ -85,12 +85,12 @@ func recvPump(tr ports.Transport) *pump {
85 }85 }
86 86
87 // attach dials, handshakes, and returns the transport plus its frame pump.87 // 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) {
89 t.Helper()89 t.Helper()
90 return attachWithEnvironment(t, dir, intent, name, sz, nil)90 return attachWithEnvironment(t, dir, intent, name, sz, nil)
91 }91 }
92 92
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) {
94 t.Helper()94 t.Helper()
95 tr, err := ipc.DialContext(context.Background(), dir)95 tr, err := ipc.DialContext(context.Background(), dir)
96 require.NoError(t, err)96 require.NoError(t, err)
@@ -263,7 +263,7 @@ func shellFixture(t *testing.T, label string) string {
263 return path263 return path
264 }264 }
265 265
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) {
267 t.Helper()267 t.Helper()
268 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"268 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"
269 require.NoError(t, tr.Send(wire.Frame{Type: wire.MsgInput, Payload: wire.MarshalInput(protocol.Input{Data: []byte(command)})}))269 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) {
531 531
532 ready := make(chan struct{})532 ready := make(chan struct{})
533 transport := &integrationTransport{}533 transport := &integrationTransport{}
534- dial := func(context.Context, string) (ports.Transport, error) {534+ dial := func(context.Context, string) (wire.Transport, error) {
535 select {535 select {
536 case <-ready:536 case <-ready:
537 return transport, nil537 return transport, nil
@@ -1075,7 +1075,7 @@ func TestLifecycleManualTimerRepeatedFireIsNonBlocking(t *testing.T) {
1075 }1075 }
1076 1076
1077 type lifecycleObservedListener struct {1077 type lifecycleObservedListener struct {
1078- ports.Listener1078+ wire.Listener
1079 closed chan struct{}1079 closed chan struct{}
1080 once sync.Once1080 once sync.Once
1081 }1081 }
@@ -1249,7 +1249,7 @@ func assertNewDaemonStartupPending(t *testing.T, durableOpen, listen <-chan stru
1249 }1249 }
1250 1250
1251 type lifecycleControlledListener struct {1251 type lifecycleControlledListener struct {
1252- ports.Listener1252+ wire.Listener
1253 entered chan struct{}1253 entered chan struct{}
1254 proceed chan struct{}1254 proceed chan struct{}
1255 closed chan struct{}1255 closed chan struct{}
M internal/app/final_acceptance_test.go +2 -3
@@ -7,14 +7,13 @@ import (
7 "time"7 "time"
8 8
9 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
10- "github.com/bnema/vev/internal/ports"
11 "github.com/bnema/vev/internal/protocol"10 "github.com/bnema/vev/internal/protocol"
12 "github.com/bnema/vev/internal/protocol/wire"11 "github.com/bnema/vev/internal/protocol/wire"
13 "github.com/bnema/vev/internal/usecase/daemon"12 "github.com/bnema/vev/internal/usecase/daemon"
14 "github.com/stretchr/testify/require"13 "github.com/stretchr/testify/require"
15 )14 )
16 15
17-func sendAcceptanceInput(t *testing.T, tr ports.Transport, data string) {16+func sendAcceptanceInput(t *testing.T, tr wire.Transport, data string) {
18 t.Helper()17 t.Helper()
19 require.NoError(t, tr.Send(wire.Frame{Type: wire.MsgInput, Payload: wire.MarshalInput(protocol.Input{Data: []byte(data)})}))18 require.NoError(t, tr.Send(wire.Frame{Type: wire.MsgInput, Payload: wire.MarshalInput(protocol.Input{Data: []byte(data)})}))
20 }19 }
@@ -60,7 +59,7 @@ func awaitAcceptanceOutput(t *testing.T, p *pump, predicate func(protocol.Output
60 return output59 return output
61 }60 }
62 61
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 {
64 t.Helper()63 t.Helper()
65 payload, err := wire.MarshalCommandRequest(protocol.CommandRequest{64 payload, err := wire.MarshalCommandRequest(protocol.CommandRequest{
66 Version: protocol.Version, RequestID: requestID, Attached: true, Slug: slug,65 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 (
11 "sync"11 "sync"
12 "testing"12 "testing"
13 13
14+ remoteadapter "github.com/bnema/vev/internal/adapters/remote"
14 "github.com/bnema/vev/internal/adapters/sshstdio"15 "github.com/bnema/vev/internal/adapters/sshstdio"
15 "github.com/bnema/vev/internal/domain"16 "github.com/bnema/vev/internal/domain"
16- "github.com/bnema/vev/internal/ports"
17 portsmocks "github.com/bnema/vev/internal/ports/mocks"17 portsmocks "github.com/bnema/vev/internal/ports/mocks"
18 "github.com/bnema/vev/internal/protocol"18 "github.com/bnema/vev/internal/protocol"
19 "github.com/bnema/vev/internal/protocol/wire"19 "github.com/bnema/vev/internal/protocol/wire"
@@ -28,7 +28,7 @@ type acceptanceRemoteFactory struct {
28 outputs chan string28 outputs chan string
29 }29 }
30 30
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) {
32 f.mu.Lock()32 f.mu.Lock()
33 f.calls = append(f.calls, target)33 f.calls = append(f.calls, target)
34 targetHandoff := f.handoff[target]34 targetHandoff := f.handoff[target]
@@ -42,7 +42,7 @@ type acceptanceRemoteDialer struct {
42 outputs chan<- string42 outputs chan<- string
43 }43 }
44 44
45-func (d acceptanceRemoteDialer) Dial(context.Context) (ports.Transport, error) {45+func (d acceptanceRemoteDialer) Dial(context.Context) (wire.Transport, error) {
46 clientConn, serverConn := net.Pipe()46 clientConn, serverConn := net.Pipe()
47 clientTransport := sshstdio.NewTransport(clientConn, clientConn, clientConn.Close)47 clientTransport := sshstdio.NewTransport(clientConn, clientConn, clientConn.Close)
48 serverTransport := sshstdio.NewTransport(serverConn, serverConn, serverConn.Close)48 serverTransport := sshstdio.NewTransport(serverConn, serverConn, serverConn.Close)
@@ -50,7 +50,7 @@ func (d acceptanceRemoteDialer) Dial(context.Context) (ports.Transport, error) {
50 return clientTransport, nil50 return clientTransport, nil
51 }51 }
52 52
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) {
54 defer func() { _ = tr.Close() }()54 defer func() { _ = tr.Close() }()
55 welcomed := false55 welcomed := false
56 for {56 for {
@@ -125,12 +125,12 @@ func TestAcceptanceRemoteDirectAndPickerUseOnlyRemoteTransports(t *testing.T) {
125 terminals := make([]*acceptanceRemoteTerminal, 0, 3)125 terminals := make([]*acceptanceRemoteTerminal, 0, 3)
126 deps := runAttachDeps{126 deps := runAttachDeps{
127 hostStore: hostStore,127 hostStore: hostStore,
128- localDialer: func() ports.Dialer {128+ localDialer: func() wire.Dialer {
129 localCalls++129 localCalls++
130 return acceptanceRemoteDialer{}130 return acceptanceRemoteDialer{}
131 },131 },
132- remoteDialerFactory: factory,132+ remoteDialerFactory: factory.DialerForRemote,
133- selectedRemoteTransport: string(ports.RemoteTransportStdio),133+ selectedRemoteTransport: string(remoteadapter.TransportStdio),
134 runClient: func(ctx context.Context, deps client.Dependencies, request client.AttachRequest) error {134 runClient: func(ctx context.Context, deps client.Dependencies, request client.AttachRequest) error {
135 term := &acceptanceRemoteTerminal{in: bytes.NewReader(nil)}135 term := &acceptanceRemoteTerminal{in: bytes.NewReader(nil)}
136 terminals = append(terminals, term)136 terminals = append(terminals, term)
M internal/app/remote_hosts.go +6 -5
@@ -15,6 +15,7 @@ import (
15 "github.com/bnema/vev/internal/platform"15 "github.com/bnema/vev/internal/platform"
16 "github.com/bnema/vev/internal/ports"16 "github.com/bnema/vev/internal/ports"
17 "github.com/bnema/vev/internal/protocol"17 "github.com/bnema/vev/internal/protocol"
18+ "github.com/bnema/vev/internal/protocol/catalogue"
18 )19 )
19 20
20 const (21 const (
@@ -196,21 +197,21 @@ func listAllSessions(ctx context.Context, deps remoteHostDeps, hosts []domain.Re
196 return nil197 return nil
197 }198 }
198 199
199-func catalogSessionsAsInfo(host string, sessions []ports.RemoteCatalogSession) []protocol.SessionInfo {200+func catalogSessionsAsInfo(host string, sessions []catalogue.RemoteCatalogSession) []protocol.SessionInfo {
200 out := make([]protocol.SessionInfo, 0, len(sessions))201 out := make([]protocol.SessionInfo, 0, len(sessions))
201 for _, session := range sessions {202 for _, session := range sessions {
202 info := protocol.SessionInfo{203 info := protocol.SessionInfo{
203 Name: domain.RemoteSessionDisplay(session.Name, host),204 Name: domain.RemoteSessionDisplay(session.Name, host),
204 Ephemeral: session.Ephemeral,205 Ephemeral: session.Ephemeral,
205- Tabs: ports.SaturateUint16(ports.CatalogTabCount(session)),206+ Tabs: catalogue.SaturateUint16(catalogue.CatalogTabCount(session)),
206 Attached: session.Attached,207 Attached: session.Attached,
207 }208 }
208 switch session.State {209 switch session.State {
209- case ports.RemoteCatalogSessionUp:210+ case catalogue.RemoteCatalogSessionUp:
210 info.State = protocol.SessionUp211 info.State = protocol.SessionUp
211- case ports.RemoteCatalogSessionDown:212+ case catalogue.RemoteCatalogSessionDown:
212 info.State = protocol.SessionDown213 info.State = protocol.SessionDown
213- case ports.RemoteCatalogSessionBroken:214+ case catalogue.RemoteCatalogSessionBroken:
214 info.State = protocol.SessionBroken215 info.State = protocol.SessionBroken
215 default:216 default:
216 slog.Debug("remote catalog session has unknown state", "host", host, "session", session.Name, "state", session.State)217 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 (
8 "strings"8 "strings"
9 "testing"9 "testing"
10 10
11+ remoteadapter "github.com/bnema/vev/internal/adapters/remote"
11 "github.com/bnema/vev/internal/domain"12 "github.com/bnema/vev/internal/domain"
12 "github.com/bnema/vev/internal/ports"13 "github.com/bnema/vev/internal/ports"
13 portsmocks "github.com/bnema/vev/internal/ports/mocks"14 portsmocks "github.com/bnema/vev/internal/ports/mocks"
14 "github.com/bnema/vev/internal/protocol"15 "github.com/bnema/vev/internal/protocol"
16+ "github.com/bnema/vev/internal/protocol/catalogue"
15 "github.com/bnema/vev/internal/protocol/wire"17 "github.com/bnema/vev/internal/protocol/wire"
16 "github.com/bnema/vev/internal/usecase/client"18 "github.com/bnema/vev/internal/usecase/client"
17 "github.com/stretchr/testify/mock"19 "github.com/stretchr/testify/mock"
@@ -173,7 +175,7 @@ func TestRemoteHostListingUsesUnifiedStore(t *testing.T) {
173 store := portsmocks.NewMockRemoteHostStore(t)175 store := portsmocks.NewMockRemoteHostStore(t)
174 store.EXPECT().Hosts().Return([]string{"arch"}, []string{"beta"}, nil).Once()176 store.EXPECT().Hosts().Return([]string{"arch"}, []string{"beta"}, nil).Once()
175 catalog := portsmocks.NewMockRemoteCatalogClient(t)177 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()
177 var out bytes.Buffer179 var out bytes.Buffer
178 err := runRemoteList(context.Background(), command{listHost: "arch"}, remoteHostDeps{180 err := runRemoteList(context.Background(), command{listHost: "arch"}, remoteHostDeps{
179 store: store,181 store: store,
@@ -210,9 +212,9 @@ func TestListAllSessionsInvariants(t *testing.T) {
210 ctx, cancel := context.WithCancel(context.Background())212 ctx, cancel := context.WithCancel(context.Background())
211 defer cancel()213 defer cancel()
212 catalog := portsmocks.NewMockRemoteCatalogClient(t)214 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) {
214 cancel()216 cancel()
215- return ports.RemoteCatalog{Sessions: []ports.RemoteCatalogSession{{Name: "dev", State: "up"}}}, nil217+ return catalogue.RemoteCatalog{Sessions: []catalogue.RemoteCatalogSession{{Name: "dev", State: "up"}}}, nil
216 }).Once()218 }).Once()
217 var out bytes.Buffer219 var out bytes.Buffer
218 err := listAllSessions(ctx, remoteHostDeps{220 err := listAllSessions(ctx, remoteHostDeps{
@@ -235,10 +237,10 @@ func TestListAllSessionsInvariants(t *testing.T) {
235 var calls []string237 var calls []string
236 catalog.EXPECT().List(mock.Anything, "arch").Run(func(_ context.Context, target string) {238 catalog.EXPECT().List(mock.Anything, "arch").Run(func(_ context.Context, target string) {
237 calls = append(calls, target)239 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()
239 catalog.EXPECT().List(mock.Anything, "mule").Run(func(_ context.Context, target string) {241 catalog.EXPECT().List(mock.Anything, "mule").Run(func(_ context.Context, target string) {
240 calls = append(calls, target)242 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()
242 var out bytes.Buffer244 var out bytes.Buffer
243 err := listAllSessions(context.Background(), remoteHostDeps{245 err := listAllSessions(context.Background(), remoteHostDeps{
244 catalog: catalog,246 catalog: catalog,
@@ -263,18 +265,18 @@ func TestListAllSessionsInvariants(t *testing.T) {
263 func TestCatalogSessionsAsInfoInvariants(t *testing.T) {265 func TestCatalogSessionsAsInfoInvariants(t *testing.T) {
264 tests := []struct {266 tests := []struct {
265 name string267 name string
266- session ports.RemoteCatalogSession268+ session catalogue.RemoteCatalogSession
267 wantState protocol.SessionState269 wantState protocol.SessionState
268 wantTabs uint16270 wantTabs uint16
269 }{271 }{
270- {name: "up", session: ports.RemoteCatalogSession{Name: "dev", State: "up", Tabs: []ports.RemoteCatalogTab{{}, {}}}, wantState: protocol.SessionUp, wantTabs: 2},272+ {name: "up", session: catalogue.RemoteCatalogSession{Name: "dev", State: "up", Tabs: []catalogue.RemoteCatalogTab{{}, {}}}, wantState: protocol.SessionUp, wantTabs: 2},
271- {name: "down", session: ports.RemoteCatalogSession{Name: "dev", State: "down", Tabs: []ports.RemoteCatalogTab{{}, {}}}, wantState: protocol.SessionDown, wantTabs: 2},273+ {name: "down", session: catalogue.RemoteCatalogSession{Name: "dev", State: "down", Tabs: []catalogue.RemoteCatalogTab{{}, {}}}, wantState: protocol.SessionDown, wantTabs: 2},
272- {name: "broken", session: ports.RemoteCatalogSession{Name: "dev", State: "broken", Tabs: []ports.RemoteCatalogTab{{}, {}}}, wantState: protocol.SessionBroken, wantTabs: 2},274+ {name: "broken", session: catalogue.RemoteCatalogSession{Name: "dev", State: "broken", Tabs: []catalogue.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},275+ {name: "unknown fails closed", session: catalogue.RemoteCatalogSession{Name: "dev", State: "unknown", Tabs: []catalogue.RemoteCatalogTab{{}, {}}}, wantState: protocol.SessionBroken, wantTabs: 2},
274 }276 }
275 for _, tt := range tests {277 for _, tt := range tests {
276 t.Run(tt.name, func(t *testing.T) {278 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})
278 require.Len(t, infos, 1)280 require.Len(t, infos, 1)
279 require.Equal(t, "dev@arch", infos[0].Name)281 require.Equal(t, "dev@arch", infos[0].Name)
280 require.Equal(t, tt.wantState, infos[0].State)282 require.Equal(t, tt.wantState, infos[0].State)
@@ -286,12 +288,12 @@ func TestCatalogSessionsAsInfoInvariants(t *testing.T) {
286 func TestRunAttachWithDepsRemoteLearning(t *testing.T) {288 func TestRunAttachWithDepsRemoteLearning(t *testing.T) {
287 store := portsmocks.NewMockRemoteHostStore(t)289 store := portsmocks.NewMockRemoteHostStore(t)
288 store.EXPECT().Remember("build@mule").Return(nil).Once()290 store.EXPECT().Remember("build@mule").Return(nil).Once()
289- factory := portsmocks.NewMockRemoteDialerFactory(t)291+ factory := newRemoteDialerFactoryMock(t)
290- factory.EXPECT().DialerForRemote("build@mule", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()292+ factory.EXPECT().DialerForRemote("build@mule", "work", remoteadapter.TransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()
291 293
292 var learner ports.RemoteHostLearner294 var learner ports.RemoteHostLearner
293 err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "build@mule", "", nil, runAttachDeps{295 err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "build@mule", "", nil, runAttachDeps{
294- remoteDialerFactory: factory,296+ remoteDialerFactory: factory.DialerForRemote,
295 hostStore: store,297 hostStore: store,
296 runClient: func(_ context.Context, deps client.Dependencies, _ client.AttachRequest) error {298 runClient: func(_ context.Context, deps client.Dependencies, _ client.AttachRequest) error {
297 learner = deps.RemoteHostLearner299 learner = deps.RemoteHostLearner
@@ -306,11 +308,11 @@ func TestRunAttachWithDepsRemoteLearning(t *testing.T) {
306 func TestRunAttachWithDepsAlwaysLearnsRemoteHost(t *testing.T) {308 func TestRunAttachWithDepsAlwaysLearnsRemoteHost(t *testing.T) {
307 store := portsmocks.NewMockRemoteHostStore(t)309 store := portsmocks.NewMockRemoteHostStore(t)
308 store.EXPECT().Remember("arch").Return(nil).Once()310 store.EXPECT().Remember("arch").Return(nil).Once()
309- factory := portsmocks.NewMockRemoteDialerFactory(t)311+ factory := newRemoteDialerFactoryMock(t)
310- factory.EXPECT().DialerForRemote("arch", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()312+ factory.EXPECT().DialerForRemote("arch", "work", remoteadapter.TransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()
311 var learner ports.RemoteHostLearner313 var learner ports.RemoteHostLearner
312 err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "arch", "", nil, runAttachDeps{314 err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "arch", "", nil, runAttachDeps{
313- remoteDialerFactory: factory,315+ remoteDialerFactory: factory.DialerForRemote,
314 hostStore: store,316 hostStore: store,
315 runClient: func(_ context.Context, deps client.Dependencies, _ client.AttachRequest) error {317 runClient: func(_ context.Context, deps client.Dependencies, _ client.AttachRequest) error {
316 learner = deps.RemoteHostLearner318 learner = deps.RemoteHostLearner
M internal/app/run.go +33 -33
@@ -132,7 +132,7 @@ var (
132 date = "unknown"132 date = "unknown"
133 133
134 openCatalogue = persist.OpenOrCreate134 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) {
136 return ipc.Listen(dir, ipc.WithRuntimeObserver(observer))136 return ipc.Listen(dir, ipc.WithRuntimeObserver(observer))
137 }137 }
138 )138 )
@@ -343,6 +343,8 @@ func parseHostArgs(args []string) (command, error) {
343 }343 }
344 }344 }
345 345
346+type remoteDialerForTarget func(target, session string, mode remoteadapter.TransportMode, log *slog.Logger) (wire.Dialer, error)
347+
346 // performanceTrace creates one serialized timestamp owner for this process.348 // performanceTrace creates one serialized timestamp owner for this process.
347 // An empty trace environment leaves all production behavior and wire bytes349 // An empty trace environment leaves all production behavior and wire bytes
348 // unchanged.350 // unchanged.
@@ -354,8 +356,8 @@ var (
354 newRemoteCatalogCache = remoteadapter.NewFileCatalogCache356 newRemoteCatalogCache = remoteadapter.NewFileCatalogCache
355 newRemoteCatalogClient = remoteadapter.NewCatalogClient357 newRemoteCatalogClient = remoteadapter.NewCatalogClient
356 newRemotePreviewClient = remoteadapter.NewPreviewClient358 newRemotePreviewClient = remoteadapter.NewPreviewClient
357- newRemoteDialerFactoryWithRuntimeObserver = func(observer ports.SerializedRuntimeObserver) ports.RemoteDialerFactory {359+ newRemoteDialerFactoryWithRuntimeObserver = func(observer ports.SerializedRuntimeObserver) remoteDialerForTarget {
358- return remoteadapter.NewDialerFactoryWithRuntimeObserver(observer)360+ return remoteadapter.NewDialerFactoryWithRuntimeObserver(observer).DialerForRemote
359 }361 }
360 runClientWithDeps runClientFunc = func(362 runClientWithDeps runClientFunc = func(
361 ctx context.Context,363 ctx context.Context,
@@ -408,7 +410,7 @@ func performanceTraceWithFactories(
408 setupErr := fmt.Errorf("configure runtime trace correlation: %w", err)410 setupErr := fmt.Errorf("configure runtime trace correlation: %w", err)
409 return nil, nil, closeTraceAfterSetupFailure(closer, setupErr)411 return nil, nil, closeTraceAfterSetupFailure(closer, setupErr)
410 }412 }
411- reporter := ports.NewSerializedRuntimeObserver(observer, runtimeTraceQueueDepth)413+ reporter := observability.NewSerialized(observer, runtimeTraceQueueDepth)
412 return reporter, &runtimeTraceCloser{reporter: reporter, closer: closer}, nil414 return reporter, &runtimeTraceCloser{reporter: reporter, closer: closer}, nil
413 }415 }
414 416
@@ -620,8 +622,8 @@ func logStartupRecoveryCounts(log *slog.Logger, records []domain.CatalogueRecord
620 func constructDaemonBeforeSocketPublication(622 func constructDaemonBeforeSocketPublication(
621 construct func() *daemon.Daemon,623 construct func() *daemon.Daemon,
622 prepare func(*daemon.Daemon) error,624 prepare func(*daemon.Daemon) error,
623- listen func() (ports.Listener, error),625+ listen func() (wire.Listener, error),
624-) (*daemon.Daemon, ports.Listener, error) {626+) (*daemon.Daemon, wire.Listener, error) {
625 d := construct()627 d := construct()
626 if err := prepare(d); err != nil {628 if err := prepare(d); err != nil {
627 return d, nil, err629 return d, nil, err
@@ -648,7 +650,7 @@ func runDaemonOwnedWithLogger(ctx context.Context, log *slog.Logger) (retErr err
648 if observerCloser != nil {650 if observerCloser != nil {
649 defer func() { retErr = errors.Join(retErr, observerCloser.Close()) }()651 defer func() { retErr = errors.Join(retErr, observerCloser.Close()) }()
650 }652 }
651- remoteDiscoveryOpt, err := remoteDiscoveryDaemonOption(platform.StateDir(), observer, os.Getenv(envRemoteTransport))653+ remoteDiscoveryOpt, err := remoteDiscoveryDaemonOption(platform.StateDir(), os.Getenv(envRemoteTransport))
652 if err != nil {654 if err != nil {
653 return err655 return err
654 }656 }
@@ -723,7 +725,7 @@ func runDaemonOwnedWithLogger(ctx context.Context, log *slog.Logger) (retErr err
723 }725 }
724 return nil726 return nil
725 },727 },
726- func() (ports.Listener, error) { return listenDaemon(ipc.SocketDir(), observer) },728+ func() (wire.Listener, error) { return listenDaemon(ipc.SocketDir(), observer) },
727 )729 )
728 if err != nil {730 if err != nil {
729 closeErr := opened.Catalogue.Close()731 closeErr := opened.Catalogue.Close()
@@ -779,7 +781,7 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) (re
779 defer func() { retErr = errors.Join(retErr, observerCloser.Close()) }()781 defer func() { retErr = errors.Join(retErr, observerCloser.Close()) }()
780 }782 }
781 return runAttachWithDeps(ctx, intent, name, remoteTarget, os.Getenv("VEV"), log, runAttachDeps{783 return runAttachWithDeps(ctx, intent, name, remoteTarget, os.Getenv("VEV"), log, runAttachDeps{
782- localDialer: func() ports.Dialer {784+ localDialer: func() wire.Dialer {
783 return localDaemonDialer{dir: ipc.SocketDir(), observer: observer}785 return localDaemonDialer{dir: ipc.SocketDir(), observer: observer}
784 },786 },
785 remoteDialerFactory: newRemoteDialerFactoryWithRuntimeObserver(observer),787 remoteDialerFactory: newRemoteDialerFactoryWithRuntimeObserver(observer),
@@ -794,17 +796,17 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) (re
794 796
795 const envRemoteTransport = "VEV_REMOTE_TRANSPORT"797 const envRemoteTransport = "VEV_REMOTE_TRANSPORT"
796 798
797-func defaultLocalDialer() ports.Dialer { return localDaemonDialer{dir: ipc.SocketDir()} }799+func defaultLocalDialer() wire.Dialer { return localDaemonDialer{dir: ipc.SocketDir()} }
798 800
799-func defaultRemoteDialerFactory() ports.RemoteDialerFactory {801+func defaultRemoteDialerFactory() remoteDialerForTarget {
800- return remoteadapter.NewDialerFactory()802+ return remoteadapter.NewDialerFactory().DialerForRemote
801 }803 }
802 804
803 type runClientFunc func(context.Context, client.Dependencies, client.AttachRequest) error805 type runClientFunc func(context.Context, client.Dependencies, client.AttachRequest) error
804 806
805 type runAttachDeps struct {807 type runAttachDeps struct {
806- localDialer func() ports.Dialer808+ localDialer func() wire.Dialer
807- remoteDialerFactory ports.RemoteDialerFactory809+ remoteDialerFactory remoteDialerForTarget
808 selectedRemoteTransport string810 selectedRemoteTransport string
809 runClient runClientFunc811 runClient runClientFunc
810 createDetached func(context.Context, string) error812 createDetached func(context.Context, string) error
@@ -819,14 +821,14 @@ type runAttachDeps struct {
819 hostStore ports.RemoteHostStore821 hostStore ports.RemoteHostStore
820 }822 }
821 823
822-func remoteTransportModeFromEnv(value string) (ports.RemoteTransportMode, error) {824+func remoteTransportModeFromEnv(value string) (remoteadapter.TransportMode, error) {
823 switch value {825 switch value {
824- case "", string(ports.RemoteTransportUDP):826+ case "", string(remoteadapter.TransportUDP):
825- return ports.RemoteTransportUDP, nil827+ return remoteadapter.TransportUDP, nil
826- case string(ports.RemoteTransportStdio):828+ case string(remoteadapter.TransportStdio):
827- return ports.RemoteTransportStdio, nil829+ return remoteadapter.TransportStdio, nil
828 default:830 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)
830 }832 }
831 }833 }
832 834
@@ -853,8 +855,8 @@ func validateRemoteAttachHandoff(target protocol.AttachTarget) error {
853 855
854 // remoteDiscoveryDaemonOption constructs the daemon-owned discovery ports from856 // remoteDiscoveryDaemonOption constructs the daemon-owned discovery ports from
855 // the same validated transport selection used by direct remote attach.857 // the same validated transport selection used by direct remote attach.
856-func remoteDiscoveryDaemonOption(stateDir string, observer ports.SerializedRuntimeObserver, transport string) (daemon.Option, error) {858+func remoteDiscoveryDaemonOption(stateDir, transport string) (daemon.Option, error) {
857- mode, err := remoteTransportModeFromEnv(transport)859+ _, err := remoteTransportModeFromEnv(transport)
858 if err != nil {860 if err != nil {
859 return nil, err861 return nil, err
860 }862 }
@@ -862,8 +864,6 @@ func remoteDiscoveryDaemonOption(stateDir string, observer ports.SerializedRunti
862 newRemoteHostStore(remoteadapter.HostStorePath(stateDir)),864 newRemoteHostStore(remoteadapter.HostStorePath(stateDir)),
863 newRemoteCatalogClient(),865 newRemoteCatalogClient(),
864 newRemoteCatalogCache(remoteadapter.CatalogCachePath(stateDir)),866 newRemoteCatalogCache(remoteadapter.CatalogCachePath(stateDir)),
865- newRemoteDialerFactoryWithRuntimeObserver(observer),
866- mode,
867 )867 )
868 preview := daemon.WithRemotePreview(newRemotePreviewClient())868 preview := daemon.WithRemotePreview(newRemotePreviewClient())
869 return func(d *daemon.Daemon) {869 return func(d *daemon.Daemon) {
@@ -929,7 +929,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
929 copyTarget := *target.RemoteTarget929 copyTarget := *target.RemoteTarget
930 selection = &copyTarget930 selection = &copyTarget
931 }931 }
932- dialer, err := factory.DialerForRemote(target.Endpoint, target.Session, mode, log)932+ dialer, err := factory(target.Endpoint, target.Session, mode, log)
933 if err != nil {933 if err != nil {
934 return nil, client.AttachRequest{}, err934 return nil, client.AttachRequest{}, err
935 }935 }
@@ -957,7 +957,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
957 if log != nil {957 if log != nil {
958 log.Info("attaching to remote session", "target", remoteTarget, "name", name, "transport", string(mode))958 log.Info("attaching to remote session", "target", remoteTarget, "name", name, "transport", string(mode))
959 }959 }
960- dialer, dialErr := factory.DialerForRemote(remoteTarget, name, mode, log)960+ dialer, dialErr := factory(remoteTarget, name, mode, log)
961 if dialErr != nil {961 if dialErr != nil {
962 return dialErr962 return dialErr
963 }963 }
@@ -1043,10 +1043,10 @@ type localDaemonDialer struct {
1043 observer ports.SerializedRuntimeObserver1043 observer ports.SerializedRuntimeObserver
1044 }1044 }
1045 1045
1046-func (d localDaemonDialer) Dial(ctx context.Context) (ports.Transport, error) {1046+func (d localDaemonDialer) Dial(ctx context.Context) (wire.Transport, error) {
1047 dial := realDial1047 dial := realDial
1048 if d.observer != nil {1048 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) {
1050 return ipc.DialContext(ctx, dir, ipc.WithRuntimeObserver(d.observer))1050 return ipc.DialContext(ctx, dir, ipc.WithRuntimeObserver(d.observer))
1051 }1051 }
1052 }1052 }
@@ -1161,7 +1161,7 @@ func runStdio(ctx context.Context) (retErr error) {
1161 if observerCloser != nil {1161 if observerCloser != nil {
1162 defer func() { retErr = errors.Join(retErr, observerCloser.Close()) }()1162 defer func() { retErr = errors.Join(retErr, observerCloser.Close()) }()
1163 }1163 }
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) {
1165 return ipc.DialContext(ctx, dir, ipc.WithRuntimeObserver(observer))1165 return ipc.DialContext(ctx, dir, ipc.WithRuntimeObserver(observer))
1166 }, realSpawn, defaultBackoff)1166 }, realSpawn, defaultBackoff)
1167 if err != nil {1167 if err != nil {
@@ -1293,7 +1293,7 @@ func runUDPProxy(ctx context.Context, ready io.Writer) (retErr error) {
1293 return err1293 return err
1294 }1294 }
1295 defer func() { _ = conn.Close() }()1295 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) {
1297 return ipc.DialContext(ctx, dir, ipc.WithRuntimeObserver(observer))1297 return ipc.DialContext(ctx, dir, ipc.WithRuntimeObserver(observer))
1298 }, realSpawn, defaultBackoff)1298 }, realSpawn, defaultBackoff)
1299 if err != nil {1299 if err != nil {
@@ -1429,15 +1429,15 @@ func listenUDPInRange(ctx context.Context, r udpPortRange) (net.PacketConn, erro
1429 1429
1430 // runStdioProxy forwards the framed transport without interpreting protocol1430 // runStdioProxy forwards the framed transport without interpreting protocol
1431 // messages. Session selection remains in the Hello sent by the thin client.1431 // 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 {
1433 return proxyTransports(ctx, client, daemon, log)1433 return proxyTransports(ctx, client, daemon, log)
1434 }1434 }
1435 1435
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 {
1437 return dgram.ProxyRuntime{Client: client, Daemon: daemon, Log: log, IdleTTL: udpProxyIdleTTL}.Run(ctx)1437 return dgram.ProxyRuntime{Client: client, Daemon: daemon, Log: log, IdleTTL: udpProxyIdleTTL}.Run(ctx)
1438 }1438 }
1439 1439
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 {
1441 return dgram.ProxyRuntime{Client: a, Daemon: b, Log: log}.Run(ctx)1441 return dgram.ProxyRuntime{Client: a, Daemon: b, Log: log}.Run(ctx)
1442 }1442 }
1443 1443
M internal/app/run_test.go +47 -45
@@ -23,8 +23,10 @@ import (
23 "github.com/bnema/vev/internal/domain"23 "github.com/bnema/vev/internal/domain"
24 "github.com/bnema/vev/internal/persist"24 "github.com/bnema/vev/internal/persist"
25 "github.com/bnema/vev/internal/ports"25 "github.com/bnema/vev/internal/ports"
26- portsmocks "github.com/bnema/vev/internal/ports/mocks"
27 "github.com/bnema/vev/internal/protocol"26 "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"
28 "github.com/bnema/vev/internal/usecase/client"30 "github.com/bnema/vev/internal/usecase/client"
29 "github.com/bnema/vev/internal/usecase/daemon"31 "github.com/bnema/vev/internal/usecase/daemon"
30 "github.com/bnema/vev/pkg/kv"32 "github.com/bnema/vev/pkg/kv"
@@ -220,7 +222,7 @@ func TestCatalogueFailureDoesNotListen(t *testing.T) {
220 require.Equal(t, stateDir, gotStateDir)222 require.Equal(t, stateDir, gotStateDir)
221 return persist.OpenResult{}, catalogueErr223 return persist.OpenResult{}, catalogueErr
222 }224 }
223- listenDaemon = func(string, ports.SerializedRuntimeObserver) (ports.Listener, error) {225+ listenDaemon = func(string, ports.SerializedRuntimeObserver) (wire.Listener, error) {
224 listenCalls++226 listenCalls++
225 return nil, errors.New("IPC listen must not run after catalogue failure")227 return nil, errors.New("IPC listen must not run after catalogue failure")
226 }228 }
@@ -251,7 +253,7 @@ func TestCatalogueRegistryConstructionPrecedesSocketPublication(t *testing.T) {
251 events = append(events, "startup-garbage-collection")253 events = append(events, "startup-garbage-collection")
252 return nil254 return nil
253 },255 },
254- func() (ports.Listener, error) {256+ func() (wire.Listener, error) {
255 require.Equal(t, []string{"catalogue-registry", "startup-garbage-collection"}, events)257 require.Equal(t, []string{"catalogue-registry", "startup-garbage-collection"}, events)
256 events = append(events, "socket-publication")258 events = append(events, "socket-publication")
257 return nil, nil259 return nil, nil
@@ -577,7 +579,7 @@ func TestAttachTargetsCreateCommonSessionRequest(t *testing.T) {
577 t.Run(tt.name, func(t *testing.T) {579 t.Run(tt.name, func(t *testing.T) {
578 cmd, err := parseArgs([]string{"attach", tt.arg})580 cmd, err := parseArgs([]string{"attach", tt.arg})
579 require.NoError(t, err)581 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{
581 Intent: cmd.intent,583 Intent: cmd.intent,
582 SessionName: cmd.name,584 SessionName: cmd.name,
583 })585 })
@@ -808,7 +810,7 @@ func TestDetachedLocalHelloIncludesTrueColor(t *testing.T) {
808 810
809 type namedDialer struct{ name string }811 type namedDialer struct{ name string }
810 812
811-func (d namedDialer) Dial(context.Context) (ports.Transport, error) {813+func (d namedDialer) Dial(context.Context) (wire.Transport, error) {
812 return nil, fmt.Errorf("not used: %s", d.name)814 return nil, fmt.Errorf("not used: %s", d.name)
813 }815 }
814 816
@@ -827,10 +829,10 @@ func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) {
827 tests := []struct {829 tests := []struct {
828 name string830 name string
829 selectedTransport string831 selectedTransport string
830- wantMode ports.RemoteTransportMode832+ wantMode remoteadapter.TransportMode
831 }{833 }{
832- {name: "default remote mode is udp", selectedTransport: "", wantMode: ports.RemoteTransportUDP},834+ {name: "default remote mode is udp", selectedTransport: "", wantMode: remoteadapter.TransportUDP},
833- {name: "explicit stdio mode", selectedTransport: "stdio", wantMode: ports.RemoteTransportStdio},835+ {name: "explicit stdio mode", selectedTransport: "stdio", wantMode: remoteadapter.TransportStdio},
834 }836 }
835 837
836 for _, tt := range tests {838 for _, tt := range tests {
@@ -839,11 +841,11 @@ func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) {
839 var gotRemote bool841 var gotRemote bool
840 var gotClipboard ports.ClipboardReader842 var gotClipboard ports.ClipboardReader
841 clip := &fakeClipboardReader{}843 clip := &fakeClipboardReader{}
842- factory := portsmocks.NewMockRemoteDialerFactory(t)844+ factory := newRemoteDialerFactoryMock(t)
843 factory.EXPECT().DialerForRemote("remote.example", "work", tt.wantMode, mock.Anything).Return(namedDialer{name: "remote"}, nil)845 factory.EXPECT().DialerForRemote("remote.example", "work", tt.wantMode, mock.Anything).Return(namedDialer{name: "remote"}, nil)
844 846
845 err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{847 err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
846- remoteDialerFactory: factory,848+ remoteDialerFactory: factory.DialerForRemote,
847 selectedRemoteTransport: tt.selectedTransport,849 selectedRemoteTransport: tt.selectedTransport,
848 clipboard: clip,850 clipboard: clip,
849 runClient: func(ctx context.Context, deps client.Dependencies, request client.AttachRequest) error {851 runClient: func(ctx context.Context, deps client.Dependencies, request client.AttachRequest) error {
@@ -877,12 +879,12 @@ func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) {
877 }879 }
878 880
879 func TestRunAttachWithDepsDirectLegacyCallbackPreservesClientOwnership(t *testing.T) {881 func TestRunAttachWithDepsDirectLegacyCallbackPreservesClientOwnership(t *testing.T) {
880- factory := portsmocks.NewMockRemoteDialerFactory(t)882+ factory := newRemoteDialerFactoryMock(t)
881- factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote-1"}, nil).Once()883+ factory.EXPECT().DialerForRemote("remote.example", "work", remoteadapter.TransportUDP, 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()884+ factory.EXPECT().DialerForRemote("selected.example", "picked", remoteadapter.TransportUDP, mock.Anything).Return(namedDialer{name: "remote-2"}, nil).Once()
883 885
884 err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{886 err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
885- remoteDialerFactory: factory,887+ remoteDialerFactory: factory.DialerForRemote,
886 runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error {888 runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error {
887 require.True(t, request.Remote)889 require.True(t, request.Remote)
888 require.Equal(t, protocol.EnvironmentPolicyClientOwned, request.EnvironmentPolicy)890 require.Equal(t, protocol.EnvironmentPolicyClientOwned, request.EnvironmentPolicy)
@@ -900,12 +902,12 @@ func TestRunAttachWithDepsDirectLegacyCallbackPreservesClientOwnership(t *testin
900 }902 }
901 903
902 func TestRunAttachWithDepsRemotePickerHandoffReopensDirectConnection(t *testing.T) {904 func TestRunAttachWithDepsRemotePickerHandoffReopensDirectConnection(t *testing.T) {
903- factory := portsmocks.NewMockRemoteDialerFactory(t)905+ factory := newRemoteDialerFactoryMock(t)
904- factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote-1"}, nil).Once()906+ factory.EXPECT().DialerForRemote("remote.example", "work", remoteadapter.TransportUDP, 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()907+ factory.EXPECT().DialerForRemote("selected.example", "picked", remoteadapter.TransportUDP, mock.Anything).Return(namedDialer{name: "remote-2"}, nil).Once()
906 var calls int908 var calls int
907 err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{909 err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
908- remoteDialerFactory: factory,910+ remoteDialerFactory: factory.DialerForRemote,
909 runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error {911 runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error {
910 calls++912 calls++
911 require.NotNil(t, deps.Dialer)913 require.NotNil(t, deps.Dialer)
@@ -922,12 +924,12 @@ func TestRunAttachWithDepsRemotePickerHandoffReopensDirectConnection(t *testing.
922 }924 }
923 925
924 func TestRunAttachWithDepsAllowsRevisitingRemoteHandoff(t *testing.T) {926 func TestRunAttachWithDepsAllowsRevisitingRemoteHandoff(t *testing.T) {
925- factory := portsmocks.NewMockRemoteDialerFactory(t)927+ factory := newRemoteDialerFactoryMock(t)
926- factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()928+ factory.EXPECT().DialerForRemote("remote.example", "work", remoteadapter.TransportUDP, 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()929+ factory.EXPECT().DialerForRemote("remote.example", "work", remoteadapter.TransportUDP, mock.Anything).Return(namedDialer{name: "remote-revisit"}, nil).Once()
928 930
929 err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{931 err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
930- remoteDialerFactory: factory,932+ remoteDialerFactory: factory.DialerForRemote,
931 runClient: func(_ context.Context, deps client.Dependencies, _ client.AttachRequest) error {933 runClient: func(_ context.Context, deps client.Dependencies, _ client.AttachRequest) error {
932 dialer, request, err := deps.AttachHandoff(protocol.AttachTarget{934 dialer, request, err := deps.AttachHandoff(protocol.AttachTarget{
933 Endpoint: "remote.example", Session: "work", Intent: protocol.IntentAttach,935 Endpoint: "remote.example", Session: "work", Intent: protocol.IntentAttach,
@@ -942,12 +944,12 @@ func TestRunAttachWithDepsAllowsRevisitingRemoteHandoff(t *testing.T) {
942 }944 }
943 945
944 func TestRunAttachWithDepsBoundsRepeatedAttachTargetHandoffs(t *testing.T) {946 func TestRunAttachWithDepsBoundsRepeatedAttachTargetHandoffs(t *testing.T) {
945- factory := portsmocks.NewMockRemoteDialerFactory(t)947+ factory := newRemoteDialerFactoryMock(t)
946- factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Times(maxAttachTargetHandoffs + 1)948+ factory.EXPECT().DialerForRemote("remote.example", "work", remoteadapter.TransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Times(maxAttachTargetHandoffs + 1)
947 949
948 calls := 0950 calls := 0
949 err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{951 err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
950- remoteDialerFactory: factory,952+ remoteDialerFactory: factory.DialerForRemote,
951 runClient: func(_ context.Context, _ client.Dependencies, _ client.AttachRequest) error {953 runClient: func(_ context.Context, _ client.Dependencies, _ client.AttachRequest) error {
952 calls++954 calls++
953 return &client.AttachTargetError{Target: protocol.AttachTarget{Endpoint: "remote.example", Session: "work", Intent: protocol.IntentAttach}}955 return &client.AttachTargetError{Target: protocol.AttachTarget{Endpoint: "remote.example", Session: "work", Intent: protocol.IntentAttach}}
@@ -958,13 +960,13 @@ func TestRunAttachWithDepsBoundsRepeatedAttachTargetHandoffs(t *testing.T) {
958 }960 }
959 961
960 func TestRunAttachWithDepsLocalPickerHandoffAttachesSelectedRemote(t *testing.T) {962 func TestRunAttachWithDepsLocalPickerHandoffAttachesSelectedRemote(t *testing.T) {
961- factory := portsmocks.NewMockRemoteDialerFactory(t)963+ factory := newRemoteDialerFactoryMock(t)
962- factory.EXPECT().DialerForRemote("selected.example", "picked", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()964+ factory.EXPECT().DialerForRemote("selected.example", "picked", remoteadapter.TransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()
963 965
964 localCalls, remoteCalls := 0, 0966 localCalls, remoteCalls := 0, 0
965 err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "", "", nil, runAttachDeps{967 err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "", "", nil, runAttachDeps{
966- localDialer: func() ports.Dialer { return namedDialer{name: "local"} },968+ localDialer: func() wire.Dialer { return namedDialer{name: "local"} },
967- remoteDialerFactory: factory,969+ remoteDialerFactory: factory.DialerForRemote,
968 runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error {970 runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error {
969 if deps.Remote {971 if deps.Remote {
970 remoteCalls++972 remoteCalls++
@@ -1003,11 +1005,11 @@ func TestRunAttachWithDepsRejectsInvalidHandoffBeforeDialing(t *testing.T) {
1003 }1005 }
1004 for _, tt := range tests {1006 for _, tt := range tests {
1005 t.Run(tt.name, func(t *testing.T) {1007 t.Run(tt.name, func(t *testing.T) {
1006- factory := portsmocks.NewMockRemoteDialerFactory(t)1008+ factory := newRemoteDialerFactoryMock(t)
1007- factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()1009+ factory.EXPECT().DialerForRemote("remote.example", "work", remoteadapter.TransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()
1008 calls := 01010 calls := 0
1009 err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{1011 err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
1010- remoteDialerFactory: factory,1012+ remoteDialerFactory: factory.DialerForRemote,
1011 runClient: func(_ context.Context, _ client.Dependencies, _ client.AttachRequest) error {1013 runClient: func(_ context.Context, _ client.Dependencies, _ client.AttachRequest) error {
1012 calls++1014 calls++
1013 return &client.AttachTargetError{Target: tt.target}1015 return &client.AttachTargetError{Target: tt.target}
@@ -1020,11 +1022,11 @@ func TestRunAttachWithDepsRejectsInvalidHandoffBeforeDialing(t *testing.T) {
1020 }1022 }
1021 1023
1022 func TestRunAttachWithDepsRejectsInvalidRemoteTransportBeforeDialing(t *testing.T) {1024 func TestRunAttachWithDepsRejectsInvalidRemoteTransportBeforeDialing(t *testing.T) {
1023- factory := portsmocks.NewMockRemoteDialerFactory(t)1025+ factory := newRemoteDialerFactoryMock(t)
1024 runClientCalled := false1026 runClientCalled := false
1025 1027
1026 err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{1028 err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
1027- remoteDialerFactory: factory,1029+ remoteDialerFactory: factory.DialerForRemote,
1028 selectedRemoteTransport: "serial",1030 selectedRemoteTransport: "serial",
1029 runClient: func(context.Context, client.Dependencies, client.AttachRequest) error {1031 runClient: func(context.Context, client.Dependencies, client.AttachRequest) error {
1030 runClientCalled = true1032 runClientCalled = true
@@ -1041,12 +1043,12 @@ func TestRunAttachWithDepsRejectsInvalidRemoteTransportBeforeDialing(t *testing.
1041 1043
1042 func TestRunAttachWithDepsReturnsFactoryErrorBeforeRunClient(t *testing.T) {1044 func TestRunAttachWithDepsReturnsFactoryErrorBeforeRunClient(t *testing.T) {
1043 factoryErr := errors.New("factory failed")1045 factoryErr := errors.New("factory failed")
1044- factory := portsmocks.NewMockRemoteDialerFactory(t)1046+ factory := newRemoteDialerFactoryMock(t)
1045- factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(nil, factoryErr)1047+ factory.EXPECT().DialerForRemote("remote.example", "work", remoteadapter.TransportUDP, mock.Anything).Return(nil, factoryErr)
1046 runClientCalled := false1048 runClientCalled := false
1047 1049
1048 err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{1050 err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
1049- remoteDialerFactory: factory,1051+ remoteDialerFactory: factory.DialerForRemote,
1050 runClient: func(context.Context, client.Dependencies, client.AttachRequest) error {1052 runClient: func(context.Context, client.Dependencies, client.AttachRequest) error {
1051 runClientCalled = true1053 runClientCalled = true
1052 return nil1054 return nil
@@ -1064,10 +1066,10 @@ func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) {
1064 var gotDialer string1066 var gotDialer string
1065 gotRemote := true1067 gotRemote := true
1066 gotClipboard := ports.ClipboardReader(&fakeClipboardReader{})1068 gotClipboard := ports.ClipboardReader(&fakeClipboardReader{})
1067- factory := portsmocks.NewMockRemoteDialerFactory(t)1069+ factory := newRemoteDialerFactoryMock(t)
1068 err := runAttachWithDeps(context.Background(), protocol.IntentEphemeral, "", "", "", nil, runAttachDeps{1070 err := runAttachWithDeps(context.Background(), protocol.IntentEphemeral, "", "", "", nil, runAttachDeps{
1069- localDialer: func() ports.Dialer { return namedDialer{name: "local"} },1071+ localDialer: func() wire.Dialer { return namedDialer{name: "local"} },
1070- remoteDialerFactory: factory,1072+ remoteDialerFactory: factory.DialerForRemote,
1071 clipboard: &fakeClipboardReader{}, // must NOT reach runClient for a local attach1073 clipboard: &fakeClipboardReader{}, // must NOT reach runClient for a local attach
1072 runClient: func(ctx context.Context, deps client.Dependencies, request client.AttachRequest) error {1074 runClient: func(ctx context.Context, deps client.Dependencies, request client.AttachRequest) error {
1073 requireNamedClientDialer(t, ctx, deps.Dialer, "local")1075 requireNamedClientDialer(t, ctx, deps.Dialer, "local")
@@ -1287,12 +1289,12 @@ type appCatalogCacheStub struct {
1287 loads int1289 loads int
1288 }1290 }
1289 1291
1290-func (s *appCatalogCacheStub) Load() ([]ports.RemoteCatalogCacheEntry, error) {1292+func (s *appCatalogCacheStub) Load() ([]catalogue.RemoteCatalogCacheEntry, error) {
1291 s.loads++1293 s.loads++
1292 return nil, nil1294 return nil, nil
1293 }1295 }
1294 1296
1295-func (s *appCatalogCacheStub) Store([]ports.RemoteCatalogCacheEntry) error { return nil }1297+func (s *appCatalogCacheStub) Store([]catalogue.RemoteCatalogCacheEntry) error { return nil }
1296 1298
1297 func TestRemoteDiscoveryDaemonOptionWiresProductionPorts(t *testing.T) {1299 func TestRemoteDiscoveryDaemonOptionWiresProductionPorts(t *testing.T) {
1298 oldHostStore := newRemoteHostStore1300 oldHostStore := newRemoteHostStore
@@ -1318,9 +1320,9 @@ func TestRemoteDiscoveryDaemonOptionWiresProductionPorts(t *testing.T) {
1318 return cache1320 return cache
1319 }1321 }
1320 newRemoteCatalogClient = func() ports.RemoteCatalogClient { return nil }1322 newRemoteCatalogClient = func() ports.RemoteCatalogClient { return nil }
1321- newRemoteDialerFactoryWithRuntimeObserver = func(ports.SerializedRuntimeObserver) ports.RemoteDialerFactory { return nil }1323+ newRemoteDialerFactoryWithRuntimeObserver = func(ports.SerializedRuntimeObserver) remoteDialerForTarget { return nil }
1322 1324
1323- option, err := remoteDiscoveryDaemonOption(stateDir, nil, "stdio")1325+ option, err := remoteDiscoveryDaemonOption(stateDir, "stdio")
1324 require.NoError(t, err)1326 require.NoError(t, err)
1325 _ = daemon.New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)), option)1327 _ = daemon.New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)), option)
1326 require.Equal(t, remoteadapter.HostStorePath(stateDir), hostPath)1328 require.Equal(t, remoteadapter.HostStorePath(stateDir), hostPath)
@@ -1329,6 +1331,6 @@ func TestRemoteDiscoveryDaemonOptionWiresProductionPorts(t *testing.T) {
1329 }1331 }
1330 1332
1331 func TestRemoteDiscoveryDaemonOptionRejectsInvalidTransport(t *testing.T) {1333 func TestRemoteDiscoveryDaemonOptionRejectsInvalidTransport(t *testing.T) {
1332- _, err := remoteDiscoveryDaemonOption(t.TempDir(), nil, "serial")1334+ _, err := remoteDiscoveryDaemonOption(t.TempDir(), "serial")
1333 require.EqualError(t, err, `vev: invalid remote transport "serial" (want "udp" or "stdio")`)1335 require.EqualError(t, err, `vev: invalid remote transport "serial" (want "udp" or "stdio")`)
1334 }1336 }
M internal/app/spawn.go +9 -9
@@ -16,7 +16,7 @@ import (
16 "github.com/bnema/vev/internal/adapters/ipc"16 "github.com/bnema/vev/internal/adapters/ipc"
17 "github.com/bnema/vev/internal/adapters/lifecycle"17 "github.com/bnema/vev/internal/adapters/lifecycle"
18 "github.com/bnema/vev/internal/platform"18 "github.com/bnema/vev/internal/platform"
19- "github.com/bnema/vev/internal/ports"19+ "github.com/bnema/vev/internal/protocol/wire"
20 "github.com/bnema/vev/pkg/safedir"20 "github.com/bnema/vev/pkg/safedir"
21 )21 )
22 22
@@ -35,7 +35,7 @@ const staleLockAge = 10 * time.Second
35 35
36 // dialFunc dials the daemon socket in dir. Injected so tests can drive the36 // dialFunc dials the daemon socket in dir. Injected so tests can drive the
37 // spawn/backoff logic without a real socket.37 // 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)
39 39
40 // spawnFunc launches a detached daemon process. Injected so tests never40 // spawnFunc launches a detached daemon process. Injected so tests never
41 // re-exec a real binary.41 // re-exec a real binary.
@@ -115,11 +115,11 @@ func waitForLifecycleAvailability(ctx context.Context, runtimeDir string, cfg ba
115 }115 }
116 116
117 type daemonOrLifecycle struct {117 type daemonOrLifecycle struct {
118- transport ports.Transport118+ transport wire.Transport
119 owner lifecycleOwnership119 owner lifecycleOwnership
120 }120 }
121 121
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) {
123 result, err := retryAttempts(ctx, cfg, func() (daemonOrLifecycle, bool, error) {123 result, err := retryAttempts(ctx, cfg, func() (daemonOrLifecycle, bool, error) {
124 if transport, err := dial(ctx, dir); err == nil {124 if transport, err := dial(ctx, dir); err == nil {
125 return daemonOrLifecycle{transport: transport}, true, nil125 return daemonOrLifecycle{transport: transport}, true, nil
@@ -149,7 +149,7 @@ func waitBackoff(ctx context.Context, duration time.Duration) error {
149 149
150 // ensureDaemonWithLifecycle never elects a spawner while another lifecycle150 // ensureDaemonWithLifecycle never elects a spawner while another lifecycle
151 // owner may still be initializing or tearing down durable state.151 // 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) {
153 if err := ctx.Err(); err != nil {153 if err := ctx.Err(); err != nil {
154 return nil, err154 return nil, err
155 }155 }
@@ -172,7 +172,7 @@ func ensureDaemonWithLifecycle(ctx context.Context, dir string, dial dialFunc, s
172 // ensureDaemon returns a transport to a running daemon, spawning one if172 // ensureDaemon returns a transport to a running daemon, spawning one if
173 // necessary after ensureDaemonWithLifecycle has established lifecycle173 // necessary after ensureDaemonWithLifecycle has established lifecycle
174 // availability. Tests also exercise this lower-level spawn election directly.174 // 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) {
176 if err := ctx.Err(); err != nil {176 if err := ctx.Err(); err != nil {
177 return nil, err177 return nil, err
178 }178 }
@@ -237,8 +237,8 @@ func acquireSpawnLock(dir string) (release func(), acquired bool, err error) {
237 237
238 // retryDial dials repeatedly with exponential backoff until the daemon238 // retryDial dials repeatedly with exponential backoff until the daemon
239 // answers, the context is cancelled, or the total budget is exhausted.239 // 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) {240+func retryDial(ctx context.Context, dir string, dial dialFunc, cfg backoffConfig) (wire.Transport, error) {
241- transport, err := retryAttempts(ctx, cfg, func() (ports.Transport, bool, error) {241+ transport, err := retryAttempts(ctx, cfg, func() (wire.Transport, bool, error) {
242 transport, err := dial(ctx, dir)242 transport, err := dial(ctx, dir)
243 return transport, err == nil, nil243 return transport, err == nil, nil
244 })244 })
@@ -249,7 +249,7 @@ func retryDial(ctx context.Context, dir string, dial dialFunc, cfg backoffConfig
249 }249 }
250 250
251 // realDial is the production dialer.251 // 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) {
253 return ipc.DialContext(ctx, dir)253 return ipc.DialContext(ctx, dir)
254 }254 }
255 255
M internal/app/spawn_test.go +18 -13
@@ -13,12 +13,17 @@ import (
13 "testing"13 "testing"
14 "time"14 "time"
15 15
16- "github.com/bnema/vev/internal/ports"16+ "github.com/bnema/vev/internal/protocol/wire"
17- portsmocks "github.com/bnema/vev/internal/ports/mocks"
18 )17 )
19 18
20 var errDialFailed = errors.New("dial failed")19 var errDialFailed = errors.New("dial failed")
21 20
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+
22 const (27 const (
23 spawnTestChildFileEnv = "VEV_SPAWN_TEST_CHILD_FILE"28 spawnTestChildFileEnv = "VEV_SPAWN_TEST_CHILD_FILE"
24 spawnTestLauncherFileEnv = "VEV_SPAWN_TEST_LAUNCHER_FILE"29 spawnTestLauncherFileEnv = "VEV_SPAWN_TEST_LAUNCHER_FILE"
@@ -305,9 +310,9 @@ func TestRetryAttemptsClampsNonPositiveInitialDelay(t *testing.T) {
305 }310 }
306 311
307 func TestRetryDialSucceedsAfterTransientFailures(t *testing.T) {312 func TestRetryDialSucceedsAfterTransientFailures(t *testing.T) {
308- want := portsmocks.NewMockTransport(t)313+ want := spawnTestTransport{}
309 var calls atomic.Int32314 var calls atomic.Int32
310- dial := func(context.Context, string) (ports.Transport, error) {315+ dial := func(context.Context, string) (wire.Transport, error) {
311 if calls.Add(1) <= 3 {316 if calls.Add(1) <= 3 {
312 return nil, errDialFailed317 return nil, errDialFailed
313 }318 }
@@ -327,7 +332,7 @@ func TestRetryDialSucceedsAfterTransientFailures(t *testing.T) {
327 }332 }
328 333
329 func TestRetryDialPermanentFailure(t *testing.T) {334 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 }
331 _, err := retryDial(context.Background(), t.TempDir(), dial, fastBackoff)336 _, err := retryDial(context.Background(), t.TempDir(), dial, fastBackoff)
332 if !errors.Is(err, ErrDaemonUnreachable) {337 if !errors.Is(err, ErrDaemonUnreachable) {
333 t.Fatalf("retryDial error = %v, want ErrDaemonUnreachable", err)338 t.Fatalf("retryDial error = %v, want ErrDaemonUnreachable", err)
@@ -338,7 +343,7 @@ func TestRetryDialContextCancelled(t *testing.T) {
338 ctx, cancel := context.WithCancel(context.Background())343 ctx, cancel := context.WithCancel(context.Background())
339 cancel()344 cancel()
340 var calls atomic.Int32345 var calls atomic.Int32
341- dial := func(context.Context, string) (ports.Transport, error) {346+ dial := func(context.Context, string) (wire.Transport, error) {
342 calls.Add(1)347 calls.Add(1)
343 return nil, errDialFailed348 return nil, errDialFailed
344 }349 }
@@ -354,7 +359,7 @@ func TestRetryDialContextCancelled(t *testing.T) {
354 func TestEnsureDaemonContextCancelledDoesNotDialOrSpawn(t *testing.T) {359 func TestEnsureDaemonContextCancelledDoesNotDialOrSpawn(t *testing.T) {
355 ctx, cancel := context.WithCancel(context.Background())360 ctx, cancel := context.WithCancel(context.Background())
356 cancel()361 cancel()
357- dial := func(context.Context, string) (ports.Transport, error) {362+ dial := func(context.Context, string) (wire.Transport, error) {
358 t.Fatal("ensureDaemon dialed with a canceled context")363 t.Fatal("ensureDaemon dialed with a canceled context")
359 return nil, nil364 return nil, nil
360 }365 }
@@ -373,7 +378,7 @@ func TestEnsureDaemonDoesNotSpawnWhenContextCancelsAfterFailedDial(t *testing.T)
373 ctx, cancel := context.WithCancel(context.Background())378 ctx, cancel := context.WithCancel(context.Background())
374 defer cancel()379 defer cancel()
375 var spawnCalls atomic.Int32380 var spawnCalls atomic.Int32
376- dial := func(context.Context, string) (ports.Transport, error) {381+ dial := func(context.Context, string) (wire.Transport, error) {
377 cancel()382 cancel()
378 return nil, errDialFailed383 return nil, errDialFailed
379 }384 }
@@ -393,9 +398,9 @@ func TestEnsureDaemonDoesNotSpawnWhenContextCancelsAfterFailedDial(t *testing.T)
393 398
394 func TestEnsureDaemonSpawnsThenDials(t *testing.T) {399 func TestEnsureDaemonSpawnsThenDials(t *testing.T) {
395 dir := filepath.Join(t.TempDir(), "vev")400 dir := filepath.Join(t.TempDir(), "vev")
396- want := portsmocks.NewMockTransport(t)401+ want := spawnTestTransport{}
397 var dialCalls, spawnCalls atomic.Int32402 var dialCalls, spawnCalls atomic.Int32
398- dial := func(context.Context, string) (ports.Transport, error) {403+ dial := func(context.Context, string) (wire.Transport, error) {
399 // First (pre-spawn) dial fails; the post-spawn retry succeeds.404 // First (pre-spawn) dial fails; the post-spawn retry succeeds.
400 if dialCalls.Add(1) == 1 {405 if dialCalls.Add(1) == 1 {
401 return nil, errDialFailed406 return nil, errDialFailed
@@ -424,9 +429,9 @@ func TestEnsureDaemonSpawnsThenDials(t *testing.T) {
424 }429 }
425 430
426 func TestEnsureDaemonReturnsExistingDaemon(t *testing.T) {431 func TestEnsureDaemonReturnsExistingDaemon(t *testing.T) {
427- want := portsmocks.NewMockTransport(t)432+ want := spawnTestTransport{}
428 var spawnCalls atomic.Int32433 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 }
430 spawn := func() error { spawnCalls.Add(1); return nil }435 spawn := func() error { spawnCalls.Add(1); return nil }
431 436
432 got, err := ensureDaemon(context.Background(), t.TempDir(), dial, spawn, fastBackoff)437 got, err := ensureDaemon(context.Background(), t.TempDir(), dial, spawn, fastBackoff)
@@ -443,7 +448,7 @@ func TestEnsureDaemonReturnsExistingDaemon(t *testing.T) {
443 448
444 func TestEnsureDaemonSpawnFailure(t *testing.T) {449 func TestEnsureDaemonSpawnFailure(t *testing.T) {
445 spawnErr := errors.New("exec boom")450 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 }
447 spawn := func() error { return spawnErr }452 spawn := func() error { return spawnErr }
448 453
449 _, err := ensureDaemon(context.Background(), filepath.Join(t.TempDir(), "vev"), dial, spawn, fastBackoff)454 _, 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 (
13 "testing"13 "testing"
14 "time"14 "time"
15 15
16+ "github.com/bnema/vev/internal/adapters/observability"
17+ remoteadapter "github.com/bnema/vev/internal/adapters/remote"
16 "github.com/bnema/vev/internal/ports"18 "github.com/bnema/vev/internal/ports"
17 portsmocks "github.com/bnema/vev/internal/ports/mocks"19 portsmocks "github.com/bnema/vev/internal/ports/mocks"
18 "github.com/bnema/vev/internal/protocol"20 "github.com/bnema/vev/internal/protocol"
@@ -182,7 +184,7 @@ func TestRunAttachJoinsTraceCloseError(t *testing.T) {
182 traceCloseErr := errors.New("trace close failed")184 traceCloseErr := errors.New("trace close failed")
183 original := newPerformanceTrace185 original := newPerformanceTrace
184 newPerformanceTrace = func(ports.Clock) (ports.SerializedRuntimeObserver, io.Closer, error) {186 newPerformanceTrace = func(ports.Clock) (ports.SerializedRuntimeObserver, io.Closer, error) {
185- return ports.NewSerializedRuntimeObserver(runtimeObserverFunc(func(ports.RuntimeMark) {}), 1), errorCloser{err: traceCloseErr}, nil187+ return observability.NewSerialized(runtimeObserverFunc(func(ports.RuntimeMark) {}), 1), errorCloser{err: traceCloseErr}, nil
186 }188 }
187 t.Cleanup(func() { newPerformanceTrace = original })189 t.Cleanup(func() { newPerformanceTrace = original })
188 t.Setenv("VEV", "")190 t.Setenv("VEV", "")
@@ -211,9 +213,9 @@ func TestRunAttachPropagatesOneObserverToRemoteTransportFactory(t *testing.T) {
211 t.Setenv("VEV", "")213 t.Setenv("VEV", "")
212 t.Setenv("XDG_STATE_HOME", t.TempDir())214 t.Setenv("XDG_STATE_HOME", t.TempDir())
213 t.Setenv("XDG_RUNTIME_DIR", t.TempDir())215 t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
214- t.Setenv(envRemoteTransport, string(ports.RemoteTransportStdio))216+ t.Setenv(envRemoteTransport, string(remoteadapter.TransportStdio))
215 217
216- observer := ports.NewSerializedRuntimeObserver(runtimeObserverFunc(func(ports.RuntimeMark) {}), 1)218+ observer := observability.NewSerialized(runtimeObserverFunc(func(ports.RuntimeMark) {}), 1)
217 originalTrace := newPerformanceTrace219 originalTrace := newPerformanceTrace
218 traceCalls := 0220 traceCalls := 0
219 newPerformanceTrace = func(ports.Clock) (ports.SerializedRuntimeObserver, io.Closer, error) {221 newPerformanceTrace = func(ports.Clock) (ports.SerializedRuntimeObserver, io.Closer, error) {
@@ -222,16 +224,16 @@ func TestRunAttachPropagatesOneObserverToRemoteTransportFactory(t *testing.T) {
222 }224 }
223 t.Cleanup(func() { newPerformanceTrace = originalTrace })225 t.Cleanup(func() { newPerformanceTrace = originalTrace })
224 226
225- factory := portsmocks.NewMockRemoteDialerFactory(t)227+ factory := newRemoteDialerFactoryMock(t)
226- factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportStdio, mock.Anything).Return(namedDialer{name: "remote"}, nil)228+ factory.EXPECT().DialerForRemote("remote.example", "work", remoteadapter.TransportStdio, mock.Anything).Return(namedDialer{name: "remote"}, nil)
227 originalFactory := newRemoteDialerFactoryWithRuntimeObserver229 originalFactory := newRemoteDialerFactoryWithRuntimeObserver
228 factoryCalls := 0230 factoryCalls := 0
229- newRemoteDialerFactoryWithRuntimeObserver = func(got ports.SerializedRuntimeObserver) ports.RemoteDialerFactory {231+ newRemoteDialerFactoryWithRuntimeObserver = func(got ports.SerializedRuntimeObserver) remoteDialerForTarget {
230 factoryCalls++232 factoryCalls++
231 if got != observer {233 if got != observer {
232 t.Fatalf("remote transport observer = %v, want process observer %v", got, observer)234 t.Fatalf("remote transport observer = %v, want process observer %v", got, observer)
233 }235 }
234- return factory236+ return factory.DialerForRemote
235 }237 }
236 t.Cleanup(func() { newRemoteDialerFactoryWithRuntimeObserver = originalFactory })238 t.Cleanup(func() { newRemoteDialerFactoryWithRuntimeObserver = originalFactory })
237 239
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
6 6
7 import (7 import (
8 "context"8 "context"
9- "log/slog"
10 9
11 "github.com/bnema/vev/internal/domain"10 "github.com/bnema/vev/internal/domain"
12- "github.com/bnema/vev/internal/ports"
13 "github.com/bnema/vev/internal/protocol"11 "github.com/bnema/vev/internal/protocol"
12+ "github.com/bnema/vev/internal/protocol/catalogue"
14 mock "github.com/stretchr/testify/mock"13 mock "github.com/stretchr/testify/mock"
15 )14 )
16 15
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-
124 // 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.16 // 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.
125 // The first argument is typically a *testing.T value.17 // The first argument is typically a *testing.T value.
126 func NewMockRemoteHostStore(t interface {18 func NewMockRemoteHostStore(t interface {
@@ -574,22 +466,22 @@ func (_m *MockRemoteCatalogClient) EXPECT() *MockRemoteCatalogClient_Expecter {
574 }466 }
575 467
576 // List provides a mock function for the type MockRemoteCatalogClient468 // 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) {
578 ret := _mock.Called(ctx, target)470 ret := _mock.Called(ctx, target)
579 471
580 if len(ret) == 0 {472 if len(ret) == 0 {
581 panic("no return value specified for List")473 panic("no return value specified for List")
582 }474 }
583 475
584- var r0 ports.RemoteCatalog476+ var r0 catalogue.RemoteCatalog
585 var r1 error477 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 {
587 return returnFunc(ctx, target)479 return returnFunc(ctx, target)
588 }480 }
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 {
590 r0 = returnFunc(ctx, target)482 r0 = returnFunc(ctx, target)
591 } else {483 } else {
592- r0 = ret.Get(0).(ports.RemoteCatalog)484+ r0 = ret.Get(0).(catalogue.RemoteCatalog)
593 }485 }
594 if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {486 if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
595 r1 = returnFunc(ctx, target)487 r1 = returnFunc(ctx, target)
@@ -629,12 +521,12 @@ func (_c *MockRemoteCatalogClient_List_Call) Run(run func(ctx context.Context, t
629 return _c521 return _c
630 }522 }
631 523
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 {
633 _c.Call.Return(remoteCatalog, err)525 _c.Call.Return(remoteCatalog, err)
634 return _c526 return _c
635 }527 }
636 528
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 {
638 _c.Call.Return(run)530 _c.Call.Return(run)
639 return _c531 return _c
640 }532 }
@@ -772,23 +664,23 @@ func (_m *MockRemoteCatalogCache) EXPECT() *MockRemoteCatalogCache_Expecter {
772 }664 }
773 665
774 // Load provides a mock function for the type MockRemoteCatalogCache666 // 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) {
776 ret := _mock.Called()668 ret := _mock.Called()
777 669
778 if len(ret) == 0 {670 if len(ret) == 0 {
779 panic("no return value specified for Load")671 panic("no return value specified for Load")
780 }672 }
781 673
782- var r0 []ports.RemoteCatalogCacheEntry674+ var r0 []catalogue.RemoteCatalogCacheEntry
783 var r1 error675 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 {
785 return returnFunc()677 return returnFunc()
786 }678 }
787- if returnFunc, ok := ret.Get(0).(func() []ports.RemoteCatalogCacheEntry); ok {679+ if returnFunc, ok := ret.Get(0).(func() []catalogue.RemoteCatalogCacheEntry); ok {
788 r0 = returnFunc()680 r0 = returnFunc()
789 } else {681 } else {
790 if ret.Get(0) != nil {682 if ret.Get(0) != nil {
791- r0 = ret.Get(0).([]ports.RemoteCatalogCacheEntry)683+ r0 = ret.Get(0).([]catalogue.RemoteCatalogCacheEntry)
792 }684 }
793 }685 }
794 if returnFunc, ok := ret.Get(1).(func() error); ok {686 if returnFunc, ok := ret.Get(1).(func() error); ok {
@@ -816,18 +708,18 @@ func (_c *MockRemoteCatalogCache_Load_Call) Run(run func()) *MockRemoteCatalogCa
816 return _c708 return _c
817 }709 }
818 710
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 {
820 _c.Call.Return(remoteCatalogCacheEntrys, err)712 _c.Call.Return(remoteCatalogCacheEntrys, err)
821 return _c713 return _c
822 }714 }
823 715
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 {
825 _c.Call.Return(run)717 _c.Call.Return(run)
826 return _c718 return _c
827 }719 }
828 720
829 // Store provides a mock function for the type MockRemoteCatalogCache721 // 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 {
831 ret := _mock.Called(remoteCatalogCacheEntrys)723 ret := _mock.Called(remoteCatalogCacheEntrys)
832 724
833 if len(ret) == 0 {725 if len(ret) == 0 {
@@ -835,7 +727,7 @@ func (_mock *MockRemoteCatalogCache) Store(remoteCatalogCacheEntrys []ports.Remo
835 }727 }
836 728
837 var r0 error729 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 {
839 r0 = returnFunc(remoteCatalogCacheEntrys)731 r0 = returnFunc(remoteCatalogCacheEntrys)
840 } else {732 } else {
841 r0 = ret.Error(0)733 r0 = ret.Error(0)
@@ -849,16 +741,16 @@ type MockRemoteCatalogCache_Store_Call struct {
849 }741 }
850 742
851 // Store is a helper method to define mock.On call743 // Store is a helper method to define mock.On call
852-// - remoteCatalogCacheEntrys []ports.RemoteCatalogCacheEntry744+// - remoteCatalogCacheEntrys []catalogue.RemoteCatalogCacheEntry
853 func (_e *MockRemoteCatalogCache_Expecter) Store(remoteCatalogCacheEntrys any) *MockRemoteCatalogCache_Store_Call {745 func (_e *MockRemoteCatalogCache_Expecter) Store(remoteCatalogCacheEntrys any) *MockRemoteCatalogCache_Store_Call {
854 return &MockRemoteCatalogCache_Store_Call{Call: _e.mock.On("Store", remoteCatalogCacheEntrys)}746 return &MockRemoteCatalogCache_Store_Call{Call: _e.mock.On("Store", remoteCatalogCacheEntrys)}
855 }747 }
856 748
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 {
858 _c.Call.Run(func(args mock.Arguments) {750 _c.Call.Run(func(args mock.Arguments) {
859- var arg0 []ports.RemoteCatalogCacheEntry751+ var arg0 []catalogue.RemoteCatalogCacheEntry
860 if args[0] != nil {752 if args[0] != nil {
861- arg0 = args[0].([]ports.RemoteCatalogCacheEntry)753+ arg0 = args[0].([]catalogue.RemoteCatalogCacheEntry)
862 }754 }
863 run(755 run(
864 arg0,756 arg0,
@@ -872,7 +764,7 @@ func (_c *MockRemoteCatalogCache_Store_Call) Return(err error) *MockRemoteCatalo
872 return _c764 return _c
873 }765 }
874 766
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 {
876 _c.Call.Return(run)768 _c.Call.Return(run)
877 return _c769 return _c
878 }770 }
M internal/ports/remote.go +8 -306
@@ -2,338 +2,40 @@ package ports
2 2
3 import (3 import (
4 "context"4 "context"
5- "errors"
6- "fmt"
7- "log/slog"
8- "math"
9- "slices"
10- "time"
11- "unicode"
12- "unicode/utf8"
13 5
14 "github.com/bnema/vev/internal/domain"6 "github.com/bnema/vev/internal/domain"
15 "github.com/bnema/vev/internal/protocol"7 "github.com/bnema/vev/internal/protocol"
8+ "github.com/bnema/vev/internal/protocol/catalogue"
16 )9 )
17 10
18-// RemoteTransportMode selects the transport used for remote attach after CLI parsing.11+// RemoteHostStore persists pinned and learned remote host targets.
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.
34 type RemoteHostStore interface {12 type RemoteHostStore interface {
35- // Hosts returns pinned hosts in stored order and learned hosts in lexical order.
36 Hosts() (pinned, learned []string, err error)13 Hosts() (pinned, learned []string, err error)
37 AddPinned(target string) error14 AddPinned(target string) error
38 RemovePinned(target string) error15 RemovePinned(target string) error
39 Remember(target string) error16 Remember(target string) error
40 Forget(target string) error17 Forget(target string) error
41- // Remove deletes target from both pinned and learned lists atomically and
42- // reports whether the target existed in either list.
43 Remove(target string) (deleted bool, err error)18 Remove(target string) (deleted bool, err error)
44 }19 }
45 20
46-// RemoteHostLearner records the remote target after a successful attach.21+// RemoteHostLearner records the validated remote target after an attach.
47-// It deliberately has no arguments because app captures the validated target
48-// while constructing the client dependency.
49 type RemoteHostLearner interface {22 type RemoteHostLearner interface {
50 RememberRemoteHost() error23 RememberRemoteHost() error
51 }24 }
52 25
53-// RemoteCatalogClient fetches a versioned session catalog from a remote host.26+// RemoteCatalogClient fetches a versioned session catalogue from a remote host.
54 type RemoteCatalogClient interface {27 type RemoteCatalogClient interface {
55- List(ctx context.Context, target string) (RemoteCatalog, error)28+ List(ctx context.Context, target string) (catalogue.RemoteCatalog, error)
56 }29 }
57 30
58-// RemotePreviewClient fetches one bounded, exact-target viewport without31+// RemotePreviewClient fetches one bounded, exact-target viewport.
59-// attaching to or mutating the remote session.
60 type RemotePreviewClient interface {32 type RemotePreviewClient interface {
61 Preview(ctx context.Context, target domain.RemoteSessionTarget, width, height uint16) (protocol.RemotePreview, error)33 Preview(ctx context.Context, target domain.RemoteSessionTarget, width, height uint16) (protocol.RemotePreview, error)
62 }34 }
63 35
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-
87 // RemoteCatalogCache persists complete remote discovery snapshots independently36 // RemoteCatalogCache persists complete remote discovery snapshots independently
88 // from the remote host registry.37 // from the remote host registry.
89 type RemoteCatalogCache interface {38 type RemoteCatalogCache interface {
90- Load() ([]RemoteCatalogCacheEntry, error)39+ Load() ([]catalogue.RemoteCatalogCacheEntry, error)
91- Store([]RemoteCatalogCacheEntry) error40+ Store([]catalogue.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- }
339 }41 }
M internal/ports/runtime_observer.go +0 -138
@@ -2,7 +2,6 @@ package ports
2 2
3 import (3 import (
4 "errors"4 "errors"
5- "sync"
6 "sync/atomic"5 "sync/atomic"
7 )6 )
8 7
@@ -75,143 +74,6 @@ type SerializedRuntimeObserver interface {
75 Close()74 Close()
76 }75 }
77 76
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-
215 // RuntimeMarkValid reports whether a mark has all correlation fields required77 // RuntimeMarkValid reports whether a mark has all correlation fields required
216 // by the JSONL schema. Tick is checked separately by the timestamp owner.78 // by the JSONL schema. Tick is checked separately by the timestamp owner.
217 func RuntimeMarkValid(mark RuntimeMark) bool {79 func RuntimeMarkValid(mark RuntimeMark) bool {
M internal/ports/runtime_observer_test.go +0 -66
@@ -2,9 +2,7 @@ package ports
2 2
3 import (3 import (
4 "reflect"4 "reflect"
5- "sync"
6 "testing"5 "testing"
7- "time"
8 )6 )
9 7
10 func TestRuntimeObserverContract(t *testing.T) {8 func TestRuntimeObserverContract(t *testing.T) {
@@ -44,70 +42,6 @@ func TestRuntimeObserverContract(t *testing.T) {
44 var _ RuntimeObserver = runtimeObserverFunc(func(RuntimeMark) {})42 var _ RuntimeObserver = runtimeObserverFunc(func(RuntimeMark) {})
45 }43 }
46 44
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-
111 func TestP4RuntimeScreenMarkKindsValidateSchema1(t *testing.T) {45 func TestP4RuntimeScreenMarkKindsValidateSchema1(t *testing.T) {
112 if RuntimeMarkSchema != 1 {46 if RuntimeMarkSchema != 1 {
113 t.Fatalf("RuntimeMarkSchema = %d, want 1", RuntimeMarkSchema)47 t.Fatalf("RuntimeMarkSchema = %d, want 1", RuntimeMarkSchema)
M internal/ports/terminal.go +1 -110
@@ -2,12 +2,11 @@ package ports
2 2
3 import (3 import (
4 "io"4 "io"
5- "strings"
6 5
7 "github.com/bnema/vev/internal/domain"6 "github.com/bnema/vev/internal/domain"
8 )7 )
9 8
10-// Terminal is the CLIENT-side controlling terminal.9+// Terminal is the client-side controlling terminal.
11 type Terminal interface {10 type Terminal interface {
12 EnterRaw() (restore func() error, err error)11 EnterRaw() (restore func() error, err error)
13 Geometry() (domain.Geometry, error)12 Geometry() (domain.Geometry, error)
@@ -16,111 +15,3 @@ type Terminal interface {
16 Out() io.Writer15 Out() io.Writer
17 Flush() error16 Flush() error
18 }17 }
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 ports1+package catalogue
2 2
3 import (3 import (
4 "testing"4 "testing"
R internal/ports/mocks/transport_mock.go → internal/protocol/wire/mocks/transport_mock.go +23 -24 renamed, 93% similar
@@ -2,12 +2,11 @@
2 // github.com/vektra/mockery2 // github.com/vektra/mockery
3 // template: testify3 // template: testify
4 4
5-package portsmocks5+package wiremocks
6 6
7 import (7 import (
8 "context"8 "context"
9 9
10- "github.com/bnema/vev/internal/ports"
11 "github.com/bnema/vev/internal/protocol/wire"10 "github.com/bnema/vev/internal/protocol/wire"
12 mock "github.com/stretchr/testify/mock"11 mock "github.com/stretchr/testify/mock"
13 )12 )
@@ -579,27 +578,27 @@ func (_m *MockDialer) EXPECT() *MockDialer_Expecter {
579 }578 }
580 579
581 // Dial provides a mock function for the type MockDialer580 // Dial provides a mock function for the type MockDialer
582-func (_mock *MockDialer) Dial(ctx context.Context) (ports.Transport, error) {581+func (_mock *MockDialer) Dial(context1 context.Context) (wire.Transport, error) {
583- ret := _mock.Called(ctx)582+ ret := _mock.Called(context1)
584 583
585 if len(ret) == 0 {584 if len(ret) == 0 {
586 panic("no return value specified for Dial")585 panic("no return value specified for Dial")
587 }586 }
588 587
589- var r0 ports.Transport588+ var r0 wire.Transport
590 var r1 error589 var r1 error
591- if returnFunc, ok := ret.Get(0).(func(context.Context) (ports.Transport, error)); ok {590+ if returnFunc, ok := ret.Get(0).(func(context.Context) (wire.Transport, error)); ok {
592- return returnFunc(ctx)591+ return returnFunc(context1)
593 }592 }
594- if returnFunc, ok := ret.Get(0).(func(context.Context) ports.Transport); ok {593+ if returnFunc, ok := ret.Get(0).(func(context.Context) wire.Transport); ok {
595- r0 = returnFunc(ctx)594+ r0 = returnFunc(context1)
596 } else {595 } else {
597 if ret.Get(0) != nil {596 if ret.Get(0) != nil {
598- r0 = ret.Get(0).(ports.Transport)597+ r0 = ret.Get(0).(wire.Transport)
599 }598 }
600 }599 }
601 if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {600 if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
602- r1 = returnFunc(ctx)601+ r1 = returnFunc(context1)
603 } else {602 } else {
604 r1 = ret.Error(1)603 r1 = ret.Error(1)
605 }604 }
@@ -612,12 +611,12 @@ type MockDialer_Dial_Call struct {
612 }611 }
613 612
614 // Dial is a helper method to define mock.On call613 // Dial is a helper method to define mock.On call
615-// - ctx context.Context614+// - context1 context.Context
616-func (_e *MockDialer_Expecter) Dial(ctx any) *MockDialer_Dial_Call {615+func (_e *MockDialer_Expecter) Dial(context1 any) *MockDialer_Dial_Call {
617- return &MockDialer_Dial_Call{Call: _e.mock.On("Dial", ctx)}616+ return &MockDialer_Dial_Call{Call: _e.mock.On("Dial", context1)}
618 }617 }
619 618
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 {
621 _c.Call.Run(func(args mock.Arguments) {620 _c.Call.Run(func(args mock.Arguments) {
622 var arg0 context.Context621 var arg0 context.Context
623 if args[0] != nil {622 if args[0] != nil {
@@ -630,12 +629,12 @@ func (_c *MockDialer_Dial_Call) Run(run func(ctx context.Context)) *MockDialer_D
630 return _c629 return _c
631 }630 }
632 631
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 {
634 _c.Call.Return(transport, err)633 _c.Call.Return(transport, err)
635 return _c634 return _c
636 }635 }
637 636
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 {
639 _c.Call.Return(run)638 _c.Call.Return(run)
640 return _c639 return _c
641 }640 }
@@ -668,23 +667,23 @@ func (_m *MockListener) EXPECT() *MockListener_Expecter {
668 }667 }
669 668
670 // Accept provides a mock function for the type MockListener669 // 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) {
672 ret := _mock.Called()671 ret := _mock.Called()
673 672
674 if len(ret) == 0 {673 if len(ret) == 0 {
675 panic("no return value specified for Accept")674 panic("no return value specified for Accept")
676 }675 }
677 676
678- var r0 ports.Transport677+ var r0 wire.Transport
679 var r1 error678 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 {
681 return returnFunc()680 return returnFunc()
682 }681 }
683- if returnFunc, ok := ret.Get(0).(func() ports.Transport); ok {682+ if returnFunc, ok := ret.Get(0).(func() wire.Transport); ok {
684 r0 = returnFunc()683 r0 = returnFunc()
685 } else {684 } else {
686 if ret.Get(0) != nil {685 if ret.Get(0) != nil {
687- r0 = ret.Get(0).(ports.Transport)686+ r0 = ret.Get(0).(wire.Transport)
688 }687 }
689 }688 }
690 if returnFunc, ok := ret.Get(1).(func() error); ok {689 if returnFunc, ok := ret.Get(1).(func() error); ok {
@@ -712,12 +711,12 @@ func (_c *MockListener_Accept_Call) Run(run func()) *MockListener_Accept_Call {
712 return _c711 return _c
713 }712 }
714 713
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 {
716 _c.Call.Return(transport, err)715 _c.Call.Return(transport, err)
717 return _c716 return _c
718 }717 }
719 718
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 {
721 _c.Call.Return(run)720 _c.Call.Return(run)
722 return _c721 return _c
723 }722 }
R internal/ports/transport.go → internal/protocol/wire/transport.go +11 -15 renamed, 66% similar
@@ -1,22 +1,18 @@
1-package ports1+package wire
2 2
3-import (3+import "context"
4- "context"
5-
6- "github.com/bnema/vev/internal/protocol/wire"
7-)
8 4
9 // Transport is a framed message channel over a single connection. Close must5 // Transport is a framed message channel over a single connection. Close must
10 // be safe to call concurrently with Send and Recv, and must unblock active6 // be safe to call concurrently with Send and Recv, and must unblock active
11 // Send and Recv calls.7 // Send and Recv calls.
12 type Transport interface {8 type Transport interface {
13- Send(wire.Frame) error9+ Send(Frame) error
14- Recv() (wire.Frame, error) // blocking; io.EOF on close10+ Recv() (Frame, error) // blocking; io.EOF on close
15 Close() error11 Close() error
16 }12 }
17 13
18-// DatagramTransport marks transports backed by a datagram link. It lets14+// DatagramTransport marks transports backed by a datagram link. Typed session
19-// usecases negotiate a conservative output window without importing adapters.15+// adapters translate this carriage detail into semantic capabilities.
20 type DatagramTransport interface {16 type DatagramTransport interface {
21 Transport17 Transport
22 DatagramTransport()18 DatagramTransport()
@@ -26,21 +22,21 @@ type DatagramTransport interface {
26 // returns once the adapter owns the frame; Send retains its synchronous wire-22 // returns once the adapter owns the frame; Send retains its synchronous wire-
27 // attempt contract. Daemon paint output may use this capability to pipeline.23 // attempt contract. Daemon paint output may use this capability to pipeline.
28 type AsyncTransport interface {24 type AsyncTransport interface {
29- SendAsync(wire.Frame) error25+ SendAsync(Frame) error
30 }26 }
31 27
32 // OwnedSynchronousTransport owns the complete bounded synchronous operation,28 // OwnedSynchronousTransport owns the complete bounded synchronous operation,
33 // including adapter queues, pacing, write deadlines, and close cancellation.29 // including adapter queues, pacing, write deadlines, and close cancellation.
34 type OwnedSynchronousTransport interface {30 type OwnedSynchronousTransport interface {
35- SendSynchronous(wire.Frame) error31+ SendSynchronous(Frame) error
36 }32 }
37 33
38-// Dialer establishes outbound Transport connections.34+// Dialer establishes outbound raw carriage connections.
39 type Dialer interface {35 type Dialer interface {
40- Dial(ctx context.Context) (Transport, error)36+ Dial(context.Context) (Transport, error)
41 }37 }
42 38
43-// Listener accepts incoming Transport connections.39+// Listener accepts incoming raw carriage connections.
44 type Listener interface {40 type Listener interface {
45 Accept() (Transport, error)41 Accept() (Transport, error)
46 Close() error42 Close() error
M internal/usecase/client/client.go +2 -1
@@ -22,6 +22,7 @@ import (
22 renderer "github.com/bnema/vev-vt"22 renderer "github.com/bnema/vev-vt"
23 "github.com/bnema/vev-vt/protocol/terminalquery"23 "github.com/bnema/vev-vt/protocol/terminalquery"
24 "github.com/bnema/vev/internal/domain"24 "github.com/bnema/vev/internal/domain"
25+ "github.com/bnema/vev/internal/domain/terminalcap"
25 "github.com/bnema/vev/internal/ports"26 "github.com/bnema/vev/internal/ports"
26 "github.com/bnema/vev/internal/protocol"27 "github.com/bnema/vev/internal/protocol"
27 "github.com/bnema/vev/internal/usecase/theme"28 "github.com/bnema/vev/internal/usecase/theme"
@@ -1121,7 +1122,7 @@ func paletteSlot(slot int) (uint8, bool) {
1121 1122
1122 // DetectTrueColor reports whether TERM/COLORTERM advertise direct color support.1123 // DetectTrueColor reports whether TERM/COLORTERM advertise direct color support.
1123 func DetectTrueColor(termEnv, colorTerm string) bool {1124 func DetectTrueColor(termEnv, colorTerm string) bool {
1124- return ports.DetectTrueColor(termEnv, colorTerm)1125+ return terminalcap.DetectTrueColor(termEnv, colorTerm)
1125 }1126 }
1126 1127
1127 func requestedOutputWindow(connection ports.ClientConnection) uint8 {1128 func requestedOutputWindow(connection ports.ClientConnection) uint8 {
M internal/usecase/client/client_test.go +27 -27
@@ -123,7 +123,7 @@ type recvItem struct {
123 wait <-chan struct{}123 wait <-chan struct{}
124 }124 }
125 125
126-type markedDatagramTransport struct{ ports.Transport }126+type markedDatagramTransport struct{ wire.Transport }
127 127
128 func (markedDatagramTransport) DatagramTransport() {}128 func (markedDatagramTransport) DatagramTransport() {}
129 129
@@ -240,7 +240,7 @@ func isType(typ wire.MsgType) any {
240 }240 }
241 241
242 // transportDialer adapts one already-open test transport to the Runner API.242 // 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 }
244 244
245 func (d transportDialer) Dial(context.Context) (ports.ClientConnection, error) {245 func (d transportDialer) Dial(context.Context) (ports.ClientConnection, error) {
246 return &rawClientConnection{raw: d.transport}, nil246 return &rawClientConnection{raw: d.transport}, nil
@@ -262,7 +262,7 @@ func testDependencies(dialer ports.ClientDialer, terminal ports.Terminal, clock
262 }262 }
263 }263 }
264 264
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 {
266 return testDependencies(transportDialer{transport: transport}, terminal, clock, nil, nil)266 return testDependencies(transportDialer{transport: transport}, terminal, clock, nil, nil)
267 }267 }
268 268
@@ -287,7 +287,7 @@ func TestRunBoundsBlockedDial(t *testing.T) {
287 clk, createdTimers := newHandshakeClock(t, 1)287 clk, createdTimers := newHandshakeClock(t, 1)
288 started := make(chan struct{})288 started := make(chan struct{})
289 dialer := newMockClientDialer(t)289 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) {
291 close(started)291 close(started)
292 <-ctx.Done()292 <-ctx.Done()
293 return nil, ctx.Err()293 return nil, ctx.Err()
@@ -503,7 +503,7 @@ func TestLocalOnlyHandoffBetweenLocalSessionsKeepsClientRunning(t *testing.T) {
503 {f: frameOf(wire.MsgCommittedRouteIdentity, mustMarshalCommittedIdentity(protocol.CommittedRouteIdentity{Target: secondTarget}))},503 {f: frameOf(wire.MsgCommittedRouteIdentity, mustMarshalCommittedIdentity(protocol.CommittedRouteIdentity{Target: secondTarget}))},
504 {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))},504 {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))},
505 }}505 }}
506- dialer := &sequenceDialer{trs: []ports.Transport{first}}506+ dialer := &sequenceDialer{trs: []wire.Transport{first}}
507 507
508 err := runTestClient(context.Background(), testDependencies(dialer, term, realClock{}, nil, nil), client.AttachRequest{508 err := runTestClient(context.Background(), testDependencies(dialer, term, realClock{}, nil, nil), client.AttachRequest{
509 Intent: protocol.IntentAttach, SessionName: "first",509 Intent: protocol.IntentAttach, SessionName: "first",
@@ -570,7 +570,7 @@ func TestStoppedLocalHandoffDialsReplacementTransport(t *testing.T) {
570 {f: welcome("stopped", targetLifecycle)},570 {f: welcome("stopped", targetLifecycle)},
571 {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))},571 {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))},
572 }}572 }}
573- dialer := &sequenceDialer{trs: []ports.Transport{first, second}}573+ dialer := &sequenceDialer{trs: []wire.Transport{first, second}}
574 574
575 err := runTestClient(context.Background(), testDependencies(dialer, term, realClock{}, nil, nil), client.AttachRequest{575 err := runTestClient(context.Background(), testDependencies(dialer, term, realClock{}, nil, nil), client.AttachRequest{
576 Intent: protocol.IntentAttach, SessionName: "source",576 Intent: protocol.IntentAttach, SessionName: "source",
@@ -613,8 +613,8 @@ func TestAcceptedHomeActionUsesCapturedRouteAfterRequestMetadataRebase(t *testin
613 {f: welcome("local", localLifecycle)},613 {f: welcome("local", localLifecycle)},
614 {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))},614 {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))},
615 }}615 }}
616- localDialer := &sequenceDialer{trs: []ports.Transport{local1, local2}}616+ localDialer := &sequenceDialer{trs: []wire.Transport{local1, local2}}
617- remoteDialer := &sequenceDialer{trs: []ports.Transport{remote}}617+ remoteDialer := &sequenceDialer{trs: []wire.Transport{remote}}
618 deps := testDependencies(localDialer, term, realClock{}, nil, nil)618 deps := testDependencies(localDialer, term, realClock{}, nil, nil)
619 deps.AttachHandoff = func(protocol.AttachTarget) (ports.ClientDialer, client.AttachRequest, error) {619 deps.AttachHandoff = func(protocol.AttachTarget) (ports.ClientDialer, client.AttachRequest, error) {
620 target := protocol.ExactSessionTarget{LifecycleID: remoteLifecycle, SessionName: "work"}620 target := protocol.ExactSessionTarget{LifecycleID: remoteLifecycle, SessionName: "work"}
@@ -741,8 +741,8 @@ func TestHybridPickerSameHostSwitchReusesRemoteTransport(t *testing.T) {
741 resizeOnce.Do(func() { term.setSize(domain.Size{Cols: 100, Rows: 40}) })741 resizeOnce.Do(func() { term.setSize(domain.Size{Cols: 100, Rows: 40}) })
742 }742 }
743 }743 }
744- localDialer := &sequenceDialer{trs: []ports.Transport{localInitial, localPicker}}744+ localDialer := &sequenceDialer{trs: []wire.Transport{localInitial, localPicker}}
745- remoteDialer := &sequenceDialer{trs: []ports.Transport{markedDatagramTransport{Transport: remote}}}745+ remoteDialer := &sequenceDialer{trs: []wire.Transport{markedDatagramTransport{Transport: remote}}}
746 deps := hybridPickerDependencies(localDialer, term, realClock{}, map[string]ports.ClientDialer{"remote": remoteDialer})746 deps := hybridPickerDependencies(localDialer, term, realClock{}, map[string]ports.ClientDialer{"remote": remoteDialer})
747 747
748 err := runTestClient(context.Background(), deps, client.AttachRequest{748 err := runTestClient(context.Background(), deps, client.AttachRequest{
@@ -822,8 +822,8 @@ func TestHybridPickerExpiredSwitchFallsBackToNewDial(t *testing.T) {
822 {f: hybridWelcomeFrame("target", targetLifecycle)},822 {f: hybridWelcomeFrame("target", targetLifecycle)},
823 {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))},823 {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))},
824 }}824 }}
825- localDialer := &sequenceDialer{trs: []ports.Transport{localInitial, localPicker}}825+ localDialer := &sequenceDialer{trs: []wire.Transport{localInitial, localPicker}}
826- remoteDialer := &sequenceDialer{trs: []ports.Transport{826+ remoteDialer := &sequenceDialer{trs: []wire.Transport{
827 markedDatagramTransport{Transport: sourceRemote}, targetRemote,827 markedDatagramTransport{Transport: sourceRemote}, targetRemote,
828 }}828 }}
829 deps := hybridPickerDependencies(localDialer, term, realClock{}, map[string]ports.ClientDialer{"remote": remoteDialer})829 deps := hybridPickerDependencies(localDialer, term, realClock{}, map[string]ports.ClientDialer{"remote": remoteDialer})
@@ -888,8 +888,8 @@ func TestHybridPickerPrepareResponseTimeoutClosesRetainedTransport(t *testing.T)
888 protocol.ParkedRoutePrepare: prepareSent,888 protocol.ParkedRoutePrepare: prepareSent,
889 })889 })
890 890
891- localDialer := &sequenceDialer{trs: []ports.Transport{localInitial}}891+ localDialer := &sequenceDialer{trs: []wire.Transport{localInitial}}
892- remoteDialer := &sequenceDialer{trs: []ports.Transport{markedDatagramTransport{Transport: remote}}}892+ remoteDialer := &sequenceDialer{trs: []wire.Transport{markedDatagramTransport{Transport: remote}}}
893 deps := hybridPickerDependencies(localDialer, term, clock, map[string]ports.ClientDialer{"remote": remoteDialer})893 deps := hybridPickerDependencies(localDialer, term, clock, map[string]ports.ClientDialer{"remote": remoteDialer})
894 894
895 ctx, cancel := context.WithCancel(context.Background())895 ctx, cancel := context.WithCancel(context.Background())
@@ -965,8 +965,8 @@ func TestHybridPickerBackResumesRetainedRemoteTransport(t *testing.T) {
965 {f: hybridWelcomeFrame("local", localLifecycle)},965 {f: hybridWelcomeFrame("local", localLifecycle)},
966 {f: navigationDirectiveFrame(protocol.NavigationBack)},966 {f: navigationDirectiveFrame(protocol.NavigationBack)},
967 }}967 }}
968- localDialer := &sequenceDialer{trs: []ports.Transport{localInitial, localPicker}}968+ localDialer := &sequenceDialer{trs: []wire.Transport{localInitial, localPicker}}
969- remoteDialer := &sequenceDialer{trs: []ports.Transport{markedDatagramTransport{Transport: remote}}}969+ remoteDialer := &sequenceDialer{trs: []wire.Transport{markedDatagramTransport{Transport: remote}}}
970 deps := hybridPickerDependencies(localDialer, term, realClock{}, map[string]ports.ClientDialer{"remote": remoteDialer})970 deps := hybridPickerDependencies(localDialer, term, realClock{}, map[string]ports.ClientDialer{"remote": remoteDialer})
971 971
972 require.NoError(t, runTestClient(context.Background(), deps, client.AttachRequest{972 require.NoError(t, runTestClient(context.Background(), deps, client.AttachRequest{
@@ -1020,9 +1020,9 @@ func TestHybridPickerDifferentHostFallsBackToNewRemoteDial(t *testing.T) {
1020 {f: hybridWelcomeFrame("target", targetLifecycle)},1020 {f: hybridWelcomeFrame("target", targetLifecycle)},
1021 {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))},1021 {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))},
1022 }}1022 }}
1023- localDialer := &sequenceDialer{trs: []ports.Transport{localInitial, localPicker}}1023+ localDialer := &sequenceDialer{trs: []wire.Transport{localInitial, localPicker}}
1024- sourceDialer := &sequenceDialer{trs: []ports.Transport{markedDatagramTransport{Transport: sourceRemote}}}1024+ sourceDialer := &sequenceDialer{trs: []wire.Transport{markedDatagramTransport{Transport: sourceRemote}}}
1025- targetDialer := &sequenceDialer{trs: []ports.Transport{targetRemote}}1025+ targetDialer := &sequenceDialer{trs: []wire.Transport{targetRemote}}
1026 deps := hybridPickerDependencies(localDialer, term, realClock{}, map[string]ports.ClientDialer{1026 deps := hybridPickerDependencies(localDialer, term, realClock{}, map[string]ports.ClientDialer{
1027 sourceTarget.Endpoint: sourceDialer,1027 sourceTarget.Endpoint: sourceDialer,
1028 targetTarget.Endpoint: targetDialer,1028 targetTarget.Endpoint: targetDialer,
@@ -1105,8 +1105,8 @@ func TestRouteNavigationPreservesRemoteHomePickerAcrossLocalReturn(t *testing.T)
1105 {f: welcome("remote-new", remoteNewLifecycle, 22)},1105 {f: welcome("remote-new", remoteNewLifecycle, 22)},
1106 {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))},1106 {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))},
1107 }}1107 }}
1108- localDialer := &sequenceDialer{trs: []ports.Transport{local1, local2, local3}}1108+ localDialer := &sequenceDialer{trs: []wire.Transport{local1, local2, local3}}
1109- remoteDialer := &sequenceDialer{trs: []ports.Transport{remote1, remote2, remote3}}1109+ remoteDialer := &sequenceDialer{trs: []wire.Transport{remote1, remote2, remote3}}
1110 1110
1111 deps := testDependencies(localDialer, term, realClock{}, nil, nil)1111 deps := testDependencies(localDialer, term, realClock{}, nil, nil)
1112 deps.AttachHandoff = func(target protocol.AttachTarget) (ports.ClientDialer, client.AttachRequest, error) {1112 deps.AttachHandoff = func(target protocol.AttachTarget) (ports.ClientDialer, client.AttachRequest, error) {
@@ -1586,7 +1586,7 @@ func TestRunPhaseASingleAttempt(t *testing.T) {
1586 }1586 }
1587 1587
1588 type sequenceDialer struct {1588 type sequenceDialer struct {
1589- trs []ports.Transport1589+ trs []wire.Transport
1590 errs []error1590 errs []error
1591 calls atomic.Int321591 calls atomic.Int32
1592 }1592 }
@@ -1709,7 +1709,7 @@ func TestRunReconnectsWithRotatedTokenAndSameClientID(t *testing.T) {
1709 tr1 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {err: io.EOF}}}1709 tr1 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {err: io.EOF}}}
1710 tr2 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(22)}, {err: io.EOF}}}1710 tr2 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(22)}, {err: io.EOF}}}
1711 tr3 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(33)}, {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))}}}1711 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}}
1713 clock := &reconnectTestClock{}1713 clock := &reconnectTestClock{}
1714 result := make(chan error, 1)1714 result := make(chan error, 1)
1715 go func() {1715 go func() {
@@ -1757,7 +1757,7 @@ func TestReconnectRestoresLastPublishedRouteTab(t *testing.T) {
1757 {f: welcome(22)},1757 {f: welcome(22)},
1758 {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))},1758 {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))},
1759 }}1759 }}
1760- dialer := &sequenceDialer{trs: []ports.Transport{tr1, tr2}}1760+ dialer := &sequenceDialer{trs: []wire.Transport{tr1, tr2}}
1761 clock := &reconnectTestClock{}1761 clock := &reconnectTestClock{}
1762 result := make(chan error, 1)1762 result := make(chan error, 1)
1763 go func() {1763 go func() {
@@ -1800,7 +1800,7 @@ func TestRemoteReconnectDropsStalePickerTargetAfterDaemonSessionSwitch(t *testin
1800 {f: welcome("new", newLifecycle, 22)},1800 {f: welcome("new", newLifecycle, 22)},
1801 {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))},1801 {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))},
1802 }}1802 }}
1803- dialer := &sequenceDialer{trs: []ports.Transport{tr1, tr2}}1803+ dialer := &sequenceDialer{trs: []wire.Transport{tr1, tr2}}
1804 clock := &reconnectTestClock{}1804 clock := &reconnectTestClock{}
1805 result := make(chan error, 1)1805 result := make(chan error, 1)
1806 go func() {1806 go func() {
@@ -1986,7 +1986,7 @@ func TestRunRememberRemoteHostAtMostOnceAcrossReconnects(t *testing.T) {
1986 1986
1987 tr1 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {err: io.EOF}}}1987 tr1 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {err: io.EOF}}}
1988 tr2 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(22)}, {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))}}}1988 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}}
1990 1990
1991 deps := testDependencies(d, term, realClock{}, nil, nil)1991 deps := testDependencies(d, term, realClock{}, nil, nil)
1992 learner := portsmocks.NewMockRemoteHostLearner(t)1992 learner := portsmocks.NewMockRemoteHostLearner(t)
@@ -2269,7 +2269,7 @@ func TestRunDoesNotRetryTerminalDetachedError(t *testing.T) {
2269 term := newRunTerminal()2269 term := newRunTerminal()
2270 defer term.in.unblock()2270 defer term.in.unblock()
2271 tr := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonSessionKilled}))}}}2271 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}}
2273 2273
2274 err := runTestClient(context.Background(), testDependencies(d, term, realClock{}, nil, nil), client.AttachRequest{Intent: protocol.IntentAttach, SessionName: "main", Remote: false})2274 err := runTestClient(context.Background(), testDependencies(d, term, realClock{}, nil, nil), client.AttachRequest{Intent: protocol.IntentAttach, SessionName: "main", Remote: false})
2275 require.Error(t, err)2275 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 {
155 func (t *clipboardToastLifecycleTransport) Recv() (wire.Frame, error) { return <-t.recv, nil }155 func (t *clipboardToastLifecycleTransport) Recv() (wire.Frame, error) { return <-t.recv, nil }
156 func (*clipboardToastLifecycleTransport) Close() error { return nil }156 func (*clipboardToastLifecycleTransport) Close() error { return nil }
157 157
158-type clipboardToastLifecycleDialer struct{ transport ports.Transport }158+type clipboardToastLifecycleDialer struct{ transport wire.Transport }
159 159
160 func (d clipboardToastLifecycleDialer) Dial(context.Context) (ports.ClientConnection, error) {160 func (d clipboardToastLifecycleDialer) Dial(context.Context) (ports.ClientConnection, error) {
161 return &rawClientConnection{raw: d.transport}, nil161 return &rawClientConnection{raw: d.transport}, nil
M internal/usecase/client/reconnect_toast_test.go +8 -8
@@ -148,7 +148,7 @@ type reconnectToastRecv struct {
148 }148 }
149 149
150 type reconnectToastSequenceDialer struct {150 type reconnectToastSequenceDialer struct {
151- transports []ports.Transport151+ transports []wire.Transport
152 calls int152 calls int
153 }153 }
154 154
@@ -1375,7 +1375,7 @@ func TestReconnectRetryFailuresPreserveAttachError(t *testing.T) {
1375 reconnectToastRecv{frame: reconnectToastWelcome(44)},1375 reconnectToastRecv{frame: reconnectToastWelcome(44)},
1376 reconnectToastRecv{err: attachErr},1376 reconnectToastRecv{err: attachErr},
1377 )1377 )
1378- dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr}}1378+ dialer := &reconnectToastSequenceDialer{transports: []wire.Transport{tr}}
1379 1379
1380 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})1380 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})
1381 require.ErrorIs(t, err, attachErr)1381 require.ErrorIs(t, err, attachErr)
@@ -1462,7 +1462,7 @@ func TestRemoteReconnectToastFailedDrawDoesNotBlankBounds(t *testing.T) {
1462 {frame: reconnectToastWelcome(44)},1462 {frame: reconnectToastWelcome(44)},
1463 {err: io.EOF},1463 {err: io.EOF},
1464 }}1464 }}
1465- dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr}}1465+ dialer := &reconnectToastSequenceDialer{transports: []wire.Transport{tr}}
1466 1466
1467 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})1467 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})
1468 require.ErrorContains(t, err, "reconnect toast write failed")1468 require.ErrorContains(t, err, "reconnect toast write failed")
@@ -1484,12 +1484,12 @@ func (w *reconnectStatusClearFailWriter) Write(p []byte) (int, error) {
1484 func TestReconnectCancellationPreservesContextWhenStatusClearFails(t *testing.T) {1484 func TestReconnectCancellationPreservesContextWhenStatusClearFails(t *testing.T) {
1485 tests := []struct {1485 tests := []struct {
1486 name string1486 name string
1487- configure func(*mockClientDialer, ports.Transport)1487+ configure func(*mockClientDialer, wire.Transport)
1488 sleep func(context.CancelFunc) bool1488 sleep func(context.CancelFunc) bool
1489 }{1489 }{
1490 {1490 {
1491 name: "after attach failure",1491 name: "after attach failure",
1492- configure: func(dialer *mockClientDialer, tr ports.Transport) {1492+ configure: func(dialer *mockClientDialer, tr wire.Transport) {
1493 dialer.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()1493 dialer.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
1494 },1494 },
1495 sleep: func(cancel context.CancelFunc) bool {1495 sleep: func(cancel context.CancelFunc) bool {
@@ -1499,7 +1499,7 @@ func TestReconnectCancellationPreservesContextWhenStatusClearFails(t *testing.T)
1499 },1499 },
1500 {1500 {
1501 name: "after reconnect dial failure",1501 name: "after reconnect dial failure",
1502- configure: func(dialer *mockClientDialer, tr ports.Transport) {1502+ configure: func(dialer *mockClientDialer, tr wire.Transport) {
1503 dialer.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()1503 dialer.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
1504 dialer.EXPECT().Dial(mock.Anything).Return(nil, errors.New("redial failed")).Once()1504 dialer.EXPECT().Dial(mock.Anything).Return(nil, errors.New("redial failed")).Once()
1505 },1505 },
@@ -1575,7 +1575,7 @@ func TestRemoteReconnectToastLifecycleWithWrappedTransportError(t *testing.T) {
1575 {frame: reconnectToastWelcome(55)},1575 {frame: reconnectToastWelcome(55)},
1576 {frame: reconnectToastDetach(protocol.ReasonDetach)},1576 {frame: reconnectToastDetach(protocol.ReasonDetach)},
1577 }}1577 }}
1578- dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}}1578+ dialer := &reconnectToastSequenceDialer{transports: []wire.Transport{tr1, tr2}}
1579 1579
1580 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})1580 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})
1581 require.NoError(t, err)1581 require.NoError(t, err)
@@ -1619,7 +1619,7 @@ func TestRemoteEphemeralReconnectUsesAssignedSessionName(t *testing.T) {
1619 {frame: reconnectToastWelcomeNamed("0", 55)},1619 {frame: reconnectToastWelcomeNamed("0", 55)},
1620 {frame: reconnectToastDetach(protocol.ReasonDetach)},1620 {frame: reconnectToastDetach(protocol.ReasonDetach)},
1621 }}1621 }}
1622- dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}}1622+ dialer := &reconnectToastSequenceDialer{transports: []wire.Transport{tr1, tr2}}
1623 1623
1624 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})1624 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})
1625 require.NoError(t, err)1625 require.NoError(t, err)
M internal/usecase/client/transport_observability_test.go +3 -2
@@ -11,6 +11,7 @@ import (
11 11
12 "github.com/stretchr/testify/require"12 "github.com/stretchr/testify/require"
13 13
14+ "github.com/bnema/vev/internal/adapters/observability"
14 "github.com/bnema/vev/internal/domain"15 "github.com/bnema/vev/internal/domain"
15 "github.com/bnema/vev/internal/ports"16 "github.com/bnema/vev/internal/ports"
16 portsmocks "github.com/bnema/vev/internal/ports/mocks"17 portsmocks "github.com/bnema/vev/internal/ports/mocks"
@@ -43,7 +44,7 @@ func TestBlockingRuntimeObserverDoesNotDelayTerminalFlushOrACK(t *testing.T) {
43 term.EXPECT().ResizeEvents().Return(resizeCh).Maybe()44 term.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
44 45
45 observer := &blockingClientRuntimeObserver{entered: make(chan struct{}), release: make(chan struct{})}46 observer := &blockingClientRuntimeObserver{entered: make(chan struct{}), release: make(chan struct{})}
46- reporter := ports.NewSerializedRuntimeObserver(observer, 1)47+ reporter := observability.NewSerialized(observer, 1)
47 defer reporter.Close()48 defer reporter.Close()
48 acked := make(chan struct{})49 acked := make(chan struct{})
49 transport := newMockClientConnection(t)50 transport := newMockClientConnection(t)
@@ -85,7 +86,7 @@ func TestTerminalFlushBoundaryTransportObservability(t *testing.T) {
85 defer in.unblock()86 defer in.unblock()
86 87
87 observer := &clientRuntimeObserver{}88 observer := &clientRuntimeObserver{}
88- reporter := ports.NewSerializedRuntimeObserver(observer, 64)89+ reporter := observability.NewSerialized(observer, 64)
89 defer reporter.Close()90 defer reporter.Close()
90 transport := newMockClientConnection(t)91 transport := newMockClientConnection(t)
91 transport.EXPECT().Send(isType(wire.MsgHello)).Return(nil).Once()92 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 (
7 "github.com/stretchr/testify/mock"7 "github.com/stretchr/testify/mock"
8 8
9 "github.com/bnema/vev/internal/ports"9 "github.com/bnema/vev/internal/ports"
10- portsmocks "github.com/bnema/vev/internal/ports/mocks"
11 "github.com/bnema/vev/internal/protocol"10 "github.com/bnema/vev/internal/protocol"
12 "github.com/bnema/vev/internal/protocol/wire"11 "github.com/bnema/vev/internal/protocol/wire"
12+ wiremocks "github.com/bnema/vev/internal/protocol/wire/mocks"
13 )13 )
14 14
15 type mockTestingT interface {15 type mockTestingT interface {
@@ -17,10 +17,10 @@ type mockTestingT interface {
17 Cleanup(func())17 Cleanup(func())
18 }18 }
19 19
20-type mockClientConnection struct{ *portsmocks.MockTransport }20+type mockClientConnection struct{ *wiremocks.MockTransport }
21 21
22 func newMockClientConnection(t mockTestingT) *mockClientConnection {22 func newMockClientConnection(t mockTestingT) *mockClientConnection {
23- return &mockClientConnection{MockTransport: portsmocks.NewMockTransport(t)}23+ return &mockClientConnection{MockTransport: wiremocks.NewMockTransport(t)}
24 }24 }
25 func (c *mockClientConnection) SendClient(m protocol.ClientMessage) error {25 func (c *mockClientConnection) SendClient(m protocol.ClientMessage) error {
26 f, e := externalClientFrame(m)26 f, e := externalClientFrame(m)
@@ -42,7 +42,7 @@ func (c *mockClientConnection) Capabilities() protocol.ConnectionCapabilities {
42 func (c *mockClientConnection) LinkState() ports.LinkState { return ports.LinkStateConnected }42 func (c *mockClientConnection) LinkState() ports.LinkState { return ports.LinkStateConnected }
43 func (c *mockClientConnection) LinkEvents() <-chan ports.LinkEvent { return nil }43 func (c *mockClientConnection) LinkEvents() <-chan ports.LinkEvent { return nil }
44 44
45-type rawClientConnection struct{ raw ports.Transport }45+type rawClientConnection struct{ raw wire.Transport }
46 46
47 func (c *rawClientConnection) SendClient(m protocol.ClientMessage) error {47 func (c *rawClientConnection) SendClient(m protocol.ClientMessage) error {
48 f, e := externalClientFrame(m)48 f, e := externalClientFrame(m)
@@ -75,10 +75,10 @@ func (c *rawClientConnection) LinkEvents() <-chan ports.LinkEvent {
75 }75 }
76 func (c *rawClientConnection) Close() error { return c.raw.Close() }76 func (c *rawClientConnection) Close() error { return c.raw.Close() }
77 77
78-type mockClientDialer struct{ *portsmocks.MockDialer }78+type mockClientDialer struct{ *wiremocks.MockDialer }
79 79
80 func newMockClientDialer(t mockTestingT) *mockClientDialer {80 func newMockClientDialer(t mockTestingT) *mockClientDialer {
81- return &mockClientDialer{MockDialer: portsmocks.NewMockDialer(t)}81+ return &mockClientDialer{MockDialer: wiremocks.NewMockDialer(t)}
82 }82 }
83 func (d *mockClientDialer) Dial(ctx context.Context) (ports.ClientConnection, error) {83 func (d *mockClientDialer) Dial(ctx context.Context) (ports.ClientConnection, error) {
84 raw, e := d.MockDialer.Dial(ctx)84 raw, e := d.MockDialer.Dial(ctx)
@@ -88,8 +88,8 @@ func (d *mockClientDialer) Dial(ctx context.Context) (ports.ClientConnection, er
88 return &rawClientConnection{raw: raw}, nil88 return &rawClientConnection{raw: raw}, nil
89 }89 }
90 90
91-func externalCapabilities(raw ports.Transport) protocol.ConnectionCapabilities {91+func externalCapabilities(raw wire.Transport) protocol.ConnectionCapabilities {
92- _, dgram := raw.(ports.DatagramTransport)92+ _, dgram := raw.(wire.DatagramTransport)
93 _, link := raw.(ports.LinkStateReporter)93 _, link := raw.(ports.LinkStateReporter)
94 window := uint8(protocol.MaxOutputWindow)94 window := uint8(protocol.MaxOutputWindow)
95 if dgram {95 if dgram {
M internal/usecase/client/typed_connection_test.go +10 -9
@@ -15,6 +15,7 @@ import (
15 portsmocks "github.com/bnema/vev/internal/ports/mocks"15 portsmocks "github.com/bnema/vev/internal/ports/mocks"
16 "github.com/bnema/vev/internal/protocol"16 "github.com/bnema/vev/internal/protocol"
17 "github.com/bnema/vev/internal/protocol/wire"17 "github.com/bnema/vev/internal/protocol/wire"
18+ wiremocks "github.com/bnema/vev/internal/protocol/wire/mocks"
18 )19 )
19 20
20 type mockTestingT interface {21 type mockTestingT interface {
@@ -22,10 +23,10 @@ type mockTestingT interface {
22 Cleanup(func())23 Cleanup(func())
23 }24 }
24 25
25-type mockClientConnection struct{ *portsmocks.MockTransport }26+type mockClientConnection struct{ *wiremocks.MockTransport }
26 27
27 func newMockClientConnection(t mockTestingT) *mockClientConnection {28 func newMockClientConnection(t mockTestingT) *mockClientConnection {
28- return &mockClientConnection{MockTransport: portsmocks.NewMockTransport(t)}29+ return &mockClientConnection{MockTransport: wiremocks.NewMockTransport(t)}
29 }30 }
30 31
31 func (c *mockClientConnection) SendClient(message protocol.ClientMessage) error {32 func (c *mockClientConnection) SendClient(message protocol.ClientMessage) error {
@@ -52,7 +53,7 @@ func (c *mockClientConnection) LinkEvents() <-chan ports.LinkEvent {
52 return testClientLinkEvents(c.MockTransport)53 return testClientLinkEvents(c.MockTransport)
53 }54 }
54 55
55-type rawClientConnection struct{ raw ports.Transport }56+type rawClientConnection struct{ raw wire.Transport }
56 57
57 func (c *rawClientConnection) SendClient(message protocol.ClientMessage) error {58 func (c *rawClientConnection) SendClient(message protocol.ClientMessage) error {
58 frame, err := testClientFrame(message)59 frame, err := testClientFrame(message)
@@ -75,10 +76,10 @@ func (c *rawClientConnection) LinkState() ports.LinkState { return testC
75 func (c *rawClientConnection) LinkEvents() <-chan ports.LinkEvent { return testClientLinkEvents(c.raw) }76 func (c *rawClientConnection) LinkEvents() <-chan ports.LinkEvent { return testClientLinkEvents(c.raw) }
76 func (c *rawClientConnection) Close() error { return c.raw.Close() }77 func (c *rawClientConnection) Close() error { return c.raw.Close() }
77 78
78-type mockClientDialer struct{ *portsmocks.MockDialer }79+type mockClientDialer struct{ *wiremocks.MockDialer }
79 80
80 func newMockClientDialer(t mockTestingT) *mockClientDialer {81 func newMockClientDialer(t mockTestingT) *mockClientDialer {
81- return &mockClientDialer{MockDialer: portsmocks.NewMockDialer(t)}82+ return &mockClientDialer{MockDialer: wiremocks.NewMockDialer(t)}
82 }83 }
83 func (d *mockClientDialer) Dial(ctx context.Context) (ports.ClientConnection, error) {84 func (d *mockClientDialer) Dial(ctx context.Context) (ports.ClientConnection, error) {
84 raw, err := d.MockDialer.Dial(ctx)85 raw, err := d.MockDialer.Dial(ctx)
@@ -179,8 +180,8 @@ func testServerMessage(frame wire.Frame) (protocol.ServerMessage, error) {
179 }180 }
180 }181 }
181 182
182-func testClientCapabilities(raw ports.Transport) protocol.ConnectionCapabilities {183+func testClientCapabilities(raw wire.Transport) protocol.ConnectionCapabilities {
183- _, dgram := raw.(ports.DatagramTransport)184+ _, dgram := raw.(wire.DatagramTransport)
184 _, link := raw.(ports.LinkStateReporter)185 _, link := raw.(ports.LinkStateReporter)
185 window := uint8(protocol.MaxOutputWindow)186 window := uint8(protocol.MaxOutputWindow)
186 if dgram {187 if dgram {
@@ -188,13 +189,13 @@ func testClientCapabilities(raw ports.Transport) protocol.ConnectionCapabilities
188 }189 }
189 return protocol.ConnectionCapabilities{OutputDataLimit: protocol.MaxOutputDataLen, PreferredOutputWindow: window, LinkState: link}190 return protocol.ConnectionCapabilities{OutputDataLimit: protocol.MaxOutputDataLen, PreferredOutputWindow: window, LinkState: link}
190 }191 }
191-func testClientLinkState(raw ports.Transport) ports.LinkState {192+func testClientLinkState(raw wire.Transport) ports.LinkState {
192 if r, ok := raw.(ports.LinkStateReporter); ok {193 if r, ok := raw.(ports.LinkStateReporter); ok {
193 return r.LinkState()194 return r.LinkState()
194 }195 }
195 return ports.LinkStateConnected196 return ports.LinkStateConnected
196 }197 }
197-func testClientLinkEvents(raw ports.Transport) <-chan ports.LinkEvent {198+func testClientLinkEvents(raw wire.Transport) <-chan ports.LinkEvent {
198 if r, ok := raw.(ports.LinkStateReporter); ok {199 if r, ok := raw.(ports.LinkStateReporter); ok {
199 return r.LinkEvents()200 return r.LinkEvents()
200 }201 }
M internal/usecase/daemon/attachment_output.go +2 -2
@@ -8,7 +8,7 @@ import (
8 8
9 renderer "github.com/bnema/vev-vt/ansi"9 renderer "github.com/bnema/vev-vt/ansi"
10 "github.com/bnema/vev/internal/domain"10 "github.com/bnema/vev/internal/domain"
11- "github.com/bnema/vev/internal/ports"11+ "github.com/bnema/vev/internal/domain/terminalcap"
12 "github.com/bnema/vev/internal/protocol"12 "github.com/bnema/vev/internal/protocol"
13 )13 )
14 14
@@ -95,7 +95,7 @@ func newOutputStateStream(windowSize ...uint8) *attachmentOutput {
95 return newOutputStateStreamForProfile(renderer.ColorProfileTrueColor, windowSize...)95 return newOutputStateStreamForProfile(renderer.ColorProfileTrueColor, windowSize...)
96 }96 }
97 97
98-func newOutputStateStreamForCapabilities(capabilities ports.TerminalCapabilities, windowSize ...uint8) *attachmentOutput {98+func newOutputStateStreamForCapabilities(capabilities terminalcap.Capabilities, windowSize ...uint8) *attachmentOutput {
99 profile := renderer.ColorProfileANSI25699 profile := renderer.ColorProfileANSI256
100 if capabilities.TrueColor() {100 if capabilities.TrueColor() {
101 profile = renderer.ColorProfileTrueColor101 profile = renderer.ColorProfileTrueColor
M internal/usecase/daemon/attachment_output_test.go +5 -5
@@ -9,7 +9,7 @@ import (
9 renderer "github.com/bnema/vev-vt/ansi"9 renderer "github.com/bnema/vev-vt/ansi"
10 "github.com/bnema/vev-vt/graphics"10 "github.com/bnema/vev-vt/graphics"
11 "github.com/bnema/vev/internal/domain"11 "github.com/bnema/vev/internal/domain"
12- "github.com/bnema/vev/internal/ports"12+ "github.com/bnema/vev/internal/domain/terminalcap"
13 "github.com/bnema/vev/internal/protocol"13 "github.com/bnema/vev/internal/protocol"
14 "github.com/bnema/vev/internal/protocol/wire"14 "github.com/bnema/vev/internal/protocol/wire"
15 "github.com/bnema/vev/internal/usecase/layout"15 "github.com/bnema/vev/internal/usecase/layout"
@@ -56,17 +56,17 @@ func TestAttachmentOutputUsesTerminalColorProfile(t *testing.T) {
56 56
57 cases := []struct {57 cases := []struct {
58 name string58 name string
59- capabilities ports.TerminalCapabilities59+ capabilities terminalcap.Capabilities
60 want string60 want string
61 }{61 }{
62 {62 {
63 name: "truecolor",63 name: "truecolor",
64- capabilities: ports.TerminalCapabilities{ColorMode: ports.TerminalColorTrueColor},64+ capabilities: terminalcap.Capabilities{ColorMode: terminalcap.TrueColor},
65 want: "\x1b[1;1H\x1b[0;38;2;255;0;0;48;2;0;0;255;58;2;0;255;0mX\x1b[0m",65 want: "\x1b[1;1H\x1b[0;38;2;255;0;0;48;2;0;0;255;58;2;0;255;0mX\x1b[0m",
66 },66 },
67 {67 {
68 name: "indexed 256",68 name: "indexed 256",
69- capabilities: ports.TerminalCapabilities{ColorMode: ports.TerminalColorIndexed256},69+ capabilities: terminalcap.Capabilities{ColorMode: terminalcap.Indexed256},
70 want: "\x1b[1;1H\x1b[0;38;5;196;48;5;21;58;5;46mX\x1b[0m",70 want: "\x1b[1;1H\x1b[0;38;5;196;48;5;21;58;5;46mX\x1b[0m",
71 },71 },
72 }72 }
@@ -451,7 +451,7 @@ func TestAttachmentOutputFailedSendKeepsTextCursorAndGraphicsSpeculative(t *test
451 graphicsState := newGraphicsOutputState()451 graphicsState := newGraphicsOutputState()
452 output := attachmentOutputWithGraphics(graphicsState)452 output := attachmentOutputWithGraphics(graphicsState)
453 output.lastCursor = cursorOut{valid: true, row: 1, col: 1}453 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}}
455 output.attachment = ac455 output.attachment = ac
456 frame := renderer.NewFrame(1, 1)456 frame := renderer.NewFrame(1, 1)
457 prepared, err := output.prepareFrame(nil, state, frame, []renderer.Damage{renderer.FullRedraw()}, true, cursorOut{row: 2, col: 3})457 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 (
12 "time"12 "time"
13 13
14 "github.com/bnema/vev/internal/domain"14 "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/domain/terminalcap"
15 "github.com/bnema/vev/internal/ports"16 "github.com/bnema/vev/internal/ports"
16 "github.com/bnema/vev/internal/protocol"17 "github.com/bnema/vev/internal/protocol"
17 "github.com/bnema/vev/internal/usecase/keys"18 "github.com/bnema/vev/internal/usecase/keys"
@@ -40,7 +41,7 @@ type attachedClient struct {
40 overlays *overlayRuntime41 overlays *overlayRuntime
41 overlayOnce sync.Once42 overlayOnce sync.Once
42 clientID [16]byte43 clientID [16]byte
43- terminalCapabilities ports.TerminalCapabilities44+ terminalCapabilities terminalcap.Capabilities
44 navigationCapabilities protocol.NavigationCapabilities45 navigationCapabilities protocol.NavigationCapabilities
45 startupOverlay protocol.StartupOverlay46 startupOverlay protocol.StartupOverlay
46 // lifecycle is the sole authority for attachment capability publication,47 // lifecycle is the sole authority for attachment capability publication,
@@ -612,7 +613,7 @@ type attachClientOptions struct {
612 clientID [16]byte613 clientID [16]byte
613 resumeCapable bool614 resumeCapable bool
614 maxOutputInFlight uint8615 maxOutputInFlight uint8
615- terminalCapabilities ports.TerminalCapabilities616+ terminalCapabilities terminalcap.Capabilities
616 capabilitiesSet bool617 capabilitiesSet bool
617 navigationCapabilities protocol.NavigationCapabilities618 navigationCapabilities protocol.NavigationCapabilities
618 startupOverlay protocol.StartupOverlay619 startupOverlay protocol.StartupOverlay
@@ -644,7 +645,7 @@ func (d *Daemon) attachClient(sess *session, tr ports.ServerConnection, sz domai
644 func (d *Daemon) finishAttachedClient(sess *session, ac *attachedClient, opts attachClientOptions) {645 func (d *Daemon) finishAttachedClient(sess *session, ac *attachedClient, opts attachClientOptions) {
645 d.touchMRU(sess)646 d.touchMRU(sess)
646 d.log.Info("client attached", "session", sess.name, "resume", opts.resumeCapable)647 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() {
648 d.publishToast(ac, domain.Notification{649 d.publishToast(ac, domain.Notification{
649 Code: domain.NoticeUser,650 Code: domain.NoticeUser,
650 Severity: domain.NoticeWarn,651 Severity: domain.NoticeWarn,
@@ -669,7 +670,7 @@ func (d *Daemon) prepareAttachedClientLocked(sess *session, tr ports.ServerConne
669 resumeToken = d.nextResumeTokenLocked()670 resumeToken = d.nextResumeTokenLocked()
670 }671 }
671 if !opts.capabilitiesSet {672 if !opts.capabilitiesSet {
672- opts.terminalCapabilities = ports.TerminalCapabilities{ColorMode: ports.TerminalColorTrueColor}673+ opts.terminalCapabilities = terminalcap.Capabilities{ColorMode: terminalcap.TrueColor}
673 }674 }
674 output := newOutputStateStreamForCapabilities(opts.terminalCapabilities, opts.maxOutputInFlight)675 output := newOutputStateStreamForCapabilities(opts.terminalCapabilities, opts.maxOutputInFlight)
675 geometry = geometry.NormalizePixels()676 geometry = geometry.NormalizePixels()
M internal/usecase/daemon/control.go +18 -17
@@ -13,6 +13,7 @@ import (
13 "github.com/bnema/vev/internal/domain"13 "github.com/bnema/vev/internal/domain"
14 "github.com/bnema/vev/internal/ports"14 "github.com/bnema/vev/internal/ports"
15 "github.com/bnema/vev/internal/protocol"15 "github.com/bnema/vev/internal/protocol"
16+ "github.com/bnema/vev/internal/protocol/catalogue"
16 "github.com/bnema/vev/internal/usecase/command"17 "github.com/bnema/vev/internal/usecase/command"
17 "github.com/bnema/vev/internal/usecase/layout"18 "github.com/bnema/vev/internal/usecase/layout"
18 )19 )
@@ -711,7 +712,7 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) {
711 }712 }
712 e.d.mu.Unlock()713 e.d.mu.Unlock()
713 714
714- rows := make([]ports.RemoteCatalogSession, 0, len(sessions)+len(stopped))715+ rows := make([]catalogue.RemoteCatalogSession, 0, len(sessions)+len(stopped))
715 liveNames := make(map[string]struct{}, len(sessions))716 liveNames := make(map[string]struct{}, len(sessions))
716 for _, sess := range sessions {717 for _, sess := range sessions {
717 e.d.refreshSessionFocusedTitles(sess)718 e.d.refreshSessionFocusedTitles(sess)
@@ -720,10 +721,10 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) {
720 if err != nil {721 if err != nil {
721 return "", err722 return "", err
722 }723 }
723- row := ports.RemoteCatalogSession{724+ row := catalogue.RemoteCatalogSession{
724 LifecycleID: snap.incarnation,725 LifecycleID: snap.incarnation,
725 Name: snap.name,726 Name: snap.name,
726- State: ports.RemoteCatalogSessionUp,727+ State: catalogue.RemoteCatalogSessionUp,
727 Ephemeral: snap.ephemeral,728 Ephemeral: snap.ephemeral,
728 Tabs: tabs,729 Tabs: tabs,
729 Attached: snap.attached,730 Attached: snap.attached,
@@ -743,13 +744,13 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) {
743 if err != nil {744 if err != nil {
744 return "", err745 return "", err
745 }746 }
746- state := ports.RemoteCatalogSessionDown747+ state := catalogue.RemoteCatalogSessionDown
747 reason := ""748 reason := ""
748 if entry.broken() {749 if entry.broken() {
749- state = ports.RemoteCatalogSessionBroken750+ state = catalogue.RemoteCatalogSessionBroken
750 reason = "session_broken"751 reason = "session_broken"
751 }752 }
752- rows = append(rows, ports.RemoteCatalogSession{753+ rows = append(rows, catalogue.RemoteCatalogSession{
753 LifecycleID: entry.incarnation,754 LifecycleID: entry.incarnation,
754 Name: entry.name,755 Name: entry.name,
755 State: state,756 State: state,
@@ -765,24 +766,24 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) {
765 }766 }
766 return rows[i].LifecycleID.String() < rows[j].LifecycleID.String()767 return rows[i].LifecycleID.String() < rows[j].LifecycleID.String()
767 })768 })
768- catalog := ports.RemoteCatalog{769+ catalog := catalogue.RemoteCatalog{
769 ProtocolVersion: protocol.Version,770 ProtocolVersion: protocol.Version,
770- SchemaVersion: ports.RemoteCatalogSchemaVersion,771+ SchemaVersion: catalogue.RemoteCatalogSchemaVersion,
771 Sessions: rows,772 Sessions: rows,
772 }773 }
773- if err := ports.ValidateRemoteCatalog(catalog); err != nil {774+ if err := catalogue.ValidateRemoteCatalog(catalog); err != nil {
774 return "", err775 return "", err
775 }776 }
776 return marshalListing(catalog)777 return marshalListing(catalog)
777 }778 }
778 779
779-func remoteCatalogTabs(view sessionView) ([]ports.RemoteCatalogTab, error) {780+func remoteCatalogTabs(view sessionView) ([]catalogue.RemoteCatalogTab, error) {
780- if len(view.tabs) > ports.RemoteCatalogMaxTabsPerSess {781+ if len(view.tabs) > catalogue.RemoteCatalogMaxTabsPerSess {
781 return nil, fmt.Errorf("remote catalog: session %q has too many tabs", view.name)782 return nil, fmt.Errorf("remote catalog: session %q has too many tabs", view.name)
782 }783 }
783- tabs := make([]ports.RemoteCatalogTab, 0, len(view.tabs))784+ tabs := make([]catalogue.RemoteCatalogTab, 0, len(view.tabs))
784 for i, tab := range view.tabs {785 for i, tab := range view.tabs {
785- tabs = append(tabs, ports.RemoteCatalogTab{786+ tabs = append(tabs, catalogue.RemoteCatalogTab{
786 ID: string(tab.id),787 ID: string(tab.id),
787 Index: uint16(i),788 Index: uint16(i),
788 Name: tab.name,789 Name: tab.name,
@@ -793,7 +794,7 @@ func remoteCatalogTabs(view sessionView) ([]ports.RemoteCatalogTab, error) {
793 return tabs, nil794 return tabs, nil
794 }795 }
795 796
796-func remoteCatalogStoppedTabs(entry inactiveSession) ([]ports.RemoteCatalogTab, error) {797+func remoteCatalogStoppedTabs(entry inactiveSession) ([]catalogue.RemoteCatalogTab, error) {
797 records := entry.tabRecords798 records := entry.tabRecords
798 if len(records) == 0 && len(entry.tabNames) != 0 {799 if len(records) == 0 && len(entry.tabNames) != 0 {
799 records = make([]domain.CatalogueTabRecord, len(entry.tabNames))800 records = make([]domain.CatalogueTabRecord, len(entry.tabNames))
@@ -801,12 +802,12 @@ func remoteCatalogStoppedTabs(entry inactiveSession) ([]ports.RemoteCatalogTab,
801 records[i].Name = name802 records[i].Name = name
802 }803 }
803 }804 }
804- if len(records) > ports.RemoteCatalogMaxTabsPerSess {805+ if len(records) > catalogue.RemoteCatalogMaxTabsPerSess {
805 return nil, fmt.Errorf("remote catalog: session %q has too many tabs", entry.name)806 return nil, fmt.Errorf("remote catalog: session %q has too many tabs", entry.name)
806 }807 }
807- tabs := make([]ports.RemoteCatalogTab, 0, len(records))808+ tabs := make([]catalogue.RemoteCatalogTab, 0, len(records))
808 for i, record := range records {809 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})
810 }811 }
811 return tabs, nil812 return tabs, nil
812 }813 }
M internal/usecase/daemon/control_test.go +13 -12
@@ -14,9 +14,10 @@ import (
14 "time"14 "time"
15 15
16 "github.com/bnema/vev/internal/domain"16 "github.com/bnema/vev/internal/domain"
17- "github.com/bnema/vev/internal/ports"17+ appports "github.com/bnema/vev/internal/ports"
18 portsmocks "github.com/bnema/vev/internal/ports/mocks"18 portsmocks "github.com/bnema/vev/internal/ports/mocks"
19 "github.com/bnema/vev/internal/protocol"19 "github.com/bnema/vev/internal/protocol"
20+ "github.com/bnema/vev/internal/protocol/catalogue"
20 "github.com/bnema/vev/internal/protocol/wire"21 "github.com/bnema/vev/internal/protocol/wire"
21 "github.com/bnema/vev/internal/usecase/command"22 "github.com/bnema/vev/internal/usecase/command"
22 "github.com/bnema/vev/internal/usecase/layout"23 "github.com/bnema/vev/internal/usecase/layout"
@@ -774,7 +775,7 @@ func TestRemoteCatalogJSONOutput(t *testing.T) {
774 require.True(t, result.OK, result.Text)775 require.True(t, result.OK, result.Text)
775 require.True(t, strings.HasSuffix(result.Output, "\n"), "catalog output must be newline-terminated")776 require.True(t, strings.HasSuffix(result.Output, "\n"), "catalog output must be newline-terminated")
776 777
777- var catalog ports.RemoteCatalog778+ var catalog catalogue.RemoteCatalog
778 require.NoError(t, json.Unmarshal([]byte(result.Output), &catalog))779 require.NoError(t, json.Unmarshal([]byte(result.Output), &catalog))
779 require.Equal(t, protocol.Version, catalog.ProtocolVersion)780 require.Equal(t, protocol.Version, catalog.ProtocolVersion)
780 781
@@ -786,13 +787,13 @@ func TestRemoteCatalogJSONOutput(t *testing.T) {
786 _, hasSessions := raw["sessions"]787 _, hasSessions := raw["sessions"]
787 require.True(t, hasProtocol && hasSchema && hasSessions)788 require.True(t, hasProtocol && hasSchema && hasSessions)
788 789
789- require.Equal(t, []ports.RemoteCatalogSession{790+ require.Equal(t, []catalogue.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: build.incarnation, Name: "build", State: "up", Ephemeral: true, Tabs: []catalogue.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: domain.IncarnationID{3}, Name: "old", State: "down", Tabs: []catalogue.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},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},
793 }, catalog.Sessions)794 }, catalog.Sessions)
794 795
795- require.Equal(t, ports.RemoteCatalogSessionDown, catalog.Sessions[1].State)796+ require.Equal(t, catalogue.RemoteCatalogSessionDown, catalog.Sessions[1].State)
796 797
797 listAfter := sendCommand(t, d, protocol.CommandRequest{Slug: "list-sessions"})798 listAfter := sendCommand(t, d, protocol.CommandRequest{Slug: "list-sessions"})
798 require.True(t, listAfter.OK, listAfter.Text)799 require.True(t, listAfter.OK, listAfter.Text)
@@ -818,14 +819,14 @@ func TestRemoteCatalogRefreshesFocusedTabTitle(t *testing.T) {
818 819
819 result := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog", JSON: true})820 result := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog", JSON: true})
820 require.True(t, result.OK, result.Text)821 require.True(t, result.OK, result.Text)
821- var catalog ports.RemoteCatalog822+ var catalog catalogue.RemoteCatalog
822 require.NoError(t, json.Unmarshal([]byte(result.Output), &catalog))823 require.NoError(t, json.Unmarshal([]byte(result.Output), &catalog))
823- require.Equal(t, []ports.RemoteCatalogSession{{824+ require.Equal(t, []catalogue.RemoteCatalogSession{{
824 LifecycleID: sess.incarnation,825 LifecycleID: sess.incarnation,
825 Name: "work",826 Name: "work",
826 State: "up",827 State: "up",
827 Ephemeral: true,828 Ephemeral: true,
828- Tabs: []ports.RemoteCatalogTab{{829+ Tabs: []catalogue.RemoteCatalogTab{{
829 ID: "t_work",830 ID: "t_work",
830 Index: 0,831 Index: 0,
831 Name: "shell",832 Name: "shell",
@@ -840,7 +841,7 @@ func TestRemoteCatalogRejectsTooManyTabs(t *testing.T) {
840 d := newTestDaemon(t, nil, stubClock{})841 d := newTestDaemon(t, nil, stubClock{})
841 sess := addControlSession(d, "work", "t_work", "p_work")842 sess := addControlSession(d, "work", "t_work", "p_work")
842 sess.mu.Lock()843 sess.mu.Lock()
843- sess.tabs = make([]*tab, ports.RemoteCatalogMaxTabsPerSess+1)844+ sess.tabs = make([]*tab, catalogue.RemoteCatalogMaxTabsPerSess+1)
844 for i := range sess.tabs {845 for i := range sess.tabs {
845 sess.tabs[i] = newTabWithStableID(846 sess.tabs[i] = newTabWithStableID(
846 fmt.Sprintf("tab-%d", i), fmt.Sprintf("pane-%d", i), newQuietPTY(), domain.Size{Cols: 80, Rows: 22},847 fmt.Sprintf("tab-%d", i), fmt.Sprintf("pane-%d", i), newQuietPTY(), domain.Size{Cols: 80, Rows: 22},
@@ -1345,7 +1346,7 @@ type controlPTYFactory struct {
1345 once sync.Once1346 once sync.Once
1346 }1347 }
1347 1348
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) {
1349 f.once.Do(func() {1350 f.once.Do(func() {
1350 if f.entered != nil {1351 if f.entered != nil {
1351 close(f.entered)1352 close(f.entered)
M internal/usecase/daemon/daemon.go +5 -9
@@ -16,6 +16,7 @@ import (
16 "time"16 "time"
17 17
18 "github.com/bnema/vev/internal/domain"18 "github.com/bnema/vev/internal/domain"
19+ "github.com/bnema/vev/internal/domain/terminalcap"
19 "github.com/bnema/vev/internal/ports"20 "github.com/bnema/vev/internal/ports"
20 "github.com/bnema/vev/internal/protocol"21 "github.com/bnema/vev/internal/protocol"
21 "github.com/bnema/vev/internal/usecase/keys"22 "github.com/bnema/vev/internal/usecase/keys"
@@ -304,8 +305,6 @@ type Daemon struct {
304 remoteCatalogClient ports.RemoteCatalogClient305 remoteCatalogClient ports.RemoteCatalogClient
305 remoteCatalogCache ports.RemoteCatalogCache306 remoteCatalogCache ports.RemoteCatalogCache
306 remotePreviewClient ports.RemotePreviewClient307 remotePreviewClient ports.RemotePreviewClient
307- remoteDialerFactory ports.RemoteDialerFactory
308- remoteTransportMode ports.RemoteTransportMode
309 // tempDir overrides os.TempDir() for clipboard-image-transfer writes308 // tempDir overrides os.TempDir() for clipboard-image-transfer writes
310 // (see clipboard.go); empty means use os.TempDir().309 // (see clipboard.go); empty means use os.TempDir().
311 tempDir string310 tempDir string
@@ -416,14 +415,11 @@ func WithRuntimeObserver(observer ports.SerializedRuntimeObserver) Option {
416 }415 }
417 416
418 // WithRemoteDiscovery installs the remote discovery ports used by the daemon.417 // WithRemoteDiscovery installs the remote discovery ports used by the daemon.
419-// The composition root validates mode before constructing the daemon.418+func WithRemoteDiscovery(store ports.RemoteHostStore, catalog ports.RemoteCatalogClient, cache ports.RemoteCatalogCache) Option {
420-func WithRemoteDiscovery(store ports.RemoteHostStore, catalog ports.RemoteCatalogClient, cache ports.RemoteCatalogCache, dialer ports.RemoteDialerFactory, mode ports.RemoteTransportMode) Option {
421 return func(d *Daemon) {419 return func(d *Daemon) {
422 d.remoteHostStore = store420 d.remoteHostStore = store
423 d.remoteCatalogClient = catalog421 d.remoteCatalogClient = catalog
424 d.remoteCatalogCache = cache422 d.remoteCatalogCache = cache
425- d.remoteDialerFactory = dialer
426- d.remoteTransportMode = mode
427 }423 }
428 }424 }
429 425
@@ -1351,14 +1347,14 @@ func (d *Daemon) finishAttach(sess *session, tr ports.ServerConnection, sz domai
1351 sess.env = copyEnvironment(h.Env)1347 sess.env = copyEnvironment(h.Env)
1352 }1348 }
1353 sess.mu.Unlock()1349 sess.mu.Unlock()
1354- terminalCapabilities := ports.DetectTerminalCapabilities(h.Env)1350+ terminalCapabilities := terminalcap.Detect(h.Env)
1355 // Kitty graphics are enabled only by the explicit direct-terminal1351 // Kitty graphics are enabled only by the explicit direct-terminal
1356 // declaration in Hello. Environment values remain useful for color and1352 // declaration in Hello. Environment values remain useful for color and
1357 // diagnostics, but cannot authorize terminal-global graphics side effects.1353 // diagnostics, but cannot authorize terminal-global graphics side effects.
1358 terminalCapabilities.KittyGraphics = h.KittyDirectGraphics1354 terminalCapabilities.KittyGraphics = h.KittyDirectGraphics
1359 if h.TrueColor && !terminalCapabilities.TrueColor() {1355 if h.TrueColor && !terminalCapabilities.TrueColor() {
1360- terminalCapabilities.ColorMode = ports.TerminalColorTrueColor1356+ terminalCapabilities.ColorMode = terminalcap.TrueColor
1361- terminalCapabilities.ColorSource = ports.TerminalCapabilityDeclared1357+ terminalCapabilities.ColorSource = terminalcap.SourceDeclared
1362 }1358 }
1363 opts := attachClientOptions{1359 opts := attachClientOptions{
1364 clientID: h.ClientID,1360 clientID: h.ClientID,
M internal/usecase/daemon/graphics_output_test.go +6 -6
@@ -13,7 +13,7 @@ import (
13 vt "github.com/bnema/vev-vt"13 vt "github.com/bnema/vev-vt"
14 "github.com/bnema/vev-vt/graphics"14 "github.com/bnema/vev-vt/graphics"
15 "github.com/bnema/vev/internal/domain"15 "github.com/bnema/vev/internal/domain"
16- "github.com/bnema/vev/internal/ports"16+ "github.com/bnema/vev/internal/domain/terminalcap"
17 "github.com/bnema/vev/internal/protocol"17 "github.com/bnema/vev/internal/protocol"
18 "github.com/bnema/vev/internal/protocol/wire"18 "github.com/bnema/vev/internal/protocol/wire"
19 "github.com/bnema/vev/internal/usecase/layout"19 "github.com/bnema/vev/internal/usecase/layout"
@@ -295,7 +295,7 @@ func TestGraphicsOutputDefersCleanupThatDoesNotFitFrameBudget(t *testing.T) {
295 seed.commit()295 seed.commit()
296 ac := &attachedClient{296 ac := &attachedClient{
297 output: attachmentOutputWithGraphics(graphicsState),297 output: attachmentOutputWithGraphics(graphicsState),
298- terminalCapabilities: ports.TerminalCapabilities{KittyGraphics: true},298+ terminalCapabilities: terminalcap.Capabilities{KittyGraphics: true},
299 }299 }
300 300
301 deferred, err := graphicsOutputDataWithDaemonLimit(nil, &capturedRenderState{}, ac, false, 0)301 deferred, err := graphicsOutputDataWithDaemonLimit(nil, &capturedRenderState{}, ac, false, 0)
@@ -512,7 +512,7 @@ func TestGraphicsNamespacePoolExhaustionFallsBackToText(t *testing.T) {
512 domain.Geometry{Size: defaultSize},512 domain.Geometry{Size: defaultSize},
513 attachClientOptions{513 attachClientOptions{
514 capabilitiesSet: true,514 capabilitiesSet: true,
515- terminalCapabilities: ports.TerminalCapabilities{KittyGraphics: true, ColorMode: ports.TerminalColorTrueColor},515+ terminalCapabilities: terminalcap.Capabilities{KittyGraphics: true, ColorMode: terminalcap.TrueColor},
516 },516 },
517 )517 )
518 d.mu.Unlock()518 d.mu.Unlock()
@@ -624,7 +624,7 @@ func TestGraphicsOutputOversizeSuppressesGraphicsWithoutCompositionError(t *test
624 }}}624 }}}
625 ac := &attachedClient{625 ac := &attachedClient{
626 output: attachmentOutputWithGraphics(newGraphicsOutputState()),626 output: attachmentOutputWithGraphics(newGraphicsOutputState()),
627- terminalCapabilities: ports.TerminalCapabilities{KittyGraphics: true},627+ terminalCapabilities: terminalcap.Capabilities{KittyGraphics: true},
628 }628 }
629 prepared, err := graphicsOutputData(state, ac, true)629 prepared, err := graphicsOutputData(state, ac, true)
630 require.NoError(t, err, "ANSI composition must continue when optional graphics exceed the bound")630 require.NoError(t, err, "ANSI composition must continue when optional graphics exceed the bound")
@@ -644,7 +644,7 @@ func TestGraphicsOutputClipsPlacementToPaneContent(t *testing.T) {
644 }}}644 }}}
645 ac := &attachedClient{645 ac := &attachedClient{
646 output: attachmentOutputWithGraphics(newGraphicsOutputState()),646 output: attachmentOutputWithGraphics(newGraphicsOutputState()),
647- terminalCapabilities: ports.TerminalCapabilities{KittyGraphics: true},647+ terminalCapabilities: terminalcap.Capabilities{KittyGraphics: true},
648 }648 }
649 prepared, err := graphicsOutputData(state, ac, true)649 prepared, err := graphicsOutputData(state, ac, true)
650 require.NoError(t, err)650 require.NoError(t, err)
@@ -825,7 +825,7 @@ func graphicsPaneTestState(tab domain.TabStableID, panes ...capturedPaneRenderSt
825 func graphicsPaneTestClient() *attachedClient {825 func graphicsPaneTestClient() *attachedClient {
826 return &attachedClient{826 return &attachedClient{
827 output: attachmentOutputWithGraphics(newGraphicsOutputState()),827 output: attachmentOutputWithGraphics(newGraphicsOutputState()),
828- terminalCapabilities: ports.TerminalCapabilities{KittyGraphics: true},828+ terminalCapabilities: terminalcap.Capabilities{KittyGraphics: true},
829 }829 }
830 }830 }
831 831
M internal/usecase/daemon/palette.go +3 -3
@@ -7,8 +7,8 @@ import (
7 7
8 renderer "github.com/bnema/vev-vt"8 renderer "github.com/bnema/vev-vt"
9 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
10- "github.com/bnema/vev/internal/ports"
11 "github.com/bnema/vev/internal/protocol"10 "github.com/bnema/vev/internal/protocol"
11+ "github.com/bnema/vev/internal/protocol/catalogue"
12 "github.com/bnema/vev/internal/usecase/command"12 "github.com/bnema/vev/internal/usecase/command"
13 "github.com/bnema/vev/internal/usecase/layout"13 "github.com/bnema/vev/internal/usecase/layout"
14 "github.com/bnema/vev/internal/usecase/palette"14 "github.com/bnema/vev/internal/usecase/palette"
@@ -105,8 +105,8 @@ func paletteRouteRepresentsDaemonSession(entry protocol.RecentRouteEntry, daemon
105 paletteRemoteDisplayOrigin(entry.HostLabel) == daemonDisplayOrigin105 paletteRemoteDisplayOrigin(entry.HostLabel) == daemonDisplayOrigin
106 }106 }
107 107
108-func remotePaletteUnavailableReason(status remoteHostStatus, session ports.RemoteCatalogSession) string {108+func remotePaletteUnavailableReason(status remoteHostStatus, session catalogue.RemoteCatalogSession) string {
109- if session.State == ports.RemoteCatalogSessionBroken {109+ if session.State == catalogue.RemoteCatalogSessionBroken {
110 return "session_broken"110 return "session_broken"
111 }111 }
112 return remoteReasonForStatus(status)112 return remoteReasonForStatus(status)
M internal/usecase/daemon/palette_test.go +20 -20
@@ -11,9 +11,9 @@ import (
11 11
12 renderer "github.com/bnema/vev-vt"12 renderer "github.com/bnema/vev-vt"
13 "github.com/bnema/vev/internal/domain"13 "github.com/bnema/vev/internal/domain"
14- "github.com/bnema/vev/internal/ports"
15 portsmocks "github.com/bnema/vev/internal/ports/mocks"14 portsmocks "github.com/bnema/vev/internal/ports/mocks"
16 "github.com/bnema/vev/internal/protocol"15 "github.com/bnema/vev/internal/protocol"
16+ "github.com/bnema/vev/internal/protocol/catalogue"
17 "github.com/bnema/vev/internal/protocol/wire"17 "github.com/bnema/vev/internal/protocol/wire"
18 "github.com/bnema/vev/internal/usecase/command"18 "github.com/bnema/vev/internal/usecase/command"
19 "github.com/bnema/vev/internal/usecase/palette"19 "github.com/bnema/vev/internal/usecase/palette"
@@ -439,11 +439,11 @@ func TestPaletteIncludesExactRemoteCatalogTargetBesideSameNameLocalSession(t *te
439 local.ephemeral = false439 local.ephemeral = false
440 remoteLifecycle := domain.SessionLifecycleID{21}440 remoteLifecycle := domain.SessionLifecycleID{21}
441 d.remoteCatalog.mu.Lock()441 d.remoteCatalog.mu.Lock()
442- d.remoteCatalog.cache["user@arch"] = ports.RemoteCatalogCacheEntry{442+ d.remoteCatalog.cache["user@arch"] = catalogue.RemoteCatalogCacheEntry{
443 Host: "user@arch", FetchedAt: now,443 Host: "user@arch", FetchedAt: now,
444- Sessions: []ports.RemoteCatalogSession{{444+ Sessions: []catalogue.RemoteCatalogSession{{
445- LifecycleID: remoteLifecycle, Name: "vev", State: ports.RemoteCatalogSessionUp,445+ LifecycleID: remoteLifecycle, Name: "vev", State: catalogue.RemoteCatalogSessionUp,
446- Tabs: []ports.RemoteCatalogTab{{ID: "tab-remote", Index: 0, Name: "shell"}}, ActiveTabID: "tab-remote",446+ Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-remote", Index: 0, Name: "shell"}}, ActiveTabID: "tab-remote",
447 }},447 }},
448 }448 }
449 d.remoteCatalog.status["user@arch"] = remoteHostFresh449 d.remoteCatalog.status["user@arch"] = remoteHostFresh
@@ -527,11 +527,11 @@ func TestPaletteMatchesRecentRemoteRouteToCatalog(t *testing.T) {
527 lifecycle := domain.SessionLifecycleID{22}527 lifecycle := domain.SessionLifecycleID{22}
528 d.remoteCatalog.mu.Lock()528 d.remoteCatalog.mu.Lock()
529 for _, endpoint := range test.catalogEndpoints {529 for _, endpoint := range test.catalogEndpoints {
530- d.remoteCatalog.cache[endpoint] = ports.RemoteCatalogCacheEntry{530+ d.remoteCatalog.cache[endpoint] = catalogue.RemoteCatalogCacheEntry{
531 Host: endpoint, FetchedAt: now,531 Host: endpoint, FetchedAt: now,
532- Sessions: []ports.RemoteCatalogSession{{532+ Sessions: []catalogue.RemoteCatalogSession{{
533- LifecycleID: lifecycle, Name: "vev", State: ports.RemoteCatalogSessionUp,533+ LifecycleID: lifecycle, Name: "vev", State: catalogue.RemoteCatalogSessionUp,
534- Tabs: []ports.RemoteCatalogTab{{ID: "tab-vev", Index: 0}}, ActiveTabID: "tab-vev",534+ Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-vev", Index: 0}}, ActiveTabID: "tab-vev",
535 }},535 }},
536 }536 }
537 d.remoteCatalog.status[endpoint] = remoteHostFresh537 d.remoteCatalog.status[endpoint] = remoteHostFresh
@@ -577,11 +577,11 @@ func TestPaletteRemoteCatalogSelectionSendsExactAttachTarget(t *testing.T) {
577 d.clock = fixedRemoteRefreshClock{now: now}577 d.clock = fixedRemoteRefreshClock{now: now}
578 remoteLifecycle := domain.SessionLifecycleID{31}578 remoteLifecycle := domain.SessionLifecycleID{31}
579 d.remoteCatalog.mu.Lock()579 d.remoteCatalog.mu.Lock()
580- d.remoteCatalog.cache["user@arch"] = ports.RemoteCatalogCacheEntry{580+ d.remoteCatalog.cache["user@arch"] = catalogue.RemoteCatalogCacheEntry{
581 Host: "user@arch", FetchedAt: now,581 Host: "user@arch", FetchedAt: now,
582- Sessions: []ports.RemoteCatalogSession{{582+ Sessions: []catalogue.RemoteCatalogSession{{
583- LifecycleID: remoteLifecycle, Name: "work", State: ports.RemoteCatalogSessionUp,583+ LifecycleID: remoteLifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp,
584- Tabs: []ports.RemoteCatalogTab{{ID: "tab-work", Index: 0, Name: "shell"}}, ActiveTabID: "tab-work",584+ Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-work", Index: 0, Name: "shell"}}, ActiveTabID: "tab-work",
585 }},585 }},
586 }586 }
587 d.remoteCatalog.status["user@arch"] = remoteHostFresh587 d.remoteCatalog.status["user@arch"] = remoteHostFresh
@@ -616,11 +616,11 @@ func TestPaletteCachedRemoteSelectionFailsClosed(t *testing.T) {
616 now := time.Unix(1_000, 0)616 now := time.Unix(1_000, 0)
617 d.clock = fixedRemoteRefreshClock{now: now}617 d.clock = fixedRemoteRefreshClock{now: now}
618 d.remoteCatalog.mu.Lock()618 d.remoteCatalog.mu.Lock()
619- d.remoteCatalog.cache["arch"] = ports.RemoteCatalogCacheEntry{619+ d.remoteCatalog.cache["arch"] = catalogue.RemoteCatalogCacheEntry{
620 Host: "arch", FetchedAt: now,620 Host: "arch", FetchedAt: now,
621- Sessions: []ports.RemoteCatalogSession{{621+ Sessions: []catalogue.RemoteCatalogSession{{
622- LifecycleID: domain.SessionLifecycleID{32}, Name: "cached", State: ports.RemoteCatalogSessionUp,622+ LifecycleID: domain.SessionLifecycleID{32}, Name: "cached", State: catalogue.RemoteCatalogSessionUp,
623- Tabs: []ports.RemoteCatalogTab{{ID: "tab-cached", Index: 0}}, ActiveTabID: "tab-cached",623+ Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-cached", Index: 0}}, ActiveTabID: "tab-cached",
624 }},624 }},
625 }625 }
626 d.remoteCatalog.status["arch"] = remoteHostCached626 d.remoteCatalog.status["arch"] = remoteHostCached
@@ -660,9 +660,9 @@ func TestRemoteRefreshUpdatesOpenPaletteAndPreservesQuery(t *testing.T) {
660 model.Insert(r)660 model.Insert(r)
661 }661 }
662 ac.overlays.paletteMu.Unlock()662 ac.overlays.paletteMu.Unlock()
663- request.result <- remoteRefreshResult{catalog: remoteCatalogForTest(ports.RemoteCatalogSession{663+ request.result <- remoteRefreshResult{catalog: remoteCatalogForTest(catalogue.RemoteCatalogSession{
664- LifecycleID: domain.SessionLifecycleID{41}, Name: "vev", State: ports.RemoteCatalogSessionUp,664+ LifecycleID: domain.SessionLifecycleID{41}, Name: "vev", State: catalogue.RemoteCatalogSessionUp,
665- Tabs: []ports.RemoteCatalogTab{{ID: "tab-vev", Index: 0}}, ActiveTabID: "tab-vev",665+ Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-vev", Index: 0}}, ActiveTabID: "tab-vev",
666 })}666 })}
667 receiveRemotePicker(t, cache.stores, "palette catalog cache store")667 receiveRemotePicker(t, cache.stores, "palette catalog cache store")
668 d.sessWg.Wait()668 d.sessWg.Wait()
M internal/usecase/daemon/picker.go +6 -6
@@ -9,8 +9,8 @@ import (
9 9
10 renderer "github.com/bnema/vev-vt"10 renderer "github.com/bnema/vev-vt"
11 "github.com/bnema/vev/internal/domain"11 "github.com/bnema/vev/internal/domain"
12- "github.com/bnema/vev/internal/ports"
13 "github.com/bnema/vev/internal/protocol"12 "github.com/bnema/vev/internal/protocol"
13+ "github.com/bnema/vev/internal/protocol/catalogue"
14 "github.com/bnema/vev/internal/usecase/layout"14 "github.com/bnema/vev/internal/usecase/layout"
15 "github.com/bnema/vev/internal/usecase/picker"15 "github.com/bnema/vev/internal/usecase/picker"
16 "github.com/bnema/vev/internal/usecase/ui"16 "github.com/bnema/vev/internal/usecase/ui"
@@ -1005,14 +1005,14 @@ func (d *Daemon) sendRemoteAttachTargetForAttachment(effect *attachmentEffect, t
1005 return nil1005 return nil
1006 }1006 }
1007 1007
1008-func (d *Daemon) remoteCatalogEntryLocked(host string) (ports.RemoteCatalogCacheEntry, bool) {1008+func (d *Daemon) remoteCatalogEntryLocked(host string) (catalogue.RemoteCatalogCacheEntry, bool) {
1009 if d.remoteCatalog.status[host] != remoteHostFresh {1009 if d.remoteCatalog.status[host] != remoteHostFresh {
1010- return ports.RemoteCatalogCacheEntry{}, false1010+ return catalogue.RemoteCatalogCacheEntry{}, false
1011 }1011 }
1012 entry, ok := d.remoteCatalog.cache[host]1012 entry, ok := d.remoteCatalog.cache[host]
1013 if !ok || remoteCatalogExpired(entry.FetchedAt, d.clock.Now()) {1013 if !ok || remoteCatalogExpired(entry.FetchedAt, d.clock.Now()) {
1014 d.remoteCatalog.status[host] = remoteHostStale1014 d.remoteCatalog.status[host] = remoteHostStale
1015- return ports.RemoteCatalogCacheEntry{}, false1015+ return catalogue.RemoteCatalogCacheEntry{}, false
1016 }1016 }
1017 return entry, true1017 return entry, true
1018 }1018 }
@@ -1031,10 +1031,10 @@ func (d *Daemon) remoteCatalogTargetReady(target domain.RemoteSessionTarget) boo
1031 if session.Name != target.SessionName || session.LifecycleID != target.LifecycleID {1031 if session.Name != target.SessionName || session.LifecycleID != target.LifecycleID {
1032 continue1032 continue
1033 }1033 }
1034- if target.Stopped != remoteSessionStateStopped(session.State) || session.State == ports.RemoteCatalogSessionBroken {1034+ if target.Stopped != remoteSessionStateStopped(session.State) || session.State == catalogue.RemoteCatalogSessionBroken {
1035 return false1035 return false
1036 }1036 }
1037- tabs := ports.CatalogTabs(session)1037+ tabs := catalogue.CatalogTabs(session)
1038 metadata := make([]domain.TabSelectorTab, 0, len(tabs))1038 metadata := make([]domain.TabSelectorTab, 0, len(tabs))
1039 for _, tab := range tabs {1039 for _, tab := range tabs {
1040 metadata = append(metadata, domain.TabSelectorTab{ID: domain.TabStableID(tab.ID), Name: tab.Name})1040 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 (
7 "github.com/stretchr/testify/require"7 "github.com/stretchr/testify/require"
8 8
9 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
10- "github.com/bnema/vev/internal/ports"
11 "github.com/bnema/vev/internal/protocol"10 "github.com/bnema/vev/internal/protocol"
11+ "github.com/bnema/vev/internal/protocol/catalogue"
12 "github.com/bnema/vev/internal/protocol/wire"12 "github.com/bnema/vev/internal/protocol/wire"
13 "github.com/bnema/vev/internal/usecase/picker"13 "github.com/bnema/vev/internal/usecase/picker"
14 )14 )
@@ -76,9 +76,9 @@ func TestStoppedLocalPickerHandoffWaitsForClientClose(t *testing.T) {
76 func TestRemotePickerRichHandoffCarriesLifecycleTabAndPolicy(t *testing.T) {76 func TestRemotePickerRichHandoffCarriesLifecycleTabAndPolicy(t *testing.T) {
77 d := newRemotePickerDaemon(nil)77 d := newRemotePickerDaemon(nil)
78 lifecycle := remoteLifecycleForTest()78 lifecycle := remoteLifecycleForTest()
79- d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{79+ d.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{{
80- Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{80+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []catalogue.RemoteCatalogSession{{
81- LifecycleID: lifecycle, Name: "work", State: "up", Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},81+ LifecycleID: lifecycle, Name: "work", State: "up", Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
82 }},82 }},
83 }})83 }})
84 d.remoteCatalog.mu.Lock()84 d.remoteCatalog.mu.Lock()
@@ -117,10 +117,10 @@ func TestRemotePickerRichHandoffRejectsMismatchedRouteKey(t *testing.T) {
117 t.Run(test.name, func(t *testing.T) {117 t.Run(test.name, func(t *testing.T) {
118 d := newRemotePickerDaemon(nil)118 d := newRemotePickerDaemon(nil)
119 lifecycle := remoteLifecycleForTest()119 lifecycle := remoteLifecycleForTest()
120- d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{120+ d.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{{
121- Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{121+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []catalogue.RemoteCatalogSession{{
122- LifecycleID: lifecycle, Name: "work", State: ports.RemoteCatalogSessionUp,122+ LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp,
123- Tabs: []ports.RemoteCatalogTab{{ID: "tab-1"}},123+ Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1"}},
124 }},124 }},
125 }})125 }})
126 d.remoteCatalog.mu.Lock()126 d.remoteCatalog.mu.Lock()
@@ -155,9 +155,9 @@ func TestRemotePickerRichHandoffRejectsReplacedLifecycle(t *testing.T) {
155 lifecycle := remoteLifecycleForTest()155 lifecycle := remoteLifecycleForTest()
156 cachedLifecycle := lifecycle156 cachedLifecycle := lifecycle
157 cachedLifecycle[0]++157 cachedLifecycle[0]++
158- d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{158+ d.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{{
159- Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{159+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []catalogue.RemoteCatalogSession{{
160- LifecycleID: cachedLifecycle, Name: "work", State: "up", Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},160+ LifecycleID: cachedLifecycle, Name: "work", State: "up", Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
161 }},161 }},
162 }})162 }})
163 d.remoteCatalog.mu.Lock()163 d.remoteCatalog.mu.Lock()
M internal/usecase/daemon/remote_catalog_state.go +13 -16
@@ -7,7 +7,7 @@ import (
7 "sync"7 "sync"
8 "time"8 "time"
9 9
10- "github.com/bnema/vev/internal/ports"10+ "github.com/bnema/vev/internal/protocol/catalogue"
11 )11 )
12 12
13 type remoteHostStatus uint813 type remoteHostStatus uint8
@@ -22,8 +22,6 @@ const (
22 remoteHostMalformed22 remoteHostMalformed
23 )23 )
24 24
25-// remoteCatalogState owns the cache-derived discovery state. Its mutex never
26-// covers cache or transport I/O; writeMu only serializes complete cache writes.
27 type remoteDiscoveryConsumerKind uint825 type remoteDiscoveryConsumerKind uint8
28 26
29 const (27 const (
@@ -31,9 +29,11 @@ const (
31 remoteDiscoveryPalette29 remoteDiscoveryPalette
32 )30 )
33 31
32+// remoteCatalogState owns cache-derived discovery state. Its mutex never
33+// covers cache or transport I/O; writeMu serializes complete cache writes.
34 type remoteCatalogState struct {34 type remoteCatalogState struct {
35 mu sync.Mutex35 mu sync.Mutex
36- cache map[string]ports.RemoteCatalogCacheEntry36+ cache map[string]catalogue.RemoteCatalogCacheEntry
37 status map[string]remoteHostStatus37 status map[string]remoteHostStatus
38 failure map[string]time.Time38 failure map[string]time.Time
39 refresh uint6439 refresh uint64
@@ -44,35 +44,35 @@ type remoteCatalogState struct {
44 44
45 func newRemoteCatalogState() remoteCatalogState {45 func newRemoteCatalogState() remoteCatalogState {
46 return remoteCatalogState{46 return remoteCatalogState{
47- cache: make(map[string]ports.RemoteCatalogCacheEntry),47+ cache: make(map[string]catalogue.RemoteCatalogCacheEntry),
48 status: make(map[string]remoteHostStatus),48 status: make(map[string]remoteHostStatus),
49 failure: make(map[string]time.Time),49 failure: make(map[string]time.Time),
50 consumers: make(map[*attachedClient]remoteDiscoveryConsumerKind),50 consumers: make(map[*attachedClient]remoteDiscoveryConsumerKind),
51 }51 }
52 }52 }
53 53
54-func cloneRemoteCatalogEntry(entry ports.RemoteCatalogCacheEntry) ports.RemoteCatalogCacheEntry {54+func cloneRemoteCatalogEntry(entry catalogue.RemoteCatalogCacheEntry) catalogue.RemoteCatalogCacheEntry {
55- sessions := make([]ports.RemoteCatalogSession, len(entry.Sessions))55+ sessions := make([]catalogue.RemoteCatalogSession, len(entry.Sessions))
56 for i, session := range entry.Sessions {56 for i, session := range entry.Sessions {
57 sessions[i] = session57 sessions[i] = session
58- if tabs := ports.CatalogTabs(session); tabs != nil {58+ if tabs := catalogue.CatalogTabs(session); tabs != nil {
59 sessions[i].Tabs = slices.Clone(tabs)59 sessions[i].Tabs = slices.Clone(tabs)
60 }60 }
61 }61 }
62- return ports.RemoteCatalogCacheEntry{62+ return catalogue.RemoteCatalogCacheEntry{
63 Host: entry.Host,63 Host: entry.Host,
64 FetchedAt: entry.FetchedAt,64 FetchedAt: entry.FetchedAt,
65 Sessions: sessions,65 Sessions: sessions,
66 }66 }
67 }67 }
68 68
69-func (s *remoteCatalogState) replaceCache(entries []ports.RemoteCatalogCacheEntry) {69+func (s *remoteCatalogState) replaceCache(entries []catalogue.RemoteCatalogCacheEntry) {
70 if s == nil {70 if s == nil {
71 return71 return
72 }72 }
73 s.mu.Lock()73 s.mu.Lock()
74 defer s.mu.Unlock()74 defer s.mu.Unlock()
75- s.cache = make(map[string]ports.RemoteCatalogCacheEntry, len(entries))75+ s.cache = make(map[string]catalogue.RemoteCatalogCacheEntry, len(entries))
76 s.status = make(map[string]remoteHostStatus, len(entries))76 s.status = make(map[string]remoteHostStatus, len(entries))
77 s.failure = make(map[string]time.Time)77 s.failure = make(map[string]time.Time)
78 for _, entry := range entries {78 for _, entry := range entries {
@@ -81,13 +81,13 @@ func (s *remoteCatalogState) replaceCache(entries []ports.RemoteCatalogCacheEntr
81 }81 }
82 }82 }
83 83
84-func (s *remoteCatalogState) cacheSnapshot() []ports.RemoteCatalogCacheEntry {84+func (s *remoteCatalogState) cacheSnapshot() []catalogue.RemoteCatalogCacheEntry {
85 if s == nil {85 if s == nil {
86 return nil86 return nil
87 }87 }
88 s.mu.Lock()88 s.mu.Lock()
89 defer s.mu.Unlock()89 defer s.mu.Unlock()
90- entries := make([]ports.RemoteCatalogCacheEntry, 0, len(s.cache))90+ entries := make([]catalogue.RemoteCatalogCacheEntry, 0, len(s.cache))
91 for _, entry := range s.cache {91 for _, entry := range s.cache {
92 entries = append(entries, cloneRemoteCatalogEntry(entry))92 entries = append(entries, cloneRemoteCatalogEntry(entry))
93 }93 }
@@ -107,9 +107,6 @@ func (d *Daemon) loadRemoteCatalogCache() {
107 d.remoteCatalog.replaceCache(entries)107 d.remoteCatalog.replaceCache(entries)
108 }108 }
109 109
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.
113 func (d *Daemon) persistRemoteCatalogCache() {110 func (d *Daemon) persistRemoteCatalogCache() {
114 if d == nil || d.remoteCatalogCache == nil {111 if d == nil || d.remoteCatalogCache == nil {
115 return112 return
M internal/usecase/daemon/remote_catalog_state_test.go +17 -17
@@ -8,23 +8,23 @@ import (
8 "testing"8 "testing"
9 "time"9 "time"
10 10
11- "github.com/bnema/vev/internal/ports"11+ "github.com/bnema/vev/internal/protocol/catalogue"
12 "github.com/stretchr/testify/require"12 "github.com/stretchr/testify/require"
13 )13 )
14 14
15 type catalogCacheStub struct {15 type catalogCacheStub struct {
16- load func() ([]ports.RemoteCatalogCacheEntry, error)16+ load func() ([]catalogue.RemoteCatalogCacheEntry, error)
17- store func([]ports.RemoteCatalogCacheEntry) error17+ store func([]catalogue.RemoteCatalogCacheEntry) error
18 }18 }
19 19
20-func (s catalogCacheStub) Load() ([]ports.RemoteCatalogCacheEntry, error) {20+func (s catalogCacheStub) Load() ([]catalogue.RemoteCatalogCacheEntry, error) {
21 if s.load == nil {21 if s.load == nil {
22 return nil, nil22 return nil, nil
23 }23 }
24 return s.load()24 return s.load()
25 }25 }
26 26
27-func (s catalogCacheStub) Store(entries []ports.RemoteCatalogCacheEntry) error {27+func (s catalogCacheStub) Store(entries []catalogue.RemoteCatalogCacheEntry) error {
28 if s.store == nil {28 if s.store == nil {
29 return nil29 return nil
30 }30 }
@@ -35,31 +35,31 @@ func TestRemoteCatalogCacheStartup(t *testing.T) {
35 now := time.Unix(0, 42)35 now := time.Unix(0, 42)
36 tests := []struct {36 tests := []struct {
37 name string37 name string
38- load func() ([]ports.RemoteCatalogCacheEntry, error)38+ load func() ([]catalogue.RemoteCatalogCacheEntry, error)
39- wantCache map[string]ports.RemoteCatalogCacheEntry39+ wantCache map[string]catalogue.RemoteCatalogCacheEntry
40 wantStatus map[string]remoteHostStatus40 wantStatus map[string]remoteHostStatus
41 }{41 }{
42 {42 {
43 name: "loads cached entries",43 name: "loads cached entries",
44- load: func() ([]ports.RemoteCatalogCacheEntry, error) {44+ load: func() ([]catalogue.RemoteCatalogCacheEntry, error) {
45- return []ports.RemoteCatalogCacheEntry{{Host: "arch", FetchedAt: now, Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "up"}}}}, nil45+ return []catalogue.RemoteCatalogCacheEntry{{Host: "arch", FetchedAt: now, Sessions: []catalogue.RemoteCatalogSession{{Name: "work", State: "up"}}}}, nil
46 },46 },
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"}}}},
48 wantStatus: map[string]remoteHostStatus{"arch": remoteHostCached},48 wantStatus: map[string]remoteHostStatus{"arch": remoteHostCached},
49 },49 },
50 {50 {
51 name: "invalid cache starts empty",51 name: "invalid cache starts empty",
52- load: func() ([]ports.RemoteCatalogCacheEntry, error) {52+ load: func() ([]catalogue.RemoteCatalogCacheEntry, error) {
53 return nil, errors.New("invalid cache")53 return nil, errors.New("invalid cache")
54 },54 },
55- wantCache: map[string]ports.RemoteCatalogCacheEntry{},55+ wantCache: map[string]catalogue.RemoteCatalogCacheEntry{},
56 wantStatus: map[string]remoteHostStatus{},56 wantStatus: map[string]remoteHostStatus{},
57 },57 },
58 }58 }
59 59
60 for _, tt := range tests {60 for _, tt := range tests {
61 t.Run(tt.name, func(t *testing.T) {61 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}))
63 63
64 d.remoteCatalog.mu.Lock()64 d.remoteCatalog.mu.Lock()
65 defer d.remoteCatalog.mu.Unlock()65 defer d.remoteCatalog.mu.Unlock()
@@ -76,7 +76,7 @@ func TestRemoteCatalogCacheStoreSerializesLockFreeIO(t *testing.T) {
76 var active, maxActive int76 var active, maxActive int
77 var activeMu sync.Mutex77 var activeMu sync.Mutex
78 78
79- cache := catalogCacheStub{store: func(entries []ports.RemoteCatalogCacheEntry) error {79+ cache := catalogCacheStub{store: func(entries []catalogue.RemoteCatalogCacheEntry) error {
80 if !d.mu.TryLock() {80 if !d.mu.TryLock() {
81 return errors.New("daemon lock held during cache I/O")81 return errors.New("daemon lock held during cache I/O")
82 }82 }
@@ -99,16 +99,16 @@ func TestRemoteCatalogCacheStoreSerializesLockFreeIO(t *testing.T) {
99 activeMu.Unlock()99 activeMu.Unlock()
100 return nil100 return nil
101 }}101 }}
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))
103 d.remoteCatalog.mu.Lock()103 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"}}}
105 d.remoteCatalog.mu.Unlock()105 d.remoteCatalog.mu.Unlock()
106 106
107 done := make(chan struct{}, 2)107 done := make(chan struct{}, 2)
108 go func() { d.persistRemoteCatalogCache(); done <- struct{}{} }()108 go func() { d.persistRemoteCatalogCache(); done <- struct{}{} }()
109 <-entered109 <-entered
110 d.remoteCatalog.mu.Lock()110 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"}}}
112 d.remoteCatalog.mu.Unlock()112 d.remoteCatalog.mu.Unlock()
113 go func() { d.persistRemoteCatalogCache(); done <- struct{}{} }()113 go func() { d.persistRemoteCatalogCache(); done <- struct{}{} }()
114 114
M internal/usecase/daemon/remote_picker.go +19 -19
@@ -10,13 +10,13 @@ import (
10 "time"10 "time"
11 11
12 "github.com/bnema/vev/internal/domain"12 "github.com/bnema/vev/internal/domain"
13- "github.com/bnema/vev/internal/ports"13+ "github.com/bnema/vev/internal/protocol/catalogue"
14 "github.com/bnema/vev/internal/usecase/palette"14 "github.com/bnema/vev/internal/usecase/palette"
15 "github.com/bnema/vev/internal/usecase/picker"15 "github.com/bnema/vev/internal/usecase/picker"
16 )16 )
17 17
18 type remoteCatalogPresentationEntry struct {18 type remoteCatalogPresentationEntry struct {
19- entry ports.RemoteCatalogCacheEntry19+ entry catalogue.RemoteCatalogCacheEntry
20 status remoteHostStatus20 status remoteHostStatus
21 }21 }
22 22
@@ -72,7 +72,7 @@ func (d *Daemon) remoteCatalogSnapshot() []remoteCatalogPresentationEntry {
72 continue72 continue
73 }73 }
74 entries = append(entries, remoteCatalogPresentationEntry{74 entries = append(entries, remoteCatalogPresentationEntry{
75- entry: ports.RemoteCatalogCacheEntry{Host: host},75+ entry: catalogue.RemoteCatalogCacheEntry{Host: host},
76 status: status,76 status: status,
77 })77 })
78 }78 }
@@ -138,11 +138,11 @@ func remotePickerStatusDetail(status remoteHostStatus, fetchedAt time.Time) stri
138 }138 }
139 }139 }
140 140
141-func remoteSessionStateStopped(state ports.RemoteCatalogSessionState) bool {141+func remoteSessionStateStopped(state catalogue.RemoteCatalogSessionState) bool {
142- return state == ports.RemoteCatalogSessionDown142+ return state == catalogue.RemoteCatalogSessionDown
143 }143 }
144 144
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) {
146 key.LifecycleID = session.LifecycleID146 key.LifecycleID = session.LifecycleID
147 key.DisplayOrigin = domain.RemoteDisplayOrigin(key.Host)147 key.DisplayOrigin = domain.RemoteDisplayOrigin(key.Host)
148 target := domain.RemoteSessionTarget{148 target := domain.RemoteSessionTarget{
@@ -152,7 +152,7 @@ func remoteCatalogSessionTarget(key domain.RemoteSessionKey, session ports.Remot
152 SessionName: session.Name,152 SessionName: session.Name,
153 Stopped: remoteSessionStateStopped(session.State),153 Stopped: remoteSessionStateStopped(session.State),
154 }154 }
155- tabs := ports.CatalogTabs(session)155+ tabs := catalogue.CatalogTabs(session)
156 if len(tabs) == 0 {156 if len(tabs) == 0 {
157 return key, target157 return key, target
158 }158 }
@@ -177,12 +177,12 @@ func remoteCatalogSessionTarget(key domain.RemoteSessionKey, session ports.Remot
177 return key, target177 return key, target
178 }178 }
179 179
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 {
181 key, target := remoteCatalogSessionTarget(key, session)181 key, target := remoteCatalogSessionTarget(key, session)
182 availability := remotePickerAvailability(status)182 availability := remotePickerAvailability(status)
183 stopped := remoteSessionStateStopped(session.State)183 stopped := remoteSessionStateStopped(session.State)
184- broken := session.State == ports.RemoteCatalogSessionBroken184+ broken := session.State == catalogue.RemoteCatalogSessionBroken
185- tabs := ports.CatalogTabs(session)185+ tabs := catalogue.CatalogTabs(session)
186 viewTabs := make([]picker.TabEntry, 0, len(tabs))186 viewTabs := make([]picker.TabEntry, 0, len(tabs))
187 active := 0187 active := 0
188 for i, tab := range tabs {188 for i, tab := range tabs {
@@ -236,7 +236,7 @@ func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSe
236 detail = "identity changed"236 detail = "identity changed"
237 case activation == picker.RemoteRestart:237 case activation == picker.RemoteRestart:
238 detail = "stopped — Enter to restart"238 detail = "stopped — Enter to restart"
239- case session.State == ports.RemoteCatalogSessionUp:239+ case session.State == catalogue.RemoteCatalogSessionUp:
240 detail = "up"240 detail = "up"
241 case stopped:241 case stopped:
242 detail = "stopped"242 detail = "stopped"
@@ -573,7 +573,7 @@ func containsRemoteDiscoveryHost(hosts []string, target string) bool {
573 // applyRemoteRefreshResult publishes one host completion only if both its573 // applyRemoteRefreshResult publishes one host completion only if both its
574 // refresh generation and current host-registry membership remain authoritative.574 // refresh generation and current host-registry membership remain authoritative.
575 // Its return reports whether durable cache state changed.575 // 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 {
577 if d == nil {577 if d == nil {
578 return false578 return false
579 }579 }
@@ -592,7 +592,7 @@ func (d *Daemon) applyRemoteRefreshResult(generation uint64, host string, catalo
592 stillKnown := containsRemoteDiscoveryHost(hosts, host)592 stillKnown := containsRemoteDiscoveryHost(hosts, host)
593 now := d.clock.Now()593 now := d.clock.Now()
594 if listErr == nil {594 if listErr == nil {
595- if err := ports.ValidateRemoteCatalog(catalog); err != nil {595+ if err := catalogue.ValidateRemoteCatalog(catalog); err != nil {
596 listErr = err596 listErr = err
597 }597 }
598 }598 }
@@ -616,13 +616,13 @@ func (d *Daemon) applyRemoteRefreshResult(generation uint64, host string, catalo
616 }616 }
617 if listErr != nil {617 if listErr != nil {
618 status := remoteHostMalformed618 status := remoteHostMalformed
619- var mismatch *ports.RemoteCatalogVersionMismatchError619+ var mismatch *catalogue.RemoteCatalogVersionMismatchError
620 if errors.As(listErr, &mismatch) {620 if errors.As(listErr, &mismatch) {
621 status = remoteHostVersionMismatch621 status = remoteHostVersionMismatch
622- } else if !errors.Is(listErr, ports.ErrInvalidRemoteCatalog) &&622+ } else if !errors.Is(listErr, catalogue.ErrInvalidRemoteCatalog) &&
623- !errors.Is(listErr, ports.ErrRemoteCatalogTooLarge) &&623+ !errors.Is(listErr, catalogue.ErrRemoteCatalogTooLarge) &&
624- !errors.Is(listErr, ports.ErrRemoteCatalogUnknownState) &&624+ !errors.Is(listErr, catalogue.ErrRemoteCatalogUnknownState) &&
625- !errors.Is(listErr, ports.ErrRemoteCatalogInvalidReason) {625+ !errors.Is(listErr, catalogue.ErrRemoteCatalogInvalidReason) {
626 status = remoteHostUnreachable626 status = remoteHostUnreachable
627 }627 }
628 d.remoteCatalog.status[host] = status628 d.remoteCatalog.status[host] = status
@@ -631,7 +631,7 @@ func (d *Daemon) applyRemoteRefreshResult(generation uint64, host string, catalo
631 d.refreshRemoteDiscoveryConsumers()631 d.refreshRemoteDiscoveryConsumers()
632 return false632 return false
633 }633 }
634- d.remoteCatalog.cache[host] = cloneRemoteCatalogEntry(ports.RemoteCatalogCacheEntry{634+ d.remoteCatalog.cache[host] = cloneRemoteCatalogEntry(catalogue.RemoteCatalogCacheEntry{
635 Host: host,635 Host: host,
636 FetchedAt: now,636 FetchedAt: now,
637 Sessions: catalog.Sessions,637 Sessions: catalog.Sessions,
M internal/usecase/daemon/remote_picker_test.go +100 -99
@@ -15,9 +15,10 @@ import (
15 "github.com/stretchr/testify/require"15 "github.com/stretchr/testify/require"
16 16
17 "github.com/bnema/vev/internal/domain"17 "github.com/bnema/vev/internal/domain"
18- "github.com/bnema/vev/internal/ports"18+ appports "github.com/bnema/vev/internal/ports"
19 portsmocks "github.com/bnema/vev/internal/ports/mocks"19 portsmocks "github.com/bnema/vev/internal/ports/mocks"
20 "github.com/bnema/vev/internal/protocol"20 "github.com/bnema/vev/internal/protocol"
21+ "github.com/bnema/vev/internal/protocol/catalogue"
21 "github.com/bnema/vev/internal/protocol/wire"22 "github.com/bnema/vev/internal/protocol/wire"
22 "github.com/bnema/vev/internal/usecase/picker"23 "github.com/bnema/vev/internal/usecase/picker"
23 )24 )
@@ -56,16 +57,16 @@ func receiveRemotePickerClose(t *testing.T, ch <-chan struct{}, what string) {
56 require.NoError(t, waitRemotePickerClose(ch, what))57 require.NoError(t, waitRemotePickerClose(ch, what))
57 }58 }
58 59
59-func newRemotePickerDaemon(store ports.RemoteHostStore) *Daemon {60+func newRemotePickerDaemon(store appports.RemoteHostStore) *Daemon {
60- return New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithRemoteDiscovery(store, nil, nil, nil, ports.RemoteTransportUDP))61+ return New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithRemoteDiscovery(store, nil, nil))
61 }62 }
62 63
63 func TestRemotePickerStoppedRowsUseCanonicalStateAndSafeSelection(t *testing.T) {64 func TestRemotePickerStoppedRowsUseCanonicalStateAndSafeSelection(t *testing.T) {
64 lifecycle := remoteLifecycleForTest()65 lifecycle := remoteLifecycleForTest()
65 key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle}66 key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle}
66- session := ports.RemoteCatalogSession{67+ session := catalogue.RemoteCatalogSession{
67- LifecycleID: lifecycle, Name: "work", State: ports.RemoteCatalogSessionDown,68+ LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionDown,
68- Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},69+ Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
69 }70 }
70 view := remotePickerView(key, session, remoteHostFresh, time.Unix(100, 0))71 view := remotePickerView(key, session, remoteHostFresh, time.Unix(100, 0))
71 require.True(t, view.Stopped)72 require.True(t, view.Stopped)
@@ -77,9 +78,9 @@ func TestRemotePickerStoppedRowsUseCanonicalStateAndSafeSelection(t *testing.T)
77 }78 }
78 79
79 func TestRemotePickerClampsStoppedOrdinalTabCount(t *testing.T) {80 func TestRemotePickerClampsStoppedOrdinalTabCount(t *testing.T) {
80- tabs := make([]ports.RemoteCatalogTab, math.MaxUint16+1)81+ tabs := make([]catalogue.RemoteCatalogTab, math.MaxUint16+1)
81- tabs[0] = ports.RemoteCatalogTab{Name: "main"}82+ tabs[0] = catalogue.RemoteCatalogTab{Name: "main"}
82- view := remotePickerView(domain.RemoteSessionKey{Host: "arch", Name: "work"}, ports.RemoteCatalogSession{83+ view := remotePickerView(domain.RemoteSessionKey{Host: "arch", Name: "work"}, catalogue.RemoteCatalogSession{
83 LifecycleID: remoteLifecycleForTest(), Name: "work", State: "down", Tabs: tabs,84 LifecycleID: remoteLifecycleForTest(), Name: "work", State: "down", Tabs: tabs,
84 }, remoteHostFresh, time.Unix(100, 0))85 }, remoteHostFresh, time.Unix(100, 0))
85 86
@@ -89,9 +90,9 @@ func TestRemotePickerClampsStoppedOrdinalTabCount(t *testing.T) {
89 90
90 func TestRemotePickerUsesCompactUpDetailRegardlessOfAttachment(t *testing.T) {91 func TestRemotePickerUsesCompactUpDetailRegardlessOfAttachment(t *testing.T) {
91 for _, attached := range []bool{false, true} {92 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{
93 LifecycleID: remoteLifecycleForTest(), Name: "work", State: "up", Attached: attached,94 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}},
95 }, remoteHostFresh, time.Now())96 }, remoteHostFresh, time.Now())
96 require.Equal(t, "up", view.RemoteDetail)97 require.Equal(t, "up", view.RemoteDetail)
97 }98 }
@@ -101,13 +102,13 @@ func TestRemotePickerScopesSameLifecycleBytesToEachEndpoint(t *testing.T) {
101 store := &remoteRefreshHostStore{hosts: []string{"remote", "vev@remote"}}102 store := &remoteRefreshHostStore{hosts: []string{"remote", "vev@remote"}}
102 d := newRemotePickerDaemon(store)103 d := newRemotePickerDaemon(store)
103 lifecycle := remoteLifecycleForTest()104 lifecycle := remoteLifecycleForTest()
104- session := ports.RemoteCatalogSession{105+ session := catalogue.RemoteCatalogSession{
105 LifecycleID: lifecycle, Name: "work", State: "up",106 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"}},
107 }108 }
108- d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{109+ d.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{
109- {Host: "remote", FetchedAt: time.Unix(100, 0), Sessions: []ports.RemoteCatalogSession{session}},110+ {Host: "remote", FetchedAt: time.Unix(100, 0), Sessions: []catalogue.RemoteCatalogSession{session}},
110- {Host: "vev@remote", FetchedAt: time.Unix(100, 0), Sessions: []ports.RemoteCatalogSession{session}},111+ {Host: "vev@remote", FetchedAt: time.Unix(100, 0), Sessions: []catalogue.RemoteCatalogSession{session}},
111 })112 })
112 d.remoteCatalog.mu.Lock()113 d.remoteCatalog.mu.Lock()
113 d.remoteCatalog.status["remote"] = remoteHostFresh114 d.remoteCatalog.status["remote"] = remoteHostFresh
@@ -131,9 +132,9 @@ func TestRemotePickerCatalogExpiresAtAttachTTL(t *testing.T) {
131 clock := &remotePickerClock{now: now}132 clock := &remotePickerClock{now: now}
132 d := newTestDaemon(t, nil, clock)133 d := newTestDaemon(t, nil, clock)
133 lifecycle := remoteLifecycleForTest()134 lifecycle := remoteLifecycleForTest()
134- d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{135+ d.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{{
135- Host: "arch", FetchedAt: now.Add(-remoteCatalogAttachTTL), Sessions: []ports.RemoteCatalogSession{{136+ Host: "arch", FetchedAt: now.Add(-remoteCatalogAttachTTL), Sessions: []catalogue.RemoteCatalogSession{{
136- LifecycleID: lifecycle, Name: "work", State: "up", Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},137+ LifecycleID: lifecycle, Name: "work", State: "up", Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
137 }},138 }},
138 }})139 }})
139 d.remoteCatalog.mu.Lock()140 d.remoteCatalog.mu.Lock()
@@ -152,9 +153,9 @@ func TestRemotePickerTargetReadyExpiresAfterPickerBuildWithoutSnapshot(t *testin
152 clock := &remotePickerClock{now: now}153 clock := &remotePickerClock{now: now}
153 d := newTestDaemon(t, nil, clock)154 d := newTestDaemon(t, nil, clock)
154 lifecycle := remoteLifecycleForTest()155 lifecycle := remoteLifecycleForTest()
155- d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{156+ d.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{{
156- Host: "arch", FetchedAt: now, Sessions: []ports.RemoteCatalogSession{{157+ Host: "arch", FetchedAt: now, Sessions: []catalogue.RemoteCatalogSession{{
157- LifecycleID: lifecycle, Name: "work", State: "up", Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},158+ LifecycleID: lifecycle, Name: "work", State: "up", Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
158 }},159 }},
159 }})160 }})
160 d.remoteCatalog.mu.Lock()161 d.remoteCatalog.mu.Lock()
@@ -177,14 +178,14 @@ func TestRemotePickerTargetReadyExpiresAfterPickerBuildWithoutSnapshot(t *testin
177 func TestRemotePickerSelectsCatalogActiveTab(t *testing.T) {178 func TestRemotePickerSelectsCatalogActiveTab(t *testing.T) {
178 tests := []struct {179 tests := []struct {
179 name string180 name string
180- tabs []ports.RemoteCatalogTab181+ tabs []catalogue.RemoteCatalogTab
181 activeTabID string182 activeTabID string
182 wantActive int183 wantActive int
183 wantTabID domain.TabStableID184 wantTabID domain.TabStableID
184 }{185 }{
185 {186 {
186 name: "active first",187 name: "active first",
187- tabs: []ports.RemoteCatalogTab{188+ tabs: []catalogue.RemoteCatalogTab{
188 {ID: "tab-active", Index: 0, Name: "active"},189 {ID: "tab-active", Index: 0, Name: "active"},
189 {ID: "tab-last", Index: 1, Name: "last"},190 {ID: "tab-last", Index: 1, Name: "last"},
190 },191 },
@@ -194,7 +195,7 @@ func TestRemotePickerSelectsCatalogActiveTab(t *testing.T) {
194 },195 },
195 {196 {
196 name: "active last",197 name: "active last",
197- tabs: []ports.RemoteCatalogTab{198+ tabs: []catalogue.RemoteCatalogTab{
198 {ID: "tab-first", Index: 0, Name: "first"},199 {ID: "tab-first", Index: 0, Name: "first"},
199 {ID: "tab-active", Index: 1, Name: "active"},200 {ID: "tab-active", Index: 1, Name: "active"},
200 },201 },
@@ -204,7 +205,7 @@ func TestRemotePickerSelectsCatalogActiveTab(t *testing.T) {
204 },205 },
205 {206 {
206 name: "empty active tab ID falls back to first stable tab",207 name: "empty active tab ID falls back to first stable tab",
207- tabs: []ports.RemoteCatalogTab{208+ tabs: []catalogue.RemoteCatalogTab{
208 {ID: "tab-first", Index: 0, Name: "first"},209 {ID: "tab-first", Index: 0, Name: "first"},
209 {ID: "", Index: 1, Name: "malformed"},210 {ID: "", Index: 1, Name: "malformed"},
210 },211 },
@@ -217,8 +218,8 @@ func TestRemotePickerSelectsCatalogActiveTab(t *testing.T) {
217 t.Run(test.name, func(t *testing.T) {218 t.Run(test.name, func(t *testing.T) {
218 d := newRemotePickerDaemon(nil)219 d := newRemotePickerDaemon(nil)
219 lifecycle := remoteLifecycleForTest()220 lifecycle := remoteLifecycleForTest()
220- d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{221+ d.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{{
221- Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{222+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []catalogue.RemoteCatalogSession{{
222 LifecycleID: lifecycle,223 LifecycleID: lifecycle,
223 Name: "vive",224 Name: "vive",
224 State: "up",225 State: "up",
@@ -259,8 +260,8 @@ func TestRemotePickerAvailabilityUsesCachedRowsForFailures(t *testing.T) {
259 for _, test := range tests {260 for _, test := range tests {
260 t.Run(test.name, func(t *testing.T) {261 t.Run(test.name, func(t *testing.T) {
261 d := newRemotePickerDaemon(nil)262 d := newRemotePickerDaemon(nil)
262- d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{263+ d.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{{
263- Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "up"}},264+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []catalogue.RemoteCatalogSession{{Name: "work", State: "up"}},
264 }})265 }})
265 d.remoteCatalog.mu.Lock()266 d.remoteCatalog.mu.Lock()
266 d.remoteCatalog.status["arch"] = test.status267 d.remoteCatalog.status["arch"] = test.status
@@ -278,8 +279,8 @@ func TestRemotePickerStaleDetailUsesLastSuccessfulFetch(t *testing.T) {
278 d := newRemotePickerDaemon(nil)279 d := newRemotePickerDaemon(nil)
279 fetchedAt := time.Date(2025, time.January, 2, 3, 4, 5, 0, time.UTC)280 fetchedAt := time.Date(2025, time.January, 2, 3, 4, 5, 0, time.UTC)
280 failedAt := fetchedAt.Add(6 * time.Hour)281 failedAt := fetchedAt.Add(6 * time.Hour)
281- d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{282+ d.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{{
282- Host: "arch", FetchedAt: fetchedAt, Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "up"}},283+ Host: "arch", FetchedAt: fetchedAt, Sessions: []catalogue.RemoteCatalogSession{{Name: "work", State: "up"}},
283 }})284 }})
284 d.remoteCatalog.mu.Lock()285 d.remoteCatalog.mu.Lock()
285 d.remoteCatalog.status["arch"] = remoteHostUnreachable286 d.remoteCatalog.status["arch"] = remoteHostUnreachable
@@ -355,7 +356,7 @@ type remoteRefreshRequest struct {
355 }356 }
356 357
357 type remoteRefreshResult struct {358 type remoteRefreshResult struct {
358- catalog ports.RemoteCatalog359+ catalog catalogue.RemoteCatalog
359 err error360 err error
360 }361 }
361 362
@@ -367,7 +368,7 @@ type channelRemoteCatalog struct {
367 violations []string368 violations []string
368 }369 }
369 370
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) {
371 c.probeMu.Lock()372 c.probeMu.Lock()
372 if c.probes && !c.d.mu.TryLock() {373 if c.probes && !c.d.mu.TryLock() {
373 c.violations = append(c.violations, "daemon lock held during remote catalog I/O")374 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
386 case result := <-request.result:387 case result := <-request.result:
387 return result.catalog, result.err388 return result.catalog, result.err
388 case <-ctx.Done():389 case <-ctx.Done():
389- return ports.RemoteCatalog{}, ctx.Err()390+ return catalogue.RemoteCatalog{}, ctx.Err()
390 }391 }
391 }392 }
392 393
393 type recordingRemoteCache struct {394 type recordingRemoteCache struct {
394 d *Daemon395 d *Daemon
395- stores chan []ports.RemoteCatalogCacheEntry396+ stores chan []catalogue.RemoteCatalogCacheEntry
396 probeMu sync.Mutex397 probeMu sync.Mutex
397 probes bool398 probes bool
398 violations []string399 violations []string
399 }400 }
400 401
401-func (*recordingRemoteCache) Load() ([]ports.RemoteCatalogCacheEntry, error) { return nil, nil }402+func (*recordingRemoteCache) Load() ([]catalogue.RemoteCatalogCacheEntry, error) { return nil, nil }
402 403
403-func (c *recordingRemoteCache) Store(entries []ports.RemoteCatalogCacheEntry) error {404+func (c *recordingRemoteCache) Store(entries []catalogue.RemoteCatalogCacheEntry) error {
404 c.probeMu.Lock()405 c.probeMu.Lock()
405 if c.probes && !c.d.mu.TryLock() {406 if c.probes && !c.d.mu.TryLock() {
406 c.violations = append(c.violations, "daemon lock held during remote cache write")407 c.violations = append(c.violations, "daemon lock held during remote cache write")
@@ -413,7 +414,7 @@ func (c *recordingRemoteCache) Store(entries []ports.RemoteCatalogCacheEntry) er
413 c.d.remoteCatalog.mu.Unlock()414 c.d.remoteCatalog.mu.Unlock()
414 }415 }
415 c.probeMu.Unlock()416 c.probeMu.Unlock()
416- copyEntries := make([]ports.RemoteCatalogCacheEntry, len(entries))417+ copyEntries := make([]catalogue.RemoteCatalogCacheEntry, len(entries))
417 for i, entry := range entries {418 for i, entry := range entries {
418 copyEntries[i] = cloneRemoteCatalogEntry(entry)419 copyEntries[i] = cloneRemoteCatalogEntry(entry)
419 }420 }
@@ -423,8 +424,8 @@ func (c *recordingRemoteCache) Store(entries []ports.RemoteCatalogCacheEntry) er
423 424
424 type fixedRemoteRefreshClock struct{ now time.Time }425 type fixedRemoteRefreshClock struct{ now time.Time }
425 426
426-func (c fixedRemoteRefreshClock) Now() time.Time { return c.now }427+func (c fixedRemoteRefreshClock) Now() time.Time { return c.now }
427-func (fixedRemoteRefreshClock) NewTimer(time.Duration) ports.Timer { return stubTimer{} }428+func (fixedRemoteRefreshClock) NewTimer(time.Duration) appports.Timer { return stubTimer{} }
428 429
429 type remotePickerClock struct {430 type remotePickerClock struct {
430 mu sync.Mutex431 mu sync.Mutex
@@ -436,7 +437,7 @@ func (c *remotePickerClock) Now() time.Time {
436 defer c.mu.Unlock()437 defer c.mu.Unlock()
437 return c.now438 return c.now
438 }439 }
439-func (*remotePickerClock) NewTimer(time.Duration) ports.Timer { return stubTimer{} }440+func (*remotePickerClock) NewTimer(time.Duration) appports.Timer { return stubTimer{} }
440 func (c *remotePickerClock) Advance(d time.Duration) {441 func (c *remotePickerClock) Advance(d time.Duration) {
441 c.mu.Lock()442 c.mu.Lock()
442 c.now = c.now.Add(d)443 c.now = c.now.Add(d)
@@ -457,19 +458,19 @@ func requireNoRemoteLockViolations(t *testing.T, catalog *channelRemoteCatalog,
457 }458 }
458 }459 }
459 460
460-func remoteCatalogForTest(sessions ...ports.RemoteCatalogSession) ports.RemoteCatalog {461+func remoteCatalogForTest(sessions ...catalogue.RemoteCatalogSession) catalogue.RemoteCatalog {
461- return ports.RemoteCatalog{462+ return catalogue.RemoteCatalog{
462 ProtocolVersion: protocol.Version,463 ProtocolVersion: protocol.Version,
463- SchemaVersion: ports.RemoteCatalogSchemaVersion,464+ SchemaVersion: catalogue.RemoteCatalogSchemaVersion,
464- Sessions: append([]ports.RemoteCatalogSession{}, sessions...),465+ Sessions: append([]catalogue.RemoteCatalogSession{}, sessions...),
465 }466 }
466 }467 }
467 468
468 func newRemoteRefreshDaemon(t *testing.T, hosts *remoteRefreshHostStore, now time.Time) (*Daemon, *channelRemoteCatalog, *recordingRemoteCache) {469 func newRemoteRefreshDaemon(t *testing.T, hosts *remoteRefreshHostStore, now time.Time) (*Daemon, *channelRemoteCatalog, *recordingRemoteCache) {
469 t.Helper()470 t.Helper()
470 catalog := &channelRemoteCatalog{requests: make(chan remoteRefreshRequest, 8)}471 catalog := &channelRemoteCatalog{requests: make(chan remoteRefreshRequest, 8)}
471- cache := &recordingRemoteCache{stores: make(chan []ports.RemoteCatalogCacheEntry, 8)}472+ cache := &recordingRemoteCache{stores: make(chan []catalogue.RemoteCatalogCacheEntry, 8)}
472- d := New(nil, fixedRemoteRefreshClock{now: now}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithRemoteDiscovery(hosts, catalog, cache, nil, ports.RemoteTransportUDP))473+ d := New(nil, fixedRemoteRefreshClock{now: now}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithRemoteDiscovery(hosts, catalog, cache))
473 catalog.d, cache.d = d, d474 catalog.d, cache.d = d, d
474 return d, catalog, cache475 return d, catalog, cache
475 }476 }
@@ -487,10 +488,10 @@ func TestRemoteRefreshStartsPerHostAndCancelsSupersededGeneration(t *testing.T)
487 ac.overlays.pickerMu.Unlock()488 ac.overlays.pickerMu.Unlock()
488 require.True(t, d.registerRemoteDiscoveryConsumer(instance.discoveryInstance()))489 require.True(t, d.registerRemoteDiscoveryConsumer(instance.discoveryInstance()))
489 lifecycle := remoteLifecycleForTest()490 lifecycle := remoteLifecycleForTest()
490- d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{491+ d.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{{
491- Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{492+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []catalogue.RemoteCatalogSession{{
492 LifecycleID: lifecycle, Name: "cached", State: "up",493 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"}},
494 }},495 }},
495 }})496 }})
496 497
@@ -504,9 +505,9 @@ func TestRemoteRefreshStartsPerHostAndCancelsSupersededGeneration(t *testing.T)
504 require.Contains(t, first, "mule")505 require.Contains(t, first, "mule")
505 requireNoRemoteLockViolations(t, catalog, nil)506 requireNoRemoteLockViolations(t, catalog, nil)
506 d.remoteCatalog.mu.Lock()507 d.remoteCatalog.mu.Lock()
507- require.Equal(t, []ports.RemoteCatalogSession{{508+ require.Equal(t, []catalogue.RemoteCatalogSession{{
508 LifecycleID: lifecycle, Name: "cached", State: "up",509 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"}},
510 }}, d.remoteCatalog.cache["arch"].Sessions, "cached rows publish before any remote completion")511 }}, d.remoteCatalog.cache["arch"].Sessions, "cached rows publish before any remote completion")
511 require.Equal(t, remoteHostRefreshing, d.remoteCatalog.status["arch"])512 require.Equal(t, remoteHostRefreshing, d.remoteCatalog.status["arch"])
512 d.remoteCatalog.mu.Unlock()513 d.remoteCatalog.mu.Unlock()
@@ -581,26 +582,26 @@ func TestRemoteRefreshResultPreservesFailuresAndEvictsSuccessfulOmissions(t *tes
581 now := time.Unix(200, 0)582 now := time.Unix(200, 0)
582 hosts := &remoteRefreshHostStore{hosts: []string{"arch", "mule"}}583 hosts := &remoteRefreshHostStore{hosts: []string{"arch", "mule"}}
583 d, _, cache := newRemoteRefreshDaemon(t, hosts, now)584 d, _, cache := newRemoteRefreshDaemon(t, hosts, now)
584- d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{585+ d.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{
585- {Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "old", State: "up"}}},586+ {Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []catalogue.RemoteCatalogSession{{Name: "old", State: "up"}}},
586- {Host: "mule", FetchedAt: time.Unix(20, 0), Sessions: []ports.RemoteCatalogSession{{Name: "stale", State: "up"}}},587+ {Host: "mule", FetchedAt: time.Unix(20, 0), Sessions: []catalogue.RemoteCatalogSession{{Name: "stale", State: "up"}}},
587 })588 })
588 d.remoteCatalog.mu.Lock()589 d.remoteCatalog.mu.Lock()
589 d.remoteCatalog.refresh = 7590 d.remoteCatalog.refresh = 7
590 d.remoteCatalog.mu.Unlock()591 d.remoteCatalog.mu.Unlock()
591 592
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")))
593 d.remoteCatalog.mu.Lock()594 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)
595 require.Equal(t, time.Unix(20, 0), d.remoteCatalog.cache["mule"].FetchedAt)596 require.Equal(t, time.Unix(20, 0), d.remoteCatalog.cache["mule"].FetchedAt)
596 require.Equal(t, remoteHostUnreachable, d.remoteCatalog.status["mule"])597 require.Equal(t, remoteHostUnreachable, d.remoteCatalog.status["mule"])
597 d.remoteCatalog.mu.Unlock()598 d.remoteCatalog.mu.Unlock()
598 599
599 require.True(t, d.applyRemoteRefreshResult(7, "arch", remoteCatalogForTest(), nil))600 require.True(t, d.applyRemoteRefreshResult(7, "arch", remoteCatalogForTest(), nil))
600 stored := receiveRemotePicker(t, cache.stores, "remote cache store")601 stored := receiveRemotePicker(t, cache.stores, "remote cache store")
601- require.Equal(t, []ports.RemoteCatalogCacheEntry{602+ require.Equal(t, []catalogue.RemoteCatalogCacheEntry{
602- {Host: "arch", FetchedAt: now, Sessions: []ports.RemoteCatalogSession{}},603+ {Host: "arch", FetchedAt: now, Sessions: []catalogue.RemoteCatalogSession{}},
603- {Host: "mule", FetchedAt: time.Unix(20, 0), Sessions: []ports.RemoteCatalogSession{{Name: "stale", State: "up"}}},604+ {Host: "mule", FetchedAt: time.Unix(20, 0), Sessions: []catalogue.RemoteCatalogSession{{Name: "stale", State: "up"}}},
604 }, stored, "every successful write persists the newest full cache atomically")605 }, stored, "every successful write persists the newest full cache atomically")
605 requireNoRemoteLockViolations(t, nil, cache)606 requireNoRemoteLockViolations(t, nil, cache)
606 d.remoteCatalog.mu.Lock()607 d.remoteCatalog.mu.Lock()
@@ -612,21 +613,21 @@ func TestRemoteRefreshResultPreservesFailuresAndEvictsSuccessfulOmissions(t *tes
612 func TestRemoteRefreshRejectsRemovedHostAndLateGeneration(t *testing.T) {613 func TestRemoteRefreshRejectsRemovedHostAndLateGeneration(t *testing.T) {
613 hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}614 hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
614 d, _, cache := newRemoteRefreshDaemon(t, hosts, time.Unix(300, 0))615 d, _, cache := newRemoteRefreshDaemon(t, hosts, time.Unix(300, 0))
615- d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{616+ d.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{{
616- Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "old", State: "up"}},617+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []catalogue.RemoteCatalogSession{{Name: "old", State: "up"}},
617 }})618 }})
618 d.remoteCatalog.mu.Lock()619 d.remoteCatalog.mu.Lock()
619 d.remoteCatalog.refresh = 9620 d.remoteCatalog.refresh = 9
620 d.remoteCatalog.mu.Unlock()621 d.remoteCatalog.mu.Unlock()
621 622
622- require.False(t, d.applyRemoteRefreshResult(8, "arch", remoteCatalogForTest(ports.RemoteCatalogSession{623+ require.False(t, d.applyRemoteRefreshResult(8, "arch", remoteCatalogForTest(catalogue.RemoteCatalogSession{
623- LifecycleID: remoteLifecycleForTest(), Name: "late", State: ports.RemoteCatalogSessionUp,624+ LifecycleID: remoteLifecycleForTest(), Name: "late", State: catalogue.RemoteCatalogSessionUp,
624- Tabs: []ports.RemoteCatalogTab{{ID: "tab-1"}},625+ Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1"}},
625 }), nil))626 }), nil))
626 hosts.set()627 hosts.set()
627- require.True(t, d.applyRemoteRefreshResult(9, "arch", remoteCatalogForTest(ports.RemoteCatalogSession{628+ require.True(t, d.applyRemoteRefreshResult(9, "arch", remoteCatalogForTest(catalogue.RemoteCatalogSession{
628- LifecycleID: remoteLifecycleForTest(), Name: "removed", State: ports.RemoteCatalogSessionUp,629+ LifecycleID: remoteLifecycleForTest(), Name: "removed", State: catalogue.RemoteCatalogSessionUp,
629- Tabs: []ports.RemoteCatalogTab{{ID: "tab-1"}},630+ Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1"}},
630 }), nil))631 }), nil))
631 require.Empty(t, receiveRemotePicker(t, cache.stores, "remote cache store"))632 require.Empty(t, receiveRemotePicker(t, cache.stores, "remote cache store"))
632 requireNoRemoteLockViolations(t, nil, cache)633 requireNoRemoteLockViolations(t, nil, cache)
@@ -640,16 +641,16 @@ func TestRemoteRefreshValidationSentinelsRemainMalformed(t *testing.T) {
640 lifecycle := remoteLifecycleForTest()641 lifecycle := remoteLifecycleForTest()
641 for _, test := range []struct {642 for _, test := range []struct {
642 name string643 name string
643- session ports.RemoteCatalogSession644+ session catalogue.RemoteCatalogSession
644 }{645 }{
645- {name: "unknown state", session: ports.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: "unknown"}},646+ {name: "unknown state", session: catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: "unknown"}},
646- {name: "invalid reason", session: ports.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: "up", Reason: "bogus"}},647+ {name: "invalid reason", session: catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: "up", Reason: "bogus"}},
647 } {648 } {
648 t.Run(test.name, func(t *testing.T) {649 t.Run(test.name, func(t *testing.T) {
649 hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}650 hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
650 d, _, _ := newRemoteRefreshDaemon(t, hosts, time.Unix(400, 0))651 d, _, _ := newRemoteRefreshDaemon(t, hosts, time.Unix(400, 0))
651 d.remoteCatalog.refresh = 1652 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}}
653 require.False(t, d.applyRemoteRefreshResult(1, "arch", catalog, nil))654 require.False(t, d.applyRemoteRefreshResult(1, "arch", catalog, nil))
654 d.remoteCatalog.mu.Lock()655 d.remoteCatalog.mu.Lock()
655 require.Equal(t, remoteHostMalformed, d.remoteCatalog.status["arch"])656 require.Equal(t, remoteHostMalformed, d.remoteCatalog.status["arch"])
@@ -661,13 +662,13 @@ func TestRemoteRefreshValidationSentinelsRemainMalformed(t *testing.T) {
661 func TestRemoteRefreshVersionMismatchPreservesStaleRows(t *testing.T) {662 func TestRemoteRefreshVersionMismatchPreservesStaleRows(t *testing.T) {
662 hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}663 hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
663 d, _, _ := newRemoteRefreshDaemon(t, hosts, time.Unix(400, 0))664 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+ old := catalogue.RemoteCatalogCacheEntry{Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []catalogue.RemoteCatalogSession{{Name: "work", State: "up"}}}
665- d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{old})666+ d.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{old})
666 d.remoteCatalog.mu.Lock()667 d.remoteCatalog.mu.Lock()
667 d.remoteCatalog.refresh = 3668 d.remoteCatalog.refresh = 3
668 d.remoteCatalog.mu.Unlock()669 d.remoteCatalog.mu.Unlock()
669 670
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}))
671 d.remoteCatalog.mu.Lock()672 d.remoteCatalog.mu.Lock()
672 require.Equal(t, old, d.remoteCatalog.cache["arch"])673 require.Equal(t, old, d.remoteCatalog.cache["arch"])
673 require.Equal(t, remoteHostVersionMismatch, d.remoteCatalog.status["arch"])674 require.Equal(t, remoteHostVersionMismatch, d.remoteCatalog.status["arch"])
@@ -676,13 +677,13 @@ func TestRemoteRefreshVersionMismatchPreservesStaleRows(t *testing.T) {
676 677
677 func TestRemotePickerHandoffSendsTargetAndLeavesNoShadowSession(t *testing.T) {678 func TestRemotePickerHandoffSendsTargetAndLeavesNoShadowSession(t *testing.T) {
678 lifecycle := remoteLifecycleForTest()679 lifecycle := remoteLifecycleForTest()
679- remoteSession := ports.RemoteCatalogSession{680+ remoteSession := catalogue.RemoteCatalogSession{
680- LifecycleID: lifecycle, Name: "work", State: ports.RemoteCatalogSessionUp,681+ LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp,
681- Tabs: []ports.RemoteCatalogTab{{ID: "tab-1"}},682+ Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1"}},
682 }683 }
683 d := newRemotePickerDaemon(nil)684 d := newRemotePickerDaemon(nil)
684- d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{685+ d.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{{
685- Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{remoteSession},686+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []catalogue.RemoteCatalogSession{remoteSession},
686 }})687 }})
687 d.remoteCatalog.mu.Lock()688 d.remoteCatalog.mu.Lock()
688 d.remoteCatalog.status["arch"] = remoteHostFresh689 d.remoteCatalog.status["arch"] = remoteHostFresh
@@ -709,11 +710,11 @@ func TestRemotePickerHandoffSendsTargetAndLeavesNoShadowSession(t *testing.T) {
709 710
710 func TestRemotePickerSelectsStoppedRemoteTabAndRestoresIt(t *testing.T) {711 func TestRemotePickerSelectsStoppedRemoteTabAndRestoresIt(t *testing.T) {
711 lifecycle := remoteLifecycleForTest()712 lifecycle := remoteLifecycleForTest()
712- remoteSession := ports.RemoteCatalogSession{713+ remoteSession := catalogue.RemoteCatalogSession{
713 LifecycleID: lifecycle,714 LifecycleID: lifecycle,
714 Name: "work",715 Name: "work",
715 State: "down",716 State: "down",
716- Tabs: []ports.RemoteCatalogTab{717+ Tabs: []catalogue.RemoteCatalogTab{
717 {ID: "tab-a", Name: "alpha", Index: 0},718 {ID: "tab-a", Name: "alpha", Index: 0},
718 {ID: "tab-b", Name: "beta", Index: 1},719 {ID: "tab-b", Name: "beta", Index: 1},
719 },720 },
@@ -738,7 +739,7 @@ func TestRemotePickerSelectsStoppedRemoteTabAndRestoresIt(t *testing.T) {
738 require.Equal(t, domain.NewStableTabSelector("tab-b"), selected.RemoteTarget.StoppedTab)739 require.Equal(t, domain.NewStableTabSelector("tab-b"), selected.RemoteTarget.StoppedTab)
739 740
740 local := newRemotePickerDaemon(nil)741 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}}})
742 local.remoteCatalog.mu.Lock()743 local.remoteCatalog.mu.Lock()
743 local.remoteCatalog.status["arch"] = remoteHostFresh744 local.remoteCatalog.status["arch"] = remoteHostFresh
744 local.remoteCatalog.mu.Unlock()745 local.remoteCatalog.mu.Unlock()
@@ -779,11 +780,11 @@ func TestRemotePickerSelectsStoppedRemoteTabAndRestoresIt(t *testing.T) {
779 780
780 func TestRemotePickerResurrectsStoppedRemoteSessionWithoutTabMetadata(t *testing.T) {781 func TestRemotePickerResurrectsStoppedRemoteSessionWithoutTabMetadata(t *testing.T) {
781 lifecycle := remoteLifecycleForTest()782 lifecycle := remoteLifecycleForTest()
782- remoteSession := ports.RemoteCatalogSession{783+ remoteSession := catalogue.RemoteCatalogSession{
783 LifecycleID: lifecycle,784 LifecycleID: lifecycle,
784 Name: "work",785 Name: "work",
785 State: "down",786 State: "down",
786- Tabs: []ports.RemoteCatalogTab{},787+ Tabs: []catalogue.RemoteCatalogTab{},
787 }788 }
788 key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}789 key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}
789 remoteView := remotePickerView(key, remoteSession, remoteHostFresh, time.Unix(10, 0))790 remoteView := remotePickerView(key, remoteSession, remoteHostFresh, time.Unix(10, 0))
@@ -807,8 +808,8 @@ func TestRemotePickerResurrectsStoppedRemoteSessionWithoutTabMetadata(t *testing
807 require.Equal(t, domain.TabSelector{}, selected.RemoteTarget.StoppedTab)808 require.Equal(t, domain.TabSelector{}, selected.RemoteTarget.StoppedTab)
808 809
809 local := newRemotePickerDaemon(nil)810 local := newRemotePickerDaemon(nil)
810- local.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{811+ local.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{{
811- Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{remoteSession},812+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []catalogue.RemoteCatalogSession{remoteSession},
812 }})813 }})
813 local.remoteCatalog.mu.Lock()814 local.remoteCatalog.mu.Lock()
814 local.remoteCatalog.status["arch"] = remoteHostFresh815 local.remoteCatalog.status["arch"] = remoteHostFresh
@@ -878,13 +879,13 @@ func TestNavigationActionHandoffSendsBoundedAction(t *testing.T) {
878 879
879 func TestRemotePickerHandoffSendFailureKeepsPickerOpen(t *testing.T) {880 func TestRemotePickerHandoffSendFailureKeepsPickerOpen(t *testing.T) {
880 lifecycle := remoteLifecycleForTest()881 lifecycle := remoteLifecycleForTest()
881- remoteSession := ports.RemoteCatalogSession{882+ remoteSession := catalogue.RemoteCatalogSession{
882- LifecycleID: lifecycle, Name: "work", State: ports.RemoteCatalogSessionUp,883+ LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp,
883- Tabs: []ports.RemoteCatalogTab{{ID: "tab-1"}},884+ Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1"}},
884 }885 }
885 d := newRemotePickerDaemon(nil)886 d := newRemotePickerDaemon(nil)
886- d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{887+ d.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{{
887- Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{remoteSession},888+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []catalogue.RemoteCatalogSession{remoteSession},
888 }})889 }})
889 d.remoteCatalog.mu.Lock()890 d.remoteCatalog.mu.Lock()
890 d.remoteCatalog.status["arch"] = remoteHostFresh891 d.remoteCatalog.status["arch"] = remoteHostFresh
@@ -921,9 +922,9 @@ func TestRemotePickerHandoffSendFailureKeepsPickerOpen(t *testing.T) {
921 require.Same(t, sess, ac.currentAttachmentSession())922 require.Same(t, sess, ac.currentAttachmentSession())
922 }923 }
923 924
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) {
925 t.Helper()926 t.Helper()
926- var tr ports.ServerConnection927+ var tr appports.ServerConnection
927 var sends chan wire.Frame928 var sends chan wire.Frame
928 if len(transports) != 0 {929 if len(transports) != 0 {
929 tr = transports[0]930 tr = transports[0]
@@ -962,12 +963,12 @@ func TestRemoteRefreshUpdatesAllOpenPickersPreservingSelection(t *testing.T) {
962 d.remoteCatalog.refresh = 4963 d.remoteCatalog.refresh = 4
963 d.remoteCatalog.mu.Unlock()964 d.remoteCatalog.mu.Unlock()
964 965
965- require.True(t, d.applyRemoteRefreshResult(4, "arch", ports.RemoteCatalog{966+ require.True(t, d.applyRemoteRefreshResult(4, "arch", catalogue.RemoteCatalog{
966 ProtocolVersion: protocol.Version,967 ProtocolVersion: protocol.Version,
967- SchemaVersion: ports.RemoteCatalogSchemaVersion,968+ SchemaVersion: catalogue.RemoteCatalogSchemaVersion,
968- Sessions: []ports.RemoteCatalogSession{{969+ Sessions: []catalogue.RemoteCatalogSession{{
969 LifecycleID: remoteLifecycleForTest(), Name: "work", State: "up",970 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}},
971 }},972 }},
972 }, nil))973 }, nil))
973 receiveRemotePicker(t, cache.stores, "remote cache store")974 receiveRemotePicker(t, cache.stores, "remote cache store")
M internal/usecase/daemon/render_pipeline_test.go +1 -1
@@ -28,7 +28,7 @@ import (
28 "github.com/stretchr/testify/require"28 "github.com/stretchr/testify/require"
29 )29 )
30 30
31-// scriptedReplayTransport is intentionally a ports.Transport only: replay31+// scriptedReplayTransport is intentionally a wire.Transport only: replay
32 // assertions must not depend on any adapter's framing implementation.32 // assertions must not depend on any adapter's framing implementation.
33 type scriptedReplayTransport struct {33 type scriptedReplayTransport struct {
34 t *testing.T34 t *testing.T
M internal/usecase/daemon/session_test.go +6 -6
@@ -399,11 +399,11 @@ func TestServeReturnsWhenLastSessionExits(t *testing.T) {
399 tr, sends, _ := newConn(t, mustHello(protocol.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24}))399 tr, sends, _ := newConn(t, mustHello(protocol.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24}))
400 400
401 l := newMockServerListener(t)401 l := newMockServerListener(t)
402- connCh := make(chan ports.Transport, 1)402+ connCh := make(chan wire.Transport, 1)
403 connCh <- tr403 connCh <- tr
404 closed := make(chan struct{})404 closed := make(chan struct{})
405 var once sync.Once405 var once sync.Once
406- l.EXPECT().Accept().RunAndReturn(func() (ports.Transport, error) {406+ l.EXPECT().Accept().RunAndReturn(func() (wire.Transport, error) {
407 select {407 select {
408 case c := <-connCh:408 case c := <-connCh:
409 return c, nil409 return c, nil
@@ -439,11 +439,11 @@ func TestServeGracefulShutdownOnContextCancel(t *testing.T) {
439 tr, sends, _ := newConn(t, mustHello(protocol.IntentNew, "long", domain.Size{Cols: 80, Rows: 24}))439 tr, sends, _ := newConn(t, mustHello(protocol.IntentNew, "long", domain.Size{Cols: 80, Rows: 24}))
440 440
441 l := newMockServerListener(t)441 l := newMockServerListener(t)
442- connCh := make(chan ports.Transport, 1)442+ connCh := make(chan wire.Transport, 1)
443 connCh <- tr443 connCh <- tr
444 closed := make(chan struct{})444 closed := make(chan struct{})
445 var once sync.Once445 var once sync.Once
446- l.EXPECT().Accept().RunAndReturn(func() (ports.Transport, error) {446+ l.EXPECT().Accept().RunAndReturn(func() (wire.Transport, error) {
447 select {447 select {
448 case c := <-connCh:448 case c := <-connCh:
449 return c, nil449 return c, nil
@@ -601,11 +601,11 @@ func TestServeReturnsDespiteWedgedClientOnShutdown(t *testing.T) {
601 tr.EXPECT().Close().RunAndReturn(func() error { closeConn(); return nil }).Maybe()601 tr.EXPECT().Close().RunAndReturn(func() error { closeConn(); return nil }).Maybe()
602 602
603 l := newMockServerListener(t)603 l := newMockServerListener(t)
604- connCh := make(chan ports.Transport, 1)604+ connCh := make(chan wire.Transport, 1)
605 connCh <- tr605 connCh <- tr
606 lnClosed := make(chan struct{})606 lnClosed := make(chan struct{})
607 var lnOnce sync.Once607 var lnOnce sync.Once
608- l.EXPECT().Accept().RunAndReturn(func() (ports.Transport, error) {608+ l.EXPECT().Accept().RunAndReturn(func() (wire.Transport, error) {
609 select {609 select {
610 case c := <-connCh:610 case c := <-connCh:
611 return c, nil611 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
613 }613 }
614 }614 }
615 615
616-func serveSnapshotListener(t *testing.T, tr ports.Transport) *mockServerListener {616+func serveSnapshotListener(t *testing.T, tr wire.Transport) *mockServerListener {
617 t.Helper()617 t.Helper()
618 l := newMockServerListener(t)618 l := newMockServerListener(t)
619- connections := make(chan ports.Transport, 1)619+ connections := make(chan wire.Transport, 1)
620 connections <- tr620 connections <- tr
621 closed := make(chan struct{})621 closed := make(chan struct{})
622 var once sync.Once622 var once sync.Once
623- l.EXPECT().Accept().RunAndReturn(func() (ports.Transport, error) {623+ l.EXPECT().Accept().RunAndReturn(func() (wire.Transport, error) {
624 select {624 select {
625 case conn := <-connections:625 case conn := <-connections:
626 return conn, nil626 return conn, nil
M internal/usecase/daemon/transport_observability_test.go +2 -1
@@ -7,6 +7,7 @@ import (
7 "testing"7 "testing"
8 "time"8 "time"
9 9
10+ "github.com/bnema/vev/internal/adapters/observability"
10 "github.com/bnema/vev/internal/domain"11 "github.com/bnema/vev/internal/domain"
11 "github.com/bnema/vev/internal/ports"12 "github.com/bnema/vev/internal/ports"
12 "github.com/bnema/vev/internal/protocol/wire"13 "github.com/bnema/vev/internal/protocol/wire"
@@ -233,7 +234,7 @@ func TestTransportObservabilityBlockedACKEndDoesNotDelayNotifyAck(t *testing.T)
233 endEntered: make(chan struct{}),234 endEntered: make(chan struct{}),
234 releaseEnd: make(chan struct{}),235 releaseEnd: make(chan struct{}),
235 }236 }
236- reporter := ports.NewSerializedRuntimeObserver(observer, 64)237+ reporter := observability.NewSerialized(observer, 64)
237 defer reporter.Close()238 defer reporter.Close()
238 d := New(nil, nil, nil, WithRuntimeObserver(reporter))239 d := New(nil, nil, nil, WithRuntimeObserver(reporter))
239 wakes := make(chan renderWake, 1)240 wakes := make(chan renderWake, 1)
M internal/usecase/daemon/typed_connection_test.go +23 -23
@@ -7,9 +7,9 @@ import (
7 "github.com/stretchr/testify/mock"7 "github.com/stretchr/testify/mock"
8 8
9 "github.com/bnema/vev/internal/ports"9 "github.com/bnema/vev/internal/ports"
10- portsmocks "github.com/bnema/vev/internal/ports/mocks"
11 "github.com/bnema/vev/internal/protocol"10 "github.com/bnema/vev/internal/protocol"
12 "github.com/bnema/vev/internal/protocol/wire"11 "github.com/bnema/vev/internal/protocol/wire"
12+ wiremocks "github.com/bnema/vev/internal/protocol/wire/mocks"
13 )13 )
14 14
15 type mockTestingT interface {15 type mockTestingT interface {
@@ -17,10 +17,10 @@ type mockTestingT interface {
17 Cleanup(func())17 Cleanup(func())
18 }18 }
19 19
20-type mockServerConnection struct{ *portsmocks.MockTransport }20+type mockServerConnection struct{ *wiremocks.MockTransport }
21 21
22 func newMockServerConnection(t mockTestingT) *mockServerConnection {22 func newMockServerConnection(t mockTestingT) *mockServerConnection {
23- return &mockServerConnection{MockTransport: portsmocks.NewMockTransport(t)}23+ return &mockServerConnection{MockTransport: wiremocks.NewMockTransport(t)}
24 }24 }
25 25
26 func (c *mockServerConnection) ReceiveClient() (protocol.ClientMessage, error) {26 func (c *mockServerConnection) ReceiveClient() (protocol.ClientMessage, error) {
@@ -52,7 +52,7 @@ func (c *mockServerConnection) LinkEvents() <-chan ports.LinkEvent {
52 return testLinkEvents(c.MockTransport)52 return testLinkEvents(c.MockTransport)
53 }53 }
54 54
55-type rawServerConnection struct{ raw ports.Transport }55+type rawServerConnection struct{ raw wire.Transport }
56 56
57 func (c *rawServerConnection) ReceiveClient() (protocol.ClientMessage, error) {57 func (c *rawServerConnection) ReceiveClient() (protocol.ClientMessage, error) {
58 return testReceiveClient(c.raw)58 return testReceiveClient(c.raw)
@@ -82,10 +82,10 @@ func (c *rawServerConnection) LinkState() ports.LinkState { return testL
82 func (c *rawServerConnection) LinkEvents() <-chan ports.LinkEvent { return testLinkEvents(c.raw) }82 func (c *rawServerConnection) LinkEvents() <-chan ports.LinkEvent { return testLinkEvents(c.raw) }
83 func (c *rawServerConnection) Close() error { return c.raw.Close() }83 func (c *rawServerConnection) Close() error { return c.raw.Close() }
84 84
85-type mockServerListener struct{ *portsmocks.MockListener }85+type mockServerListener struct{ *wiremocks.MockListener }
86 86
87 func newMockServerListener(t mockTestingT) *mockServerListener {87 func newMockServerListener(t mockTestingT) *mockServerListener {
88- return &mockServerListener{MockListener: portsmocks.NewMockListener(t)}88+ return &mockServerListener{MockListener: wiremocks.NewMockListener(t)}
89 }89 }
90 90
91 func (l *mockServerListener) Accept() (ports.ServerConnection, error) {91 func (l *mockServerListener) Accept() (ports.ServerConnection, error) {
@@ -96,7 +96,7 @@ func (l *mockServerListener) Accept() (ports.ServerConnection, error) {
96 return &rawServerConnection{raw: raw}, nil96 return &rawServerConnection{raw: raw}, nil
97 }97 }
98 98
99-func testReceiveClient(raw ports.Transport) (protocol.ClientMessage, error) {99+func testReceiveClient(raw wire.Transport) (protocol.ClientMessage, error) {
100 frame, err := raw.Recv()100 frame, err := raw.Recv()
101 if err != nil {101 if err != nil {
102 return nil, err102 return nil, err
@@ -166,7 +166,7 @@ func testDecodeClientFrame(frame wire.Frame) (protocol.ClientMessage, error) {
166 }166 }
167 }167 }
168 168
169-func testSendServer(raw ports.Transport, message protocol.ServerMessage) error {169+func testSendServer(raw wire.Transport, message protocol.ServerMessage) error {
170 frame, err := testServerFrame(message)170 frame, err := testServerFrame(message)
171 if err != nil {171 if err != nil {
172 return err172 return err
@@ -174,8 +174,8 @@ func testSendServer(raw ports.Transport, message protocol.ServerMessage) error {
174 return raw.Send(frame)174 return raw.Send(frame)
175 }175 }
176 176
177-func testSendServerAsync(raw ports.Transport, message protocol.ServerMessage) error {177+func testSendServerAsync(raw wire.Transport, message protocol.ServerMessage) error {
178- transport, ok := raw.(ports.AsyncTransport)178+ transport, ok := raw.(wire.AsyncTransport)
179 if !ok {179 if !ok {
180 return errors.New("test server connection: async unsupported")180 return errors.New("test server connection: async unsupported")
181 }181 }
@@ -186,8 +186,8 @@ func testSendServerAsync(raw ports.Transport, message protocol.ServerMessage) er
186 return transport.SendAsync(frame)186 return transport.SendAsync(frame)
187 }187 }
188 188
189-func testSendServerSynchronous(raw ports.Transport, message protocol.ServerMessage) error {189+func testSendServerSynchronous(raw wire.Transport, message protocol.ServerMessage) error {
190- transport, ok := raw.(ports.OwnedSynchronousTransport)190+ transport, ok := raw.(wire.OwnedSynchronousTransport)
191 if !ok {191 if !ok {
192 return errors.New("test server connection: synchronous unsupported")192 return errors.New("test server connection: synchronous unsupported")
193 }193 }
@@ -198,7 +198,7 @@ func testSendServerSynchronous(raw ports.Transport, message protocol.ServerMessa
198 return transport.SendSynchronous(frame)198 return transport.SendSynchronous(frame)
199 }199 }
200 200
201-func testSendOutput(raw ports.Transport, output protocol.Output) error {201+func testSendOutput(raw wire.Transport, output protocol.Output) error {
202 frame, err := testServerFrame(output)202 frame, err := testServerFrame(output)
203 if err != nil {203 if err != nil {
204 return err204 return err
@@ -206,8 +206,8 @@ func testSendOutput(raw ports.Transport, output protocol.Output) error {
206 return raw.Send(frame)206 return raw.Send(frame)
207 }207 }
208 208
209-func testSendOutputAsync(raw ports.Transport, output protocol.Output) error {209+func testSendOutputAsync(raw wire.Transport, output protocol.Output) error {
210- transport, ok := raw.(ports.AsyncTransport)210+ transport, ok := raw.(wire.AsyncTransport)
211 if !ok {211 if !ok {
212 return errors.New("test server connection: async unsupported")212 return errors.New("test server connection: async unsupported")
213 }213 }
@@ -218,8 +218,8 @@ func testSendOutputAsync(raw ports.Transport, output protocol.Output) error {
218 return transport.SendAsync(frame)218 return transport.SendAsync(frame)
219 }219 }
220 220
221-func testSendOutputSynchronous(raw ports.Transport, output protocol.Output) error {221+func testSendOutputSynchronous(raw wire.Transport, output protocol.Output) error {
222- transport, ok := raw.(ports.OwnedSynchronousTransport)222+ transport, ok := raw.(wire.OwnedSynchronousTransport)
223 if !ok {223 if !ok {
224 return errors.New("test server connection: synchronous unsupported")224 return errors.New("test server connection: synchronous unsupported")
225 }225 }
@@ -230,10 +230,10 @@ func testSendOutputSynchronous(raw ports.Transport, output protocol.Output) erro
230 return transport.SendSynchronous(frame)230 return transport.SendSynchronous(frame)
231 }231 }
232 232
233-func testServerCapabilities(raw ports.Transport) protocol.ConnectionCapabilities {233+func testServerCapabilities(raw wire.Transport) protocol.ConnectionCapabilities {
234- _, datagram := raw.(ports.DatagramTransport)234+ _, datagram := raw.(wire.DatagramTransport)
235- _, async := raw.(ports.AsyncTransport)235+ _, async := raw.(wire.AsyncTransport)
236- _, synchronous := raw.(ports.OwnedSynchronousTransport)236+ _, synchronous := raw.(wire.OwnedSynchronousTransport)
237 _, link := raw.(ports.LinkStateReporter)237 _, link := raw.(ports.LinkStateReporter)
238 window := uint8(protocol.MaxOutputWindow)238 window := uint8(protocol.MaxOutputWindow)
239 if datagram {239 if datagram {
@@ -242,14 +242,14 @@ func testServerCapabilities(raw ports.Transport) protocol.ConnectionCapabilities
242 return protocol.ConnectionCapabilities{OutputDataLimit: protocol.MaxOutputDataLen, PreferredOutputWindow: window, AsyncSend: async, OwnedSynchronousSend: synchronous, LinkState: link}242 return protocol.ConnectionCapabilities{OutputDataLimit: protocol.MaxOutputDataLen, PreferredOutputWindow: window, AsyncSend: async, OwnedSynchronousSend: synchronous, LinkState: link}
243 }243 }
244 244
245-func testLinkState(raw ports.Transport) ports.LinkState {245+func testLinkState(raw wire.Transport) ports.LinkState {
246 if reporter, ok := raw.(ports.LinkStateReporter); ok {246 if reporter, ok := raw.(ports.LinkStateReporter); ok {
247 return reporter.LinkState()247 return reporter.LinkState()
248 }248 }
249 return ports.LinkStateConnected249 return ports.LinkStateConnected
250 }250 }
251 251
252-func testLinkEvents(raw ports.Transport) <-chan ports.LinkEvent {252+func testLinkEvents(raw wire.Transport) <-chan ports.LinkEvent {
253 if reporter, ok := raw.(ports.LinkStateReporter); ok {253 if reporter, ok := raw.(ports.LinkStateReporter); ok {
254 return reporter.LinkEvents()254 return reporter.LinkEvents()
255 }255 }
M scripts/remote-picker-harness/main.go +23 -22
@@ -21,6 +21,7 @@ import (
21 "github.com/bnema/vev/internal/domain"21 "github.com/bnema/vev/internal/domain"
22 "github.com/bnema/vev/internal/ports"22 "github.com/bnema/vev/internal/ports"
23 "github.com/bnema/vev/internal/protocol"23 "github.com/bnema/vev/internal/protocol"
24+ "github.com/bnema/vev/internal/protocol/catalogue"
24 "github.com/bnema/vev/internal/protocol/wire"25 "github.com/bnema/vev/internal/protocol/wire"
25 )26 )
26 27
@@ -82,7 +83,7 @@ func run() (runErr error) {
82 }83 }
83 84
84 func runLiveStdioPhase(ctx context.Context, target string, catalog ports.RemoteCatalogClient, preview ports.RemotePreviewClient) error {85 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)
86 if err != nil {87 if err != nil {
87 return fmt.Errorf("open stdio transport: %w", err)88 return fmt.Errorf("open stdio transport: %w", err)
88 }89 }
@@ -114,7 +115,7 @@ func runLiveStdioPhase(ctx context.Context, target string, catalog ports.RemoteC
114 if err != nil {115 if err != nil {
115 return fmt.Errorf("multi-tab catalog: %w", err)116 return fmt.Errorf("multi-tab catalog: %w", err)
116 }117 }
117- beforeTabs := ports.CatalogTabs(liveWithTabs)118+ beforeTabs := catalogue.CatalogTabs(liveWithTabs)
118 if err := sendCommand(ctx, stdio, "close-tab"); err != nil {119 if err := sendCommand(ctx, stdio, "close-tab"); err != nil {
119 return fmt.Errorf("remove tab: %w", err)120 return fmt.Errorf("remove tab: %w", err)
120 }121 }
@@ -122,7 +123,7 @@ func runLiveStdioPhase(ctx context.Context, target string, catalog ports.RemoteC
122 if err != nil {123 if err != nil {
123 return fmt.Errorf("removed-tab catalog: %w", err)124 return fmt.Errorf("removed-tab catalog: %w", err)
124 }125 }
125- removedID, ok := removedCatalogTabID(beforeTabs, ports.CatalogTabs(after))126+ removedID, ok := removedCatalogTabID(beforeTabs, catalogue.CatalogTabs(after))
126 if !ok {127 if !ok {
127 return errors.New("removed-tab catalog did not identify exactly one removed tab")128 return errors.New("removed-tab catalog did not identify exactly one removed tab")
128 }129 }
@@ -161,7 +162,7 @@ func runLocalPickerUnitedPhase(ctx context.Context, target string, catalog ports
161 inputMarker = "VEV_REMOTE_PICKER_INPUT_OK"162 inputMarker = "VEV_REMOTE_PICKER_INPUT_OK"
162 )163 )
163 164
164- remote, err := openTransport(ctx, ports.RemoteTransportStdio, target)165+ remote, err := openTransport(ctx, remoteadapter.TransportStdio, target)
165 if err != nil {166 if err != nil {
166 return fmt.Errorf("open remote picker source: %w", err)167 return fmt.Errorf("open remote picker source: %w", err)
167 }168 }
@@ -201,7 +202,7 @@ func runLocalPickerUnitedPhase(ctx context.Context, target string, catalog ports
201 if remoteCatalog.ActiveTabID == "" {202 if remoteCatalog.ActiveTabID == "" {
202 return errors.New("remote picker catalog omitted active tab")203 return errors.New("remote picker catalog omitted active tab")
203 }204 }
204- tabs := ports.CatalogTabs(remoteCatalog)205+ tabs := catalogue.CatalogTabs(remoteCatalog)
205 if len(tabs) != 2 {206 if len(tabs) != 2 {
206 return errors.New("remote picker catalog did not retain both tabs")207 return errors.New("remote picker catalog did not retain both tabs")
207 }208 }
@@ -347,7 +348,7 @@ func runRestartResumePhase(ctx context.Context, target string, catalog ports.Rem
347 if err != nil {348 if err != nil {
348 return domain.RemoteSessionTarget{}, fmt.Errorf("resumed target: %w", err)349 return domain.RemoteSessionTarget{}, fmt.Errorf("resumed target: %w", err)
349 }350 }
350- resumed, err := openTransport(ctx, ports.RemoteTransportStdio, target)351+ resumed, err := openTransport(ctx, remoteadapter.TransportStdio, target)
351 if err != nil {352 if err != nil {
352 return domain.RemoteSessionTarget{}, fmt.Errorf("open restart stdio transport: %w", err)353 return domain.RemoteSessionTarget{}, fmt.Errorf("open restart stdio transport: %w", err)
353 }354 }
@@ -381,7 +382,7 @@ func runRestartResumePhase(ctx context.Context, target string, catalog ports.Rem
381 }382 }
382 383
383 func runUDPPhase(ctx context.Context, target string) error {384 func runUDPPhase(ctx context.Context, target string) error {
384- udp, err := openTransport(ctx, ports.RemoteTransportUDP, target)385+ udp, err := openTransport(ctx, remoteadapter.TransportUDP, target)
385 if err != nil {386 if err != nil {
386 return fmt.Errorf("open UDP transport: %w", err)387 return fmt.Errorf("open UDP transport: %w", err)
387 }388 }
@@ -418,7 +419,7 @@ func runStaleTargetPhase(ctx context.Context, target string, catalog ports.Remot
418 if err := waitForMissingSession(ctx, catalog, target, sessionName); err != nil {419 if err := waitForMissingSession(ctx, catalog, target, sessionName); err != nil {
419 return fmt.Errorf("wait for destroyed session: %w", err)420 return fmt.Errorf("wait for destroyed session: %w", err)
420 }421 }
421- stale, err := openTransport(ctx, ports.RemoteTransportStdio, target)422+ stale, err := openTransport(ctx, remoteadapter.TransportStdio, target)
422 if err != nil {423 if err != nil {
423 return fmt.Errorf("open stale-target transport: %w", err)424 return fmt.Errorf("open stale-target transport: %w", err)
424 }425 }
@@ -429,7 +430,7 @@ func runStaleTargetPhase(ctx context.Context, target string, catalog ports.Remot
429 return nil430 return nil
430 }431 }
431 432
432-func removedCatalogTabID(before, after []ports.RemoteCatalogTab) (string, bool) {433+func removedCatalogTabID(before, after []catalogue.RemoteCatalogTab) (string, bool) {
433 remaining := make(map[string]struct{}, len(after))434 remaining := make(map[string]struct{}, len(after))
434 for _, tab := range after {435 for _, tab := range after {
435 remaining[tab.ID] = struct{}{}436 remaining[tab.ID] = struct{}{}
@@ -464,7 +465,7 @@ type harnessFrame struct {
464 }465 }
465 466
466 type harnessTransport struct {467 type harnessTransport struct {
467- ports.Transport468+ wire.Transport
468 probe *visualProbe469 probe *visualProbe
469 frames chan harnessFrame470 frames chan harnessFrame
470 queued []harnessFrame471 queued []harnessFrame
@@ -473,7 +474,7 @@ type harnessTransport struct {
473 closeErr error474 closeErr error
474 }475 }
475 476
476-func newHarnessTransport(transport ports.Transport) *harnessTransport {477+func newHarnessTransport(transport wire.Transport) *harnessTransport {
477 h := &harnessTransport{478 h := &harnessTransport{
478 Transport: transport,479 Transport: transport,
479 probe: newVisualProbe(domain.Size{Cols: defaultProbeCols, Rows: defaultProbeRows}),480 probe: newVisualProbe(domain.Size{Cols: defaultProbeCols, Rows: defaultProbeRows}),
@@ -506,13 +507,13 @@ func (h *harnessTransport) Close() error {
506 return h.closeErr507 return h.closeErr
507 }508 }
508 509
509-func openTransport(ctx context.Context, mode ports.RemoteTransportMode, target string) (*harnessTransport, error) {510+func openTransport(ctx context.Context, mode remoteadapter.TransportMode, target string) (*harnessTransport, error) {
510- var transport ports.Transport511+ var transport wire.Transport
511 var err error512 var err error
512 switch mode {513 switch mode {
513- case ports.RemoteTransportStdio:514+ case remoteadapter.TransportStdio:
514 transport, err = sshstdio.DialContext(ctx, target, "", slog.New(slog.DiscardHandler))515 transport, err = sshstdio.DialContext(ctx, target, "", slog.New(slog.DiscardHandler))
515- case ports.RemoteTransportUDP:516+ case remoteadapter.TransportUDP:
516 dialer := dgram.NewRemoteDialerWithLogger(target, "", slog.New(slog.DiscardHandler))517 dialer := dgram.NewRemoteDialerWithLogger(target, "", slog.New(slog.DiscardHandler))
517 dialer.BootstrapTimeout = 8 * time.Second518 dialer.BootstrapTimeout = 8 * time.Second
518 dialer.ProbeTimeout = 3 * time.Second519 dialer.ProbeTimeout = 3 * time.Second
@@ -893,8 +894,8 @@ func expectNoSuchTarget(ctx context.Context, tr *harnessTransport, target domain
893 return nil894 return nil
894 }895 }
895 896
896-func targetForSession(endpoint string, session ports.RemoteCatalogSession, stopped bool) (domain.RemoteSessionTarget, error) {897+func targetForSession(endpoint string, session catalogue.RemoteCatalogSession, stopped bool) (domain.RemoteSessionTarget, error) {
897- tabs := ports.CatalogTabs(session)898+ tabs := catalogue.CatalogTabs(session)
898 if len(tabs) == 0 {899 if len(tabs) == 0 {
899 return domain.RemoteSessionTarget{}, errors.New("catalog session has no tabs")900 return domain.RemoteSessionTarget{}, errors.New("catalog session has no tabs")
900 }901 }
@@ -932,18 +933,18 @@ func targetEndpoint(endpoint string) string {
932 return endpoint933 return endpoint
933 }934 }
934 935
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) {
936 return waitForSessionWithTabCount(ctx, catalog, endpoint, name, state, 0)937 return waitForSessionWithTabCount(ctx, catalog, endpoint, name, state, 0)
937 }938 }
938 939
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) {
940 deadline, cancel := context.WithTimeout(ctx, 10*time.Second)941 deadline, cancel := context.WithTimeout(ctx, 10*time.Second)
941 defer cancel()942 defer cancel()
942 for {943 for {
943 result, err := catalog.List(deadline, endpoint)944 result, err := catalog.List(deadline, endpoint)
944 if err == nil {945 if err == nil {
945 for _, session := range result.Sessions {946 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) {
947 return session, nil948 return session, nil
948 }949 }
949 }950 }
@@ -951,9 +952,9 @@ func waitForSessionWithTabCount(ctx context.Context, catalog ports.RemoteCatalog
951 select {952 select {
952 case <-deadline.Done():953 case <-deadline.Done():
953 if err != nil {954 if err != nil {
954- return ports.RemoteCatalogSession{}, err955+ return catalogue.RemoteCatalogSession{}, err
955 }956 }
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)
957 case <-time.After(100 * time.Millisecond):958 case <-time.After(100 * time.Millisecond):
958 }959 }
959 }960 }
M transport_replay_integration_test.go +3 -4
@@ -10,7 +10,6 @@ import (
10 renderer "github.com/bnema/vev-vt/ansi"10 renderer "github.com/bnema/vev-vt/ansi"
11 "github.com/bnema/vev/internal/adapters/ipc"11 "github.com/bnema/vev/internal/adapters/ipc"
12 "github.com/bnema/vev/internal/adapters/sshstdio"12 "github.com/bnema/vev/internal/adapters/sshstdio"
13- "github.com/bnema/vev/internal/ports"
14 "github.com/bnema/vev/internal/protocol"13 "github.com/bnema/vev/internal/protocol"
15 "github.com/bnema/vev/internal/protocol/wire"14 "github.com/bnema/vev/internal/protocol/wire"
16 "github.com/bnema/vev/internal/testutil/replaytest"15 "github.com/bnema/vev/internal/testutil/replaytest"
@@ -68,11 +67,11 @@ func TestThemeGenerationTransportSequences(t *testing.T) {
68 67
69 tests := []struct {68 tests := []struct {
70 name string69 name string
71- pair func(*testing.T) (ports.Transport, ports.Transport)70+ pair func(*testing.T) (wire.Transport, wire.Transport)
72 }{71 }{
73 {72 {
74 name: "local ipc socket",73 name: "local ipc socket",
75- pair: func(t *testing.T) (ports.Transport, ports.Transport) {74+ pair: func(t *testing.T) (wire.Transport, wire.Transport) {
76 left, right := net.Pipe()75 left, right := net.Pipe()
77 t.Cleanup(func() { _ = left.Close(); _ = right.Close() })76 t.Cleanup(func() { _ = left.Close(); _ = right.Close() })
78 return ipc.NewTransport(left), ipc.NewTransport(right)77 return ipc.NewTransport(left), ipc.NewTransport(right)
@@ -80,7 +79,7 @@ func TestThemeGenerationTransportSequences(t *testing.T) {
80 },79 },
81 {80 {
82 name: "remote ssh stdio",81 name: "remote ssh stdio",
83- pair: func(t *testing.T) (ports.Transport, ports.Transport) {82+ pair: func(t *testing.T) (wire.Transport, wire.Transport) {
84 clientRead, serverWrite := io.Pipe()83 clientRead, serverWrite := io.Pipe()
85 serverRead, clientWrite := io.Pipe()84 serverRead, clientWrite := io.Pipe()
86 closeClient := func() error {85 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