1use crate::catalog::CatalogHandle;
15use crate::cli::HistoryArgs;
16use crate::config::PipelineConfig;
17use crate::error::{CliError, CliResult};
18use crate::serve::history::{ListFilter, RunRecord};
19
20pub async fn run(args: HistoryArgs) -> CliResult<()> {
22 let (handle, pipeline_name) = connect(&args).await?;
23
24 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 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
65async 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
90pub(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
105pub(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 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 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 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 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 let capped = select_runs(all.clone(), None, 1);
251 assert_eq!(capped.len(), 1);
252 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}