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    pub account_uuid: String,
179    pub signed_in: bool,
180    pub parked: Option<Park>,
181    pub usage: Option<Snapshot>,
182    pub stale: Option<Stale>,
183    /// How long this account lasts, from what its limits have been doing.
184    ///
185    /// The question this whole tool exists to answer is which account to use next, and two
186    /// instantaneous percentages do not answer it: 73% of a weekly limit means nothing
187    /// without knowing whether it was 40% this morning.
188    pub runway: crate::history::Runway,
189}
190
191impl Row {
192    /// Whether `pitboard use` would switch to it now.
193    pub fn switchable(&self, now: i64) -> bool {
194        !self.signed_in && self.parked.as_ref().is_some_and(|p| p.restorable_at(now))
195    }
196
197    /// The account, the way pitboard tells accounts apart. `None` for a row nothing has
198    /// enrolled.
199    pub fn key(&self) -> Option<Key> {
200        self.label
201            .as_ref()
202            .map(|label| Key::new(self.provider, label.clone()))
203    }
204
205    /// What to tell a person about [`Row::stale`], in the words of this row's own tool.
206    pub fn explanation(&self) -> Option<&'static str> {
207        self.stale
208            .and_then(|stale| stale.explanation_for(self.provider))
209    }
210
211    /// A tool's live login that pitboard could not pin on any account: one it could not
212    /// read, or read and could not use. It has no email and no account id, and nothing to
213    /// type about it but `pitboard doctor`, so a front end says what it is rather than
214    /// showing it as an account nobody has enrolled.
215    pub fn unplaced(&self) -> bool {
216        self.label.is_none()
217            && !self.signed_in
218            && matches!(
219                self.stale,
220                Some(Stale::LoginUnreadable | Stale::LoginUnusable)
221            )
222    }
223}
224
225pub struct Report {
226    pub now: i64,
227    pub rows: Vec<Row>,
228    /// Who the default tool's live login belongs to, as its service says: Claude Code's
229    /// config can be a day behind. Every tool's answer is in the rows.
230    pub signed_in: Result<Owner, String>,
231    /// Which credential slot this report speaks for.
232    ///
233    /// `CLAUDE_CONFIG_DIR` selects a different keychain item, so "who is signed in" is a
234    /// fact about one slot and not about the machine. Accounts and their parked logins
235    /// belong to the machine; what is in use does not. This report used to name accounts
236    /// without ever saying which slot it was speaking for, so on a machine with a second
237    /// config directory it was silently answering about one of them.
238    pub slot: Slot,
239}
240
241/// A credential slot, named.
242#[derive(Debug, Clone, PartialEq, Eq)]
243pub struct Slot {
244    /// The keychain item, which is what actually selects the login.
245    pub service: String,
246    /// Whether this is the one a `claude` with no `CLAUDE_CONFIG_DIR` reads.
247    pub default: bool,
248}
249
250/// What is true of one tool's live login.
251///
252/// Per tool, because there is no such thing as "the account signed in on this machine": a
253/// Claude Code login and a Codex login are different programs reading different stores, and
254/// neither signs the other out.
255#[derive(Default)]
256struct LiveLogin {
257    /// Whose it is. `None` means nobody is signed in to this tool, but only when
258    /// [`Facts::asked`] is true: unasked and absent are different things, and reading one as
259    /// the other says the account in use is gone. `Some(Err)` is a login whose owner could
260    /// not be learned this time, and says why.
261    signed_in: Option<Result<Owner, String>>,
262    /// The account the tool's own local record names, where it keeps one. It can be behind
263    /// the login it describes, so it never decides a switch; it keeps the row that is
264    /// signed in from reading as though nobody is, when the service cannot be asked.
265    recorded_uuid: Option<String>,
266    usage: Option<Result<Snapshot, Stale>>,
267    /// There is a login and pitboard cannot use it: it could not be read, or it was read and
268    /// is not one account's login. A tool in this state whose record names none of its
269    /// accounts still gets a row, so that nobody reads its silence as nobody signed in.
270    out_of_reach: bool,
271}
272
273impl LiveLogin {
274    fn usage(&self) -> Result<Snapshot, Stale> {
275        self.usage.clone().unwrap_or(Err(Stale::NothingSignedIn))
276    }
277}
278
279/// Everything gathered from the machine and the network, so assembling it touches neither.
280struct Facts {
281    live: BTreeMap<ProviderId, LiveLogin>,
282    asked: bool,
283    /// One per enrolled account, in order.
284    parked_usage: Vec<Result<Snapshot, Stale>>,
285    claude_code_cache: Option<Snapshot>,
286}
287
288impl Facts {
289    fn live_for(&self, which: ProviderId) -> Option<&LiveLogin> {
290        self.live.get(&which)
291    }
292}
293
294/// The parked login to ask about an account with, or why there is none.
295///
296/// Whether the document holds what a usage call needs is the tool's own question, answered
297/// when it is asked: a Codex login keeps its token somewhere a Claude Code login does not.
298fn parked_document(
299    ctx: &Context,
300    key: &Key,
301    parked: Option<&Park>,
302    now: i64,
303) -> Result<Value, Stale> {
304    match parked {
305        None => Err(Stale::NothingParked),
306        Some(p) if !p.askable_at(now) => Err(Stale::ParkedAccessExpired),
307        Some(p) => park::load(ctx, key, p).map_err(|_| Stale::ParkUnreadable),
308    }
309}
310
311/// Which slot this machine's `claude` would read right now.
312fn slot_of(ctx: &Context) -> Slot {
313    Slot {
314        service: claude::live_service(ctx),
315        default: claude::is_default_slot(ctx),
316    }
317}
318
319/// What is known without asking anyone: who each tool's own record says is signed in, and
320/// the last numbers pitboard measured. Touches no network, so it answers at once and works
321/// on a train.
322///
323/// Each tool is asked for what it keeps without anybody's agreement: Claude Code's config,
324/// which can be a day behind the login it describes, and a Codex login's own claims. Good
325/// enough to say who is in use; never good enough to move a login. It used to read Claude
326/// Code's alone, so a signed-in Codex account read as parked whenever the app had no
327/// network.
328/// Which tools a report is about: the one a bare name means, which is what pitboard was
329/// before there was a second, and every other tool somebody has enrolled an account of.
330///
331/// A tool is opted into by enrolling one of its accounts. Until then pitboard reads nothing
332/// of it and asks its service nothing: somebody who uses pitboard for Claude Code and also
333/// has Codex installed has not asked for their Codex login to be read, or sent to OpenAI on
334/// every refresh of a menu bar.
335fn in_use(state: &State) -> Vec<ProviderId> {
336    ProviderId::ALL
337        .iter()
338        .copied()
339        .filter(|&which| {
340            which == crate::label::DEFAULT || state.accounts.iter().any(|a| a.provider() == which)
341        })
342        .collect()
343}
344
345pub fn gather_offline(ctx: &Context, state: &State) -> Report {
346    let tools = in_use(state);
347    let recorded: BTreeMap<ProviderId, crate::provider::Identity> = tools
348        .iter()
349        .filter_map(|&which| Some((which, crate::provider::of(which).recorded_identity(ctx)?)))
350        .collect();
351    let facts = Facts {
352        live: tools
353            .iter()
354            .map(|&which| {
355                let login = LiveLogin {
356                    recorded_uuid: recorded.get(&which).map(|id| id.account_id.clone()),
357                    usage: Some(Err(Stale::NotAsked)),
358                    ..LiveLogin::default()
359                };
360                (which, login)
361            })
362            .collect(),
363        asked: false,
364        parked_usage: state
365            .accounts
366            .iter()
367            .map(|_| Err(Stale::NotAsked))
368            .collect(),
369        claude_code_cache: claude::load_config(ctx)
370            .ok()
371            .as_ref()
372            .and_then(crate::usage::from_config_cache),
373    };
374    let remembered = readings::load(ctx);
375    Report {
376        now: ctx.now(),
377        slot: slot_of(ctx),
378        rows: assemble(
379            state,
380            &facts,
381            |uuid| remembered.get(uuid).cloned(),
382            |uuid| crate::history::runway_for(ctx, uuid, ctx.now()),
383            ctx.now(),
384        ),
385        signed_in: recorded.get(&crate::label::DEFAULT).map_or_else(
386            || Err("not asked".into()),
387            |id| {
388                Ok(Owner {
389                    account_uuid: id.account_id.clone(),
390                    email: id.email.clone(),
391                    organization_uuid: id.group.clone().unwrap_or_default(),
392                })
393            },
394        ),
395    }
396}
397
398/// What one account's request came to: the reading, and what the budget should learn from
399/// it. Recorded by the caller once every request has finished, never from the thread that
400/// made it.
401type Asked = (Result<Snapshot, Stale>, Option<(String, budget::Outcome)>);
402
403/// What one tool's live login came to, and what the budget should learn from asking about
404/// it.
405type Answered = (LiveLogin, Option<(String, budget::Outcome)>);
406
407/// A provider's failure as the usage path has always classified one.
408fn to_api(error: crate::provider::ProviderError) -> ApiError {
409    use crate::provider::ProviderError as P;
410    match error {
411        P::Unauthorized => ApiError::Unauthorized,
412        P::RateLimited { retry_after, .. } => ApiError::RateLimited { retry_after },
413        P::Network { detail, .. } => ApiError::Network(detail),
414        P::Unexpected { status, .. } => ApiError::Unexpected { status },
415        P::Malformed { detail, .. }
416        | P::ShapeUnexpected { detail, .. }
417        | P::Unsupported { reason: detail, .. } => ApiError::Malformed(detail),
418        P::NoLogin { .. } => ApiError::Malformed("nothing is signed in".into()),
419        P::InvalidGrant { .. } => ApiError::InvalidGrant,
420    }
421}
422
423/// Ask about one account, or say why not.
424fn ask_usage(
425    ctx: &Context,
426    which: ProviderId,
427    account_uuid: Option<&str>,
428    document: &Value,
429    signed_in: bool,
430    remembered: Option<&Snapshot>,
431    fresh: bool,
432) -> Asked {
433    // An account pitboard cannot name cannot be budgeted for; it is asked about, which is
434    // what always happened.
435    if let Some(uuid) = account_uuid
436        && let Some(held) = budget::may_ask(ctx, uuid, remembered, fresh)
437    {
438        let stale = match held {
439            budget::Held::Fresh => Stale::AskedRecently,
440            budget::Held::RateLimited => Stale::RateLimited,
441            budget::Held::Unreachable => Stale::Unreachable,
442        };
443        return (Err(stale), None);
444    }
445    // Through the provider, because what a usage call needs is not the same everywhere:
446    // Codex sends an account id header it reads out of the credential, and Gemini needs a
447    // project id from a file the credential never mentions.
448    let answer = crate::provider::of(which).usage(
449        ctx,
450        &crate::provider::Credential::new(which, document.clone()),
451    );
452    // A park that is not the shape its own tool keeps was damaged at rest, which is a fact
453    // about the vault and not about the service. Said as such rather than as an answer the
454    // service gave.
455    if !signed_in
456        && matches!(
457            answer,
458            Err(crate::provider::ProviderError::ShapeUnexpected { .. })
459        )
460    {
461        return (Err(Stale::ParkUnreadable), None);
462    }
463    let answer = answer.map_err(to_api);
464    let learned = account_uuid.and_then(|uuid| {
465        let outcome = match &answer {
466            Ok(_) => budget::Outcome::Answered,
467            Err(api::ApiError::RateLimited { retry_after }) => {
468                budget::Outcome::RateLimited(*retry_after)
469            }
470            Err(api::ApiError::Network(_) | api::ApiError::Unexpected { .. }) => {
471                budget::Outcome::Unreachable
472            }
473            // A token that is refused, or an answer that is not understood, is not a reason
474            // to wait: waiting fixes neither.
475            Err(_) => return None,
476        };
477        Some((uuid.to_string(), outcome))
478    });
479    (answer.map_err(|e| Stale::of(&e, signed_in)), learned)
480}
481
482/// Ask about one tool's live login: whose it is, and what it has left.
483///
484/// `read` is what reading the tool's store came to, kept whole. An error there is not
485/// nothing signed in: the login may be exactly where it always was, behind a keychain that
486/// is locked for the moment, and saying it is gone sends somebody to sign in again for
487/// nothing. It used to be read as exactly that.
488///
489/// `active` is the account pitboard last recorded as signed in to this tool, which stands
490/// in for the tool's own record when the login cannot be read at all. Codex keeps no
491/// record apart from the login itself, so without this a Codex login caught half written
492/// named nobody, and the account in use was told to sign in again while `doctor`, reading
493/// the same machine, called it signed in.
494fn ask_live(
495    ctx: &Context,
496    which: ProviderId,
497    read: &Result<Option<Value>, ProviderError>,
498    active: Option<&str>,
499    remembered: &HashMap<String, Snapshot>,
500    fresh: bool,
501) -> Answered {
502    let tool = crate::provider::of(which);
503    let own_record = || tool.recorded_identity(ctx).map(|id| id.account_id);
504    let document = match read {
505        Ok(Some(document)) => document,
506        Ok(None) => return (LiveLogin::default(), None),
507        Err(error) => {
508            let unreadable = LiveLogin {
509                signed_in: Some(Err(error.to_string())),
510                recorded_uuid: own_record().or_else(|| active.map(str::to_owned)),
511                usage: Some(Err(Stale::LoginUnreadable)),
512                out_of_reach: true,
513            };
514            return (unreadable, None);
515        }
516    };
517    match tool.slice(document) {
518        Ok(_) => {}
519        // A document holding no account's login at all. Claude Code's `/logout` deletes the
520        // login and leaves the document behind with the machine's MCP tokens still in it;
521        // asking Anthropic whose that was got an answer about a shape rather than a person,
522        // and the row read as Anthropic answering badly when the truth is that nobody is
523        // signed in.
524        Err(ProviderError::NoLogin { .. }) => return (LiveLogin::default(), None),
525        // A login that is there and is not one account's: signed in with an API key, or a
526        // Codex login that a session still running from before a switch rewrote with its
527        // own account's tokens under the other account's id. Something is signed in, so
528        // it is not nobody; nobody is asked about it, because there is no one account's
529        // token to ask with. The tool's own record says whose it most likely is, and
530        // pitboard's own record does not stand in, because what is there was read and is
531        // not that account's.
532        Err(unusable) => {
533            let login = LiveLogin {
534                signed_in: Some(Err(unusable.to_string())),
535                recorded_uuid: own_record(),
536                usage: Some(Err(Stale::LoginUnusable)),
537                out_of_reach: true,
538            };
539            return (login, None);
540        }
541    }
542    let credential = crate::provider::Credential::new(which, document.clone());
543    // Who owns it is asked whatever the budget says: it decides which account a row
544    // belongs to, it is not a measurement, and a switch needs it.
545    let owner = tool
546        .identify(ctx, &credential)
547        .map(|found| Owner {
548            account_uuid: found.account_id,
549            email: found.email,
550            organization_uuid: found.group.unwrap_or_default(),
551        })
552        .map_err(|e| to_api(e).to_string());
553    // The tool's own record, for when its service could not say whose the login is. It
554    // stands in twice: to keep the signed-in row signed in, and to key the budget. Without
555    // the second, a morning when identifying fails is a morning when every `status` asks
556    // about usage with no floor at all, which is what the budget exists to stop. Claude
557    // Code's account always came from its config here before there was a second tool.
558    let recorded = match &owner {
559        Ok(_) => None,
560        Err(_) => own_record(),
561    };
562    let uuid = owner
563        .as_ref()
564        .ok()
565        .map(|o| o.account_uuid.clone())
566        .or_else(|| recorded.clone());
567    let (usage, learned) = ask_usage(
568        ctx,
569        which,
570        uuid.as_deref(),
571        document,
572        true,
573        uuid.as_deref().and_then(|u| remembered.get(u)),
574        fresh,
575    );
576    let login = LiveLogin {
577        signed_in: Some(owner),
578        recorded_uuid: recorded,
579        usage: Some(usage),
580        out_of_reach: false,
581    };
582    (login, learned)
583}
584
585/// Every tool's answer, filed under the tool it was asked about.
586///
587/// A thread that stopped before answering stands in for its own tool and nobody else's. It
588/// used to be replaced by an empty answer filed under Claude Code whichever tool it had
589/// been asking about, and because answers are collected in order, a Codex thread that
590/// panicked quietly erased Claude Code's signed-in row.
591fn settle(
592    ctx: &Context,
593    answers: Vec<(ProviderId, std::thread::Result<Answered>)>,
594) -> (
595    BTreeMap<ProviderId, LiveLogin>,
596    Vec<(String, budget::Outcome)>,
597) {
598    let mut live = BTreeMap::new();
599    let mut learned = Vec::new();
600    for (which, answer) in answers {
601        let (login, outcome) = answer.unwrap_or_else(|_| {
602            // Whose the login is was never learned, so the tool's own record stands in for
603            // it, the same as when its service cannot be reached.
604            let interrupted = LiveLogin {
605                signed_in: Some(Err("the check did not finish".into())),
606                recorded_uuid: crate::provider::of(which)
607                    .recorded_identity(ctx)
608                    .map(|id| id.account_id),
609                usage: Some(Err(Stale::Interrupted)),
610                out_of_reach: false,
611            };
612            (interrupted, None)
613        });
614        live.insert(which, login);
615        learned.extend(outcome);
616    }
617    (live, learned)
618}
619
620pub fn gather(ctx: &Context, state: &State, fresh: bool) -> Report {
621    let now = ctx.now();
622    // Each tool's live login, whole, or why it could not be read. Not a token out of it:
623    // what a usage call needs is not the same everywhere, and pulling one field out here
624    // would decide that for all of them.
625    let live_documents: Vec<(ProviderId, Result<Option<Value>, ProviderError>)> = in_use(state)
626        .into_iter()
627        .map(|which| {
628            let read = crate::provider::of(which)
629                .read_live(ctx)
630                .map(|found| found.map(|credential| credential.raw));
631            (which, read)
632        })
633        .collect();
634    let parked_documents: Vec<Result<Value, Stale>> = state
635        .accounts
636        .iter()
637        .map(|a| parked_document(ctx, &a.key(), a.parked.as_ref(), now))
638        .collect();
639    let config = claude::load_config(ctx).ok();
640    let remembered = readings::load(ctx);
641
642    let (answers, parked_asked): (Vec<(ProviderId, std::thread::Result<Answered>)>, Vec<Asked>) =
643        std::thread::scope(|scope| {
644            // Each handle stays with the tool it asks about, so a thread that panics can
645            // only ever stand in for its own tool.
646            let per_tool: Vec<_> = live_documents
647                .iter()
648                .map(|(which, read)| {
649                    let remembered = &remembered;
650                    let which = *which;
651                    let active = active_uuid(state, which);
652                    let handle =
653                        scope.spawn(move || ask_live(ctx, which, read, active, remembered, fresh));
654                    (which, handle)
655                })
656                .collect();
657            let parked: Vec<_> = parked_documents
658                .iter()
659                .zip(state.accounts.iter())
660                .map(|(token, account)| {
661                    let remembered = &remembered;
662                    scope.spawn(move || match token.as_ref() {
663                        Err(stale) => (Err(*stale), None),
664                        Ok(document) => ask_usage(
665                            ctx,
666                            account.provider(),
667                            Some(&account.account_uuid),
668                            document,
669                            false,
670                            remembered.get(&account.account_uuid),
671                            fresh,
672                        ),
673                    })
674                })
675                .collect();
676            (
677                per_tool
678                    .into_iter()
679                    .map(|(which, handle)| (which, handle.join()))
680                    .collect(),
681                parked
682                    .into_iter()
683                    .map(|h| h.join().unwrap_or((Err(Stale::Interrupted), None)))
684                    .collect(),
685            )
686        });
687    let (live, live_learned) = settle(ctx, answers);
688
689    // Once, from one thread. Every account's record lives in one file, so a thread each
690    // reading it, changing one entry and writing it back would erase what the others
691    // learned.
692    let learned: Vec<(String, budget::Outcome)> = live_learned
693        .into_iter()
694        .chain(
695            parked_asked
696                .iter()
697                .filter_map(|(_, learned)| learned.clone()),
698        )
699        .collect();
700    budget::record(ctx, &learned);
701
702    let signed_in_default = live
703        .get(&crate::label::DEFAULT)
704        .and_then(|login| login.signed_in.clone());
705    let facts = Facts {
706        live,
707        asked: true,
708        parked_usage: parked_asked.into_iter().map(|(usage, _)| usage).collect(),
709        claude_code_cache: config.as_ref().and_then(crate::usage::from_config_cache),
710    };
711    let rows = assemble(
712        state,
713        &facts,
714        |uuid| remembered.get(uuid).cloned(),
715        |uuid| crate::history::runway_for(ctx, uuid, now),
716        now,
717    );
718    for row in &rows {
719        if let Some(live) = row.usage.as_ref().filter(|u| u.source == Source::Live) {
720            crate::history::record(ctx, &row.account_uuid, live);
721        }
722    }
723    readings::remember(
724        ctx,
725        &rows
726            .iter()
727            .filter_map(|r| {
728                r.usage
729                    .as_ref()
730                    .filter(|u| u.source == Source::Live)
731                    .map(|u| (r.account_uuid.clone(), u.clone()))
732            })
733            .collect::<Vec<_>>(),
734    );
735
736    Report {
737        slot: slot_of(ctx),
738        now,
739        rows,
740        // The default tool's answer. Every tool's is in the rows, which is where a
741        // machine with more than one signed in has to be read from: there is no single
742        // fact called "the account signed in on this machine" any more.
743        signed_in: match signed_in_default {
744            Some(Ok(owner)) => Ok(owner),
745            Some(Err(why)) => Err(why),
746            None => Err("nothing is signed in".into()),
747        },
748    }
749}
750
751/// The account pitboard last recorded as signed in to this tool, by its id.
752fn active_uuid(state: &State, which: ProviderId) -> Option<&str> {
753    let label = state.active_for(which)?;
754    state
755        .get(&Key::new(which, label))
756        .map(|account| account.account_uuid.as_str())
757}
758
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    lasting: impl Fn(&str) -> crate::history::Runway,
772    now: i64,
773) -> Vec<Row> {
774    // Per tool, because an account is signed in to its own tool or to nothing. Comparing
775    // every account against one machine-wide answer would have marked a Codex account
776    // signed in because a Claude Code account with the same uuid was.
777    let live_uuid = |which: ProviderId| -> Option<&str> {
778        let live = facts.live_for(which)?;
779        match (&live.signed_in, facts.asked) {
780            (Some(Ok(owner)), _) => Some(owner.account_uuid.as_str()),
781            // Unreachable, unreadable, or never asked. The service decides who is signed
782            // in; without its answer, the tool's own local record is the only one there is.
783            (Some(Err(_)), _) | (None, false) => live.recorded_uuid.as_deref(),
784            (None, true) => None,
785        }
786    };
787    // Claude Code's cache counts only for Claude Code's accounts, and only when it was
788    // measured for the account in question.
789    let cached_for = |which: ProviderId, uuid: &str| {
790        facts
791            .claude_code_cache
792            .clone()
793            .filter(|c| which == ProviderId::Claude && c.account_uuid.as_deref() == Some(uuid))
794    };
795    // What was asked, or Claude Code's cache when nothing was, folded into what every front
796    // end has recorded. So a row shows the one reading the status lines show, and neither
797    // an answer that lags a session's latest response nor a cache that moves only when
798    // Claude Code asks can take it backwards.
799    let reading = |uuid: &str, asked: &Result<Snapshot, Stale>, cached: Option<Snapshot>| {
800        let recalled = recall(uuid);
801        match asked {
802            Ok(live) => (merge(recalled.as_ref(), Some(live), now), None),
803            Err(stale) => (merge(recalled.as_ref(), cached.as_ref(), now), Some(*stale)),
804        }
805    };
806
807    let mut rows: Vec<Row> = state
808        .accounts
809        .iter()
810        .zip(&facts.parked_usage)
811        .map(|(account, parked)| {
812            let uuid = account.account_uuid.as_str();
813            let which = account.provider();
814            let signed_in = live_uuid(which) == Some(uuid);
815            let (usage, stale) = if signed_in {
816                let live = facts
817                    .live_for(which)
818                    .map_or(Err(Stale::NothingSignedIn), LiveLogin::usage);
819                reading(uuid, &live, cached_for(which, uuid))
820            } else {
821                reading(uuid, parked, None)
822            };
823            Row {
824                provider: which,
825                label: Some(account.label.clone()),
826                email: account.email.clone(),
827                account_uuid: account.account_uuid.clone(),
828                signed_in,
829                parked: account.parked.clone(),
830                usage,
831                stale,
832                runway: lasting(uuid),
833            }
834        })
835        .collect();
836
837    // A tool signed in to an account nothing has enrolled still gets a row, so somebody can
838    // see what is there and give it a name. One per tool, because each can have its own,
839    // and told apart by tool as well as by account: two tools' identities are two
840    // namespaces, and one tool's row must never stand in for another's.
841    for &which in ProviderId::ALL {
842        let Some(live) = facts.live_for(which) else {
843            continue;
844        };
845        if let Some(Ok(owner)) = &live.signed_in {
846            if rows
847                .iter()
848                .any(|r| r.provider == which && r.account_uuid == owner.account_uuid)
849            {
850                continue;
851            }
852            let (usage, stale) = reading(
853                &owner.account_uuid,
854                &live.usage(),
855                cached_for(which, &owner.account_uuid),
856            );
857            rows.push(Row {
858                provider: which,
859                label: None,
860                email: owner.email.clone(),
861                account_uuid: owner.account_uuid.clone(),
862                signed_in: true,
863                parked: None,
864                usage,
865                stale,
866                runway: lasting(&owner.account_uuid),
867            });
868            continue;
869        }
870        // A login that could not be read, or was read and is not one account's, and that no
871        // record pins on any of the tool's accounts. Said, rather than left out, because
872        // leaving it out reads as nobody being signed in; but only for a tool somebody uses
873        // through pitboard, so a machine that has never enrolled a Codex account sees
874        // nothing about Codex at all.
875        let enrolled = state.accounts.iter().any(|a| a.provider() == which);
876        let placed = rows.iter().any(|r| r.provider == which && r.signed_in);
877        if live.out_of_reach && enrolled && !placed {
878            rows.push(Row {
879                provider: which,
880                label: None,
881                email: String::new(),
882                account_uuid: String::new(),
883                signed_in: false,
884                parked: None,
885                usage: None,
886                stale: live.usage().err(),
887                runway: crate::history::Runway::Unknown,
888            });
889        }
890    }
891
892    // Grouped by tool, in the order every listing uses, and the signed-in account first
893    // within each. Stable, so a machine with one tool reads in exactly the order it always
894    // did.
895    rows.sort_by_key(|r| (rank(r.provider), !r.signed_in));
896    rows
897}
898
899#[cfg(test)]
900mod tests {
901    use super::*;
902    use crate::api::scripted::{Asked as Question, ScriptedApi, Trouble};
903    use crate::state::Account;
904    use crate::store::memory::{Fault, MemoryHost};
905    use crate::time::{Clock, FixedClock};
906    use crate::usage::Window;
907    use serde_json::json;
908    use std::sync::Arc;
909
910    const NOW: i64 = 1_789_935_000;
911
912    fn owner(uuid: &str) -> Owner {
913        Owner {
914            account_uuid: uuid.into(),
915            email: format!("{uuid}@example.com"),
916            organization_uuid: "org".into(),
917        }
918    }
919
920    fn reading(percent: f64, source: Source, account: Option<&str>) -> Snapshot {
921        Snapshot {
922            windows: vec![Window {
923                kind: "session".into(),
924                scope: None,
925                severity: None,
926                percent,
927                resets_at: Some(NOW + 3_600),
928                is_active: true,
929                length_seconds: None,
930            }],
931            observed_at: Some(NOW - 7_200),
932            account_uuid: account.map(str::to_owned),
933            source,
934        }
935    }
936
937    fn parked(refresh_expires_at: i64) -> Park {
938        Park {
939            service: "pitboard-park-x-1".into(),
940            parked_at: NOW - 86_400,
941            refresh_fingerprint: "f".into(),
942            access_expires_at: Some(NOW - 3_600),
943            refresh_expires_at: Some(refresh_expires_at),
944        }
945    }
946
947    fn account(label: &str) -> Account {
948        Account {
949            last_used_at: None,
950            label: label.into(),
951            account_uuid: format!("{label}-uuid"),
952            email: format!("{label}@example.com"),
953            detail: crate::state::Detail::Claude {
954                organization_uuid: "org".into(),
955                oauth_account: json!({}),
956            },
957            parked: Some(parked(NOW + 20 * 86_400)),
958        }
959    }
960
961    fn state(labels: &[&str]) -> State {
962        State {
963            accounts: labels.iter().map(|l| account(l)).collect(),
964            ..State::default()
965        }
966    }
967
968    fn facts(
969        signed_in: &str,
970        live: Result<Snapshot, Stale>,
971        parked: Vec<Result<Snapshot, Stale>>,
972    ) -> Facts {
973        only_claude(
974            LiveLogin {
975                signed_in: Some(Ok(owner(signed_in))),
976                usage: Some(live),
977                ..LiveLogin::default()
978            },
979            parked,
980        )
981    }
982
983    /// The one-tool case every test here was written for, in the shape facts now take.
984    fn only_claude(live: LiveLogin, parked: Vec<Result<Snapshot, Stale>>) -> Facts {
985        Facts {
986            live: std::iter::once((ProviderId::Claude, live)).collect(),
987            asked: true,
988            parked_usage: parked,
989            claude_code_cache: None,
990        }
991    }
992
993    /// Anthropic decides who is signed in, but unreachable is not absent. Without this the
994    /// account in use renders as one with nothing parked, advising a sign-in it does not
995    /// need.
996    #[test]
997    fn an_unreachable_anthropic_leaves_the_signed_in_row_signed_in() {
998        let state = state(&["alpha", "beta"]);
999        let facts = Facts {
1000            live: std::iter::once((
1001                ProviderId::Claude,
1002                LiveLogin {
1003                    signed_in: Some(Err("could not reach Anthropic: offline".into())),
1004                    recorded_uuid: Some("alpha-uuid".into()),
1005                    usage: Some(Err(Stale::Unreachable)),
1006                    out_of_reach: false,
1007                },
1008            ))
1009            .collect(),
1010            asked: true,
1011            parked_usage: vec![Err(Stale::Unreachable), Err(Stale::Unreachable)],
1012            claude_code_cache: None,
1013        };
1014        let rows = assemble(&state, &facts, nothing_remembered, nothing_known, NOW);
1015        let a = rows
1016            .iter()
1017            .find(|r| r.account_uuid == "alpha-uuid")
1018            .unwrap();
1019        assert!(
1020            a.signed_in,
1021            "the account in use is still the account in use"
1022        );
1023        let b = rows.iter().find(|r| r.account_uuid == "beta-uuid").unwrap();
1024        assert!(!b.signed_in);
1025    }
1026
1027    fn nothing_remembered(_: &str) -> Option<Snapshot> {
1028        None
1029    }
1030
1031    fn nothing_known(_: &str) -> crate::history::Runway {
1032        crate::history::Runway::Unknown
1033    }
1034
1035    #[test]
1036    fn every_stale_code_is_its_json_form() {
1037        for stale in [
1038            Stale::NothingSignedIn,
1039            Stale::LoginUnreadable,
1040            Stale::LoginUnusable,
1041            Stale::SessionExpired,
1042            Stale::ParkedAccessExpired,
1043            Stale::NothingParked,
1044            Stale::ParkUnreadable,
1045            Stale::RateLimited,
1046            Stale::Unreachable,
1047            Stale::ServerError,
1048            Stale::AnswerNotUnderstood,
1049            Stale::LoginRefused,
1050            Stale::Interrupted,
1051            Stale::NotAsked,
1052        ] {
1053            assert_eq!(serde_json::to_value(stale).unwrap(), stale.code());
1054        }
1055    }
1056
1057    /// The three answers that used to arrive as one. What a front end does next differs for
1058    /// each: wait and ask again, stop asking because the shape moved, or tell the person
1059    /// their parked login is finished.
1060    #[test]
1061    fn anthropics_failures_are_told_apart() {
1062        use crate::api::ApiError;
1063        let parked = |e: &ApiError| Stale::of(e, false);
1064        assert_eq!(
1065            parked(&ApiError::Unexpected { status: 503 }),
1066            Stale::ServerError
1067        );
1068        assert_eq!(
1069            parked(&ApiError::Malformed("no windows".into())),
1070            Stale::AnswerNotUnderstood
1071        );
1072        assert_eq!(parked(&ApiError::InvalidGrant), Stale::LoginRefused);
1073        assert_eq!(
1074            parked(&ApiError::RateLimited { retry_after: None }),
1075            Stale::RateLimited
1076        );
1077        assert_eq!(
1078            parked(&ApiError::Network("down".into())),
1079            Stale::Unreachable
1080        );
1081
1082        // The same token failure means different things for a live login and a parked one.
1083        assert_eq!(parked(&ApiError::Unauthorized), Stale::ParkedAccessExpired);
1084        assert_eq!(
1085            Stale::of(&ApiError::Unauthorized, true),
1086            Stale::SessionExpired
1087        );
1088    }
1089
1090    #[test]
1091    fn a_live_reading_wins_over_claude_codes_cache() {
1092        let s = state(&["work"]);
1093        let mut f = facts(
1094            "work-uuid",
1095            Ok(reading(30.0, Source::Live, None)),
1096            vec![Err(Stale::NothingParked)],
1097        );
1098        f.claude_code_cache = Some(reading(2.0, Source::ClaudeCodeCache, Some("work-uuid")));
1099        let rows = assemble(&s, &f, nothing_remembered, nothing_known, NOW);
1100        let usage = rows[0].usage.as_ref().unwrap();
1101        assert_eq!(usage.source, Source::Live);
1102        assert_eq!(usage.windows[0].percent, 30.0);
1103        assert_eq!(rows[0].stale, None);
1104    }
1105
1106    #[test]
1107    fn an_expired_session_falls_back_to_claude_codes_cache_and_says_why() {
1108        let s = state(&["work"]);
1109        let mut f = facts(
1110            "work-uuid",
1111            Err(Stale::SessionExpired),
1112            vec![Err(Stale::NothingParked)],
1113        );
1114        f.claude_code_cache = Some(reading(2.0, Source::ClaudeCodeCache, Some("work-uuid")));
1115        let rows = assemble(&s, &f, nothing_remembered, nothing_known, NOW);
1116        assert_eq!(
1117            rows[0].usage.as_ref().unwrap().source,
1118            Source::ClaudeCodeCache
1119        );
1120        assert_eq!(rows[0].stale, Some(Stale::SessionExpired));
1121    }
1122
1123    /// What the menu bar shows between its own reads, and all it shows offline. Claude Code's
1124    /// cache moves only when Claude Code asks, and every session records its numbers into
1125    /// pitboard's readings, so showing the cache over them had the menu bar disagree with
1126    /// every status line on the machine.
1127    #[test]
1128    fn offline_the_account_in_use_shows_the_newer_of_claude_codes_cache_and_the_readings() {
1129        let s = state(&["work"]);
1130        let mut f = facts(
1131            "work-uuid",
1132            Err(Stale::NotAsked),
1133            vec![Err(Stale::NothingParked)],
1134        );
1135        f.asked = false;
1136        f.claude_code_cache = Some(reading(20.0, Source::ClaudeCodeCache, Some("work-uuid")));
1137        let recorded = |_: &str| Some(reading(22.0, Source::Remembered, Some("work-uuid")));
1138        let rows = assemble(&s, &f, recorded, nothing_known, NOW);
1139        let usage = rows[0].usage.as_ref().unwrap();
1140        assert_eq!(usage.windows[0].percent, 22.0);
1141        assert_eq!(usage.source, Source::Remembered);
1142        assert_eq!(rows[0].stale, Some(Stale::NotAsked));
1143
1144        f.claude_code_cache = Some(reading(25.0, Source::ClaudeCodeCache, Some("work-uuid")));
1145        let rows = assemble(&s, &f, recorded, nothing_known, NOW);
1146        let usage = rows[0].usage.as_ref().unwrap();
1147        assert_eq!(
1148            usage.windows[0].percent, 25.0,
1149            "and the cache where it is newer"
1150        );
1151        assert_eq!(usage.source, Source::ClaudeCodeCache);
1152    }
1153
1154    /// Anthropic's usage answer can be behind the numbers a session has had in its latest
1155    /// response: one taken before the session recorded them. Shown as it came, the menu bar
1156    /// read 20% while every session said 22%.
1157    #[test]
1158    fn a_live_reading_behind_what_a_session_recorded_does_not_move_the_row_back() {
1159        let s = state(&["work"]);
1160        let f = facts(
1161            "work-uuid",
1162            Ok(reading(20.0, Source::Live, None)),
1163            vec![Err(Stale::NothingParked)],
1164        );
1165        let recorded = |_: &str| {
1166            let mut since = reading(22.0, Source::Remembered, Some("work-uuid"));
1167            since.observed_at = Some(NOW - 60);
1168            Some(since)
1169        };
1170        let rows = assemble(&s, &f, recorded, nothing_known, NOW);
1171        assert_eq!(rows[0].usage.as_ref().unwrap().windows[0].percent, 22.0);
1172        assert_eq!(rows[0].stale, None);
1173
1174        let f = facts(
1175            "work-uuid",
1176            Ok(reading(30.0, Source::Live, None)),
1177            vec![Err(Stale::NothingParked)],
1178        );
1179        let usage = assemble(&s, &f, recorded, nothing_known, NOW)[0]
1180            .usage
1181            .clone()
1182            .unwrap();
1183        assert_eq!(usage.windows[0].percent, 30.0);
1184        assert_eq!(
1185            usage.source,
1186            Source::Live,
1187            "a live reading ahead is shown as live"
1188        );
1189    }
1190
1191    /// A banked reset used on claude.ai lowers a limit's share and keeps its reset, as this
1192    /// machine's sessions measured on 2026-09-29. Folded in by share alone, Anthropic's
1193    /// answer lost to the 100% recorded before it, and the row read full until the reset.
1194    #[test]
1195    fn a_live_reading_taken_after_what_was_recorded_is_shown_however_low() {
1196        let s = state(&["work"]);
1197        let mut answered = reading(14.0, Source::Live, None);
1198        answered.observed_at = Some(NOW);
1199        let f = facts("work-uuid", Ok(answered), vec![Err(Stale::NothingParked)]);
1200        let recorded = |_: &str| Some(reading(100.0, Source::Remembered, Some("work-uuid")));
1201        let usage = assemble(&s, &f, recorded, nothing_known, NOW)[0]
1202            .usage
1203            .clone()
1204            .unwrap();
1205        assert_eq!(usage.windows[0].percent, 14.0);
1206        assert_eq!(usage.source, Source::Live);
1207    }
1208
1209    #[test]
1210    fn claude_codes_cache_for_another_account_is_never_shown_as_this_one() {
1211        let s = state(&["work"]);
1212        let mut f = facts(
1213            "work-uuid",
1214            Err(Stale::Unreachable),
1215            vec![Err(Stale::NothingParked)],
1216        );
1217        f.claude_code_cache = Some(reading(99.0, Source::ClaudeCodeCache, Some("someone-else")));
1218        assert!(
1219            assemble(&s, &f, nothing_remembered, nothing_known, NOW)[0]
1220                .usage
1221                .is_none()
1222        );
1223    }
1224
1225    #[test]
1226    fn a_parked_account_is_asked_with_its_own_login() {
1227        let s = state(&["work", "personal"]);
1228        let f = facts(
1229            "work-uuid",
1230            Ok(reading(30.0, Source::Live, None)),
1231            vec![
1232                Err(Stale::NothingParked),
1233                Ok(reading(12.0, Source::Live, None)),
1234            ],
1235        );
1236        let rows = assemble(&s, &f, nothing_remembered, nothing_known, NOW);
1237        let personal = rows
1238            .iter()
1239            .find(|r| r.label.as_deref() == Some("personal"))
1240            .unwrap();
1241        assert_eq!(personal.usage.as_ref().unwrap().windows[0].percent, 12.0);
1242        assert!(personal.switchable(NOW));
1243    }
1244
1245    /// The login signed in has a session of its own tool's to renew, and a parked one does
1246    /// not. The same refusal means different things on each side, and saying "parked" about
1247    /// the login somebody is using sends them looking for a park that is not there.
1248    #[test]
1249    fn a_refused_token_is_an_expired_session_when_it_is_the_one_signed_in() {
1250        let api = crate::api::scripted::ScriptedApi::new();
1251        api.token_trouble("t", crate::api::scripted::Trouble::Unauthorized);
1252        let ctx = Context::new(std::path::PathBuf::from("/nowhere"))
1253            .with_pitboard_home(std::env::temp_dir().join("pitboard-status-refused"))
1254            .with_scripted_api(api);
1255        let login = serde_json::json!({"claudeAiOauth": {"accessToken": "t"}});
1256        let ask = |signed_in| {
1257            ask_usage(
1258                &ctx,
1259                ProviderId::Claude,
1260                None,
1261                &login,
1262                signed_in,
1263                None,
1264                true,
1265            )
1266            .0
1267        };
1268        assert_eq!(ask(true).unwrap_err(), Stale::SessionExpired);
1269        assert_eq!(ask(false).unwrap_err(), Stale::ParkedAccessExpired);
1270    }
1271
1272    /// A parked Codex login keeps its token where a Claude Code login does not. Reading it
1273    /// with Claude Code's layout called every Codex park unreadable; what is unreadable now
1274    /// is decided by the tool the park belongs to.
1275    #[test]
1276    fn a_park_not_in_its_own_tools_shape_is_unreadable_rather_than_an_answer() {
1277        let ctx = Context::new(std::path::PathBuf::from("/nowhere"));
1278        let not_codex = serde_json::json!({"claudeAiOauth": {"accessToken": "t"}});
1279        let (answer, learned) =
1280            ask_usage(&ctx, ProviderId::Codex, None, &not_codex, false, None, true);
1281        assert_eq!(answer.unwrap_err(), Stale::ParkUnreadable);
1282        assert!(
1283            learned.is_none(),
1284            "nothing was asked, so there is nothing to learn"
1285        );
1286    }
1287
1288    #[test]
1289    fn a_parked_login_past_its_access_expiry_is_not_asked() {
1290        let ctx = Context::from_env();
1291        let personal = Key::new(ProviderId::Claude, "personal");
1292        let token = parked_document(&ctx, &personal, Some(&parked(NOW + 86_400)), NOW);
1293        assert_eq!(token, Err(Stale::ParkedAccessExpired));
1294        assert_eq!(
1295            parked_document(&ctx, &personal, None, NOW),
1296            Err(Stale::NothingParked)
1297        );
1298    }
1299
1300    #[test]
1301    fn what_cannot_be_asked_falls_back_to_what_was_remembered() {
1302        let s = state(&["work", "personal"]);
1303        let f = facts(
1304            "work-uuid",
1305            Ok(reading(30.0, Source::Live, None)),
1306            vec![Err(Stale::NothingParked), Err(Stale::ParkedAccessExpired)],
1307        );
1308        let remembered = |uuid: &str| {
1309            (uuid == "personal-uuid").then(|| reading(44.0, Source::Remembered, Some(uuid)))
1310        };
1311        let rows = assemble(&s, &f, remembered, nothing_known, NOW);
1312        let personal = rows
1313            .iter()
1314            .find(|r| r.label.as_deref() == Some("personal"))
1315            .unwrap();
1316        assert_eq!(personal.usage.as_ref().unwrap().source, Source::Remembered);
1317        assert_eq!(personal.stale, Some(Stale::ParkedAccessExpired));
1318    }
1319
1320    #[test]
1321    fn the_signed_in_account_comes_first_and_is_taken_from_the_server_not_the_config() {
1322        let s = state(&["alpha", "beta"]);
1323        let f = facts(
1324            "beta-uuid",
1325            Ok(reading(5.0, Source::Live, None)),
1326            vec![Err(Stale::NothingParked), Err(Stale::NothingParked)],
1327        );
1328        let rows = assemble(&s, &f, nothing_remembered, nothing_known, NOW);
1329        assert_eq!(rows[0].label.as_deref(), Some("beta"));
1330        assert!(rows[0].signed_in && !rows[0].switchable(NOW));
1331        assert!(!rows[1].signed_in);
1332    }
1333
1334    /// Every stale reason, for the tests that hold each one to something.
1335    const EVERY_STALE: [Stale; 15] = [
1336        Stale::NothingSignedIn,
1337        Stale::LoginUnreadable,
1338        Stale::LoginUnusable,
1339        Stale::SessionExpired,
1340        Stale::ParkedAccessExpired,
1341        Stale::NothingParked,
1342        Stale::ParkUnreadable,
1343        Stale::RateLimited,
1344        Stale::Unreachable,
1345        Stale::ServerError,
1346        Stale::AnswerNotUnderstood,
1347        Stale::LoginRefused,
1348        Stale::AskedRecently,
1349        Stale::Interrupted,
1350        Stale::NotAsked,
1351    ];
1352
1353    /// What a Claude Code row said before a row knew its tool, word for word. A person who
1354    /// has only ever used Claude Code must not read a single word differently.
1355    #[test]
1356    fn claude_codes_explanations_are_word_for_word_what_they_were() {
1357        let before = |stale: Stale| match stale {
1358            Stale::NothingSignedIn => Some("nothing is signed in"),
1359            Stale::SessionExpired => Some("Claude Code's session has expired; `claude` renews it"),
1360            Stale::ParkedAccessExpired | Stale::NothingParked | Stale::AskedRecently => None,
1361            Stale::ParkUnreadable => Some("its parked login cannot be read; run `pitboard doctor`"),
1362            Stale::RateLimited => Some("Anthropic is rate limiting usage checks"),
1363            Stale::Unreachable => Some("Anthropic could not be reached"),
1364            Stale::ServerError => Some("Anthropic answered with an error; try again later"),
1365            Stale::AnswerNotUnderstood => Some("Anthropic's answer was not understood"),
1366            Stale::LoginRefused => Some("its parked login is no longer accepted; sign in again"),
1367            Stale::Interrupted => Some("the check did not finish"),
1368            Stale::NotAsked => Some("read without asking Anthropic"),
1369            // New, so there is no before to hold them to.
1370            Stale::LoginUnreadable => {
1371                Some("Claude Code's login could not be read; run `pitboard doctor`")
1372            }
1373            Stale::LoginUnusable => Some(
1374                "Claude Code's login is not one pitboard can park or switch; run `pitboard doctor`",
1375            ),
1376        };
1377        for stale in EVERY_STALE {
1378            assert_eq!(
1379                stale.explanation_for(ProviderId::Claude),
1380                before(stale),
1381                "{stale:?}"
1382            );
1383            assert_eq!(
1384                stale.explanation(),
1385                stale.explanation_for(ProviderId::Claude),
1386                "a caller with only a code gets the default tool's words"
1387            );
1388        }
1389    }
1390
1391    /// "Anthropic could not be reached" said about a Codex account sends somebody to check
1392    /// the wrong service, and "`claude` renews it" names a program that has nothing to do
1393    /// with a Codex session.
1394    #[test]
1395    fn a_codex_row_names_openai_and_codex_never_anthropic_or_claude() {
1396        for stale in EVERY_STALE {
1397            let Some(said) = stale.explanation_for(ProviderId::Codex) else {
1398                assert_eq!(stale.explanation_for(ProviderId::Claude), None, "{stale:?}");
1399                continue;
1400            };
1401            for other in ["Anthropic", "Claude", "claude"] {
1402                assert!(!said.contains(other), "{stale:?}: {said}");
1403            }
1404        }
1405        assert_eq!(
1406            Stale::Unreachable.explanation_for(ProviderId::Codex),
1407            Some("OpenAI could not be reached")
1408        );
1409        assert_eq!(
1410            Stale::SessionExpired.explanation_for(ProviderId::Codex),
1411            Some("Codex's session has expired; `codex` renews it")
1412        );
1413
1414        // And a row asks in its own tool's words without the caller having to know which.
1415        let mut codex = codex_account("work", "work-acc");
1416        codex.parked = None;
1417        let s = State {
1418            accounts: vec![codex],
1419            ..State::default()
1420        };
1421        let f = Facts {
1422            live: BTreeMap::new(),
1423            asked: true,
1424            parked_usage: vec![Err(Stale::Unreachable)],
1425            claude_code_cache: None,
1426        };
1427        let rows = assemble(&s, &f, nothing_remembered, nothing_known, NOW);
1428        assert_eq!(rows[0].explanation(), Some("OpenAI could not be reached"));
1429    }
1430
1431    fn codex_account(label: &str, uuid: &str) -> Account {
1432        Account {
1433            last_used_at: None,
1434            label: label.into(),
1435            account_uuid: uuid.into(),
1436            email: format!("{label}@example.com"),
1437            detail: crate::state::Detail::Codex {
1438                workspace_id: None,
1439                plan: None,
1440            },
1441            parked: Some(parked(NOW + 20 * 86_400)),
1442        }
1443    }
1444
1445    fn live(owner_uuid: &str, usage: Result<Snapshot, Stale>) -> LiveLogin {
1446        LiveLogin {
1447            signed_in: Some(Ok(owner(owner_uuid))),
1448            usage: Some(usage),
1449            ..LiveLogin::default()
1450        }
1451    }
1452
1453    /// Every row says which tool it is for, the unenrolled one included, and the same
1454    /// identity on two tools is two rows. Deduplicated by identity alone, a Codex login
1455    /// whose id happened to match an enrolled Claude Code account vanished from the list.
1456    #[test]
1457    fn the_same_identity_on_two_tools_is_two_rows() {
1458        let s = state(&["same"]);
1459        let f = Facts {
1460            live: [
1461                (
1462                    ProviderId::Claude,
1463                    live("same-uuid", Ok(reading(10.0, Source::Live, None))),
1464                ),
1465                (
1466                    ProviderId::Codex,
1467                    live("same-uuid", Ok(reading(20.0, Source::Live, None))),
1468                ),
1469            ]
1470            .into_iter()
1471            .collect(),
1472            asked: true,
1473            parked_usage: vec![Err(Stale::NothingParked)],
1474            claude_code_cache: None,
1475        };
1476        let rows = assemble(&s, &f, nothing_remembered, nothing_known, NOW);
1477        assert_eq!(rows.len(), 2, "one row per tool");
1478        assert_eq!(rows[0].provider, ProviderId::Claude);
1479        assert_eq!(rows[0].label.as_deref(), Some("same"));
1480        assert_eq!(rows[1].provider, ProviderId::Codex);
1481        assert_eq!(rows[1].label, None, "nothing has enrolled the Codex login");
1482        assert!(rows[1].signed_in);
1483        assert_eq!(rows[1].usage.as_ref().unwrap().windows[0].percent, 20.0);
1484    }
1485
1486    /// Grouped by tool in the order every listing uses, signed in first within each, and
1487    /// otherwise in the order they were enrolled.
1488    #[test]
1489    fn rows_are_grouped_by_tool_with_the_signed_in_account_first_in_each() {
1490        let mut s = state(&["alpha"]);
1491        s.accounts.push(codex_account("work", "work-acc"));
1492        s.accounts.push(account("beta"));
1493        s.accounts.push(codex_account("home", "home-acc"));
1494        let f = Facts {
1495            live: [
1496                (
1497                    ProviderId::Claude,
1498                    live("beta-uuid", Ok(reading(5.0, Source::Live, None))),
1499                ),
1500                (
1501                    ProviderId::Codex,
1502                    live("home-acc", Ok(reading(6.0, Source::Live, None))),
1503                ),
1504            ]
1505            .into_iter()
1506            .collect(),
1507            asked: true,
1508            parked_usage: vec![Err(Stale::NothingParked); 4],
1509            claude_code_cache: None,
1510        };
1511        let order: Vec<(ProviderId, String, bool)> =
1512            assemble(&s, &f, nothing_remembered, nothing_known, NOW)
1513                .into_iter()
1514                .map(|r| (r.provider, r.label.unwrap_or_default(), r.signed_in))
1515                .collect();
1516        assert_eq!(
1517            order,
1518            [
1519                (ProviderId::Claude, "beta".into(), true),
1520                (ProviderId::Claude, "alpha".into(), false),
1521                (ProviderId::Codex, "home".into(), true),
1522                (ProviderId::Codex, "work".into(), false),
1523            ]
1524        );
1525    }
1526
1527    /// Claude Code's cache is a Claude Code account's numbers and nobody else's, however
1528    /// the identities happen to line up.
1529    #[test]
1530    fn claude_codes_cache_is_never_shown_for_another_tools_account() {
1531        let mut s = State::default();
1532        s.accounts.push(codex_account("work", "work-acc"));
1533        let mut f = Facts {
1534            live: std::iter::once((ProviderId::Codex, live("work-acc", Err(Stale::Unreachable))))
1535                .collect(),
1536            asked: true,
1537            parked_usage: vec![Err(Stale::NothingParked)],
1538            claude_code_cache: None,
1539        };
1540        f.claude_code_cache = Some(reading(77.0, Source::ClaudeCodeCache, Some("work-acc")));
1541        let rows = assemble(&s, &f, nothing_remembered, nothing_known, NOW);
1542        assert!(rows[0].signed_in);
1543        assert!(rows[0].usage.is_none(), "{:?}", rows[0].usage);
1544    }
1545
1546    /// A thread that panicked stands in for its own tool and nobody else's. It used to be
1547    /// filed under Claude Code whichever tool it had been asking about, and since answers
1548    /// are collected in order, a Codex thread that panicked erased Claude Code's signed-in
1549    /// row.
1550    #[test]
1551    fn a_thread_that_stopped_stands_in_for_its_own_tool_only() {
1552        let home = scratch("panicked");
1553        let (ctx, _mem, _api) = machine(&home.0, None);
1554        let claude_answer: Answered = (
1555            live("alpha-uuid", Ok(reading(30.0, Source::Live, None))),
1556            Some(("alpha-uuid".into(), budget::Outcome::Answered)),
1557        );
1558        let panicked: Box<dyn std::any::Any + Send> = Box::new("boom");
1559        let (settled, learned) = settle(
1560            &ctx,
1561            vec![
1562                (ProviderId::Claude, Ok(claude_answer)),
1563                (ProviderId::Codex, Err(panicked)),
1564            ],
1565        );
1566        assert_eq!(
1567            learned,
1568            [("alpha-uuid".to_string(), budget::Outcome::Answered)]
1569        );
1570        let codex = &settled[&ProviderId::Codex];
1571        assert_eq!(codex.usage().err(), Some(Stale::Interrupted));
1572        assert!(
1573            matches!(codex.signed_in, Some(Err(_))),
1574            "unknown, not nobody"
1575        );
1576
1577        let mut s = state(&["alpha"]);
1578        s.accounts.push(codex_account("work", "work-acc"));
1579        let f = Facts {
1580            live: settled,
1581            asked: true,
1582            parked_usage: vec![Err(Stale::NothingParked), Err(Stale::NothingParked)],
1583            claude_code_cache: None,
1584        };
1585        let rows = assemble(&s, &f, nothing_remembered, nothing_known, NOW);
1586        let alpha = rows
1587            .iter()
1588            .find(|r| r.provider == ProviderId::Claude)
1589            .unwrap();
1590        assert!(alpha.signed_in, "Claude Code's row survives Codex's thread");
1591        assert_eq!(alpha.usage.as_ref().unwrap().windows[0].percent, 30.0);
1592    }
1593
1594    /// Claude Code's `/logout` deletes the login and leaves the document behind, still
1595    /// holding the machine's MCP tokens. That is nobody signed in. It used to be sent to
1596    /// Anthropic to identify, and the row read as Anthropic answering badly.
1597    #[test]
1598    fn a_document_holding_no_account_is_nothing_signed_in_and_nobody_is_asked() {
1599        let home = scratch("logged-out");
1600        let (ctx, _mem, api) = machine(&home.0, None);
1601        let logged_out = json!({"mcpOAuth": {"some-server": {"token": "unrelated"}}});
1602        let (login, learned) = ask_live(
1603            &ctx,
1604            ProviderId::Claude,
1605            &Ok(Some(logged_out)),
1606            None,
1607            &HashMap::new(),
1608            true,
1609        );
1610        assert!(login.signed_in.is_none(), "nobody, rather than unknown");
1611        assert_eq!(login.usage().err(), Some(Stale::NothingSignedIn));
1612        assert!(learned.is_none());
1613        assert_eq!(api.calls(), 0, "Anthropic was asked: {:?}", api.asked());
1614    }
1615
1616    /// A keychain that is locked for the moment is not a login that is gone. Read as one,
1617    /// the account in use showed as parked with nothing parked, and the advice was to sign
1618    /// in again.
1619    #[test]
1620    fn a_login_that_cannot_be_read_is_said_rather_than_read_as_nobody_signed_in() {
1621        let home = scratch("unreadable");
1622        let (ctx, mem, api) = machine(&home.0, Some("alpha-uuid"));
1623        let service = claude::live_service(&ctx);
1624        mem.live().plant(
1625            &service,
1626            &json!({"claudeAiOauth": {"accessToken": "t"}}).to_string(),
1627        );
1628        mem.live()
1629            .fault(&service, Fault::Unreadable("the keychain is locked".into()));
1630        let mut s = state(&["alpha", "beta"]);
1631        for a in &mut s.accounts {
1632            a.parked = None;
1633        }
1634
1635        let report = gather(&ctx, &s, true);
1636        let alpha = report
1637            .rows
1638            .iter()
1639            .find(|r| r.label.as_deref() == Some("alpha"))
1640            .unwrap();
1641        assert!(
1642            alpha.signed_in,
1643            "Claude Code's own record still names the account in use"
1644        );
1645        assert_eq!(alpha.stale, Some(Stale::LoginUnreadable));
1646        assert_eq!(
1647            alpha.explanation(),
1648            Some("Claude Code's login could not be read; run `pitboard doctor`")
1649        );
1650        let why = report.signed_in.expect_err("nobody could say whose it is");
1651        assert_ne!(why, "nothing is signed in");
1652        assert_eq!(api.calls(), 0, "a login nobody could read was sent nowhere");
1653    }
1654
1655    /// A tool whose login could not be read and whose own record names none of its
1656    /// accounts gets a row of its own, so its silence is not read as nobody signed in. Only
1657    /// for a tool somebody uses through pitboard: a machine that has never enrolled a Codex
1658    /// account sees nothing about Codex.
1659    #[test]
1660    fn an_unreadable_login_gets_a_row_only_for_a_tool_with_accounts() {
1661        let unreadable = || LiveLogin {
1662            signed_in: Some(Err("the keychain is locked".into())),
1663            usage: Some(Err(Stale::LoginUnreadable)),
1664            out_of_reach: true,
1665            ..LiveLogin::default()
1666        };
1667        let facts_with = |parked: usize| Facts {
1668            live: [
1669                (
1670                    ProviderId::Claude,
1671                    live("alpha-uuid", Ok(reading(5.0, Source::Live, None))),
1672                ),
1673                (ProviderId::Codex, unreadable()),
1674            ]
1675            .into_iter()
1676            .collect(),
1677            asked: true,
1678            parked_usage: vec![Err(Stale::NothingParked); parked],
1679            claude_code_cache: None,
1680        };
1681
1682        let claude_only = state(&["alpha"]);
1683        let rows = assemble(
1684            &claude_only,
1685            &facts_with(1),
1686            nothing_remembered,
1687            nothing_known,
1688            NOW,
1689        );
1690        assert_eq!(
1691            rows.len(),
1692            1,
1693            "nothing new for somebody who never used Codex"
1694        );
1695
1696        let mut both = state(&["alpha"]);
1697        both.accounts.push(codex_account("work", "work-acc"));
1698        let rows = assemble(
1699            &both,
1700            &facts_with(2),
1701            nothing_remembered,
1702            nothing_known,
1703            NOW,
1704        );
1705        let said = rows
1706            .iter()
1707            .find(|r| r.provider == ProviderId::Codex && r.label.is_none())
1708            .expect("a row saying Codex's login could not be read");
1709        assert_eq!(said.stale, Some(Stale::LoginUnreadable));
1710        assert!(!said.signed_in && !said.switchable(NOW));
1711        assert_eq!(
1712            said.explanation(),
1713            Some("Codex's login could not be read; run `pitboard doctor`")
1714        );
1715        let work = rows
1716            .iter()
1717            .find(|r| r.label.as_deref() == Some("work"))
1718            .unwrap();
1719        assert!(!work.signed_in);
1720    }
1721
1722    /// When whose the login is cannot be learned, the tool's own record keys the budget.
1723    /// Keyed on nothing, a morning when Anthropic's profile endpoint is failing was a
1724    /// morning when every `status` asked about usage with no floor at all. Before there was
1725    /// a second tool the account always came from Claude Code's config here.
1726    #[test]
1727    fn a_failing_identify_still_keeps_the_ask_again_floor() {
1728        let home = scratch("floor");
1729        let (ctx, _mem, api) = machine(&home.0, Some("acc-x"));
1730        api.token_trouble("access-x", Trouble::Offline);
1731        budget::record(&ctx, &[("acc-x".into(), budget::Outcome::Answered)]);
1732        let login = json!({"claudeAiOauth": {"accessToken": "access-x"}});
1733
1734        let (live, learned) = ask_live(
1735            &ctx,
1736            ProviderId::Claude,
1737            &Ok(Some(login.clone())),
1738            None,
1739            &HashMap::new(),
1740            false,
1741        );
1742        assert_eq!(live.usage().err(), Some(Stale::AskedRecently));
1743        assert_eq!(live.recorded_uuid.as_deref(), Some("acc-x"));
1744        assert!(learned.is_none());
1745        assert_eq!(
1746            api.asked(),
1747            [Question::Owner("access-x".into())],
1748            "whose it is is always asked; what it has left is not, inside the floor"
1749        );
1750
1751        // Asked for anyway, what is learned is kept under the same account.
1752        let (live, learned) = ask_live(
1753            &ctx,
1754            ProviderId::Claude,
1755            &Ok(Some(login)),
1756            None,
1757            &HashMap::new(),
1758            true,
1759        );
1760        assert_eq!(live.usage().err(), Some(Stale::Unreachable));
1761        assert_eq!(
1762            learned,
1763            Some(("acc-x".to_string(), budget::Outcome::Unreachable))
1764        );
1765    }
1766
1767    /// Offline, each tool is asked for its own record. It used to be Claude Code's config
1768    /// alone, so on a plane a signed-in Codex account read as parked.
1769    #[test]
1770    fn offline_every_tool_names_its_own_signed_in_account() {
1771        let home = scratch("offline");
1772        let (ctx, _mem, api) = machine(&home.0, Some("alpha-uuid"));
1773        let codex = crate::provider::of(ProviderId::Codex)
1774            .live(&ctx)
1775            .expect("Codex keeps its login in a file here");
1776        let login = json!({
1777            "auth_mode": "chatgpt",
1778            "tokens": {
1779                "id_token": crate::provider::jwt::unsigned(&json!({
1780                    "email": "work@example.com",
1781                    "https://api.openai.com/auth": {"chatgpt_account_id": "work-acc"},
1782                })),
1783                "access_token": "codex-access",
1784                "refresh_token": "codex-refresh",
1785                "account_id": "work-acc",
1786            },
1787            "last_refresh": "2026-09-15T05:05:11Z",
1788        });
1789        crate::store::write_raw(&codex.chain, &codex.service, &login.to_string())
1790            .expect("a Codex login");
1791
1792        let mut s = state(&["alpha", "beta"]);
1793        s.accounts.push(codex_account("work", "work-acc"));
1794        s.accounts.push(codex_account("home", "home-acc"));
1795        let report = gather_offline(&ctx, &s);
1796        let signed_in: Vec<(ProviderId, &str)> = report
1797            .rows
1798            .iter()
1799            .filter(|r| r.signed_in)
1800            .map(|r| (r.provider, r.label.as_deref().unwrap_or_default()))
1801            .collect();
1802        assert_eq!(
1803            signed_in,
1804            [(ProviderId::Claude, "alpha"), (ProviderId::Codex, "work")]
1805        );
1806        assert_eq!(
1807            report.signed_in.expect("Claude Code's config").account_uuid,
1808            "alpha-uuid"
1809        );
1810        assert_eq!(api.calls(), 0, "offline asks nobody");
1811    }
1812
1813    /// Put `raw` where this machine's Codex keeps its login, exactly as written.
1814    fn plant_codex(ctx: &Context, raw: &str) {
1815        let codex = crate::provider::of(ProviderId::Codex)
1816            .live(ctx)
1817            .expect("Codex keeps its login in a file here");
1818        crate::store::write_raw(&codex.chain, &codex.service, raw).expect("a Codex login");
1819    }
1820
1821    /// A Codex login whose ID token names `tokens_of` and whose account id names
1822    /// `account_id`. The two are the same in every login Codex writes on its own.
1823    fn codex_login(tokens_of: &str, account_id: &str) -> String {
1824        json!({
1825            "auth_mode": "chatgpt",
1826            "tokens": {
1827                "id_token": crate::provider::jwt::unsigned(&json!({
1828                    "email": format!("{tokens_of}@example.com"),
1829                    "https://api.openai.com/auth": {"chatgpt_account_id": tokens_of},
1830                })),
1831                "access_token": format!("access-{tokens_of}"),
1832                "refresh_token": format!("refresh-{tokens_of}"),
1833                "account_id": account_id,
1834            },
1835            "last_refresh": "2026-09-15T05:05:11Z",
1836        })
1837        .to_string()
1838    }
1839
1840    /// Two Codex accounts, `a` parked and `b` the one pitboard last switched to, which has
1841    /// no park because a Codex park is moved rather than copied.
1842    fn codex_a_parked_b_active() -> State {
1843        let mut b = codex_account("b", "acc-B");
1844        b.parked = None;
1845        let mut s = State {
1846            accounts: vec![codex_account("a", "acc-A"), b],
1847            ..State::default()
1848        };
1849        s.set_active(ProviderId::Codex, Some("b".into()));
1850        s
1851    }
1852
1853    fn codex_row<'a>(report: &'a Report, label: &str) -> &'a Row {
1854        report
1855            .rows
1856            .iter()
1857            .find(|r| r.provider == ProviderId::Codex && r.label.as_deref() == Some(label))
1858            .unwrap_or_else(|| panic!("a row for codex/{label}"))
1859    }
1860
1861    /// What a codex still running from before a switch leaves when it refreshes in the
1862    /// middle of one: its own account's tokens under the other account's id. That is not
1863    /// nobody signed in, which is what status used to say while `status --offline` named
1864    /// the account the tokens belong to and `doctor` failed the login. Online, offline and
1865    /// doctor now agree on whose it is, and online says pitboard cannot use it.
1866    #[test]
1867    fn a_codex_login_that_mixes_two_accounts_is_said_rather_than_read_as_nobody() {
1868        let home = scratch("mixed");
1869        let (ctx, _mem, api) = machine(&home.0, None);
1870        plant_codex(&ctx, &codex_login("acc-A", "acc-B"));
1871        let s = codex_a_parked_b_active();
1872
1873        let report = gather(&ctx, &s, true);
1874        let a = codex_row(&report, "a");
1875        assert!(a.signed_in, "the tokens are a's, as its own record says");
1876        assert_eq!(a.stale, Some(Stale::LoginUnusable));
1877        assert_eq!(
1878            a.explanation(),
1879            Some("Codex's login is not one pitboard can park or switch; run `pitboard doctor`")
1880        );
1881        assert!(!codex_row(&report, "b").signed_in);
1882        assert!(
1883            report
1884                .rows
1885                .iter()
1886                .all(|r| r.stale != Some(Stale::NothingSignedIn)),
1887            "something is signed in"
1888        );
1889        assert!(
1890            report.rows.iter().all(|r| !r.unplaced()),
1891            "the login is pinned on a, so it needs no row of its own"
1892        );
1893        assert_eq!(
1894            api.calls(),
1895            0,
1896            "nobody was asked about a login mixing two accounts"
1897        );
1898
1899        let offline = gather_offline(&ctx, &s);
1900        assert!(codex_row(&offline, "a").signed_in, "offline says the same");
1901    }
1902
1903    /// Signed in with an API key: something is signed in, and it is no account. Not nobody,
1904    /// and not the account pitboard last switched to either, whose login the key replaced.
1905    #[test]
1906    fn a_codex_login_with_an_api_key_is_said_and_pinned_on_no_account() {
1907        let home = scratch("api-key");
1908        let (ctx, _mem, api) = machine(&home.0, None);
1909        plant_codex(
1910            &ctx,
1911            &json!({"auth_mode": "apikey", "OPENAI_API_KEY": "sk-not-a-real-key"}).to_string(),
1912        );
1913        let s = codex_a_parked_b_active();
1914
1915        let report = gather(&ctx, &s, true);
1916        assert!(
1917            report.rows.iter().all(|r| !r.signed_in),
1918            "no account is signed in"
1919        );
1920        let said = report
1921            .rows
1922            .iter()
1923            .find(|r| r.unplaced())
1924            .expect("a row saying what is signed in to Codex");
1925        assert_eq!(said.provider, ProviderId::Codex);
1926        assert_eq!(said.stale, Some(Stale::LoginUnusable));
1927        assert!(said.email.is_empty() && said.account_uuid.is_empty());
1928        assert_eq!(api.calls(), 0);
1929    }
1930
1931    /// Somebody who uses pitboard for Claude Code and also has Codex signed in has not
1932    /// asked for their Codex login to be read or sent anywhere. Until a Codex account is
1933    /// enrolled, status says nothing about Codex and asks OpenAI nothing.
1934    #[test]
1935    fn a_tool_with_nothing_enrolled_is_neither_read_nor_asked() {
1936        let home = scratch("not-opted-in");
1937        let (ctx, _mem, api) = machine(&home.0, None);
1938        plant_codex(&ctx, &codex_login("acc-B", "acc-B"));
1939        let s = State::default();
1940
1941        let report = gather(&ctx, &s, true);
1942        assert!(
1943            report.rows.iter().all(|r| r.provider != ProviderId::Codex),
1944            "no Codex row for a machine that enrolled no Codex account"
1945        );
1946        assert!(
1947            api.asked()
1948                .iter()
1949                .all(|asked| !matches!(asked, crate::api::scripted::Asked::Usage(t) if t.contains("acc-B"))),
1950            "and nothing was asked about it"
1951        );
1952        let offline = gather_offline(&ctx, &s);
1953        assert!(offline.rows.iter().all(|r| r.provider != ProviderId::Codex));
1954    }
1955
1956    /// Codex writes its login with a plain truncating write, so a read can catch it half
1957    /// written. Codex keeps no record apart from the login, so its own record names nobody
1958    /// then, and pitboard's record of its last switch stands in for it the way `doctor`
1959    /// already lets it. Without that, the account in use was told to sign in again.
1960    #[test]
1961    fn a_codex_login_caught_half_written_still_names_the_account_in_use() {
1962        let home = scratch("half-written");
1963        let (ctx, _mem, api) = machine(&home.0, None);
1964        let whole = codex_login("acc-B", "acc-B");
1965        plant_codex(&ctx, &whole[..whole.len() / 2]);
1966        let s = codex_a_parked_b_active();
1967        crate::state::save(&ctx, &s).expect("an account list");
1968
1969        let report = gather(&ctx, &s, true);
1970        let b = codex_row(&report, "b");
1971        assert!(b.signed_in, "the account pitboard last switched to");
1972        assert_eq!(b.stale, Some(Stale::LoginUnreadable));
1973        assert!(!codex_row(&report, "a").signed_in);
1974        assert!(
1975            report.rows.iter().all(|r| !r.unplaced()),
1976            "pinned, so no row of its own"
1977        );
1978        assert_eq!(api.calls(), 0);
1979
1980        let doctor = crate::doctor::gather(&ctx);
1981        let active: Vec<&str> = doctor
1982            .parks
1983            .iter()
1984            .filter(|p| p.active)
1985            .map(|p| p.label.as_str())
1986            .collect();
1987        assert_eq!(active, ["b"], "doctor reads the same machine the same way");
1988    }
1989
1990    /// Only nothing at all is nobody. A Claude Code document with no account in it is what
1991    /// `/logout` leaves; one that is not a document of Claude Code's shape at all is a login
1992    /// pitboard cannot use, and is said as one.
1993    #[test]
1994    fn only_a_document_holding_no_login_is_nobody_signed_in() {
1995        let home = scratch("shapes");
1996        let (ctx, _mem, api) = machine(&home.0, Some("alpha-uuid"));
1997        let ask = |document: Value| {
1998            ask_live(
1999                &ctx,
2000                ProviderId::Claude,
2001                &Ok(Some(document)),
2002                Some("beta-uuid"),
2003                &HashMap::new(),
2004                true,
2005            )
2006            .0
2007        };
2008
2009        let nobody = ask(json!({"mcpOAuth": {}}));
2010        assert!(nobody.signed_in.is_none() && !nobody.out_of_reach);
2011        assert_eq!(nobody.usage().err(), Some(Stale::NothingSignedIn));
2012
2013        let unusable = ask(json!(["a login", "that is not an object"]));
2014        assert!(matches!(unusable.signed_in, Some(Err(_))));
2015        assert!(unusable.out_of_reach);
2016        assert_eq!(unusable.usage().err(), Some(Stale::LoginUnusable));
2017        assert_eq!(
2018            unusable.recorded_uuid.as_deref(),
2019            Some("alpha-uuid"),
2020            "Claude Code's config, never pitboard's record of its last switch"
2021        );
2022        assert_eq!(api.calls(), 0);
2023    }
2024
2025    /// A front end is told which rows are a login pitboard could not pin on any account,
2026    /// so it can say so instead of showing an account nobody has enrolled.
2027    #[test]
2028    fn a_row_is_unplaced_only_when_it_is_a_login_on_no_account() {
2029        let mut row = Row {
2030            provider: ProviderId::Codex,
2031            label: None,
2032            email: String::new(),
2033            account_uuid: String::new(),
2034            signed_in: false,
2035            parked: None,
2036            usage: None,
2037            stale: Some(Stale::LoginUnreadable),
2038            runway: crate::history::Runway::Unknown,
2039        };
2040        assert!(row.unplaced());
2041        row.stale = Some(Stale::LoginUnusable);
2042        assert!(row.unplaced());
2043        row.signed_in = true;
2044        row.stale = Some(Stale::SessionExpired);
2045        assert!(!row.unplaced(), "an unenrolled login that is signed in");
2046        row.signed_in = false;
2047        row.label = Some("work".into());
2048        row.stale = Some(Stale::LoginUnreadable);
2049        assert!(!row.unplaced(), "an account");
2050    }
2051
2052    /// A home of this test's own, removed when the test is done with it.
2053    struct Scratch(std::path::PathBuf);
2054
2055    impl Drop for Scratch {
2056        fn drop(&mut self) {
2057            let _ = std::fs::remove_dir_all(&self.0);
2058        }
2059    }
2060
2061    fn scratch(name: &str) -> Scratch {
2062        let root = std::env::temp_dir().join(format!(
2063            "pitboard-status-{name}-{}-{:?}",
2064            std::process::id(),
2065            std::thread::current().id()
2066        ));
2067        let _ = std::fs::remove_dir_all(&root);
2068        std::fs::create_dir_all(&root).expect("a scratch home");
2069        Scratch(root)
2070    }
2071
2072    /// A machine of this test's own: stores in memory, services from a script, a clock
2073    /// that stands still, every tool's home inside `root`, and Claude Code's config naming
2074    /// `recorded` as signed in where it names anybody.
2075    fn machine(
2076        root: &std::path::Path,
2077        recorded: Option<&str>,
2078    ) -> (Context, Arc<MemoryHost>, Arc<ScriptedApi>) {
2079        let mem = MemoryHost::new();
2080        let api = ScriptedApi::new();
2081        let ctx = Context::new(root.to_path_buf())
2082            .with_pitboard_home(root.join(".pitboard"))
2083            .with_codex_home(root.join("codex").to_string_lossy().into())
2084            // Named, so nothing here looks up this machine's own `codex` on `PATH`.
2085            .with_codex_program(root.join("bin").join("codex"))
2086            .with_memory_stores(Arc::clone(&mem))
2087            .with_scripted_api(Arc::clone(&api))
2088            .with_clock(Arc::new(FixedClock::at(NOW)) as Arc<dyn Clock>);
2089        crate::home::ensure(&ctx).expect("a pitboard home");
2090        if let Some(uuid) = recorded {
2091            std::fs::write(
2092                root.join(".claude.json"),
2093                json!({"oauthAccount": {
2094                    "accountUuid": uuid,
2095                    "emailAddress": format!("{uuid}@example.com"),
2096                    "organizationUuid": "org",
2097                }})
2098                .to_string(),
2099            )
2100            .expect("a Claude Code config");
2101        }
2102        (ctx, mem, api)
2103    }
2104}