detached b337f41

feat(control): classify command outcomes explicitly

commit
b337f41f91efd5c2c29485a865070b84d2b1b20d
author
brice · · 2026-09-20 10:34 UTC
parent
69cc15b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
34 files · +1291 -159
M buf.yaml +5 -0
@@ -15,3 +15,8 @@ breaking:
1515 # One approved clean break at the migration baseline: the Protobuf
1616 # schema epoch starts here and replaces the custom MsgType codecs.
1717 - EXTENSION_NO_DELETE
18+ ignore_only:
19+ # Protocol v58 intentionally replaces CommandResult.ok with a closed
20+ # outcome. Strict version equality prevents cross-version decoding.
21+ FIELD_NO_DELETE:
22+ - internal/protocol/wire/schema/command.proto
@@ -15,3 +15,8 @@ breaking:
15 # One approved clean break at the migration baseline: the Protobuf15 # One approved clean break at the migration baseline: the Protobuf
16 # schema epoch starts here and replaces the custom MsgType codecs.16 # schema epoch starts here and replaces the custom MsgType codecs.
17 - EXTENSION_NO_DELETE17 - EXTENSION_NO_DELETE
18+ ignore_only:
19+ # Protocol v58 intentionally replaces CommandResult.ok with a closed
20+ # outcome. Strict version equality prevents cross-version decoding.
21+ FIELD_NO_DELETE:
22+ - internal/protocol/wire/schema/command.proto
M docs/architecture.md +12 -2
@@ -1,6 +1,16 @@
11 # Architecture
22
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.
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. The current strict protocol version is 58.
4+
5+`CommandResult` has a closed explicit outcome contract:
6+
7+| Outcome | Meaning | Fields |
8+| --- | --- | --- |
9+| `succeeded` | The command completed successfully. | code and text are empty; output may be present. |
10+| `failed` | A definite failure, including validation/cancellation before dispatch. | code is nonzero; output is empty. |
11+| `outcome_unknown` | Dispatch was attempted but no final outcome can be established (timeout, cancellation, EOF, or lost reply). | code and output are empty; callers must not replay automatically. |
12+
13+Every result retains the request ID for strict correlation. Version negotiation requires exact equality, so protocol 58 intentionally has no compatibility fallback for the deleted legacy `ok` field.
414
515 ## Package ownership
616
@@ -197,7 +207,7 @@ Use cases exchange `protocol.ClientMessage` and `protocol.ServerMessage` values.
197207 `go tool buf generate`, and handle the new variant in `sessionwire`.
198208 Never edit generated `*.pb.go`; never add manual IDs or dispatch tables.
199209 - Implement I/O, queues, workers, environment integration, or technology selection in an adapter or `internal/app`.
200-- Bump `internal/protocol.Version` for negotiated wire layout changes (currently `57`). The preamble epoch (`wire.ProtocolEpoch`, QUIC ALPN `vev/1`) bumps only for an intentional clean break.
210+- Bump `internal/protocol.Version` for negotiated wire layout changes (currently `58`). The preamble epoch (`wire.ProtocolEpoch`, QUIC ALPN `vev/1`) bumps only for an intentional clean break.
201211
202212 ## Broker wire core (Plan 001 P3.1, not activated)
203213
@@ -1,6 +1,16 @@
1 # Architecture1 # Architecture
2 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.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. The current strict protocol version is 58.
4+
5+`CommandResult` has a closed explicit outcome contract:
6+
7+| Outcome | Meaning | Fields |
8+| --- | --- | --- |
9+| `succeeded` | The command completed successfully. | code and text are empty; output may be present. |
10+| `failed` | A definite failure, including validation/cancellation before dispatch. | code is nonzero; output is empty. |
11+| `outcome_unknown` | Dispatch was attempted but no final outcome can be established (timeout, cancellation, EOF, or lost reply). | code and output are empty; callers must not replay automatically. |
12+
13+Every result retains the request ID for strict correlation. Version negotiation requires exact equality, so protocol 58 intentionally has no compatibility fallback for the deleted legacy `ok` field.
4 14
5 ## Package ownership15 ## Package ownership
6 16
@@ -197,7 +207,7 @@ Use cases exchange `protocol.ClientMessage` and `protocol.ServerMessage` values.
197 `go tool buf generate`, and handle the new variant in `sessionwire`.207 `go tool buf generate`, and handle the new variant in `sessionwire`.
198 Never edit generated `*.pb.go`; never add manual IDs or dispatch tables.208 Never edit generated `*.pb.go`; never add manual IDs or dispatch tables.
199 - Implement I/O, queues, workers, environment integration, or technology selection in an adapter or `internal/app`.209 - Implement I/O, queues, workers, environment integration, or technology selection in an adapter or `internal/app`.
200-- Bump `internal/protocol.Version` for negotiated wire layout changes (currently `57`). The preamble epoch (`wire.ProtocolEpoch`, QUIC ALPN `vev/1`) bumps only for an intentional clean break.210+- Bump `internal/protocol.Version` for negotiated wire layout changes (currently `58`). The preamble epoch (`wire.ProtocolEpoch`, QUIC ALPN `vev/1`) bumps only for an intentional clean break.
201 211
202 ## Broker wire core (Plan 001 P3.1, not activated)212 ## Broker wire core (Plan 001 P3.1, not activated)
203 213
M docs/protocol.md +15 -12
@@ -77,7 +77,7 @@ mutation:
7777 |---|---|---|
7878 | `DecodeMessageHello`, version mismatch | `ErrorMsg{ErrVersionMismatch}` | no |
7979 | `DecodeMessageHello`, malformed | `ErrorMsg{ErrInternal}` | no |
80-| `DecodeMessageCommand` | `CommandResult{Code, Text}` | yes, else silent close |
80+| `DecodeMessageCommand` | `CommandResult{Outcome, Code, Text}` | yes, else silent close |
8181 | `DecodeMessageKill` | `ErrorMsg{ErrInternal}` | no — a malformed kill has no reliable RequestID to correlate a `KillResult` to |
8282 | `DecodeMessageRemotePreview` | `RemotePreview{Status: Malformed}` | no |
8383 | `DecodeMessageNavigationInventory` | `NavigationInventoryResponse{Invalid / VersionMismatch}` | yes, else silent close |
@@ -92,7 +92,9 @@ negotiation: equality is mandatory.
9292
9393 Command requests additionally carry a 10-second result deadline
9494 (`daemon/command_tracker.go:CommandRequestTimeout`), tracked per
95-connection and correlated by `RequestID`.
95+connection and correlated by `RequestID`. Every dispatched request produces
96+one closed `CommandResult` outcome: succeeded, failed, or outcome unknown.
97+Timeout or reply loss after dispatch is outcome unknown and is never replayed.
9698
9799 ## Broker negotiation
98100
@@ -196,16 +198,17 @@ answers every normally decoded kill request with exactly one correlated
196198 `KillOutcomeUnknown`. The client consumes that result; a close without a
197199 result, a mismatched `RequestID`, or a wrong-typed reply is `outcome unknown`
198200 and is never replayed, so a control failure whose response cannot be delivered
199-is never mistaken for success. The remaining one-shot controls (`List`,
200-`CommandRequest`, remote preview, inventory, and picker control) have no
201-explicit per-request result. They do not treat close as success: the daemon
202-attempts one response and then closes, and a client that receives no valid
203-response reports a receive error rather than a silent success. Because no
204-typed result exists, an undeliverable response is logged daemon-side only where
205-the handler has a send-failure path (`CommandRequest`, remote preview) and is
206-otherwise unrecorded, so the client is left without any outcome at all; these
207-paths stay weaker than the explicit `KillResult`, and replacing them with
208-explicit per-request results belongs to the P7 cutover (GO-005).
201+is never mistaken for success. `CommandRequest` follows the same correlated
202+model through `CommandResult`: succeeded, failed, or outcome unknown. A timeout,
203+close, cancellation, malformed reply, or mismatched `RequestID` after send is
204+outcome unknown and is never replayed.
205+
206+The remaining one-shot controls (`List`, remote preview, inventory, and picker
207+control) do not treat close as success, but they have no closed outcome field.
208+The daemon attempts one response and then closes; a client that receives no
209+valid response reports a receive error. An undeliverable response is logged
210+only where the handler has a send-failure path, so these controls remain weaker
211+than `KillResult` and `CommandResult`.
209212
210213 ## Limits
211214
@@ -77,7 +77,7 @@ mutation:
77 |---|---|---|77 |---|---|---|
78 | `DecodeMessageHello`, version mismatch | `ErrorMsg{ErrVersionMismatch}` | no |78 | `DecodeMessageHello`, version mismatch | `ErrorMsg{ErrVersionMismatch}` | no |
79 | `DecodeMessageHello`, malformed | `ErrorMsg{ErrInternal}` | no |79 | `DecodeMessageHello`, malformed | `ErrorMsg{ErrInternal}` | no |
80-| `DecodeMessageCommand` | `CommandResult{Code, Text}` | yes, else silent close |80+| `DecodeMessageCommand` | `CommandResult{Outcome, Code, Text}` | yes, else silent close |
81 | `DecodeMessageKill` | `ErrorMsg{ErrInternal}` | no — a malformed kill has no reliable RequestID to correlate a `KillResult` to |81 | `DecodeMessageKill` | `ErrorMsg{ErrInternal}` | no — a malformed kill has no reliable RequestID to correlate a `KillResult` to |
82 | `DecodeMessageRemotePreview` | `RemotePreview{Status: Malformed}` | no |82 | `DecodeMessageRemotePreview` | `RemotePreview{Status: Malformed}` | no |
83 | `DecodeMessageNavigationInventory` | `NavigationInventoryResponse{Invalid / VersionMismatch}` | yes, else silent close |83 | `DecodeMessageNavigationInventory` | `NavigationInventoryResponse{Invalid / VersionMismatch}` | yes, else silent close |
@@ -92,7 +92,9 @@ negotiation: equality is mandatory.
92 92
93 Command requests additionally carry a 10-second result deadline93 Command requests additionally carry a 10-second result deadline
94 (`daemon/command_tracker.go:CommandRequestTimeout`), tracked per94 (`daemon/command_tracker.go:CommandRequestTimeout`), tracked per
95-connection and correlated by `RequestID`.95+connection and correlated by `RequestID`. Every dispatched request produces
96+one closed `CommandResult` outcome: succeeded, failed, or outcome unknown.
97+Timeout or reply loss after dispatch is outcome unknown and is never replayed.
96 98
97 ## Broker negotiation99 ## Broker negotiation
98 100
@@ -196,16 +198,17 @@ answers every normally decoded kill request with exactly one correlated
196 `KillOutcomeUnknown`. The client consumes that result; a close without a198 `KillOutcomeUnknown`. The client consumes that result; a close without a
197 result, a mismatched `RequestID`, or a wrong-typed reply is `outcome unknown`199 result, a mismatched `RequestID`, or a wrong-typed reply is `outcome unknown`
198 and is never replayed, so a control failure whose response cannot be delivered200 and is never replayed, so a control failure whose response cannot be delivered
199-is never mistaken for success. The remaining one-shot controls (`List`,201+is never mistaken for success. `CommandRequest` follows the same correlated
200-`CommandRequest`, remote preview, inventory, and picker control) have no202+model through `CommandResult`: succeeded, failed, or outcome unknown. A timeout,
201-explicit per-request result. They do not treat close as success: the daemon203+close, cancellation, malformed reply, or mismatched `RequestID` after send is
202-attempts one response and then closes, and a client that receives no valid204+outcome unknown and is never replayed.
203-response reports a receive error rather than a silent success. Because no205+
204-typed result exists, an undeliverable response is logged daemon-side only where206+The remaining one-shot controls (`List`, remote preview, inventory, and picker
205-the handler has a send-failure path (`CommandRequest`, remote preview) and is207+control) do not treat close as success, but they have no closed outcome field.
206-otherwise unrecorded, so the client is left without any outcome at all; these208+The daemon attempts one response and then closes; a client that receives no
207-paths stay weaker than the explicit `KillResult`, and replacing them with209+valid response reports a receive error. An undeliverable response is logged
208-explicit per-request results belongs to the P7 cutover (GO-005).210+only where the handler has a send-failure path, so these controls remain weaker
211+than `KillResult` and `CommandResult`.
209 212
210 ## Limits213 ## Limits
211 214
M internal/adapters/sessionwire/client_test.go +1 -1
@@ -72,7 +72,7 @@ func TestClientConnectionDecodesEveryServerMessage(t *testing.T) {
7272 {name: "detached", message: protocol.Detached{Reason: protocol.ReasonDetach}},
7373 {name: "pong", message: protocol.Pong{}},
7474 {name: "sessions", message: protocol.Sessions{}},
75- {name: "command result", message: protocol.CommandResult{RequestID: 1, OK: true}},
75+ {name: "command result", message: protocol.CommandResult{RequestID: 1, Outcome: protocol.CommandSucceeded}},
7676 {name: "kill result", message: protocol.KillResult{RequestID: 1, Outcome: protocol.KillSucceeded}},
7777 {name: "kill result partial failures", message: protocol.KillResult{RequestID: 2, Outcome: protocol.KillFailed, Code: protocol.ErrInternal, Text: "partial", Failures: []protocol.KillFailure{{Class: "stopped", Name: "work", Text: "boom"}}}},
7878 {name: "attach target", message: protocol.AttachTarget{Session: "work", Intent: protocol.IntentAttach}},
@@ -72,7 +72,7 @@ func TestClientConnectionDecodesEveryServerMessage(t *testing.T) {
72 {name: "detached", message: protocol.Detached{Reason: protocol.ReasonDetach}},72 {name: "detached", message: protocol.Detached{Reason: protocol.ReasonDetach}},
73 {name: "pong", message: protocol.Pong{}},73 {name: "pong", message: protocol.Pong{}},
74 {name: "sessions", message: protocol.Sessions{}},74 {name: "sessions", message: protocol.Sessions{}},
75- {name: "command result", message: protocol.CommandResult{RequestID: 1, OK: true}},75+ {name: "command result", message: protocol.CommandResult{RequestID: 1, Outcome: protocol.CommandSucceeded}},
76 {name: "kill result", message: protocol.KillResult{RequestID: 1, Outcome: protocol.KillSucceeded}},76 {name: "kill result", message: protocol.KillResult{RequestID: 1, Outcome: protocol.KillSucceeded}},
77 {name: "kill result partial failures", message: protocol.KillResult{RequestID: 2, Outcome: protocol.KillFailed, Code: protocol.ErrInternal, Text: "partial", Failures: []protocol.KillFailure{{Class: "stopped", Name: "work", Text: "boom"}}}},77 {name: "kill result partial failures", message: protocol.KillResult{RequestID: 2, Outcome: protocol.KillFailed, Code: protocol.ErrInternal, Text: "partial", Failures: []protocol.KillFailure{{Class: "stopped", Name: "work", Text: "boom"}}}},
78 {name: "attach target", message: protocol.AttachTarget{Session: "work", Intent: protocol.IntentAttach}},78 {name: "attach target", message: protocol.AttachTarget{Session: "work", Intent: protocol.IntentAttach}},
A internal/adapters/sessionwire/command_result_test.go +238 -0
@@ -0,0 +1,238 @@
1+package sessionwire
2+
3+// CommandResult wire contract: one command request carries exactly one closed
4+// terminal outcome (Succeeded, Failed, or Unknown), correlation is preserved
5+// byte-for-byte, and the decode boundary refuses a malformed, unspecified, or
6+// out-of-range outcome instead of aliasing it into a valid one. The result is a
7+// bounded control envelope: a failure's Code and Text can never ride the
8+// Output/bulk ceiling exemption.
9+
10+import (
11+ "encoding/hex"
12+ "math"
13+ "testing"
14+
15+ "google.golang.org/protobuf/proto"
16+
17+ "github.com/bnema/vev/internal/protocol"
18+ "github.com/bnema/vev/internal/protocol/wire"
19+ "github.com/stretchr/testify/require"
20+)
21+
22+func testCommandResults() []protocol.CommandResult {
23+ return []protocol.CommandResult{
24+ {RequestID: 0x1122334455, Outcome: protocol.CommandSucceeded, Output: "work\tsh\n"},
25+ {RequestID: 7, Outcome: protocol.CommandFailed, Code: protocol.ErrNoSuchTarget, Text: "no live sessions"},
26+ {RequestID: 8, Outcome: protocol.CommandOutcomeUnknown, Text: "command request timed out"},
27+ }
28+}
29+
30+// commandResultPinnedBytes pins the exact serialized bytes of each fixture in
31+// testCommandResults order, so a schema, encoder, or envelope-variant change
32+// that silently re-lays-out a CommandResult fails loudly here.
33+var commandResultPinnedBytes = []string{
34+ "3a1308d588cd9192022a08776f726b0973680a3001",
35+ "3a180807180922106e6f206c6976652073657373696f6e733002",
36+ "3a1f08082219636f6d6d616e6420726571756573742074696d6564206f75743003",
37+}
38+
39+// TestCommandResultByteRoundTrip proves every closed outcome has pinned
40+// serialized bytes and re-encodes to byte-identical bytes after a strict scan +
41+// generated unmarshal + semantic decode, with the request correlation id and the
42+// bounded text/output intact.
43+func TestCommandResultByteRoundTrip(t *testing.T) {
44+ for index, message := range testCommandResults() {
45+ require.True(t, message.Valid(), "fixture %+v must obey the outcome contract", message)
46+ raw, err := EncodeServerMessage(message)
47+ require.NoError(t, err)
48+ require.Equal(t, commandResultPinnedBytes[index], hex.EncodeToString(raw), "CommandResult %d must keep its pinned bytes", index)
49+ require.Equal(t, categoryControl, serverEnvelopeCategory(raw), "a CommandResult is a control envelope")
50+ require.NoError(t, wire.ScanEnvelope(&wire.ServerEnvelope{}, raw))
51+
52+ decoded, err := DecodeServerEnvelope(raw)
53+ require.NoError(t, err)
54+ require.Equal(t, message, decoded)
55+
56+ reencoded, err := EncodeServerMessage(decoded)
57+ require.NoError(t, err)
58+ require.Equal(t, raw, reencoded, "CommandResult must re-encode byte-for-byte")
59+ }
60+}
61+
62+// TestCommandResultEnvelopeStrictScan proves the scan boundary rejects a
63+// truncated prefix, a truncated payload, trailing garbage, and a concatenated
64+// duplicate before any semantic decode, and that each rejected frame surfaces
65+// as a malformed decode failure rather than a valid result.
66+func TestCommandResultEnvelopeStrictScan(t *testing.T) {
67+ valid, err := EncodeServerMessage(testCommandResults()[0])
68+ require.NoError(t, err)
69+ require.NoError(t, wire.ScanEnvelope(&wire.ServerEnvelope{}, valid))
70+
71+ clone := func(raw []byte) []byte { return append([]byte(nil), raw...) }
72+ tests := []struct {
73+ name string
74+ raw []byte
75+ wantErr error
76+ }{
77+ {name: "truncated tag prefix", raw: valid[:1], wantErr: wire.ErrScanTruncated},
78+ {name: "truncated length prefix", raw: valid[:3], wantErr: wire.ErrScanTruncated},
79+ {name: "truncated payload", raw: valid[:len(valid)-1], wantErr: wire.ErrScanTruncated},
80+ {name: "trailing garbage", raw: append(clone(valid), 0xFF), wantErr: wire.ErrScanTruncated},
81+ {name: "concatenated duplicate", raw: append(clone(valid), valid...), wantErr: wire.ErrScanDuplicate},
82+ }
83+ for _, tt := range tests {
84+ t.Run(tt.name, func(t *testing.T) {
85+ require.ErrorIs(t, wire.ScanEnvelope(&wire.ServerEnvelope{}, tt.raw), tt.wantErr)
86+
87+ _, decodeErr := DecodeServerEnvelope(tt.raw)
88+ var failure *protocol.DecodeFailure
89+ require.ErrorAs(t, decodeErr, &failure)
90+ require.Equal(t, protocol.DecodeMalformed, failure.Category, "a corrupt CommandResult frame is malformed, never a result")
91+ })
92+ }
93+
94+ // Every proper prefix of a valid frame is a truncated prefix in one of the
95+ // leading tag, length, or nested-payload bytes; none may scan or decode.
96+ t.Run("every truncated prefix", func(t *testing.T) {
97+ for length := 1; length < len(valid); length++ {
98+ require.ErrorIs(t, wire.ScanEnvelope(&wire.ServerEnvelope{}, valid[:length]), wire.ErrScanTruncated, "prefix %d", length)
99+ _, decodeErr := DecodeServerEnvelope(valid[:length])
100+ var failure *protocol.DecodeFailure
101+ require.ErrorAs(t, decodeErr, &failure, "prefix %d", length)
102+ require.Equal(t, protocol.DecodeMalformed, failure.Category, "prefix %d", length)
103+ }
104+ })
105+}
106+
107+// TestCommandResultWrongDirectionFailsClosed proves a server-only CommandResult
108+// payload is refused on the client direction and the server decoder rejects a
109+// nil envelope, so a result can never be consumed as a request.
110+func TestCommandResultWrongDirectionFailsClosed(t *testing.T) {
111+ converted, err := commandResultToWire(testCommandResults()[0])
112+ require.NoError(t, err)
113+ raw, err := proto.Marshal(&wire.ServerEnvelope{Payload: &wire.ServerEnvelope_CommandResult{
114+ CommandResult: converted,
115+ }})
116+ require.NoError(t, err)
117+
118+ var clientEnvelope wire.ClientEnvelope
119+ err = wire.ScanEnvelope(&clientEnvelope, raw)
120+ require.Error(t, err, "a server CommandResult must not scan as a client envelope")
121+
122+ _, err = decodeProtoClient(&wire.ClientEnvelope{Payload: nil})
123+ require.ErrorIs(t, err, ErrWrongDirection)
124+ _, err = decodeProtoServer(nil)
125+ require.ErrorIs(t, err, ErrInvalidMessage)
126+}
127+
128+// TestCommandResultRejectsMalformedZeroAndOutOfRangeOutcome proves the decode
129+// boundary refuses an unspecified (zero) outcome, an outcome outside the closed
130+// enumeration, and every outcome/code/text combination the contract forbids.
131+// The out-of-range cases include values whose low byte would otherwise truncate
132+// into a valid outcome (257 -> 1, 259 -> 3, 65537 -> 1), so a hostile or corrupt
133+// frame can never alias success or unknown.
134+func TestCommandResultRejectsMalformedZeroAndOutOfRangeOutcome(t *testing.T) {
135+ failed := wire.CommandOutcome_COMMAND_OUTCOME_FAILED
136+ tests := []struct {
137+ name string
138+ raw *wire.CommandResult
139+ }{
140+ {name: "nil result"},
141+ {name: "unspecified zero outcome", raw: &wire.CommandResult{RequestId: 1}},
142+ {name: "outcome one above range", raw: &wire.CommandResult{RequestId: 1, Outcome: failed + 2}},
143+ {name: "outcome far above range", raw: &wire.CommandResult{RequestId: 1, Outcome: 255}},
144+ {name: "outcome truncating into succeeded", raw: &wire.CommandResult{RequestId: 1, Outcome: 257}},
145+ {name: "outcome truncating into unknown", raw: &wire.CommandResult{RequestId: 1, Outcome: 259}},
146+ {name: "outcome truncating at 16 bits", raw: &wire.CommandResult{RequestId: 1, Outcome: 65537}},
147+ {name: "outcome at int32 maximum", raw: &wire.CommandResult{RequestId: 1, Outcome: wire.CommandOutcome(math.MaxInt32)}},
148+ {name: "negative outcome", raw: &wire.CommandResult{RequestId: 1, Outcome: -1}},
149+ {name: "code overflows uint16", raw: &wire.CommandResult{RequestId: 1, Outcome: wire.CommandOutcome_COMMAND_OUTCOME_SUCCEEDED, Code: 1 << 16}},
150+ {name: "succeeded with a code", raw: &wire.CommandResult{RequestId: 1, Outcome: wire.CommandOutcome_COMMAND_OUTCOME_SUCCEEDED, Code: uint32(protocol.ErrInternal)}},
151+ {name: "succeeded with text", raw: &wire.CommandResult{RequestId: 1, Outcome: wire.CommandOutcome_COMMAND_OUTCOME_SUCCEEDED, Text: "boom"}},
152+ {name: "failed without a code", raw: &wire.CommandResult{RequestId: 1, Outcome: failed}},
153+ {name: "failed with output", raw: &wire.CommandResult{RequestId: 1, Outcome: failed, Code: uint32(protocol.ErrInternal), Output: "leak"}},
154+ {name: "unknown with a code", raw: &wire.CommandResult{RequestId: 1, Outcome: wire.CommandOutcome_COMMAND_OUTCOME_UNKNOWN, Code: uint32(protocol.ErrInternal)}},
155+ {name: "unknown with output", raw: &wire.CommandResult{RequestId: 1, Outcome: wire.CommandOutcome_COMMAND_OUTCOME_UNKNOWN, Output: "leak"}},
156+ }
157+ for _, tt := range tests {
158+ t.Run(tt.name, func(t *testing.T) {
159+ _, err := commandResultFromWire(tt.raw)
160+ require.ErrorIs(t, err, errProtoConvertRange)
161+
162+ if tt.raw == nil {
163+ return
164+ }
165+ // The serialized boundary must refuse the same frame end to end:
166+ // a well-formed protobuf with a forbidden value is still refused.
167+ payload, marshalErr := proto.Marshal(&wire.ServerEnvelope{Payload: &wire.ServerEnvelope_CommandResult{CommandResult: tt.raw}})
168+ require.NoError(t, marshalErr)
169+ _, decodeErr := DecodeServerEnvelope(payload)
170+ require.Error(t, decodeErr, "a forbidden outcome must never decode into a semantic result")
171+ var failure *protocol.DecodeFailure
172+ require.ErrorAs(t, decodeErr, &failure)
173+ })
174+ }
175+}
176+
177+// TestCommandResultConversionArmInventory is the sessionwire arm inventory for
178+// the explicit command result: value and pointer arms share the value arm's
179+// bytes, and a typed nil pointer arm fails closed with ErrInvalidMessage instead
180+// of panicking. A new arm added without coverage fails here.
181+func TestCommandResultConversionArmInventory(t *testing.T) {
182+ message := testCommandResults()[0]
183+ valueRaw, err := EncodeServerMessage(message)
184+ require.NoError(t, err)
185+
186+ tests := []struct {
187+ name string
188+ run func() ([]byte, error)
189+ wantRaw []byte
190+ wantErr error
191+ }{
192+ {name: "value arm", run: func() ([]byte, error) { return EncodeServerMessage(message) }, wantRaw: valueRaw},
193+ {name: "pointer arm", run: func() ([]byte, error) { return EncodeServerMessage(&message) }, wantRaw: valueRaw},
194+ {name: "typed nil pointer arm", run: func() ([]byte, error) { return EncodeServerMessage((*protocol.CommandResult)(nil)) }, wantErr: ErrInvalidMessage},
195+ }
196+ for _, tt := range tests {
197+ t.Run(tt.name, func(t *testing.T) {
198+ raw, err := tt.run()
199+ if tt.wantErr != nil {
200+ require.ErrorIs(t, err, tt.wantErr)
201+ return
202+ }
203+ require.NoError(t, err)
204+ require.Equal(t, tt.wantRaw, raw, "every arm must share the value arm's bytes")
205+ })
206+ }
207+
208+ decoded, err := DecodeServerEnvelope(valueRaw)
209+ require.NoError(t, err)
210+ require.Equal(t, message, decoded)
211+}
212+
213+// TestCommandResultIsBoundedControlEnvelope proves a CommandResult rides the
214+// strict control ceiling, never the bulk exemption, on send and receive, so a
215+// failure's error text stays inside the documented 1 MiB control bound.
216+func TestCommandResultIsBoundedControlEnvelope(t *testing.T) {
217+ message := testCommandResults()[1]
218+
219+ oversize := protocol.CommandResult{
220+ RequestID: 1,
221+ Outcome: protocol.CommandFailed,
222+ Code: protocol.ErrInternal,
223+ Text: string(make([]byte, wire.ControlEnvelopeLimit)),
224+ }
225+ oversizeRaw, err := EncodeServerMessage(oversize)
226+ require.NoError(t, err)
227+ require.ErrorIs(t, checkCategoryCeiling(oversizeRaw, categoryControl, wire.AbsoluteEnvelopeLimit), wire.ErrScanLength,
228+ "a CommandResult above the control ceiling must fail even under an absolute negotiated ceiling")
229+
230+ send := &serverConnection{raw: &scriptedTransport{}, ceilings: defaultProtoCeilings()}
231+ require.NoError(t, send.SendServer(message))
232+ require.ErrorIs(t, send.SendServer(oversize), wire.ErrScanLength)
233+
234+ recv := &clientConnection{raw: &scriptedTransport{recv: []wire.Envelope{{Payload: oversizeRaw}}}, ceilings: defaultProtoCeilings()}
235+ recv.preambleOnce.Do(func() {})
236+ _, err = recv.ReceiveServer()
237+ require.Error(t, err)
238+}
@@ -0,0 +1,238 @@
1+package sessionwire
2+
3+// CommandResult wire contract: one command request carries exactly one closed
4+// terminal outcome (Succeeded, Failed, or Unknown), correlation is preserved
5+// byte-for-byte, and the decode boundary refuses a malformed, unspecified, or
6+// out-of-range outcome instead of aliasing it into a valid one. The result is a
7+// bounded control envelope: a failure's Code and Text can never ride the
8+// Output/bulk ceiling exemption.
9+
10+import (
11+ "encoding/hex"
12+ "math"
13+ "testing"
14+
15+ "google.golang.org/protobuf/proto"
16+
17+ "github.com/bnema/vev/internal/protocol"
18+ "github.com/bnema/vev/internal/protocol/wire"
19+ "github.com/stretchr/testify/require"
20+)
21+
22+func testCommandResults() []protocol.CommandResult {
23+ return []protocol.CommandResult{
24+ {RequestID: 0x1122334455, Outcome: protocol.CommandSucceeded, Output: "work\tsh\n"},
25+ {RequestID: 7, Outcome: protocol.CommandFailed, Code: protocol.ErrNoSuchTarget, Text: "no live sessions"},
26+ {RequestID: 8, Outcome: protocol.CommandOutcomeUnknown, Text: "command request timed out"},
27+ }
28+}
29+
30+// commandResultPinnedBytes pins the exact serialized bytes of each fixture in
31+// testCommandResults order, so a schema, encoder, or envelope-variant change
32+// that silently re-lays-out a CommandResult fails loudly here.
33+var commandResultPinnedBytes = []string{
34+ "3a1308d588cd9192022a08776f726b0973680a3001",
35+ "3a180807180922106e6f206c6976652073657373696f6e733002",
36+ "3a1f08082219636f6d6d616e6420726571756573742074696d6564206f75743003",
37+}
38+
39+// TestCommandResultByteRoundTrip proves every closed outcome has pinned
40+// serialized bytes and re-encodes to byte-identical bytes after a strict scan +
41+// generated unmarshal + semantic decode, with the request correlation id and the
42+// bounded text/output intact.
43+func TestCommandResultByteRoundTrip(t *testing.T) {
44+ for index, message := range testCommandResults() {
45+ require.True(t, message.Valid(), "fixture %+v must obey the outcome contract", message)
46+ raw, err := EncodeServerMessage(message)
47+ require.NoError(t, err)
48+ require.Equal(t, commandResultPinnedBytes[index], hex.EncodeToString(raw), "CommandResult %d must keep its pinned bytes", index)
49+ require.Equal(t, categoryControl, serverEnvelopeCategory(raw), "a CommandResult is a control envelope")
50+ require.NoError(t, wire.ScanEnvelope(&wire.ServerEnvelope{}, raw))
51+
52+ decoded, err := DecodeServerEnvelope(raw)
53+ require.NoError(t, err)
54+ require.Equal(t, message, decoded)
55+
56+ reencoded, err := EncodeServerMessage(decoded)
57+ require.NoError(t, err)
58+ require.Equal(t, raw, reencoded, "CommandResult must re-encode byte-for-byte")
59+ }
60+}
61+
62+// TestCommandResultEnvelopeStrictScan proves the scan boundary rejects a
63+// truncated prefix, a truncated payload, trailing garbage, and a concatenated
64+// duplicate before any semantic decode, and that each rejected frame surfaces
65+// as a malformed decode failure rather than a valid result.
66+func TestCommandResultEnvelopeStrictScan(t *testing.T) {
67+ valid, err := EncodeServerMessage(testCommandResults()[0])
68+ require.NoError(t, err)
69+ require.NoError(t, wire.ScanEnvelope(&wire.ServerEnvelope{}, valid))
70+
71+ clone := func(raw []byte) []byte { return append([]byte(nil), raw...) }
72+ tests := []struct {
73+ name string
74+ raw []byte
75+ wantErr error
76+ }{
77+ {name: "truncated tag prefix", raw: valid[:1], wantErr: wire.ErrScanTruncated},
78+ {name: "truncated length prefix", raw: valid[:3], wantErr: wire.ErrScanTruncated},
79+ {name: "truncated payload", raw: valid[:len(valid)-1], wantErr: wire.ErrScanTruncated},
80+ {name: "trailing garbage", raw: append(clone(valid), 0xFF), wantErr: wire.ErrScanTruncated},
81+ {name: "concatenated duplicate", raw: append(clone(valid), valid...), wantErr: wire.ErrScanDuplicate},
82+ }
83+ for _, tt := range tests {
84+ t.Run(tt.name, func(t *testing.T) {
85+ require.ErrorIs(t, wire.ScanEnvelope(&wire.ServerEnvelope{}, tt.raw), tt.wantErr)
86+
87+ _, decodeErr := DecodeServerEnvelope(tt.raw)
88+ var failure *protocol.DecodeFailure
89+ require.ErrorAs(t, decodeErr, &failure)
90+ require.Equal(t, protocol.DecodeMalformed, failure.Category, "a corrupt CommandResult frame is malformed, never a result")
91+ })
92+ }
93+
94+ // Every proper prefix of a valid frame is a truncated prefix in one of the
95+ // leading tag, length, or nested-payload bytes; none may scan or decode.
96+ t.Run("every truncated prefix", func(t *testing.T) {
97+ for length := 1; length < len(valid); length++ {
98+ require.ErrorIs(t, wire.ScanEnvelope(&wire.ServerEnvelope{}, valid[:length]), wire.ErrScanTruncated, "prefix %d", length)
99+ _, decodeErr := DecodeServerEnvelope(valid[:length])
100+ var failure *protocol.DecodeFailure
101+ require.ErrorAs(t, decodeErr, &failure, "prefix %d", length)
102+ require.Equal(t, protocol.DecodeMalformed, failure.Category, "prefix %d", length)
103+ }
104+ })
105+}
106+
107+// TestCommandResultWrongDirectionFailsClosed proves a server-only CommandResult
108+// payload is refused on the client direction and the server decoder rejects a
109+// nil envelope, so a result can never be consumed as a request.
110+func TestCommandResultWrongDirectionFailsClosed(t *testing.T) {
111+ converted, err := commandResultToWire(testCommandResults()[0])
112+ require.NoError(t, err)
113+ raw, err := proto.Marshal(&wire.ServerEnvelope{Payload: &wire.ServerEnvelope_CommandResult{
114+ CommandResult: converted,
115+ }})
116+ require.NoError(t, err)
117+
118+ var clientEnvelope wire.ClientEnvelope
119+ err = wire.ScanEnvelope(&clientEnvelope, raw)
120+ require.Error(t, err, "a server CommandResult must not scan as a client envelope")
121+
122+ _, err = decodeProtoClient(&wire.ClientEnvelope{Payload: nil})
123+ require.ErrorIs(t, err, ErrWrongDirection)
124+ _, err = decodeProtoServer(nil)
125+ require.ErrorIs(t, err, ErrInvalidMessage)
126+}
127+
128+// TestCommandResultRejectsMalformedZeroAndOutOfRangeOutcome proves the decode
129+// boundary refuses an unspecified (zero) outcome, an outcome outside the closed
130+// enumeration, and every outcome/code/text combination the contract forbids.
131+// The out-of-range cases include values whose low byte would otherwise truncate
132+// into a valid outcome (257 -> 1, 259 -> 3, 65537 -> 1), so a hostile or corrupt
133+// frame can never alias success or unknown.
134+func TestCommandResultRejectsMalformedZeroAndOutOfRangeOutcome(t *testing.T) {
135+ failed := wire.CommandOutcome_COMMAND_OUTCOME_FAILED
136+ tests := []struct {
137+ name string
138+ raw *wire.CommandResult
139+ }{
140+ {name: "nil result"},
141+ {name: "unspecified zero outcome", raw: &wire.CommandResult{RequestId: 1}},
142+ {name: "outcome one above range", raw: &wire.CommandResult{RequestId: 1, Outcome: failed + 2}},
143+ {name: "outcome far above range", raw: &wire.CommandResult{RequestId: 1, Outcome: 255}},
144+ {name: "outcome truncating into succeeded", raw: &wire.CommandResult{RequestId: 1, Outcome: 257}},
145+ {name: "outcome truncating into unknown", raw: &wire.CommandResult{RequestId: 1, Outcome: 259}},
146+ {name: "outcome truncating at 16 bits", raw: &wire.CommandResult{RequestId: 1, Outcome: 65537}},
147+ {name: "outcome at int32 maximum", raw: &wire.CommandResult{RequestId: 1, Outcome: wire.CommandOutcome(math.MaxInt32)}},
148+ {name: "negative outcome", raw: &wire.CommandResult{RequestId: 1, Outcome: -1}},
149+ {name: "code overflows uint16", raw: &wire.CommandResult{RequestId: 1, Outcome: wire.CommandOutcome_COMMAND_OUTCOME_SUCCEEDED, Code: 1 << 16}},
150+ {name: "succeeded with a code", raw: &wire.CommandResult{RequestId: 1, Outcome: wire.CommandOutcome_COMMAND_OUTCOME_SUCCEEDED, Code: uint32(protocol.ErrInternal)}},
151+ {name: "succeeded with text", raw: &wire.CommandResult{RequestId: 1, Outcome: wire.CommandOutcome_COMMAND_OUTCOME_SUCCEEDED, Text: "boom"}},
152+ {name: "failed without a code", raw: &wire.CommandResult{RequestId: 1, Outcome: failed}},
153+ {name: "failed with output", raw: &wire.CommandResult{RequestId: 1, Outcome: failed, Code: uint32(protocol.ErrInternal), Output: "leak"}},
154+ {name: "unknown with a code", raw: &wire.CommandResult{RequestId: 1, Outcome: wire.CommandOutcome_COMMAND_OUTCOME_UNKNOWN, Code: uint32(protocol.ErrInternal)}},
155+ {name: "unknown with output", raw: &wire.CommandResult{RequestId: 1, Outcome: wire.CommandOutcome_COMMAND_OUTCOME_UNKNOWN, Output: "leak"}},
156+ }
157+ for _, tt := range tests {
158+ t.Run(tt.name, func(t *testing.T) {
159+ _, err := commandResultFromWire(tt.raw)
160+ require.ErrorIs(t, err, errProtoConvertRange)
161+
162+ if tt.raw == nil {
163+ return
164+ }
165+ // The serialized boundary must refuse the same frame end to end:
166+ // a well-formed protobuf with a forbidden value is still refused.
167+ payload, marshalErr := proto.Marshal(&wire.ServerEnvelope{Payload: &wire.ServerEnvelope_CommandResult{CommandResult: tt.raw}})
168+ require.NoError(t, marshalErr)
169+ _, decodeErr := DecodeServerEnvelope(payload)
170+ require.Error(t, decodeErr, "a forbidden outcome must never decode into a semantic result")
171+ var failure *protocol.DecodeFailure
172+ require.ErrorAs(t, decodeErr, &failure)
173+ })
174+ }
175+}
176+
177+// TestCommandResultConversionArmInventory is the sessionwire arm inventory for
178+// the explicit command result: value and pointer arms share the value arm's
179+// bytes, and a typed nil pointer arm fails closed with ErrInvalidMessage instead
180+// of panicking. A new arm added without coverage fails here.
181+func TestCommandResultConversionArmInventory(t *testing.T) {
182+ message := testCommandResults()[0]
183+ valueRaw, err := EncodeServerMessage(message)
184+ require.NoError(t, err)
185+
186+ tests := []struct {
187+ name string
188+ run func() ([]byte, error)
189+ wantRaw []byte
190+ wantErr error
191+ }{
192+ {name: "value arm", run: func() ([]byte, error) { return EncodeServerMessage(message) }, wantRaw: valueRaw},
193+ {name: "pointer arm", run: func() ([]byte, error) { return EncodeServerMessage(&message) }, wantRaw: valueRaw},
194+ {name: "typed nil pointer arm", run: func() ([]byte, error) { return EncodeServerMessage((*protocol.CommandResult)(nil)) }, wantErr: ErrInvalidMessage},
195+ }
196+ for _, tt := range tests {
197+ t.Run(tt.name, func(t *testing.T) {
198+ raw, err := tt.run()
199+ if tt.wantErr != nil {
200+ require.ErrorIs(t, err, tt.wantErr)
201+ return
202+ }
203+ require.NoError(t, err)
204+ require.Equal(t, tt.wantRaw, raw, "every arm must share the value arm's bytes")
205+ })
206+ }
207+
208+ decoded, err := DecodeServerEnvelope(valueRaw)
209+ require.NoError(t, err)
210+ require.Equal(t, message, decoded)
211+}
212+
213+// TestCommandResultIsBoundedControlEnvelope proves a CommandResult rides the
214+// strict control ceiling, never the bulk exemption, on send and receive, so a
215+// failure's error text stays inside the documented 1 MiB control bound.
216+func TestCommandResultIsBoundedControlEnvelope(t *testing.T) {
217+ message := testCommandResults()[1]
218+
219+ oversize := protocol.CommandResult{
220+ RequestID: 1,
221+ Outcome: protocol.CommandFailed,
222+ Code: protocol.ErrInternal,
223+ Text: string(make([]byte, wire.ControlEnvelopeLimit)),
224+ }
225+ oversizeRaw, err := EncodeServerMessage(oversize)
226+ require.NoError(t, err)
227+ require.ErrorIs(t, checkCategoryCeiling(oversizeRaw, categoryControl, wire.AbsoluteEnvelopeLimit), wire.ErrScanLength,
228+ "a CommandResult above the control ceiling must fail even under an absolute negotiated ceiling")
229+
230+ send := &serverConnection{raw: &scriptedTransport{}, ceilings: defaultProtoCeilings()}
231+ require.NoError(t, send.SendServer(message))
232+ require.ErrorIs(t, send.SendServer(oversize), wire.ErrScanLength)
233+
234+ recv := &clientConnection{raw: &scriptedTransport{recv: []wire.Envelope{{Payload: oversizeRaw}}}, ceilings: defaultProtoCeilings()}
235+ recv.preambleOnce.Do(func() {})
236+ _, err = recv.ReceiveServer()
237+ require.Error(t, err)
238+}
M internal/adapters/sessionwire/proto_convert_misc.go +14 -4
@@ -289,19 +289,29 @@ func errorFromWire(message *wire.ErrorMsg) (protocol.ErrorMsg, error) {
289289 return protocol.ErrorMsg{Code: code, Text: message.GetText()}, nil
290290 }
291291
292-func commandResultToWire(message protocol.CommandResult) *wire.CommandResult {
293- return &wire.CommandResult{RequestId: message.RequestID, Ok: message.OK, Code: uint32(message.Code), Text: message.Text, Output: message.Output}
292+func commandResultToWire(message protocol.CommandResult) (*wire.CommandResult, error) {
293+ if !message.Valid() {
294+ return nil, errProtoConvertRange
295+ }
296+ return &wire.CommandResult{RequestId: message.RequestID, Outcome: wire.CommandOutcome(message.Outcome), Code: uint32(message.Code), Text: message.Text, Output: message.Output}, nil
294297 }
295298
296299 func commandResultFromWire(message *wire.CommandResult) (protocol.CommandResult, error) {
297- if message == nil {
300+ // The outcome is narrowed through a closed-range check before the cast:
301+ // a 32-bit wire value whose low byte would otherwise truncate into a valid
302+ // outcome (257 -> Succeeded, 259 -> Unknown) is refused, not aliased.
303+ if message == nil || message.GetOutcome() < wire.CommandOutcome_COMMAND_OUTCOME_SUCCEEDED || message.GetOutcome() > wire.CommandOutcome_COMMAND_OUTCOME_UNKNOWN {
298304 return protocol.CommandResult{}, errProtoConvertRange
299305 }
300306 code, err := mustUint16(message.GetCode())
301307 if err != nil {
302308 return protocol.CommandResult{}, err
303309 }
304- return protocol.CommandResult{RequestID: message.GetRequestId(), OK: message.GetOk(), Code: code, Text: message.GetText(), Output: message.GetOutput()}, nil
310+ result := protocol.CommandResult{RequestID: message.GetRequestId(), Outcome: protocol.CommandOutcome(message.GetOutcome()), Code: code, Text: message.GetText(), Output: message.GetOutput()}
311+ if !result.Valid() {
312+ return protocol.CommandResult{}, errProtoConvertRange
313+ }
314+ return result, nil
305315 }
306316
307317 func uiFenceToWire(message protocol.UIFence) *wire.UIFence {
@@ -289,19 +289,29 @@ func errorFromWire(message *wire.ErrorMsg) (protocol.ErrorMsg, error) {
289 return protocol.ErrorMsg{Code: code, Text: message.GetText()}, nil289 return protocol.ErrorMsg{Code: code, Text: message.GetText()}, nil
290 }290 }
291 291
292-func commandResultToWire(message protocol.CommandResult) *wire.CommandResult {292+func commandResultToWire(message protocol.CommandResult) (*wire.CommandResult, error) {
293- return &wire.CommandResult{RequestId: message.RequestID, Ok: message.OK, Code: uint32(message.Code), Text: message.Text, Output: message.Output}293+ if !message.Valid() {
294+ return nil, errProtoConvertRange
295+ }
296+ return &wire.CommandResult{RequestId: message.RequestID, Outcome: wire.CommandOutcome(message.Outcome), Code: uint32(message.Code), Text: message.Text, Output: message.Output}, nil
294 }297 }
295 298
296 func commandResultFromWire(message *wire.CommandResult) (protocol.CommandResult, error) {299 func commandResultFromWire(message *wire.CommandResult) (protocol.CommandResult, error) {
297- if message == nil {300+ // The outcome is narrowed through a closed-range check before the cast:
301+ // a 32-bit wire value whose low byte would otherwise truncate into a valid
302+ // outcome (257 -> Succeeded, 259 -> Unknown) is refused, not aliased.
303+ if message == nil || message.GetOutcome() < wire.CommandOutcome_COMMAND_OUTCOME_SUCCEEDED || message.GetOutcome() > wire.CommandOutcome_COMMAND_OUTCOME_UNKNOWN {
298 return protocol.CommandResult{}, errProtoConvertRange304 return protocol.CommandResult{}, errProtoConvertRange
299 }305 }
300 code, err := mustUint16(message.GetCode())306 code, err := mustUint16(message.GetCode())
301 if err != nil {307 if err != nil {
302 return protocol.CommandResult{}, err308 return protocol.CommandResult{}, err
303 }309 }
304- return protocol.CommandResult{RequestID: message.GetRequestId(), OK: message.GetOk(), Code: code, Text: message.GetText(), Output: message.GetOutput()}, nil310+ result := protocol.CommandResult{RequestID: message.GetRequestId(), Outcome: protocol.CommandOutcome(message.GetOutcome()), Code: code, Text: message.GetText(), Output: message.GetOutput()}
311+ if !result.Valid() {
312+ return protocol.CommandResult{}, errProtoConvertRange
313+ }
314+ return result, nil
305 }315 }
306 316
307 func uiFenceToWire(message protocol.UIFence) *wire.UIFence {317 func uiFenceToWire(message protocol.UIFence) *wire.UIFence {
M internal/adapters/sessionwire/proto_convert_test.go +1 -1
@@ -93,7 +93,7 @@ func TestProtoServerRoundTrips(t *testing.T) {
9393 protocol.Detached{Reason: protocol.ReasonDetach},
9494 protocol.Pong{},
9595 protocol.Sessions{},
96- protocol.CommandResult{RequestID: 1, OK: true},
96+ protocol.CommandResult{RequestID: 1, Outcome: protocol.CommandSucceeded},
9797 protocol.KillResult{RequestID: 1, Outcome: protocol.KillSucceeded},
9898 protocol.KillResult{RequestID: 2, Outcome: protocol.KillFailed, Code: protocol.ErrNoSuchSession, Text: "no such session: work", Failures: []protocol.KillFailure{{Class: "stopped", Name: "work", Text: "delete failed"}}},
9999 protocol.AttachTarget{Session: "work", Intent: protocol.IntentAttach},
@@ -93,7 +93,7 @@ func TestProtoServerRoundTrips(t *testing.T) {
93 protocol.Detached{Reason: protocol.ReasonDetach},93 protocol.Detached{Reason: protocol.ReasonDetach},
94 protocol.Pong{},94 protocol.Pong{},
95 protocol.Sessions{},95 protocol.Sessions{},
96- protocol.CommandResult{RequestID: 1, OK: true},96+ protocol.CommandResult{RequestID: 1, Outcome: protocol.CommandSucceeded},
97 protocol.KillResult{RequestID: 1, Outcome: protocol.KillSucceeded},97 protocol.KillResult{RequestID: 1, Outcome: protocol.KillSucceeded},
98 protocol.KillResult{RequestID: 2, Outcome: protocol.KillFailed, Code: protocol.ErrNoSuchSession, Text: "no such session: work", Failures: []protocol.KillFailure{{Class: "stopped", Name: "work", Text: "delete failed"}}},98 protocol.KillResult{RequestID: 2, Outcome: protocol.KillFailed, Code: protocol.ErrNoSuchSession, Text: "no such session: work", Failures: []protocol.KillFailure{{Class: "stopped", Name: "work", Text: "delete failed"}}},
99 protocol.AttachTarget{Session: "work", Intent: protocol.IntentAttach},99 protocol.AttachTarget{Session: "work", Intent: protocol.IntentAttach},
M internal/adapters/sessionwire/proto_envelope.go +5 -1
@@ -393,7 +393,11 @@ func encodeProtoServer(message protocol.ServerMessage) (*wire.ServerEnvelope, er
393393 }
394394 return encodeProtoServer(*m)
395395 case protocol.CommandResult:
396- return &wire.ServerEnvelope{Payload: &wire.ServerEnvelope_CommandResult{CommandResult: commandResultToWire(m)}}, nil
396+ converted, err := commandResultToWire(m)
397+ if err != nil {
398+ return nil, err
399+ }
400+ return &wire.ServerEnvelope{Payload: &wire.ServerEnvelope_CommandResult{CommandResult: converted}}, nil
397401 case protocol.KillResult:
398402 return &wire.ServerEnvelope{Payload: &wire.ServerEnvelope_KillResult{KillResult: killResultToWire(m)}}, nil
399403 case *protocol.KillResult:
@@ -393,7 +393,11 @@ func encodeProtoServer(message protocol.ServerMessage) (*wire.ServerEnvelope, er
393 }393 }
394 return encodeProtoServer(*m)394 return encodeProtoServer(*m)
395 case protocol.CommandResult:395 case protocol.CommandResult:
396- return &wire.ServerEnvelope{Payload: &wire.ServerEnvelope_CommandResult{CommandResult: commandResultToWire(m)}}, nil396+ converted, err := commandResultToWire(m)
397+ if err != nil {
398+ return nil, err
399+ }
400+ return &wire.ServerEnvelope{Payload: &wire.ServerEnvelope_CommandResult{CommandResult: converted}}, nil
397 case protocol.KillResult:401 case protocol.KillResult:
398 return &wire.ServerEnvelope{Payload: &wire.ServerEnvelope_KillResult{KillResult: killResultToWire(m)}}, nil402 return &wire.ServerEnvelope{Payload: &wire.ServerEnvelope_KillResult{KillResult: killResultToWire(m)}}, nil
399 case *protocol.KillResult:403 case *protocol.KillResult:
M internal/adapters/sessionwire/server_test.go +1 -1
@@ -61,7 +61,7 @@ func TestServerConnectionEncodesEveryServerMessage(t *testing.T) {
6161 detached := protocol.Detached{Reason: protocol.ReasonDetach}
6262 pong := protocol.Pong{}
6363 sessions := protocol.Sessions{}
64- commandResult := protocol.CommandResult{RequestID: 1, OK: true}
64+ commandResult := protocol.CommandResult{RequestID: 1, Outcome: protocol.CommandSucceeded}
6565 killResult := protocol.KillResult{RequestID: 1, Outcome: protocol.KillSucceeded}
6666 killFailure := protocol.KillResult{RequestID: 2, Outcome: protocol.KillFailed, Code: protocol.ErrInternal, Text: "partial", Failures: []protocol.KillFailure{{Class: "stopped", Name: "work", Text: "boom"}}}
6767 attachTarget := protocol.AttachTarget{Session: "work", Intent: protocol.IntentAttach}
@@ -61,7 +61,7 @@ func TestServerConnectionEncodesEveryServerMessage(t *testing.T) {
61 detached := protocol.Detached{Reason: protocol.ReasonDetach}61 detached := protocol.Detached{Reason: protocol.ReasonDetach}
62 pong := protocol.Pong{}62 pong := protocol.Pong{}
63 sessions := protocol.Sessions{}63 sessions := protocol.Sessions{}
64- commandResult := protocol.CommandResult{RequestID: 1, OK: true}64+ commandResult := protocol.CommandResult{RequestID: 1, Outcome: protocol.CommandSucceeded}
65 killResult := protocol.KillResult{RequestID: 1, Outcome: protocol.KillSucceeded}65 killResult := protocol.KillResult{RequestID: 1, Outcome: protocol.KillSucceeded}
66 killFailure := protocol.KillResult{RequestID: 2, Outcome: protocol.KillFailed, Code: protocol.ErrInternal, Text: "partial", Failures: []protocol.KillFailure{{Class: "stopped", Name: "work", Text: "boom"}}}66 killFailure := protocol.KillResult{RequestID: 2, Outcome: protocol.KillFailed, Code: protocol.ErrInternal, Text: "partial", Failures: []protocol.KillFailure{{Class: "stopped", Name: "work", Text: "boom"}}}
67 attachTarget := protocol.AttachTarget{Session: "work", Intent: protocol.IntentAttach}67 attachTarget := protocol.AttachTarget{Session: "work", Intent: protocol.IntentAttach}
M internal/app/broker_offline_client_test.go +3 -3
@@ -378,12 +378,12 @@ func TestOfflineBrokerSharedTransportKeepsAttachmentsIndependent(t *testing.T) {
378378 require.Equal(t, sizes[i], receiveOfflineOutputSize(t, connection, sizes[i]))
379379 }
380380 require.NoError(t, connections[0].SendClient(protocol.CommandRequest{Version: protocol.Version, RequestID: 6, Attached: true, Slug: "next-tab"}))
381- require.True(t, receiveOfflineCommandResult(t, connections[0], 6).OK)
381+ require.True(t, receiveOfflineCommandResult(t, connections[0], 6).Outcome == protocol.CommandSucceeded)
382382 require.Equal(t, sizes[0], receiveOfflineOutputSize(t, connections[0], sizes[0]))
383383 for _, connection := range connections {
384384 require.NoError(t, connection.SendClient(protocol.CommandRequest{Version: protocol.Version, RequestID: 7, Attached: true, Slug: "next-tab"}))
385385 result := receiveOfflineCommandResult(t, connection, 7)
386- require.True(t, result.OK, result.Text)
386+ require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
387387 }
388388
389389 require.NoError(t, connections[0].SendClient(protocol.Detach{}))
@@ -392,7 +392,7 @@ func TestOfflineBrokerSharedTransportKeepsAttachmentsIndependent(t *testing.T) {
392392 for i := 1; i < len(connections); i++ {
393393 requestID := uint64(20 + i)
394394 require.NoError(t, connections[i].SendClient(protocol.CommandRequest{Version: protocol.Version, RequestID: requestID, Attached: true, Slug: "next-tab"}))
395- require.True(t, receiveOfflineCommandResult(t, connections[i], requestID).OK)
395+ require.True(t, receiveOfflineCommandResult(t, connections[i], requestID).Outcome == protocol.CommandSucceeded)
396396 }
397397 for i := 1; i < len(connections); i++ {
398398 require.NoError(t, connections[i].Close())
@@ -378,12 +378,12 @@ func TestOfflineBrokerSharedTransportKeepsAttachmentsIndependent(t *testing.T) {
378 require.Equal(t, sizes[i], receiveOfflineOutputSize(t, connection, sizes[i]))378 require.Equal(t, sizes[i], receiveOfflineOutputSize(t, connection, sizes[i]))
379 }379 }
380 require.NoError(t, connections[0].SendClient(protocol.CommandRequest{Version: protocol.Version, RequestID: 6, Attached: true, Slug: "next-tab"}))380 require.NoError(t, connections[0].SendClient(protocol.CommandRequest{Version: protocol.Version, RequestID: 6, Attached: true, Slug: "next-tab"}))
381- require.True(t, receiveOfflineCommandResult(t, connections[0], 6).OK)381+ require.True(t, receiveOfflineCommandResult(t, connections[0], 6).Outcome == protocol.CommandSucceeded)
382 require.Equal(t, sizes[0], receiveOfflineOutputSize(t, connections[0], sizes[0]))382 require.Equal(t, sizes[0], receiveOfflineOutputSize(t, connections[0], sizes[0]))
383 for _, connection := range connections {383 for _, connection := range connections {
384 require.NoError(t, connection.SendClient(protocol.CommandRequest{Version: protocol.Version, RequestID: 7, Attached: true, Slug: "next-tab"}))384 require.NoError(t, connection.SendClient(protocol.CommandRequest{Version: protocol.Version, RequestID: 7, Attached: true, Slug: "next-tab"}))
385 result := receiveOfflineCommandResult(t, connection, 7)385 result := receiveOfflineCommandResult(t, connection, 7)
386- require.True(t, result.OK, result.Text)386+ require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
387 }387 }
388 388
389 require.NoError(t, connections[0].SendClient(protocol.Detach{}))389 require.NoError(t, connections[0].SendClient(protocol.Detach{}))
@@ -392,7 +392,7 @@ func TestOfflineBrokerSharedTransportKeepsAttachmentsIndependent(t *testing.T) {
392 for i := 1; i < len(connections); i++ {392 for i := 1; i < len(connections); i++ {
393 requestID := uint64(20 + i)393 requestID := uint64(20 + i)
394 require.NoError(t, connections[i].SendClient(protocol.CommandRequest{Version: protocol.Version, RequestID: requestID, Attached: true, Slug: "next-tab"}))394 require.NoError(t, connections[i].SendClient(protocol.CommandRequest{Version: protocol.Version, RequestID: requestID, Attached: true, Slug: "next-tab"}))
395- require.True(t, receiveOfflineCommandResult(t, connections[i], requestID).OK)395+ require.True(t, receiveOfflineCommandResult(t, connections[i], requestID).Outcome == protocol.CommandSucceeded)
396 }396 }
397 for i := 1; i < len(connections); i++ {397 for i := 1; i < len(connections); i++ {
398 require.NoError(t, connections[i].Close())398 require.NoError(t, connections[i].Close())
M internal/app/cmd.go +53 -3
@@ -127,6 +127,30 @@ func cmdHelp(invocation cmdInvocation) string {
127127
128128 var errDaemonUnreachable = errors.New("daemon not running")
129129
130+// errCommandNotSent reports a command request that was never placed on the wire
131+// because its caller was already canceled. The outcome is definite: the daemon
132+// cannot have acted, so it must not be reported as indeterminate.
133+var errCommandNotSent = errors.New("command request not sent")
134+
135+// errCommandOutcomeUnknown reports a command whose reply was lost, wrong-typed,
136+// uncorrelated, malformed, or never observed before the deadline. The daemon may
137+// already have committed the command, so the outcome is indeterminate, the
138+// request is never replayed, and a late result cannot change the verdict.
139+var errCommandOutcomeUnknown = errors.New("vev: command outcome unknown")
140+
141+// commandOutcomeUnknown classifies one observation detail as an indeterminate
142+// command outcome while preserving that detail's exact message and unwrap chain.
143+type commandOutcomeUnknown struct{ detail error }
144+
145+func (e *commandOutcomeUnknown) Error() string {
146+ return "command outcome unknown: " + e.detail.Error()
147+}
148+func (e *commandOutcomeUnknown) Unwrap() error { return e.detail }
149+
150+// Is reports the shared unknown-outcome sentinel so callers classify a lost
151+// reply without matching on its observation text.
152+func (*commandOutcomeUnknown) Is(target error) bool { return target == errCommandOutcomeUnknown }
153+
130154 type cmdDeps struct {
131155 stdout io.Writer
132156 getenv func(string) string
@@ -191,6 +215,13 @@ func runCmdWithDeps(ctx context.Context, invocation cmdInvocation, deps cmdDeps)
191215 requestID, outcome := tracker.Publish(generation)
192216 request.RequestID = requestID
193217 connection := sessionwire.NewClientConnection(transport)
218+ // A caller already canceled before the send has a definite not-sent outcome:
219+ // the request is never placed on the wire, so nothing can commit and the
220+ // request is never replayed.
221+ if ctxErr := ctx.Err(); ctxErr != nil {
222+ tracker.Remove(requestID, generation)
223+ return fmt.Errorf("%w: %w", errCommandNotSent, ctxErr)
224+ }
194225 if err := connection.SendClient(request); err != nil {
195226 tracker.Remove(requestID, generation)
196227 return &exitCoded{code: 3, err: fmt.Errorf("sending command: %w", err)}
@@ -206,7 +237,14 @@ func runCmdWithDeps(ctx context.Context, invocation cmdInvocation, deps cmdDeps)
206237 tracker.Fail(requestID, generation, fmt.Errorf("unexpected command reply %T", reply))
207238 return
208239 }
209- result.RequestID = requestID
240+ if result.RequestID != requestID {
241+ tracker.Fail(requestID, generation, fmt.Errorf("unexpected command reply request ID %d", result.RequestID))
242+ return
243+ }
244+ if !result.Valid() {
245+ tracker.Fail(requestID, generation, errors.New("malformed command outcome"))
246+ return
247+ }
210248 tracker.Complete(generation, result)
211249 }()
212250 commandClock := deps.clock
@@ -215,9 +253,21 @@ func runCmdWithDeps(ctx context.Context, invocation cmdInvocation, deps cmdDeps)
215253 }
216254 result, err := tracker.Wait(ctx, commandClock, requestID, generation, outcome)
217255 if err != nil {
218- return &exitCoded{code: 3, err: err}
256+ // A reply that never arrives (loss, EOF, cancellation, or deadline) leaves
257+ // the outcome indeterminate: the request was sent, so it is never replayed.
258+ return &exitCoded{code: 3, err: &commandOutcomeUnknown{detail: err}}
219259 }
220- if !result.OK {
260+ if result.Outcome == protocol.CommandOutcomeUnknown {
261+ text := result.Text
262+ if text == "" {
263+ text = "daemon did not report a final outcome"
264+ }
265+ return &exitCoded{code: 3, err: &commandOutcomeUnknown{detail: errors.New(text)}}
266+ }
267+ if result.Outcome != protocol.CommandSucceeded {
268+ if result.Text == "" {
269+ result.Text = "command failed"
270+ }
221271 if result.Code == protocol.ErrInvalidCommandArgs {
222272 return &exitCoded{code: 2, err: errors.New(result.Text)}
223273 }
@@ -127,6 +127,30 @@ func cmdHelp(invocation cmdInvocation) string {
127 127
128 var errDaemonUnreachable = errors.New("daemon not running")128 var errDaemonUnreachable = errors.New("daemon not running")
129 129
130+// errCommandNotSent reports a command request that was never placed on the wire
131+// because its caller was already canceled. The outcome is definite: the daemon
132+// cannot have acted, so it must not be reported as indeterminate.
133+var errCommandNotSent = errors.New("command request not sent")
134+
135+// errCommandOutcomeUnknown reports a command whose reply was lost, wrong-typed,
136+// uncorrelated, malformed, or never observed before the deadline. The daemon may
137+// already have committed the command, so the outcome is indeterminate, the
138+// request is never replayed, and a late result cannot change the verdict.
139+var errCommandOutcomeUnknown = errors.New("vev: command outcome unknown")
140+
141+// commandOutcomeUnknown classifies one observation detail as an indeterminate
142+// command outcome while preserving that detail's exact message and unwrap chain.
143+type commandOutcomeUnknown struct{ detail error }
144+
145+func (e *commandOutcomeUnknown) Error() string {
146+ return "command outcome unknown: " + e.detail.Error()
147+}
148+func (e *commandOutcomeUnknown) Unwrap() error { return e.detail }
149+
150+// Is reports the shared unknown-outcome sentinel so callers classify a lost
151+// reply without matching on its observation text.
152+func (*commandOutcomeUnknown) Is(target error) bool { return target == errCommandOutcomeUnknown }
153+
130 type cmdDeps struct {154 type cmdDeps struct {
131 stdout io.Writer155 stdout io.Writer
132 getenv func(string) string156 getenv func(string) string
@@ -191,6 +215,13 @@ func runCmdWithDeps(ctx context.Context, invocation cmdInvocation, deps cmdDeps)
191 requestID, outcome := tracker.Publish(generation)215 requestID, outcome := tracker.Publish(generation)
192 request.RequestID = requestID216 request.RequestID = requestID
193 connection := sessionwire.NewClientConnection(transport)217 connection := sessionwire.NewClientConnection(transport)
218+ // A caller already canceled before the send has a definite not-sent outcome:
219+ // the request is never placed on the wire, so nothing can commit and the
220+ // request is never replayed.
221+ if ctxErr := ctx.Err(); ctxErr != nil {
222+ tracker.Remove(requestID, generation)
223+ return fmt.Errorf("%w: %w", errCommandNotSent, ctxErr)
224+ }
194 if err := connection.SendClient(request); err != nil {225 if err := connection.SendClient(request); err != nil {
195 tracker.Remove(requestID, generation)226 tracker.Remove(requestID, generation)
196 return &exitCoded{code: 3, err: fmt.Errorf("sending command: %w", err)}227 return &exitCoded{code: 3, err: fmt.Errorf("sending command: %w", err)}
@@ -206,7 +237,14 @@ func runCmdWithDeps(ctx context.Context, invocation cmdInvocation, deps cmdDeps)
206 tracker.Fail(requestID, generation, fmt.Errorf("unexpected command reply %T", reply))237 tracker.Fail(requestID, generation, fmt.Errorf("unexpected command reply %T", reply))
207 return238 return
208 }239 }
209- result.RequestID = requestID240+ if result.RequestID != requestID {
241+ tracker.Fail(requestID, generation, fmt.Errorf("unexpected command reply request ID %d", result.RequestID))
242+ return
243+ }
244+ if !result.Valid() {
245+ tracker.Fail(requestID, generation, errors.New("malformed command outcome"))
246+ return
247+ }
210 tracker.Complete(generation, result)248 tracker.Complete(generation, result)
211 }()249 }()
212 commandClock := deps.clock250 commandClock := deps.clock
@@ -215,9 +253,21 @@ func runCmdWithDeps(ctx context.Context, invocation cmdInvocation, deps cmdDeps)
215 }253 }
216 result, err := tracker.Wait(ctx, commandClock, requestID, generation, outcome)254 result, err := tracker.Wait(ctx, commandClock, requestID, generation, outcome)
217 if err != nil {255 if err != nil {
218- return &exitCoded{code: 3, err: err}256+ // A reply that never arrives (loss, EOF, cancellation, or deadline) leaves
257+ // the outcome indeterminate: the request was sent, so it is never replayed.
258+ return &exitCoded{code: 3, err: &commandOutcomeUnknown{detail: err}}
219 }259 }
220- if !result.OK {260+ if result.Outcome == protocol.CommandOutcomeUnknown {
261+ text := result.Text
262+ if text == "" {
263+ text = "daemon did not report a final outcome"
264+ }
265+ return &exitCoded{code: 3, err: &commandOutcomeUnknown{detail: errors.New(text)}}
266+ }
267+ if result.Outcome != protocol.CommandSucceeded {
268+ if result.Text == "" {
269+ result.Text = "command failed"
270+ }
221 if result.Code == protocol.ErrInvalidCommandArgs {271 if result.Code == protocol.ErrInvalidCommandArgs {
222 return &exitCoded{code: 2, err: errors.New(result.Text)}272 return &exitCoded{code: 2, err: errors.New(result.Text)}
223 }273 }
A internal/app/cmd_outcome_test.go +271 -0
@@ -0,0 +1,271 @@
1+package app
2+
3+// CommandResult caller contract (explicit command-result slice). runCmd sends
4+// exactly one CommandRequest with a unique nonzero RequestID and consumes
5+// exactly one correlated CommandResult: a lost, wrong-typed, uncorrelated, or
6+// malformed reply, or a deadline/cancellation after the send, is a typed
7+// outcome-unknown that is never replayed and whose send count stays one. A
8+// caller canceled before the send is a definite not-sent outcome, and a
9+// definite daemon failure keeps its bounded error and exit code. Every case is
10+// bounded and uses fake clocks or gates, never sleeps.
11+
12+import (
13+ "context"
14+ "io"
15+ "strings"
16+ "testing"
17+ "time"
18+
19+ "github.com/stretchr/testify/mock"
20+ "github.com/stretchr/testify/require"
21+ "google.golang.org/protobuf/proto"
22+
23+ "github.com/bnema/vev/internal/adapters/sessionwire"
24+ "github.com/bnema/vev/internal/ports"
25+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
26+ "github.com/bnema/vev/internal/protocol"
27+ "github.com/bnema/vev/internal/protocol/wire"
28+ "github.com/bnema/vev/internal/usecase/daemon"
29+)
30+
31+// countCommandRequests reports how many CommandRequests the transport placed on
32+// the wire: exactly one is the no-replay invariant.
33+func countCommandRequests(t *testing.T, transport *cmdTestTransport) int {
34+ t.Helper()
35+ count := 0
36+ for _, frame := range transport.sent {
37+ message, err := sessionwire.DecodeClientEnvelope(frame.Payload)
38+ if err != nil {
39+ continue
40+ }
41+ if _, ok := message.(protocol.CommandRequest); ok {
42+ count++
43+ }
44+ }
45+ return count
46+}
47+
48+// runCmdReply drives one runCmdWithDeps invocation against a scripted transport.
49+func runCmdReply(ctx context.Context, transport *cmdTestTransport, clock ports.Clock) error {
50+ return runCmdWithDeps(ctx, cmdInvocation{slug: "split-right"}, cmdDeps{
51+ stdout: io.Discard,
52+ getenv: func(string) string { return "" },
53+ dial: func(context.Context, string) (wire.Transport, error) { return transport, nil },
54+ clock: clock,
55+ })
56+}
57+
58+// rawServerEnvelope wraps already-serialized server envelope bytes without
59+// re-encoding them. Fixtures that model hostile or corrupt frames use it so the
60+// now-correct strict semantic encoder can never reject the fixture first.
61+func rawServerEnvelope(raw []byte) wire.Envelope { return wire.Envelope{Payload: raw} }
62+
63+// malformedCommandOutcomeEnvelope marshals a server CommandResult whose
64+// outcome/code combination the semantic contract forbids (a succeeded outcome
65+// with a nonzero code). proto.Marshal deliberately bypasses
66+// sessionwire.EncodeServerMessage, which refuses the semantic value outright.
67+func malformedCommandOutcomeEnvelope(t *testing.T) wire.Envelope {
68+ t.Helper()
69+ raw, err := proto.Marshal(&wire.ServerEnvelope{Payload: &wire.ServerEnvelope_CommandResult{CommandResult: &wire.CommandResult{
70+ RequestId: 1,
71+ Outcome: wire.CommandOutcome_COMMAND_OUTCOME_SUCCEEDED,
72+ Code: uint32(protocol.ErrInternal),
73+ }}})
74+ require.NoError(t, err)
75+ // The semantic encoder must refuse the same value, which proves this fixture
76+ // really is a hand-built frame rather than a re-encoded semantic message.
77+ _, encodeErr := sessionwire.EncodeServerMessage(protocol.CommandResult{
78+ RequestID: 1, Outcome: protocol.CommandSucceeded, Code: protocol.ErrInternal,
79+ })
80+ require.Error(t, encodeErr, "a forbidden outcome must not be encodable")
81+ return rawServerEnvelope(raw)
82+}
83+
84+// TestRunCmdLostUncorrelatedOrWrongTypedReplyIsOutcomeUnknown proves a reply
85+// that is lost, wrong-typed, uncorrelated, or malformed leaves a typed
86+// outcome-unknown: the request was sent, so it is never resent, and the
87+// one-shot connection is retired exactly once.
88+func TestRunCmdLostUncorrelatedOrWrongTypedReplyIsOutcomeUnknown(t *testing.T) {
89+ tests := []struct {
90+ name string
91+ transport *cmdTestTransport
92+ wantContain string
93+ }{
94+ {
95+ name: "reply lost with EOF",
96+ transport: &cmdTestTransport{recvErr: io.EOF},
97+ wantContain: "EOF",
98+ },
99+ {
100+ name: "reply lost with a closed pipe",
101+ transport: &cmdTestTransport{recvErr: io.ErrClosedPipe},
102+ wantContain: "closed pipe",
103+ },
104+ {
105+ name: "wrong reply type",
106+ transport: &cmdTestTransport{recv: mustServerEnvelope(protocol.Pong{})},
107+ wantContain: "unexpected command reply protocol.Pong",
108+ },
109+ {
110+ name: "wrong reply request ID",
111+ transport: &cmdTestTransport{preserveReplyID: true, recv: mustServerEnvelope(protocol.CommandResult{
112+ RequestID: 99, Outcome: protocol.CommandSucceeded,
113+ })},
114+ wantContain: "unexpected command reply request ID 99",
115+ },
116+ {
117+ name: "malformed outcome bytes are refused",
118+ transport: &cmdTestTransport{recv: malformedCommandOutcomeEnvelope(t)},
119+ wantContain: "wire value out of semantic range",
120+ },
121+ {
122+ name: "truncated envelope bytes are refused",
123+ transport: &cmdTestTransport{recv: rawServerEnvelope(
124+ mustServerEnvelope(protocol.CommandResult{RequestID: 1, Outcome: protocol.CommandSucceeded}).Payload[:3])},
125+ wantContain: "wire: envelope is truncated",
126+ },
127+ {
128+ name: "definite daemon unknown outcome",
129+ transport: &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{
130+ Outcome: protocol.CommandOutcomeUnknown, Text: "daemon is shutting down",
131+ })},
132+ wantContain: "daemon is shutting down",
133+ },
134+ }
135+ for _, tt := range tests {
136+ t.Run(tt.name, func(t *testing.T) {
137+ err := runCmdReply(context.Background(), tt.transport, nil)
138+
139+ require.ErrorIs(t, err, errCommandOutcomeUnknown, "a lost or uncorrelated reply is outcome-unknown")
140+ require.NotErrorIs(t, err, errCommandNotSent, "the request was sent, so the outcome is not a definite not-sent")
141+ require.ErrorContains(t, err, tt.wantContain)
142+ require.Equal(t, 3, ExitCode(err))
143+ require.Equal(t, 1, countCommandRequests(t, tt.transport), "an indeterminate command must never be resent")
144+ require.Equal(t, 1, tt.transport.closeCalls, "the one-shot connection is retired exactly once")
145+ })
146+ }
147+}
148+
149+// TestRunCmdCancelAfterSendIsOutcomeUnknownWithoutResend proves a caller
150+// canceled while awaiting the reply reports outcome-unknown, sends exactly one
151+// request, and retires the connection.
152+func TestRunCmdCancelAfterSendIsOutcomeUnknownWithoutResend(t *testing.T) {
153+ replyStarted := make(chan struct{})
154+ recvGate := make(chan struct{})
155+ transport := &cmdTestTransport{replyStarted: replyStarted, recvGate: recvGate}
156+
157+ ctx, cancel := context.WithCancel(context.Background())
158+ defer cancel()
159+ done := make(chan error, 1)
160+ go func() { done <- runCmdReply(ctx, transport, nil) }()
161+
162+ // Wait until the reply read is actually parked, then cancel. The gate makes
163+ // entry deterministic without sleeping.
164+ <-replyStarted
165+ cancel()
166+ err := <-done
167+ close(recvGate)
168+
169+ require.ErrorIs(t, err, errCommandOutcomeUnknown)
170+ require.Equal(t, 3, ExitCode(err))
171+ require.Equal(t, 1, countCommandRequests(t, transport), "a canceled command must never be resent")
172+ require.Equal(t, 1, transport.closeCalls, "cancellation after the send must retire the connection")
173+}
174+
175+// TestRunCmdTimeoutAfterSendIsOutcomeUnknownWithoutResend proves a request
176+// whose result deadline expires while awaiting the reply reports
177+// outcome-unknown, sends exactly one request, and retires the connection. The
178+// fake clock keeps the deadline deterministic.
179+func TestRunCmdTimeoutAfterSendIsOutcomeUnknownWithoutResend(t *testing.T) {
180+ timerCh := make(chan time.Time, 1)
181+ timer := portsmocks.NewMockTimer(t)
182+ timer.EXPECT().C().Return(timerCh).Once()
183+ timer.EXPECT().Stop().Return(true).Once()
184+ delayCh := make(chan time.Duration, 1)
185+ clock := portsmocks.NewMockClock(t)
186+ clock.EXPECT().NewTimer(mock.Anything).Run(func(delay time.Duration) {
187+ delayCh <- delay
188+ }).Return(timer).Once()
189+
190+ replyStarted := make(chan struct{})
191+ recvGate := make(chan struct{})
192+ transport := &cmdTestTransport{replyStarted: replyStarted, recvGate: recvGate}
193+ done := make(chan error, 1)
194+ go func() { done <- runCmdReply(context.Background(), transport, clock) }()
195+
196+ <-replyStarted
197+ require.Equal(t, daemon.CommandRequestTimeout, <-delayCh)
198+ timerCh <- time.Time{}
199+ err := <-done
200+ close(recvGate)
201+
202+ require.ErrorIs(t, err, errCommandOutcomeUnknown)
203+ require.ErrorContains(t, err, daemon.ErrCommandRequestTimeout.Error())
204+ require.Equal(t, 3, ExitCode(err))
205+ require.Equal(t, 1, countCommandRequests(t, transport), "a timed-out command must never be resent")
206+ require.Equal(t, 1, transport.closeCalls, "a result timeout must retire the connection")
207+}
208+
209+// TestRunCmdCancelBeforeSendIsDefiniteNotSent proves a caller already canceled
210+// before the request is sent reports a definite not-sent outcome, puts no
211+// CommandRequest on the wire, and is not misclassified as unknown.
212+func TestRunCmdCancelBeforeSendIsDefiniteNotSent(t *testing.T) {
213+ transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{Outcome: protocol.CommandSucceeded})}
214+
215+ ctx, cancel := context.WithCancel(context.Background())
216+ cancel()
217+ err := runCmdReply(ctx, transport, nil)
218+
219+ require.ErrorIs(t, err, errCommandNotSent)
220+ require.NotErrorIs(t, err, errCommandOutcomeUnknown, "a not-sent command has a definite outcome")
221+ require.Equal(t, 0, countCommandRequests(t, transport), "a canceled-before-send command must not reach the wire")
222+}
223+
224+// TestRunCmdSuccessAndFailurePreserveCorrelationAndBoundedError proves a
225+// definite daemon outcome is reported with its exact text and exit code, and
226+// still sends exactly one request.
227+func TestRunCmdSuccessAndFailurePreserveCorrelationAndBoundedError(t *testing.T) {
228+ tests := []struct {
229+ name string
230+ result protocol.CommandResult
231+ wantCode int
232+ }{
233+ {
234+ name: "success",
235+ result: protocol.CommandResult{Outcome: protocol.CommandSucceeded, Output: "done"},
236+ wantCode: 0,
237+ },
238+ {
239+ name: "invalid arguments are a usage error",
240+ result: protocol.CommandResult{Outcome: protocol.CommandFailed, Code: protocol.ErrInvalidCommandArgs, Text: "usage: split-right"},
241+ wantCode: 2,
242+ },
243+ {
244+ name: "runtime failure is an exit one failure",
245+ result: protocol.CommandResult{Outcome: protocol.CommandFailed, Code: protocol.ErrNoSuchTarget, Text: "no live sessions"},
246+ wantCode: 1,
247+ },
248+ }
249+ for _, tt := range tests {
250+ t.Run(tt.name, func(t *testing.T) {
251+ out := new(strings.Builder)
252+ transport := &cmdTestTransport{recv: mustServerEnvelope(tt.result)}
253+ err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{
254+ stdout: out,
255+ getenv: func(string) string { return "" },
256+ dial: func(context.Context, string) (wire.Transport, error) { return transport, nil },
257+ })
258+
259+ require.Equal(t, tt.wantCode, ExitCode(err))
260+ require.Equal(t, 1, countCommandRequests(t, transport), "a definite outcome sends exactly one request")
261+ require.NotErrorIs(t, err, errCommandOutcomeUnknown, "a definite outcome is never reported as unknown")
262+ if tt.result.Outcome == protocol.CommandSucceeded {
263+ require.NoError(t, err)
264+ require.Equal(t, tt.result.Output+"\n", out.String())
265+ } else {
266+ require.ErrorContains(t, err, tt.result.Text)
267+ require.NotErrorIs(t, err, errCommandNotSent)
268+ }
269+ })
270+ }
271+}
@@ -0,0 +1,271 @@
1+package app
2+
3+// CommandResult caller contract (explicit command-result slice). runCmd sends
4+// exactly one CommandRequest with a unique nonzero RequestID and consumes
5+// exactly one correlated CommandResult: a lost, wrong-typed, uncorrelated, or
6+// malformed reply, or a deadline/cancellation after the send, is a typed
7+// outcome-unknown that is never replayed and whose send count stays one. A
8+// caller canceled before the send is a definite not-sent outcome, and a
9+// definite daemon failure keeps its bounded error and exit code. Every case is
10+// bounded and uses fake clocks or gates, never sleeps.
11+
12+import (
13+ "context"
14+ "io"
15+ "strings"
16+ "testing"
17+ "time"
18+
19+ "github.com/stretchr/testify/mock"
20+ "github.com/stretchr/testify/require"
21+ "google.golang.org/protobuf/proto"
22+
23+ "github.com/bnema/vev/internal/adapters/sessionwire"
24+ "github.com/bnema/vev/internal/ports"
25+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
26+ "github.com/bnema/vev/internal/protocol"
27+ "github.com/bnema/vev/internal/protocol/wire"
28+ "github.com/bnema/vev/internal/usecase/daemon"
29+)
30+
31+// countCommandRequests reports how many CommandRequests the transport placed on
32+// the wire: exactly one is the no-replay invariant.
33+func countCommandRequests(t *testing.T, transport *cmdTestTransport) int {
34+ t.Helper()
35+ count := 0
36+ for _, frame := range transport.sent {
37+ message, err := sessionwire.DecodeClientEnvelope(frame.Payload)
38+ if err != nil {
39+ continue
40+ }
41+ if _, ok := message.(protocol.CommandRequest); ok {
42+ count++
43+ }
44+ }
45+ return count
46+}
47+
48+// runCmdReply drives one runCmdWithDeps invocation against a scripted transport.
49+func runCmdReply(ctx context.Context, transport *cmdTestTransport, clock ports.Clock) error {
50+ return runCmdWithDeps(ctx, cmdInvocation{slug: "split-right"}, cmdDeps{
51+ stdout: io.Discard,
52+ getenv: func(string) string { return "" },
53+ dial: func(context.Context, string) (wire.Transport, error) { return transport, nil },
54+ clock: clock,
55+ })
56+}
57+
58+// rawServerEnvelope wraps already-serialized server envelope bytes without
59+// re-encoding them. Fixtures that model hostile or corrupt frames use it so the
60+// now-correct strict semantic encoder can never reject the fixture first.
61+func rawServerEnvelope(raw []byte) wire.Envelope { return wire.Envelope{Payload: raw} }
62+
63+// malformedCommandOutcomeEnvelope marshals a server CommandResult whose
64+// outcome/code combination the semantic contract forbids (a succeeded outcome
65+// with a nonzero code). proto.Marshal deliberately bypasses
66+// sessionwire.EncodeServerMessage, which refuses the semantic value outright.
67+func malformedCommandOutcomeEnvelope(t *testing.T) wire.Envelope {
68+ t.Helper()
69+ raw, err := proto.Marshal(&wire.ServerEnvelope{Payload: &wire.ServerEnvelope_CommandResult{CommandResult: &wire.CommandResult{
70+ RequestId: 1,
71+ Outcome: wire.CommandOutcome_COMMAND_OUTCOME_SUCCEEDED,
72+ Code: uint32(protocol.ErrInternal),
73+ }}})
74+ require.NoError(t, err)
75+ // The semantic encoder must refuse the same value, which proves this fixture
76+ // really is a hand-built frame rather than a re-encoded semantic message.
77+ _, encodeErr := sessionwire.EncodeServerMessage(protocol.CommandResult{
78+ RequestID: 1, Outcome: protocol.CommandSucceeded, Code: protocol.ErrInternal,
79+ })
80+ require.Error(t, encodeErr, "a forbidden outcome must not be encodable")
81+ return rawServerEnvelope(raw)
82+}
83+
84+// TestRunCmdLostUncorrelatedOrWrongTypedReplyIsOutcomeUnknown proves a reply
85+// that is lost, wrong-typed, uncorrelated, or malformed leaves a typed
86+// outcome-unknown: the request was sent, so it is never resent, and the
87+// one-shot connection is retired exactly once.
88+func TestRunCmdLostUncorrelatedOrWrongTypedReplyIsOutcomeUnknown(t *testing.T) {
89+ tests := []struct {
90+ name string
91+ transport *cmdTestTransport
92+ wantContain string
93+ }{
94+ {
95+ name: "reply lost with EOF",
96+ transport: &cmdTestTransport{recvErr: io.EOF},
97+ wantContain: "EOF",
98+ },
99+ {
100+ name: "reply lost with a closed pipe",
101+ transport: &cmdTestTransport{recvErr: io.ErrClosedPipe},
102+ wantContain: "closed pipe",
103+ },
104+ {
105+ name: "wrong reply type",
106+ transport: &cmdTestTransport{recv: mustServerEnvelope(protocol.Pong{})},
107+ wantContain: "unexpected command reply protocol.Pong",
108+ },
109+ {
110+ name: "wrong reply request ID",
111+ transport: &cmdTestTransport{preserveReplyID: true, recv: mustServerEnvelope(protocol.CommandResult{
112+ RequestID: 99, Outcome: protocol.CommandSucceeded,
113+ })},
114+ wantContain: "unexpected command reply request ID 99",
115+ },
116+ {
117+ name: "malformed outcome bytes are refused",
118+ transport: &cmdTestTransport{recv: malformedCommandOutcomeEnvelope(t)},
119+ wantContain: "wire value out of semantic range",
120+ },
121+ {
122+ name: "truncated envelope bytes are refused",
123+ transport: &cmdTestTransport{recv: rawServerEnvelope(
124+ mustServerEnvelope(protocol.CommandResult{RequestID: 1, Outcome: protocol.CommandSucceeded}).Payload[:3])},
125+ wantContain: "wire: envelope is truncated",
126+ },
127+ {
128+ name: "definite daemon unknown outcome",
129+ transport: &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{
130+ Outcome: protocol.CommandOutcomeUnknown, Text: "daemon is shutting down",
131+ })},
132+ wantContain: "daemon is shutting down",
133+ },
134+ }
135+ for _, tt := range tests {
136+ t.Run(tt.name, func(t *testing.T) {
137+ err := runCmdReply(context.Background(), tt.transport, nil)
138+
139+ require.ErrorIs(t, err, errCommandOutcomeUnknown, "a lost or uncorrelated reply is outcome-unknown")
140+ require.NotErrorIs(t, err, errCommandNotSent, "the request was sent, so the outcome is not a definite not-sent")
141+ require.ErrorContains(t, err, tt.wantContain)
142+ require.Equal(t, 3, ExitCode(err))
143+ require.Equal(t, 1, countCommandRequests(t, tt.transport), "an indeterminate command must never be resent")
144+ require.Equal(t, 1, tt.transport.closeCalls, "the one-shot connection is retired exactly once")
145+ })
146+ }
147+}
148+
149+// TestRunCmdCancelAfterSendIsOutcomeUnknownWithoutResend proves a caller
150+// canceled while awaiting the reply reports outcome-unknown, sends exactly one
151+// request, and retires the connection.
152+func TestRunCmdCancelAfterSendIsOutcomeUnknownWithoutResend(t *testing.T) {
153+ replyStarted := make(chan struct{})
154+ recvGate := make(chan struct{})
155+ transport := &cmdTestTransport{replyStarted: replyStarted, recvGate: recvGate}
156+
157+ ctx, cancel := context.WithCancel(context.Background())
158+ defer cancel()
159+ done := make(chan error, 1)
160+ go func() { done <- runCmdReply(ctx, transport, nil) }()
161+
162+ // Wait until the reply read is actually parked, then cancel. The gate makes
163+ // entry deterministic without sleeping.
164+ <-replyStarted
165+ cancel()
166+ err := <-done
167+ close(recvGate)
168+
169+ require.ErrorIs(t, err, errCommandOutcomeUnknown)
170+ require.Equal(t, 3, ExitCode(err))
171+ require.Equal(t, 1, countCommandRequests(t, transport), "a canceled command must never be resent")
172+ require.Equal(t, 1, transport.closeCalls, "cancellation after the send must retire the connection")
173+}
174+
175+// TestRunCmdTimeoutAfterSendIsOutcomeUnknownWithoutResend proves a request
176+// whose result deadline expires while awaiting the reply reports
177+// outcome-unknown, sends exactly one request, and retires the connection. The
178+// fake clock keeps the deadline deterministic.
179+func TestRunCmdTimeoutAfterSendIsOutcomeUnknownWithoutResend(t *testing.T) {
180+ timerCh := make(chan time.Time, 1)
181+ timer := portsmocks.NewMockTimer(t)
182+ timer.EXPECT().C().Return(timerCh).Once()
183+ timer.EXPECT().Stop().Return(true).Once()
184+ delayCh := make(chan time.Duration, 1)
185+ clock := portsmocks.NewMockClock(t)
186+ clock.EXPECT().NewTimer(mock.Anything).Run(func(delay time.Duration) {
187+ delayCh <- delay
188+ }).Return(timer).Once()
189+
190+ replyStarted := make(chan struct{})
191+ recvGate := make(chan struct{})
192+ transport := &cmdTestTransport{replyStarted: replyStarted, recvGate: recvGate}
193+ done := make(chan error, 1)
194+ go func() { done <- runCmdReply(context.Background(), transport, clock) }()
195+
196+ <-replyStarted
197+ require.Equal(t, daemon.CommandRequestTimeout, <-delayCh)
198+ timerCh <- time.Time{}
199+ err := <-done
200+ close(recvGate)
201+
202+ require.ErrorIs(t, err, errCommandOutcomeUnknown)
203+ require.ErrorContains(t, err, daemon.ErrCommandRequestTimeout.Error())
204+ require.Equal(t, 3, ExitCode(err))
205+ require.Equal(t, 1, countCommandRequests(t, transport), "a timed-out command must never be resent")
206+ require.Equal(t, 1, transport.closeCalls, "a result timeout must retire the connection")
207+}
208+
209+// TestRunCmdCancelBeforeSendIsDefiniteNotSent proves a caller already canceled
210+// before the request is sent reports a definite not-sent outcome, puts no
211+// CommandRequest on the wire, and is not misclassified as unknown.
212+func TestRunCmdCancelBeforeSendIsDefiniteNotSent(t *testing.T) {
213+ transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{Outcome: protocol.CommandSucceeded})}
214+
215+ ctx, cancel := context.WithCancel(context.Background())
216+ cancel()
217+ err := runCmdReply(ctx, transport, nil)
218+
219+ require.ErrorIs(t, err, errCommandNotSent)
220+ require.NotErrorIs(t, err, errCommandOutcomeUnknown, "a not-sent command has a definite outcome")
221+ require.Equal(t, 0, countCommandRequests(t, transport), "a canceled-before-send command must not reach the wire")
222+}
223+
224+// TestRunCmdSuccessAndFailurePreserveCorrelationAndBoundedError proves a
225+// definite daemon outcome is reported with its exact text and exit code, and
226+// still sends exactly one request.
227+func TestRunCmdSuccessAndFailurePreserveCorrelationAndBoundedError(t *testing.T) {
228+ tests := []struct {
229+ name string
230+ result protocol.CommandResult
231+ wantCode int
232+ }{
233+ {
234+ name: "success",
235+ result: protocol.CommandResult{Outcome: protocol.CommandSucceeded, Output: "done"},
236+ wantCode: 0,
237+ },
238+ {
239+ name: "invalid arguments are a usage error",
240+ result: protocol.CommandResult{Outcome: protocol.CommandFailed, Code: protocol.ErrInvalidCommandArgs, Text: "usage: split-right"},
241+ wantCode: 2,
242+ },
243+ {
244+ name: "runtime failure is an exit one failure",
245+ result: protocol.CommandResult{Outcome: protocol.CommandFailed, Code: protocol.ErrNoSuchTarget, Text: "no live sessions"},
246+ wantCode: 1,
247+ },
248+ }
249+ for _, tt := range tests {
250+ t.Run(tt.name, func(t *testing.T) {
251+ out := new(strings.Builder)
252+ transport := &cmdTestTransport{recv: mustServerEnvelope(tt.result)}
253+ err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{
254+ stdout: out,
255+ getenv: func(string) string { return "" },
256+ dial: func(context.Context, string) (wire.Transport, error) { return transport, nil },
257+ })
258+
259+ require.Equal(t, tt.wantCode, ExitCode(err))
260+ require.Equal(t, 1, countCommandRequests(t, transport), "a definite outcome sends exactly one request")
261+ require.NotErrorIs(t, err, errCommandOutcomeUnknown, "a definite outcome is never reported as unknown")
262+ if tt.result.Outcome == protocol.CommandSucceeded {
263+ require.NoError(t, err)
264+ require.Equal(t, tt.result.Output+"\n", out.String())
265+ } else {
266+ require.ErrorContains(t, err, tt.result.Text)
267+ require.NotErrorIs(t, err, errCommandNotSent)
268+ }
269+ })
270+ }
271+}
M internal/app/cmd_test.go +50 -24
@@ -190,7 +190,7 @@ func TestMoveCmdPreservesPositionalArguments(t *testing.T) {
190190 t.Run(tt.slug, func(t *testing.T) {
191191 parsed, err := parseArgs(append([]string{"cmd", tt.slug}, tt.args...))
192192 require.NoError(t, err)
193- transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{OK: true})}
193+ transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{Outcome: protocol.CommandSucceeded})}
194194 require.NoError(t, runCmdWithDeps(context.Background(), parsed.cmd, cmdDeps{
195195 stdout: io.Discard,
196196 getenv: func(string) string { return "" },
@@ -208,7 +208,7 @@ func TestMoveCmdPreservesPositionalArguments(t *testing.T) {
208208 }
209209
210210 func TestRemoteCatalogCommandEnsuresDaemon(t *testing.T) {
211- transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{OK: true})}
211+ transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{Outcome: protocol.CommandSucceeded})}
212212 ensureCalls := 0
213213 err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "remote-catalog", jsonOut: true}, cmdDeps{
214214 stdout: io.Discard,
@@ -235,7 +235,7 @@ func TestMoveCmdInvalidArgumentResultExitsTwo(t *testing.T) {
235235 } {
236236 t.Run(invocation.slug+"/"+strconv.Itoa(len(invocation.args)), func(t *testing.T) {
237237 transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{
238- Code: protocol.ErrInvalidCommandArgs, Text: "invalid command arguments",
238+ Outcome: protocol.CommandFailed, Code: protocol.ErrInvalidCommandArgs, Text: "invalid command arguments",
239239 })}
240240 err := runCmdWithDeps(context.Background(), invocation, cmdDeps{
241241 stdout: io.Discard,
@@ -257,7 +257,7 @@ func TestTargetPaneCmdsParseAndUseSelfTarget(t *testing.T) {
257257 t.Run(slug, func(t *testing.T) {
258258 invocation, err := parseArgs([]string{"cmd", "--self", slug})
259259 require.NoError(t, err)
260- transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{OK: true})}
260+ transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{Outcome: protocol.CommandSucceeded})}
261261 require.NoError(t, runCmdWithDeps(context.Background(), invocation.cmd, cmdDeps{
262262 stdout: io.Discard,
263263 getenv: func(string) string { return "session=work,tab=t_abc,pane=p_def" },
@@ -277,7 +277,7 @@ func TestTargetPaneCmdsParseAndUseSelfTarget(t *testing.T) {
277277 }
278278
279279 func TestRunCmdBuildsOneShotTargetedRequest(t *testing.T) {
280- transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{OK: true, Output: "done"})}
280+ transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{Outcome: protocol.CommandSucceeded, Output: "done"})}
281281 out := new(strings.Builder)
282282 err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right", self: true}, cmdDeps{
283283 stdout: out,
@@ -304,7 +304,7 @@ func TestRunCmdBuildsOneShotTargetedRequest(t *testing.T) {
304304 }
305305
306306 func TestRunCmdVEVWithoutSelfUsesIDsOnlyAsSessionLocator(t *testing.T) {
307- transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{OK: true})}
307+ transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{Outcome: protocol.CommandSucceeded})}
308308 err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{
309309 stdout: io.Discard,
310310 getenv: func(string) string { return "session=old,tab=t_abc,pane=p_def" },
@@ -324,7 +324,7 @@ func TestRunCmdVEVWithoutSelfUsesIDsOnlyAsSessionLocator(t *testing.T) {
324324 }
325325
326326 func TestRunCmdExplicitSessionDoesNotUseEnvIDsWithoutSelf(t *testing.T) {
327- transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{OK: true})}
327+ transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{Outcome: protocol.CommandSucceeded})}
328328 err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "new-tab", session: "current"}, cmdDeps{
329329 stdout: io.Discard,
330330 getenv: func(string) string { return "session=old,tab=t_abc,pane=p_def" },
@@ -407,18 +407,23 @@ func TestRunCmdTimesOutPendingRequest(t *testing.T) {
407407 }
408408 }
409409
410-func TestRunCmdZeroRequestIDReplyReturnsDaemonError(t *testing.T) {
411- const want = "daemon error"
412- transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{
413- Code: protocol.ErrInternal, Text: want,
410+func TestRunCmdZeroRequestIDReplyIsRejected(t *testing.T) {
411+ const want = "unexpected command reply request ID 0"
412+ transport := &cmdTestTransport{preserveReplyID: true, recv: mustServerEnvelope(protocol.CommandResult{
413+ Outcome: protocol.CommandFailed, Code: protocol.ErrInternal, Text: want,
414414 })}
415415 err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{
416416 stdout: io.Discard,
417417 getenv: func(string) string { return "" },
418418 dial: func(context.Context, string) (wire.Transport, error) { return transport, nil },
419419 })
420- if err == nil || err.Error() != want {
421- t.Fatalf("error = %v, want daemon error %q", err, want)
420+ // A correctly typed but uncorrelated reply is an indeterminate outcome, not a
421+ // definite daemon failure: the request was sent, so it is never replayed.
422+ if err == nil || !errors.Is(err, errCommandOutcomeUnknown) {
423+ t.Fatalf("error = %v, want an outcome-unknown rejection of reply request ID 0", err)
424+ }
425+ if !strings.Contains(err.Error(), want) {
426+ t.Fatalf("error = %v, want message containing %q", err, want)
422427 }
423428 if errors.Is(err, daemon.ErrCommandRequestTimeout) {
424429 t.Fatalf("error = %v, want daemon error instead of timeout", err)
@@ -452,22 +457,22 @@ func TestRunCmdClassifiesDaemonCommandErrors(t *testing.T) {
452457 }{
453458 {
454459 name: "invalid command arguments are usage errors",
455- result: protocol.CommandResult{Code: protocol.ErrInvalidCommandArgs, Text: "usage: toast [-l level] <message>"},
460+ result: protocol.CommandResult{Outcome: protocol.CommandFailed, Code: protocol.ErrInvalidCommandArgs, Text: "usage: toast [-l level] <message>"},
456461 wantCode: 2,
457462 },
458463 {
459464 name: "missing runtime target is a command failure",
460- result: protocol.CommandResult{Code: protocol.ErrNoSuchTarget, Text: "no live sessions"},
465+ result: protocol.CommandResult{Outcome: protocol.CommandFailed, Code: protocol.ErrNoSuchTarget, Text: "no live sessions"},
461466 wantCode: 1,
462467 },
463468 {
464469 name: "resize not in split remains an exit one failure",
465- result: protocol.CommandResult{Code: protocol.ErrNoSuchTarget, Text: "pane is not in a split"},
470+ result: protocol.CommandResult{Outcome: protocol.CommandFailed, Code: protocol.ErrNoSuchTarget, Text: "pane is not in a split"},
466471 wantCode: 1,
467472 },
468473 {
469474 name: "resize minimum remains an exit one failure",
470- result: protocol.CommandResult{Code: protocol.ErrNoSuchTarget, Text: "pane cannot be resized further"},
475+ result: protocol.CommandResult{Outcome: protocol.CommandFailed, Code: protocol.ErrNoSuchTarget, Text: "pane cannot be resized further"},
471476 wantCode: 1,
472477 },
473478 }
@@ -487,13 +492,15 @@ func TestRunCmdClassifiesDaemonCommandErrors(t *testing.T) {
487492 }
488493
489494 type cmdTestTransport struct {
490- sent []wire.Envelope
491- recv wire.Envelope
492- recvErr error
493- recvCalls int
494- closeCalls int
495- recvStarted chan struct{}
496- recvGate <-chan struct{}
495+ sent []wire.Envelope
496+ recv wire.Envelope
497+ recvErr error
498+ recvCalls int
499+ closeCalls int
500+ recvStarted chan struct{}
501+ replyStarted chan struct{}
502+ recvGate <-chan struct{}
503+ preserveReplyID bool
497504 }
498505
499506 func (t *cmdTestTransport) Send(frame wire.Envelope) error {
@@ -515,9 +522,28 @@ func (t *cmdTestTransport) Recv() (wire.Envelope, error) {
515522 if t.recvCalls == 1 {
516523 return mustPreambleResponse(), nil
517524 }
525+ if t.replyStarted != nil {
526+ select {
527+ case <-t.replyStarted:
528+ default:
529+ close(t.replyStarted)
530+ }
531+ }
518532 if t.recvGate != nil {
519533 <-t.recvGate
520534 }
535+ if t.recvErr == nil && !t.preserveReplyID {
536+ message, err := sessionwire.DecodeServerEnvelope(t.recv.Payload)
537+ if err == nil {
538+ if result, ok := message.(protocol.CommandResult); ok && result.RequestID == 0 {
539+ requestMessage, decodeErr := sessionwire.DecodeClientEnvelope(t.sent[1].Payload)
540+ if decodeErr == nil {
541+ result.RequestID = requestMessage.(protocol.CommandRequest).RequestID
542+ t.recv = mustServerEnvelope(result)
543+ }
544+ }
545+ }
546+ }
521547 return t.recv, t.recvErr
522548 }
523549
@@ -190,7 +190,7 @@ func TestMoveCmdPreservesPositionalArguments(t *testing.T) {
190 t.Run(tt.slug, func(t *testing.T) {190 t.Run(tt.slug, func(t *testing.T) {
191 parsed, err := parseArgs(append([]string{"cmd", tt.slug}, tt.args...))191 parsed, err := parseArgs(append([]string{"cmd", tt.slug}, tt.args...))
192 require.NoError(t, err)192 require.NoError(t, err)
193- transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{OK: true})}193+ transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{Outcome: protocol.CommandSucceeded})}
194 require.NoError(t, runCmdWithDeps(context.Background(), parsed.cmd, cmdDeps{194 require.NoError(t, runCmdWithDeps(context.Background(), parsed.cmd, cmdDeps{
195 stdout: io.Discard,195 stdout: io.Discard,
196 getenv: func(string) string { return "" },196 getenv: func(string) string { return "" },
@@ -208,7 +208,7 @@ func TestMoveCmdPreservesPositionalArguments(t *testing.T) {
208 }208 }
209 209
210 func TestRemoteCatalogCommandEnsuresDaemon(t *testing.T) {210 func TestRemoteCatalogCommandEnsuresDaemon(t *testing.T) {
211- transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{OK: true})}211+ transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{Outcome: protocol.CommandSucceeded})}
212 ensureCalls := 0212 ensureCalls := 0
213 err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "remote-catalog", jsonOut: true}, cmdDeps{213 err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "remote-catalog", jsonOut: true}, cmdDeps{
214 stdout: io.Discard,214 stdout: io.Discard,
@@ -235,7 +235,7 @@ func TestMoveCmdInvalidArgumentResultExitsTwo(t *testing.T) {
235 } {235 } {
236 t.Run(invocation.slug+"/"+strconv.Itoa(len(invocation.args)), func(t *testing.T) {236 t.Run(invocation.slug+"/"+strconv.Itoa(len(invocation.args)), func(t *testing.T) {
237 transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{237 transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{
238- Code: protocol.ErrInvalidCommandArgs, Text: "invalid command arguments",238+ Outcome: protocol.CommandFailed, Code: protocol.ErrInvalidCommandArgs, Text: "invalid command arguments",
239 })}239 })}
240 err := runCmdWithDeps(context.Background(), invocation, cmdDeps{240 err := runCmdWithDeps(context.Background(), invocation, cmdDeps{
241 stdout: io.Discard,241 stdout: io.Discard,
@@ -257,7 +257,7 @@ func TestTargetPaneCmdsParseAndUseSelfTarget(t *testing.T) {
257 t.Run(slug, func(t *testing.T) {257 t.Run(slug, func(t *testing.T) {
258 invocation, err := parseArgs([]string{"cmd", "--self", slug})258 invocation, err := parseArgs([]string{"cmd", "--self", slug})
259 require.NoError(t, err)259 require.NoError(t, err)
260- transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{OK: true})}260+ transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{Outcome: protocol.CommandSucceeded})}
261 require.NoError(t, runCmdWithDeps(context.Background(), invocation.cmd, cmdDeps{261 require.NoError(t, runCmdWithDeps(context.Background(), invocation.cmd, cmdDeps{
262 stdout: io.Discard,262 stdout: io.Discard,
263 getenv: func(string) string { return "session=work,tab=t_abc,pane=p_def" },263 getenv: func(string) string { return "session=work,tab=t_abc,pane=p_def" },
@@ -277,7 +277,7 @@ func TestTargetPaneCmdsParseAndUseSelfTarget(t *testing.T) {
277 }277 }
278 278
279 func TestRunCmdBuildsOneShotTargetedRequest(t *testing.T) {279 func TestRunCmdBuildsOneShotTargetedRequest(t *testing.T) {
280- transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{OK: true, Output: "done"})}280+ transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{Outcome: protocol.CommandSucceeded, Output: "done"})}
281 out := new(strings.Builder)281 out := new(strings.Builder)
282 err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right", self: true}, cmdDeps{282 err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right", self: true}, cmdDeps{
283 stdout: out,283 stdout: out,
@@ -304,7 +304,7 @@ func TestRunCmdBuildsOneShotTargetedRequest(t *testing.T) {
304 }304 }
305 305
306 func TestRunCmdVEVWithoutSelfUsesIDsOnlyAsSessionLocator(t *testing.T) {306 func TestRunCmdVEVWithoutSelfUsesIDsOnlyAsSessionLocator(t *testing.T) {
307- transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{OK: true})}307+ transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{Outcome: protocol.CommandSucceeded})}
308 err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{308 err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{
309 stdout: io.Discard,309 stdout: io.Discard,
310 getenv: func(string) string { return "session=old,tab=t_abc,pane=p_def" },310 getenv: func(string) string { return "session=old,tab=t_abc,pane=p_def" },
@@ -324,7 +324,7 @@ func TestRunCmdVEVWithoutSelfUsesIDsOnlyAsSessionLocator(t *testing.T) {
324 }324 }
325 325
326 func TestRunCmdExplicitSessionDoesNotUseEnvIDsWithoutSelf(t *testing.T) {326 func TestRunCmdExplicitSessionDoesNotUseEnvIDsWithoutSelf(t *testing.T) {
327- transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{OK: true})}327+ transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{Outcome: protocol.CommandSucceeded})}
328 err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "new-tab", session: "current"}, cmdDeps{328 err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "new-tab", session: "current"}, cmdDeps{
329 stdout: io.Discard,329 stdout: io.Discard,
330 getenv: func(string) string { return "session=old,tab=t_abc,pane=p_def" },330 getenv: func(string) string { return "session=old,tab=t_abc,pane=p_def" },
@@ -407,18 +407,23 @@ func TestRunCmdTimesOutPendingRequest(t *testing.T) {
407 }407 }
408 }408 }
409 409
410-func TestRunCmdZeroRequestIDReplyReturnsDaemonError(t *testing.T) {410+func TestRunCmdZeroRequestIDReplyIsRejected(t *testing.T) {
411- const want = "daemon error"411+ const want = "unexpected command reply request ID 0"
412- transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{412+ transport := &cmdTestTransport{preserveReplyID: true, recv: mustServerEnvelope(protocol.CommandResult{
413- Code: protocol.ErrInternal, Text: want,413+ Outcome: protocol.CommandFailed, Code: protocol.ErrInternal, Text: want,
414 })}414 })}
415 err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{415 err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{
416 stdout: io.Discard,416 stdout: io.Discard,
417 getenv: func(string) string { return "" },417 getenv: func(string) string { return "" },
418 dial: func(context.Context, string) (wire.Transport, error) { return transport, nil },418 dial: func(context.Context, string) (wire.Transport, error) { return transport, nil },
419 })419 })
420- if err == nil || err.Error() != want {420+ // A correctly typed but uncorrelated reply is an indeterminate outcome, not a
421- t.Fatalf("error = %v, want daemon error %q", err, want)421+ // definite daemon failure: the request was sent, so it is never replayed.
422+ if err == nil || !errors.Is(err, errCommandOutcomeUnknown) {
423+ t.Fatalf("error = %v, want an outcome-unknown rejection of reply request ID 0", err)
424+ }
425+ if !strings.Contains(err.Error(), want) {
426+ t.Fatalf("error = %v, want message containing %q", err, want)
422 }427 }
423 if errors.Is(err, daemon.ErrCommandRequestTimeout) {428 if errors.Is(err, daemon.ErrCommandRequestTimeout) {
424 t.Fatalf("error = %v, want daemon error instead of timeout", err)429 t.Fatalf("error = %v, want daemon error instead of timeout", err)
@@ -452,22 +457,22 @@ func TestRunCmdClassifiesDaemonCommandErrors(t *testing.T) {
452 }{457 }{
453 {458 {
454 name: "invalid command arguments are usage errors",459 name: "invalid command arguments are usage errors",
455- result: protocol.CommandResult{Code: protocol.ErrInvalidCommandArgs, Text: "usage: toast [-l level] <message>"},460+ result: protocol.CommandResult{Outcome: protocol.CommandFailed, Code: protocol.ErrInvalidCommandArgs, Text: "usage: toast [-l level] <message>"},
456 wantCode: 2,461 wantCode: 2,
457 },462 },
458 {463 {
459 name: "missing runtime target is a command failure",464 name: "missing runtime target is a command failure",
460- result: protocol.CommandResult{Code: protocol.ErrNoSuchTarget, Text: "no live sessions"},465+ result: protocol.CommandResult{Outcome: protocol.CommandFailed, Code: protocol.ErrNoSuchTarget, Text: "no live sessions"},
461 wantCode: 1,466 wantCode: 1,
462 },467 },
463 {468 {
464 name: "resize not in split remains an exit one failure",469 name: "resize not in split remains an exit one failure",
465- result: protocol.CommandResult{Code: protocol.ErrNoSuchTarget, Text: "pane is not in a split"},470+ result: protocol.CommandResult{Outcome: protocol.CommandFailed, Code: protocol.ErrNoSuchTarget, Text: "pane is not in a split"},
466 wantCode: 1,471 wantCode: 1,
467 },472 },
468 {473 {
469 name: "resize minimum remains an exit one failure",474 name: "resize minimum remains an exit one failure",
470- result: protocol.CommandResult{Code: protocol.ErrNoSuchTarget, Text: "pane cannot be resized further"},475+ result: protocol.CommandResult{Outcome: protocol.CommandFailed, Code: protocol.ErrNoSuchTarget, Text: "pane cannot be resized further"},
471 wantCode: 1,476 wantCode: 1,
472 },477 },
473 }478 }
@@ -487,13 +492,15 @@ func TestRunCmdClassifiesDaemonCommandErrors(t *testing.T) {
487 }492 }
488 493
489 type cmdTestTransport struct {494 type cmdTestTransport struct {
490- sent []wire.Envelope495+ sent []wire.Envelope
491- recv wire.Envelope496+ recv wire.Envelope
492- recvErr error497+ recvErr error
493- recvCalls int498+ recvCalls int
494- closeCalls int499+ closeCalls int
495- recvStarted chan struct{}500+ recvStarted chan struct{}
496- recvGate <-chan struct{}501+ replyStarted chan struct{}
502+ recvGate <-chan struct{}
503+ preserveReplyID bool
497 }504 }
498 505
499 func (t *cmdTestTransport) Send(frame wire.Envelope) error {506 func (t *cmdTestTransport) Send(frame wire.Envelope) error {
@@ -515,9 +522,28 @@ func (t *cmdTestTransport) Recv() (wire.Envelope, error) {
515 if t.recvCalls == 1 {522 if t.recvCalls == 1 {
516 return mustPreambleResponse(), nil523 return mustPreambleResponse(), nil
517 }524 }
525+ if t.replyStarted != nil {
526+ select {
527+ case <-t.replyStarted:
528+ default:
529+ close(t.replyStarted)
530+ }
531+ }
518 if t.recvGate != nil {532 if t.recvGate != nil {
519 <-t.recvGate533 <-t.recvGate
520 }534 }
535+ if t.recvErr == nil && !t.preserveReplyID {
536+ message, err := sessionwire.DecodeServerEnvelope(t.recv.Payload)
537+ if err == nil {
538+ if result, ok := message.(protocol.CommandResult); ok && result.RequestID == 0 {
539+ requestMessage, decodeErr := sessionwire.DecodeClientEnvelope(t.sent[1].Payload)
540+ if decodeErr == nil {
541+ result.RequestID = requestMessage.(protocol.CommandRequest).RequestID
542+ t.recv = mustServerEnvelope(result)
543+ }
544+ }
545+ }
546+ }
521 return t.recv, t.recvErr547 return t.recv, t.recvErr
522 }548 }
523 549
M internal/app/daemon_integration_test.go +1 -1
@@ -339,7 +339,7 @@ func TestIntegration_MalformedCommandPreservesVersionAndRequestID(t *testing.T)
339339 {
340340 name: "incompatible version",
341341 payload: versionMismatch,
342- want: &protocol.CommandResult{RequestID: 42, Code: protocol.ErrVersionMismatch, Text: "protocol version mismatch"},
342+ want: &protocol.CommandResult{Outcome: protocol.CommandFailed, RequestID: 42, Code: protocol.ErrVersionMismatch, Text: "protocol version mismatch"},
343343 },
344344 {
345345 // A single zero byte carries no recoverable envelope tag:
@@ -339,7 +339,7 @@ func TestIntegration_MalformedCommandPreservesVersionAndRequestID(t *testing.T)
339 {339 {
340 name: "incompatible version",340 name: "incompatible version",
341 payload: versionMismatch,341 payload: versionMismatch,
342- want: &protocol.CommandResult{RequestID: 42, Code: protocol.ErrVersionMismatch, Text: "protocol version mismatch"},342+ want: &protocol.CommandResult{Outcome: protocol.CommandFailed, RequestID: 42, Code: protocol.ErrVersionMismatch, Text: "protocol version mismatch"},
343 },343 },
344 {344 {
345 // A single zero byte carries no recoverable envelope tag:345 // A single zero byte carries no recoverable envelope tag:
M internal/app/final_acceptance_test.go +2 -2
@@ -141,8 +141,8 @@ func TestAcceptanceAttachedCommandUsesItsConnectionOnly(t *testing.T) {
141141
142142 firstResult := awaitAcceptanceCommandResult(t, first, firstPump, 101, "new-tab")
143143 secondResult := awaitAcceptanceCommandResult(t, second, secondPump, 202, "new-tab")
144- require.True(t, firstResult.OK, firstResult.Text)
145- require.True(t, secondResult.OK, secondResult.Text)
144+ require.True(t, firstResult.Outcome == protocol.CommandSucceeded, firstResult.Text)
145+ require.True(t, secondResult.Outcome == protocol.CommandSucceeded, secondResult.Text)
146146 require.Empty(t, firstResult.Output)
147147 require.Empty(t, secondResult.Output)
148148 require.NotEqual(t, firstResult.RequestID, secondResult.RequestID)
@@ -141,8 +141,8 @@ func TestAcceptanceAttachedCommandUsesItsConnectionOnly(t *testing.T) {
141 141
142 firstResult := awaitAcceptanceCommandResult(t, first, firstPump, 101, "new-tab")142 firstResult := awaitAcceptanceCommandResult(t, first, firstPump, 101, "new-tab")
143 secondResult := awaitAcceptanceCommandResult(t, second, secondPump, 202, "new-tab")143 secondResult := awaitAcceptanceCommandResult(t, second, secondPump, 202, "new-tab")
144- require.True(t, firstResult.OK, firstResult.Text)144+ require.True(t, firstResult.Outcome == protocol.CommandSucceeded, firstResult.Text)
145- require.True(t, secondResult.OK, secondResult.Text)145+ require.True(t, secondResult.Outcome == protocol.CommandSucceeded, secondResult.Text)
146 require.Empty(t, firstResult.Output)146 require.Empty(t, firstResult.Output)
147 require.Empty(t, secondResult.Output)147 require.Empty(t, secondResult.Output)
148 require.NotEqual(t, firstResult.RequestID, secondResult.RequestID)148 require.NotEqual(t, firstResult.RequestID, secondResult.RequestID)
M internal/protocol/command.go +25 -2
@@ -15,11 +15,34 @@ type CommandRequest struct {
1515 JSON bool
1616 }
1717
18-// CommandResult reports a control command's outcome.
18+// CommandOutcome is the closed terminal state of a dispatched command.
19+type CommandOutcome uint8
20+
21+const (
22+ CommandSucceeded CommandOutcome = iota + 1
23+ CommandFailed
24+ CommandOutcomeUnknown
25+)
26+
27+// CommandResult reports a control command's explicit terminal outcome.
1928 type CommandResult struct {
2029 RequestID uint64
21- OK bool
30+ Outcome CommandOutcome
2231 Code uint16
2332 Text string
2433 Output string
2534 }
35+
36+// Valid reports whether the result obeys the closed outcome contract.
37+func (r CommandResult) Valid() bool {
38+ switch r.Outcome {
39+ case CommandSucceeded:
40+ return r.Code == 0 && r.Text == ""
41+ case CommandFailed:
42+ return r.Code != 0 && r.Output == ""
43+ case CommandOutcomeUnknown:
44+ return r.Code == 0 && r.Output == ""
45+ default:
46+ return false
47+ }
48+}
@@ -15,11 +15,34 @@ type CommandRequest struct {
15 JSON bool15 JSON bool
16 }16 }
17 17
18-// CommandResult reports a control command's outcome.18+// CommandOutcome is the closed terminal state of a dispatched command.
19+type CommandOutcome uint8
20+
21+const (
22+ CommandSucceeded CommandOutcome = iota + 1
23+ CommandFailed
24+ CommandOutcomeUnknown
25+)
26+
27+// CommandResult reports a control command's explicit terminal outcome.
19 type CommandResult struct {28 type CommandResult struct {
20 RequestID uint6429 RequestID uint64
21- OK bool30+ Outcome CommandOutcome
22 Code uint1631 Code uint16
23 Text string32 Text string
24 Output string33 Output string
25 }34 }
35+
36+// Valid reports whether the result obeys the closed outcome contract.
37+func (r CommandResult) Valid() bool {
38+ switch r.Outcome {
39+ case CommandSucceeded:
40+ return r.Code == 0 && r.Text == ""
41+ case CommandFailed:
42+ return r.Code != 0 && r.Output == ""
43+ case CommandOutcomeUnknown:
44+ return r.Code == 0 && r.Output == ""
45+ default:
46+ return false
47+ }
48+}
M internal/protocol/session.go +1 -1
@@ -16,7 +16,7 @@ import (
1616 // daemonmux physical preamble conversation (multiplex.proto), whose
1717 // negotiated ceilings and accepted daemon binding ride the same wire
1818 // version as the session and broker conversations.
19-const Version uint16 = 57
19+const Version uint16 = 58
2020
2121 // HandshakeTimeout bounds every transport handshake from connect through the
2222 // first committed publication. It excludes the preceding client-local
@@ -16,7 +16,7 @@ import (
16 // daemonmux physical preamble conversation (multiplex.proto), whose16 // daemonmux physical preamble conversation (multiplex.proto), whose
17 // negotiated ceilings and accepted daemon binding ride the same wire17 // negotiated ceilings and accepted daemon binding ride the same wire
18 // version as the session and broker conversations.18 // version as the session and broker conversations.
19-const Version uint16 = 5719+const Version uint16 = 58
20 20
21 // HandshakeTimeout bounds every transport handshake from connect through the21 // HandshakeTimeout bounds every transport handshake from connect through the
22 // first committed publication. It excludes the preceding client-local22 // first committed publication. It excludes the preceding client-local
M internal/protocol/session_test.go +1 -1
@@ -10,7 +10,7 @@ import (
1010 )
1111
1212 func TestVersionRemainsWireCompatible(t *testing.T) {
13- require.Equal(t, uint16(57), Version)
13+ require.Equal(t, uint16(58), Version)
1414 require.Equal(t, (16<<20)-407, MaxOutputDataLen)
1515 }
1616
@@ -10,7 +10,7 @@ import (
10 )10 )
11 11
12 func TestVersionRemainsWireCompatible(t *testing.T) {12 func TestVersionRemainsWireCompatible(t *testing.T) {
13- require.Equal(t, uint16(57), Version)13+ require.Equal(t, uint16(58), Version)
14 require.Equal(t, (16<<20)-407, MaxOutputDataLen)14 require.Equal(t, (16<<20)-407, MaxOutputDataLen)
15 }15 }
16 16
M internal/protocol/wire/command.pb.go +83 -21 generated file: collapsed
M internal/protocol/wire/envelope_inventory_test.go +2 -2
@@ -15,8 +15,8 @@ import (
1515 // shares a payload type except route navigation failure, and the preamble
1616 // constants agree with the schema authority.
1717 func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) {
18- if protocol.Version != 57 {
19- t.Fatalf("protocol.Version = %d, want 57", protocol.Version)
18+ if protocol.Version != 58 {
19+ t.Fatalf("protocol.Version = %d, want 58", protocol.Version)
2020 }
2121 if ProtocolEpoch != 1 {
2222 t.Fatalf("ProtocolEpoch = %d, want 1", ProtocolEpoch)
@@ -15,8 +15,8 @@ import (
15 // shares a payload type except route navigation failure, and the preamble15 // shares a payload type except route navigation failure, and the preamble
16 // constants agree with the schema authority.16 // constants agree with the schema authority.
17 func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) {17 func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) {
18- if protocol.Version != 57 {18+ if protocol.Version != 58 {
19- t.Fatalf("protocol.Version = %d, want 57", protocol.Version)19+ t.Fatalf("protocol.Version = %d, want 58", protocol.Version)
20 }20 }
21 if ProtocolEpoch != 1 {21 if ProtocolEpoch != 1 {
22 t.Fatalf("ProtocolEpoch = %d, want 1", ProtocolEpoch)22 t.Fatalf("ProtocolEpoch = %d, want 1", ProtocolEpoch)
M internal/protocol/wire/schema/command.proto +12 -2
@@ -20,11 +20,21 @@ message CommandRequest {
2020 bool json = 10;
2121 }
2222
23-// CommandResult reports a control command's outcome.
23+// CommandOutcome is the closed terminal state of a command request.
24+enum CommandOutcome {
25+ COMMAND_OUTCOME_UNSPECIFIED = 0;
26+ COMMAND_OUTCOME_SUCCEEDED = 1;
27+ COMMAND_OUTCOME_FAILED = 2;
28+ COMMAND_OUTCOME_UNKNOWN = 3;
29+}
30+
31+// CommandResult reports a control command's explicit terminal outcome.
2432 message CommandResult {
2533 uint64 request_id = 1;
26- bool ok = 2;
34+ reserved 2;
35+ reserved "ok";
2736 uint32 code = 3;
2837 string text = 4;
2938 string output = 5;
39+ CommandOutcome outcome = 6;
3040 }
@@ -20,11 +20,21 @@ message CommandRequest {
20 bool json = 10;20 bool json = 10;
21 }21 }
22 22
23-// CommandResult reports a control command's outcome.23+// CommandOutcome is the closed terminal state of a command request.
24+enum CommandOutcome {
25+ COMMAND_OUTCOME_UNSPECIFIED = 0;
26+ COMMAND_OUTCOME_SUCCEEDED = 1;
27+ COMMAND_OUTCOME_FAILED = 2;
28+ COMMAND_OUTCOME_UNKNOWN = 3;
29+}
30+
31+// CommandResult reports a control command's explicit terminal outcome.
24 message CommandResult {32 message CommandResult {
25 uint64 request_id = 1;33 uint64 request_id = 1;
26- bool ok = 2;34+ reserved 2;
35+ reserved "ok";
27 uint32 code = 3;36 uint32 code = 3;
28 string text = 4;37 string text = 4;
29 string output = 5;38 string output = 5;
39+ CommandOutcome outcome = 6;
30 }40 }
A internal/protocol/wire/schema_policy_test.go +41 -0
@@ -0,0 +1,41 @@
1+package wire
2+
3+import (
4+ "os"
5+ "strings"
6+ "testing"
7+)
8+
9+// TestCommandResultV58CleanBreakPolicy pins both halves of the intentional
10+// v58 clean break: Buf permits field deletion only under strict versioning,
11+// while the deleted field number and name remain unavailable for reuse.
12+func TestCommandResultV58CleanBreakPolicy(t *testing.T) {
13+ buf, err := os.ReadFile("../../../buf.yaml")
14+ if err != nil {
15+ t.Fatal(err)
16+ }
17+ policy := string(buf)
18+ for _, want := range []string{
19+ "ignore_only:",
20+ "FIELD_NO_DELETE:",
21+ "- internal/protocol/wire/schema/command.proto",
22+ } {
23+ if !strings.Contains(policy, want) {
24+ t.Fatalf("buf breaking policy must scope the v58 field deletion to command.proto: missing %q", want)
25+ }
26+ }
27+ if strings.Contains(policy, "- FIELD_NO_DELETE") {
28+ t.Fatal("buf breaking policy must not permit field deletion globally")
29+ }
30+
31+ schema, err := os.ReadFile("schema/command.proto")
32+ if err != nil {
33+ t.Fatal(err)
34+ }
35+ text := string(schema)
36+ for _, reservation := range []string{"reserved 2;", `reserved "ok";`} {
37+ if !strings.Contains(text, reservation) {
38+ t.Fatalf("CommandResult must retain %s", reservation)
39+ }
40+ }
41+}
@@ -0,0 +1,41 @@
1+package wire
2+
3+import (
4+ "os"
5+ "strings"
6+ "testing"
7+)
8+
9+// TestCommandResultV58CleanBreakPolicy pins both halves of the intentional
10+// v58 clean break: Buf permits field deletion only under strict versioning,
11+// while the deleted field number and name remain unavailable for reuse.
12+func TestCommandResultV58CleanBreakPolicy(t *testing.T) {
13+ buf, err := os.ReadFile("../../../buf.yaml")
14+ if err != nil {
15+ t.Fatal(err)
16+ }
17+ policy := string(buf)
18+ for _, want := range []string{
19+ "ignore_only:",
20+ "FIELD_NO_DELETE:",
21+ "- internal/protocol/wire/schema/command.proto",
22+ } {
23+ if !strings.Contains(policy, want) {
24+ t.Fatalf("buf breaking policy must scope the v58 field deletion to command.proto: missing %q", want)
25+ }
26+ }
27+ if strings.Contains(policy, "- FIELD_NO_DELETE") {
28+ t.Fatal("buf breaking policy must not permit field deletion globally")
29+ }
30+
31+ schema, err := os.ReadFile("schema/command.proto")
32+ if err != nil {
33+ t.Fatal(err)
34+ }
35+ text := string(schema)
36+ for _, reservation := range []string{"reserved 2;", `reserved "ok";`} {
37+ if !strings.Contains(text, reservation) {
38+ t.Fatalf("CommandResult must retain %s", reservation)
39+ }
40+ }
41+}
M internal/usecase/daemon/attachment_effect_gate_test.go +1 -1
@@ -742,7 +742,7 @@ func TestAttachedNavigationCommandSendsResultAfterLocalTransition(t *testing.T)
742742 break
743743 }
744744 }
745- require.True(t, result.OK, result.Text)
745+ require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
746746 var action protocol.RouteNavigationAction
747747 for _, frame := range frames {
748748 message, err := sessionwire.DecodeServerEnvelope(frame.Payload)
@@ -742,7 +742,7 @@ func TestAttachedNavigationCommandSendsResultAfterLocalTransition(t *testing.T)
742 break742 break
743 }743 }
744 }744 }
745- require.True(t, result.OK, result.Text)745+ require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
746 var action protocol.RouteNavigationAction746 var action protocol.RouteNavigationAction
747 for _, frame := range frames {747 for _, frame := range frames {
748 message, err := sessionwire.DecodeServerEnvelope(frame.Payload)748 message, err := sessionwire.DecodeServerEnvelope(frame.Payload)
M internal/usecase/daemon/attachment_multiplex_semantics_test.go +1 -1
@@ -131,7 +131,7 @@ func TestMultiplexAttachmentLocalStateAndFrames(t *testing.T) {
131131 result, ok := decodeServerMessage(t, awaitFrame(t, firstFrames, "CommandResult")).(protocol.CommandResult)
132132 require.True(t, ok)
133133 require.Equal(t, uint64(77), result.RequestID)
134- require.True(t, result.OK, result.Text)
134+ require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
135135 select {
136136 case frame := <-peerFrames:
137137 if command, ok := decodeServerMessage(t, frame).(protocol.CommandResult); ok && command.RequestID == 77 {
@@ -131,7 +131,7 @@ func TestMultiplexAttachmentLocalStateAndFrames(t *testing.T) {
131 result, ok := decodeServerMessage(t, awaitFrame(t, firstFrames, "CommandResult")).(protocol.CommandResult)131 result, ok := decodeServerMessage(t, awaitFrame(t, firstFrames, "CommandResult")).(protocol.CommandResult)
132 require.True(t, ok)132 require.True(t, ok)
133 require.Equal(t, uint64(77), result.RequestID)133 require.Equal(t, uint64(77), result.RequestID)
134- require.True(t, result.OK, result.Text)134+ require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
135 select {135 select {
136 case frame := <-peerFrames:136 case frame := <-peerFrames:
137 if command, ok := decodeServerMessage(t, frame).(protocol.CommandResult); ok && command.RequestID == 77 {137 if command, ok := decodeServerMessage(t, frame).(protocol.CommandResult); ok && command.RequestID == 77 {
M internal/usecase/daemon/client_frame_routing.go +5 -2
@@ -315,7 +315,10 @@ func (d *Daemon) commandResultEffect(effect *attachmentEffect) (*attachmentEffec
315315 }
316316
317317 func (d *Daemon) executeAttachedCommand(effect *attachmentEffect, request protocol.CommandRequest) protocol.CommandResult {
318- result := protocol.CommandResult{RequestID: request.RequestID}
318+ // Every validation arm below answers with a definite failure; the
319+ // success arm replaces the outcome explicitly. A zero (unspecified)
320+ // outcome would be refused by the wire decoder on the client side.
321+ result := protocol.CommandResult{RequestID: request.RequestID, Outcome: protocol.CommandFailed}
319322 if request.Version != protocol.Version {
320323 result.Code = protocol.ErrInvalidCommandArgs
321324 result.Text = "unsupported command protocol version"
@@ -369,7 +372,7 @@ func (d *Daemon) executeAttachedCommand(effect *attachmentEffect, request protoc
369372 return cmd.Run(paletteExec{d: d, sess: sess, ac: effect.ac, effect: effect}, request.Args)
370373 })
371374 if err == nil {
372- result.OK = true
375+ result.Outcome = protocol.CommandSucceeded
373376 return result
374377 }
375378 if errors.Is(err, command.ErrInvalidArguments) {
@@ -315,7 +315,10 @@ func (d *Daemon) commandResultEffect(effect *attachmentEffect) (*attachmentEffec
315 }315 }
316 316
317 func (d *Daemon) executeAttachedCommand(effect *attachmentEffect, request protocol.CommandRequest) protocol.CommandResult {317 func (d *Daemon) executeAttachedCommand(effect *attachmentEffect, request protocol.CommandRequest) protocol.CommandResult {
318- result := protocol.CommandResult{RequestID: request.RequestID}318+ // Every validation arm below answers with a definite failure; the
319+ // success arm replaces the outcome explicitly. A zero (unspecified)
320+ // outcome would be refused by the wire decoder on the client side.
321+ result := protocol.CommandResult{RequestID: request.RequestID, Outcome: protocol.CommandFailed}
319 if request.Version != protocol.Version {322 if request.Version != protocol.Version {
320 result.Code = protocol.ErrInvalidCommandArgs323 result.Code = protocol.ErrInvalidCommandArgs
321 result.Text = "unsupported command protocol version"324 result.Text = "unsupported command protocol version"
@@ -369,7 +372,7 @@ func (d *Daemon) executeAttachedCommand(effect *attachmentEffect, request protoc
369 return cmd.Run(paletteExec{d: d, sess: sess, ac: effect.ac, effect: effect}, request.Args)372 return cmd.Run(paletteExec{d: d, sess: sess, ac: effect.ac, effect: effect}, request.Args)
370 })373 })
371 if err == nil {374 if err == nil {
372- result.OK = true375+ result.Outcome = protocol.CommandSucceeded
373 return result376 return result
374 }377 }
375 if errors.Is(err, command.ErrInvalidArguments) {378 if errors.Is(err, command.ErrInvalidArguments) {
A internal/usecase/daemon/command_result_outcome_test.go +375 -0
@@ -0,0 +1,375 @@
1+package daemon
2+
3+// Explicit CommandResult daemon contract for the one-shot control path: a
4+// dispatched command has exactly one closed terminal outcome; a request whose
5+// deadline expires while its command is still running answers with exactly one
6+// correlated CommandOutcomeUnknown and never a second result for the late
7+// completion; and every definite refusal or success preserves the wire request
8+// correlation and a bounded error. All cases are deterministic and bounded
9+// (fake clock / PTY gates / awaitFrame timeouts), never sleeping.
10+
11+import (
12+ "context"
13+ "io"
14+ "sync"
15+ "testing"
16+ "time"
17+
18+ "github.com/stretchr/testify/require"
19+
20+ "github.com/bnema/vev/internal/adapters/sessionwire"
21+ "github.com/bnema/vev/internal/domain"
22+ "github.com/bnema/vev/internal/ports"
23+ "github.com/bnema/vev/internal/protocol"
24+ "github.com/bnema/vev/internal/protocol/wire"
25+)
26+
27+// gatedControlFactory blocks its first pane Open until release, so a test holds
28+// a dispatched command inside its PTY spawn while the request deadline expires.
29+// Later Opens (a background floating prewarm) return immediately.
30+type gatedControlFactory struct {
31+ entered chan struct{}
32+ release chan struct{}
33+ // gateOnce signals first-Open entry exactly once; releaseOnce closes the
34+ // release channel exactly once. They are separate because Open blocks inside
35+ // its own Once body waiting for the release that releaseOnce performs.
36+ gateOnce sync.Once
37+ releaseOnce sync.Once
38+
39+ mu sync.Mutex
40+ ptys []*gatedControlPTY
41+ first *gatedControlPTY
42+ firstOpened bool
43+ shut bool
44+}
45+
46+func newGatedControlFactory() *gatedControlFactory {
47+ f := &gatedControlFactory{entered: make(chan struct{}), release: make(chan struct{})}
48+ // Pre-allocate the gated pane so a test can await its reader even while the
49+ // first Open is still parked inside the gate.
50+ f.first = &gatedControlPTY{readStarted: make(chan struct{}), stop: make(chan struct{})}
51+ return f
52+}
53+
54+func (f *gatedControlFactory) Open(_ context.Context, _ string, _ []string, _ []string, _ string, _ domain.Geometry) (ports.PTY, error) {
55+ f.mu.Lock()
56+ first := f.first
57+ gate := !f.firstOpened
58+ if gate {
59+ f.firstOpened = true
60+ }
61+ f.mu.Unlock()
62+ if gate {
63+ f.gateOnce.Do(func() { close(f.entered) })
64+ <-f.release
65+ return first, nil
66+ }
67+ p := &gatedControlPTY{readStarted: make(chan struct{}), stop: make(chan struct{})}
68+ f.mu.Lock()
69+ f.ptys = append(f.ptys, p)
70+ shut := f.shut
71+ f.mu.Unlock()
72+ if shut {
73+ _ = p.Close()
74+ }
75+ return p, nil
76+}
77+
78+func (f *gatedControlFactory) releaseOpen() {
79+ f.releaseOnce.Do(func() { close(f.release) })
80+}
81+
82+func (f *gatedControlFactory) firstReadStarted() <-chan struct{} {
83+ f.mu.Lock()
84+ defer f.mu.Unlock()
85+ return f.first.readStarted
86+}
87+
88+func (f *gatedControlFactory) close() {
89+ f.releaseOpen()
90+ f.mu.Lock()
91+ defer f.mu.Unlock()
92+ f.shut = true
93+ _ = f.first.Close()
94+ for _, p := range f.ptys {
95+ _ = p.Close()
96+ }
97+}
98+
99+// gatedControlPTY signals when its reader starts and parks until closed, so a
100+// test observes a committed tab's launched reader without sleeping.
101+type gatedControlPTY struct {
102+ readStarted chan struct{}
103+ stop chan struct{}
104+ startOnce sync.Once
105+ closeOnce sync.Once
106+}
107+
108+func (p *gatedControlPTY) Read([]byte) (int, error) {
109+ p.startOnce.Do(func() { close(p.readStarted) })
110+ <-p.stop
111+ return 0, io.EOF
112+}
113+
114+func (*gatedControlPTY) Write(b []byte) (int, error) { return len(b), nil }
115+func (*gatedControlPTY) Resize(domain.Geometry) error { return nil }
116+func (*gatedControlPTY) Pid() int { return 0 }
117+func (*gatedControlPTY) ForegroundPgid() (int, error) { return 0, nil }
118+
119+func (p *gatedControlPTY) Close() error {
120+ p.closeOnce.Do(func() { close(p.stop) })
121+ return nil
122+}
123+
124+func controlSessionTabCount(t *testing.T, d *Daemon, name string) int {
125+ t.Helper()
126+ d.mu.Lock()
127+ sess := d.findByNameLocked(name)
128+ d.mu.Unlock()
129+ require.NotNil(t, sess, "session %q must exist", name)
130+ sess.mu.Lock()
131+ defer sess.mu.Unlock()
132+ return len(sess.tabs)
133+}
134+
135+// requireNoFurtherCommandResults proves the handler produced no second result.
136+func requireNoFurtherCommandResults(t *testing.T, sends chan wire.Envelope) {
137+ t.Helper()
138+ for {
139+ select {
140+ case frame := <-sends:
141+ if envelopeMessageName(t, frame.Payload) != "CommandResult" {
142+ continue
143+ }
144+ message, err := sessionwire.DecodeServerEnvelope(frame.Payload)
145+ require.NoError(t, err)
146+ t.Fatalf("command answered a second result: %+v", message)
147+ default:
148+ return
149+ }
150+ }
151+}
152+
153+// commandResultCaptureTransport records every server envelope and whether the
154+// one-shot control connection was retired, so a test can assert both without a
155+// mock expectation race.
156+type commandResultCaptureTransport struct {
157+ mu sync.Mutex
158+ sends []wire.Envelope
159+ closed bool
160+}
161+
162+func (c *commandResultCaptureTransport) Send(envelope wire.Envelope) error {
163+ c.mu.Lock()
164+ defer c.mu.Unlock()
165+ c.sends = append(c.sends, envelope)
166+ return nil
167+}
168+
169+func (*commandResultCaptureTransport) Recv() (wire.Envelope, error) {
170+ return wire.Envelope{}, io.EOF
171+}
172+
173+func (c *commandResultCaptureTransport) Close() error {
174+ c.mu.Lock()
175+ defer c.mu.Unlock()
176+ c.closed = true
177+ return nil
178+}
179+
180+func (c *commandResultCaptureTransport) commandResults(t *testing.T) []protocol.CommandResult {
181+ t.Helper()
182+ c.mu.Lock()
183+ frames := append([]wire.Envelope(nil), c.sends...)
184+ c.mu.Unlock()
185+ results := make([]protocol.CommandResult, 0, len(frames))
186+ for _, frame := range frames {
187+ message, err := sessionwire.DecodeServerEnvelope(frame.Payload)
188+ require.NoError(t, err)
189+ if result, ok := message.(protocol.CommandResult); ok {
190+ results = append(results, result)
191+ }
192+ }
193+ return results
194+}
195+
196+func (c *commandResultCaptureTransport) isClosed() bool {
197+ c.mu.Lock()
198+ defer c.mu.Unlock()
199+ return c.closed
200+}
201+
202+// TestHandleCommandTimeoutSendsOneCorrelatedUnknownAndDropsLateCompletion is
203+// the core timeout contract: the command is dispatched and still running when
204+// the deadline expires, so the client receives exactly one CommandOutcomeUnknown
205+// correlated to its RequestID. The command then commits late; that late
206+// completion produces no second result and cannot change the observed outcome.
207+func TestHandleCommandTimeoutSendsOneCorrelatedUnknownAndDropsLateCompletion(t *testing.T) {
208+ factory := newGatedControlFactory()
209+ clock := &signalClock{timers: make(chan *signalTimer, 16)}
210+ d := newTestDaemon(t, factory, clock)
211+ addControlSession(d, "work", "t_work", "p_work")
212+ t.Cleanup(func() {
213+ factory.close()
214+ d.sessWg.Wait()
215+ })
216+
217+ frame := commandFrame(t, protocol.CommandRequest{RequestID: 17, Slug: "new-tab", TargetSession: "work"})
218+ tr, sends, releaseConn := newConn(t, frame)
219+ defer releaseConn()
220+ done := make(chan error, 1)
221+ go func() { done <- d.handleCommandFrame(tr, frame) }()
222+
223+ // The command is admitted and parked inside its pane spawn.
224+ awaitTestCompletion(t, factory.entered, "command was not dispatched")
225+ timer := <-clock.timers
226+ require.Equal(t, CommandRequestTimeout, timer.duration)
227+ timer.ch <- time.Time{}
228+
229+ result := awaitCommandResult(t, sends)
230+ require.Equal(t, uint64(17), result.RequestID, "the unknown outcome must correlate to its request")
231+ require.Equal(t, protocol.CommandOutcomeUnknown, result.Outcome)
232+ require.True(t, result.Valid(), "%+v must obey the outcome contract", result)
233+ require.Zero(t, result.Code, "an unknown outcome carries no definite code")
234+ require.Empty(t, result.Output, "an unknown outcome carries no output")
235+ require.Equal(t, ErrCommandRequestTimeout.Error(), result.Text)
236+ require.NoError(t, <-done, "the one-shot handler must answer and return")
237+
238+ // Release the parked command: it now commits after the client already saw
239+ // the unknown outcome.
240+ factory.releaseOpen()
241+ awaitTestCompletion(t, factory.firstReadStarted(), "late completion never committed its tab")
242+ require.Equal(t, 2, controlSessionTabCount(t, d, "work"), "the timed-out command still committed late")
243+
244+ requireNoFurtherCommandResults(t, sends)
245+}
246+
247+// TestHandleCommandTimeoutRetiresConnectionAfterOneResult proves the timeout
248+// path attempts exactly one correlated unknown result and then retires the
249+// one-shot control connection, so a late completion has no channel to answer on.
250+func TestHandleCommandTimeoutRetiresConnectionAfterOneResult(t *testing.T) {
251+ factory := newGatedControlFactory()
252+ clock := &signalClock{timers: make(chan *signalTimer, 16)}
253+ d := newTestDaemon(t, factory, clock)
254+ addControlSession(d, "work", "t_work", "p_work")
255+ t.Cleanup(func() {
256+ factory.close()
257+ d.sessWg.Wait()
258+ })
259+
260+ capture := &commandResultCaptureTransport{}
261+ frame := commandFrame(t, protocol.CommandRequest{RequestID: 23, Slug: "new-tab", TargetSession: "work"})
262+ done := make(chan error, 1)
263+ go func() { done <- d.handleCommandFrame(&rawServerConnection{raw: capture}, frame) }()
264+
265+ awaitTestCompletion(t, factory.entered, "command was not dispatched")
266+ timer := <-clock.timers
267+ require.Equal(t, CommandRequestTimeout, timer.duration)
268+ timer.ch <- time.Time{}
269+ require.NoError(t, <-done)
270+
271+ results := capture.commandResults(t)
272+ require.Len(t, results, 1, "a timed-out command answers exactly one result")
273+ require.Equal(t, uint64(23), results[0].RequestID)
274+ require.Equal(t, protocol.CommandOutcomeUnknown, results[0].Outcome)
275+ require.True(t, capture.isClosed(), "a one-shot control connection is retired after its result")
276+}
277+
278+// TestHandleCommandDefiniteOutcomesPreserveCorrelationAndOutcome proves each
279+// definite refusal and each success answers once with the closed outcome, the
280+// wire request correlation, and (for failures) a bounded error and no output.
281+func TestHandleCommandDefiniteOutcomesPreserveCorrelationAndOutcome(t *testing.T) {
282+ tests := []struct {
283+ name string
284+ request protocol.CommandRequest
285+ wantOutcome protocol.CommandOutcome
286+ wantCode uint16
287+ wantOutput bool
288+ }{
289+ {
290+ name: "success carries a bounded output",
291+ request: protocol.CommandRequest{Slug: "list-sessions"},
292+ wantOutcome: protocol.CommandSucceeded,
293+ },
294+ {
295+ name: "unknown slug is a definite failure",
296+ request: protocol.CommandRequest{Slug: "no-such"},
297+ wantOutcome: protocol.CommandFailed,
298+ wantCode: protocol.ErrUnknownCommand,
299+ },
300+ {
301+ name: "non-scriptable command is a definite failure",
302+ request: protocol.CommandRequest{Slug: "session-picker"},
303+ wantOutcome: protocol.CommandFailed,
304+ wantCode: protocol.ErrNotScriptable,
305+ },
306+ {
307+ name: "version mismatch is a definite failure",
308+ request: protocol.CommandRequest{Version: protocol.Version + 1, Slug: "list-sessions"},
309+ wantOutcome: protocol.CommandFailed,
310+ wantCode: protocol.ErrVersionMismatch,
311+ },
312+ {
313+ name: "attached relay refusal is a definite failure",
314+ request: protocol.CommandRequest{Attached: true, Slug: "list-sessions"},
315+ wantOutcome: protocol.CommandFailed,
316+ wantCode: protocol.ErrNotScriptable,
317+ },
318+ }
319+ for i, tt := range tests {
320+ t.Run(tt.name, func(t *testing.T) {
321+ d := newTestDaemon(t, nil, stubClock{})
322+ request := tt.request
323+ request.RequestID = uint64(31 + i)
324+
325+ result := sendCommand(t, d, request)
326+
327+ require.Equal(t, request.RequestID, result.RequestID, "the outcome must correlate to its request")
328+ require.Equal(t, tt.wantOutcome, result.Outcome)
329+ require.True(t, result.Valid(), "%+v must obey the outcome contract", result)
330+ if tt.wantOutcome == protocol.CommandFailed {
331+ require.Equal(t, tt.wantCode, result.Code)
332+ require.NotEmpty(t, result.Text, "a definite failure must carry bounded error text")
333+ require.LessOrEqual(t, len(result.Text), wire.ControlEnvelopeLimit)
334+ require.Empty(t, result.Output, "a failure must not carry success output")
335+ } else {
336+ require.Zero(t, result.Code)
337+ require.Empty(t, result.Text)
338+ require.NotEmpty(t, result.Output)
339+ }
340+ })
341+ }
342+}
343+
344+// TestAttachedCommandRefusalSendsDefiniteFailedOutcome proves an attached
345+// command refusal answers with a decodable, definite failure correlated to the
346+// frame's RequestID rather than an unspecified outcome the wire boundary would
347+// refuse.
348+func TestAttachedCommandRefusalSendsDefiniteFailedOutcome(t *testing.T) {
349+ d, sess, ac, _ := newManualSessionWithPTYs(t, nil)
350+ transport := &closeTrackingTransport{}
351+ ac.replaceTransport(transport)
352+ rc := d.attachCoordinator(sess, nil, ac, true)
353+ token := sess.captureAttachmentCapability(ac, transport)
354+ token.lease = rc.attachmentLease(ac)
355+ ac.installTestAttachmentCapability(token)
356+
357+ require.False(t, d.handleAttachmentClientFrame(token, mustClientEnvelope(protocol.CommandRequest{
358+ Version: protocol.Version, RequestID: 41, Slug: "no-such", Attached: true,
359+ })))
360+
361+ var result protocol.CommandResult
362+ found := false
363+ for _, frame := range transport.Sends() {
364+ message, err := sessionwire.DecodeServerEnvelope(frame.Payload)
365+ require.NoError(t, err)
366+ if decoded, ok := message.(protocol.CommandResult); ok {
367+ result, found = decoded, true
368+ }
369+ }
370+ require.True(t, found, "an attached refusal must answer with a CommandResult")
371+ require.Equal(t, uint64(41), result.RequestID)
372+ require.Equal(t, protocol.CommandFailed, result.Outcome)
373+ require.Equal(t, protocol.ErrUnknownCommand, result.Code)
374+ require.True(t, result.Valid(), "%+v must obey the outcome contract", result)
375+}
@@ -0,0 +1,375 @@
1+package daemon
2+
3+// Explicit CommandResult daemon contract for the one-shot control path: a
4+// dispatched command has exactly one closed terminal outcome; a request whose
5+// deadline expires while its command is still running answers with exactly one
6+// correlated CommandOutcomeUnknown and never a second result for the late
7+// completion; and every definite refusal or success preserves the wire request
8+// correlation and a bounded error. All cases are deterministic and bounded
9+// (fake clock / PTY gates / awaitFrame timeouts), never sleeping.
10+
11+import (
12+ "context"
13+ "io"
14+ "sync"
15+ "testing"
16+ "time"
17+
18+ "github.com/stretchr/testify/require"
19+
20+ "github.com/bnema/vev/internal/adapters/sessionwire"
21+ "github.com/bnema/vev/internal/domain"
22+ "github.com/bnema/vev/internal/ports"
23+ "github.com/bnema/vev/internal/protocol"
24+ "github.com/bnema/vev/internal/protocol/wire"
25+)
26+
27+// gatedControlFactory blocks its first pane Open until release, so a test holds
28+// a dispatched command inside its PTY spawn while the request deadline expires.
29+// Later Opens (a background floating prewarm) return immediately.
30+type gatedControlFactory struct {
31+ entered chan struct{}
32+ release chan struct{}
33+ // gateOnce signals first-Open entry exactly once; releaseOnce closes the
34+ // release channel exactly once. They are separate because Open blocks inside
35+ // its own Once body waiting for the release that releaseOnce performs.
36+ gateOnce sync.Once
37+ releaseOnce sync.Once
38+
39+ mu sync.Mutex
40+ ptys []*gatedControlPTY
41+ first *gatedControlPTY
42+ firstOpened bool
43+ shut bool
44+}
45+
46+func newGatedControlFactory() *gatedControlFactory {
47+ f := &gatedControlFactory{entered: make(chan struct{}), release: make(chan struct{})}
48+ // Pre-allocate the gated pane so a test can await its reader even while the
49+ // first Open is still parked inside the gate.
50+ f.first = &gatedControlPTY{readStarted: make(chan struct{}), stop: make(chan struct{})}
51+ return f
52+}
53+
54+func (f *gatedControlFactory) Open(_ context.Context, _ string, _ []string, _ []string, _ string, _ domain.Geometry) (ports.PTY, error) {
55+ f.mu.Lock()
56+ first := f.first
57+ gate := !f.firstOpened
58+ if gate {
59+ f.firstOpened = true
60+ }
61+ f.mu.Unlock()
62+ if gate {
63+ f.gateOnce.Do(func() { close(f.entered) })
64+ <-f.release
65+ return first, nil
66+ }
67+ p := &gatedControlPTY{readStarted: make(chan struct{}), stop: make(chan struct{})}
68+ f.mu.Lock()
69+ f.ptys = append(f.ptys, p)
70+ shut := f.shut
71+ f.mu.Unlock()
72+ if shut {
73+ _ = p.Close()
74+ }
75+ return p, nil
76+}
77+
78+func (f *gatedControlFactory) releaseOpen() {
79+ f.releaseOnce.Do(func() { close(f.release) })
80+}
81+
82+func (f *gatedControlFactory) firstReadStarted() <-chan struct{} {
83+ f.mu.Lock()
84+ defer f.mu.Unlock()
85+ return f.first.readStarted
86+}
87+
88+func (f *gatedControlFactory) close() {
89+ f.releaseOpen()
90+ f.mu.Lock()
91+ defer f.mu.Unlock()
92+ f.shut = true
93+ _ = f.first.Close()
94+ for _, p := range f.ptys {
95+ _ = p.Close()
96+ }
97+}
98+
99+// gatedControlPTY signals when its reader starts and parks until closed, so a
100+// test observes a committed tab's launched reader without sleeping.
101+type gatedControlPTY struct {
102+ readStarted chan struct{}
103+ stop chan struct{}
104+ startOnce sync.Once
105+ closeOnce sync.Once
106+}
107+
108+func (p *gatedControlPTY) Read([]byte) (int, error) {
109+ p.startOnce.Do(func() { close(p.readStarted) })
110+ <-p.stop
111+ return 0, io.EOF
112+}
113+
114+func (*gatedControlPTY) Write(b []byte) (int, error) { return len(b), nil }
115+func (*gatedControlPTY) Resize(domain.Geometry) error { return nil }
116+func (*gatedControlPTY) Pid() int { return 0 }
117+func (*gatedControlPTY) ForegroundPgid() (int, error) { return 0, nil }
118+
119+func (p *gatedControlPTY) Close() error {
120+ p.closeOnce.Do(func() { close(p.stop) })
121+ return nil
122+}
123+
124+func controlSessionTabCount(t *testing.T, d *Daemon, name string) int {
125+ t.Helper()
126+ d.mu.Lock()
127+ sess := d.findByNameLocked(name)
128+ d.mu.Unlock()
129+ require.NotNil(t, sess, "session %q must exist", name)
130+ sess.mu.Lock()
131+ defer sess.mu.Unlock()
132+ return len(sess.tabs)
133+}
134+
135+// requireNoFurtherCommandResults proves the handler produced no second result.
136+func requireNoFurtherCommandResults(t *testing.T, sends chan wire.Envelope) {
137+ t.Helper()
138+ for {
139+ select {
140+ case frame := <-sends:
141+ if envelopeMessageName(t, frame.Payload) != "CommandResult" {
142+ continue
143+ }
144+ message, err := sessionwire.DecodeServerEnvelope(frame.Payload)
145+ require.NoError(t, err)
146+ t.Fatalf("command answered a second result: %+v", message)
147+ default:
148+ return
149+ }
150+ }
151+}
152+
153+// commandResultCaptureTransport records every server envelope and whether the
154+// one-shot control connection was retired, so a test can assert both without a
155+// mock expectation race.
156+type commandResultCaptureTransport struct {
157+ mu sync.Mutex
158+ sends []wire.Envelope
159+ closed bool
160+}
161+
162+func (c *commandResultCaptureTransport) Send(envelope wire.Envelope) error {
163+ c.mu.Lock()
164+ defer c.mu.Unlock()
165+ c.sends = append(c.sends, envelope)
166+ return nil
167+}
168+
169+func (*commandResultCaptureTransport) Recv() (wire.Envelope, error) {
170+ return wire.Envelope{}, io.EOF
171+}
172+
173+func (c *commandResultCaptureTransport) Close() error {
174+ c.mu.Lock()
175+ defer c.mu.Unlock()
176+ c.closed = true
177+ return nil
178+}
179+
180+func (c *commandResultCaptureTransport) commandResults(t *testing.T) []protocol.CommandResult {
181+ t.Helper()
182+ c.mu.Lock()
183+ frames := append([]wire.Envelope(nil), c.sends...)
184+ c.mu.Unlock()
185+ results := make([]protocol.CommandResult, 0, len(frames))
186+ for _, frame := range frames {
187+ message, err := sessionwire.DecodeServerEnvelope(frame.Payload)
188+ require.NoError(t, err)
189+ if result, ok := message.(protocol.CommandResult); ok {
190+ results = append(results, result)
191+ }
192+ }
193+ return results
194+}
195+
196+func (c *commandResultCaptureTransport) isClosed() bool {
197+ c.mu.Lock()
198+ defer c.mu.Unlock()
199+ return c.closed
200+}
201+
202+// TestHandleCommandTimeoutSendsOneCorrelatedUnknownAndDropsLateCompletion is
203+// the core timeout contract: the command is dispatched and still running when
204+// the deadline expires, so the client receives exactly one CommandOutcomeUnknown
205+// correlated to its RequestID. The command then commits late; that late
206+// completion produces no second result and cannot change the observed outcome.
207+func TestHandleCommandTimeoutSendsOneCorrelatedUnknownAndDropsLateCompletion(t *testing.T) {
208+ factory := newGatedControlFactory()
209+ clock := &signalClock{timers: make(chan *signalTimer, 16)}
210+ d := newTestDaemon(t, factory, clock)
211+ addControlSession(d, "work", "t_work", "p_work")
212+ t.Cleanup(func() {
213+ factory.close()
214+ d.sessWg.Wait()
215+ })
216+
217+ frame := commandFrame(t, protocol.CommandRequest{RequestID: 17, Slug: "new-tab", TargetSession: "work"})
218+ tr, sends, releaseConn := newConn(t, frame)
219+ defer releaseConn()
220+ done := make(chan error, 1)
221+ go func() { done <- d.handleCommandFrame(tr, frame) }()
222+
223+ // The command is admitted and parked inside its pane spawn.
224+ awaitTestCompletion(t, factory.entered, "command was not dispatched")
225+ timer := <-clock.timers
226+ require.Equal(t, CommandRequestTimeout, timer.duration)
227+ timer.ch <- time.Time{}
228+
229+ result := awaitCommandResult(t, sends)
230+ require.Equal(t, uint64(17), result.RequestID, "the unknown outcome must correlate to its request")
231+ require.Equal(t, protocol.CommandOutcomeUnknown, result.Outcome)
232+ require.True(t, result.Valid(), "%+v must obey the outcome contract", result)
233+ require.Zero(t, result.Code, "an unknown outcome carries no definite code")
234+ require.Empty(t, result.Output, "an unknown outcome carries no output")
235+ require.Equal(t, ErrCommandRequestTimeout.Error(), result.Text)
236+ require.NoError(t, <-done, "the one-shot handler must answer and return")
237+
238+ // Release the parked command: it now commits after the client already saw
239+ // the unknown outcome.
240+ factory.releaseOpen()
241+ awaitTestCompletion(t, factory.firstReadStarted(), "late completion never committed its tab")
242+ require.Equal(t, 2, controlSessionTabCount(t, d, "work"), "the timed-out command still committed late")
243+
244+ requireNoFurtherCommandResults(t, sends)
245+}
246+
247+// TestHandleCommandTimeoutRetiresConnectionAfterOneResult proves the timeout
248+// path attempts exactly one correlated unknown result and then retires the
249+// one-shot control connection, so a late completion has no channel to answer on.
250+func TestHandleCommandTimeoutRetiresConnectionAfterOneResult(t *testing.T) {
251+ factory := newGatedControlFactory()
252+ clock := &signalClock{timers: make(chan *signalTimer, 16)}
253+ d := newTestDaemon(t, factory, clock)
254+ addControlSession(d, "work", "t_work", "p_work")
255+ t.Cleanup(func() {
256+ factory.close()
257+ d.sessWg.Wait()
258+ })
259+
260+ capture := &commandResultCaptureTransport{}
261+ frame := commandFrame(t, protocol.CommandRequest{RequestID: 23, Slug: "new-tab", TargetSession: "work"})
262+ done := make(chan error, 1)
263+ go func() { done <- d.handleCommandFrame(&rawServerConnection{raw: capture}, frame) }()
264+
265+ awaitTestCompletion(t, factory.entered, "command was not dispatched")
266+ timer := <-clock.timers
267+ require.Equal(t, CommandRequestTimeout, timer.duration)
268+ timer.ch <- time.Time{}
269+ require.NoError(t, <-done)
270+
271+ results := capture.commandResults(t)
272+ require.Len(t, results, 1, "a timed-out command answers exactly one result")
273+ require.Equal(t, uint64(23), results[0].RequestID)
274+ require.Equal(t, protocol.CommandOutcomeUnknown, results[0].Outcome)
275+ require.True(t, capture.isClosed(), "a one-shot control connection is retired after its result")
276+}
277+
278+// TestHandleCommandDefiniteOutcomesPreserveCorrelationAndOutcome proves each
279+// definite refusal and each success answers once with the closed outcome, the
280+// wire request correlation, and (for failures) a bounded error and no output.
281+func TestHandleCommandDefiniteOutcomesPreserveCorrelationAndOutcome(t *testing.T) {
282+ tests := []struct {
283+ name string
284+ request protocol.CommandRequest
285+ wantOutcome protocol.CommandOutcome
286+ wantCode uint16
287+ wantOutput bool
288+ }{
289+ {
290+ name: "success carries a bounded output",
291+ request: protocol.CommandRequest{Slug: "list-sessions"},
292+ wantOutcome: protocol.CommandSucceeded,
293+ },
294+ {
295+ name: "unknown slug is a definite failure",
296+ request: protocol.CommandRequest{Slug: "no-such"},
297+ wantOutcome: protocol.CommandFailed,
298+ wantCode: protocol.ErrUnknownCommand,
299+ },
300+ {
301+ name: "non-scriptable command is a definite failure",
302+ request: protocol.CommandRequest{Slug: "session-picker"},
303+ wantOutcome: protocol.CommandFailed,
304+ wantCode: protocol.ErrNotScriptable,
305+ },
306+ {
307+ name: "version mismatch is a definite failure",
308+ request: protocol.CommandRequest{Version: protocol.Version + 1, Slug: "list-sessions"},
309+ wantOutcome: protocol.CommandFailed,
310+ wantCode: protocol.ErrVersionMismatch,
311+ },
312+ {
313+ name: "attached relay refusal is a definite failure",
314+ request: protocol.CommandRequest{Attached: true, Slug: "list-sessions"},
315+ wantOutcome: protocol.CommandFailed,
316+ wantCode: protocol.ErrNotScriptable,
317+ },
318+ }
319+ for i, tt := range tests {
320+ t.Run(tt.name, func(t *testing.T) {
321+ d := newTestDaemon(t, nil, stubClock{})
322+ request := tt.request
323+ request.RequestID = uint64(31 + i)
324+
325+ result := sendCommand(t, d, request)
326+
327+ require.Equal(t, request.RequestID, result.RequestID, "the outcome must correlate to its request")
328+ require.Equal(t, tt.wantOutcome, result.Outcome)
329+ require.True(t, result.Valid(), "%+v must obey the outcome contract", result)
330+ if tt.wantOutcome == protocol.CommandFailed {
331+ require.Equal(t, tt.wantCode, result.Code)
332+ require.NotEmpty(t, result.Text, "a definite failure must carry bounded error text")
333+ require.LessOrEqual(t, len(result.Text), wire.ControlEnvelopeLimit)
334+ require.Empty(t, result.Output, "a failure must not carry success output")
335+ } else {
336+ require.Zero(t, result.Code)
337+ require.Empty(t, result.Text)
338+ require.NotEmpty(t, result.Output)
339+ }
340+ })
341+ }
342+}
343+
344+// TestAttachedCommandRefusalSendsDefiniteFailedOutcome proves an attached
345+// command refusal answers with a decodable, definite failure correlated to the
346+// frame's RequestID rather than an unspecified outcome the wire boundary would
347+// refuse.
348+func TestAttachedCommandRefusalSendsDefiniteFailedOutcome(t *testing.T) {
349+ d, sess, ac, _ := newManualSessionWithPTYs(t, nil)
350+ transport := &closeTrackingTransport{}
351+ ac.replaceTransport(transport)
352+ rc := d.attachCoordinator(sess, nil, ac, true)
353+ token := sess.captureAttachmentCapability(ac, transport)
354+ token.lease = rc.attachmentLease(ac)
355+ ac.installTestAttachmentCapability(token)
356+
357+ require.False(t, d.handleAttachmentClientFrame(token, mustClientEnvelope(protocol.CommandRequest{
358+ Version: protocol.Version, RequestID: 41, Slug: "no-such", Attached: true,
359+ })))
360+
361+ var result protocol.CommandResult
362+ found := false
363+ for _, frame := range transport.Sends() {
364+ message, err := sessionwire.DecodeServerEnvelope(frame.Payload)
365+ require.NoError(t, err)
366+ if decoded, ok := message.(protocol.CommandResult); ok {
367+ result, found = decoded, true
368+ }
369+ }
370+ require.True(t, found, "an attached refusal must answer with a CommandResult")
371+ require.Equal(t, uint64(41), result.RequestID)
372+ require.Equal(t, protocol.CommandFailed, result.Outcome)
373+ require.Equal(t, protocol.ErrUnknownCommand, result.Code)
374+ require.True(t, result.Valid(), "%+v must obey the outcome contract", result)
375+}
M internal/usecase/daemon/command_tracker_test.go +3 -3
@@ -61,15 +61,15 @@ func TestCommandRequestTrackerTimeoutAndGenerationIsolation(t *testing.T) {
6161 // old connection must not complete the new request.
6262 secondOutcome, ok := tracker.Track(requestID, 8)
6363 require.True(t, ok)
64- tracker.Complete(4, protocol.CommandResult{RequestID: requestID, OK: true})
64+ tracker.Complete(4, protocol.CommandResult{RequestID: requestID, Outcome: protocol.CommandSucceeded})
6565 select {
6666 case <-secondOutcome:
6767 t.Fatal("late result from the old generation completed a newer request")
6868 default:
6969 }
70- tracker.Complete(8, protocol.CommandResult{RequestID: requestID, OK: true})
70+ tracker.Complete(8, protocol.CommandResult{RequestID: requestID, Outcome: protocol.CommandSucceeded})
7171 result := <-secondOutcome
72- require.True(t, result.Result.OK)
72+ require.True(t, result.Result.Outcome == protocol.CommandSucceeded)
7373
7474 t.Run("wait outcomes", func(t *testing.T) {
7575 failure := errors.New("transport failed")
@@ -61,15 +61,15 @@ func TestCommandRequestTrackerTimeoutAndGenerationIsolation(t *testing.T) {
61 // old connection must not complete the new request.61 // old connection must not complete the new request.
62 secondOutcome, ok := tracker.Track(requestID, 8)62 secondOutcome, ok := tracker.Track(requestID, 8)
63 require.True(t, ok)63 require.True(t, ok)
64- tracker.Complete(4, protocol.CommandResult{RequestID: requestID, OK: true})64+ tracker.Complete(4, protocol.CommandResult{RequestID: requestID, Outcome: protocol.CommandSucceeded})
65 select {65 select {
66 case <-secondOutcome:66 case <-secondOutcome:
67 t.Fatal("late result from the old generation completed a newer request")67 t.Fatal("late result from the old generation completed a newer request")
68 default:68 default:
69 }69 }
70- tracker.Complete(8, protocol.CommandResult{RequestID: requestID, OK: true})70+ tracker.Complete(8, protocol.CommandResult{RequestID: requestID, Outcome: protocol.CommandSucceeded})
71 result := <-secondOutcome71 result := <-secondOutcome
72- require.True(t, result.Result.OK)72+ require.True(t, result.Result.Outcome == protocol.CommandSucceeded)
73 73
74 t.Run("wait outcomes", func(t *testing.T) {74 t.Run("wait outcomes", func(t *testing.T) {
75 failure := errors.New("transport failed")75 failure := errors.New("transport failed")
M internal/usecase/daemon/control.go +5 -4
@@ -23,11 +23,12 @@ func (d *Daemon) handleCommand(tr ports.ServerConnection, request protocol.Comma
2323 defer func() { _ = tr.Close() }()
2424
2525 if request.Version != protocol.Version {
26- return d.sendCommandResult(tr, protocol.CommandResult{RequestID: request.RequestID, Code: protocol.ErrVersionMismatch, Text: "protocol version mismatch"})
26+ return d.sendCommandResult(tr, protocol.CommandResult{RequestID: request.RequestID, Outcome: protocol.CommandFailed, Code: protocol.ErrVersionMismatch, Text: "protocol version mismatch"})
2727 }
2828 if request.Attached {
2929 return d.sendCommandResult(tr, protocol.CommandResult{
3030 RequestID: request.RequestID,
31+ Outcome: protocol.CommandFailed,
3132 Code: protocol.ErrNotScriptable,
3233 Text: "attached command relay is not enabled",
3334 })
@@ -55,7 +56,7 @@ func (d *Daemon) handleCommand(tr ports.ServerConnection, request protocol.Comma
5556 if waitErr != nil {
5657 return d.sendCommandResult(tr, protocol.CommandResult{
5758 RequestID: request.RequestID,
58- Code: protocol.ErrInternal,
59+ Outcome: protocol.CommandOutcomeUnknown,
5960 Text: waitErr.Error(),
6061 })
6162 }
@@ -152,7 +153,7 @@ func (d *Daemon) dispatchCommand(ctx context.Context, request protocol.CommandRe
152153 func (d *Daemon) runControl(cmd command.Command, exec controlExec, request protocol.CommandRequest) protocol.CommandResult {
153154 result, err := cmd.Control(exec, request.Args, command.ControlOptions{JSON: request.JSON})
154155 if err == nil {
155- return protocol.CommandResult{OK: true, Output: result.Output}
156+ return protocol.CommandResult{Outcome: protocol.CommandSucceeded, Output: result.Output}
156157 }
157158 switch {
158159 case errors.Is(err, command.ErrInvalidArguments), errors.Is(err, errSessionNameRequired), errors.Is(err, domain.ErrInvalidSessionName):
@@ -181,7 +182,7 @@ func (d *Daemon) runControl(cmd command.Command, exec controlExec, request proto
181182 }
182183
183184 func commandFailure(code uint16, text string) protocol.CommandResult {
184- return protocol.CommandResult{Code: code, Text: text}
185+ return protocol.CommandResult{Outcome: protocol.CommandFailed, Code: code, Text: text}
185186 }
186187
187188 // resolveTargetSession applies explicit-name, stable-ID, then unique-session
@@ -23,11 +23,12 @@ func (d *Daemon) handleCommand(tr ports.ServerConnection, request protocol.Comma
23 defer func() { _ = tr.Close() }()23 defer func() { _ = tr.Close() }()
24 24
25 if request.Version != protocol.Version {25 if request.Version != protocol.Version {
26- return d.sendCommandResult(tr, protocol.CommandResult{RequestID: request.RequestID, Code: protocol.ErrVersionMismatch, Text: "protocol version mismatch"})26+ return d.sendCommandResult(tr, protocol.CommandResult{RequestID: request.RequestID, Outcome: protocol.CommandFailed, Code: protocol.ErrVersionMismatch, Text: "protocol version mismatch"})
27 }27 }
28 if request.Attached {28 if request.Attached {
29 return d.sendCommandResult(tr, protocol.CommandResult{29 return d.sendCommandResult(tr, protocol.CommandResult{
30 RequestID: request.RequestID,30 RequestID: request.RequestID,
31+ Outcome: protocol.CommandFailed,
31 Code: protocol.ErrNotScriptable,32 Code: protocol.ErrNotScriptable,
32 Text: "attached command relay is not enabled",33 Text: "attached command relay is not enabled",
33 })34 })
@@ -55,7 +56,7 @@ func (d *Daemon) handleCommand(tr ports.ServerConnection, request protocol.Comma
55 if waitErr != nil {56 if waitErr != nil {
56 return d.sendCommandResult(tr, protocol.CommandResult{57 return d.sendCommandResult(tr, protocol.CommandResult{
57 RequestID: request.RequestID,58 RequestID: request.RequestID,
58- Code: protocol.ErrInternal,59+ Outcome: protocol.CommandOutcomeUnknown,
59 Text: waitErr.Error(),60 Text: waitErr.Error(),
60 })61 })
61 }62 }
@@ -152,7 +153,7 @@ func (d *Daemon) dispatchCommand(ctx context.Context, request protocol.CommandRe
152 func (d *Daemon) runControl(cmd command.Command, exec controlExec, request protocol.CommandRequest) protocol.CommandResult {153 func (d *Daemon) runControl(cmd command.Command, exec controlExec, request protocol.CommandRequest) protocol.CommandResult {
153 result, err := cmd.Control(exec, request.Args, command.ControlOptions{JSON: request.JSON})154 result, err := cmd.Control(exec, request.Args, command.ControlOptions{JSON: request.JSON})
154 if err == nil {155 if err == nil {
155- return protocol.CommandResult{OK: true, Output: result.Output}156+ return protocol.CommandResult{Outcome: protocol.CommandSucceeded, Output: result.Output}
156 }157 }
157 switch {158 switch {
158 case errors.Is(err, command.ErrInvalidArguments), errors.Is(err, errSessionNameRequired), errors.Is(err, domain.ErrInvalidSessionName):159 case errors.Is(err, command.ErrInvalidArguments), errors.Is(err, errSessionNameRequired), errors.Is(err, domain.ErrInvalidSessionName):
@@ -181,7 +182,7 @@ func (d *Daemon) runControl(cmd command.Command, exec controlExec, request proto
181 }182 }
182 183
183 func commandFailure(code uint16, text string) protocol.CommandResult {184 func commandFailure(code uint16, text string) protocol.CommandResult {
184- return protocol.CommandResult{Code: code, Text: text}185+ return protocol.CommandResult{Outcome: protocol.CommandFailed, Code: code, Text: text}
185 }186 }
186 187
187 // resolveTargetSession applies explicit-name, stable-ID, then unique-session188 // resolveTargetSession applies explicit-name, stable-ID, then unique-session
M internal/usecase/daemon/control_test.go +53 -53
@@ -80,7 +80,7 @@ func TestConsumeOrExpelControlSelfTargetsNonFocusedPane(t *testing.T) {
8080 TargetPane: targetStableID,
8181 })
8282
83- require.True(t, result.OK, result.Text)
83+ require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
8484 require.Empty(t, result.Output)
8585 require.Zero(t, testAttachmentTabIndex(h.session), "--self must not change the attachment tab")
8686 h.tab.mu.Lock()
@@ -111,7 +111,7 @@ func TestConsumeOrExpelControlEdgeNoopReturnsOKAndPreservesFocus(t *testing.T) {
111111 TargetPane: h.panes["pane-1"].stableID,
112112 })
113113
114- require.True(t, result.OK, result.Text)
114+ require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
115115 require.Empty(t, result.Output)
116116 require.Equal(t, before, h.snapshot())
117117 require.Empty(t, h.daemon.notices.history())
@@ -148,7 +148,7 @@ func TestHandleCommandTimesOutWithRequestGeneration(t *testing.T) {
148148 timer.ch <- time.Time{}
149149 result := awaitCommandResult(t, sends)
150150 require.Equal(t, uint64(17), result.RequestID)
151- require.Equal(t, protocol.ErrInternal, result.Code)
151+ require.Equal(t, uint16(0), result.Code)
152152 require.Equal(t, ErrCommandRequestTimeout.Error(), result.Text)
153153 require.NoError(t, <-done)
154154 close(factory.release)
@@ -203,7 +203,7 @@ func TestHandleConnRoutesCommand(t *testing.T) {
203203 d.handleConn(tr)
204204
205205 result := awaitCommandResult(t, sends)
206- require.True(t, result.OK, result.Text)
206+ require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
207207 }
208208
209209 func TestHandleCommandDispatchAndTargetErrors(t *testing.T) {
@@ -248,7 +248,7 @@ func TestHandleCommandDispatchAndTargetErrors(t *testing.T) {
248248 tt.arrange(d)
249249 }
250250 result := sendCommand(t, d, tt.request)
251- require.False(t, result.OK)
251+ require.False(t, result.Outcome == protocol.CommandSucceeded)
252252 require.Equal(t, tt.code, result.Code)
253253 })
254254 }
@@ -263,7 +263,7 @@ func TestHandleCommandResolvesStaleSessionNameByStableIDs(t *testing.T) {
263263 TargetSession: "old-name", TargetTab: "t_work", TargetPane: "p_work",
264264 })
265265
266- require.True(t, result.OK, result.Text)
266+ require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
267267 require.Equal(t, uint64(1), sess.mruAt.Load())
268268 }
269269
@@ -283,7 +283,7 @@ func TestHandleCommandStableIDsResolveSessionWithoutSelectingTab(t *testing.T) {
283283 TargetTab: "t_second", TargetPane: "p_second",
284284 })
285285
286- require.True(t, result.OK, result.Text)
286+ require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
287287 first.mu.Lock()
288288 require.Equal(t, "targeted", first.name)
289289 first.mu.Unlock()
@@ -315,7 +315,7 @@ func TestHandleCommandStableIDsDoNotRedirectSplitFromCurrentFocus(t *testing.T)
315315 TargetTab: "t_origin", TargetPane: "p_origin",
316316 })
317317
318- require.True(t, result.OK, result.Text)
318+ require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
319319 sess.mu.Lock()
320320 activeIndex := testAttachmentTabIndexLocked(sess)
321321 sess.mu.Unlock()
@@ -351,7 +351,7 @@ func TestHandleCommandSelfTargetsNonActiveTabAndPane(t *testing.T) {
351351 Slug: "rename-tab", Args: []string{"invoking"}, Self: true,
352352 TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking",
353353 })
354- require.True(t, rename.OK, rename.Text)
354+ require.True(t, rename.Outcome == protocol.CommandSucceeded, rename.Text)
355355 active.mu.Lock()
356356 require.Empty(t, active.name)
357357 active.mu.Unlock()
@@ -363,7 +363,7 @@ func TestHandleCommandSelfTargetsNonActiveTabAndPane(t *testing.T) {
363363 Slug: "split-right", Self: true,
364364 TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking",
365365 })
366- require.True(t, split.OK, split.Text)
366+ require.True(t, split.Outcome == protocol.CommandSucceeded, split.Text)
367367 active.mu.Lock()
368368 require.Len(t, active.panes, 1)
369369 active.mu.Unlock()
@@ -377,7 +377,7 @@ func TestHandleCommandSelfTargetsNonActiveTabAndPane(t *testing.T) {
377377 Slug: "grow-pane-width", Self: true,
378378 TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking",
379379 })
380- require.True(t, grow.OK, grow.Text)
380+ require.True(t, grow.Outcome == protocol.CommandSucceeded, grow.Text)
381381 invoking.mu.Lock()
382382 require.Equal(t, beforeFocus, invoking.tree.Focus, "targeted resize must not refocus the tab")
383383 require.Equal(t, beforeGeneration+1, invoking.layoutGeneration)
@@ -391,7 +391,7 @@ func TestHandleCommandSelfTargetsNonActiveTabAndPane(t *testing.T) {
391391 Slug: "equalize-panes", Self: true,
392392 TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking",
393393 })
394- require.True(t, equalize.OK, equalize.Text)
394+ require.True(t, equalize.Outcome == protocol.CommandSucceeded, equalize.Text)
395395 invoking.mu.Lock()
396396 require.Equal(t, beforeFocus, invoking.tree.Focus, "targeted equalize must not refocus the tab")
397397 for _, child := range invoking.tree.Root.Children {
@@ -416,8 +416,8 @@ func TestResizeControlOneShotsTargetDetachedSessions(t *testing.T) {
416416 d := newTestDaemon(t, factory, stubClock{})
417417 t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
418418 sess := addControlSession(d, "work", "t_work", "p_work")
419- require.True(t, sendCommand(t, d, protocol.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK)
420- require.True(t, sendCommand(t, d, protocol.CommandRequest{Slug: "split-down", TargetSession: "work"}).OK)
419+ require.True(t, sendCommand(t, d, protocol.CommandRequest{Slug: "split-right", TargetSession: "work"}).Outcome == protocol.CommandSucceeded)
420+ require.True(t, sendCommand(t, d, protocol.CommandRequest{Slug: "split-down", TargetSession: "work"}).Outcome == protocol.CommandSucceeded)
421421 tb := testAttachmentTab(sess)
422422 tb.mu.Lock()
423423 generation := tb.layoutGeneration
@@ -429,7 +429,7 @@ func TestResizeControlOneShotsTargetDetachedSessions(t *testing.T) {
429429 request := target.request()
430430 request.Slug = slug
431431 result := sendCommand(t, d, request)
432- require.True(t, result.OK, result.Text)
432+ require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
433433 require.Empty(t, result.Output)
434434 tb.mu.Lock()
435435 require.Equal(t, generation+1, tb.layoutGeneration, "one accepted action has one layout generation boundary")
@@ -451,7 +451,7 @@ func TestResizeControlNonSelfVEVLocatorUsesActiveTarget(t *testing.T) {
451451 d := newTestDaemon(t, factory, stubClock{})
452452 t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
453453 sess := addControlSession(d, "work", "t_active", "p_active")
454- require.True(t, sendCommand(t, d, protocol.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK)
454+ require.True(t, sendCommand(t, d, protocol.CommandRequest{Slug: "split-right", TargetSession: "work"}).Outcome == protocol.CommandSucceeded)
455455 active := sess.tabs[0]
456456 locator := newTabWithStableID("t_locator", "p_locator", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
457457 locator.ctx, locator.cancel = context.WithCancel(d.serveCtx)
@@ -466,7 +466,7 @@ func TestResizeControlNonSelfVEVLocatorUsesActiveTarget(t *testing.T) {
466466 result := sendCommand(t, d, protocol.CommandRequest{
467467 Slug: "grow-pane-width", TargetSession: "work", TargetTab: "t_locator", TargetPane: "p_locator",
468468 })
469- require.True(t, result.OK, result.Text)
469+ require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
470470 active.mu.Lock()
471471 require.Equal(t, before+1, active.layoutGeneration, "stable IDs locate the session but must not redirect a non-self action")
472472 active.mu.Unlock()
@@ -483,7 +483,7 @@ func TestResizeControlTooSmallUsesStableFailure(t *testing.T) {
483483 d := newTestDaemon(t, factory, stubClock{})
484484 t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
485485 sess := addControlSession(d, "work", "t_work", "p_work")
486- require.True(t, sendCommand(t, d, protocol.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK)
486+ require.True(t, sendCommand(t, d, protocol.CommandRequest{Slug: "split-right", TargetSession: "work"}).Outcome == protocol.CommandSucceeded)
487487 tb := testAttachmentTab(sess)
488488 tb.mu.Lock()
489489 tb.size.Cols = 41
@@ -491,7 +491,7 @@ func TestResizeControlTooSmallUsesStableFailure(t *testing.T) {
491491 tb.mu.Unlock()
492492
493493 result := sendCommand(t, d, protocol.CommandRequest{Slug: "grow-pane-width", TargetSession: "work"})
494- require.False(t, result.OK)
494+ require.False(t, result.Outcome == protocol.CommandSucceeded)
495495 require.Equal(t, protocol.ErrNoSuchTarget, result.Code)
496496 require.Equal(t, "pane cannot be resized further", result.Text)
497497 }
@@ -510,7 +510,7 @@ func TestHandleCommandSelfListPanesUsesInvokingTab(t *testing.T) {
510510 Slug: "list-panes", Self: true, JSON: true,
511511 TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking",
512512 })
513- require.True(t, result.OK, result.Text)
513+ require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
514514 require.Contains(t, result.Output, "p_invoking")
515515 require.NotContains(t, result.Output, "p_active")
516516 }
@@ -523,7 +523,7 @@ func TestHandleCommandRenameSessionRejectsInvalidNameAsCommandArgs(t *testing.T)
523523 Slug: "rename-session", Args: []string{"invalid name"}, TargetSession: "work",
524524 })
525525
526- require.False(t, result.OK)
526+ require.False(t, result.Outcome == protocol.CommandSucceeded)
527527 require.Equal(t, protocol.ErrInvalidCommandArgs, result.Code)
528528 }
529529
@@ -540,7 +540,7 @@ func TestCloseCommandsReportMutationOutcome(t *testing.T) {
540540 target: daemonActionTarget{session: sess, tab: stale},
541541 }, protocol.CommandRequest{Slug: "close-tab"})
542542
543- require.False(t, result.OK)
543+ require.False(t, result.Outcome == protocol.CommandSucceeded)
544544 require.Equal(t, protocol.ErrInternal, result.Code)
545545 require.Len(t, sess.tabs, 1, "a stale close must retain the live tab")
546546 })
@@ -558,7 +558,7 @@ func TestCloseCommandsReportMutationOutcome(t *testing.T) {
558558
559559 result := sendCommand(t, d, protocol.CommandRequest{Slug: "close-pane", TargetSession: "work"})
560560
561- require.False(t, result.OK)
561+ require.False(t, result.Outcome == protocol.CommandSucceeded)
562562 require.Equal(t, protocol.ErrInternal, result.Code)
563563 d.mu.Lock()
564564 require.Same(t, sess, d.sessions[sess.id], "failed final-pane close must retain the session")
@@ -580,7 +580,7 @@ func TestCloseCommandsReportMutationOutcome(t *testing.T) {
580580
581581 result := sendCommand(t, d, protocol.CommandRequest{Slug: "close-tab", TargetSession: "work"})
582582
583- require.True(t, result.OK, result.Text)
583+ require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
584584 sess.mu.Lock()
585585 require.Len(t, sess.tabs, 1)
586586 require.Equal(t, "t_second", sess.tabs[0].stableID)
@@ -623,7 +623,7 @@ func TestHandleCommandHeadlessMutations(t *testing.T) {
623623
624624 result := sendCommand(t, d, protocol.CommandRequest{Slug: tt.slug, Args: tt.args, TargetSession: "work"})
625625
626- require.True(t, result.OK, result.Text)
626+ require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
627627 tt.verify(t, d, sess)
628628 })
629629 }
@@ -642,7 +642,7 @@ func TestHandleCommandNewSessionInheritsHeadlessIdentityAndViewport(t *testing.T
642642
643643 result := sendCommand(t, d, protocol.CommandRequest{Slug: "new-session", Args: []string{"scripted"}, TargetSession: "work"})
644644
645- require.True(t, result.OK, result.Text)
645+ require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
646646 d.mu.Lock()
647647 created := d.findByNameLocked("scripted")
648648 d.mu.Unlock()
@@ -659,7 +659,7 @@ func TestHandleCommandNewSessionInheritsHeadlessIdentityAndViewport(t *testing.T
659659 require.Empty(t, source.snapshotAttachments())
660660
661661 taken := sendCommand(t, d, protocol.CommandRequest{Slug: "new-session", Args: []string{"scripted"}, TargetSession: "work"})
662- require.False(t, taken.OK)
662+ require.False(t, taken.Outcome == protocol.CommandSucceeded)
663663 require.Equal(t, protocol.ErrNameTaken, taken.Code)
664664 }
665665
@@ -676,7 +676,7 @@ func TestHandleCommandNewSessionPreservesSourceGeometry(t *testing.T) {
676676
677677 result := sendCommand(t, d, protocol.CommandRequest{Slug: "new-session", Args: []string{"scripted"}, TargetSession: "work"})
678678
679- require.True(t, result.OK, result.Text)
679+ require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
680680 d.mu.Lock()
681681 created := d.findByNameLocked("scripted")
682682 d.mu.Unlock()
@@ -698,11 +698,11 @@ func TestHandleCommandValidatesToastAndQueuesForDetachedSession(t *testing.T) {
698698 sess := addControlSession(d, "work", "t_work", "p_work")
699699
700700 bad := sendCommand(t, d, protocol.CommandRequest{Slug: "toast", Args: []string{"-l", "loud", "hello"}, TargetSession: "work"})
701- require.False(t, bad.OK)
701+ require.False(t, bad.Outcome == protocol.CommandSucceeded)
702702 require.Equal(t, protocol.ErrInvalidCommandArgs, bad.Code)
703703
704704 good := sendCommand(t, d, protocol.CommandRequest{Slug: "toast", Args: []string{"-l", "warn", "hello"}, TargetSession: "work"})
705- require.True(t, good.OK, good.Text)
705+ require.True(t, good.Outcome == protocol.CommandSucceeded, good.Text)
706706 d.notices.mu.Lock()
707707 require.Len(t, d.notices.pending, 1)
708708 require.Equal(t, domain.NoticeUser, d.notices.pending[0].Code)
@@ -716,16 +716,16 @@ func TestHandleCommandListingsContainStableIDsMarkersAndCWD(t *testing.T) {
716716 sess.tabs[0].name = "shell"
717717
718718 sessions := sendCommand(t, d, protocol.CommandRequest{Slug: "list-sessions"})
719- require.True(t, sessions.OK, sessions.Text)
719+ require.True(t, sessions.Outcome == protocol.CommandSucceeded, sessions.Text)
720720 require.Contains(t, sessions.Output, "NAME\tSTATE\tTABS\tATTACHED\tACTIVE")
721721 require.Contains(t, sessions.Output, "work\tephemeral\t1\tfalse\ttrue")
722722
723723 tabs := sendCommand(t, d, protocol.CommandRequest{Slug: "list-tabs", TargetSession: "work"})
724- require.True(t, tabs.OK, tabs.Text)
724+ require.True(t, tabs.Outcome == protocol.CommandSucceeded, tabs.Text)
725725 require.Contains(t, tabs.Output, "0\tt_work\tshell\t1\ttrue")
726726
727727 panes := sendCommand(t, d, protocol.CommandRequest{Slug: "list-panes", TargetSession: "work", JSON: true})
728- require.True(t, panes.OK, panes.Text)
728+ require.True(t, panes.Outcome == protocol.CommandSucceeded, panes.Text)
729729 var decoded []map[string]any
730730 require.NoError(t, json.Unmarshal([]byte(panes.Output), &decoded))
731731 require.Len(t, decoded, 1)
@@ -741,7 +741,7 @@ func TestRemoteCatalogLeavesStoppedSessionsStopped(t *testing.T) {
741741 d.mu.Unlock()
742742
743743 result := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog", JSON: true})
744- require.True(t, result.OK, result.Text)
744+ require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
745745
746746 // Observation is read-only: the stopped record stays stopped, is not
747747 // resumed into a live session, and is not purged.
@@ -783,18 +783,18 @@ func TestRemoteCatalogJSONOutput(t *testing.T) {
783783 d.mu.Unlock()
784784
785785 listBefore := sendCommand(t, d, protocol.CommandRequest{Slug: "list-sessions"})
786- require.True(t, listBefore.OK, listBefore.Text)
786+ require.True(t, listBefore.Outcome == protocol.CommandSucceeded, listBefore.Text)
787787
788788 missingJSON := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog"})
789- require.False(t, missingJSON.OK)
789+ require.False(t, missingJSON.Outcome == protocol.CommandSucceeded)
790790 require.Equal(t, protocol.ErrInvalidCommandArgs, missingJSON.Code)
791791
792792 withArgs := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog", Args: []string{"extra"}, JSON: true})
793- require.False(t, withArgs.OK)
793+ require.False(t, withArgs.Outcome == protocol.CommandSucceeded)
794794 require.Equal(t, protocol.ErrInvalidCommandArgs, withArgs.Code)
795795
796796 result := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog", JSON: true})
797- require.True(t, result.OK, result.Text)
797+ require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
798798 require.True(t, strings.HasSuffix(result.Output, "\n"), "catalog output must be newline-terminated")
799799
800800 var catalog catalogue.RemoteCatalog
@@ -818,7 +818,7 @@ func TestRemoteCatalogJSONOutput(t *testing.T) {
818818 require.Equal(t, catalogue.RemoteCatalogSessionDown, catalog.Sessions[1].State)
819819
820820 listAfter := sendCommand(t, d, protocol.CommandRequest{Slug: "list-sessions"})
821- require.True(t, listAfter.OK, listAfter.Text)
821+ require.True(t, listAfter.Outcome == protocol.CommandSucceeded, listAfter.Text)
822822 require.Equal(t, listBefore.Output, listAfter.Output)
823823 require.Contains(t, listAfter.Output, "work\tnamed\t2\ttrue\t")
824824 require.NotContains(t, listAfter.Output, "old")
@@ -840,7 +840,7 @@ func TestRemoteCatalogRefreshesFocusedTabTitle(t *testing.T) {
840840 }
841841
842842 result := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog", JSON: true})
843- require.True(t, result.OK, result.Text)
843+ require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
844844 var catalog catalogue.RemoteCatalog
845845 require.NoError(t, json.Unmarshal([]byte(result.Output), &catalog))
846846 require.Equal(t, []catalogue.RemoteCatalogSession{{
@@ -872,7 +872,7 @@ func TestRemoteCatalogRejectsTooManyTabs(t *testing.T) {
872872 sess.mu.Unlock()
873873
874874 result := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog", JSON: true})
875- require.False(t, result.OK)
875+ require.False(t, result.Outcome == protocol.CommandSucceeded)
876876 require.Contains(t, result.Text, "too many tabs")
877877 }
878878
@@ -927,8 +927,8 @@ func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) {
927927 close(factory.release)
928928 <-firstDone
929929 <-secondDone
930- require.True(t, awaitCommandResult(t, firstSends).OK)
931- require.True(t, awaitCommandResult(t, secondSends).OK)
930+ require.True(t, awaitCommandResult(t, firstSends).Outcome == protocol.CommandSucceeded)
931+ require.True(t, awaitCommandResult(t, secondSends).Outcome == protocol.CommandSucceeded)
932932
933933 sess.mu.Lock()
934934 require.Zero(t, testAttachmentTabIndexLocked(sess), "self commands must not select the invoking tab")
@@ -975,7 +975,7 @@ func TestHandleCommandMovePaneUsesActiveFocusedSourceWithSessionFlag(t *testing.
975975 result := sendCommand(t, d, protocol.CommandRequest{
976976 Slug: "move-pane", Args: []string{"dest", "t_dest"}, TargetSession: "work",
977977 })
978- require.True(t, result.OK, result.Text)
978+ require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
979979
980980 source.mu.Lock()
981981 require.Len(t, source.tabs, 1)
@@ -1008,7 +1008,7 @@ func TestHandleCommandMovePanePurgeAdmissionMapsToRetryableError(t *testing.T) {
10081008 result := sendCommand(t, d, protocol.CommandRequest{
10091009 Slug: "move-pane", Args: []string{"dest", "t_dest"}, TargetSession: "work",
10101010 })
1011- require.False(t, result.OK)
1011+ require.False(t, result.Outcome == protocol.CommandSucceeded)
10121012 require.Equal(t, protocol.ErrInternal, result.Code)
10131013 require.Equal(t, errPurgeAdmissionClosed.Error(), result.Text)
10141014 d.endPurgeAdmission()
@@ -1030,7 +1030,7 @@ func TestHandleCommandMovePaneSelfUsesStableSourceIDs(t *testing.T) {
10301030 Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true,
10311031 TargetSession: "work", TargetTab: "t_inactive", TargetPane: "p_inactive",
10321032 })
1033- require.True(t, result.OK, result.Text)
1033+ require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
10341034
10351035 active := source.tabs[0]
10361036 active.mu.Lock()
@@ -1068,7 +1068,7 @@ func TestHandleCommandMoveTabUsesActiveTabWithoutSelf(t *testing.T) {
10681068 Slug: "move-tab", Args: []string{"dest"}, TargetSession: "work", Self: false,
10691069 TargetTab: "t_second", TargetPane: "p_second",
10701070 })
1071- require.True(t, result.OK, result.Text)
1071+ require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
10721072
10731073 source.mu.Lock()
10741074 require.Len(t, source.tabs, 1)
@@ -1096,7 +1096,7 @@ func TestHandleCommandMoveTabSelfUsesStableTab(t *testing.T) {
10961096 Slug: "move-tab", Args: []string{"dest"}, Self: true,
10971097 TargetSession: "work", TargetTab: "t_second", TargetPane: "p_second",
10981098 })
1099- require.True(t, result.OK, result.Text)
1099+ require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
11001100
11011101 source.mu.Lock()
11021102 require.Len(t, source.tabs, 1)
@@ -1215,7 +1215,7 @@ func TestHandleCommandMoveCommandsRejectInvalidArgsAndTargets(t *testing.T) {
12151215 d := newTestDaemon(t, nil, stubClock{})
12161216 request := tt.arrange(d)
12171217 result := sendCommand(t, d, request)
1218- require.False(t, result.OK)
1218+ require.False(t, result.Outcome == protocol.CommandSucceeded)
12191219 require.Equal(t, tt.code, result.Code)
12201220 if tt.text != "" {
12211221 require.Equal(t, tt.text, result.Text)
@@ -1234,7 +1234,7 @@ func TestHandleCommandMovePaneRelocatedStableIDsOverrideAdvisorySessionName(t *t
12341234 Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true,
12351235 TargetSession: "old-name", TargetTab: "t_work", TargetPane: "p_work",
12361236 })
1237- require.True(t, result.OK, result.Text)
1237+ require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
12381238
12391239 source.mu.Lock()
12401240 require.Len(t, source.tabs, 0)
@@ -1266,7 +1266,7 @@ func TestHandleCommandMovePaneStableIDsLocateSessionWithoutSelfRedirect(t *testi
12661266 Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true,
12671267 TargetSession: "old-name", TargetTab: "t_inactive", TargetPane: "p_inactive",
12681268 })
1269- require.True(t, result.OK, result.Text)
1269+ require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
12701270
12711271 source.mu.Lock()
12721272 require.Len(t, source.tabs, 1)
@@ -1332,7 +1332,7 @@ func TestHandleCommandOppositeMoveCommandsDoNotDeadlock(t *testing.T) {
13321332 select {
13331333 case <-done:
13341334 result := awaitCommandResult(t, sends)
1335- require.True(t, result.OK, result.Text)
1335+ require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
13361336 case <-time.After(5 * time.Second):
13371337 t.Fatal("opposite-direction move commands deadlocked")
13381338 }
@@ -1514,7 +1514,7 @@ func TestCloseTabParticipantsChangedReportsRetryable(t *testing.T) {
15141514 t.Cleanup(func() { d.afterAttachmentEffectParticipantsSnapshotted = nil })
15151515
15161516 result := sendCommand(t, d, protocol.CommandRequest{Slug: "close-tab", TargetSession: "work"})
1517- require.False(t, result.OK)
1517+ require.False(t, result.Outcome == protocol.CommandSucceeded)
15181518 require.Equal(t, protocol.ErrInternal, result.Code)
15191519 require.Equal(t, sessionKillRetryMessage, result.Text)
15201520
@@ -80,7 +80,7 @@ func TestConsumeOrExpelControlSelfTargetsNonFocusedPane(t *testing.T) {
80 TargetPane: targetStableID,80 TargetPane: targetStableID,
81 })81 })
82 82
83- require.True(t, result.OK, result.Text)83+ require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
84 require.Empty(t, result.Output)84 require.Empty(t, result.Output)
85 require.Zero(t, testAttachmentTabIndex(h.session), "--self must not change the attachment tab")85 require.Zero(t, testAttachmentTabIndex(h.session), "--self must not change the attachment tab")
86 h.tab.mu.Lock()86 h.tab.mu.Lock()
@@ -111,7 +111,7 @@ func TestConsumeOrExpelControlEdgeNoopReturnsOKAndPreservesFocus(t *testing.T) {
111 TargetPane: h.panes["pane-1"].stableID,111 TargetPane: h.panes["pane-1"].stableID,
112 })112 })
113 113
114- require.True(t, result.OK, result.Text)114+ require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
115 require.Empty(t, result.Output)115 require.Empty(t, result.Output)
116 require.Equal(t, before, h.snapshot())116 require.Equal(t, before, h.snapshot())
117 require.Empty(t, h.daemon.notices.history())117 require.Empty(t, h.daemon.notices.history())
@@ -148,7 +148,7 @@ func TestHandleCommandTimesOutWithRequestGeneration(t *testing.T) {
148 timer.ch <- time.Time{}148 timer.ch <- time.Time{}
149 result := awaitCommandResult(t, sends)149 result := awaitCommandResult(t, sends)
150 require.Equal(t, uint64(17), result.RequestID)150 require.Equal(t, uint64(17), result.RequestID)
151- require.Equal(t, protocol.ErrInternal, result.Code)151+ require.Equal(t, uint16(0), result.Code)
152 require.Equal(t, ErrCommandRequestTimeout.Error(), result.Text)152 require.Equal(t, ErrCommandRequestTimeout.Error(), result.Text)
153 require.NoError(t, <-done)153 require.NoError(t, <-done)
154 close(factory.release)154 close(factory.release)
@@ -203,7 +203,7 @@ func TestHandleConnRoutesCommand(t *testing.T) {
203 d.handleConn(tr)203 d.handleConn(tr)
204 204
205 result := awaitCommandResult(t, sends)205 result := awaitCommandResult(t, sends)
206- require.True(t, result.OK, result.Text)206+ require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
207 }207 }
208 208
209 func TestHandleCommandDispatchAndTargetErrors(t *testing.T) {209 func TestHandleCommandDispatchAndTargetErrors(t *testing.T) {
@@ -248,7 +248,7 @@ func TestHandleCommandDispatchAndTargetErrors(t *testing.T) {
248 tt.arrange(d)248 tt.arrange(d)
249 }249 }
250 result := sendCommand(t, d, tt.request)250 result := sendCommand(t, d, tt.request)
251- require.False(t, result.OK)251+ require.False(t, result.Outcome == protocol.CommandSucceeded)
252 require.Equal(t, tt.code, result.Code)252 require.Equal(t, tt.code, result.Code)
253 })253 })
254 }254 }
@@ -263,7 +263,7 @@ func TestHandleCommandResolvesStaleSessionNameByStableIDs(t *testing.T) {
263 TargetSession: "old-name", TargetTab: "t_work", TargetPane: "p_work",263 TargetSession: "old-name", TargetTab: "t_work", TargetPane: "p_work",
264 })264 })
265 265
266- require.True(t, result.OK, result.Text)266+ require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
267 require.Equal(t, uint64(1), sess.mruAt.Load())267 require.Equal(t, uint64(1), sess.mruAt.Load())
268 }268 }
269 269
@@ -283,7 +283,7 @@ func TestHandleCommandStableIDsResolveSessionWithoutSelectingTab(t *testing.T) {
283 TargetTab: "t_second", TargetPane: "p_second",283 TargetTab: "t_second", TargetPane: "p_second",
284 })284 })
285 285
286- require.True(t, result.OK, result.Text)286+ require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
287 first.mu.Lock()287 first.mu.Lock()
288 require.Equal(t, "targeted", first.name)288 require.Equal(t, "targeted", first.name)
289 first.mu.Unlock()289 first.mu.Unlock()
@@ -315,7 +315,7 @@ func TestHandleCommandStableIDsDoNotRedirectSplitFromCurrentFocus(t *testing.T)
315 TargetTab: "t_origin", TargetPane: "p_origin",315 TargetTab: "t_origin", TargetPane: "p_origin",
316 })316 })
317 317
318- require.True(t, result.OK, result.Text)318+ require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
319 sess.mu.Lock()319 sess.mu.Lock()
320 activeIndex := testAttachmentTabIndexLocked(sess)320 activeIndex := testAttachmentTabIndexLocked(sess)
321 sess.mu.Unlock()321 sess.mu.Unlock()
@@ -351,7 +351,7 @@ func TestHandleCommandSelfTargetsNonActiveTabAndPane(t *testing.T) {
351 Slug: "rename-tab", Args: []string{"invoking"}, Self: true,351 Slug: "rename-tab", Args: []string{"invoking"}, Self: true,
352 TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking",352 TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking",
353 })353 })
354- require.True(t, rename.OK, rename.Text)354+ require.True(t, rename.Outcome == protocol.CommandSucceeded, rename.Text)
355 active.mu.Lock()355 active.mu.Lock()
356 require.Empty(t, active.name)356 require.Empty(t, active.name)
357 active.mu.Unlock()357 active.mu.Unlock()
@@ -363,7 +363,7 @@ func TestHandleCommandSelfTargetsNonActiveTabAndPane(t *testing.T) {
363 Slug: "split-right", Self: true,363 Slug: "split-right", Self: true,
364 TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking",364 TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking",
365 })365 })
366- require.True(t, split.OK, split.Text)366+ require.True(t, split.Outcome == protocol.CommandSucceeded, split.Text)
367 active.mu.Lock()367 active.mu.Lock()
368 require.Len(t, active.panes, 1)368 require.Len(t, active.panes, 1)
369 active.mu.Unlock()369 active.mu.Unlock()
@@ -377,7 +377,7 @@ func TestHandleCommandSelfTargetsNonActiveTabAndPane(t *testing.T) {
377 Slug: "grow-pane-width", Self: true,377 Slug: "grow-pane-width", Self: true,
378 TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking",378 TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking",
379 })379 })
380- require.True(t, grow.OK, grow.Text)380+ require.True(t, grow.Outcome == protocol.CommandSucceeded, grow.Text)
381 invoking.mu.Lock()381 invoking.mu.Lock()
382 require.Equal(t, beforeFocus, invoking.tree.Focus, "targeted resize must not refocus the tab")382 require.Equal(t, beforeFocus, invoking.tree.Focus, "targeted resize must not refocus the tab")
383 require.Equal(t, beforeGeneration+1, invoking.layoutGeneration)383 require.Equal(t, beforeGeneration+1, invoking.layoutGeneration)
@@ -391,7 +391,7 @@ func TestHandleCommandSelfTargetsNonActiveTabAndPane(t *testing.T) {
391 Slug: "equalize-panes", Self: true,391 Slug: "equalize-panes", Self: true,
392 TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking",392 TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking",
393 })393 })
394- require.True(t, equalize.OK, equalize.Text)394+ require.True(t, equalize.Outcome == protocol.CommandSucceeded, equalize.Text)
395 invoking.mu.Lock()395 invoking.mu.Lock()
396 require.Equal(t, beforeFocus, invoking.tree.Focus, "targeted equalize must not refocus the tab")396 require.Equal(t, beforeFocus, invoking.tree.Focus, "targeted equalize must not refocus the tab")
397 for _, child := range invoking.tree.Root.Children {397 for _, child := range invoking.tree.Root.Children {
@@ -416,8 +416,8 @@ func TestResizeControlOneShotsTargetDetachedSessions(t *testing.T) {
416 d := newTestDaemon(t, factory, stubClock{})416 d := newTestDaemon(t, factory, stubClock{})
417 t.Cleanup(func() { factory.close(); d.sessWg.Wait() })417 t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
418 sess := addControlSession(d, "work", "t_work", "p_work")418 sess := addControlSession(d, "work", "t_work", "p_work")
419- require.True(t, sendCommand(t, d, protocol.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK)419+ require.True(t, sendCommand(t, d, protocol.CommandRequest{Slug: "split-right", TargetSession: "work"}).Outcome == protocol.CommandSucceeded)
420- require.True(t, sendCommand(t, d, protocol.CommandRequest{Slug: "split-down", TargetSession: "work"}).OK)420+ require.True(t, sendCommand(t, d, protocol.CommandRequest{Slug: "split-down", TargetSession: "work"}).Outcome == protocol.CommandSucceeded)
421 tb := testAttachmentTab(sess)421 tb := testAttachmentTab(sess)
422 tb.mu.Lock()422 tb.mu.Lock()
423 generation := tb.layoutGeneration423 generation := tb.layoutGeneration
@@ -429,7 +429,7 @@ func TestResizeControlOneShotsTargetDetachedSessions(t *testing.T) {
429 request := target.request()429 request := target.request()
430 request.Slug = slug430 request.Slug = slug
431 result := sendCommand(t, d, request)431 result := sendCommand(t, d, request)
432- require.True(t, result.OK, result.Text)432+ require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
433 require.Empty(t, result.Output)433 require.Empty(t, result.Output)
434 tb.mu.Lock()434 tb.mu.Lock()
435 require.Equal(t, generation+1, tb.layoutGeneration, "one accepted action has one layout generation boundary")435 require.Equal(t, generation+1, tb.layoutGeneration, "one accepted action has one layout generation boundary")
@@ -451,7 +451,7 @@ func TestResizeControlNonSelfVEVLocatorUsesActiveTarget(t *testing.T) {
451 d := newTestDaemon(t, factory, stubClock{})451 d := newTestDaemon(t, factory, stubClock{})
452 t.Cleanup(func() { factory.close(); d.sessWg.Wait() })452 t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
453 sess := addControlSession(d, "work", "t_active", "p_active")453 sess := addControlSession(d, "work", "t_active", "p_active")
454- require.True(t, sendCommand(t, d, protocol.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK)454+ require.True(t, sendCommand(t, d, protocol.CommandRequest{Slug: "split-right", TargetSession: "work"}).Outcome == protocol.CommandSucceeded)
455 active := sess.tabs[0]455 active := sess.tabs[0]
456 locator := newTabWithStableID("t_locator", "p_locator", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})456 locator := newTabWithStableID("t_locator", "p_locator", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
457 locator.ctx, locator.cancel = context.WithCancel(d.serveCtx)457 locator.ctx, locator.cancel = context.WithCancel(d.serveCtx)
@@ -466,7 +466,7 @@ func TestResizeControlNonSelfVEVLocatorUsesActiveTarget(t *testing.T) {
466 result := sendCommand(t, d, protocol.CommandRequest{466 result := sendCommand(t, d, protocol.CommandRequest{
467 Slug: "grow-pane-width", TargetSession: "work", TargetTab: "t_locator", TargetPane: "p_locator",467 Slug: "grow-pane-width", TargetSession: "work", TargetTab: "t_locator", TargetPane: "p_locator",
468 })468 })
469- require.True(t, result.OK, result.Text)469+ require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
470 active.mu.Lock()470 active.mu.Lock()
471 require.Equal(t, before+1, active.layoutGeneration, "stable IDs locate the session but must not redirect a non-self action")471 require.Equal(t, before+1, active.layoutGeneration, "stable IDs locate the session but must not redirect a non-self action")
472 active.mu.Unlock()472 active.mu.Unlock()
@@ -483,7 +483,7 @@ func TestResizeControlTooSmallUsesStableFailure(t *testing.T) {
483 d := newTestDaemon(t, factory, stubClock{})483 d := newTestDaemon(t, factory, stubClock{})
484 t.Cleanup(func() { factory.close(); d.sessWg.Wait() })484 t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
485 sess := addControlSession(d, "work", "t_work", "p_work")485 sess := addControlSession(d, "work", "t_work", "p_work")
486- require.True(t, sendCommand(t, d, protocol.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK)486+ require.True(t, sendCommand(t, d, protocol.CommandRequest{Slug: "split-right", TargetSession: "work"}).Outcome == protocol.CommandSucceeded)
487 tb := testAttachmentTab(sess)487 tb := testAttachmentTab(sess)
488 tb.mu.Lock()488 tb.mu.Lock()
489 tb.size.Cols = 41489 tb.size.Cols = 41
@@ -491,7 +491,7 @@ func TestResizeControlTooSmallUsesStableFailure(t *testing.T) {
491 tb.mu.Unlock()491 tb.mu.Unlock()
492 492
493 result := sendCommand(t, d, protocol.CommandRequest{Slug: "grow-pane-width", TargetSession: "work"})493 result := sendCommand(t, d, protocol.CommandRequest{Slug: "grow-pane-width", TargetSession: "work"})
494- require.False(t, result.OK)494+ require.False(t, result.Outcome == protocol.CommandSucceeded)
495 require.Equal(t, protocol.ErrNoSuchTarget, result.Code)495 require.Equal(t, protocol.ErrNoSuchTarget, result.Code)
496 require.Equal(t, "pane cannot be resized further", result.Text)496 require.Equal(t, "pane cannot be resized further", result.Text)
497 }497 }
@@ -510,7 +510,7 @@ func TestHandleCommandSelfListPanesUsesInvokingTab(t *testing.T) {
510 Slug: "list-panes", Self: true, JSON: true,510 Slug: "list-panes", Self: true, JSON: true,
511 TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking",511 TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking",
512 })512 })
513- require.True(t, result.OK, result.Text)513+ require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
514 require.Contains(t, result.Output, "p_invoking")514 require.Contains(t, result.Output, "p_invoking")
515 require.NotContains(t, result.Output, "p_active")515 require.NotContains(t, result.Output, "p_active")
516 }516 }
@@ -523,7 +523,7 @@ func TestHandleCommandRenameSessionRejectsInvalidNameAsCommandArgs(t *testing.T)
523 Slug: "rename-session", Args: []string{"invalid name"}, TargetSession: "work",523 Slug: "rename-session", Args: []string{"invalid name"}, TargetSession: "work",
524 })524 })
525 525
526- require.False(t, result.OK)526+ require.False(t, result.Outcome == protocol.CommandSucceeded)
527 require.Equal(t, protocol.ErrInvalidCommandArgs, result.Code)527 require.Equal(t, protocol.ErrInvalidCommandArgs, result.Code)
528 }528 }
529 529
@@ -540,7 +540,7 @@ func TestCloseCommandsReportMutationOutcome(t *testing.T) {
540 target: daemonActionTarget{session: sess, tab: stale},540 target: daemonActionTarget{session: sess, tab: stale},
541 }, protocol.CommandRequest{Slug: "close-tab"})541 }, protocol.CommandRequest{Slug: "close-tab"})
542 542
543- require.False(t, result.OK)543+ require.False(t, result.Outcome == protocol.CommandSucceeded)
544 require.Equal(t, protocol.ErrInternal, result.Code)544 require.Equal(t, protocol.ErrInternal, result.Code)
545 require.Len(t, sess.tabs, 1, "a stale close must retain the live tab")545 require.Len(t, sess.tabs, 1, "a stale close must retain the live tab")
546 })546 })
@@ -558,7 +558,7 @@ func TestCloseCommandsReportMutationOutcome(t *testing.T) {
558 558
559 result := sendCommand(t, d, protocol.CommandRequest{Slug: "close-pane", TargetSession: "work"})559 result := sendCommand(t, d, protocol.CommandRequest{Slug: "close-pane", TargetSession: "work"})
560 560
561- require.False(t, result.OK)561+ require.False(t, result.Outcome == protocol.CommandSucceeded)
562 require.Equal(t, protocol.ErrInternal, result.Code)562 require.Equal(t, protocol.ErrInternal, result.Code)
563 d.mu.Lock()563 d.mu.Lock()
564 require.Same(t, sess, d.sessions[sess.id], "failed final-pane close must retain the session")564 require.Same(t, sess, d.sessions[sess.id], "failed final-pane close must retain the session")
@@ -580,7 +580,7 @@ func TestCloseCommandsReportMutationOutcome(t *testing.T) {
580 580
581 result := sendCommand(t, d, protocol.CommandRequest{Slug: "close-tab", TargetSession: "work"})581 result := sendCommand(t, d, protocol.CommandRequest{Slug: "close-tab", TargetSession: "work"})
582 582
583- require.True(t, result.OK, result.Text)583+ require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
584 sess.mu.Lock()584 sess.mu.Lock()
585 require.Len(t, sess.tabs, 1)585 require.Len(t, sess.tabs, 1)
586 require.Equal(t, "t_second", sess.tabs[0].stableID)586 require.Equal(t, "t_second", sess.tabs[0].stableID)
@@ -623,7 +623,7 @@ func TestHandleCommandHeadlessMutations(t *testing.T) {
623 623
624 result := sendCommand(t, d, protocol.CommandRequest{Slug: tt.slug, Args: tt.args, TargetSession: "work"})624 result := sendCommand(t, d, protocol.CommandRequest{Slug: tt.slug, Args: tt.args, TargetSession: "work"})
625 625
626- require.True(t, result.OK, result.Text)626+ require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
627 tt.verify(t, d, sess)627 tt.verify(t, d, sess)
628 })628 })
629 }629 }
@@ -642,7 +642,7 @@ func TestHandleCommandNewSessionInheritsHeadlessIdentityAndViewport(t *testing.T
642 642
643 result := sendCommand(t, d, protocol.CommandRequest{Slug: "new-session", Args: []string{"scripted"}, TargetSession: "work"})643 result := sendCommand(t, d, protocol.CommandRequest{Slug: "new-session", Args: []string{"scripted"}, TargetSession: "work"})
644 644
645- require.True(t, result.OK, result.Text)645+ require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
646 d.mu.Lock()646 d.mu.Lock()
647 created := d.findByNameLocked("scripted")647 created := d.findByNameLocked("scripted")
648 d.mu.Unlock()648 d.mu.Unlock()
@@ -659,7 +659,7 @@ func TestHandleCommandNewSessionInheritsHeadlessIdentityAndViewport(t *testing.T
659 require.Empty(t, source.snapshotAttachments())659 require.Empty(t, source.snapshotAttachments())
660 660
661 taken := sendCommand(t, d, protocol.CommandRequest{Slug: "new-session", Args: []string{"scripted"}, TargetSession: "work"})661 taken := sendCommand(t, d, protocol.CommandRequest{Slug: "new-session", Args: []string{"scripted"}, TargetSession: "work"})
662- require.False(t, taken.OK)662+ require.False(t, taken.Outcome == protocol.CommandSucceeded)
663 require.Equal(t, protocol.ErrNameTaken, taken.Code)663 require.Equal(t, protocol.ErrNameTaken, taken.Code)
664 }664 }
665 665
@@ -676,7 +676,7 @@ func TestHandleCommandNewSessionPreservesSourceGeometry(t *testing.T) {
676 676
677 result := sendCommand(t, d, protocol.CommandRequest{Slug: "new-session", Args: []string{"scripted"}, TargetSession: "work"})677 result := sendCommand(t, d, protocol.CommandRequest{Slug: "new-session", Args: []string{"scripted"}, TargetSession: "work"})
678 678
679- require.True(t, result.OK, result.Text)679+ require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
680 d.mu.Lock()680 d.mu.Lock()
681 created := d.findByNameLocked("scripted")681 created := d.findByNameLocked("scripted")
682 d.mu.Unlock()682 d.mu.Unlock()
@@ -698,11 +698,11 @@ func TestHandleCommandValidatesToastAndQueuesForDetachedSession(t *testing.T) {
698 sess := addControlSession(d, "work", "t_work", "p_work")698 sess := addControlSession(d, "work", "t_work", "p_work")
699 699
700 bad := sendCommand(t, d, protocol.CommandRequest{Slug: "toast", Args: []string{"-l", "loud", "hello"}, TargetSession: "work"})700 bad := sendCommand(t, d, protocol.CommandRequest{Slug: "toast", Args: []string{"-l", "loud", "hello"}, TargetSession: "work"})
701- require.False(t, bad.OK)701+ require.False(t, bad.Outcome == protocol.CommandSucceeded)
702 require.Equal(t, protocol.ErrInvalidCommandArgs, bad.Code)702 require.Equal(t, protocol.ErrInvalidCommandArgs, bad.Code)
703 703
704 good := sendCommand(t, d, protocol.CommandRequest{Slug: "toast", Args: []string{"-l", "warn", "hello"}, TargetSession: "work"})704 good := sendCommand(t, d, protocol.CommandRequest{Slug: "toast", Args: []string{"-l", "warn", "hello"}, TargetSession: "work"})
705- require.True(t, good.OK, good.Text)705+ require.True(t, good.Outcome == protocol.CommandSucceeded, good.Text)
706 d.notices.mu.Lock()706 d.notices.mu.Lock()
707 require.Len(t, d.notices.pending, 1)707 require.Len(t, d.notices.pending, 1)
708 require.Equal(t, domain.NoticeUser, d.notices.pending[0].Code)708 require.Equal(t, domain.NoticeUser, d.notices.pending[0].Code)
@@ -716,16 +716,16 @@ func TestHandleCommandListingsContainStableIDsMarkersAndCWD(t *testing.T) {
716 sess.tabs[0].name = "shell"716 sess.tabs[0].name = "shell"
717 717
718 sessions := sendCommand(t, d, protocol.CommandRequest{Slug: "list-sessions"})718 sessions := sendCommand(t, d, protocol.CommandRequest{Slug: "list-sessions"})
719- require.True(t, sessions.OK, sessions.Text)719+ require.True(t, sessions.Outcome == protocol.CommandSucceeded, sessions.Text)
720 require.Contains(t, sessions.Output, "NAME\tSTATE\tTABS\tATTACHED\tACTIVE")720 require.Contains(t, sessions.Output, "NAME\tSTATE\tTABS\tATTACHED\tACTIVE")
721 require.Contains(t, sessions.Output, "work\tephemeral\t1\tfalse\ttrue")721 require.Contains(t, sessions.Output, "work\tephemeral\t1\tfalse\ttrue")
722 722
723 tabs := sendCommand(t, d, protocol.CommandRequest{Slug: "list-tabs", TargetSession: "work"})723 tabs := sendCommand(t, d, protocol.CommandRequest{Slug: "list-tabs", TargetSession: "work"})
724- require.True(t, tabs.OK, tabs.Text)724+ require.True(t, tabs.Outcome == protocol.CommandSucceeded, tabs.Text)
725 require.Contains(t, tabs.Output, "0\tt_work\tshell\t1\ttrue")725 require.Contains(t, tabs.Output, "0\tt_work\tshell\t1\ttrue")
726 726
727 panes := sendCommand(t, d, protocol.CommandRequest{Slug: "list-panes", TargetSession: "work", JSON: true})727 panes := sendCommand(t, d, protocol.CommandRequest{Slug: "list-panes", TargetSession: "work", JSON: true})
728- require.True(t, panes.OK, panes.Text)728+ require.True(t, panes.Outcome == protocol.CommandSucceeded, panes.Text)
729 var decoded []map[string]any729 var decoded []map[string]any
730 require.NoError(t, json.Unmarshal([]byte(panes.Output), &decoded))730 require.NoError(t, json.Unmarshal([]byte(panes.Output), &decoded))
731 require.Len(t, decoded, 1)731 require.Len(t, decoded, 1)
@@ -741,7 +741,7 @@ func TestRemoteCatalogLeavesStoppedSessionsStopped(t *testing.T) {
741 d.mu.Unlock()741 d.mu.Unlock()
742 742
743 result := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog", JSON: true})743 result := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog", JSON: true})
744- require.True(t, result.OK, result.Text)744+ require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
745 745
746 // Observation is read-only: the stopped record stays stopped, is not746 // Observation is read-only: the stopped record stays stopped, is not
747 // resumed into a live session, and is not purged.747 // resumed into a live session, and is not purged.
@@ -783,18 +783,18 @@ func TestRemoteCatalogJSONOutput(t *testing.T) {
783 d.mu.Unlock()783 d.mu.Unlock()
784 784
785 listBefore := sendCommand(t, d, protocol.CommandRequest{Slug: "list-sessions"})785 listBefore := sendCommand(t, d, protocol.CommandRequest{Slug: "list-sessions"})
786- require.True(t, listBefore.OK, listBefore.Text)786+ require.True(t, listBefore.Outcome == protocol.CommandSucceeded, listBefore.Text)
787 787
788 missingJSON := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog"})788 missingJSON := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog"})
789- require.False(t, missingJSON.OK)789+ require.False(t, missingJSON.Outcome == protocol.CommandSucceeded)
790 require.Equal(t, protocol.ErrInvalidCommandArgs, missingJSON.Code)790 require.Equal(t, protocol.ErrInvalidCommandArgs, missingJSON.Code)
791 791
792 withArgs := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog", Args: []string{"extra"}, JSON: true})792 withArgs := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog", Args: []string{"extra"}, JSON: true})
793- require.False(t, withArgs.OK)793+ require.False(t, withArgs.Outcome == protocol.CommandSucceeded)
794 require.Equal(t, protocol.ErrInvalidCommandArgs, withArgs.Code)794 require.Equal(t, protocol.ErrInvalidCommandArgs, withArgs.Code)
795 795
796 result := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog", JSON: true})796 result := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog", JSON: true})
797- require.True(t, result.OK, result.Text)797+ require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
798 require.True(t, strings.HasSuffix(result.Output, "\n"), "catalog output must be newline-terminated")798 require.True(t, strings.HasSuffix(result.Output, "\n"), "catalog output must be newline-terminated")
799 799
800 var catalog catalogue.RemoteCatalog800 var catalog catalogue.RemoteCatalog
@@ -818,7 +818,7 @@ func TestRemoteCatalogJSONOutput(t *testing.T) {
818 require.Equal(t, catalogue.RemoteCatalogSessionDown, catalog.Sessions[1].State)818 require.Equal(t, catalogue.RemoteCatalogSessionDown, catalog.Sessions[1].State)
819 819
820 listAfter := sendCommand(t, d, protocol.CommandRequest{Slug: "list-sessions"})820 listAfter := sendCommand(t, d, protocol.CommandRequest{Slug: "list-sessions"})
821- require.True(t, listAfter.OK, listAfter.Text)821+ require.True(t, listAfter.Outcome == protocol.CommandSucceeded, listAfter.Text)
822 require.Equal(t, listBefore.Output, listAfter.Output)822 require.Equal(t, listBefore.Output, listAfter.Output)
823 require.Contains(t, listAfter.Output, "work\tnamed\t2\ttrue\t")823 require.Contains(t, listAfter.Output, "work\tnamed\t2\ttrue\t")
824 require.NotContains(t, listAfter.Output, "old")824 require.NotContains(t, listAfter.Output, "old")
@@ -840,7 +840,7 @@ func TestRemoteCatalogRefreshesFocusedTabTitle(t *testing.T) {
840 }840 }
841 841
842 result := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog", JSON: true})842 result := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog", JSON: true})
843- require.True(t, result.OK, result.Text)843+ require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
844 var catalog catalogue.RemoteCatalog844 var catalog catalogue.RemoteCatalog
845 require.NoError(t, json.Unmarshal([]byte(result.Output), &catalog))845 require.NoError(t, json.Unmarshal([]byte(result.Output), &catalog))
846 require.Equal(t, []catalogue.RemoteCatalogSession{{846 require.Equal(t, []catalogue.RemoteCatalogSession{{
@@ -872,7 +872,7 @@ func TestRemoteCatalogRejectsTooManyTabs(t *testing.T) {
872 sess.mu.Unlock()872 sess.mu.Unlock()
873 873
874 result := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog", JSON: true})874 result := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog", JSON: true})
875- require.False(t, result.OK)875+ require.False(t, result.Outcome == protocol.CommandSucceeded)
876 require.Contains(t, result.Text, "too many tabs")876 require.Contains(t, result.Text, "too many tabs")
877 }877 }
878 878
@@ -927,8 +927,8 @@ func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) {
927 close(factory.release)927 close(factory.release)
928 <-firstDone928 <-firstDone
929 <-secondDone929 <-secondDone
930- require.True(t, awaitCommandResult(t, firstSends).OK)930+ require.True(t, awaitCommandResult(t, firstSends).Outcome == protocol.CommandSucceeded)
931- require.True(t, awaitCommandResult(t, secondSends).OK)931+ require.True(t, awaitCommandResult(t, secondSends).Outcome == protocol.CommandSucceeded)
932 932
933 sess.mu.Lock()933 sess.mu.Lock()
934 require.Zero(t, testAttachmentTabIndexLocked(sess), "self commands must not select the invoking tab")934 require.Zero(t, testAttachmentTabIndexLocked(sess), "self commands must not select the invoking tab")
@@ -975,7 +975,7 @@ func TestHandleCommandMovePaneUsesActiveFocusedSourceWithSessionFlag(t *testing.
975 result := sendCommand(t, d, protocol.CommandRequest{975 result := sendCommand(t, d, protocol.CommandRequest{
976 Slug: "move-pane", Args: []string{"dest", "t_dest"}, TargetSession: "work",976 Slug: "move-pane", Args: []string{"dest", "t_dest"}, TargetSession: "work",
977 })977 })
978- require.True(t, result.OK, result.Text)978+ require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
979 979
980 source.mu.Lock()980 source.mu.Lock()
981 require.Len(t, source.tabs, 1)981 require.Len(t, source.tabs, 1)
@@ -1008,7 +1008,7 @@ func TestHandleCommandMovePanePurgeAdmissionMapsToRetryableError(t *testing.T) {
1008 result := sendCommand(t, d, protocol.CommandRequest{1008 result := sendCommand(t, d, protocol.CommandRequest{
1009 Slug: "move-pane", Args: []string{"dest", "t_dest"}, TargetSession: "work",1009 Slug: "move-pane", Args: []string{"dest", "t_dest"}, TargetSession: "work",
1010 })1010 })
1011- require.False(t, result.OK)1011+ require.False(t, result.Outcome == protocol.CommandSucceeded)
1012 require.Equal(t, protocol.ErrInternal, result.Code)1012 require.Equal(t, protocol.ErrInternal, result.Code)
1013 require.Equal(t, errPurgeAdmissionClosed.Error(), result.Text)1013 require.Equal(t, errPurgeAdmissionClosed.Error(), result.Text)
1014 d.endPurgeAdmission()1014 d.endPurgeAdmission()
@@ -1030,7 +1030,7 @@ func TestHandleCommandMovePaneSelfUsesStableSourceIDs(t *testing.T) {
1030 Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true,1030 Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true,
1031 TargetSession: "work", TargetTab: "t_inactive", TargetPane: "p_inactive",1031 TargetSession: "work", TargetTab: "t_inactive", TargetPane: "p_inactive",
1032 })1032 })
1033- require.True(t, result.OK, result.Text)1033+ require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
1034 1034
1035 active := source.tabs[0]1035 active := source.tabs[0]
1036 active.mu.Lock()1036 active.mu.Lock()
@@ -1068,7 +1068,7 @@ func TestHandleCommandMoveTabUsesActiveTabWithoutSelf(t *testing.T) {
1068 Slug: "move-tab", Args: []string{"dest"}, TargetSession: "work", Self: false,1068 Slug: "move-tab", Args: []string{"dest"}, TargetSession: "work", Self: false,
1069 TargetTab: "t_second", TargetPane: "p_second",1069 TargetTab: "t_second", TargetPane: "p_second",
1070 })1070 })
1071- require.True(t, result.OK, result.Text)1071+ require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
1072 1072
1073 source.mu.Lock()1073 source.mu.Lock()
1074 require.Len(t, source.tabs, 1)1074 require.Len(t, source.tabs, 1)
@@ -1096,7 +1096,7 @@ func TestHandleCommandMoveTabSelfUsesStableTab(t *testing.T) {
1096 Slug: "move-tab", Args: []string{"dest"}, Self: true,1096 Slug: "move-tab", Args: []string{"dest"}, Self: true,
1097 TargetSession: "work", TargetTab: "t_second", TargetPane: "p_second",1097 TargetSession: "work", TargetTab: "t_second", TargetPane: "p_second",
1098 })1098 })
1099- require.True(t, result.OK, result.Text)1099+ require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
1100 1100
1101 source.mu.Lock()1101 source.mu.Lock()
1102 require.Len(t, source.tabs, 1)1102 require.Len(t, source.tabs, 1)
@@ -1215,7 +1215,7 @@ func TestHandleCommandMoveCommandsRejectInvalidArgsAndTargets(t *testing.T) {
1215 d := newTestDaemon(t, nil, stubClock{})1215 d := newTestDaemon(t, nil, stubClock{})
1216 request := tt.arrange(d)1216 request := tt.arrange(d)
1217 result := sendCommand(t, d, request)1217 result := sendCommand(t, d, request)
1218- require.False(t, result.OK)1218+ require.False(t, result.Outcome == protocol.CommandSucceeded)
1219 require.Equal(t, tt.code, result.Code)1219 require.Equal(t, tt.code, result.Code)
1220 if tt.text != "" {1220 if tt.text != "" {
1221 require.Equal(t, tt.text, result.Text)1221 require.Equal(t, tt.text, result.Text)
@@ -1234,7 +1234,7 @@ func TestHandleCommandMovePaneRelocatedStableIDsOverrideAdvisorySessionName(t *t
1234 Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true,1234 Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true,
1235 TargetSession: "old-name", TargetTab: "t_work", TargetPane: "p_work",1235 TargetSession: "old-name", TargetTab: "t_work", TargetPane: "p_work",
1236 })1236 })
1237- require.True(t, result.OK, result.Text)1237+ require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
1238 1238
1239 source.mu.Lock()1239 source.mu.Lock()
1240 require.Len(t, source.tabs, 0)1240 require.Len(t, source.tabs, 0)
@@ -1266,7 +1266,7 @@ func TestHandleCommandMovePaneStableIDsLocateSessionWithoutSelfRedirect(t *testi
1266 Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true,1266 Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true,
1267 TargetSession: "old-name", TargetTab: "t_inactive", TargetPane: "p_inactive",1267 TargetSession: "old-name", TargetTab: "t_inactive", TargetPane: "p_inactive",
1268 })1268 })
1269- require.True(t, result.OK, result.Text)1269+ require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
1270 1270
1271 source.mu.Lock()1271 source.mu.Lock()
1272 require.Len(t, source.tabs, 1)1272 require.Len(t, source.tabs, 1)
@@ -1332,7 +1332,7 @@ func TestHandleCommandOppositeMoveCommandsDoNotDeadlock(t *testing.T) {
1332 select {1332 select {
1333 case <-done:1333 case <-done:
1334 result := awaitCommandResult(t, sends)1334 result := awaitCommandResult(t, sends)
1335- require.True(t, result.OK, result.Text)1335+ require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
1336 case <-time.After(5 * time.Second):1336 case <-time.After(5 * time.Second):
1337 t.Fatal("opposite-direction move commands deadlocked")1337 t.Fatal("opposite-direction move commands deadlocked")
1338 }1338 }
@@ -1514,7 +1514,7 @@ func TestCloseTabParticipantsChangedReportsRetryable(t *testing.T) {
1514 t.Cleanup(func() { d.afterAttachmentEffectParticipantsSnapshotted = nil })1514 t.Cleanup(func() { d.afterAttachmentEffectParticipantsSnapshotted = nil })
1515 1515
1516 result := sendCommand(t, d, protocol.CommandRequest{Slug: "close-tab", TargetSession: "work"})1516 result := sendCommand(t, d, protocol.CommandRequest{Slug: "close-tab", TargetSession: "work"})
1517- require.False(t, result.OK)1517+ require.False(t, result.Outcome == protocol.CommandSucceeded)
1518 require.Equal(t, protocol.ErrInternal, result.Code)1518 require.Equal(t, protocol.ErrInternal, result.Code)
1519 require.Equal(t, sessionKillRetryMessage, result.Text)1519 require.Equal(t, sessionKillRetryMessage, result.Text)
1520 1520
M internal/usecase/daemon/daemon.go +1 -1
@@ -1263,7 +1263,7 @@ func (d *Daemon) handleInitialDecodeFailure(ctx context.Context, tr ports.Server
12631263 if failure.Version != 0 && failure.Version != protocol.Version {
12641264 code, text = protocol.ErrVersionMismatch, "protocol version mismatch"
12651265 }
1266- send(protocol.CommandResult{RequestID: failure.RequestID, Code: code, Text: text})
1266+ send(protocol.CommandResult{RequestID: failure.RequestID, Outcome: protocol.CommandFailed, Code: code, Text: text})
12671267 case protocol.DecodeMessageKill:
12681268 send(serverError(protocol.ErrInternal, "malformed kill request"))
12691269 case protocol.DecodeMessageRemotePreview:
@@ -1263,7 +1263,7 @@ func (d *Daemon) handleInitialDecodeFailure(ctx context.Context, tr ports.Server
1263 if failure.Version != 0 && failure.Version != protocol.Version {1263 if failure.Version != 0 && failure.Version != protocol.Version {
1264 code, text = protocol.ErrVersionMismatch, "protocol version mismatch"1264 code, text = protocol.ErrVersionMismatch, "protocol version mismatch"
1265 }1265 }
1266- send(protocol.CommandResult{RequestID: failure.RequestID, Code: code, Text: text})1266+ send(protocol.CommandResult{RequestID: failure.RequestID, Outcome: protocol.CommandFailed, Code: code, Text: text})
1267 case protocol.DecodeMessageKill:1267 case protocol.DecodeMessageKill:
1268 send(serverError(protocol.ErrInternal, "malformed kill request"))1268 send(serverError(protocol.ErrInternal, "malformed kill request"))
1269 case protocol.DecodeMessageRemotePreview:1269 case protocol.DecodeMessageRemotePreview:
M internal/usecase/daemon/move_rejection_test.go +1 -1
@@ -106,7 +106,7 @@ func TestMoveRejectionPresentationParity(t *testing.T) {
106106 require.Equal(t, tt.paletteText, userErr.Msg)
107107
108108 result := moveCommandFailure(tt.err)
109- require.False(t, result.OK)
109+ require.False(t, result.Outcome == protocol.CommandSucceeded)
110110 require.Equal(t, descriptor.CommandCode, result.Code)
111111 require.Equal(t, descriptor.CommandText, result.Text)
112112 require.Equal(t, protocol.ErrNoSuchTarget, result.Code)
@@ -106,7 +106,7 @@ func TestMoveRejectionPresentationParity(t *testing.T) {
106 require.Equal(t, tt.paletteText, userErr.Msg)106 require.Equal(t, tt.paletteText, userErr.Msg)
107 107
108 result := moveCommandFailure(tt.err)108 result := moveCommandFailure(tt.err)
109- require.False(t, result.OK)109+ require.False(t, result.Outcome == protocol.CommandSucceeded)
110 require.Equal(t, descriptor.CommandCode, result.Code)110 require.Equal(t, descriptor.CommandCode, result.Code)
111 require.Equal(t, descriptor.CommandText, result.Text)111 require.Equal(t, descriptor.CommandText, result.Text)
112 require.Equal(t, protocol.ErrNoSuchTarget, result.Code)112 require.Equal(t, protocol.ErrNoSuchTarget, result.Code)
M internal/usecase/daemon/purge_test.go +3 -3
@@ -235,7 +235,7 @@ func TestPurgeAdmissionGatesAndReopens(t *testing.T) {
235235 Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true,
236236 TargetSession: "work", TargetTab: "t_work", TargetPane: "p_work",
237237 })
238- require.False(t, rejected.OK, "a move during the purge must be rejected")
238+ require.False(t, rejected.Outcome == protocol.CommandSucceeded, "a move during the purge must be rejected")
239239
240240 d.endPurgeAdmission()
241241 require.NoError(t, awaitTestValue(t, created, "route did not resume after purge admission reopened"))
@@ -245,7 +245,7 @@ func TestPurgeAdmissionGatesAndReopens(t *testing.T) {
245245 Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true,
246246 TargetSession: "work", TargetTab: "t_work", TargetPane: "p_work",
247247 })
248- require.True(t, accepted.OK, accepted.Text)
248+ require.True(t, accepted.Outcome == protocol.CommandSucceeded, accepted.Text)
249249 }
250250
251251 // TestPurgeAllLinearizesConcurrentCreate races KillAll against a route create.
@@ -664,7 +664,7 @@ func TestCommandPathPurgeAdmissionMapsToRetryableError(t *testing.T) {
664664 Slug: "new-session", Args: []string{"fresh"}, Self: true,
665665 TargetSession: "work", TargetTab: "t_work", TargetPane: "p_work",
666666 })
667- require.False(t, result.OK)
667+ require.False(t, result.Outcome == protocol.CommandSucceeded)
668668 require.Equal(t, protocol.ErrInternal, result.Code)
669669 require.Equal(t, errPurgeAdmissionClosed.Error(), result.Text)
670670 d.endPurgeAdmission()
@@ -235,7 +235,7 @@ func TestPurgeAdmissionGatesAndReopens(t *testing.T) {
235 Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true,235 Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true,
236 TargetSession: "work", TargetTab: "t_work", TargetPane: "p_work",236 TargetSession: "work", TargetTab: "t_work", TargetPane: "p_work",
237 })237 })
238- require.False(t, rejected.OK, "a move during the purge must be rejected")238+ require.False(t, rejected.Outcome == protocol.CommandSucceeded, "a move during the purge must be rejected")
239 239
240 d.endPurgeAdmission()240 d.endPurgeAdmission()
241 require.NoError(t, awaitTestValue(t, created, "route did not resume after purge admission reopened"))241 require.NoError(t, awaitTestValue(t, created, "route did not resume after purge admission reopened"))
@@ -245,7 +245,7 @@ func TestPurgeAdmissionGatesAndReopens(t *testing.T) {
245 Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true,245 Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true,
246 TargetSession: "work", TargetTab: "t_work", TargetPane: "p_work",246 TargetSession: "work", TargetTab: "t_work", TargetPane: "p_work",
247 })247 })
248- require.True(t, accepted.OK, accepted.Text)248+ require.True(t, accepted.Outcome == protocol.CommandSucceeded, accepted.Text)
249 }249 }
250 250
251 // TestPurgeAllLinearizesConcurrentCreate races KillAll against a route create.251 // TestPurgeAllLinearizesConcurrentCreate races KillAll against a route create.
@@ -664,7 +664,7 @@ func TestCommandPathPurgeAdmissionMapsToRetryableError(t *testing.T) {
664 Slug: "new-session", Args: []string{"fresh"}, Self: true,664 Slug: "new-session", Args: []string{"fresh"}, Self: true,
665 TargetSession: "work", TargetTab: "t_work", TargetPane: "p_work",665 TargetSession: "work", TargetTab: "t_work", TargetPane: "p_work",
666 })666 })
667- require.False(t, result.OK)667+ require.False(t, result.Outcome == protocol.CommandSucceeded)
668 require.Equal(t, protocol.ErrInternal, result.Code)668 require.Equal(t, protocol.ErrInternal, result.Code)
669 require.Equal(t, errPurgeAdmissionClosed.Error(), result.Text)669 require.Equal(t, errPurgeAdmissionClosed.Error(), result.Text)
670 d.endPurgeAdmission()670 d.endPurgeAdmission()
M internal/usecase/daemon/resize_mode_test.go +3 -3
@@ -57,11 +57,11 @@ func TestResizeControlHeadlessAndErrors(t *testing.T) {
5757 sess := addControlSession(d, "work", "t_work", "p_work")
5858
5959 tooEarly := sendCommand(t, d, protocol.CommandRequest{Slug: "grow-pane-width", TargetSession: "work"})
60- require.False(t, tooEarly.OK)
60+ require.False(t, tooEarly.Outcome == protocol.CommandSucceeded)
6161 require.Equal(t, protocol.ErrNoSuchTarget, tooEarly.Code)
6262 require.Equal(t, "pane is not in a split", tooEarly.Text)
6363
64- require.True(t, sendCommand(t, d, protocol.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK)
64+ require.True(t, sendCommand(t, d, protocol.CommandRequest{Slug: "split-right", TargetSession: "work"}).Outcome == protocol.CommandSucceeded)
6565 tb := testAttachmentTab(sess)
6666 tb.mu.Lock()
6767 beforeFocus := tb.tree.Focus
@@ -88,7 +88,7 @@ func TestResizeControlHeadlessAndErrors(t *testing.T) {
8888 tb.mu.Unlock()
8989
9090 equalized := sendCommand(t, d, protocol.CommandRequest{Slug: "equalize-panes", TargetSession: "work"})
91- require.True(t, equalized.OK, equalized.Text)
91+ require.True(t, equalized.Outcome == protocol.CommandSucceeded, equalized.Text)
9292 require.Empty(t, equalized.Output)
9393 }
9494
@@ -57,11 +57,11 @@ func TestResizeControlHeadlessAndErrors(t *testing.T) {
57 sess := addControlSession(d, "work", "t_work", "p_work")57 sess := addControlSession(d, "work", "t_work", "p_work")
58 58
59 tooEarly := sendCommand(t, d, protocol.CommandRequest{Slug: "grow-pane-width", TargetSession: "work"})59 tooEarly := sendCommand(t, d, protocol.CommandRequest{Slug: "grow-pane-width", TargetSession: "work"})
60- require.False(t, tooEarly.OK)60+ require.False(t, tooEarly.Outcome == protocol.CommandSucceeded)
61 require.Equal(t, protocol.ErrNoSuchTarget, tooEarly.Code)61 require.Equal(t, protocol.ErrNoSuchTarget, tooEarly.Code)
62 require.Equal(t, "pane is not in a split", tooEarly.Text)62 require.Equal(t, "pane is not in a split", tooEarly.Text)
63 63
64- require.True(t, sendCommand(t, d, protocol.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK)64+ require.True(t, sendCommand(t, d, protocol.CommandRequest{Slug: "split-right", TargetSession: "work"}).Outcome == protocol.CommandSucceeded)
65 tb := testAttachmentTab(sess)65 tb := testAttachmentTab(sess)
66 tb.mu.Lock()66 tb.mu.Lock()
67 beforeFocus := tb.tree.Focus67 beforeFocus := tb.tree.Focus
@@ -88,7 +88,7 @@ func TestResizeControlHeadlessAndErrors(t *testing.T) {
88 tb.mu.Unlock()88 tb.mu.Unlock()
89 89
90 equalized := sendCommand(t, d, protocol.CommandRequest{Slug: "equalize-panes", TargetSession: "work"})90 equalized := sendCommand(t, d, protocol.CommandRequest{Slug: "equalize-panes", TargetSession: "work"})
91- require.True(t, equalized.OK, equalized.Text)91+ require.True(t, equalized.Outcome == protocol.CommandSucceeded, equalized.Text)
92 require.Empty(t, equalized.Output)92 require.Empty(t, equalized.Output)
93 }93 }
94 94
M internal/usecase/daemon/typed_connection_test.go +3 -3
@@ -335,7 +335,7 @@ func (d *Daemon) handleCommandFrame(connection ports.ServerConnection, envelope
335335 code = protocol.ErrVersionMismatch
336336 text = "protocol version mismatch"
337337 }
338- return d.sendCommandResult(connection, protocol.CommandResult{RequestID: failure.RequestID, Code: code, Text: text})
338+ return d.sendCommandResult(connection, protocol.CommandResult{RequestID: failure.RequestID, Outcome: protocol.CommandFailed, Code: code, Text: text})
339339 case protocol.DecodeMessageHello:
340340 if failure.Version != 0 && failure.Version != protocol.Version {
341341 code = protocol.ErrVersionMismatch
@@ -345,11 +345,11 @@ func (d *Daemon) handleCommandFrame(connection ports.ServerConnection, envelope
345345 }
346346 }
347347 }
348- return d.sendCommandResult(connection, protocol.CommandResult{Code: code, Text: text})
348+ return d.sendCommandResult(connection, protocol.CommandResult{Outcome: protocol.CommandFailed, Code: code, Text: text})
349349 }
350350 command, ok := request.(protocol.CommandRequest)
351351 if !ok {
352- return d.sendCommandResult(connection, protocol.CommandResult{Code: protocol.ErrInternal, Text: "expected command"})
352+ return d.sendCommandResult(connection, protocol.CommandResult{Outcome: protocol.CommandFailed, Code: protocol.ErrInternal, Text: "expected command"})
353353 }
354354 return d.handleCommand(connection, command)
355355 }
@@ -335,7 +335,7 @@ func (d *Daemon) handleCommandFrame(connection ports.ServerConnection, envelope
335 code = protocol.ErrVersionMismatch335 code = protocol.ErrVersionMismatch
336 text = "protocol version mismatch"336 text = "protocol version mismatch"
337 }337 }
338- return d.sendCommandResult(connection, protocol.CommandResult{RequestID: failure.RequestID, Code: code, Text: text})338+ return d.sendCommandResult(connection, protocol.CommandResult{RequestID: failure.RequestID, Outcome: protocol.CommandFailed, Code: code, Text: text})
339 case protocol.DecodeMessageHello:339 case protocol.DecodeMessageHello:
340 if failure.Version != 0 && failure.Version != protocol.Version {340 if failure.Version != 0 && failure.Version != protocol.Version {
341 code = protocol.ErrVersionMismatch341 code = protocol.ErrVersionMismatch
@@ -345,11 +345,11 @@ func (d *Daemon) handleCommandFrame(connection ports.ServerConnection, envelope
345 }345 }
346 }346 }
347 }347 }
348- return d.sendCommandResult(connection, protocol.CommandResult{Code: code, Text: text})348+ return d.sendCommandResult(connection, protocol.CommandResult{Outcome: protocol.CommandFailed, Code: code, Text: text})
349 }349 }
350 command, ok := request.(protocol.CommandRequest)350 command, ok := request.(protocol.CommandRequest)
351 if !ok {351 if !ok {
352- return d.sendCommandResult(connection, protocol.CommandResult{Code: protocol.ErrInternal, Text: "expected command"})352+ return d.sendCommandResult(connection, protocol.CommandResult{Outcome: protocol.CommandFailed, Code: protocol.ErrInternal, Text: "expected command"})
353 }353 }
354 return d.handleCommand(connection, command)354 return d.handleCommand(connection, command)
355 }355 }

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