use crate::cli::commands::lifecycle;
use crate::cli::commands::{doctor_fix, doctor_rescue, doctor_restore};
use crate::cli::output::{OutputConfig, OutputFormat};
use crate::cli::DoctorArgs;
use crate::config;
use crate::error::OlError;
use crate::hooks;
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum CheckStatus {
Pass,
Warn,
Fail,
}
#[derive(Clone)]
pub(crate) struct DoctorCheck {
pub status: CheckStatus,
pub message: String,
}
impl DoctorCheck {
pub(crate) fn pass(message: impl Into<String>) -> Self {
Self {
status: CheckStatus::Pass,
message: message.into(),
}
}
pub(crate) fn warn(message: impl Into<String>) -> Self {
Self {
status: CheckStatus::Warn,
message: message.into(),
}
}
pub(crate) fn fail(message: impl Into<String>) -> Self {
Self {
status: CheckStatus::Fail,
message: message.into(),
}
}
pub(crate) fn is_pass(&self) -> bool {
self.status != CheckStatus::Fail
}
}
#[allow(dead_code)] pub(crate) struct DoctorReport {
pub checks: Vec<DoctorCheck>,
pub issues: Vec<String>,
pub daemon_alive: bool,
pub daemon_uptime_secs: Option<u64>,
pub port: u16,
}
#[allow(dead_code)] impl DoctorReport {
pub(crate) fn all_pass(&self) -> bool {
self.checks.iter().all(DoctorCheck::is_pass)
}
pub(crate) fn fail_count(&self) -> usize {
self.checks
.iter()
.filter(|c| c.status == CheckStatus::Fail)
.count()
}
}
pub fn run_doctor(args: &DoctorArgs, output: &OutputConfig) -> Result<(), OlError> {
if args.trigger_panic {
panic!("openlatch crash-report validation panic");
}
if args.rescue && args.fix {
doctor_rescue::run(args, output, true)?;
return doctor_fix::run(args, output);
}
if args.rescue && args.restore {
doctor_rescue::run(args, output, false)?;
return doctor_restore::run(args, output);
}
if args.fix {
return doctor_fix::run(args, output);
}
if args.restore {
return doctor_restore::run(args, output);
}
if args.rescue {
return doctor_rescue::run(args, output, false);
}
crate::cli::header::print(output, &["doctor"]);
if output.format == OutputFormat::Human && !output.quiet {
eprintln!();
}
let report = run_all_checks(output)?;
print_diagnostic_results(&report, output);
Ok(())
}
pub(crate) fn run_all_checks(_output: &OutputConfig) -> Result<DoctorReport, OlError> {
let cfg = config::Config::load(None, None, false)?;
let ol_dir = config::openlatch_dir();
let mut issues: Vec<String> = Vec::new();
let mut checks: Vec<DoctorCheck> = Vec::new();
match hooks::detect_agent() {
Ok(agent) => {
let label = match &agent {
hooks::DetectedAgent::ClaudeCode { claude_dir, .. } => {
format!("Claude Code ({})", claude_dir.display())
}
};
checks.push(DoctorCheck::pass(format!("Agent detected: {label}")));
}
Err(e) => {
let msg = format!("Agent not found: {} ({})", e.message, e.code);
checks.push(DoctorCheck::fail(msg.clone()));
issues.push("No AI agent detected. Install Claude Code to use OpenLatch.".to_string());
}
}
let config_path = ol_dir.join("config.toml");
if config_path.exists() {
match config::Config::load(None, None, false) {
Ok(_) => {
checks.push(DoctorCheck::pass(format!(
"Config file: {}",
config_path.display()
)));
}
Err(e) => {
checks.push(DoctorCheck::fail(format!(
"Config file invalid: {} ({})",
e.message, e.code
)));
issues.push(format!(
"Config file '{}' has errors. Delete and re-run 'openlatch init'.",
config_path.display()
));
}
}
} else {
checks.push(DoctorCheck::fail(format!(
"Config file missing: {}",
config_path.display()
)));
issues.push("Config file missing. Run 'openlatch init' to create it.".to_string());
}
#[cfg(feature = "crash-report")]
{
let resolved = crate::crash_report::current_state(&ol_dir);
let label = match resolved.decided_by {
crate::crash_report::consent::DecidedBy::SentryDisabledEnv => {
"off (SENTRY_DISABLED env)"
}
crate::crash_report::consent::DecidedBy::NoBakedDsn => "off (no DSN baked)",
crate::crash_report::consent::DecidedBy::ConfigFile => {
if resolved.enabled() {
"on (config.toml)"
} else {
"off (config.toml)"
}
}
crate::crash_report::consent::DecidedBy::DefaultEnabled => "on (default)",
};
checks.push(DoctorCheck::pass(format!("Crash reporting: {label}")));
}
#[cfg(not(feature = "crash-report"))]
{
checks.push(DoctorCheck::pass(
"Crash reporting: not compiled in".to_string(),
));
}
let token_path = ol_dir.join("daemon.token");
let mut daemon_token: Option<String> = None;
if token_path.exists() {
match std::fs::read_to_string(&token_path) {
Ok(content) if !content.trim().is_empty() => {
daemon_token = Some(content.trim().to_string());
checks.push(DoctorCheck::pass(format!(
"Auth token: {} (valid)",
token_path.display()
)));
}
Ok(_) => {
checks.push(DoctorCheck::fail(format!(
"Auth token empty: {}",
token_path.display()
)));
issues.push("Auth token is empty. Run 'openlatch init' to regenerate.".to_string());
}
Err(e) => {
checks.push(DoctorCheck::fail(format!(
"Auth token unreadable: {} — {e}",
token_path.display()
)));
issues.push(format!(
"Cannot read token file '{}'. Check file permissions.",
token_path.display()
));
}
}
} else {
checks.push(DoctorCheck::fail(format!(
"Auth token missing: {}",
token_path.display()
)));
issues.push("Auth token missing. Run 'openlatch init' to generate one.".to_string());
}
let pid = lifecycle::read_pid_file();
let daemon_alive = pid.map(lifecycle::is_process_alive).unwrap_or(false);
let bound_port = config::read_port_file();
if let (true, Some(bound)) = (daemon_alive, bound_port) {
if bound != cfg.port {
checks.push(DoctorCheck::fail(format!(
"Daemon identity: daemon.port says this instance is on {bound}, but the configured port is {}",
cfg.port
)));
issues.push(format!(
"PID {} belongs to the daemon on port {bound}; port {} is served by a different process. Checks below describe that other instance — re-run with OPENLATCH_DIR set to the instance you mean.",
pid.unwrap_or(0),
cfg.port
));
}
}
let mut daemon_reachable = false;
if daemon_alive {
let url = format!("http://127.0.0.1:{}/health", cfg.port);
let health: Option<serde_json::Value> = reqwest::blocking::get(&url)
.ok()
.filter(|r| r.status().is_success())
.and_then(|r| r.json().ok());
let reachable = health.is_some();
daemon_reachable = reachable;
if reachable {
let same_instance = bound_port.map(|b| b == cfg.port).unwrap_or(true);
if same_instance {
checks.push(DoctorCheck::pass(format!(
"Daemon: running on port {} (PID {})",
cfg.port,
pid.unwrap()
)));
} else {
checks.push(DoctorCheck::pass(format!(
"Daemon: port {} is serving (PID unknown — local daemon.pid {} is on port {})",
cfg.port,
pid.unwrap(),
bound_port.unwrap()
)));
}
if let Some(running) = health
.as_ref()
.and_then(|h| h.get("version"))
.and_then(|v| v.as_str())
{
let installed = env!("OPENLATCH_VERSION");
if running != installed {
checks.push(DoctorCheck::warn(format!(
"Daemon binary: serving {running}, {installed} installed on disk"
)));
issues.push(format!(
"The daemon is still serving {running} while {installed} sits on disk — \
an upgrade landed but never took effect (a package manager replaces the \
binary; it does not restart the process). Run 'openlatch restart'."
));
}
}
} else {
checks.push(DoctorCheck::fail(format!(
"Daemon: process alive (PID {}) but /health unreachable on port {}",
pid.unwrap(),
cfg.port
)));
issues.push(format!(
"Daemon process exists but /health on port {} doesn't respond. Try 'openlatch restart'.",
cfg.port
));
}
} else {
checks.push(DoctorCheck::fail(format!(
"Daemon: not running (port {})",
cfg.port
)));
issues.push("Daemon is not running. Run 'openlatch start' to start it.".to_string());
}
let mut daemon_uptime_secs: Option<u64> = None;
if daemon_alive {
let metrics_url = format!("http://127.0.0.1:{}/metrics", cfg.port);
match reqwest::blocking::get(&metrics_url).and_then(|r| r.json::<serde_json::Value>()) {
Ok(m) => {
daemon_uptime_secs = m.get("uptime_secs").and_then(|v| v.as_u64());
let status = m
.get("cloud_status")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let drops = m
.get("cloud_drop_count")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let api_url = m
.get("cloud_api_url")
.and_then(|v| v.as_str())
.unwrap_or("");
match status {
"connected" => {
checks.push(DoctorCheck::pass(format!("Cloud: connected ({api_url})")));
}
"network_error" => {
let msg =
format!("Cloud: network errors — {drops} event(s) dropped ({api_url})");
checks.push(DoctorCheck::fail(msg.clone()));
issues.push(format!(
"Daemon cannot reach cloud at {api_url}. Check OPENLATCH_API_URL and network connectivity."
));
}
"auth_error" => {
let msg = format!("Cloud: auth error — credential rejected ({api_url})");
checks.push(DoctorCheck::fail(msg));
issues.push(
"Run 'openlatch auth login' to refresh the cloud credential."
.to_string(),
);
}
"no_credential" => {
let msg = format!("Cloud: no credential — forwarding paused ({api_url})");
checks.push(DoctorCheck::fail(msg));
issues.push(
"Run 'openlatch auth login' to store an API key, or set \
[cloud] enabled = false in config.toml."
.to_string(),
);
}
"not_configured" => {
checks.push(DoctorCheck::pass(
"Cloud: disabled in daemon config".to_string(),
));
}
other => {
checks.push(DoctorCheck::fail(format!(
"Cloud: unknown status '{other}'"
)));
}
}
}
Err(_) => {
checks.push(DoctorCheck::fail(
"Cloud: could not read daemon /metrics — daemon may be unhealthy".to_string(),
));
}
}
} else if cfg.cloud.enabled {
match tokio::runtime::Runtime::new() {
Ok(rt) => {
let (pass, message) = rt.block_on(check_cloud(&cfg.cloud.api_url));
if pass {
checks.push(DoctorCheck::pass(message));
} else {
checks.push(DoctorCheck::fail(message.clone()));
if message.contains("not authenticated") {
issues.push("Run 'openlatch auth login' to enable cloud sync.".to_string());
} else {
issues.push(format!(
"Cloud unreachable or credential rejected at {}. Check network connectivity or re-run 'openlatch auth login'.",
cfg.cloud.api_url
));
}
}
}
Err(_) => {
checks.push(DoctorCheck::fail(
"Cloud: check skipped (runtime init failed)".to_string(),
));
}
}
} else {
checks.push(DoctorCheck::pass("Cloud: disabled in config".to_string()));
}
if let Ok(agent) = hooks::detect_agent() {
let settings_path = match &agent {
hooks::DetectedAgent::ClaudeCode { settings_path, .. } => settings_path.clone(),
};
if settings_path.exists() {
match crate::hooks::health::inspect_file(&settings_path) {
Ok(health) => {
if health.missing_events.is_empty() {
checks.push(DoctorCheck::pass(format!(
"Hooks: all entries present in {}",
settings_path.display()
)));
} else {
for hook in &health.missing_events {
checks.push(DoctorCheck::fail(format!(
"Hooks: {hook} missing from {}",
settings_path.display()
)));
}
issues.push(format!(
"Missing hooks: {}. Run 'openlatch doctor --fix' to reinstall them (it keeps the current token, so running agent sessions keep capturing).",
health.missing_events.join(", ")
));
}
}
Err(e) => {
checks.push(DoctorCheck::fail(format!(
"Hooks: cannot read {} — {}",
settings_path.display(),
e.message
)));
issues.push(format!(
"Cannot read settings file '{}'. Check permissions.",
settings_path.display()
));
}
}
check_hook_binding(
&settings_path,
daemon_token.as_deref(),
cfg.port,
daemon_reachable,
&mut checks,
&mut issues,
);
#[cfg(feature = "boundary")]
check_boundary_wiring(&cfg, &settings_path, &mut checks, &mut issues);
} else {
checks.push(DoctorCheck::fail(format!(
"Hooks: settings.json not found at {}",
settings_path.display()
)));
issues.push(
"settings.json not found. Run 'openlatch init' to install hooks.".to_string(),
);
}
}
check_supervision_state(&cfg, &mut checks, &mut issues);
check_fallback_activity(
&ol_dir,
daemon_alive,
daemon_uptime_secs,
&mut checks,
&mut issues,
);
check_auto_update_drift(&cfg, &mut checks, &mut issues);
Ok(DoctorReport {
checks,
issues,
daemon_alive,
daemon_uptime_secs,
port: cfg.port,
})
}
pub(crate) fn print_diagnostic_results(report: &DoctorReport, output: &OutputConfig) {
if output.format == OutputFormat::Json {
let checks_json: Vec<serde_json::Value> = report
.checks
.iter()
.map(|c| {
serde_json::json!({
"pass": c.is_pass(),
"status": match c.status {
CheckStatus::Pass => "pass",
CheckStatus::Warn => "warn",
CheckStatus::Fail => "fail",
},
"message": c.message,
})
})
.collect();
output.print_json(&serde_json::json!({
"checks": checks_json,
"issues": report.issues,
"issue_count": report.issues.len(),
}));
} else if !output.quiet {
for check in &report.checks {
let mark = match check.status {
CheckStatus::Pass => crate::cli::color::checkmark(output.color),
CheckStatus::Warn => crate::cli::color::warning_mark(output.color),
CheckStatus::Fail => crate::cli::color::cross(output.color),
};
eprintln!("{mark} {}", check.message);
}
eprintln!();
if report.issues.is_empty() {
eprintln!("All checks passed.");
return;
}
let n = report.issues.len();
eprintln!("{n} issue{} found:", if n == 1 { "" } else { "s" });
for issue in &report.issues {
eprintln!();
eprintln!(" {issue}");
}
if let Some(next) = suggested_next_step(report) {
eprintln!();
eprintln!("Next: {next}");
}
}
}
fn suggested_next_step(report: &DoctorReport) -> Option<&'static str> {
let mentions = |needle: &str| report.issues.iter().any(|i| i.contains(needle));
if mentions("openlatch init") {
Some("run 'openlatch init' to repair the hook installation")
} else if mentions("openlatch start") || mentions("openlatch stop") {
Some("run 'openlatch start' to bring the daemon back up")
} else if mentions("openlatch supervision install") {
Some("run 'openlatch supervision install' to make the daemon survive a reboot")
} else if report.fail_count() > 0 {
Some("run 'openlatch doctor --fix' to attempt an automatic repair")
} else {
None
}
}
async fn check_cloud(api_url: &str) -> (bool, String) {
use secrecy::ExposeSecret;
let store = crate::core::auth::KeyringCredentialStore::new();
let file_store = crate::cli::commands::auth::make_file_store();
let key = match crate::core::auth::retrieve_credential(
&store as &dyn crate::core::auth::CredentialStore,
&file_store as &dyn crate::core::auth::CredentialStore,
) {
Ok(k) => k,
Err(_) => return (false, "Cloud: not authenticated".to_string()),
};
let key_str = key.expose_secret().to_string();
let result = crate::cli::commands::auth::validate_online_full(&key_str, api_url).await;
if result.online {
let org = if result.org_name.is_empty() {
String::new()
} else {
format!(" — org {}", result.org_name)
};
(true, format!("Cloud: reachable ({api_url}){org}"))
} else {
(
false,
format!("Cloud: unreachable or credential rejected ({api_url})"),
)
}
}
fn check_hook_binding(
settings_path: &std::path::Path,
daemon_token: Option<&str>,
daemon_port: u16,
daemon_reachable: bool,
checks: &mut Vec<DoctorCheck>,
issues: &mut Vec<String>,
) {
let raw = match std::fs::read_to_string(settings_path) {
Ok(s) => s,
Err(_) => return,
};
let parsed = match crate::hooks::jsonc::parse_settings_value(&raw) {
Ok(v) => v,
Err(e) => {
checks.push(DoctorCheck::fail(format!(
"Hook config: cannot parse settings.json ({})",
e.code
)));
return;
}
};
let health = crate::hooks::health::inspect(&parsed);
if health.commands == 0 {
return;
}
let expected_bin = &health.expected_bin;
if health.missing_bin.is_empty() && health.drifted_bin.is_empty() {
checks.push(DoctorCheck::pass(format!(
"Hook binary: {} (referenced by settings.json)",
expected_bin.display()
)));
}
if !health.missing_bin.is_empty() {
checks.push(DoctorCheck::fail(format!(
"Hook binary missing: {}",
health.missing_bin.join(", ")
)));
issues.push(
"Hook command points at a binary that does not exist. Run 'openlatch doctor --fix' to stage the binary and rewrite the hook command (it keeps the current token, so running agent sessions keep capturing)."
.to_string(),
);
}
if !health.drifted_bin.is_empty() {
checks.push(DoctorCheck::fail(format!(
"Hook binary drift: settings.json uses {}, current install is {}",
health.drifted_bin.join(", "),
expected_bin.display()
)));
issues.push(
"Hook command points at a stale binary. Run 'openlatch doctor --fix' to re-link settings.json to the current install."
.to_string(),
);
}
if let Some(token) = daemon_token {
let settings_token = parsed
.get("env")
.and_then(|e| e.get("OPENLATCH_TOKEN"))
.and_then(|v| v.as_str());
match settings_token {
Some(t) if t == token => {
checks.push(DoctorCheck::pass(
"Hook token: settings.json env matches daemon.token".to_string(),
));
}
Some(_) => {
checks.push(DoctorCheck::fail(
"Hook token: settings.json OPENLATCH_TOKEN does not match daemon.token"
.to_string(),
));
issues.push(
"Hook subprocess will be rejected with 401. Run 'openlatch init' to re-sync the token."
.to_string(),
);
}
None => {
checks.push(DoctorCheck::fail(
"Hook token: OPENLATCH_TOKEN not set in settings.json env".to_string(),
));
issues.push(
"Hook subprocess will not receive OPENLATCH_TOKEN. Run 'openlatch init' to install it."
.to_string(),
);
}
}
}
let settings_port = parsed
.get("env")
.and_then(|e| e.get("OPENLATCH_PORT"))
.and_then(|v| {
v.as_str()
.and_then(|s| s.trim().parse::<u16>().ok())
.or_else(|| v.as_u64().and_then(|n| u16::try_from(n).ok()))
});
let dir_is_default = std::env::var("OPENLATCH_DIR")
.ok()
.filter(|d| !d.is_empty())
.is_none();
match settings_port {
Some(p) if p < config::MIN_USER_PORT || daemon_port < config::MIN_USER_PORT => {
checks.push(DoctorCheck::fail(format!(
"Hook port: {p} in settings.json / {daemon_port} configured — not a usable port"
)));
issues.push(format!(
"OPENLATCH_PORT must be between {} and {}. Re-run 'openlatch init' with a valid OPENLATCH_PORT (or unset it to probe automatically).",
config::MIN_USER_PORT,
u16::MAX
));
}
Some(p) if p == daemon_port && !daemon_reachable => {
checks.push(DoctorCheck::warn(format!(
"Hook port: settings.json env matches the configured port ({daemon_port}), but no daemon answered"
)));
}
Some(p) if p == daemon_port => {
checks.push(DoctorCheck::pass(format!(
"Hook port: settings.json env matches daemon port ({daemon_port})"
)));
}
Some(p) => {
checks.push(DoctorCheck::fail(format!(
"Hook port: settings.json OPENLATCH_PORT is {p}, daemon is on {daemon_port}"
)));
issues.push(format!(
"Hook subprocess will connect to port {p} and fail open silently. Set OPENLATCH_PORT to {daemon_port} in the agent's settings env."
));
}
None if dir_is_default => {
checks.push(DoctorCheck::pass(format!(
"Hook port: resolved from daemon.port ({daemon_port})"
)));
}
None => {
checks.push(DoctorCheck::fail(
"Hook port: OPENLATCH_PORT not set in settings.json env, but OPENLATCH_DIR is non-default"
.to_string(),
));
issues.push(format!(
"The hook cannot see OPENLATCH_DIR, so it reads the default directory's daemon.port instead of this instance's and fails open silently. Set OPENLATCH_PORT to {daemon_port} in the agent's settings env."
));
}
}
}
fn check_supervision_state(
cfg: &config::Config,
checks: &mut Vec<DoctorCheck>,
issues: &mut Vec<String>,
) {
use crate::supervision::{select_supervisor, SupervisionMode, SupervisorKind};
let configured_backend = match cfg.supervision.backend {
SupervisorKind::Launchd => "launchd",
SupervisorKind::Systemd => "systemd",
SupervisorKind::TaskScheduler => "task_scheduler",
SupervisorKind::None => "none",
};
match (&cfg.supervision.mode, select_supervisor()) {
(SupervisionMode::Active, Some(sup)) => match sup.status() {
Ok(s) if s.installed && s.running && !s.unit_current => {
checks.push(DoctorCheck::fail(format!(
"Supervision: running an outdated unit ({configured_backend}, {})",
s.description
)));
issues.push(
"The installed supervisor unit predates this client's restart semantics \
(it may still use the old never-fires restart policy). Run 'openlatch \
supervision install' to regenerate it."
.to_string(),
);
}
Ok(s) if s.installed && s.running => {
checks.push(DoctorCheck::pass(format!(
"Supervision: active ({configured_backend}, {})",
s.description
)));
}
Ok(s) if s.installed => {
checks.push(DoctorCheck::fail(format!(
"Supervision: installed but supervisor not running ({configured_backend})"
)));
issues.push(
"Supervisor is registered but not running. Check OS logs or run \
'openlatch supervision install' to rebuild the artifact."
.to_string(),
);
}
Ok(_) => {
checks.push(DoctorCheck::fail(format!(
"Supervision: config says active but OS artifact is missing ({configured_backend})"
)));
issues.push(
"Supervisor drifted — OS artifact deleted while config still expects it. \
Run 'openlatch supervision install' (or 'openlatch doctor --fix')."
.to_string(),
);
}
Err(e) => {
checks.push(DoctorCheck::fail(format!(
"Supervision: cannot query supervisor ({}) — {}",
e.code, e.message
)));
}
},
(SupervisionMode::Deferred, _) => {
let reason = cfg
.supervision
.disabled_reason
.as_deref()
.unwrap_or("unknown");
checks.push(DoctorCheck::warn(format!(
"Supervision: deferred — {reason}"
)));
issues.push(format!(
"Supervision was deferred ({reason}), so nothing will restart the daemon after \
a crash or a reboot — you did not choose this. Run 'openlatch supervision \
install' once the underlying condition is resolved (headless session, missing \
systemd, and so on). ({})",
crate::error::ERR_NO_SUPERVISOR
));
}
(SupervisionMode::Disabled, _) => {
let reason = cfg
.supervision
.disabled_reason
.as_deref()
.unwrap_or("user_opt_out");
if crate::supervision::absence_is_deliberate(Some(reason)) {
checks.push(DoctorCheck::pass(format!(
"Supervision: disabled ({reason}) — as requested"
)));
} else {
checks.push(DoctorCheck::warn(format!(
"Supervision: disabled ({reason})"
)));
issues.push(format!(
"Supervision is off for a reason you did not choose ({reason}), so nothing \
will restart the daemon after a crash or a reboot. Run 'openlatch \
supervision install' if this machine can support one. ({})",
crate::error::ERR_NO_SUPERVISOR
));
}
}
(SupervisionMode::Active, None) => {
checks.push(DoctorCheck::fail(
"Supervision: config says active but no supervisor is available on this OS"
.to_string(),
));
issues.push(
"Supervisor unsupported on this OS. Run 'openlatch supervision disable' to \
clear the stale state."
.to_string(),
);
}
}
}
#[cfg(feature = "boundary")]
fn check_boundary_wiring(
cfg: &config::Config,
settings_path: &std::path::Path,
checks: &mut Vec<DoctorCheck>,
issues: &mut Vec<String>,
) {
use crate::cli::commands::boundary::{
classify_boundary, read_boundary_base_url, BoundaryState,
};
let port = cfg.boundary.port;
let wired = read_boundary_base_url(settings_path);
let url = wired.as_deref().unwrap_or_default();
match classify_boundary(cfg, wired.as_deref()) {
BoundaryState::Disabled => {
checks.push(DoctorCheck::pass(
"Model boundary: disabled in config — agents connect to the provider directly"
.to_string(),
));
}
BoundaryState::Isolated => {
checks.push(DoctorCheck::pass(format!(
"Model boundary: isolated instance on port {port} — {} is not managed by it",
settings_path.display()
)));
}
BoundaryState::Wired => {
checks.push(DoctorCheck::pass(format!(
"Model boundary: agent wired to {url} and the listener is up"
)));
}
BoundaryState::WiredButDown => {
checks.push(DoctorCheck::fail(format!(
"Model boundary: agent is wired to {url} but nothing is listening there"
)));
issues.push(format!(
"Every agent on this machine is pointed at 127.0.0.1:{port} and no listener \
holds it — model calls will fail with ECONNREFUSED. Run 'openlatch start' to \
bring the boundary back up, or 'openlatch stop' to clear the wiring and go \
direct."
));
}
BoundaryState::WiredToForeign => {
checks.push(DoctorCheck::fail(format!(
"Model boundary: agent is wired to {url}, held by a process that is NOT OpenLatch"
)));
issues.push(format!(
"127.0.0.1:{port} is occupied by something else while the agent config points \
at it — your provider API key is being sent to that process. Identify it \
(lsof -i :{port}), stop it, then run 'openlatch restart'. ({})",
crate::error::ERR_BOUNDARY_PORT_FOREIGN
));
}
BoundaryState::PreflightFailed(why) => {
checks.push(DoctorCheck::warn(format!(
"Model boundary: listener is up but its preflight failed — {why}"
)));
issues.push(format!(
"The boundary on 127.0.0.1:{port} cannot reach the provider, so the agent \
was deliberately left unwired: model calls go direct and keep working, \
but nothing is captured. Fix reachability to https://api.anthropic.com \
(proxy, VPN, TLS interception), then run 'openlatch restart' — the daemon \
re-wires itself as soon as the check passes. ({})",
crate::error::ERR_BOUNDARY_PREFLIGHT_FAILED
));
}
BoundaryState::PreflightPending => {
checks.push(DoctorCheck::warn(
"Model boundary: listener is up, its preflight has not finished yet".to_string(),
));
issues.push(format!(
"The boundary on 127.0.0.1:{port} is still verifying it can reach the \
provider; the agent is wired only once it can. Re-run 'openlatch doctor' \
in a moment."
));
}
BoundaryState::UpUnwired => {
checks.push(DoctorCheck::warn(
"Model boundary: listener is up but the agent is not wired to it".to_string(),
));
issues.push(format!(
"The boundary is listening on 127.0.0.1:{port} but the agent has no \
ANTHROPIC_BASE_URL pointing at it, so model calls bypass it entirely and \
nothing is captured. Run 'openlatch restart' to re-wire."
));
}
BoundaryState::Down => {
checks.push(DoctorCheck::pass(
"Model boundary: not wired, nothing listening — consistent".to_string(),
));
}
BoundaryState::ForeignIdle => {
checks.push(DoctorCheck::pass(format!(
"Model boundary: not wired; 127.0.0.1:{port} is held by another process"
)));
}
}
}
fn check_fallback_activity(
ol_dir: &std::path::Path,
daemon_alive: bool,
daemon_uptime_secs: Option<u64>,
checks: &mut Vec<DoctorCheck>,
issues: &mut Vec<String>,
) {
if !daemon_alive {
return;
}
let Some(uptime) = daemon_uptime_secs else {
return;
};
let fallback_path = ol_dir.join("logs").join("fallback.jsonl");
if !fallback_path.exists() {
checks.push(DoctorCheck::pass(
"Hook fallback log: no offline events recorded".to_string(),
));
return;
}
let meta = match std::fs::metadata(&fallback_path) {
Ok(m) => m,
Err(e) => {
checks.push(DoctorCheck::fail(format!(
"Hook fallback log: cannot stat {} — {e}",
fallback_path.display()
)));
return;
}
};
let mtime = match meta.modified() {
Ok(t) => t,
Err(_) => return,
};
let daemon_start = std::time::SystemTime::now()
.checked_sub(std::time::Duration::from_secs(uptime))
.unwrap_or(std::time::UNIX_EPOCH);
if mtime > daemon_start {
let age_secs = mtime
.duration_since(daemon_start)
.map(|d| d.as_secs())
.unwrap_or(0);
checks.push(DoctorCheck::fail(format!(
"Hook fallback log: {} written {age_secs}s after daemon start — hook subprocess is not reaching the daemon",
fallback_path.display()
)));
issues.push(
"Hook binary is writing to fallback.jsonl while the daemon is up. \
Likely causes: token mismatch, wrong port, or stale settings.json. \
Run 'openlatch doctor' above for Hook token/binary checks, then 'openlatch init' to repair."
.to_string(),
);
} else {
checks.push(DoctorCheck::pass(format!(
"Hook fallback log: quiet since daemon start ({})",
fallback_path.display()
)));
}
}
fn check_auto_update_drift(
cfg: &config::Config,
checks: &mut Vec<DoctorCheck>,
issues: &mut Vec<String>,
) {
use crate::install_state::{InstallMethod, InstallState};
let state = InstallState::load_or_default();
let (Some(actual), Some(npm)) = (
state.actual_binary_version.as_deref(),
state.npm_reported_version.as_deref(),
) else {
checks.push(DoctorCheck::pass(
"Auto-update: no drift recorded".to_string(),
));
return;
};
if !matches!(state.install_method, InstallMethod::Npm) {
return;
}
if actual == npm {
checks.push(DoctorCheck::pass(format!(
"Auto-update: in sync (npm={npm}, binary={actual})"
)));
return;
}
let last_update = state.last_updated_at.as_deref();
let stale_drift = last_update
.and_then(|ts| chrono::DateTime::parse_from_rfc3339(ts).ok())
.map(|t| (chrono::Utc::now() - t.with_timezone(&chrono::Utc)).num_days() > 30)
.unwrap_or(false);
if stale_drift && cfg.update.auto_update {
checks.push(DoctorCheck::fail(format!(
"Auto-update: drift unchanged for >30d (npm={npm}, binary={actual}, last={})",
last_update.unwrap_or("?")
)));
issues.push(format!(
"Auto-update appears stalled. The binary is at {actual} but npm last installed {npm} \
over 30 days ago. Run `npm install -g @openlatch/client@{actual}` to resync, then \
check the daemon log for `target=update` errors."
));
} else {
checks.push(DoctorCheck::pass(format!(
"Auto-update: drift detected (binary {actual} > npm {npm}) — run `npm install -g @openlatch/client@{actual}` to sync"
)));
}
}