use anyhow::{Context, Result};
use serde::Serialize;
use crate::db;
pub(in crate::cli) fn run_status(json: bool) -> Result<()> {
let report = load_status_report()?;
if json {
println!("{}", serde_json::to_string_pretty(&report)?);
return Ok(());
}
print_status_report(&report);
Ok(())
}
fn load_status_report() -> Result<StatusReport> {
let conn = db::open_db()?;
let db_path = db::db_path();
let db_size = std::fs::metadata(&db_path)
.with_context(|| format!("failed to stat database path {}", db_path.display()))?
.len();
let version = crate::build_info::version_label();
let stats = db::query_system_stats(&conn)?;
let today_start = chrono::Local::now()
.date_naive()
.and_hms_opt(0, 0, 0)
.map(|dt| dt.and_utc().timestamp())
.unwrap_or(0);
let daily_stats = db::query_daily_activity_stats(&conn, today_start)?;
let top_projects = db::query_top_projects(&conn, 5)?;
let now = chrono::Utc::now().timestamp();
Ok(StatusReport {
version,
database: StatusDatabase {
path: db_path.display().to_string(),
size_bytes: db_size,
size_mb: db_size as f64 / 1_048_576.0,
},
totals: StatusTotals {
memories: stats.active_memories,
observations: stats.active_observations,
sessions: stats.session_summaries,
raw_messages: stats.raw_messages,
},
capture_pipeline: CapturePipelineStatus {
captured: stats.captured_events,
extract_todo: stats.pending_extraction_tasks,
extract_running: stats.processing_extraction_tasks,
extract_failed: stats.failed_extraction_tasks,
pending_candidates: stats.pending_memory_candidates,
oldest_task_epoch: stats.oldest_pending_extraction_epoch,
oldest_task_age_secs: stats
.oldest_pending_extraction_epoch
.map(|epoch| now.saturating_sub(epoch)),
},
pending_observations: PendingObservationStatus {
ready: stats.ready_pending_observations,
delayed: stats.delayed_pending_observations,
processing: stats.processing_pending_observations,
expired: stats.expired_processing_pending_observations,
failed: stats.failed_pending_observations,
oldest_ready_epoch: stats.oldest_ready_pending_epoch,
oldest_ready_age_secs: stats
.oldest_ready_pending_epoch
.map(|epoch| now.saturating_sub(epoch)),
},
jobs: JobStatus {
pending: stats.pending_jobs,
processing: stats.processing_jobs,
failed: stats.failed_jobs,
stuck: stats.stuck_jobs,
},
worker_daemon: WorkerDaemonStatus {
health: worker_health_tag(stats.worker_daemon_healthy, stats.worker_heartbeat_age_secs)
.to_string(),
heartbeat_age_secs: stats.worker_heartbeat_age_secs,
owner: stats.worker_heartbeat_owner,
},
today: DailyStatus {
new_memories: daily_stats.memories,
new_observations: daily_stats.observations,
},
top_projects: top_projects
.into_iter()
.map(|project| TopProjectStatus {
project: project.project,
count: project.count,
})
.collect(),
})
}
fn print_status_report(report: &StatusReport) {
println!("remem v{}", report.version);
println!(
"Database: {} ({:.1} MB)",
report.database.path, report.database.size_mb
);
println!();
println!(" Memories: {:>6}", report.totals.memories);
println!(" Observations: {:>6}", report.totals.observations);
println!(" Sessions: {:>6}", report.totals.sessions);
println!(" Raw messages: {:>6}", report.totals.raw_messages);
println!();
println!("Capture pipeline:");
println!(" Captured: {:>6}", report.capture_pipeline.captured);
println!(
" Extract todo: {:>6}",
report.capture_pipeline.extract_todo
);
println!(
" Extract run: {:>6}",
report.capture_pipeline.extract_running
);
println!(
" Extract fail: {:>6}",
report.capture_pipeline.extract_failed
);
println!(
" Candidates: {:>6}",
report.capture_pipeline.pending_candidates
);
if let Some(age_secs) = report.capture_pipeline.oldest_task_age_secs {
println!(" Oldest task: {:>6}s", age_secs);
}
println!();
println!("Pending observations:");
println!(" Ready: {:>6}", report.pending_observations.ready);
println!(" Delayed: {:>6}", report.pending_observations.delayed);
println!(
" Processing: {:>6}",
report.pending_observations.processing
);
println!(" Expired: {:>6}", report.pending_observations.expired);
println!(" Failed: {:>6}", report.pending_observations.failed);
if let Some(age_secs) = report.pending_observations.oldest_ready_age_secs {
println!(" Oldest ready: {:>6}s", age_secs);
}
println!();
println!("Jobs:");
println!(" Pending: {:>6}", report.jobs.pending);
println!(" Processing: {:>6}", report.jobs.processing);
println!(" Failed: {:>6}", report.jobs.failed);
println!(" Stuck: {:>6}", report.jobs.stuck);
println!();
println!("Worker daemon:");
println!(" Health: {:>7}", report.worker_daemon.health);
if let Some(age_secs) = report.worker_daemon.heartbeat_age_secs {
println!(" Last beat: {:>6}s", age_secs);
}
if let Some(owner) = &report.worker_daemon.owner {
println!(" Owner: {}", owner);
}
println!();
println!("Today:");
println!(" New memories: {:>4}", report.today.new_memories);
println!(" New observations: {:>4}", report.today.new_observations);
if !report.top_projects.is_empty() {
println!();
println!("Top projects:");
for project in &report.top_projects {
println!(" {:>4} {}", project.count, project.project);
}
}
}
fn worker_health_tag(healthy: bool, heartbeat_age_secs: Option<i64>) -> &'static str {
if healthy {
"healthy"
} else if heartbeat_age_secs.is_some() {
"stale"
} else {
"missing"
}
}
#[derive(Debug, Clone, Serialize)]
pub(super) struct StatusReport {
pub version: String,
pub database: StatusDatabase,
pub totals: StatusTotals,
pub capture_pipeline: CapturePipelineStatus,
pub pending_observations: PendingObservationStatus,
pub jobs: JobStatus,
pub worker_daemon: WorkerDaemonStatus,
pub today: DailyStatus,
pub top_projects: Vec<TopProjectStatus>,
}
#[derive(Debug, Clone, Serialize)]
pub(super) struct StatusDatabase {
pub path: String,
pub size_bytes: u64,
pub size_mb: f64,
}
#[derive(Debug, Clone, Serialize)]
pub(super) struct StatusTotals {
pub memories: i64,
pub observations: i64,
pub sessions: i64,
pub raw_messages: i64,
}
#[derive(Debug, Clone, Serialize)]
pub(super) struct CapturePipelineStatus {
pub captured: i64,
pub extract_todo: i64,
pub extract_running: i64,
pub extract_failed: i64,
pub pending_candidates: i64,
pub oldest_task_epoch: Option<i64>,
pub oldest_task_age_secs: Option<i64>,
}
#[derive(Debug, Clone, Serialize)]
pub(super) struct PendingObservationStatus {
pub ready: i64,
pub delayed: i64,
pub processing: i64,
pub expired: i64,
pub failed: i64,
pub oldest_ready_epoch: Option<i64>,
pub oldest_ready_age_secs: Option<i64>,
}
#[derive(Debug, Clone, Serialize)]
pub(super) struct JobStatus {
pub pending: i64,
pub processing: i64,
pub failed: i64,
pub stuck: i64,
}
#[derive(Debug, Clone, Serialize)]
pub(super) struct WorkerDaemonStatus {
pub health: String,
pub heartbeat_age_secs: Option<i64>,
pub owner: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub(super) struct DailyStatus {
pub new_memories: i64,
pub new_observations: i64,
}
#[derive(Debug, Clone, Serialize)]
pub(super) struct TopProjectStatus {
pub project: String,
pub count: i64,
}
#[cfg(test)]
mod tests {
use serde_json::Value;
use super::*;
#[test]
fn cli_status_json_report_is_machine_parseable() -> std::result::Result<(), serde_json::Error> {
let report = StatusReport {
version: "0.4.5".to_string(),
database: StatusDatabase {
path: "/tmp/remem-test".to_string(),
size_bytes: 1_048_576,
size_mb: 1.0,
},
totals: StatusTotals {
memories: 1,
observations: 2,
sessions: 3,
raw_messages: 4,
},
capture_pipeline: CapturePipelineStatus {
captured: 5,
extract_todo: 6,
extract_running: 7,
extract_failed: 8,
pending_candidates: 9,
oldest_task_epoch: Some(10),
oldest_task_age_secs: Some(11),
},
pending_observations: PendingObservationStatus {
ready: 12,
delayed: 13,
processing: 14,
expired: 15,
failed: 16,
oldest_ready_epoch: Some(17),
oldest_ready_age_secs: Some(18),
},
jobs: JobStatus {
pending: 19,
processing: 20,
failed: 21,
stuck: 22,
},
worker_daemon: WorkerDaemonStatus {
health: "healthy".to_string(),
heartbeat_age_secs: Some(23),
owner: Some("worker-1".to_string()),
},
today: DailyStatus {
new_memories: 24,
new_observations: 25,
},
top_projects: vec![TopProjectStatus {
project: "proj".to_string(),
count: 26,
}],
};
let text = serde_json::to_string(&report)?;
let parsed: Value = serde_json::from_str(&text)?;
assert_eq!(parsed["version"], "0.4.5");
assert_eq!(parsed["database"]["size_bytes"], 1_048_576);
assert_eq!(parsed["totals"]["memories"], 1);
assert_eq!(parsed["capture_pipeline"]["extract_todo"], 6);
assert_eq!(parsed["pending_observations"]["failed"], 16);
assert_eq!(parsed["worker_daemon"]["health"], "healthy");
assert_eq!(parsed["top_projects"][0]["project"], "proj");
Ok(())
}
}