1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
17#[serde(rename_all = "snake_case")]
18pub enum Stale {
19 NothingSignedIn,
20 SessionExpired,
22 ParkedAccessExpired,
24 NothingParked,
25 ParkUnreadable,
26 RateLimited,
27 Unreachable,
28 Unexpected,
29 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 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 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 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 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 pub signed_in: Result<Owner, String>,
100}
101
102struct Facts {
104 signed_in: Option<Result<Owner, ApiError>>,
107 asked: bool,
108 config_uuid: Option<String>,
112 live_usage: Result<Snapshot, Stale>,
113 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
125fn 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
142pub 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 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 (Some(Err(_)), _) | (None, false) => facts.config_uuid.as_deref(),
260 (None, true) => None,
261 };
262 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 #[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}