Skip to main content

pitboard_core/
status.rs

1//! `pitboard status`: who is signed in, what each account has left, and which accounts can
2//! be switched to.
3//!
4//! Numbers are asked of Anthropic rather than read from Claude Code's cache, which only moves
5//! when Claude Code itself asks. Every account is asked at once, so the command costs one
6//! round trip, not one per account.
7
8use crate::api::{self, ApiError, Owner};
9use crate::context::Context;
10use crate::state::{Park, State};
11use crate::usage::{Snapshot, Source};
12use crate::{claude, park, readings, store, time};
13use serde_json::Value;
14
15/// Why a reading is not live.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
17#[serde(rename_all = "snake_case")]
18pub enum Stale {
19    NothingSignedIn,
20    /// Claude Code's own session has expired; its next call renews it.
21    SessionExpired,
22    /// A parked login's access token has expired and could not be renewed this time.
23    ParkedAccessExpired,
24    NothingParked,
25    ParkUnreadable,
26    RateLimited,
27    Unreachable,
28    Unexpected,
29}
30
31impl Stale {
32    fn of(error: &ApiError, signed_in: bool) -> Stale {
33        match error {
34            ApiError::Unauthorized if signed_in => Stale::SessionExpired,
35            ApiError::Unauthorized => Stale::ParkedAccessExpired,
36            ApiError::RateLimited => Stale::RateLimited,
37            ApiError::Network(_) => Stale::Unreachable,
38            ApiError::Unexpected { .. } | ApiError::Malformed(_) | ApiError::InvalidGrant => {
39                Stale::Unexpected
40            }
41        }
42    }
43
44    /// Stable, for a program to branch on; the same as its JSON form.
45    pub fn code(self) -> &'static str {
46        match self {
47            Stale::NothingSignedIn => "nothing_signed_in",
48            Stale::SessionExpired => "session_expired",
49            Stale::ParkedAccessExpired => "parked_access_expired",
50            Stale::NothingParked => "nothing_parked",
51            Stale::ParkUnreadable => "park_unreadable",
52            Stale::RateLimited => "rate_limited",
53            Stale::Unreachable => "unreachable",
54            Stale::Unexpected => "unexpected",
55        }
56    }
57
58    /// Only what is worth a word: a parked login going quiet is how parking works.
59    pub fn explanation(self) -> Option<&'static str> {
60        match self {
61            Stale::NothingSignedIn => Some("nothing is signed in"),
62            Stale::SessionExpired => Some("Claude Code's session has expired; `claude` renews it"),
63            Stale::ParkedAccessExpired | Stale::NothingParked => None,
64            Stale::ParkUnreadable => Some("its parked login cannot be read; run `pitboard doctor`"),
65            Stale::RateLimited => Some("Anthropic is rate limiting usage checks"),
66            Stale::Unreachable => Some("Anthropic could not be reached"),
67            Stale::Unexpected => Some("Anthropic's answer was not understood"),
68        }
69    }
70}
71
72pub struct Row {
73    /// `None` for an account that is signed in but not enrolled.
74    pub label: Option<String>,
75    pub email: String,
76    pub account_uuid: String,
77    pub signed_in: bool,
78    pub parked: Option<Park>,
79    pub usage: Option<Snapshot>,
80    pub stale: Option<Stale>,
81}
82
83impl Row {
84    /// Whether `pitboard use` would switch to it now.
85    pub fn switchable(&self, now: i64) -> bool {
86        !self.signed_in && self.parked.as_ref().is_some_and(|p| p.restorable_at(now))
87    }
88}
89
90pub struct Report {
91    pub now: i64,
92    pub rows: Vec<Row>,
93    /// Who the live login belongs to, as Anthropic says: Claude Code's config can be a day
94    /// behind.
95    pub signed_in: Result<Owner, String>,
96}
97
98/// Everything gathered from the machine and the network, so assembling it touches neither.
99struct Facts {
100    signed_in: Option<Result<Owner, ApiError>>,
101    live_usage: Result<Snapshot, Stale>,
102    /// One per enrolled account, in order.
103    parked_usage: Vec<Result<Snapshot, Stale>>,
104    claude_code_cache: Option<Snapshot>,
105}
106
107fn access_token(oauth: &Value) -> Option<String> {
108    oauth
109        .get("accessToken")
110        .and_then(Value::as_str)
111        .map(str::to_owned)
112}
113
114/// The token to ask about a parked account with, or why there is none.
115fn parked_token(
116    ctx: &Context,
117    label: &str,
118    parked: Option<&Park>,
119    now: i64,
120) -> Result<String, Stale> {
121    match parked {
122        None => Err(Stale::NothingParked),
123        Some(p) if !p.askable_at(now) => Err(Stale::ParkedAccessExpired),
124        Some(p) => park::load(ctx, label, p)
125            .ok()
126            .and_then(|oauth| access_token(&oauth))
127            .ok_or(Stale::ParkUnreadable),
128    }
129}
130
131pub fn gather(ctx: &Context, state: &State) -> Report {
132    let now = time::now();
133    let live_token = store::read(ctx, &claude::live_service(ctx))
134        .ok()
135        .flatten()
136        .and_then(|doc| access_token(&doc["claudeAiOauth"]));
137    let parked_tokens: Vec<Result<String, Stale>> = state
138        .accounts
139        .iter()
140        .map(|a| parked_token(ctx, &a.label, a.parked.as_ref(), now))
141        .collect();
142
143    let (signed_in, live_usage, parked_usage) = std::thread::scope(|scope| {
144        let owner = scope.spawn(|| live_token.as_deref().map(|t| api::owner(ctx, t)));
145        let live = scope.spawn(|| match live_token.as_deref() {
146            Some(token) => api::usage(ctx, token).map_err(|e| Stale::of(&e, true)),
147            None => Err(Stale::NothingSignedIn),
148        });
149        let parked: Vec<_> = parked_tokens
150            .iter()
151            .map(|token| {
152                scope.spawn(move || {
153                    let token = token.as_deref().map_err(|stale| *stale)?;
154                    api::usage(ctx, token).map_err(|e| Stale::of(&e, false))
155                })
156            })
157            .collect();
158        (
159            owner.join().ok().flatten(),
160            live.join().unwrap_or(Err(Stale::Unexpected)),
161            parked
162                .into_iter()
163                .map(|h| h.join().unwrap_or(Err(Stale::Unexpected)))
164                .collect(),
165        )
166    });
167
168    let facts = Facts {
169        signed_in,
170        live_usage,
171        parked_usage,
172        claude_code_cache: claude::load_config(ctx)
173            .ok()
174            .as_ref()
175            .and_then(crate::usage::from_config_cache),
176    };
177    let remembered = readings::load(ctx);
178    let rows = assemble(state, &facts, |uuid| remembered.get(uuid).cloned());
179    readings::remember(
180        ctx,
181        &rows
182            .iter()
183            .filter_map(|r| {
184                r.usage
185                    .as_ref()
186                    .filter(|u| u.source == Source::Live)
187                    .map(|u| (r.account_uuid.clone(), u.clone()))
188            })
189            .collect::<Vec<_>>(),
190    );
191
192    Report {
193        now,
194        rows,
195        signed_in: match facts.signed_in {
196            Some(Ok(owner)) => Ok(owner),
197            Some(Err(e)) => Err(e.to_string()),
198            None => Err("nothing is signed in".into()),
199        },
200    }
201}
202
203fn assemble(state: &State, facts: &Facts, recall: impl Fn(&str) -> Option<Snapshot>) -> Vec<Row> {
204    let live_uuid = match &facts.signed_in {
205        Some(Ok(owner)) => Some(owner.account_uuid.as_str()),
206        _ => None,
207    };
208    // Claude Code's cache counts only when it was measured for the account in question.
209    let cached_for = |uuid: &str| {
210        facts
211            .claude_code_cache
212            .clone()
213            .filter(|c| c.account_uuid.as_deref() == Some(uuid))
214    };
215    let reading = |asked: &Result<Snapshot, Stale>, fallback: Option<Snapshot>| match asked {
216        Ok(live) => (Some(live.clone()), None),
217        Err(stale) => (fallback, Some(*stale)),
218    };
219
220    let mut rows: Vec<Row> = state
221        .accounts
222        .iter()
223        .zip(&facts.parked_usage)
224        .map(|(account, parked)| {
225            let uuid = account.account_uuid.as_str();
226            let signed_in = live_uuid == Some(uuid);
227            let (usage, stale) = if signed_in {
228                reading(&facts.live_usage, cached_for(uuid).or_else(|| recall(uuid)))
229            } else {
230                reading(parked, recall(uuid))
231            };
232            Row {
233                label: Some(account.label.clone()),
234                email: account.email.clone(),
235                account_uuid: account.account_uuid.clone(),
236                signed_in,
237                parked: account.parked.clone(),
238                usage,
239                stale,
240            }
241        })
242        .collect();
243
244    if let Some(Ok(owner)) = &facts.signed_in
245        && !rows.iter().any(|r| r.signed_in)
246    {
247        let (usage, stale) = reading(&facts.live_usage, cached_for(&owner.account_uuid));
248        rows.push(Row {
249            label: None,
250            email: owner.email.clone(),
251            account_uuid: owner.account_uuid.clone(),
252            signed_in: true,
253            parked: None,
254            usage,
255            stale,
256        });
257    }
258
259    rows.sort_by_key(|r| !r.signed_in);
260    rows
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266    use crate::state::Account;
267    use crate::usage::Window;
268    use serde_json::json;
269
270    const NOW: i64 = 1_789_935_000;
271
272    fn owner(uuid: &str) -> Owner {
273        Owner {
274            account_uuid: uuid.into(),
275            email: format!("{uuid}@example.com"),
276            organization_uuid: "org".into(),
277        }
278    }
279
280    fn reading(percent: f64, source: Source, account: Option<&str>) -> Snapshot {
281        Snapshot {
282            windows: vec![Window {
283                kind: "session".into(),
284                scope: None,
285                percent,
286                resets_at: Some(NOW + 3_600),
287                is_active: true,
288            }],
289            observed_at: Some(NOW - 7_200),
290            account_uuid: account.map(str::to_owned),
291            source,
292        }
293    }
294
295    fn parked(refresh_expires_at: i64) -> Park {
296        Park {
297            service: "pitboard-park-x-1".into(),
298            parked_at: NOW - 86_400,
299            refresh_fingerprint: "f".into(),
300            access_expires_at: Some(NOW - 3_600),
301            refresh_expires_at: Some(refresh_expires_at),
302        }
303    }
304
305    fn account(label: &str) -> Account {
306        Account {
307            label: label.into(),
308            account_uuid: format!("{label}-uuid"),
309            email: format!("{label}@example.com"),
310            organization_uuid: "org".into(),
311            oauth_account: json!({}),
312            parked: Some(parked(NOW + 20 * 86_400)),
313        }
314    }
315
316    fn state(labels: &[&str]) -> State {
317        State {
318            accounts: labels.iter().map(|l| account(l)).collect(),
319            ..State::default()
320        }
321    }
322
323    fn facts(
324        signed_in: &str,
325        live: Result<Snapshot, Stale>,
326        parked: Vec<Result<Snapshot, Stale>>,
327    ) -> Facts {
328        Facts {
329            signed_in: Some(Ok(owner(signed_in))),
330            live_usage: live,
331            parked_usage: parked,
332            claude_code_cache: None,
333        }
334    }
335
336    fn nothing_remembered(_: &str) -> Option<Snapshot> {
337        None
338    }
339
340    #[test]
341    fn every_stale_code_is_its_json_form() {
342        for stale in [
343            Stale::NothingSignedIn,
344            Stale::SessionExpired,
345            Stale::ParkedAccessExpired,
346            Stale::NothingParked,
347            Stale::ParkUnreadable,
348            Stale::RateLimited,
349            Stale::Unreachable,
350            Stale::Unexpected,
351        ] {
352            assert_eq!(serde_json::to_value(stale).unwrap(), stale.code());
353        }
354    }
355
356    #[test]
357    fn a_live_reading_wins_over_claude_codes_cache() {
358        let s = state(&["work"]);
359        let mut f = facts(
360            "work-uuid",
361            Ok(reading(30.0, Source::Live, None)),
362            vec![Err(Stale::NothingParked)],
363        );
364        f.claude_code_cache = Some(reading(2.0, Source::ClaudeCodeCache, Some("work-uuid")));
365        let rows = assemble(&s, &f, nothing_remembered);
366        let usage = rows[0].usage.as_ref().unwrap();
367        assert_eq!(usage.source, Source::Live);
368        assert_eq!(usage.windows[0].percent, 30.0);
369        assert_eq!(rows[0].stale, None);
370    }
371
372    #[test]
373    fn an_expired_session_falls_back_to_claude_codes_cache_and_says_why() {
374        let s = state(&["work"]);
375        let mut f = facts(
376            "work-uuid",
377            Err(Stale::SessionExpired),
378            vec![Err(Stale::NothingParked)],
379        );
380        f.claude_code_cache = Some(reading(2.0, Source::ClaudeCodeCache, Some("work-uuid")));
381        let rows = assemble(&s, &f, nothing_remembered);
382        assert_eq!(
383            rows[0].usage.as_ref().unwrap().source,
384            Source::ClaudeCodeCache
385        );
386        assert_eq!(rows[0].stale, Some(Stale::SessionExpired));
387    }
388
389    #[test]
390    fn claude_codes_cache_for_another_account_is_never_shown_as_this_one() {
391        let s = state(&["work"]);
392        let mut f = facts(
393            "work-uuid",
394            Err(Stale::Unreachable),
395            vec![Err(Stale::NothingParked)],
396        );
397        f.claude_code_cache = Some(reading(99.0, Source::ClaudeCodeCache, Some("someone-else")));
398        assert!(assemble(&s, &f, nothing_remembered)[0].usage.is_none());
399    }
400
401    #[test]
402    fn a_parked_account_is_asked_with_its_own_login() {
403        let s = state(&["work", "personal"]);
404        let f = facts(
405            "work-uuid",
406            Ok(reading(30.0, Source::Live, None)),
407            vec![
408                Err(Stale::NothingParked),
409                Ok(reading(12.0, Source::Live, None)),
410            ],
411        );
412        let rows = assemble(&s, &f, nothing_remembered);
413        let personal = rows
414            .iter()
415            .find(|r| r.label.as_deref() == Some("personal"))
416            .unwrap();
417        assert_eq!(personal.usage.as_ref().unwrap().windows[0].percent, 12.0);
418        assert!(personal.switchable(NOW));
419    }
420
421    #[test]
422    fn a_parked_login_past_its_access_expiry_is_not_asked() {
423        let ctx = Context::from_env();
424        let token = parked_token(&ctx, "personal", Some(&parked(NOW + 86_400)), NOW);
425        assert_eq!(token, Err(Stale::ParkedAccessExpired));
426        assert_eq!(
427            parked_token(&ctx, "personal", None, NOW),
428            Err(Stale::NothingParked)
429        );
430    }
431
432    #[test]
433    fn what_cannot_be_asked_falls_back_to_what_was_remembered() {
434        let s = state(&["work", "personal"]);
435        let f = facts(
436            "work-uuid",
437            Ok(reading(30.0, Source::Live, None)),
438            vec![Err(Stale::NothingParked), Err(Stale::ParkedAccessExpired)],
439        );
440        let remembered = |uuid: &str| {
441            (uuid == "personal-uuid").then(|| reading(44.0, Source::Remembered, Some(uuid)))
442        };
443        let rows = assemble(&s, &f, remembered);
444        let personal = rows
445            .iter()
446            .find(|r| r.label.as_deref() == Some("personal"))
447            .unwrap();
448        assert_eq!(personal.usage.as_ref().unwrap().source, Source::Remembered);
449        assert_eq!(personal.stale, Some(Stale::ParkedAccessExpired));
450    }
451
452    #[test]
453    fn the_signed_in_account_comes_first_and_is_taken_from_the_server_not_the_config() {
454        let s = state(&["alpha", "beta"]);
455        let f = facts(
456            "beta-uuid",
457            Ok(reading(5.0, Source::Live, None)),
458            vec![Err(Stale::NothingParked), Err(Stale::NothingParked)],
459        );
460        let rows = assemble(&s, &f, nothing_remembered);
461        assert_eq!(rows[0].label.as_deref(), Some("beta"));
462        assert!(rows[0].signed_in && !rows[0].switchable(NOW));
463        assert!(!rows[1].signed_in);
464    }
465}