@team-agent/installer 0.5.43 → 0.5.45
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 +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/emit.rs +44 -33
- package/crates/team-agent/src/cli/mod.rs +99 -9
- package/crates/team-agent/src/cli/named_address.rs +206 -4
- package/crates/team-agent/src/cli/send.rs +105 -2
- package/crates/team-agent/src/cli/spec.rs +1 -1
- package/crates/team-agent/src/cli/tests/run_delegation.rs +7 -4
- package/crates/team-agent/src/coordinator/steps/abnormal.rs +4 -4
- package/crates/team-agent/src/layout/worker_env.rs +35 -0
- package/crates/team-agent/src/leader/lease.rs +40 -16
- package/crates/team-agent/src/leader/provider_attribution.rs +25 -6
- package/crates/team-agent/src/leader/registry.rs +1 -1
- package/crates/team-agent/src/lifecycle/launch.rs +49 -14
- package/crates/team-agent/src/lifecycle/restart/common.rs +6 -2
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +3 -0
- package/crates/team-agent/src/mcp_server/tools.rs +67 -8
- package/crates/team-agent/src/model/mod.rs +6 -0
- package/crates/team-agent/src/model/name_similarity.rs +266 -0
- package/crates/team-agent/src/platform/process.rs +33 -2
- package/crates/team-agent/src/state/persist.rs +28 -0
- package/crates/team-agent/src/state/repository.rs +7 -0
- package/crates/team-agent/src/topology.rs +13 -0
- package/package.json +4 -4
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -308,7 +308,24 @@ fn command_help(command: Option<&str>) -> String {
|
|
|
308
308
|
Some("quick-start") => "usage: team-agent quick-start [TEAMDIR] [--workspace WORKSPACE] [--name NAME] [--team-id TEAM|--team TEAM] [--yes] [--no-display] [--backend tmux|conpty] [--json]\n\ndefaults: display_backend=adaptive; set display_backend: none in TEAM.md or pass --no-display to use one worker window per agent.\n\n--backend selects the worker transport (Phase 1d Batch 2): tmux (default on POSIX; unchanged behavior), conpty (Windows-native ConPTY worker transport; requires the shim binary and Windows host).".to_string(),
|
|
309
309
|
Some("start") => compat_hidden_help("start", "usage: team-agent start [TEAMDIR] [--yes] [--fresh] [--json]"),
|
|
310
310
|
Some("compile") => "usage: team-agent compile --team TEAM [--out FILE] [--json]".to_string(),
|
|
311
|
-
Some("send") =>
|
|
311
|
+
Some("send") => concat!(
|
|
312
|
+
"usage: team-agent send TARGET MESSAGE... ",
|
|
313
|
+
"[--workspace WORKSPACE] [--team TEAM] [--targets AGENTS] ",
|
|
314
|
+
"[--to-name NAME] [--pane PANE] [--task TASK] [--sender SENDER] ",
|
|
315
|
+
"[--watch-result] [--requires-ack|--no-ack] [--no-wait] ",
|
|
316
|
+
"[--timeout SECONDS] [--confirm-human] [--message-id ID] [--json]\n\n",
|
|
317
|
+
"TARGET is a short id scoped by --team; MCP `to` is a short id scoped ",
|
|
318
|
+
"by the worker's owner team.\n",
|
|
319
|
+
"--to-name accepts: --to-name AGENT, --to-name TEAM/AGENT, or ",
|
|
320
|
+
"--to-name WORKSPACE::TEAM/AGENT.\n",
|
|
321
|
+
"--team scopes only a bare --to-name AGENT; qualified forms keep the ",
|
|
322
|
+
"scope in the address.\n",
|
|
323
|
+
"TEAM/AGENT and WORKSPACE::TEAM/AGENT are not valid positional TARGET ",
|
|
324
|
+
"or MCP `to` values.\n\n",
|
|
325
|
+
"MVP: name-based cross-workspace addressing assumes trusted local ",
|
|
326
|
+
"caller; no auth gate."
|
|
327
|
+
)
|
|
328
|
+
.to_string(),
|
|
312
329
|
Some("allow-peer-talk") => "usage: team-agent allow-peer-talk A B [--workspace WORKSPACE] [--json]".to_string(),
|
|
313
330
|
Some("status") => "usage: team-agent status [AGENT] [--workspace WORKSPACE] [--team TEAM] [--summary|--json] [--detail]\n\n默认输出: worker,空闲|工作|错误;错误细分走 status --summary".to_string(),
|
|
314
331
|
Some("stop") => compat_hidden_help("stop", "usage: team-agent stop [--workspace WORKSPACE] [--team TEAM] [--keep-logs] [--json]"),
|
|
@@ -357,43 +374,33 @@ fn emit_unknown_subcommand_usage(command: &str) -> ExitCode {
|
|
|
357
374
|
if let Some(suggestion) = nearest_subcommand(command) {
|
|
358
375
|
eprintln!("team-agent: did you mean `{suggestion}`?");
|
|
359
376
|
}
|
|
360
|
-
|
|
377
|
+
// 0.5.45 naming-addressing (RED-6): unknown subcommand exits 1
|
|
378
|
+
// (Error), aligned with the family of typo refusals throughout
|
|
379
|
+
// send/named. Pre-0.5.45 mapped to Usage (2) argparse-style; the
|
|
380
|
+
// shared refusal shape ("typo diagnostic + advisory suggestion +
|
|
381
|
+
// exit 1") is the invariant callers of `team-agent send` /
|
|
382
|
+
// `--to-name` also see, so keeping unknown-subcommand at 2 was
|
|
383
|
+
// internal drift.
|
|
384
|
+
ExitCode::Error
|
|
361
385
|
}
|
|
362
386
|
|
|
363
|
-
/// 在已知子命令里找与 `input`
|
|
387
|
+
/// 在已知子命令里找与 `input` 最接近的一个。0.5.45 naming-addressing
|
|
388
|
+
/// (design §3.2/§4.1) 抽公 shared `model::name_similarity` — 距离
|
|
389
|
+
/// 阈值与排序规则跟 CLI `--to-name` typo suggestion 走同一份纯函数,
|
|
390
|
+
/// 避免两套 fuzzy 逻辑漂移。既有 `statu -> status` 行为保留(RED-6
|
|
391
|
+
/// grep guard + prefix-hit priority)。
|
|
364
392
|
fn nearest_subcommand(input: &str) -> Option<&'static str> {
|
|
365
|
-
|
|
393
|
+
use crate::model::name_similarity::{rank, Candidate};
|
|
394
|
+
let candidates: Vec<Candidate<&'static str>> = COMMAND_SPECS
|
|
366
395
|
.iter()
|
|
367
396
|
.filter(|spec| spec.suggestion_index)
|
|
368
|
-
.map(|spec|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
candidates
|
|
376
|
-
.map(|c| (c, levenshtein(input, c)))
|
|
377
|
-
.filter(|(_, d)| *d <= max_distance)
|
|
378
|
-
.min_by_key(|(_, d)| *d)
|
|
379
|
-
.map(|(c, _)| c)
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
/// 标准 Levenshtein 编辑距离(纯函数,无依赖;子命令建议用)。
|
|
383
|
-
fn levenshtein(a: &str, b: &str) -> usize {
|
|
384
|
-
let a: Vec<char> = a.chars().collect();
|
|
385
|
-
let b: Vec<char> = b.chars().collect();
|
|
386
|
-
let mut prev: Vec<usize> = (0..=b.len()).collect();
|
|
387
|
-
let mut curr = vec![0usize; b.len() + 1];
|
|
388
|
-
for (i, &ca) in a.iter().enumerate() {
|
|
389
|
-
curr[0] = i + 1;
|
|
390
|
-
for (j, &cb) in b.iter().enumerate() {
|
|
391
|
-
let cost = if ca == cb { 0 } else { 1 };
|
|
392
|
-
curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(prev[j] + cost);
|
|
393
|
-
}
|
|
394
|
-
std::mem::swap(&mut prev, &mut curr);
|
|
395
|
-
}
|
|
396
|
-
prev[b.len()]
|
|
397
|
+
.map(|spec| Candidate {
|
|
398
|
+
match_key: spec.name.to_string(),
|
|
399
|
+
stable_key: spec.name.to_string(),
|
|
400
|
+
payload: spec.name,
|
|
401
|
+
})
|
|
402
|
+
.collect();
|
|
403
|
+
rank(input, &candidates).into_iter().next()
|
|
397
404
|
}
|
|
398
405
|
|
|
399
406
|
fn emit_usage_error(message: &str) {
|
|
@@ -2011,6 +2018,10 @@ mod tests {
|
|
|
2011
2018
|
|
|
2012
2019
|
#[test]
|
|
2013
2020
|
fn e8_levenshtein_basic() {
|
|
2021
|
+
// 0.5.45 naming-addressing: distance function moved to
|
|
2022
|
+
// `crate::model::name_similarity` (shared with --to-name typo
|
|
2023
|
+
// suggestions). Same math, single source.
|
|
2024
|
+
use crate::model::name_similarity::levenshtein;
|
|
2014
2025
|
assert_eq!(levenshtein("kitten", "sitting"), 3);
|
|
2015
2026
|
assert_eq!(levenshtein("status", "status"), 0);
|
|
2016
2027
|
assert_eq!(levenshtein("statu", "status"), 1);
|
|
@@ -4100,7 +4100,7 @@ pub mod leader_port {
|
|
|
4100
4100
|
"action": "rerun with --confirm to claim ownership of this team",
|
|
4101
4101
|
}));
|
|
4102
4102
|
}
|
|
4103
|
-
let state = crate::state::persist::
|
|
4103
|
+
let state = crate::state::persist::load_runtime_state_without_migrations(workspace)
|
|
4104
4104
|
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
4105
4105
|
let Some(team_id) = resolve_owner_team_id(&state, team) else {
|
|
4106
4106
|
return Ok(json!({
|
|
@@ -4112,8 +4112,7 @@ pub mod leader_port {
|
|
|
4112
4112
|
}));
|
|
4113
4113
|
};
|
|
4114
4114
|
let requested_team = team;
|
|
4115
|
-
let
|
|
4116
|
-
let resolved_team = explicit_team.map(|_| team_id.as_str().to_string());
|
|
4115
|
+
let resolved_team = Some(team_id.as_str().to_string());
|
|
4117
4116
|
if !positive_caller_pane_env_present() {
|
|
4118
4117
|
let bind = crate::leader::bind_owner_from_caller_pane(workspace, &team_id, None)
|
|
4119
4118
|
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
@@ -4124,6 +4123,7 @@ pub mod leader_port {
|
|
|
4124
4123
|
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
4125
4124
|
let mut value = lease_value(result);
|
|
4126
4125
|
insert_resolved_team(&mut value, requested_team, resolved_team.as_deref());
|
|
4126
|
+
append_team_session_ready(workspace, resolved_team.as_deref(), &mut value);
|
|
4127
4127
|
if value.get("ok").and_then(Value::as_bool) == Some(true) {
|
|
4128
4128
|
emit_topology_convergence_event(
|
|
4129
4129
|
workspace,
|
|
@@ -4137,6 +4137,7 @@ pub mod leader_port {
|
|
|
4137
4137
|
"takeover",
|
|
4138
4138
|
&mut value,
|
|
4139
4139
|
);
|
|
4140
|
+
restore_non_target_teams(workspace, &state, resolved_team.as_deref());
|
|
4140
4141
|
}
|
|
4141
4142
|
Ok(value)
|
|
4142
4143
|
}
|
|
@@ -4146,7 +4147,7 @@ pub mod leader_port {
|
|
|
4146
4147
|
team: Option<&str>,
|
|
4147
4148
|
confirm: bool,
|
|
4148
4149
|
) -> Result<Value, CliError> {
|
|
4149
|
-
let state = crate::state::persist::
|
|
4150
|
+
let state = crate::state::persist::load_runtime_state_without_migrations(workspace)
|
|
4150
4151
|
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
4151
4152
|
let Some(team_id) = resolve_owner_team_id(&state, team) else {
|
|
4152
4153
|
return Ok(json!({
|
|
@@ -4165,12 +4166,12 @@ pub mod leader_port {
|
|
|
4165
4166
|
}
|
|
4166
4167
|
return Ok(owner_bind_value(bind));
|
|
4167
4168
|
}
|
|
4168
|
-
let
|
|
4169
|
-
let resolved_team = explicit_team.map(|_| team_id.as_str().to_string());
|
|
4169
|
+
let resolved_team = Some(team_id.as_str().to_string());
|
|
4170
4170
|
let result = crate::leader::claim_leader(workspace, resolved_team.as_deref(), confirm)
|
|
4171
4171
|
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
4172
4172
|
let mut value = lease_value(result);
|
|
4173
4173
|
insert_resolved_team(&mut value, team, resolved_team.as_deref());
|
|
4174
|
+
append_team_session_ready(workspace, resolved_team.as_deref(), &mut value);
|
|
4174
4175
|
if value.get("ok").and_then(Value::as_bool) == Some(true) {
|
|
4175
4176
|
emit_topology_convergence_event(
|
|
4176
4177
|
workspace,
|
|
@@ -4184,6 +4185,7 @@ pub mod leader_port {
|
|
|
4184
4185
|
"claim-leader",
|
|
4185
4186
|
&mut value,
|
|
4186
4187
|
);
|
|
4188
|
+
restore_non_target_teams(workspace, &state, resolved_team.as_deref());
|
|
4187
4189
|
}
|
|
4188
4190
|
Ok(value)
|
|
4189
4191
|
}
|
|
@@ -4279,6 +4281,7 @@ pub mod leader_port {
|
|
|
4279
4281
|
"owner_epoch": convergence.get("owner_epoch").cloned().unwrap_or(Value::Null),
|
|
4280
4282
|
"persisted": convergence.get("persisted").cloned().unwrap_or(Value::Bool(false)),
|
|
4281
4283
|
"checked_paths": convergence.get("checked_paths").cloned().unwrap_or_else(|| json!([])),
|
|
4284
|
+
"team_session_ready": response.get("team_session_ready").cloned().unwrap_or(Value::Null),
|
|
4282
4285
|
}),
|
|
4283
4286
|
);
|
|
4284
4287
|
}
|
|
@@ -4344,9 +4347,96 @@ pub mod leader_port {
|
|
|
4344
4347
|
| crate::state::projection::OwnerTeamResolution::Ambiguous { .. } => None,
|
|
4345
4348
|
}
|
|
4346
4349
|
}
|
|
4347
|
-
None => Some(TeamKey::new(
|
|
4348
|
-
|
|
4349
|
-
|
|
4350
|
+
None => Some(TeamKey::new(active_or_derived_team_key(state))),
|
|
4351
|
+
}
|
|
4352
|
+
}
|
|
4353
|
+
|
|
4354
|
+
fn active_or_derived_team_key(state: &Value) -> String {
|
|
4355
|
+
state
|
|
4356
|
+
.get("active_team_key")
|
|
4357
|
+
.and_then(Value::as_str)
|
|
4358
|
+
.filter(|team| !team.is_empty())
|
|
4359
|
+
.map(str::to_string)
|
|
4360
|
+
.unwrap_or_else(|| crate::state::projection::team_state_key(state))
|
|
4361
|
+
}
|
|
4362
|
+
|
|
4363
|
+
fn append_team_session_ready(workspace: &Path, team: Option<&str>, response: &mut Value) {
|
|
4364
|
+
let Some(obj) = response.as_object_mut() else {
|
|
4365
|
+
return;
|
|
4366
|
+
};
|
|
4367
|
+
let Some(convergence) = obj.get("topology_convergence") else {
|
|
4368
|
+
return;
|
|
4369
|
+
};
|
|
4370
|
+
if convergence.get("status").and_then(Value::as_str) != Some("converged") {
|
|
4371
|
+
return;
|
|
4372
|
+
}
|
|
4373
|
+
let Some(endpoint) = convergence
|
|
4374
|
+
.get("new_tmux_endpoint")
|
|
4375
|
+
.and_then(Value::as_str)
|
|
4376
|
+
.filter(|endpoint| !endpoint.is_empty())
|
|
4377
|
+
else {
|
|
4378
|
+
obj.insert("team_session_ready".to_string(), Value::Null);
|
|
4379
|
+
return;
|
|
4380
|
+
};
|
|
4381
|
+
let session_name = crate::state::persist::load_runtime_state_without_migrations(workspace)
|
|
4382
|
+
.ok()
|
|
4383
|
+
.and_then(|state| target_team_session_name(&state, team));
|
|
4384
|
+
let ready = session_name
|
|
4385
|
+
.as_deref()
|
|
4386
|
+
.and_then(|session| crate::topology::team_session_ready_on_endpoint(endpoint, session));
|
|
4387
|
+
obj.insert(
|
|
4388
|
+
"team_session_ready".to_string(),
|
|
4389
|
+
ready.map(Value::Bool).unwrap_or(Value::Null),
|
|
4390
|
+
);
|
|
4391
|
+
}
|
|
4392
|
+
|
|
4393
|
+
fn target_team_session_name(state: &Value, team: Option<&str>) -> Option<String> {
|
|
4394
|
+
team.and_then(|team| {
|
|
4395
|
+
state
|
|
4396
|
+
.get("teams")
|
|
4397
|
+
.and_then(Value::as_object)
|
|
4398
|
+
.and_then(|teams| teams.get(team))
|
|
4399
|
+
})
|
|
4400
|
+
.and_then(|team_state| team_state.get("session_name"))
|
|
4401
|
+
.and_then(Value::as_str)
|
|
4402
|
+
.filter(|session| !session.is_empty())
|
|
4403
|
+
.or_else(|| state.get("session_name").and_then(Value::as_str))
|
|
4404
|
+
.filter(|session| !session.is_empty())
|
|
4405
|
+
.map(str::to_string)
|
|
4406
|
+
}
|
|
4407
|
+
|
|
4408
|
+
fn restore_non_target_teams(workspace: &Path, before: &Value, target: Option<&str>) {
|
|
4409
|
+
let Some(target) = target.filter(|team| !team.is_empty()) else {
|
|
4410
|
+
return;
|
|
4411
|
+
};
|
|
4412
|
+
let Some(before_teams) = before.get("teams").and_then(Value::as_object) else {
|
|
4413
|
+
return;
|
|
4414
|
+
};
|
|
4415
|
+
let Ok(mut latest) =
|
|
4416
|
+
crate::state::persist::load_runtime_state_without_migrations(workspace)
|
|
4417
|
+
else {
|
|
4418
|
+
return;
|
|
4419
|
+
};
|
|
4420
|
+
let Some(latest_teams) = latest.get_mut("teams").and_then(Value::as_object_mut) else {
|
|
4421
|
+
return;
|
|
4422
|
+
};
|
|
4423
|
+
let mut changed = false;
|
|
4424
|
+
for (team, before_entry) in before_teams {
|
|
4425
|
+
if team == target {
|
|
4426
|
+
continue;
|
|
4427
|
+
}
|
|
4428
|
+
if latest_teams.get(team) != Some(before_entry) {
|
|
4429
|
+
latest_teams.insert(team.clone(), before_entry.clone());
|
|
4430
|
+
changed = true;
|
|
4431
|
+
}
|
|
4432
|
+
}
|
|
4433
|
+
if changed {
|
|
4434
|
+
let _ = crate::state::repository::StateRepository::new(workspace).save(
|
|
4435
|
+
crate::state::repository::StateWriteIntent::LeaderBindingRestoreNonTargetTeams {
|
|
4436
|
+
target_team_key: target,
|
|
4437
|
+
},
|
|
4438
|
+
&latest,
|
|
4439
|
+
);
|
|
4350
4440
|
}
|
|
4351
4441
|
}
|
|
4352
4442
|
|
|
@@ -61,6 +61,11 @@ pub(crate) struct NamedAddressError {
|
|
|
61
61
|
pub requested_team: Option<String>,
|
|
62
62
|
pub available_team_keys: Vec<String>,
|
|
63
63
|
pub suggested_name: Option<String>,
|
|
64
|
+
/// 0.5.45 naming-addressing (design §3.4, RED-2/RED-3): echo the
|
|
65
|
+
/// user's original typo so JSON consumers + N38 human can pair
|
|
66
|
+
/// `requested_name`/`suggested_name` without re-parsing. Absent
|
|
67
|
+
/// for exact refusals like empty-workspace.
|
|
68
|
+
pub requested_name: Option<String>,
|
|
64
69
|
}
|
|
65
70
|
|
|
66
71
|
impl NamedAddressError {
|
|
@@ -74,18 +79,149 @@ impl NamedAddressError {
|
|
|
74
79
|
requested_team: None,
|
|
75
80
|
available_team_keys: Vec::new(),
|
|
76
81
|
suggested_name: None,
|
|
82
|
+
requested_name: None,
|
|
77
83
|
}
|
|
78
84
|
}
|
|
79
85
|
|
|
80
86
|
pub(crate) fn n38_message(&self) -> String {
|
|
87
|
+
// 0.5.45 naming-addressing (design §3.4, RED-3): the N38 first
|
|
88
|
+
// line embeds the human-readable `message` (which contains
|
|
89
|
+
// the original typo like `qa-namng`/`btea`) after the reason.
|
|
90
|
+
// Pre-0.5.45 the line was `Error: <reason>` only; the typo
|
|
91
|
+
// hid in the JSON-only `error` field, so the human line
|
|
92
|
+
// couldn't be scanned to recover the original request.
|
|
81
93
|
format!(
|
|
82
|
-
"Error: {}\nAction: {}\nLog: {}",
|
|
94
|
+
"Error: {}: {}\nAction: {}\nLog: {}",
|
|
83
95
|
self.kind.as_str(),
|
|
96
|
+
self.message,
|
|
84
97
|
self.action,
|
|
85
98
|
self.log
|
|
86
99
|
)
|
|
87
100
|
}
|
|
88
101
|
|
|
102
|
+
/// 0.5.45 (design §3.4, RED-2 named-team): rank team-scope typo
|
|
103
|
+
/// against target workspace `teams.*` keys; candidate `name`
|
|
104
|
+
/// preserves the qualified `team/entity` shape so the copyable
|
|
105
|
+
/// token in Action can be pasted back into `--to-name` unchanged.
|
|
106
|
+
fn with_scoped_suggestions_for_team(
|
|
107
|
+
self,
|
|
108
|
+
state: &Value,
|
|
109
|
+
team_typo: &str,
|
|
110
|
+
entity: &str,
|
|
111
|
+
parsed: &ParsedNamedAddress,
|
|
112
|
+
) -> Self {
|
|
113
|
+
use crate::model::name_similarity::{rank, Candidate};
|
|
114
|
+
let candidates: Vec<Candidate<(String, String)>> = state
|
|
115
|
+
.get("teams")
|
|
116
|
+
.and_then(Value::as_object)
|
|
117
|
+
.map(|teams| {
|
|
118
|
+
teams
|
|
119
|
+
.keys()
|
|
120
|
+
.map(|team_key| Candidate {
|
|
121
|
+
match_key: team_key.clone(),
|
|
122
|
+
stable_key: team_key.clone(),
|
|
123
|
+
payload: (team_key.clone(), entity.to_string()),
|
|
124
|
+
})
|
|
125
|
+
.collect()
|
|
126
|
+
})
|
|
127
|
+
.unwrap_or_default();
|
|
128
|
+
let ranked = rank(team_typo, &candidates);
|
|
129
|
+
let requested_display = parsed.display_name();
|
|
130
|
+
let candidate_values: Vec<Value> = ranked
|
|
131
|
+
.iter()
|
|
132
|
+
.map(|(team_key, entity)| {
|
|
133
|
+
let name = format!("{team_key}/{entity}");
|
|
134
|
+
json!({
|
|
135
|
+
"name": name,
|
|
136
|
+
"team_key": team_key,
|
|
137
|
+
"agent_id": entity,
|
|
138
|
+
"advisory": true,
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
.collect();
|
|
142
|
+
let best = ranked
|
|
143
|
+
.first()
|
|
144
|
+
.map(|(team_key, entity)| format!("{team_key}/{entity}"));
|
|
145
|
+
self.with_scoped_suggestions(requested_display, candidate_values, best)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/// 0.5.45 (design §3.4, RED-2 named-agent): rank agent-scope typo
|
|
149
|
+
/// against the legal `teams[team].agents` keys.
|
|
150
|
+
fn with_scoped_suggestions_for_agent(
|
|
151
|
+
self,
|
|
152
|
+
team_entry: &Value,
|
|
153
|
+
team: &str,
|
|
154
|
+
agent_typo: &str,
|
|
155
|
+
parsed: &ParsedNamedAddress,
|
|
156
|
+
) -> Self {
|
|
157
|
+
use crate::model::name_similarity::{rank, Candidate};
|
|
158
|
+
let candidates: Vec<Candidate<String>> = team_entry
|
|
159
|
+
.get("agents")
|
|
160
|
+
.and_then(Value::as_object)
|
|
161
|
+
.map(|agents| {
|
|
162
|
+
agents
|
|
163
|
+
.keys()
|
|
164
|
+
.map(|agent_id| Candidate {
|
|
165
|
+
match_key: agent_id.clone(),
|
|
166
|
+
stable_key: agent_id.clone(),
|
|
167
|
+
payload: agent_id.clone(),
|
|
168
|
+
})
|
|
169
|
+
.collect()
|
|
170
|
+
})
|
|
171
|
+
.unwrap_or_default();
|
|
172
|
+
let ranked = rank(agent_typo, &candidates);
|
|
173
|
+
let requested_display = parsed.display_name();
|
|
174
|
+
let candidate_values: Vec<Value> = ranked
|
|
175
|
+
.iter()
|
|
176
|
+
.map(|agent_id| {
|
|
177
|
+
json!({
|
|
178
|
+
"name": format!("{team}/{agent_id}"),
|
|
179
|
+
"team_key": team,
|
|
180
|
+
"agent_id": agent_id,
|
|
181
|
+
"advisory": true,
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
.collect();
|
|
185
|
+
let best = ranked
|
|
186
|
+
.first()
|
|
187
|
+
.map(|agent_id| format!("{team}/{agent_id}"));
|
|
188
|
+
self.with_scoped_suggestions(requested_display, candidate_values, best)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/// 0.5.45 naming-addressing (design §3.3/§3.4): attach an
|
|
192
|
+
/// advisory suggestion payload. `requested` is the original typo
|
|
193
|
+
/// (surfaced verbatim in JSON + N38 Action); `ranked_candidates`
|
|
194
|
+
/// carry the scope-safe candidate payloads produced by
|
|
195
|
+
/// `model::name_similarity::rank`. The best candidate populates
|
|
196
|
+
/// `suggested_name` for convenience; the full list appears as
|
|
197
|
+
/// `candidates` in the JSON envelope.
|
|
198
|
+
pub(crate) fn with_scoped_suggestions(
|
|
199
|
+
mut self,
|
|
200
|
+
requested: impl Into<String>,
|
|
201
|
+
ranked_candidates: Vec<Value>,
|
|
202
|
+
best_copyable: Option<String>,
|
|
203
|
+
) -> Self {
|
|
204
|
+
let requested = requested.into();
|
|
205
|
+
self.requested_name = Some(requested.clone());
|
|
206
|
+
self.candidates = ranked_candidates;
|
|
207
|
+
self.suggested_name = best_copyable.clone();
|
|
208
|
+
// Rewrite Action so the human line points at the actual
|
|
209
|
+
// returned candidate (RED-4). Falls back to a status-JSON
|
|
210
|
+
// hint when no candidate cleared the threshold.
|
|
211
|
+
if let Some(best) = best_copyable {
|
|
212
|
+
self.action = format!(
|
|
213
|
+
"Did you mean `{best}`? Rerun with `--to-name {best}`."
|
|
214
|
+
);
|
|
215
|
+
} else {
|
|
216
|
+
self.action = format!(
|
|
217
|
+
"No close matches for `{requested}`. \
|
|
218
|
+
Run `team-agent status --json` for the current team_key + agent list \
|
|
219
|
+
and combine them as `<team>/<agent>` for `--to-name`."
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
self
|
|
223
|
+
}
|
|
224
|
+
|
|
89
225
|
pub(crate) fn to_json(&self) -> Value {
|
|
90
226
|
let mut obj = serde_json::Map::new();
|
|
91
227
|
obj.insert("ok".to_string(), Value::Bool(false));
|
|
@@ -124,6 +260,12 @@ impl NamedAddressError {
|
|
|
124
260
|
Value::String(suggested.to_string()),
|
|
125
261
|
);
|
|
126
262
|
}
|
|
263
|
+
if let Some(requested) = self.requested_name.as_deref() {
|
|
264
|
+
obj.insert(
|
|
265
|
+
"requested_name".to_string(),
|
|
266
|
+
Value::String(requested.to_string()),
|
|
267
|
+
);
|
|
268
|
+
}
|
|
127
269
|
Value::Object(obj)
|
|
128
270
|
}
|
|
129
271
|
}
|
|
@@ -261,8 +403,20 @@ pub(crate) fn resolve_name_with_workspace_transports(
|
|
|
261
403
|
pub(crate) fn resolve_name_for_cli(
|
|
262
404
|
sender_workspace: &Path,
|
|
263
405
|
raw_name: &str,
|
|
406
|
+
bare_team_scope: Option<&str>,
|
|
264
407
|
) -> Result<(ResolvedNamedAddress, Box<dyn Transport>), NamedAddressError> {
|
|
265
408
|
let parsed = parse_named_address(raw_name)?;
|
|
409
|
+
// 0.5.45 naming-addressing (design §3.1, RED-1): bare `--to-name`
|
|
410
|
+
// + explicit `--team` normalizes to `ParsedTarget::TeamEntity`
|
|
411
|
+
// BEFORE transport selection. Only fires when:
|
|
412
|
+
// 1. address parsed as bare (no `team/`, no `workspace::`),
|
|
413
|
+
// 2. caller passed a non-empty `bare_team_scope`.
|
|
414
|
+
// Qualified addresses keep the scope in the address (design §1
|
|
415
|
+
// priority: workspace::team/entity > team/entity > bare + --team
|
|
416
|
+
// > bare workspace-unique). resolve_name_with_transport and the
|
|
417
|
+
// registry-to-E6 internal helper deliberately pass `None` so
|
|
418
|
+
// caller-supplied `--team` never overrides a full address.
|
|
419
|
+
let parsed = normalize_bare_with_team_scope(parsed, bare_team_scope);
|
|
266
420
|
let target_workspace = resolve_workspace(sender_workspace, parsed.workspace.as_deref())?;
|
|
267
421
|
let state = load_state_checked(&target_workspace)?;
|
|
268
422
|
let transport = transport_for_cli_target(&target_workspace, &state, &parsed);
|
|
@@ -276,6 +430,34 @@ pub(crate) fn resolve_name_for_cli(
|
|
|
276
430
|
Ok((resolved, transport))
|
|
277
431
|
}
|
|
278
432
|
|
|
433
|
+
/// 0.5.45 naming-addressing (design §3.1): apply bare-team scope归一.
|
|
434
|
+
/// Only bare targets with no workspace prefix consume `--team`;
|
|
435
|
+
/// qualified addresses keep the scope embedded in the address.
|
|
436
|
+
fn normalize_bare_with_team_scope(
|
|
437
|
+
parsed: ParsedNamedAddress,
|
|
438
|
+
bare_team_scope: Option<&str>,
|
|
439
|
+
) -> ParsedNamedAddress {
|
|
440
|
+
let Some(scope) = bare_team_scope.map(str::trim).filter(|s| !s.is_empty()) else {
|
|
441
|
+
return parsed;
|
|
442
|
+
};
|
|
443
|
+
if parsed.workspace.is_some() {
|
|
444
|
+
return parsed;
|
|
445
|
+
}
|
|
446
|
+
match parsed.target {
|
|
447
|
+
ParsedTarget::BareAgent(agent) => ParsedNamedAddress {
|
|
448
|
+
workspace: parsed.workspace,
|
|
449
|
+
target: ParsedTarget::TeamEntity {
|
|
450
|
+
team: scope.to_string(),
|
|
451
|
+
entity: agent,
|
|
452
|
+
},
|
|
453
|
+
},
|
|
454
|
+
target => ParsedNamedAddress {
|
|
455
|
+
workspace: parsed.workspace,
|
|
456
|
+
target,
|
|
457
|
+
},
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
279
461
|
/// E6 wiring helper (`cli::send::maybe_enqueue_offline_leader_mailbox`):
|
|
280
462
|
/// re-parse a `<workspace>::<team>/leader` name and return the resolved
|
|
281
463
|
/// target workspace + canonical team_key so send.rs can enqueue the
|
|
@@ -462,12 +644,32 @@ fn resolve_worker(
|
|
|
462
644
|
parsed: &ParsedNamedAddress,
|
|
463
645
|
transport: &dyn Transport,
|
|
464
646
|
) -> Result<ResolvedNamedAddress, NamedAddressError> {
|
|
465
|
-
let team_entry = team_entry(state, team)
|
|
466
|
-
|
|
647
|
+
let team_entry = match team_entry(state, team) {
|
|
648
|
+
Some(entry) => entry,
|
|
649
|
+
None => {
|
|
650
|
+
// 0.5.45 naming-addressing (design §3.4, RED-2 named):
|
|
651
|
+
// typo in team scope. Candidates come from the target
|
|
652
|
+
// workspace's `teams.*.*` — scope-safe because we're
|
|
653
|
+
// already inside the requested workspace projection.
|
|
654
|
+
return Err(name_not_resolvable_team(team)
|
|
655
|
+
.with_scoped_suggestions_for_team(state, team, agent, parsed));
|
|
656
|
+
}
|
|
657
|
+
};
|
|
658
|
+
let agent_entry = match team_entry
|
|
467
659
|
.get("agents")
|
|
468
660
|
.and_then(Value::as_object)
|
|
469
661
|
.and_then(|agents| agents.get(agent))
|
|
470
|
-
|
|
662
|
+
{
|
|
663
|
+
Some(entry) => entry,
|
|
664
|
+
None => {
|
|
665
|
+
// 0.5.45 naming-addressing (design §3.4, RED-2 named):
|
|
666
|
+
// typo in agent within a legal team. Candidates come from
|
|
667
|
+
// `teams[team].agents` — scope stays in the requested
|
|
668
|
+
// team, so sibling teams cannot leak.
|
|
669
|
+
return Err(name_not_resolvable_agent(team, agent)
|
|
670
|
+
.with_scoped_suggestions_for_agent(team_entry, team, agent, parsed));
|
|
671
|
+
}
|
|
672
|
+
};
|
|
471
673
|
let session = string_field(team_entry, "session_name")
|
|
472
674
|
.ok_or_else(|| name_not_resolvable("team is missing session_name"))?;
|
|
473
675
|
let window = string_field(agent_entry, "window")
|