Files
gitlore/src/cli/commands/sync_status.rs
Taylor Eernisse cf6d27435a feat(robot): add elapsed_ms timing, --fields support, and actionable error actions
Robot mode consistency improvements across all command output:

Timing:
- Every robot JSON response now includes meta.elapsed_ms measuring
  wall-clock time from command start to serialization. Agents can use
  this to detect slow queries and tune --limit or --project filters.

Field selection (--fields):
- print_list_issues_json and print_list_mrs_json accept an optional
  fields slice that prunes each item in the response array to only
  the requested keys. A "minimal" preset expands to
  [iid, title, state, updated_at_iso] for token-efficient agent scans.
- filter_fields and expand_fields_preset live in the new
  src/cli/robot.rs module alongside RobotMeta.

Actionable error recovery:
- LoreError gains an actions() method returning concrete shell commands
  an agent can execute to recover (e.g. "ollama serve" for
  OllamaUnavailable, "lore init" for ConfigNotFound).
- RobotError now serializes an "actions" array (empty array omitted)
  so agents can parse and offer one-click fixes.

Envelope consistency:
- show issue/MR JSON responses now use the standard
  {"ok":true,"data":...,"meta":...} envelope instead of bare data,
  matching all other commands.

Files: src/cli/robot.rs (new), src/core/error.rs,
       src/cli/commands/{count,embed,generate_docs,ingest,list,show,stats,sync_status}.rs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 23:46:48 -05:00

455 lines
12 KiB
Rust

use console::style;
use rusqlite::Connection;
use serde::Serialize;
use crate::Config;
use crate::cli::robot::RobotMeta;
use crate::core::db::create_connection;
use crate::core::error::Result;
use crate::core::metrics::StageTiming;
use crate::core::paths::get_db_path;
use crate::core::time::{format_full_datetime, ms_to_iso};
const RECENT_RUNS_LIMIT: usize = 10;
#[derive(Debug)]
pub struct SyncRunInfo {
pub id: i64,
pub started_at: i64,
pub finished_at: Option<i64>,
pub status: String,
pub command: String,
pub error: Option<String>,
pub run_id: Option<String>,
pub total_items_processed: i64,
pub total_errors: i64,
pub stages: Option<Vec<StageTiming>>,
}
#[derive(Debug)]
pub struct CursorInfo {
pub project_path: String,
pub resource_type: String,
pub updated_at_cursor: Option<i64>,
pub tie_breaker_id: Option<i64>,
}
#[derive(Debug)]
pub struct DataSummary {
pub issue_count: i64,
pub mr_count: i64,
pub discussion_count: i64,
pub note_count: i64,
pub system_note_count: i64,
}
#[derive(Debug)]
pub struct SyncStatusResult {
pub runs: Vec<SyncRunInfo>,
pub cursors: Vec<CursorInfo>,
pub summary: DataSummary,
}
pub fn run_sync_status(config: &Config) -> Result<SyncStatusResult> {
let db_path = get_db_path(config.storage.db_path.as_deref());
let conn = create_connection(&db_path)?;
let runs = get_recent_sync_runs(&conn, RECENT_RUNS_LIMIT)?;
let cursors = get_cursor_positions(&conn)?;
let summary = get_data_summary(&conn)?;
Ok(SyncStatusResult {
runs,
cursors,
summary,
})
}
fn get_recent_sync_runs(conn: &Connection, limit: usize) -> Result<Vec<SyncRunInfo>> {
let mut stmt = conn.prepare(
"SELECT id, started_at, finished_at, status, command, error,
run_id, total_items_processed, total_errors, metrics_json
FROM sync_runs
ORDER BY started_at DESC
LIMIT ?1",
)?;
let runs: std::result::Result<Vec<_>, _> = stmt
.query_map([limit as i64], |row| {
let metrics_json: Option<String> = row.get(9)?;
let stages: Option<Vec<StageTiming>> =
metrics_json.and_then(|json| serde_json::from_str(&json).ok());
Ok(SyncRunInfo {
id: row.get(0)?,
started_at: row.get(1)?,
finished_at: row.get(2)?,
status: row.get(3)?,
command: row.get(4)?,
error: row.get(5)?,
run_id: row.get(6)?,
total_items_processed: row.get::<_, Option<i64>>(7)?.unwrap_or(0),
total_errors: row.get::<_, Option<i64>>(8)?.unwrap_or(0),
stages,
})
})?
.collect();
Ok(runs?)
}
fn get_cursor_positions(conn: &Connection) -> Result<Vec<CursorInfo>> {
let mut stmt = conn.prepare(
"SELECT p.path_with_namespace, sc.resource_type, sc.updated_at_cursor, sc.tie_breaker_id
FROM sync_cursors sc
JOIN projects p ON sc.project_id = p.id
ORDER BY p.path_with_namespace, sc.resource_type",
)?;
let cursors: std::result::Result<Vec<_>, _> = stmt
.query_map([], |row| {
Ok(CursorInfo {
project_path: row.get(0)?,
resource_type: row.get(1)?,
updated_at_cursor: row.get(2)?,
tie_breaker_id: row.get(3)?,
})
})?
.collect();
Ok(cursors?)
}
fn get_data_summary(conn: &Connection) -> Result<DataSummary> {
let issue_count: i64 = conn
.query_row("SELECT COUNT(*) FROM issues", [], |row| row.get(0))
.unwrap_or(0);
let mr_count: i64 = conn
.query_row("SELECT COUNT(*) FROM merge_requests", [], |row| row.get(0))
.unwrap_or(0);
let discussion_count: i64 = conn
.query_row("SELECT COUNT(*) FROM discussions", [], |row| row.get(0))
.unwrap_or(0);
let (note_count, system_note_count): (i64, i64) = conn
.query_row(
"SELECT COUNT(*), COALESCE(SUM(is_system), 0) FROM notes",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.unwrap_or((0, 0));
Ok(DataSummary {
issue_count,
mr_count,
discussion_count,
note_count,
system_note_count,
})
}
fn format_duration(ms: i64) -> String {
let seconds = ms / 1000;
let minutes = seconds / 60;
let hours = minutes / 60;
if hours > 0 {
format!("{}h {}m {}s", hours, minutes % 60, seconds % 60)
} else if minutes > 0 {
format!("{}m {}s", minutes, seconds % 60)
} else if ms >= 1000 {
format!("{:.1}s", ms as f64 / 1000.0)
} else {
format!("{}ms", ms)
}
}
fn format_number(n: i64) -> String {
let is_negative = n < 0;
let abs_n = n.unsigned_abs();
let s = abs_n.to_string();
let chars: Vec<char> = s.chars().collect();
let mut result = String::new();
if is_negative {
result.push('-');
}
for (i, c) in chars.iter().enumerate() {
if i > 0 && (chars.len() - i).is_multiple_of(3) {
result.push(',');
}
result.push(*c);
}
result
}
#[derive(Serialize)]
struct SyncStatusJsonOutput {
ok: bool,
data: SyncStatusJsonData,
meta: RobotMeta,
}
#[derive(Serialize)]
struct SyncStatusJsonData {
runs: Vec<SyncRunJsonInfo>,
cursors: Vec<CursorJsonInfo>,
summary: SummaryJsonInfo,
}
#[derive(Serialize)]
struct SyncRunJsonInfo {
id: i64,
status: String,
command: String,
started_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
completed_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
duration_ms: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
run_id: Option<String>,
total_items_processed: i64,
total_errors: i64,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
stages: Option<Vec<StageTiming>>,
}
#[derive(Serialize)]
struct CursorJsonInfo {
project: String,
resource_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
updated_at_cursor: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
tie_breaker_id: Option<i64>,
}
#[derive(Serialize)]
struct SummaryJsonInfo {
issues: i64,
merge_requests: i64,
discussions: i64,
notes: i64,
system_notes: i64,
}
pub fn print_sync_status_json(result: &SyncStatusResult, elapsed_ms: u64) {
let runs = result
.runs
.iter()
.map(|run| {
let duration_ms = run.finished_at.map(|f| f - run.started_at);
SyncRunJsonInfo {
id: run.id,
status: run.status.clone(),
command: run.command.clone(),
started_at: ms_to_iso(run.started_at),
completed_at: run.finished_at.map(ms_to_iso),
duration_ms,
run_id: run.run_id.clone(),
total_items_processed: run.total_items_processed,
total_errors: run.total_errors,
error: run.error.clone(),
stages: run.stages.clone(),
}
})
.collect();
let cursors = result
.cursors
.iter()
.map(|c| CursorJsonInfo {
project: c.project_path.clone(),
resource_type: c.resource_type.clone(),
updated_at_cursor: c.updated_at_cursor.filter(|&ts| ts > 0).map(ms_to_iso),
tie_breaker_id: c.tie_breaker_id,
})
.collect();
let output = SyncStatusJsonOutput {
ok: true,
data: SyncStatusJsonData {
runs,
cursors,
summary: SummaryJsonInfo {
issues: result.summary.issue_count,
merge_requests: result.summary.mr_count,
discussions: result.summary.discussion_count,
notes: result.summary.note_count - result.summary.system_note_count,
system_notes: result.summary.system_note_count,
},
},
meta: RobotMeta { elapsed_ms },
};
println!("{}", serde_json::to_string(&output).unwrap());
}
pub fn print_sync_status(result: &SyncStatusResult) {
println!("{}", style("Recent Sync Runs").bold().underlined());
println!();
if result.runs.is_empty() {
println!(" {}", style("No sync runs recorded yet.").dim());
println!(
" {}",
style("Run 'lore sync' or 'lore ingest' to start.").dim()
);
} else {
for run in &result.runs {
print_run_line(run);
}
}
println!();
println!("{}", style("Cursor Positions").bold().underlined());
println!();
if result.cursors.is_empty() {
println!(" {}", style("No cursors recorded yet.").dim());
} else {
for cursor in &result.cursors {
println!(
" {} ({}):",
style(&cursor.project_path).cyan(),
cursor.resource_type
);
match cursor.updated_at_cursor {
Some(ts) if ts > 0 => {
println!(" Last updated_at: {}", ms_to_iso(ts));
}
_ => {
println!(" Last updated_at: {}", style("Not started").dim());
}
}
if let Some(id) = cursor.tie_breaker_id {
println!(" Last GitLab ID: {}", id);
}
}
}
println!();
println!("{}", style("Data Summary").bold().underlined());
println!();
println!(
" Issues: {}",
style(format_number(result.summary.issue_count)).bold()
);
println!(
" MRs: {}",
style(format_number(result.summary.mr_count)).bold()
);
println!(
" Discussions: {}",
style(format_number(result.summary.discussion_count)).bold()
);
let user_notes = result.summary.note_count - result.summary.system_note_count;
println!(
" Notes: {} {}",
style(format_number(user_notes)).bold(),
style(format!(
"(excluding {} system)",
format_number(result.summary.system_note_count)
))
.dim()
);
}
fn print_run_line(run: &SyncRunInfo) {
let status_styled = match run.status.as_str() {
"succeeded" => style(&run.status).green(),
"failed" => style(&run.status).red(),
"running" => style(&run.status).yellow(),
_ => style(&run.status).dim(),
};
let run_label = run
.run_id
.as_deref()
.map_or_else(|| format!("#{}", run.id), |id| format!("Run {id}"));
let duration = run.finished_at.map(|f| format_duration(f - run.started_at));
let time = format_full_datetime(run.started_at);
let mut parts = vec![
format!("{}", style(run_label).bold()),
format!("{status_styled}"),
format!("{}", style(&run.command).dim()),
time,
];
if let Some(d) = duration {
parts.push(d);
} else {
parts.push("in progress".to_string());
}
if run.total_items_processed > 0 {
parts.push(format!("{} items", run.total_items_processed));
}
if run.total_errors > 0 {
parts.push(format!(
"{}",
style(format!("{} errors", run.total_errors)).red()
));
}
println!(" {}", parts.join(" | "));
if let Some(error) = &run.error {
println!(" {}", style(error).red());
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_duration_handles_seconds() {
assert_eq!(format_duration(5_000), "5.0s");
assert_eq!(format_duration(59_000), "59.0s");
}
#[test]
fn format_duration_handles_minutes() {
assert_eq!(format_duration(60_000), "1m 0s");
assert_eq!(format_duration(90_000), "1m 30s");
assert_eq!(format_duration(300_000), "5m 0s");
}
#[test]
fn format_duration_handles_hours() {
assert_eq!(format_duration(3_600_000), "1h 0m 0s");
assert_eq!(format_duration(5_400_000), "1h 30m 0s");
assert_eq!(format_duration(3_723_000), "1h 2m 3s");
}
#[test]
fn format_duration_handles_milliseconds() {
assert_eq!(format_duration(500), "500ms");
assert_eq!(format_duration(0), "0ms");
}
#[test]
fn format_number_adds_thousands_separators() {
assert_eq!(format_number(1000), "1,000");
assert_eq!(format_number(1234567), "1,234,567");
}
}