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