@team-agent/installer 0.5.1 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +118 -9
- package/Cargo.toml +2 -2
- package/crates/team-agent/Cargo.toml +1 -0
- package/crates/team-agent/src/app_server_test_support.rs +258 -0
- package/crates/team-agent/src/cli/adapters.rs +32 -3
- package/crates/team-agent/src/cli/attach_app_server_leader.rs +16 -0
- package/crates/team-agent/src/cli/diagnose.rs +14 -5
- package/crates/team-agent/src/cli/emit.rs +74 -1
- package/crates/team-agent/src/cli/mod.rs +70 -25
- package/crates/team-agent/src/cli/named_address.rs +111 -0
- package/crates/team-agent/src/cli/send.rs +98 -3
- package/crates/team-agent/src/cli/status_port.rs +44 -6
- package/crates/team-agent/src/cli/tests/named_address.rs +131 -0
- package/crates/team-agent/src/cli/tests/run_delegation.rs +2 -0
- package/crates/team-agent/src/cli/types.rs +17 -0
- package/crates/team-agent/src/codex_app_server.rs +488 -0
- package/crates/team-agent/src/conpty/backend.rs +710 -0
- package/crates/team-agent/src/conpty/mod.rs +32 -0
- package/crates/team-agent/src/coordinator/backoff.rs +45 -6
- package/crates/team-agent/src/diagnose/orphans.rs +11 -3
- package/crates/team-agent/src/leader/lease.rs +144 -7
- package/crates/team-agent/src/leader/owner_bind.rs +5 -2
- package/crates/team-agent/src/leader/start.rs +18 -5
- package/crates/team-agent/src/leader/tests/lease_api.rs +172 -0
- package/crates/team-agent/src/lib.rs +23 -0
- package/crates/team-agent/src/lifecycle/launch.rs +127 -3
- package/crates/team-agent/src/lifecycle/restart/common.rs +70 -0
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +7 -0
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +63 -0
- package/crates/team-agent/src/messaging/delivery.rs +329 -9
- package/crates/team-agent/src/messaging/leader_receiver.rs +17 -138
- package/crates/team-agent/src/messaging/mod.rs +2 -2
- package/crates/team-agent/src/messaging/results.rs +78 -21
- package/crates/team-agent/src/messaging/tests/runtime.rs +232 -0
- package/crates/team-agent/src/tmux_backend.rs +31 -0
- package/crates/team-agent/src/transport_factory.rs +632 -0
- package/package.json +4 -4
|
@@ -0,0 +1,710 @@
|
|
|
1
|
+
//! `ConPtyBackend` — implements the existing `Transport` trait using the
|
|
2
|
+
//! named-pipe protocol in `super::protocol`.
|
|
3
|
+
//!
|
|
4
|
+
//! ## Design boundary (design.md §Transport Boundary:97-124)
|
|
5
|
+
//!
|
|
6
|
+
//! Every trait method has one of three flavours:
|
|
7
|
+
//!
|
|
8
|
+
//! - **Required**: forwards to the shim via `PipeClient::request`.
|
|
9
|
+
//! - **Exempt typed no-op**: e.g. `configure_adaptive_pane_title` — the
|
|
10
|
+
//! shim has no pane title so the method returns `Ok(())` locally.
|
|
11
|
+
//! - **Typed unsupported**: `attach_session` returns
|
|
12
|
+
//! `AttachOutcome::Unsupported { reason }` because ConPTY has no attach
|
|
13
|
+
//! concept (design.md:123-124; there is no `tmux attach`).
|
|
14
|
+
//!
|
|
15
|
+
//! ## Phase 1a scope
|
|
16
|
+
//!
|
|
17
|
+
//! This file lands the Transport impl **shape** + honest degradation
|
|
18
|
+
//! when no pipe client is connected. The pipe transport itself
|
|
19
|
+
//! (Windows named-pipe socket + real shim binary) lives in Phase 1b.
|
|
20
|
+
//!
|
|
21
|
+
//! When a `PipeClient` is not available (Mac dev host, tests, or a live
|
|
22
|
+
//! Windows host whose shim died), every trait method returns
|
|
23
|
+
//! `TransportError::MuxUnavailable { backend: BackendKind::ConPty, detail }`
|
|
24
|
+
//! — the same honest-fail path the coordinator uses today when a tmux
|
|
25
|
+
//! server is missing. This satisfies MUST-NOT-13 (do not silently
|
|
26
|
+
//! success) + CR C-3 (stale shim → honest degradation).
|
|
27
|
+
//!
|
|
28
|
+
//! ## CR anchors
|
|
29
|
+
//!
|
|
30
|
+
//! - **C-1 pipe_token**: only lives in `PipeClient` in-memory state.
|
|
31
|
+
//! `ConPtyBackend` is Send+Sync; the token field is behind
|
|
32
|
+
//! `parking_lot::Mutex` and is NEVER handed out via a getter that
|
|
33
|
+
//! could feed serialisation.
|
|
34
|
+
//! - **C-2 kill_server**: this backend's `kill_server` closes the
|
|
35
|
+
//! pipe + tells the shim to `Shutdown`. Semantics are per-workspace
|
|
36
|
+
//! (each `ConPtyBackend` owns exactly one team shim), so callers who
|
|
37
|
+
//! invoke `kill_server` in a `KillDecision::KillWholeServer` branch
|
|
38
|
+
//! still get the same "tear down this workspace's transport" effect
|
|
39
|
+
//! they get from tmux.
|
|
40
|
+
//! - **C-3 stale event**: when `state.shim_pid` disagrees with what
|
|
41
|
+
//! `hello` returns, the backend emits `conpty_transport.shim_pid_stale`
|
|
42
|
+
//! BEFORE returning `MuxUnavailable`. Handled in
|
|
43
|
+
//! `PipeClient::ensure_hello` (Phase 1b).
|
|
44
|
+
//! - **C-5 pipe_token_mismatch**: the `PipeClient::request` inspection
|
|
45
|
+
//! of the response's `ProtocolError::PipeTokenMismatch` translates
|
|
46
|
+
//! directly to `TransportError::MuxUnavailable`. The client MUST NOT
|
|
47
|
+
//! silently rotate its own token.
|
|
48
|
+
|
|
49
|
+
use std::collections::BTreeMap;
|
|
50
|
+
use std::path::Path;
|
|
51
|
+
use std::sync::atomic::{AtomicU64, Ordering};
|
|
52
|
+
|
|
53
|
+
use crate::model::enums::PaneLiveness;
|
|
54
|
+
use crate::transport::{
|
|
55
|
+
AttachOutcome, BackendKind, CaptureRange, CapturedText, InjectPayload, InjectReport,
|
|
56
|
+
InjectStage, InjectVerification, Key, PaneField, PaneId, PaneInfo, SessionName,
|
|
57
|
+
SetEnvOutcome, SpawnResult, SubmitVerification, Target, Transport, TransportError,
|
|
58
|
+
TurnVerification, WindowName,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
use conpty_transport::{
|
|
62
|
+
protocol, CaptureRequest, HelloResult, InjectRequest, Op, ProtocolError, Request, Response,
|
|
63
|
+
SpawnRequest, SpawnResult as ProtoSpawnResult,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/// The `Transport` implementation for the named ConPTY backend.
|
|
67
|
+
///
|
|
68
|
+
/// The pipe client is optional so this struct can be constructed on
|
|
69
|
+
/// non-Windows hosts and in tests without a live shim; all trait methods
|
|
70
|
+
/// return `MuxUnavailable` in that case (honest degradation, not silent
|
|
71
|
+
/// success).
|
|
72
|
+
pub struct ConPtyBackend {
|
|
73
|
+
/// Canonical workspace-hash + team-key key — used only for the
|
|
74
|
+
/// `MuxUnavailable` diagnostic detail so operators see which shim
|
|
75
|
+
/// was expected.
|
|
76
|
+
workspace_hash: String,
|
|
77
|
+
team_key: String,
|
|
78
|
+
/// In-memory pipe_token learned from `hello`. CR C-1: never
|
|
79
|
+
/// persisted. Stored behind std::sync::Mutex so the backend can
|
|
80
|
+
/// be `Send + Sync` and shared across threads.
|
|
81
|
+
pipe_token: std::sync::Mutex<Option<String>>,
|
|
82
|
+
/// Present when a `PipeClient` is wired. When present, all
|
|
83
|
+
/// Required trait methods forward through the client; when absent,
|
|
84
|
+
/// they degrade to `MuxUnavailable` honest-fail.
|
|
85
|
+
pipe_client: Option<Box<dyn PipeClientTrait>>,
|
|
86
|
+
/// Monotonic request id counter — never persisted, never surfaced
|
|
87
|
+
/// to callers.
|
|
88
|
+
request_counter: AtomicU64,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
impl ConPtyBackend {
|
|
92
|
+
/// New backend for `(workspace_hash, team_key)`. The pipe client is
|
|
93
|
+
/// left unset; callers wire it via `with_pipe_client`.
|
|
94
|
+
pub fn new(workspace_hash: impl Into<String>, team_key: impl Into<String>) -> Self {
|
|
95
|
+
Self {
|
|
96
|
+
workspace_hash: workspace_hash.into(),
|
|
97
|
+
team_key: team_key.into(),
|
|
98
|
+
pipe_token: std::sync::Mutex::new(None),
|
|
99
|
+
pipe_client: None,
|
|
100
|
+
request_counter: AtomicU64::new(0),
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/// Wire a live PipeClient. Immediately performs `hello` to negotiate
|
|
105
|
+
/// the pipe_token; on failure, drops the client so subsequent calls
|
|
106
|
+
/// degrade honestly.
|
|
107
|
+
pub fn with_pipe_client(
|
|
108
|
+
mut self,
|
|
109
|
+
client: Box<dyn PipeClientTrait>,
|
|
110
|
+
) -> Result<Self, TransportError> {
|
|
111
|
+
// Hello does NOT require token pre-match; use a placeholder.
|
|
112
|
+
let req = Request::new(
|
|
113
|
+
self.next_request_id(),
|
|
114
|
+
&self.workspace_hash,
|
|
115
|
+
&self.team_key,
|
|
116
|
+
"PENDING",
|
|
117
|
+
Op::Hello,
|
|
118
|
+
);
|
|
119
|
+
let resp = client.request(&req)?;
|
|
120
|
+
if !resp.ok {
|
|
121
|
+
return Err(TransportError::MuxUnavailable {
|
|
122
|
+
backend: BackendKind::ConPty,
|
|
123
|
+
detail: format!("hello failed: {:?}", resp.error),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
let hello: HelloResult = serde_json::from_value(resp.result)
|
|
127
|
+
.map_err(|e| TransportError::MuxUnavailable {
|
|
128
|
+
backend: BackendKind::ConPty,
|
|
129
|
+
detail: format!("hello response malformed: {e}"),
|
|
130
|
+
})?;
|
|
131
|
+
*self.pipe_token.lock().unwrap() = Some(hello.pipe_token);
|
|
132
|
+
self.pipe_client = Some(client);
|
|
133
|
+
Ok(self)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
fn next_request_id(&self) -> String {
|
|
137
|
+
let n = self.request_counter.fetch_add(1, Ordering::Relaxed);
|
|
138
|
+
format!("req-{n}")
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
fn build_request(&self, op: Op, payload: serde_json::Value) -> Result<Request, TransportError> {
|
|
142
|
+
let token = self
|
|
143
|
+
.pipe_token
|
|
144
|
+
.lock()
|
|
145
|
+
.unwrap()
|
|
146
|
+
.clone()
|
|
147
|
+
.ok_or_else(|| self.mux_unavailable("hello_never_completed"))?;
|
|
148
|
+
Ok(Request::new(
|
|
149
|
+
self.next_request_id(),
|
|
150
|
+
&self.workspace_hash,
|
|
151
|
+
&self.team_key,
|
|
152
|
+
token,
|
|
153
|
+
op,
|
|
154
|
+
)
|
|
155
|
+
.with_payload(payload))
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
fn dispatch(&self, op: Op, payload: serde_json::Value) -> Result<Response, TransportError> {
|
|
159
|
+
let Some(client) = self.pipe_client.as_ref() else {
|
|
160
|
+
return Err(self.mux_unavailable(&format!("{op:?}").to_lowercase()));
|
|
161
|
+
};
|
|
162
|
+
let req = self.build_request(op, payload)?;
|
|
163
|
+
let resp = client.request(&req)?;
|
|
164
|
+
if !resp.ok {
|
|
165
|
+
return Err(map_protocol_error(resp.error.as_ref()));
|
|
166
|
+
}
|
|
167
|
+
Ok(resp)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
fn do_spawn(
|
|
171
|
+
&self,
|
|
172
|
+
session: &SessionName,
|
|
173
|
+
window: &WindowName,
|
|
174
|
+
argv: &[String],
|
|
175
|
+
cwd: &Path,
|
|
176
|
+
env: &BTreeMap<String, String>,
|
|
177
|
+
env_unset: &[String],
|
|
178
|
+
) -> Result<SpawnResult, TransportError> {
|
|
179
|
+
let payload = serde_json::to_value(SpawnRequest {
|
|
180
|
+
session: session.as_str().to_string(),
|
|
181
|
+
window: window.as_str().to_string(),
|
|
182
|
+
argv: argv.to_vec(),
|
|
183
|
+
cwd: cwd.to_string_lossy().to_string(),
|
|
184
|
+
env: env.clone(),
|
|
185
|
+
env_unset: env_unset.to_vec(),
|
|
186
|
+
cols: 120,
|
|
187
|
+
rows: 30,
|
|
188
|
+
})
|
|
189
|
+
.map_err(|e| TransportError::Spawn {
|
|
190
|
+
backend: BackendKind::ConPty,
|
|
191
|
+
source: std::io::Error::other(e),
|
|
192
|
+
})?;
|
|
193
|
+
let resp = self.dispatch(Op::Spawn, payload)?;
|
|
194
|
+
let spawn: ProtoSpawnResult = serde_json::from_value(resp.result).map_err(|e| {
|
|
195
|
+
TransportError::Spawn {
|
|
196
|
+
backend: BackendKind::ConPty,
|
|
197
|
+
source: std::io::Error::other(e),
|
|
198
|
+
}
|
|
199
|
+
})?;
|
|
200
|
+
Ok(SpawnResult {
|
|
201
|
+
pane_id: PaneId::new(spawn.pane_id),
|
|
202
|
+
session: SessionName::new(spawn.session),
|
|
203
|
+
window: WindowName::new(spawn.window),
|
|
204
|
+
child_pid: spawn.child_pid,
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/// Build a `MuxUnavailable` error explaining that no pipe client is
|
|
209
|
+
/// wired for this backend. The detail string carries the
|
|
210
|
+
/// `(workspace_hash, team_key)` so operators can correlate with
|
|
211
|
+
/// state.json's `transport.pipe_name`.
|
|
212
|
+
fn mux_unavailable(&self, op: &str) -> TransportError {
|
|
213
|
+
TransportError::MuxUnavailable {
|
|
214
|
+
backend: BackendKind::ConPty,
|
|
215
|
+
detail: format!(
|
|
216
|
+
"no_pipe_client (op={op}, workspace_hash={ws}, team_key={team}); \
|
|
217
|
+
no live shim wired — this is honest degradation, not silent success",
|
|
218
|
+
ws = self.workspace_hash,
|
|
219
|
+
team = self.team_key,
|
|
220
|
+
),
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/// Resolve a `Target` to a pane_id string for the shim protocol.
|
|
226
|
+
/// SessionWindow targets look up via `list_targets` are not supported
|
|
227
|
+
/// at the trait boundary today — the coordinator delivery path always
|
|
228
|
+
/// resolves worker IDs to `Target::Pane` before injection. If a
|
|
229
|
+
/// SessionWindow slips in, return a `TargetNotFound`.
|
|
230
|
+
fn pane_id_from_target(target: &Target) -> Result<String, TransportError> {
|
|
231
|
+
match target {
|
|
232
|
+
Target::Pane(p) => Ok(p.as_str().to_string()),
|
|
233
|
+
Target::SessionWindow { session, window } => Err(TransportError::TargetNotFound {
|
|
234
|
+
target: format!(
|
|
235
|
+
"conpty backend requires Target::Pane; got SessionWindow({}, {})",
|
|
236
|
+
session.as_str(),
|
|
237
|
+
window.as_str()
|
|
238
|
+
),
|
|
239
|
+
}),
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/// Map a `ProtocolError` returned by the shim to a `TransportError`
|
|
244
|
+
/// per design §Request Shape:332-338 + CR C-5.
|
|
245
|
+
fn map_protocol_error(err: Option<&ProtocolError>) -> TransportError {
|
|
246
|
+
let Some(err) = err else {
|
|
247
|
+
return TransportError::MuxUnavailable {
|
|
248
|
+
backend: BackendKind::ConPty,
|
|
249
|
+
detail: "shim returned ok=false without error payload".to_string(),
|
|
250
|
+
};
|
|
251
|
+
};
|
|
252
|
+
match err {
|
|
253
|
+
// CR C-5: token mismatch is honest MuxUnavailable — never silent
|
|
254
|
+
// retry with a rotated token.
|
|
255
|
+
ProtocolError::PipeTokenMismatch { message } => TransportError::MuxUnavailable {
|
|
256
|
+
backend: BackendKind::ConPty,
|
|
257
|
+
detail: format!("pipe_token_mismatch: {message}"),
|
|
258
|
+
},
|
|
259
|
+
ProtocolError::SchemaSkew { message, sent, expected } => TransportError::MuxUnavailable {
|
|
260
|
+
backend: BackendKind::ConPty,
|
|
261
|
+
detail: format!("schema_skew sent={sent} expected={expected}: {message}"),
|
|
262
|
+
},
|
|
263
|
+
ProtocolError::TargetNotFound { message } => {
|
|
264
|
+
TransportError::TargetNotFound { target: message.clone() }
|
|
265
|
+
}
|
|
266
|
+
ProtocolError::Spawn { message } => TransportError::Spawn {
|
|
267
|
+
backend: BackendKind::ConPty,
|
|
268
|
+
source: std::io::Error::other(message.clone()),
|
|
269
|
+
},
|
|
270
|
+
ProtocolError::Inject { message, stage } => TransportError::Inject {
|
|
271
|
+
stage: match stage.as_str() {
|
|
272
|
+
"text" | "load_buffer" => InjectStage::LoadBuffer,
|
|
273
|
+
_ => InjectStage::Submit,
|
|
274
|
+
},
|
|
275
|
+
source: std::io::Error::other(message.clone()),
|
|
276
|
+
},
|
|
277
|
+
ProtocolError::Capture { message } => TransportError::Capture {
|
|
278
|
+
source: std::io::Error::other(message.clone()),
|
|
279
|
+
},
|
|
280
|
+
ProtocolError::ShimUnavailable { message } | ProtocolError::Other { message } => {
|
|
281
|
+
TransportError::MuxUnavailable {
|
|
282
|
+
backend: BackendKind::ConPty,
|
|
283
|
+
detail: message.clone(),
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/// Object-safe pipe-client trait so `ConPtyBackend` can hold a boxed
|
|
290
|
+
/// client without depending on the concrete Windows named-pipe type.
|
|
291
|
+
/// Phase 1b implements this against a real `\\.\pipe\...` handle;
|
|
292
|
+
/// Phase 1a tests can implement it against an in-memory
|
|
293
|
+
/// `Cursor<Vec<u8>>` pair.
|
|
294
|
+
///
|
|
295
|
+
/// The `request` method returns the raw `Response`; the backend layer
|
|
296
|
+
/// maps errors into `TransportError`.
|
|
297
|
+
pub trait PipeClientTrait: Send + Sync {
|
|
298
|
+
fn request(&self, req: &Request) -> Result<Response, TransportError>;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/// Wrap a `conpty_transport::PipeClient` (which returns `Response`
|
|
302
|
+
/// directly) into a `PipeClientTrait` (which returns
|
|
303
|
+
/// `Result<Response, TransportError>`). Used by team-agent's fake-worker
|
|
304
|
+
/// path to plug in the portable `LocalShimClient`.
|
|
305
|
+
pub struct PipeClientAdapter<C: conpty_transport::PipeClient>(pub C);
|
|
306
|
+
|
|
307
|
+
impl<C: conpty_transport::PipeClient> PipeClientTrait for PipeClientAdapter<C> {
|
|
308
|
+
fn request(&self, req: &Request) -> Result<Response, TransportError> {
|
|
309
|
+
Ok(self.0.request(req))
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
impl Transport for ConPtyBackend {
|
|
314
|
+
fn kind(&self) -> BackendKind {
|
|
315
|
+
BackendKind::ConPty
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
fn probes_real_tmux_socket_roots(&self) -> bool {
|
|
319
|
+
// Design §Transport Boundary:102 — ConPTY exposes pipe name via
|
|
320
|
+
// a separate diagnostic field, NOT this tmux method.
|
|
321
|
+
false
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
fn tmux_endpoint(&self) -> Option<String> {
|
|
325
|
+
// Same — pipe name is exposed elsewhere, not here.
|
|
326
|
+
None
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
fn spawn_first(
|
|
330
|
+
&self,
|
|
331
|
+
session: &SessionName,
|
|
332
|
+
window: &WindowName,
|
|
333
|
+
argv: &[String],
|
|
334
|
+
cwd: &Path,
|
|
335
|
+
env: &BTreeMap<String, String>,
|
|
336
|
+
) -> Result<SpawnResult, TransportError> {
|
|
337
|
+
self.do_spawn(session, window, argv, cwd, env, &[])
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
fn spawn_into(
|
|
341
|
+
&self,
|
|
342
|
+
session: &SessionName,
|
|
343
|
+
window: &WindowName,
|
|
344
|
+
argv: &[String],
|
|
345
|
+
cwd: &Path,
|
|
346
|
+
env: &BTreeMap<String, String>,
|
|
347
|
+
) -> Result<SpawnResult, TransportError> {
|
|
348
|
+
self.do_spawn(session, window, argv, cwd, env, &[])
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
fn spawn_first_with_env_unset(
|
|
352
|
+
&self,
|
|
353
|
+
session: &SessionName,
|
|
354
|
+
window: &WindowName,
|
|
355
|
+
argv: &[String],
|
|
356
|
+
cwd: &Path,
|
|
357
|
+
env: &BTreeMap<String, String>,
|
|
358
|
+
env_unset: &[String],
|
|
359
|
+
) -> Result<SpawnResult, TransportError> {
|
|
360
|
+
self.do_spawn(session, window, argv, cwd, env, env_unset)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
fn spawn_into_with_env_unset(
|
|
364
|
+
&self,
|
|
365
|
+
session: &SessionName,
|
|
366
|
+
window: &WindowName,
|
|
367
|
+
argv: &[String],
|
|
368
|
+
cwd: &Path,
|
|
369
|
+
env: &BTreeMap<String, String>,
|
|
370
|
+
env_unset: &[String],
|
|
371
|
+
) -> Result<SpawnResult, TransportError> {
|
|
372
|
+
self.do_spawn(session, window, argv, cwd, env, env_unset)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
fn inject(
|
|
376
|
+
&self,
|
|
377
|
+
target: &Target,
|
|
378
|
+
payload: &InjectPayload,
|
|
379
|
+
submit: Key,
|
|
380
|
+
_bracketed: bool,
|
|
381
|
+
) -> Result<InjectReport, TransportError> {
|
|
382
|
+
let pane_id = pane_id_from_target(target)?;
|
|
383
|
+
let text = payload.text().unwrap_or("").to_string();
|
|
384
|
+
let submit_key = match submit {
|
|
385
|
+
Key::Enter => Some("enter".to_string()),
|
|
386
|
+
Key::Up => Some("up".to_string()),
|
|
387
|
+
Key::Down => Some("down".to_string()),
|
|
388
|
+
Key::Left => Some("left".to_string()),
|
|
389
|
+
Key::Right => Some("right".to_string()),
|
|
390
|
+
Key::Escape => Some("escape".to_string()),
|
|
391
|
+
Key::CtrlC => Some("ctrl_c".to_string()),
|
|
392
|
+
Key::Char(_) | Key::CancelMode => None,
|
|
393
|
+
};
|
|
394
|
+
let payload_json = serde_json::to_value(InjectRequest {
|
|
395
|
+
pane_id,
|
|
396
|
+
text,
|
|
397
|
+
submit_key,
|
|
398
|
+
bracketed: _bracketed,
|
|
399
|
+
})
|
|
400
|
+
.map_err(|e| TransportError::Inject {
|
|
401
|
+
stage: InjectStage::LoadBuffer,
|
|
402
|
+
source: std::io::Error::other(e),
|
|
403
|
+
})?;
|
|
404
|
+
self.dispatch(Op::Inject, payload_json)?;
|
|
405
|
+
// Design §SubmitVerification:346 — text + CR = EnterSentWithoutPlaceholderCheck.
|
|
406
|
+
Ok(InjectReport {
|
|
407
|
+
stage_reached: InjectStage::Submit,
|
|
408
|
+
inject_verification: if matches!(payload, InjectPayload::Empty) {
|
|
409
|
+
InjectVerification::NoToken
|
|
410
|
+
} else {
|
|
411
|
+
InjectVerification::CaptureContainsToken
|
|
412
|
+
},
|
|
413
|
+
submit_verification: match submit {
|
|
414
|
+
Key::Enter => SubmitVerification::EnterSentWithoutPlaceholderCheck,
|
|
415
|
+
other => SubmitVerification::KeySentAfterVisibleToken { key: other },
|
|
416
|
+
},
|
|
417
|
+
turn_verification: TurnVerification::NotYetObserved,
|
|
418
|
+
attempts: 1,
|
|
419
|
+
submit_diagnostics: None,
|
|
420
|
+
})
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
fn send_keys(&self, _target: &Target, _keys: &[Key]) -> Result<(), TransportError> {
|
|
424
|
+
self.dispatch(Op::SendKeys, serde_json::Value::Null)?;
|
|
425
|
+
Ok(())
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
fn capture(
|
|
429
|
+
&self,
|
|
430
|
+
target: &Target,
|
|
431
|
+
range: CaptureRange,
|
|
432
|
+
) -> Result<CapturedText, TransportError> {
|
|
433
|
+
let pane_id = pane_id_from_target(target)?;
|
|
434
|
+
let range_str = match range {
|
|
435
|
+
CaptureRange::Full => "full".to_string(),
|
|
436
|
+
CaptureRange::Head(n) => format!("head:{n}"),
|
|
437
|
+
CaptureRange::Tail(n) => format!("tail:{n}"),
|
|
438
|
+
};
|
|
439
|
+
let payload = serde_json::to_value(CaptureRequest {
|
|
440
|
+
pane_id,
|
|
441
|
+
range: range_str,
|
|
442
|
+
})
|
|
443
|
+
.map_err(|e| TransportError::Capture {
|
|
444
|
+
source: std::io::Error::other(e),
|
|
445
|
+
})?;
|
|
446
|
+
let resp = self.dispatch(Op::Capture, payload)?;
|
|
447
|
+
let cap: protocol::CaptureResult = serde_json::from_value(resp.result).map_err(|e| {
|
|
448
|
+
TransportError::Capture {
|
|
449
|
+
source: std::io::Error::other(e),
|
|
450
|
+
}
|
|
451
|
+
})?;
|
|
452
|
+
Ok(CapturedText {
|
|
453
|
+
text: cap.text,
|
|
454
|
+
range,
|
|
455
|
+
})
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
fn query(&self, _target: &Target, _field: PaneField) -> Result<Option<String>, TransportError> {
|
|
459
|
+
Ok(None)
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
fn liveness(&self, pane: &PaneId) -> Result<PaneLiveness, TransportError> {
|
|
463
|
+
let resp = self.dispatch(
|
|
464
|
+
Op::Liveness,
|
|
465
|
+
serde_json::json!({"pane_id": pane.as_str()}),
|
|
466
|
+
)?;
|
|
467
|
+
let known = resp.result["known"].as_bool().unwrap_or(false);
|
|
468
|
+
let alive = resp.result["alive"].as_bool().unwrap_or(false);
|
|
469
|
+
Ok(match (known, alive) {
|
|
470
|
+
(true, true) => PaneLiveness::Live,
|
|
471
|
+
(true, false) => PaneLiveness::Dead,
|
|
472
|
+
_ => PaneLiveness::Unknown,
|
|
473
|
+
})
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
fn list_targets(&self) -> Result<Vec<PaneInfo>, TransportError> {
|
|
477
|
+
let resp = self.dispatch(Op::ListTargets, serde_json::Value::Null)?;
|
|
478
|
+
let empty = vec![];
|
|
479
|
+
let list = resp.result["targets"].as_array().unwrap_or(&empty);
|
|
480
|
+
Ok(list
|
|
481
|
+
.iter()
|
|
482
|
+
.map(|row| PaneInfo {
|
|
483
|
+
pane_id: PaneId::new(row["pane_id"].as_str().unwrap_or("").to_string()),
|
|
484
|
+
session: SessionName::new(row["session"].as_str().unwrap_or("").to_string()),
|
|
485
|
+
window_index: None,
|
|
486
|
+
window_name: row["window"]
|
|
487
|
+
.as_str()
|
|
488
|
+
.map(|w| WindowName::new(w.to_string())),
|
|
489
|
+
pane_index: None,
|
|
490
|
+
tty: None,
|
|
491
|
+
current_command: None,
|
|
492
|
+
current_path: None,
|
|
493
|
+
active: true,
|
|
494
|
+
pane_pid: row["child_pid"].as_u64().map(|p| p as u32),
|
|
495
|
+
leader_env: BTreeMap::new(),
|
|
496
|
+
})
|
|
497
|
+
.collect())
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
fn has_session(&self, session: &SessionName) -> Result<bool, TransportError> {
|
|
501
|
+
let resp = self.dispatch(
|
|
502
|
+
Op::HasSession,
|
|
503
|
+
serde_json::json!({"session": session.as_str()}),
|
|
504
|
+
)?;
|
|
505
|
+
Ok(resp.result["present"].as_bool().unwrap_or(false))
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
fn list_windows(&self, session: &SessionName) -> Result<Vec<WindowName>, TransportError> {
|
|
509
|
+
let resp = self.dispatch(
|
|
510
|
+
Op::ListWindows,
|
|
511
|
+
serde_json::json!({"session": session.as_str()}),
|
|
512
|
+
)?;
|
|
513
|
+
let empty = vec![];
|
|
514
|
+
let list = resp.result["windows"].as_array().unwrap_or(&empty);
|
|
515
|
+
Ok(list
|
|
516
|
+
.iter()
|
|
517
|
+
.filter_map(|v| v.as_str())
|
|
518
|
+
.map(|s| WindowName::new(s.to_string()))
|
|
519
|
+
.collect())
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
fn set_session_env(
|
|
523
|
+
&self,
|
|
524
|
+
_session: &SessionName,
|
|
525
|
+
_key: &str,
|
|
526
|
+
_value: &str,
|
|
527
|
+
) -> Result<SetEnvOutcome, TransportError> {
|
|
528
|
+
// Design §Transport Boundary:118 — ConPTY internalises env at
|
|
529
|
+
// spawn time; post-spawn mutation is NOT supported. This is a
|
|
530
|
+
// typed capability refusal, NOT an error, so it returns
|
|
531
|
+
// `Ok(InternalizedAtSpawn)` even without a live pipe client.
|
|
532
|
+
Ok(SetEnvOutcome::InternalizedAtSpawn)
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
fn has_pane(&self, pane: &PaneId) -> Result<Option<bool>, TransportError> {
|
|
536
|
+
let resp = self.dispatch(
|
|
537
|
+
Op::HasPane,
|
|
538
|
+
serde_json::json!({"pane_id": pane.as_str()}),
|
|
539
|
+
)?;
|
|
540
|
+
Ok(Some(resp.result["present"].as_bool().unwrap_or(false)))
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
fn kill_server(&self) -> Result<(), TransportError> {
|
|
544
|
+
// CR C-2: kill_server on ConPTY = shutdown this workspace's
|
|
545
|
+
// shim (per-team, NOT global). Existing tmux callers already
|
|
546
|
+
// gate the invocation on a KillDecision::KillWholeServer
|
|
547
|
+
// branch that scopes to the caller's transport instance, so
|
|
548
|
+
// per-workspace semantics naturally hold here.
|
|
549
|
+
self.dispatch(Op::Shutdown, serde_json::Value::Null)?;
|
|
550
|
+
Ok(())
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
fn kill_session(&self, session: &SessionName) -> Result<(), TransportError> {
|
|
554
|
+
self.dispatch(
|
|
555
|
+
Op::KillSession,
|
|
556
|
+
serde_json::json!({"session": session.as_str()}),
|
|
557
|
+
)?;
|
|
558
|
+
Ok(())
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
fn kill_window(&self, target: &Target) -> Result<(), TransportError> {
|
|
562
|
+
match target {
|
|
563
|
+
Target::Pane(pane) => {
|
|
564
|
+
self.dispatch(
|
|
565
|
+
Op::KillPane,
|
|
566
|
+
serde_json::json!({"pane_id": pane.as_str()}),
|
|
567
|
+
)?;
|
|
568
|
+
}
|
|
569
|
+
Target::SessionWindow { session, window } => {
|
|
570
|
+
self.dispatch(
|
|
571
|
+
Op::KillWindow,
|
|
572
|
+
serde_json::json!({
|
|
573
|
+
"session": session.as_str(),
|
|
574
|
+
"window": window.as_str(),
|
|
575
|
+
}),
|
|
576
|
+
)?;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
Ok(())
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
fn kill_pane(&self, pane: &PaneId) -> Result<(), TransportError> {
|
|
583
|
+
self.dispatch(
|
|
584
|
+
Op::KillPane,
|
|
585
|
+
serde_json::json!({"pane_id": pane.as_str()}),
|
|
586
|
+
)?;
|
|
587
|
+
Ok(())
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
fn attach_session(&self, _session: &SessionName) -> Result<AttachOutcome, TransportError> {
|
|
591
|
+
// Design §Transport Boundary:123 + §Visibility Without tmux attach:374
|
|
592
|
+
// — ConPTY has NO attach concept. Typed refusal, not error.
|
|
593
|
+
// This is honest and matches the tmux-only method's semantics.
|
|
594
|
+
Ok(AttachOutcome::Unsupported {
|
|
595
|
+
reason: "ConPTY worker backend has no interactive attach; \
|
|
596
|
+
use `team-agent capture` or the optional \
|
|
597
|
+
`windows-view` (Phase 5) for scrollback."
|
|
598
|
+
.to_string(),
|
|
599
|
+
})
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
#[cfg(test)]
|
|
604
|
+
mod tests {
|
|
605
|
+
use super::*;
|
|
606
|
+
|
|
607
|
+
#[test]
|
|
608
|
+
fn kind_is_conpty() {
|
|
609
|
+
let b = ConPtyBackend::new("wshash", "team-a");
|
|
610
|
+
assert_eq!(b.kind(), BackendKind::ConPty);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
#[test]
|
|
614
|
+
fn tmux_specific_methods_return_none_or_false() {
|
|
615
|
+
let b = ConPtyBackend::new("wshash", "team-a");
|
|
616
|
+
// Design §Transport Boundary:102 — ConPTY MUST NOT lie by
|
|
617
|
+
// reporting a fake tmux socket.
|
|
618
|
+
assert_eq!(b.tmux_endpoint(), None);
|
|
619
|
+
assert!(!b.probes_real_tmux_socket_roots());
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
#[test]
|
|
623
|
+
fn attach_session_returns_typed_unsupported() {
|
|
624
|
+
// Design §Transport Boundary:123 anchor. The refusal is
|
|
625
|
+
// `AttachOutcome::Unsupported`, NOT a `TransportError`.
|
|
626
|
+
let b = ConPtyBackend::new("wshash", "team-a");
|
|
627
|
+
let out = b.attach_session(&SessionName::new("s")).unwrap();
|
|
628
|
+
match out {
|
|
629
|
+
AttachOutcome::Unsupported { reason } => {
|
|
630
|
+
assert!(!reason.is_empty(), "reason must be non-empty");
|
|
631
|
+
}
|
|
632
|
+
other => panic!("expected Unsupported, got {other:?}"),
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
#[test]
|
|
637
|
+
fn set_session_env_returns_internalized_at_spawn() {
|
|
638
|
+
// Design §Transport Boundary:118. Typed refusal, not error.
|
|
639
|
+
let b = ConPtyBackend::new("wshash", "team-a");
|
|
640
|
+
let out = b
|
|
641
|
+
.set_session_env(&SessionName::new("s"), "KEY", "value")
|
|
642
|
+
.unwrap();
|
|
643
|
+
assert_eq!(out, SetEnvOutcome::InternalizedAtSpawn);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
#[test]
|
|
647
|
+
fn no_pipe_client_returns_honest_mux_unavailable_not_success() {
|
|
648
|
+
// MUST-NOT-13 + CR C-3: with no pipe client wired we must NOT
|
|
649
|
+
// silently pretend the spawn/inject/capture succeeded.
|
|
650
|
+
//
|
|
651
|
+
// Each backend method dispatches through the underlying protocol
|
|
652
|
+
// `Op::*`; when no client is wired the `mux_unavailable` diag
|
|
653
|
+
// records the lowercased op discriminant ("spawn", "inject",
|
|
654
|
+
// etc.). Every method must therefore surface a distinct-shape
|
|
655
|
+
// MuxUnavailable, not silently swallow the request.
|
|
656
|
+
let b = ConPtyBackend::new("wshash", "team-a");
|
|
657
|
+
let errs = [
|
|
658
|
+
b.spawn_first(
|
|
659
|
+
&SessionName::new("s"),
|
|
660
|
+
&WindowName::new("w"),
|
|
661
|
+
&[],
|
|
662
|
+
Path::new("/tmp"),
|
|
663
|
+
&BTreeMap::new(),
|
|
664
|
+
)
|
|
665
|
+
.unwrap_err(),
|
|
666
|
+
b.spawn_into(
|
|
667
|
+
&SessionName::new("s"),
|
|
668
|
+
&WindowName::new("w"),
|
|
669
|
+
&[],
|
|
670
|
+
Path::new("/tmp"),
|
|
671
|
+
&BTreeMap::new(),
|
|
672
|
+
)
|
|
673
|
+
.unwrap_err(),
|
|
674
|
+
b.inject(
|
|
675
|
+
&Target::Pane(PaneId::new("conpty:test")),
|
|
676
|
+
&InjectPayload::Empty,
|
|
677
|
+
Key::Enter,
|
|
678
|
+
false,
|
|
679
|
+
)
|
|
680
|
+
.unwrap_err(),
|
|
681
|
+
b.capture(
|
|
682
|
+
&Target::Pane(PaneId::new("conpty:test")),
|
|
683
|
+
CaptureRange::Tail(80),
|
|
684
|
+
)
|
|
685
|
+
.unwrap_err(),
|
|
686
|
+
b.list_targets().unwrap_err(),
|
|
687
|
+
b.kill_session(&SessionName::new("s")).unwrap_err(),
|
|
688
|
+
];
|
|
689
|
+
for err in errs {
|
|
690
|
+
match err {
|
|
691
|
+
TransportError::MuxUnavailable { backend, detail } => {
|
|
692
|
+
assert_eq!(backend, BackendKind::ConPty);
|
|
693
|
+
assert!(
|
|
694
|
+
detail.contains("no_pipe_client"),
|
|
695
|
+
"detail must anchor on `no_pipe_client` tag, got {detail}"
|
|
696
|
+
);
|
|
697
|
+
assert!(
|
|
698
|
+
detail.contains("workspace_hash=wshash"),
|
|
699
|
+
"detail must include workspace_hash, got {detail}"
|
|
700
|
+
);
|
|
701
|
+
assert!(
|
|
702
|
+
detail.contains("team_key=team-a"),
|
|
703
|
+
"detail must include team_key, got {detail}"
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
other => panic!("expected MuxUnavailable, got {other:?}"),
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|