1use crate::api::{self, ApiError, Owner};
13use crate::context::Context;
14use crate::error::Cause;
15use crate::provider::claude::paths as claude;
16use crate::provider::{ProviderError, ProviderId};
17use crate::state::{Key, Park, State};
18use crate::usage::{Snapshot, Source, merge};
19use crate::{budget, park, readings};
20use serde_json::Value;
21use std::collections::{BTreeMap, HashMap};
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
25#[serde(rename_all = "snake_case")]
26#[non_exhaustive]
27pub enum Stale {
28 NothingSignedIn,
29 LoginUnreadable,
34 LoginUnusable,
39 SessionExpired,
41 ParkedAccessExpired,
43 NothingParked,
44 ParkUnreadable,
45 RateLimited,
46 Unreachable,
47 ServerError,
49 AnswerNotUnderstood,
52 LoginRefused,
54 AskedRecently,
57 Interrupted,
60 NotAsked,
62}
63
64impl Stale {
65 fn of(error: &ApiError, signed_in: bool) -> Stale {
69 match Cause::of(error) {
70 Cause::TokenExpired if signed_in => Stale::SessionExpired,
71 Cause::TokenExpired => Stale::ParkedAccessExpired,
72 Cause::RateLimited => Stale::RateLimited,
73 Cause::Unreachable => Stale::Unreachable,
74 Cause::ServerError => Stale::ServerError,
75 Cause::AnswerNotUnderstood => Stale::AnswerNotUnderstood,
76 Cause::LoginRefused => Stale::LoginRefused,
77 }
78 }
79
80 pub fn code(self) -> &'static str {
82 match self {
83 Stale::NothingSignedIn => "nothing_signed_in",
84 Stale::LoginUnreadable => "login_unreadable",
85 Stale::LoginUnusable => "login_unusable",
86 Stale::SessionExpired => "session_expired",
87 Stale::ParkedAccessExpired => "parked_access_expired",
88 Stale::NothingParked => "nothing_parked",
89 Stale::ParkUnreadable => "park_unreadable",
90 Stale::RateLimited => "rate_limited",
91 Stale::Unreachable => "unreachable",
92 Stale::ServerError => "server_error",
93 Stale::AnswerNotUnderstood => "answer_not_understood",
94 Stale::LoginRefused => "login_refused",
95 Stale::AskedRecently => "asked_recently",
96 Stale::Interrupted => "interrupted",
97 Stale::NotAsked => "not_asked",
98 }
99 }
100
101 pub fn explanation(self) -> Option<&'static str> {
106 self.explanation_for(crate::label::DEFAULT)
107 }
108
109 pub fn explanation_for(self, provider: ProviderId) -> Option<&'static str> {
115 let per_tool = |claude: &'static str, codex: &'static str| match provider {
118 ProviderId::Claude => claude,
119 ProviderId::Codex => codex,
120 };
121 match self {
122 Stale::NothingSignedIn => Some("nothing is signed in"),
123 Stale::LoginUnreadable => Some(per_tool(
124 "Claude Code's login could not be read; run `pitboard doctor`",
125 "Codex's login could not be read; run `pitboard doctor`",
126 )),
127 Stale::LoginUnusable => Some(per_tool(
128 "Claude Code's login is not one Pitboard can park or switch; run `pitboard doctor`",
129 "Codex's login is not one Pitboard can park or switch; run `pitboard doctor`",
130 )),
131 Stale::SessionExpired => Some(per_tool(
132 "Claude Code's session has expired; `claude` renews it",
133 "Codex's session has expired; `codex` renews it",
134 )),
135 Stale::ParkedAccessExpired | Stale::NothingParked => None,
136 Stale::ParkUnreadable => Some("its parked login cannot be read; run `pitboard doctor`"),
137 Stale::RateLimited => Some(per_tool(
138 "Anthropic is rate limiting usage checks",
139 "OpenAI is rate limiting usage checks",
140 )),
141 Stale::Unreachable => Some(per_tool(
142 "Anthropic could not be reached",
143 "OpenAI could not be reached",
144 )),
145 Stale::ServerError => Some(per_tool(
146 "Anthropic answered with an error; try again later",
147 "OpenAI answered with an error; try again later",
148 )),
149 Stale::AnswerNotUnderstood => Some(per_tool(
150 "Anthropic's answer was not understood",
151 "OpenAI's answer was not understood",
152 )),
153 Stale::LoginRefused => Some("its parked login is no longer accepted; sign in again"),
154 Stale::AskedRecently => None,
157 Stale::Interrupted => Some("the check did not finish"),
158 Stale::NotAsked => Some(per_tool(
159 "read without asking Anthropic",
160 "read without asking OpenAI",
161 )),
162 }
163 }
164}
165
166pub struct Row {
167 pub provider: ProviderId,
174 pub label: Option<String>,
177 pub email: String,
178 pub id: String,
181 pub account_uuid: String,
182 pub organization_uuid: Option<String>,
184 pub signed_in: bool,
185 pub parked: Option<Park>,
186 pub usage: Option<Snapshot>,
187 pub stale: Option<Stale>,
188}
189
190impl Row {
191 pub fn lasts(&self, now: i64) -> Option<crate::pace::Lasts<'_>> {
196 let usage = self.usage.as_ref()?;
197 if self.signed_in {
198 crate::pace::lasts(&usage.windows, usage.observed_at.unwrap_or(now), now)
199 } else {
200 crate::pace::until_reset(&usage.windows, now)
201 }
202 }
203
204 pub fn switchable(&self, now: i64) -> bool {
206 !self.signed_in && self.parked.as_ref().is_some_and(|p| p.restorable_at(now))
207 }
208
209 pub fn key(&self) -> Option<Key> {
212 self.label
213 .as_ref()
214 .map(|label| Key::new(self.provider, label.clone()))
215 }
216
217 pub fn explanation(&self) -> Option<&'static str> {
219 self.stale
220 .and_then(|stale| stale.explanation_for(self.provider))
221 }
222
223 pub fn unplaced(&self) -> bool {
228 self.label.is_none()
229 && !self.signed_in
230 && matches!(
231 self.stale,
232 Some(Stale::LoginUnreadable | Stale::LoginUnusable)
233 )
234 }
235}
236
237pub struct Report {
238 pub now: i64,
239 pub rows: Vec<Row>,
240 pub signed_in: Result<Owner, String>,
243 pub slot: Slot,
251}
252
253#[derive(Debug, Clone, PartialEq, Eq)]
255pub struct Slot {
256 pub service: String,
258 pub default: bool,
260}
261
262#[derive(Default)]
268struct LiveLogin {
269 signed_in: Option<Result<Owner, String>>,
274 recorded_id: Option<String>,
278 usage: Option<Result<Snapshot, Stale>>,
279 out_of_reach: bool,
283}
284
285impl LiveLogin {
286 fn usage(&self) -> Result<Snapshot, Stale> {
287 self.usage.clone().unwrap_or(Err(Stale::NothingSignedIn))
288 }
289}
290
291struct Facts {
293 live: BTreeMap<ProviderId, LiveLogin>,
294 asked: bool,
295 parked_usage: Vec<Result<Snapshot, Stale>>,
297 claude_code_cache: Option<Snapshot>,
298}
299
300impl Facts {
301 fn live_for(&self, which: ProviderId) -> Option<&LiveLogin> {
302 self.live.get(&which)
303 }
304}
305
306fn parked_document(
311 ctx: &Context,
312 key: &Key,
313 parked: Option<&Park>,
314 now: i64,
315) -> Result<Value, Stale> {
316 match parked {
317 None => Err(Stale::NothingParked),
318 Some(p) if !p.askable_at(now) => Err(Stale::ParkedAccessExpired),
319 Some(p) => park::load(ctx, key, p).map_err(|_| Stale::ParkUnreadable),
320 }
321}
322
323fn slot_of(ctx: &Context) -> Slot {
325 Slot {
326 service: claude::live_service(ctx),
327 default: claude::is_default_slot(ctx),
328 }
329}
330
331fn in_use(state: &State) -> Vec<ProviderId> {
348 ProviderId::ALL
349 .iter()
350 .copied()
351 .filter(|&which| {
352 which == crate::label::DEFAULT || state.accounts.iter().any(|a| a.provider() == which)
353 })
354 .collect()
355}
356
357pub fn gather_offline(ctx: &Context, state: &State) -> Report {
358 let tools = in_use(state);
359 let recorded: BTreeMap<ProviderId, Owner> = tools
360 .iter()
361 .filter_map(|&which| {
362 let found = crate::provider::of(which).recorded_identity(ctx)?;
363 Some((which, Owner::from(found)))
364 })
365 .collect();
366 let facts = Facts {
367 live: tools
368 .iter()
369 .map(|&which| {
370 let login = LiveLogin {
371 recorded_id: recorded.get(&which).map(|owner| state.id_of(which, owner)),
372 usage: Some(Err(Stale::NotAsked)),
373 ..LiveLogin::default()
374 };
375 (which, login)
376 })
377 .collect(),
378 asked: false,
379 parked_usage: state
380 .accounts
381 .iter()
382 .map(|_| Err(Stale::NotAsked))
383 .collect(),
384 claude_code_cache: claude::load_config(ctx)
385 .ok()
386 .as_ref()
387 .and_then(crate::usage::from_config_cache),
388 };
389 let remembered = readings::load(ctx);
390 Report {
391 now: ctx.now(),
392 slot: slot_of(ctx),
393 rows: assemble(
394 state,
395 &facts,
396 |uuid| remembered.get(uuid).cloned(),
397 ctx.now(),
398 ),
399 signed_in: recorded
400 .get(&crate::label::DEFAULT)
401 .cloned()
402 .ok_or_else(|| "not asked".into()),
403 }
404}
405
406type Asked = (Result<Snapshot, Stale>, Option<(String, budget::Outcome)>);
410
411type Answered = (LiveLogin, Option<(String, budget::Outcome)>);
414
415fn to_api(error: crate::provider::ProviderError) -> ApiError {
417 use crate::provider::ProviderError as P;
418 match error {
419 P::Unauthorized => ApiError::Unauthorized,
420 P::RateLimited { retry_after, .. } => ApiError::RateLimited { retry_after },
421 P::Network { detail, .. } => ApiError::Network(detail),
422 P::Unexpected { status, .. } => ApiError::Unexpected { status },
423 P::Malformed { detail, .. }
424 | P::ShapeUnexpected { detail, .. }
425 | P::Unsupported { reason: detail, .. } => ApiError::Malformed(detail),
426 P::NoLogin { .. } => ApiError::Malformed("nothing is signed in".into()),
427 P::InvalidGrant { .. } => ApiError::InvalidGrant,
428 }
429}
430
431fn ask_usage(
433 ctx: &Context,
434 which: ProviderId,
435 id: Option<&str>,
436 document: &Value,
437 signed_in: bool,
438 remembered: Option<&Snapshot>,
439 fresh: bool,
440) -> Asked {
441 if let Some(id) = id
444 && let Some(held) = budget::may_ask(ctx, id, remembered, fresh)
445 {
446 let stale = match held {
447 budget::Held::Fresh => Stale::AskedRecently,
448 budget::Held::RateLimited => Stale::RateLimited,
449 budget::Held::Unreachable => Stale::Unreachable,
450 };
451 return (Err(stale), None);
452 }
453 let answer = crate::provider::of(which).usage(
457 ctx,
458 &crate::provider::Credential::new(which, document.clone()),
459 );
460 if !signed_in
464 && matches!(
465 answer,
466 Err(crate::provider::ProviderError::ShapeUnexpected { .. })
467 )
468 {
469 return (Err(Stale::ParkUnreadable), None);
470 }
471 let answer = answer.map_err(to_api);
472 let learned = id.and_then(|id| {
473 let outcome = match &answer {
474 Ok(_) => budget::Outcome::Answered,
475 Err(api::ApiError::RateLimited { retry_after }) => {
476 budget::Outcome::RateLimited(*retry_after)
477 }
478 Err(api::ApiError::Network(_) | api::ApiError::Unexpected { .. }) => {
479 budget::Outcome::Unreachable
480 }
481 Err(_) => return None,
484 };
485 Some((id.to_string(), outcome))
486 });
487 (answer.map_err(|e| Stale::of(&e, signed_in)), learned)
488}
489
490fn ask_live(
503 ctx: &Context,
504 state: &State,
505 which: ProviderId,
506 read: &Result<Option<Value>, ProviderError>,
507 remembered: &HashMap<String, Snapshot>,
508 fresh: bool,
509) -> Answered {
510 let tool = crate::provider::of(which);
511 let own_record = || recorded_id(ctx, state, which);
512 let document = match read {
513 Ok(Some(document)) => document,
514 Ok(None) => return (LiveLogin::default(), None),
515 Err(error) => {
516 let unreadable = LiveLogin {
517 signed_in: Some(Err(error.to_string())),
518 recorded_id: own_record().or_else(|| active_id(state, which).map(str::to_owned)),
519 usage: Some(Err(Stale::LoginUnreadable)),
520 out_of_reach: true,
521 };
522 return (unreadable, None);
523 }
524 };
525 match tool.slice(document) {
526 Ok(_) => {}
527 Err(ProviderError::NoLogin { .. }) => return (LiveLogin::default(), None),
533 Err(unusable) => {
541 let login = LiveLogin {
542 signed_in: Some(Err(unusable.to_string())),
543 recorded_id: own_record(),
544 usage: Some(Err(Stale::LoginUnusable)),
545 out_of_reach: true,
546 };
547 return (login, None);
548 }
549 }
550 let credential = crate::provider::Credential::new(which, document.clone());
551 let owner = tool
554 .identify(ctx, &credential)
555 .map(Owner::from)
556 .map_err(|e| to_api(e).to_string());
557 let recorded = match &owner {
563 Ok(_) => None,
564 Err(_) => own_record(),
565 };
566 let id = owner
567 .as_ref()
568 .ok()
569 .map(|o| state.id_of(which, o))
570 .or_else(|| recorded.clone());
571 let (usage, learned) = ask_usage(
572 ctx,
573 which,
574 id.as_deref(),
575 document,
576 true,
577 id.as_deref().and_then(|id| remembered.get(id)),
578 fresh,
579 );
580 let login = LiveLogin {
581 signed_in: Some(owner),
582 recorded_id: recorded,
583 usage: Some(usage),
584 out_of_reach: false,
585 };
586 (login, learned)
587}
588
589fn settle(
596 ctx: &Context,
597 state: &State,
598 answers: Vec<(ProviderId, std::thread::Result<Answered>)>,
599) -> (
600 BTreeMap<ProviderId, LiveLogin>,
601 Vec<(String, budget::Outcome)>,
602) {
603 let mut live = BTreeMap::new();
604 let mut learned = Vec::new();
605 for (which, answer) in answers {
606 let (login, outcome) = answer.unwrap_or_else(|_| {
607 let interrupted = LiveLogin {
610 signed_in: Some(Err("the check did not finish".into())),
611 recorded_id: recorded_id(ctx, state, which),
612 usage: Some(Err(Stale::Interrupted)),
613 out_of_reach: false,
614 };
615 (interrupted, None)
616 });
617 live.insert(which, login);
618 learned.extend(outcome);
619 }
620 (live, learned)
621}
622
623pub fn gather(ctx: &Context, permit: crate::service::Permit, state: &State, fresh: bool) -> Report {
624 let now = ctx.now();
625 let live_documents: Vec<(ProviderId, Result<Option<Value>, ProviderError>)> = in_use(state)
629 .into_iter()
630 .map(|which| {
631 let read = crate::provider::of(which)
632 .read_live(ctx)
633 .map(|found| found.map(|credential| credential.raw));
634 (which, read)
635 })
636 .collect();
637 let parked_documents: Vec<Result<Value, Stale>> = state
638 .accounts
639 .iter()
640 .map(|a| parked_document(ctx, &a.key(), a.parked.as_ref(), now))
641 .collect();
642 let config = claude::load_config(ctx).ok();
643 let remembered = readings::load(ctx);
644
645 let (answers, parked_asked): (Vec<(ProviderId, std::thread::Result<Answered>)>, Vec<Asked>) =
646 std::thread::scope(|scope| {
647 let per_tool: Vec<_> = live_documents
650 .iter()
651 .map(|(which, read)| {
652 let remembered = &remembered;
653 let which = *which;
654 let handle =
655 scope.spawn(move || ask_live(ctx, state, which, read, remembered, fresh));
656 (which, handle)
657 })
658 .collect();
659 let parked: Vec<_> = parked_documents
660 .iter()
661 .zip(state.accounts.iter())
662 .map(|(token, account)| {
663 let remembered = &remembered;
664 scope.spawn(move || match token.as_ref() {
665 Err(stale) => (Err(*stale), None),
666 Ok(document) => ask_usage(
667 ctx,
668 account.provider(),
669 Some(&account.id),
670 document,
671 false,
672 remembered.get(&account.id),
673 fresh,
674 ),
675 })
676 })
677 .collect();
678 (
679 per_tool
680 .into_iter()
681 .map(|(which, handle)| (which, handle.join()))
682 .collect(),
683 parked
684 .into_iter()
685 .map(|h| h.join().unwrap_or((Err(Stale::Interrupted), None)))
686 .collect(),
687 )
688 });
689 let (live, live_learned) = settle(ctx, state, answers);
690
691 let learned: Vec<(String, budget::Outcome)> = live_learned
695 .into_iter()
696 .chain(
697 parked_asked
698 .iter()
699 .filter_map(|(_, learned)| learned.clone()),
700 )
701 .collect();
702 budget::record(ctx, permit, &learned);
703
704 let signed_in_default = live
705 .get(&crate::label::DEFAULT)
706 .and_then(|login| login.signed_in.clone());
707 let facts = Facts {
708 live,
709 asked: true,
710 parked_usage: parked_asked.into_iter().map(|(usage, _)| usage).collect(),
711 claude_code_cache: config.as_ref().and_then(crate::usage::from_config_cache),
712 };
713 let rows = assemble(state, &facts, |uuid| remembered.get(uuid).cloned(), now);
714 crate::home::remove_retired(ctx, permit);
715 readings::remember(
716 ctx,
717 permit,
718 &rows
719 .iter()
720 .filter_map(|r| {
721 r.usage
722 .as_ref()
723 .filter(|u| u.source == Source::Live)
724 .map(|u| (r.id.clone(), u.clone()))
725 })
726 .collect::<Vec<_>>(),
727 );
728
729 Report {
730 slot: slot_of(ctx),
731 now,
732 rows,
733 signed_in: match signed_in_default {
737 Some(Ok(owner)) => Ok(owner),
738 Some(Err(why)) => Err(why),
739 None => Err("nothing is signed in".into()),
740 },
741 }
742}
743
744fn active_id(state: &State, which: ProviderId) -> Option<&str> {
746 let label = state.active_for(which)?;
747 state
748 .get(&Key::new(which, label))
749 .map(|account| account.id.as_str())
750}
751
752fn recorded_id(ctx: &Context, state: &State, which: ProviderId) -> Option<String> {
754 crate::provider::of(which)
755 .recorded_identity(ctx)
756 .map(|found| state.id_of(which, &Owner::from(found)))
757}
758
759fn rank(which: ProviderId) -> usize {
761 ProviderId::ALL
762 .iter()
763 .position(|&known| known == which)
764 .unwrap_or(usize::MAX)
765}
766
767fn assemble(
768 state: &State,
769 facts: &Facts,
770 recall: impl Fn(&str) -> Option<Snapshot>,
771 now: i64,
772) -> Vec<Row> {
773 let live_ids: BTreeMap<ProviderId, String> = ProviderId::ALL
777 .iter()
778 .filter_map(|&which| {
779 let live = facts.live_for(which)?;
780 let id = match (&live.signed_in, facts.asked) {
781 (Some(Ok(owner)), _) => Some(state.id_of(which, owner)),
782 (Some(Err(_)), _) | (None, false) => live.recorded_id.clone(),
786 (None, true) => None,
787 }?;
788 Some((which, id))
789 })
790 .collect();
791 let cached_for = |which: ProviderId, uuid: &str| {
794 facts
795 .claude_code_cache
796 .clone()
797 .filter(|c| which == ProviderId::Claude && c.account_uuid.as_deref() == Some(uuid))
798 };
799 let reading = |id: &str, asked: &Result<Snapshot, Stale>, cached: Option<Snapshot>| {
804 let recalled = recall(id);
805 match asked {
806 Ok(live) => (merge(recalled.as_ref(), Some(live), now), None),
807 Err(stale) => (merge(recalled.as_ref(), cached.as_ref(), now), Some(*stale)),
808 }
809 };
810
811 let mut rows: Vec<Row> = state
812 .accounts
813 .iter()
814 .zip(&facts.parked_usage)
815 .map(|(account, parked)| {
816 let id = account.id.as_str();
817 let which = account.provider();
818 let signed_in = live_ids.get(&which).is_some_and(|live| live == id);
819 let live_unreadable = facts
820 .live_for(which)
821 .is_some_and(|live| live.usage() == Err(Stale::LoginUnreadable));
822 let (usage, stale) = if signed_in {
823 let live = facts
824 .live_for(which)
825 .map_or(Err(Stale::NothingSignedIn), LiveLogin::usage);
826 reading(id, &live, cached_for(which, &account.account_uuid))
827 } else if account.parked.is_none()
828 && live_unreadable
829 && state.active_for(which) == Some(account.label.as_str())
830 {
831 reading(id, &Err(Stale::LoginUnreadable), None)
834 } else {
835 reading(id, parked, None)
836 };
837 Row {
838 provider: which,
839 label: Some(account.label.clone()),
840 email: account.email.clone(),
841 id: account.id.clone(),
842 account_uuid: account.account_uuid.clone(),
843 organization_uuid: account.claude().map(|c| c.organization_uuid.to_owned()),
844 signed_in,
845 parked: account.parked.clone(),
846 usage,
847 stale,
848 }
849 })
850 .collect();
851
852 for &which in ProviderId::ALL {
857 let Some(live) = facts.live_for(which) else {
858 continue;
859 };
860 if let Some(Ok(owner)) = &live.signed_in {
861 let id = state.id_of(which, owner);
862 if rows.iter().any(|r| r.provider == which && r.id == id) {
863 continue;
864 }
865 let (usage, stale) =
866 reading(&id, &live.usage(), cached_for(which, &owner.account_uuid));
867 rows.push(Row {
868 provider: which,
869 label: None,
870 email: owner.email.clone(),
871 id,
872 account_uuid: owner.account_uuid.clone(),
873 organization_uuid: (which == ProviderId::Claude)
874 .then(|| owner.organization_uuid.clone()),
875 signed_in: true,
876 parked: None,
877 usage,
878 stale,
879 });
880 continue;
881 }
882 let enrolled = state.accounts.iter().any(|a| a.provider() == which);
888 let placed = rows.iter().any(|r| r.provider == which && r.signed_in);
889 if live.out_of_reach && enrolled && !placed {
890 rows.push(Row {
891 provider: which,
892 label: None,
893 email: String::new(),
894 id: String::new(),
895 account_uuid: String::new(),
896 organization_uuid: None,
897 signed_in: false,
898 parked: None,
899 usage: None,
900 stale: live.usage().err(),
901 });
902 }
903 }
904
905 rows.sort_by_key(|r| (rank(r.provider), !r.signed_in));
909 rows
910}
911
912#[cfg(test)]
913mod tests {
914 use super::*;
915 use crate::api::scripted::{Asked as Question, ScriptedApi, Trouble};
916 use crate::host::memory::MemoryHost;
917 use crate::service::Permit;
918 use crate::state::Account;
919 use crate::store::memory::Fault;
920 use crate::time::{Clock, FixedClock};
921 use crate::usage::Window;
922 use serde_json::json;
923 use std::sync::Arc;
924
925 const NOW: i64 = 1_789_935_000;
926
927 fn owner(uuid: &str) -> Owner {
928 Owner {
929 account_uuid: uuid.into(),
930 email: format!("{uuid}@example.com"),
931 organization_uuid: "org".into(),
932 }
933 }
934
935 fn reading(percent: f64, source: Source, account: Option<&str>) -> Snapshot {
936 Snapshot {
937 windows: vec![Window {
938 kind: "session".into(),
939 scope: None,
940 severity: None,
941 percent,
942 resets_at: Some(NOW + 3_600),
943 is_active: true,
944 length_seconds: None,
945 }],
946 observed_at: Some(NOW - 7_200),
947 account_uuid: account.map(str::to_owned),
948 source,
949 }
950 }
951
952 fn parked(refresh_expires_at: i64) -> Park {
953 Park {
954 service: "pitboard-park-x-1".into(),
955 parked_at: NOW - 86_400,
956 refresh_fingerprint: "f".into(),
957 access_expires_at: Some(NOW - 3_600),
958 refresh_expires_at: Some(refresh_expires_at),
959 }
960 }
961
962 fn account(label: &str) -> Account {
963 Account {
964 last_used_at: None,
965 label: label.into(),
966 id: format!("{label}-uuid"),
967 account_uuid: format!("{label}-uuid"),
968 email: format!("{label}@example.com"),
969 detail: crate::state::Detail::Claude {
970 organization_uuid: "org".into(),
971 oauth_account: json!({}),
972 },
973 parked: Some(parked(NOW + 20 * 86_400)),
974 }
975 }
976
977 fn state(labels: &[&str]) -> State {
978 State {
979 accounts: labels.iter().map(|l| account(l)).collect(),
980 ..State::default()
981 }
982 }
983
984 fn facts(
985 signed_in: &str,
986 live: Result<Snapshot, Stale>,
987 parked: Vec<Result<Snapshot, Stale>>,
988 ) -> Facts {
989 only_claude(
990 LiveLogin {
991 signed_in: Some(Ok(owner(signed_in))),
992 usage: Some(live),
993 ..LiveLogin::default()
994 },
995 parked,
996 )
997 }
998
999 fn only_claude(live: LiveLogin, parked: Vec<Result<Snapshot, Stale>>) -> Facts {
1001 Facts {
1002 live: std::iter::once((ProviderId::Claude, live)).collect(),
1003 asked: true,
1004 parked_usage: parked,
1005 claude_code_cache: None,
1006 }
1007 }
1008
1009 #[test]
1013 fn an_unreachable_anthropic_leaves_the_signed_in_row_signed_in() {
1014 let state = state(&["alpha", "beta"]);
1015 let facts = Facts {
1016 live: std::iter::once((
1017 ProviderId::Claude,
1018 LiveLogin {
1019 signed_in: Some(Err("could not reach Anthropic: offline".into())),
1020 recorded_id: Some("alpha-uuid".into()),
1021 usage: Some(Err(Stale::Unreachable)),
1022 out_of_reach: false,
1023 },
1024 ))
1025 .collect(),
1026 asked: true,
1027 parked_usage: vec![Err(Stale::Unreachable), Err(Stale::Unreachable)],
1028 claude_code_cache: None,
1029 };
1030 let rows = assemble(&state, &facts, nothing_remembered, NOW);
1031 let a = rows
1032 .iter()
1033 .find(|r| r.account_uuid == "alpha-uuid")
1034 .unwrap();
1035 assert!(
1036 a.signed_in,
1037 "the account in use is still the account in use"
1038 );
1039 let b = rows.iter().find(|r| r.account_uuid == "beta-uuid").unwrap();
1040 assert!(!b.signed_in);
1041 }
1042
1043 fn nothing_remembered(_: &str) -> Option<Snapshot> {
1044 None
1045 }
1046
1047 #[test]
1048 fn every_stale_code_is_its_json_form() {
1049 for stale in [
1050 Stale::NothingSignedIn,
1051 Stale::LoginUnreadable,
1052 Stale::LoginUnusable,
1053 Stale::SessionExpired,
1054 Stale::ParkedAccessExpired,
1055 Stale::NothingParked,
1056 Stale::ParkUnreadable,
1057 Stale::RateLimited,
1058 Stale::Unreachable,
1059 Stale::ServerError,
1060 Stale::AnswerNotUnderstood,
1061 Stale::LoginRefused,
1062 Stale::Interrupted,
1063 Stale::NotAsked,
1064 ] {
1065 assert_eq!(serde_json::to_value(stale).unwrap(), stale.code());
1066 }
1067 }
1068
1069 #[test]
1073 fn anthropics_failures_are_told_apart() {
1074 use crate::api::ApiError;
1075 let parked = |e: &ApiError| Stale::of(e, false);
1076 assert_eq!(
1077 parked(&ApiError::Unexpected { status: 503 }),
1078 Stale::ServerError
1079 );
1080 assert_eq!(
1081 parked(&ApiError::Malformed("no windows".into())),
1082 Stale::AnswerNotUnderstood
1083 );
1084 assert_eq!(parked(&ApiError::InvalidGrant), Stale::LoginRefused);
1085 assert_eq!(
1086 parked(&ApiError::RateLimited { retry_after: None }),
1087 Stale::RateLimited
1088 );
1089 assert_eq!(
1090 parked(&ApiError::Network("down".into())),
1091 Stale::Unreachable
1092 );
1093
1094 assert_eq!(parked(&ApiError::Unauthorized), Stale::ParkedAccessExpired);
1096 assert_eq!(
1097 Stale::of(&ApiError::Unauthorized, true),
1098 Stale::SessionExpired
1099 );
1100 }
1101
1102 #[test]
1103 fn a_live_reading_wins_over_claude_codes_cache() {
1104 let s = state(&["work"]);
1105 let mut f = facts(
1106 "work-uuid",
1107 Ok(reading(30.0, Source::Live, None)),
1108 vec![Err(Stale::NothingParked)],
1109 );
1110 f.claude_code_cache = Some(reading(2.0, Source::ClaudeCodeCache, Some("work-uuid")));
1111 let rows = assemble(&s, &f, nothing_remembered, NOW);
1112 let usage = rows[0].usage.as_ref().unwrap();
1113 assert_eq!(usage.source, Source::Live);
1114 assert_eq!(usage.windows[0].percent, 30.0);
1115 assert_eq!(rows[0].stale, None);
1116 }
1117
1118 #[test]
1119 fn an_expired_session_falls_back_to_claude_codes_cache_and_says_why() {
1120 let s = state(&["work"]);
1121 let mut f = facts(
1122 "work-uuid",
1123 Err(Stale::SessionExpired),
1124 vec![Err(Stale::NothingParked)],
1125 );
1126 f.claude_code_cache = Some(reading(2.0, Source::ClaudeCodeCache, Some("work-uuid")));
1127 let rows = assemble(&s, &f, nothing_remembered, NOW);
1128 assert_eq!(
1129 rows[0].usage.as_ref().unwrap().source,
1130 Source::ClaudeCodeCache
1131 );
1132 assert_eq!(rows[0].stale, Some(Stale::SessionExpired));
1133 }
1134
1135 #[test]
1140 fn offline_the_account_in_use_shows_the_newer_of_claude_codes_cache_and_the_readings() {
1141 let s = state(&["work"]);
1142 let mut f = facts(
1143 "work-uuid",
1144 Err(Stale::NotAsked),
1145 vec![Err(Stale::NothingParked)],
1146 );
1147 f.asked = false;
1148 f.claude_code_cache = Some(reading(20.0, Source::ClaudeCodeCache, Some("work-uuid")));
1149 let recorded = |_: &str| Some(reading(22.0, Source::Remembered, Some("work-uuid")));
1150 let rows = assemble(&s, &f, recorded, NOW);
1151 let usage = rows[0].usage.as_ref().unwrap();
1152 assert_eq!(usage.windows[0].percent, 22.0);
1153 assert_eq!(usage.source, Source::Remembered);
1154 assert_eq!(rows[0].stale, Some(Stale::NotAsked));
1155
1156 f.claude_code_cache = Some(reading(25.0, Source::ClaudeCodeCache, Some("work-uuid")));
1157 let rows = assemble(&s, &f, recorded, NOW);
1158 let usage = rows[0].usage.as_ref().unwrap();
1159 assert_eq!(
1160 usage.windows[0].percent, 25.0,
1161 "and the cache where it is newer"
1162 );
1163 assert_eq!(usage.source, Source::ClaudeCodeCache);
1164 }
1165
1166 #[test]
1170 fn a_live_reading_behind_what_a_session_recorded_does_not_move_the_row_back() {
1171 let s = state(&["work"]);
1172 let f = facts(
1173 "work-uuid",
1174 Ok(reading(20.0, Source::Live, None)),
1175 vec![Err(Stale::NothingParked)],
1176 );
1177 let recorded = |_: &str| {
1178 let mut since = reading(22.0, Source::Remembered, Some("work-uuid"));
1179 since.observed_at = Some(NOW - 60);
1180 Some(since)
1181 };
1182 let rows = assemble(&s, &f, recorded, NOW);
1183 assert_eq!(rows[0].usage.as_ref().unwrap().windows[0].percent, 22.0);
1184 assert_eq!(rows[0].stale, None);
1185
1186 let f = facts(
1187 "work-uuid",
1188 Ok(reading(30.0, Source::Live, None)),
1189 vec![Err(Stale::NothingParked)],
1190 );
1191 let usage = assemble(&s, &f, recorded, NOW)[0].usage.clone().unwrap();
1192 assert_eq!(usage.windows[0].percent, 30.0);
1193 assert_eq!(
1194 usage.source,
1195 Source::Live,
1196 "a live reading ahead is shown as live"
1197 );
1198 }
1199
1200 #[test]
1204 fn a_live_reading_taken_after_what_was_recorded_is_shown_however_low() {
1205 let s = state(&["work"]);
1206 let mut answered = reading(14.0, Source::Live, None);
1207 answered.observed_at = Some(NOW);
1208 let f = facts("work-uuid", Ok(answered), vec![Err(Stale::NothingParked)]);
1209 let recorded = |_: &str| Some(reading(100.0, Source::Remembered, Some("work-uuid")));
1210 let usage = assemble(&s, &f, recorded, NOW)[0].usage.clone().unwrap();
1211 assert_eq!(usage.windows[0].percent, 14.0);
1212 assert_eq!(usage.source, Source::Live);
1213 }
1214
1215 #[test]
1216 fn claude_codes_cache_for_another_account_is_never_shown_as_this_one() {
1217 let s = state(&["work"]);
1218 let mut f = facts(
1219 "work-uuid",
1220 Err(Stale::Unreachable),
1221 vec![Err(Stale::NothingParked)],
1222 );
1223 f.claude_code_cache = Some(reading(99.0, Source::ClaudeCodeCache, Some("someone-else")));
1224 assert!(assemble(&s, &f, nothing_remembered, NOW)[0].usage.is_none());
1225 }
1226
1227 #[test]
1228 fn a_parked_account_is_asked_with_its_own_login() {
1229 let s = state(&["work", "personal"]);
1230 let f = facts(
1231 "work-uuid",
1232 Ok(reading(30.0, Source::Live, None)),
1233 vec![
1234 Err(Stale::NothingParked),
1235 Ok(reading(12.0, Source::Live, None)),
1236 ],
1237 );
1238 let rows = assemble(&s, &f, nothing_remembered, NOW);
1239 let personal = rows
1240 .iter()
1241 .find(|r| r.label.as_deref() == Some("personal"))
1242 .unwrap();
1243 assert_eq!(personal.usage.as_ref().unwrap().windows[0].percent, 12.0);
1244 assert!(personal.switchable(NOW));
1245 }
1246
1247 #[test]
1251 fn a_refused_token_is_an_expired_session_when_it_is_the_one_signed_in() {
1252 let api = crate::api::scripted::ScriptedApi::new();
1253 api.token_trouble("t", crate::api::scripted::Trouble::Unauthorized);
1254 let ctx = Context::new(std::path::PathBuf::from("/nowhere"))
1255 .with_pitboard_home(std::env::temp_dir().join("pitboard-status-refused"))
1256 .with_scripted_api(api);
1257 let login = serde_json::json!({"claudeAiOauth": {"accessToken": "t"}});
1258 let ask = |signed_in| {
1259 ask_usage(
1260 &ctx,
1261 ProviderId::Claude,
1262 None,
1263 &login,
1264 signed_in,
1265 None,
1266 true,
1267 )
1268 .0
1269 };
1270 assert_eq!(ask(true).unwrap_err(), Stale::SessionExpired);
1271 assert_eq!(ask(false).unwrap_err(), Stale::ParkedAccessExpired);
1272 }
1273
1274 #[test]
1278 fn a_park_not_in_its_own_tools_shape_is_unreadable_rather_than_an_answer() {
1279 let ctx = Context::new(std::path::PathBuf::from("/nowhere"));
1280 let not_codex = serde_json::json!({"claudeAiOauth": {"accessToken": "t"}});
1281 let (answer, learned) =
1282 ask_usage(&ctx, ProviderId::Codex, None, ¬_codex, false, None, true);
1283 assert_eq!(answer.unwrap_err(), Stale::ParkUnreadable);
1284 assert!(
1285 learned.is_none(),
1286 "nothing was asked, so there is nothing to learn"
1287 );
1288 }
1289
1290 #[test]
1291 fn a_parked_login_past_its_access_expiry_is_not_asked() {
1292 let ctx = Context::for_unit_test();
1293 let personal = Key::new(ProviderId::Claude, "personal");
1294 let token = parked_document(&ctx, &personal, Some(&parked(NOW + 86_400)), NOW);
1295 assert_eq!(token, Err(Stale::ParkedAccessExpired));
1296 assert_eq!(
1297 parked_document(&ctx, &personal, None, NOW),
1298 Err(Stale::NothingParked)
1299 );
1300 }
1301
1302 #[test]
1303 fn what_cannot_be_asked_falls_back_to_what_was_remembered() {
1304 let s = state(&["work", "personal"]);
1305 let f = facts(
1306 "work-uuid",
1307 Ok(reading(30.0, Source::Live, None)),
1308 vec![Err(Stale::NothingParked), Err(Stale::ParkedAccessExpired)],
1309 );
1310 let remembered = |uuid: &str| {
1311 (uuid == "personal-uuid").then(|| reading(44.0, Source::Remembered, Some(uuid)))
1312 };
1313 let rows = assemble(&s, &f, remembered, NOW);
1314 let personal = rows
1315 .iter()
1316 .find(|r| r.label.as_deref() == Some("personal"))
1317 .unwrap();
1318 assert_eq!(personal.usage.as_ref().unwrap().source, Source::Remembered);
1319 assert_eq!(personal.stale, Some(Stale::ParkedAccessExpired));
1320 }
1321
1322 #[test]
1323 fn the_signed_in_account_comes_first_and_is_taken_from_the_server_not_the_config() {
1324 let s = state(&["alpha", "beta"]);
1325 let f = facts(
1326 "beta-uuid",
1327 Ok(reading(5.0, Source::Live, None)),
1328 vec![Err(Stale::NothingParked), Err(Stale::NothingParked)],
1329 );
1330 let rows = assemble(&s, &f, nothing_remembered, NOW);
1331 assert_eq!(rows[0].label.as_deref(), Some("beta"));
1332 assert!(rows[0].signed_in && !rows[0].switchable(NOW));
1333 assert!(!rows[1].signed_in);
1334 }
1335
1336 const EVERY_STALE: [Stale; 15] = [
1338 Stale::NothingSignedIn,
1339 Stale::LoginUnreadable,
1340 Stale::LoginUnusable,
1341 Stale::SessionExpired,
1342 Stale::ParkedAccessExpired,
1343 Stale::NothingParked,
1344 Stale::ParkUnreadable,
1345 Stale::RateLimited,
1346 Stale::Unreachable,
1347 Stale::ServerError,
1348 Stale::AnswerNotUnderstood,
1349 Stale::LoginRefused,
1350 Stale::AskedRecently,
1351 Stale::Interrupted,
1352 Stale::NotAsked,
1353 ];
1354
1355 #[test]
1358 fn claude_codes_explanations_are_word_for_word_what_they_were() {
1359 let before = |stale: Stale| match stale {
1360 Stale::NothingSignedIn => Some("nothing is signed in"),
1361 Stale::SessionExpired => Some("Claude Code's session has expired; `claude` renews it"),
1362 Stale::ParkedAccessExpired | Stale::NothingParked | Stale::AskedRecently => None,
1363 Stale::ParkUnreadable => Some("its parked login cannot be read; run `pitboard doctor`"),
1364 Stale::RateLimited => Some("Anthropic is rate limiting usage checks"),
1365 Stale::Unreachable => Some("Anthropic could not be reached"),
1366 Stale::ServerError => Some("Anthropic answered with an error; try again later"),
1367 Stale::AnswerNotUnderstood => Some("Anthropic's answer was not understood"),
1368 Stale::LoginRefused => Some("its parked login is no longer accepted; sign in again"),
1369 Stale::Interrupted => Some("the check did not finish"),
1370 Stale::NotAsked => Some("read without asking Anthropic"),
1371 Stale::LoginUnreadable => {
1373 Some("Claude Code's login could not be read; run `pitboard doctor`")
1374 }
1375 Stale::LoginUnusable => Some(
1376 "Claude Code's login is not one Pitboard can park or switch; run `pitboard doctor`",
1377 ),
1378 };
1379 for stale in EVERY_STALE {
1380 assert_eq!(
1381 stale.explanation_for(ProviderId::Claude),
1382 before(stale),
1383 "{stale:?}"
1384 );
1385 assert_eq!(
1386 stale.explanation(),
1387 stale.explanation_for(ProviderId::Claude),
1388 "a caller with only a code gets the default tool's words"
1389 );
1390 }
1391 }
1392
1393 #[test]
1397 fn a_codex_row_names_openai_and_codex_never_anthropic_or_claude() {
1398 for stale in EVERY_STALE {
1399 let Some(said) = stale.explanation_for(ProviderId::Codex) else {
1400 assert_eq!(stale.explanation_for(ProviderId::Claude), None, "{stale:?}");
1401 continue;
1402 };
1403 for other in ["Anthropic", "Claude", "claude"] {
1404 assert!(!said.contains(other), "{stale:?}: {said}");
1405 }
1406 }
1407 assert_eq!(
1408 Stale::Unreachable.explanation_for(ProviderId::Codex),
1409 Some("OpenAI could not be reached")
1410 );
1411 assert_eq!(
1412 Stale::SessionExpired.explanation_for(ProviderId::Codex),
1413 Some("Codex's session has expired; `codex` renews it")
1414 );
1415
1416 let mut codex = codex_account("work", "work-acc");
1418 codex.parked = None;
1419 let s = State {
1420 accounts: vec![codex],
1421 ..State::default()
1422 };
1423 let f = Facts {
1424 live: BTreeMap::new(),
1425 asked: true,
1426 parked_usage: vec![Err(Stale::Unreachable)],
1427 claude_code_cache: None,
1428 };
1429 let rows = assemble(&s, &f, nothing_remembered, NOW);
1430 assert_eq!(rows[0].explanation(), Some("OpenAI could not be reached"));
1431 }
1432
1433 fn codex_account(label: &str, uuid: &str) -> Account {
1434 Account {
1435 last_used_at: None,
1436 label: label.into(),
1437 id: uuid.into(),
1438 account_uuid: uuid.into(),
1439 email: format!("{label}@example.com"),
1440 detail: crate::state::Detail::Codex {
1441 workspace_id: None,
1442 plan: None,
1443 },
1444 parked: Some(parked(NOW + 20 * 86_400)),
1445 }
1446 }
1447
1448 fn live(owner_uuid: &str, usage: Result<Snapshot, Stale>) -> LiveLogin {
1449 LiveLogin {
1450 signed_in: Some(Ok(owner(owner_uuid))),
1451 usage: Some(usage),
1452 ..LiveLogin::default()
1453 }
1454 }
1455
1456 #[test]
1460 fn the_same_identity_on_two_tools_is_two_rows() {
1461 let s = state(&["same"]);
1462 let f = Facts {
1463 live: [
1464 (
1465 ProviderId::Claude,
1466 live("same-uuid", Ok(reading(10.0, Source::Live, None))),
1467 ),
1468 (
1469 ProviderId::Codex,
1470 live("same-uuid", Ok(reading(20.0, Source::Live, None))),
1471 ),
1472 ]
1473 .into_iter()
1474 .collect(),
1475 asked: true,
1476 parked_usage: vec![Err(Stale::NothingParked)],
1477 claude_code_cache: None,
1478 };
1479 let rows = assemble(&s, &f, nothing_remembered, NOW);
1480 assert_eq!(rows.len(), 2, "one row per tool");
1481 assert_eq!(rows[0].provider, ProviderId::Claude);
1482 assert_eq!(rows[0].label.as_deref(), Some("same"));
1483 assert_eq!(rows[1].provider, ProviderId::Codex);
1484 assert_eq!(rows[1].label, None, "nothing has enrolled the Codex login");
1485 assert!(rows[1].signed_in);
1486 assert_eq!(rows[1].usage.as_ref().unwrap().windows[0].percent, 20.0);
1487 }
1488
1489 #[test]
1492 fn rows_are_grouped_by_tool_with_the_signed_in_account_first_in_each() {
1493 let mut s = state(&["alpha"]);
1494 s.accounts.push(codex_account("work", "work-acc"));
1495 s.accounts.push(account("beta"));
1496 s.accounts.push(codex_account("home", "home-acc"));
1497 let f = Facts {
1498 live: [
1499 (
1500 ProviderId::Claude,
1501 live("beta-uuid", Ok(reading(5.0, Source::Live, None))),
1502 ),
1503 (
1504 ProviderId::Codex,
1505 live("home-acc", Ok(reading(6.0, Source::Live, None))),
1506 ),
1507 ]
1508 .into_iter()
1509 .collect(),
1510 asked: true,
1511 parked_usage: vec![Err(Stale::NothingParked); 4],
1512 claude_code_cache: None,
1513 };
1514 let order: Vec<(ProviderId, String, bool)> = assemble(&s, &f, nothing_remembered, NOW)
1515 .into_iter()
1516 .map(|r| (r.provider, r.label.unwrap_or_default(), r.signed_in))
1517 .collect();
1518 assert_eq!(
1519 order,
1520 [
1521 (ProviderId::Claude, "beta".into(), true),
1522 (ProviderId::Claude, "alpha".into(), false),
1523 (ProviderId::Codex, "home".into(), true),
1524 (ProviderId::Codex, "work".into(), false),
1525 ]
1526 );
1527 }
1528
1529 #[test]
1532 fn claude_codes_cache_is_never_shown_for_another_tools_account() {
1533 let mut s = State::default();
1534 s.accounts.push(codex_account("work", "work-acc"));
1535 let mut f = Facts {
1536 live: std::iter::once((ProviderId::Codex, live("work-acc", Err(Stale::Unreachable))))
1537 .collect(),
1538 asked: true,
1539 parked_usage: vec![Err(Stale::NothingParked)],
1540 claude_code_cache: None,
1541 };
1542 f.claude_code_cache = Some(reading(77.0, Source::ClaudeCodeCache, Some("work-acc")));
1543 let rows = assemble(&s, &f, nothing_remembered, NOW);
1544 assert!(rows[0].signed_in);
1545 assert!(rows[0].usage.is_none(), "{:?}", rows[0].usage);
1546 }
1547
1548 #[test]
1553 fn a_thread_that_stopped_stands_in_for_its_own_tool_only() {
1554 let home = scratch("panicked");
1555 let (ctx, _mem, _api) = machine(&home.0, None);
1556 let claude_answer: Answered = (
1557 live("alpha-uuid", Ok(reading(30.0, Source::Live, None))),
1558 Some(("alpha-uuid".into(), budget::Outcome::Answered)),
1559 );
1560 let panicked: Box<dyn std::any::Any + Send> = Box::new("boom");
1561 let (settled, learned) = settle(
1562 &ctx,
1563 &state(&["alpha"]),
1564 vec![
1565 (ProviderId::Claude, Ok(claude_answer)),
1566 (ProviderId::Codex, Err(panicked)),
1567 ],
1568 );
1569 assert_eq!(
1570 learned,
1571 [("alpha-uuid".to_string(), budget::Outcome::Answered)]
1572 );
1573 let codex = &settled[&ProviderId::Codex];
1574 assert_eq!(codex.usage().err(), Some(Stale::Interrupted));
1575 assert!(
1576 matches!(codex.signed_in, Some(Err(_))),
1577 "unknown, not nobody"
1578 );
1579
1580 let mut s = state(&["alpha"]);
1581 s.accounts.push(codex_account("work", "work-acc"));
1582 let f = Facts {
1583 live: settled,
1584 asked: true,
1585 parked_usage: vec![Err(Stale::NothingParked), Err(Stale::NothingParked)],
1586 claude_code_cache: None,
1587 };
1588 let rows = assemble(&s, &f, nothing_remembered, NOW);
1589 let alpha = rows
1590 .iter()
1591 .find(|r| r.provider == ProviderId::Claude)
1592 .unwrap();
1593 assert!(alpha.signed_in, "Claude Code's row survives Codex's thread");
1594 assert_eq!(alpha.usage.as_ref().unwrap().windows[0].percent, 30.0);
1595 }
1596
1597 #[test]
1601 fn a_document_holding_no_account_is_nothing_signed_in_and_nobody_is_asked() {
1602 let home = scratch("logged-out");
1603 let (ctx, _mem, api) = machine(&home.0, None);
1604 let logged_out = json!({"mcpOAuth": {"some-server": {"token": "unrelated"}}});
1605 let (login, learned) = ask_live(
1606 &ctx,
1607 &State::default(),
1608 ProviderId::Claude,
1609 &Ok(Some(logged_out)),
1610 &HashMap::new(),
1611 true,
1612 );
1613 assert!(login.signed_in.is_none(), "nobody, rather than unknown");
1614 assert_eq!(login.usage().err(), Some(Stale::NothingSignedIn));
1615 assert!(learned.is_none());
1616 assert_eq!(api.calls(), 0, "Anthropic was asked: {:?}", api.asked());
1617 }
1618
1619 #[test]
1623 fn a_login_that_cannot_be_read_is_said_rather_than_read_as_nobody_signed_in() {
1624 let home = scratch("unreadable");
1625 let (ctx, mem, api) = machine(&home.0, Some("alpha-uuid"));
1626 let service = claude::live_service(&ctx);
1627 mem.live().plant(
1628 &service,
1629 &json!({"claudeAiOauth": {"accessToken": "t"}}).to_string(),
1630 );
1631 mem.live()
1632 .fault(&service, Fault::Unreadable("the keychain is locked".into()));
1633 let mut s = state(&["alpha", "beta"]);
1634 for a in &mut s.accounts {
1635 a.parked = None;
1636 }
1637
1638 let report = gather(&ctx, Permit::for_a_test(), &s, true);
1639 let alpha = report
1640 .rows
1641 .iter()
1642 .find(|r| r.label.as_deref() == Some("alpha"))
1643 .unwrap();
1644 assert!(
1645 alpha.signed_in,
1646 "Claude Code's own record still names the account in use"
1647 );
1648 assert_eq!(alpha.stale, Some(Stale::LoginUnreadable));
1649 assert_eq!(
1650 alpha.explanation(),
1651 Some("Claude Code's login could not be read; run `pitboard doctor`")
1652 );
1653 let why = report.signed_in.expect_err("nobody could say whose it is");
1654 assert_ne!(why, "nothing is signed in");
1655 assert_eq!(api.calls(), 0, "a login nobody could read was sent nowhere");
1656 }
1657
1658 #[test]
1664 fn the_account_last_switched_to_is_not_sent_to_sign_in_while_the_login_cannot_be_read() {
1665 let mut s = state(&["alpha", "beta"]);
1666 s.accounts[1].parked = None;
1667 s.set_active(ProviderId::Claude, Some("beta".into()));
1668 let facts = only_claude(
1669 LiveLogin {
1670 signed_in: Some(Err("the keychain is locked".into())),
1671 recorded_id: Some("alpha-uuid".into()),
1672 usage: Some(Err(Stale::LoginUnreadable)),
1673 out_of_reach: true,
1674 },
1675 vec![Err(Stale::NotAsked), Err(Stale::NothingParked)],
1676 );
1677
1678 let rows = assemble(&s, &facts, nothing_remembered, NOW);
1679 let beta = rows
1680 .iter()
1681 .find(|r| r.label.as_deref() == Some("beta"))
1682 .unwrap();
1683 assert!(!beta.signed_in);
1684 assert_eq!(beta.stale, Some(Stale::LoginUnreadable));
1685
1686 let readable = only_claude(
1688 live("alpha-uuid", Ok(reading(5.0, Source::Live, None))),
1689 vec![Err(Stale::NotAsked), Err(Stale::NothingParked)],
1690 );
1691 let rows = assemble(&s, &readable, nothing_remembered, NOW);
1692 let beta = rows
1693 .iter()
1694 .find(|r| r.label.as_deref() == Some("beta"))
1695 .unwrap();
1696 assert_eq!(beta.stale, Some(Stale::NothingParked));
1697 }
1698
1699 #[test]
1704 fn an_unreadable_login_gets_a_row_only_for_a_tool_with_accounts() {
1705 let unreadable = || LiveLogin {
1706 signed_in: Some(Err("the keychain is locked".into())),
1707 usage: Some(Err(Stale::LoginUnreadable)),
1708 out_of_reach: true,
1709 ..LiveLogin::default()
1710 };
1711 let facts_with = |parked: usize| Facts {
1712 live: [
1713 (
1714 ProviderId::Claude,
1715 live("alpha-uuid", Ok(reading(5.0, Source::Live, None))),
1716 ),
1717 (ProviderId::Codex, unreadable()),
1718 ]
1719 .into_iter()
1720 .collect(),
1721 asked: true,
1722 parked_usage: vec![Err(Stale::NothingParked); parked],
1723 claude_code_cache: None,
1724 };
1725
1726 let claude_only = state(&["alpha"]);
1727 let rows = assemble(&claude_only, &facts_with(1), nothing_remembered, NOW);
1728 assert_eq!(
1729 rows.len(),
1730 1,
1731 "nothing new for somebody who never used Codex"
1732 );
1733
1734 let mut both = state(&["alpha"]);
1735 both.accounts.push(codex_account("work", "work-acc"));
1736 let rows = assemble(&both, &facts_with(2), nothing_remembered, NOW);
1737 let said = rows
1738 .iter()
1739 .find(|r| r.provider == ProviderId::Codex && r.label.is_none())
1740 .expect("a row saying Codex's login could not be read");
1741 assert_eq!(said.stale, Some(Stale::LoginUnreadable));
1742 assert!(!said.signed_in && !said.switchable(NOW));
1743 assert_eq!(
1744 said.explanation(),
1745 Some("Codex's login could not be read; run `pitboard doctor`")
1746 );
1747 let work = rows
1748 .iter()
1749 .find(|r| r.label.as_deref() == Some("work"))
1750 .unwrap();
1751 assert!(!work.signed_in);
1752 }
1753
1754 #[test]
1759 fn a_failing_identify_still_keeps_the_ask_again_floor() {
1760 let home = scratch("floor");
1761 let (ctx, _mem, api) = machine(&home.0, Some("acc-x"));
1762 let nothing_enrolled = State::default();
1763 let filed = nothing_enrolled.id_of(ProviderId::Claude, &owner("acc-x"));
1764 api.token_trouble("access-x", Trouble::Offline);
1765 budget::record(
1766 &ctx,
1767 Permit::for_a_test(),
1768 &[(filed.clone(), budget::Outcome::Answered)],
1769 );
1770 let login = json!({"claudeAiOauth": {"accessToken": "access-x"}});
1771
1772 let (live, learned) = ask_live(
1773 &ctx,
1774 ¬hing_enrolled,
1775 ProviderId::Claude,
1776 &Ok(Some(login.clone())),
1777 &HashMap::new(),
1778 false,
1779 );
1780 assert_eq!(live.usage().err(), Some(Stale::AskedRecently));
1781 assert_eq!(live.recorded_id.as_deref(), Some(filed.as_str()));
1782 assert!(learned.is_none());
1783 assert_eq!(
1784 api.asked(),
1785 [Question::Owner("access-x".into())],
1786 "whose it is is always asked; what it has left is not, inside the floor"
1787 );
1788
1789 let (live, learned) = ask_live(
1791 &ctx,
1792 ¬hing_enrolled,
1793 ProviderId::Claude,
1794 &Ok(Some(login)),
1795 &HashMap::new(),
1796 true,
1797 );
1798 assert_eq!(live.usage().err(), Some(Stale::Unreachable));
1799 assert_eq!(learned, Some((filed, budget::Outcome::Unreachable)));
1800 }
1801
1802 #[test]
1805 fn offline_every_tool_names_its_own_signed_in_account() {
1806 let home = scratch("offline");
1807 let (ctx, _mem, api) = machine(&home.0, Some("alpha-uuid"));
1808 let codex = crate::provider::of(ProviderId::Codex)
1809 .live(&ctx)
1810 .expect("Codex keeps its login in a file here");
1811 let login = json!({
1812 "auth_mode": "chatgpt",
1813 "tokens": {
1814 "id_token": crate::provider::jwt::unsigned(&json!({
1815 "email": "work@example.com",
1816 "https://api.openai.com/auth": {"chatgpt_account_id": "work-acc"},
1817 })),
1818 "access_token": "codex-access",
1819 "refresh_token": "codex-refresh",
1820 "account_id": "work-acc",
1821 },
1822 "last_refresh": "2026-09-15T05:05:11Z",
1823 });
1824 crate::store::write_raw(
1825 &codex.chain,
1826 Permit::for_a_test(),
1827 &codex.service,
1828 &login.to_string(),
1829 )
1830 .expect("a Codex login");
1831
1832 let mut s = state(&["alpha", "beta"]);
1833 s.accounts.push(codex_account("work", "work-acc"));
1834 s.accounts.push(codex_account("home", "home-acc"));
1835 let report = gather_offline(&ctx, &s);
1836 let signed_in: Vec<(ProviderId, &str)> = report
1837 .rows
1838 .iter()
1839 .filter(|r| r.signed_in)
1840 .map(|r| (r.provider, r.label.as_deref().unwrap_or_default()))
1841 .collect();
1842 assert_eq!(
1843 signed_in,
1844 [(ProviderId::Claude, "alpha"), (ProviderId::Codex, "work")]
1845 );
1846 assert_eq!(
1847 report.signed_in.expect("Claude Code's config").account_uuid,
1848 "alpha-uuid"
1849 );
1850 assert_eq!(api.calls(), 0, "offline asks nobody");
1851 }
1852
1853 fn plant_codex(ctx: &Context, raw: &str) {
1855 let codex = crate::provider::of(ProviderId::Codex)
1856 .live(ctx)
1857 .expect("Codex keeps its login in a file here");
1858 crate::store::write_raw(&codex.chain, Permit::for_a_test(), &codex.service, raw)
1859 .expect("a Codex login");
1860 }
1861
1862 fn codex_login(tokens_of: &str, account_id: &str) -> String {
1865 json!({
1866 "auth_mode": "chatgpt",
1867 "tokens": {
1868 "id_token": crate::provider::jwt::unsigned(&json!({
1869 "email": format!("{tokens_of}@example.com"),
1870 "https://api.openai.com/auth": {"chatgpt_account_id": tokens_of},
1871 })),
1872 "access_token": format!("access-{tokens_of}"),
1873 "refresh_token": format!("refresh-{tokens_of}"),
1874 "account_id": account_id,
1875 },
1876 "last_refresh": "2026-09-15T05:05:11Z",
1877 })
1878 .to_string()
1879 }
1880
1881 fn codex_a_parked_b_active() -> State {
1884 let mut b = codex_account("b", "acc-B");
1885 b.parked = None;
1886 let mut s = State {
1887 accounts: vec![codex_account("a", "acc-A"), b],
1888 ..State::default()
1889 };
1890 s.set_active(ProviderId::Codex, Some("b".into()));
1891 s
1892 }
1893
1894 fn codex_row<'a>(report: &'a Report, label: &str) -> &'a Row {
1895 report
1896 .rows
1897 .iter()
1898 .find(|r| r.provider == ProviderId::Codex && r.label.as_deref() == Some(label))
1899 .unwrap_or_else(|| panic!("a row for codex/{label}"))
1900 }
1901
1902 #[test]
1908 fn a_codex_login_that_mixes_two_accounts_is_said_rather_than_read_as_nobody() {
1909 let home = scratch("mixed");
1910 let (ctx, _mem, api) = machine(&home.0, None);
1911 plant_codex(&ctx, &codex_login("acc-A", "acc-B"));
1912 let s = codex_a_parked_b_active();
1913
1914 let report = gather(&ctx, Permit::for_a_test(), &s, true);
1915 let a = codex_row(&report, "a");
1916 assert!(a.signed_in, "the tokens are a's, as its own record says");
1917 assert_eq!(a.stale, Some(Stale::LoginUnusable));
1918 assert_eq!(
1919 a.explanation(),
1920 Some("Codex's login is not one Pitboard can park or switch; run `pitboard doctor`")
1921 );
1922 assert!(!codex_row(&report, "b").signed_in);
1923 assert!(
1924 report
1925 .rows
1926 .iter()
1927 .all(|r| r.stale != Some(Stale::NothingSignedIn)),
1928 "something is signed in"
1929 );
1930 assert!(
1931 report.rows.iter().all(|r| !r.unplaced()),
1932 "the login is pinned on a, so it needs no row of its own"
1933 );
1934 assert_eq!(
1935 api.calls(),
1936 0,
1937 "nobody was asked about a login mixing two accounts"
1938 );
1939
1940 let offline = gather_offline(&ctx, &s);
1941 assert!(codex_row(&offline, "a").signed_in, "offline says the same");
1942 }
1943
1944 #[test]
1947 fn a_codex_login_with_an_api_key_is_said_and_pinned_on_no_account() {
1948 let home = scratch("api-key");
1949 let (ctx, _mem, api) = machine(&home.0, None);
1950 plant_codex(
1951 &ctx,
1952 &json!({"auth_mode": "apikey", "OPENAI_API_KEY": "sk-not-a-real-key"}).to_string(),
1953 );
1954 let s = codex_a_parked_b_active();
1955
1956 let report = gather(&ctx, Permit::for_a_test(), &s, true);
1957 assert!(
1958 report.rows.iter().all(|r| !r.signed_in),
1959 "no account is signed in"
1960 );
1961 let said = report
1962 .rows
1963 .iter()
1964 .find(|r| r.unplaced())
1965 .expect("a row saying what is signed in to Codex");
1966 assert_eq!(said.provider, ProviderId::Codex);
1967 assert_eq!(said.stale, Some(Stale::LoginUnusable));
1968 assert!(said.email.is_empty() && said.account_uuid.is_empty());
1969 assert_eq!(api.calls(), 0);
1970 }
1971
1972 #[test]
1976 fn a_tool_with_nothing_enrolled_is_neither_read_nor_asked() {
1977 let home = scratch("not-opted-in");
1978 let (ctx, _mem, api) = machine(&home.0, None);
1979 plant_codex(&ctx, &codex_login("acc-B", "acc-B"));
1980 let s = State::default();
1981
1982 let report = gather(&ctx, Permit::for_a_test(), &s, true);
1983 assert!(
1984 report.rows.iter().all(|r| r.provider != ProviderId::Codex),
1985 "no Codex row for a machine that enrolled no Codex account"
1986 );
1987 assert!(
1988 api.asked()
1989 .iter()
1990 .all(|asked| !matches!(asked, crate::api::scripted::Asked::Usage(t) if t.contains("acc-B"))),
1991 "and nothing was asked about it"
1992 );
1993 let offline = gather_offline(&ctx, &s);
1994 assert!(offline.rows.iter().all(|r| r.provider != ProviderId::Codex));
1995 }
1996
1997 #[test]
2002 fn a_codex_login_caught_half_written_still_names_the_account_in_use() {
2003 let home = scratch("half-written");
2004 let (ctx, _mem, api) = machine(&home.0, None);
2005 let whole = codex_login("acc-B", "acc-B");
2006 plant_codex(&ctx, &whole[..whole.len() / 2]);
2007 let s = codex_a_parked_b_active();
2008 crate::state::save(&ctx, Permit::for_a_test(), &s).expect("an account list");
2009
2010 let report = gather(&ctx, Permit::for_a_test(), &s, true);
2011 let b = codex_row(&report, "b");
2012 assert!(b.signed_in, "the account Pitboard last switched to");
2013 assert_eq!(b.stale, Some(Stale::LoginUnreadable));
2014 assert!(!codex_row(&report, "a").signed_in);
2015 assert!(
2016 report.rows.iter().all(|r| !r.unplaced()),
2017 "pinned, so no row of its own"
2018 );
2019 assert_eq!(api.calls(), 0);
2020
2021 let doctor = crate::doctor::gather(&ctx);
2022 let active: Vec<&str> = doctor
2023 .parks
2024 .iter()
2025 .filter(|p| p.active)
2026 .map(|p| p.label.as_str())
2027 .collect();
2028 assert_eq!(active, ["b"], "doctor reads the same machine the same way");
2029 }
2030
2031 #[test]
2035 fn only_a_document_holding_no_login_is_nobody_signed_in() {
2036 let home = scratch("shapes");
2037 let (ctx, _mem, api) = machine(&home.0, Some("alpha-uuid"));
2038 let mut last_switched_to_beta = state(&["alpha", "beta"]);
2039 last_switched_to_beta.set_active(ProviderId::Claude, Some("beta".into()));
2040 let ask = |document: Value| {
2041 ask_live(
2042 &ctx,
2043 &last_switched_to_beta,
2044 ProviderId::Claude,
2045 &Ok(Some(document)),
2046 &HashMap::new(),
2047 true,
2048 )
2049 .0
2050 };
2051
2052 let nobody = ask(json!({"mcpOAuth": {}}));
2053 assert!(nobody.signed_in.is_none() && !nobody.out_of_reach);
2054 assert_eq!(nobody.usage().err(), Some(Stale::NothingSignedIn));
2055
2056 let unusable = ask(json!(["a login", "that is not an object"]));
2057 assert!(matches!(unusable.signed_in, Some(Err(_))));
2058 assert!(unusable.out_of_reach);
2059 assert_eq!(unusable.usage().err(), Some(Stale::LoginUnusable));
2060 assert_eq!(
2061 unusable.recorded_id.as_deref(),
2062 Some("alpha-uuid"),
2063 "Claude Code's config, never Pitboard's record of its last switch"
2064 );
2065 assert_eq!(api.calls(), 0);
2066 }
2067
2068 #[test]
2071 fn the_row_in_use_is_the_organisation_anthropic_names() {
2072 let in_organisation = |label: &str, id: &str, organization: &str| Account {
2073 id: id.into(),
2074 account_uuid: "acc".into(),
2075 detail: crate::state::Detail::Claude {
2076 organization_uuid: organization.into(),
2077 oauth_account: json!({}),
2078 },
2079 ..account(label)
2080 };
2081 let state = State {
2082 accounts: vec![
2083 in_organisation("work", "acc", "org-a"),
2084 in_organisation("team", "acc_org-b", "org-b"),
2085 ],
2086 ..State::default()
2087 };
2088 let facts = only_claude(
2089 LiveLogin {
2090 signed_in: Some(Ok(Owner {
2091 account_uuid: "acc".into(),
2092 email: "acc@example.com".into(),
2093 organization_uuid: "org-b".into(),
2094 })),
2095 usage: Some(Ok(reading(30.0, Source::Live, None))),
2096 ..LiveLogin::default()
2097 },
2098 vec![Err(Stale::NothingParked), Err(Stale::NothingParked)],
2099 );
2100
2101 let rows = assemble(&state, &facts, nothing_remembered, NOW);
2102 let said: Vec<(Option<&str>, &str, Option<&str>, bool)> = rows
2103 .iter()
2104 .map(|r| {
2105 (
2106 r.label.as_deref(),
2107 r.id.as_str(),
2108 r.organization_uuid.as_deref(),
2109 r.signed_in,
2110 )
2111 })
2112 .collect();
2113 assert_eq!(
2114 said,
2115 [
2116 (Some("team"), "acc_org-b", Some("org-b"), true),
2117 (Some("work"), "acc", Some("org-a"), false),
2118 ]
2119 );
2120 }
2121
2122 #[test]
2125 fn a_row_is_unplaced_only_when_it_is_a_login_on_no_account() {
2126 let mut row = Row {
2127 provider: ProviderId::Codex,
2128 label: None,
2129 email: String::new(),
2130 id: String::new(),
2131 account_uuid: String::new(),
2132 organization_uuid: None,
2133 signed_in: false,
2134 parked: None,
2135 usage: None,
2136 stale: Some(Stale::LoginUnreadable),
2137 };
2138 assert!(row.unplaced());
2139 row.stale = Some(Stale::LoginUnusable);
2140 assert!(row.unplaced());
2141 row.signed_in = true;
2142 row.stale = Some(Stale::SessionExpired);
2143 assert!(!row.unplaced(), "an unenrolled login that is signed in");
2144 row.signed_in = false;
2145 row.label = Some("work".into());
2146 row.stale = Some(Stale::LoginUnreadable);
2147 assert!(!row.unplaced(), "an account");
2148 }
2149
2150 struct Scratch(std::path::PathBuf);
2152
2153 impl Drop for Scratch {
2154 fn drop(&mut self) {
2155 let _ = std::fs::remove_dir_all(&self.0);
2156 }
2157 }
2158
2159 fn scratch(name: &str) -> Scratch {
2160 let root = std::env::temp_dir().join(format!(
2161 "pitboard-status-{name}-{}-{:?}",
2162 std::process::id(),
2163 std::thread::current().id()
2164 ));
2165 let _ = std::fs::remove_dir_all(&root);
2166 std::fs::create_dir_all(&root).expect("a scratch home");
2167 Scratch(root)
2168 }
2169
2170 fn machine(
2174 root: &std::path::Path,
2175 recorded: Option<&str>,
2176 ) -> (Context, Arc<MemoryHost>, Arc<ScriptedApi>) {
2177 let mem = MemoryHost::new();
2178 let api = ScriptedApi::new();
2179 let ctx = Context::new(root.to_path_buf())
2180 .with_pitboard_home(root.join(".pitboard"))
2181 .with_codex_home(root.join("codex").to_string_lossy().into())
2182 .with_codex_program(root.join("bin").join("codex"))
2184 .with_memory_stores(Arc::clone(&mem))
2185 .with_scripted_api(Arc::clone(&api))
2186 .with_clock(Arc::new(FixedClock::at(NOW)) as Arc<dyn Clock>);
2187 crate::home::ensure(&ctx, Permit::for_a_test()).expect("a Pitboard home");
2188 if let Some(uuid) = recorded {
2189 std::fs::write(
2190 root.join(".claude.json"),
2191 json!({"oauthAccount": {
2192 "accountUuid": uuid,
2193 "emailAddress": format!("{uuid}@example.com"),
2194 "organizationUuid": "org",
2195 }})
2196 .to_string(),
2197 )
2198 .expect("a Claude Code config");
2199 }
2200 (ctx, mem, api)
2201 }
2202}