use std::path::{Path, PathBuf};
use std::time::Duration;
use crate::core::doctor::{CheckStatus, DoctorCheck, DoctorReport};
use crate::core::paths::FrameworkPaths;
use super::discover::{TRUSTY_MEMORY_DEFAULT_ADDR, TRUSTY_SEARCH_DEFAULT_ADDR, discover_addr};
#[path = "doctor_output_style.rs"]
mod doctor_output_style;
use doctor_output_style::{
check_output_style, check_output_style_legacy_ids, check_output_style_staleness,
};
#[path = "doctor_fs_checks.rs"]
mod doctor_fs_checks;
use doctor_fs_checks::{check_agents, check_instructions, check_skill_source, check_skills};
#[path = "doctor_deploy_validate.rs"]
mod doctor_deploy_validate;
use doctor_deploy_validate::check_deployment_completeness;
#[path = "doctor_agent_reachability.rs"]
mod doctor_agent_reachability;
use doctor_agent_reachability::check_agent_reachability;
#[path = "doctor_asset_tier.rs"]
mod doctor_asset_tier;
use doctor_asset_tier::check_asset_tier;
#[path = "doctor_transcript_saving.rs"]
mod doctor_transcript_saving;
use doctor_transcript_saving::check_transcript_saving;
#[path = "doctor_staleness.rs"]
mod doctor_staleness;
use doctor_staleness::check_legacy_instruction_sources;
#[path = "doctor_skill_drift.rs"]
mod doctor_skill_drift;
use doctor_skill_drift::check_skill_staleness;
#[path = "doctor_binary_provenance.rs"]
mod doctor_binary_provenance;
use doctor_binary_provenance::check_binary_provenance;
#[path = "doctor_skill_unmanaged.rs"]
mod doctor_skill_unmanaged;
use doctor_skill_unmanaged::check_skill_unmanaged;
#[path = "doctor_agent_skills.rs"]
mod doctor_agent_skills;
use doctor_agent_skills::check_agent_skills;
#[path = "doctor_gh_account.rs"]
mod doctor_gh_account;
use doctor_gh_account::check_gh_account;
#[path = "doctor_hooks_hygiene.rs"]
mod doctor_hooks_hygiene;
use doctor_hooks_hygiene::check_hooks_hygiene;
#[path = "doctor_tcc.rs"]
mod doctor_tcc;
use doctor_tcc::check_tcc_taint;
#[path = "doctor_scaffold_tracking.rs"]
mod doctor_scaffold_tracking;
use doctor_scaffold_tracking::check_scaffold_tracking;
#[path = "doctor_push_guard.rs"]
mod doctor_push_guard;
use doctor_push_guard::check_push_guard;
#[path = "doctor_worktree_disk.rs"]
mod doctor_worktree_disk;
use doctor_worktree_disk::check_worktree_disk;
#[path = "doctor_legacy_overrides.rs"]
mod doctor_legacy_overrides;
use doctor_legacy_overrides::check_legacy_overrides;
#[path = "doctor_search_pin.rs"]
mod doctor_search_pin;
use doctor_search_pin::check_search_index_pin;
#[path = "doctor_session_store.rs"]
mod doctor_session_store;
use doctor_session_store::check_session_store;
#[path = "doctor_stray_mcp.rs"]
mod doctor_stray_mcp;
use doctor_stray_mcp::check_stray_mcp_json;
pub const PROBE_TIMEOUT: Duration = Duration::from_secs(10);
const PROBE_ATTEMPTS: usize = 3;
const PROBE_RETRY_DELAY: Duration = Duration::from_millis(500);
pub async fn run_doctor(
project_dir: Option<&Path>,
repos_root: Option<&Path>,
active_workspace_paths: &[PathBuf],
) -> DoctorReport {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
let paths = match project_dir {
Some(dir) => FrameworkPaths::for_managed_workspace(dir),
None => FrameworkPaths::default(),
};
let skills_root: &Path = project_dir.unwrap_or(&home);
let (agent_skills, agent_skills_prose_hints) = check_agent_skills(&paths);
let mut checks = vec![
check_instructions(project_dir),
check_agents(&paths, project_dir),
check_agent_reachability(&paths, project_dir),
check_asset_tier(&paths, project_dir, &home),
check_transcript_saving(),
check_skills(skills_root),
check_skill_source(&paths),
check_output_style(project_dir, &home),
check_output_style_staleness(project_dir, &home),
check_output_style_legacy_ids(project_dir, &home),
check_deployment_completeness(&paths),
check_skill_staleness(&paths, project_dir),
check_skill_unmanaged(&paths, project_dir),
check_legacy_instruction_sources(&home),
check_legacy_overrides(project_dir),
agent_skills,
agent_skills_prose_hints,
];
checks.push(check_memory(&home).await);
checks.push(check_search(&home, project_dir).await);
checks.push(check_search_index_pin(&home, project_dir).await);
checks.push(check_worktrees(repos_root, active_workspace_paths).await);
checks.push(check_worktree_disk(repos_root, active_workspace_paths).await);
checks.push(check_gh_account().await);
checks.push(check_oauth_token_config());
let (hooks_contamination, hooks_foreign_conflict) =
check_hooks_hygiene(project_dir, active_workspace_paths);
checks.push(hooks_contamination);
checks.push(hooks_foreign_conflict);
checks.push(check_tcc_taint());
checks.push(check_scaffold_tracking(project_dir));
checks.push(check_push_guard(project_dir));
checks.push(check_binary_provenance());
checks.push(check_session_store(&FrameworkPaths::default().root));
checks.push(check_stray_mcp_json(
&crate::core::mcp_provenance::default_framework_root(),
project_dir,
&home,
));
DoctorReport::from_checks(checks)
}
fn check_oauth_token_config() -> DoctorCheck {
let relocated = crate::core::trusty_tools_config::managed_claude_config_dir().is_some();
let status = crate::core::oauth_token::compute_status();
build_oauth_token_check(relocated, status.stored_token_present, status.env_var_set)
}
fn build_oauth_token_check(
relocated: bool,
stored_token_present: bool,
env_var_set: bool,
) -> DoctorCheck {
let token_available = stored_token_present || env_var_set;
if relocated && !token_available {
DoctorCheck::new(
"oauth_token",
CheckStatus::Warn,
"managed sessions relocate CLAUDE_CONFIG_DIR but no CLAUDE_CODE_OAUTH_TOKEN is \
configured — a `/login` inside a managed session may loop between \"successful\" \
and \"not logged in\" (issue #2246). Note an ambient ANTHROPIC_API_KEY does NOT \
help: managed spawns strip it. Run `claude setup-token` then `tm auth set-token`",
)
} else {
DoctorCheck::new(
"oauth_token",
CheckStatus::Ok,
"managed-session auth looks configured",
)
}
}
async fn check_worktrees(
repos_root: Option<&Path>,
active_workspace_paths: &[PathBuf],
) -> DoctorCheck {
let Some(root) = repos_root else {
return DoctorCheck::new(
"worktrees",
CheckStatus::Ok,
"no managed workspace root configured — worktree scan skipped",
);
};
if !root.is_dir() {
return DoctorCheck::new(
"worktrees",
CheckStatus::Ok,
format!("{} does not exist — no worktrees to check", root.display()),
);
}
let root = root.to_path_buf();
let active_set: std::collections::HashSet<PathBuf> = active_workspace_paths
.iter()
.map(|p| std::fs::canonicalize(p).unwrap_or_else(|_| p.clone()))
.collect();
let orphans = tokio::task::spawn_blocking(move || {
crate::session_manager::prune::find_orphaned_worktrees(&root, &active_set)
})
.await
.unwrap_or_else(|e| {
tracing::error!("doctor: worktree scan panicked: {e}");
Vec::new()
});
if orphans.is_empty() {
DoctorCheck::new("worktrees", CheckStatus::Ok, "no orphaned worktrees found")
} else {
DoctorCheck::new(
"worktrees",
CheckStatus::Warn,
format!(
"{} orphaned worktree dir(s) found — run `tm session prune --worktrees` to remove",
orphans.len()
),
)
}
}
async fn check_memory(home: &Path) -> DoctorCheck {
let dir = home.join(".trusty-memory");
let default = TRUSTY_MEMORY_DEFAULT_ADDR
.parse()
.expect("static default is valid");
let env = std::env::var("TRUSTY_MEMORY_ADDR").ok();
let addr = discover_addr(&dir, default, env.as_deref()).await;
probe_health(
"memory",
"trusty-memory",
&format!("http://{addr}/health"),
&addr.to_string(),
)
.await
}
enum ProbeOutcome {
Success(Option<serde_json::Value>),
NonSuccess(u16),
TimedOut,
Unreachable(String),
}
async fn probe_health_once(url: &str) -> ProbeOutcome {
let client = match reqwest::Client::builder().timeout(PROBE_TIMEOUT).build() {
Ok(c) => c,
Err(e) => return ProbeOutcome::Unreachable(e.to_string()),
};
match client.get(url).send().await {
Ok(resp) if resp.status().is_success() => {
ProbeOutcome::Success(resp.json::<serde_json::Value>().await.ok())
}
Ok(resp) => ProbeOutcome::NonSuccess(resp.status().as_u16()),
Err(e) if e.is_timeout() => ProbeOutcome::TimedOut,
Err(e) => ProbeOutcome::Unreachable(e.to_string()),
}
}
async fn probe_health(check: &str, service: &str, url: &str, addr: &str) -> DoctorCheck {
let mut last_timeout = false;
let mut last_err: Option<String> = None;
let mut last_status: Option<u16> = None;
for attempt in 0..PROBE_ATTEMPTS {
match probe_health_once(url).await {
ProbeOutcome::Success(body) => {
return interpret_health(check, service, addr, body.as_ref());
}
ProbeOutcome::NonSuccess(code) => {
last_status = Some(code);
last_timeout = false;
}
ProbeOutcome::TimedOut => {
last_timeout = true;
}
ProbeOutcome::Unreachable(e) => {
last_err = Some(e);
last_timeout = false;
}
}
if attempt + 1 < PROBE_ATTEMPTS {
tokio::time::sleep(PROBE_RETRY_DELAY).await;
}
}
if last_timeout {
return DoctorCheck::new(
check,
CheckStatus::Unknown,
format!(
"{service} at {addr} did not answer /health within {}s across {PROBE_ATTEMPTS} \
attempts, but the connection was NOT refused — the daemon may be alive and \
merely slow. Health could not be determined. Check the MCP surface before \
restarting anything.",
PROBE_TIMEOUT.as_secs()
),
);
}
if let Some(code) = last_status {
return DoctorCheck::new(
check,
CheckStatus::Fail,
format!("{service} at {addr} returned HTTP {code}"),
);
}
DoctorCheck::new(
check,
CheckStatus::Fail,
format!(
"{service} unreachable at {addr}: {}",
last_err.unwrap_or_else(|| "connection refused".to_string())
),
)
}
fn interpret_health(
check: &str,
service: &str,
addr: &str,
body: Option<&serde_json::Value>,
) -> DoctorCheck {
let Some(body) = body else {
return DoctorCheck::new(
check,
CheckStatus::Unknown,
format!(
"{service} at {addr} answered /health but the body was unreadable — the \
listener is up; whether its workers are progressing is UNKNOWN"
),
);
};
let worker = body.get("worker");
let wedged = worker
.and_then(|w| w.get("wedged"))
.and_then(serde_json::Value::as_bool);
match wedged {
Some(true) => {
let oldest = worker
.and_then(|w| w.get("oldest_age_secs"))
.and_then(serde_json::Value::as_u64)
.unwrap_or_default();
let in_flight = worker
.and_then(|w| w.get("in_flight"))
.and_then(serde_json::Value::as_u64)
.unwrap_or_default();
return DoctorCheck::new(
check,
CheckStatus::Fail,
format!(
"{service} at {addr} is answering /health BUT reports a WEDGED worker \
pool: oldest in-flight operation {oldest}s, {in_flight} in flight. The \
listener responding does not mean writes are progressing (issue #3992)."
),
);
}
None => {
return DoctorCheck::new(
check,
CheckStatus::Unknown,
format!(
"{service} at {addr} is reachable but does not report worker-pool \
occupancy (pre-#4001 build) — liveness confirmed, progress UNKNOWN"
),
);
}
Some(false) => {}
}
if body.get("daemon_state").and_then(|v| v.as_str()) == Some("warming") {
return DoctorCheck::new(
check,
CheckStatus::Warn,
format!(
"{service} at {addr} is WARMING UP (embedder initialising) — normal shortly after a restart"
),
);
}
if body.get("status").and_then(|v| v.as_str()) == Some("degraded") {
let detail = body
.get("detail")
.and_then(|v| v.as_str())
.unwrap_or("no detail reported");
return DoctorCheck::new(
check,
CheckStatus::Warn,
format!("{service} at {addr} reports DEGRADED: {detail}"),
);
}
DoctorCheck::new(
check,
CheckStatus::Ok,
format!("{service} healthy at {addr}, workers progressing"),
)
}
async fn check_search(home: &Path, project_dir: Option<&Path>) -> DoctorCheck {
let dir = home.join(".trusty-search");
let default = TRUSTY_SEARCH_DEFAULT_ADDR
.parse()
.expect("static default is valid");
let env = std::env::var("TRUSTY_SEARCH_ADDR").ok();
let addr = discover_addr(&dir, default, env.as_deref()).await;
match http_get_ok(&format!("http://{addr}/health")).await {
Ok(true) => {}
Ok(false) => {
return DoctorCheck::new(
"search",
CheckStatus::Fail,
format!("trusty-search at {addr} returned a non-2xx status"),
);
}
Err(e) => {
return DoctorCheck::new(
"search",
CheckStatus::Fail,
format!("trusty-search unreachable at {addr}: {e}"),
);
}
}
let expected_index = expected_search_index_id(project_dir);
match http_get_json(&format!("http://{addr}/indexes")).await {
Ok(body) if index_present(&body, &expected_index) => DoctorCheck::new(
"search",
CheckStatus::Ok,
format!("trusty-search healthy at {addr}, `{expected_index}` index present"),
),
Ok(_) => DoctorCheck::new(
"search",
CheckStatus::Warn,
format!("trusty-search healthy at {addr} but the `{expected_index}` index is missing"),
),
Err(e) => DoctorCheck::new(
"search",
CheckStatus::Warn,
format!("trusty-search healthy at {addr} but listing indexes failed: {e}"),
),
}
}
fn expected_search_index_id(project_dir: Option<&Path>) -> String {
let start = match project_dir {
Some(dir) => dir.to_path_buf(),
None => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
};
let root = trusty_common::resolve_project_root(&start);
trusty_common::derive_index_id(&root)
}
fn index_present(body: &serde_json::Value, name: &str) -> bool {
let array = body
.as_array()
.or_else(|| body.get("indexes").and_then(|v| v.as_array()));
let Some(array) = array else {
return false;
};
array.iter().any(|entry| {
if entry.as_str() == Some(name) {
return true;
}
["id", "name", "index_id"]
.iter()
.any(|key| entry.get(key).and_then(|v| v.as_str()) == Some(name))
})
}
async fn http_get_ok(url: &str) -> anyhow::Result<bool> {
let client = reqwest::Client::builder().timeout(PROBE_TIMEOUT).build()?;
let resp = client.get(url).send().await?;
Ok(resp.status().is_success())
}
async fn http_get_json(url: &str) -> anyhow::Result<serde_json::Value> {
let client = reqwest::Client::builder().timeout(PROBE_TIMEOUT).build()?;
let body = client
.get(url)
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(body)
}
#[cfg(test)]
#[path = "doctor_tests.rs"]
mod tests;