Prune commentary and stale Rust port notes
This commit is contained in:
1 parent
5428cd75c9
commit
25370731d0
193 files changed
+693
-16219
No files matched your search
+51
-249
@@ -1,34 +1,3 @@
|
||||
//! Usage-limit reporting -- the same numbers as Claude Code's `/usage`.
|
||||
//!
|
||||
//! Polls `https://api.anthropic.com/api/oauth/usage` with the OAuth access
|
||||
//! token from Claude Code's local credential store. The endpoint is
|
||||
//! undocumented and has changed before, so everything here is best-effort:
|
||||
//! every field is optional, and failure degrades to an "unavailable" snapshot
|
||||
//! with the reason, never an error that breaks the screen.
|
||||
//!
|
||||
//! Two rules learned from others hitting this endpoint: send `User-Agent:
|
||||
//! claude-code/<version>` (without it, requests land in an aggressively
|
||||
//! rate-limited bucket) and poll no more often than every 180 s. The cache
|
||||
//! below enforces the latter across any number of phone refreshes; there is no
|
||||
//! background poll at all.
|
||||
//!
|
||||
//! One [`UsageProvider`] per paid service, so a second service later is a new
|
||||
//! impl behind the same snapshot shape, not a parallel screen.
|
||||
//!
|
||||
//! **Asked of the machine that spends the tokens, not of this one.** A session
|
||||
//! runs wherever its setup says, so the account being billed is that machine's.
|
||||
//! In the layout this project aims at, `ai-server` is on the host, the host has
|
||||
//! no `claude` CLI, and the CLI machine is a remote -- so the one set of numbers
|
||||
//! the screen could show would be an account with no sessions. Credentials are
|
||||
//! read through the session `Transport`, one snapshot per setup that offers
|
||||
//! Claude.
|
||||
//!
|
||||
//! The token is read *to* the backend and the HTTP call is made from here, so
|
||||
//! the far machine needs nothing beyond a shell and the wire format stays in
|
||||
//! one place. The cost is that a remote machine's token is in this process's
|
||||
//! memory for the length of a fetch, which is the same trust the backend
|
||||
//! already has over that machine.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -44,31 +13,22 @@ const MIN_POLL_INTERVAL: Duration = Duration::from_secs(180);
|
||||
/// Matched to the CLI version the wire formats were pinned against.
|
||||
const USER_AGENT: &str = "claude-code/2.1.237";
|
||||
|
||||
/// One rate-limit window, as the phone renders it: a labeled bar.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UsageWindow {
|
||||
/// The API's own word for which window this is -- `session` for the
|
||||
/// five-hour one, `weekly_all`, `weekly_scoped`, or whatever new kind it
|
||||
/// starts sending.
|
||||
///
|
||||
/// Carried beside the label because a caller that wants one particular
|
||||
/// window has to ask for it without matching on display text: the label is
|
||||
/// written for a person and would silently select nothing the day it
|
||||
/// changes.
|
||||
pub kind: String,
|
||||
pub label: String,
|
||||
/// 0-100.
|
||||
pub percent: f64,
|
||||
/// ISO-8601, as the API sends it; absent for windows that never reset.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resets_at: Option<String>,
|
||||
/// Whether this window is currently the binding one.
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
/// What came back when a machine was asked about its limits.
|
||||
///
|
||||
/// Four answers rather than a flag and a message, because the screen has to
|
||||
/// treat them differently. "Nobody is logged in here" is a machine working
|
||||
/// exactly as configured, while "I could not reach it" is a fault worth
|
||||
@@ -78,14 +38,9 @@ pub struct UsageWindow {
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
#[serde(tag = "state", rename_all = "camelCase")]
|
||||
pub enum UsageState {
|
||||
/// Numbers were fetched; `windows` has them.
|
||||
Ok,
|
||||
/// The machine answered and has no Claude credentials. A choice, not a
|
||||
/// fault: nothing to report and nothing to fix.
|
||||
NotLoggedIn,
|
||||
/// The machine could not be asked at all.
|
||||
Unreachable { detail: String },
|
||||
/// The machine is logged in, but the usage endpoint did not answer.
|
||||
Failed { detail: String },
|
||||
}
|
||||
|
||||
@@ -93,11 +48,7 @@ pub enum UsageState {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UsageSnapshot {
|
||||
pub provider: String,
|
||||
/// Which machine these are the numbers for. The point of the whole module:
|
||||
/// they belong to an account on a particular box.
|
||||
pub setup: String,
|
||||
/// That machine's current label, resolved when the snapshot is built, so
|
||||
/// renaming a setup renames it here too.
|
||||
pub setup_name: String,
|
||||
#[serde(flatten)]
|
||||
pub state: UsageState,
|
||||
@@ -112,16 +63,11 @@ pub struct UsageSnapshot {
|
||||
/// labels a row with, and [`crate::config::DriverKind::usage_provider`],
|
||||
/// which is how a session says which of those rows is about it.
|
||||
pub const CLAUDE: &str = "claude";
|
||||
/// The invented one, for testing the screens that draw these -- see
|
||||
/// [`Fixture`].
|
||||
pub const ECHO: &str = "echo";
|
||||
|
||||
pub trait UsageProvider: Send + Sync {
|
||||
fn name(&self) -> &'static str;
|
||||
/// Blocking -- call off the async workers.
|
||||
fn fetch(&self) -> UsageSnapshot;
|
||||
/// How long an answer from this one may be reused.
|
||||
///
|
||||
/// A property of the provider rather than of the cache, because what
|
||||
/// sets it is what asking costs: [`ClaudeUsage`] makes a network call
|
||||
/// against an endpoint that rate-limits impatient callers, and the
|
||||
@@ -133,23 +79,13 @@ pub trait UsageProvider: Send + Sync {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the numbers behind Claude Code's `/usage` from one machine, using the
|
||||
/// credentials that machine stores -- nothing to configure, and it reports on
|
||||
/// exactly the account whose CLI runs the sessions there.
|
||||
pub struct ClaudeUsage {
|
||||
pub setup: String,
|
||||
pub setup_name: String,
|
||||
/// How to reach that machine. `Here` for the backend's own.
|
||||
pub transport: Transport,
|
||||
/// The CLI to run there, for the one thing this asks of it: refreshing its
|
||||
/// own expired token. The provider's, so a machine with the CLI somewhere
|
||||
/// odd is asked at the same path its sessions run.
|
||||
pub program: String,
|
||||
}
|
||||
|
||||
/// Where Claude Code keeps its credentials, as a shell word rather than a path:
|
||||
/// `$HOME` is expanded by the shell on the machine being asked, which is the
|
||||
/// only place that knows what it is.
|
||||
const CREDENTIALS: &str = "$HOME/.claude/.credentials.json";
|
||||
|
||||
impl ClaudeUsage {
|
||||
@@ -164,9 +100,6 @@ impl ClaudeUsage {
|
||||
}
|
||||
}
|
||||
|
||||
/// The machine's stored OAuth token, or which of the two ways of not having
|
||||
/// one this is. Read through `sh -c` so `$HOME` resolves on the far machine;
|
||||
/// a path built here would be this machine's home directory.
|
||||
fn access_token(&self) -> Result<String, UsageState> {
|
||||
let launch = Launch::new(
|
||||
"sh",
|
||||
@@ -186,10 +119,59 @@ impl ClaudeUsage {
|
||||
.as_str()
|
||||
.map(String::from)
|
||||
})
|
||||
// A file that exists but carries no token is the same situation as
|
||||
// no file: nobody has logged in here yet.
|
||||
.ok_or(UsageState::NotLoggedIn)
|
||||
}
|
||||
|
||||
fn call(&self, token: &str) -> Result<Value, Refused> {
|
||||
let text = ureq::get(USAGE_URL)
|
||||
.header("Authorization", &format!("Bearer {token}"))
|
||||
.header("anthropic-beta", "oauth-2025-04-20")
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.call()
|
||||
.and_then(|mut response| response.body_mut().read_to_string())
|
||||
// The error string can embed the URL but never the token.
|
||||
.map_err(|err| match err {
|
||||
ureq::Error::StatusCode(401) => Refused::Unauthorized,
|
||||
other => Refused::Other(why(&other)),
|
||||
})?;
|
||||
serde_json::from_str(&text)
|
||||
.map_err(|err| Refused::Other(format!("usage endpoint sent non-JSON: {err}")))
|
||||
}
|
||||
|
||||
/// **The CLI does the refresh, never this.** Anthropic's OAuth rotates the
|
||||
/// refresh token, so whoever refreshes second presents a dead one and the
|
||||
/// machine is logged out until somebody runs `/login` on it -- and the
|
||||
/// machine we would be refreshing on is usually one with a live session of
|
||||
/// its own. Running the CLI keeps it the only writer of
|
||||
/// `.credentials.json`.
|
||||
fn after_cli_refresh(&self, stale: &str) -> Result<Value, UsageState> {
|
||||
let launch = Launch::new(&self.program, vec!["doctor".to_string()], None);
|
||||
if let Err(err) = self.transport.capture_blocking(&launch) {
|
||||
return Err(UsageState::Failed {
|
||||
detail: format!(
|
||||
"the Claude login on {} has expired, and `{} doctor` couldn't be run there to refresh it: {err:#}",
|
||||
self.setup_name, self.program
|
||||
),
|
||||
});
|
||||
}
|
||||
let fresh = self.access_token()?;
|
||||
if fresh == stale {
|
||||
return Err(self.still_expired());
|
||||
}
|
||||
self.call(&fresh).map_err(|err| match err {
|
||||
Refused::Unauthorized => self.still_expired(),
|
||||
Refused::Other(detail) => UsageState::Failed { detail },
|
||||
})
|
||||
}
|
||||
|
||||
fn still_expired(&self) -> UsageState {
|
||||
UsageState::Failed {
|
||||
detail: format!(
|
||||
"the Claude login on {} has expired and could not be refreshed; run `{} /login` there",
|
||||
self.setup_name, self.program
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl UsageProvider for ClaudeUsage {
|
||||
@@ -216,8 +198,6 @@ impl UsageProvider for ClaudeUsage {
|
||||
}
|
||||
}
|
||||
|
||||
/// Why one call to the usage endpoint did not produce numbers.
|
||||
///
|
||||
/// 401 is apart from the rest because it is the only one with a way out: the
|
||||
/// endpoint answered, and it means the access token has expired rather than
|
||||
/// that anything is broken.
|
||||
@@ -226,79 +206,6 @@ enum Refused {
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl ClaudeUsage {
|
||||
/// One call to the endpoint with one token.
|
||||
fn call(&self, token: &str) -> Result<Value, Refused> {
|
||||
let text = ureq::get(USAGE_URL)
|
||||
.header("Authorization", &format!("Bearer {token}"))
|
||||
.header("anthropic-beta", "oauth-2025-04-20")
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.call()
|
||||
.and_then(|mut response| response.body_mut().read_to_string())
|
||||
// The error string can embed the URL but never the token.
|
||||
.map_err(|err| match err {
|
||||
ureq::Error::StatusCode(401) => Refused::Unauthorized,
|
||||
other => Refused::Other(why(&other)),
|
||||
})?;
|
||||
serde_json::from_str(&text)
|
||||
.map_err(|err| Refused::Other(format!("usage endpoint sent non-JSON: {err}")))
|
||||
}
|
||||
|
||||
/// Have the machine's own CLI refresh its token, then ask once more.
|
||||
///
|
||||
/// **The CLI does the refresh, never this.** Anthropic's OAuth rotates the
|
||||
/// refresh token, so whoever refreshes second presents a dead one and the
|
||||
/// machine is logged out until somebody runs `/login` on it -- and the
|
||||
/// machine we would be refreshing on is usually one with a live session of
|
||||
/// its own. Running the CLI keeps it the only writer of
|
||||
/// `.credentials.json`.
|
||||
///
|
||||
/// `doctor` rather than the `auth status` it reads like, measured against
|
||||
/// this CLI (2.1.258) on 2026-09-05 with a deliberately invalid token:
|
||||
/// `auth status` reports `loggedIn: true` off the file alone and never
|
||||
/// touches the network, so it would have refreshed nothing while looking
|
||||
/// like it had. `doctor` resolves the account, which is what makes it
|
||||
/// refresh, and it spends no quota. The same probe showed what a *failed*
|
||||
/// refresh does -- the CLI blanks both tokens -- so this must stay on the
|
||||
/// 401 path, where the access token is already dead, and never be used to
|
||||
/// refresh speculatively.
|
||||
///
|
||||
/// Only a token that actually changed is retried, so a CLI that refreshed
|
||||
/// nothing costs one call rather than two, and this cannot become a loop.
|
||||
fn after_cli_refresh(&self, stale: &str) -> Result<Value, UsageState> {
|
||||
let launch = Launch::new(&self.program, vec!["doctor".to_string()], None);
|
||||
if let Err(err) = self.transport.capture_blocking(&launch) {
|
||||
return Err(UsageState::Failed {
|
||||
detail: format!(
|
||||
"the Claude login on {} has expired, and `{} doctor` couldn't be run there to refresh it: {err:#}",
|
||||
self.setup_name, self.program
|
||||
),
|
||||
});
|
||||
}
|
||||
let fresh = self.access_token()?;
|
||||
if fresh == stale {
|
||||
return Err(self.still_expired());
|
||||
}
|
||||
self.call(&fresh).map_err(|err| match err {
|
||||
Refused::Unauthorized => self.still_expired(),
|
||||
Refused::Other(detail) => UsageState::Failed { detail },
|
||||
})
|
||||
}
|
||||
|
||||
/// A login the CLI could not renew: the one state here somebody has to act
|
||||
/// on, so it says where and what to run.
|
||||
fn still_expired(&self) -> UsageState {
|
||||
UsageState::Failed {
|
||||
detail: format!(
|
||||
"the Claude login on {} has expired and could not be refreshed; run `{} /login` there",
|
||||
self.setup_name, self.program
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a failed call to the usage endpoint should say.
|
||||
///
|
||||
/// A status is not a network fault and must not be reported as one: the
|
||||
/// endpoint answered. 401 never reaches here -- it has its own way out in
|
||||
/// [`ClaudeUsage::after_cli_refresh`] -- so what is left is a refusal nobody
|
||||
@@ -310,19 +217,9 @@ fn why(err: &ureq::Error) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Which kind of "no credentials" a failed read was.
|
||||
///
|
||||
/// The distinction is the point of having both states. `cat` failing because
|
||||
/// the file is not there is a machine nobody has logged in on -- a decision
|
||||
/// somebody made, with nothing to fix. Anything else is a machine this server
|
||||
/// could not ask, which is a fault and reads as one.
|
||||
///
|
||||
/// Matched on the shell's own words rather than an exit status because there is
|
||||
/// only one that survives being wrapped in `sh -c` and passed back through ssh.
|
||||
fn why_no_credentials(detail: &str) -> UsageState {
|
||||
// "No such file or directory" is GNU and BSD coreutils; busybox says "can't
|
||||
// open". Anything unrecognised is treated as unreachable, which is the
|
||||
// answer that gets looked at rather than ignored.
|
||||
let missing = ["No such file", "no such file", "can't open", "cannot open"];
|
||||
if missing.iter().any(|phrase| detail.contains(phrase)) {
|
||||
UsageState::NotLoggedIn
|
||||
@@ -333,9 +230,6 @@ fn why_no_credentials(detail: &str) -> UsageState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pulls the `limits` array apart, defensively: entries with no percent are
|
||||
/// skipped, and unknown kinds keep their raw name as the label rather than
|
||||
/// being dropped -- a new window appearing should show up, not vanish.
|
||||
fn parse_windows(body: &Value) -> Vec<UsageWindow> {
|
||||
let Some(limits) = body.get("limits").and_then(Value::as_array) else {
|
||||
return Vec::new();
|
||||
@@ -377,9 +271,6 @@ fn parse_windows(body: &Value) -> Vec<UsageWindow> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// An invented answer, so the screens that draw these can be exercised
|
||||
/// without an account.
|
||||
///
|
||||
/// Every state the usage bar and the usage dialog can be in is otherwise
|
||||
/// reachable only by spending somebody's quota or by breaking a machine:
|
||||
/// a number near the top, a machine nobody has logged into, one that
|
||||
@@ -389,21 +280,13 @@ fn parse_windows(body: &Value) -> Vec<UsageWindow> {
|
||||
/// with `/usage` (see `session::echo`), which is the same bargain the
|
||||
/// rest of that driver makes: the fixture is invented, what is real is
|
||||
/// the path it travels.
|
||||
///
|
||||
/// Shared by the session layer, which writes it, and [`UsageMonitor`],
|
||||
/// which reads it. Empty until something sets it, and an empty fixture
|
||||
/// produces no snapshot at all -- an echo session meters nothing, and
|
||||
/// nothing is what the phone should draw.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct Fixture {
|
||||
said: Arc<Mutex<Option<Reported>>>,
|
||||
}
|
||||
|
||||
/// What a meter answered: which of the four states it is in, and whatever
|
||||
/// windows go with it. Empty for every state but [`UsageState::Ok`].
|
||||
type Reported = (UsageState, Vec<UsageWindow>);
|
||||
|
||||
/// How long the invented five-hour window has left, when nothing says.
|
||||
const FIXTURE_MINUTES: i64 = 125;
|
||||
|
||||
impl Fixture {
|
||||
@@ -419,11 +302,6 @@ impl Fixture {
|
||||
self.said.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
/// Acts on the words typed after `/usage`, and says what it did.
|
||||
///
|
||||
/// The vocabulary lives here rather than in the echo driver because
|
||||
/// these are this module's states: a driver spelling them out would
|
||||
/// be a second place that has to learn about a fifth one.
|
||||
pub fn command(&self, words: &str) -> String {
|
||||
let mut words = words.split_whitespace();
|
||||
let Some(first) = words.next() else {
|
||||
@@ -471,8 +349,6 @@ impl Fixture {
|
||||
}
|
||||
}
|
||||
|
||||
/// The three windows Claude reports today, invented around one number.
|
||||
///
|
||||
/// Three rather than one because the bar under a session header reads the
|
||||
/// five-hour window and the dialog behind the button draws all of them,
|
||||
/// and a fixture with one window leaves half the screen untested. The
|
||||
@@ -480,12 +356,7 @@ impl Fixture {
|
||||
/// -- which is what colours the button -- is still the one asked for.
|
||||
fn fixture_windows(percent: f64, reset: Option<&str>) -> Vec<UsageWindow> {
|
||||
let resets_at = match reset {
|
||||
// The state a real response is in between blocks: there is no
|
||||
// window running, so there is nothing to reset. It is not a
|
||||
// missing value, and the phone words it differently.
|
||||
Some("never") | Some("none") => None,
|
||||
// A timestamp that arrives and cannot be read, which is the one
|
||||
// case that really is "we could not find out".
|
||||
Some("unreadable") | Some("bad") => Some("whenever it feels like it".to_string()),
|
||||
other => Some(reset_in(
|
||||
other
|
||||
@@ -518,17 +389,12 @@ fn fixture_windows(percent: f64, reset: Option<&str>) -> Vec<UsageWindow> {
|
||||
]
|
||||
}
|
||||
|
||||
/// `minutes` from now, in the format the real endpoint sends.
|
||||
fn reset_in(minutes: i64) -> String {
|
||||
let at = time::OffsetDateTime::now_utc() + time::Duration::minutes(minutes);
|
||||
at.format(&time::format_description::well_known::Rfc3339)
|
||||
// Formatting a timestamp cannot fail for any input this builds;
|
||||
// saying so beats a fixture that silently has no reset time.
|
||||
.unwrap_or_else(|_| "unformattable".to_string())
|
||||
}
|
||||
|
||||
/// One line naming what a fixture is currently claiming, for the reply
|
||||
/// the echo session writes back.
|
||||
fn describe(state: &UsageState, windows: &[UsageWindow]) -> String {
|
||||
match state {
|
||||
UsageState::Ok => match windows.first() {
|
||||
@@ -548,8 +414,6 @@ fn describe(state: &UsageState, windows: &[UsageWindow]) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// The fixture, as a provider, so it travels the same route and the same
|
||||
/// cache as a real meter rather than being spliced in at the screen.
|
||||
struct EchoUsage {
|
||||
setup: String,
|
||||
setup_name: String,
|
||||
@@ -584,15 +448,11 @@ impl UsageProvider for EchoUsage {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read from memory, and set by somebody who is about to look at the
|
||||
/// screen it changes.
|
||||
fn poll_interval(&self) -> Duration {
|
||||
Duration::ZERO
|
||||
}
|
||||
}
|
||||
|
||||
/// Which paid services a machine can be asked about.
|
||||
///
|
||||
/// Derived from what the setup says it can run, so a machine with no Claude
|
||||
/// provider is not asked about Claude limits -- it has none, and a row saying
|
||||
/// so would be a fact about nothing.
|
||||
@@ -608,9 +468,6 @@ fn providers_for(setup: &SetupConfig, fixture: &Fixture) -> Vec<Box<dyn UsagePro
|
||||
let Some(name) = provider.kind.usage_provider() else {
|
||||
continue;
|
||||
};
|
||||
// A machine offering two Claude providers has one account, not
|
||||
// two: the meter belongs to the machine and the service, which is
|
||||
// exactly what the cache is keyed by.
|
||||
if found.iter().any(|already| already.name() == name) {
|
||||
continue;
|
||||
}
|
||||
@@ -621,9 +478,6 @@ fn providers_for(setup: &SetupConfig, fixture: &Fixture) -> Vec<Box<dyn UsagePro
|
||||
transport: Transport::for_setup(setup),
|
||||
program: provider.program().to_string(),
|
||||
})),
|
||||
// Nothing at all until a test has asked for something: an
|
||||
// echo session costs nothing, so the honest answer is no row
|
||||
// rather than a row saying zero.
|
||||
ECHO if fixture.is_set() => found.push(Box::new(EchoUsage {
|
||||
setup: setup.id.clone(),
|
||||
setup_name: setup.name.clone(),
|
||||
@@ -635,22 +489,11 @@ fn providers_for(setup: &SetupConfig, fixture: &Fixture) -> Vec<Box<dyn UsagePro
|
||||
found
|
||||
}
|
||||
|
||||
/// One machine's numbers for one service, and when they were fetched.
|
||||
///
|
||||
/// Keyed by the machine and the service rather than by position: the set is not
|
||||
/// fixed at startup -- setups are added, renamed and removed from the phone --
|
||||
/// and a positional cache would hand one machine's numbers to another the
|
||||
/// moment the list shifted.
|
||||
type Cached = HashMap<(String, &'static str), (Instant, UsageSnapshot)>;
|
||||
|
||||
#[derive(Default)]
|
||||
/// The cache in front of whatever machines exist: at most one real fetch per
|
||||
/// machine per service per [`MIN_POLL_INTERVAL`], however often the phone asks.
|
||||
pub struct UsageMonitor {
|
||||
cache: Mutex<Cached>,
|
||||
/// The invented meter an echo session can put up; empty unless one
|
||||
/// has. Shared with the session layer, which is where the command
|
||||
/// that sets it is typed -- see [`Fixture`].
|
||||
fixture: Fixture,
|
||||
}
|
||||
|
||||
@@ -662,12 +505,6 @@ impl UsageMonitor {
|
||||
}
|
||||
}
|
||||
|
||||
/// One snapshot per machine that offers a paid service, in the order the
|
||||
/// machines are configured.
|
||||
///
|
||||
/// Blocking -- call via `spawn_blocking`. Takes the setups rather than
|
||||
/// holding the manager, so this module stays below the session layer rather
|
||||
/// than reaching up into it.
|
||||
pub fn snapshots(&self, setups: &[SetupConfig]) -> Vec<UsageSnapshot> {
|
||||
let mut fresh = Vec::new();
|
||||
for setup in setups {
|
||||
@@ -676,17 +513,11 @@ impl UsageMonitor {
|
||||
if let Some((fetched, snapshot)) = self.cache.lock().unwrap().get(&key)
|
||||
&& fetched.elapsed() < provider.poll_interval()
|
||||
{
|
||||
// Cached numbers, but the machine's *name* is read fresh: a
|
||||
// rename should show immediately rather than waiting out a
|
||||
// poll interval it has nothing to do with.
|
||||
let mut snapshot = snapshot.clone();
|
||||
snapshot.setup_name = setup.name.clone();
|
||||
fresh.push(snapshot);
|
||||
continue;
|
||||
}
|
||||
// Fetched without the lock held: this makes a network call per
|
||||
// machine, and holding the cache across them would serialise
|
||||
// every phone asking for the screen behind the slowest ssh.
|
||||
let snapshot = provider.fetch();
|
||||
self.cache
|
||||
.lock()
|
||||
@@ -695,7 +526,6 @@ impl UsageMonitor {
|
||||
fresh.push(snapshot);
|
||||
}
|
||||
}
|
||||
// Machines that have gone away should not keep their numbers alive.
|
||||
let live: std::collections::HashSet<&str> =
|
||||
setups.iter().map(|setup| setup.id.as_str()).collect();
|
||||
self.cache
|
||||
@@ -725,9 +555,6 @@ mod tests {
|
||||
transport: Transport::for_setup(&unreachable_setup()),
|
||||
program: "/opt/claude".to_string(),
|
||||
};
|
||||
// The machine cannot be reached, so the refresh attempt fails there
|
||||
// rather than at the endpoint -- and the message still has to name the
|
||||
// machine and the command, since that is all anybody gets to act on.
|
||||
let UsageState::Failed { detail } = provider
|
||||
.after_cli_refresh("stale")
|
||||
.expect_err("an unreachable machine cannot refresh anything")
|
||||
@@ -750,7 +577,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parses_the_limits_array_defensively() {
|
||||
// Trimmed from a live 2026-08-24 response.
|
||||
let body: Value = serde_json::from_str(
|
||||
r#"{"limits":[
|
||||
{"kind":"session","group":"session","percent":70,"severity":"normal","resets_at":"2026-08-25T04:29:59+00:00","scope":null,"is_active":true},
|
||||
@@ -768,13 +594,10 @@ mod tests {
|
||||
assert!(windows[0].active);
|
||||
assert_eq!(windows[1].label, "Weekly (all models)");
|
||||
assert_eq!(windows[2].label, "Weekly (Fable)");
|
||||
// Unknown kinds surface under their raw name instead of vanishing.
|
||||
assert_eq!(windows[3].label, "mystery_new_window");
|
||||
assert_eq!(windows[3].resets_at, None);
|
||||
}
|
||||
|
||||
/// A setup naming a machine that cannot be dialled, so nothing here touches
|
||||
/// the network beyond ssh failing to resolve it.
|
||||
fn unreachable_setup() -> SetupConfig {
|
||||
SetupConfig {
|
||||
id: "far".to_string(),
|
||||
@@ -805,9 +628,6 @@ mod tests {
|
||||
program: "claude".to_string(),
|
||||
};
|
||||
let snapshot = provider.fetch();
|
||||
// The distinction the old single `error` string could not make: this
|
||||
// machine was never reached, which is not the same as a machine that
|
||||
// answered and has nobody logged in.
|
||||
assert!(
|
||||
matches!(snapshot.state, UsageState::Unreachable { .. }),
|
||||
"{:?}",
|
||||
@@ -820,8 +640,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_missing_credential_file_is_a_choice_and_anything_else_is_a_fault() {
|
||||
// What a real shell says when nobody has logged in on that machine.
|
||||
// Nothing to fix, so it must not read as an error.
|
||||
assert_eq!(
|
||||
why_no_credentials("cat: /home/x/.claude/.credentials.json: No such file or directory"),
|
||||
UsageState::NotLoggedIn
|
||||
@@ -831,16 +649,12 @@ mod tests {
|
||||
UsageState::NotLoggedIn
|
||||
);
|
||||
|
||||
// What ssh says when the machine is not there. Worth chasing, and the
|
||||
// detail is carried so somebody can.
|
||||
let refused = why_no_credentials("ssh: connect to host vm port 22: Connection refused");
|
||||
assert!(
|
||||
matches!(&refused, UsageState::Unreachable { detail } if detail.contains("refused")),
|
||||
"{refused:?}"
|
||||
);
|
||||
|
||||
// Anything unrecognised errs towards the state that gets looked at,
|
||||
// rather than silently claiming nobody is logged in.
|
||||
assert!(matches!(
|
||||
why_no_credentials("something nobody has seen before"),
|
||||
UsageState::Unreachable { .. }
|
||||
@@ -856,16 +670,10 @@ mod tests {
|
||||
command: None,
|
||||
models: vec![],
|
||||
}];
|
||||
// A machine with no Claude on it has no Claude limits, and a row
|
||||
// reporting on it would be a fact about nothing. Echo included:
|
||||
// an echo session spends nothing, so until a fixture says
|
||||
// otherwise there is no meter to report.
|
||||
let unset = Fixture::new();
|
||||
assert!(providers_for(&echo_only, &unset).is_empty());
|
||||
assert_eq!(providers_for(&unreachable_setup(), &unset).len(), 1);
|
||||
|
||||
// And with one set, that machine has exactly the invented meter
|
||||
// -- under the name the session's `usageProvider` will name.
|
||||
let fixture = Fixture::new();
|
||||
fixture.command("42");
|
||||
let found = providers_for(&echo_only, &fixture);
|
||||
@@ -873,12 +681,9 @@ mod tests {
|
||||
assert_eq!(found[0].name(), ECHO);
|
||||
assert_eq!(DriverKind::Echo.usage_provider(), Some(ECHO));
|
||||
assert_eq!(DriverKind::ClaudeCli.usage_provider(), Some(CLAUDE));
|
||||
// A local model costs nothing to run, so it meters nothing.
|
||||
assert_eq!(DriverKind::LlamaCpp.usage_provider(), None);
|
||||
}
|
||||
|
||||
/// The states the fixture exists to make reachable, and the one thing
|
||||
/// it must not do: invent a reset time for a window that has none.
|
||||
#[test]
|
||||
fn the_fixture_says_each_state_the_screens_have_to_draw() {
|
||||
let fixture = Fixture::new();
|
||||
@@ -891,8 +696,6 @@ mod tests {
|
||||
assert_eq!(windows[0].percent, 42.0);
|
||||
assert!(windows[0].resets_at.is_some());
|
||||
|
||||
// Between blocks: no reset time, which the phone words as the
|
||||
// window not running rather than as a time it could not read.
|
||||
fixture.command("42 never");
|
||||
assert_eq!(fixture.read().expect("set").1[0].resets_at, None);
|
||||
|
||||
@@ -905,7 +708,6 @@ mod tests {
|
||||
fixture.command("off");
|
||||
assert!(fixture.read().is_none());
|
||||
|
||||
// A word it does not know changes nothing and says what it takes.
|
||||
fixture.command("42");
|
||||
let refused = fixture.command("sideways");
|
||||
assert!(
|
||||
|
||||
Reference in new issue
Block a user