Skip to main content

mj_controller/
hel_quota.rs

1//! One-pane quota collection for Mjolnir harness profiles.
2
3use std::collections::{BTreeMap, BTreeSet, HashMap};
4use std::path::Path;
5use std::sync::{Arc, Mutex};
6use std::time::{Duration, SystemTime};
7
8use anyhow::{Context, Result, bail};
9use chrono::{DateTime, Datelike, Days, FixedOffset, Local, NaiveDate, NaiveTime, TimeZone};
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13use crate::claude_usage;
14use crate::codex_usage::{self, CodexUsageClient, CodexUsageStatus};
15use crate::grok_usage;
16use hel::hel_config::HarnessKind;
17use hel::hel_config::harness_authentication_marker;
18use hel::hel_credentials::{
19    MAX_CREDENTIAL_BYTES, credential_expiry, credential_fingerprint, credential_freshness,
20};
21
22pub use mj_client::quota::{API_LABEL, ProfileQuota, QuotaWindow, projects_exhaustion};
23
24#[derive(Debug, Clone)]
25pub struct QuotaRefreshRequest {
26    pub profile_id: String,
27    pub harness: HarnessKind,
28    pub source_home: std::path::PathBuf,
29    pub environment: BTreeMap<String, String>,
30    pub cwd: std::path::PathBuf,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct QuotaRefreshOutcome {
35    pub report: ProfileQuota,
36    pub credentials_changed: bool,
37}
38
39#[derive(Default)]
40pub struct QuotaManager {
41    codex_clients: HashMap<String, CodexUsageClient>,
42    reports: BTreeMap<String, ProfileQuota>,
43}
44
45impl QuotaManager {
46    pub fn reports(&self) -> &BTreeMap<String, ProfileQuota> {
47        &self.reports
48    }
49
50    /// Refresh each profile independently so one slow harness cannot delay the
51    /// others. `on_report` runs per profile in completion order, so fast
52    /// harnesses report without waiting for the slowest one in the batch.
53    pub async fn refresh_profiles<F, Fut>(
54        &mut self,
55        requests: Vec<QuotaRefreshRequest>,
56        mut on_report: F,
57    ) where
58        F: FnMut(QuotaRefreshOutcome) -> Fut,
59        Fut: Future<Output = ()> + Send,
60    {
61        let batch = requests
62            .iter()
63            .map(|request| request.profile_id.clone())
64            .collect::<BTreeSet<_>>();
65        let mut tasks = tokio::task::JoinSet::new();
66        for request in requests {
67            let client = self.codex_clients.remove(&request.profile_id);
68            tasks.spawn(refresh_profile(request, client));
69        }
70
71        while let Some(result) = tasks.join_next().await {
72            let (outcome, client) = match result {
73                Ok(output) => output,
74                Err(error) => {
75                    tracing::warn!(%error, "quota refresh task failed");
76                    continue;
77                }
78            };
79            if let Some(client) = client {
80                self.codex_clients
81                    .insert(outcome.report.profile_id.clone(), client);
82            }
83            self.reports
84                .insert(outcome.report.profile_id.clone(), outcome.report.clone());
85            on_report(outcome).await;
86        }
87        self.stop_clients_outside_batch(&batch).await;
88    }
89
90    /// Stop the cached clients whose profiles are not in `keep`. Every batch
91    /// carries the whole configured set, so a client left over from an earlier
92    /// batch belongs to a profile the configuration no longer has. Each one
93    /// owns a live `codex app-server` child that nothing would ever hand back
94    /// to a refresh again, so it would run until the controller exits.
95    async fn stop_clients_outside_batch(&mut self, keep: &BTreeSet<String>) {
96        let stranded = self
97            .codex_clients
98            .keys()
99            .filter(|profile_id| !keep.contains(*profile_id))
100            .cloned()
101            .collect::<Vec<_>>();
102        for profile_id in stranded {
103            if let Some(client) = self.codex_clients.remove(&profile_id) {
104                tracing::info!(profile_id, "stopping the quota client of a removed profile");
105                client.shutdown().await;
106            }
107        }
108    }
109
110    pub async fn shutdown(mut self) {
111        for (_, client) in self.codex_clients.drain() {
112            client.shutdown().await;
113        }
114    }
115}
116
117async fn refresh_profile(
118    request: QuotaRefreshRequest,
119    mut codex_client: Option<CodexUsageClient>,
120) -> (QuotaRefreshOutcome, Option<CodexUsageClient>) {
121    let credential_path = harness_authentication_marker(request.harness, &request.source_home);
122    let credential_before = credential_marker_fingerprint(&credential_path).await;
123    let QuotaRefreshRequest {
124        profile_id,
125        harness,
126        source_home,
127        environment,
128        cwd,
129    } = request;
130    let environment = environment.into_iter().collect::<HashMap<_, _>>();
131    let refreshed_at_epoch_seconds = SystemTime::now()
132        .duration_since(SystemTime::UNIX_EPOCH)
133        .unwrap_or_default()
134        .as_secs();
135    let result = match harness {
136        HarnessKind::Codex => {
137            if codex_login_is_near_expiry(&credential_path).await {
138                match codex_usage::refresh_login(
139                    &mut codex_client,
140                    cwd.clone(),
141                    environment.clone(),
142                )
143                .await
144                {
145                    Ok(()) => tracing::info!(
146                        profile_id = %profile_id,
147                        "refreshed Codex login ahead of expiry"
148                    ),
149                    Err(error) => tracing::warn!(
150                        profile_id = %profile_id,
151                        %error,
152                        "could not refresh the Codex login ahead of expiry"
153                    ),
154                }
155            }
156            let status = codex_usage::refresh(&mut codex_client, cwd, environment).await;
157            match status {
158                CodexUsageStatus::Available(report) => Ok(ProfileQuota {
159                    profile_id: profile_id.clone(),
160                    harness,
161                    windows: [report.primary, report.secondary]
162                        .into_iter()
163                        .flatten()
164                        .map(|window| QuotaWindow {
165                            label: window.label,
166                            remaining_percent: Some(window.remaining_percent),
167                            used: None,
168                            limit: None,
169                            resets: window.resets_at.and_then(format_reset_local_seconds),
170                            resets_at_epoch_seconds: window.resets_at,
171                        })
172                        .collect(),
173                    extra: None,
174                    error: None,
175                    refreshed_at_epoch_seconds,
176                }),
177                CodexUsageStatus::Unavailable(error) => Err(anyhow::anyhow!(error)),
178            }
179        }
180        HarnessKind::Claude => claude_usage::query(source_home, environment)
181            .await
182            .map(|report| ProfileQuota {
183                profile_id: profile_id.clone(),
184                harness,
185                windows: [
186                    report.five_hour.map(|window| ("5H", window)),
187                    report.week.map(|window| ("Week", window)),
188                ]
189                .into_iter()
190                .flatten()
191                .map(|(label, window)| QuotaWindow {
192                    label: label.to_string(),
193                    remaining_percent: Some(window.remaining_percent),
194                    used: None,
195                    limit: None,
196                    resets: window
197                        .reset_context
198                        .as_deref()
199                        .and_then(normalize_reset_text),
200                    resets_at_epoch_seconds: window
201                        .reset_context
202                        .as_deref()
203                        .and_then(normalize_reset_epoch_seconds),
204                })
205                .collect(),
206                extra: None,
207                error: None,
208                refreshed_at_epoch_seconds,
209            })
210            .map_err(|error| anyhow::anyhow!(error.to_string())),
211        HarnessKind::Kimi => {
212            query_kimi(&source_home, &environment)
213                .await
214                .map(|(windows, extra)| ProfileQuota {
215                    profile_id: profile_id.clone(),
216                    harness,
217                    windows,
218                    extra,
219                    error: None,
220                    refreshed_at_epoch_seconds,
221                })
222        }
223        // Grok Build publishes no HTTP quota endpoint. Its own usage view polls
224        // an ACP billing extension, and so does Mjolnir.
225        HarnessKind::Grok => {
226            grok_usage::query(source_home.clone(), cwd, environment)
227                .await
228                .map(|report| ProfileQuota {
229                    profile_id: profile_id.clone(),
230                    harness,
231                    windows: vec![QuotaWindow {
232                        label: report.period_label.clone(),
233                        remaining_percent: Some(report.remaining_percent()),
234                        // Grok Build reports a share of the allowance, not the
235                        // credit amounts behind it.
236                        used: None,
237                        limit: None,
238                        resets: report.resets_at.and_then(format_reset_local_seconds),
239                        resets_at_epoch_seconds: report.resets_at,
240                    }],
241                    extra: None,
242                    error: None,
243                    refreshed_at_epoch_seconds,
244                })
245                .map_err(|error| anyhow::anyhow!(error.to_string()))
246        }
247        HarnessKind::Deepseek => Ok(ProfileQuota {
248            profile_id: profile_id.clone(),
249            harness,
250            windows: Vec::new(),
251            extra: Some(API_LABEL.to_owned()),
252            error: None,
253            refreshed_at_epoch_seconds,
254        }),
255        HarnessKind::Muse => crate::muse_usage::query(&source_home, &environment)
256            .await
257            .map(|report| ProfileQuota {
258                profile_id: profile_id.clone(),
259                harness,
260                windows: report
261                    .windows
262                    .into_iter()
263                    .map(|window| QuotaWindow {
264                        label: window.label,
265                        remaining_percent: Some(window.remaining_percent),
266                        used: None,
267                        limit: None,
268                        resets: window.resets_at.and_then(format_reset_local_seconds),
269                        resets_at_epoch_seconds: window.resets_at,
270                    })
271                    .collect(),
272                extra: report.note,
273                error: None,
274                refreshed_at_epoch_seconds,
275            }),
276    };
277    let report = result.unwrap_or_else(|error| ProfileQuota {
278        profile_id,
279        harness,
280        windows: Vec::new(),
281        extra: None,
282        error: Some(error.to_string()),
283        refreshed_at_epoch_seconds,
284    });
285    let credential_after = credential_marker_fingerprint(&credential_path).await;
286    let credentials_changed = match (credential_before, credential_after) {
287        (Ok(before), Ok(after)) => before != after,
288        (Err(error), _) | (_, Err(error)) => {
289            tracing::warn!(path = %credential_path.display(), %error, "could not fingerprint quota credentials");
290            false
291        }
292    };
293    (
294        QuotaRefreshOutcome {
295            report,
296            credentials_changed,
297        },
298        codex_client,
299    )
300}
301
302/// Shortest gap to expiry Hel will leave a Codex login sitting at. A token with
303/// a long life gets a proportionally wider margin, because the poll interval
304/// buys nothing once the whole life is short.
305const CODEX_MINIMUM_REFRESH_MARGIN_MS: i64 = 60 * 60 * 1000;
306
307/// Whether the profile's Codex login is close enough to expiry that a container
308/// copy of it could reach the single-use refresh race before the next poll.
309async fn codex_login_is_near_expiry(marker: &Path) -> bool {
310    let Ok(bytes) = tokio::fs::read(marker).await else {
311        return false;
312    };
313    if bytes.len() > MAX_CREDENTIAL_BYTES {
314        return false;
315    }
316    let now = SystemTime::now()
317        .duration_since(SystemTime::UNIX_EPOCH)
318        .unwrap_or_default()
319        .as_millis() as i64;
320    codex_login_needs_refresh(
321        credential_expiry(HarnessKind::Codex, &bytes),
322        credential_freshness(HarnessKind::Codex, &bytes),
323        now,
324    )
325}
326
327/// The margin is the larger of one hour and a tenth of the token's life, where
328/// the life is what the last refresh bought. A credential that says nothing
329/// about its own age falls back to the flat hour.
330fn codex_login_needs_refresh(
331    expiry_millis: Option<i64>,
332    last_refresh_millis: Option<i64>,
333    now_millis: i64,
334) -> bool {
335    let Some(expiry) = expiry_millis else {
336        return false;
337    };
338    let lifetime = last_refresh_millis
339        .map(|refreshed| expiry.saturating_sub(refreshed))
340        .unwrap_or_default();
341    let margin = CODEX_MINIMUM_REFRESH_MARGIN_MS.max(lifetime / 10);
342    expiry.saturating_sub(now_millis) < margin
343}
344
345async fn credential_marker_fingerprint(path: &Path) -> Result<Option<String>> {
346    let metadata = match tokio::fs::metadata(path).await {
347        Ok(metadata) => metadata,
348        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
349        Err(error) => return Err(error).context("inspect credential marker"),
350    };
351    if metadata.len() > MAX_CREDENTIAL_BYTES as u64 {
352        bail!("credential marker exceeds {MAX_CREDENTIAL_BYTES} bytes");
353    }
354    let bytes = tokio::fs::read(path)
355        .await
356        .context("read credential marker")?;
357    if bytes.len() > MAX_CREDENTIAL_BYTES {
358        bail!("credential marker exceeds {MAX_CREDENTIAL_BYTES} bytes");
359    }
360    Ok(Some(credential_fingerprint(&bytes)))
361}
362
363async fn query_kimi(
364    home: &Path,
365    environment: &HashMap<String, String>,
366) -> Result<(Vec<QuotaWindow>, Option<String>)> {
367    let base = environment
368        .get("KIMI_CODE_BASE_URL")
369        .map(String::as_str)
370        .unwrap_or("https://api.kimi.com/coding/v1")
371        .trim_end_matches('/');
372    let client = reqwest::Client::builder()
373        .timeout(Duration::from_secs(10))
374        .build()
375        .context("build Kimi quota client")?;
376    let credentials_path = home.join("credentials/kimi-code.json");
377    let usage_url = format!("{base}/usages");
378    let response = fetch_bearer_with_auth_retry(&client, &usage_url, |force, rejected_token| {
379        ensure_fresh_kimi_token(
380            &client,
381            home,
382            &credentials_path,
383            environment,
384            force,
385            rejected_token,
386        )
387    })
388    .await?;
389    if !response.status().is_success() {
390        bail!("Kimi Code quota returned HTTP {}", response.status());
391    }
392    let payload: Value = response.json().await.context("decode Kimi Code quota")?;
393    Ok(parse_kimi_usage(&payload))
394}
395
396const KIMI_OAUTH_CLIENT_ID: &str = "17e5f671-d194-4dfb-9706-5516cb48c098";
397
398#[derive(Clone, Debug, Deserialize, Serialize)]
399struct KimiCredentials {
400    #[serde(alias = "accessToken")]
401    access_token: String,
402    #[serde(default, alias = "refreshToken")]
403    refresh_token: String,
404    #[serde(default, alias = "expiresAt")]
405    expires_at: i64,
406    #[serde(default)]
407    scope: String,
408    #[serde(default, alias = "tokenType")]
409    token_type: String,
410    #[serde(default, alias = "expiresIn")]
411    expires_in: i64,
412}
413
414impl KimiCredentials {
415    /// Whether this is a different pair from `other`. A refresh rotates the
416    /// access token, the refresh token and the expiry together, so those three
417    /// fields are what tells two pairs apart; the rest only describes them.
418    fn differs_from(&self, other: &Self) -> bool {
419        self.access_token != other.access_token
420            || self.refresh_token != other.refresh_token
421            || self.expires_at != other.expires_at
422    }
423
424    fn needs_refresh(&self) -> bool {
425        if self.expires_at == 0 {
426            return false;
427        }
428        let now = SystemTime::now()
429            .duration_since(SystemTime::UNIX_EPOCH)
430            .unwrap_or_default()
431            .as_secs() as i64;
432        let threshold = 300.max(self.expires_in / 2);
433        self.expires_at - now < threshold
434    }
435}
436
437async fn read_kimi_credentials(path: &Path) -> Result<KimiCredentials> {
438    let bytes = tokio::fs::read(path)
439        .await
440        .context("Kimi Code credentials are unavailable")?;
441    let credentials: KimiCredentials =
442        serde_json::from_slice(&bytes).context("Kimi Code credentials are invalid")?;
443    if credentials.access_token.is_empty() {
444        bail!("Kimi Code access token is missing");
445    }
446    Ok(credentials)
447}
448
449async fn fetch_bearer_with_auth_retry<F, Fut>(
450    client: &reqwest::Client,
451    url: &str,
452    mut authenticate: F,
453) -> Result<reqwest::Response>
454where
455    F: FnMut(bool, Option<String>) -> Fut,
456    Fut: std::future::Future<Output = Result<String>>,
457{
458    let token = authenticate(false, None).await?;
459    let response = client
460        .get(url)
461        .bearer_auth(&token)
462        .header(reqwest::header::ACCEPT, "application/json")
463        .send()
464        .await
465        .context("query quota")?;
466    if response.status() != reqwest::StatusCode::UNAUTHORIZED {
467        return Ok(response);
468    }
469
470    let refreshed = authenticate(true, Some(token)).await?;
471    client
472        .get(url)
473        .bearer_auth(refreshed)
474        .header(reqwest::header::ACCEPT, "application/json")
475        .send()
476        .await
477        .context("retry quota after authentication refresh")
478}
479
480/// Hand back a usable Kimi Code access token, refreshing the stored pair when
481/// it is stale or when the server rejected it.
482///
483/// The refresh lock is taken before the round trip, but a peer may break a lock
484/// it judges stale while that round trip is in flight, so ownership is checked
485/// again immediately before the credentials are written rather than only when
486/// the lock is released: see `decide_kimi_refresh_persist` for what a refresh
487/// that lost its lock does with the pair it fetched.
488async fn ensure_fresh_kimi_token(
489    client: &reqwest::Client,
490    home: &Path,
491    credentials_path: &Path,
492    environment: &HashMap<String, String>,
493    force: bool,
494    rejected_token: Option<String>,
495) -> Result<String> {
496    let initial = read_kimi_credentials(credentials_path).await?;
497    if !force && !initial.needs_refresh() {
498        return Ok(initial.access_token);
499    }
500
501    let refresh_lock = KimiRefreshLock::acquire(home).await?;
502    let active = read_kimi_credentials(credentials_path).await?;
503    let changed_while_waiting = active.differs_from(&initial);
504    if (!force && !active.needs_refresh())
505        || (force
506            && (changed_while_waiting
507                || rejected_token.is_some_and(|token| token != active.access_token)))
508    {
509        refresh_lock.release().await?;
510        return Ok(active.access_token);
511    }
512    if active.refresh_token.is_empty() {
513        refresh_lock.release().await?;
514        bail!("Kimi Code refresh token is missing; run `kimi login`");
515    }
516
517    let oauth_host = environment
518        .get("KIMI_CODE_OAUTH_HOST")
519        .or_else(|| environment.get("KIMI_OAUTH_HOST"))
520        .map(String::as_str)
521        .unwrap_or("https://auth.kimi.com")
522        .trim_end_matches('/');
523    let response = client
524        .post(format!("{oauth_host}/api/oauth/token"))
525        .header(reqwest::header::ACCEPT, "application/json")
526        .form(&[
527            ("client_id", KIMI_OAUTH_CLIENT_ID),
528            ("grant_type", "refresh_token"),
529            ("refresh_token", active.refresh_token.as_str()),
530        ])
531        .send()
532        .await
533        .context("refresh Kimi Code access token")?;
534    if !response.status().is_success() {
535        let status = response.status();
536        if matches!(
537            status,
538            reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN
539        ) {
540            tokio::time::sleep(Duration::from_millis(100)).await;
541            let recovery = read_kimi_credentials(credentials_path).await?;
542            if recovery.refresh_token != active.refresh_token && !recovery.access_token.is_empty() {
543                refresh_lock.release().await?;
544                return Ok(recovery.access_token);
545            }
546        }
547        refresh_lock.release().await?;
548        bail!("Kimi Code token refresh returned HTTP {status}");
549    }
550
551    let payload: Value = response
552        .json()
553        .await
554        .context("decode Kimi Code token refresh")?;
555    let access_token = required_string(&payload, "access_token", "Kimi Code token refresh")?;
556    let refresh_token = required_string(&payload, "refresh_token", "Kimi Code token refresh")?;
557    let expires_in = payload
558        .get("expires_in")
559        .and_then(value_i64)
560        .filter(|value| *value > 0)
561        .context("Kimi Code token refresh is missing expires_in")?;
562    let now = SystemTime::now()
563        .duration_since(SystemTime::UNIX_EPOCH)
564        .unwrap_or_default()
565        .as_secs() as i64;
566    let refreshed = KimiCredentials {
567        access_token: access_token.to_string(),
568        refresh_token: refresh_token.to_string(),
569        expires_at: now + expires_in,
570        scope: payload
571            .get("scope")
572            .and_then(Value::as_str)
573            .unwrap_or_default()
574            .to_string(),
575        token_type: payload
576            .get("token_type")
577            .and_then(Value::as_str)
578            .unwrap_or("Bearer")
579            .to_string(),
580        expires_in,
581    };
582    // Prove the lock is still Mjolnir's before the write, not after it: a peer that
583    // broke the lock during the round trip may already have stored a newer pair,
584    // and overwriting that would strand both refreshes.
585    let ownership = confirm_kimi_lock_ownership(&refresh_lock.path, &refresh_lock.ownership);
586    let on_disk = match &ownership {
587        // A proven loss makes the file the authority on which pair is live.
588        Err(KimiLockLoss::Stolen { .. } | KimiLockLoss::Gone) => {
589            read_kimi_credentials(credentials_path).await.ok()
590        }
591        Ok(_) | Err(KimiLockLoss::Unproven(_)) => None,
592    };
593    match decide_kimi_refresh_persist(&ownership, on_disk.as_ref(), &active) {
594        KimiRefreshPersist::Save => {
595            save_kimi_credentials(credentials_path, &refreshed)?;
596            if let Err(error) = refresh_lock.release().await {
597                // The lock was Mjolnir's when the pair was written and the write
598                // landed; losing it in the microseconds since costs the lock,
599                // not a valid credential.
600                tracing::warn!(
601                    %error,
602                    "saved refreshed Kimi Code credentials, then lost the OAuth refresh lock before releasing it"
603                );
604            }
605            Ok(refreshed.access_token)
606        }
607        KimiRefreshPersist::SaveContested(loss) => {
608            save_kimi_credentials(credentials_path, &refreshed)?;
609            tracing::warn!(
610                path = %refresh_lock.path.display(),
611                %loss,
612                "another Kimi Code token refresh took the OAuth refresh lock, but left behind the pair this refresh already spent; saved Mjolnir's refreshed pair, the only live one"
613            );
614            Ok(refreshed.access_token)
615        }
616        KimiRefreshPersist::Adopt { access_token, loss } => {
617            tracing::warn!(
618                path = %refresh_lock.path.display(),
619                %loss,
620                "another Kimi Code token refresh took the OAuth refresh lock and stored its own credentials; using those instead of the pair Mjolnir just fetched"
621            );
622            Ok(access_token)
623        }
624    }
625}
626
627fn save_kimi_credentials(path: &Path, credentials: &KimiCredentials) -> Result<()> {
628    let mut body = serde_json::to_vec_pretty(credentials)?;
629    body.push(b'\n');
630    hel::hel_config::atomic_write(path, &body).context("save refreshed Kimi Code credentials")
631}
632
633/// What a completed refresh does with the pair it just fetched.
634#[derive(Debug, Clone, PartialEq, Eq)]
635enum KimiRefreshPersist {
636    /// The lock is Mjolnir's: save the refreshed pair and give the lock back.
637    Save,
638    /// The lock is another refresher's, and the file still holds the pair this
639    /// refresh spent: save the refreshed pair anyway and leave the lock alone.
640    SaveContested(KimiLockLoss),
641    /// The lock's new holder finished first and stored its own pair: return
642    /// that token and write nothing.
643    Adopt {
644        access_token: String,
645        loss: KimiLockLoss,
646    },
647}
648
649/// Decide how a completed refresh persists its result.
650///
651/// `ownership` is the lock check taken immediately before the write, `on_disk`
652/// the pair the credentials file carried when that check reported a proven
653/// loss (`None` when the file was not consulted, or could not be read), and
654/// `active` the pair whose refresh token this refresh spent at the server.
655///
656/// A lost lock never fails the refresh: exactly one of the two pairs is live,
657/// and the file says which. A pair on disk that moved on from `active` is the
658/// other refresher's, and it is the live one, because the server rotated Mjolnir's
659/// pair away from it. A file that still holds `active` is dead whichever
660/// refresh wrote it — its refresh token is the one Mjolnir just spent — so the
661/// refreshed pair is the only live credential anywhere and has to be stored,
662/// even over a contested lock; leaving the spent pair in place would force a
663/// `kimi login`. The peer recovers the same way Mjolnir does, by re-reading the
664/// file when the server rejects its consumed token.
665fn decide_kimi_refresh_persist(
666    ownership: &Result<SystemTime, KimiLockLoss>,
667    on_disk: Option<&KimiCredentials>,
668    active: &KimiCredentials,
669) -> KimiRefreshPersist {
670    let loss = match ownership {
671        Ok(_) => return KimiRefreshPersist::Save,
672        // A check that could not read the directory proves nothing about who
673        // holds it, so it is no reason to treat the lock as lost.
674        Err(KimiLockLoss::Unproven(_)) => return KimiRefreshPersist::Save,
675        Err(loss) => loss.clone(),
676    };
677    match on_disk {
678        Some(pair) if pair.differs_from(active) => KimiRefreshPersist::Adopt {
679            access_token: pair.access_token.clone(),
680            loss,
681        },
682        _ => KimiRefreshPersist::SaveContested(loss),
683    }
684}
685
686fn required_string<'a>(payload: &'a Value, key: &str, context: &str) -> Result<&'a str> {
687    payload
688        .get(key)
689        .and_then(Value::as_str)
690        .filter(|value| !value.is_empty())
691        .with_context(|| format!("{context} is missing {key}"))
692}
693
694struct KimiRefreshLock {
695    path: std::path::PathBuf,
696    ownership: Arc<Mutex<KimiLockOwnership>>,
697    heartbeat: Option<tokio::task::JoinHandle<()>>,
698}
699
700/// What Mjolnir knows about the lock directory it created. The Kimi Code CLI
701/// breaks a lock whose modification time stopped moving and takes it over, so
702/// holding the directory is not the same as owning it: Mjolnir checks the mtime it
703/// published is still there before touching or removing the directory.
704/// Touching a lock the CLI now owns trips the CLI's own ownership check
705/// (`ECOMPROMISED`, proper-lockfile 4.1.2 `lib/lockfile.js:114-140`), and
706/// removing it would hand a third holder a lock the CLI is still using.
707#[derive(Debug)]
708enum KimiLockOwnership {
709    /// Mjolnir published this modification time and the directory still carried it
710    /// when Mjolnir last looked.
711    Held(SystemTime),
712    /// Mjolnir must not touch or remove the directory again.
713    Lost(KimiLockLoss),
714}
715
716/// Why the lock directory is not Mjolnir's any more.
717#[derive(Debug, Clone, PartialEq, Eq)]
718enum KimiLockLoss {
719    /// It carries a modification time Mjolnir never published: another holder broke
720    /// the lock and took it.
721    Stolen {
722        published: SystemTime,
723        observed: SystemTime,
724    },
725    /// It is gone: another holder broke the lock, or Mjolnir already released it.
726    Gone,
727    /// It could not be inspected, so Mjolnir cannot prove the lock is still its
728    /// own. Mjolnir leaves it alone; whoever wants it next breaks it once Mjolnir's
729    /// modification time goes stale.
730    Unproven(String),
731}
732
733impl std::fmt::Display for KimiLockLoss {
734    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
735        match self {
736            Self::Stolen {
737                published,
738                observed,
739            } => write!(
740                formatter,
741                "another process took it: Mjolnir published modification time {}, but the directory carries {}",
742                epoch_label(*published),
743                epoch_label(*observed)
744            ),
745            Self::Gone => formatter.write_str("another process removed it"),
746            Self::Unproven(error) => {
747                write!(
748                    formatter,
749                    "Mjolnir could not confirm it still owns it: {error}"
750                )
751            }
752        }
753    }
754}
755
756fn epoch_label(time: SystemTime) -> String {
757    match time.duration_since(SystemTime::UNIX_EPOCH) {
758        Ok(since) => format!("{:.3}", since.as_secs_f64()),
759        Err(_) => "before the epoch".to_string(),
760    }
761}
762
763/// The Kimi Code CLI is the other holder of this lock, and it agrees that a
764/// live holder keeps the directory's mtime moving. It takes the lock through
765/// `proper-lockfile` with `stale: 5_000` (kimi-code
766/// `packages/oauth/src/oauth-manager.ts:216-220`; the shipped binary carries
767/// the same `stale: 5e3`), which rewrites the mtime every `stale / 2` for as
768/// long as the lock is held (proper-lockfile 4.1.2 `lib/lockfile.js:99-183`,
769/// interval resolved at `lib/lockfile.js:220-221`) and removes any lock whose
770/// mtime is older than `stale` (`lib/lockfile.js:67-79, 84-86`). A CLI refresh
771/// can hold the lock far longer than that — three tries against a 30s HTTP
772/// timeout plus backoff (`packages/oauth/src/oauth.ts:56-73, 226-263`) — but
773/// never silently, so a stopped mtime still means the holder is gone. Its
774/// mtimes can also land up to a second in the future
775/// (`lib/mtime-precision.js:44-52`), which `break_stale_kimi_lock` reads as
776/// "not stale" because `duration_since` fails: the safe answer.
777const KIMI_CLI_LOCK_STALE_AFTER: Duration = Duration::from_secs(5);
778/// A holder republishes the lock directory's modification time on this
779/// interval, so a lock whose mtime stopped moving has no live holder. The CLI
780/// judges Mjolnir's lock by that same mtime, so the interval has to fit inside
781/// `KIMI_CLI_LOCK_STALE_AFTER` several times over: one beat pays for the wait
782/// between touches, and the rest is stall the heartbeat task may absorb.
783const KIMI_LOCK_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(1);
784/// How long the heartbeat task may stall — descheduled, starved, or blocked on
785/// a slow filesystem — before the CLI is entitled to break a lock Mjolnir still
786/// holds and rotate the credentials alongside it. It is the CLI's window less
787/// the interval Mjolnir already spends waiting between touches.
788const KIMI_LOCK_HEARTBEAT_STALL_TOLERANCE: Duration = Duration::from_secs(
789    KIMI_CLI_LOCK_STALE_AFTER.as_secs() - KIMI_LOCK_HEARTBEAT_INTERVAL.as_secs(),
790);
791/// Beats of silence Mjolnir waits out before it calls another holder's lock
792/// abandoned. Several beats of slack, so a live holder delayed by the scheduler
793/// keeps its lock, and deliberately more patient than the CLI's 5s: breaking
794/// later than the peer can never steal a live lock, and it costs no recovery
795/// time, because the CLI reclaims a lock a crashed Mjolnir left behind after its
796/// own 5s.
797const KIMI_LOCK_STALE_HEARTBEATS: u64 = 10;
798/// Derived from the heartbeat so the two cannot drift apart.
799const KIMI_LOCK_STALE_AFTER: Duration =
800    Duration::from_secs(KIMI_LOCK_STALE_HEARTBEATS * KIMI_LOCK_HEARTBEAT_INTERVAL.as_secs());
801const _: () = assert!(
802    KIMI_LOCK_HEARTBEAT_STALL_TOLERANCE.as_secs() >= 4 * KIMI_LOCK_HEARTBEAT_INTERVAL.as_secs(),
803    "Mjolnir must be able to miss several beats in a row and still hold a lock the Kimi Code CLI could otherwise break"
804);
805const _: () = assert!(
806    KIMI_LOCK_STALE_AFTER.as_secs() >= KIMI_CLI_LOCK_STALE_AFTER.as_secs(),
807    "Mjolnir must not call a lock stale sooner than the Kimi Code CLI does, or it can break a lock the CLI still holds"
808);
809const KIMI_LOCK_RETRY_INTERVAL: Duration = Duration::from_millis(500);
810const KIMI_LOCK_WAIT: Duration = Duration::from_secs(60);
811/// Filesystems record modification times at their own precision — whole
812/// seconds on ext3 and HFS+ — and the Kimi Code CLI leans on that, rounding
813/// its own writes up to the next whole second so a coarse filesystem stores
814/// them unchanged (`lib/mtime-precision.js:44-52`); its mtimes therefore land
815/// up to a second in the future. So a time Mjolnir published and the time it reads
816/// back can differ by anything under a second and still be the same write.
817/// Nothing smaller than a second distinguishes holders: taking the lock from
818/// Mjolnir costs another holder at least `KIMI_CLI_LOCK_STALE_AFTER` of silence
819/// first, so a thief's modification time is seconds away, never milliseconds.
820const KIMI_LOCK_MTIME_TOLERANCE: Duration = Duration::from_secs(1);
821
822impl KimiRefreshLock {
823    async fn acquire(home: &Path) -> Result<Self> {
824        Self::acquire_within(home, KIMI_LOCK_WAIT).await
825    }
826
827    async fn acquire_within(home: &Path, wait: Duration) -> Result<Self> {
828        let oauth_dir = home.join("oauth");
829        tokio::fs::create_dir_all(&oauth_dir)
830            .await
831            .context("prepare Kimi Code OAuth lock")?;
832        let sentinel = oauth_dir.join("kimi-code");
833        tokio::fs::OpenOptions::new()
834            .create(true)
835            .append(true)
836            .open(&sentinel)
837            .await
838            .context("prepare Kimi Code OAuth lock sentinel")?;
839        let path = oauth_dir.join("kimi-code.lock");
840        let deadline = tokio::time::Instant::now() + wait;
841        loop {
842            match tokio::fs::create_dir(&path).await {
843                Ok(()) => return Self::claim(path).await,
844                Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
845                    if tokio::time::Instant::now() >= deadline {
846                        break;
847                    }
848                    // A holder killed mid-refresh leaves its directory behind
849                    // forever; break the lock once its heartbeat has stopped
850                    // and retry the create immediately.
851                    if !break_stale_kimi_lock(&path).await {
852                        tokio::time::sleep(KIMI_LOCK_RETRY_INTERVAL).await;
853                    }
854                }
855                Err(error) => return Err(error).context("acquire Kimi Code OAuth refresh lock"),
856            }
857        }
858        bail!(
859            "timed out waiting for Kimi Code OAuth refresh lock {}; another Kimi Code refresh is holding it, or a crashed one left it behind and the directory has to be removed",
860            path.display()
861        )
862    }
863
864    /// Take ownership of a directory Mjolnir just created. The modification time
865    /// the filesystem recorded for the create is the first proof of ownership;
866    /// every heartbeat republishes it.
867    async fn claim(path: std::path::PathBuf) -> Result<Self> {
868        let published = kimi_lock_mtime(&path)
869            .map_err(anyhow::Error::new)
870            .and_then(|mtime| mtime.context("it vanished as Mjolnir created it"));
871        match published {
872            Ok(published) => Ok(Self::held(path, published)),
873            Err(error) => {
874                // Without a first modification time Mjolnir could never prove the
875                // lock is its own, so it could never release it either. Give it
876                // back now instead of leaving it for a stale-breaker.
877                let _ = tokio::fs::remove_dir(&path).await;
878                Err(error).with_context(|| {
879                    format!(
880                        "claim the new Kimi Code OAuth refresh lock {}",
881                        path.display()
882                    )
883                })
884            }
885        }
886    }
887
888    fn held(path: std::path::PathBuf, published: SystemTime) -> Self {
889        let ownership = Arc::new(Mutex::new(KimiLockOwnership::Held(published)));
890        let heartbeat_path = path.clone();
891        let heartbeat_ownership = Arc::clone(&ownership);
892        let heartbeat = tokio::spawn(async move {
893            loop {
894                tokio::time::sleep(KIMI_LOCK_HEARTBEAT_INTERVAL).await;
895                match beat_kimi_lock(&heartbeat_path, &heartbeat_ownership) {
896                    Ok(()) => {}
897                    Err(KimiLockLoss::Unproven(error)) => {
898                        tracing::debug!(path = %heartbeat_path.display(), %error, "heartbeat Kimi Code OAuth refresh lock");
899                    }
900                    Err(loss) => {
901                        tracing::warn!(path = %heartbeat_path.display(), %loss, "stopped heartbeating a Kimi Code OAuth refresh lock Mjolnir no longer holds");
902                        return;
903                    }
904                }
905            }
906        });
907        Self {
908            path,
909            ownership,
910            heartbeat: Some(heartbeat),
911        }
912    }
913
914    /// Give the lock back. Fails when the lock stopped being Mjolnir's, because the
915    /// refresh it was protecting then ran beside another one. Callers that have
916    /// already confirmed ownership and stored valid credentials treat that
917    /// failure as a lost lock rather than a failed refresh; see
918    /// `ensure_fresh_kimi_token`.
919    async fn release(mut self) -> Result<()> {
920        if let Some(heartbeat) = self.heartbeat.take() {
921            heartbeat.abort();
922        }
923        if let Err(loss) = confirm_kimi_lock_ownership(&self.path, &self.ownership) {
924            bail!(
925                "the Kimi Code OAuth refresh lock {} stopped being Mjolnir's mid-refresh: {loss}; another Kimi Code token refresh may have rotated the credentials beside this one",
926                self.path.display()
927            );
928        }
929        match tokio::fs::remove_dir(&self.path).await {
930            Ok(()) => {
931                *lock_ownership(&self.ownership) = KimiLockOwnership::Lost(KimiLockLoss::Gone)
932            }
933            Err(error) => {
934                tracing::warn!(path = %self.path.display(), %error, "release Kimi Code OAuth refresh lock");
935            }
936        }
937        Ok(())
938    }
939}
940
941impl Drop for KimiRefreshLock {
942    fn drop(&mut self) {
943        if let Some(heartbeat) = self.heartbeat.take() {
944            heartbeat.abort();
945        }
946        if matches!(*lock_ownership(&self.ownership), KimiLockOwnership::Lost(_)) {
947            // Already released, or reported where the loss was discovered.
948            return;
949        }
950        match confirm_kimi_lock_ownership(&self.path, &self.ownership) {
951            Ok(_) => {
952                if let Err(error) = std::fs::remove_dir(&self.path) {
953                    tracing::warn!(path = %self.path.display(), %error, "release Kimi Code OAuth refresh lock");
954                }
955            }
956            Err(loss) => {
957                tracing::warn!(path = %self.path.display(), %loss, "left a Kimi Code OAuth refresh lock Mjolnir no longer holds in place");
958            }
959        }
960    }
961}
962
963fn lock_ownership(
964    ownership: &Mutex<KimiLockOwnership>,
965) -> std::sync::MutexGuard<'_, KimiLockOwnership> {
966    ownership
967        .lock()
968        .unwrap_or_else(|poisoned| poisoned.into_inner())
969}
970
971/// Check that the lock directory still carries the modification time Mjolnir
972/// published, and remember a loss so every later check agrees. `Ok` hands back
973/// the published time; `Err` means Mjolnir must neither touch nor remove the
974/// directory.
975fn confirm_kimi_lock_ownership(
976    path: &Path,
977    ownership: &Mutex<KimiLockOwnership>,
978) -> Result<SystemTime, KimiLockLoss> {
979    let published = match &*lock_ownership(ownership) {
980        KimiLockOwnership::Held(published) => *published,
981        KimiLockOwnership::Lost(loss) => return Err(loss.clone()),
982    };
983    let loss = match kimi_lock_mtime(path) {
984        Ok(Some(observed)) if kimi_lock_mtime_matches(published, observed) => {
985            return Ok(published);
986        }
987        Ok(Some(observed)) => KimiLockLoss::Stolen {
988            published,
989            observed,
990        },
991        Ok(None) => KimiLockLoss::Gone,
992        // A stat that fails says nothing about who holds the lock, so the
993        // ownership Mjolnir recorded stands and a later beat can confirm it again.
994        Err(error) => return Err(KimiLockLoss::Unproven(error.to_string())),
995    };
996    *lock_ownership(ownership) = KimiLockOwnership::Lost(loss.clone());
997    Err(loss)
998}
999
1000/// One heartbeat: prove the directory is still Mjolnir's, then publish a fresh
1001/// modification time on it.
1002fn beat_kimi_lock(path: &Path, ownership: &Mutex<KimiLockOwnership>) -> Result<(), KimiLockLoss> {
1003    confirm_kimi_lock_ownership(path, ownership)?;
1004    let published = SystemTime::now();
1005    if let Err(error) = touch_kimi_lock(path, published) {
1006        // Only a time actually written may be remembered, or the next check
1007        // would report a theft that never happened.
1008        return Err(KimiLockLoss::Unproven(error.to_string()));
1009    }
1010    let mut ownership = lock_ownership(ownership);
1011    if matches!(*ownership, KimiLockOwnership::Held(_)) {
1012        *ownership = KimiLockOwnership::Held(published);
1013    }
1014    Ok(())
1015}
1016
1017/// Whether a modification time read back from the lock directory is the one Mjolnir
1018/// published. See `KIMI_LOCK_MTIME_TOLERANCE` for why a sub-second difference
1019/// is the same write rather than another holder's.
1020fn kimi_lock_mtime_matches(published: SystemTime, observed: SystemTime) -> bool {
1021    observed
1022        .duration_since(published)
1023        .or_else(|_| published.duration_since(observed))
1024        .is_ok_and(|drift| drift < KIMI_LOCK_MTIME_TOLERANCE)
1025}
1026
1027/// The lock directory's modification time, or `None` when the directory is
1028/// gone. Mjolnir and the Kimi Code CLI share this one value and nothing else: the
1029/// CLI releases its lock with a plain `rmdir`, so the directory has to stay
1030/// empty and the mtime is the whole protocol.
1031fn kimi_lock_mtime(path: &Path) -> std::io::Result<Option<SystemTime>> {
1032    match std::fs::metadata(path) {
1033        Ok(metadata) => metadata.modified().map(Some),
1034        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
1035        Err(error) => Err(error),
1036    }
1037}
1038
1039/// Publish a lock directory's modification time, the signal that its holder is
1040/// still alive. Windows opens a directory handle only under backup semantics,
1041/// so the heartbeat would otherwise be a silent no-op there and every live lock
1042/// would look abandoned.
1043fn touch_kimi_lock(path: &Path, modified: SystemTime) -> std::io::Result<()> {
1044    let mut options = std::fs::OpenOptions::new();
1045    options.read(true);
1046    #[cfg(windows)]
1047    {
1048        use std::os::windows::fs::OpenOptionsExt;
1049        const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
1050        options.custom_flags(FILE_FLAG_BACKUP_SEMANTICS);
1051    }
1052    options
1053        .open(path)?
1054        .set_times(std::fs::FileTimes::new().set_modified(modified))
1055}
1056
1057/// Remove a lock directory whose heartbeat has stopped, so a holder killed
1058/// mid-refresh cannot poison the profile home until someone removes it by hand.
1059/// Returns whether the lock is gone and the caller should retry the create at
1060/// once; a lock another process removes or recreates underneath simply loses or
1061/// wins the next create.
1062async fn break_stale_kimi_lock(path: &Path) -> bool {
1063    let modified = match kimi_lock_mtime(path) {
1064        Ok(Some(modified)) => modified,
1065        Ok(None) => return true,
1066        Err(error) => {
1067            tracing::warn!(path = %path.display(), %error, "inspect Kimi Code OAuth refresh lock");
1068            return false;
1069        }
1070    };
1071    let age = SystemTime::now().duration_since(modified).ok();
1072    let Some(age) = age.filter(|age| *age >= KIMI_LOCK_STALE_AFTER) else {
1073        return false;
1074    };
1075    match tokio::fs::remove_dir(path).await {
1076        Ok(()) => {
1077            tracing::warn!(
1078                path = %path.display(),
1079                age_seconds = age.as_secs(),
1080                "removed a Kimi Code OAuth refresh lock whose holder stopped heartbeating"
1081            );
1082            true
1083        }
1084        Err(error) if error.kind() == std::io::ErrorKind::NotFound => true,
1085        Err(error) => {
1086            tracing::warn!(path = %path.display(), %error, "remove stale Kimi Code OAuth refresh lock");
1087            false
1088        }
1089    }
1090}
1091
1092fn parse_kimi_usage(payload: &Value) -> (Vec<QuotaWindow>, Option<String>) {
1093    let mut windows = Vec::new();
1094    if let Some(summary) = payload.get("usage")
1095        && let Some(window) = parse_kimi_window(summary, "Weekly limit")
1096    {
1097        windows.push(window);
1098    }
1099    if let Some(limits) = payload.get("limits").and_then(Value::as_array) {
1100        for (index, item) in limits.iter().enumerate() {
1101            let detail = item.get("detail").unwrap_or(item);
1102            if let Some(window) = parse_kimi_window(detail, &format!("Limit #{}", index + 1)) {
1103                windows.push(window);
1104            }
1105        }
1106    }
1107    let extra = payload
1108        .pointer("/boosterWallet/balance/amountLeft")
1109        .and_then(value_i64)
1110        .map(|value| format!("booster {} remaining", value / 1_000_000));
1111    (windows, extra)
1112}
1113
1114fn parse_kimi_window(value: &Value, fallback: &str) -> Option<QuotaWindow> {
1115    let limit = value.get("limit").and_then(value_i64);
1116    let used = value.get("used").and_then(value_i64).or_else(|| {
1117        let remaining = value.get("remaining").and_then(value_i64)?;
1118        Some(limit? - remaining)
1119    });
1120    if used.is_none() && limit.is_none() {
1121        return None;
1122    }
1123    let provider_label = value
1124        .get("name")
1125        .or_else(|| value.get("title"))
1126        .and_then(Value::as_str)
1127        .unwrap_or(fallback);
1128    let label = if provider_label.to_ascii_lowercase().contains("week") {
1129        "Week".to_string()
1130    } else if provider_label.to_ascii_lowercase().contains("5h") || fallback.starts_with("Limit #")
1131    {
1132        "5H".to_string()
1133    } else {
1134        provider_label.to_string()
1135    };
1136    let reset_value = ["resetAt", "reset_at", "resetTime", "reset_time"]
1137        .iter()
1138        .find_map(|key| value.get(*key));
1139    let resets = reset_value.and_then(normalize_kimi_reset);
1140    let resets_at_epoch_seconds = reset_value.and_then(kimi_reset_epoch_seconds);
1141    let remaining_percent = match (used, limit) {
1142        (Some(used), Some(limit)) if limit > 0 => {
1143            Some((100 - used.clamp(0, limit) * 100 / limit) as u8)
1144        }
1145        _ => None,
1146    };
1147    Some(QuotaWindow {
1148        label,
1149        remaining_percent,
1150        used,
1151        limit,
1152        resets,
1153        resets_at_epoch_seconds,
1154    })
1155}
1156
1157fn value_i64(value: &Value) -> Option<i64> {
1158    value
1159        .as_i64()
1160        .or_else(|| value.as_str()?.parse::<i64>().ok())
1161}
1162
1163fn normalize_kimi_reset(value: &Value) -> Option<String> {
1164    value
1165        .as_f64()
1166        .and_then(format_reset_local)
1167        .or_else(|| value.as_str().and_then(normalize_reset_text))
1168}
1169
1170fn kimi_reset_epoch_seconds(value: &Value) -> Option<i64> {
1171    value
1172        .as_f64()
1173        .map(|epoch| {
1174            if epoch.abs() >= 1_000_000_000_000.0 {
1175                (epoch / 1000.0).trunc() as i64
1176            } else {
1177                epoch.trunc() as i64
1178            }
1179        })
1180        .or_else(|| value.as_str().and_then(normalize_reset_epoch_seconds))
1181}
1182
1183/// Format a Unix reset timestamp as wall-clock time in the machine's local
1184/// time zone. Accepts seconds or milliseconds and rejects non-finite or
1185/// out-of-range values.
1186pub(crate) fn format_reset_local(epoch: f64) -> Option<String> {
1187    if !epoch.is_finite() {
1188        return None;
1189    }
1190    let seconds = if epoch.abs() >= 1_000_000_000_000.0 {
1191        (epoch / 1000.0).trunc() as i64
1192    } else {
1193        epoch.trunc() as i64
1194    };
1195    let local = Local.timestamp_opt(seconds, 0).single()?;
1196    Some(format_reset_label(local.fixed_offset()))
1197}
1198
1199pub(crate) fn format_reset_local_seconds(epoch: i64) -> Option<String> {
1200    format_reset_local(epoch as f64)
1201}
1202
1203/// Normalize a provider's textual reset value to the compact 24-hour form
1204/// used by the dashboard. A time-only value is the next occurrence of that
1205/// wall-clock time; Claude Code uses this shape for its five-hour window.
1206pub(crate) fn normalize_reset_text(value: &str) -> Option<String> {
1207    normalize_reset_at(value, Local::now().fixed_offset()).map(format_reset_label)
1208}
1209
1210pub(crate) fn normalize_reset_epoch_seconds(value: &str) -> Option<i64> {
1211    normalize_reset_at(value, Local::now().fixed_offset()).map(|reset| reset.timestamp())
1212}
1213
1214fn normalize_reset_at(value: &str, now: DateTime<FixedOffset>) -> Option<DateTime<FixedOffset>> {
1215    let value = value.trim();
1216    if value.is_empty() {
1217        return None;
1218    }
1219    if let Ok(epoch) = value.parse::<f64>() {
1220        let seconds = if epoch.abs() >= 1_000_000_000_000.0 {
1221            (epoch / 1000.0).trunc() as i64
1222        } else {
1223            epoch.trunc() as i64
1224        };
1225        return Local
1226            .timestamp_opt(seconds, 0)
1227            .single()
1228            .map(|reset| reset.fixed_offset());
1229    }
1230    if let Ok(timestamp) = DateTime::parse_from_rfc3339(value) {
1231        return Some(timestamp.with_timezone(&Local).fixed_offset());
1232    }
1233
1234    let value = value
1235        .strip_prefix("at ")
1236        .unwrap_or(value)
1237        .split('(')
1238        .next()
1239        .unwrap_or(value)
1240        .trim()
1241        .trim_end_matches(',');
1242    let parse_time = |value: &str| {
1243        let value = value
1244            .to_ascii_lowercase()
1245            .chars()
1246            .filter(|ch| !ch.is_whitespace())
1247            .collect::<String>();
1248        let value = ["am", "pm"]
1249            .into_iter()
1250            .find_map(|suffix| {
1251                let hour = value.strip_suffix(suffix)?;
1252                (!hour.contains(':')).then(|| format!("{hour}:00{suffix}"))
1253            })
1254            .unwrap_or(value);
1255        ["%I:%M%P", "%I%P", "%H:%M"]
1256            .iter()
1257            .find_map(|format| NaiveTime::parse_from_str(&value, format).ok())
1258    };
1259
1260    // Claude has used both `Aug 14 at 4am` and `Aug 14, 4am` across
1261    // releases. Keep the provider punctuation out of the date/time parsers.
1262    let dated_time = value.split_once(" at ").or_else(|| {
1263        value
1264            .split_once(',')
1265            .map(|(date, time)| (date, time.trim()))
1266    });
1267    if let Some((date, time)) = dated_time {
1268        let time = parse_time(time.trim())?;
1269        let date = date.trim().trim_end_matches(',');
1270        let date = match date.to_ascii_lowercase().as_str() {
1271            "today" => now.date_naive(),
1272            "tomorrow" => now.date_naive().checked_add_days(Days::new(1))?,
1273            _ => NaiveDate::parse_from_str(
1274                &format!("{} {}", date.replace(',', ""), now.year()),
1275                "%b %e %Y",
1276            )
1277            .ok()?,
1278        };
1279        return now
1280            .timezone()
1281            .from_local_datetime(&date.and_time(time))
1282            .single();
1283    }
1284
1285    let time = parse_time(value)?;
1286    let mut date = now.date_naive();
1287    let mut reset = now
1288        .timezone()
1289        .from_local_datetime(&date.and_time(time))
1290        .single()?;
1291    if reset <= now {
1292        date = date.checked_add_days(Days::new(1))?;
1293        reset = now
1294            .timezone()
1295            .from_local_datetime(&date.and_time(time))
1296            .single()?;
1297    }
1298    Some(reset)
1299}
1300
1301/// Pure formatter split from local-zone discovery for deterministic tests.
1302fn format_reset_label(reset: DateTime<FixedOffset>) -> String {
1303    reset.format("%H:%M %b %-d").to_string()
1304}
1305
1306#[cfg(test)]
1307mod tests {
1308    use super::*;
1309    use axum::body::Bytes;
1310    use axum::extract::State;
1311    use axum::http::{HeaderMap, StatusCode};
1312    use axum::routing::{get, post};
1313    use axum::{Json, Router};
1314    use std::sync::{Arc, Mutex};
1315
1316    #[test]
1317    fn parses_kimi_summary_limits_and_booster_without_credentials() {
1318        let payload = serde_json::json!({
1319            "usage": {"name":"Weekly", "used":40, "limit":1000, "resetAt":"tomorrow"},
1320            "limits": [{"detail":{"remaining":"90", "limit":"100", "name":"5h"}}],
1321            "boosterWallet": {"balance":{"amountLeft":42000000}}
1322        });
1323        let (windows, extra) = parse_kimi_usage(&payload);
1324        assert_eq!(windows.len(), 2);
1325        assert_eq!(windows[0].used, Some(40));
1326        assert_eq!(windows[1].used, Some(10));
1327        assert_eq!(windows[0].label, "Week");
1328        assert_eq!(windows[0].remaining_percent, Some(96));
1329        assert_eq!(windows[1].label, "5H");
1330        assert_eq!(windows[1].remaining_percent, Some(90));
1331        assert_eq!(extra.as_deref(), Some("booster 42 remaining"));
1332    }
1333
1334    #[test]
1335    fn compact_includes_reset_and_error_states() {
1336        let report = ProfileQuota {
1337            profile_id: "codex-1".into(),
1338            harness: HarnessKind::Codex,
1339            windows: vec![QuotaWindow {
1340                label: "5H".into(),
1341                remaining_percent: Some(70),
1342                used: None,
1343                limit: None,
1344                resets: Some("10:00 Jun 17".into()),
1345                resets_at_epoch_seconds: Some(14_400),
1346            }],
1347            extra: None,
1348            error: None,
1349            refreshed_at_epoch_seconds: 0,
1350        };
1351        assert!(report.compact().contains("70% left"));
1352        assert!(report.compact().contains("resets 10:00 Jun 17"));
1353    }
1354
1355    #[test]
1356    fn compact_shows_login_expired_without_unavailable_prefix() {
1357        let report = ProfileQuota {
1358            profile_id: "claude2".into(),
1359            harness: HarnessKind::Claude,
1360            windows: vec![],
1361            extra: None,
1362            error: Some(claude_usage::LOGIN_EXPIRED.into()),
1363            refreshed_at_epoch_seconds: 0,
1364        };
1365        assert_eq!(report.compact(), claude_usage::LOGIN_EXPIRED);
1366        assert_eq!(
1367            report.error_label().as_deref(),
1368            Some(claude_usage::LOGIN_EXPIRED)
1369        );
1370    }
1371
1372    #[test]
1373    fn compact_shows_other_errors_as_unavailable() {
1374        let report = ProfileQuota {
1375            profile_id: "claude2".into(),
1376            harness: HarnessKind::Claude,
1377            windows: vec![],
1378            extra: None,
1379            error: Some("query Claude usage: HTTP 429".into()),
1380            refreshed_at_epoch_seconds: 0,
1381        };
1382        assert_eq!(report.compact(), "unavailable");
1383        assert_eq!(report.error_label().as_deref(), Some("unavailable"));
1384    }
1385
1386    #[test]
1387    fn compact_displays_a_shared_reset_once() {
1388        let report = ProfileQuota {
1389            profile_id: "codex-1".into(),
1390            harness: HarnessKind::Codex,
1391            windows: vec![
1392                QuotaWindow {
1393                    label: "5H".into(),
1394                    remaining_percent: Some(70),
1395                    used: None,
1396                    limit: None,
1397                    resets: Some("10:00 Jun 17".into()),
1398                    resets_at_epoch_seconds: Some(14_400),
1399                },
1400                QuotaWindow {
1401                    label: "Week".into(),
1402                    remaining_percent: Some(55),
1403                    used: None,
1404                    limit: None,
1405                    resets: Some("10:00 Jun 17".into()),
1406                    resets_at_epoch_seconds: Some(14_400),
1407                },
1408            ],
1409            extra: None,
1410            error: None,
1411            refreshed_at_epoch_seconds: 0,
1412        };
1413        assert_eq!(
1414            report.compact(),
1415            "5H 70% left, resets 10:00 Jun 17 · Week 55% left"
1416        );
1417    }
1418
1419    #[test]
1420    fn compact_hides_claude_short_window_when_week_is_exhausted() {
1421        let report = ProfileQuota {
1422            profile_id: "claude".into(),
1423            harness: HarnessKind::Claude,
1424            windows: vec![
1425                QuotaWindow {
1426                    label: "5H".into(),
1427                    remaining_percent: Some(100),
1428                    used: None,
1429                    limit: None,
1430                    resets: None,
1431                    resets_at_epoch_seconds: None,
1432                },
1433                QuotaWindow {
1434                    label: "Week".into(),
1435                    remaining_percent: Some(0),
1436                    used: None,
1437                    limit: None,
1438                    resets: Some("03:59 Aug 14".into()),
1439                    resets_at_epoch_seconds: None,
1440                },
1441            ],
1442            extra: None,
1443            error: None,
1444            refreshed_at_epoch_seconds: 0,
1445        };
1446
1447        assert_eq!(report.compact(), "Week 0% left, resets 03:59 Aug 14");
1448    }
1449
1450    #[cfg(unix)]
1451    #[tokio::test]
1452    async fn a_grok_profile_reports_its_billing_period_as_one_quota_window() {
1453        use std::os::unix::fs::PermissionsExt;
1454
1455        let directory = tempfile::tempdir().unwrap();
1456        let executable = directory.path().join("grok");
1457        std::fs::write(directory.path().join("auth.json"), b"old credentials").unwrap();
1458        std::fs::write(
1459            &executable,
1460            "#!/bin/sh\nprintf 'refreshed credentials' > \"$GROK_HOME/auth.json\"\nwhile IFS= read -r line; do\n  case \"$line\" in\n    *initialize*) printf '{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{}}\\n' ;;\n    *billing*) printf '{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{\"config\":{\"creditUsagePercent\":25.0,\"currentPeriod\":{\"type\":\"USAGE_PERIOD_TYPE_WEEKLY\",\"end\":\"2026-08-18T05:22:07+00:00\"}},\"subscription_tier\":\"X Premium+\"}}\\n' ;;\n  esac\ndone\n",
1461        )
1462        .unwrap();
1463        std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap();
1464        let environment = BTreeMap::from([
1465            (
1466                "GROK_HOME".to_owned(),
1467                directory.path().to_string_lossy().into_owned(),
1468            ),
1469            (
1470                "PATH".to_owned(),
1471                directory.path().to_string_lossy().into_owned(),
1472            ),
1473        ]);
1474
1475        let (outcome, _) = refresh_profile(
1476            QuotaRefreshRequest {
1477                profile_id: "grok".into(),
1478                harness: HarnessKind::Grok,
1479                source_home: directory.path().to_path_buf(),
1480                environment,
1481                cwd: directory.path().to_path_buf(),
1482            },
1483            None,
1484        )
1485        .await;
1486        assert!(outcome.credentials_changed);
1487        let report = outcome.report;
1488
1489        assert_eq!(report.error, None, "{:?}", report.error);
1490        // One long window and no short one: Grok Build has no 5-hour budget.
1491        assert_eq!(report.windows.len(), 1);
1492        assert_eq!(report.weekly_window().unwrap().remaining_percent, Some(75));
1493        assert_eq!(report.five_hour_window(), None);
1494        // The subscription tier stays off the row; the fixture carries it to
1495        // prove it is ignored.
1496        assert_eq!(report.extra, None);
1497        assert!(report.compact().starts_with("Week 75% left, resets "));
1498    }
1499
1500    /// A `codex app-server` stand-in on `PATH` that logs every request line it
1501    /// reads, so a test can assert the exact protocol exchange.
1502    #[cfg(unix)]
1503    fn fake_codex_app_server(
1504        directory: &Path,
1505        script: &str,
1506    ) -> (BTreeMap<String, String>, std::path::PathBuf) {
1507        use std::os::unix::fs::PermissionsExt;
1508
1509        let executable = directory.join("codex");
1510        std::fs::write(&executable, script).unwrap();
1511        std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap();
1512        let log = directory.join("requests.jsonl");
1513        let environment = BTreeMap::from([
1514            ("PATH".to_owned(), directory.to_string_lossy().into_owned()),
1515            (
1516                "CODEX_USAGE_TEST_LOG".to_owned(),
1517                log.to_string_lossy().into_owned(),
1518            ),
1519            (
1520                "CODEX_AUTH_FILE".to_owned(),
1521                directory.join("auth.json").to_string_lossy().into_owned(),
1522            ),
1523        ]);
1524        (environment, log)
1525    }
1526
1527    /// A Codex `auth.json` whose access token is a JWT expiring `expires_in`
1528    /// from now, last refreshed `refreshed_ago` before now.
1529    #[cfg(unix)]
1530    fn write_codex_auth(home: &Path, expires_in: Duration, refreshed_ago: Duration) {
1531        use base64::Engine as _;
1532
1533        let now = chrono::Utc::now();
1534        let expiry = (now + chrono::TimeDelta::from_std(expires_in).unwrap()).timestamp();
1535        let segment = |value: Value| {
1536            base64::engine::general_purpose::URL_SAFE_NO_PAD
1537                .encode(serde_json::to_vec(&value).unwrap())
1538        };
1539        let access_token = format!(
1540            "{}.{}.signature-is-never-checked",
1541            segment(serde_json::json!({ "alg": "RS256", "typ": "JWT" })),
1542            segment(serde_json::json!({ "exp": expiry })),
1543        );
1544        let body = serde_json::json!({
1545            "auth_mode": "chatgpt",
1546            "tokens": {
1547                "access_token": access_token,
1548                "refresh_token": "refresh",
1549                "id_token": "id",
1550                "account_id": "account",
1551            },
1552            "last_refresh": (now - chrono::TimeDelta::from_std(refreshed_ago).unwrap())
1553                .to_rfc3339(),
1554        });
1555        std::fs::write(home.join("auth.json"), serde_json::to_vec(&body).unwrap()).unwrap();
1556    }
1557
1558    #[cfg(unix)]
1559    fn codex_request_log(log: &Path) -> Vec<Value> {
1560        std::fs::read_to_string(log)
1561            .unwrap()
1562            .lines()
1563            .map(|line| serde_json::from_str::<Value>(line).unwrap())
1564            .collect()
1565    }
1566
1567    #[cfg(unix)]
1568    async fn poll_codex_profile(
1569        directory: &Path,
1570        environment: BTreeMap<String, String>,
1571    ) -> QuotaRefreshOutcome {
1572        let (outcome, client) = refresh_profile(
1573            QuotaRefreshRequest {
1574                profile_id: "codex".into(),
1575                harness: HarnessKind::Codex,
1576                source_home: directory.to_path_buf(),
1577                environment,
1578                cwd: directory.to_path_buf(),
1579            },
1580            None,
1581        )
1582        .await;
1583        if let Some(client) = client {
1584            client.shutdown().await;
1585        }
1586        outcome
1587    }
1588
1589    #[cfg(unix)]
1590    #[tokio::test]
1591    async fn a_codex_login_near_expiry_is_rotated_before_the_usage_query() {
1592        let directory = tempfile::tempdir().unwrap();
1593        // Ten minutes left on a one-hour token: inside the one-hour margin.
1594        write_codex_auth(
1595            directory.path(),
1596            Duration::from_secs(600),
1597            Duration::from_secs(3_000),
1598        );
1599        let (environment, log) = fake_codex_app_server(
1600            directory.path(),
1601            r#"#!/bin/sh
1602read_and_log() {
1603    IFS= read -r line || exit 1
1604    printf '%s\n' "$line" >> "$CODEX_USAGE_TEST_LOG"
1605}
1606read_and_log
1607printf '%s\n' '{"id":1,"result":{}}'
1608read_and_log
1609read_and_log
1610printf '%s\n' '{"auth_mode":"chatgpt","tokens":{"access_token":"rotated"}}' > "$CODEX_AUTH_FILE"
1611printf '%s\n' '{"id":2,"result":{"account":{"type":"chatgpt"}}}'
1612read_and_log
1613printf '%s\n' '{"id":3,"result":{"account":{"type":"chatgpt"}}}'
1614read_and_log
1615printf '%s\n' '{"id":4,"result":{"rateLimits":{"primary":{"usedPercent":25,"windowDurationMins":300}}}}'
1616"#,
1617        );
1618
1619        let outcome = poll_codex_profile(directory.path(), environment).await;
1620
1621        assert_eq!(outcome.report.error, None);
1622        assert_eq!(
1623            outcome.report.five_hour_window().unwrap().remaining_percent,
1624            Some(75)
1625        );
1626        // The rotated file has to reach live sessions, which is what the
1627        // changed-credentials flag asks the daemon to do.
1628        assert!(outcome.credentials_changed);
1629
1630        let messages = codex_request_log(&log);
1631        assert_eq!(messages.len(), 5);
1632        assert_eq!(messages[0]["method"], "initialize");
1633        assert_eq!(messages[1]["method"], "initialized");
1634        assert_eq!(messages[2]["method"], "account/read");
1635        assert_eq!(messages[2]["params"]["refreshToken"], true);
1636        assert_eq!(messages[3]["method"], "account/read");
1637        assert_eq!(messages[3]["params"]["refreshToken"], false);
1638        assert_eq!(messages[4]["method"], "account/rateLimits/read");
1639    }
1640
1641    #[cfg(unix)]
1642    #[tokio::test]
1643    async fn a_codex_login_far_from_expiry_is_polled_without_a_rotation() {
1644        let directory = tempfile::tempdir().unwrap();
1645        // Ten hours left on an eleven-hour token: outside both margins.
1646        write_codex_auth(
1647            directory.path(),
1648            Duration::from_secs(10 * 3_600),
1649            Duration::from_secs(3_600),
1650        );
1651        let (environment, log) = fake_codex_app_server(
1652            directory.path(),
1653            r#"#!/bin/sh
1654read_and_log() {
1655    IFS= read -r line || exit 1
1656    printf '%s\n' "$line" >> "$CODEX_USAGE_TEST_LOG"
1657}
1658read_and_log
1659printf '%s\n' '{"id":1,"result":{}}'
1660read_and_log
1661read_and_log
1662printf '%s\n' '{"id":2,"result":{"account":{"type":"chatgpt"}}}'
1663read_and_log
1664printf '%s\n' '{"id":3,"result":{"rateLimits":{"primary":{"usedPercent":25,"windowDurationMins":300}}}}'
1665"#,
1666        );
1667
1668        let outcome = poll_codex_profile(directory.path(), environment).await;
1669
1670        assert_eq!(outcome.report.error, None);
1671        assert!(!outcome.credentials_changed);
1672
1673        let messages = codex_request_log(&log);
1674        assert_eq!(messages.len(), 4);
1675        assert_eq!(messages[0]["method"], "initialize");
1676        assert_eq!(messages[1]["method"], "initialized");
1677        assert_eq!(messages[2]["params"]["refreshToken"], false);
1678        assert_eq!(messages[3]["method"], "account/rateLimits/read");
1679    }
1680
1681    #[cfg(unix)]
1682    #[tokio::test]
1683    async fn a_codex_app_server_without_the_refresh_flag_still_reports_quota() {
1684        let directory = tempfile::tempdir().unwrap();
1685        write_codex_auth(
1686            directory.path(),
1687            Duration::from_secs(600),
1688            Duration::from_secs(3_000),
1689        );
1690        let (environment, _log) = fake_codex_app_server(
1691            directory.path(),
1692            r#"#!/bin/sh
1693IFS= read -r line || exit 1
1694printf '%s\n' '{"id":1,"result":{}}'
1695IFS= read -r line || exit 1
1696IFS= read -r line || exit 1
1697printf '%s\n' '{"id":2,"error":{"code":-32601,"message":"unknown parameter"}}'
1698IFS= read -r line || exit 1
1699printf '%s\n' '{"id":3,"result":{"account":{"type":"chatgpt"}}}'
1700IFS= read -r line || exit 1
1701printf '%s\n' '{"id":4,"result":{"rateLimits":{"primary":{"usedPercent":40,"windowDurationMins":300}}}}'
1702"#,
1703        );
1704
1705        let outcome = poll_codex_profile(directory.path(), environment).await;
1706
1707        assert_eq!(outcome.report.error, None);
1708        assert_eq!(
1709            outcome.report.five_hour_window().unwrap().remaining_percent,
1710            Some(60)
1711        );
1712    }
1713
1714    #[test]
1715    fn a_codex_refresh_margin_is_an_hour_or_a_tenth_of_the_token_life() {
1716        let hour = 3_600_000;
1717        let now = 1_800_000_000_000;
1718        // A short-lived token: the flat hour decides.
1719        assert!(codex_login_needs_refresh(
1720            Some(now + hour / 2),
1721            Some(now - hour / 2),
1722            now
1723        ));
1724        assert!(!codex_login_needs_refresh(
1725            Some(now + 2 * hour),
1726            Some(now - hour),
1727            now
1728        ));
1729        // A long-lived token: a tenth of its life is wider than the hour.
1730        assert!(codex_login_needs_refresh(
1731            Some(now + 3 * hour),
1732            Some(now - 40 * hour),
1733            now
1734        ));
1735        // Without a last refresh, only the flat hour is known.
1736        assert!(codex_login_needs_refresh(Some(now + hour / 2), None, now));
1737        assert!(!codex_login_needs_refresh(Some(now + 3 * hour), None, now));
1738        // An unreadable expiry is not a reason to spend the refresh token.
1739        assert!(!codex_login_needs_refresh(None, Some(now - hour), now));
1740    }
1741
1742    #[tokio::test]
1743    async fn a_missing_codex_credential_file_asks_for_no_rotation() {
1744        let directory = tempfile::tempdir().unwrap();
1745        assert!(!codex_login_is_near_expiry(&directory.path().join("auth.json")).await);
1746    }
1747
1748    #[tokio::test]
1749    async fn an_unreachable_grok_reports_the_failure_instead_of_a_zero_reading() {
1750        let directory = tempfile::tempdir().unwrap();
1751
1752        let (outcome, _) = refresh_profile(
1753            QuotaRefreshRequest {
1754                profile_id: "grok".into(),
1755                harness: HarnessKind::Grok,
1756                source_home: directory.path().to_path_buf(),
1757                environment: BTreeMap::from([(
1758                    "PATH".to_owned(),
1759                    directory.path().to_string_lossy().into_owned(),
1760                )]),
1761                cwd: directory.path().to_path_buf(),
1762            },
1763            None,
1764        )
1765        .await;
1766        let report = outcome.report;
1767
1768        assert!(report.windows.is_empty());
1769        assert_eq!(
1770            report.error.as_deref(),
1771            Some("Grok Build executable not found")
1772        );
1773    }
1774
1775    #[tokio::test]
1776    async fn muse_quota_refresh_recovers_and_populates_dashboard_windows() {
1777        let directory = tempfile::tempdir().unwrap();
1778        let credentials = br#"{"providers":{"meta":{"access_token":"profile-token"}}}"#;
1779        std::fs::write(directory.path().join("auth.json"), credentials).unwrap();
1780        let rejected = Arc::new(std::sync::atomic::AtomicBool::new(true));
1781        let app = Router::new()
1782            .route(
1783                "/muse-code/key",
1784                post(
1785                    |State(rejected): State<Arc<std::sync::atomic::AtomicBool>>,
1786                     headers: HeaderMap,
1787                     Json(body): Json<Value>| async move {
1788                        assert_eq!(headers["authorization"], "Bearer profile-token");
1789                        assert_eq!(body, serde_json::json!({"onboard": false}));
1790                        if rejected.load(std::sync::atomic::Ordering::SeqCst) {
1791                            return (StatusCode::UNAUTHORIZED, Json(serde_json::json!({})));
1792                        }
1793                        (
1794                            StatusCode::OK,
1795                            Json(serde_json::json!({
1796                                "api_key": "must-not-be-persisted",
1797                                "subs_usage": {
1798                                    "weekly": {"used_percent": 1, "resets_at": 1789344000},
1799                                    "window": {
1800                                        "used_percent": 3,
1801                                        "window_duration_mins": 300,
1802                                        "resets_at": 1788890595
1803                                    }
1804                                }
1805                            })),
1806                        )
1807                    },
1808                ),
1809            )
1810            .with_state(rejected.clone());
1811        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1812        let address = listener.local_addr().unwrap();
1813        let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
1814        let request = QuotaRefreshRequest {
1815            profile_id: "muse".into(),
1816            harness: HarnessKind::Muse,
1817            source_home: directory.path().to_path_buf(),
1818            environment: BTreeMap::from([(
1819                "TBH_MINT_BASE_URL".into(),
1820                format!("http://{address}"),
1821            )]),
1822            cwd: directory.path().to_path_buf(),
1823        };
1824        let mut manager = QuotaManager::default();
1825        manager
1826            .refresh_profiles(vec![request.clone()], |_| async {})
1827            .await;
1828        assert!(manager.reports()["muse"].error.is_some());
1829        rejected.store(false, std::sync::atomic::Ordering::SeqCst);
1830        manager
1831            .refresh_profiles(vec![request], |outcome| async move {
1832                assert!(!outcome.credentials_changed);
1833            })
1834            .await;
1835        let report = &manager.reports()["muse"];
1836        assert_eq!(report.error, None);
1837        assert_eq!(report.extra, None);
1838        assert_eq!(report.weekly_window().unwrap().remaining_percent, Some(99));
1839        assert_eq!(
1840            report.five_hour_window().unwrap().remaining_percent,
1841            Some(97)
1842        );
1843        assert_eq!(
1844            report.weekly_window().unwrap().resets_at_epoch_seconds,
1845            Some(1789344000)
1846        );
1847        assert!(report.weekly_window().unwrap().resets.is_some());
1848        assert!(report.compact().contains("Week 99% left"));
1849        assert_eq!(
1850            std::fs::read(directory.path().join("auth.json")).unwrap(),
1851            credentials
1852        );
1853        manager.shutdown().await;
1854        server.abort();
1855        assert!(server.await.unwrap_err().is_cancelled());
1856    }
1857
1858    #[tokio::test]
1859    async fn deepseek_reports_api_instead_of_inventing_quota() {
1860        let directory = tempfile::tempdir().unwrap();
1861        let (outcome, _) = refresh_profile(
1862            QuotaRefreshRequest {
1863                profile_id: "deepseek".into(),
1864                harness: HarnessKind::Deepseek,
1865                source_home: directory.path().to_path_buf(),
1866                environment: BTreeMap::new(),
1867                cwd: directory.path().to_path_buf(),
1868            },
1869            None,
1870        )
1871        .await;
1872
1873        assert!(outcome.report.windows.is_empty());
1874        assert_eq!(outcome.report.error, None);
1875        assert_eq!(outcome.report.extra.as_deref(), Some(API_LABEL));
1876        assert_eq!(outcome.report.compact(), API_LABEL);
1877    }
1878
1879    #[tokio::test]
1880    async fn expired_claude_credentials_report_login_expired() {
1881        let directory = tempfile::tempdir().unwrap();
1882        std::fs::write(
1883            directory.path().join(".credentials.json"),
1884            serde_json::to_vec(&serde_json::json!({
1885                "claudeAiOauth": {
1886                    "accessToken": "sk-ant-oat01-expired",
1887                    "expiresAt": 1,
1888                }
1889            }))
1890            .unwrap(),
1891        )
1892        .unwrap();
1893
1894        let (outcome, _) = refresh_profile(
1895            QuotaRefreshRequest {
1896                profile_id: "claude2".into(),
1897                harness: HarnessKind::Claude,
1898                source_home: directory.path().to_path_buf(),
1899                environment: BTreeMap::new(),
1900                cwd: directory.path().to_path_buf(),
1901            },
1902            None,
1903        )
1904        .await;
1905        let report = outcome.report;
1906
1907        assert!(report.windows.is_empty());
1908        assert_eq!(report.error.as_deref(), Some(claude_usage::LOGIN_EXPIRED));
1909        assert_eq!(report.compact(), claude_usage::LOGIN_EXPIRED);
1910    }
1911
1912    #[test]
1913    fn a_monthly_window_shares_the_long_window_column_with_a_weekly_one() {
1914        for label in ["Week", "Month"] {
1915            let report = ProfileQuota {
1916                profile_id: "grok".into(),
1917                harness: HarnessKind::Grok,
1918                windows: vec![QuotaWindow {
1919                    label: label.into(),
1920                    remaining_percent: Some(60),
1921                    used: None,
1922                    limit: None,
1923                    resets: None,
1924                    resets_at_epoch_seconds: None,
1925                }],
1926                extra: None,
1927                error: None,
1928                refreshed_at_epoch_seconds: 0,
1929            };
1930
1931            assert!(report.weekly_window().is_some(), "{label}");
1932            assert_eq!(report.compact(), format!("{label} 60% left"));
1933        }
1934    }
1935
1936    #[test]
1937    fn kimi_uses_percent_left_and_hides_a_short_window_on_sustainable_pace() {
1938        let report = ProfileQuota {
1939            profile_id: "kimi".into(),
1940            harness: HarnessKind::Kimi,
1941            windows: vec![
1942                QuotaWindow {
1943                    label: "Week".into(),
1944                    remaining_percent: Some(94),
1945                    used: Some(6),
1946                    limit: Some(100),
1947                    resets: Some("12:22 Aug 18".into()),
1948                    resets_at_epoch_seconds: Some(604_800),
1949                },
1950                QuotaWindow {
1951                    label: "5H".into(),
1952                    remaining_percent: Some(97),
1953                    used: Some(3),
1954                    limit: Some(100),
1955                    resets: Some("10:22 Aug 13".into()),
1956                    resets_at_epoch_seconds: Some(18_000),
1957                },
1958            ],
1959            extra: None,
1960            error: None,
1961            refreshed_at_epoch_seconds: 3_600,
1962        };
1963
1964        assert_eq!(report.compact(), "Week 94% left, resets 12:22 Aug 18");
1965    }
1966
1967    #[test]
1968    fn short_window_is_shown_only_when_burn_rate_projects_early_exhaustion() {
1969        let window = QuotaWindow {
1970            label: "5H".into(),
1971            remaining_percent: Some(70),
1972            used: None,
1973            limit: None,
1974            resets: Some("later".into()),
1975            resets_at_epoch_seconds: Some(14_400),
1976        };
1977        assert!(projects_exhaustion(&window, 0));
1978
1979        let sustainable = QuotaWindow {
1980            remaining_percent: Some(80),
1981            ..window
1982        };
1983        assert!(!projects_exhaustion(&sustainable, 0));
1984    }
1985
1986    #[derive(Clone, Default)]
1987    struct KimiServerState {
1988        refresh_forms: Arc<Mutex<Vec<String>>>,
1989    }
1990
1991    async fn test_kimi_usage(headers: HeaderMap) -> (StatusCode, Json<Value>) {
1992        let accepted = headers
1993            .get(reqwest::header::AUTHORIZATION)
1994            .and_then(|value| value.to_str().ok())
1995            == Some("Bearer fresh-access");
1996        if accepted {
1997            (
1998                StatusCode::OK,
1999                Json(serde_json::json!({
2000                    "usage": {"name": "Weekly", "used": 1, "limit": 100}
2001                })),
2002            )
2003        } else {
2004            (StatusCode::UNAUTHORIZED, Json(serde_json::json!({})))
2005        }
2006    }
2007
2008    async fn test_kimi_refresh(State(state): State<KimiServerState>, body: Bytes) -> Json<Value> {
2009        state
2010            .refresh_forms
2011            .lock()
2012            .unwrap()
2013            .push(String::from_utf8(body.to_vec()).unwrap());
2014        Json(serde_json::json!({
2015            "access_token": "fresh-access",
2016            "refresh_token": "fresh-refresh",
2017            "expires_in": 900,
2018            "scope": "kimi-code",
2019            "token_type": "Bearer"
2020        }))
2021    }
2022
2023    #[tokio::test]
2024    async fn kimi_quota_refreshes_after_unauthorized_and_retries() {
2025        let state = KimiServerState::default();
2026        let app = Router::new()
2027            .route("/coding/v1/usages", get(test_kimi_usage))
2028            .route("/api/oauth/token", post(test_kimi_refresh))
2029            .with_state(state.clone());
2030        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2031        let address = listener.local_addr().unwrap();
2032        let server = tokio::spawn(async move {
2033            axum::serve(listener, app).await.unwrap();
2034        });
2035
2036        let home = tempfile::tempdir().unwrap();
2037        let credentials_path = home.path().join("credentials/kimi-code.json");
2038        tokio::fs::create_dir_all(credentials_path.parent().unwrap())
2039            .await
2040            .unwrap();
2041        let future_expiry = SystemTime::now()
2042            .duration_since(SystemTime::UNIX_EPOCH)
2043            .unwrap()
2044            .as_secs()
2045            + 3_600;
2046        tokio::fs::write(
2047            &credentials_path,
2048            serde_json::to_vec(&serde_json::json!({
2049                "access_token": "rejected-access",
2050                "refresh_token": "old-refresh",
2051                "expires_at": future_expiry,
2052                "scope": "kimi-code",
2053                "token_type": "Bearer",
2054                "expires_in": 900
2055            }))
2056            .unwrap(),
2057        )
2058        .await
2059        .unwrap();
2060        let endpoint = format!("http://{address}");
2061        let environment = HashMap::from([
2062            ("KIMI_CODE_BASE_URL".into(), format!("{endpoint}/coding/v1")),
2063            ("KIMI_CODE_OAUTH_HOST".into(), endpoint),
2064        ]);
2065
2066        let (windows, _) = query_kimi(home.path(), &environment).await.unwrap();
2067
2068        assert_eq!(windows[0].used, Some(1));
2069        let form = {
2070            let forms = state.refresh_forms.lock().unwrap();
2071            assert_eq!(forms.len(), 1);
2072            url::form_urlencoded::parse(forms[0].as_bytes())
2073                .into_owned()
2074                .collect::<HashMap<_, _>>()
2075        };
2076        assert_eq!(
2077            form.get("grant_type").map(String::as_str),
2078            Some("refresh_token")
2079        );
2080        assert_eq!(
2081            form.get("refresh_token").map(String::as_str),
2082            Some("old-refresh")
2083        );
2084        let saved = read_kimi_credentials(&credentials_path).await.unwrap();
2085        assert_eq!(saved.access_token, "fresh-access");
2086        assert_eq!(saved.refresh_token, "fresh-refresh");
2087        assert!(!home.path().join("oauth/kimi-code.lock").exists());
2088        server.abort();
2089    }
2090
2091    /// Backdate the lock directory the way a holder that stopped heartbeating
2092    /// leaves it behind.
2093    fn age_kimi_lock(path: &Path, age: Duration) {
2094        touch_kimi_lock(path, SystemTime::now() - age).expect("backdate lock directory");
2095    }
2096
2097    #[tokio::test]
2098    async fn a_kimi_refresh_lock_left_by_a_crashed_holder_is_broken_and_reacquired() {
2099        let home = tempfile::tempdir().unwrap();
2100        let lock = home.path().join("oauth/kimi-code.lock");
2101        std::fs::create_dir_all(&lock).unwrap();
2102        age_kimi_lock(&lock, KIMI_LOCK_STALE_AFTER + Duration::from_secs(60));
2103
2104        let started = std::time::Instant::now();
2105        let held = KimiRefreshLock::acquire_within(home.path(), Duration::from_secs(10))
2106            .await
2107            .expect("an orphaned lock must not block a refresh");
2108        let waited = started.elapsed();
2109
2110        assert!(
2111            waited < Duration::from_secs(5),
2112            "acquisition waited {waited:?}"
2113        );
2114        held.release().await.expect("release an uncontested lock");
2115        assert!(!lock.exists(), "the released lock must be gone");
2116    }
2117
2118    #[tokio::test]
2119    async fn a_heartbeating_kimi_refresh_lock_is_not_broken_by_a_waiter() {
2120        let home = tempfile::tempdir().unwrap();
2121        let lock = home.path().join("oauth/kimi-code.lock");
2122        std::fs::create_dir_all(&lock).unwrap();
2123
2124        let error = KimiRefreshLock::acquire_within(home.path(), Duration::from_millis(600))
2125            .await
2126            .err()
2127            .expect("a lock with a live holder must be waited out, not stolen");
2128
2129        assert!(
2130            error.to_string().contains("kimi-code.lock"),
2131            "the timeout must name the lock: {error}"
2132        );
2133        assert!(lock.exists(), "a live holder's lock must survive a waiter");
2134    }
2135
2136    /// The Kimi Code CLI breaks a lock whose modification time is more than
2137    /// `KIMI_CLI_LOCK_STALE_AFTER` old, so Mjolnir's beats have to be frequent
2138    /// enough that a stalled heartbeat task still cannot cost it a live lock.
2139    #[tokio::test]
2140    async fn a_held_kimi_lock_republishes_its_mtime_several_times_per_cli_break_window() {
2141        let home = tempfile::tempdir().unwrap();
2142        let held = KimiRefreshLock::acquire(home.path()).await.unwrap();
2143        let lock = home.path().join("oauth/kimi-code.lock");
2144
2145        // Half the peer's break window: two beats have to land inside it, so
2146        // Mjolnir publishes at least four times per window and can miss several in
2147        // a row and still hold the lock.
2148        let watched = KIMI_CLI_LOCK_STALE_AFTER / 2;
2149        let deadline = tokio::time::Instant::now() + watched;
2150        let mut published = vec![kimi_lock_mtime(&lock).unwrap().expect("the created lock")];
2151        while tokio::time::Instant::now() < deadline {
2152            tokio::time::sleep(Duration::from_millis(50)).await;
2153            let observed = kimi_lock_mtime(&lock).unwrap().expect("a held lock");
2154            if published.last() != Some(&observed) {
2155                published.push(observed);
2156            }
2157            assert_eq!(
2158                std::fs::read_dir(&lock).unwrap().count(),
2159                0,
2160                "the CLI releases this lock with a plain rmdir, so it must stay empty"
2161            );
2162        }
2163
2164        assert!(
2165            published.len() >= 3,
2166            "the lock's modification time moved {} times in {watched:?}; the Kimi Code CLI breaks a lock after {KIMI_CLI_LOCK_STALE_AFTER:?} without a beat",
2167            published.len() - 1
2168        );
2169        held.release().await.expect("release an uncontested lock");
2170    }
2171
2172    #[tokio::test]
2173    async fn a_stolen_kimi_refresh_lock_is_left_to_its_new_holder_and_fails_the_refresh() {
2174        let home = tempfile::tempdir().unwrap();
2175        let held = KimiRefreshLock::acquire(home.path()).await.unwrap();
2176        let lock = home.path().join("oauth/kimi-code.lock");
2177
2178        // The Kimi Code CLI breaks a lock it judges stale and takes it over:
2179        // rmdir, mkdir, then its own modification time. The CLI dates those
2180        // ahead of the clock; date this one far enough ahead that only the new
2181        // holder could have written it.
2182        std::fs::remove_dir(&lock).unwrap();
2183        std::fs::create_dir(&lock).unwrap();
2184        let thief = SystemTime::now() + Duration::from_secs(30);
2185        touch_kimi_lock(&lock, thief).unwrap();
2186
2187        // Mjolnir must stop beating: a touch on the CLI's lock trips the CLI's own
2188        // mtime ownership check and it abandons its refresh with ECOMPROMISED.
2189        tokio::time::sleep(2 * KIMI_LOCK_HEARTBEAT_INTERVAL + Duration::from_millis(400)).await;
2190        let observed = kimi_lock_mtime(&lock)
2191            .unwrap()
2192            .expect("the new holder's lock");
2193        assert!(
2194            observed.duration_since(SystemTime::now()).is_ok(),
2195            "Mjolnir kept heartbeating a lock it no longer holds: the directory carries {} instead of the new holder's {}",
2196            epoch_label(observed),
2197            epoch_label(thief)
2198        );
2199
2200        let error = held
2201            .release()
2202            .await
2203            .expect_err("a refresh that lost its lock must fail loudly");
2204        let message = error.to_string();
2205        assert!(message.contains("kimi-code.lock"), "{message}");
2206        assert!(message.contains("another process took it"), "{message}");
2207        assert!(
2208            lock.exists(),
2209            "Mjolnir must not remove a lock another holder owns"
2210        );
2211    }
2212
2213    #[tokio::test]
2214    async fn a_kimi_refresh_lock_removed_underneath_hel_fails_the_refresh() {
2215        let home = tempfile::tempdir().unwrap();
2216        let held = KimiRefreshLock::acquire(home.path()).await.unwrap();
2217        let lock = home.path().join("oauth/kimi-code.lock");
2218        std::fs::remove_dir(&lock).unwrap();
2219
2220        let error = held
2221            .release()
2222            .await
2223            .expect_err("a refresh that lost its lock must fail loudly");
2224
2225        assert!(
2226            error.to_string().contains("another process removed it"),
2227            "{error}"
2228        );
2229        assert!(!lock.exists(), "Mjolnir must not recreate a lock it lost");
2230    }
2231
2232    fn kimi_pair(access: &str, refresh: &str, expires_at: i64) -> KimiCredentials {
2233        KimiCredentials {
2234            access_token: access.into(),
2235            refresh_token: refresh.into(),
2236            expires_at,
2237            scope: "kimi-code".into(),
2238            token_type: "Bearer".into(),
2239            expires_in: 900,
2240        }
2241    }
2242
2243    fn a_stolen_lock() -> KimiLockLoss {
2244        KimiLockLoss::Stolen {
2245            published: SystemTime::UNIX_EPOCH,
2246            observed: SystemTime::UNIX_EPOCH + Duration::from_secs(30),
2247        }
2248    }
2249
2250    #[test]
2251    fn a_lock_still_held_saves_the_refreshed_pair_and_releases() {
2252        let active = kimi_pair("spent-access", "spent-refresh", 100);
2253
2254        assert_eq!(
2255            decide_kimi_refresh_persist(&Ok(SystemTime::now()), None, &active),
2256            KimiRefreshPersist::Save
2257        );
2258    }
2259
2260    #[test]
2261    fn an_unprovable_ownership_check_still_saves_the_refreshed_pair() {
2262        let active = kimi_pair("spent-access", "spent-refresh", 100);
2263        let unproven = Err(KimiLockLoss::Unproven("stat failed".into()));
2264
2265        assert_eq!(
2266            decide_kimi_refresh_persist(&unproven, None, &active),
2267            KimiRefreshPersist::Save,
2268            "a failed stat proves nothing about the lock and must not discard valid tokens"
2269        );
2270    }
2271
2272    #[test]
2273    fn a_stolen_lock_adopts_the_thiefs_newer_credentials() {
2274        let active = kimi_pair("spent-access", "spent-refresh", 100);
2275        let thiefs = kimi_pair("thief-access", "thief-refresh", 900);
2276
2277        assert_eq!(
2278            decide_kimi_refresh_persist(&Err(a_stolen_lock()), Some(&thiefs), &active),
2279            KimiRefreshPersist::Adopt {
2280                access_token: "thief-access".into(),
2281                loss: a_stolen_lock(),
2282            }
2283        );
2284    }
2285
2286    #[test]
2287    fn a_removed_lock_adopts_the_newer_credentials_left_on_disk() {
2288        let active = kimi_pair("spent-access", "spent-refresh", 100);
2289        let thiefs = kimi_pair("thief-access", "thief-refresh", 900);
2290
2291        assert_eq!(
2292            decide_kimi_refresh_persist(&Err(KimiLockLoss::Gone), Some(&thiefs), &active),
2293            KimiRefreshPersist::Adopt {
2294                access_token: "thief-access".into(),
2295                loss: KimiLockLoss::Gone,
2296            }
2297        );
2298    }
2299
2300    /// The pair on disk is dead whoever wrote it: its refresh token is the one
2301    /// this refresh just spent at the server.
2302    #[test]
2303    fn a_stolen_lock_saves_hels_pair_over_the_spent_one_on_disk() {
2304        let active = kimi_pair("spent-access", "spent-refresh", 100);
2305        let on_disk = active.clone();
2306
2307        assert_eq!(
2308            decide_kimi_refresh_persist(&Err(a_stolen_lock()), Some(&on_disk), &active),
2309            KimiRefreshPersist::SaveContested(a_stolen_lock())
2310        );
2311    }
2312
2313    #[test]
2314    fn an_unreadable_credentials_file_after_a_lost_lock_saves_hels_pair() {
2315        let active = kimi_pair("spent-access", "spent-refresh", 100);
2316
2317        assert_eq!(
2318            decide_kimi_refresh_persist(&Err(KimiLockLoss::Gone), None, &active),
2319            KimiRefreshPersist::SaveContested(KimiLockLoss::Gone),
2320            "nothing readable is newer, so the refreshed pair is the only live one"
2321        );
2322    }
2323
2324    #[test]
2325    fn any_rotated_field_marks_the_disk_pair_as_the_other_refreshers() {
2326        let active = kimi_pair("spent-access", "spent-refresh", 100);
2327        let rotations = [
2328            kimi_pair("other-access", "spent-refresh", 100),
2329            kimi_pair("spent-access", "other-refresh", 100),
2330            kimi_pair("spent-access", "spent-refresh", 900),
2331        ];
2332
2333        for on_disk in &rotations {
2334            assert!(
2335                matches!(
2336                    decide_kimi_refresh_persist(&Err(a_stolen_lock()), Some(on_disk), &active),
2337                    KimiRefreshPersist::Adopt { .. }
2338                ),
2339                "{on_disk:?} is a rotated pair, not the spent one"
2340            );
2341        }
2342    }
2343
2344    #[test]
2345    fn a_disk_pair_that_differs_only_in_description_is_still_the_spent_one() {
2346        let active = kimi_pair("spent-access", "spent-refresh", 100);
2347        let on_disk = KimiCredentials {
2348            scope: "kimi-code extra".into(),
2349            token_type: "bearer".into(),
2350            expires_in: 1_800,
2351            ..active.clone()
2352        };
2353
2354        assert_eq!(
2355            decide_kimi_refresh_persist(&Err(a_stolen_lock()), Some(&on_disk), &active),
2356            KimiRefreshPersist::SaveContested(a_stolen_lock())
2357        );
2358    }
2359
2360    #[derive(Clone)]
2361    struct KimiThiefState {
2362        home: std::path::PathBuf,
2363        /// The pair the lock's new holder stores before Mjolnir's own refresh
2364        /// returns, when it got that far.
2365        winner: Option<Value>,
2366    }
2367
2368    /// Answer the refresh, but take the lock over first the way the Kimi Code
2369    /// CLI takes over one it judges stale: rmdir, mkdir, then a modification
2370    /// time of its own, dated ahead of the clock as the CLI dates its locks.
2371    async fn test_kimi_refresh_stealing_the_lock(
2372        State(state): State<KimiThiefState>,
2373        _body: Bytes,
2374    ) -> Json<Value> {
2375        let lock = state.home.join("oauth/kimi-code.lock");
2376        std::fs::remove_dir(&lock).unwrap();
2377        std::fs::create_dir(&lock).unwrap();
2378        touch_kimi_lock(&lock, SystemTime::now() + Duration::from_secs(30)).unwrap();
2379        if let Some(winner) = &state.winner {
2380            std::fs::write(
2381                state.home.join("credentials/kimi-code.json"),
2382                serde_json::to_vec(winner).unwrap(),
2383            )
2384            .unwrap();
2385        }
2386        Json(serde_json::json!({
2387            "access_token": "hel-access",
2388            "refresh_token": "hel-refresh",
2389            "expires_in": 900,
2390            "scope": "kimi-code",
2391            "token_type": "Bearer"
2392        }))
2393    }
2394
2395    /// Serve one refresh that steals the lock while it is in flight, and hand
2396    /// back what `ensure_fresh_kimi_token` made of it.
2397    async fn refresh_against_a_lock_thief(
2398        home: &Path,
2399        winner: Option<Value>,
2400    ) -> (Result<String>, std::path::PathBuf) {
2401        let credentials_path = home.join("credentials/kimi-code.json");
2402        tokio::fs::create_dir_all(credentials_path.parent().unwrap())
2403            .await
2404            .unwrap();
2405        let soon = SystemTime::now()
2406            .duration_since(SystemTime::UNIX_EPOCH)
2407            .unwrap()
2408            .as_secs() as i64
2409            + 10;
2410        tokio::fs::write(
2411            &credentials_path,
2412            serde_json::to_vec(&serde_json::json!({
2413                "access_token": "spent-access",
2414                "refresh_token": "spent-refresh",
2415                "expires_at": soon,
2416                "scope": "kimi-code",
2417                "token_type": "Bearer",
2418                "expires_in": 900
2419            }))
2420            .unwrap(),
2421        )
2422        .await
2423        .unwrap();
2424
2425        let app = Router::new()
2426            .route(
2427                "/api/oauth/token",
2428                post(test_kimi_refresh_stealing_the_lock),
2429            )
2430            .with_state(KimiThiefState {
2431                home: home.to_path_buf(),
2432                winner,
2433            });
2434        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2435        let address = listener.local_addr().unwrap();
2436        let server = tokio::spawn(async move {
2437            axum::serve(listener, app).await.unwrap();
2438        });
2439        let environment =
2440            HashMap::from([("KIMI_CODE_OAUTH_HOST".into(), format!("http://{address}"))]);
2441
2442        let token = ensure_fresh_kimi_token(
2443            &reqwest::Client::new(),
2444            home,
2445            &credentials_path,
2446            &environment,
2447            false,
2448            None,
2449        )
2450        .await;
2451        server.abort();
2452        (token, credentials_path)
2453    }
2454
2455    #[tokio::test]
2456    async fn a_refresh_that_loses_its_lock_returns_the_winners_stored_token() {
2457        let home = tempfile::tempdir().unwrap();
2458        let winner = serde_json::json!({
2459            "access_token": "winner-access",
2460            "refresh_token": "winner-refresh",
2461            "expires_at": 4_102_444_800i64,
2462            "scope": "kimi-code",
2463            "token_type": "Bearer",
2464            "expires_in": 900
2465        });
2466
2467        let (token, credentials_path) =
2468            refresh_against_a_lock_thief(home.path(), Some(winner)).await;
2469
2470        assert_eq!(
2471            token.unwrap(),
2472            "winner-access",
2473            "a contested lock must not fail a refresh when a live token exists"
2474        );
2475        let saved = read_kimi_credentials(&credentials_path).await.unwrap();
2476        assert_eq!(
2477            saved.access_token, "winner-access",
2478            "Mjolnir must not clobber the credentials the lock's new holder stored"
2479        );
2480        assert!(
2481            home.path().join("oauth/kimi-code.lock").exists(),
2482            "Mjolnir must not remove a lock another holder owns"
2483        );
2484    }
2485
2486    /// The pair the thief left behind is the one this refresh already spent, so
2487    /// it is dead: only Mjolnir's pair can still authenticate, and storing it is
2488    /// what keeps the peer's own recovery re-read working.
2489    #[tokio::test]
2490    async fn a_refresh_that_loses_its_lock_saves_its_pair_over_the_spent_one() {
2491        let home = tempfile::tempdir().unwrap();
2492
2493        let (token, credentials_path) = refresh_against_a_lock_thief(home.path(), None).await;
2494
2495        assert_eq!(token.unwrap(), "hel-access");
2496        let saved = read_kimi_credentials(&credentials_path).await.unwrap();
2497        assert_eq!(
2498            saved.access_token, "hel-access",
2499            "leaving the spent pair on disk would force a `kimi login`"
2500        );
2501        assert_eq!(saved.refresh_token, "hel-refresh");
2502        assert!(
2503            home.path().join("oauth/kimi-code.lock").exists(),
2504            "Mjolnir must not remove a lock another holder owns"
2505        );
2506    }
2507
2508    /// The child is reaped by the shutdown, so a live pid means it was never
2509    /// stopped.
2510    #[cfg(unix)]
2511    fn process_is_gone(pid: i32) -> bool {
2512        // SAFETY: signal 0 only probes whether the process exists.
2513        unsafe { libc::kill(pid, 0) != 0 }
2514    }
2515
2516    #[cfg(unix)]
2517    #[tokio::test]
2518    async fn dropping_a_profile_from_the_configuration_stops_its_codex_quota_client() {
2519        use std::os::unix::fs::PermissionsExt;
2520
2521        let directory = tempfile::tempdir().unwrap();
2522        let executable = directory.path().join("codex");
2523        let pid_file = directory.path().join("codex.pid");
2524        // A `codex app-server` stand-in: answer one quota refresh, then stay
2525        // alive on stdin the way the real one does between refreshes.
2526        std::fs::write(
2527            &executable,
2528            r#"#!/bin/sh
2529printf '%s\n' "$$" > "$CODEX_QUOTA_TEST_PID"
2530IFS= read -r line || exit 0
2531printf '%s\n' '{"id":1,"result":{}}'
2532IFS= read -r line || exit 0
2533IFS= read -r line || exit 0
2534printf '%s\n' '{"id":2,"result":{"account":{"type":"chatgpt"}}}'
2535IFS= read -r line || exit 0
2536printf '%s\n' '{"id":3,"result":{"rateLimits":{"primary":{"usedPercent":25,"windowDurationMins":300}}}}'
2537while IFS= read -r line; do :; done
2538"#,
2539        )
2540        .unwrap();
2541        std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap();
2542        let request = QuotaRefreshRequest {
2543            profile_id: "codex-1".into(),
2544            harness: HarnessKind::Codex,
2545            source_home: directory.path().to_path_buf(),
2546            environment: BTreeMap::from([
2547                (
2548                    "PATH".to_owned(),
2549                    directory.path().to_string_lossy().into_owned(),
2550                ),
2551                (
2552                    "CODEX_QUOTA_TEST_PID".to_owned(),
2553                    pid_file.to_string_lossy().into_owned(),
2554                ),
2555            ]),
2556            cwd: directory.path().to_path_buf(),
2557        };
2558
2559        let mut quotas = QuotaManager::default();
2560        quotas.refresh_profiles(vec![request], |_| async {}).await;
2561
2562        assert_eq!(
2563            quotas.reports()["codex-1"].error,
2564            None,
2565            "the stand-in app-server must answer the quota query"
2566        );
2567        let pid = std::fs::read_to_string(&pid_file)
2568            .unwrap()
2569            .trim()
2570            .parse::<i32>()
2571            .unwrap();
2572        assert!(
2573            !process_is_gone(pid),
2574            "the app-server child is cached between refreshes"
2575        );
2576
2577        // The profile leaves the configuration, so the next batch no longer
2578        // carries it.
2579        quotas.refresh_profiles(Vec::new(), |_| async {}).await;
2580
2581        assert!(
2582            process_is_gone(pid),
2583            "a profile removed from the configuration must not leave its `codex app-server` child running"
2584        );
2585        quotas.shutdown().await;
2586    }
2587
2588    #[test]
2589    fn reset_time_normalization_uses_24_hour_month_day_format() {
2590        let paris = FixedOffset::east_opt(2 * 3_600).expect("offset");
2591        let reset = paris
2592            .with_ymd_and_hms(2026, 6, 17, 16, 49, 0)
2593            .single()
2594            .expect("instant");
2595        assert_eq!(format_reset_label(reset), "16:49 Jun 17");
2596        assert_eq!(
2597            normalize_reset_text("Jun 17 at 4:49pm").as_deref(),
2598            Some("16:49 Jun 17")
2599        );
2600    }
2601
2602    #[test]
2603    fn reset_timestamp_accepts_seconds_and_milliseconds() {
2604        let seconds = 1_781_712_540_f64;
2605        assert_eq!(
2606            format_reset_local(seconds),
2607            format_reset_local(seconds * 1_000.0)
2608        );
2609        assert_eq!(
2610            format_reset_local(seconds),
2611            format_reset_local_seconds(seconds as i64)
2612        );
2613    }
2614
2615    #[test]
2616    fn time_only_reset_is_rendered_as_the_next_datetime() {
2617        let zone = FixedOffset::west_opt(5 * 3_600).expect("offset");
2618        let now = zone
2619            .with_ymd_and_hms(2026, 8, 10, 14, 0, 0)
2620            .single()
2621            .expect("now");
2622        assert_eq!(
2623            normalize_reset_at("3:30 PM (America/Chicago)", now)
2624                .map(format_reset_label)
2625                .as_deref(),
2626            Some("15:30 Aug 10")
2627        );
2628        assert_eq!(
2629            normalize_reset_at("at 1pm (America/Chicago)", now)
2630                .map(format_reset_label)
2631                .as_deref(),
2632            Some("13:00 Aug 11")
2633        );
2634    }
2635
2636    #[test]
2637    fn claude_comma_separated_reset_is_normalized() {
2638        let zone = FixedOffset::west_opt(5 * 3_600).expect("offset");
2639        let now = zone
2640            .with_ymd_and_hms(2026, 8, 11, 7, 0, 0)
2641            .single()
2642            .expect("now");
2643        assert_eq!(
2644            normalize_reset_at("Aug 14, 4am (America/Chicago)", now)
2645                .map(format_reset_label)
2646                .as_deref(),
2647            Some("04:00 Aug 14")
2648        );
2649    }
2650}