openlatch-client 0.0.1

The open-source security layer for AI agents — client forwarder
Documentation
/// `openlatch status` command handler.
///
/// Displays daemon status in a compact dashboard format (D-06 when running, D-07 when stopped).
/// All path references use `config::openlatch_dir()` per PLAT-02.
///
/// SECURITY (T-02-09): Never include token value in status output.
use crate::cli::commands::lifecycle;
use crate::cli::output::{OutputConfig, OutputFormat};
use crate::config;
use crate::error::OlError;

/// Run the `openlatch status` command.
///
/// Per D-06: compact dashboard when running.
/// Per D-07: stopped format with last-seen info when daemon is not running.
///
/// # Errors
///
/// Returns an error if config cannot be loaded.
pub fn run_status(output: &OutputConfig) -> Result<(), OlError> {
    let version = env!("CARGO_PKG_VERSION");
    let cfg = config::Config::load(None, None, false)?;

    // Check if daemon is running
    let pid_opt = lifecycle::read_pid_file();
    let is_running = pid_opt.map(lifecycle::is_process_alive).unwrap_or(false);

    if is_running {
        let pid = pid_opt.unwrap();
        show_running_status(version, cfg.port, pid, output)?;
    } else {
        show_stopped_status(version, output)?;
    }

    Ok(())
}

/// Display compact dashboard when daemon is running (D-06).
fn show_running_status(
    version: &str,
    port: u16,
    pid: u32,
    output: &OutputConfig,
) -> Result<(), OlError> {
    // Try to get metrics from daemon
    let health = fetch_health(port);
    let metrics = fetch_metrics(port);

    let uptime_str = health
        .as_ref()
        .and_then(|h| h.get("uptime_secs"))
        .and_then(|v| v.as_u64())
        .map(format_uptime)
        .unwrap_or_else(|| "unknown".to_string());

    let agent_str = health
        .as_ref()
        .and_then(|h| h.get("agent"))
        .and_then(|v| v.as_str())
        .unwrap_or("Claude Code")
        .to_string();

    let update_available = metrics
        .as_ref()
        .and_then(|m| m.get("update_available"))
        .and_then(|v| v.as_str())
        .map(|s| s.to_string());

    let total_events = metrics
        .as_ref()
        .and_then(|m| m.get("events_processed"))
        .and_then(|v| v.as_u64())
        .unwrap_or(0);

    // PLAT-02: Use config::openlatch_dir() for all path access
    let log_path = config::openlatch_dir().join("logs").join(format!(
        "events-{}.jsonl",
        chrono::Local::now().format("%Y-%m-%d")
    ));

    if output.format == OutputFormat::Json {
        // SECURITY (T-02-09): No token in output
        let json = serde_json::json!({
            "status": "running",
            "version": version,
            "port": port,
            "pid": pid,
            "uptime": uptime_str,
            "events": total_events,
            "log_path": log_path.to_string_lossy(),
            "update_available": update_available,
        });
        output.print_json(&json);
    } else if !output.quiet {
        eprintln!("openlatch v{version}  running  uptime {uptime_str}");
        eprintln!("  Port:     {port}");
        eprintln!("  Agent:    {agent_str}");
        eprintln!("  Events:   {total_events}");
        eprintln!("  Log:      {}", log_path.display());
        // UPDT-03: Show update suggestion when newer version is available
        if let Some(ref latest) = update_available {
            eprintln!("  Update:   v{latest} available (run: npx openlatch@latest)");
        }
    }

    Ok(())
}

/// Display stopped status with last-seen info (D-07).
fn show_stopped_status(version: &str, output: &OutputConfig) -> Result<(), OlError> {
    // Try to read last shutdown from daemon.log
    let (last_seen, last_events) = read_last_session_info();

    if output.format == OutputFormat::Json {
        let json = serde_json::json!({
            "status": "stopped",
            "version": version,
            "last_seen": last_seen,
            "last_session_events": last_events,
        });
        output.print_json(&json);
    } else if !output.quiet {
        eprintln!("openlatch v{version}  stopped");
        if let Some(ref ts) = last_seen {
            let relative = format_relative_time(ts);
            eprintln!("  Last seen:  {ts} ({relative} ago)");
        } else {
            eprintln!("  Last seen:  never");
        }
        eprintln!("  Events:     {last_events} in last session");
        eprintln!("  Suggestion: Run 'openlatch start' to resume");
    }

    Ok(())
}

/// Fetch health data from the daemon's /health endpoint.
fn fetch_health(port: u16) -> Option<serde_json::Value> {
    let url = format!("http://127.0.0.1:{port}/health");
    let client = reqwest::blocking::Client::builder()
        .timeout(std::time::Duration::from_secs(2))
        .build()
        .ok()?;
    let resp = client.get(&url).send().ok()?;
    if resp.status().is_success() {
        resp.json().ok()
    } else {
        None
    }
}

/// Fetch metrics from the daemon's /metrics endpoint.
fn fetch_metrics(port: u16) -> Option<serde_json::Value> {
    let url = format!("http://127.0.0.1:{port}/metrics");
    let client = reqwest::blocking::Client::builder()
        .timeout(std::time::Duration::from_secs(2))
        .build()
        .ok()?;
    let resp = client.get(&url).send().ok()?;
    if resp.status().is_success() {
        resp.json().ok()
    } else {
        None
    }
}

/// Read the last session info from daemon.log.
///
/// Returns (last_seen_timestamp, event_count).
fn read_last_session_info() -> (Option<String>, u64) {
    // PLAT-02: Use config::openlatch_dir() for path access
    let log_path = config::openlatch_dir().join("daemon.log");
    let Ok(content) = std::fs::read_to_string(&log_path) else {
        return (None, 0);
    };

    let mut last_seen = None;
    let mut last_events = 0u64;

    // Look for shutdown log entry with event count
    for line in content.lines().rev() {
        if let Ok(entry) = serde_json::from_str::<serde_json::Value>(line) {
            if entry
                .get("message")
                .and_then(|m| m.as_str())
                .map(|m| m.contains("daemon stopped") || m.contains("shutdown"))
                .unwrap_or(false)
            {
                last_seen = entry
                    .get("timestamp")
                    .and_then(|t| t.as_str())
                    .map(|s| s.to_string());
                last_events = entry.get("events").and_then(|e| e.as_u64()).unwrap_or(0);
                break;
            }
        }
    }

    (last_seen, last_events)
}

/// Format uptime in seconds to a human-readable string.
fn format_uptime(secs: u64) -> String {
    if secs < 60 {
        format!("{secs}s")
    } else if secs < 3600 {
        format!("{}m {}s", secs / 60, secs % 60)
    } else {
        format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
    }
}

/// Format a timestamp string as a relative time ("5 minutes").
fn format_relative_time(ts: &str) -> String {
    // Parse RFC 3339 or ISO 8601 timestamp
    if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(ts) {
        let now = chrono::Utc::now();
        let duration = now.signed_duration_since(dt.with_timezone(&chrono::Utc));
        let secs = duration.num_seconds();
        if secs < 60 {
            return format!("{secs} seconds");
        } else if secs < 3600 {
            return format!("{} minutes", secs / 60);
        } else if secs < 86400 {
            return format!("{} hours", secs / 3600);
        } else {
            return format!("{} days", secs / 86400);
        }
    }
    "unknown time".to_string()
}