use crate::cli::commands::lifecycle;
use crate::cli::output::{OutputConfig, OutputFormat};
use crate::config;
use crate::error::OlError;
pub fn run_status(output: &OutputConfig) -> Result<(), OlError> {
let version = env!("CARGO_PKG_VERSION");
let cfg = config::Config::load(None, None, false)?;
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, &cfg.cloud.api_url)?;
} else {
show_stopped_status(version, output)?;
}
Ok(())
}
fn show_running_status(
version: &str,
port: u16,
pid: u32,
output: &OutputConfig,
cloud_api_url: &str,
) -> Result<(), OlError> {
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);
let log_path = config::openlatch_dir().join("logs").join(format!(
"events-{}.jsonl",
chrono::Local::now().format("%Y-%m-%d")
));
let cloud_status_daemon = metrics
.as_ref()
.and_then(|m| m.get("cloud_status"))
.and_then(|v| v.as_str())
.unwrap_or("not_configured")
.to_string();
let cloud_forwarded_count = metrics
.as_ref()
.and_then(|m| m.get("cloud_forwarded_count"))
.and_then(|v| v.as_u64())
.unwrap_or(0);
let cloud_last_sync_secs = metrics
.as_ref()
.and_then(|m| m.get("cloud_last_sync_secs"))
.and_then(|v| v.as_u64())
.unwrap_or(0);
let live_cloud_status = if cloud_status_daemon == "not_configured"
&& check_credential_configured() == "not_configured"
{
"not_configured".to_string()
} else {
probe_cloud_status(cloud_api_url).to_string()
};
let cloud_last_sync_relative = format_relative_secs(cloud_last_sync_secs);
let supervision_line = supervision_summary_line();
if output.format == OutputFormat::Json {
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,
"cloud_status": live_cloud_status,
"cloud_forwarded_count": cloud_forwarded_count,
"cloud_last_sync": cloud_last_sync_relative,
"cloud_api_url": cloud_api_url,
"supervision": supervision_json(),
});
output.print_json(&json);
} else if !output.quiet {
crate::cli::header::print(output, &["running", &format!("uptime {uptime_str}")]);
eprintln!(" Port: {port}");
eprintln!(" Agent: {agent_str}");
eprintln!(" Events: {total_events}");
eprintln!(" Log: {}", log_path.display());
if let Some(ref latest) = update_available {
eprintln!(" Update: v{latest} available (run: npx openlatch@latest)");
}
if live_cloud_status == "not_configured" {
eprintln!(" Cloud: not configured");
eprintln!(" Run 'openlatch auth login' to enable cloud sync");
} else {
eprintln!(" Cloud: {live_cloud_status} ({cloud_api_url})");
eprintln!(
" Synced: {cloud_forwarded_count} events, last {cloud_last_sync_relative}"
);
}
eprintln!(" Supervision: {supervision_line}");
}
Ok(())
}
fn show_stopped_status(version: &str, output: &OutputConfig) -> Result<(), OlError> {
let (last_seen, last_events) = read_last_session_info();
let cloud_status = check_credential_configured();
let supervision_line = supervision_summary_line();
if output.format == OutputFormat::Json {
let json = serde_json::json!({
"status": "stopped",
"version": version,
"last_seen": last_seen,
"last_session_events": last_events,
"cloud_status": cloud_status,
"supervision": supervision_json(),
});
output.print_json(&json);
} else if !output.quiet {
crate::cli::header::print(output, &["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");
if cloud_status == "not_configured" {
eprintln!(" Cloud: not configured");
eprintln!(" Run 'openlatch auth login' to enable cloud sync");
} else {
eprintln!(" Cloud: configured (not running)");
}
eprintln!(" Supervision: {supervision_line}");
}
Ok(())
}
fn supervision_summary_line() -> String {
use crate::supervision::{SupervisionMode, SupervisorKind};
let cfg = match config::Config::load(None, None, false) {
Ok(c) => c,
Err(_) => return "unknown".to_string(),
};
let backend = match cfg.supervision.backend {
SupervisorKind::Launchd => "launchd",
SupervisorKind::Systemd => "systemd",
SupervisorKind::TaskScheduler => "task_scheduler",
SupervisorKind::None => "none",
};
match cfg.supervision.mode {
SupervisionMode::Active => format!("{backend} (active)"),
SupervisionMode::Deferred => {
let reason = cfg.supervision.disabled_reason.unwrap_or_default();
if reason.is_empty() {
format!("{backend} (deferred)")
} else {
format!("{backend} (deferred — {reason})")
}
}
SupervisionMode::Disabled => {
let reason = cfg
.supervision
.disabled_reason
.unwrap_or_else(|| "user_opt_out".to_string());
format!("disabled ({reason})")
}
}
}
fn supervision_json() -> serde_json::Value {
use crate::supervision::{SupervisionMode, SupervisorKind};
let cfg = match config::Config::load(None, None, false) {
Ok(c) => c,
Err(_) => return serde_json::json!(null),
};
let backend = match cfg.supervision.backend {
SupervisorKind::Launchd => "launchd",
SupervisorKind::Systemd => "systemd",
SupervisorKind::TaskScheduler => "task_scheduler",
SupervisorKind::None => "none",
};
let mode = match cfg.supervision.mode {
SupervisionMode::Active => "active",
SupervisionMode::Deferred => "deferred",
SupervisionMode::Disabled => "disabled",
};
serde_json::json!({
"mode": mode,
"backend": backend,
"disabled_reason": cfg.supervision.disabled_reason,
})
}
fn check_credential_configured() -> &'static str {
let store = crate::core::auth::KeyringCredentialStore::new();
let openlatch_dir = config::openlatch_dir();
let agent_id = config::Config::load(None, None, false)
.ok()
.and_then(|c| c.agent_id)
.unwrap_or_default();
let file_store = crate::core::auth::FileCredentialStore::new(
openlatch_dir.join("credentials.enc"),
agent_id,
);
if crate::core::auth::retrieve_credential(
&store as &dyn crate::core::auth::CredentialStore,
&file_store as &dyn crate::core::auth::CredentialStore,
)
.is_ok()
{
"configured"
} else {
"not_configured"
}
}
fn probe_cloud_status(api_url: &str) -> &'static str {
use secrecy::ExposeSecret;
let store = crate::core::auth::KeyringCredentialStore::new();
let openlatch_dir = config::openlatch_dir();
let agent_id = config::Config::load(None, None, false)
.ok()
.and_then(|c| c.agent_id)
.unwrap_or_default();
let file_store = crate::core::auth::FileCredentialStore::new(
openlatch_dir.join("credentials.enc"),
agent_id,
);
let key = match crate::core::auth::retrieve_credential(
&store as &dyn crate::core::auth::CredentialStore,
&file_store as &dyn crate::core::auth::CredentialStore,
) {
Ok(k) => k,
Err(_) => return "not_configured",
};
let client = match reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(3))
.use_rustls_tls()
.build()
{
Ok(c) => c,
Err(_) => return "disconnected",
};
let base = api_url.trim_end_matches('/');
let url = format!("{base}/api/v1/users/me");
let result = client.get(&url).bearer_auth(key.expose_secret()).send();
match result {
Ok(resp) if resp.status().is_success() => "connected",
Ok(resp)
if resp.status() == reqwest::StatusCode::UNAUTHORIZED
|| resp.status() == reqwest::StatusCode::FORBIDDEN =>
{
"auth_error"
}
_ => "disconnected",
}
}
fn format_relative_secs(epoch_secs: u64) -> String {
if epoch_secs == 0 {
return "never".to_string();
}
use std::time::{SystemTime, UNIX_EPOCH};
let now_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let elapsed = now_secs.saturating_sub(epoch_secs);
if elapsed < 60 {
format!("{elapsed}s ago")
} else if elapsed < 3600 {
format!("{}m ago", elapsed / 60)
} else if elapsed < 86400 {
format!("{}h ago", elapsed / 3600)
} else {
format!("{}d ago", elapsed / 86400)
}
}
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))
.use_rustls_tls()
.build()
.ok()?;
let resp = client.get(&url).send().ok()?;
if resp.status().is_success() {
resp.json().ok()
} else {
None
}
}
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))
.use_rustls_tls()
.build()
.ok()?;
let resp = client.get(&url).send().ok()?;
if resp.status().is_success() {
resp.json().ok()
} else {
None
}
}
fn read_last_session_info() -> (Option<String>, u64) {
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;
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)
}
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)
}
}
fn format_relative_time(ts: &str) -> String {
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()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_relative_secs_zero_returns_never() {
assert_eq!(format_relative_secs(0), "never");
}
#[test]
fn test_format_relative_secs_12_returns_12s_ago() {
use std::time::{SystemTime, UNIX_EPOCH};
let now_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let past = now_secs.saturating_sub(12);
assert_eq!(format_relative_secs(past), "12s ago");
}
#[test]
fn test_format_relative_secs_90_returns_1m_ago() {
use std::time::{SystemTime, UNIX_EPOCH};
let now_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let past = now_secs.saturating_sub(90);
assert_eq!(format_relative_secs(past), "1m ago");
}
#[test]
fn test_format_relative_secs_3700_returns_1h_ago() {
use std::time::{SystemTime, UNIX_EPOCH};
let now_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let past = now_secs.saturating_sub(3700);
assert_eq!(format_relative_secs(past), "1h ago");
}
}