Skip to main content

keel_cli/
status.rs

1//! `keel status` — the "what is Keel doing for me" screen (dx-spec §6).
2//!
3//! One screen, from the two evidence files when present: `.keel/discovery.db`
4//! (wrapped coverage, calls, retries, cache hit rate, breaker/throttle events,
5//! the observed-not-retried and unwrapped-coverage gaps, and a trailing-7-day
6//! window) and `.keel/journal.db` (the flows table — how many ran, completed,
7//! and how many are resumable after a crash). Reads only; the journal schema
8//! is frozen (`contracts/journal.sql`); the discovery schema is
9//! `keel-journal`'s own (not contract-frozen). No evidence yet → a friendly
10//! nudge, exit 0.
11//!
12//! Determinism (dx-spec §5): the trailing week window is computed from
13//! *stored* daily buckets keyed on `now_ms`'s UTC day — never a wall-clock
14//! label baked into the JSON — so `--json` output is a pure function of the
15//! evidence file plus the injected `now_ms` (mirrors `flows`' `fmt_age`).
16
17use std::path::Path;
18
19use keel_journal::{DailyStats, MS_PER_DAY, TargetStats};
20use rusqlite::{Connection, OpenFlags};
21use serde::Serialize;
22
23use crate::render::to_json;
24use crate::{EXIT_FAILURE, Rendered, evidence};
25
26/// How many trailing UTC days "this week" covers (today plus the 6 before it).
27const WINDOW_DAYS: i64 = 7;
28
29/// Per-target line in the status report.
30#[derive(Debug, Serialize)]
31struct TargetLine {
32    breaker_opens: i64,
33    cache_hits: i64,
34    calls: i64,
35    failures: i64,
36    not_retried: i64,
37    retries: i64,
38    successes: i64,
39    target: String,
40    throttled: i64,
41    unwrapped_calls: i64,
42}
43
44/// The flows-table summary.
45#[derive(Debug, Default, Serialize)]
46struct FlowSummary {
47    completed: i64,
48    dead: i64,
49    failed: i64,
50    resumable: i64,
51    running: i64,
52    total: i64,
53}
54
55/// Trailing-window aggregates over the last [`WINDOW_DAYS`] stored daily
56/// buckets, ending on the day `now_ms` falls in (dx-spec §6, "retries saved
57/// this week"). Zero on a legacy (v1) discovery file, which has no buckets.
58#[derive(Debug, Default, Serialize)]
59struct WeekSummary {
60    breaker_opens: i64,
61    cache_hits: i64,
62    calls: i64,
63    failures: i64,
64    not_retried: i64,
65    retries: i64,
66    successes: i64,
67    throttled: i64,
68    unwrapped_calls: i64,
69}
70
71/// The whole status report — one struct, so the human screen and the `--json`
72/// twin cannot drift (every human fact has a JSON counterpart).
73#[derive(Debug, Serialize)]
74struct StatusReport {
75    breaker_opens: i64,
76    cache_hit_rate: f64,
77    cache_hits: i64,
78    calls: i64,
79    discovery_present: bool,
80    failures: i64,
81    flows: FlowSummary,
82    journal_present: bool,
83    not_retried: i64,
84    retries: i64,
85    success_rate: f64,
86    successes: i64,
87    targets: Vec<TargetLine>,
88    targets_wrapped: usize,
89    throttled: i64,
90    unwrapped_calls: i64,
91    week: WeekSummary,
92    wrapped_coverage: f64,
93}
94
95/// Build the status report for `project`, windowing "this week" against
96/// `now_ms` (the caller's clock — `SystemClock` in production, a fixed value
97/// under test).
98pub fn run(project: &Path, now_ms: i64) -> Rendered {
99    let discovery = match evidence::read_discovery(project) {
100        Ok(d) => d,
101        Err(e) => return soft_error(&e),
102    };
103    let daily = match evidence::read_discovery_daily(project) {
104        Ok(d) => d,
105        Err(e) => return soft_error(&e),
106    };
107    let discovery_present = evidence::discovery_db(project).exists();
108    // Honor the policy's `journal` key (file: locations), like the engine does.
109    let journal_path = evidence::resolved_journal(project).path;
110    let journal_present = journal_path.exists();
111
112    let flows = if journal_present {
113        match read_flows(&journal_path) {
114            Ok(f) => f,
115            Err(e) => return soft_error(&e),
116        }
117    } else {
118        FlowSummary::default()
119    };
120
121    let report = aggregate(
122        discovery,
123        &daily,
124        flows,
125        discovery_present,
126        journal_present,
127        now_ms,
128    );
129    let human = human(&report);
130    Rendered::ok(human, to_json(&report))
131}
132
133/// Fold per-target stats, the daily buckets, and the flow summary into the
134/// report.
135fn aggregate(
136    discovery: Vec<TargetStats>,
137    daily: &[DailyStats],
138    flows: FlowSummary,
139    discovery_present: bool,
140    journal_present: bool,
141    now_ms: i64,
142) -> StatusReport {
143    let mut r = StatusReport {
144        breaker_opens: 0,
145        cache_hit_rate: 0.0,
146        cache_hits: 0,
147        calls: 0,
148        discovery_present,
149        failures: 0,
150        flows,
151        journal_present,
152        not_retried: 0,
153        retries: 0,
154        success_rate: 0.0,
155        successes: 0,
156        targets: Vec::new(),
157        targets_wrapped: discovery.len(),
158        throttled: 0,
159        unwrapped_calls: 0,
160        week: WeekSummary::default(),
161        wrapped_coverage: 0.0,
162    };
163    for s in discovery {
164        r.calls += s.calls;
165        r.retries += s.retries;
166        r.successes += s.successes;
167        r.failures += s.failures;
168        r.cache_hits += s.cache_hits;
169        r.throttled += s.throttled;
170        r.breaker_opens += s.breaker_opens;
171        r.not_retried += s.not_retried;
172        r.unwrapped_calls += s.unwrapped_calls;
173        r.targets.push(TargetLine {
174            breaker_opens: s.breaker_opens,
175            cache_hits: s.cache_hits,
176            calls: s.calls,
177            failures: s.failures,
178            not_retried: s.not_retried,
179            retries: s.retries,
180            successes: s.successes,
181            target: s.target,
182            throttled: s.throttled,
183            unwrapped_calls: s.unwrapped_calls,
184        });
185    }
186    r.cache_hit_rate = ratio(r.cache_hits, r.calls);
187    r.success_rate = ratio(r.successes, r.successes + r.failures);
188    r.wrapped_coverage = ratio(r.calls - r.unwrapped_calls, r.calls);
189    r.week = week_window(daily, now_ms);
190    r
191}
192
193/// Sum the daily buckets stored for the trailing [`WINDOW_DAYS`] UTC days
194/// ending on `now_ms`'s day — a pure function of *stored* days, never a
195/// wall-clock label, so `--json` stays byte-deterministic under an injected
196/// clock (dx-spec §5).
197fn week_window(daily: &[DailyStats], now_ms: i64) -> WeekSummary {
198    let end_day = now_ms.div_euclid(MS_PER_DAY);
199    let start_day = end_day - (WINDOW_DAYS - 1);
200    let mut w = WeekSummary::default();
201    for d in daily {
202        if d.day < start_day || d.day > end_day {
203            continue;
204        }
205        w.calls += d.calls;
206        w.retries += d.retries;
207        w.successes += d.successes;
208        w.failures += d.failures;
209        w.cache_hits += d.cache_hits;
210        w.throttled += d.throttled;
211        w.breaker_opens += d.breaker_opens;
212        w.not_retried += d.not_retried;
213        w.unwrapped_calls += d.unwrapped_calls;
214    }
215    w
216}
217
218/// A rate rounded to 4 decimals so the JSON twin is byte-stable.
219fn ratio(num: i64, denom: i64) -> f64 {
220    if denom <= 0 {
221        return 0.0;
222    }
223    #[expect(clippy::cast_precision_loss, reason = "counts are small; 4-dp rounded")]
224    let raw = num as f64 / denom as f64;
225    (raw * 10_000.0).round() / 10_000.0
226}
227
228/// Count flows by status. `resumable` = flows in `running` (the recovery set;
229/// `SqliteJournal::incomplete_flows` treats only `running` as resumable).
230fn read_flows(path: &Path) -> Result<FlowSummary, String> {
231    let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)
232        .map_err(|e| format!("could not open {}: {e}", path.display()))?;
233    let mut stmt = conn
234        .prepare("SELECT status, COUNT(*) FROM flows GROUP BY status")
235        .map_err(|e| format!("could not read flows: {e}"))?;
236    let rows = stmt
237        .query_map([], |row| {
238            Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
239        })
240        .map_err(|e| format!("could not read flows: {e}"))?;
241    let mut f = FlowSummary::default();
242    for row in rows {
243        let (status, count) = row.map_err(|e| format!("could not read flows: {e}"))?;
244        match status.as_str() {
245            "running" => f.running = count,
246            "completed" => f.completed = count,
247            "failed" => f.failed = count,
248            "dead" => f.dead = count,
249            _ => {}
250        }
251        f.total += count;
252    }
253    f.resumable = f.running;
254    Ok(f)
255}
256
257/// The human screen. Derived entirely from [`StatusReport`], so it can never
258/// show a fact the JSON twin omits.
259fn human(r: &StatusReport) -> String {
260    if !r.discovery_present && !r.journal_present {
261        return "keel \u{25b8} no evidence yet.\n  Run `keel run <script>` to start recording coverage and flows.".to_owned();
262    }
263    let mut lines: Vec<String> = vec![
264        "keel \u{25b8} status\n".to_owned(),
265        format!(
266            "  wrapped targets:  {} ({:.1}% of calls covered by policy)\n  calls:            {} ({} ok, {} failed, {} cached)\n  retries:          {} ({} saved this week)\n",
267            r.targets_wrapped,
268            r.wrapped_coverage * 100.0,
269            r.calls,
270            r.successes,
271            r.failures,
272            r.cache_hits,
273            r.retries,
274            r.week.retries,
275        ),
276        format!(
277            "  success rate:     {:.1}%\n  cache hit rate:   {:.1}%\n",
278            r.success_rate * 100.0,
279            r.cache_hit_rate * 100.0,
280        ),
281        format!(
282            "  breaker events:   {}\n  throttled:        {}\n",
283            r.breaker_opens, r.throttled,
284        ),
285        format!(
286            "  flows:            {} total ({} completed, {} running, {} failed, {} dead)\n  resumable:        {}\n",
287            r.flows.total,
288            r.flows.completed,
289            r.flows.running,
290            r.flows.failed,
291            r.flows.dead,
292            r.flows.resumable,
293        ),
294    ];
295    if r.unwrapped_calls > 0 {
296        lines.push(format!(
297            "  coverage gap:     {} call(s) observed on targets with no policy entry — run `keel init` to add them.\n",
298            r.unwrapped_calls,
299        ));
300    }
301    let not_retried_targets: Vec<&str> = r
302        .targets
303        .iter()
304        .filter(|t| t.not_retried > 0)
305        .map(|t| t.target.as_str())
306        .collect();
307    if !not_retried_targets.is_empty() {
308        lines.push(format!(
309            "  observed, not retried: {} call(s) on {} — non-idempotent (no idempotency key), so Keel will not retry them by default; add `idempotency.header` in keel.toml to allow it.\n",
310            r.not_retried,
311            not_retried_targets.join(", "),
312        ));
313    }
314    if !r.targets.is_empty() {
315        lines.push("  by target:\n".to_owned());
316        for t in &r.targets {
317            lines.push(format!(
318                "    {}  ({} calls, {} retries, {} cache hits)\n",
319                t.target, t.calls, t.retries, t.cache_hits,
320            ));
321        }
322    }
323    lines.concat()
324}
325
326/// An evidence-read failure (exit 1: the underlying data could not be read).
327fn soft_error(message: &str) -> Rendered {
328    #[derive(Serialize)]
329    struct ErrReport<'a> {
330        error: &'a str,
331    }
332    Rendered {
333        human: format!("keel \u{25b8} status unavailable: {message}"),
334        json: to_json(&ErrReport { error: message }),
335        exit: EXIT_FAILURE,
336        to_stderr: true,
337    }
338}
339
340#[cfg(test)]
341mod tests {
342    use keel_journal::{CallObservation, CallResult, DiscoveryStore, ManualClock, ObservedError};
343
344    use super::*;
345
346    const T0: i64 = 1_783_728_000_000; // an arbitrary but fixed UTC instant
347
348    #[test]
349    fn empty_project_nudges_to_run() {
350        let dir = tempfile::TempDir::new().unwrap();
351        let r = run(dir.path(), T0);
352        assert_eq!(r.exit, crate::EXIT_OK);
353        assert!(r.human.contains("no evidence yet"));
354        assert_eq!(r.json["discovery_present"], false);
355        assert_eq!(r.json["journal_present"], false);
356    }
357
358    #[test]
359    fn ratio_rounds_and_guards_zero_denominator() {
360        assert!((ratio(1, 3) - 0.3333).abs() < f64::EPSILON);
361        assert!((ratio(0, 0) - 0.0).abs() < f64::EPSILON);
362        assert!((ratio(1, 2) - 0.5).abs() < f64::EPSILON);
363    }
364
365    fn observation(target: &str, not_retried: bool, wrapped: bool) -> CallObservation {
366        CallObservation {
367            target: target.to_owned(),
368            result: if not_retried {
369                CallResult::Failure
370            } else {
371                CallResult::Success
372            },
373            attempts: if not_retried { 1 } else { 2 },
374            latency_ms: 10,
375            throttled: false,
376            breaker_opened: false,
377            not_retried,
378            wrapped,
379            error: not_retried.then_some(ObservedError {
380                class: keel_journal::ErrorClass::Http,
381                http_status: Some(500),
382            }),
383        }
384    }
385
386    #[test]
387    fn coverage_gap_and_not_retried_surface_in_the_report() {
388        let dir = tempfile::TempDir::new().unwrap();
389        std::fs::create_dir_all(dir.path().join(".keel")).unwrap();
390        let clock = ManualClock::new(T0);
391        {
392            let store =
393                DiscoveryStore::open(dir.path().join(".keel").join("discovery.db"), clock).unwrap();
394            store
395                .record(&observation("api.stripe.com", true, true))
396                .unwrap();
397            store
398                .record(&observation("api.unconfigured.com", false, false))
399                .unwrap();
400        }
401
402        let r = run(dir.path(), T0);
403        assert_eq!(r.exit, crate::EXIT_OK);
404        assert_eq!(r.json["not_retried"], 1);
405        assert_eq!(r.json["unwrapped_calls"], 1);
406        assert!(
407            (r.json["wrapped_coverage"].as_f64().unwrap() - 0.5).abs() < f64::EPSILON,
408            "1 of 2 calls came from a target with no policy entry"
409        );
410        assert!(r.human.contains("observed, not retried"));
411        assert!(r.human.contains("api.stripe.com"));
412        assert!(r.human.contains("coverage gap"));
413    }
414
415    #[test]
416    fn week_window_sums_only_the_trailing_seven_stored_days() {
417        let dir = tempfile::TempDir::new().unwrap();
418        std::fs::create_dir_all(dir.path().join(".keel")).unwrap();
419        let clock = ManualClock::new(T0);
420        let db = dir.path().join(".keel").join("discovery.db");
421        {
422            let store = DiscoveryStore::open(&db, clock.clone()).unwrap();
423            store.record(&observation("api.x", false, true)).unwrap(); // day 0, in window
424            clock.advance(6 * MS_PER_DAY);
425            store.record(&observation("api.x", false, true)).unwrap(); // day 6, in window (edge)
426            clock.advance(MS_PER_DAY); // day 7: now outside a window ending at day 6
427            store.record(&observation("api.x", false, true)).unwrap(); // recorded so day 7 exists,
428            // but a report as-of day 6 must not see it.
429        }
430
431        let as_of_day_6 = T0 + 6 * MS_PER_DAY;
432        let r = run(dir.path(), as_of_day_6);
433        // Window is [day 0, day 6] inclusive: 2 of the 3 recorded calls.
434        assert_eq!(r.json["week"]["calls"], 2);
435        assert_eq!(
436            r.json["calls"], 3,
437            "lifetime total is unaffected by windowing"
438        );
439
440        let as_of_day_7 = T0 + 7 * MS_PER_DAY;
441        let r7 = run(dir.path(), as_of_day_7);
442        // Window slides to [day 1, day 7]: day 0's call falls out, day 7's falls in.
443        assert_eq!(r7.json["week"]["calls"], 2);
444    }
445}