885 lines
29 KiB
Rust
885 lines
29 KiB
Rust
use std::collections::HashSet;
|
|
use std::path::Path;
|
|
use std::process::{Output, Stdio};
|
|
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,
|
|
}
|
|
|
|
impl HealthService {
|
|
pub fn new(config: Config) -> Self {
|
|
Self { config }
|
|
}
|
|
|
|
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);
|
|
HealthReport::from_checks(checks)
|
|
}
|
|
|
|
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 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())
|
|
);
|
|
}
|
|
}
|