Add browser hand doctor and daemon logs

This commit is contained in:
AlexZ
2026-05-09 18:40:57 +08:00
parent a62b3252e8
commit 1332b6341b
6 changed files with 355 additions and 4 deletions
+2
View File
@@ -125,6 +125,8 @@ actionbook extension ping # Measure bridge RTT
actionbook extension install # Fallback: install to ~/Actionbook/extension/ (requires manual Chrome load)
actionbook extension uninstall # Remove extension
actionbook extension path # Print install path, status, and version
actionbook browser doctor --json # Diagnose browser hand health without a session
actionbook browser logs daemon --json # Read daemon/browser process logs
actionbook daemon restart # Stop the running daemon (next CLI call respawns)
```
+5
View File
@@ -345,6 +345,7 @@ Default timeout: 30000ms. Override with `--timeout <ms>`.
actionbook browser logs console --session s1 --tab t1
actionbook browser logs console --level warn,error --session s1 --tab t1
actionbook browser logs errors --session s1 --tab t1
actionbook browser logs daemon --tail 100 --json
# Network
actionbook browser network requests --session s1 --tab t1
@@ -358,6 +359,10 @@ actionbook browser network har stop --session s1 --tab t1 # Stop and
actionbook browser network har stop --session s1 --tab t1 --out /tmp/trace.har # Custom output path
```
Use `actionbook browser doctor --json` to inspect browser hand health without
requiring an active session. Use `actionbook browser doctor --start --json` to
start the daemon if needed and verify request/response health.
HAR recording is per-tab. Multiple tabs or sessions can record independently. `har start` accepts `--max-entries N` to set the ring-buffer cap (default: 10000). Output is HAR 1.2 JSON with request/response headers and timings (no response bodies — use `--dump` for that). If `--out` is omitted, a timestamped file is created in `~/.actionbook/har/`. Redirect chains produce one entry per hop. Error codes: `HAR_ALREADY_RECORDING`, `HAR_NOT_RECORDING`.
<Note>
+54
View File
@@ -138,6 +138,8 @@ pub struct TabArgs {
pub enum BrowserCommands {
/// Show browser help
Help,
/// Diagnose browser daemon/hand health without requiring an existing session
Doctor(BrowserDoctorArgs),
// ── Session lifecycle ──────────────────────────────────────
/// Start or attach a browser session
@@ -317,6 +319,22 @@ pub enum LogsCommands {
Console(observation::logs_console::Cmd),
/// Get error logs (window error events + unhandled rejections)
Errors(observation::logs_errors::Cmd),
/// Read daemon/browser process logs
Daemon(DaemonLogsArgs),
}
#[derive(Args, Debug, Clone)]
pub struct BrowserDoctorArgs {
/// Start the daemon if it is not running, then re-check health.
#[arg(long)]
pub start: bool,
}
#[derive(Args, Debug, Clone)]
pub struct DaemonLogsArgs {
/// Return only the last N log lines.
#[arg(long, default_value_t = 100)]
pub tail: usize,
}
#[derive(Subcommand, Debug)]
@@ -434,6 +452,7 @@ impl BrowserCommands {
pub fn to_action(&self) -> Option<Action> {
Some(match self {
Self::Help => return None,
Self::Doctor(_) => return None,
Self::Start(cmd) => Action::StartSession(cmd.clone()),
Self::ListSessions(cmd) => Action::ListSessions(cmd.clone()),
Self::Status(cmd) => Action::SessionStatus(cmd.clone()),
@@ -489,6 +508,7 @@ impl BrowserCommands {
Self::Logs { command } => match command {
LogsCommands::Console(cmd) => Action::LogsConsole(cmd.clone()),
LogsCommands::Errors(cmd) => Action::LogsErrors(cmd.clone()),
LogsCommands::Daemon(_) => return None,
},
Self::Network { command } => match command {
NetworkCommands::Requests(cmd) => Action::NetworkRequests(cmd.clone()),
@@ -540,6 +560,7 @@ impl BrowserCommands {
pub fn command_name(&self) -> &str {
match self {
Self::Help => "help",
Self::Doctor(_) => "browser doctor",
Self::Start(_) => session::start::COMMAND_NAME,
Self::ListSessions(_) => session::list::COMMAND_NAME,
Self::Status(_) => session::status::COMMAND_NAME,
@@ -586,6 +607,7 @@ impl BrowserCommands {
Self::Logs { command } => match command {
LogsCommands::Console(_) => observation::logs_console::COMMAND_NAME,
LogsCommands::Errors(_) => observation::logs_errors::COMMAND_NAME,
LogsCommands::Daemon(_) => "browser logs daemon",
},
Self::Network { command } => match command {
NetworkCommands::Requests(_) => observation::network_requests::COMMAND_NAME,
@@ -623,6 +645,7 @@ impl BrowserCommands {
pub fn context(&self, result: &ActionResult) -> Option<ResponseContext> {
match self {
Self::Help => None,
Self::Doctor(_) => None,
Self::Start(cmd) => session::start::context(cmd, result),
Self::ListSessions(cmd) => session::list::context(cmd, result),
Self::Status(cmd) => session::status::context(cmd, result),
@@ -666,6 +689,7 @@ impl BrowserCommands {
Self::Logs { command } => match command {
LogsCommands::Console(cmd) => observation::logs_console::context(cmd, result),
LogsCommands::Errors(cmd) => observation::logs_errors::context(cmd, result),
LogsCommands::Daemon(_) => None,
},
Self::Network { command } => match command {
NetworkCommands::Requests(cmd) => {
@@ -1034,6 +1058,36 @@ mod tests {
}
}
#[test]
fn try_parse_from_accepts_browser_doctor_command() {
let cli = Cli::try_parse_from(["actionbook", "--json", "browser", "doctor", "--start"])
.expect("parse browser doctor");
assert!(cli.json);
match cli.command {
Some(Commands::Browser {
command: BrowserCommands::Doctor(cmd),
}) => assert!(cmd.start),
other => panic!("expected browser doctor command, got {other:?}"),
}
}
#[test]
fn try_parse_from_accepts_browser_logs_daemon_command() {
let cli = Cli::try_parse_from(["actionbook", "browser", "logs", "daemon", "--tail", "7"])
.expect("parse browser logs daemon");
match cli.command {
Some(Commands::Browser {
command:
BrowserCommands::Logs {
command: LogsCommands::Daemon(cmd),
},
}) => assert_eq!(cmd.tail, 7),
other => panic!("expected browser logs daemon command, got {other:?}"),
}
}
#[test]
fn try_parse_from_accepts_browser_hover_command() {
let cli = Cli::try_parse_from([
+289 -2
View File
@@ -8,7 +8,11 @@ use actionbook_cli::action::Action;
use actionbook_cli::action_result::ActionResult;
use actionbook_cli::browser::interaction;
use actionbook_cli::browser::navigation;
use actionbook_cli::cli::{BrowserCommands, Cli, Commands, DaemonCommands, ExtensionCommands};
use actionbook_cli::browser::session;
use actionbook_cli::cli::{
BrowserCommands, BrowserDoctorArgs, Cli, Commands, DaemonCommands, DaemonLogsArgs,
ExtensionCommands, LogsCommands,
};
use actionbook_cli::config;
use actionbook_cli::daemon::cdp_error_classifier::CdpErrorCode;
use actionbook_cli::output::{self, JsonEnvelope};
@@ -216,6 +220,28 @@ async fn handle_browser(
return Ok(());
}
match command {
BrowserCommands::Doctor(cmd) => {
handle_browser_doctor(cmd, json_mode, timeout_ms).await?;
return Ok(());
}
BrowserCommands::Logs {
command: LogsCommands::Daemon(cmd),
} => {
handle_browser_daemon_logs(cmd, json_mode)?;
return Ok(());
}
other => {
return handle_browser_daemon_action(other, json_mode, timeout_ms).await;
}
}
}
async fn handle_browser_daemon_action(
command: BrowserCommands,
json_mode: bool,
timeout_ms: Option<u64>,
) -> Result<(), Box<dyn std::error::Error>> {
let start = Instant::now();
let command = match command {
BrowserCommands::Start(cmd) => match config::resolve_start_command(cmd) {
@@ -386,6 +412,265 @@ async fn handle_browser(
Ok(())
}
async fn handle_browser_doctor(
cmd: BrowserDoctorArgs,
json_mode: bool,
timeout_ms: Option<u64>,
) -> Result<(), Box<dyn std::error::Error>> {
let start = Instant::now();
let doctor_call = run_browser_doctor(cmd.start);
let result = if let Some(ms) = timeout_ms {
match tokio::time::timeout(Duration::from_millis(ms), doctor_call).await {
Ok(result) => result,
Err(_) => Err(ActionResult::fatal_with_details(
"BROWSER_DOCTOR_TIMEOUT",
format!("browser doctor timed out after {ms}ms"),
"increase --timeout, or run `actionbook browser logs daemon --tail 100 --json`",
json!({ "timeout_ms": ms }),
)),
}
} else {
doctor_call.await
};
let duration = start.elapsed();
match result {
Ok(data) => {
if json_mode {
let envelope = JsonEnvelope::success("browser doctor", None, data, duration);
println!("{}", serde_json::to_string(&envelope)?);
} else {
print_browser_doctor_text(&data);
}
}
Err(result) => {
if json_mode {
let envelope = JsonEnvelope::from_result("browser doctor", None, &result, duration);
println!("{}", serde_json::to_string(&envelope)?);
} else {
let text = output::format_text("browser doctor", &None, &result);
eprintln!("{text}");
}
flush_and_exit(1);
}
}
Ok(())
}
async fn run_browser_doctor(start_daemon: bool) -> Result<serde_json::Value, ActionResult> {
let socket_path = actionbook_cli::daemon::server::socket_path();
let pid_path = actionbook_cli::daemon::server::pid_path();
let ready_path = socket_path.with_extension("ready");
let version_path = actionbook_cli::daemon::server::version_path();
let log_path = socket_path.with_extension("log");
let binary_path = std::env::current_exe()
.ok()
.map(|p| p.display().to_string());
let before_running = actionbook_cli::daemon::server::is_daemon_running();
let before_pid = actionbook_cli::daemon::server::read_daemon_pid();
let mut connectable = false;
let mut request_ok = false;
let mut request_error = None;
let mut sessions = serde_json::Value::Null;
if start_daemon || before_running {
match DaemonClient::connect().await {
Ok(mut client) => {
connectable = true;
match client
.send_action(&Action::ListSessions(session::list::Cmd {}))
.await
{
Ok(ActionResult::Ok { data }) => {
request_ok = true;
sessions = data;
}
Ok(other) => {
request_error = Some(format!("{other:?}"));
}
Err(err) => {
request_error = Some(err.to_string());
}
}
}
Err(err) if start_daemon => {
return Err(ActionResult::fatal_with_details(
"BROWSER_DOCTOR_FAILED",
err.to_string(),
"run `actionbook browser logs daemon --tail 100 --json`, then retry `actionbook daemon restart --json`",
json!({
"socket_path": socket_path.display().to_string(),
"pid_path": pid_path.display().to_string(),
"log_path": log_path.display().to_string(),
"last_error": last_non_empty_log_line(&log_path),
}),
));
}
Err(err) => {
request_error = Some(err.to_string());
}
}
}
let daemon_running = actionbook_cli::daemon::server::is_daemon_running();
let daemon_pid = if daemon_running {
actionbook_cli::daemon::server::read_daemon_pid().or(before_pid)
} else {
None
};
let status = if daemon_running && connectable && request_ok {
"ok"
} else if daemon_running || connectable || request_ok {
"degraded"
} else {
"not_running"
};
let recover_command = if daemon_running {
"actionbook daemon restart --json"
} else {
"actionbook browser doctor --start --json"
};
Ok(json!({
"status": status,
"binary_found": binary_path.is_some(),
"binary_path": binary_path,
"daemon_running": daemon_running,
"daemon_pid": daemon_pid,
"socket_path": socket_path.display().to_string(),
"pid_path": pid_path.display().to_string(),
"ready_path": ready_path.display().to_string(),
"version_path": version_path.display().to_string(),
"log_path": log_path.display().to_string(),
"cdp_endpoint": first_session_field(&sessions, "cdp_endpoint"),
"browser_binary": std::env::var("ACTIONBOOK_BROWSER_EXECUTABLE").ok(),
"profile_dir": std::env::var("ACTIONBOOK_BROWSER_PROFILE").ok(),
"proxy": proxy_snapshot(),
"last_error": last_non_empty_log_line(&log_path),
"recover_command": recover_command,
"checks": [
{ "name": "binary_found", "ok": binary_path.is_some() },
{ "name": "daemon_running", "ok": daemon_running },
{ "name": "daemon_connectable", "ok": connectable },
{ "name": "daemon_request_ok", "ok": request_ok }
],
"request_error": request_error,
"sessions": sessions,
"started": start_daemon && !before_running && daemon_running,
}))
}
fn print_browser_doctor_text(data: &serde_json::Value) {
println!(
"browser doctor: {}",
data.get("status")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
);
for key in [
"binary_found",
"daemon_running",
"daemon_pid",
"socket_path",
"log_path",
"recover_command",
] {
if let Some(value) = data.get(key) {
println!("{key}: {value}");
}
}
if let Some(last_error) = data.get("last_error").and_then(|v| v.as_str()) {
println!("last_error: {last_error}");
}
}
fn handle_browser_daemon_logs(
cmd: DaemonLogsArgs,
json_mode: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let start = Instant::now();
let log_path = actionbook_cli::daemon::server::socket_path().with_extension("log");
let (lines, truncated, missing) = tail_lines(&log_path, cmd.tail);
let data = json!({
"log_path": log_path.display().to_string(),
"tail": cmd.tail,
"lines": lines,
"missing": missing,
"__truncated": truncated,
});
if json_mode {
let envelope = JsonEnvelope::success("browser logs daemon", None, data, start.elapsed());
println!("{}", serde_json::to_string(&envelope)?);
} else {
if missing {
println!("daemon log not found: {}", log_path.display());
} else {
for line in lines {
println!("{line}");
}
}
}
Ok(())
}
fn tail_lines(path: &std::path::Path, tail: usize) -> (Vec<String>, bool, bool) {
let Ok(text) = std::fs::read_to_string(path) else {
return (Vec::new(), false, true);
};
let lines = text.lines().map(str::to_string).collect::<Vec<_>>();
let truncated = lines.len() > tail;
let start = lines.len().saturating_sub(tail);
(lines[start..].to_vec(), truncated, false)
}
fn last_non_empty_log_line(path: &std::path::Path) -> Option<String> {
std::fs::read_to_string(path).ok().and_then(|text| {
text.lines()
.rev()
.map(str::trim)
.find(|line| !line.is_empty())
.map(str::to_string)
})
}
fn proxy_snapshot() -> serde_json::Value {
let mut value = serde_json::Map::new();
for name in [
"HTTPS_PROXY",
"HTTP_PROXY",
"ALL_PROXY",
"https_proxy",
"http_proxy",
"all_proxy",
] {
if let Ok(raw) = std::env::var(name) {
value.insert(name.to_string(), json!(redact_proxy(&raw)));
}
}
serde_json::Value::Object(value)
}
fn redact_proxy(raw: &str) -> String {
if let Some((scheme, rest)) = raw.split_once("://")
&& let Some(at) = rest.rfind('@')
{
return format!("{scheme}://<redacted>@{}", &rest[at + 1..]);
}
raw.to_string()
}
fn first_session_field(sessions: &serde_json::Value, field: &str) -> serde_json::Value {
sessions
.get("sessions")
.and_then(|v| v.as_array())
.and_then(|items| items.first())
.and_then(|item| item.get(field))
.cloned()
.unwrap_or(serde_json::Value::Null)
}
async fn handle_daemon(
command: DaemonCommands,
json_mode: bool,
@@ -528,7 +813,7 @@ Commands:
browser Control browser sessions, tabs, and page interactions
extension Manage the Chrome extension (status, ping, install, uninstall, path)
daemon restart Stop the running daemon (next CLI call auto-respawns one)
setup Configure actionbook (or --target <agent> for quick skills install)
setup Configure actionbook (or --target <agent> for quick skills install)
help Show this help
--version Show version
@@ -563,6 +848,7 @@ Most commands require --session <SID> and --tab <TID>.
Session-level commands need only --session. Start and list-sessions need neither.
Session:
doctor Diagnose browser hand health
start Start or attach a browser session
list-sessions List all active sessions
status --session Show session status
@@ -602,6 +888,7 @@ Observation:
Logs:
logs console --session --tab Get console logs
logs errors --session --tab Get error logs (exceptions + rejections)
logs daemon Read daemon/browser process logs
Network:
network requests --session --tab List tracked network requests
+2 -2
View File
@@ -88,12 +88,12 @@ All commands support `--help` for full usage and examples.
| Wait | `wait element`, `wait navigation`, `wait network-idle`, `wait condition` | `actionbook browser wait element --help` |
| Cookies | `cookies list`, `cookies get`, `cookies set`, `cookies delete`, `cookies clear` | `actionbook browser cookies list --help` |
| Storage | `local-storage list\|get\|set\|delete\|clear`, `session-storage ...` | `actionbook browser local-storage get --help` |
| Logs | `logs console`, `logs errors` | `actionbook browser logs console --help` |
| Logs | `logs console`, `logs errors`, `logs daemon` | `actionbook browser logs console --help` |
| Network | `network requests`, `network request <id>`, `network har start`, `network har stop` | `actionbook browser network requests --help` |
| Query | `query one\|all\|nth\|count` | `actionbook browser query --help` |
| Batch | `batch-new-tab`, `batch-snapshot`, `batch-click` | `actionbook browser batch-new-tab --help` |
| Extension | `extension status`, `extension ping`, `extension install`, `extension uninstall`, `extension path` | `actionbook extension status --help` |
| Daemon | `daemon restart` | `actionbook daemon restart --help` |
| Daemon | `browser doctor`, `daemon restart` | `actionbook browser doctor --help` |
Full command reference: [command-reference.md](references/command-reference.md)
@@ -414,6 +414,9 @@ actionbook extension path # Print install path, insta
The actionbook daemon runs in the background and manages browser sessions. It auto-starts on first CLI call.
```bash
actionbook browser doctor --json # Inspect browser hand health without requiring a session
actionbook browser doctor --start --json # Start daemon if needed, then re-check request/response health
actionbook browser logs daemon --tail 100 --json # Read daemon/browser process logs
actionbook daemon restart # Stop the running daemon (next CLI call respawns)
```