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 account_uuid: String,
179 pub signed_in: bool,
180 pub parked: Option<Park>,
181 pub usage: Option<Snapshot>,
182 pub stale: Option<Stale>,
183 pub runway: crate::history::Runway,
189}
190
191impl Row {
192 pub fn switchable(&self, now: i64) -> bool {
194 !self.signed_in && self.parked.as_ref().is_some_and(|p| p.restorable_at(now))
195 }
196
197 pub fn key(&self) -> Option<Key> {
200 self.label
201 .as_ref()
202 .map(|label| Key::new(self.provider, label.clone()))
203 }
204
205 pub fn explanation(&self) -> Option<&'static str> {
207 self.stale
208 .and_then(|stale| stale.explanation_for(self.provider))
209 }
210
211 pub fn unplaced(&self) -> bool {
216 self.label.is_none()
217 && !self.signed_in
218 && matches!(
219 self.stale,
220 Some(Stale::LoginUnreadable | Stale::LoginUnusable)
221 )
222 }
223}
224
225pub struct Report {
226 pub now: i64,
227 pub rows: Vec<Row>,
228 pub signed_in: Result<Owner, String>,
231 pub slot: Slot,
239}
240
241#[derive(Debug, Clone, PartialEq, Eq)]
243pub struct Slot {
244 pub service: String,
246 pub default: bool,
248}
249
250#[derive(Default)]
256struct LiveLogin {
257 signed_in: Option<Result<Owner, String>>,
262 recorded_uuid: Option<String>,
266 usage: Option<Result<Snapshot, Stale>>,
267 out_of_reach: bool,
271}
272
273impl LiveLogin {
274 fn usage(&self) -> Result<Snapshot, Stale> {
275 self.usage.clone().unwrap_or(Err(Stale::NothingSignedIn))
276 }
277}
278
279struct Facts {
281 live: BTreeMap<ProviderId, LiveLogin>,
282 asked: bool,
283 parked_usage: Vec<Result<Snapshot, Stale>>,
285 claude_code_cache: Option<Snapshot>,
286}
287
288impl Facts {
289 fn live_for(&self, which: ProviderId) -> Option<&LiveLogin> {
290 self.live.get(&which)
291 }
292}
293
294fn parked_document(
299 ctx: &Context,
300 key: &Key,
301 parked: Option<&Park>,
302 now: i64,
303) -> Result<Value, Stale> {
304 match parked {
305 None => Err(Stale::NothingParked),
306 Some(p) if !p.askable_at(now) => Err(Stale::ParkedAccessExpired),
307 Some(p) => park::load(ctx, key, p).map_err(|_| Stale::ParkUnreadable),
308 }
309}
310
311fn slot_of(ctx: &Context) -> Slot {
313 Slot {
314 service: claude::live_service(ctx),
315 default: claude::is_default_slot(ctx),
316 }
317}
318
319fn in_use(state: &State) -> Vec<ProviderId> {
336 ProviderId::ALL
337 .iter()
338 .copied()
339 .filter(|&which| {
340 which == crate::label::DEFAULT || state.accounts.iter().any(|a| a.provider() == which)
341 })
342 .collect()
343}
344
345pub fn gather_offline(ctx: &Context, state: &State) -> Report {
346 let tools = in_use(state);
347 let recorded: BTreeMap<ProviderId, crate::provider::Identity> = tools
348 .iter()
349 .filter_map(|&which| Some((which, crate::provider::of(which).recorded_identity(ctx)?)))
350 .collect();
351 let facts = Facts {
352 live: tools
353 .iter()
354 .map(|&which| {
355 let login = LiveLogin {
356 recorded_uuid: recorded.get(&which).map(|id| id.account_id.clone()),
357 usage: Some(Err(Stale::NotAsked)),
358 ..LiveLogin::default()
359 };
360 (which, login)
361 })
362 .collect(),
363 asked: false,
364 parked_usage: state
365 .accounts
366 .iter()
367 .map(|_| Err(Stale::NotAsked))
368 .collect(),
369 claude_code_cache: claude::load_config(ctx)
370 .ok()
371 .as_ref()
372 .and_then(crate::usage::from_config_cache),
373 };
374 let remembered = readings::load(ctx);
375 Report {
376 now: ctx.now(),
377 slot: slot_of(ctx),
378 rows: assemble(
379 state,
380 &facts,
381 |uuid| remembered.get(uuid).cloned(),
382 |uuid| crate::history::runway_for(ctx, uuid, ctx.now()),
383 ctx.now(),
384 ),
385 signed_in: recorded.get(&crate::label::DEFAULT).map_or_else(
386 || Err("not asked".into()),
387 |id| {
388 Ok(Owner {
389 account_uuid: id.account_id.clone(),
390 email: id.email.clone(),
391 organization_uuid: id.group.clone().unwrap_or_default(),
392 })
393 },
394 ),
395 }
396}
397
398type Asked = (Result<Snapshot, Stale>, Option<(String, budget::Outcome)>);
402
403type Answered = (LiveLogin, Option<(String, budget::Outcome)>);
406
407fn to_api(error: crate::provider::ProviderError) -> ApiError {
409 use crate::provider::ProviderError as P;
410 match error {
411 P::Unauthorized => ApiError::Unauthorized,
412 P::RateLimited { retry_after, .. } => ApiError::RateLimited { retry_after },
413 P::Network { detail, .. } => ApiError::Network(detail),
414 P::Unexpected { status, .. } => ApiError::Unexpected { status },
415 P::Malformed { detail, .. }
416 | P::ShapeUnexpected { detail, .. }
417 | P::Unsupported { reason: detail, .. } => ApiError::Malformed(detail),
418 P::NoLogin { .. } => ApiError::Malformed("nothing is signed in".into()),
419 P::InvalidGrant { .. } => ApiError::InvalidGrant,
420 }
421}
422
423fn ask_usage(
425 ctx: &Context,
426 which: ProviderId,
427 account_uuid: Option<&str>,
428 document: &Value,
429 signed_in: bool,
430 remembered: Option<&Snapshot>,
431 fresh: bool,
432) -> Asked {
433 if let Some(uuid) = account_uuid
436 && let Some(held) = budget::may_ask(ctx, uuid, remembered, fresh)
437 {
438 let stale = match held {
439 budget::Held::Fresh => Stale::AskedRecently,
440 budget::Held::RateLimited => Stale::RateLimited,
441 budget::Held::Unreachable => Stale::Unreachable,
442 };
443 return (Err(stale), None);
444 }
445 let answer = crate::provider::of(which).usage(
449 ctx,
450 &crate::provider::Credential::new(which, document.clone()),
451 );
452 if !signed_in
456 && matches!(
457 answer,
458 Err(crate::provider::ProviderError::ShapeUnexpected { .. })
459 )
460 {
461 return (Err(Stale::ParkUnreadable), None);
462 }
463 let answer = answer.map_err(to_api);
464 let learned = account_uuid.and_then(|uuid| {
465 let outcome = match &answer {
466 Ok(_) => budget::Outcome::Answered,
467 Err(api::ApiError::RateLimited { retry_after }) => {
468 budget::Outcome::RateLimited(*retry_after)
469 }
470 Err(api::ApiError::Network(_) | api::ApiError::Unexpected { .. }) => {
471 budget::Outcome::Unreachable
472 }
473 Err(_) => return None,
476 };
477 Some((uuid.to_string(), outcome))
478 });
479 (answer.map_err(|e| Stale::of(&e, signed_in)), learned)
480}
481
482fn ask_live(
495 ctx: &Context,
496 which: ProviderId,
497 read: &Result<Option<Value>, ProviderError>,
498 active: Option<&str>,
499 remembered: &HashMap<String, Snapshot>,
500 fresh: bool,
501) -> Answered {
502 let tool = crate::provider::of(which);
503 let own_record = || tool.recorded_identity(ctx).map(|id| id.account_id);
504 let document = match read {
505 Ok(Some(document)) => document,
506 Ok(None) => return (LiveLogin::default(), None),
507 Err(error) => {
508 let unreadable = LiveLogin {
509 signed_in: Some(Err(error.to_string())),
510 recorded_uuid: own_record().or_else(|| active.map(str::to_owned)),
511 usage: Some(Err(Stale::LoginUnreadable)),
512 out_of_reach: true,
513 };
514 return (unreadable, None);
515 }
516 };
517 match tool.slice(document) {
518 Ok(_) => {}
519 Err(ProviderError::NoLogin { .. }) => return (LiveLogin::default(), None),
525 Err(unusable) => {
533 let login = LiveLogin {
534 signed_in: Some(Err(unusable.to_string())),
535 recorded_uuid: own_record(),
536 usage: Some(Err(Stale::LoginUnusable)),
537 out_of_reach: true,
538 };
539 return (login, None);
540 }
541 }
542 let credential = crate::provider::Credential::new(which, document.clone());
543 let owner = tool
546 .identify(ctx, &credential)
547 .map(|found| Owner {
548 account_uuid: found.account_id,
549 email: found.email,
550 organization_uuid: found.group.unwrap_or_default(),
551 })
552 .map_err(|e| to_api(e).to_string());
553 let recorded = match &owner {
559 Ok(_) => None,
560 Err(_) => own_record(),
561 };
562 let uuid = owner
563 .as_ref()
564 .ok()
565 .map(|o| o.account_uuid.clone())
566 .or_else(|| recorded.clone());
567 let (usage, learned) = ask_usage(
568 ctx,
569 which,
570 uuid.as_deref(),
571 document,
572 true,
573 uuid.as_deref().and_then(|u| remembered.get(u)),
574 fresh,
575 );
576 let login = LiveLogin {
577 signed_in: Some(owner),
578 recorded_uuid: recorded,
579 usage: Some(usage),
580 out_of_reach: false,
581 };
582 (login, learned)
583}
584
585fn settle(
592 ctx: &Context,
593 answers: Vec<(ProviderId, std::thread::Result<Answered>)>,
594) -> (
595 BTreeMap<ProviderId, LiveLogin>,
596 Vec<(String, budget::Outcome)>,
597) {
598 let mut live = BTreeMap::new();
599 let mut learned = Vec::new();
600 for (which, answer) in answers {
601 let (login, outcome) = answer.unwrap_or_else(|_| {
602 let interrupted = LiveLogin {
605 signed_in: Some(Err("the check did not finish".into())),
606 recorded_uuid: crate::provider::of(which)
607 .recorded_identity(ctx)
608 .map(|id| id.account_id),
609 usage: Some(Err(Stale::Interrupted)),
610 out_of_reach: false,
611 };
612 (interrupted, None)
613 });
614 live.insert(which, login);
615 learned.extend(outcome);
616 }
617 (live, learned)
618}
619
620pub fn gather(ctx: &Context, state: &State, fresh: bool) -> Report {
621 let now = ctx.now();
622 let live_documents: Vec<(ProviderId, Result<Option<Value>, ProviderError>)> = in_use(state)
626 .into_iter()
627 .map(|which| {
628 let read = crate::provider::of(which)
629 .read_live(ctx)
630 .map(|found| found.map(|credential| credential.raw));
631 (which, read)
632 })
633 .collect();
634 let parked_documents: Vec<Result<Value, Stale>> = state
635 .accounts
636 .iter()
637 .map(|a| parked_document(ctx, &a.key(), a.parked.as_ref(), now))
638 .collect();
639 let config = claude::load_config(ctx).ok();
640 let remembered = readings::load(ctx);
641
642 let (answers, parked_asked): (Vec<(ProviderId, std::thread::Result<Answered>)>, Vec<Asked>) =
643 std::thread::scope(|scope| {
644 let per_tool: Vec<_> = live_documents
647 .iter()
648 .map(|(which, read)| {
649 let remembered = &remembered;
650 let which = *which;
651 let active = active_uuid(state, which);
652 let handle =
653 scope.spawn(move || ask_live(ctx, which, read, active, remembered, fresh));
654 (which, handle)
655 })
656 .collect();
657 let parked: Vec<_> = parked_documents
658 .iter()
659 .zip(state.accounts.iter())
660 .map(|(token, account)| {
661 let remembered = &remembered;
662 scope.spawn(move || match token.as_ref() {
663 Err(stale) => (Err(*stale), None),
664 Ok(document) => ask_usage(
665 ctx,
666 account.provider(),
667 Some(&account.account_uuid),
668 document,
669 false,
670 remembered.get(&account.account_uuid),
671 fresh,
672 ),
673 })
674 })
675 .collect();
676 (
677 per_tool
678 .into_iter()
679 .map(|(which, handle)| (which, handle.join()))
680 .collect(),
681 parked
682 .into_iter()
683 .map(|h| h.join().unwrap_or((Err(Stale::Interrupted), None)))
684 .collect(),
685 )
686 });
687 let (live, live_learned) = settle(ctx, answers);
688
689 let learned: Vec<(String, budget::Outcome)> = live_learned
693 .into_iter()
694 .chain(
695 parked_asked
696 .iter()
697 .filter_map(|(_, learned)| learned.clone()),
698 )
699 .collect();
700 budget::record(ctx, &learned);
701
702 let signed_in_default = live
703 .get(&crate::label::DEFAULT)
704 .and_then(|login| login.signed_in.clone());
705 let facts = Facts {
706 live,
707 asked: true,
708 parked_usage: parked_asked.into_iter().map(|(usage, _)| usage).collect(),
709 claude_code_cache: config.as_ref().and_then(crate::usage::from_config_cache),
710 };
711 let rows = assemble(
712 state,
713 &facts,
714 |uuid| remembered.get(uuid).cloned(),
715 |uuid| crate::history::runway_for(ctx, uuid, now),
716 now,
717 );
718 for row in &rows {
719 if let Some(live) = row.usage.as_ref().filter(|u| u.source == Source::Live) {
720 crate::history::record(ctx, &row.account_uuid, live);
721 }
722 }
723 readings::remember(
724 ctx,
725 &rows
726 .iter()
727 .filter_map(|r| {
728 r.usage
729 .as_ref()
730 .filter(|u| u.source == Source::Live)
731 .map(|u| (r.account_uuid.clone(), u.clone()))
732 })
733 .collect::<Vec<_>>(),
734 );
735
736 Report {
737 slot: slot_of(ctx),
738 now,
739 rows,
740 signed_in: match signed_in_default {
744 Some(Ok(owner)) => Ok(owner),
745 Some(Err(why)) => Err(why),
746 None => Err("nothing is signed in".into()),
747 },
748 }
749}
750
751fn active_uuid(state: &State, which: ProviderId) -> Option<&str> {
753 let label = state.active_for(which)?;
754 state
755 .get(&Key::new(which, label))
756 .map(|account| account.account_uuid.as_str())
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 lasting: impl Fn(&str) -> crate::history::Runway,
772 now: i64,
773) -> Vec<Row> {
774 let live_uuid = |which: ProviderId| -> Option<&str> {
778 let live = facts.live_for(which)?;
779 match (&live.signed_in, facts.asked) {
780 (Some(Ok(owner)), _) => Some(owner.account_uuid.as_str()),
781 (Some(Err(_)), _) | (None, false) => live.recorded_uuid.as_deref(),
784 (None, true) => None,
785 }
786 };
787 let cached_for = |which: ProviderId, uuid: &str| {
790 facts
791 .claude_code_cache
792 .clone()
793 .filter(|c| which == ProviderId::Claude && c.account_uuid.as_deref() == Some(uuid))
794 };
795 let reading = |uuid: &str, asked: &Result<Snapshot, Stale>, cached: Option<Snapshot>| {
800 let recalled = recall(uuid);
801 match asked {
802 Ok(live) => (merge(recalled.as_ref(), Some(live), now), None),
803 Err(stale) => (merge(recalled.as_ref(), cached.as_ref(), now), Some(*stale)),
804 }
805 };
806
807 let mut rows: Vec<Row> = state
808 .accounts
809 .iter()
810 .zip(&facts.parked_usage)
811 .map(|(account, parked)| {
812 let uuid = account.account_uuid.as_str();
813 let which = account.provider();
814 let signed_in = live_uuid(which) == Some(uuid);
815 let (usage, stale) = if signed_in {
816 let live = facts
817 .live_for(which)
818 .map_or(Err(Stale::NothingSignedIn), LiveLogin::usage);
819 reading(uuid, &live, cached_for(which, uuid))
820 } else {
821 reading(uuid, parked, None)
822 };
823 Row {
824 provider: which,
825 label: Some(account.label.clone()),
826 email: account.email.clone(),
827 account_uuid: account.account_uuid.clone(),
828 signed_in,
829 parked: account.parked.clone(),
830 usage,
831 stale,
832 runway: lasting(uuid),
833 }
834 })
835 .collect();
836
837 for &which in ProviderId::ALL {
842 let Some(live) = facts.live_for(which) else {
843 continue;
844 };
845 if let Some(Ok(owner)) = &live.signed_in {
846 if rows
847 .iter()
848 .any(|r| r.provider == which && r.account_uuid == owner.account_uuid)
849 {
850 continue;
851 }
852 let (usage, stale) = reading(
853 &owner.account_uuid,
854 &live.usage(),
855 cached_for(which, &owner.account_uuid),
856 );
857 rows.push(Row {
858 provider: which,
859 label: None,
860 email: owner.email.clone(),
861 account_uuid: owner.account_uuid.clone(),
862 signed_in: true,
863 parked: None,
864 usage,
865 stale,
866 runway: lasting(&owner.account_uuid),
867 });
868 continue;
869 }
870 let enrolled = state.accounts.iter().any(|a| a.provider() == which);
876 let placed = rows.iter().any(|r| r.provider == which && r.signed_in);
877 if live.out_of_reach && enrolled && !placed {
878 rows.push(Row {
879 provider: which,
880 label: None,
881 email: String::new(),
882 account_uuid: String::new(),
883 signed_in: false,
884 parked: None,
885 usage: None,
886 stale: live.usage().err(),
887 runway: crate::history::Runway::Unknown,
888 });
889 }
890 }
891
892 rows.sort_by_key(|r| (rank(r.provider), !r.signed_in));
896 rows
897}
898
899#[cfg(test)]
900mod tests {
901 use super::*;
902 use crate::api::scripted::{Asked as Question, ScriptedApi, Trouble};
903 use crate::state::Account;
904 use crate::store::memory::{Fault, MemoryHost};
905 use crate::time::{Clock, FixedClock};
906 use crate::usage::Window;
907 use serde_json::json;
908 use std::sync::Arc;
909
910 const NOW: i64 = 1_789_935_000;
911
912 fn owner(uuid: &str) -> Owner {
913 Owner {
914 account_uuid: uuid.into(),
915 email: format!("{uuid}@example.com"),
916 organization_uuid: "org".into(),
917 }
918 }
919
920 fn reading(percent: f64, source: Source, account: Option<&str>) -> Snapshot {
921 Snapshot {
922 windows: vec![Window {
923 kind: "session".into(),
924 scope: None,
925 severity: None,
926 percent,
927 resets_at: Some(NOW + 3_600),
928 is_active: true,
929 length_seconds: None,
930 }],
931 observed_at: Some(NOW - 7_200),
932 account_uuid: account.map(str::to_owned),
933 source,
934 }
935 }
936
937 fn parked(refresh_expires_at: i64) -> Park {
938 Park {
939 service: "pitboard-park-x-1".into(),
940 parked_at: NOW - 86_400,
941 refresh_fingerprint: "f".into(),
942 access_expires_at: Some(NOW - 3_600),
943 refresh_expires_at: Some(refresh_expires_at),
944 }
945 }
946
947 fn account(label: &str) -> Account {
948 Account {
949 last_used_at: None,
950 label: label.into(),
951 account_uuid: format!("{label}-uuid"),
952 email: format!("{label}@example.com"),
953 detail: crate::state::Detail::Claude {
954 organization_uuid: "org".into(),
955 oauth_account: json!({}),
956 },
957 parked: Some(parked(NOW + 20 * 86_400)),
958 }
959 }
960
961 fn state(labels: &[&str]) -> State {
962 State {
963 accounts: labels.iter().map(|l| account(l)).collect(),
964 ..State::default()
965 }
966 }
967
968 fn facts(
969 signed_in: &str,
970 live: Result<Snapshot, Stale>,
971 parked: Vec<Result<Snapshot, Stale>>,
972 ) -> Facts {
973 only_claude(
974 LiveLogin {
975 signed_in: Some(Ok(owner(signed_in))),
976 usage: Some(live),
977 ..LiveLogin::default()
978 },
979 parked,
980 )
981 }
982
983 fn only_claude(live: LiveLogin, parked: Vec<Result<Snapshot, Stale>>) -> Facts {
985 Facts {
986 live: std::iter::once((ProviderId::Claude, live)).collect(),
987 asked: true,
988 parked_usage: parked,
989 claude_code_cache: None,
990 }
991 }
992
993 #[test]
997 fn an_unreachable_anthropic_leaves_the_signed_in_row_signed_in() {
998 let state = state(&["alpha", "beta"]);
999 let facts = Facts {
1000 live: std::iter::once((
1001 ProviderId::Claude,
1002 LiveLogin {
1003 signed_in: Some(Err("could not reach Anthropic: offline".into())),
1004 recorded_uuid: Some("alpha-uuid".into()),
1005 usage: Some(Err(Stale::Unreachable)),
1006 out_of_reach: false,
1007 },
1008 ))
1009 .collect(),
1010 asked: true,
1011 parked_usage: vec![Err(Stale::Unreachable), Err(Stale::Unreachable)],
1012 claude_code_cache: None,
1013 };
1014 let rows = assemble(&state, &facts, nothing_remembered, nothing_known, NOW);
1015 let a = rows
1016 .iter()
1017 .find(|r| r.account_uuid == "alpha-uuid")
1018 .unwrap();
1019 assert!(
1020 a.signed_in,
1021 "the account in use is still the account in use"
1022 );
1023 let b = rows.iter().find(|r| r.account_uuid == "beta-uuid").unwrap();
1024 assert!(!b.signed_in);
1025 }
1026
1027 fn nothing_remembered(_: &str) -> Option<Snapshot> {
1028 None
1029 }
1030
1031 fn nothing_known(_: &str) -> crate::history::Runway {
1032 crate::history::Runway::Unknown
1033 }
1034
1035 #[test]
1036 fn every_stale_code_is_its_json_form() {
1037 for stale in [
1038 Stale::NothingSignedIn,
1039 Stale::LoginUnreadable,
1040 Stale::LoginUnusable,
1041 Stale::SessionExpired,
1042 Stale::ParkedAccessExpired,
1043 Stale::NothingParked,
1044 Stale::ParkUnreadable,
1045 Stale::RateLimited,
1046 Stale::Unreachable,
1047 Stale::ServerError,
1048 Stale::AnswerNotUnderstood,
1049 Stale::LoginRefused,
1050 Stale::Interrupted,
1051 Stale::NotAsked,
1052 ] {
1053 assert_eq!(serde_json::to_value(stale).unwrap(), stale.code());
1054 }
1055 }
1056
1057 #[test]
1061 fn anthropics_failures_are_told_apart() {
1062 use crate::api::ApiError;
1063 let parked = |e: &ApiError| Stale::of(e, false);
1064 assert_eq!(
1065 parked(&ApiError::Unexpected { status: 503 }),
1066 Stale::ServerError
1067 );
1068 assert_eq!(
1069 parked(&ApiError::Malformed("no windows".into())),
1070 Stale::AnswerNotUnderstood
1071 );
1072 assert_eq!(parked(&ApiError::InvalidGrant), Stale::LoginRefused);
1073 assert_eq!(
1074 parked(&ApiError::RateLimited { retry_after: None }),
1075 Stale::RateLimited
1076 );
1077 assert_eq!(
1078 parked(&ApiError::Network("down".into())),
1079 Stale::Unreachable
1080 );
1081
1082 assert_eq!(parked(&ApiError::Unauthorized), Stale::ParkedAccessExpired);
1084 assert_eq!(
1085 Stale::of(&ApiError::Unauthorized, true),
1086 Stale::SessionExpired
1087 );
1088 }
1089
1090 #[test]
1091 fn a_live_reading_wins_over_claude_codes_cache() {
1092 let s = state(&["work"]);
1093 let mut f = facts(
1094 "work-uuid",
1095 Ok(reading(30.0, Source::Live, None)),
1096 vec![Err(Stale::NothingParked)],
1097 );
1098 f.claude_code_cache = Some(reading(2.0, Source::ClaudeCodeCache, Some("work-uuid")));
1099 let rows = assemble(&s, &f, nothing_remembered, nothing_known, NOW);
1100 let usage = rows[0].usage.as_ref().unwrap();
1101 assert_eq!(usage.source, Source::Live);
1102 assert_eq!(usage.windows[0].percent, 30.0);
1103 assert_eq!(rows[0].stale, None);
1104 }
1105
1106 #[test]
1107 fn an_expired_session_falls_back_to_claude_codes_cache_and_says_why() {
1108 let s = state(&["work"]);
1109 let mut f = facts(
1110 "work-uuid",
1111 Err(Stale::SessionExpired),
1112 vec![Err(Stale::NothingParked)],
1113 );
1114 f.claude_code_cache = Some(reading(2.0, Source::ClaudeCodeCache, Some("work-uuid")));
1115 let rows = assemble(&s, &f, nothing_remembered, nothing_known, NOW);
1116 assert_eq!(
1117 rows[0].usage.as_ref().unwrap().source,
1118 Source::ClaudeCodeCache
1119 );
1120 assert_eq!(rows[0].stale, Some(Stale::SessionExpired));
1121 }
1122
1123 #[test]
1128 fn offline_the_account_in_use_shows_the_newer_of_claude_codes_cache_and_the_readings() {
1129 let s = state(&["work"]);
1130 let mut f = facts(
1131 "work-uuid",
1132 Err(Stale::NotAsked),
1133 vec![Err(Stale::NothingParked)],
1134 );
1135 f.asked = false;
1136 f.claude_code_cache = Some(reading(20.0, Source::ClaudeCodeCache, Some("work-uuid")));
1137 let recorded = |_: &str| Some(reading(22.0, Source::Remembered, Some("work-uuid")));
1138 let rows = assemble(&s, &f, recorded, nothing_known, NOW);
1139 let usage = rows[0].usage.as_ref().unwrap();
1140 assert_eq!(usage.windows[0].percent, 22.0);
1141 assert_eq!(usage.source, Source::Remembered);
1142 assert_eq!(rows[0].stale, Some(Stale::NotAsked));
1143
1144 f.claude_code_cache = Some(reading(25.0, Source::ClaudeCodeCache, Some("work-uuid")));
1145 let rows = assemble(&s, &f, recorded, nothing_known, NOW);
1146 let usage = rows[0].usage.as_ref().unwrap();
1147 assert_eq!(
1148 usage.windows[0].percent, 25.0,
1149 "and the cache where it is newer"
1150 );
1151 assert_eq!(usage.source, Source::ClaudeCodeCache);
1152 }
1153
1154 #[test]
1158 fn a_live_reading_behind_what_a_session_recorded_does_not_move_the_row_back() {
1159 let s = state(&["work"]);
1160 let f = facts(
1161 "work-uuid",
1162 Ok(reading(20.0, Source::Live, None)),
1163 vec![Err(Stale::NothingParked)],
1164 );
1165 let recorded = |_: &str| {
1166 let mut since = reading(22.0, Source::Remembered, Some("work-uuid"));
1167 since.observed_at = Some(NOW - 60);
1168 Some(since)
1169 };
1170 let rows = assemble(&s, &f, recorded, nothing_known, NOW);
1171 assert_eq!(rows[0].usage.as_ref().unwrap().windows[0].percent, 22.0);
1172 assert_eq!(rows[0].stale, None);
1173
1174 let f = facts(
1175 "work-uuid",
1176 Ok(reading(30.0, Source::Live, None)),
1177 vec![Err(Stale::NothingParked)],
1178 );
1179 let usage = assemble(&s, &f, recorded, nothing_known, NOW)[0]
1180 .usage
1181 .clone()
1182 .unwrap();
1183 assert_eq!(usage.windows[0].percent, 30.0);
1184 assert_eq!(
1185 usage.source,
1186 Source::Live,
1187 "a live reading ahead is shown as live"
1188 );
1189 }
1190
1191 #[test]
1195 fn a_live_reading_taken_after_what_was_recorded_is_shown_however_low() {
1196 let s = state(&["work"]);
1197 let mut answered = reading(14.0, Source::Live, None);
1198 answered.observed_at = Some(NOW);
1199 let f = facts("work-uuid", Ok(answered), vec![Err(Stale::NothingParked)]);
1200 let recorded = |_: &str| Some(reading(100.0, Source::Remembered, Some("work-uuid")));
1201 let usage = assemble(&s, &f, recorded, nothing_known, NOW)[0]
1202 .usage
1203 .clone()
1204 .unwrap();
1205 assert_eq!(usage.windows[0].percent, 14.0);
1206 assert_eq!(usage.source, Source::Live);
1207 }
1208
1209 #[test]
1210 fn claude_codes_cache_for_another_account_is_never_shown_as_this_one() {
1211 let s = state(&["work"]);
1212 let mut f = facts(
1213 "work-uuid",
1214 Err(Stale::Unreachable),
1215 vec![Err(Stale::NothingParked)],
1216 );
1217 f.claude_code_cache = Some(reading(99.0, Source::ClaudeCodeCache, Some("someone-else")));
1218 assert!(
1219 assemble(&s, &f, nothing_remembered, nothing_known, NOW)[0]
1220 .usage
1221 .is_none()
1222 );
1223 }
1224
1225 #[test]
1226 fn a_parked_account_is_asked_with_its_own_login() {
1227 let s = state(&["work", "personal"]);
1228 let f = facts(
1229 "work-uuid",
1230 Ok(reading(30.0, Source::Live, None)),
1231 vec![
1232 Err(Stale::NothingParked),
1233 Ok(reading(12.0, Source::Live, None)),
1234 ],
1235 );
1236 let rows = assemble(&s, &f, nothing_remembered, nothing_known, NOW);
1237 let personal = rows
1238 .iter()
1239 .find(|r| r.label.as_deref() == Some("personal"))
1240 .unwrap();
1241 assert_eq!(personal.usage.as_ref().unwrap().windows[0].percent, 12.0);
1242 assert!(personal.switchable(NOW));
1243 }
1244
1245 #[test]
1249 fn a_refused_token_is_an_expired_session_when_it_is_the_one_signed_in() {
1250 let api = crate::api::scripted::ScriptedApi::new();
1251 api.token_trouble("t", crate::api::scripted::Trouble::Unauthorized);
1252 let ctx = Context::new(std::path::PathBuf::from("/nowhere"))
1253 .with_pitboard_home(std::env::temp_dir().join("pitboard-status-refused"))
1254 .with_scripted_api(api);
1255 let login = serde_json::json!({"claudeAiOauth": {"accessToken": "t"}});
1256 let ask = |signed_in| {
1257 ask_usage(
1258 &ctx,
1259 ProviderId::Claude,
1260 None,
1261 &login,
1262 signed_in,
1263 None,
1264 true,
1265 )
1266 .0
1267 };
1268 assert_eq!(ask(true).unwrap_err(), Stale::SessionExpired);
1269 assert_eq!(ask(false).unwrap_err(), Stale::ParkedAccessExpired);
1270 }
1271
1272 #[test]
1276 fn a_park_not_in_its_own_tools_shape_is_unreadable_rather_than_an_answer() {
1277 let ctx = Context::new(std::path::PathBuf::from("/nowhere"));
1278 let not_codex = serde_json::json!({"claudeAiOauth": {"accessToken": "t"}});
1279 let (answer, learned) =
1280 ask_usage(&ctx, ProviderId::Codex, None, ¬_codex, false, None, true);
1281 assert_eq!(answer.unwrap_err(), Stale::ParkUnreadable);
1282 assert!(
1283 learned.is_none(),
1284 "nothing was asked, so there is nothing to learn"
1285 );
1286 }
1287
1288 #[test]
1289 fn a_parked_login_past_its_access_expiry_is_not_asked() {
1290 let ctx = Context::from_env();
1291 let personal = Key::new(ProviderId::Claude, "personal");
1292 let token = parked_document(&ctx, &personal, Some(&parked(NOW + 86_400)), NOW);
1293 assert_eq!(token, Err(Stale::ParkedAccessExpired));
1294 assert_eq!(
1295 parked_document(&ctx, &personal, None, NOW),
1296 Err(Stale::NothingParked)
1297 );
1298 }
1299
1300 #[test]
1301 fn what_cannot_be_asked_falls_back_to_what_was_remembered() {
1302 let s = state(&["work", "personal"]);
1303 let f = facts(
1304 "work-uuid",
1305 Ok(reading(30.0, Source::Live, None)),
1306 vec![Err(Stale::NothingParked), Err(Stale::ParkedAccessExpired)],
1307 );
1308 let remembered = |uuid: &str| {
1309 (uuid == "personal-uuid").then(|| reading(44.0, Source::Remembered, Some(uuid)))
1310 };
1311 let rows = assemble(&s, &f, remembered, nothing_known, NOW);
1312 let personal = rows
1313 .iter()
1314 .find(|r| r.label.as_deref() == Some("personal"))
1315 .unwrap();
1316 assert_eq!(personal.usage.as_ref().unwrap().source, Source::Remembered);
1317 assert_eq!(personal.stale, Some(Stale::ParkedAccessExpired));
1318 }
1319
1320 #[test]
1321 fn the_signed_in_account_comes_first_and_is_taken_from_the_server_not_the_config() {
1322 let s = state(&["alpha", "beta"]);
1323 let f = facts(
1324 "beta-uuid",
1325 Ok(reading(5.0, Source::Live, None)),
1326 vec![Err(Stale::NothingParked), Err(Stale::NothingParked)],
1327 );
1328 let rows = assemble(&s, &f, nothing_remembered, nothing_known, NOW);
1329 assert_eq!(rows[0].label.as_deref(), Some("beta"));
1330 assert!(rows[0].signed_in && !rows[0].switchable(NOW));
1331 assert!(!rows[1].signed_in);
1332 }
1333
1334 const EVERY_STALE: [Stale; 15] = [
1336 Stale::NothingSignedIn,
1337 Stale::LoginUnreadable,
1338 Stale::LoginUnusable,
1339 Stale::SessionExpired,
1340 Stale::ParkedAccessExpired,
1341 Stale::NothingParked,
1342 Stale::ParkUnreadable,
1343 Stale::RateLimited,
1344 Stale::Unreachable,
1345 Stale::ServerError,
1346 Stale::AnswerNotUnderstood,
1347 Stale::LoginRefused,
1348 Stale::AskedRecently,
1349 Stale::Interrupted,
1350 Stale::NotAsked,
1351 ];
1352
1353 #[test]
1356 fn claude_codes_explanations_are_word_for_word_what_they_were() {
1357 let before = |stale: Stale| match stale {
1358 Stale::NothingSignedIn => Some("nothing is signed in"),
1359 Stale::SessionExpired => Some("Claude Code's session has expired; `claude` renews it"),
1360 Stale::ParkedAccessExpired | Stale::NothingParked | Stale::AskedRecently => None,
1361 Stale::ParkUnreadable => Some("its parked login cannot be read; run `pitboard doctor`"),
1362 Stale::RateLimited => Some("Anthropic is rate limiting usage checks"),
1363 Stale::Unreachable => Some("Anthropic could not be reached"),
1364 Stale::ServerError => Some("Anthropic answered with an error; try again later"),
1365 Stale::AnswerNotUnderstood => Some("Anthropic's answer was not understood"),
1366 Stale::LoginRefused => Some("its parked login is no longer accepted; sign in again"),
1367 Stale::Interrupted => Some("the check did not finish"),
1368 Stale::NotAsked => Some("read without asking Anthropic"),
1369 Stale::LoginUnreadable => {
1371 Some("Claude Code's login could not be read; run `pitboard doctor`")
1372 }
1373 Stale::LoginUnusable => Some(
1374 "Claude Code's login is not one pitboard can park or switch; run `pitboard doctor`",
1375 ),
1376 };
1377 for stale in EVERY_STALE {
1378 assert_eq!(
1379 stale.explanation_for(ProviderId::Claude),
1380 before(stale),
1381 "{stale:?}"
1382 );
1383 assert_eq!(
1384 stale.explanation(),
1385 stale.explanation_for(ProviderId::Claude),
1386 "a caller with only a code gets the default tool's words"
1387 );
1388 }
1389 }
1390
1391 #[test]
1395 fn a_codex_row_names_openai_and_codex_never_anthropic_or_claude() {
1396 for stale in EVERY_STALE {
1397 let Some(said) = stale.explanation_for(ProviderId::Codex) else {
1398 assert_eq!(stale.explanation_for(ProviderId::Claude), None, "{stale:?}");
1399 continue;
1400 };
1401 for other in ["Anthropic", "Claude", "claude"] {
1402 assert!(!said.contains(other), "{stale:?}: {said}");
1403 }
1404 }
1405 assert_eq!(
1406 Stale::Unreachable.explanation_for(ProviderId::Codex),
1407 Some("OpenAI could not be reached")
1408 );
1409 assert_eq!(
1410 Stale::SessionExpired.explanation_for(ProviderId::Codex),
1411 Some("Codex's session has expired; `codex` renews it")
1412 );
1413
1414 let mut codex = codex_account("work", "work-acc");
1416 codex.parked = None;
1417 let s = State {
1418 accounts: vec![codex],
1419 ..State::default()
1420 };
1421 let f = Facts {
1422 live: BTreeMap::new(),
1423 asked: true,
1424 parked_usage: vec![Err(Stale::Unreachable)],
1425 claude_code_cache: None,
1426 };
1427 let rows = assemble(&s, &f, nothing_remembered, nothing_known, NOW);
1428 assert_eq!(rows[0].explanation(), Some("OpenAI could not be reached"));
1429 }
1430
1431 fn codex_account(label: &str, uuid: &str) -> Account {
1432 Account {
1433 last_used_at: None,
1434 label: label.into(),
1435 account_uuid: uuid.into(),
1436 email: format!("{label}@example.com"),
1437 detail: crate::state::Detail::Codex {
1438 workspace_id: None,
1439 plan: None,
1440 },
1441 parked: Some(parked(NOW + 20 * 86_400)),
1442 }
1443 }
1444
1445 fn live(owner_uuid: &str, usage: Result<Snapshot, Stale>) -> LiveLogin {
1446 LiveLogin {
1447 signed_in: Some(Ok(owner(owner_uuid))),
1448 usage: Some(usage),
1449 ..LiveLogin::default()
1450 }
1451 }
1452
1453 #[test]
1457 fn the_same_identity_on_two_tools_is_two_rows() {
1458 let s = state(&["same"]);
1459 let f = Facts {
1460 live: [
1461 (
1462 ProviderId::Claude,
1463 live("same-uuid", Ok(reading(10.0, Source::Live, None))),
1464 ),
1465 (
1466 ProviderId::Codex,
1467 live("same-uuid", Ok(reading(20.0, Source::Live, None))),
1468 ),
1469 ]
1470 .into_iter()
1471 .collect(),
1472 asked: true,
1473 parked_usage: vec![Err(Stale::NothingParked)],
1474 claude_code_cache: None,
1475 };
1476 let rows = assemble(&s, &f, nothing_remembered, nothing_known, NOW);
1477 assert_eq!(rows.len(), 2, "one row per tool");
1478 assert_eq!(rows[0].provider, ProviderId::Claude);
1479 assert_eq!(rows[0].label.as_deref(), Some("same"));
1480 assert_eq!(rows[1].provider, ProviderId::Codex);
1481 assert_eq!(rows[1].label, None, "nothing has enrolled the Codex login");
1482 assert!(rows[1].signed_in);
1483 assert_eq!(rows[1].usage.as_ref().unwrap().windows[0].percent, 20.0);
1484 }
1485
1486 #[test]
1489 fn rows_are_grouped_by_tool_with_the_signed_in_account_first_in_each() {
1490 let mut s = state(&["alpha"]);
1491 s.accounts.push(codex_account("work", "work-acc"));
1492 s.accounts.push(account("beta"));
1493 s.accounts.push(codex_account("home", "home-acc"));
1494 let f = Facts {
1495 live: [
1496 (
1497 ProviderId::Claude,
1498 live("beta-uuid", Ok(reading(5.0, Source::Live, None))),
1499 ),
1500 (
1501 ProviderId::Codex,
1502 live("home-acc", Ok(reading(6.0, Source::Live, None))),
1503 ),
1504 ]
1505 .into_iter()
1506 .collect(),
1507 asked: true,
1508 parked_usage: vec![Err(Stale::NothingParked); 4],
1509 claude_code_cache: None,
1510 };
1511 let order: Vec<(ProviderId, String, bool)> =
1512 assemble(&s, &f, nothing_remembered, nothing_known, NOW)
1513 .into_iter()
1514 .map(|r| (r.provider, r.label.unwrap_or_default(), r.signed_in))
1515 .collect();
1516 assert_eq!(
1517 order,
1518 [
1519 (ProviderId::Claude, "beta".into(), true),
1520 (ProviderId::Claude, "alpha".into(), false),
1521 (ProviderId::Codex, "home".into(), true),
1522 (ProviderId::Codex, "work".into(), false),
1523 ]
1524 );
1525 }
1526
1527 #[test]
1530 fn claude_codes_cache_is_never_shown_for_another_tools_account() {
1531 let mut s = State::default();
1532 s.accounts.push(codex_account("work", "work-acc"));
1533 let mut f = Facts {
1534 live: std::iter::once((ProviderId::Codex, live("work-acc", Err(Stale::Unreachable))))
1535 .collect(),
1536 asked: true,
1537 parked_usage: vec![Err(Stale::NothingParked)],
1538 claude_code_cache: None,
1539 };
1540 f.claude_code_cache = Some(reading(77.0, Source::ClaudeCodeCache, Some("work-acc")));
1541 let rows = assemble(&s, &f, nothing_remembered, nothing_known, NOW);
1542 assert!(rows[0].signed_in);
1543 assert!(rows[0].usage.is_none(), "{:?}", rows[0].usage);
1544 }
1545
1546 #[test]
1551 fn a_thread_that_stopped_stands_in_for_its_own_tool_only() {
1552 let home = scratch("panicked");
1553 let (ctx, _mem, _api) = machine(&home.0, None);
1554 let claude_answer: Answered = (
1555 live("alpha-uuid", Ok(reading(30.0, Source::Live, None))),
1556 Some(("alpha-uuid".into(), budget::Outcome::Answered)),
1557 );
1558 let panicked: Box<dyn std::any::Any + Send> = Box::new("boom");
1559 let (settled, learned) = settle(
1560 &ctx,
1561 vec![
1562 (ProviderId::Claude, Ok(claude_answer)),
1563 (ProviderId::Codex, Err(panicked)),
1564 ],
1565 );
1566 assert_eq!(
1567 learned,
1568 [("alpha-uuid".to_string(), budget::Outcome::Answered)]
1569 );
1570 let codex = &settled[&ProviderId::Codex];
1571 assert_eq!(codex.usage().err(), Some(Stale::Interrupted));
1572 assert!(
1573 matches!(codex.signed_in, Some(Err(_))),
1574 "unknown, not nobody"
1575 );
1576
1577 let mut s = state(&["alpha"]);
1578 s.accounts.push(codex_account("work", "work-acc"));
1579 let f = Facts {
1580 live: settled,
1581 asked: true,
1582 parked_usage: vec![Err(Stale::NothingParked), Err(Stale::NothingParked)],
1583 claude_code_cache: None,
1584 };
1585 let rows = assemble(&s, &f, nothing_remembered, nothing_known, NOW);
1586 let alpha = rows
1587 .iter()
1588 .find(|r| r.provider == ProviderId::Claude)
1589 .unwrap();
1590 assert!(alpha.signed_in, "Claude Code's row survives Codex's thread");
1591 assert_eq!(alpha.usage.as_ref().unwrap().windows[0].percent, 30.0);
1592 }
1593
1594 #[test]
1598 fn a_document_holding_no_account_is_nothing_signed_in_and_nobody_is_asked() {
1599 let home = scratch("logged-out");
1600 let (ctx, _mem, api) = machine(&home.0, None);
1601 let logged_out = json!({"mcpOAuth": {"some-server": {"token": "unrelated"}}});
1602 let (login, learned) = ask_live(
1603 &ctx,
1604 ProviderId::Claude,
1605 &Ok(Some(logged_out)),
1606 None,
1607 &HashMap::new(),
1608 true,
1609 );
1610 assert!(login.signed_in.is_none(), "nobody, rather than unknown");
1611 assert_eq!(login.usage().err(), Some(Stale::NothingSignedIn));
1612 assert!(learned.is_none());
1613 assert_eq!(api.calls(), 0, "Anthropic was asked: {:?}", api.asked());
1614 }
1615
1616 #[test]
1620 fn a_login_that_cannot_be_read_is_said_rather_than_read_as_nobody_signed_in() {
1621 let home = scratch("unreadable");
1622 let (ctx, mem, api) = machine(&home.0, Some("alpha-uuid"));
1623 let service = claude::live_service(&ctx);
1624 mem.live().plant(
1625 &service,
1626 &json!({"claudeAiOauth": {"accessToken": "t"}}).to_string(),
1627 );
1628 mem.live()
1629 .fault(&service, Fault::Unreadable("the keychain is locked".into()));
1630 let mut s = state(&["alpha", "beta"]);
1631 for a in &mut s.accounts {
1632 a.parked = None;
1633 }
1634
1635 let report = gather(&ctx, &s, true);
1636 let alpha = report
1637 .rows
1638 .iter()
1639 .find(|r| r.label.as_deref() == Some("alpha"))
1640 .unwrap();
1641 assert!(
1642 alpha.signed_in,
1643 "Claude Code's own record still names the account in use"
1644 );
1645 assert_eq!(alpha.stale, Some(Stale::LoginUnreadable));
1646 assert_eq!(
1647 alpha.explanation(),
1648 Some("Claude Code's login could not be read; run `pitboard doctor`")
1649 );
1650 let why = report.signed_in.expect_err("nobody could say whose it is");
1651 assert_ne!(why, "nothing is signed in");
1652 assert_eq!(api.calls(), 0, "a login nobody could read was sent nowhere");
1653 }
1654
1655 #[test]
1660 fn an_unreadable_login_gets_a_row_only_for_a_tool_with_accounts() {
1661 let unreadable = || LiveLogin {
1662 signed_in: Some(Err("the keychain is locked".into())),
1663 usage: Some(Err(Stale::LoginUnreadable)),
1664 out_of_reach: true,
1665 ..LiveLogin::default()
1666 };
1667 let facts_with = |parked: usize| Facts {
1668 live: [
1669 (
1670 ProviderId::Claude,
1671 live("alpha-uuid", Ok(reading(5.0, Source::Live, None))),
1672 ),
1673 (ProviderId::Codex, unreadable()),
1674 ]
1675 .into_iter()
1676 .collect(),
1677 asked: true,
1678 parked_usage: vec![Err(Stale::NothingParked); parked],
1679 claude_code_cache: None,
1680 };
1681
1682 let claude_only = state(&["alpha"]);
1683 let rows = assemble(
1684 &claude_only,
1685 &facts_with(1),
1686 nothing_remembered,
1687 nothing_known,
1688 NOW,
1689 );
1690 assert_eq!(
1691 rows.len(),
1692 1,
1693 "nothing new for somebody who never used Codex"
1694 );
1695
1696 let mut both = state(&["alpha"]);
1697 both.accounts.push(codex_account("work", "work-acc"));
1698 let rows = assemble(
1699 &both,
1700 &facts_with(2),
1701 nothing_remembered,
1702 nothing_known,
1703 NOW,
1704 );
1705 let said = rows
1706 .iter()
1707 .find(|r| r.provider == ProviderId::Codex && r.label.is_none())
1708 .expect("a row saying Codex's login could not be read");
1709 assert_eq!(said.stale, Some(Stale::LoginUnreadable));
1710 assert!(!said.signed_in && !said.switchable(NOW));
1711 assert_eq!(
1712 said.explanation(),
1713 Some("Codex's login could not be read; run `pitboard doctor`")
1714 );
1715 let work = rows
1716 .iter()
1717 .find(|r| r.label.as_deref() == Some("work"))
1718 .unwrap();
1719 assert!(!work.signed_in);
1720 }
1721
1722 #[test]
1727 fn a_failing_identify_still_keeps_the_ask_again_floor() {
1728 let home = scratch("floor");
1729 let (ctx, _mem, api) = machine(&home.0, Some("acc-x"));
1730 api.token_trouble("access-x", Trouble::Offline);
1731 budget::record(&ctx, &[("acc-x".into(), budget::Outcome::Answered)]);
1732 let login = json!({"claudeAiOauth": {"accessToken": "access-x"}});
1733
1734 let (live, learned) = ask_live(
1735 &ctx,
1736 ProviderId::Claude,
1737 &Ok(Some(login.clone())),
1738 None,
1739 &HashMap::new(),
1740 false,
1741 );
1742 assert_eq!(live.usage().err(), Some(Stale::AskedRecently));
1743 assert_eq!(live.recorded_uuid.as_deref(), Some("acc-x"));
1744 assert!(learned.is_none());
1745 assert_eq!(
1746 api.asked(),
1747 [Question::Owner("access-x".into())],
1748 "whose it is is always asked; what it has left is not, inside the floor"
1749 );
1750
1751 let (live, learned) = ask_live(
1753 &ctx,
1754 ProviderId::Claude,
1755 &Ok(Some(login)),
1756 None,
1757 &HashMap::new(),
1758 true,
1759 );
1760 assert_eq!(live.usage().err(), Some(Stale::Unreachable));
1761 assert_eq!(
1762 learned,
1763 Some(("acc-x".to_string(), budget::Outcome::Unreachable))
1764 );
1765 }
1766
1767 #[test]
1770 fn offline_every_tool_names_its_own_signed_in_account() {
1771 let home = scratch("offline");
1772 let (ctx, _mem, api) = machine(&home.0, Some("alpha-uuid"));
1773 let codex = crate::provider::of(ProviderId::Codex)
1774 .live(&ctx)
1775 .expect("Codex keeps its login in a file here");
1776 let login = json!({
1777 "auth_mode": "chatgpt",
1778 "tokens": {
1779 "id_token": crate::provider::jwt::unsigned(&json!({
1780 "email": "work@example.com",
1781 "https://api.openai.com/auth": {"chatgpt_account_id": "work-acc"},
1782 })),
1783 "access_token": "codex-access",
1784 "refresh_token": "codex-refresh",
1785 "account_id": "work-acc",
1786 },
1787 "last_refresh": "2026-09-15T05:05:11Z",
1788 });
1789 crate::store::write_raw(&codex.chain, &codex.service, &login.to_string())
1790 .expect("a Codex login");
1791
1792 let mut s = state(&["alpha", "beta"]);
1793 s.accounts.push(codex_account("work", "work-acc"));
1794 s.accounts.push(codex_account("home", "home-acc"));
1795 let report = gather_offline(&ctx, &s);
1796 let signed_in: Vec<(ProviderId, &str)> = report
1797 .rows
1798 .iter()
1799 .filter(|r| r.signed_in)
1800 .map(|r| (r.provider, r.label.as_deref().unwrap_or_default()))
1801 .collect();
1802 assert_eq!(
1803 signed_in,
1804 [(ProviderId::Claude, "alpha"), (ProviderId::Codex, "work")]
1805 );
1806 assert_eq!(
1807 report.signed_in.expect("Claude Code's config").account_uuid,
1808 "alpha-uuid"
1809 );
1810 assert_eq!(api.calls(), 0, "offline asks nobody");
1811 }
1812
1813 fn plant_codex(ctx: &Context, raw: &str) {
1815 let codex = crate::provider::of(ProviderId::Codex)
1816 .live(ctx)
1817 .expect("Codex keeps its login in a file here");
1818 crate::store::write_raw(&codex.chain, &codex.service, raw).expect("a Codex login");
1819 }
1820
1821 fn codex_login(tokens_of: &str, account_id: &str) -> String {
1824 json!({
1825 "auth_mode": "chatgpt",
1826 "tokens": {
1827 "id_token": crate::provider::jwt::unsigned(&json!({
1828 "email": format!("{tokens_of}@example.com"),
1829 "https://api.openai.com/auth": {"chatgpt_account_id": tokens_of},
1830 })),
1831 "access_token": format!("access-{tokens_of}"),
1832 "refresh_token": format!("refresh-{tokens_of}"),
1833 "account_id": account_id,
1834 },
1835 "last_refresh": "2026-09-15T05:05:11Z",
1836 })
1837 .to_string()
1838 }
1839
1840 fn codex_a_parked_b_active() -> State {
1843 let mut b = codex_account("b", "acc-B");
1844 b.parked = None;
1845 let mut s = State {
1846 accounts: vec![codex_account("a", "acc-A"), b],
1847 ..State::default()
1848 };
1849 s.set_active(ProviderId::Codex, Some("b".into()));
1850 s
1851 }
1852
1853 fn codex_row<'a>(report: &'a Report, label: &str) -> &'a Row {
1854 report
1855 .rows
1856 .iter()
1857 .find(|r| r.provider == ProviderId::Codex && r.label.as_deref() == Some(label))
1858 .unwrap_or_else(|| panic!("a row for codex/{label}"))
1859 }
1860
1861 #[test]
1867 fn a_codex_login_that_mixes_two_accounts_is_said_rather_than_read_as_nobody() {
1868 let home = scratch("mixed");
1869 let (ctx, _mem, api) = machine(&home.0, None);
1870 plant_codex(&ctx, &codex_login("acc-A", "acc-B"));
1871 let s = codex_a_parked_b_active();
1872
1873 let report = gather(&ctx, &s, true);
1874 let a = codex_row(&report, "a");
1875 assert!(a.signed_in, "the tokens are a's, as its own record says");
1876 assert_eq!(a.stale, Some(Stale::LoginUnusable));
1877 assert_eq!(
1878 a.explanation(),
1879 Some("Codex's login is not one pitboard can park or switch; run `pitboard doctor`")
1880 );
1881 assert!(!codex_row(&report, "b").signed_in);
1882 assert!(
1883 report
1884 .rows
1885 .iter()
1886 .all(|r| r.stale != Some(Stale::NothingSignedIn)),
1887 "something is signed in"
1888 );
1889 assert!(
1890 report.rows.iter().all(|r| !r.unplaced()),
1891 "the login is pinned on a, so it needs no row of its own"
1892 );
1893 assert_eq!(
1894 api.calls(),
1895 0,
1896 "nobody was asked about a login mixing two accounts"
1897 );
1898
1899 let offline = gather_offline(&ctx, &s);
1900 assert!(codex_row(&offline, "a").signed_in, "offline says the same");
1901 }
1902
1903 #[test]
1906 fn a_codex_login_with_an_api_key_is_said_and_pinned_on_no_account() {
1907 let home = scratch("api-key");
1908 let (ctx, _mem, api) = machine(&home.0, None);
1909 plant_codex(
1910 &ctx,
1911 &json!({"auth_mode": "apikey", "OPENAI_API_KEY": "sk-not-a-real-key"}).to_string(),
1912 );
1913 let s = codex_a_parked_b_active();
1914
1915 let report = gather(&ctx, &s, true);
1916 assert!(
1917 report.rows.iter().all(|r| !r.signed_in),
1918 "no account is signed in"
1919 );
1920 let said = report
1921 .rows
1922 .iter()
1923 .find(|r| r.unplaced())
1924 .expect("a row saying what is signed in to Codex");
1925 assert_eq!(said.provider, ProviderId::Codex);
1926 assert_eq!(said.stale, Some(Stale::LoginUnusable));
1927 assert!(said.email.is_empty() && said.account_uuid.is_empty());
1928 assert_eq!(api.calls(), 0);
1929 }
1930
1931 #[test]
1935 fn a_tool_with_nothing_enrolled_is_neither_read_nor_asked() {
1936 let home = scratch("not-opted-in");
1937 let (ctx, _mem, api) = machine(&home.0, None);
1938 plant_codex(&ctx, &codex_login("acc-B", "acc-B"));
1939 let s = State::default();
1940
1941 let report = gather(&ctx, &s, true);
1942 assert!(
1943 report.rows.iter().all(|r| r.provider != ProviderId::Codex),
1944 "no Codex row for a machine that enrolled no Codex account"
1945 );
1946 assert!(
1947 api.asked()
1948 .iter()
1949 .all(|asked| !matches!(asked, crate::api::scripted::Asked::Usage(t) if t.contains("acc-B"))),
1950 "and nothing was asked about it"
1951 );
1952 let offline = gather_offline(&ctx, &s);
1953 assert!(offline.rows.iter().all(|r| r.provider != ProviderId::Codex));
1954 }
1955
1956 #[test]
1961 fn a_codex_login_caught_half_written_still_names_the_account_in_use() {
1962 let home = scratch("half-written");
1963 let (ctx, _mem, api) = machine(&home.0, None);
1964 let whole = codex_login("acc-B", "acc-B");
1965 plant_codex(&ctx, &whole[..whole.len() / 2]);
1966 let s = codex_a_parked_b_active();
1967 crate::state::save(&ctx, &s).expect("an account list");
1968
1969 let report = gather(&ctx, &s, true);
1970 let b = codex_row(&report, "b");
1971 assert!(b.signed_in, "the account pitboard last switched to");
1972 assert_eq!(b.stale, Some(Stale::LoginUnreadable));
1973 assert!(!codex_row(&report, "a").signed_in);
1974 assert!(
1975 report.rows.iter().all(|r| !r.unplaced()),
1976 "pinned, so no row of its own"
1977 );
1978 assert_eq!(api.calls(), 0);
1979
1980 let doctor = crate::doctor::gather(&ctx);
1981 let active: Vec<&str> = doctor
1982 .parks
1983 .iter()
1984 .filter(|p| p.active)
1985 .map(|p| p.label.as_str())
1986 .collect();
1987 assert_eq!(active, ["b"], "doctor reads the same machine the same way");
1988 }
1989
1990 #[test]
1994 fn only_a_document_holding_no_login_is_nobody_signed_in() {
1995 let home = scratch("shapes");
1996 let (ctx, _mem, api) = machine(&home.0, Some("alpha-uuid"));
1997 let ask = |document: Value| {
1998 ask_live(
1999 &ctx,
2000 ProviderId::Claude,
2001 &Ok(Some(document)),
2002 Some("beta-uuid"),
2003 &HashMap::new(),
2004 true,
2005 )
2006 .0
2007 };
2008
2009 let nobody = ask(json!({"mcpOAuth": {}}));
2010 assert!(nobody.signed_in.is_none() && !nobody.out_of_reach);
2011 assert_eq!(nobody.usage().err(), Some(Stale::NothingSignedIn));
2012
2013 let unusable = ask(json!(["a login", "that is not an object"]));
2014 assert!(matches!(unusable.signed_in, Some(Err(_))));
2015 assert!(unusable.out_of_reach);
2016 assert_eq!(unusable.usage().err(), Some(Stale::LoginUnusable));
2017 assert_eq!(
2018 unusable.recorded_uuid.as_deref(),
2019 Some("alpha-uuid"),
2020 "Claude Code's config, never pitboard's record of its last switch"
2021 );
2022 assert_eq!(api.calls(), 0);
2023 }
2024
2025 #[test]
2028 fn a_row_is_unplaced_only_when_it_is_a_login_on_no_account() {
2029 let mut row = Row {
2030 provider: ProviderId::Codex,
2031 label: None,
2032 email: String::new(),
2033 account_uuid: String::new(),
2034 signed_in: false,
2035 parked: None,
2036 usage: None,
2037 stale: Some(Stale::LoginUnreadable),
2038 runway: crate::history::Runway::Unknown,
2039 };
2040 assert!(row.unplaced());
2041 row.stale = Some(Stale::LoginUnusable);
2042 assert!(row.unplaced());
2043 row.signed_in = true;
2044 row.stale = Some(Stale::SessionExpired);
2045 assert!(!row.unplaced(), "an unenrolled login that is signed in");
2046 row.signed_in = false;
2047 row.label = Some("work".into());
2048 row.stale = Some(Stale::LoginUnreadable);
2049 assert!(!row.unplaced(), "an account");
2050 }
2051
2052 struct Scratch(std::path::PathBuf);
2054
2055 impl Drop for Scratch {
2056 fn drop(&mut self) {
2057 let _ = std::fs::remove_dir_all(&self.0);
2058 }
2059 }
2060
2061 fn scratch(name: &str) -> Scratch {
2062 let root = std::env::temp_dir().join(format!(
2063 "pitboard-status-{name}-{}-{:?}",
2064 std::process::id(),
2065 std::thread::current().id()
2066 ));
2067 let _ = std::fs::remove_dir_all(&root);
2068 std::fs::create_dir_all(&root).expect("a scratch home");
2069 Scratch(root)
2070 }
2071
2072 fn machine(
2076 root: &std::path::Path,
2077 recorded: Option<&str>,
2078 ) -> (Context, Arc<MemoryHost>, Arc<ScriptedApi>) {
2079 let mem = MemoryHost::new();
2080 let api = ScriptedApi::new();
2081 let ctx = Context::new(root.to_path_buf())
2082 .with_pitboard_home(root.join(".pitboard"))
2083 .with_codex_home(root.join("codex").to_string_lossy().into())
2084 .with_codex_program(root.join("bin").join("codex"))
2086 .with_memory_stores(Arc::clone(&mem))
2087 .with_scripted_api(Arc::clone(&api))
2088 .with_clock(Arc::new(FixedClock::at(NOW)) as Arc<dyn Clock>);
2089 crate::home::ensure(&ctx).expect("a pitboard home");
2090 if let Some(uuid) = recorded {
2091 std::fs::write(
2092 root.join(".claude.json"),
2093 json!({"oauthAccount": {
2094 "accountUuid": uuid,
2095 "emailAddress": format!("{uuid}@example.com"),
2096 "organizationUuid": "org",
2097 }})
2098 .to_string(),
2099 )
2100 .expect("a Claude Code config");
2101 }
2102 (ctx, mem, api)
2103 }
2104}