Skip to main content

faucet_cli/commands/
history.rs

1//! `faucet history` — terminal view of the run history recorded in a config's
2//! `catalog:` store (#391).
3//!
4//! The catalog store (the same backend `faucet serve` history and `faucet plan
5//! --diff` use) records a [`RunRecord`] per run. This command reads the most
6//! recent N of them and prints a table — status, duration, throughput — without
7//! standing up `faucet serve`. Read-only; requires the `catalog` build feature.
8//!
9//! Run records are written by the control plane (`faucet serve`); a plain
10//! `faucet run` with a `catalog:` block records dataset observations (see
11//! `faucet catalog`) but not run records. Point `faucet history` at the same
12//! store your `serve` instance writes to, to see its runs here.
13
14use crate::catalog::CatalogHandle;
15use crate::cli::HistoryArgs;
16use crate::config::PipelineConfig;
17use crate::error::{CliError, CliResult};
18use crate::serve::history::{ListFilter, RunRecord};
19
20/// Execute the `history` subcommand.
21pub async fn run(args: HistoryArgs) -> CliResult<()> {
22    let (handle, pipeline_name) = connect(&args).await?;
23
24    // Over-fetch when filtering by row so the post-filter still returns a full
25    // page; otherwise fetch exactly the requested limit. Newest-first ordering
26    // is guaranteed by the backend.
27    let fetch = if args.row.is_some() {
28        args.limit.max(200)
29    } else {
30        args.limit.max(1)
31    };
32    let page = handle
33        .store
34        .list(&ListFilter {
35            name: pipeline_name,
36            limit: fetch,
37            ..Default::default()
38        })
39        .await
40        .map_err(|e| CliError::Internal(format!("catalog run-history read: {e}")))?;
41
42    let runs = select_runs(page.runs, args.row.as_deref(), args.limit);
43
44    if args.json {
45        // The RunRecord's `Serialize` already omits secret-bearing fields
46        // (config bodies are only present for cluster runs); scrub as a backstop.
47        let json = serde_json::to_string_pretty(&runs)
48            .map_err(|e| CliError::Internal(format!("rendering history JSON: {e}")))?;
49        println!("{}", crate::secrets::registry::redact(&json));
50        return Ok(());
51    }
52
53    if runs.is_empty() {
54        println!(
55            "no runs recorded yet in this catalog store \
56             (run history is written by `faucet serve`)"
57        );
58        return Ok(());
59    }
60
61    print!("{}", render_table(&runs));
62    Ok(())
63}
64
65/// Load the config named by the flags and connect its `catalog:` store,
66/// returning the handle + the pipeline name to filter run records by.
67async fn connect(args: &HistoryArgs) -> CliResult<(CatalogHandle, Option<String>)> {
68    let cwd = std::env::current_dir()?;
69    let env_path =
70        crate::env_loader::resolve_env_file(args.env_file.as_deref(), args.no_env_file, &cwd)?;
71    crate::env_loader::load_env_file_if_present(env_path.as_deref())?;
72    let path = match &args.config {
73        Some(p) => p.clone(),
74        None => crate::env_loader::discover_config_path(&cwd).ok_or(CliError::NoConfigOrFromEnv)?,
75    };
76    let cfg = PipelineConfig::from_path_async(&path, args.profile.as_deref()).await?;
77    let spec = cfg.catalog.as_ref().ok_or_else(|| {
78        CliError::Config(
79            "no `catalog:` block in this config — add one naming the store (e.g. \
80             `catalog: { url: sqlite:./faucet-catalog.db }`), or run \
81             `faucet schema catalog` for the block's JSON Schema. `faucet history` \
82             requires the `catalog` build feature."
83                .to_string(),
84        )
85    })?;
86    let handle = crate::catalog::connect_from_spec(spec).await?;
87    Ok((handle, cfg.name.clone()))
88}
89
90/// Apply the `--row` filter and `--limit` truncation to a fetched page
91/// (newest-first order preserved by the backend). Pure — unit-testable without
92/// a catalog store.
93pub(crate) fn select_runs(
94    mut runs: Vec<RunRecord>,
95    row: Option<&str>,
96    limit: usize,
97) -> Vec<RunRecord> {
98    if let Some(row) = row {
99        runs.retain(|r| r.invocations.iter().any(|i| i.row_id == row));
100    }
101    runs.truncate(limit);
102    runs
103}
104
105/// Render the run table (newest first). Pure so it is unit-testable.
106pub(crate) fn render_table(runs: &[RunRecord]) -> String {
107    let mut out = String::new();
108    out.push_str(&format!(
109        "{:<20}  {:<10}  {:<19}  {:>10}  {:>12}  {:>10}  ROWS\n",
110        "RUN ID", "STATUS", "STARTED", "DURATION", "ROWS OUT", "ROWS/S"
111    ));
112    for r in runs {
113        let started = r
114            .started_at
115            .or(Some(r.submitted_at))
116            .map(|t| t.format("%Y-%m-%d %H:%M:%S").to_string())
117            .unwrap_or_else(|| "-".to_string());
118        let duration = match r.elapsed_secs {
119            Some(s) => format!("{s:.1}s"),
120            None => "-".to_string(),
121        };
122        let rate = match r.elapsed_secs {
123            Some(s) if s > 0.0 => format!("{:.0}", r.records_written as f64 / s),
124            _ => "-".to_string(),
125        };
126        // Truncate long run ids (UUIDs) so the table stays aligned.
127        let id = if r.run_id.len() > 20 {
128            format!("{}…", &r.run_id[..19])
129        } else {
130            r.run_id.clone()
131        };
132        out.push_str(&format!(
133            "{:<20}  {:<10}  {:<19}  {:>10}  {:>12}  {:>10}  {}\n",
134            id,
135            r.status.as_str(),
136            started,
137            duration,
138            r.records_written,
139            rate,
140            r.invocations.len(),
141        ));
142        if let Some(err) = &r.error {
143            out.push_str(&format!("  └─ error: {err}\n"));
144        }
145    }
146    out
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use crate::serve::history::{InvocationRecord, RunStatus};
153    use chrono::{TimeZone, Utc};
154
155    fn record(id: &str, status: RunStatus, rows: u64, elapsed: Option<f64>) -> RunRecord {
156        let t = Utc.with_ymd_and_hms(2026, 7, 1, 12, 0, 0).unwrap();
157        RunRecord {
158            run_id: id.into(),
159            name: Some("demo".into()),
160            labels: Default::default(),
161            status,
162            submitted_at: t,
163            started_at: Some(t),
164            finished_at: Some(t),
165            elapsed_secs: elapsed,
166            records_written: rows,
167            invocations: vec![InvocationRecord {
168                row_id: "us".into(),
169                parent_record_key: None,
170                records_written: rows as usize,
171                error: None,
172            }],
173            error: (status == RunStatus::Failed).then(|| "boom".to_string()),
174            idempotency_key: None,
175            doctor_report: None,
176            config_body: None,
177            config_format: None,
178            timeout_secs: None,
179            clock: None,
180            attempt: 0,
181            replay_of: None,
182            callback: None,
183        }
184    }
185
186    #[test]
187    fn table_lists_runs_with_status_and_throughput() {
188        let runs = vec![
189            record("run-1", RunStatus::Completed, 1000, Some(2.0)),
190            record("run-2", RunStatus::Failed, 0, Some(0.5)),
191        ];
192        let t = render_table(&runs);
193        assert!(t.contains("RUN ID"), "{t}");
194        assert!(t.contains("run-1"), "{t}");
195        assert!(t.contains("completed"), "{t}");
196        assert!(t.contains("500"), "rows/s = 1000/2 = 500: {t}");
197        assert!(t.contains("failed"), "{t}");
198        assert!(t.contains("error: boom"), "failed run shows its error: {t}");
199    }
200
201    #[test]
202    fn missing_elapsed_renders_dashes_not_a_panic() {
203        let runs = vec![record("r", RunStatus::Running, 5, None)];
204        let t = render_table(&runs);
205        assert!(t.contains("running"), "{t}");
206        // No division by zero / no NaN in the rate column.
207        assert!(!t.contains("NaN") && !t.contains("inf"), "{t}");
208    }
209
210    #[tokio::test]
211    async fn reads_seeded_in_memory_catalog() {
212        use crate::serve::history::RunHistory;
213        // Seed a memory backend with two runs, then read them back via list().
214        let store =
215            crate::serve::history::memory::MemoryHistory::new(std::time::Duration::from_secs(3600));
216        store
217            .upsert(&record("a", RunStatus::Completed, 10, Some(1.0)))
218            .await
219            .unwrap();
220        store
221            .upsert(&record("b", RunStatus::Completed, 20, Some(1.0)))
222            .await
223            .unwrap();
224        let page = store
225            .list(&ListFilter {
226                name: Some("demo".into()),
227                limit: 10,
228                ..Default::default()
229            })
230            .await
231            .unwrap();
232        assert_eq!(page.runs.len(), 2);
233        let t = render_table(&page.runs);
234        assert!(t.contains("a") && t.contains("b"), "{t}");
235    }
236
237    #[test]
238    fn select_runs_filters_by_row_and_truncates() {
239        let mut a = record("a", RunStatus::Completed, 1, Some(1.0));
240        a.invocations[0].row_id = "us".into();
241        let mut b = record("b", RunStatus::Completed, 1, Some(1.0));
242        b.invocations[0].row_id = "eu".into();
243        let all = vec![a.clone(), b.clone()];
244
245        // --row keeps only matching invocations.
246        let only_eu = select_runs(all.clone(), Some("eu"), 20);
247        assert_eq!(only_eu.len(), 1);
248        assert_eq!(only_eu[0].run_id, "b");
249        // No filter, but --limit truncates.
250        let capped = select_runs(all.clone(), None, 1);
251        assert_eq!(capped.len(), 1);
252        // A row nobody has → empty.
253        assert!(select_runs(all, Some("apac"), 20).is_empty());
254    }
255
256    #[tokio::test]
257    async fn run_errors_without_a_catalog_block() {
258        use crate::cli::HistoryArgs;
259        let dir = tempfile::tempdir().unwrap();
260        let path = dir.path().join("faucet.yaml");
261        std::fs::write(
262            &path,
263            "version: 1\nname: demo\npipeline:\n  source: { type: rest, config: { path: /x } }\n  sink: { type: jsonl, config: { path: o } }\n",
264        )
265        .unwrap();
266        let err = super::run(HistoryArgs {
267            config: Some(path),
268            env_file: None,
269            no_env_file: true,
270            profile: None,
271            limit: 20,
272            row: None,
273            json: false,
274        })
275        .await;
276        match err {
277            Err(CliError::Config(m)) => assert!(m.contains("catalog"), "got: {m}"),
278            other => panic!("expected a no-catalog Config error, got {other:?}"),
279        }
280    }
281}