@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,975 @@
|
|
|
1
|
+
//! CLI-local stable name resolver for `team-agent send --to-name`.
|
|
2
|
+
|
|
3
|
+
use std::path::{Path, PathBuf};
|
|
4
|
+
|
|
5
|
+
use serde_json::{json, Value};
|
|
6
|
+
|
|
7
|
+
use crate::transport::{PaneInfo, Transport};
|
|
8
|
+
|
|
9
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
10
|
+
pub(crate) enum NamedTargetKind {
|
|
11
|
+
Worker,
|
|
12
|
+
Leader,
|
|
13
|
+
SessionWindow,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
17
|
+
pub(crate) enum NamedAddressErrorKind {
|
|
18
|
+
NameInvalid,
|
|
19
|
+
WorkspaceNotFound,
|
|
20
|
+
StateNotFound,
|
|
21
|
+
NameNotResolvable,
|
|
22
|
+
NameNotLive,
|
|
23
|
+
NameAmbiguous,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
impl NamedAddressErrorKind {
|
|
27
|
+
pub(crate) fn as_str(self) -> &'static str {
|
|
28
|
+
match self {
|
|
29
|
+
Self::NameInvalid => "name_invalid",
|
|
30
|
+
Self::WorkspaceNotFound => "workspace_not_found",
|
|
31
|
+
Self::StateNotFound => "state_not_found",
|
|
32
|
+
Self::NameNotResolvable => "name_not_resolvable",
|
|
33
|
+
Self::NameNotLive => "name_not_live",
|
|
34
|
+
Self::NameAmbiguous => "name_ambiguous",
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
40
|
+
pub(crate) struct NamedAddressError {
|
|
41
|
+
pub kind: NamedAddressErrorKind,
|
|
42
|
+
pub message: String,
|
|
43
|
+
pub action: String,
|
|
44
|
+
pub log: String,
|
|
45
|
+
pub candidates: Vec<Value>,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
impl NamedAddressError {
|
|
49
|
+
pub(crate) fn new(kind: NamedAddressErrorKind, message: impl Into<String>) -> Self {
|
|
50
|
+
Self {
|
|
51
|
+
kind,
|
|
52
|
+
message: message.into(),
|
|
53
|
+
action: "inspect team-agent status and use an explicit workspace/team name".to_string(),
|
|
54
|
+
log: "named-address resolver refused before injection".to_string(),
|
|
55
|
+
candidates: Vec::new(),
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
pub(crate) fn n38_message(&self) -> String {
|
|
60
|
+
format!(
|
|
61
|
+
"Error: {}\nAction: {}\nLog: {}",
|
|
62
|
+
self.kind.as_str(),
|
|
63
|
+
self.action,
|
|
64
|
+
self.log
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
pub(crate) fn to_json(&self) -> Value {
|
|
69
|
+
json!({
|
|
70
|
+
"ok": false,
|
|
71
|
+
"status": "refused",
|
|
72
|
+
"reason": self.kind.as_str(),
|
|
73
|
+
"error": self.message,
|
|
74
|
+
"action": self.action,
|
|
75
|
+
"log": self.log,
|
|
76
|
+
"candidates": self.candidates,
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
impl std::fmt::Display for NamedAddressError {
|
|
82
|
+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
83
|
+
f.write_str(&self.n38_message())
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
impl std::error::Error for NamedAddressError {}
|
|
88
|
+
|
|
89
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
90
|
+
pub(crate) struct ResolvedNamedAddress {
|
|
91
|
+
pub raw_name: String,
|
|
92
|
+
pub target_kind: NamedTargetKind,
|
|
93
|
+
pub sender_workspace: PathBuf,
|
|
94
|
+
pub target_workspace: PathBuf,
|
|
95
|
+
pub team_key: Option<String>,
|
|
96
|
+
pub agent_id: Option<String>,
|
|
97
|
+
pub pane_id: String,
|
|
98
|
+
pub session_name: Option<String>,
|
|
99
|
+
pub window_name: Option<String>,
|
|
100
|
+
pub tmux_endpoint: Option<String>,
|
|
101
|
+
pub transport_kind: Option<String>,
|
|
102
|
+
pub app_server: Option<Value>,
|
|
103
|
+
pub state_pane_id: Option<String>,
|
|
104
|
+
pub state_pane_stale: bool,
|
|
105
|
+
pub agent_status: Option<String>,
|
|
106
|
+
pub warning: Option<String>,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
#[derive(Clone, Copy)]
|
|
110
|
+
pub(crate) struct NamedWorkspaceTransport<'a> {
|
|
111
|
+
pub workspace: &'a Path,
|
|
112
|
+
pub transport: &'a dyn Transport,
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
pub(crate) fn resolve_name_with_transport(
|
|
116
|
+
sender_workspace: &Path,
|
|
117
|
+
raw_name: &str,
|
|
118
|
+
transport: &dyn Transport,
|
|
119
|
+
) -> Result<ResolvedNamedAddress, NamedAddressError> {
|
|
120
|
+
let parsed = parse_named_address(raw_name)?;
|
|
121
|
+
let target_workspace = resolve_workspace(sender_workspace, parsed.workspace.as_deref())?;
|
|
122
|
+
let state = load_state_checked(&target_workspace)?;
|
|
123
|
+
resolve_in_workspace(
|
|
124
|
+
sender_workspace,
|
|
125
|
+
&target_workspace,
|
|
126
|
+
&state,
|
|
127
|
+
&parsed,
|
|
128
|
+
transport,
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
pub(crate) fn resolve_name_with_workspace_transports(
|
|
133
|
+
sender_workspace: &Path,
|
|
134
|
+
raw_name: &str,
|
|
135
|
+
workspaces: &[NamedWorkspaceTransport<'_>],
|
|
136
|
+
) -> Result<ResolvedNamedAddress, NamedAddressError> {
|
|
137
|
+
let parsed = parse_named_address(raw_name)?;
|
|
138
|
+
if parsed.workspace.is_some() {
|
|
139
|
+
let target_workspace = resolve_workspace(sender_workspace, parsed.workspace.as_deref())?;
|
|
140
|
+
let Some(entry) = workspaces.iter().find(|entry| {
|
|
141
|
+
resolve_workspace(sender_workspace, Some(entry.workspace))
|
|
142
|
+
.ok()
|
|
143
|
+
.as_ref()
|
|
144
|
+
== Some(&target_workspace)
|
|
145
|
+
}) else {
|
|
146
|
+
return Err(workspace_not_found(&target_workspace));
|
|
147
|
+
};
|
|
148
|
+
let state = load_state_checked(&target_workspace)?;
|
|
149
|
+
return resolve_in_workspace(
|
|
150
|
+
sender_workspace,
|
|
151
|
+
&target_workspace,
|
|
152
|
+
&state,
|
|
153
|
+
&parsed,
|
|
154
|
+
entry.transport,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
let mut successes = Vec::new();
|
|
159
|
+
let mut first_error = None;
|
|
160
|
+
for entry in workspaces {
|
|
161
|
+
let target_workspace = resolve_workspace(sender_workspace, Some(entry.workspace))?;
|
|
162
|
+
match load_state_checked(&target_workspace).and_then(|state| {
|
|
163
|
+
resolve_in_workspace(
|
|
164
|
+
sender_workspace,
|
|
165
|
+
&target_workspace,
|
|
166
|
+
&state,
|
|
167
|
+
&parsed,
|
|
168
|
+
entry.transport,
|
|
169
|
+
)
|
|
170
|
+
}) {
|
|
171
|
+
Ok(resolved) => successes.push(resolved),
|
|
172
|
+
Err(err) => {
|
|
173
|
+
if first_error.is_none() {
|
|
174
|
+
first_error = Some(err);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
match successes.len() {
|
|
181
|
+
1 => successes.into_iter().next().ok_or_else(|| {
|
|
182
|
+
NamedAddressError::new(
|
|
183
|
+
NamedAddressErrorKind::NameNotResolvable,
|
|
184
|
+
"named address was not resolvable",
|
|
185
|
+
)
|
|
186
|
+
}),
|
|
187
|
+
n if n > 1 => {
|
|
188
|
+
let mut err = NamedAddressError::new(
|
|
189
|
+
NamedAddressErrorKind::NameAmbiguous,
|
|
190
|
+
"name matches multiple workspaces",
|
|
191
|
+
);
|
|
192
|
+
let name = parsed.display_name();
|
|
193
|
+
err.action =
|
|
194
|
+
format!("Use an explicit workspace-qualified name such as <workspace>::{name}");
|
|
195
|
+
err.log = "same team/name is live in multiple workspaces".to_string();
|
|
196
|
+
err.candidates = successes
|
|
197
|
+
.into_iter()
|
|
198
|
+
.map(|resolved| candidate_json(&resolved))
|
|
199
|
+
.collect();
|
|
200
|
+
Err(err)
|
|
201
|
+
}
|
|
202
|
+
_ => Err(first_error.unwrap_or_else(|| {
|
|
203
|
+
NamedAddressError::new(
|
|
204
|
+
NamedAddressErrorKind::NameNotResolvable,
|
|
205
|
+
"named address was not resolvable",
|
|
206
|
+
)
|
|
207
|
+
})),
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
pub(crate) fn resolve_name_for_cli(
|
|
212
|
+
sender_workspace: &Path,
|
|
213
|
+
raw_name: &str,
|
|
214
|
+
) -> Result<(ResolvedNamedAddress, Box<dyn Transport>), NamedAddressError> {
|
|
215
|
+
let parsed = parse_named_address(raw_name)?;
|
|
216
|
+
let target_workspace = resolve_workspace(sender_workspace, parsed.workspace.as_deref())?;
|
|
217
|
+
let state = load_state_checked(&target_workspace)?;
|
|
218
|
+
let transport = transport_for_cli_target(&target_workspace, &state, &parsed);
|
|
219
|
+
let resolved = resolve_in_workspace(
|
|
220
|
+
sender_workspace,
|
|
221
|
+
&target_workspace,
|
|
222
|
+
&state,
|
|
223
|
+
&parsed,
|
|
224
|
+
transport.as_ref(),
|
|
225
|
+
)?;
|
|
226
|
+
Ok((resolved, transport))
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
230
|
+
struct ParsedNamedAddress {
|
|
231
|
+
workspace: Option<PathBuf>,
|
|
232
|
+
target: ParsedTarget,
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
impl ParsedNamedAddress {
|
|
236
|
+
fn display_name(&self) -> String {
|
|
237
|
+
match &self.target {
|
|
238
|
+
ParsedTarget::BareAgent(agent) => agent.clone(),
|
|
239
|
+
ParsedTarget::TeamEntity { team, entity } => format!("{team}/{entity}"),
|
|
240
|
+
ParsedTarget::SessionWindow { session, window } => format!("{session}:{window}"),
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
246
|
+
enum ParsedTarget {
|
|
247
|
+
BareAgent(String),
|
|
248
|
+
TeamEntity { team: String, entity: String },
|
|
249
|
+
SessionWindow { session: String, window: String },
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
fn parse_named_address(raw_name: &str) -> Result<ParsedNamedAddress, NamedAddressError> {
|
|
253
|
+
let raw = raw_name.trim();
|
|
254
|
+
if raw.is_empty() {
|
|
255
|
+
return Err(name_invalid("name is empty"));
|
|
256
|
+
}
|
|
257
|
+
let (workspace, name) = if let Some((workspace, rest)) = raw.split_once("::") {
|
|
258
|
+
if workspace.trim().is_empty() || rest.trim().is_empty() {
|
|
259
|
+
return Err(name_invalid(
|
|
260
|
+
"workspace-qualified name must include workspace and target",
|
|
261
|
+
));
|
|
262
|
+
}
|
|
263
|
+
(Some(PathBuf::from(workspace)), rest.trim())
|
|
264
|
+
} else {
|
|
265
|
+
(None, raw)
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
if name.contains("//") {
|
|
269
|
+
return Err(name_invalid("name contains an empty path segment"));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
let target = if name.contains('/') {
|
|
273
|
+
let parts = name.split('/').collect::<Vec<_>>();
|
|
274
|
+
if parts.len() != 2 || !valid_component(parts[0]) || !valid_component(parts[1]) {
|
|
275
|
+
return Err(name_invalid("expected <team>/<agent> or <team>/leader"));
|
|
276
|
+
}
|
|
277
|
+
ParsedTarget::TeamEntity {
|
|
278
|
+
team: parts[0].to_string(),
|
|
279
|
+
entity: parts[1].to_string(),
|
|
280
|
+
}
|
|
281
|
+
} else if name.contains(':') {
|
|
282
|
+
let parts = name.split(':').collect::<Vec<_>>();
|
|
283
|
+
if parts.len() != 2 || parts[0].trim().is_empty() || parts[1].trim().is_empty() {
|
|
284
|
+
return Err(name_invalid("expected <session>:<window>"));
|
|
285
|
+
}
|
|
286
|
+
ParsedTarget::SessionWindow {
|
|
287
|
+
session: parts[0].to_string(),
|
|
288
|
+
window: parts[1].to_string(),
|
|
289
|
+
}
|
|
290
|
+
} else {
|
|
291
|
+
if !valid_component(name) {
|
|
292
|
+
return Err(name_invalid("expected a non-empty agent id"));
|
|
293
|
+
}
|
|
294
|
+
ParsedTarget::BareAgent(name.to_string())
|
|
295
|
+
};
|
|
296
|
+
Ok(ParsedNamedAddress { workspace, target })
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
fn valid_component(raw: &str) -> bool {
|
|
300
|
+
!raw.trim().is_empty()
|
|
301
|
+
&& !raw.contains(char::is_whitespace)
|
|
302
|
+
&& !raw.contains('/')
|
|
303
|
+
&& !raw.contains(':')
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
fn resolve_workspace(
|
|
307
|
+
sender_workspace: &Path,
|
|
308
|
+
explicit: Option<&Path>,
|
|
309
|
+
) -> Result<PathBuf, NamedAddressError> {
|
|
310
|
+
let input = explicit.unwrap_or(sender_workspace);
|
|
311
|
+
if explicit.is_some() && !input.exists() {
|
|
312
|
+
return Err(workspace_not_found(input));
|
|
313
|
+
}
|
|
314
|
+
crate::model::paths::canonical_run_workspace(input).map_err(|error| {
|
|
315
|
+
let mut err = NamedAddressError::new(
|
|
316
|
+
NamedAddressErrorKind::WorkspaceNotFound,
|
|
317
|
+
format!("workspace cannot be resolved: {error}"),
|
|
318
|
+
);
|
|
319
|
+
err.action = "Use an existing Team Agent workspace path before `::`".to_string();
|
|
320
|
+
err.log = format!("workspace={}", input.display());
|
|
321
|
+
err
|
|
322
|
+
})
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
fn load_state_checked(workspace: &Path) -> Result<Value, NamedAddressError> {
|
|
326
|
+
let path = crate::state::persist::runtime_state_path(workspace);
|
|
327
|
+
if !path.exists() {
|
|
328
|
+
let mut err = NamedAddressError::new(
|
|
329
|
+
NamedAddressErrorKind::StateNotFound,
|
|
330
|
+
"target workspace has no Team Agent runtime state",
|
|
331
|
+
);
|
|
332
|
+
err.action = "Run team-agent quick-start/restart in the target workspace, or choose a workspace that has .team/runtime/state.json".to_string();
|
|
333
|
+
err.log = format!("state_path={}", path.display());
|
|
334
|
+
return Err(err);
|
|
335
|
+
}
|
|
336
|
+
crate::state::persist::load_runtime_state(workspace).map_err(|error| {
|
|
337
|
+
let mut err = NamedAddressError::new(
|
|
338
|
+
NamedAddressErrorKind::StateNotFound,
|
|
339
|
+
format!("target runtime state cannot be loaded: {error}"),
|
|
340
|
+
);
|
|
341
|
+
err.action = "Inspect or repair the target workspace runtime state".to_string();
|
|
342
|
+
err.log = format!("state_path={}", path.display());
|
|
343
|
+
err
|
|
344
|
+
})
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
fn resolve_in_workspace(
|
|
348
|
+
sender_workspace: &Path,
|
|
349
|
+
target_workspace: &Path,
|
|
350
|
+
state: &Value,
|
|
351
|
+
parsed: &ParsedNamedAddress,
|
|
352
|
+
transport: &dyn Transport,
|
|
353
|
+
) -> Result<ResolvedNamedAddress, NamedAddressError> {
|
|
354
|
+
match &parsed.target {
|
|
355
|
+
ParsedTarget::BareAgent(agent) => {
|
|
356
|
+
resolve_bare_agent(sender_workspace, target_workspace, state, agent, transport)
|
|
357
|
+
}
|
|
358
|
+
ParsedTarget::TeamEntity { team, entity } if entity == "leader" => resolve_leader(
|
|
359
|
+
sender_workspace,
|
|
360
|
+
target_workspace,
|
|
361
|
+
state,
|
|
362
|
+
team,
|
|
363
|
+
parsed,
|
|
364
|
+
transport,
|
|
365
|
+
),
|
|
366
|
+
ParsedTarget::TeamEntity { team, entity } => resolve_worker(
|
|
367
|
+
sender_workspace,
|
|
368
|
+
target_workspace,
|
|
369
|
+
state,
|
|
370
|
+
team,
|
|
371
|
+
entity,
|
|
372
|
+
parsed,
|
|
373
|
+
transport,
|
|
374
|
+
),
|
|
375
|
+
ParsedTarget::SessionWindow { session, window } => resolve_session_window(
|
|
376
|
+
sender_workspace,
|
|
377
|
+
target_workspace,
|
|
378
|
+
session,
|
|
379
|
+
window,
|
|
380
|
+
parsed,
|
|
381
|
+
transport,
|
|
382
|
+
),
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
fn resolve_worker(
|
|
387
|
+
sender_workspace: &Path,
|
|
388
|
+
target_workspace: &Path,
|
|
389
|
+
state: &Value,
|
|
390
|
+
team: &str,
|
|
391
|
+
agent: &str,
|
|
392
|
+
parsed: &ParsedNamedAddress,
|
|
393
|
+
transport: &dyn Transport,
|
|
394
|
+
) -> Result<ResolvedNamedAddress, NamedAddressError> {
|
|
395
|
+
let team_entry = team_entry(state, team).ok_or_else(|| name_not_resolvable_team(team))?;
|
|
396
|
+
let agent_entry = team_entry
|
|
397
|
+
.get("agents")
|
|
398
|
+
.and_then(Value::as_object)
|
|
399
|
+
.and_then(|agents| agents.get(agent))
|
|
400
|
+
.ok_or_else(|| name_not_resolvable_agent(team, agent))?;
|
|
401
|
+
let session = string_field(team_entry, "session_name")
|
|
402
|
+
.ok_or_else(|| name_not_resolvable("team is missing session_name"))?;
|
|
403
|
+
let window = string_field(agent_entry, "window")
|
|
404
|
+
.or_else(|| string_field(agent_entry, "window_name"))
|
|
405
|
+
.unwrap_or(agent);
|
|
406
|
+
let targets = list_targets(transport)?;
|
|
407
|
+
let matches = matching_session_window(&targets, session, window);
|
|
408
|
+
match matches.len() {
|
|
409
|
+
1 => {
|
|
410
|
+
let pane = matches[0];
|
|
411
|
+
let state_pane_id = string_field(agent_entry, "pane_id").map(str::to_string);
|
|
412
|
+
let state_pane_stale = state_pane_id
|
|
413
|
+
.as_deref()
|
|
414
|
+
.is_some_and(|state_pane| state_pane != pane.pane_id.as_str());
|
|
415
|
+
let agent_status = string_field(agent_entry, "status").map(str::to_string);
|
|
416
|
+
let warning = agent_status
|
|
417
|
+
.as_deref()
|
|
418
|
+
.filter(|status| *status == "paused" || *status == "stopped")
|
|
419
|
+
.map(|status| {
|
|
420
|
+
format!("agent_status:{status}; pane is live, so delivery is allowed")
|
|
421
|
+
});
|
|
422
|
+
Ok(ResolvedNamedAddress {
|
|
423
|
+
raw_name: parsed.display_name(),
|
|
424
|
+
target_kind: NamedTargetKind::Worker,
|
|
425
|
+
sender_workspace: sender_workspace.to_path_buf(),
|
|
426
|
+
target_workspace: target_workspace.to_path_buf(),
|
|
427
|
+
team_key: Some(team.to_string()),
|
|
428
|
+
agent_id: Some(agent.to_string()),
|
|
429
|
+
pane_id: pane.pane_id.as_str().to_string(),
|
|
430
|
+
session_name: Some(session.to_string()),
|
|
431
|
+
window_name: Some(window.to_string()),
|
|
432
|
+
tmux_endpoint: transport.tmux_endpoint(),
|
|
433
|
+
transport_kind: Some("direct_tmux".to_string()),
|
|
434
|
+
app_server: None,
|
|
435
|
+
state_pane_id,
|
|
436
|
+
state_pane_stale,
|
|
437
|
+
agent_status,
|
|
438
|
+
warning,
|
|
439
|
+
})
|
|
440
|
+
}
|
|
441
|
+
0 => Err(name_not_live_worker(
|
|
442
|
+
team,
|
|
443
|
+
agent,
|
|
444
|
+
session,
|
|
445
|
+
window,
|
|
446
|
+
transport.tmux_endpoint(),
|
|
447
|
+
)),
|
|
448
|
+
_ => Err(name_ambiguous(
|
|
449
|
+
"multiple live panes match the same session/window",
|
|
450
|
+
matches
|
|
451
|
+
.into_iter()
|
|
452
|
+
.map(|pane| {
|
|
453
|
+
json!({
|
|
454
|
+
"name": format!("{team}/{agent}"),
|
|
455
|
+
"workspace": target_workspace.display().to_string(),
|
|
456
|
+
"team_key": team,
|
|
457
|
+
"agent_id": agent,
|
|
458
|
+
"session": session,
|
|
459
|
+
"window": window,
|
|
460
|
+
"pane_id": pane.pane_id.as_str(),
|
|
461
|
+
})
|
|
462
|
+
})
|
|
463
|
+
.collect(),
|
|
464
|
+
)),
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
fn resolve_bare_agent(
|
|
469
|
+
sender_workspace: &Path,
|
|
470
|
+
target_workspace: &Path,
|
|
471
|
+
state: &Value,
|
|
472
|
+
agent: &str,
|
|
473
|
+
transport: &dyn Transport,
|
|
474
|
+
) -> Result<ResolvedNamedAddress, NamedAddressError> {
|
|
475
|
+
let Some(teams) = state.get("teams").and_then(Value::as_object) else {
|
|
476
|
+
return Err(name_not_resolvable("state has no teams map"));
|
|
477
|
+
};
|
|
478
|
+
let mut live = Vec::new();
|
|
479
|
+
let mut state_matches = 0usize;
|
|
480
|
+
for (team, team_entry) in teams {
|
|
481
|
+
let Some(agents) = team_entry.get("agents").and_then(Value::as_object) else {
|
|
482
|
+
continue;
|
|
483
|
+
};
|
|
484
|
+
if agents.get(agent).is_none() {
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
state_matches += 1;
|
|
488
|
+
let parsed = ParsedNamedAddress {
|
|
489
|
+
workspace: None,
|
|
490
|
+
target: ParsedTarget::TeamEntity {
|
|
491
|
+
team: team.clone(),
|
|
492
|
+
entity: agent.to_string(),
|
|
493
|
+
},
|
|
494
|
+
};
|
|
495
|
+
if let Ok(resolved) = resolve_worker(
|
|
496
|
+
sender_workspace,
|
|
497
|
+
target_workspace,
|
|
498
|
+
state,
|
|
499
|
+
team,
|
|
500
|
+
agent,
|
|
501
|
+
&parsed,
|
|
502
|
+
transport,
|
|
503
|
+
) {
|
|
504
|
+
live.push(resolved);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
match live.len() {
|
|
508
|
+
1 => live
|
|
509
|
+
.into_iter()
|
|
510
|
+
.next()
|
|
511
|
+
.ok_or_else(|| name_not_resolvable("named address resolved no live candidates")),
|
|
512
|
+
n if n > 1 => Err(name_ambiguous(
|
|
513
|
+
"bare agent name is live in multiple teams",
|
|
514
|
+
live.into_iter()
|
|
515
|
+
.map(|resolved| candidate_json(&resolved))
|
|
516
|
+
.collect(),
|
|
517
|
+
)),
|
|
518
|
+
_ if state_matches > 0 => {
|
|
519
|
+
let mut err = NamedAddressError::new(
|
|
520
|
+
NamedAddressErrorKind::NameNotLive,
|
|
521
|
+
"agent exists in state but no live pane matched",
|
|
522
|
+
);
|
|
523
|
+
err.action =
|
|
524
|
+
format!("Use an explicit team name or restart the target agent: <team>/{agent}");
|
|
525
|
+
err.log = format!("workspace={} agent_id={agent}", target_workspace.display());
|
|
526
|
+
Err(err)
|
|
527
|
+
}
|
|
528
|
+
_ => Err(name_not_resolvable(&format!(
|
|
529
|
+
"agent `{agent}` is not present in any team"
|
|
530
|
+
))),
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
fn resolve_leader(
|
|
535
|
+
sender_workspace: &Path,
|
|
536
|
+
target_workspace: &Path,
|
|
537
|
+
state: &Value,
|
|
538
|
+
team: &str,
|
|
539
|
+
parsed: &ParsedNamedAddress,
|
|
540
|
+
transport: &dyn Transport,
|
|
541
|
+
) -> Result<ResolvedNamedAddress, NamedAddressError> {
|
|
542
|
+
let receiver = if let Some(team_entry) = team_entry(state, team) {
|
|
543
|
+
team_entry.get("leader_receiver")
|
|
544
|
+
} else {
|
|
545
|
+
state.get("leader_receiver")
|
|
546
|
+
}
|
|
547
|
+
.ok_or_else(|| name_not_live_leader(team, None, None, None, transport.tmux_endpoint()))?;
|
|
548
|
+
if let Some((mode, transport_kind)) = receiver_transport_conflict(receiver) {
|
|
549
|
+
let mut err = NamedAddressError::new(
|
|
550
|
+
NamedAddressErrorKind::NameNotLive,
|
|
551
|
+
"leader_receiver mode/transport_kind conflict",
|
|
552
|
+
);
|
|
553
|
+
err.action =
|
|
554
|
+
"Rebind the leader: team-agent claim-leader, takeover, or attach-app-server-leader"
|
|
555
|
+
.to_string();
|
|
556
|
+
err.log = format!(
|
|
557
|
+
"name={team}/leader mode={mode} transport_kind={transport_kind} expected=single_transport_kind"
|
|
558
|
+
);
|
|
559
|
+
return Err(err);
|
|
560
|
+
}
|
|
561
|
+
if crate::codex_app_server::receiver_is_app_server(receiver) {
|
|
562
|
+
return resolve_app_server_leader(
|
|
563
|
+
sender_workspace,
|
|
564
|
+
target_workspace,
|
|
565
|
+
team,
|
|
566
|
+
parsed,
|
|
567
|
+
receiver,
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
let pane_id = string_field(receiver, "pane_id")
|
|
571
|
+
.filter(|pane| !pane.is_empty())
|
|
572
|
+
.ok_or_else(|| name_not_live_leader(team, None, None, None, transport.tmux_endpoint()))?;
|
|
573
|
+
let session = string_field(receiver, "session_name");
|
|
574
|
+
let window = string_field(receiver, "window_name");
|
|
575
|
+
let socket = string_field(receiver, "tmux_socket")
|
|
576
|
+
.map(str::to_string)
|
|
577
|
+
.or_else(|| transport.tmux_endpoint());
|
|
578
|
+
let targets = list_targets(transport)?;
|
|
579
|
+
let matches = targets
|
|
580
|
+
.iter()
|
|
581
|
+
.filter(|target| target.pane_id.as_str() == pane_id)
|
|
582
|
+
.collect::<Vec<_>>();
|
|
583
|
+
match matches.len() {
|
|
584
|
+
1 => Ok(ResolvedNamedAddress {
|
|
585
|
+
raw_name: parsed.display_name(),
|
|
586
|
+
target_kind: NamedTargetKind::Leader,
|
|
587
|
+
sender_workspace: sender_workspace.to_path_buf(),
|
|
588
|
+
target_workspace: target_workspace.to_path_buf(),
|
|
589
|
+
team_key: Some(team.to_string()),
|
|
590
|
+
agent_id: None,
|
|
591
|
+
pane_id: pane_id.to_string(),
|
|
592
|
+
session_name: session.map(str::to_string),
|
|
593
|
+
window_name: window.map(str::to_string),
|
|
594
|
+
tmux_endpoint: socket,
|
|
595
|
+
transport_kind: Some("direct_tmux".to_string()),
|
|
596
|
+
app_server: None,
|
|
597
|
+
state_pane_id: Some(pane_id.to_string()),
|
|
598
|
+
state_pane_stale: false,
|
|
599
|
+
agent_status: None,
|
|
600
|
+
warning: None,
|
|
601
|
+
}),
|
|
602
|
+
0 => Err(name_not_live_leader(
|
|
603
|
+
team,
|
|
604
|
+
Some(pane_id),
|
|
605
|
+
session,
|
|
606
|
+
window,
|
|
607
|
+
socket,
|
|
608
|
+
)),
|
|
609
|
+
_ => Err(name_ambiguous(
|
|
610
|
+
"multiple live panes matched leader_receiver pane id",
|
|
611
|
+
matches
|
|
612
|
+
.into_iter()
|
|
613
|
+
.map(|pane| {
|
|
614
|
+
json!({
|
|
615
|
+
"name": format!("{team}/leader"),
|
|
616
|
+
"workspace": target_workspace.display().to_string(),
|
|
617
|
+
"team_key": team,
|
|
618
|
+
"pane_id": pane.pane_id.as_str(),
|
|
619
|
+
"session": pane.session.as_str(),
|
|
620
|
+
"window": pane.window_name.as_ref().map(|w| w.as_str()),
|
|
621
|
+
})
|
|
622
|
+
})
|
|
623
|
+
.collect(),
|
|
624
|
+
)),
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
fn receiver_transport_conflict(receiver: &Value) -> Option<(String, String)> {
|
|
629
|
+
let mode = receiver.get("mode").and_then(Value::as_str)?;
|
|
630
|
+
let transport_kind = receiver.get("transport_kind").and_then(Value::as_str)?;
|
|
631
|
+
if !mode.is_empty() && !transport_kind.is_empty() && mode != transport_kind {
|
|
632
|
+
return Some((mode.to_string(), transport_kind.to_string()));
|
|
633
|
+
}
|
|
634
|
+
None
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
fn resolve_app_server_leader(
|
|
638
|
+
sender_workspace: &Path,
|
|
639
|
+
target_workspace: &Path,
|
|
640
|
+
team: &str,
|
|
641
|
+
parsed: &ParsedNamedAddress,
|
|
642
|
+
receiver: &Value,
|
|
643
|
+
) -> Result<ResolvedNamedAddress, NamedAddressError> {
|
|
644
|
+
let expected = crate::codex_app_server::binding_from_receiver(receiver).map_err(|error| {
|
|
645
|
+
let mut err = NamedAddressError::new(
|
|
646
|
+
NamedAddressErrorKind::NameNotLive,
|
|
647
|
+
format!("app-server leader receiver is incomplete: {error}"),
|
|
648
|
+
);
|
|
649
|
+
err.action = format!("Rebind the leader: team-agent attach-app-server-leader --team {team} --socket <socket> --thread-id <thread_id>");
|
|
650
|
+
err.log = format!("name={team}/leader expected=leader_receiver.app_server");
|
|
651
|
+
err
|
|
652
|
+
})?;
|
|
653
|
+
let actual = crate::codex_app_server::attach_probe(&expected.socket, &expected.thread_id)
|
|
654
|
+
.map_err(|error| {
|
|
655
|
+
let mut err = NamedAddressError::new(
|
|
656
|
+
NamedAddressErrorKind::NameNotLive,
|
|
657
|
+
format!("app-server leader thread is not live: {error}"),
|
|
658
|
+
);
|
|
659
|
+
err.action = format!("Rebind the leader: team-agent attach-app-server-leader --team {team} --socket {} --thread-id {}", expected.socket, expected.thread_id);
|
|
660
|
+
err.log = format!(
|
|
661
|
+
"name={team}/leader socket={} thread_id={} reason={}",
|
|
662
|
+
expected.socket,
|
|
663
|
+
expected.thread_id,
|
|
664
|
+
error
|
|
665
|
+
);
|
|
666
|
+
err
|
|
667
|
+
})?;
|
|
668
|
+
if actual.thread_id != expected.thread_id
|
|
669
|
+
|| actual.session_id != expected.session_id
|
|
670
|
+
|| actual.cwd != expected.cwd
|
|
671
|
+
|| actual.cli_version != expected.cli_version
|
|
672
|
+
{
|
|
673
|
+
let mut err = NamedAddressError::new(
|
|
674
|
+
NamedAddressErrorKind::NameNotLive,
|
|
675
|
+
"app-server leader thread identity tuple is stale",
|
|
676
|
+
);
|
|
677
|
+
err.action = format!("Rebind the leader: team-agent attach-app-server-leader --team {team} --socket {} --thread-id {}", expected.socket, expected.thread_id);
|
|
678
|
+
err.log = format!(
|
|
679
|
+
"name={team}/leader expected_thread={} actual_thread={} expected_session={} actual_session={} expected_cwd={} actual_cwd={}",
|
|
680
|
+
expected.thread_id,
|
|
681
|
+
actual.thread_id,
|
|
682
|
+
expected.session_id,
|
|
683
|
+
actual.session_id,
|
|
684
|
+
expected.cwd,
|
|
685
|
+
actual.cwd
|
|
686
|
+
);
|
|
687
|
+
return Err(err);
|
|
688
|
+
}
|
|
689
|
+
Ok(ResolvedNamedAddress {
|
|
690
|
+
raw_name: parsed.display_name(),
|
|
691
|
+
target_kind: NamedTargetKind::Leader,
|
|
692
|
+
sender_workspace: sender_workspace.to_path_buf(),
|
|
693
|
+
target_workspace: target_workspace.to_path_buf(),
|
|
694
|
+
team_key: Some(team.to_string()),
|
|
695
|
+
agent_id: None,
|
|
696
|
+
pane_id: String::new(),
|
|
697
|
+
session_name: None,
|
|
698
|
+
window_name: None,
|
|
699
|
+
tmux_endpoint: None,
|
|
700
|
+
transport_kind: Some("codex_app_server".to_string()),
|
|
701
|
+
app_server: receiver.get("app_server").cloned(),
|
|
702
|
+
state_pane_id: None,
|
|
703
|
+
state_pane_stale: false,
|
|
704
|
+
agent_status: None,
|
|
705
|
+
warning: None,
|
|
706
|
+
})
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
fn resolve_session_window(
|
|
710
|
+
sender_workspace: &Path,
|
|
711
|
+
target_workspace: &Path,
|
|
712
|
+
session: &str,
|
|
713
|
+
window: &str,
|
|
714
|
+
parsed: &ParsedNamedAddress,
|
|
715
|
+
transport: &dyn Transport,
|
|
716
|
+
) -> Result<ResolvedNamedAddress, NamedAddressError> {
|
|
717
|
+
let targets = list_targets(transport)?;
|
|
718
|
+
let matches = matching_session_window(&targets, session, window);
|
|
719
|
+
match matches.len() {
|
|
720
|
+
1 => Ok(ResolvedNamedAddress {
|
|
721
|
+
raw_name: parsed.display_name(),
|
|
722
|
+
target_kind: NamedTargetKind::SessionWindow,
|
|
723
|
+
sender_workspace: sender_workspace.to_path_buf(),
|
|
724
|
+
target_workspace: target_workspace.to_path_buf(),
|
|
725
|
+
team_key: None,
|
|
726
|
+
agent_id: None,
|
|
727
|
+
pane_id: matches[0].pane_id.as_str().to_string(),
|
|
728
|
+
session_name: Some(session.to_string()),
|
|
729
|
+
window_name: Some(window.to_string()),
|
|
730
|
+
tmux_endpoint: transport.tmux_endpoint(),
|
|
731
|
+
transport_kind: Some("direct_tmux".to_string()),
|
|
732
|
+
app_server: None,
|
|
733
|
+
state_pane_id: None,
|
|
734
|
+
state_pane_stale: false,
|
|
735
|
+
agent_status: None,
|
|
736
|
+
warning: None,
|
|
737
|
+
}),
|
|
738
|
+
0 => {
|
|
739
|
+
let mut err = NamedAddressError::new(
|
|
740
|
+
NamedAddressErrorKind::NameNotLive,
|
|
741
|
+
"diagnostic session/window name is not live",
|
|
742
|
+
);
|
|
743
|
+
err.action = "Check tmux session/window names or use a canonical <workspace>::<team>/<agent> name".to_string();
|
|
744
|
+
err.log = format!(
|
|
745
|
+
"workspace={} session={session} window={window} tmux_endpoint={}",
|
|
746
|
+
target_workspace.display(),
|
|
747
|
+
transport
|
|
748
|
+
.tmux_endpoint()
|
|
749
|
+
.unwrap_or_else(|| "<unknown>".to_string())
|
|
750
|
+
);
|
|
751
|
+
Err(err)
|
|
752
|
+
}
|
|
753
|
+
_ => Err(name_ambiguous(
|
|
754
|
+
"multiple live panes match diagnostic session/window",
|
|
755
|
+
matches
|
|
756
|
+
.into_iter()
|
|
757
|
+
.map(|pane| {
|
|
758
|
+
json!({
|
|
759
|
+
"name": format!("{session}:{window}"),
|
|
760
|
+
"workspace": target_workspace.display().to_string(),
|
|
761
|
+
"session": session,
|
|
762
|
+
"window": window,
|
|
763
|
+
"pane_id": pane.pane_id.as_str(),
|
|
764
|
+
})
|
|
765
|
+
})
|
|
766
|
+
.collect(),
|
|
767
|
+
)),
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
fn transport_for_cli_target(
|
|
772
|
+
target_workspace: &Path,
|
|
773
|
+
state: &Value,
|
|
774
|
+
parsed: &ParsedNamedAddress,
|
|
775
|
+
) -> Box<dyn Transport> {
|
|
776
|
+
if let ParsedTarget::TeamEntity { team, entity } = &parsed.target {
|
|
777
|
+
if entity == "leader" {
|
|
778
|
+
if let Some(socket) = team_entry(state, team)
|
|
779
|
+
.and_then(|entry| entry.get("leader_receiver"))
|
|
780
|
+
.or_else(|| state.get("leader_receiver"))
|
|
781
|
+
.and_then(|receiver| string_field(receiver, "tmux_socket"))
|
|
782
|
+
{
|
|
783
|
+
return Box::new(crate::tmux_backend::TmuxBackend::for_tmux_endpoint(socket));
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
if let Some(entry) = team_entry(state, team) {
|
|
787
|
+
let selected = crate::tmux_backend::tmux_backend_for_runtime_state_or_workspace(
|
|
788
|
+
target_workspace,
|
|
789
|
+
Some(entry),
|
|
790
|
+
);
|
|
791
|
+
return Box::new(selected.backend);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
if let ParsedTarget::BareAgent(agent) = &parsed.target {
|
|
796
|
+
if let Some((_, entry)) = unique_state_team_for_agent(state, agent) {
|
|
797
|
+
let selected = crate::tmux_backend::tmux_backend_for_runtime_state_or_workspace(
|
|
798
|
+
target_workspace,
|
|
799
|
+
Some(entry),
|
|
800
|
+
);
|
|
801
|
+
return Box::new(selected.backend);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
let selected = crate::tmux_backend::tmux_backend_for_runtime_state_or_workspace(
|
|
806
|
+
target_workspace,
|
|
807
|
+
Some(state),
|
|
808
|
+
);
|
|
809
|
+
Box::new(selected.backend)
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
fn unique_state_team_for_agent<'a>(state: &'a Value, agent: &str) -> Option<(&'a str, &'a Value)> {
|
|
813
|
+
let teams = state.get("teams")?.as_object()?;
|
|
814
|
+
let mut matches = teams
|
|
815
|
+
.iter()
|
|
816
|
+
.filter(|(_, team)| {
|
|
817
|
+
team.get("agents")
|
|
818
|
+
.and_then(Value::as_object)
|
|
819
|
+
.is_some_and(|agents| agents.contains_key(agent))
|
|
820
|
+
})
|
|
821
|
+
.map(|(team, entry)| (team.as_str(), entry));
|
|
822
|
+
let first = matches.next()?;
|
|
823
|
+
matches.next().is_none().then_some(first)
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
fn matching_session_window<'a>(
|
|
827
|
+
targets: &'a [PaneInfo],
|
|
828
|
+
session: &str,
|
|
829
|
+
window: &str,
|
|
830
|
+
) -> Vec<&'a PaneInfo> {
|
|
831
|
+
targets
|
|
832
|
+
.iter()
|
|
833
|
+
.filter(|target| {
|
|
834
|
+
target.session.as_str() == session
|
|
835
|
+
&& target
|
|
836
|
+
.window_name
|
|
837
|
+
.as_ref()
|
|
838
|
+
.is_some_and(|name| name.as_str() == window)
|
|
839
|
+
})
|
|
840
|
+
.collect()
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
fn list_targets(transport: &dyn Transport) -> Result<Vec<PaneInfo>, NamedAddressError> {
|
|
844
|
+
transport.list_targets().map_err(|error| {
|
|
845
|
+
let mut err = NamedAddressError::new(
|
|
846
|
+
NamedAddressErrorKind::NameNotLive,
|
|
847
|
+
format!("target tmux endpoint could not be listed: {error}"),
|
|
848
|
+
);
|
|
849
|
+
err.action =
|
|
850
|
+
"Verify the target tmux server is running, or reattach/restart the target team"
|
|
851
|
+
.to_string();
|
|
852
|
+
err.log = format!(
|
|
853
|
+
"tmux_endpoint={} list_targets_error={error}",
|
|
854
|
+
transport
|
|
855
|
+
.tmux_endpoint()
|
|
856
|
+
.unwrap_or_else(|| "<unknown>".to_string())
|
|
857
|
+
);
|
|
858
|
+
err
|
|
859
|
+
})
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
fn team_entry<'a>(state: &'a Value, team: &str) -> Option<&'a Value> {
|
|
863
|
+
state
|
|
864
|
+
.get("teams")
|
|
865
|
+
.and_then(Value::as_object)
|
|
866
|
+
.and_then(|teams| teams.get(team))
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
fn string_field<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
|
|
870
|
+
value
|
|
871
|
+
.get(key)
|
|
872
|
+
.and_then(Value::as_str)
|
|
873
|
+
.filter(|s| !s.is_empty())
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
fn candidate_json(resolved: &ResolvedNamedAddress) -> Value {
|
|
877
|
+
json!({
|
|
878
|
+
"name": resolved.raw_name,
|
|
879
|
+
"workspace": resolved.target_workspace.display().to_string(),
|
|
880
|
+
"team_key": resolved.team_key,
|
|
881
|
+
"agent_id": resolved.agent_id,
|
|
882
|
+
"session": resolved.session_name,
|
|
883
|
+
"window": resolved.window_name,
|
|
884
|
+
"pane_id": resolved.pane_id,
|
|
885
|
+
"tmux_endpoint": resolved.tmux_endpoint,
|
|
886
|
+
})
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
fn name_invalid(message: &str) -> NamedAddressError {
|
|
890
|
+
let mut err = NamedAddressError::new(NamedAddressErrorKind::NameInvalid, message);
|
|
891
|
+
err.action =
|
|
892
|
+
"Use <team>/<agent>, <workspace>::<team>/<agent>, <team>/leader, or <session>:<window>"
|
|
893
|
+
.to_string();
|
|
894
|
+
err.log = "name parser rejected the input before reading state".to_string();
|
|
895
|
+
err
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
fn workspace_not_found(path: &Path) -> NamedAddressError {
|
|
899
|
+
let mut err = NamedAddressError::new(
|
|
900
|
+
NamedAddressErrorKind::WorkspaceNotFound,
|
|
901
|
+
"workspace path does not exist",
|
|
902
|
+
);
|
|
903
|
+
err.action = "Use an existing workspace path before `::`".to_string();
|
|
904
|
+
err.log = format!("workspace={}", path.display());
|
|
905
|
+
err
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
fn name_not_resolvable(message: &str) -> NamedAddressError {
|
|
909
|
+
let mut err = NamedAddressError::new(
|
|
910
|
+
NamedAddressErrorKind::NameNotResolvable,
|
|
911
|
+
message.to_string(),
|
|
912
|
+
);
|
|
913
|
+
err.action = "Use a canonical <workspace>::<team>/<agent> or <team>/<agent> name from team-agent status --json".to_string();
|
|
914
|
+
err.log = "state did not contain the requested team/name tuple".to_string();
|
|
915
|
+
err
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
fn name_not_resolvable_team(team: &str) -> NamedAddressError {
|
|
919
|
+
name_not_resolvable(&format!(
|
|
920
|
+
"team `{team}` is not present in target workspace state"
|
|
921
|
+
))
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
fn name_not_resolvable_agent(team: &str, agent: &str) -> NamedAddressError {
|
|
925
|
+
name_not_resolvable(&format!("agent `{agent}` is not present in team `{team}`"))
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
fn name_not_live_worker(
|
|
929
|
+
team: &str,
|
|
930
|
+
agent: &str,
|
|
931
|
+
session: &str,
|
|
932
|
+
window: &str,
|
|
933
|
+
endpoint: Option<String>,
|
|
934
|
+
) -> NamedAddressError {
|
|
935
|
+
let mut err = NamedAddressError::new(
|
|
936
|
+
NamedAddressErrorKind::NameNotLive,
|
|
937
|
+
"state has the team/agent but no live pane matches its session/window",
|
|
938
|
+
);
|
|
939
|
+
err.action = format!("Restart the target agent or send to a live pane after checking `team-agent status --team {team} --json`");
|
|
940
|
+
err.log = format!(
|
|
941
|
+
"name={team}/{agent} expected_session={session} expected_window={window} tmux_endpoint={}",
|
|
942
|
+
endpoint.unwrap_or_else(|| "<unknown>".to_string())
|
|
943
|
+
);
|
|
944
|
+
err
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
fn name_not_live_leader(
|
|
948
|
+
team: &str,
|
|
949
|
+
pane_id: Option<&str>,
|
|
950
|
+
session: Option<&str>,
|
|
951
|
+
window: Option<&str>,
|
|
952
|
+
socket: Option<String>,
|
|
953
|
+
) -> NamedAddressError {
|
|
954
|
+
let mut err = NamedAddressError::new(
|
|
955
|
+
NamedAddressErrorKind::NameNotLive,
|
|
956
|
+
"leader_receiver is missing or not live on its recorded tmux socket",
|
|
957
|
+
);
|
|
958
|
+
err.action = "Run claim-leader, attach-leader, or takeover for the target team; resolver will not guess another leader pane".to_string();
|
|
959
|
+
err.log = format!(
|
|
960
|
+
"name={team}/leader expected_pane={} expected_session={} expected_window={} tmux_socket={}",
|
|
961
|
+
pane_id.unwrap_or("<missing>"),
|
|
962
|
+
session.unwrap_or("<unknown>"),
|
|
963
|
+
window.unwrap_or("<unknown>"),
|
|
964
|
+
socket.unwrap_or_else(|| "<unknown>".to_string())
|
|
965
|
+
);
|
|
966
|
+
err
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
fn name_ambiguous(message: &str, candidates: Vec<Value>) -> NamedAddressError {
|
|
970
|
+
let mut err = NamedAddressError::new(NamedAddressErrorKind::NameAmbiguous, message);
|
|
971
|
+
err.action = "Use an explicit <workspace>::<team>/<agent> or <team>/<agent> name".to_string();
|
|
972
|
+
err.log = format!("{} candidates matched", candidates.len());
|
|
973
|
+
err.candidates = candidates;
|
|
974
|
+
err
|
|
975
|
+
}
|