@team-agent/installer 0.5.91 → 0.5.92
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 +43 -10
- package/crates/team-agent/src/cli/diagnose.rs +78 -39
- package/crates/team-agent/src/cli/mod.rs +37 -9
- package/crates/team-agent/src/cli/triage.rs +183 -0
- package/crates/team-agent/src/coordinator/health.rs +41 -1
- package/crates/team-agent/src/db/migration.rs +36 -15
- package/crates/team-agent/src/redaction.rs +5 -1
- package/crates/team-agent/src/topology.rs +17 -11
- package/package.json +4 -4
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -500,7 +500,7 @@ pub fn cmd_diagnose(args: &DiagnoseArgs) -> Result<CmdResult, CliError> {
|
|
|
500
500
|
.join(".team")
|
|
501
501
|
.join("logs")
|
|
502
502
|
.join("events.jsonl");
|
|
503
|
-
return Ok(
|
|
503
|
+
return Ok(crate::cli::triage::report(
|
|
504
504
|
json!({
|
|
505
505
|
"event_log": event_log.to_string_lossy().to_string(),
|
|
506
506
|
"issues": issues,
|
|
@@ -518,9 +518,10 @@ pub fn cmd_diagnose(args: &DiagnoseArgs) -> Result<CmdResult, CliError> {
|
|
|
518
518
|
"suggested_repairs": suggested_repairs,
|
|
519
519
|
}),
|
|
520
520
|
args.json,
|
|
521
|
+
"diagnose",
|
|
521
522
|
));
|
|
522
523
|
}
|
|
523
|
-
let selected = crate::state::selector::
|
|
524
|
+
let selected = crate::state::selector::resolve_active_team_readonly(
|
|
524
525
|
&args.workspace,
|
|
525
526
|
args.team.as_deref(),
|
|
526
527
|
crate::state::selector::SelectorMode::RuntimeOnly,
|
|
@@ -555,7 +556,7 @@ pub fn cmd_diagnose(args: &DiagnoseArgs) -> Result<CmdResult, CliError> {
|
|
|
555
556
|
Some(selected.team_key.as_str()),
|
|
556
557
|
);
|
|
557
558
|
let ok = issues.as_array().is_some_and(Vec::is_empty);
|
|
558
|
-
Ok(
|
|
559
|
+
Ok(crate::cli::triage::report(
|
|
559
560
|
json!({
|
|
560
561
|
"event_log": event_log.to_string_lossy().to_string(),
|
|
561
562
|
"issues": issues,
|
|
@@ -573,6 +574,7 @@ pub fn cmd_diagnose(args: &DiagnoseArgs) -> Result<CmdResult, CliError> {
|
|
|
573
574
|
"suggested_repairs": suggested_repairs,
|
|
574
575
|
}),
|
|
575
576
|
args.json,
|
|
577
|
+
"diagnose",
|
|
576
578
|
))
|
|
577
579
|
}
|
|
578
580
|
|
|
@@ -1513,15 +1515,21 @@ pub fn cmd_doctor(args: &DoctorArgs) -> Result<CmdResult, CliError> {
|
|
|
1513
1515
|
args.team.as_deref(),
|
|
1514
1516
|
Some("comms"),
|
|
1515
1517
|
)?;
|
|
1518
|
+
let result = CmdResult::from_json(value, args.json);
|
|
1516
1519
|
if !args.json {
|
|
1517
|
-
let json_tail =
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1520
|
+
let json_tail = match &result.output {
|
|
1521
|
+
CmdOutput::Json(value) => serde_json::to_string_pretty(&sort_json(value))?,
|
|
1522
|
+
_ => String::new(),
|
|
1523
|
+
};
|
|
1524
|
+
return Ok(CmdResult {
|
|
1525
|
+
output: CmdOutput::Human(format!("{COMMS_BOUNDARY_TEXT}\n{json_tail}")),
|
|
1526
|
+
..result
|
|
1527
|
+
});
|
|
1521
1528
|
}
|
|
1522
|
-
return Ok(
|
|
1529
|
+
return Ok(result);
|
|
1523
1530
|
}
|
|
1524
|
-
let
|
|
1531
|
+
let default_report = args.gate.is_none() && !args.cleanup_orphans && !args.fix_schema;
|
|
1532
|
+
let mut value = if matches!(args.gate, Some(DoctorGate::Orphans)) {
|
|
1525
1533
|
crate::diagnose::orphans::orphan_gate_json(&args.workspace, args.fix, args.confirm)?
|
|
1526
1534
|
} else if args.cleanup_orphans {
|
|
1527
1535
|
crate::diagnose::orphans::cleanup_orphans_json(&args.workspace, args.confirm)?
|
|
@@ -1541,7 +1549,32 @@ pub fn cmd_doctor(args: &DoctorArgs) -> Result<CmdResult, CliError> {
|
|
|
1541
1549
|
);
|
|
1542
1550
|
value
|
|
1543
1551
|
};
|
|
1544
|
-
|
|
1552
|
+
if default_report {
|
|
1553
|
+
let base_ok = value.get("ok").and_then(Value::as_bool).unwrap_or(false);
|
|
1554
|
+
let has_blocking_issues = value
|
|
1555
|
+
.get("issues")
|
|
1556
|
+
.and_then(Value::as_array)
|
|
1557
|
+
.is_some_and(|issues| {
|
|
1558
|
+
issues.iter().any(|issue| {
|
|
1559
|
+
let id = issue
|
|
1560
|
+
.as_str()
|
|
1561
|
+
.or_else(|| issue.get("id").and_then(Value::as_str));
|
|
1562
|
+
id != Some("leader_not_attached")
|
|
1563
|
+
})
|
|
1564
|
+
});
|
|
1565
|
+
if let Some(object) = value.as_object_mut() {
|
|
1566
|
+
object.insert(
|
|
1567
|
+
"ok".to_string(),
|
|
1568
|
+
Value::Bool(base_ok && !has_blocking_issues),
|
|
1569
|
+
);
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
let result = CmdResult::from_json(value, args.json);
|
|
1573
|
+
Ok(if default_report && !args.json {
|
|
1574
|
+
crate::cli::triage::human_result(result, "doctor")
|
|
1575
|
+
} else {
|
|
1576
|
+
result
|
|
1577
|
+
})
|
|
1545
1578
|
}
|
|
1546
1579
|
|
|
1547
1580
|
#[cfg(test)]
|
|
@@ -96,12 +96,6 @@ pub(crate) fn classify_tmux_server_error(error_text: &str) -> &'static str {
|
|
|
96
96
|
pub(crate) fn diagnose_runtime(state: &Value, backend: &dyn Transport) -> (Value, Value) {
|
|
97
97
|
let mut issues = Vec::new();
|
|
98
98
|
let mut repairs = Vec::new();
|
|
99
|
-
for issue in crate::topology::diagnose_topology_issues(state, backend) {
|
|
100
|
-
if let Some(id) = crate::topology::issue_id(&issue) {
|
|
101
|
-
repairs.push(topology_repair_hint(id));
|
|
102
|
-
}
|
|
103
|
-
issues.push(issue);
|
|
104
|
-
}
|
|
105
99
|
|
|
106
100
|
if state
|
|
107
101
|
.get("leader_receiver")
|
|
@@ -118,41 +112,63 @@ pub(crate) fn diagnose_runtime(state: &Value, backend: &dyn Transport) -> (Value
|
|
|
118
112
|
));
|
|
119
113
|
}
|
|
120
114
|
|
|
121
|
-
|
|
115
|
+
// Probe the session before any topology enumeration. A missing session is
|
|
116
|
+
// terminal for all window/pane/socket probes; repeating those tmux calls
|
|
117
|
+
// only pays the dead-socket timeout again on every layer. The persisted
|
|
118
|
+
// endpoint/socket split is state-only, so preserve that fact before the
|
|
119
|
+
// short-circuit without probing either dead endpoint.
|
|
120
|
+
let persisted_socket_conflict = crate::topology::endpoint_socket_conflict(state);
|
|
121
|
+
let session_unavailable = state
|
|
122
122
|
.get("session_name")
|
|
123
123
|
.and_then(Value::as_str)
|
|
124
124
|
.filter(|s| !s.is_empty())
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
Err(error) => {
|
|
139
|
-
// 0.5.39 Slice 1 (tmux-server-death-locate §11.1 B):
|
|
140
|
-
// when the transport error stderr matches "server exited
|
|
141
|
-
// unexpectedly", the physical layer that disappeared is
|
|
142
|
-
// the tmux server itself — not just this team's session.
|
|
143
|
-
// Surface `tmux_server_crashed` so the user's next
|
|
144
|
-
// action is a coordinator/host-level recovery, not a
|
|
145
|
-
// per-agent `restart <agent>`.
|
|
146
|
-
let error_str = error.to_string();
|
|
147
|
-
let issue_id = classify_tmux_server_error(&error_str);
|
|
148
|
-
issues.push(json!(issue_id));
|
|
149
|
-
let mut hint = recovery_hint(session_name, issue_id, "team-agent diagnose");
|
|
150
|
-
if let Some(obj) = hint.as_object_mut() {
|
|
151
|
-
obj.insert("reason".to_string(), Value::String(error_str));
|
|
125
|
+
.is_some_and(|session_name| {
|
|
126
|
+
match backend.has_session(&crate::transport::SessionName::new(
|
|
127
|
+
session_name.to_string(),
|
|
128
|
+
)) {
|
|
129
|
+
Ok(true) => false,
|
|
130
|
+
Ok(false) => {
|
|
131
|
+
issues.push(json!("tmux_session_missing"));
|
|
132
|
+
repairs.push(recovery_hint(
|
|
133
|
+
session_name,
|
|
134
|
+
"tmux_session_missing",
|
|
135
|
+
"team-agent restart",
|
|
136
|
+
));
|
|
137
|
+
true
|
|
152
138
|
}
|
|
153
|
-
|
|
139
|
+
Err(error) => {
|
|
140
|
+
// 0.5.39 Slice 1 (tmux-server-death-locate §11.1 B):
|
|
141
|
+
// when the transport error stderr matches "server exited
|
|
142
|
+
// unexpectedly", the physical layer that disappeared is
|
|
143
|
+
// the tmux server itself — not just this team's session.
|
|
144
|
+
let error_str = error.to_string();
|
|
145
|
+
let issue_id = classify_tmux_server_error(&error_str);
|
|
146
|
+
issues.push(json!(issue_id));
|
|
147
|
+
let mut hint = recovery_hint(session_name, issue_id, "team-agent diagnose");
|
|
148
|
+
if let Some(obj) = hint.as_object_mut() {
|
|
149
|
+
obj.insert("reason".to_string(), Value::String(error_str));
|
|
150
|
+
}
|
|
151
|
+
repairs.push(hint);
|
|
152
|
+
true
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
if session_unavailable {
|
|
158
|
+
if let Some(issue) = persisted_socket_conflict {
|
|
159
|
+
if let Some(id) = crate::topology::issue_id(&issue) {
|
|
160
|
+
repairs.push(topology_repair_hint(id));
|
|
154
161
|
}
|
|
162
|
+
issues.push(issue);
|
|
163
|
+
}
|
|
164
|
+
return (Value::Array(issues), Value::Array(repairs));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
for issue in crate::topology::diagnose_topology_issues(state, backend) {
|
|
168
|
+
if let Some(id) = crate::topology::issue_id(&issue) {
|
|
169
|
+
repairs.push(topology_repair_hint(id));
|
|
155
170
|
}
|
|
171
|
+
issues.push(issue);
|
|
156
172
|
}
|
|
157
173
|
|
|
158
174
|
if leader_receiver_attached(state) {
|
|
@@ -624,6 +640,17 @@ pub(crate) fn append_registry_channel_unbound_to_report(
|
|
|
624
640
|
repairs.push(recovery_hint(&team_key, issue_id, repair));
|
|
625
641
|
object.insert("issues".to_string(), Value::Array(issues));
|
|
626
642
|
object.insert("suggested_repairs".to_string(), Value::Array(repairs));
|
|
643
|
+
let is_healthy_unattached_host =
|
|
644
|
+
class == crate::lifecycle::launch::LeaderBindingClass::Unbound
|
|
645
|
+
&& object.get("ok").and_then(Value::as_bool) == Some(true)
|
|
646
|
+
&& object
|
|
647
|
+
.get("profile_smoke")
|
|
648
|
+
.and_then(|profile| profile.get("checks"))
|
|
649
|
+
.and_then(Value::as_array)
|
|
650
|
+
.is_some_and(|checks| !checks.is_empty());
|
|
651
|
+
if !is_healthy_unattached_host {
|
|
652
|
+
object.insert("ok".to_string(), Value::Bool(false));
|
|
653
|
+
}
|
|
627
654
|
}
|
|
628
655
|
|
|
629
656
|
pub(crate) fn append_selected_live_leader_workspace_mismatch(
|
|
@@ -652,7 +679,7 @@ fn selected_live_leader_workspace_mismatch(
|
|
|
652
679
|
workspace: &std::path::Path,
|
|
653
680
|
team: Option<&str>,
|
|
654
681
|
) -> Option<(Value, Value)> {
|
|
655
|
-
let selected = crate::state::selector::
|
|
682
|
+
let selected = crate::state::selector::resolve_active_team_readonly(
|
|
656
683
|
workspace,
|
|
657
684
|
team,
|
|
658
685
|
crate::state::selector::SelectorMode::RuntimeOnly,
|
|
@@ -766,8 +793,10 @@ fn append_coordinator_health_issue(
|
|
|
766
793
|
issues: &mut Value,
|
|
767
794
|
repairs: &mut Value,
|
|
768
795
|
) {
|
|
796
|
+
// Diagnose must observe coordinator state without initializing or rewriting
|
|
797
|
+
// `.team/runtime/team.db`, including when the file is missing or empty.
|
|
769
798
|
let workspace = crate::coordinator::WorkspacePath::new(workspace.to_path_buf());
|
|
770
|
-
let health = crate::coordinator::
|
|
799
|
+
let health = crate::coordinator::coordinator_health_read_only(&workspace);
|
|
771
800
|
let Some(id) = coordinator_issue_id(state, &health) else {
|
|
772
801
|
return;
|
|
773
802
|
};
|
|
@@ -1910,14 +1939,24 @@ pub(crate) fn provider_doctor_checks() -> Value {
|
|
|
1910
1939
|
] {
|
|
1911
1940
|
let adapter = crate::provider::get_adapter(provider);
|
|
1912
1941
|
let name = provider_wire(provider);
|
|
1913
|
-
let
|
|
1942
|
+
let fake = matches!(provider, crate::provider::Provider::Fake);
|
|
1943
|
+
let (auth, version) = if fake {
|
|
1944
|
+
(crate::provider::AuthHintStatus::Present, "fake".to_string())
|
|
1945
|
+
} else {
|
|
1946
|
+
(
|
|
1947
|
+
crate::provider::AuthHintStatus::Unknown,
|
|
1948
|
+
"unknown".to_string(),
|
|
1949
|
+
)
|
|
1950
|
+
};
|
|
1914
1951
|
providers.insert(
|
|
1915
1952
|
name.to_string(),
|
|
1916
1953
|
json!({
|
|
1917
|
-
"auth":
|
|
1954
|
+
"auth": auth,
|
|
1918
1955
|
"command": provider_command(provider),
|
|
1919
1956
|
"installed": adapter.is_installed(),
|
|
1920
1957
|
"version": version,
|
|
1958
|
+
"probe_status": "not_run",
|
|
1959
|
+
"provider_probe_status": "not_run",
|
|
1921
1960
|
}),
|
|
1922
1961
|
);
|
|
1923
1962
|
}
|
|
@@ -66,6 +66,7 @@ pub mod profile;
|
|
|
66
66
|
pub mod send;
|
|
67
67
|
pub(crate) mod spec;
|
|
68
68
|
pub mod status;
|
|
69
|
+
pub(crate) mod triage;
|
|
69
70
|
pub mod types;
|
|
70
71
|
|
|
71
72
|
pub use adapters::*;
|
|
@@ -5050,17 +5051,44 @@ pub mod diagnose_port {
|
|
|
5050
5051
|
return;
|
|
5051
5052
|
}
|
|
5052
5053
|
for (idx, line) in text.lines().enumerate() {
|
|
5053
|
-
|
|
5054
|
-
|
|
5055
|
-
|
|
5056
|
-
|
|
5057
|
-
|
|
5058
|
-
|
|
5059
|
-
|
|
5060
|
-
|
|
5061
|
-
|
|
5054
|
+
let Some(key) = secret_assignment_key(line) else {
|
|
5055
|
+
continue;
|
|
5056
|
+
};
|
|
5057
|
+
let rel = path.strip_prefix(root).unwrap_or(path);
|
|
5058
|
+
findings.push(json!({
|
|
5059
|
+
"path": rel.to_string_lossy().to_string(),
|
|
5060
|
+
"line": idx.saturating_add(1),
|
|
5061
|
+
"rule": "api_key_assignment",
|
|
5062
|
+
"match_excerpt": format!("{key}=<redacted>"),
|
|
5063
|
+
}));
|
|
5062
5064
|
}
|
|
5063
5065
|
}
|
|
5066
|
+
|
|
5067
|
+
fn secret_assignment_key(line: &str) -> Option<&'static str> {
|
|
5068
|
+
let line = line.trim_start();
|
|
5069
|
+
let line = match line.strip_prefix("export") {
|
|
5070
|
+
Some(rest) if rest.chars().next().is_some_and(char::is_whitespace) => rest.trim_start(),
|
|
5071
|
+
_ => line,
|
|
5072
|
+
};
|
|
5073
|
+
["OPENAI_API_KEY", "ANTHROPIC_API_KEY"]
|
|
5074
|
+
.into_iter()
|
|
5075
|
+
.find(|key| {
|
|
5076
|
+
line.strip_prefix(*key)
|
|
5077
|
+
.and_then(|rest| rest.strip_prefix('='))
|
|
5078
|
+
.is_some_and(|rhs| {
|
|
5079
|
+
let rhs = rhs.trim();
|
|
5080
|
+
let rhs = rhs
|
|
5081
|
+
.strip_prefix('"')
|
|
5082
|
+
.and_then(|value| value.strip_suffix('"'))
|
|
5083
|
+
.or_else(|| {
|
|
5084
|
+
rhs.strip_prefix('\'')
|
|
5085
|
+
.and_then(|value| value.strip_suffix('\''))
|
|
5086
|
+
})
|
|
5087
|
+
.unwrap_or(rhs);
|
|
5088
|
+
!rhs.is_empty()
|
|
5089
|
+
})
|
|
5090
|
+
})
|
|
5091
|
+
}
|
|
5064
5092
|
///
|
|
5065
5093
|
/// `run_comms_selftest`(`--comms`/`--gate comms`)。**纯 state-read,零 token**(MUST-NOT-13)。
|
|
5066
5094
|
#[cfg(test)]
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
//! Command-specific human triage projections for `doctor` and `diagnose`.
|
|
2
|
+
//!
|
|
3
|
+
//! The JSON report remains the source of truth. This renderer only projects its
|
|
4
|
+
//! stable issue and repair entries; it does not perform I/O or recompute health.
|
|
5
|
+
|
|
6
|
+
use serde_json::Value;
|
|
7
|
+
|
|
8
|
+
use super::{CmdOutput, CmdResult};
|
|
9
|
+
|
|
10
|
+
const LINE_LIMIT_BYTES: usize = 160;
|
|
11
|
+
|
|
12
|
+
pub(crate) fn report(value: Value, as_json: bool, command: &str) -> CmdResult {
|
|
13
|
+
let result = CmdResult::from_json(value, as_json);
|
|
14
|
+
if as_json {
|
|
15
|
+
result
|
|
16
|
+
} else {
|
|
17
|
+
human_result(result, command)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
pub(crate) fn human_result(result: CmdResult, command: &str) -> CmdResult {
|
|
22
|
+
let text = match &result.output {
|
|
23
|
+
CmdOutput::Json(report) => render(command, report),
|
|
24
|
+
CmdOutput::Human(text) => text.clone(),
|
|
25
|
+
CmdOutput::None => String::new(),
|
|
26
|
+
};
|
|
27
|
+
CmdResult {
|
|
28
|
+
output: CmdOutput::Human(text),
|
|
29
|
+
..result
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
pub(crate) fn render(command: &str, report: &Value) -> String {
|
|
34
|
+
// Keep the renderer downstream of the external redaction boundary. The
|
|
35
|
+
// generic emitter redacts again for defense in depth.
|
|
36
|
+
let report = crate::redaction::redact_external_value(report);
|
|
37
|
+
let ok = report.get("ok").and_then(Value::as_bool).unwrap_or(false);
|
|
38
|
+
let mut lines = vec![bounded(format!(
|
|
39
|
+
"{command}: {}",
|
|
40
|
+
if ok { "ok" } else { "needs attention" }
|
|
41
|
+
))];
|
|
42
|
+
|
|
43
|
+
if command == "doctor" {
|
|
44
|
+
for finding in array_items(
|
|
45
|
+
report
|
|
46
|
+
.get("secret_scan")
|
|
47
|
+
.and_then(|scan| scan.get("findings")),
|
|
48
|
+
) {
|
|
49
|
+
let Some(object) = finding.as_object() else {
|
|
50
|
+
continue;
|
|
51
|
+
};
|
|
52
|
+
let (Some(rule), Some(path), Some(line)) = (
|
|
53
|
+
object.get("rule").and_then(Value::as_str),
|
|
54
|
+
object.get("path").and_then(Value::as_str),
|
|
55
|
+
object.get("line").and_then(Value::as_u64),
|
|
56
|
+
) else {
|
|
57
|
+
continue;
|
|
58
|
+
};
|
|
59
|
+
lines.push(bounded(format!("warn: {rule} in {path}:{line}")));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for issue in array_items(report.get("issues")) {
|
|
64
|
+
lines.push(bounded(format!("issue: {}", summarize(issue, false))));
|
|
65
|
+
}
|
|
66
|
+
for repair in array_items(report.get("suggested_repairs")) {
|
|
67
|
+
lines.push(bounded(format!("repair: {}", summarize(repair, true))));
|
|
68
|
+
}
|
|
69
|
+
if let Some(error) = report.get("error").filter(|value| !value.is_null()) {
|
|
70
|
+
lines.push(bounded(format!("detail: {}", summarize(error, false))));
|
|
71
|
+
}
|
|
72
|
+
if lines.len() == 1 {
|
|
73
|
+
lines.push(bounded("detail: no issues detected".to_string()));
|
|
74
|
+
}
|
|
75
|
+
lines.join("\n")
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
fn array_items(value: Option<&Value>) -> &[Value] {
|
|
79
|
+
value.and_then(Value::as_array).map(Vec::as_slice).unwrap_or(&[])
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
fn summarize(value: &Value, repair: bool) -> String {
|
|
83
|
+
match value {
|
|
84
|
+
Value::String(value) => value.clone(),
|
|
85
|
+
Value::Null => "null".to_string(),
|
|
86
|
+
Value::Bool(value) => value.to_string(),
|
|
87
|
+
Value::Number(value) => value.to_string(),
|
|
88
|
+
Value::Array(values) => format!("{} entries", values.len()),
|
|
89
|
+
Value::Object(object) => {
|
|
90
|
+
let keys = if repair {
|
|
91
|
+
["action", "hint_action", "next_action", "issue", "code", "reason"]
|
|
92
|
+
.as_slice()
|
|
93
|
+
} else {
|
|
94
|
+
["code", "issue", "id", "type", "status", "reason", "message"]
|
|
95
|
+
.as_slice()
|
|
96
|
+
};
|
|
97
|
+
let mut parts = Vec::new();
|
|
98
|
+
for key in keys {
|
|
99
|
+
if let Some(value) = object.get(*key).and_then(Value::as_str) {
|
|
100
|
+
parts.push(format!("{key}={value}"));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if parts.is_empty() {
|
|
104
|
+
let mut names = object.keys().cloned().collect::<Vec<_>>();
|
|
105
|
+
names.sort_unstable();
|
|
106
|
+
format!("fields={}", names.join(","))
|
|
107
|
+
} else {
|
|
108
|
+
parts.join("; ")
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
fn bounded(line: String) -> String {
|
|
115
|
+
let clean = line
|
|
116
|
+
.chars()
|
|
117
|
+
.map(|ch| if ch.is_control() { ' ' } else { ch })
|
|
118
|
+
.collect::<String>();
|
|
119
|
+
if clean.len() <= LINE_LIMIT_BYTES {
|
|
120
|
+
return clean;
|
|
121
|
+
}
|
|
122
|
+
let budget = LINE_LIMIT_BYTES.saturating_sub('…'.len_utf8());
|
|
123
|
+
let mut end = 0;
|
|
124
|
+
for (index, ch) in clean.char_indices() {
|
|
125
|
+
let next = index + ch.len_utf8();
|
|
126
|
+
if next > budget {
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
end = next;
|
|
130
|
+
}
|
|
131
|
+
format!("{}…", &clean[..end.min(budget)])
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
#[cfg(test)]
|
|
135
|
+
mod tests {
|
|
136
|
+
use super::*;
|
|
137
|
+
use serde_json::json;
|
|
138
|
+
|
|
139
|
+
#[test]
|
|
140
|
+
fn renderer_projects_each_issue_and_repair_without_dumping_runtime() {
|
|
141
|
+
let report = json!({
|
|
142
|
+
"ok": false,
|
|
143
|
+
"issues": ["short_issue", {"code": "object_issue", "message": "detail"}],
|
|
144
|
+
"suggested_repairs": [{"issue": "object_issue", "action": "run repair"}],
|
|
145
|
+
"providers": {"claude": {"version": "secret"}},
|
|
146
|
+
"runtime": {"workspace": "/tmp/secret"}
|
|
147
|
+
});
|
|
148
|
+
let output = render("diagnose", &report);
|
|
149
|
+
assert!(output.contains("issue: short_issue"));
|
|
150
|
+
assert!(output.contains("issue: code=object_issue"));
|
|
151
|
+
assert!(output.contains("repair: action=run repair"));
|
|
152
|
+
assert!(!output.contains("providers:"));
|
|
153
|
+
assert!(!output.contains("runtime:"));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
#[test]
|
|
157
|
+
fn renderer_sanitizes_and_bounds_utf8_lines() {
|
|
158
|
+
let report = json!({"ok": false, "issues": ["问题\n".to_string() + &"x".repeat(200)]});
|
|
159
|
+
for line in render("doctor", &report).lines() {
|
|
160
|
+
assert!(line.len() <= LINE_LIMIT_BYTES);
|
|
161
|
+
assert!(!line.chars().any(char::is_control));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
#[test]
|
|
166
|
+
fn renderer_projects_secret_findings_without_values() {
|
|
167
|
+
let report = json!({
|
|
168
|
+
"ok": false,
|
|
169
|
+
"secret_scan": {
|
|
170
|
+
"findings": [{
|
|
171
|
+
"rule": "api_key_assignment",
|
|
172
|
+
"path": "leaky-role.md",
|
|
173
|
+
"line": 7,
|
|
174
|
+
"match_excerpt": "OPENAI_API_KEY=secret"
|
|
175
|
+
}]
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
let output = render("doctor", &report);
|
|
179
|
+
assert!(output.contains("warn: api_key_assignment in leaky-role.md:7"));
|
|
180
|
+
assert!(!output.contains("match_excerpt"));
|
|
181
|
+
assert!(!output.contains("OPENAI_API_KEY=secret"));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -74,7 +74,19 @@ use super::types::{
|
|
|
74
74
|
/// returns: HealthReport。ok = 进程在跑 ∧ metadata 三元全等 ∧ 二进制身份一致 ∧ schema 兼容;service_available 刻意排除二进制身份,表示「这个 daemon 还能处理本队队列」;status 区分 Missing / InvalidPid / Running / Stale
|
|
75
75
|
/// ---
|
|
76
76
|
pub fn coordinator_health(workspace: &WorkspacePath) -> HealthReport {
|
|
77
|
-
|
|
77
|
+
coordinator_health_with_schema(workspace, message_store_schema_health(workspace))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/// Read-only coordinator health for diagnose. Unlike the lifecycle path, this
|
|
81
|
+
/// never opens MessageStore and therefore cannot initialize or rewrite team.db.
|
|
82
|
+
pub fn coordinator_health_read_only(workspace: &WorkspacePath) -> HealthReport {
|
|
83
|
+
coordinator_health_with_schema(workspace, message_store_schema_health_read_only(workspace))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
fn coordinator_health_with_schema(
|
|
87
|
+
workspace: &WorkspacePath,
|
|
88
|
+
schema: SchemaHealth,
|
|
89
|
+
) -> HealthReport {
|
|
78
90
|
let current_binary_identity = current_coordinator_binary_identity();
|
|
79
91
|
let pid_path = coordinator_pid_path(workspace);
|
|
80
92
|
let pid = read_pid_file(&pid_path);
|
|
@@ -1004,6 +1016,34 @@ pub(crate) fn message_store_schema_health(workspace: &WorkspacePath) -> SchemaHe
|
|
|
1004
1016
|
}
|
|
1005
1017
|
}
|
|
1006
1018
|
|
|
1019
|
+
fn message_store_schema_health_read_only(workspace: &WorkspacePath) -> SchemaHealth {
|
|
1020
|
+
let action = Some("run team-agent doctor --fix-schema --json".to_string());
|
|
1021
|
+
match crate::db::migration::schema_diagnosis_workspace_read_only(workspace.as_path()) {
|
|
1022
|
+
Ok(diagnosis) if diagnosis.ok && diagnosis.status == "ok" => SchemaHealth {
|
|
1023
|
+
ok: true,
|
|
1024
|
+
schema_version: crate::db::schema::SCHEMA_VERSION,
|
|
1025
|
+
error: None,
|
|
1026
|
+
action: None,
|
|
1027
|
+
},
|
|
1028
|
+
Ok(diagnosis) => SchemaHealth {
|
|
1029
|
+
ok: false,
|
|
1030
|
+
schema_version: crate::db::schema::SCHEMA_VERSION,
|
|
1031
|
+
error: Some(SchemaError::InitFailed {
|
|
1032
|
+
message: format!("team.db status: {}", diagnosis.status),
|
|
1033
|
+
}),
|
|
1034
|
+
action,
|
|
1035
|
+
},
|
|
1036
|
+
Err(error) => SchemaHealth {
|
|
1037
|
+
ok: false,
|
|
1038
|
+
schema_version: crate::db::schema::SCHEMA_VERSION,
|
|
1039
|
+
error: Some(SchemaError::InitFailed {
|
|
1040
|
+
message: error.to_string(),
|
|
1041
|
+
}),
|
|
1042
|
+
action,
|
|
1043
|
+
},
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1007
1047
|
fn read_pid_file(path: &Path) -> Option<Pid> {
|
|
1008
1048
|
let text = std::fs::read_to_string(path).ok()?;
|
|
1009
1049
|
let raw = text.trim().parse::<u32>().ok()?;
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
use std::path::{Path, PathBuf};
|
|
10
10
|
|
|
11
|
-
use rusqlite::Connection;
|
|
11
|
+
use rusqlite::{Connection, OpenFlags};
|
|
12
12
|
|
|
13
13
|
use crate::db::schema::{ensure_schema_indexes, table_layout, SCHEMA_VERSION};
|
|
14
14
|
use crate::db::DbError;
|
|
@@ -423,22 +423,33 @@ pub fn ensure_table_layout(
|
|
|
423
423
|
/// `schema_migration.py:schema_diagnosis`:只读判定(不变更 DB)。
|
|
424
424
|
fn schema_diagnosis(db_path: &Path, schema_version: i64) -> Result<Diagnosis, DbError> {
|
|
425
425
|
if !db_path.exists() {
|
|
426
|
-
|
|
427
|
-
// first-use state — ok:true is layered with the explicit status axis and the
|
|
428
|
-
// recommended_action guidance (Python schema_migration.py:180-190 verbatim),
|
|
429
|
-
// never a silent fake-green.
|
|
430
|
-
return Ok(Diagnosis {
|
|
431
|
-
ok: true,
|
|
432
|
-
status: "missing".to_string(),
|
|
433
|
-
user_version: 0,
|
|
434
|
-
layout_diffs: vec![],
|
|
435
|
-
recommended_action:
|
|
436
|
-
"No team.db exists yet; initialize_schema will create it on first use.".to_string(),
|
|
437
|
-
});
|
|
426
|
+
return Ok(missing_schema_diagnosis());
|
|
438
427
|
}
|
|
439
428
|
let conn = Connection::open(db_path)?;
|
|
440
|
-
|
|
441
|
-
|
|
429
|
+
schema_diagnosis_from_connection(&conn, schema_version)
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
fn missing_schema_diagnosis() -> Diagnosis {
|
|
433
|
+
// T3-3 cr verdict (A parity lock, 2026-06-10): a missing db is the LEGAL
|
|
434
|
+
// first-use state — ok:true is layered with the explicit status axis and the
|
|
435
|
+
// recommended_action guidance (Python schema_migration.py:180-190 verbatim),
|
|
436
|
+
// never a silent fake-green.
|
|
437
|
+
Diagnosis {
|
|
438
|
+
ok: true,
|
|
439
|
+
status: "missing".to_string(),
|
|
440
|
+
user_version: 0,
|
|
441
|
+
layout_diffs: vec![],
|
|
442
|
+
recommended_action:
|
|
443
|
+
"No team.db exists yet; initialize_schema will create it on first use.".to_string(),
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
fn schema_diagnosis_from_connection(
|
|
448
|
+
conn: &Connection,
|
|
449
|
+
schema_version: i64,
|
|
450
|
+
) -> Result<Diagnosis, DbError> {
|
|
451
|
+
let uv = pragma_user_version(conn)?;
|
|
452
|
+
let diffs = layout_diffs(conn)?;
|
|
442
453
|
let diff_tables: Vec<String> = diffs.iter().map(|d| d.table.to_string()).collect();
|
|
443
454
|
let ok = diffs.is_empty() && uv == schema_version;
|
|
444
455
|
Ok(Diagnosis {
|
|
@@ -466,6 +477,16 @@ pub fn schema_diagnosis_workspace(workspace: &Path) -> Result<Diagnosis, DbError
|
|
|
466
477
|
)
|
|
467
478
|
}
|
|
468
479
|
|
|
480
|
+
/// 对 workspace 的 team.db 执行严格只读 diagnosis,不创建或初始化缺失/空文件。
|
|
481
|
+
pub fn schema_diagnosis_workspace_read_only(workspace: &Path) -> Result<Diagnosis, DbError> {
|
|
482
|
+
let db_path = workspace.join(".team").join("runtime").join("team.db");
|
|
483
|
+
if !db_path.exists() {
|
|
484
|
+
return Ok(missing_schema_diagnosis());
|
|
485
|
+
}
|
|
486
|
+
let conn = Connection::open_with_flags(&db_path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
|
|
487
|
+
schema_diagnosis_from_connection(&conn, SCHEMA_VERSION)
|
|
488
|
+
}
|
|
489
|
+
|
|
469
490
|
/// `schema_migration.py:_workspace_from_db_path`:`<ws>/.team/runtime/team.db` → `<ws>`。
|
|
470
491
|
fn workspace_from_db_path(db_path: &Path) -> Option<PathBuf> {
|
|
471
492
|
let parts: Vec<_> = db_path.iter().collect();
|
|
@@ -55,7 +55,11 @@ pub(crate) fn redact_external_value(value: &serde_json::Value) -> serde_json::Va
|
|
|
55
55
|
object
|
|
56
56
|
.iter()
|
|
57
57
|
.map(|(key, value)| {
|
|
58
|
-
let value = if
|
|
58
|
+
let value = if key.eq_ignore_ascii_case("auth")
|
|
59
|
+
&& value.as_str() == Some("unknown")
|
|
60
|
+
{
|
|
61
|
+
value.clone()
|
|
62
|
+
} else if is_sensitive_env_key(key) {
|
|
59
63
|
Value::String(REDACTED.to_string())
|
|
60
64
|
} else if is_argument_array_key(key) {
|
|
61
65
|
redact_argument_array(value)
|
|
@@ -32,6 +32,19 @@ pub fn issue_id(issue: &Value) -> Option<&str> {
|
|
|
32
32
|
issue.get("id").and_then(Value::as_str)
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
pub fn endpoint_socket_conflict(state: &Value) -> Option<Value> {
|
|
36
|
+
let endpoint = non_empty_str(state, "tmux_endpoint")?;
|
|
37
|
+
let socket = non_empty_str(state, "tmux_socket")?;
|
|
38
|
+
if same_endpoint(endpoint, socket) {
|
|
39
|
+
return None;
|
|
40
|
+
}
|
|
41
|
+
Some(json!({
|
|
42
|
+
"id": TMUX_ENDPOINT_SOCKET_CONFLICT,
|
|
43
|
+
"tmux_endpoint": endpoint,
|
|
44
|
+
"tmux_socket": socket,
|
|
45
|
+
}))
|
|
46
|
+
}
|
|
47
|
+
|
|
35
48
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
36
49
|
pub enum EndpointConvergenceDecision {
|
|
37
50
|
NoConflict,
|
|
@@ -338,21 +351,14 @@ fn classify_agents_for_observed_pane(
|
|
|
338
351
|
}
|
|
339
352
|
|
|
340
353
|
fn append_socket_split_issues(state: &Value, issues: &mut Vec<Value>, include_readiness: bool) {
|
|
341
|
-
let
|
|
342
|
-
let socket = non_empty_str(state, "tmux_socket");
|
|
343
|
-
let (Some(endpoint), Some(socket)) = (endpoint, socket) else {
|
|
354
|
+
let Some(conflict) = endpoint_socket_conflict(state) else {
|
|
344
355
|
return;
|
|
345
356
|
};
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
}
|
|
357
|
+
let endpoint = non_empty_str(state, "tmux_endpoint").unwrap_or_default();
|
|
358
|
+
let socket = non_empty_str(state, "tmux_socket").unwrap_or_default();
|
|
349
359
|
|
|
350
360
|
let session = non_empty_str(state, "session_name").unwrap_or_default();
|
|
351
|
-
issues.push(
|
|
352
|
-
"id": TMUX_ENDPOINT_SOCKET_CONFLICT,
|
|
353
|
-
"tmux_endpoint": endpoint,
|
|
354
|
-
"tmux_socket": socket,
|
|
355
|
-
}));
|
|
361
|
+
issues.push(conflict);
|
|
356
362
|
|
|
357
363
|
if state
|
|
358
364
|
.get("leader_receiver")
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@team-agent/installer",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.92",
|
|
4
4
|
"description": "npx installer for Team Agent",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"codex",
|
|
@@ -20,9 +20,9 @@
|
|
|
20
20
|
"team-agent-installer": "npm/install.mjs"
|
|
21
21
|
},
|
|
22
22
|
"optionalDependencies": {
|
|
23
|
-
"@team-agent/cli-darwin-arm64": "0.5.
|
|
24
|
-
"@team-agent/cli-darwin-x64": "0.5.
|
|
25
|
-
"@team-agent/cli-linux-x64": "0.5.
|
|
23
|
+
"@team-agent/cli-darwin-arm64": "0.5.92",
|
|
24
|
+
"@team-agent/cli-darwin-x64": "0.5.92",
|
|
25
|
+
"@team-agent/cli-linux-x64": "0.5.92"
|
|
26
26
|
},
|
|
27
27
|
"scripts": {
|
|
28
28
|
"postinstall": "node npm/bincheck.mjs",
|