PicoBot/src/health.rs
xiaoxixi d9ad58b84b feat(scheduler): unify scheduled task execution and delivery
Replace the dual task/monitor model, NO_REPLY string protocol, and Agent
self-delivery with a single Scheduled Run path: claim-time JobRun snapshots,
isolated Root/named Agent execution, exactly-once complete_scheduled_run
termination, and Scheduler-owned policy delivery through a persistent outbox.

- SQLite v11: drop job_kind/model/delete_after_run, add job_runs with
  status/outcome joint constraints and delivery lease columns; one-shot
  BEGIN IMMEDIATE migration with atomic rollback.
- Non-blocking JoinSet event loop with bounded run/delivery concurrency;
  terminal commit before any channel I/O; recover unfinished runs as unknown.
- ExecutionOrigin::Scheduled propagates to descendants, completion sink is
  top-level only, background delegation downgrades to foreground.
- Typed delivery receipts, fixed target_session_id, idempotent
  scheduled:<job_run_id> history insert.
- New cron_runs read-only tool; cron_add/update drop kind/model; WebUI and
  Health consume the same JobRun projection.
- Bump version to 1.22.0.
2026-08-21 14:59:02 +08:00

1104 lines
38 KiB
Rust

use std::collections::HashSet;
use std::path::Path;
use std::process::{Output, Stdio};
use std::sync::{Arc, RwLock};
use std::time::Duration;
use serde::{Deserialize, Serialize};
use tokio::process::Command;
use crate::config::{Config, McpTransport, expand_path};
pub const SUPPORTED_AGENT_BROWSER_VERSION: &str = "0.33.0";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum HealthStatus {
Pass,
Warning,
Fail,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum HealthOverall {
Healthy,
Degraded,
Unhealthy,
}
#[derive(Debug, Clone, Serialize)]
pub struct HealthCheck {
pub name: String,
pub category: String,
pub required: bool,
pub status: HealthStatus,
pub detail: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub remediation: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct HealthReport {
pub version: &'static str,
pub overall: HealthOverall,
pub checks: Vec<HealthCheck>,
}
impl HealthReport {
pub fn configuration_error(error: impl Into<String>) -> Self {
Self::from_checks(vec![HealthCheck {
name: "configuration".to_string(),
category: "core".to_string(),
required: true,
status: HealthStatus::Fail,
detail: error.into(),
remediation: Some(
"Fix ~/.picobot/config.json (or ./config.json) and run picobot health again."
.to_string(),
),
}])
}
fn from_checks(checks: Vec<HealthCheck>) -> Self {
let overall = if checks
.iter()
.any(|check| check.required && check.status == HealthStatus::Fail)
{
HealthOverall::Unhealthy
} else if checks
.iter()
.any(|check| check.status != HealthStatus::Pass)
{
HealthOverall::Degraded
} else {
HealthOverall::Healthy
};
Self {
version: env!("CARGO_PKG_VERSION"),
overall,
checks,
}
}
pub fn is_usable(&self) -> bool {
self.overall != HealthOverall::Unhealthy
}
pub fn render_text(&self) -> String {
let overall = match self.overall {
HealthOverall::Healthy => "HEALTHY",
HealthOverall::Degraded => "DEGRADED",
HealthOverall::Unhealthy => "UNHEALTHY",
};
let mut lines = vec![format!("PicoBot {} health: {overall}", self.version)];
for check in &self.checks {
let icon = match check.status {
HealthStatus::Pass => "",
HealthStatus::Warning => "!",
HealthStatus::Fail => "",
};
let requirement = if check.required {
"required"
} else {
"optional"
};
lines.push(format!(
"{icon} [{} / {requirement}] {}{}",
check.category, check.name, check.detail
));
if let Some(remediation) = &check.remediation {
lines.push(format!(" Fix: {remediation}"));
}
}
lines.join("\n")
}
}
#[derive(Clone)]
pub struct HealthService {
config: Config,
scheduler_runtime: Arc<RwLock<Option<SchedulerHealthRuntime>>>,
}
#[derive(Clone)]
struct SchedulerHealthRuntime {
storage: Arc<crate::storage::Storage>,
channels: HashSet<String>,
catalog: Option<Arc<crate::agent::AgentCatalog>>,
}
impl HealthService {
pub fn new(config: Config) -> Self {
Self {
config,
scheduler_runtime: Arc::new(RwLock::new(None)),
}
}
pub fn with_scheduler_runtime(
self,
storage: Arc<crate::storage::Storage>,
channels: Vec<String>,
) -> Self {
*self.scheduler_runtime.write().unwrap() = Some(SchedulerHealthRuntime {
storage,
channels: channels.into_iter().collect(),
catalog: None,
});
self
}
pub fn bind_agent_catalog(&self, catalog: Arc<crate::agent::AgentCatalog>) {
if let Some(runtime) = self.scheduler_runtime.write().unwrap().as_mut() {
runtime.catalog = Some(catalog);
}
}
pub async fn check(&self) -> HealthReport {
let mut checks = vec![
check_configuration_recovery(&self.config),
check_workspace(&self.config),
check_required_binary(
"bash",
"core",
"Install Bash and make it available on PATH.",
),
check_search_backend("content search", &["rg", "grep"], &["rg"]),
check_search_backend("file search", &["fd", "fdfind", "find"], &["fd", "fdfind"]),
check_optional_binary("systemd service management", "systemctl", "service"),
];
checks.extend(self.check_mcp_commands());
checks.extend(self.check_browser().await);
checks.extend(self.check_scheduler().await);
HealthReport::from_checks(checks)
}
async fn check_scheduler(&self) -> Vec<HealthCheck> {
let scheduler_config = self.config.gateway.scheduler.clone().unwrap_or_default();
if !scheduler_config.enabled {
return vec![HealthCheck {
name: "scheduled runs".to_string(),
category: "configured".to_string(),
required: false,
status: HealthStatus::Pass,
detail: "scheduler disabled; persisted jobs are not executed".to_string(),
remediation: None,
}];
}
let runtime = self.scheduler_runtime.read().unwrap().clone();
let Some(runtime) = runtime else {
return vec![HealthCheck {
name: "scheduled runs".to_string(),
category: "runtime".to_string(),
required: false,
status: HealthStatus::Pass,
detail: "runtime scheduler state is checked by the running Gateway".to_string(),
remediation: None,
}];
};
let jobs = match runtime.storage.list_scheduled_jobs().await {
Ok(jobs) => jobs,
Err(error) => {
return vec![HealthCheck {
name: "scheduled run storage".to_string(),
category: "runtime".to_string(),
required: true,
status: HealthStatus::Fail,
detail: format!("cannot read scheduled jobs: {error}"),
remediation: Some(
"Check the configured SQLite database and Gateway logs.".to_string(),
),
}];
}
};
let now = chrono::Utc::now().timestamp_millis();
let stale_after = scheduler_config
.poll_interval_secs
.max(60)
.saturating_mul(10_000)
.min(i64::MAX as u64) as i64;
let mut bad_references = Vec::new();
let mut stale_deliveries = Vec::new();
let mut unhealthy_latest = Vec::new();
let mut silent_unknown = Vec::new();
let mut covered_intervals = Vec::new();
let mut invalid_next = Vec::new();
match runtime
.storage
.list_stale_scheduled_deliveries(now.saturating_sub(stale_after), 100)
.await
{
Ok(runs) => {
stale_deliveries.extend(
runs.into_iter()
.map(|run| format!("{}#{}", run.job_id, run.id)),
);
}
Err(error) => bad_references.push(format!("delivery backlog unavailable ({error})")),
}
for job in &jobs {
if !runtime.channels.contains(&job.channel) {
bad_references.push(format!("{}: channel {}", job.id, job.channel));
}
if let Some(agent_id) = job.agent_id.as_deref()
&& runtime
.catalog
.as_ref()
.is_none_or(|catalog| catalog.get(agent_id).is_none())
{
bad_references.push(format!("{}: agent {}", job.id, agent_id));
}
if job.enabled && crate::scheduler::next_run_for_schedule(&job.schedule, now).is_none()
{
invalid_next.push(job.id.clone());
}
let runs = match runtime.storage.list_scheduled_job_runs(&job.id, 20).await {
Ok(runs) => runs,
Err(error) => {
bad_references.push(format!("{}: run history unavailable ({error})", job.id));
continue;
}
};
if let Some(latest) = runs.first() {
if matches!(
latest.status,
crate::storage::ScheduledRunStatus::Unknown
| crate::storage::ScheduledRunStatus::Failed
| crate::storage::ScheduledRunStatus::TimedOut
) {
unhealthy_latest.push(format!("{}#{}", job.id, latest.id));
}
if job.delivery_policy == crate::storage::DeliveryPolicy::Never
&& latest.outcome == Some(crate::storage::ScheduledOutcomeKind::Unknown)
{
silent_unknown.push(format!("{}#{}", job.id, latest.id));
}
if let crate::scheduler::Schedule::Every { every_ms } = job.schedule
&& i64::try_from(every_ms).ok().is_some_and(|interval| {
latest
.duration_ms
.is_some_and(|duration| duration >= interval)
})
{
covered_intervals.push(format!("{}#{}", job.id, latest.id));
}
}
}
vec![
scheduler_health_check(
"scheduled references",
bad_references,
"all Agent and channel references are available",
"Update or disable jobs that reference missing Agents or channels.",
),
scheduler_health_check(
"scheduled delivery backlog",
stale_deliveries,
"no stale pending or delivering notifications",
"Inspect cron_runs and the target channel configuration.",
),
scheduler_health_check(
"scheduled latest outcomes",
unhealthy_latest,
"no latest run is failed, timed out, or unknown",
"Inspect cron_runs for the diagnostic and assess external side effects before retrying.",
),
scheduler_health_check(
"silent unknown scheduled runs",
silent_unknown,
"no never-delivery job has an unknown latest outcome",
"Inspect the run manually; delivery_policy=never prevents automatic notification.",
),
scheduler_health_check(
"scheduled execution intervals",
covered_intervals,
"recent execution durations fit their Every intervals",
"Increase the interval or split long-running jobs.",
),
scheduler_health_check(
"scheduled next runs",
invalid_next,
"all enabled schedules can compute a next run",
"Correct the schedule expression or disable the job.",
),
]
}
fn check_mcp_commands(&self) -> Vec<HealthCheck> {
let mut seen = HashSet::new();
let mut checks = Vec::new();
for server in &self.config.mcp.servers {
if !server.enabled {
continue;
}
if !matches!(server.transport, McpTransport::Stdio) {
continue;
}
let Some(command) = server.command.as_deref() else {
checks.push(HealthCheck {
name: format!("MCP server {}", server.name),
category: "configured".to_string(),
required: true,
status: HealthStatus::Fail,
detail: "stdio server has no command".to_string(),
remediation: Some("Set mcp.servers[].command.".to_string()),
});
continue;
};
if !seen.insert(command.to_string()) {
continue;
}
let installed = command_exists(command);
checks.push(HealthCheck {
name: format!("MCP command {command}"),
category: "configured".to_string(),
required: true,
status: if installed {
HealthStatus::Pass
} else {
HealthStatus::Fail
},
detail: if installed {
"installed".to_string()
} else {
"not found on PATH".to_string()
},
remediation: (!installed).then(|| {
format!("Install '{command}' or set an absolute mcp.servers[].command path.")
}),
});
}
if checks.is_empty() {
checks.push(HealthCheck {
name: "MCP stdio commands".to_string(),
category: "configured".to_string(),
required: false,
status: HealthStatus::Pass,
detail: "no stdio MCP servers configured".to_string(),
remediation: None,
});
}
checks
}
async fn check_browser(&self) -> Vec<HealthCheck> {
let browser = &self.config.browser;
if !browser.enabled {
return vec![HealthCheck {
name: "agent-browser".to_string(),
category: "configured".to_string(),
required: false,
status: HealthStatus::Pass,
detail: "browser tool disabled; dependency not required".to_string(),
remediation: None,
}];
}
let mut checks = Vec::new();
if !browser.allowed_domains.is_empty() {
checks.push(HealthCheck {
name: "persistent browser availability".to_string(),
category: "configured".to_string(),
required: false,
status: HealthStatus::Warning,
detail: "ordinary transient browsing is available, but persistent Chrome profiles are unavailable while allowed_domains is configured".to_string(),
remediation: Some(
"Keep allowed_domains for contained transient browsing, or clear it only if reusable persistent profiles are required; agent-browser 0.33.0 cannot combine both guarantees.".to_string(),
),
});
}
if !command_exists(&browser.command) {
checks.push(HealthCheck {
name: "agent-browser CLI".to_string(),
category: "configured".to_string(),
required: true,
status: HealthStatus::Fail,
detail: format!("'{}' was not found", browser.command),
remediation: Some(format!(
"Run `npm install -g agent-browser@{SUPPORTED_AGENT_BROWSER_VERSION}` (or `cargo install agent-browser --version {SUPPORTED_AGENT_BROWSER_VERSION} --locked`), then `agent-browser install`."
)),
});
return checks;
}
let version = command_output(
&browser.command,
&["--version"],
&[],
Duration::from_secs(5),
)
.await;
match version {
Ok(version_output) => {
let version_number = extract_version(&version_output);
let exact = version_number.as_deref() == Some(SUPPORTED_AGENT_BROWSER_VERSION);
checks.push(HealthCheck {
name: "agent-browser CLI".to_string(),
category: "configured".to_string(),
required: true,
status: if exact {
HealthStatus::Pass
} else {
HealthStatus::Warning
},
detail: format!(
"installed version {}; PicoBot is validated with {}",
version_number.unwrap_or_else(|| version_output.trim().to_string()),
SUPPORTED_AGENT_BROWSER_VERSION
),
remediation: (!exact).then(|| {
format!(
"Install agent-browser@{SUPPORTED_AGENT_BROWSER_VERSION} for the validated CLI contract."
)
}),
});
}
Err(error) => checks.push(HealthCheck {
name: "agent-browser CLI".to_string(),
category: "configured".to_string(),
required: true,
status: HealthStatus::Fail,
detail: error,
remediation: Some("Reinstall agent-browser and verify it can execute.".to_string()),
}),
}
if let Some(path) = browser.browser_executable_path.as_deref() {
let path = expand_path(path);
let path = if path.is_absolute() {
path
} else {
expand_path(&self.config.workspace_dir).join(path)
};
let installed = executable_file(&path);
checks.push(HealthCheck {
name: "configured browser executable".to_string(),
category: "configured".to_string(),
required: true,
status: if installed {
HealthStatus::Pass
} else {
HealthStatus::Fail
},
detail: if installed {
format!("executable file found at {}", path.display())
} else {
format!("missing or not executable at {}", path.display())
},
remediation: (!installed).then(|| {
"Fix browser.browser_executable_path and its execute permissions, or run `agent-browser install`.".to_string()
}),
});
}
let doctor_executable = browser
.browser_executable_path
.as_deref()
.map(expand_path)
.map(|path| {
if path.is_absolute() {
path
} else {
expand_path(&self.config.workspace_dir).join(path)
}
})
.map(|path| path.to_string_lossy().into_owned());
let doctor_executable_env = doctor_executable
.as_deref()
.map(|path| ("AGENT_BROWSER_EXECUTABLE_PATH", path));
let doctor_socket_dir = match tempfile::Builder::new()
.prefix("picobot-health-agent-browser-")
.tempdir()
{
Ok(directory) => directory,
Err(error) => {
checks.push(agent_browser_doctor_failure(format!(
"failed to create isolated doctor directory: {error}"
)));
return checks;
}
};
let socket_dir = doctor_socket_dir.path().to_string_lossy().into_owned();
let mut doctor_env = vec![("AGENT_BROWSER_SOCKET_DIR", socket_dir.as_str())];
if let Some(env) = doctor_executable_env {
doctor_env.push(env);
}
let doctor = capture_command(
&browser.command,
&[
"--namespace",
"picobot-health",
"doctor",
"--offline",
"--json",
],
&doctor_env,
Duration::from_secs(30),
)
.await;
match doctor {
Ok(output) => match parse_agent_browser_doctor(&output) {
Ok(report) => checks.extend(agent_browser_doctor_checks(&report)),
Err(error) => checks.push(agent_browser_doctor_failure(error)),
},
Err(error) => checks.push(agent_browser_doctor_failure(error)),
}
checks
}
}
fn scheduler_health_check(
name: &str,
findings: Vec<String>,
healthy_detail: &str,
remediation: &str,
) -> HealthCheck {
if findings.is_empty() {
HealthCheck {
name: name.to_string(),
category: "runtime".to_string(),
required: false,
status: HealthStatus::Pass,
detail: healthy_detail.to_string(),
remediation: None,
}
} else {
let total = findings.len();
let mut sample = findings.into_iter().take(5).collect::<Vec<_>>().join(", ");
if total > 5 {
sample.push_str(&format!(", and {} more", total - 5));
}
HealthCheck {
name: name.to_string(),
category: "runtime".to_string(),
required: false,
status: HealthStatus::Warning,
detail: format!("{total} finding(s): {sample}"),
remediation: Some(remediation.to_string()),
}
}
}
fn check_configuration_recovery(config: &Config) -> HealthCheck {
if config.diagnostics.is_empty() {
return HealthCheck {
name: "configuration compatibility".to_string(),
category: "core".to_string(),
required: false,
status: HealthStatus::Pass,
detail: "no recoverable configuration problems detected".to_string(),
remediation: None,
};
}
let paths = config
.diagnostics
.iter()
.take(5)
.map(|diagnostic| diagnostic.path.as_str())
.collect::<Vec<_>>()
.join(", ");
HealthCheck {
name: "configuration compatibility".to_string(),
category: "core".to_string(),
required: false,
status: HealthStatus::Warning,
detail: format!(
"ignored {} recoverable configuration item(s): {paths}",
config.diagnostics.len()
),
remediation: Some(
"Review and clean invalid entries in WebUI Settings → config.json.".to_string(),
),
}
}
fn check_workspace(config: &Config) -> HealthCheck {
let workspace = expand_path(&config.workspace_dir);
let exists = workspace.is_dir();
HealthCheck {
name: "workspace".to_string(),
category: "core".to_string(),
required: true,
status: if exists {
HealthStatus::Pass
} else {
HealthStatus::Fail
},
detail: if exists {
format!("{} is available", workspace.display())
} else {
format!("{} does not exist", workspace.display())
},
remediation: (!exists)
.then(|| "Create the configured workspace directory or fix workspace_dir.".to_string()),
}
}
fn check_required_binary(name: &str, category: &str, remediation: &str) -> HealthCheck {
let installed = command_exists(name);
HealthCheck {
name: name.to_string(),
category: category.to_string(),
required: true,
status: if installed {
HealthStatus::Pass
} else {
HealthStatus::Fail
},
detail: if installed {
"installed".to_string()
} else {
"not found on PATH".to_string()
},
remediation: (!installed).then(|| remediation.to_string()),
}
}
fn check_search_backend(name: &str, candidates: &[&str], preferred: &[&str]) -> HealthCheck {
let found = candidates.iter().copied().find(|name| command_exists(name));
let (status, detail, remediation) = search_backend_result(found, candidates, preferred);
HealthCheck {
name: name.to_string(),
category: "core".to_string(),
required: true,
status,
detail,
remediation,
}
}
fn search_backend_result(
found: Option<&str>,
candidates: &[&str],
preferred: &[&str],
) -> (HealthStatus, String, Option<String>) {
match found {
Some(found) if preferred.contains(&found) => (
HealthStatus::Pass,
format!("using preferred backend {found}"),
None,
),
Some(found) => (
HealthStatus::Warning,
format!("using fallback backend {found}"),
Some(format!(
"Install {} for faster searches.",
preferred.join(" or ")
)),
),
None => (
HealthStatus::Fail,
"no supported backend found".to_string(),
Some(format!("Install one of: {}.", candidates.join(", "))),
),
}
}
fn check_optional_binary(name: &str, binary: &str, category: &str) -> HealthCheck {
let installed = command_exists(binary);
HealthCheck {
name: name.to_string(),
category: category.to_string(),
required: false,
status: HealthStatus::Pass,
detail: if installed {
format!("{binary} installed")
} else {
format!("{binary} not installed; feature remains unavailable")
},
remediation: None,
}
}
fn command_exists(command: &str) -> bool {
if command.contains(std::path::MAIN_SEPARATOR) {
executable_file(Path::new(command))
} else {
which::which(command).is_ok()
}
}
fn executable_file(path: &Path) -> bool {
let Ok(metadata) = path.metadata() else {
return false;
};
if !metadata.is_file() {
return false;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
metadata.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
{
true
}
}
async fn capture_command(
command: &str,
args: &[&str],
env: &[(&str, &str)],
timeout: Duration,
) -> Result<Output, String> {
let mut process = Command::new(command);
process
.args(args)
.envs(env.iter().copied())
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
tokio::time::timeout(timeout, process.output())
.await
.map_err(|_| format!("command timed out after {} seconds", timeout.as_secs()))?
.map_err(|error| format!("failed to start: {error}"))
}
async fn command_output(
command: &str,
args: &[&str],
env: &[(&str, &str)],
timeout: Duration,
) -> Result<String, String> {
let output = capture_command(command, args, env, timeout).await?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
if !output.status.success() {
let detail = if stderr.trim().is_empty() {
stdout.trim()
} else {
stderr.trim()
};
return Err(format!(
"exited with {}: {}",
output.status,
truncate(detail, 1_000)
));
}
let combined = if stdout.trim().is_empty() {
stderr.trim()
} else {
stdout.trim()
};
Ok(truncate(combined, 4_000))
}
#[derive(Debug, Deserialize)]
struct AgentBrowserDoctorReport {
#[serde(default)]
checks: Vec<AgentBrowserDoctorCheck>,
#[serde(default)]
success: bool,
}
#[derive(Debug, Deserialize)]
struct AgentBrowserDoctorCheck {
id: String,
message: String,
status: String,
}
fn parse_agent_browser_doctor(output: &Output) -> Result<AgentBrowserDoctorReport, String> {
const MAX_DOCTOR_OUTPUT_BYTES: usize = 64 * 1024;
if output.stdout.len() > MAX_DOCTOR_OUTPUT_BYTES {
return Err(format!(
"agent-browser doctor returned more than {MAX_DOCTOR_OUTPUT_BYTES} bytes"
));
}
let stdout = String::from_utf8_lossy(&output.stdout);
match serde_json::from_str::<AgentBrowserDoctorReport>(stdout.trim()) {
Ok(report) => Ok(report),
Err(error) => {
let stderr = String::from_utf8_lossy(&output.stderr);
let detail = if stderr.trim().is_empty() {
stdout.trim()
} else {
stderr.trim()
};
Err(format!(
"agent-browser doctor returned invalid JSON ({error}): {}",
truncate(detail, 1_000)
))
}
}
}
fn agent_browser_doctor_checks(report: &AgentBrowserDoctorReport) -> Vec<HealthCheck> {
let mut installation = doctor_named_check(
report,
"browser installation",
|check| check.id == "chrome.installed",
"Run `agent-browser install`, or configure browser.browser_executable_path.",
);
let launch = doctor_named_check(
report,
"browser headless launch",
|check| check.id.starts_with("launch."),
"Run `agent-browser doctor --debug`, then `agent-browser install --with-deps` on Linux or `agent-browser install` on other platforms.",
);
if installation.status == HealthStatus::Fail && launch.status == HealthStatus::Pass {
installation.status = HealthStatus::Pass;
installation.detail =
"headless launch confirmed an available configured or system browser".to_string();
installation.remediation = None;
}
let other_issues = report
.checks
.iter()
.filter(|check| {
check.id != "chrome.installed"
&& !check.id.starts_with("launch.")
&& matches!(check.status.as_str(), "warn" | "warning" | "fail")
})
.collect::<Vec<_>>();
let unexplained_failure = !report.success
&& !report
.checks
.iter()
.any(|check| check.status.as_str() == "fail");
let runtime_status = if other_issues
.iter()
.any(|check| check.status.as_str() == "fail")
|| unexplained_failure
{
HealthStatus::Fail
} else if other_issues.is_empty() {
HealthStatus::Pass
} else {
HealthStatus::Warning
};
let runtime_detail = if other_issues.is_empty() {
if runtime_status == HealthStatus::Pass {
"isolated offline doctor completed without environment warnings".to_string()
} else {
"doctor reported failure without a structured failing check".to_string()
}
} else {
let details = other_issues
.iter()
.take(3)
.map(|check| format!("{}: {}", check.id, check.message))
.collect::<Vec<_>>()
.join("; ");
truncate(
&format!("{} environment issue(s): {details}", other_issues.len()),
1_000,
)
};
let runtime = HealthCheck {
name: "agent-browser environment".to_string(),
category: "configured".to_string(),
required: true,
status: runtime_status,
detail: runtime_detail,
remediation: (runtime_status != HealthStatus::Pass).then(|| {
"Run `agent-browser doctor --debug` to inspect the reported environment checks."
.to_string()
}),
};
vec![installation, launch, runtime]
}
fn doctor_named_check(
report: &AgentBrowserDoctorReport,
name: &str,
predicate: impl Fn(&AgentBrowserDoctorCheck) -> bool,
remediation: &str,
) -> HealthCheck {
let found = report.checks.iter().find(|check| predicate(check));
let (status, detail) = match found {
Some(check) => (doctor_status(&check.status), check.message.clone()),
None => (
HealthStatus::Fail,
format!("agent-browser doctor did not report {name}"),
),
};
HealthCheck {
name: name.to_string(),
category: "configured".to_string(),
required: true,
status,
detail,
remediation: (status != HealthStatus::Pass).then(|| remediation.to_string()),
}
}
fn doctor_status(status: &str) -> HealthStatus {
match status {
"pass" | "info" => HealthStatus::Pass,
"warn" | "warning" => HealthStatus::Warning,
"fail" => HealthStatus::Fail,
_ => HealthStatus::Warning,
}
}
fn agent_browser_doctor_failure(detail: String) -> HealthCheck {
HealthCheck {
name: "agent-browser runtime".to_string(),
category: "configured".to_string(),
required: true,
status: HealthStatus::Fail,
detail,
remediation: Some(
"Run `agent-browser doctor --debug`, then `agent-browser install --with-deps` on Linux or `agent-browser install` on other platforms."
.to_string(),
),
}
}
fn extract_version(output: &str) -> Option<String> {
output
.split_whitespace()
.map(|token| {
token
.trim_start_matches('v')
.trim_matches(|c: char| c == ',' || c == ';')
})
.find(|token| {
let mut parts = token.split('.');
parts.clone().count() >= 3 && parts.all(|part| part.chars().all(|c| c.is_ascii_digit()))
})
.map(str::to_string)
}
fn truncate(value: &str, max: usize) -> String {
if value.len() <= max {
value.to_string()
} else {
format!("{}", &value[..value.floor_char_boundary(max)])
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn required_failure_makes_report_unhealthy() {
let report = HealthReport::from_checks(vec![HealthCheck {
name: "x".into(),
category: "core".into(),
required: true,
status: HealthStatus::Fail,
detail: "missing".into(),
remediation: None,
}]);
assert_eq!(report.overall, HealthOverall::Unhealthy);
assert!(!report.is_usable());
}
#[test]
fn report_serializes_the_management_api_contract() {
let report = HealthReport::from_checks(vec![HealthCheck {
name: "content search".into(),
category: "core".into(),
required: true,
status: HealthStatus::Warning,
detail: "using fallback backend".into(),
remediation: Some("Install rg.".into()),
}]);
let value = serde_json::to_value(report).unwrap();
assert_eq!(value["version"], env!("CARGO_PKG_VERSION"));
assert_eq!(value["overall"], "degraded");
assert_eq!(value["checks"][0]["status"], "warning");
assert_eq!(value["checks"][0]["required"], true);
assert_eq!(value["checks"][0]["remediation"], "Install rg.");
}
#[test]
fn fdfind_is_a_preferred_file_search_backend() {
let (status, detail, remediation) =
search_backend_result(Some("fdfind"), &["fd", "fdfind", "find"], &["fd", "fdfind"]);
assert_eq!(status, HealthStatus::Pass);
assert_eq!(detail, "using preferred backend fdfind");
assert_eq!(remediation, None);
let (status, _, remediation) =
search_backend_result(Some("find"), &["fd", "fdfind", "find"], &["fd", "fdfind"]);
assert_eq!(status, HealthStatus::Warning);
assert_eq!(
remediation.as_deref(),
Some("Install fd or fdfind for faster searches.")
);
}
#[test]
fn doctor_report_exposes_install_launch_and_environment_checks() {
let report: AgentBrowserDoctorReport = serde_json::from_value(serde_json::json!({
"success": true,
"checks": [
{"id": "env.version", "message": "CLI version 0.33.0", "status": "pass"},
{"id": "chrome.installed", "message": "Chromium found", "status": "pass"},
{"id": "launch.elapsed", "message": "Headless launch in 1.2s", "status": "pass"}
]
}))
.unwrap();
let checks = agent_browser_doctor_checks(&report);
assert_eq!(checks.len(), 3);
assert_eq!(checks[0].name, "browser installation");
assert_eq!(checks[0].status, HealthStatus::Pass);
assert_eq!(checks[1].name, "browser headless launch");
assert_eq!(checks[1].status, HealthStatus::Pass);
assert_eq!(checks[2].name, "agent-browser environment");
assert_eq!(checks[2].status, HealthStatus::Pass);
}
#[test]
fn doctor_launch_failure_is_required_and_actionable() {
let report: AgentBrowserDoctorReport = serde_json::from_value(serde_json::json!({
"success": false,
"checks": [
{"id": "env.disk_free", "message": "low disk", "status": "warn"},
{"id": "chrome.installed", "message": "Chromium found", "status": "pass"},
{"id": "launch.daemon", "message": "shared library missing", "status": "fail"}
]
}))
.unwrap();
let checks = agent_browser_doctor_checks(&report);
assert_eq!(checks[1].status, HealthStatus::Fail);
assert!(checks[1].required);
assert!(checks[1].detail.contains("shared library missing"));
assert!(checks[1].remediation.is_some());
assert_eq!(checks[2].status, HealthStatus::Warning);
}
#[test]
fn successful_launch_accepts_a_configured_browser_without_bundled_chrome() {
let report: AgentBrowserDoctorReport = serde_json::from_value(serde_json::json!({
"success": false,
"checks": [
{"id": "chrome.installed", "message": "Chrome for Testing missing", "status": "fail"},
{"id": "launch.elapsed", "message": "Headless launch in 0.8s", "status": "pass"}
]
}))
.unwrap();
let checks = agent_browser_doctor_checks(&report);
assert_eq!(checks[0].status, HealthStatus::Pass);
assert!(checks[0].detail.contains("launch confirmed"));
assert_eq!(checks[1].status, HealthStatus::Pass);
assert_eq!(checks[2].status, HealthStatus::Pass);
}
#[test]
fn extracts_agent_browser_version() {
assert_eq!(
extract_version("agent-browser 0.33.0"),
Some("0.33.0".to_string())
);
}
}