Skip to main content

context_bar_core/
live.rs

1//! 5h-block burn status — the engine half of the live dashboard (ROADMAP B2)
2//! and the native popover gauge (C1). Pure: derives burn rate, % of limit,
3//! ETA-to-limit, and a projected block total from a snapshot + a clock.
4//!
5//! Our 5h window is ROLLING (the oldest turn ages out), not a fixed aligned
6//! block, so "projected total" and "ETA" are honest estimates of the current
7//! trajectory, not guarantees. `resets_at` is the oldest in-window turn + 5h
8//! (when the window first frees), which we use to recover the window start.
9
10use crate::aggregate::parse_iso;
11use crate::usage_signal::AgentUsage;
12
13/// Length of the rolling session window, seconds (5h).
14pub const WIN_SESSION_SECS: f64 = 5.0 * 3600.0;
15
16/// Burn snapshot for one agent's active 5h window.
17#[derive(Clone, Debug, Default, PartialEq, serde::Serialize)]
18pub struct BlockStatus {
19    pub tokens: u64,
20    pub cost: f64,
21    pub cache_read: u64,
22    /// Account utilization % of the 5h limit (from the usage API / statusline).
23    pub pct_of_limit: Option<f64>,
24    pub resets_at: Option<String>,
25    /// Seconds until the window frees (clamped ≥ 0).
26    pub secs_until_reset: Option<i64>,
27    /// Hours elapsed since the window's oldest turn (0..=5).
28    pub elapsed_hr: Option<f64>,
29    /// Spend rate over the elapsed window, USD/hour.
30    pub burn_cost_per_hr: Option<f64>,
31    /// Token rate over the elapsed window, tokens/minute.
32    pub burn_tokens_per_min: Option<f64>,
33    /// Projected window cost if the current rate holds to window end.
34    pub projected_cost: Option<f64>,
35    /// Seconds until the account limit is hit at the current %/hour, if known.
36    pub eta_to_limit_secs: Option<i64>,
37}
38
39/// Compute the active-block burn status for an agent, or `None` when the 5h
40/// window is empty (no active block).
41pub fn block_status(agent: &AgentUsage, now: f64) -> Option<BlockStatus> {
42    if agent.session_5h_tokens == 0 && agent.cost_5h <= 0.0 {
43        return None;
44    }
45
46    let mut s = BlockStatus {
47        tokens: agent.session_5h_tokens,
48        cost: agent.cost_5h,
49        cache_read: agent.cache_read_tokens_5h,
50        pct_of_limit: agent.session_5h_percent,
51        resets_at: agent.session_5h_resets_at.clone(),
52        ..Default::default()
53    };
54
55    // Recover the window start from resets_at (= oldest-in-window turn + 5h).
56    if let Some(reset_ts) = agent.session_5h_resets_at.as_deref().and_then(|r| parse_iso(Some(r))) {
57        let secs_until = (reset_ts - now).round() as i64;
58        s.secs_until_reset = Some(secs_until.max(0));
59
60        let window_start = reset_ts - WIN_SESSION_SECS;
61        let elapsed = (now - window_start).clamp(0.0, WIN_SESSION_SECS);
62        if elapsed > 0.0 {
63            let elapsed_hr = elapsed / 3600.0;
64            s.elapsed_hr = Some(elapsed_hr);
65            s.burn_cost_per_hr = Some(agent.cost_5h / elapsed_hr);
66            s.burn_tokens_per_min = Some(agent.session_5h_tokens as f64 / (elapsed / 60.0));
67
68            // Projection: keep the current rate for the remainder of the window.
69            let remaining_hr = (WIN_SESSION_SECS - elapsed) / 3600.0;
70            s.projected_cost = Some(agent.cost_5h + s.burn_cost_per_hr.unwrap() * remaining_hr);
71
72            // ETA to the account limit from the current utilization rate.
73            if let Some(pct) = agent.session_5h_percent {
74                if pct > 0.0 && pct < 100.0 {
75                    let pct_per_hr = pct / elapsed_hr;
76                    if pct_per_hr > 0.0 {
77                        let hrs_to_100 = (100.0 - pct) / pct_per_hr;
78                        s.eta_to_limit_secs = Some((hrs_to_100 * 3600.0).round() as i64);
79                    }
80                }
81            }
82        }
83    }
84
85    Some(s)
86}
87
88/// Quota tier for color-coding a gauge (green < 50% ≤ yellow < 80% ≤ red).
89#[derive(Clone, Copy, Debug, PartialEq, Eq)]
90pub enum Tier {
91    Ok,
92    Warn,
93    Critical,
94}
95
96impl Tier {
97    pub fn from_pct(pct: f64) -> Tier {
98        if pct >= 80.0 {
99            Tier::Critical
100        } else if pct >= 50.0 {
101            Tier::Warn
102        } else {
103            Tier::Ok
104        }
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    fn agent(tokens: u64, cost: f64, pct: Option<f64>, resets_at: Option<&str>) -> AgentUsage {
113        AgentUsage {
114            session_5h_tokens: tokens,
115            cost_5h: cost,
116            session_5h_percent: pct,
117            session_5h_resets_at: resets_at.map(str::to_string),
118            ..Default::default()
119        }
120    }
121
122    #[test]
123    fn empty_block_is_none() {
124        assert!(block_status(&agent(0, 0.0, None, None), 1_000.0).is_none());
125    }
126
127    #[test]
128    fn burn_and_projection_from_elapsed() {
129        // Window resets at t=18000 (5h). now=9000 → started at 0, elapsed 2.5h.
130        let now = 9000.0;
131        let resets = crate::aggregate::iso_utc(18000.0);
132        let a = agent(150_000, 10.0, Some(40.0), Some(&resets));
133        let s = block_status(&a, now).unwrap();
134        assert_eq!(s.secs_until_reset, Some(9000));
135        let eh = s.elapsed_hr.unwrap();
136        assert!((eh - 2.5).abs() < 1e-6, "elapsed {eh}");
137        // burn = $10 / 2.5h = $4/hr.
138        assert!((s.burn_cost_per_hr.unwrap() - 4.0).abs() < 1e-6);
139        // projected = 10 + 4 * 2.5 (remaining) = 20.
140        assert!((s.projected_cost.unwrap() - 20.0).abs() < 1e-6);
141        // tokens/min = 150000 / 150min = 1000.
142        assert!((s.burn_tokens_per_min.unwrap() - 1000.0).abs() < 1e-6);
143        // pct 40 over 2.5h → 16%/hr → 60% left → 3.75h → 13500s.
144        assert_eq!(s.eta_to_limit_secs, Some(13500));
145    }
146
147    #[test]
148    fn no_reset_still_reports_totals() {
149        let s = block_status(&agent(500, 1.0, None, None), 100.0).unwrap();
150        assert_eq!(s.tokens, 500);
151        assert!(s.burn_cost_per_hr.is_none());
152        assert!(s.secs_until_reset.is_none());
153    }
154
155    #[test]
156    fn tiers() {
157        assert_eq!(Tier::from_pct(10.0), Tier::Ok);
158        assert_eq!(Tier::from_pct(60.0), Tier::Warn);
159        assert_eq!(Tier::from_pct(95.0), Tier::Critical);
160    }
161}