@team-agent/installer 0.5.0 → 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 +111 -3
- package/crates/team-agent/src/cli/mod.rs +72 -25
- package/crates/team-agent/src/cli/named_address.rs +975 -0
- package/crates/team-agent/src/cli/send.rs +200 -1
- package/crates/team-agent/src/cli/status_port.rs +44 -6
- package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -0
- package/crates/team-agent/src/cli/tests/mod.rs +1 -0
- package/crates/team-agent/src/cli/tests/named_address.rs +586 -0
- package/crates/team-agent/src/cli/tests/run_delegation.rs +2 -0
- package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
- package/crates/team-agent/src/cli/types.rs +21 -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 +90 -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,632 @@
|
|
|
1
|
+
//! 0.5.x Phase 1d Batch 0: backend assembly factory.
|
|
2
|
+
//!
|
|
3
|
+
//! Truth sources (READ-ONLY):
|
|
4
|
+
//! - Design: `.team/artifacts/0.5.x-backend-assembly-factory-design.md`
|
|
5
|
+
//! - CR verdict: `.team/artifacts/0.5.x-backend-factory-cr-verdict.md`
|
|
6
|
+
//! (6 constraints; every one anchors into this module)
|
|
7
|
+
//!
|
|
8
|
+
//! ## Scope of Batch 0 (this commit)
|
|
9
|
+
//!
|
|
10
|
+
//! - Add the factory type + resolver ONLY. Zero caller migration.
|
|
11
|
+
//! - Ship the 5 independent tests the design lists (§Batch 0
|
|
12
|
+
//! Verification) + the C-1 grep guard (resolver has no
|
|
13
|
+
//! `if conpty|pty { .. tmux .. }` fallback branch) + the C-5 grep
|
|
14
|
+
//! guard (this file must never call `kill_server`, emit stale
|
|
15
|
+
//! events, or start a shim).
|
|
16
|
+
//! - C-6 gate: Batch 0 tests + guards must be GREEN before Batch 1
|
|
17
|
+
//! moves callers. This commit is the Batch 0 half; the very next
|
|
18
|
+
//! commit is Batch 1.
|
|
19
|
+
//!
|
|
20
|
+
//! ## CR anchors
|
|
21
|
+
//!
|
|
22
|
+
//! - **C-1** fail-closed × 2:
|
|
23
|
+
//! ① explicit `conpty` on an unsupported host / missing shim
|
|
24
|
+
//! surfaces `MuxUnavailable` — NEVER silent-downgrade to tmux.
|
|
25
|
+
//! ② `spec.runtime.backend = "pty"` returns
|
|
26
|
+
//! `UnsupportedSpecBackendLiteral` — NEVER auto-map ConPTY.
|
|
27
|
+
//! - **C-2** CLI override vs `state.transport.kind`: if both are set
|
|
28
|
+
//! and disagree, the factory REFUSES with `BackendConflict` (unless
|
|
29
|
+
//! fresh_switch is explicitly true — the caller path threads this
|
|
30
|
+
//! through when the user passes `--fresh`).
|
|
31
|
+
//! - **C-3** two-source conflict N38 events: when the resolver chooses
|
|
32
|
+
//! a backend and there's a shadow source that was ignored, the
|
|
33
|
+
//! `ResolvedTransport.notices` list carries structured entries the
|
|
34
|
+
//! caller emits into `events.jsonl`. Emission happens at the caller
|
|
35
|
+
//! boundary (not here) because the factory itself must have zero
|
|
36
|
+
//! side effects (C-5).
|
|
37
|
+
//! - **C-4** compact-status JSON stability is a separate grep-guard
|
|
38
|
+
//! test (`transport_factory_compact_status_guard.rs`); this module
|
|
39
|
+
//! just declares which fields are `--detail`-only.
|
|
40
|
+
//! - **C-5** factory purity: this file NEVER calls
|
|
41
|
+
//! `transport.kill_server(...)`, NEVER writes to `event_log`, NEVER
|
|
42
|
+
//! opens a pipe, NEVER starts a shim, NEVER rotates a token. Guard
|
|
43
|
+
//! test greps this file for those calls.
|
|
44
|
+
//! - **C-6** Batch 0 gate: the 5 core resolver tests + both C-1 grep
|
|
45
|
+
//! guards + the C-5 grep guard must pass before Batch 1's caller
|
|
46
|
+
//! migration begins. Enforced by CI + local test order.
|
|
47
|
+
|
|
48
|
+
use std::path::Path;
|
|
49
|
+
|
|
50
|
+
use crate::conpty::ConPtyBackend;
|
|
51
|
+
use crate::tmux_backend::TmuxBackend;
|
|
52
|
+
use crate::transport::{BackendKind, Transport};
|
|
53
|
+
|
|
54
|
+
/// User-visible backend selector on the CLI (`--backend tmux|conpty`).
|
|
55
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
56
|
+
pub enum RequestedTransportBackend {
|
|
57
|
+
Tmux,
|
|
58
|
+
ConPty,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
impl RequestedTransportBackend {
|
|
62
|
+
pub fn parse_literal(s: &str) -> Option<Self> {
|
|
63
|
+
match s.trim().to_ascii_lowercase().as_str() {
|
|
64
|
+
"tmux" => Some(Self::Tmux),
|
|
65
|
+
"conpty" => Some(Self::ConPty),
|
|
66
|
+
_ => None,
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
pub fn as_wire(self) -> &'static str {
|
|
71
|
+
match self {
|
|
72
|
+
Self::Tmux => "tmux",
|
|
73
|
+
Self::ConPty => "conpty",
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/// Which lifecycle site is asking the factory to resolve a transport.
|
|
79
|
+
/// Passed through so events + coordinator boot metadata can attribute
|
|
80
|
+
/// which caller made the choice. This has no effect on resolution
|
|
81
|
+
/// itself; it exists only for observability + C-3 event payloads.
|
|
82
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
83
|
+
pub enum TransportPurpose {
|
|
84
|
+
Launch,
|
|
85
|
+
LifecycleWorker,
|
|
86
|
+
Coordinator,
|
|
87
|
+
Status,
|
|
88
|
+
Diagnose,
|
|
89
|
+
Shutdown,
|
|
90
|
+
MessageDelivery,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/// Factory input. Every field except `workspace` and `purpose` is
|
|
94
|
+
/// optional; the resolution order (§Factory Design:141-149) picks the
|
|
95
|
+
/// first source that fires.
|
|
96
|
+
///
|
|
97
|
+
/// The lifetimes tie each borrowed value to the caller so the factory
|
|
98
|
+
/// is a pure function of its inputs.
|
|
99
|
+
#[derive(Debug, Clone, Copy)]
|
|
100
|
+
pub struct TransportFactoryInput<'a> {
|
|
101
|
+
pub workspace: &'a Path,
|
|
102
|
+
pub team_key: Option<&'a str>,
|
|
103
|
+
pub state: Option<&'a serde_json::Value>,
|
|
104
|
+
pub spec_backend_literal: Option<&'a str>,
|
|
105
|
+
pub explicit_backend: Option<RequestedTransportBackend>,
|
|
106
|
+
pub purpose: TransportPurpose,
|
|
107
|
+
/// True only when the caller has confirmed the switch (via
|
|
108
|
+
/// `--fresh` or a similar explicit gate). Used to allow C-2's
|
|
109
|
+
/// otherwise-refuse path.
|
|
110
|
+
pub allow_backend_switch: bool,
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
impl<'a> TransportFactoryInput<'a> {
|
|
114
|
+
pub fn new(workspace: &'a Path, purpose: TransportPurpose) -> Self {
|
|
115
|
+
Self {
|
|
116
|
+
workspace,
|
|
117
|
+
team_key: None,
|
|
118
|
+
state: None,
|
|
119
|
+
spec_backend_literal: None,
|
|
120
|
+
explicit_backend: None,
|
|
121
|
+
purpose,
|
|
122
|
+
allow_backend_switch: false,
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
pub fn with_team_key(mut self, team_key: Option<&'a str>) -> Self {
|
|
127
|
+
self.team_key = team_key;
|
|
128
|
+
self
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
pub fn with_state(mut self, state: Option<&'a serde_json::Value>) -> Self {
|
|
132
|
+
self.state = state;
|
|
133
|
+
self
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
pub fn with_spec_backend_literal(mut self, literal: Option<&'a str>) -> Self {
|
|
137
|
+
self.spec_backend_literal = literal;
|
|
138
|
+
self
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
pub fn with_explicit_backend(mut self, override_: Option<RequestedTransportBackend>) -> Self {
|
|
142
|
+
self.explicit_backend = override_;
|
|
143
|
+
self
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
pub fn allow_backend_switch(mut self, allow: bool) -> Self {
|
|
147
|
+
self.allow_backend_switch = allow;
|
|
148
|
+
self
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/// One-line trace of what the factory resolved and why. Emitted by the
|
|
153
|
+
/// caller (never by the factory itself — C-5) as
|
|
154
|
+
/// `transport.backend_selected`.
|
|
155
|
+
///
|
|
156
|
+
/// The `source` field is a stable wire string so telemetry can pivot
|
|
157
|
+
/// on it. Values: `"cli"`, `"state.transport.kind"`,
|
|
158
|
+
/// `"legacy_tmux_endpoint"`, `"spec.runtime.backend"`, `"default"`.
|
|
159
|
+
///
|
|
160
|
+
/// No `Clone` — the boxed `Transport` trait object is not cloneable.
|
|
161
|
+
pub struct ResolvedTransport {
|
|
162
|
+
pub backend: Box<dyn Transport>,
|
|
163
|
+
pub kind: BackendKind,
|
|
164
|
+
pub source: &'static str,
|
|
165
|
+
pub tmux_endpoint_used: Option<String>,
|
|
166
|
+
pub team_key: Option<String>,
|
|
167
|
+
/// N38 交底: extra events the caller should emit next to
|
|
168
|
+
/// `transport.backend_selected` so users can explain WHY a shadow
|
|
169
|
+
/// source was ignored (C-3).
|
|
170
|
+
pub notices: Vec<TransportNotice>,
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
impl std::fmt::Debug for ResolvedTransport {
|
|
174
|
+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
175
|
+
f.debug_struct("ResolvedTransport")
|
|
176
|
+
.field("kind", &self.kind)
|
|
177
|
+
.field("source", &self.source)
|
|
178
|
+
.field("tmux_endpoint_used", &self.tmux_endpoint_used)
|
|
179
|
+
.field("team_key", &self.team_key)
|
|
180
|
+
.field("notices", &self.notices)
|
|
181
|
+
.field("backend", &"<dyn Transport>")
|
|
182
|
+
.finish()
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/// A single N38-style notice the caller should emit into events.jsonl
|
|
187
|
+
/// after choosing a backend. The factory returns them; it never emits
|
|
188
|
+
/// (C-5).
|
|
189
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
190
|
+
pub struct TransportNotice {
|
|
191
|
+
pub event: &'static str,
|
|
192
|
+
pub payload: serde_json::Value,
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/// Every way the factory can refuse honestly (MUST-NOT-13 + C-1/C-2).
|
|
196
|
+
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
|
197
|
+
pub enum TransportFactoryError {
|
|
198
|
+
/// C-1 ①: explicit `conpty` was requested but a required precondition
|
|
199
|
+
/// isn't met (missing `team_key`, non-Windows host with no shim
|
|
200
|
+
/// wired, etc.). Callers MUST surface this as
|
|
201
|
+
/// `TransportError::MuxUnavailable` — NEVER silent-downgrade to
|
|
202
|
+
/// tmux.
|
|
203
|
+
#[error("conpty backend requires {reason}")]
|
|
204
|
+
ConPtyPreconditionUnmet { reason: String },
|
|
205
|
+
|
|
206
|
+
/// C-1 ②: `spec.runtime.backend = "pty"` was seen. The MVP does not
|
|
207
|
+
/// auto-map `pty` to ConPTY (design §Non-Goals:370). Callers must
|
|
208
|
+
/// treat this as a hard error.
|
|
209
|
+
#[error("unsupported spec.runtime.backend literal {literal:?}; only tmux|conpty are supported in Phase 1d")]
|
|
210
|
+
UnsupportedSpecBackendLiteral { literal: String },
|
|
211
|
+
|
|
212
|
+
/// C-2: CLI `--backend X` and `state.transport.kind=Y` disagree and
|
|
213
|
+
/// the caller did not pass `allow_backend_switch=true` (i.e. no
|
|
214
|
+
/// `--fresh`). Refuse rather than orphan existing workers whose
|
|
215
|
+
/// `pane_id` prefix matches the OTHER backend.
|
|
216
|
+
#[error(
|
|
217
|
+
"backend conflict: CLI requested {cli:?} but state.transport.kind={state:?}; \
|
|
218
|
+
switching would abandon existing {state:?} workers — pass --fresh to switch"
|
|
219
|
+
)]
|
|
220
|
+
BackendConflict {
|
|
221
|
+
cli: RequestedTransportBackend,
|
|
222
|
+
state: BackendKind,
|
|
223
|
+
},
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/// Resolve a transport per the 5-layer order:
|
|
227
|
+
///
|
|
228
|
+
/// 1. Explicit CLI override.
|
|
229
|
+
/// 2. `state.transport.kind`.
|
|
230
|
+
/// 3. Legacy `state.tmux_endpoint` / `state.tmux_socket` → tmux.
|
|
231
|
+
/// 4. Spec `runtime.backend` literal (`tmux` / `conpty`; `pty`
|
|
232
|
+
/// explicitly unsupported).
|
|
233
|
+
/// 5. Default → tmux workspace socket.
|
|
234
|
+
///
|
|
235
|
+
/// The function returns `ResolvedTransport` for the success path or
|
|
236
|
+
/// `TransportFactoryError` for any refusal. **Never silently downgrades
|
|
237
|
+
/// conpty → tmux, never auto-maps pty → conpty, never runs
|
|
238
|
+
/// side-effects (open pipe, kill server, rotate token, emit event —
|
|
239
|
+
/// see C-5 guard test).**
|
|
240
|
+
pub fn resolve_transport(
|
|
241
|
+
input: TransportFactoryInput<'_>,
|
|
242
|
+
) -> Result<ResolvedTransport, TransportFactoryError> {
|
|
243
|
+
let state_kind = input
|
|
244
|
+
.state
|
|
245
|
+
.and_then(|s| s.pointer("/transport/kind"))
|
|
246
|
+
.and_then(|v| v.as_str())
|
|
247
|
+
.and_then(RequestedTransportBackend::parse_literal);
|
|
248
|
+
// ── C-2 ────────────────────────────────────────────────────────
|
|
249
|
+
// If CLI override disagrees with state.transport.kind, refuse
|
|
250
|
+
// unless the caller opted-in via allow_backend_switch (--fresh).
|
|
251
|
+
// This is fail-closed BEFORE we build any backend so no side effect
|
|
252
|
+
// leaks.
|
|
253
|
+
if let (Some(cli), Some(state)) = (input.explicit_backend, state_kind) {
|
|
254
|
+
if cli != state && !input.allow_backend_switch {
|
|
255
|
+
return Err(TransportFactoryError::BackendConflict {
|
|
256
|
+
cli,
|
|
257
|
+
state: requested_to_kind(state),
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
// ── Layer 1: CLI override ─────────────────────────────────────
|
|
262
|
+
if let Some(requested) = input.explicit_backend {
|
|
263
|
+
return build_selected(&input, requested, "cli");
|
|
264
|
+
}
|
|
265
|
+
// ── Layer 2: state.transport.kind ─────────────────────────────
|
|
266
|
+
if let Some(requested) = state_kind {
|
|
267
|
+
return build_selected(&input, requested, "state.transport.kind");
|
|
268
|
+
}
|
|
269
|
+
// ── Layer 3: legacy tmux endpoint / socket ────────────────────
|
|
270
|
+
if let Some((_, _)) = crate::tmux_backend::runtime_tmux_endpoint_from_state_pub(input.state) {
|
|
271
|
+
return build_selected(&input, RequestedTransportBackend::Tmux, "legacy_tmux_endpoint");
|
|
272
|
+
}
|
|
273
|
+
// ── Layer 4: spec.runtime.backend literal ─────────────────────
|
|
274
|
+
if let Some(literal) = input.spec_backend_literal {
|
|
275
|
+
match literal.trim().to_ascii_lowercase().as_str() {
|
|
276
|
+
"tmux" => {
|
|
277
|
+
return build_selected(&input, RequestedTransportBackend::Tmux, "spec.runtime.backend");
|
|
278
|
+
}
|
|
279
|
+
"conpty" => {
|
|
280
|
+
return build_selected(&input, RequestedTransportBackend::ConPty, "spec.runtime.backend");
|
|
281
|
+
}
|
|
282
|
+
other => {
|
|
283
|
+
// C-1 ②: `pty` (and everything else) is NOT auto-mapped
|
|
284
|
+
// to ConPTY. Fail closed with a typed error.
|
|
285
|
+
return Err(TransportFactoryError::UnsupportedSpecBackendLiteral {
|
|
286
|
+
literal: other.to_string(),
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
// ── Layer 5: default → tmux workspace ─────────────────────────
|
|
292
|
+
build_selected(&input, RequestedTransportBackend::Tmux, "default")
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
fn requested_to_kind(r: RequestedTransportBackend) -> BackendKind {
|
|
296
|
+
match r {
|
|
297
|
+
RequestedTransportBackend::Tmux => BackendKind::Tmux,
|
|
298
|
+
RequestedTransportBackend::ConPty => BackendKind::ConPty,
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
fn build_selected(
|
|
303
|
+
input: &TransportFactoryInput<'_>,
|
|
304
|
+
requested: RequestedTransportBackend,
|
|
305
|
+
source: &'static str,
|
|
306
|
+
) -> Result<ResolvedTransport, TransportFactoryError> {
|
|
307
|
+
match requested {
|
|
308
|
+
RequestedTransportBackend::Tmux => Ok(build_tmux(input, source)),
|
|
309
|
+
RequestedTransportBackend::ConPty => build_conpty(input, source),
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
fn build_tmux(input: &TransportFactoryInput<'_>, source: &'static str) -> ResolvedTransport {
|
|
314
|
+
// Preserve the existing runtime endpoint / workspace fallback shape
|
|
315
|
+
// so tmux callers see byte-equivalent behavior.
|
|
316
|
+
let selection = crate::tmux_backend::tmux_backend_for_runtime_state_or_workspace(
|
|
317
|
+
input.workspace,
|
|
318
|
+
input.state,
|
|
319
|
+
);
|
|
320
|
+
// C-3: if we chose tmux because of `state.transport.kind = tmux`
|
|
321
|
+
// OR `spec.runtime.backend = tmux` OR `default`, but a legacy tmux
|
|
322
|
+
// endpoint is *also* present, that is not a conflict (they
|
|
323
|
+
// agree). The conflict scenario in design §Two-Source Conflict
|
|
324
|
+
// Scene C is `legacy tmux + spec conpty` — order 3 wins, spec is
|
|
325
|
+
// ignored. Callers of the `spec.runtime.backend` path with a
|
|
326
|
+
// legacy tmux endpoint present take a different code path (they
|
|
327
|
+
// hit layer 3 first). So there is no notice to emit for tmux.
|
|
328
|
+
ResolvedTransport {
|
|
329
|
+
kind: BackendKind::Tmux,
|
|
330
|
+
source,
|
|
331
|
+
tmux_endpoint_used: selection.tmux_endpoint_used,
|
|
332
|
+
team_key: input.team_key.map(str::to_string),
|
|
333
|
+
backend: Box::new(selection.backend),
|
|
334
|
+
notices: Vec::new(),
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
fn build_conpty(
|
|
339
|
+
input: &TransportFactoryInput<'_>,
|
|
340
|
+
source: &'static str,
|
|
341
|
+
) -> Result<ResolvedTransport, TransportFactoryError> {
|
|
342
|
+
// C-1 fail-closed: ConPTY needs a team_key. Missing → refuse.
|
|
343
|
+
// NEVER silent-downgrade to tmux; that would violate MUST-NOT-13.
|
|
344
|
+
let Some(team_key) = input.team_key else {
|
|
345
|
+
return Err(TransportFactoryError::ConPtyPreconditionUnmet {
|
|
346
|
+
reason: "team_key required for conpty (design §Factory Design:159-163)"
|
|
347
|
+
.to_string(),
|
|
348
|
+
});
|
|
349
|
+
};
|
|
350
|
+
// Build a workspace hash from the canonical workspace path using
|
|
351
|
+
// the same source of truth the tmux short-socket derivation uses,
|
|
352
|
+
// so the shim + tmux socket see identical workspace identity.
|
|
353
|
+
let workspace_hash = crate::tmux_backend::workspace_short_hash_pub(input.workspace);
|
|
354
|
+
// No pipe client is wired here (that's Batch 2/3 scope). Without
|
|
355
|
+
// a live client, `ConPtyBackend` degrades to `MuxUnavailable`
|
|
356
|
+
// honestly — which is exactly what C-1 requires when the caller
|
|
357
|
+
// asked for conpty on a host with no shim.
|
|
358
|
+
let backend = ConPtyBackend::new(workspace_hash, team_key);
|
|
359
|
+
let mut notices = Vec::new();
|
|
360
|
+
// C-3: if state has BOTH `transport.kind=conpty` and a legacy
|
|
361
|
+
// `tmux_endpoint`, tell the caller so they emit
|
|
362
|
+
// `transport.legacy_tmux_endpoint_ignored`. Same shape for the
|
|
363
|
+
// Scene C case (legacy tmux + spec conpty): if we're on the spec
|
|
364
|
+
// path we would have hit the legacy branch first, so this only
|
|
365
|
+
// fires when `state.transport.kind=conpty` explicitly overrode
|
|
366
|
+
// the legacy fields.
|
|
367
|
+
if let Some(state) = input.state {
|
|
368
|
+
if source == "state.transport.kind" {
|
|
369
|
+
if let Some(legacy) = state.get("tmux_endpoint").and_then(|v| v.as_str()) {
|
|
370
|
+
notices.push(TransportNotice {
|
|
371
|
+
event: "transport.legacy_tmux_endpoint_ignored",
|
|
372
|
+
payload: serde_json::json!({
|
|
373
|
+
"chosen_backend": "conpty",
|
|
374
|
+
"legacy_tmux_endpoint": legacy,
|
|
375
|
+
"reason": "state.transport.kind=conpty explicitly overrides legacy tmux endpoint",
|
|
376
|
+
}),
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
Ok(ResolvedTransport {
|
|
382
|
+
kind: BackendKind::ConPty,
|
|
383
|
+
source,
|
|
384
|
+
tmux_endpoint_used: None,
|
|
385
|
+
team_key: Some(team_key.to_string()),
|
|
386
|
+
backend: Box::new(backend),
|
|
387
|
+
notices,
|
|
388
|
+
})
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
392
|
+
// Tmux channel helpers.
|
|
393
|
+
//
|
|
394
|
+
// These are intentional tmux-specific entrypoints for callers that
|
|
395
|
+
// target a KNOWN tmux socket (external leader panes, orphan diagnosis,
|
|
396
|
+
// managed leader launcher). They are NOT part of the team backend
|
|
397
|
+
// selection surface. Callers grep for these names to signal intent.
|
|
398
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
399
|
+
|
|
400
|
+
/// 0.5.x Phase 1d Batch 3 read-path convenience: build a
|
|
401
|
+
/// `ResolvedTransport` for a plain workspace + optional state pair.
|
|
402
|
+
///
|
|
403
|
+
/// This is the single entrypoint the coordinator boot / status /
|
|
404
|
+
/// diagnose read-only paths use. It follows the same 5-layer resolution
|
|
405
|
+
/// order as `resolve_transport`; it does NOT accept a CLI backend
|
|
406
|
+
/// override (read paths don't have one) — that avoids accidentally
|
|
407
|
+
/// widening the CLI surface at every reader.
|
|
408
|
+
///
|
|
409
|
+
/// On `Err`, callers should fall back to a tmux workspace transport
|
|
410
|
+
/// (byte-equivalent to today's shape) and record the refusal so
|
|
411
|
+
/// telemetry stays honest.
|
|
412
|
+
pub fn resolve_read_only_transport(
|
|
413
|
+
workspace: &Path,
|
|
414
|
+
state: Option<&serde_json::Value>,
|
|
415
|
+
purpose: TransportPurpose,
|
|
416
|
+
) -> Result<ResolvedTransport, TransportFactoryError> {
|
|
417
|
+
let input = TransportFactoryInput::new(workspace, purpose)
|
|
418
|
+
.with_state(state)
|
|
419
|
+
// Read paths never pass a team_key — they use whatever state
|
|
420
|
+
// says. If `state.transport.kind = conpty` but there is no
|
|
421
|
+
// team_key, factory refuses (C-1 ①), which is the same honest
|
|
422
|
+
// signal we want to surface to the coordinator/status reader.
|
|
423
|
+
.with_team_key(
|
|
424
|
+
state
|
|
425
|
+
.and_then(|s| s.get("teams"))
|
|
426
|
+
.and_then(|t| t.as_object())
|
|
427
|
+
.and_then(|obj| obj.keys().next().map(String::as_str)),
|
|
428
|
+
);
|
|
429
|
+
resolve_transport(input)
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/// Tmux transport for a caller-supplied tmux endpoint literal. Used by
|
|
433
|
+
/// leader launcher, diagnose/orphans, and external leader pane
|
|
434
|
+
/// discovery — NOT for worker/team selection.
|
|
435
|
+
pub fn tmux_endpoint_transport(endpoint: &str) -> TmuxBackend {
|
|
436
|
+
TmuxBackend::for_tmux_endpoint(endpoint)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/// Tmux transport for the default (shared) socket. Same intent as
|
|
440
|
+
/// above — used by leader-side channel operations, never worker/team
|
|
441
|
+
/// selection.
|
|
442
|
+
pub fn tmux_default_transport() -> TmuxBackend {
|
|
443
|
+
TmuxBackend::new()
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/// Tmux transport for the per-workspace socket. This is what the tmux
|
|
447
|
+
/// launch path uses when there is no persisted endpoint. Not for
|
|
448
|
+
/// generic worker/team selection — that goes through
|
|
449
|
+
/// `resolve_transport`.
|
|
450
|
+
pub fn tmux_workspace_transport(workspace: &Path) -> TmuxBackend {
|
|
451
|
+
TmuxBackend::for_workspace(workspace)
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/// Tmux transport for a specific named tmux socket (e.g.
|
|
455
|
+
/// `ta-abcdef012345`). Used by orphan-scanner and diagnose paths that
|
|
456
|
+
/// intentionally enumerate the tmux socket roots — NOT for team
|
|
457
|
+
/// backend selection.
|
|
458
|
+
pub fn tmux_socket_name_transport(socket: &str) -> TmuxBackend {
|
|
459
|
+
TmuxBackend::for_socket_name(socket)
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
463
|
+
// Tests (§Batch 0 Verification, design.md:220-224).
|
|
464
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
465
|
+
#[cfg(test)]
|
|
466
|
+
mod tests {
|
|
467
|
+
use super::*;
|
|
468
|
+
use std::path::PathBuf;
|
|
469
|
+
|
|
470
|
+
fn ws() -> PathBuf {
|
|
471
|
+
std::env::temp_dir().join("ta-phase1d-factory-tests")
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Test #1 — no state/spec/override → tmux workspace backend.
|
|
475
|
+
#[test]
|
|
476
|
+
fn no_input_resolves_to_default_tmux_workspace_backend() {
|
|
477
|
+
let workspace = ws();
|
|
478
|
+
let input = TransportFactoryInput::new(&workspace, TransportPurpose::Launch);
|
|
479
|
+
let resolved = resolve_transport(input).expect("default path must succeed");
|
|
480
|
+
assert_eq!(resolved.kind, BackendKind::Tmux);
|
|
481
|
+
assert_eq!(resolved.source, "default");
|
|
482
|
+
assert!(resolved.notices.is_empty());
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Test #2 — legacy `state.tmux_endpoint` → tmux endpoint (order 3).
|
|
486
|
+
#[test]
|
|
487
|
+
fn legacy_tmux_endpoint_resolves_to_tmux_endpoint_backend() {
|
|
488
|
+
let workspace = ws();
|
|
489
|
+
let state = serde_json::json!({ "tmux_endpoint": "default" });
|
|
490
|
+
let input = TransportFactoryInput::new(&workspace, TransportPurpose::LifecycleWorker)
|
|
491
|
+
.with_state(Some(&state));
|
|
492
|
+
let resolved = resolve_transport(input).expect("legacy path must succeed");
|
|
493
|
+
assert_eq!(resolved.kind, BackendKind::Tmux);
|
|
494
|
+
assert_eq!(resolved.source, "legacy_tmux_endpoint");
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Test #3 — explicit conpty (via CLI) with team_key → ConPty.
|
|
498
|
+
#[test]
|
|
499
|
+
fn explicit_conpty_with_team_key_resolves_to_conpty() {
|
|
500
|
+
let workspace = ws();
|
|
501
|
+
let input = TransportFactoryInput::new(&workspace, TransportPurpose::LifecycleWorker)
|
|
502
|
+
.with_team_key(Some("team-a"))
|
|
503
|
+
.with_explicit_backend(Some(RequestedTransportBackend::ConPty));
|
|
504
|
+
let resolved = resolve_transport(input).expect("explicit conpty must succeed with team_key");
|
|
505
|
+
assert_eq!(resolved.kind, BackendKind::ConPty);
|
|
506
|
+
assert_eq!(resolved.source, "cli");
|
|
507
|
+
assert_eq!(resolved.team_key.as_deref(), Some("team-a"));
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Test #4 — explicit conpty WITHOUT team_key → refusal (C-1 ①).
|
|
511
|
+
#[test]
|
|
512
|
+
fn explicit_conpty_without_team_key_refuses_no_silent_fallback() {
|
|
513
|
+
let workspace = ws();
|
|
514
|
+
let input = TransportFactoryInput::new(&workspace, TransportPurpose::LifecycleWorker)
|
|
515
|
+
.with_explicit_backend(Some(RequestedTransportBackend::ConPty));
|
|
516
|
+
let err = resolve_transport(input).expect_err("conpty without team_key must refuse");
|
|
517
|
+
match err {
|
|
518
|
+
TransportFactoryError::ConPtyPreconditionUnmet { reason } => {
|
|
519
|
+
assert!(reason.contains("team_key"), "reason must mention team_key: {reason}");
|
|
520
|
+
}
|
|
521
|
+
other => panic!("expected ConPtyPreconditionUnmet, got {other:?}"),
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Test #5 — `pty` literal is NOT silently mapped to ConPTY (C-1 ②).
|
|
526
|
+
#[test]
|
|
527
|
+
fn pty_literal_is_not_silently_mapped_to_conpty() {
|
|
528
|
+
let workspace = ws();
|
|
529
|
+
let input = TransportFactoryInput::new(&workspace, TransportPurpose::Launch)
|
|
530
|
+
.with_team_key(Some("team-a"))
|
|
531
|
+
.with_spec_backend_literal(Some("pty"));
|
|
532
|
+
let err = resolve_transport(input)
|
|
533
|
+
.expect_err("pty literal must be refused, not auto-mapped");
|
|
534
|
+
match err {
|
|
535
|
+
TransportFactoryError::UnsupportedSpecBackendLiteral { literal } => {
|
|
536
|
+
assert_eq!(literal, "pty");
|
|
537
|
+
}
|
|
538
|
+
other => panic!("expected UnsupportedSpecBackendLiteral, got {other:?}"),
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// C-2 supplementary — CLI override vs state disagreement refuses.
|
|
543
|
+
#[test]
|
|
544
|
+
fn cli_override_disagreeing_with_state_kind_refuses_unless_fresh() {
|
|
545
|
+
let workspace = ws();
|
|
546
|
+
let state = serde_json::json!({ "transport": { "kind": "conpty" } });
|
|
547
|
+
let input = TransportFactoryInput::new(&workspace, TransportPurpose::LifecycleWorker)
|
|
548
|
+
.with_team_key(Some("team-a"))
|
|
549
|
+
.with_state(Some(&state))
|
|
550
|
+
.with_explicit_backend(Some(RequestedTransportBackend::Tmux));
|
|
551
|
+
let err = resolve_transport(input).expect_err("disagreement must refuse");
|
|
552
|
+
match err {
|
|
553
|
+
TransportFactoryError::BackendConflict { cli, state } => {
|
|
554
|
+
assert_eq!(cli, RequestedTransportBackend::Tmux);
|
|
555
|
+
assert_eq!(state, BackendKind::ConPty);
|
|
556
|
+
}
|
|
557
|
+
other => panic!("expected BackendConflict, got {other:?}"),
|
|
558
|
+
}
|
|
559
|
+
// With allow_backend_switch=true (== --fresh), the same input succeeds.
|
|
560
|
+
let input_fresh = TransportFactoryInput::new(&workspace, TransportPurpose::LifecycleWorker)
|
|
561
|
+
.with_team_key(Some("team-a"))
|
|
562
|
+
.with_state(Some(&state))
|
|
563
|
+
.with_explicit_backend(Some(RequestedTransportBackend::Tmux))
|
|
564
|
+
.allow_backend_switch(true);
|
|
565
|
+
let ok = resolve_transport(input_fresh).expect("--fresh must allow switch");
|
|
566
|
+
assert_eq!(ok.kind, BackendKind::Tmux);
|
|
567
|
+
assert_eq!(ok.source, "cli");
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// C-3 supplementary — state kind=conpty + legacy tmux endpoint emits
|
|
571
|
+
// a `legacy_tmux_endpoint_ignored` notice.
|
|
572
|
+
#[test]
|
|
573
|
+
fn state_conpty_with_legacy_tmux_endpoint_emits_ignored_notice() {
|
|
574
|
+
let workspace = ws();
|
|
575
|
+
let state = serde_json::json!({
|
|
576
|
+
"transport": { "kind": "conpty" },
|
|
577
|
+
"tmux_endpoint": "default",
|
|
578
|
+
});
|
|
579
|
+
let input = TransportFactoryInput::new(&workspace, TransportPurpose::LifecycleWorker)
|
|
580
|
+
.with_team_key(Some("team-a"))
|
|
581
|
+
.with_state(Some(&state));
|
|
582
|
+
let resolved = resolve_transport(input).expect("must succeed");
|
|
583
|
+
assert_eq!(resolved.kind, BackendKind::ConPty);
|
|
584
|
+
assert_eq!(resolved.source, "state.transport.kind");
|
|
585
|
+
// N38: one notice emitted.
|
|
586
|
+
assert_eq!(resolved.notices.len(), 1);
|
|
587
|
+
assert_eq!(resolved.notices[0].event, "transport.legacy_tmux_endpoint_ignored");
|
|
588
|
+
assert_eq!(
|
|
589
|
+
resolved.notices[0].payload["legacy_tmux_endpoint"],
|
|
590
|
+
serde_json::json!("default")
|
|
591
|
+
);
|
|
592
|
+
assert_eq!(
|
|
593
|
+
resolved.notices[0].payload["chosen_backend"],
|
|
594
|
+
serde_json::json!("conpty")
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// C-3 also — pure conpty state (no legacy) emits zero notices.
|
|
599
|
+
#[test]
|
|
600
|
+
fn state_conpty_without_legacy_emits_no_notice() {
|
|
601
|
+
let workspace = ws();
|
|
602
|
+
let state = serde_json::json!({ "transport": { "kind": "conpty" } });
|
|
603
|
+
let input = TransportFactoryInput::new(&workspace, TransportPurpose::LifecycleWorker)
|
|
604
|
+
.with_team_key(Some("team-a"))
|
|
605
|
+
.with_state(Some(&state));
|
|
606
|
+
let resolved = resolve_transport(input).expect("must succeed");
|
|
607
|
+
assert_eq!(resolved.kind, BackendKind::ConPty);
|
|
608
|
+
assert!(resolved.notices.is_empty());
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Wire-string invariants for RequestedTransportBackend so a rename
|
|
612
|
+
// in Rust cannot silently drift the CLI accept-list.
|
|
613
|
+
#[test]
|
|
614
|
+
fn requested_backend_wire_strings_are_pinned() {
|
|
615
|
+
assert_eq!(RequestedTransportBackend::Tmux.as_wire(), "tmux");
|
|
616
|
+
assert_eq!(RequestedTransportBackend::ConPty.as_wire(), "conpty");
|
|
617
|
+
assert_eq!(
|
|
618
|
+
RequestedTransportBackend::parse_literal("tmux"),
|
|
619
|
+
Some(RequestedTransportBackend::Tmux)
|
|
620
|
+
);
|
|
621
|
+
assert_eq!(
|
|
622
|
+
RequestedTransportBackend::parse_literal("conpty"),
|
|
623
|
+
Some(RequestedTransportBackend::ConPty)
|
|
624
|
+
);
|
|
625
|
+
// pty is deliberately NOT parsed as ConPty at the wire level
|
|
626
|
+
// either. The spec.runtime.backend layer handles the typed
|
|
627
|
+
// refusal for the spec case; here we just prove the CLI
|
|
628
|
+
// literal parser rejects it.
|
|
629
|
+
assert_eq!(RequestedTransportBackend::parse_literal("pty"), None);
|
|
630
|
+
assert_eq!(RequestedTransportBackend::parse_literal("nonsense"), None);
|
|
631
|
+
}
|
|
632
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@team-agent/installer",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
4
4
|
"description": "npx installer for Team Agent",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"codex",
|
|
@@ -20,9 +20,9 @@
|
|
|
20
20
|
"team-agent-installer": "npm/install.mjs"
|
|
21
21
|
},
|
|
22
22
|
"optionalDependencies": {
|
|
23
|
-
"@team-agent/cli-darwin-arm64": "0.5.
|
|
24
|
-
"@team-agent/cli-darwin-x64": "0.5.
|
|
25
|
-
"@team-agent/cli-linux-x64": "0.5.
|
|
23
|
+
"@team-agent/cli-darwin-arm64": "0.5.2",
|
|
24
|
+
"@team-agent/cli-darwin-x64": "0.5.2",
|
|
25
|
+
"@team-agent/cli-linux-x64": "0.5.2"
|
|
26
26
|
},
|
|
27
27
|
"scripts": {
|
|
28
28
|
"postinstall": "node npm/bincheck.mjs",
|