From 8e84e554ad0e2fd4283b23c546841a3434bddcc6 Mon Sep 17 00:00:00 2001 From: Sagnik Ghosh Date: Tue, 18 Aug 2026 01:10:13 +0530 Subject: [PATCH] Add `autter doctor` end-to-end setup validation command `autter debug` is a full support dump: it always exits 0 and buries failures in ~200 lines of report, so users have no one-command way to answer "is autter actually working right now?". `autter doctor` closes that gap with a focused pass/warn/fail report built on the existing diagnostics primitives: - Setup: git binary/version, config-file parse (surfacing the silently ignored malformed ~/.autter/config.json), repository allow/exclude eligibility - Background service: daemon readiness + real trace2 ingestion probe - Git capture: trace2 global config for both configured and terminal git, plus the trace2 file self-check - End-to-end checkpoint: a real checkpoint event through checkpoint -> daemon -> working log -> commit -> attribution (skipped with a pointer when the service check already failed) - AI agent hooks: per-agent install/up-to-date status via the install-hooks installers, including the VS Code >= 1.109.3 native hooks chain (refactored out of debug.rs for reuse) - Account & sync: auth state (expired sessions fail loudly since sync silently no-ops) and an API reachability probe when authenticated Every failure prints a concrete `fix:` line. Exits 0 when nothing failed (warnings allowed), 1 otherwise; `--json` emits a stable machine-readable report for CI/scripts. Error paths that previously suggested `autter debug` now point at `autter doctor`; debug remains unchanged as the deep support dump. Generated-By: PostHog Desktop Task-Id: c84ac9ce-31cc-4e70-8451-62857be00f1c --- src/commands/arg_parser.rs | 15 + src/commands/autter_handlers.rs | 14 +- src/commands/debug.rs | 61 +- src/commands/doctor.rs | 1017 +++++++++++++++++++++++++++++++ src/commands/git_handlers.rs | 4 +- src/commands/mod.rs | 11 +- src/diagnostics.rs | 17 +- tests/integration/doctor.rs | 163 +++++ tests/integration/main.rs | 1 + 9 files changed, 1273 insertions(+), 30 deletions(-) create mode 100644 src/commands/doctor.rs create mode 100644 tests/integration/doctor.rs diff --git a/src/commands/arg_parser.rs b/src/commands/arg_parser.rs index 1a7b83d..c8d2a6f 100644 --- a/src/commands/arg_parser.rs +++ b/src/commands/arg_parser.rs @@ -351,6 +351,21 @@ autter config [ | set | unset ] summary: "Print support/debug diagnostics", body: "autter debug\n\n Print support/debug diagnostics.", }, + HelpEntry { + name: "doctor", + aliases: &[], + summary: "Validate the autter setup end-to-end", + body: "autter doctor [--json] [--skip-trace2-checks] + + Run setup validation checks: git and config, the background service, + trace2 capture, a real end-to-end checkpoint round-trip, AI agent hooks, + and login/connectivity. Every failed check prints a concrete fix. + + Exits 0 when no check fails (warnings allowed), 1 when any check fails. + + --json Machine-readable single-line JSON report + --skip-trace2-checks Skip the trace2 event-capture self-check", + }, HelpEntry { name: "bg", aliases: &["d", "daemon"], diff --git a/src/commands/autter_handlers.rs b/src/commands/autter_handlers.rs index 7e1178b..ccf4418 100644 --- a/src/commands/autter_handlers.rs +++ b/src/commands/autter_handlers.rs @@ -75,8 +75,8 @@ pub fn handle_autter(args: &[String]) { // per-PID log files. // // Skip for commands that must work without a running background service - // (help, version, config, d management, debug, upgrade) so users can - // always diagnose and recover from a broken state. + // (help, version, config, d management, debug, doctor, upgrade) so users + // can always diagnose and recover from a broken state. let needs_daemon = !matches!( args[0].as_str(), "help" @@ -90,6 +90,7 @@ pub fn handle_autter(args: &[String]) { | "d" | "daemon" | "debug" + | "doctor" | "upgrade" | "install-hooks" | "install" @@ -107,7 +108,7 @@ pub fn handle_autter(args: &[String]) { "error: failed to connect to autter background service: {}", err ); - commands::suggest_autter_debug(); + commands::suggest_autter_doctor(); if args[0].as_str() == "checkpoint" { std::process::exit(0); } @@ -157,6 +158,9 @@ pub fn handle_autter(args: &[String]) { "debug" => { commands::debug::handle_debug(&args[1..]); } + "doctor" => { + commands::doctor::handle_doctor(&args[1..]); + } "bg" | "d" | "daemon" => { commands::daemon::handle_daemon(&args[1..]); } @@ -216,7 +220,7 @@ pub fn handle_autter(args: &[String]) { } Err(e) => { eprintln!("Install hooks failed: {}", e); - commands::suggest_autter_debug(); + commands::suggest_autter_doctor(); std::process::exit(1); } }, @@ -228,7 +232,7 @@ pub fn handle_autter(args: &[String]) { } Err(e) => { eprintln!("Uninstall hooks failed: {}", e); - commands::suggest_autter_debug(); + commands::suggest_autter_doctor(); std::process::exit(1); } }, diff --git a/src/commands/debug.rs b/src/commands/debug.rs index 0d3d591..632ad49 100644 --- a/src/commands/debug.rs +++ b/src/commands/debug.rs @@ -481,10 +481,36 @@ fn collect_agent_capture_info() -> Vec { lines } +/// Outcome of walking the VS Code native-hooks chain, shared between the +/// debug report (which prints the lines) and `autter doctor` (which turns the +/// outcome into a pass/warn/fail check). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum VsCodeChainOutcome { + /// No VS Code CLI found on this machine. + NotDetected, + /// VS Code found but its version could not be determined. + VersionUnknown, + /// VS Code predates native agent hooks; the autter extension captures. + LegacyExtensionMode, + /// Native-hooks mode and every link in the chain is in place. + Complete, + /// Native-hooks mode but the hook file or chat settings are missing. + Incomplete, +} + +pub(crate) struct VsCodeNativeHooksChain { + pub(crate) outcome: VsCodeChainOutcome, + pub(crate) lines: Vec, +} + +fn collect_vscode_native_hooks_chain() -> Vec { + inspect_vscode_native_hooks_chain().lines +} + /// Walk the chain VS Code's built-in Copilot agent needs for AI edits to /// checkpoint on VS Code >= 1.109.3: the autter extension stands down there /// and capture only happens if VS Code loads ~/.copilot/hooks/autter.json. -fn collect_vscode_native_hooks_chain() -> Vec { +pub(crate) fn inspect_vscode_native_hooks_chain() -> VsCodeNativeHooksChain { use crate::mdm::utils::{ MIN_VSCODE_NATIVE_HOOKS_VERSION, VSCODE_USER_COPILOT_HOOKS_LOCATION, get_editor_version, home_dir, parse_version_triple, resolve_editor_cli, settings_paths_for_products, @@ -495,14 +521,20 @@ fn collect_vscode_native_hooks_chain() -> Vec { let Some(cli) = resolve_editor_cli("code") else { lines.push(" VS Code CLI not found; skipping chain checks".to_string()); - return lines; + return VsCodeNativeHooksChain { + outcome: VsCodeChainOutcome::NotDetected, + lines, + }; }; let version_str = match get_editor_version(&cli) { Ok(v) => v, Err(err) => { lines.push(format!(" VS Code version: ", err)); - return lines; + return VsCodeNativeHooksChain { + outcome: VsCodeChainOutcome::VersionUnknown, + lines, + }; } }; let first_line = version_str.lines().next().unwrap_or("").trim().to_string(); @@ -518,7 +550,10 @@ fn collect_vscode_native_hooks_chain() -> Vec { " Capture mode: autter extension detection (VS Code predates native agent hooks, added in {}.{}.{})", min_major, min_minor, min_patch )); - return lines; + return VsCodeNativeHooksChain { + outcome: VsCodeChainOutcome::LegacyExtensionMode, + lines, + }; } lines.push(format!( @@ -567,13 +602,19 @@ fn collect_vscode_native_hooks_chain() -> Vec { " Chain status: OK — Copilot agent-mode edits should checkpoint (restart VS Code if hooks were just installed)" .to_string(), ); - } else { - lines.push( - " Chain status: INCOMPLETE — Copilot agent-mode edits are likely NOT being captured; run `autter install-hooks` and restart VS Code" - .to_string(), - ); + return VsCodeNativeHooksChain { + outcome: VsCodeChainOutcome::Complete, + lines, + }; + } + lines.push( + " Chain status: INCOMPLETE — Copilot agent-mode edits are likely NOT being captured; run `autter install-hooks` and restart VS Code" + .to_string(), + ); + VsCodeNativeHooksChain { + outcome: VsCodeChainOutcome::Incomplete, + lines, } - lines } /// Read `chat.useHooks` and the `~/.copilot/hooks` entry of diff --git a/src/commands/doctor.rs b/src/commands/doctor.rs new file mode 100644 index 0000000..5d10ce9 --- /dev/null +++ b/src/commands/doctor.rs @@ -0,0 +1,1017 @@ +//! `autter doctor` -- one-command setup validation. +//! +//! Runs the trust-critical checks (git, config, background service, trace2 +//! capture, an end-to-end checkpoint round-trip, AI agent hooks, and +//! login/connectivity) and prints one pass/warn/fail line per check with a +//! concrete fix for every failure. Unlike `autter debug` (a full support +//! dump that always exits 0), `doctor` is focused and scriptable: it exits +//! non-zero when any check fails, and `--json` emits a machine-readable +//! report on a single line. + +use crate::auth::{AuthState, collect_auth_status, format_unix_timestamp}; +use crate::commands::arg_parser::{self, ScanMode}; +use crate::config::{self, Config}; +use crate::diagnostics::{DiagnosticCheckResult, DiagnosticStatus, GitDiagnosticTarget}; +use crate::process_timeout::run_command_with_timeout; +use serde::Serialize; +use std::time::Duration; + +const SKIP_TRACE2_CHECKS_FLAG: &str = "--skip-trace2-checks"; +const DOCTOR_COMMAND_TIMEOUT: Duration = Duration::from_secs(3); +const DOCTOR_COMMAND_POLL_INTERVAL: Duration = Duration::from_millis(100); +const CONNECTIVITY_TIMEOUT_SECS: u64 = 5; +/// Cheap unauthenticated endpoint used only as a reachability probe. Keep in +/// sync with `releases_endpoint()` in `upgrade.rs`. +const CONNECTIVITY_PROBE_ENDPOINT: &str = "/worker/releases"; + +/// Minimum git version required for autter to function correctly. Keep in +/// sync with `MIN_GIT_VERSION` in `install_hooks.rs` and `debug.rs`. +const MIN_GIT_VERSION: (u32, u32, u32) = (2, 22, 0); +const MIN_GIT_VERSION_DISPLAY: &str = "2.22.0"; + +const SECTION_SETUP: &str = "Setup"; +const SECTION_SERVICE: &str = "Background service"; +const SECTION_TRACE2: &str = "Git capture (trace2)"; +const SECTION_E2E: &str = "End-to-end checkpoint"; +const SECTION_AGENTS: &str = "AI agent hooks"; +const SECTION_ACCOUNT: &str = "Account & sync"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +enum DoctorStatus { + Passed, + Warning, + Failed, + Skipped, +} + +#[derive(Debug, Clone, Serialize)] +struct DoctorCheck { + section: &'static str, + name: String, + status: DoctorStatus, + summary: String, + #[serde(skip_serializing_if = "Vec::is_empty")] + details: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + remediation: Option, +} + +#[derive(Debug, Clone, Copy, Serialize)] +struct DoctorSummary { + passed: usize, + warnings: usize, + failed: usize, + skipped: usize, +} + +#[derive(Debug, Serialize)] +struct DoctorOutput { + autter_version: String, + checks: Vec, + summary: DoctorSummary, + ok: bool, +} + +#[derive(Debug, Clone, Copy, Default)] +struct DoctorOptions { + skip_trace2_checks: bool, +} + +pub fn handle_doctor(args: &[String]) { + let pp = match arg_parser::pre_parse(args, ScanMode::Full, false) { + Ok(pp) => pp, + Err(e) => { + eprintln!("error: {}", e); + std::process::exit(crate::commands::EXIT_USAGE_ERROR); + } + }; + if pp.flags.help { + arg_parser::print_command_help("doctor"); + return; + } + arg_parser::merge_global_flags(&pp.flags); + + let mut options = DoctorOptions::default(); + for arg in &pp.rest { + match arg.as_str() { + SKIP_TRACE2_CHECKS_FLAG => options.skip_trace2_checks = true, + "help" => { + arg_parser::print_command_help("doctor"); + return; + } + unknown => { + eprintln!("error: unknown doctor argument: {}", unknown); + arg_parser::print_command_help("doctor"); + std::process::exit(crate::commands::EXIT_USAGE_ERROR); + } + } + } + + let output = run_doctor(&options); + if output.summary.failed > 0 { + std::process::exit(crate::commands::EXIT_RUNTIME_ERROR); + } +} + +fn run_doctor(options: &DoctorOptions) -> DoctorOutput { + let json = arg_parser::json(); + let mut reporter = Reporter::new(json); + + if !json { + println!("autter doctor (autter {})", version_string()); + println!("Validating your autter setup -- the end-to-end checks take a few seconds."); + } + + let git_cmd = Config::get().git_cmd().to_string(); + + // Setup: git binary/version, config file parse, repository eligibility. + let (git_check, terminal_git_ok) = check_git_versions(&git_cmd); + reporter.add(git_check); + reporter.add(check_config_file()); + reporter.add(check_repository()); + + // Background service: readiness plus a real trace2 ingestion probe. This + // starts (or restarts) the daemon when needed, so it must run before the + // end-to-end checkpoint check. + let mut daemon_check = check_from_diagnostic( + SECTION_SERVICE, + "background service", + crate::diagnostics::prepare_daemon_for_debug_self_checks(&git_cmd), + ); + if daemon_check.status == DoctorStatus::Passed { + daemon_check.summary = + "background service is running and ingesting git trace2 events".to_string(); + } + let daemon_ok = daemon_check.status != DoctorStatus::Failed; + reporter.add(daemon_check); + + // Git capture: the trace2 global config every git invocation depends on, + // checked for both the git autter runs and the git on the user's PATH. + let configured_target = GitDiagnosticTarget::new("configured git", &git_cmd); + let terminal_target = GitDiagnosticTarget::new("terminal git", "git"); + reporter.add(check_from_diagnostic( + SECTION_TRACE2, + "trace2 config (configured git)", + crate::diagnostics::check_trace2_global_config(&configured_target), + )); + reporter.add(check_from_diagnostic( + SECTION_TRACE2, + "trace2 config (terminal git)", + crate::diagnostics::check_trace2_global_config(&terminal_target), + )); + if options.skip_trace2_checks { + reporter.add(skipped_check( + SECTION_TRACE2, + "trace2 event capture", + format!("skipped ({})", SKIP_TRACE2_CHECKS_FLAG), + )); + } else { + reporter.add(check_from_diagnostic( + SECTION_TRACE2, + "trace2 event capture", + crate::diagnostics::run_trace2_file_self_check(&configured_target), + )); + } + + // End-to-end: a real checkpoint event through the full pipeline -- + // `autter checkpoint` -> background service -> working log -> commit -> + // attribution. Runs against the terminal git when available since that is + // what the user's own commits go through. + if daemon_ok { + let e2e_target = if terminal_git_ok { + &terminal_target + } else { + &configured_target + }; + let mut e2e_check = check_from_diagnostic( + SECTION_E2E, + "checkpoint round-trip", + crate::diagnostics::run_attribution_self_check(e2e_target), + ); + if e2e_check.status == DoctorStatus::Passed { + e2e_check.summary = format!( + "checkpoint -> service -> commit -> attribution round-trip succeeded ({})", + e2e_target.label + ); + } + reporter.add(e2e_check); + } else { + reporter.add(skipped_check( + SECTION_E2E, + "checkpoint round-trip", + "skipped -- fix the background service check first", + )); + } + + // AI agent hooks: would an edit made right now actually checkpoint? + for check in agent_hook_checks() { + reporter.add(check); + } + if let Some(check) = vscode_native_hooks_check() { + reporter.add(check); + } + + // Account & sync: auth state and API reachability. Uploads silently + // no-op when unauthenticated, so doctor is where a broken login surfaces. + let (auth_check, authenticated) = check_auth(); + reporter.add(auth_check); + reporter.add(check_connectivity(authenticated)); + + reporter.finish() +} + +// =========================================================================== +// Individual checks +// =========================================================================== + +/// Check the configured git binary (and the terminal `git` on PATH) run and +/// meet the minimum supported version. Returns the check plus whether the +/// terminal git is usable, which decides the end-to-end check's git target. +fn check_git_versions(git_cmd: &str) -> (DoctorCheck, bool) { + let mut details = Vec::new(); + let mut status = DoctorStatus::Passed; + let mut summary; + let mut remediation = None; + + match git_version_of(git_cmd) { + Ok((raw, parsed)) => { + details.push(format!("configured git: {} ({})", git_cmd, raw)); + match parsed { + Some(version) if version >= MIN_GIT_VERSION => { + summary = format!( + "git {}.{}.{} meets the {} minimum", + version.0, version.1, version.2, MIN_GIT_VERSION_DISPLAY + ); + } + Some(version) => { + status = DoctorStatus::Failed; + summary = format!( + "git {}.{}.{} is below the {} minimum -- attribution will not work", + version.0, version.1, version.2, MIN_GIT_VERSION_DISPLAY + ); + remediation = Some(format!( + "upgrade git to {} or newer, then re-run `autter doctor`", + MIN_GIT_VERSION_DISPLAY + )); + } + None => { + status = DoctorStatus::Warning; + summary = format!( + "could not parse the git version from '{}' (minimum is {})", + raw, MIN_GIT_VERSION_DISPLAY + ); + } + } + } + Err(err) => { + status = DoctorStatus::Failed; + summary = "the configured git binary could not be run".to_string(); + details.push(format!("configured git: {} ({})", git_cmd, err)); + remediation = Some( + "point autter at a working git binary (`autter config set git_path `), then re-run `autter doctor`" + .to_string(), + ); + } + } + + let terminal_git_ok = match git_version_of("git") { + Ok((raw, parsed)) => { + details.push(format!("terminal git: {}", raw)); + if let Some(version) = parsed + && version < MIN_GIT_VERSION + && status != DoctorStatus::Failed + { + status = DoctorStatus::Failed; + summary = format!( + "the git on your PATH ({}.{}.{}) is below the {} minimum -- your own git commands will not be captured", + version.0, version.1, version.2, MIN_GIT_VERSION_DISPLAY + ); + remediation = Some(format!( + "upgrade git to {} or newer, then re-run `autter doctor`", + MIN_GIT_VERSION_DISPLAY + )); + } + true + } + Err(err) => { + details.push(format!("terminal git: not runnable ({})", err)); + if status == DoctorStatus::Passed { + status = DoctorStatus::Warning; + summary.push_str(" -- but no runnable `git` was found on PATH"); + } + false + } + }; + + ( + DoctorCheck { + section: SECTION_SETUP, + name: "git version".to_string(), + status, + summary, + details, + remediation, + }, + terminal_git_ok, + ) +} + +/// Raw `--version` output plus the parsed (major, minor, patch), when the +/// output was parseable. +type GitVersionProbe = (String, Option<(u32, u32, u32)>); + +fn git_version_of(program: &str) -> Result { + let output = run_command_with_timeout( + program, + &["--version"], + None, + DOCTOR_COMMAND_TIMEOUT, + DOCTOR_COMMAND_POLL_INTERVAL, + &[], + )?; + if output.timed_out { + return Err("timed out".to_string()); + } + if output.status != Some(0) { + return Err(format!( + "exit status {}: {}", + output + .status + .map(|code| code.to_string()) + .unwrap_or_else(|| "".to_string()), + output.stderr + )); + } + let raw = output.stdout.trim().to_string(); + let parsed = crate::mdm::utils::parse_version_triple(&raw); + Ok((raw, parsed)) +} + +/// Surface a malformed `~/.autter/config.json`. The normal runtime load path +/// silently falls back to defaults when the file cannot be parsed, so a typo +/// there means every setting in the file is ignored without any signal -- +/// this check is where that failure mode becomes visible. +fn check_config_file() -> DoctorCheck { + let name = "configuration file".to_string(); + let Some(path) = config::config_file_path_public() else { + return DoctorCheck { + section: SECTION_SETUP, + name, + status: DoctorStatus::Failed, + summary: "could not determine the config file path".to_string(), + details: Vec::new(), + remediation: Some( + "check that your home directory is set (HOME on macOS/Linux, USERPROFILE on Windows), then re-run `autter doctor`" + .to_string(), + ), + }; + }; + + if !path.exists() { + return DoctorCheck { + section: SECTION_SETUP, + name, + status: DoctorStatus::Passed, + summary: "no config file present (defaults in use)".to_string(), + details: vec![format!("path: {}", path.display())], + remediation: None, + }; + } + + match config::load_file_config_public() { + Ok(_) => DoctorCheck { + section: SECTION_SETUP, + name, + status: DoctorStatus::Passed, + summary: "config file is valid".to_string(), + details: vec![format!("path: {}", path.display())], + remediation: None, + }, + Err(err) => DoctorCheck { + section: SECTION_SETUP, + name, + status: DoctorStatus::Failed, + summary: "config file could not be parsed -- autter is silently running with defaults" + .to_string(), + details: vec![format!("path: {}", path.display()), err], + remediation: Some(format!( + "fix or remove {} (autter ignores an unparseable config without warning), then re-run `autter doctor`", + path.display() + )), + }, + } +} + +/// When run inside a repository, verify the allow/exclude repository filters +/// do not block it -- a filtered-out repo records no attribution at all. +fn check_repository() -> DoctorCheck { + let name = "repository".to_string(); + let Ok(repo) = crate::git::find_repository_in_path(".") else { + return skipped_check( + SECTION_SETUP, + "repository", + "not inside a git repository (repository checks skipped)", + ); + }; + + let workdir = repo + .workdir() + .map(|p| p.display().to_string()) + .unwrap_or_else(|_| "".to_string()); + let config = Config::get(); + + if !config.has_repository_filters() { + return DoctorCheck { + section: SECTION_SETUP, + name, + status: DoctorStatus::Passed, + summary: "repository is eligible for attribution capture".to_string(), + details: vec![format!("workdir: {}", workdir)], + remediation: None, + }; + } + + if config.is_allowed_repository(&Some(repo)) { + DoctorCheck { + section: SECTION_SETUP, + name, + status: DoctorStatus::Passed, + summary: "repository matches your allow/exclude repository filters".to_string(), + details: vec![format!("workdir: {}", workdir)], + remediation: None, + } + } else { + DoctorCheck { + section: SECTION_SETUP, + name, + status: DoctorStatus::Failed, + summary: + "this repository is blocked by allow_repositories/exclude_repositories -- autter will not capture attribution here" + .to_string(), + details: vec![format!("workdir: {}", workdir)], + remediation: Some( + "update allow_repositories / exclude_repositories via `autter config` if this repository should be tracked" + .to_string(), + ), + } + } +} + +/// Per-agent hook status from the same installers `autter install-hooks` +/// manages. Agents that are not installed on this machine are collapsed into +/// a single skipped line so detected problems stay prominent. +fn agent_hook_checks() -> Vec { + use crate::mdm::agents::get_all_installers; + use crate::mdm::hook_installer::HookInstallerParams; + + let binary_path = + std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("autter")); + let params = HookInstallerParams { binary_path }; + + let mut checks = Vec::new(); + let mut not_detected = Vec::new(); + + for installer in get_all_installers() { + let name = installer.name().to_string(); + match installer.check_hooks(¶ms) { + Ok(result) if !result.tool_installed => not_detected.push(name), + Ok(result) if installer.id() == "vscode" => { + // VS Code has no config-file hooks; hooks_installed reflects + // the autter extension, which carries manual-edit capture and + // (on older VS Code) Copilot AI-edit capture. + if result.hooks_installed { + checks.push(DoctorCheck { + section: SECTION_AGENTS, + name, + status: DoctorStatus::Passed, + summary: "autter extension installed".to_string(), + details: Vec::new(), + remediation: None, + }); + } else { + checks.push(DoctorCheck { + section: SECTION_AGENTS, + name, + status: DoctorStatus::Failed, + summary: + "autter extension NOT installed -- manual edits (and Copilot edits on older VS Code) are not captured" + .to_string(), + details: Vec::new(), + remediation: Some( + "run `autter install-hooks`, then restart VS Code".to_string(), + ), + }); + } + } + Ok(result) if !result.hooks_installed => checks.push(DoctorCheck { + section: SECTION_AGENTS, + name: name.clone(), + status: DoctorStatus::Failed, + summary: + "detected, but autter hooks are NOT installed -- its edits are not captured" + .to_string(), + details: Vec::new(), + remediation: Some(format!("run `autter install-hooks`, then restart {}", name)), + }), + Ok(result) if result.hooks_up_to_date => checks.push(DoctorCheck { + section: SECTION_AGENTS, + name, + status: DoctorStatus::Passed, + summary: "hooks installed (up to date)".to_string(), + details: Vec::new(), + remediation: None, + }), + Ok(_) => checks.push(DoctorCheck { + section: SECTION_AGENTS, + name: name.clone(), + status: DoctorStatus::Warning, + summary: "hooks installed but out of date".to_string(), + details: Vec::new(), + remediation: Some(format!( + "run `autter install-hooks` to refresh them, then restart {}", + name + )), + }), + Err(err) => checks.push(DoctorCheck { + section: SECTION_AGENTS, + name, + status: DoctorStatus::Failed, + summary: format!("hook status check failed: {}", err), + details: Vec::new(), + remediation: Some( + "resolve the issue above, then run `autter install-hooks`".to_string(), + ), + }), + } + } + + if checks.is_empty() && !not_detected.is_empty() { + checks.push(DoctorCheck { + section: SECTION_AGENTS, + name: "AI agents".to_string(), + status: DoctorStatus::Warning, + summary: "no supported AI coding agents detected on this machine".to_string(), + details: vec![format!("looked for: {}", not_detected.join(", "))], + remediation: Some( + "for remote dev (SSH/WSL/devcontainers), install autter and run `autter install-hooks` on the machine where your agents run" + .to_string(), + ), + }); + } else if !not_detected.is_empty() { + checks.push(skipped_check( + SECTION_AGENTS, + "not detected", + not_detected.join(", "), + )); + } + + checks +} + +/// The VS Code >= 1.109.3 native-hooks chain: on those builds the autter +/// extension defers AI-edit capture to VS Code itself, so capture silently +/// stops unless the hook file and chat settings are all in place. +fn vscode_native_hooks_check() -> Option { + use crate::commands::debug::{VsCodeChainOutcome, inspect_vscode_native_hooks_chain}; + + let chain = inspect_vscode_native_hooks_chain(); + let name = "VS Code Copilot native hooks".to_string(); + match chain.outcome { + VsCodeChainOutcome::NotDetected => None, + VsCodeChainOutcome::VersionUnknown => Some(DoctorCheck { + section: SECTION_AGENTS, + name, + status: DoctorStatus::Warning, + summary: "could not determine the VS Code version".to_string(), + details: chain.lines, + remediation: None, + }), + VsCodeChainOutcome::LegacyExtensionMode => Some(DoctorCheck { + section: SECTION_AGENTS, + name, + status: DoctorStatus::Passed, + summary: "VS Code predates native agent hooks -- the autter extension handles capture" + .to_string(), + details: chain.lines, + remediation: None, + }), + VsCodeChainOutcome::Complete => Some(DoctorCheck { + section: SECTION_AGENTS, + name, + status: DoctorStatus::Passed, + summary: + "Copilot agent-mode capture chain is complete (restart VS Code if hooks were just installed)" + .to_string(), + details: chain.lines, + remediation: None, + }), + VsCodeChainOutcome::Incomplete => Some(DoctorCheck { + section: SECTION_AGENTS, + name, + status: DoctorStatus::Failed, + summary: "Copilot agent-mode edits are likely NOT being captured".to_string(), + details: chain.lines, + remediation: Some("run `autter install-hooks` and restart VS Code".to_string()), + }), + } +} + +/// Auth state check. Returns the check plus whether the user is authenticated +/// for sync (logged in or API key), which gates the connectivity probe. +fn check_auth() -> (DoctorCheck, bool) { + let name = "authentication".to_string(); + let status = collect_auth_status(); + let has_api_key = Config::get().api_key().is_some(); + let mut details = vec![format!("credential backend: {}", status.backend)]; + + match status.state { + AuthState::LoggedIn => { + let who = status + .email + .or(status.name) + .or(status.user_id) + .unwrap_or_else(|| "".to_string()); + if let Some(expiry) = status.refresh_token_expires_at { + details.push(format!("session valid until: {}", format_unix_timestamp(expiry))); + } + if has_api_key { + details.push("API key also configured".to_string()); + } + ( + DoctorCheck { + section: SECTION_ACCOUNT, + name, + status: DoctorStatus::Passed, + summary: format!("logged in as {}", who), + details, + remediation: None, + }, + true, + ) + } + AuthState::RefreshExpired => ( + DoctorCheck { + section: SECTION_ACCOUNT, + name, + status: DoctorStatus::Failed, + summary: + "login session expired -- cloud sync is silently disabled until you log in again" + .to_string(), + details, + remediation: Some("run `autter login` to re-authenticate".to_string()), + }, + has_api_key, + ), + AuthState::Error(err) => ( + DoctorCheck { + section: SECTION_ACCOUNT, + name, + status: DoctorStatus::Failed, + summary: format!("could not read stored credentials: {}", err), + details, + remediation: Some("run `autter login` to re-authenticate".to_string()), + }, + has_api_key, + ), + AuthState::LoggedOut if has_api_key => ( + DoctorCheck { + section: SECTION_ACCOUNT, + name, + status: DoctorStatus::Passed, + summary: "authenticated via configured API key".to_string(), + details, + remediation: None, + }, + true, + ), + AuthState::LoggedOut => ( + DoctorCheck { + section: SECTION_ACCOUNT, + name, + status: DoctorStatus::Warning, + summary: "not logged in -- attribution is captured locally but never syncs" + .to_string(), + details, + remediation: Some( + "run `autter login` to enable cloud sync (ignore this if you use autter locally only)" + .to_string(), + ), + }, + false, + ), + } +} + +/// Probe the API base URL. Any HTTP response (including 4xx/5xx) proves the +/// service is reachable; only transport failures (DNS, TLS, proxy, firewall) +/// fail the check. +fn check_connectivity(authenticated: bool) -> DoctorCheck { + let name = "cloud connectivity".to_string(); + let base_url = Config::get() + .api_base_url() + .trim_end_matches('/') + .to_string(); + + if !authenticated { + return skipped_check( + SECTION_ACCOUNT, + "cloud connectivity", + "not authenticated -- skipping the API reachability probe", + ); + } + + let url = format!("{}{}", base_url, CONNECTIVITY_PROBE_ENDPOINT); + let agent = crate::http::build_agent(Some(CONNECTIVITY_TIMEOUT_SECS)); + match crate::http::send(agent.get(&url)) { + Ok(response) => DoctorCheck { + section: SECTION_ACCOUNT, + name, + status: DoctorStatus::Passed, + summary: format!("API reachable (HTTP {})", response.status_code), + details: vec![format!("url: {}", url)], + remediation: None, + }, + Err(err) => DoctorCheck { + section: SECTION_ACCOUNT, + name, + status: DoctorStatus::Failed, + summary: "could not reach the autter API -- sync and uploads will fail".to_string(), + details: vec![format!("url: {}", url), format!("error: {}", err)], + remediation: Some(format!( + "check network/proxy/firewall access to {}, then re-run `autter doctor`", + base_url + )), + }, + } +} + +// =========================================================================== +// Plumbing +// =========================================================================== + +fn check_from_diagnostic( + section: &'static str, + name: &str, + result: DiagnosticCheckResult, +) -> DoctorCheck { + let status = match result.status { + DiagnosticStatus::Passed => DoctorStatus::Passed, + DiagnosticStatus::Failed => DoctorStatus::Failed, + DiagnosticStatus::Skipped => DoctorStatus::Skipped, + }; + DoctorCheck { + section, + name: name.to_string(), + status, + summary: result.summary, + details: result.details, + remediation: result.remediation, + } +} + +fn skipped_check( + section: &'static str, + name: impl Into, + summary: impl Into, +) -> DoctorCheck { + DoctorCheck { + section, + name: name.into(), + status: DoctorStatus::Skipped, + summary: summary.into(), + details: Vec::new(), + remediation: None, + } +} + +fn version_string() -> String { + if cfg!(debug_assertions) { + format!("{} (debug)", env!("CARGO_PKG_VERSION")) + } else { + env!("CARGO_PKG_VERSION").to_string() + } +} + +fn summarize(checks: &[DoctorCheck]) -> DoctorSummary { + let mut summary = DoctorSummary { + passed: 0, + warnings: 0, + failed: 0, + skipped: 0, + }; + for check in checks { + match check.status { + DoctorStatus::Passed => summary.passed += 1, + DoctorStatus::Warning => summary.warnings += 1, + DoctorStatus::Failed => summary.failed += 1, + DoctorStatus::Skipped => summary.skipped += 1, + } + } + summary +} + +/// Streams human output check-by-check as results come in (the slow checks +/// poll for a few seconds), or collects everything for a single-line JSON +/// document at the end. +struct Reporter { + json: bool, + current_section: Option<&'static str>, + checks: Vec, +} + +impl Reporter { + fn new(json: bool) -> Self { + Self { + json, + current_section: None, + checks: Vec::new(), + } + } + + fn add(&mut self, check: DoctorCheck) { + if !self.json { + if self.current_section != Some(check.section) { + println!(); + println!("{}", check.section); + self.current_section = Some(check.section); + } + print_human_check(&check); + } + self.checks.push(check); + } + + fn finish(self) -> DoctorOutput { + let summary = summarize(&self.checks); + let output = DoctorOutput { + autter_version: version_string(), + checks: self.checks, + summary, + ok: summary.failed == 0, + }; + + if self.json { + match serde_json::to_string(&output) { + Ok(serialized) => println!("{}", serialized), + Err(err) => { + eprintln!("Error: failed to serialize doctor report: {}", err); + std::process::exit(crate::commands::EXIT_RUNTIME_ERROR); + } + } + return output; + } + + println!(); + let mut parts = vec![arg_parser::paint( + "1;32", + &format!("{} passed", summary.passed), + )]; + if summary.warnings > 0 { + let label = if summary.warnings == 1 { + "warning" + } else { + "warnings" + }; + parts.push(arg_parser::paint( + "1;33", + &format!("{} {}", summary.warnings, label), + )); + } + if summary.failed > 0 { + parts.push(arg_parser::paint( + "1;31", + &format!("{} failed", summary.failed), + )); + } + if summary.skipped > 0 { + parts.push(arg_parser::paint( + "90", + &format!("{} skipped", summary.skipped), + )); + } + println!("Summary: {}", parts.join(", ")); + + if summary.failed > 0 { + println!("Fixes are listed next to each failed check above."); + println!("For a full support report to share, run: autter debug"); + } else if summary.warnings > 0 { + println!("No failures. Review the warnings above if capture or sync seems off."); + } else { + println!("All checks passed -- autter is capturing and attributing correctly."); + } + + output + } +} + +fn print_human_check(check: &DoctorCheck) { + let (symbol, color) = match check.status { + DoctorStatus::Passed => ("✓", "1;32"), + DoctorStatus::Warning => ("⚠", "1;33"), + DoctorStatus::Failed => ("✗", "1;31"), + DoctorStatus::Skipped => ("-", "90"), + }; + println!( + " {} {}: {}", + arg_parser::paint(color, symbol), + check.name, + check.summary + ); + if check.status != DoctorStatus::Passed + && let Some(remediation) = &check.remediation + { + println!(" fix: {}", remediation); + } + if matches!(check.status, DoctorStatus::Failed | DoctorStatus::Warning) { + for detail in &check.details { + println!(" {}", detail); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_check(status: DoctorStatus) -> DoctorCheck { + DoctorCheck { + section: SECTION_SETUP, + name: "example".to_string(), + status, + summary: "summary".to_string(), + details: Vec::new(), + remediation: None, + } + } + + #[test] + fn summarize_counts_each_status() { + let checks = vec![ + make_check(DoctorStatus::Passed), + make_check(DoctorStatus::Passed), + make_check(DoctorStatus::Warning), + make_check(DoctorStatus::Failed), + make_check(DoctorStatus::Skipped), + ]; + let summary = summarize(&checks); + assert_eq!(summary.passed, 2); + assert_eq!(summary.warnings, 1); + assert_eq!(summary.failed, 1); + assert_eq!(summary.skipped, 1); + } + + #[test] + fn diagnostic_statuses_map_to_doctor_statuses() { + let diagnostic = DiagnosticCheckResult { + status: DiagnosticStatus::Failed, + summary: "broken".to_string(), + details: vec!["detail".to_string()], + commands: Vec::new(), + trace2_json: None, + remediation: Some("fix it".to_string()), + }; + let check = check_from_diagnostic(SECTION_SERVICE, "background service", diagnostic); + assert_eq!(check.status, DoctorStatus::Failed); + assert_eq!(check.summary, "broken"); + assert_eq!(check.remediation.as_deref(), Some("fix it")); + + let skipped = DiagnosticCheckResult { + status: DiagnosticStatus::Skipped, + summary: "skipped".to_string(), + details: Vec::new(), + commands: Vec::new(), + trace2_json: None, + remediation: None, + }; + assert_eq!( + check_from_diagnostic(SECTION_TRACE2, "x", skipped).status, + DoctorStatus::Skipped + ); + } + + #[test] + fn json_output_shape_is_stable() { + let checks = vec![make_check(DoctorStatus::Passed), { + let mut failed = make_check(DoctorStatus::Failed); + failed.remediation = Some("do the thing".to_string()); + failed + }]; + let summary = summarize(&checks); + let output = DoctorOutput { + autter_version: "test".to_string(), + checks, + summary, + ok: summary.failed == 0, + }; + let value: serde_json::Value = + serde_json::from_str(&serde_json::to_string(&output).unwrap()).unwrap(); + assert!(!value["ok"].as_bool().unwrap()); + assert_eq!(value["summary"]["passed"], 1); + assert_eq!(value["summary"]["failed"], 1); + assert_eq!(value["checks"][0]["status"], "passed"); + assert_eq!(value["checks"][1]["status"], "failed"); + assert_eq!(value["checks"][1]["remediation"], "do the thing"); + // Passed checks with no remediation omit the key entirely. + assert!(value["checks"][0].get("remediation").is_none()); + } +} diff --git a/src/commands/git_handlers.rs b/src/commands/git_handlers.rs index ad425a8..5f72bab 100644 --- a/src/commands/git_handlers.rs +++ b/src/commands/git_handlers.rs @@ -676,7 +676,7 @@ fn proxy_to_git( // Only nudge humans: scripts and tools parsing proxied git stderr // must see git's output unchanged. if std::io::IsTerminal::is_terminal(&std::io::stderr()) { - crate::commands::suggest_autter_debug(); + crate::commands::suggest_autter_doctor(); } std::process::exit(1); } @@ -705,7 +705,7 @@ fn proxy_to_git( // Only nudge humans: scripts and tools parsing proxied git stderr // must see git's output unchanged. if std::io::IsTerminal::is_terminal(&std::io::stderr()) { - crate::commands::suggest_autter_debug(); + crate::commands::suggest_autter_doctor(); } std::process::exit(1); } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 2ae05e4..99a3fd7 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -7,6 +7,7 @@ pub mod config; pub mod daemon; pub mod debug; pub mod diff; +pub mod doctor; pub mod exchange_nonce; pub mod fetch_notes; pub mod file_changes; @@ -51,14 +52,14 @@ pub const EXIT_RUNTIME_ERROR: i32 = 1; /// Usage/argument error in a user-invoked subcommand. See [`EXIT_SUCCESS`]. pub const EXIT_USAGE_ERROR: i32 = 2; -/// Print a one-line stderr pointer to `autter debug`. +/// Print a one-line stderr pointer to `autter doctor`. /// /// Reserved for error paths that usually mean a broken installation /// (unreachable background service, failed hook install, unrunnable git -/// binary) rather than a bad invocation -- `autter debug` runs the -/// self-checks and prints a `fix:` line for each failure. -pub fn suggest_autter_debug() { +/// binary) rather than a bad invocation -- `autter doctor` runs the +/// setup checks and prints a `fix:` line for each failure. +pub fn suggest_autter_doctor() { eprintln!( - "[autter] run `autter debug` to diagnose -- each failed check includes a suggested fix" + "[autter] run `autter doctor` to check your setup -- each failed check includes a suggested fix" ); } diff --git a/src/diagnostics.rs b/src/diagnostics.rs index 3e37801..aee6255 100644 --- a/src/diagnostics.rs +++ b/src/diagnostics.rs @@ -29,14 +29,15 @@ const DEBUG_CHECK_TIMEOUT: Duration = Duration::from_secs(3); const DAEMON_CONTROL_TIMEOUT: Duration = Duration::from_millis(500); const POLL_INTERVAL: Duration = Duration::from_millis(100); -// Remediation strings surfaced as `fix:` lines in the debug report. Keep them -// to concrete next commands a stuck user can run without support. -const REMEDIATION_DAEMON_PATHS: &str = "check that your home directory is set and writable (HOME on macOS/Linux, USERPROFILE on Windows), then re-run `autter debug`"; -const REMEDIATION_DAEMON_FAILED: &str = "run `autter daemon status` for details, then `autter daemon restart`; if it keeps failing, re-run `autter install` and then `autter debug`"; -const REMEDIATION_TRACE2_CONFIG_INSPECT: &str = "check that git runs at all (`git --version`) and that your global git config is readable, then re-run `autter debug`"; -const REMEDIATION_TRACE2_CONFIG_MISSING: &str = "run `autter install` to write the required trace2 settings to your global git config, then re-run `autter debug`"; -const REMEDIATION_ATTRIBUTION: &str = "run `autter daemon restart`, then re-run `autter debug`; if the trace2 config check for this git also failed, run `autter install` first -- attribution depends on it"; -const REMEDIATION_TRACE2_FILE: &str = "check that your global git config is writable and that no GIT_TRACE2* environment variables are set, then re-run `autter debug`; if the trace2 config check also failed, run `autter install`"; +// Remediation strings surfaced as `fix:` lines in the doctor and debug +// reports. Keep them to concrete next commands a stuck user can run without +// support. +const REMEDIATION_DAEMON_PATHS: &str = "check that your home directory is set and writable (HOME on macOS/Linux, USERPROFILE on Windows), then re-run `autter doctor`"; +const REMEDIATION_DAEMON_FAILED: &str = "run `autter daemon status` for details, then `autter daemon restart`; if it keeps failing, re-run `autter install` and then `autter doctor`"; +const REMEDIATION_TRACE2_CONFIG_INSPECT: &str = "check that git runs at all (`git --version`) and that your global git config is readable, then re-run `autter doctor`"; +const REMEDIATION_TRACE2_CONFIG_MISSING: &str = "run `autter install` to write the required trace2 settings to your global git config, then re-run `autter doctor`"; +const REMEDIATION_ATTRIBUTION: &str = "run `autter daemon restart`, then re-run `autter doctor`; if the trace2 config check for this git also failed, run `autter install` first -- attribution depends on it"; +const REMEDIATION_TRACE2_FILE: &str = "check that your global git config is writable and that no GIT_TRACE2* environment variables are set, then re-run `autter doctor`; if the trace2 config check also failed, run `autter install`"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DiagnosticStatus { diff --git a/tests/integration/doctor.rs b/tests/integration/doctor.rs new file mode 100644 index 0000000..59e5811 --- /dev/null +++ b/tests/integration/doctor.rs @@ -0,0 +1,163 @@ +//! Integration tests for `autter doctor`. +//! +//! The test harness wires trace2 into the per-test daemon via the +//! `GIT_TRACE2_EVENT` environment variable, while doctor validates the +//! production wiring (global git config). So the end-to-end test first writes +//! the trace2 keys `autter install` would write -- pointed at this test's +//! daemon socket -- and then expects the full checkpoint round-trip to pass. + +use crate::repos::test_repo::TestRepo; +use autter::daemon::DaemonConfig; + +/// Write the trace2 global config `autter install` sets up in production, +/// targeting this test repo's isolated daemon socket. +fn write_trace2_global_config(repo: &TestRepo) { + let target = DaemonConfig::trace2_event_target_for_path(&repo.daemon_trace_socket_path()); + repo.git_og(&["config", "--global", "trace2.eventTarget", &target]) + .expect("write trace2.eventTarget"); + repo.git_og(&["config", "--global", "trace2.eventNesting", "10"]) + .expect("write trace2.eventNesting"); +} + +/// Doctor prints the JSON report as a single stdout line; the combined +/// stdout/stderr capture may carry other noise around it. +fn parse_doctor_json(raw: &str) -> serde_json::Value { + raw.lines() + .filter_map(|line| { + let trimmed = line.trim(); + if trimmed.starts_with('{') { + serde_json::from_str(trimmed).ok() + } else { + None + } + }) + .next_back() + .unwrap_or_else(|| panic!("no JSON object line in doctor output:\n{}", raw)) +} + +fn check<'a>(report: &'a serde_json::Value, name: &str) -> &'a serde_json::Value { + report["checks"] + .as_array() + .expect("checks array") + .iter() + .find(|c| c["name"] == name) + .unwrap_or_else(|| panic!("missing check '{}' in report:\n{}", name, report)) +} + +#[test] +fn doctor_validates_checkpoint_round_trip_end_to_end() { + let repo = TestRepo::new(); + write_trace2_global_config(&repo); + + let (raw, exited_zero) = match repo.autter(&["doctor", "--json"]) { + Ok(output) => (output, true), + Err(output) => (output, false), + }; + let report = parse_doctor_json(&raw); + + // Every expected check is present. + for name in [ + "git version", + "configuration file", + "repository", + "background service", + "trace2 config (configured git)", + "trace2 config (terminal git)", + "trace2 event capture", + "checkpoint round-trip", + "authentication", + "cloud connectivity", + ] { + check(&report, name); + } + + // The environment-independent core: setup, service, capture wiring, and a + // real checkpoint event flowing checkpoint -> daemon -> commit -> blame. + assert_eq!(check(&report, "git version")["status"], "passed", "{}", raw); + assert_eq!( + check(&report, "configuration file")["status"], + "passed", + "{}", + raw + ); + assert_eq!(check(&report, "repository")["status"], "passed", "{}", raw); + assert_eq!( + check(&report, "background service")["status"], + "passed", + "{}", + raw + ); + assert_eq!( + check(&report, "trace2 config (configured git)")["status"], + "passed", + "{}", + raw + ); + assert_eq!( + check(&report, "checkpoint round-trip")["status"], + "passed", + "{}", + raw + ); + + // Tests run logged out: auth must not hard-fail, and the connectivity + // probe must be skipped so tests never touch the network. + assert_ne!( + check(&report, "authentication")["status"], + "failed", + "{}", + raw + ); + assert_eq!( + check(&report, "cloud connectivity")["status"], + "skipped", + "{}", + raw + ); + + // Summary bookkeeping and exit code track the failed count. (Agent-hook + // checks may legitimately fail on developer machines with agents + // installed outside the isolated test HOME, so overall success is not + // asserted -- only consistency.) + let checks = report["checks"].as_array().unwrap(); + let failed = checks.iter().filter(|c| c["status"] == "failed").count() as u64; + assert_eq!( + report["summary"]["failed"].as_u64().unwrap(), + failed, + "{}", + raw + ); + assert_eq!(report["ok"].as_bool().unwrap(), failed == 0, "{}", raw); + assert_eq!(exited_zero, failed == 0, "{}", raw); +} + +#[test] +fn doctor_rejects_unknown_arguments() { + let repo = TestRepo::new(); + let err = repo + .autter(&["doctor", "--definitely-not-a-flag"]) + .expect_err("unknown doctor argument should fail"); + assert!( + err.contains("unknown doctor argument"), + "unexpected error output: {}", + err + ); +} + +#[test] +fn doctor_skip_trace2_flag_skips_event_capture_check() { + let repo = TestRepo::new(); + write_trace2_global_config(&repo); + + let raw = match repo.autter(&["doctor", "--json", "--skip-trace2-checks"]) { + Ok(output) => output, + Err(output) => output, + }; + let report = parse_doctor_json(&raw); + assert_eq!( + check(&report, "trace2 event capture")["status"], + "skipped", + "{}", + raw + ); +} diff --git a/tests/integration/main.rs b/tests/integration/main.rs index 21d7f05..275804f 100644 --- a/tests/integration/main.rs +++ b/tests/integration/main.rs @@ -51,6 +51,7 @@ mod daemon_unit; mod diff; mod diff_comprehensive; mod diff_ignore_binary; +mod doctor; mod droid; mod e2big_post_filter; mod e2e_user_scenarios;