Skip to main content

pitboard_core/
status.rs

1//! `pitboard status`: who is signed in, what each account has left, and which accounts can
2//! be switched to.
3//!
4//! Numbers are asked of each tool's own service rather than read from a tool's cache, which
5//! only moves when the tool itself asks. Every account is asked at once, so the command
6//! costs one round trip, not one per account.
7//!
8//! Every row says which tool it belongs to. There is no such thing as "the account signed
9//! in on this machine": each tool has a live login of its own or none, and a row is about
10//! exactly one of them.
11
12use 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/// Why a reading is not live.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
25#[serde(rename_all = "snake_case")]
26#[non_exhaustive]
27pub enum Stale {
28    NothingSignedIn,
29    /// The tool's live login is there to be read and could not be: a keychain that is
30    /// locked for the moment, a store the tool's configuration puts where Pitboard does not
31    /// reach. Never the same as nothing signed in, which would tell somebody their login is
32    /// gone when it is only out of reach.
33    LoginUnreadable,
34    /// The tool's live login was read and is not one account's login Pitboard can park or
35    /// switch: signed in with an API key rather than an account, or a Codex login whose
36    /// tokens belong to one account and whose account id names another. Something is
37    /// signed in, so saying nobody is would send somebody to sign in again for nothing.
38    LoginUnusable,
39    /// The tool's own session has expired; its next call renews it.
40    SessionExpired,
41    /// A parked login's access token has expired and could not be renewed this time.
42    ParkedAccessExpired,
43    NothingParked,
44    ParkUnreadable,
45    RateLimited,
46    Unreachable,
47    /// The service answered badly and may answer well later.
48    ServerError,
49    /// The service's answer was not in a shape Pitboard understands, which means the shape
50    /// moved. Asking again produces the same thing.
51    AnswerNotUnderstood,
52    /// The service will not accept this parked login again.
53    LoginRefused,
54    /// Asked recently enough that the answer cannot have moved by a percentage point, so
55    /// the number shown is the one already measured rather than a new request.
56    AskedRecently,
57    /// The thread that was asking stopped before it answered. Nothing to do with the
58    /// service, which is why it used to be filed under the same code as a bad answer.
59    Interrupted,
60    /// Nobody was asked: this reading was taken without touching the network.
61    NotAsked,
62}
63
64impl Stale {
65    /// Three answers used to arrive here as one. A front end deciding whether to ask
66    /// again needs to tell a bad morning at the service from an answer whose shape moved
67    /// from a login that is finished, and could not.
68    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    /// Stable, for a program to branch on; the same as its JSON form.
81    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    /// What is worth a word, in the default tool's words.
102    ///
103    /// For a caller holding a code and nothing else. A row knows its own tool, and
104    /// [`Row::explanation`] says it in that tool's words.
105    pub fn explanation(self) -> Option<&'static str> {
106        self.explanation_for(crate::label::DEFAULT)
107    }
108
109    /// Only what is worth a word: a parked login going quiet is how parking works.
110    ///
111    /// Named for the tool the row belongs to. "Anthropic could not be reached" said about a
112    /// Codex account sends somebody to check the wrong service, and "`claude` renews it"
113    /// said about a Codex session names a program that has nothing to do with it.
114    pub fn explanation_for(self, provider: ProviderId) -> Option<&'static str> {
115        // A sentence per tool rather than one assembled at run time, so every word a person
116        // reads is here to be read, and Claude Code's are exactly what they always were.
117        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            // Not worth a word: it is the ordinary state of a number that is
155            // already as true as asking again would make it.
156            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    /// Which tool this account belongs to, or whose live login this row is.
168    ///
169    /// Every row has one, including a login nobody has enrolled and a login that could not
170    /// be read, so a front end can always say which tool a row is about and what to type to
171    /// act on it. A hint that said `pitboard enroll work --sign-in` about a Codex account
172    /// would have enrolled a Claude Code one.
173    pub provider: ProviderId,
174    /// `None` for a login nothing has enrolled: one that is signed in, or one Pitboard
175    /// could not pin on any account (see [`Row::unplaced`]).
176    pub label: Option<String>,
177    pub email: String,
178    /// What the account's readings and budget are filed under. Empty where
179    /// [`Row::account_uuid`] is.
180    pub id: String,
181    pub account_uuid: String,
182    /// The Claude organisation the login is for. `None` for another tool's account.
183    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    /// How long this account lasts as of `now`, from its limits' paces: the question this
192    /// whole tool exists to answer is which account to use next, and a share alone does not
193    /// answer it. 73% of a weekly limit is plenty on Saturday and a warning on Monday. An
194    /// account not in use is not being used, so it lasts until its first reset.
195    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    /// Whether `pitboard use` would switch to it now.
205    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    /// The account, the way Pitboard tells accounts apart. `None` for a row nothing has
210    /// enrolled.
211    pub fn key(&self) -> Option<Key> {
212        self.label
213            .as_ref()
214            .map(|label| Key::new(self.provider, label.clone()))
215    }
216
217    /// What to tell a person about [`Row::stale`], in the words of this row's own tool.
218    pub fn explanation(&self) -> Option<&'static str> {
219        self.stale
220            .and_then(|stale| stale.explanation_for(self.provider))
221    }
222
223    /// A tool's live login that Pitboard could not pin on any account: one it could not
224    /// read, or read and could not use. It has no email and no account id, and nothing to
225    /// type about it but `pitboard doctor`, so a front end says what it is rather than
226    /// showing it as an account nobody has enrolled.
227    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    /// Who the default tool's live login belongs to, as its service says: Claude Code's
241    /// config can be a day behind. Every tool's answer is in the rows.
242    pub signed_in: Result<Owner, String>,
243    /// Which credential slot this report speaks for.
244    ///
245    /// `CLAUDE_CONFIG_DIR` selects a different keychain item, so "who is signed in" is a
246    /// fact about one slot and not about the machine. Accounts and their parked logins
247    /// belong to the machine; what is in use does not. This report used to name accounts
248    /// without ever saying which slot it was speaking for, so on a machine with a second
249    /// config directory it was silently answering about one of them.
250    pub slot: Slot,
251}
252
253/// A credential slot, named.
254#[derive(Debug, Clone, PartialEq, Eq)]
255pub struct Slot {
256    /// The keychain item, which is what actually selects the login.
257    pub service: String,
258    /// Whether this is the one a `claude` with no `CLAUDE_CONFIG_DIR` reads.
259    pub default: bool,
260}
261
262/// What is true of one tool's live login.
263///
264/// Per tool, because there is no such thing as "the account signed in on this machine": a
265/// Claude Code login and a Codex login are different programs reading different stores, and
266/// neither signs the other out.
267#[derive(Default)]
268struct LiveLogin {
269    /// Whose it is. `None` means nobody is signed in to this tool, but only when
270    /// [`Facts::asked`] is true: unasked and absent are different things, and reading one as
271    /// the other says the account in use is gone. `Some(Err)` is a login whose owner could
272    /// not be learned this time, and says why.
273    signed_in: Option<Result<Owner, String>>,
274    /// The id of the account the tool's own local record names, where it keeps one. It can
275    /// be behind the login it describes, so it never decides a switch; it keeps the row
276    /// that is signed in from reading as though nobody is, when the service cannot be asked.
277    recorded_id: Option<String>,
278    usage: Option<Result<Snapshot, Stale>>,
279    /// There is a login and Pitboard cannot use it: it could not be read, or it was read and
280    /// is not one account's login. A tool in this state whose record names none of its
281    /// accounts still gets a row, so that nobody reads its silence as nobody signed in.
282    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
291/// Everything gathered from the machine and the network, so assembling it touches neither.
292struct Facts {
293    live: BTreeMap<ProviderId, LiveLogin>,
294    asked: bool,
295    /// One per enrolled account, in order.
296    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
306/// The parked login to ask about an account with, or why there is none.
307///
308/// Whether the document holds what a usage call needs is the tool's own question, answered
309/// when it is asked: a Codex login keeps its token somewhere a Claude Code login does not.
310fn 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
323/// Which slot this machine's `claude` would read right now.
324fn slot_of(ctx: &Context) -> Slot {
325    Slot {
326        service: claude::live_service(ctx),
327        default: claude::is_default_slot(ctx),
328    }
329}
330
331/// What is known without asking anyone: who each tool's own record says is signed in, and
332/// the last numbers Pitboard measured. Touches no network, so it answers at once and works
333/// on a train.
334///
335/// Each tool is asked for what it keeps without anybody's agreement: Claude Code's config,
336/// which can be a day behind the login it describes, and a Codex login's own claims. Good
337/// enough to say who is in use; never good enough to move a login. It used to read Claude
338/// Code's alone, so a signed-in Codex account read as parked whenever the app had no
339/// network.
340/// Which tools a report is about: the one a bare name means, which is what Pitboard was
341/// before there was a second, and every other tool somebody has enrolled an account of.
342///
343/// A tool is opted into by enrolling one of its accounts. Until then Pitboard reads nothing
344/// of it and asks its service nothing: somebody who uses Pitboard for Claude Code and also
345/// has Codex installed has not asked for their Codex login to be read, or sent to OpenAI on
346/// every refresh of a menu bar.
347fn 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
406/// What one account's request came to: the reading, and what the budget should learn from
407/// it. Recorded by the caller once every request has finished, never from the thread that
408/// made it.
409type Asked = (Result<Snapshot, Stale>, Option<(String, budget::Outcome)>);
410
411/// What one tool's live login came to, and what the budget should learn from asking about
412/// it.
413type Answered = (LiveLogin, Option<(String, budget::Outcome)>);
414
415/// A provider's failure as the usage path has always classified one.
416fn 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
431/// Ask about one account, or say why not.
432fn 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    // An account Pitboard cannot name cannot be budgeted for; it is asked about, which is
442    // what always happened.
443    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    // Through the provider, because what a usage call needs is not the same everywhere:
454    // Codex sends an account id header it reads out of the credential, and Gemini needs a
455    // project id from a file the credential never mentions.
456    let answer = crate::provider::of(which).usage(
457        ctx,
458        &crate::provider::Credential::new(which, document.clone()),
459    );
460    // A park that is not the shape its own tool keeps was damaged at rest, which is a fact
461    // about the vault and not about the service. Said as such rather than as an answer the
462    // service gave.
463    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            // A token that is refused, or an answer that is not understood, is not a reason
482            // to wait: waiting fixes neither.
483            Err(_) => return None,
484        };
485        Some((id.to_string(), outcome))
486    });
487    (answer.map_err(|e| Stale::of(&e, signed_in)), learned)
488}
489
490/// Ask about one tool's live login: whose it is, and what it has left.
491///
492/// `read` is what reading the tool's store came to, kept whole. An error there is not
493/// nothing signed in: the login may be exactly where it always was, behind a keychain that
494/// is locked for the moment, and saying it is gone sends somebody to sign in again for
495/// nothing. It used to be read as exactly that.
496///
497/// The account `state` last recorded as signed in to this tool stands in for the tool's own
498/// record when the login cannot be read at all. Codex keeps no
499/// record apart from the login itself, so without this a Codex login caught half written
500/// named nobody, and the account in use was told to sign in again while `doctor`, reading
501/// the same machine, called it signed in.
502fn 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        // A document holding no account's login at all. Claude Code's `/logout` deletes the
528        // login and leaves the document behind with the machine's MCP tokens still in it;
529        // asking Anthropic whose that was got an answer about a shape rather than a person,
530        // and the row read as Anthropic answering badly when the truth is that nobody is
531        // signed in.
532        Err(ProviderError::NoLogin { .. }) => return (LiveLogin::default(), None),
533        // A login that is there and is not one account's: signed in with an API key, or a
534        // Codex login that a session still running from before a switch rewrote with its
535        // own account's tokens under the other account's id. Something is signed in, so
536        // it is not nobody; nobody is asked about it, because there is no one account's
537        // token to ask with. The tool's own record says whose it most likely is, and
538        // Pitboard's own record does not stand in, because what is there was read and is
539        // not that account's.
540        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    // Who owns it is asked whatever the budget says: it decides which account a row
552    // belongs to, it is not a measurement, and a switch needs it.
553    let owner = tool
554        .identify(ctx, &credential)
555        .map(Owner::from)
556        .map_err(|e| to_api(e).to_string());
557    // The tool's own record, for when its service could not say whose the login is. It
558    // stands in twice: to keep the signed-in row signed in, and to key the budget. Without
559    // the second, a morning when identifying fails is a morning when every `status` asks
560    // about usage with no floor at all, which is what the budget exists to stop. Claude
561    // Code's account always came from its config here before there was a second tool.
562    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
589/// Every tool's answer, filed under the tool it was asked about.
590///
591/// A thread that stopped before answering stands in for its own tool and nobody else's. It
592/// used to be replaced by an empty answer filed under Claude Code whichever tool it had
593/// been asking about, and because answers are collected in order, a Codex thread that
594/// panicked quietly erased Claude Code's signed-in row.
595fn 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            // Whose the login is was never learned, so the tool's own record stands in for
608            // it, the same as when its service cannot be reached.
609            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    // Each tool's live login, whole, or why it could not be read. Not a token out of it:
626    // what a usage call needs is not the same everywhere, and pulling one field out here
627    // would decide that for all of them.
628    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            // Each handle stays with the tool it asks about, so a thread that panics can
648            // only ever stand in for its own tool.
649            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    // Once, from one thread. Every account's record lives in one file, so a thread each
692    // reading it, changing one entry and writing it back would erase what the others
693    // learned.
694    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        // The default tool's answer. Every tool's is in the rows, which is where a
734        // machine with more than one signed in has to be read from: there is no single
735        // fact called "the account signed in on this machine" any more.
736        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
744/// The account Pitboard last recorded as signed in to this tool, by its id.
745fn 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
752/// The id of the account the tool's own record names as signed in.
753fn 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
759/// Where a tool comes in a listing: the order [`ProviderId::ALL`] gives them.
760fn 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    // Per tool, because an account is signed in to its own tool or to nothing. Comparing
774    // every account against one machine-wide answer would have marked a Codex account
775    // signed in because a Claude Code account with the same uuid was.
776    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                // Unreachable, unreadable, or never asked. The service decides who is
783                // signed in; without its answer, the tool's own local record is the only
784                // one there is.
785                (Some(Err(_)), _) | (None, false) => live.recorded_id.clone(),
786                (None, true) => None,
787            }?;
788            Some((which, id))
789        })
790        .collect();
791    // Claude Code's cache counts only for Claude Code's accounts, and only when it was
792    // measured for the account in question.
793    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    // What was asked, or Claude Code's cache when nothing was, folded into what every front
800    // end has recorded. So a row shows the one reading the status lines show, and neither
801    // an answer that lags a session's latest response nor a cache that moves only when
802    // Claude Code asks can take it backwards.
803    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                // Nothing parked because Pitboard put its login in use, and that login is
832                // the one that could not be read: unknown, not gone.
833                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    // A tool signed in to an account nothing has enrolled still gets a row, so somebody can
853    // see what is there and give it a name. One per tool, because each can have its own,
854    // and told apart by tool as well as by account: two tools' identities are two
855    // namespaces, and one tool's row must never stand in for another's.
856    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        // A login that could not be read, or was read and is not one account's, and that no
883        // record pins on any of the tool's accounts. Said, rather than left out, because
884        // leaving it out reads as nobody being signed in; but only for a tool somebody uses
885        // through Pitboard, so a machine that has never enrolled a Codex account sees
886        // nothing about Codex at all.
887        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    // Grouped by tool, in the order every listing uses, and the signed-in account first
906    // within each. Stable, so a machine with one tool reads in exactly the order it always
907    // did.
908    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    /// The one-tool case every test here was written for, in the shape facts now take.
1000    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    /// Anthropic decides who is signed in, but unreachable is not absent. Without this the
1010    /// account in use renders as one with nothing parked, advising a sign-in it does not
1011    /// need.
1012    #[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    /// The three answers that used to arrive as one. What a front end does next differs for
1070    /// each: wait and ask again, stop asking because the shape moved, or tell the person
1071    /// their parked login is finished.
1072    #[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        // The same token failure means different things for a live login and a parked one.
1095        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    /// What the menu bar shows between its own reads, and all it shows offline. Claude Code's
1136    /// cache moves only when Claude Code asks, and every session records its numbers into
1137    /// Pitboard's readings, so showing the cache over them had the menu bar disagree with
1138    /// every status line on the machine.
1139    #[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    /// Anthropic's usage answer can be behind the numbers a session has had in its latest
1167    /// response: one taken before the session recorded them. Shown as it came, the menu bar
1168    /// read 20% while every session said 22%.
1169    #[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    /// A banked reset used on claude.ai lowers a limit's share and keeps its reset, as this
1201    /// machine's sessions measured on 2026-09-29. Folded in by share alone, Anthropic's
1202    /// answer lost to the 100% recorded before it, and the row read full until the reset.
1203    #[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    /// The login signed in has a session of its own tool's to renew, and a parked one does
1248    /// not. The same refusal means different things on each side, and saying "parked" about
1249    /// the login somebody is using sends them looking for a park that is not there.
1250    #[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    /// A parked Codex login keeps its token where a Claude Code login does not. Reading it
1275    /// with Claude Code's layout called every Codex park unreadable; what is unreadable now
1276    /// is decided by the tool the park belongs to.
1277    #[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, &not_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    /// Every stale reason, for the tests that hold each one to something.
1337    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    /// What a Claude Code row said before a row knew its tool, word for word. A person who
1356    /// has only ever used Claude Code must not read a single word differently.
1357    #[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            // New, so there is no before to hold them to.
1372            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    /// "Anthropic could not be reached" said about a Codex account sends somebody to check
1394    /// the wrong service, and "`claude` renews it" names a program that has nothing to do
1395    /// with a Codex session.
1396    #[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        // And a row asks in its own tool's words without the caller having to know which.
1417        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    /// Every row says which tool it is for, the unenrolled one included, and the same
1457    /// identity on two tools is two rows. Deduplicated by identity alone, a Codex login
1458    /// whose id happened to match an enrolled Claude Code account vanished from the list.
1459    #[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    /// Grouped by tool in the order every listing uses, signed in first within each, and
1490    /// otherwise in the order they were enrolled.
1491    #[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    /// Claude Code's cache is a Claude Code account's numbers and nobody else's, however
1530    /// the identities happen to line up.
1531    #[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    /// A thread that panicked stands in for its own tool and nobody else's. It used to be
1549    /// filed under Claude Code whichever tool it had been asking about, and since answers
1550    /// are collected in order, a Codex thread that panicked erased Claude Code's signed-in
1551    /// row.
1552    #[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    /// Claude Code's `/logout` deletes the login and leaves the document behind, still
1598    /// holding the machine's MCP tokens. That is nobody signed in. It used to be sent to
1599    /// Anthropic to identify, and the row read as Anthropic answering badly.
1600    #[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    /// A keychain that is locked for the moment is not a login that is gone. Read as one,
1620    /// the account in use showed as parked with nothing parked, and the advice was to sign
1621    /// in again.
1622    #[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    /// While the login in use cannot be read, the account Pitboard last switched to has
1659    /// nothing parked because its login is the one Pitboard put in use, which may be exactly
1660    /// where it was. A `/login` where the keychain is locked moves Claude Code's record to
1661    /// another account and leaves that login in the keychain, so the record's account reads
1662    /// as signed in and this one was told to sign in again.
1663    #[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        // Once the login can be read, nothing parked means what it says.
1687        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    /// A tool whose login could not be read and whose own record names none of its
1700    /// accounts gets a row of its own, so its silence is not read as nobody signed in. Only
1701    /// for a tool somebody uses through Pitboard: a machine that has never enrolled a Codex
1702    /// account sees nothing about Codex.
1703    #[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    /// When whose the login is cannot be learned, the tool's own record keys the budget.
1755    /// Keyed on nothing, a morning when Anthropic's profile endpoint is failing was a
1756    /// morning when every `status` asked about usage with no floor at all. Before there was
1757    /// a second tool the account always came from Claude Code's config here.
1758    #[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            &nothing_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        // Asked for anyway, what is learned is kept under the same account.
1790        let (live, learned) = ask_live(
1791            &ctx,
1792            &nothing_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    /// Offline, each tool is asked for its own record. It used to be Claude Code's config
1803    /// alone, so on a plane a signed-in Codex account read as parked.
1804    #[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    /// Put `raw` where this machine's Codex keeps its login, exactly as written.
1854    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    /// A Codex login whose ID token names `tokens_of` and whose account id names
1863    /// `account_id`. The two are the same in every login Codex writes on its own.
1864    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    /// Two Codex accounts, `a` parked and `b` the one Pitboard last switched to, which has
1882    /// no park because a Codex park is moved rather than copied.
1883    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    /// What a codex still running from before a switch leaves when it refreshes in the
1903    /// middle of one: its own account's tokens under the other account's id. That is not
1904    /// nobody signed in, which is what status used to say while `status --offline` named
1905    /// the account the tokens belong to and `doctor` failed the login. Online, offline and
1906    /// doctor now agree on whose it is, and online says Pitboard cannot use it.
1907    #[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    /// Signed in with an API key: something is signed in, and it is no account. Not nobody,
1945    /// and not the account Pitboard last switched to either, whose login the key replaced.
1946    #[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    /// Somebody who uses Pitboard for Claude Code and also has Codex signed in has not
1973    /// asked for their Codex login to be read or sent anywhere. Until a Codex account is
1974    /// enrolled, status says nothing about Codex and asks OpenAI nothing.
1975    #[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    /// Codex writes its login with a plain truncating write, so a read can catch it half
1998    /// written. Codex keeps no record apart from the login, so its own record names nobody
1999    /// then, and Pitboard's record of its last switch stands in for it the way `doctor`
2000    /// already lets it. Without that, the account in use was told to sign in again.
2001    #[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    /// Only nothing at all is nobody. A Claude Code document with no account in it is what
2032    /// `/logout` leaves; one that is not a document of Claude Code's shape at all is a login
2033    /// Pitboard cannot use, and is said as one.
2034    #[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    /// One person's two organisations are two rows, and the one in use is the organisation
2069    /// Anthropic names.
2070    #[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    /// A front end is told which rows are a login Pitboard could not pin on any account,
2123    /// so it can say so instead of showing an account nobody has enrolled.
2124    #[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    /// A home of this test's own, removed when the test is done with it.
2151    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    /// A machine of this test's own: stores in memory, services from a script, a clock
2171    /// that stands still, every tool's home inside `root`, and Claude Code's config naming
2172    /// `recorded` as signed in where it names anybody.
2173    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            // Named, so nothing here looks up this machine's own `codex` on `PATH`.
2183            .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}