@team-agent/installer 0.5.94 → 0.5.98
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/adapters.rs +381 -214
- package/crates/team-agent/src/cli/diagnose.rs +40 -10
- package/crates/team-agent/src/cli/emit.rs +6 -26
- package/crates/team-agent/src/cli/mod.rs +114 -30
- package/crates/team-agent/src/cli/spec.rs +2 -5
- package/crates/team-agent/src/cli/tests/base.rs +20 -10
- package/crates/team-agent/src/cli/tests/divergence.rs +12 -21
- package/crates/team-agent/src/cli/tests/main_preserved.rs +6 -1
- package/crates/team-agent/src/cli/tests/missing_subcommands.rs +20 -11
- package/crates/team-agent/src/cli/tests/mod.rs +0 -1
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +43 -0
- package/crates/team-agent/src/cli/triage.rs +31 -31
- package/crates/team-agent/src/cli/types.rs +3 -9
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +26 -0
- package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +10 -4
- package/crates/team-agent/src/lifecycle/tests/restart_build_before_destroy_0540_contract.rs +128 -11
- package/package.json +4 -4
- package/skills/team-agent/references/team-agent-operator.md +20 -28
- package/crates/team-agent/src/cli/tests/compile.rs +0 -192
|
@@ -161,6 +161,19 @@ pub(crate) fn diagnose_runtime(state: &Value, backend: &dyn Transport) -> (Value
|
|
|
161
161
|
}
|
|
162
162
|
issues.push(issue);
|
|
163
163
|
}
|
|
164
|
+
// Preserve distinct worker failures even when the shared session probe
|
|
165
|
+
// short-circuits pane enumeration. This keeps scope evidence without
|
|
166
|
+
// issuing another transport probe per worker.
|
|
167
|
+
if let Some(agents) = state.get("agents").and_then(Value::as_object) {
|
|
168
|
+
for agent_id in agents.keys() {
|
|
169
|
+
let issue = format!("worker_window_missing:{agent_id}");
|
|
170
|
+
issues.push(json!(issue.clone()));
|
|
171
|
+
repairs.push(json!({
|
|
172
|
+
"issue": issue,
|
|
173
|
+
"action": "team-agent restart",
|
|
174
|
+
}));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
164
177
|
return (Value::Array(issues), Value::Array(repairs));
|
|
165
178
|
}
|
|
166
179
|
|
|
@@ -289,6 +302,7 @@ pub(crate) fn diagnose_runtime_for_workspace(
|
|
|
289
302
|
state: &Value,
|
|
290
303
|
backend: &dyn Transport,
|
|
291
304
|
selected_team_key: Option<&str>,
|
|
305
|
+
coordinator_health: &crate::coordinator::HealthReport,
|
|
292
306
|
) -> (Value, Value) {
|
|
293
307
|
let (mut issues, mut repairs) = diagnose_runtime(state, backend);
|
|
294
308
|
append_live_leader_workspace_mismatch_issue(
|
|
@@ -306,7 +320,7 @@ pub(crate) fn diagnose_runtime_for_workspace(
|
|
|
306
320
|
&mut repairs,
|
|
307
321
|
);
|
|
308
322
|
append_legacy_snapshot_issue(workspace, state, &mut issues);
|
|
309
|
-
append_coordinator_health_issue(workspace, state, &mut issues, &mut repairs);
|
|
323
|
+
append_coordinator_health_issue(workspace, state, coordinator_health, &mut issues, &mut repairs);
|
|
310
324
|
append_runtime_bindings_stale_after_boot_issue(workspace, state, &mut issues, &mut repairs);
|
|
311
325
|
(issues, repairs)
|
|
312
326
|
}
|
|
@@ -322,8 +336,27 @@ pub(crate) fn workspace_has_existing_team_runtime(
|
|
|
322
336
|
workspace: &std::path::Path,
|
|
323
337
|
team_key: &str,
|
|
324
338
|
) -> bool {
|
|
325
|
-
|
|
326
|
-
|
|
339
|
+
// A source TEAM.md/team.spec.yaml is install/configuration context, not a
|
|
340
|
+
// live runtime. Only persisted runtime state/specs make topology checks
|
|
341
|
+
// applicable; an empty workspace therefore remains a healthy no-runtime
|
|
342
|
+
// report instead of a fabricated missing-session issue.
|
|
343
|
+
if let Ok(state) = crate::state::persist::load_runtime_state_without_migrations(workspace) {
|
|
344
|
+
// The workspace's top-level view may describe a stopped sibling.
|
|
345
|
+
// Preserve the top-level fallback for legacy single-team state only
|
|
346
|
+
// when there is no selected team entry.
|
|
347
|
+
let team_state = state
|
|
348
|
+
.get("teams")
|
|
349
|
+
.and_then(|teams| teams.get(team_key))
|
|
350
|
+
.unwrap_or(&state);
|
|
351
|
+
let terminal = team_state
|
|
352
|
+
.get("status")
|
|
353
|
+
.and_then(Value::as_str)
|
|
354
|
+
.is_some_and(|status| matches!(status, "stopped" | "shutdown" | "archived" | "terminal"));
|
|
355
|
+
if terminal {
|
|
356
|
+
return false;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
crate::model::paths::runtime_spec_path(workspace, team_key).is_file()
|
|
327
360
|
|| crate::state::persist::runtime_state_path(workspace).is_file()
|
|
328
361
|
}
|
|
329
362
|
|
|
@@ -790,21 +823,18 @@ fn append_legacy_snapshot_issue(workspace: &std::path::Path, state: &Value, issu
|
|
|
790
823
|
fn append_coordinator_health_issue(
|
|
791
824
|
workspace: &std::path::Path,
|
|
792
825
|
state: &Value,
|
|
826
|
+
health: &crate::coordinator::HealthReport,
|
|
793
827
|
issues: &mut Value,
|
|
794
828
|
repairs: &mut Value,
|
|
795
829
|
) {
|
|
796
|
-
|
|
797
|
-
// `.team/runtime/team.db`, including when the file is missing or empty.
|
|
798
|
-
let workspace = crate::coordinator::WorkspacePath::new(workspace.to_path_buf());
|
|
799
|
-
let health = crate::coordinator::coordinator_health_read_only(&workspace);
|
|
800
|
-
let Some(id) = coordinator_issue_id(state, &health) else {
|
|
830
|
+
let Some(id) = coordinator_issue_id(state, health) else {
|
|
801
831
|
return;
|
|
802
832
|
};
|
|
803
833
|
if let Some(items) = issues.as_array_mut() {
|
|
804
|
-
items.push(coordinator_issue_value(id,
|
|
834
|
+
items.push(coordinator_issue_value(id, health, workspace));
|
|
805
835
|
}
|
|
806
836
|
if let Some(items) = repairs.as_array_mut() {
|
|
807
|
-
items.push(coordinator_repair_hint(id,
|
|
837
|
+
items.push(coordinator_repair_hint(id, health));
|
|
808
838
|
}
|
|
809
839
|
}
|
|
810
840
|
|
|
@@ -174,7 +174,6 @@ fn dispatch(command: &str, args: &[String], cwd: &Path) -> Result<ExitCode, CliE
|
|
|
174
174
|
match command {
|
|
175
175
|
"init" => cmd_init(&init_args(args, cwd)).map(emit_result),
|
|
176
176
|
"quick-start" => cmd_quick_start(&quick_start_args(args, cwd)?).map(emit_result),
|
|
177
|
-
"compile" => cmd_compile(&compile_args(args, cwd)?).map(emit_result),
|
|
178
177
|
"send" => cmd_send(&send_args(args, cwd)?).map(emit_result),
|
|
179
178
|
"allow-peer-talk" => {
|
|
180
179
|
cmd_allow_peer_talk(&allow_peer_talk_args(args, cwd)?).map(emit_result)
|
|
@@ -225,7 +224,8 @@ fn dispatch(command: &str, args: &[String], cwd: &Path) -> Result<ExitCode, CliE
|
|
|
225
224
|
"profile" => cmd_profile(&profile_args(args, cwd)?).map(emit_result),
|
|
226
225
|
"results" => cmd_results(&results_args(args, cwd)?).map(emit_result),
|
|
227
226
|
"wait" => cmd_wait(&wait_args(args, cwd)?).map(emit_result),
|
|
228
|
-
|
|
227
|
+
// Compatibility spelling: parse and execute the exact doctor contract.
|
|
228
|
+
"diagnose" => cmd_doctor(&doctor_args(args, cwd)).map(emit_result),
|
|
229
229
|
"preflight" => cmd_preflight(&preflight_args(args, cwd)).map(emit_result),
|
|
230
230
|
"wait-ready" => cmd_wait_ready(&wait_ready_args(args, cwd)).map(emit_result),
|
|
231
231
|
"e2e" => cmd_e2e(&e2e_args(args, cwd)).map(emit_result),
|
|
@@ -238,7 +238,6 @@ fn dispatch(command: &str, args: &[String], cwd: &Path) -> Result<ExitCode, CliE
|
|
|
238
238
|
const DISPATCH_COMMANDS: &[&str] = &[
|
|
239
239
|
"init",
|
|
240
240
|
"quick-start",
|
|
241
|
-
"compile",
|
|
242
241
|
"send",
|
|
243
242
|
"allow-peer-talk",
|
|
244
243
|
"status",
|
|
@@ -340,7 +339,7 @@ pub(crate) fn default_help() -> String {
|
|
|
340
339
|
"reset-agent",
|
|
341
340
|
],
|
|
342
341
|
);
|
|
343
|
-
append_help_section(&mut out, "Diagnose", &["
|
|
342
|
+
append_help_section(&mut out, "Diagnose", &["doctor"]);
|
|
344
343
|
append_help_section(
|
|
345
344
|
&mut out,
|
|
346
345
|
"Guided recovery",
|
|
@@ -400,7 +399,6 @@ fn command_help(command: Option<&str>) -> String {
|
|
|
400
399
|
Some("init") => compat_hidden_help("init", "usage: team-agent init [--workspace WORKSPACE] [--force] [--json]"),
|
|
401
400
|
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] [--detail]\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).\n\n--detail includes internal receiver/topology diagnostics in JSON output.\n\nAfter a successful start, use the returned `send_commands` (or choose an agent explicitly) with `team-agent send AGENT MESSAGE`.".to_string(),
|
|
402
401
|
Some("start") => compat_hidden_help("start", "usage: team-agent start [TEAMDIR] [--yes] [--fresh] [--json]"),
|
|
403
|
-
Some("compile") => "usage: team-agent compile --team TEAM [--out FILE] [--json]".to_string(),
|
|
404
402
|
Some("send") => concat!(
|
|
405
403
|
"usage: team-agent send TO MESSAGE... ",
|
|
406
404
|
"[--workspace WORKSPACE] [--team TEAM] ",
|
|
@@ -444,7 +442,7 @@ fn command_help(command: Option<&str>) -> String {
|
|
|
444
442
|
Some("profile") => "usage: team-agent profile COMMAND NAME [--workspace WORKSPACE] [--team TEAM] [--auth-mode MODE] [--proxy-mode direct|inherit] [--json]".to_string(),
|
|
445
443
|
Some("results") => "usage: team-agent results --case CASE_ID [--workspace WORKSPACE] [--team TEAM] [--json]".to_string(),
|
|
446
444
|
Some("wait") => "usage: team-agent wait --task TASK [--workspace WORKSPACE] [--json]".to_string(),
|
|
447
|
-
Some("diagnose") => "usage: team-agent
|
|
445
|
+
Some("diagnose") => "usage: team-agent doctor [SPEC] [--workspace WORKSPACE] [--team TEAM] [--gate orphans|comms] [--comms] [--fix] [--fix-schema] [--cleanup-orphans] [--confirm] [--json]".to_string(),
|
|
448
446
|
Some("preflight") => "usage: team-agent preflight [TEAMDIR] [--json]".to_string(),
|
|
449
447
|
Some("wait-ready") => "usage: team-agent wait-ready [--workspace WORKSPACE] [--team TEAM] [--timeout SECONDS] [--json]".to_string(),
|
|
450
448
|
Some("e2e") => "usage: team-agent e2e [--workspace WORKSPACE] [--providers LIST] [--real] [--json]".to_string(),
|
|
@@ -852,7 +850,6 @@ struct ParsedArgs {
|
|
|
852
850
|
result: Option<String>,
|
|
853
851
|
real: bool,
|
|
854
852
|
assignee: Option<String>,
|
|
855
|
-
out: Option<PathBuf>,
|
|
856
853
|
auth_mode: Option<String>,
|
|
857
854
|
proxy_mode: Option<String>,
|
|
858
855
|
pane: Option<String>,
|
|
@@ -949,7 +946,6 @@ fn parse_args(args: &[String]) -> ParsedArgs {
|
|
|
949
946
|
"--result" => parsed.result = next_arg(args, &mut i),
|
|
950
947
|
"--real" => parsed.real = true,
|
|
951
948
|
"--assignee" => parsed.assignee = next_arg(args, &mut i),
|
|
952
|
-
"--out" => parsed.out = next_arg(args, &mut i).map(PathBuf::from),
|
|
953
949
|
"--auth-mode" => parsed.auth_mode = next_arg(args, &mut i),
|
|
954
950
|
"--proxy-mode" => parsed.proxy_mode = next_arg(args, &mut i),
|
|
955
951
|
"--pane" => parsed.pane = next_arg(args, &mut i),
|
|
@@ -1144,23 +1140,6 @@ fn init_args(args: &[String], cwd: &Path) -> InitArgs {
|
|
|
1144
1140
|
}
|
|
1145
1141
|
}
|
|
1146
1142
|
|
|
1147
|
-
fn compile_args(args: &[String], cwd: &Path) -> Result<CompileArgs, CliError> {
|
|
1148
|
-
let parsed = parse_args(args);
|
|
1149
|
-
let team = parsed
|
|
1150
|
-
.team
|
|
1151
|
-
.as_deref()
|
|
1152
|
-
.map(PathBuf::from)
|
|
1153
|
-
.ok_or_else(|| CliError::Usage("missing --team".to_string()))?;
|
|
1154
|
-
let out = parsed
|
|
1155
|
-
.out
|
|
1156
|
-
.unwrap_or_else(|| PathBuf::from("team.spec.yaml"));
|
|
1157
|
-
Ok(CompileArgs {
|
|
1158
|
-
team: resolve_cli_path(cwd, &team),
|
|
1159
|
-
out: resolve_cli_path(cwd, &out),
|
|
1160
|
-
json: parsed.json,
|
|
1161
|
-
})
|
|
1162
|
-
}
|
|
1163
|
-
|
|
1164
1143
|
fn resolve_cli_path(cwd: &Path, path: &Path) -> PathBuf {
|
|
1165
1144
|
if path.is_absolute() {
|
|
1166
1145
|
path.to_path_buf()
|
|
@@ -2172,7 +2151,8 @@ mod tests {
|
|
|
2172
2151
|
fn hidden_commands_not_in_default_help() {
|
|
2173
2152
|
let top_help = command_help(None);
|
|
2174
2153
|
let visible = visible_help_commands(&top_help);
|
|
2175
|
-
|
|
2154
|
+
assert!(visible.iter().any(|command| command == "doctor"));
|
|
2155
|
+
for command in ["leaders", "diagnose", "e2e", "peek", "coordinator"] {
|
|
2176
2156
|
assert!(
|
|
2177
2157
|
!visible.iter().any(|visible| visible == command),
|
|
2178
2158
|
"`{command}` must stay hidden from default help"
|
|
@@ -516,10 +516,11 @@ pub mod lifecycle_port {
|
|
|
516
516
|
/// (经同一帧 list_targets pane→session)。state 无任何锚 → 退命名判据 + spare_fallback event。
|
|
517
517
|
fn anchor_sessions_from_state(
|
|
518
518
|
state: &Value,
|
|
519
|
+
endpoint: Option<&str>,
|
|
519
520
|
pane_targets: &[crate::transport::PaneInfo],
|
|
520
521
|
event_log: &crate::event_log::EventLog,
|
|
521
522
|
) -> std::collections::BTreeSet<String> {
|
|
522
|
-
let anchor_pane_ids = collect_state_leader_anchor_pane_ids(state);
|
|
523
|
+
let anchor_pane_ids = collect_state_leader_anchor_pane_ids(state, endpoint);
|
|
523
524
|
if anchor_pane_ids.is_empty() {
|
|
524
525
|
// 无锚(state 损坏/未记)→ 退纯命名前缀判据(下游 sessions_to_kill 仍 spare 前缀)。
|
|
525
526
|
let _ = event_log.write(
|
|
@@ -649,7 +650,12 @@ pub mod lifecycle_port {
|
|
|
649
650
|
}
|
|
650
651
|
}
|
|
651
652
|
};
|
|
652
|
-
let protected_sessions = anchor_sessions_from_state(
|
|
653
|
+
let protected_sessions = anchor_sessions_from_state(
|
|
654
|
+
state,
|
|
655
|
+
transport.tmux_endpoint().as_deref(),
|
|
656
|
+
&targets,
|
|
657
|
+
event_log,
|
|
658
|
+
);
|
|
653
659
|
let mut result = ShutdownSocketCleanup::default();
|
|
654
660
|
for session in candidates {
|
|
655
661
|
if protected_sessions.contains(session.as_str()) {
|
|
@@ -791,6 +797,43 @@ pub mod lifecycle_port {
|
|
|
791
797
|
use crate::transport::test_support::OfflineTransport;
|
|
792
798
|
use crate::transport::{PaneId, PaneInfo, SessionName};
|
|
793
799
|
|
|
800
|
+
#[test]
|
|
801
|
+
fn leader_process_protection_does_not_alias_panes_across_endpoints() {
|
|
802
|
+
let state = json!({
|
|
803
|
+
"team_owner": {"pane_id": "%2"},
|
|
804
|
+
"leader_receiver": {"pane_id": "%2", "tmux_socket": "/tmp/s4-host-only.sock"}
|
|
805
|
+
});
|
|
806
|
+
let table = [(10, 1), (20, 10), (30, 10), (31, 30)]
|
|
807
|
+
.into_iter()
|
|
808
|
+
.map(|(pid, ppid)| ProcessInfo { pid, ppid, pgid: None, session: None, command: String::new() })
|
|
809
|
+
.collect::<Vec<_>>();
|
|
810
|
+
for (endpoint, pane_pid, should_protect) in [
|
|
811
|
+
("/tmp/s4-worker-only.sock", 20, false),
|
|
812
|
+
("/tmp/s4-host-only.sock", 30, true),
|
|
813
|
+
] {
|
|
814
|
+
let transport = OfflineTransport::new()
|
|
815
|
+
.with_tmux_endpoint(endpoint)
|
|
816
|
+
.with_targets(vec![PaneInfo {
|
|
817
|
+
pane_id: PaneId::new("%2"),
|
|
818
|
+
session: SessionName::new("ordinary-session"),
|
|
819
|
+
window_index: None,
|
|
820
|
+
window_name: None,
|
|
821
|
+
pane_index: None,
|
|
822
|
+
tty: None,
|
|
823
|
+
current_command: None,
|
|
824
|
+
current_path: None,
|
|
825
|
+
active: true,
|
|
826
|
+
pane_pid: Some(pane_pid),
|
|
827
|
+
leader_env: BTreeMap::new(),
|
|
828
|
+
}]);
|
|
829
|
+
let mut protected = ShutdownProtection::default();
|
|
830
|
+
extend_protection_with_leader_panes(&mut protected, &transport, &state, &table);
|
|
831
|
+
assert_eq!(protected.pids.contains(&pane_pid), should_protect);
|
|
832
|
+
assert_eq!(protected.pids.contains(&31), should_protect, "leader descendants");
|
|
833
|
+
assert_eq!(protected.pids.contains(&10), should_protect, "leader's server");
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
794
837
|
#[test]
|
|
795
838
|
fn audit_write_failure_skips_destructive_kill() {
|
|
796
839
|
let root =
|
|
@@ -1042,7 +1085,10 @@ pub mod lifecycle_port {
|
|
|
1042
1085
|
// External and managed leaders both pass the same positive ownership
|
|
1043
1086
|
// gate; topology only decides whether a verified session is reduced
|
|
1044
1087
|
// to worker panes or removed as a whole.
|
|
1045
|
-
let leader_anchor_ids = collect_state_leader_anchor_pane_ids(
|
|
1088
|
+
let leader_anchor_ids = collect_state_leader_anchor_pane_ids(
|
|
1089
|
+
&state,
|
|
1090
|
+
transport.tmux_endpoint().as_deref(),
|
|
1091
|
+
);
|
|
1046
1092
|
let (live_targets_now, live_targets_known) = match transport.list_targets() {
|
|
1047
1093
|
Ok(targets) => (targets, true),
|
|
1048
1094
|
Err(error) => {
|
|
@@ -2059,12 +2105,13 @@ pub mod lifecycle_port {
|
|
|
2059
2105
|
/// → 任一 team 的 shutdown 都不杀任何 team 的 leader 锚 pane
|
|
2060
2106
|
pub fn collect_state_leader_anchor_pane_ids(
|
|
2061
2107
|
state: &Value,
|
|
2108
|
+
endpoint: Option<&str>,
|
|
2062
2109
|
) -> std::collections::BTreeSet<String> {
|
|
2063
2110
|
let mut out = std::collections::BTreeSet::new();
|
|
2064
|
-
push_anchor_pane_id(state, &mut out);
|
|
2111
|
+
push_anchor_pane_id(state, endpoint, &mut out);
|
|
2065
2112
|
if let Some(teams) = state.get("teams").and_then(Value::as_object) {
|
|
2066
2113
|
for (_, team_state) in teams {
|
|
2067
|
-
push_anchor_pane_id(team_state, &mut out);
|
|
2114
|
+
push_anchor_pane_id(team_state, endpoint, &mut out);
|
|
2068
2115
|
}
|
|
2069
2116
|
}
|
|
2070
2117
|
out
|
|
@@ -2184,17 +2231,40 @@ pub mod lifecycle_port {
|
|
|
2184
2231
|
Ok(())
|
|
2185
2232
|
}
|
|
2186
2233
|
|
|
2187
|
-
///
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2234
|
+
/// Pane IDs are server-local. Exclude proven foreign-endpoint anchors, but
|
|
2235
|
+
/// retain legacy anchors when their endpoint (or the transport's) is unknown.
|
|
2236
|
+
fn push_anchor_pane_id(
|
|
2237
|
+
state: &Value,
|
|
2238
|
+
endpoint: Option<&str>,
|
|
2239
|
+
out: &mut std::collections::BTreeSet<String>,
|
|
2240
|
+
) {
|
|
2241
|
+
for (key, peer_key) in [("team_owner", "leader_receiver"), ("leader_receiver", "team_owner")] {
|
|
2242
|
+
let Some(anchor) = state.get(key) else { continue };
|
|
2243
|
+
let Some(pane_id) = anchor.get("pane_id").and_then(Value::as_str).filter(|s| !s.is_empty()) else {
|
|
2244
|
+
continue;
|
|
2245
|
+
};
|
|
2246
|
+
// team_owner often records only a pane ID; its receiver supplies
|
|
2247
|
+
// the endpoint only when it describes that exact same anchor.
|
|
2248
|
+
let anchor_endpoint = anchor.get("tmux_socket").and_then(Value::as_str)
|
|
2194
2249
|
.filter(|s| !s.is_empty())
|
|
2195
|
-
|
|
2196
|
-
|
|
2250
|
+
.or_else(|| {
|
|
2251
|
+
state.get(peer_key)
|
|
2252
|
+
.filter(|peer| peer.get("pane_id").and_then(Value::as_str) == Some(pane_id))
|
|
2253
|
+
.and_then(|peer| peer.get("tmux_socket"))
|
|
2254
|
+
.and_then(Value::as_str)
|
|
2255
|
+
.filter(|s| !s.is_empty())
|
|
2256
|
+
});
|
|
2257
|
+
if let (Some(anchor_path), Some(transport_path)) = (
|
|
2258
|
+
anchor_endpoint.and_then(socket_file_for_endpoint),
|
|
2259
|
+
endpoint.filter(|s| !s.is_empty()).and_then(socket_file_for_endpoint),
|
|
2260
|
+
) {
|
|
2261
|
+
let anchor_path = anchor_path.canonicalize().unwrap_or(anchor_path);
|
|
2262
|
+
let transport_path = transport_path.canonicalize().unwrap_or(transport_path);
|
|
2263
|
+
if anchor_path != transport_path {
|
|
2264
|
+
continue;
|
|
2265
|
+
}
|
|
2197
2266
|
}
|
|
2267
|
+
out.insert(pane_id.to_string());
|
|
2198
2268
|
}
|
|
2199
2269
|
}
|
|
2200
2270
|
|
|
@@ -2305,8 +2375,10 @@ pub mod lifecycle_port {
|
|
|
2305
2375
|
);
|
|
2306
2376
|
// Source 2: state.json team_owner / leader_receiver 真锚 pane_id(top-level +
|
|
2307
2377
|
// teams[*]),per-workspace socket 命中。
|
|
2308
|
-
let anchor_pane_ids
|
|
2309
|
-
|
|
2378
|
+
let anchor_pane_ids = collect_state_leader_anchor_pane_ids(
|
|
2379
|
+
state,
|
|
2380
|
+
transport.tmux_endpoint().as_deref(),
|
|
2381
|
+
);
|
|
2310
2382
|
leader_pane_pids.extend(
|
|
2311
2383
|
pane_targets
|
|
2312
2384
|
.iter()
|
|
@@ -2322,6 +2394,7 @@ pub mod lifecycle_port {
|
|
|
2322
2394
|
// 的 list_targets 找 anchor pane_id → pane_pid → 进入 process_tree 保护。
|
|
2323
2395
|
// 不在 state 中的 socket 不查(MUST-17 不撒宽 / 不主动枚举全机器 sockets)。
|
|
2324
2396
|
for socket_endpoint in collect_state_recorded_tmux_sockets(state) {
|
|
2397
|
+
let anchor_pane_ids = collect_state_leader_anchor_pane_ids(state, Some(&socket_endpoint));
|
|
2325
2398
|
let cross_backend =
|
|
2326
2399
|
crate::tmux_backend::TmuxBackend::for_tmux_endpoint(&socket_endpoint);
|
|
2327
2400
|
let cross_panes =
|
|
@@ -4752,17 +4825,15 @@ pub mod diagnose_port {
|
|
|
4752
4825
|
&& profile_smoke_value.get("status").and_then(Value::as_str)
|
|
4753
4826
|
== Some("legacy_team_invalid");
|
|
4754
4827
|
let effective_smoke_ok = profile_smoke_ok || legacy_only_failure;
|
|
4828
|
+
// Coordinator and runtime topology are facts of the unified pipeline,
|
|
4829
|
+
// not of the static environment probe. The caller supplies the
|
|
4830
|
+
// selected runtime workspace and performs one read-only health read.
|
|
4755
4831
|
let ok = workspace_valid && (team_context || workspace_has_entries) && effective_smoke_ok;
|
|
4756
|
-
let health = crate::coordinator::coordinator_health(
|
|
4757
|
-
&crate::coordinator::WorkspacePath::new(workspace.to_path_buf()),
|
|
4758
|
-
);
|
|
4759
|
-
let coordinator_ok = health.ok;
|
|
4760
|
-
let coordinator_error = health.metadata_mismatch_reason.clone();
|
|
4761
4832
|
let state = crate::state::persist::load_runtime_state(workspace).unwrap_or(Value::Null);
|
|
4762
4833
|
let grok_slot = crate::cli::grok_slot_report(workspace, &state).to_json();
|
|
4763
4834
|
let grok_slot_ok = grok_slot.get("consistent").and_then(Value::as_bool) == Some(true)
|
|
4764
4835
|
&& grok_slot.get("readable").and_then(Value::as_bool) == Some(true);
|
|
4765
|
-
let ok = ok && grok_slot_ok
|
|
4836
|
+
let ok = ok && grok_slot_ok;
|
|
4766
4837
|
Ok(json!({
|
|
4767
4838
|
"tmux": {
|
|
4768
4839
|
"installed": tmux_installed,
|
|
@@ -4770,14 +4841,14 @@ pub mod diagnose_port {
|
|
|
4770
4841
|
},
|
|
4771
4842
|
"workspace": workspace.to_string_lossy().to_string(),
|
|
4772
4843
|
"workspace_is_git_repo": workspace.join(".git").exists(),
|
|
4773
|
-
"providers":
|
|
4844
|
+
"providers": crate::cli::diagnose::provider_doctor_checks(),
|
|
4774
4845
|
"mcp": {
|
|
4775
4846
|
"server_command": which_path("team_orchestrator"),
|
|
4776
4847
|
"local_module": true,
|
|
4777
4848
|
},
|
|
4778
4849
|
"secret_scan": secret_scan(workspace),
|
|
4779
4850
|
"profile_smoke": profile_smoke_value,
|
|
4780
|
-
"coordinator":
|
|
4851
|
+
"coordinator": coordinator_not_required_value(),
|
|
4781
4852
|
"grok_slot": grok_slot,
|
|
4782
4853
|
"ok": ok,
|
|
4783
4854
|
"error": if ok {
|
|
@@ -4789,10 +4860,6 @@ pub mod diagnose_port {
|
|
|
4789
4860
|
.unwrap_or_else(|| json!("grok_slot_mismatch"))
|
|
4790
4861
|
} else if !profile_smoke_ok && !legacy_only_failure {
|
|
4791
4862
|
json!("profile_smoke_failed")
|
|
4792
|
-
} else if !coordinator_ok {
|
|
4793
|
-
coordinator_error
|
|
4794
|
-
.map(Value::String)
|
|
4795
|
-
.unwrap_or_else(|| json!("coordinator_unavailable"))
|
|
4796
4863
|
} else if workspace_valid {
|
|
4797
4864
|
json!("workspace has no Team Agent spec or runtime context")
|
|
4798
4865
|
} else {
|
|
@@ -4984,11 +5051,12 @@ pub mod diagnose_port {
|
|
|
4984
5051
|
continue;
|
|
4985
5052
|
};
|
|
4986
5053
|
let rel = path.strip_prefix(root).unwrap_or(path);
|
|
5054
|
+
// Keep only a locating rule/path/line tuple. Never include even
|
|
5055
|
+
// redacted assignment text in the structured report.
|
|
4987
5056
|
findings.push(json!({
|
|
4988
5057
|
"path": rel.to_string_lossy().to_string(),
|
|
4989
5058
|
"line": idx.saturating_add(1),
|
|
4990
5059
|
"rule": "api_key_assignment",
|
|
4991
|
-
"match_excerpt": format!("{key}=<redacted>"),
|
|
4992
5060
|
}));
|
|
4993
5061
|
}
|
|
4994
5062
|
}
|
|
@@ -5153,7 +5221,23 @@ pub mod diagnose_port {
|
|
|
5153
5221
|
})
|
|
5154
5222
|
}
|
|
5155
5223
|
|
|
5156
|
-
fn
|
|
5224
|
+
pub(crate) fn coordinator_not_required_value() -> Value {
|
|
5225
|
+
json!({
|
|
5226
|
+
"ok": true,
|
|
5227
|
+
"status": "not_required",
|
|
5228
|
+
"pid": Value::Null,
|
|
5229
|
+
"metadata": Value::Null,
|
|
5230
|
+
"metadata_ok": true,
|
|
5231
|
+
"metadata_mismatch_reason": Value::Null,
|
|
5232
|
+
"binary_path": Value::Null,
|
|
5233
|
+
"binary_version": Value::Null,
|
|
5234
|
+
"schema_ok": true,
|
|
5235
|
+
"schema_error": Value::Null,
|
|
5236
|
+
"schema": {"message_store_schema_version": Value::Null},
|
|
5237
|
+
})
|
|
5238
|
+
}
|
|
5239
|
+
|
|
5240
|
+
pub(crate) fn coordinator_health_value(health: crate::coordinator::HealthReport) -> Value {
|
|
5157
5241
|
let expose_binary_drift = health.service_available && !health.metadata_ok;
|
|
5158
5242
|
let binary_identity_relation = health.binary_identity_relation.as_str();
|
|
5159
5243
|
let mut value = json!({
|
|
@@ -44,7 +44,6 @@ pub(crate) enum TokenUsage {
|
|
|
44
44
|
pub(crate) enum DispatchKind {
|
|
45
45
|
Init,
|
|
46
46
|
QuickStart,
|
|
47
|
-
Compile,
|
|
48
47
|
Send,
|
|
49
48
|
AllowPeerTalk,
|
|
50
49
|
Status,
|
|
@@ -90,7 +89,6 @@ pub(crate) enum DispatchKind {
|
|
|
90
89
|
pub(crate) const ALL_DISPATCH_KINDS: &[DispatchKind] = &[
|
|
91
90
|
DispatchKind::Init,
|
|
92
91
|
DispatchKind::QuickStart,
|
|
93
|
-
DispatchKind::Compile,
|
|
94
92
|
DispatchKind::Send,
|
|
95
93
|
DispatchKind::AllowPeerTalk,
|
|
96
94
|
DispatchKind::Status,
|
|
@@ -176,7 +174,7 @@ pub(crate) const COMMAND_SPECS: &[CommandSpec] = &[
|
|
|
176
174
|
CommandSpec { name: "start-agent", tier: CommandTier::Core, category: CommandCategory::WorkerLifecycle, kind: CommandKind::Dispatch(DispatchKind::StartAgent), summary: "start an existing worker", usage: "usage: team-agent start-agent AGENT [--workspace WORKSPACE] [--team TEAM] [--force] [--allow-fresh] [--no-display] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, alias_of: None, sunset: None, action: None, governance: None },
|
|
177
175
|
CommandSpec { name: "stop-agent", tier: CommandTier::Core, category: CommandCategory::WorkerLifecycle, kind: CommandKind::Dispatch(DispatchKind::StopAgent), summary: "stop a worker", usage: "usage: team-agent stop-agent AGENT [--workspace WORKSPACE] [--team TEAM] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
|
|
178
176
|
CommandSpec { name: "reset-agent", tier: CommandTier::Core, category: CommandCategory::WorkerLifecycle, kind: CommandKind::Dispatch(DispatchKind::ResetAgent), summary: "reset a worker session", usage: "usage: team-agent reset-agent AGENT [--workspace WORKSPACE] [--team TEAM] [--discard-session] [--no-display] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, alias_of: None, sunset: None, action: None, governance: None },
|
|
179
|
-
CommandSpec { name: "diagnose", tier: CommandTier::
|
|
177
|
+
CommandSpec { name: "diagnose", tier: CommandTier::CompatHidden, category: CommandCategory::Compat, kind: CommandKind::Dispatch(DispatchKind::Diagnose), summary: "compatibility alias for doctor", usage: "usage: team-agent doctor [SPEC] [--workspace WORKSPACE] [--team TEAM] [--gate orphans|comms] [--comms] [--fix] [--fix-schema] [--cleanup-orphans] [--confirm] [--json]", default_help: false, command_help: true, suggestion_index: false, token_usage: TokenUsage::No, alias_of: Some("doctor"), sunset: None, action: None, governance: None },
|
|
180
178
|
CommandSpec { name: "claim-leader", tier: CommandTier::Guided, category: CommandCategory::GuidedRecovery, kind: CommandKind::Dispatch(DispatchKind::ClaimLeader), summary: "bind this pane when diagnose reports rebind_required", usage: "usage: team-agent claim-leader [--workspace WORKSPACE] [--team TEAM] [--confirm] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
|
|
181
179
|
CommandSpec { name: "takeover", tier: CommandTier::Guided, category: CommandCategory::GuidedRecovery, kind: CommandKind::Dispatch(DispatchKind::Takeover), summary: "replace a live leader binding with explicit consent", usage: "usage: team-agent takeover [--workspace WORKSPACE] [--team TEAM] [--confirm] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
|
|
182
180
|
CommandSpec { name: "attach-leader", tier: CommandTier::Guided, category: CommandCategory::GuidedRecovery, kind: CommandKind::Dispatch(DispatchKind::AttachLeader), summary: "bind an external leader pane", usage: "usage: team-agent attach-leader [--workspace WORKSPACE] [--team TEAM] [--pane PANE] [--provider PROVIDER] [--confirm] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
|
|
@@ -196,7 +194,7 @@ pub(crate) const COMMAND_SPECS: &[CommandSpec] = &[
|
|
|
196
194
|
CommandSpec { name: "stuck-cancel", tier: CommandTier::CompatHidden, category: CommandCategory::Compat, kind: CommandKind::Dispatch(DispatchKind::StuckCancel), summary: "legacy stuck alert suppression", usage: "usage: team-agent stuck-cancel AGENT [--workspace WORKSPACE] [--alert-type stuck|idle_fallback|cross_worker_deadlock|all] [--json]", default_help: false, command_help: true, suggestion_index: false, token_usage: TokenUsage::No, alias_of: None, sunset: Some("C2"), action: Some("use the concrete notification action or `team-agent diagnose`"), governance: None },
|
|
197
195
|
CommandSpec { name: "acknowledge-idle", tier: CommandTier::CompatHidden, category: CommandCategory::Compat, kind: CommandKind::Dispatch(DispatchKind::AcknowledgeIdle), summary: "legacy idle acknowledgement", usage: "usage: team-agent acknowledge-idle [--workspace WORKSPACE] [--team TEAM] [--json]", default_help: false, command_help: true, suggestion_index: false, token_usage: TokenUsage::No, alias_of: None, sunset: Some("C2"), action: Some("use the concrete idle notification action or `team-agent diagnose`"), governance: None },
|
|
198
196
|
CommandSpec { name: "leaders", tier: CommandTier::Secondary, category: CommandCategory::Observe, kind: CommandKind::Dispatch(DispatchKind::Leaders), summary: "list live, stale, or ambiguous host leaders", usage: "usage: team-agent leaders [QUERY|--search TEXT] [--all|--stale] [--json] | --prune [--dry-run] [--json]", default_help: false, command_help: true, suggestion_index: false, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
|
|
199
|
-
CommandSpec { name: "doctor", tier: CommandTier::
|
|
197
|
+
CommandSpec { name: "doctor", tier: CommandTier::Core, category: CommandCategory::Observe, kind: CommandKind::Dispatch(DispatchKind::Doctor), summary: "inspect environment and Team runtime health", usage: "usage: team-agent doctor [SPEC] [--workspace WORKSPACE] [--team TEAM] [--gate orphans|comms] [--comms] [--fix] [--fix-schema] [--cleanup-orphans] [--confirm] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
|
|
200
198
|
CommandSpec { name: "attach-app-server-leader", tier: CommandTier::Guided, category: CommandCategory::GuidedRecovery, kind: CommandKind::Dispatch(DispatchKind::AttachAppServerLeader), summary: "bind an app-server leader receiver", usage: "usage: team-agent attach-app-server-leader [--workspace WORKSPACE] [--team TEAM] --socket unix:///path.sock --thread-id THREAD_ID [--json]", default_help: false, command_help: true, suggestion_index: false, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
|
|
201
199
|
CommandSpec { name: "remove-agent", tier: CommandTier::Secondary, category: CommandCategory::WorkerLifecycle, kind: CommandKind::Dispatch(DispatchKind::RemoveAgent), summary: "remove a worker", usage: "usage: team-agent remove-agent AGENT [--workspace WORKSPACE] [--team TEAM] [--from-spec] [--confirm] [--force] [--json]", default_help: false, command_help: true, suggestion_index: false, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
|
|
202
200
|
CommandSpec { name: "fork-agent", tier: CommandTier::Secondary, category: CommandCategory::WorkerLifecycle, kind: CommandKind::Dispatch(DispatchKind::ForkAgent), summary: "fork a worker", usage: "usage: team-agent fork-agent SOURCE_AGENT --as AGENT [--label LABEL] [--workspace WORKSPACE] [--team TEAM] [--no-display] [--json]", default_help: false, command_help: true, suggestion_index: false, token_usage: TokenUsage::Conditional, alias_of: None, sunset: None, action: None, governance: None },
|
|
@@ -210,7 +208,6 @@ pub(crate) const COMMAND_SPECS: &[CommandSpec] = &[
|
|
|
210
208
|
CommandSpec { name: "identity", tier: CommandTier::DevInternal, category: CommandCategory::Dev, kind: CommandKind::Dispatch(DispatchKind::Identity), summary: "inspect identity metadata", usage: "usage: team-agent identity [--workspace WORKSPACE] [--team TEAM] [--json]", default_help: false, command_help: true, suggestion_index: false, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
|
|
211
209
|
CommandSpec { name: "watch", tier: CommandTier::DevInternal, category: CommandCategory::Dev, kind: CommandKind::Dispatch(DispatchKind::Watch), summary: "watch event logs", usage: "usage: team-agent watch [--workspace WORKSPACE] [--team TEAM]", default_help: false, command_help: true, suggestion_index: false, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
|
|
212
210
|
CommandSpec { name: "sessions", tier: CommandTier::DevInternal, category: CommandCategory::Dev, kind: CommandKind::Dispatch(DispatchKind::Sessions), summary: "inspect session inventory", usage: "usage: team-agent sessions [--workspace WORKSPACE] [--team TEAM] [--json]", default_help: false, command_help: true, suggestion_index: false, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
|
|
213
|
-
CommandSpec { name: "compile", tier: CommandTier::DevInternal, category: CommandCategory::Dev, kind: CommandKind::Dispatch(DispatchKind::Compile), summary: "compile a team spec", usage: "usage: team-agent compile --team TEAM [--out FILE] [--json]", default_help: false, command_help: true, suggestion_index: false, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
|
|
214
211
|
CommandSpec { name: "validate", tier: CommandTier::DevInternal, category: CommandCategory::Dev, kind: CommandKind::Dispatch(DispatchKind::Validate), summary: "validate a team spec", usage: "usage: team-agent validate [SPEC] [--json]", default_help: false, command_help: true, suggestion_index: false, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
|
|
215
212
|
CommandSpec { name: "preflight", tier: CommandTier::DevInternal, category: CommandCategory::Dev, kind: CommandKind::Dispatch(DispatchKind::Preflight), summary: "run preflight checks", usage: "usage: team-agent preflight [TEAMDIR] [--json]", default_help: false, command_help: true, suggestion_index: false, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
|
|
216
213
|
CommandSpec { name: "wait-ready", tier: CommandTier::DevInternal, category: CommandCategory::Dev, kind: CommandKind::Dispatch(DispatchKind::WaitReady), summary: "wait for runtime readiness", usage: "usage: team-agent wait-ready [--workspace WORKSPACE] [--team TEAM] [--timeout SECONDS] [--json]", default_help: false, command_help: true, suggestion_index: false, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
|
|
@@ -671,13 +671,12 @@ fn exit_code_numeric() {
|
|
|
671
671
|
}
|
|
672
672
|
|
|
673
673
|
// =========================================================================
|
|
674
|
-
// cmd_doctor
|
|
674
|
+
// cmd_doctor: --fix without --gate is a structured failure in both renderers.
|
|
675
675
|
// =========================================================================
|
|
676
676
|
|
|
677
677
|
#[test]
|
|
678
|
-
fn
|
|
679
|
-
|
|
680
|
-
let args = DoctorArgs {
|
|
678
|
+
fn cmd_doctor_fix_without_gate_reports_usage_failure() {
|
|
679
|
+
let mut args = DoctorArgs {
|
|
681
680
|
spec: None,
|
|
682
681
|
workspace: PathBuf::from("."),
|
|
683
682
|
gate: None,
|
|
@@ -689,12 +688,23 @@ fn cmd_doctor_fix_without_gate_is_usage_error() {
|
|
|
689
688
|
confirm: false,
|
|
690
689
|
json: false,
|
|
691
690
|
};
|
|
692
|
-
let
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
);
|
|
691
|
+
let human = cmd_doctor(&args).expect("structured usage failure");
|
|
692
|
+
assert_eq!(human.exit, ExitCode::Error);
|
|
693
|
+
let CmdOutput::Human(text) = human.output else {
|
|
694
|
+
panic!("expected human triage");
|
|
695
|
+
};
|
|
696
|
+
assert!(text.contains("fix_requires_gate"), "{text}");
|
|
697
|
+
assert!(text.contains("add --gate orphans|comms"), "{text}");
|
|
698
|
+
|
|
699
|
+
args.json = true;
|
|
700
|
+
let result = cmd_doctor(&args).expect("structured JSON usage failure");
|
|
701
|
+
assert_eq!(result.exit, ExitCode::Error);
|
|
702
|
+
let CmdOutput::Json(value) = result.output else {
|
|
703
|
+
panic!("expected JSON usage failure");
|
|
704
|
+
};
|
|
705
|
+
assert_eq!(value["ok"], false);
|
|
706
|
+
assert_eq!(value["error"], "--fix requires --gate");
|
|
707
|
+
assert_eq!(value["issues"], json!(["fix_requires_gate"]));
|
|
698
708
|
}
|
|
699
709
|
|
|
700
710
|
// =========================================================================
|
|
@@ -457,14 +457,10 @@ fn red_send_target_none_is_not_broadcast() {
|
|
|
457
457
|
assert_eq!(send_target(None, Some("*")), MessageTarget::Broadcast);
|
|
458
458
|
}
|
|
459
459
|
|
|
460
|
-
//
|
|
461
|
-
// golden: for --comms WITHOUT --json, cmd_doctor returns the STRING
|
|
462
|
-
// f"{COMMS_BOUNDARY_TEXT}\n{json.dumps(result, indent=2, ensure_ascii=False, sort_keys=True)}".
|
|
463
|
-
// Rust always does CmdResult::from_json -> CmdOutput::Json (wrong shape).
|
|
460
|
+
// Unified doctor human output is triage only, including the comms gate.
|
|
464
461
|
#[test]
|
|
465
|
-
fn
|
|
466
|
-
|
|
467
|
-
let args = DoctorArgs {
|
|
462
|
+
fn cmd_doctor_comms_human_is_compact_triage_without_boundary_prefix() {
|
|
463
|
+
let mut args = DoctorArgs {
|
|
468
464
|
spec: None,
|
|
469
465
|
workspace: PathBuf::from("."),
|
|
470
466
|
gate: None,
|
|
@@ -479,21 +475,16 @@ fn red_cmd_doctor_comms_human_is_boundary_text_plus_sorted_json() {
|
|
|
479
475
|
let result = cmd_doctor(&args).expect("comms doctor returns CmdResult");
|
|
480
476
|
let text = match result.output {
|
|
481
477
|
CmdOutput::Human(s) => s,
|
|
482
|
-
other => panic!(
|
|
483
|
-
"comms WITHOUT --json MUST be a Human boundary-text + JSON string, got {other:?}"
|
|
484
|
-
),
|
|
478
|
+
other => panic!("comms WITHOUT --json must be human triage, got {other:?}"),
|
|
485
479
|
};
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
.unwrap();
|
|
494
|
-
let parsed: Value = serde_json::from_str(json_tail)
|
|
495
|
-
.expect("comms human tail MUST be indent=2 sort_keys JSON of the selftest result");
|
|
496
|
-
assert!(parsed.is_object(), "comms selftest JSON tail is an object");
|
|
480
|
+
args.json = true;
|
|
481
|
+
let CmdOutput::Json(report) = cmd_doctor(&args).unwrap().output else {
|
|
482
|
+
panic!("--json must preserve the structured report");
|
|
483
|
+
};
|
|
484
|
+
assert_eq!(text, crate::cli::triage::render("doctor", &report));
|
|
485
|
+
assert!(text.starts_with("doctor:"));
|
|
486
|
+
assert_eq!(text.lines().count(), 1 + report["issues"].as_array().unwrap().len()
|
|
487
|
+
+ report["suggested_repairs"].as_array().unwrap().len());
|
|
497
488
|
}
|
|
498
489
|
|
|
499
490
|
// ---- #13 / #27 (P2): run() must NOT treat 'claude_code' as a passthrough trigger ----
|
|
@@ -368,12 +368,17 @@ fn ux_doctor_secret_scan_findings_name_the_exact_trigger() {
|
|
|
368
368
|
.pointer("/secret_scan/findings/0")
|
|
369
369
|
.and_then(serde_json::Value::as_object)
|
|
370
370
|
.expect("secret-scan must report the concrete trigger");
|
|
371
|
-
for key in ["path", "line", "rule"
|
|
371
|
+
for key in ["path", "line", "rule"] {
|
|
372
372
|
assert!(
|
|
373
373
|
finding.contains_key(key),
|
|
374
374
|
"secret-scan finding missing `{key}`: {finding:?}"
|
|
375
375
|
);
|
|
376
376
|
}
|
|
377
|
+
assert_eq!(finding["path"], "leaky-role.md");
|
|
378
|
+
assert_eq!(finding["line"], 1);
|
|
379
|
+
assert_eq!(finding["rule"], "api_key_assignment");
|
|
380
|
+
assert!(!finding.contains_key("match_excerpt"));
|
|
381
|
+
assert!(!value.to_string().contains("sk-test-red-contract"));
|
|
377
382
|
let _ = std::fs::remove_dir_all(&ws);
|
|
378
383
|
}
|
|
379
384
|
#[test]
|