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>
This commit is contained in:
Taylor Eernisse
2026-02-06 23:46:48 -05:00
parent 4ce0130620
commit cf6d27435a
10 changed files with 138 additions and 15 deletions

View File

@@ -3,6 +3,7 @@ use rusqlite::Connection;
use serde::Serialize; use serde::Serialize;
use crate::Config; use crate::Config;
use crate::cli::robot::RobotMeta;
use crate::core::db::create_connection; use crate::core::db::create_connection;
use crate::core::error::Result; use crate::core::error::Result;
use crate::core::events_db::{self, EventCounts}; use crate::core::events_db::{self, EventCounts};
@@ -196,6 +197,7 @@ fn format_number(n: i64) -> String {
struct CountJsonOutput { struct CountJsonOutput {
ok: bool, ok: bool,
data: CountJsonData, data: CountJsonData,
meta: RobotMeta,
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -228,6 +230,7 @@ pub fn run_count_events(config: &Config) -> Result<EventCounts> {
struct EventCountJsonOutput { struct EventCountJsonOutput {
ok: bool, ok: bool,
data: EventCountJsonData, data: EventCountJsonData,
meta: RobotMeta,
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -245,7 +248,7 @@ struct EventTypeCounts {
total: usize, total: usize,
} }
pub fn print_event_count_json(counts: &EventCounts) { pub fn print_event_count_json(counts: &EventCounts, elapsed_ms: u64) {
let output = EventCountJsonOutput { let output = EventCountJsonOutput {
ok: true, ok: true,
data: EventCountJsonData { data: EventCountJsonData {
@@ -266,6 +269,7 @@ pub fn print_event_count_json(counts: &EventCounts) {
}, },
total: counts.total(), total: counts.total(),
}, },
meta: RobotMeta { elapsed_ms },
}; };
println!("{}", serde_json::to_string(&output).unwrap()); println!("{}", serde_json::to_string(&output).unwrap());
@@ -317,7 +321,7 @@ pub fn print_event_count(counts: &EventCounts) {
); );
} }
pub fn print_count_json(result: &CountResult) { pub fn print_count_json(result: &CountResult, elapsed_ms: u64) {
let breakdown = result.state_breakdown.as_ref().map(|b| CountJsonBreakdown { let breakdown = result.state_breakdown.as_ref().map(|b| CountJsonBreakdown {
opened: b.opened, opened: b.opened,
closed: b.closed, closed: b.closed,
@@ -333,6 +337,7 @@ pub fn print_count_json(result: &CountResult) {
system_excluded: result.system_count, system_excluded: result.system_count,
breakdown, breakdown,
}, },
meta: RobotMeta { elapsed_ms },
}; };
println!("{}", serde_json::to_string(&output).unwrap()); println!("{}", serde_json::to_string(&output).unwrap());

View File

@@ -2,6 +2,7 @@ use console::style;
use serde::Serialize; use serde::Serialize;
use crate::Config; use crate::Config;
use crate::cli::robot::RobotMeta;
use crate::core::db::{LATEST_SCHEMA_VERSION, create_connection, get_schema_version}; use crate::core::db::{LATEST_SCHEMA_VERSION, create_connection, get_schema_version};
use crate::core::error::{LoreError, Result}; use crate::core::error::{LoreError, Result};
use crate::core::paths::get_db_path; use crate::core::paths::get_db_path;
@@ -112,12 +113,14 @@ pub fn print_embed(result: &EmbedCommandResult) {
struct EmbedJsonOutput<'a> { struct EmbedJsonOutput<'a> {
ok: bool, ok: bool,
data: &'a EmbedCommandResult, data: &'a EmbedCommandResult,
meta: RobotMeta,
} }
pub fn print_embed_json(result: &EmbedCommandResult) { pub fn print_embed_json(result: &EmbedCommandResult, elapsed_ms: u64) {
let output = EmbedJsonOutput { let output = EmbedJsonOutput {
ok: true, ok: true,
data: result, data: result,
meta: RobotMeta { elapsed_ms },
}; };
println!("{}", serde_json::to_string(&output).unwrap()); println!("{}", serde_json::to_string(&output).unwrap());
} }

View File

@@ -4,6 +4,7 @@ use serde::Serialize;
use tracing::info; use tracing::info;
use crate::Config; use crate::Config;
use crate::cli::robot::RobotMeta;
use crate::core::db::create_connection; use crate::core::db::create_connection;
use crate::core::error::Result; use crate::core::error::Result;
use crate::core::paths::get_db_path; use crate::core::paths::get_db_path;
@@ -150,6 +151,7 @@ pub fn print_generate_docs(result: &GenerateDocsResult) {
struct GenerateDocsJsonOutput { struct GenerateDocsJsonOutput {
ok: bool, ok: bool,
data: GenerateDocsJsonData, data: GenerateDocsJsonData,
meta: RobotMeta,
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -162,7 +164,7 @@ struct GenerateDocsJsonData {
errored: usize, errored: usize,
} }
pub fn print_generate_docs_json(result: &GenerateDocsResult) { pub fn print_generate_docs_json(result: &GenerateDocsResult, elapsed_ms: u64) {
let output = GenerateDocsJsonOutput { let output = GenerateDocsJsonOutput {
ok: true, ok: true,
data: GenerateDocsJsonData { data: GenerateDocsJsonData {
@@ -180,6 +182,7 @@ pub fn print_generate_docs_json(result: &GenerateDocsResult) {
unchanged: result.unchanged, unchanged: result.unchanged,
errored: result.errored, errored: result.errored,
}, },
meta: RobotMeta { elapsed_ms },
}; };
println!("{}", serde_json::to_string(&output).unwrap()); println!("{}", serde_json::to_string(&output).unwrap());
} }

View File

@@ -9,6 +9,7 @@ use serde::Serialize;
use tracing::Instrument; use tracing::Instrument;
use crate::Config; use crate::Config;
use crate::cli::robot::RobotMeta;
use crate::core::db::create_connection; use crate::core::db::create_connection;
use crate::core::error::{LoreError, Result}; use crate::core::error::{LoreError, Result};
use crate::core::lock::{AppLock, LockOptions}; use crate::core::lock::{AppLock, LockOptions};
@@ -732,6 +733,7 @@ fn print_mr_project_summary(path: &str, result: &IngestMrProjectResult) {
struct IngestJsonOutput { struct IngestJsonOutput {
ok: bool, ok: bool,
data: IngestJsonData, data: IngestJsonData,
meta: RobotMeta,
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -768,7 +770,7 @@ struct IngestMrStats {
diffnotes_count: usize, diffnotes_count: usize,
} }
pub fn print_ingest_summary_json(result: &IngestResult) { pub fn print_ingest_summary_json(result: &IngestResult, elapsed_ms: u64) {
let (issues, merge_requests) = if result.resource_type == "issues" { let (issues, merge_requests) = if result.resource_type == "issues" {
( (
Some(IngestIssueStats { Some(IngestIssueStats {
@@ -807,6 +809,7 @@ pub fn print_ingest_summary_json(result: &IngestResult) {
resource_events_fetched: result.resource_events_fetched, resource_events_fetched: result.resource_events_fetched,
resource_events_failed: result.resource_events_failed, resource_events_failed: result.resource_events_failed,
}, },
meta: RobotMeta { elapsed_ms },
}; };
println!("{}", serde_json::to_string(&output).unwrap()); println!("{}", serde_json::to_string(&output).unwrap());

View File

@@ -3,6 +3,7 @@ use rusqlite::Connection;
use serde::Serialize; use serde::Serialize;
use crate::Config; use crate::Config;
use crate::cli::robot::{RobotMeta, expand_fields_preset, filter_fields};
use crate::core::db::create_connection; use crate::core::db::create_connection;
use crate::core::error::{LoreError, Result}; use crate::core::error::{LoreError, Result};
use crate::core::paths::get_db_path; use crate::core::paths::get_db_path;
@@ -734,9 +735,20 @@ pub fn print_list_issues(result: &ListResult) {
println!("{table}"); println!("{table}");
} }
pub fn print_list_issues_json(result: &ListResult) { pub fn print_list_issues_json(result: &ListResult, elapsed_ms: u64, fields: Option<&[String]>) {
let json_result = ListResultJson::from(result); let json_result = ListResultJson::from(result);
match serde_json::to_string_pretty(&json_result) { let meta = RobotMeta { elapsed_ms };
let output = serde_json::json!({
"ok": true,
"data": json_result,
"meta": meta,
});
let mut output = output;
if let Some(f) = fields {
let expanded = expand_fields_preset(f, "issues");
filter_fields(&mut output, "issues", &expanded);
}
match serde_json::to_string(&output) {
Ok(json) => println!("{json}"), Ok(json) => println!("{json}"),
Err(e) => eprintln!("Error serializing to JSON: {e}"), Err(e) => eprintln!("Error serializing to JSON: {e}"),
} }
@@ -819,9 +831,20 @@ pub fn print_list_mrs(result: &MrListResult) {
println!("{table}"); println!("{table}");
} }
pub fn print_list_mrs_json(result: &MrListResult) { pub fn print_list_mrs_json(result: &MrListResult, elapsed_ms: u64, fields: Option<&[String]>) {
let json_result = MrListResultJson::from(result); let json_result = MrListResultJson::from(result);
match serde_json::to_string_pretty(&json_result) { let meta = RobotMeta { elapsed_ms };
let output = serde_json::json!({
"ok": true,
"data": json_result,
"meta": meta,
});
let mut output = output;
if let Some(f) = fields {
let expanded = expand_fields_preset(f, "mrs");
filter_fields(&mut output, "mrs", &expanded);
}
match serde_json::to_string(&output) {
Ok(json) => println!("{json}"), Ok(json) => println!("{json}"),
Err(e) => eprintln!("Error serializing to JSON: {e}"), Err(e) => eprintln!("Error serializing to JSON: {e}"),
} }

View File

@@ -3,6 +3,7 @@ use rusqlite::Connection;
use serde::Serialize; use serde::Serialize;
use crate::Config; use crate::Config;
use crate::cli::robot::RobotMeta;
use crate::core::db::create_connection; use crate::core::db::create_connection;
use crate::core::error::{LoreError, Result}; use crate::core::error::{LoreError, Result};
use crate::core::paths::get_db_path; use crate::core::paths::get_db_path;
@@ -1042,17 +1043,29 @@ impl From<&MrNoteDetail> for MrNoteDetailJson {
} }
} }
pub fn print_show_issue_json(issue: &IssueDetail) { pub fn print_show_issue_json(issue: &IssueDetail, elapsed_ms: u64) {
let json_result = IssueDetailJson::from(issue); let json_result = IssueDetailJson::from(issue);
match serde_json::to_string_pretty(&json_result) { let meta = RobotMeta { elapsed_ms };
let output = serde_json::json!({
"ok": true,
"data": json_result,
"meta": meta,
});
match serde_json::to_string(&output) {
Ok(json) => println!("{json}"), Ok(json) => println!("{json}"),
Err(e) => eprintln!("Error serializing to JSON: {e}"), Err(e) => eprintln!("Error serializing to JSON: {e}"),
} }
} }
pub fn print_show_mr_json(mr: &MrDetail) { pub fn print_show_mr_json(mr: &MrDetail, elapsed_ms: u64) {
let json_result = MrDetailJson::from(mr); let json_result = MrDetailJson::from(mr);
match serde_json::to_string_pretty(&json_result) { let meta = RobotMeta { elapsed_ms };
let output = serde_json::json!({
"ok": true,
"data": json_result,
"meta": meta,
});
match serde_json::to_string(&output) {
Ok(json) => println!("{json}"), Ok(json) => println!("{json}"),
Err(e) => eprintln!("Error serializing to JSON: {e}"), Err(e) => eprintln!("Error serializing to JSON: {e}"),
} }

View File

@@ -3,6 +3,7 @@ use rusqlite::Connection;
use serde::Serialize; use serde::Serialize;
use crate::Config; use crate::Config;
use crate::cli::robot::RobotMeta;
use crate::core::db::create_connection; use crate::core::db::create_connection;
use crate::core::error::Result; use crate::core::error::Result;
use crate::core::paths::get_db_path; use crate::core::paths::get_db_path;
@@ -441,9 +442,10 @@ pub fn print_stats(result: &StatsResult) {
struct StatsJsonOutput { struct StatsJsonOutput {
ok: bool, ok: bool,
data: StatsResult, data: StatsResult,
meta: RobotMeta,
} }
pub fn print_stats_json(result: &StatsResult) { pub fn print_stats_json(result: &StatsResult, elapsed_ms: u64) {
let output = StatsJsonOutput { let output = StatsJsonOutput {
ok: true, ok: true,
data: StatsResult { data: StatsResult {
@@ -471,6 +473,7 @@ pub fn print_stats_json(result: &StatsResult) {
}), }),
}), }),
}, },
meta: RobotMeta { elapsed_ms },
}; };
println!("{}", serde_json::to_string(&output).unwrap()); println!("{}", serde_json::to_string(&output).unwrap());
} }

View File

@@ -3,6 +3,7 @@ use rusqlite::Connection;
use serde::Serialize; use serde::Serialize;
use crate::Config; use crate::Config;
use crate::cli::robot::RobotMeta;
use crate::core::db::create_connection; use crate::core::db::create_connection;
use crate::core::error::Result; use crate::core::error::Result;
use crate::core::metrics::StageTiming; use crate::core::metrics::StageTiming;
@@ -190,6 +191,7 @@ fn format_number(n: i64) -> String {
struct SyncStatusJsonOutput { struct SyncStatusJsonOutput {
ok: bool, ok: bool,
data: SyncStatusJsonData, data: SyncStatusJsonData,
meta: RobotMeta,
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -238,7 +240,7 @@ struct SummaryJsonInfo {
system_notes: i64, system_notes: i64,
} }
pub fn print_sync_status_json(result: &SyncStatusResult) { pub fn print_sync_status_json(result: &SyncStatusResult, elapsed_ms: u64) {
let runs = result let runs = result
.runs .runs
.iter() .iter()
@@ -284,6 +286,7 @@ pub fn print_sync_status_json(result: &SyncStatusResult) {
system_notes: result.summary.system_note_count, system_notes: result.summary.system_note_count,
}, },
}, },
meta: RobotMeta { elapsed_ms },
}; };
println!("{}", serde_json::to_string(&output).unwrap()); println!("{}", serde_json::to_string(&output).unwrap());

44
src/cli/robot.rs Normal file
View File

@@ -0,0 +1,44 @@
use serde::Serialize;
#[derive(Debug, Serialize)]
pub struct RobotMeta {
pub elapsed_ms: u64,
}
/// Filter JSON object fields in-place for `--fields` support.
/// Retains only the specified field names on each item in the list array.
pub fn filter_fields(value: &mut serde_json::Value, list_key: &str, fields: &[String]) {
if fields.is_empty() {
return;
}
if let Some(items) = value
.get_mut("data")
.and_then(|d| d.get_mut(list_key))
.and_then(|v| v.as_array_mut())
{
for item in items {
if let Some(obj) = item.as_object_mut() {
obj.retain(|k, _| fields.iter().any(|f| f == k));
}
}
}
}
/// Expand the `minimal` preset into concrete field names.
pub fn expand_fields_preset(fields: &[String], entity: &str) -> Vec<String> {
if fields.len() == 1 && fields[0] == "minimal" {
match entity {
"issues" => ["iid", "title", "state", "updated_at_iso"]
.iter()
.map(|s| (*s).to_string())
.collect(),
"mrs" => ["iid", "title", "state", "updated_at_iso"]
.iter()
.map(|s| (*s).to_string())
.collect(),
_ => fields.to_vec(),
}
} else {
fields.to_vec()
}
}

View File

@@ -239,11 +239,32 @@ impl LoreError {
self.code().exit_code() self.code().exit_code()
} }
pub fn actions(&self) -> Vec<&'static str> {
match self {
Self::ConfigNotFound { .. } => vec!["lore init"],
Self::ConfigInvalid { .. } => vec!["lore init --force"],
Self::GitLabAuthFailed => {
vec!["export GITLAB_TOKEN=glpat-xxx", "lore auth"]
}
Self::TokenNotSet { .. } => vec!["export GITLAB_TOKEN=glpat-xxx"],
Self::OllamaUnavailable { .. } => vec!["ollama serve"],
Self::OllamaModelNotFound { .. } => vec!["ollama pull nomic-embed-text"],
Self::DatabaseLocked { .. } => vec!["lore ingest --force"],
Self::EmbeddingsNotBuilt => vec!["lore embed"],
Self::EmbeddingFailed { .. } => vec!["lore embed --retry-failed"],
Self::MigrationFailed { .. } => vec!["lore migrate"],
Self::GitLabNetworkError { .. } => vec!["lore doctor"],
_ => vec![],
}
}
pub fn to_robot_error(&self) -> RobotError { pub fn to_robot_error(&self) -> RobotError {
let actions = self.actions().into_iter().map(String::from).collect();
RobotError { RobotError {
code: self.code().to_string(), code: self.code().to_string(),
message: self.to_string(), message: self.to_string(),
suggestion: self.suggestion().map(String::from), suggestion: self.suggestion().map(String::from),
actions,
} }
} }
} }
@@ -254,6 +275,8 @@ pub struct RobotError {
pub message: String, pub message: String,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub suggestion: Option<String>, pub suggestion: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub actions: Vec<String>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]