Replace the footer's placeholder usage with a real observation ledger
The footer showed invented percentages. It now shows what two harnesses actually report, or says it does not know. lumbridge-core gains an append-only per-profile UsageLedger and a projection that labels every derived value estimated, withholds a burn rate from a single sample, withholds a window fraction with no reported ceiling, withholds an exhaustion estimate that lands after the reset, and reports an expired window as rolled over rather than freezing its last percentage. A missing fact renders as missing, never as zero. (0012) lumbridge-harness is the impure side: processes, clocks, and untrusted wire text in, observations out. Three adapters: - Codex's account/rateLimits/read over the app-server's JSON-RPC stdio. The client cannot express a request outside a two-variant enum and answers every server-to-client request with -32601, so a harness asking Lumbridge for a credential is refused by construction. (0013) - Claude Code's session transcripts, as a byte-offset tail follower that reports nothing until the backlog is read to EOF — a partially-read backlog is indistinguishable from a burst of spend, and the first run against 20 MB reported forty-six billion tokens an hour. The parser models four counters, so the conversations in those files are not representable. (0014) - Claude Code's five-hour and seven-day subscription windows, via a bridge installed as its statusLine command. 0014 had claimed no such surface existed; it does, and the record is corrected in place rather than quietly edited. Lumbridge does not read the OAuth credential to call the account usage endpoint, which is what comparable tools do — AGENTS.md forbids it, and 0015 says so rather than leaving the gap unexplained. Also in here: a capability-check ordering fix in the workspace reducer, where the applied-request replay table was consulted before the capability check and so answered questions the caller had no right to ask; the GPUI spike wired to the live probes with per-harness gauges and provenance chips; and a launcher that matches its own window by PID, because GPUI sets WM_NAME but not _NET_WM_NAME and a title match never succeeded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
7fe84f71e2
commit
ef52aa7ce2
Generated
+11
@@ -867,6 +867,17 @@ dependencies = [
|
|||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lumbridge-harness"
|
||||||
|
version = "0.0.1"
|
||||||
|
dependencies = [
|
||||||
|
"lumbridge-core",
|
||||||
|
"nix",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.20",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lumbridge-pty"
|
name = "lumbridge-pty"
|
||||||
version = "0.0.1"
|
version = "0.0.1"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ members = [
|
|||||||
"apps/lumbridge",
|
"apps/lumbridge",
|
||||||
"crates/lumbridge-buzz",
|
"crates/lumbridge-buzz",
|
||||||
"crates/lumbridge-core",
|
"crates/lumbridge-core",
|
||||||
|
"crates/lumbridge-harness",
|
||||||
"crates/lumbridge-pty",
|
"crates/lumbridge-pty",
|
||||||
"crates/lumbridge-runtime",
|
"crates/lumbridge-runtime",
|
||||||
"crates/lumbridge-storage",
|
"crates/lumbridge-storage",
|
||||||
|
|||||||
@@ -25,9 +25,12 @@ responsive one-, three-, or five-panel workspace—five on a 3440 px ultrawide
|
|||||||
where every panel owns its own context, work surface, and decision shelf. One
|
where every panel owns its own context, work surface, and decision shelf. One
|
||||||
panel contains a styled actor-owned VT session and five comparison surfaces
|
panel contains a styled actor-owned VT session and five comparison surfaces
|
||||||
remain deterministic.
|
remain deterministic.
|
||||||
Retained-history navigation is wired. We are still validating terminal text
|
Retained-history navigation is wired. The usage footer is a live strip over an
|
||||||
selection and mouse input, standalone runtime IPC/durability, ACP integration,
|
append-only ledger with provenance, fed by two real adapters: Codex's
|
||||||
packaging, and usage-data contracts before a large implementation.
|
documented quota surface and Claude Code's session transcripts. A harness with
|
||||||
|
no adapter renders an explicit gap rather than a zero. We are still validating
|
||||||
|
terminal text selection and mouse input, standalone runtime IPC/durability, ACP
|
||||||
|
integration, and packaging before a large implementation.
|
||||||
|
|
||||||
## Product shape
|
## Product shape
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,13 @@
|
|||||||
|
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
|
||||||
|
mod usage;
|
||||||
mod workspace;
|
mod workspace;
|
||||||
|
|
||||||
|
pub use usage::{
|
||||||
|
AccountProfile, AccountProfileId, FooterUsage, UsageConfidence, UsageError, UsageLedger,
|
||||||
|
UsageObservation, UsageProjection, UsageProvenance, UsageUnit, UsageWindow, format_duration_ms,
|
||||||
|
};
|
||||||
pub use workspace::{
|
pub use workspace::{
|
||||||
CommandOrigin, CommandRequestId, LayoutNode, PaneCloseDisposition, PaneDefinition, PaneId,
|
CommandOrigin, CommandRequestId, LayoutNode, PaneCloseDisposition, PaneDefinition, PaneId,
|
||||||
PaneLaunchIntent, PanePlacement, PaneSurface, SplitAxis, SplitRatio, WorkspaceApplyOutcome,
|
PaneLaunchIntent, PanePlacement, PaneSurface, SplitAxis, SplitRatio, WorkspaceApplyOutcome,
|
||||||
@@ -96,15 +101,6 @@ impl fmt::Display for Platform {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
||||||
pub enum UsageProvenance {
|
|
||||||
ProviderReported,
|
|
||||||
HarnessReported,
|
|
||||||
LocallyMeasured,
|
|
||||||
Estimated,
|
|
||||||
Unavailable,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
pub struct ProductStatus {
|
pub struct ProductStatus {
|
||||||
pub platform: Platform,
|
pub platform: Platform,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -464,6 +464,11 @@ impl WorkspaceState {
|
|||||||
|
|
||||||
/// Applies one idempotent command after checking its required capability.
|
/// Applies one idempotent command after checking its required capability.
|
||||||
///
|
///
|
||||||
|
/// Authority is checked before the replay table is consulted. A caller that
|
||||||
|
/// cannot perform a command also cannot learn whether its request ID was
|
||||||
|
/// already applied, so the retry path is not an oracle over the workspace's
|
||||||
|
/// history.
|
||||||
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// Rejects insufficient authority and invalid state transitions without
|
/// Rejects insufficient authority and invalid state transitions without
|
||||||
@@ -473,6 +478,11 @@ impl WorkspaceState {
|
|||||||
request: WorkspaceRequest,
|
request: WorkspaceRequest,
|
||||||
granted: WorkspaceCapability,
|
granted: WorkspaceCapability,
|
||||||
) -> Result<WorkspaceApplyOutcome, WorkspaceError> {
|
) -> Result<WorkspaceApplyOutcome, WorkspaceError> {
|
||||||
|
request.origin.validate()?;
|
||||||
|
let required = request.command.required_capability();
|
||||||
|
if granted < required {
|
||||||
|
return Err(WorkspaceError::CapabilityDenied { required, granted });
|
||||||
|
}
|
||||||
if let Some(applied) = self.applied_requests.get(&request.request_id) {
|
if let Some(applied) = self.applied_requests.get(&request.request_id) {
|
||||||
if applied == &request {
|
if applied == &request {
|
||||||
return Ok(WorkspaceApplyOutcome {
|
return Ok(WorkspaceApplyOutcome {
|
||||||
@@ -483,11 +493,6 @@ impl WorkspaceState {
|
|||||||
}
|
}
|
||||||
return Err(WorkspaceError::RequestConflict(request.request_id));
|
return Err(WorkspaceError::RequestConflict(request.request_id));
|
||||||
}
|
}
|
||||||
request.origin.validate()?;
|
|
||||||
let required = request.command.required_capability();
|
|
||||||
if granted < required {
|
|
||||||
return Err(WorkspaceError::CapabilityDenied { required, granted });
|
|
||||||
}
|
|
||||||
|
|
||||||
let event = self.apply_command(request.command.clone())?;
|
let event = self.apply_command(request.command.clone())?;
|
||||||
self.applied_requests
|
self.applied_requests
|
||||||
@@ -749,6 +754,33 @@ mod tests {
|
|||||||
assert_eq!(state.revision(), 1);
|
assert_eq!(state.revision(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replaying_an_applied_request_still_requires_its_capability() {
|
||||||
|
let mut state = workspace();
|
||||||
|
let command = request(
|
||||||
|
"launch-agent",
|
||||||
|
WorkspaceCommand::SplitPane {
|
||||||
|
target: id(PaneId::new, "root"),
|
||||||
|
pane: pane("agent", true),
|
||||||
|
axis: SplitAxis::Horizontal,
|
||||||
|
ratio: SplitRatio::default(),
|
||||||
|
placement: PanePlacement::After,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
state
|
||||||
|
.apply(command.clone(), WorkspaceCapability::Execute)
|
||||||
|
.expect("execute may launch");
|
||||||
|
// The replay table must not become an oracle: a caller holding less
|
||||||
|
// authority learns nothing about what has already been applied.
|
||||||
|
assert!(matches!(
|
||||||
|
state.apply(command, WorkspaceCapability::Observe),
|
||||||
|
Err(WorkspaceError::CapabilityDenied {
|
||||||
|
required: WorkspaceCapability::Execute,
|
||||||
|
granted: WorkspaceCapability::Observe,
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reused_request_id_must_match_origin_and_command() {
|
fn reused_request_id_must_match_origin_and_command() {
|
||||||
let mut state = workspace();
|
let mut state = workspace();
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
[package]
|
||||||
|
name = "lumbridge-harness"
|
||||||
|
description = "Harness launch profiles and documented status/usage probes for Lumbridge"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
lumbridge-core = { path = "../lumbridge-core" }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
thiserror = "2.0"
|
||||||
|
|
||||||
|
[target.'cfg(unix)'.dependencies]
|
||||||
|
nix = { version = "0.28", features = ["process", "signal"] }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
//! A supervised, byte-capped stdio child.
|
||||||
|
//!
|
||||||
|
//! The child is launched with a cleared environment and a literal allowlist.
|
||||||
|
//! It speaks a machine protocol, so it gets pipes rather than a PTY: a TTY
|
||||||
|
//! would invite terminal control sequences in both directions for no benefit.
|
||||||
|
//! Its stderr is discarded rather than captured, because a harness's diagnostic
|
||||||
|
//! output is exactly the kind of text that ends up in a log carrying a path or
|
||||||
|
//! a token fragment.
|
||||||
|
//!
|
||||||
|
//! Reading a pipe blocks. The child therefore splits into three owners: a
|
||||||
|
//! reader that may block on its own thread, a writer, and a killer the
|
||||||
|
//! supervising thread keeps. Shutdown kills first and joins second, so a child
|
||||||
|
//! that has gone quiet — or hostile — can never hold the caller hostage.
|
||||||
|
//!
|
||||||
|
//! The child is put in its own process group and the group is what gets
|
||||||
|
//! killed. A pipe reports end of file only when *every* write end closes, so
|
||||||
|
//! killing the direct child alone is not enough: a launcher that execs the
|
||||||
|
//! real program as a grandchild with inherited stdio — which is how the npm
|
||||||
|
//! distribution of Codex works — leaves that grandchild holding our stdout,
|
||||||
|
//! and the reader would block forever on a pipe nothing will ever write to.
|
||||||
|
|
||||||
|
use std::io::{BufRead, BufReader, Read, Write};
|
||||||
|
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use crate::HarnessError;
|
||||||
|
|
||||||
|
/// Environment a probe child may inherit.
|
||||||
|
///
|
||||||
|
/// Literal names only: never a pattern and never a user-supplied name.
|
||||||
|
///
|
||||||
|
/// This deliberately mirrors but does not reuse the terminal crate's private
|
||||||
|
/// allowlist. AGENTS.md keeps the harness and terminal boundaries separate,
|
||||||
|
/// and the two lists genuinely differ: a probe needs `CODEX_HOME` so it reads
|
||||||
|
/// the same account the user's panes use, and has no use for `SHELL`.
|
||||||
|
/// `CODEX_HOME` is a filesystem path, never a secret.
|
||||||
|
pub const PROBE_ENV_ALLOWLIST: &[&str] = &[
|
||||||
|
"CODEX_HOME",
|
||||||
|
"HOME",
|
||||||
|
"LANG",
|
||||||
|
"LC_ALL",
|
||||||
|
"LC_CTYPE",
|
||||||
|
"LOGNAME",
|
||||||
|
"PATH",
|
||||||
|
"TMPDIR",
|
||||||
|
"USER",
|
||||||
|
"XDG_CONFIG_HOME",
|
||||||
|
"XDG_DATA_HOME",
|
||||||
|
"XDG_RUNTIME_DIR",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// A single frame longer than this is treated as a fault rather than buffered.
|
||||||
|
/// A hostile or broken relay must not be able to grow this process's memory.
|
||||||
|
pub(crate) const MAX_LINE_BYTES: usize = 256 * 1024;
|
||||||
|
|
||||||
|
/// A freshly launched child, before its pipes are handed to their owners.
|
||||||
|
pub(crate) struct ProbeChild {
|
||||||
|
child: Arc<Mutex<Child>>,
|
||||||
|
group: u32,
|
||||||
|
stdin: ChildStdin,
|
||||||
|
stdout: ChildStdout,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProbeChild {
|
||||||
|
/// Launches a probe program with a minimal environment.
|
||||||
|
///
|
||||||
|
/// This runs on the caller's thread, following the same pattern as the
|
||||||
|
/// runtime actor: start synchronously so a missing program is an immediate
|
||||||
|
/// error, then transfer the pipes to their threads.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`HarnessError::EmptyProgram`] for a blank program name and
|
||||||
|
/// [`HarnessError::SpawnFailed`] if the process cannot start. The
|
||||||
|
/// underlying [`std::io::Error`] is deliberately reduced to its
|
||||||
|
/// [`std::io::ErrorKind`] so a filesystem path cannot travel in an error.
|
||||||
|
pub(crate) fn spawn(program: &str, arguments: &[&str]) -> Result<Self, HarnessError> {
|
||||||
|
if program.trim().is_empty() {
|
||||||
|
return Err(HarnessError::EmptyProgram);
|
||||||
|
}
|
||||||
|
let mut command = Command::new(program);
|
||||||
|
command
|
||||||
|
.args(arguments)
|
||||||
|
.env_clear()
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::null());
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::process::CommandExt;
|
||||||
|
// The child leads its own group, so its group ID is its process ID
|
||||||
|
// and every descendant it does not deliberately detach joins it.
|
||||||
|
command.process_group(0);
|
||||||
|
}
|
||||||
|
for name in PROBE_ENV_ALLOWLIST {
|
||||||
|
if let Some(value) = std::env::var_os(name) {
|
||||||
|
command.env(name, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut child = command
|
||||||
|
.spawn()
|
||||||
|
.map_err(|error| HarnessError::SpawnFailed(error.kind()))?;
|
||||||
|
let broken = HarnessError::SpawnFailed(std::io::ErrorKind::BrokenPipe);
|
||||||
|
let group = child.id();
|
||||||
|
let stdin = child.stdin.take().ok_or(broken)?;
|
||||||
|
let stdout = child.stdout.take().ok_or(broken)?;
|
||||||
|
Ok(Self {
|
||||||
|
child: Arc::new(Mutex::new(child)),
|
||||||
|
group,
|
||||||
|
stdin,
|
||||||
|
stdout,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Splits the child into its three independent owners.
|
||||||
|
pub(crate) fn into_parts(self) -> (ProbeWriter, ProbeReader, ProbeKiller) {
|
||||||
|
(
|
||||||
|
ProbeWriter { stdin: self.stdin },
|
||||||
|
ProbeReader {
|
||||||
|
reader: BufReader::new(self.stdout),
|
||||||
|
},
|
||||||
|
ProbeKiller {
|
||||||
|
child: self.child,
|
||||||
|
group: self.group,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The writing half.
|
||||||
|
pub(crate) struct ProbeWriter {
|
||||||
|
stdin: ChildStdin,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProbeWriter {
|
||||||
|
/// Writes one newline-terminated frame.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`HarnessError::Eof`] once the child has closed its input.
|
||||||
|
pub(crate) fn write_line(&mut self, line: &str) -> Result<(), HarnessError> {
|
||||||
|
self.stdin
|
||||||
|
.write_all(line.as_bytes())
|
||||||
|
.and_then(|()| self.stdin.write_all(b"\n"))
|
||||||
|
.and_then(|()| self.stdin.flush())
|
||||||
|
.map_err(|_| HarnessError::Eof)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The reading half. Its methods block, so it belongs on its own thread.
|
||||||
|
pub(crate) struct ProbeReader {
|
||||||
|
reader: BufReader<ChildStdout>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProbeReader {
|
||||||
|
/// Reads one frame, refusing an oversized line instead of buffering it.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`HarnessError::Eof`] at end of stream and
|
||||||
|
/// [`HarnessError::OversizedLine`] past [`MAX_LINE_BYTES`].
|
||||||
|
pub(crate) fn read_line(&mut self) -> Result<String, HarnessError> {
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
let read = self
|
||||||
|
.reader
|
||||||
|
.by_ref()
|
||||||
|
.take(MAX_LINE_BYTES as u64 + 1)
|
||||||
|
.read_until(b'\n', &mut buffer)
|
||||||
|
.map_err(|_| HarnessError::Eof)?;
|
||||||
|
if read == 0 {
|
||||||
|
return Err(HarnessError::Eof);
|
||||||
|
}
|
||||||
|
if read > MAX_LINE_BYTES {
|
||||||
|
return Err(HarnessError::OversizedLine(read));
|
||||||
|
}
|
||||||
|
String::from_utf8(buffer).map_err(|_| HarnessError::Malformed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The half a supervisor keeps so it can always stop the child.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(crate) struct ProbeKiller {
|
||||||
|
child: Arc<Mutex<Child>>,
|
||||||
|
group: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProbeKiller {
|
||||||
|
/// Ends the child and everything in its process group, then reaps it.
|
||||||
|
///
|
||||||
|
/// Killing the whole group is what closes every write end of our stdout
|
||||||
|
/// pipe, which is what unblocks the reader. Idempotent: killing an
|
||||||
|
/// already-dead process is not an error worth reporting, and a poisoned
|
||||||
|
/// lock means another thread died mid-kill, which the operating system has
|
||||||
|
/// already resolved.
|
||||||
|
pub(crate) fn kill(&self) {
|
||||||
|
self.kill_group();
|
||||||
|
if let Ok(mut child) = self.child.lock() {
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn kill_group(&self) {
|
||||||
|
use nix::sys::signal::{Signal, killpg};
|
||||||
|
use nix::unistd::Pid;
|
||||||
|
|
||||||
|
if let Ok(group) = i32::try_from(self.group) {
|
||||||
|
let _ = killpg(Pid::from_raw(group), Signal::SIGKILL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
fn kill_group(&self) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{MAX_LINE_BYTES, PROBE_ENV_ALLOWLIST, ProbeChild};
|
||||||
|
use crate::HarnessError;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_program_is_refused_before_spawning() {
|
||||||
|
assert!(matches!(
|
||||||
|
ProbeChild::spawn(" ", &[]).err(),
|
||||||
|
Some(HarnessError::EmptyProgram)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_missing_program_reports_a_kind_and_never_a_path() {
|
||||||
|
let Err(error) = ProbeChild::spawn("lumbridge-no-such-probe-binary", &[]) else {
|
||||||
|
panic!("a missing program cannot spawn");
|
||||||
|
};
|
||||||
|
assert!(matches!(error, HarnessError::SpawnFailed(_)));
|
||||||
|
assert!(
|
||||||
|
!error.to_string().contains("lumbridge-no-such-probe-binary"),
|
||||||
|
"an error must not carry a filesystem path"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_allowlist_is_literal_sorted_and_free_of_secret_bearing_names() {
|
||||||
|
let mut sorted = PROBE_ENV_ALLOWLIST.to_vec();
|
||||||
|
sorted.sort_unstable();
|
||||||
|
assert_eq!(sorted, PROBE_ENV_ALLOWLIST);
|
||||||
|
for name in PROBE_ENV_ALLOWLIST {
|
||||||
|
let upper = name.to_uppercase();
|
||||||
|
assert!(
|
||||||
|
!upper.contains("TOKEN")
|
||||||
|
&& !upper.contains("KEY")
|
||||||
|
&& !upper.contains("SECRET")
|
||||||
|
&& !upper.contains("PASSWORD"),
|
||||||
|
"{name} could carry a credential into a child"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_child_round_trips_one_frame() {
|
||||||
|
let Ok(child) = ProbeChild::spawn("cat", &[]) else {
|
||||||
|
return; // cat is not guaranteed on every supported platform.
|
||||||
|
};
|
||||||
|
let (mut writer, mut reader, killer) = child.into_parts();
|
||||||
|
writer
|
||||||
|
.write_line(r#"{"ok":true}"#)
|
||||||
|
.expect("cat accepts input");
|
||||||
|
assert_eq!(
|
||||||
|
reader.read_line().expect("cat echoes the line"),
|
||||||
|
"{\"ok\":true}\n"
|
||||||
|
);
|
||||||
|
killer.kill();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_oversized_frame_is_refused_rather_than_buffered() {
|
||||||
|
// Generated by the child so the test never deadlocks writing more than
|
||||||
|
// a pipe buffer into a process that is not reading.
|
||||||
|
let script = format!("head -c {} /dev/zero | tr '\\0' 'x'", MAX_LINE_BYTES + 16);
|
||||||
|
let Ok(child) = ProbeChild::spawn("sh", &["-c", &script]) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let (_writer, mut reader, killer) = child.into_parts();
|
||||||
|
assert!(matches!(
|
||||||
|
reader.read_line(),
|
||||||
|
Err(HarnessError::OversizedLine(_))
|
||||||
|
));
|
||||||
|
killer.kill();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn killing_a_child_unblocks_a_reader_blocked_on_a_grandchild() {
|
||||||
|
// A launcher that backgrounds a helper and waits is the shape of the
|
||||||
|
// npm Codex distribution: the helper inherits our stdout, so killing
|
||||||
|
// only the direct child would leave the reader blocked forever.
|
||||||
|
let Ok(child) = ProbeChild::spawn("sh", &["-c", "sleep 30 & wait"]) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let (_writer, mut reader, killer) = child.into_parts();
|
||||||
|
let reading = std::thread::spawn(move || reader.read_line());
|
||||||
|
killer.kill();
|
||||||
|
let outcome = reading.join().expect("the reader thread must not panic");
|
||||||
|
assert!(
|
||||||
|
matches!(outcome, Err(HarnessError::Eof)),
|
||||||
|
"killing the group must end the read rather than hang it"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn killing_a_child_unblocks_its_reader() {
|
||||||
|
// `sleep` never writes, so the reader is blocked until the kill lands.
|
||||||
|
let Ok(child) = ProbeChild::spawn("sleep", &["30"]) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let (_writer, mut reader, killer) = child.into_parts();
|
||||||
|
let reading = std::thread::spawn(move || reader.read_line());
|
||||||
|
killer.kill();
|
||||||
|
let outcome = reading.join().expect("the reader thread must not panic");
|
||||||
|
assert!(
|
||||||
|
matches!(outcome, Err(HarnessError::Eof)),
|
||||||
|
"a killed child must end the read rather than hang it"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,836 @@
|
|||||||
|
//! The Claude Code usage adapter, over two different surfaces.
|
||||||
|
//!
|
||||||
|
//! **Subscription windows** ([`statusline`]) are the real quota. Claude Code
|
||||||
|
//! 2.1.80 and later pipe a `rate_limits` object carrying `five_hour` and
|
||||||
|
//! `seven_day` — each with `used_percentage` and `resets_at` — to the
|
||||||
|
//! configured `statusLine` command on every turn. Those are rate-limit headers
|
||||||
|
//! the CLI already received on its own API responses, so reading them costs
|
||||||
|
//! nothing and they are `ProviderReported`. Lumbridge does not call the
|
||||||
|
//! account's usage endpoint for them: that would mean reading Claude Code's
|
||||||
|
//! OAuth credential, which AGENTS.md forbids. The status line is pushed to a
|
||||||
|
//! command the user installs, so no credential is ever touched.
|
||||||
|
//!
|
||||||
|
//! **Token consumption** ([`transcript`]) comes from the session transcripts
|
||||||
|
//! Claude Code writes under `~/.claude/projects`. Those record the API's
|
||||||
|
//! `usage` object per assistant turn. A running total is the harness's own
|
||||||
|
//! record rather than a provider's statement of account, so it is
|
||||||
|
//! `HarnessReported`, and it carries no ceiling — spend is not a quota.
|
||||||
|
//!
|
||||||
|
//! Both are tail followers. Each remembers a byte offset and reads only what
|
||||||
|
//! was appended since, which is what makes the token total monotonic — the
|
||||||
|
//! ledger rejects an observation that moves a profile backwards.
|
||||||
|
|
||||||
|
mod statusline;
|
||||||
|
mod transcript;
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::{Read, Seek, SeekFrom};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::mpsc::{self, Receiver, SyncSender, TryRecvError};
|
||||||
|
use std::thread::{self, JoinHandle};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use lumbridge_core::{AccountProfile, UsageObservation, UsageProvenance, UsageUnit};
|
||||||
|
|
||||||
|
use crate::HarnessError;
|
||||||
|
use crate::claude::statusline::parse_feed_line;
|
||||||
|
pub use crate::claude::statusline::{ClaudeWindowKind, WindowReading};
|
||||||
|
use crate::claude::transcript::MAX_RECORD_BYTES;
|
||||||
|
use crate::clock::MonotonicWallClock;
|
||||||
|
use crate::probe::{ProbeHealth, ProbeOutcome, UsageProbe};
|
||||||
|
|
||||||
|
pub use transcript::TokenTally;
|
||||||
|
|
||||||
|
/// Transcripts are appended to constantly; polling faster than this buys
|
||||||
|
/// nothing but disk churn.
|
||||||
|
const MIN_POLL_INTERVAL: Duration = Duration::from_secs(1);
|
||||||
|
/// How long the worker sleeps between filesystem scans.
|
||||||
|
const WORKER_TICK: Duration = Duration::from_millis(250);
|
||||||
|
/// A bound on how much of the tree one scan will walk.
|
||||||
|
const MAX_DEPTH: usize = 6;
|
||||||
|
const MAX_FILES_PER_SCAN: usize = 4_096;
|
||||||
|
/// A bound on how much any one poll will read, so a large backlog is absorbed
|
||||||
|
/// across several polls instead of stalling one.
|
||||||
|
const MAX_BYTES_PER_POLL: usize = 8 * 1024 * 1024;
|
||||||
|
const EVENT_QUEUE: usize = 64;
|
||||||
|
|
||||||
|
/// Where and how often to read transcripts.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct ClaudeCodeProbeOptions {
|
||||||
|
/// The Claude Code projects root. Defaults to `$CLAUDE_CONFIG_DIR/projects`
|
||||||
|
/// or `~/.claude/projects`.
|
||||||
|
pub projects_root: PathBuf,
|
||||||
|
/// Where the Lumbridge status-line bridge appends rate-limit readings.
|
||||||
|
/// Absent until the user installs the bridge, which is why the windows read
|
||||||
|
/// as unavailable rather than as zero before then.
|
||||||
|
pub rate_limit_feed: PathBuf,
|
||||||
|
pub poll_interval: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ClaudeCodeProbeOptions {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
projects_root: default_projects_root(),
|
||||||
|
rate_limit_feed: default_rate_limit_feed(),
|
||||||
|
poll_interval: Duration::from_secs(10),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the status-line bridge writes, under Lumbridge's own data root rather
|
||||||
|
/// than inside the harness's configuration.
|
||||||
|
///
|
||||||
|
/// `LUMBRIDGE_CLAUDE_FEED` overrides it, and must be read here as well as in the
|
||||||
|
/// bridge: the two halves have to resolve the same path or the bridge writes
|
||||||
|
/// somewhere the probe never looks and the windows read as unavailable forever.
|
||||||
|
#[must_use]
|
||||||
|
pub fn default_rate_limit_feed() -> PathBuf {
|
||||||
|
if let Some(configured) = std::env::var_os("LUMBRIDGE_CLAUDE_FEED") {
|
||||||
|
return PathBuf::from(configured);
|
||||||
|
}
|
||||||
|
let root = std::env::var_os("XDG_DATA_HOME").map_or_else(
|
||||||
|
|| {
|
||||||
|
std::env::var_os("HOME")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.join(".local")
|
||||||
|
.join("share")
|
||||||
|
},
|
||||||
|
PathBuf::from,
|
||||||
|
);
|
||||||
|
root.join("lumbridge").join("claude-rate-limits.jsonl")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The documented default location of Claude Code's session transcripts.
|
||||||
|
#[must_use]
|
||||||
|
pub fn default_projects_root() -> PathBuf {
|
||||||
|
if let Some(configured) = std::env::var_os("CLAUDE_CONFIG_DIR") {
|
||||||
|
return PathBuf::from(configured).join("projects");
|
||||||
|
}
|
||||||
|
std::env::var_os("HOME")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.join(".claude")
|
||||||
|
.join("projects")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum WorkerEvent {
|
||||||
|
Observation(UsageObservation),
|
||||||
|
Health(ProbeHealth),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A running Claude Code usage probe.
|
||||||
|
pub struct ClaudeCodeProbe {
|
||||||
|
profiles: Vec<AccountProfile>,
|
||||||
|
events: Option<Receiver<WorkerEvent>>,
|
||||||
|
stop: Arc<AtomicBool>,
|
||||||
|
worker: Option<JoinHandle<()>>,
|
||||||
|
health: ProbeHealth,
|
||||||
|
tokens: TokenTally,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClaudeCodeProbe {
|
||||||
|
/// The static, account-free profiles this probe reports under: one per
|
||||||
|
/// documented subscription window, plus the transcript token total.
|
||||||
|
///
|
||||||
|
/// The windows come first because they are the quota — what a user means
|
||||||
|
/// by "how much do I have left" — and the token total is the supporting
|
||||||
|
/// fact about spend.
|
||||||
|
fn profiles_for() -> Result<Vec<AccountProfile>, HarnessError> {
|
||||||
|
let mut profiles = Vec::new();
|
||||||
|
for kind in ClaudeWindowKind::ALL {
|
||||||
|
profiles.push(
|
||||||
|
AccountProfile::new(
|
||||||
|
kind.profile_id(),
|
||||||
|
"Claude Code",
|
||||||
|
"Anthropic",
|
||||||
|
kind.scope(),
|
||||||
|
"subscription",
|
||||||
|
)
|
||||||
|
.map_err(|_| HarnessError::EmptyProgram)?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
profiles.push(
|
||||||
|
AccountProfile::new(
|
||||||
|
"claude-code-transcripts",
|
||||||
|
"Claude Code",
|
||||||
|
"Anthropic",
|
||||||
|
"session transcripts",
|
||||||
|
"subscription",
|
||||||
|
)
|
||||||
|
.map_err(|_| HarnessError::EmptyProgram)?,
|
||||||
|
);
|
||||||
|
Ok(profiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts the probe and its worker.
|
||||||
|
///
|
||||||
|
/// A missing projects directory is not an error: the probe reports zero
|
||||||
|
/// and keeps looking, because Claude Code creates it on first use.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`HarnessError::ProbeIntervalTooShort`] below one second.
|
||||||
|
pub fn start(options: ClaudeCodeProbeOptions) -> Result<Self, HarnessError> {
|
||||||
|
if options.poll_interval < MIN_POLL_INTERVAL {
|
||||||
|
return Err(HarnessError::ProbeIntervalTooShort);
|
||||||
|
}
|
||||||
|
let profiles = Self::profiles_for()?;
|
||||||
|
let worker_profiles = profiles.clone();
|
||||||
|
let (event_sender, events) = mpsc::sync_channel(EVENT_QUEUE);
|
||||||
|
let stop = Arc::new(AtomicBool::new(false));
|
||||||
|
let worker_stop = Arc::clone(&stop);
|
||||||
|
let worker = thread::Builder::new()
|
||||||
|
.name("lumbridge-claude-probe".to_owned())
|
||||||
|
.spawn(move || {
|
||||||
|
run_worker(&options, &worker_profiles, &event_sender, &worker_stop);
|
||||||
|
})
|
||||||
|
.map_err(|error| HarnessError::SpawnFailed(error.kind()))?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
profiles,
|
||||||
|
events: Some(events),
|
||||||
|
stop,
|
||||||
|
worker: Some(worker),
|
||||||
|
health: ProbeHealth::Starting,
|
||||||
|
tokens: TokenTally::default(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The token split behind the latest observation.
|
||||||
|
///
|
||||||
|
/// The ledger holds only the total; this keeps the breakdown available for
|
||||||
|
/// a detail view without a second pass over the transcripts.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn tokens(&self) -> TokenTally {
|
||||||
|
self.tokens
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UsageProbe for ClaudeCodeProbe {
|
||||||
|
fn poll(&mut self) -> ProbeOutcome {
|
||||||
|
let Some(events) = self.events.as_ref() else {
|
||||||
|
return ProbeOutcome::idle(self.health);
|
||||||
|
};
|
||||||
|
let mut observations = Vec::new();
|
||||||
|
loop {
|
||||||
|
match events.try_recv() {
|
||||||
|
Ok(WorkerEvent::Observation(observation)) => observations.push(observation),
|
||||||
|
Ok(WorkerEvent::Health(health)) => self.health = health,
|
||||||
|
Err(TryRecvError::Empty) => break,
|
||||||
|
Err(TryRecvError::Disconnected) => {
|
||||||
|
if !self.health.is_faulted() {
|
||||||
|
self.health = ProbeHealth::Stopped;
|
||||||
|
}
|
||||||
|
self.events = None;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ProbeOutcome::new(observations, self.health)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn profiles(&self) -> &[AccountProfile] {
|
||||||
|
&self.profiles
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shutdown(&mut self) {
|
||||||
|
self.stop.store(true, Ordering::Relaxed);
|
||||||
|
self.events = None;
|
||||||
|
if let Some(worker) = self.worker.take() {
|
||||||
|
let _ = worker.join();
|
||||||
|
}
|
||||||
|
if !self.health.is_faulted() {
|
||||||
|
self.health = ProbeHealth::Stopped;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for ClaudeCodeProbe {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.shutdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-transcript read position. The offset is what makes the total monotonic.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct FollowState {
|
||||||
|
offsets: BTreeMap<PathBuf, u64>,
|
||||||
|
tokens: TokenTally,
|
||||||
|
/// Read position in the status-line feed, and the newest reading seen.
|
||||||
|
feed_offset: u64,
|
||||||
|
windows: BTreeMap<&'static str, WindowReading>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads whatever the status-line bridge appended and keeps the newest line.
|
||||||
|
///
|
||||||
|
/// The feed is a state snapshot per line, not a stream of deltas, so only the
|
||||||
|
/// last complete line matters — an older one is superseded, never summed.
|
||||||
|
fn follow_feed(path: &Path, state: &mut FollowState, observed_at_ms: u64) {
|
||||||
|
let Ok(metadata) = std::fs::metadata(path) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let length = metadata.len();
|
||||||
|
if length < state.feed_offset {
|
||||||
|
state.feed_offset = 0;
|
||||||
|
}
|
||||||
|
if length == state.feed_offset {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let take = usize::try_from(length - state.feed_offset)
|
||||||
|
.unwrap_or(usize::MAX)
|
||||||
|
.min(MAX_BYTES_PER_POLL);
|
||||||
|
let Ok(mut file) = File::open(path) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if file.seek(SeekFrom::Start(state.feed_offset)).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut buffer = vec![0_u8; take];
|
||||||
|
let Ok(read) = file.read(&mut buffer) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
buffer.truncate(read);
|
||||||
|
let chunk = String::from_utf8_lossy(&buffer);
|
||||||
|
|
||||||
|
let mut consumed = 0;
|
||||||
|
let mut newest = None;
|
||||||
|
for line in chunk.split_inclusive('\n') {
|
||||||
|
if !line.ends_with('\n') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
consumed += line.len();
|
||||||
|
if line.len() <= MAX_RECORD_BYTES
|
||||||
|
&& let Some(readings) = parse_feed_line(line.trim_end(), observed_at_ms)
|
||||||
|
{
|
||||||
|
newest = Some(readings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.feed_offset += consumed as u64;
|
||||||
|
if let Some(readings) = newest {
|
||||||
|
for (kind, reading) in readings {
|
||||||
|
state.windows.insert(kind.profile_id(), reading);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_worker(
|
||||||
|
options: &ClaudeCodeProbeOptions,
|
||||||
|
profiles: &[AccountProfile],
|
||||||
|
events: &SyncSender<WorkerEvent>,
|
||||||
|
stop: &AtomicBool,
|
||||||
|
) {
|
||||||
|
let token_profile = profiles
|
||||||
|
.iter()
|
||||||
|
.find(|profile| profile.id().as_str() == "claude-code-transcripts")
|
||||||
|
.cloned();
|
||||||
|
let mut clock = MonotonicWallClock::start();
|
||||||
|
let mut state = FollowState::default();
|
||||||
|
let poll_interval_ms = u64::try_from(options.poll_interval.as_millis()).unwrap_or(u64::MAX);
|
||||||
|
let mut last_poll_ms = 0;
|
||||||
|
let mut last_emitted = TokenTally::default();
|
||||||
|
let mut last_windows: BTreeMap<&'static str, WindowReading> = BTreeMap::new();
|
||||||
|
let mut primed = false;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if stop.load(Ordering::Relaxed) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let now_ms = clock.now_ms();
|
||||||
|
// While priming, scan every tick to work through the backlog quickly.
|
||||||
|
// After that, respect the poll interval.
|
||||||
|
if primed && now_ms.saturating_sub(last_poll_ms) < poll_interval_ms {
|
||||||
|
thread::sleep(WORKER_TICK);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
last_poll_ms = now_ms;
|
||||||
|
|
||||||
|
let caught_up = scan(&options.projects_root, &mut state);
|
||||||
|
// The quota windows are independent of the transcript backlog: a
|
||||||
|
// status-line reading is a complete snapshot, so it can be reported
|
||||||
|
// immediately rather than waiting for priming.
|
||||||
|
follow_feed(&options.rate_limit_feed, &mut state, now_ms);
|
||||||
|
for kind in ClaudeWindowKind::ALL {
|
||||||
|
let Some(reading) = state.windows.get(kind.profile_id()).copied() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if last_windows.get(kind.profile_id()) == Some(&reading) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
last_windows.insert(kind.profile_id(), reading);
|
||||||
|
let Some(profile) = profiles
|
||||||
|
.iter()
|
||||||
|
.find(|profile| profile.id().as_str() == kind.profile_id())
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let observation = window_observation(profile, reading, now_ms);
|
||||||
|
if matches!(
|
||||||
|
events.try_send(WorkerEvent::Observation(observation)),
|
||||||
|
Err(mpsc::TrySendError::Disconnected(_))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing is reported until the follower has read every existing
|
||||||
|
// transcript to its end. A partially-read backlog looks exactly like
|
||||||
|
// an enormous burst of spend, and the ledger would derive a burn rate
|
||||||
|
// from it — one early run reported forty-six billion tokens an hour,
|
||||||
|
// which was catch-up, not usage. The first reading must be a true
|
||||||
|
// baseline before any rate can mean anything.
|
||||||
|
if !caught_up {
|
||||||
|
thread::sleep(WORKER_TICK);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !primed {
|
||||||
|
primed = true;
|
||||||
|
let _ = events.try_send(WorkerEvent::Health(ProbeHealth::Ready));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only speak when the number changed. An unchanged total re-recorded
|
||||||
|
// every poll would flush the ledger's bounded history of real readings.
|
||||||
|
if state.tokens == last_emitted {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
last_emitted = state.tokens;
|
||||||
|
let Some(profile) = token_profile.as_ref() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Ok(observation) = UsageObservation::counted(
|
||||||
|
profile.id().clone(),
|
||||||
|
UsageUnit::Tokens,
|
||||||
|
state.tokens.total(),
|
||||||
|
UsageProvenance::HarnessReported,
|
||||||
|
now_ms,
|
||||||
|
) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
// No `with_limit` and no `with_window`: the transcript states what was
|
||||||
|
// spent and says nothing about a ceiling or a reset. Attaching either
|
||||||
|
// would be an invention.
|
||||||
|
if matches!(
|
||||||
|
events.try_send(WorkerEvent::Observation(observation)),
|
||||||
|
Err(mpsc::TrySendError::Disconnected(_))
|
||||||
|
) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turns a window reading into the observation the ledger should hold.
|
||||||
|
///
|
||||||
|
/// A usable reading is `ProviderReported`: the CLI is relaying rate-limit
|
||||||
|
/// headers from its own API responses, not computing a number. An absent
|
||||||
|
/// window is an explicit gap — an account with no plan limits, or a window the
|
||||||
|
/// API has stopped reporting — never a zero.
|
||||||
|
fn window_observation(
|
||||||
|
profile: &AccountProfile,
|
||||||
|
reading: WindowReading,
|
||||||
|
observed_at_ms: u64,
|
||||||
|
) -> UsageObservation {
|
||||||
|
let WindowReading::Usable { permille, window } = reading else {
|
||||||
|
return UsageObservation::unavailable(profile.id().clone(), observed_at_ms);
|
||||||
|
};
|
||||||
|
let Ok(observation) = UsageObservation::counted(
|
||||||
|
profile.id().clone(),
|
||||||
|
UsageUnit::WindowPermille,
|
||||||
|
permille,
|
||||||
|
UsageProvenance::ProviderReported,
|
||||||
|
observed_at_ms,
|
||||||
|
) else {
|
||||||
|
return UsageObservation::unavailable(profile.id().clone(), observed_at_ms);
|
||||||
|
};
|
||||||
|
let observation = observation
|
||||||
|
.with_limit(1_000)
|
||||||
|
.unwrap_or_else(|_| UsageObservation::unavailable(profile.id().clone(), observed_at_ms));
|
||||||
|
let Some(window) = window else {
|
||||||
|
return observation;
|
||||||
|
};
|
||||||
|
observation
|
||||||
|
.clone()
|
||||||
|
.with_window(window)
|
||||||
|
.unwrap_or(observation)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walks the projects tree and reads whatever was appended since last time.
|
||||||
|
///
|
||||||
|
/// Returns whether every transcript was read to its end. A `false` means the
|
||||||
|
/// per-poll byte budget ran out with work remaining, and the running total is
|
||||||
|
/// therefore mid-catch-up rather than current.
|
||||||
|
fn scan(root: &Path, state: &mut FollowState) -> bool {
|
||||||
|
let mut transcripts = Vec::new();
|
||||||
|
collect_transcripts(root, 0, &mut transcripts);
|
||||||
|
let mut budget = MAX_BYTES_PER_POLL;
|
||||||
|
let mut caught_up = true;
|
||||||
|
for path in transcripts {
|
||||||
|
if budget == 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if !follow(&path, state, &mut budget) {
|
||||||
|
caught_up = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
caught_up
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_transcripts(directory: &Path, depth: usize, found: &mut Vec<PathBuf>) {
|
||||||
|
if depth > MAX_DEPTH || found.len() >= MAX_FILES_PER_SCAN {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Ok(entries) = std::fs::read_dir(directory) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
if found.len() >= MAX_FILES_PER_SCAN {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let path = entry.path();
|
||||||
|
let Ok(file_type) = entry.file_type() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
// Deliberately not following symlinks: a link inside the projects tree
|
||||||
|
// could otherwise point this reader at an arbitrary file.
|
||||||
|
if file_type.is_dir() {
|
||||||
|
collect_transcripts(&path, depth + 1, found);
|
||||||
|
} else if file_type.is_file() && path.extension().is_some_and(|ext| ext == "jsonl") {
|
||||||
|
found.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads one transcript from its remembered offset.
|
||||||
|
///
|
||||||
|
/// Returns whether this file is now read to its end.
|
||||||
|
fn follow(path: &Path, state: &mut FollowState, budget: &mut usize) -> bool {
|
||||||
|
let offset = state.offsets.get(path).copied().unwrap_or(0);
|
||||||
|
let Ok(metadata) = std::fs::metadata(path) else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
let length = metadata.len();
|
||||||
|
if length < offset {
|
||||||
|
// The file shrank, so it is not the file we were reading. Start over
|
||||||
|
// rather than trusting an offset into different content.
|
||||||
|
state.offsets.insert(path.to_path_buf(), 0);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let pending = usize::try_from(length - offset).unwrap_or(usize::MAX);
|
||||||
|
if pending == 0 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let take = pending.min(*budget);
|
||||||
|
|
||||||
|
let Ok(mut file) = File::open(path) else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
if file.seek(SeekFrom::Start(offset)).is_err() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let mut buffer = vec![0_u8; take];
|
||||||
|
let Ok(read) = file.read(&mut buffer) else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
buffer.truncate(read);
|
||||||
|
// A transcript is UTF-8 JSON. A chunk boundary can split a multi-byte
|
||||||
|
// character, so decode lossily and stop at the last complete line — the
|
||||||
|
// offset only advances past whole records either way.
|
||||||
|
let chunk = String::from_utf8_lossy(&buffer);
|
||||||
|
let (tally, consumed) = transcript::consume_chunk(&chunk);
|
||||||
|
state.tokens.add(tally.tokens);
|
||||||
|
state
|
||||||
|
.offsets
|
||||||
|
.insert(path.to_path_buf(), offset + consumed as u64);
|
||||||
|
*budget = budget.saturating_sub(consumed);
|
||||||
|
// Caught up only if we took the whole remainder and parsed all of it. A
|
||||||
|
// trailing partial line counts as caught up: the record is still being
|
||||||
|
// written, and waiting for it is correct.
|
||||||
|
take == pending && pending.saturating_sub(consumed) < MAX_RECORD_BYTES
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{
|
||||||
|
ClaudeCodeProbe, ClaudeCodeProbeOptions, FollowState, TokenTally, default_projects_root,
|
||||||
|
scan,
|
||||||
|
};
|
||||||
|
use crate::HarnessError;
|
||||||
|
use crate::probe::{ProbeHealth, UsageProbe};
|
||||||
|
use lumbridge_core::{UsageProvenance, UsageUnit};
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
const RECORD: &str = r#"{"type":"assistant","message":{"model":"claude-opus-5","content":[{"type":"text","text":"private"}],"usage":{"input_tokens":1,"output_tokens":10,"cache_creation_input_tokens":100,"cache_read_input_tokens":1000}}}"#;
|
||||||
|
|
||||||
|
fn scratch(tag: &str) -> PathBuf {
|
||||||
|
let root = std::env::temp_dir().join(format!(
|
||||||
|
"lumbridge-claude-probe-{}-{tag}",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
std::fs::create_dir_all(root.join("project-a")).expect("scratch dir");
|
||||||
|
root
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append(path: &PathBuf, times: usize) {
|
||||||
|
let mut file = std::fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(path)
|
||||||
|
.expect("transcript is writable");
|
||||||
|
for _ in 0..times {
|
||||||
|
writeln!(file, "{RECORD}").expect("append");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_missing_projects_root_reports_zero_rather_than_failing() {
|
||||||
|
let mut state = FollowState::default();
|
||||||
|
scan(
|
||||||
|
&PathBuf::from("/nonexistent/lumbridge/claude/root"),
|
||||||
|
&mut state,
|
||||||
|
);
|
||||||
|
assert_eq!(state.tokens, TokenTally::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn appended_records_accumulate_and_never_double_count() {
|
||||||
|
let root = scratch("accumulate");
|
||||||
|
let transcript = root.join("project-a").join("session.jsonl");
|
||||||
|
append(&transcript, 2);
|
||||||
|
|
||||||
|
let mut state = FollowState::default();
|
||||||
|
assert!(scan(&root, &mut state));
|
||||||
|
assert_eq!(state.tokens.total(), 2 * 1_111);
|
||||||
|
|
||||||
|
// A second scan with no new bytes must add nothing.
|
||||||
|
assert!(scan(&root, &mut state));
|
||||||
|
assert_eq!(state.tokens.total(), 2 * 1_111);
|
||||||
|
|
||||||
|
// Only the newly appended record is counted.
|
||||||
|
append(&transcript, 1);
|
||||||
|
assert!(scan(&root, &mut state));
|
||||||
|
assert_eq!(state.tokens.total(), 3 * 1_111);
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nested_subagent_transcripts_are_counted_too() {
|
||||||
|
let root = scratch("nested");
|
||||||
|
let nested = root.join("project-a").join("session").join("subagents");
|
||||||
|
std::fs::create_dir_all(&nested).expect("nested dirs");
|
||||||
|
append(&root.join("project-a").join("session.jsonl"), 1);
|
||||||
|
append(&nested.join("agent-1.jsonl"), 1);
|
||||||
|
|
||||||
|
let mut state = FollowState::default();
|
||||||
|
assert!(scan(&root, &mut state));
|
||||||
|
assert_eq!(
|
||||||
|
state.tokens.total(),
|
||||||
|
2 * 1_111,
|
||||||
|
"a subagent's tokens are the user's tokens"
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_truncated_transcript_restarts_rather_than_reading_a_stale_offset() {
|
||||||
|
let root = scratch("truncated");
|
||||||
|
let transcript = root.join("project-a").join("session.jsonl");
|
||||||
|
append(&transcript, 3);
|
||||||
|
let mut state = FollowState::default();
|
||||||
|
assert!(scan(&root, &mut state));
|
||||||
|
let before = state.tokens.total();
|
||||||
|
assert_eq!(before, 3 * 1_111);
|
||||||
|
|
||||||
|
std::fs::write(&transcript, "").expect("truncate");
|
||||||
|
// The shrink is reported as not-caught-up on purpose: the offset was
|
||||||
|
// just reset, so the probe must re-scan before it trusts the total
|
||||||
|
// again rather than reporting mid-reset.
|
||||||
|
assert!(
|
||||||
|
!scan(&root, &mut state),
|
||||||
|
"a shrunk file asks to be read again"
|
||||||
|
);
|
||||||
|
assert!(scan(&root, &mut state), "and is caught up on the next pass");
|
||||||
|
assert_eq!(
|
||||||
|
state.tokens.total(),
|
||||||
|
before,
|
||||||
|
"a shrunk file resets its offset without inventing or losing tokens"
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_transcript_files_are_ignored() {
|
||||||
|
let root = scratch("ignored");
|
||||||
|
std::fs::write(root.join("project-a").join("notes.md"), RECORD).expect("write");
|
||||||
|
std::fs::write(root.join("project-a").join("history.json"), RECORD).expect("write");
|
||||||
|
let mut state = FollowState::default();
|
||||||
|
assert!(scan(&root, &mut state));
|
||||||
|
assert_eq!(state.tokens, TokenTally::default());
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_too_frequent_poll_interval_is_refused() {
|
||||||
|
let options = ClaudeCodeProbeOptions {
|
||||||
|
poll_interval: Duration::from_millis(10),
|
||||||
|
..ClaudeCodeProbeOptions::default()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
ClaudeCodeProbe::start(options).err(),
|
||||||
|
Some(HarnessError::ProbeIntervalTooShort)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_probe_reports_harness_reported_tokens_with_no_ceiling() {
|
||||||
|
let root = scratch("probe");
|
||||||
|
append(&root.join("project-a").join("session.jsonl"), 4);
|
||||||
|
let mut probe = ClaudeCodeProbe::start(ClaudeCodeProbeOptions {
|
||||||
|
projects_root: root.clone(),
|
||||||
|
rate_limit_feed: root.join("feed.jsonl"),
|
||||||
|
poll_interval: Duration::from_secs(1),
|
||||||
|
})
|
||||||
|
.expect("the probe starts");
|
||||||
|
|
||||||
|
let mut observations = Vec::new();
|
||||||
|
for _ in 0..200 {
|
||||||
|
observations.extend(probe.poll().into_observations());
|
||||||
|
if !observations.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(20));
|
||||||
|
}
|
||||||
|
probe.shutdown();
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
|
||||||
|
let observation = observations.first().expect("a reading arrives");
|
||||||
|
assert_eq!(observation.provenance(), UsageProvenance::HarnessReported);
|
||||||
|
assert_eq!(observation.unit(), Some(UsageUnit::Tokens));
|
||||||
|
assert_eq!(observation.consumed(), 4 * 1_111);
|
||||||
|
assert_eq!(
|
||||||
|
observation.limit(),
|
||||||
|
None,
|
||||||
|
"a transcript states spend, never a ceiling"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
observation.window(),
|
||||||
|
None,
|
||||||
|
"a transcript states spend, never a reset"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_status_line_feed_becomes_provider_reported_windows() {
|
||||||
|
let root = scratch("windows");
|
||||||
|
let feed = root.join("feed.jsonl");
|
||||||
|
// The shape the bridge writes, with a reset far enough ahead that the
|
||||||
|
// window is still open when the probe reads it.
|
||||||
|
let resets_at = (std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.expect("a clock after the epoch")
|
||||||
|
.as_secs())
|
||||||
|
+ 3_600;
|
||||||
|
std::fs::write(
|
||||||
|
&feed,
|
||||||
|
format!(
|
||||||
|
"{{\"rate_limits_available\":true,\"rate_limits\":{{\"five_hour\":{{\"used_percentage\":42,\"resets_at\":{resets_at}}},\"seven_day\":{{\"used_percentage\":8.5,\"resets_at\":{resets_at}}}}}}}\n"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.expect("feed is writable");
|
||||||
|
|
||||||
|
let mut probe = ClaudeCodeProbe::start(ClaudeCodeProbeOptions {
|
||||||
|
projects_root: root.clone(),
|
||||||
|
rate_limit_feed: feed,
|
||||||
|
poll_interval: Duration::from_secs(1),
|
||||||
|
})
|
||||||
|
.expect("the probe starts");
|
||||||
|
|
||||||
|
let mut observations = Vec::new();
|
||||||
|
for _ in 0..200 {
|
||||||
|
observations.extend(probe.poll().into_observations());
|
||||||
|
if observations.len() >= 2 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(20));
|
||||||
|
}
|
||||||
|
probe.shutdown();
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
|
||||||
|
let five = observations
|
||||||
|
.iter()
|
||||||
|
.find(|observation| observation.profile().as_str() == "claude-code-five-hour")
|
||||||
|
.expect("the five-hour window is reported");
|
||||||
|
assert_eq!(
|
||||||
|
five.provenance(),
|
||||||
|
UsageProvenance::ProviderReported,
|
||||||
|
"the CLI relays the provider's own rate-limit headers"
|
||||||
|
);
|
||||||
|
assert_eq!(five.unit(), Some(UsageUnit::WindowPermille));
|
||||||
|
assert_eq!(five.consumed(), 420);
|
||||||
|
assert_eq!(
|
||||||
|
five.limit(),
|
||||||
|
Some(1_000),
|
||||||
|
"a percentage is a share of one whole"
|
||||||
|
);
|
||||||
|
assert!(five.window().is_some(), "a documented window has a reset");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_account_without_plan_limits_reports_a_gap_not_a_zero() {
|
||||||
|
let root = scratch("nolimits");
|
||||||
|
let feed = root.join("feed.jsonl");
|
||||||
|
std::fs::write(&feed, "{\"rate_limits_available\":false}\n").expect("writable");
|
||||||
|
let mut probe = ClaudeCodeProbe::start(ClaudeCodeProbeOptions {
|
||||||
|
projects_root: root.clone(),
|
||||||
|
rate_limit_feed: feed,
|
||||||
|
poll_interval: Duration::from_secs(1),
|
||||||
|
})
|
||||||
|
.expect("starts");
|
||||||
|
let mut observations = Vec::new();
|
||||||
|
for _ in 0..200 {
|
||||||
|
observations.extend(probe.poll().into_observations());
|
||||||
|
if !observations.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(20));
|
||||||
|
}
|
||||||
|
probe.shutdown();
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
let window = observations
|
||||||
|
.iter()
|
||||||
|
.find(|observation| observation.profile().as_str() == "claude-code-five-hour")
|
||||||
|
.expect("the window is still named");
|
||||||
|
assert_eq!(window.provenance(), UsageProvenance::Unavailable);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shutdown_is_prompt_and_idempotent() {
|
||||||
|
let root = scratch("shutdown");
|
||||||
|
let mut probe = ClaudeCodeProbe::start(ClaudeCodeProbeOptions {
|
||||||
|
projects_root: root.clone(),
|
||||||
|
rate_limit_feed: root.join("feed.jsonl"),
|
||||||
|
poll_interval: Duration::from_secs(60),
|
||||||
|
})
|
||||||
|
.expect("starts");
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
probe.shutdown();
|
||||||
|
probe.shutdown();
|
||||||
|
assert!(started.elapsed() < Duration::from_secs(5));
|
||||||
|
assert!(matches!(
|
||||||
|
probe.poll().health(),
|
||||||
|
ProbeHealth::Stopped | ProbeHealth::Starting | ProbeHealth::Ready
|
||||||
|
));
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_default_root_follows_the_documented_location() {
|
||||||
|
let root = default_projects_root();
|
||||||
|
assert!(root.ends_with("projects"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
//! Claude Code's status-line rate-limit payload: the subscription window.
|
||||||
|
//!
|
||||||
|
//! Claude Code 2.1.80 and later pipe a `rate_limits` object to the configured
|
||||||
|
//! `statusLine` command on every turn. The shape is documented inside the CLI
|
||||||
|
//! itself, which describes `five_hour` and `seven_day` as
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! "five_hour": { // present only while the API reports it and its resets_at has not passed
|
||||||
|
//! "used_percentage": number, // Percentage of limit used (0-100)
|
||||||
|
//! "resets_at": number // Unix epoch seconds when this window resets
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! These are the real quota windows, and they cost nothing to read: the CLI is
|
||||||
|
//! relaying rate-limit headers that already came back on its own API responses.
|
||||||
|
//! That makes them `ProviderReported` under decision 0013's test — the harness
|
||||||
|
//! forwards the provider's number rather than computing one.
|
||||||
|
//!
|
||||||
|
//! `rate_limits_available` is the honest-gap signal, and the CLI documents it:
|
||||||
|
//! "False when plan rate limits do not apply (API key, Bedrock, Vertex, or
|
||||||
|
//! missing profile scope) — `rate_limits` will be null." An API-key user has no
|
||||||
|
//! subscription window, and this field says so rather than leaving us guessing.
|
||||||
|
//!
|
||||||
|
//! Nothing else from the status-line payload is modelled. It also carries the
|
||||||
|
//! session's cost, its transcript path, the current working directory, and the
|
||||||
|
//! model — none of which this parser can represent.
|
||||||
|
|
||||||
|
use lumbridge_core::UsageWindow;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
/// Percent is 0-100 on the wire; a spend limit may exceed 100 once breached.
|
||||||
|
const MAX_PERCENT: f64 = 100.0;
|
||||||
|
const PERMILLE_PER_PERCENT: f64 = 10.0;
|
||||||
|
const MILLIS_PER_SECOND: i64 = 1_000;
|
||||||
|
/// Documented window lengths, used to give each reading a window start so the
|
||||||
|
/// ledger can scope a burn rate to one quota period.
|
||||||
|
const FIVE_HOUR_MS: u64 = 5 * 60 * 60 * 1_000;
|
||||||
|
const SEVEN_DAY_MS: u64 = 7 * 24 * 60 * 60 * 1_000;
|
||||||
|
|
||||||
|
/// One line of the feed a Lumbridge status-line bridge appends.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(crate) struct FeedLineWire {
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) rate_limits_available: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) rate_limits: Option<RateLimitsWire>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
pub(crate) struct RateLimitsWire {
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) five_hour: Option<WindowWire>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) seven_day: Option<WindowWire>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(crate) struct WindowWire {
|
||||||
|
/// The documented field. `utilization` is accepted as a sibling because the
|
||||||
|
/// OAuth usage endpoint spells the same quantity that way, and a rename
|
||||||
|
/// should degrade rather than silently go dark.
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) used_percentage: Option<f64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) utilization: Option<f64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) resets_at: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which documented subscription window a reading describes.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum ClaudeWindowKind {
|
||||||
|
FiveHour,
|
||||||
|
SevenDay,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClaudeWindowKind {
|
||||||
|
pub(crate) const ALL: [Self; 2] = [Self::FiveHour, Self::SevenDay];
|
||||||
|
|
||||||
|
/// Static, account-free profile identifiers.
|
||||||
|
pub(crate) const fn profile_id(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::FiveHour => "claude-code-five-hour",
|
||||||
|
Self::SevenDay => "claude-code-seven-day",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Named for the window, not for a model: these limits are account-scoped.
|
||||||
|
pub(crate) const fn scope(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::FiveHour => "five-hour window",
|
||||||
|
Self::SevenDay => "seven-day window",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn length_ms(self) -> u64 {
|
||||||
|
match self {
|
||||||
|
Self::FiveHour => FIVE_HOUR_MS,
|
||||||
|
Self::SevenDay => SEVEN_DAY_MS,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What one window in the feed means at a point in time.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum WindowReading {
|
||||||
|
/// A usable share of the window.
|
||||||
|
Usable {
|
||||||
|
permille: u64,
|
||||||
|
window: Option<UsageWindow>,
|
||||||
|
},
|
||||||
|
/// The window is absent, or the account has no plan limits at all.
|
||||||
|
Absent,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads one window against the moment it was observed.
|
||||||
|
///
|
||||||
|
/// Total: every input produces a decision and none of them panics.
|
||||||
|
pub(crate) fn parse_window(
|
||||||
|
wire: &WindowWire,
|
||||||
|
kind: ClaudeWindowKind,
|
||||||
|
observed_at_ms: u64,
|
||||||
|
) -> WindowReading {
|
||||||
|
let Some(percent) = wire.used_percentage.or(wire.utilization) else {
|
||||||
|
return WindowReading::Absent;
|
||||||
|
};
|
||||||
|
if !percent.is_finite() || percent < 0.0 {
|
||||||
|
return WindowReading::Absent;
|
||||||
|
}
|
||||||
|
// A spend limit can report over 100 once breached; a quota share cannot
|
||||||
|
// exceed the whole, so clamp rather than letting the ledger hold >100%.
|
||||||
|
//
|
||||||
|
// The value is clamped to 0..=100 and scaled by ten before rounding, so it
|
||||||
|
// is a whole number in 0..=1000 — well inside u64 and inside the range f64
|
||||||
|
// represents exactly. The conversion cannot truncate or lose a sign here.
|
||||||
|
#[expect(
|
||||||
|
clippy::cast_possible_truncation,
|
||||||
|
clippy::cast_sign_loss,
|
||||||
|
reason = "a rounded whole number clamped to 0..=1000"
|
||||||
|
)]
|
||||||
|
let permille = (percent.clamp(0.0, MAX_PERCENT) * PERMILLE_PER_PERCENT).round() as u64;
|
||||||
|
|
||||||
|
let window = wire
|
||||||
|
.resets_at
|
||||||
|
.and_then(unix_seconds_to_millis)
|
||||||
|
// The CLI documents that a window is present only while its resets_at
|
||||||
|
// has not passed. One that has is stale, so its start-and-reset pair is
|
||||||
|
// dropped and only the percentage survives.
|
||||||
|
.filter(|resets_at_ms| *resets_at_ms > observed_at_ms)
|
||||||
|
.and_then(|resets_at_ms| {
|
||||||
|
let started_at_ms = resets_at_ms.saturating_sub(kind.length_ms());
|
||||||
|
if started_at_ms <= observed_at_ms {
|
||||||
|
UsageWindow::new(started_at_ms, resets_at_ms).ok()
|
||||||
|
} else {
|
||||||
|
// The local clock disagrees with the provider about the start.
|
||||||
|
// Keep the reset, which is the half that bounds a forecast.
|
||||||
|
Some(UsageWindow::until(resets_at_ms))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
WindowReading::Usable { permille, window }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unix_seconds_to_millis(seconds: i64) -> Option<u64> {
|
||||||
|
u64::try_from(seconds.checked_mul(MILLIS_PER_SECOND)?).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads one feed line into a reading per documented window.
|
||||||
|
///
|
||||||
|
/// `rate_limits_available: false` is a positive statement that this account has
|
||||||
|
/// no plan limits, so every window reads as absent rather than unknown.
|
||||||
|
pub(crate) fn parse_feed_line(
|
||||||
|
line: &str,
|
||||||
|
observed_at_ms: u64,
|
||||||
|
) -> Option<[(ClaudeWindowKind, WindowReading); 2]> {
|
||||||
|
let feed: FeedLineWire = serde_json::from_str(line).ok()?;
|
||||||
|
if feed.rate_limits_available == Some(false) {
|
||||||
|
return Some([
|
||||||
|
(ClaudeWindowKind::FiveHour, WindowReading::Absent),
|
||||||
|
(ClaudeWindowKind::SevenDay, WindowReading::Absent),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
let limits = feed.rate_limits?;
|
||||||
|
let five = limits
|
||||||
|
.five_hour
|
||||||
|
.as_ref()
|
||||||
|
.map_or(WindowReading::Absent, |w| {
|
||||||
|
parse_window(w, ClaudeWindowKind::FiveHour, observed_at_ms)
|
||||||
|
});
|
||||||
|
let seven = limits
|
||||||
|
.seven_day
|
||||||
|
.as_ref()
|
||||||
|
.map_or(WindowReading::Absent, |w| {
|
||||||
|
parse_window(w, ClaudeWindowKind::SevenDay, observed_at_ms)
|
||||||
|
});
|
||||||
|
Some([
|
||||||
|
(ClaudeWindowKind::FiveHour, five),
|
||||||
|
(ClaudeWindowKind::SevenDay, seven),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{ClaudeWindowKind, WindowReading, parse_feed_line};
|
||||||
|
|
||||||
|
const HOUR_MS: u64 = 3_600_000;
|
||||||
|
const RESETS_AT_SECONDS: i64 = 1_800_000_000;
|
||||||
|
const RESETS_AT_MS: u64 = 1_800_000_000_000;
|
||||||
|
|
||||||
|
fn line(five: &str, seven: &str) -> String {
|
||||||
|
format!(
|
||||||
|
r#"{{"rate_limits_available":true,"rate_limits":{{"five_hour":{five},"seven_day":{seven}}}}}"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_documented_shape_yields_both_windows() {
|
||||||
|
let body = line(
|
||||||
|
&format!(r#"{{"used_percentage":42,"resets_at":{RESETS_AT_SECONDS}}}"#),
|
||||||
|
&format!(r#"{{"used_percentage":8.5,"resets_at":{RESETS_AT_SECONDS}}}"#),
|
||||||
|
);
|
||||||
|
let readings = parse_feed_line(&body, RESETS_AT_MS - HOUR_MS).expect("a valid feed line");
|
||||||
|
let WindowReading::Usable { permille, window } = readings[0].1 else {
|
||||||
|
panic!("the five-hour window is usable");
|
||||||
|
};
|
||||||
|
assert_eq!(readings[0].0, ClaudeWindowKind::FiveHour);
|
||||||
|
assert_eq!(permille, 420);
|
||||||
|
let window = window.expect("a documented window length builds a window");
|
||||||
|
assert_eq!(window.resets_at_ms(), RESETS_AT_MS);
|
||||||
|
assert_eq!(
|
||||||
|
window.started_at_ms(),
|
||||||
|
Some(RESETS_AT_MS - 5 * HOUR_MS),
|
||||||
|
"the five-hour window starts five hours before it resets"
|
||||||
|
);
|
||||||
|
|
||||||
|
let WindowReading::Usable { permille, .. } = readings[1].1 else {
|
||||||
|
panic!("the seven-day window is usable");
|
||||||
|
};
|
||||||
|
assert_eq!(permille, 85, "a fractional percent keeps its tenth");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_api_key_account_reports_absent_rather_than_zero() {
|
||||||
|
// The CLI documents rate_limits_available as false for API key,
|
||||||
|
// Bedrock, Vertex, or a missing profile scope.
|
||||||
|
let readings = parse_feed_line(
|
||||||
|
r#"{"rate_limits_available":false,"rate_limits":null}"#,
|
||||||
|
RESETS_AT_MS,
|
||||||
|
)
|
||||||
|
.expect("a valid feed line");
|
||||||
|
assert!(readings.iter().all(|(_, r)| *r == WindowReading::Absent));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_missing_window_is_absent_without_affecting_the_other() {
|
||||||
|
let body = line(
|
||||||
|
&format!(r#"{{"used_percentage":10,"resets_at":{RESETS_AT_SECONDS}}}"#),
|
||||||
|
"null",
|
||||||
|
);
|
||||||
|
let readings = parse_feed_line(&body, RESETS_AT_MS - HOUR_MS).expect("valid");
|
||||||
|
assert!(matches!(readings[0].1, WindowReading::Usable { .. }));
|
||||||
|
assert_eq!(readings[1].1, WindowReading::Absent);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_reset_that_has_passed_keeps_the_percentage_and_drops_the_window() {
|
||||||
|
let body = line(
|
||||||
|
&format!(r#"{{"used_percentage":99,"resets_at":{RESETS_AT_SECONDS}}}"#),
|
||||||
|
"null",
|
||||||
|
);
|
||||||
|
let readings = parse_feed_line(&body, RESETS_AT_MS + 1).expect("valid");
|
||||||
|
assert_eq!(
|
||||||
|
readings[0].1,
|
||||||
|
WindowReading::Usable {
|
||||||
|
permille: 990,
|
||||||
|
window: None
|
||||||
|
},
|
||||||
|
"a window whose reset has passed is stale, not current"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_oauth_spelling_of_the_same_field_is_accepted() {
|
||||||
|
let body = line(
|
||||||
|
&format!(r#"{{"utilization":25,"resets_at":{RESETS_AT_SECONDS}}}"#),
|
||||||
|
"null",
|
||||||
|
);
|
||||||
|
let readings = parse_feed_line(&body, RESETS_AT_MS - HOUR_MS).expect("valid");
|
||||||
|
assert!(matches!(
|
||||||
|
readings[0].1,
|
||||||
|
WindowReading::Usable { permille: 250, .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_over_range_or_nonsense_percentage_is_clamped_or_refused() {
|
||||||
|
let over = line(r#"{"used_percentage":140}"#, "null");
|
||||||
|
assert!(matches!(
|
||||||
|
parse_feed_line(&over, 0).expect("valid")[0].1,
|
||||||
|
WindowReading::Usable {
|
||||||
|
permille: 1_000,
|
||||||
|
window: None
|
||||||
|
}
|
||||||
|
));
|
||||||
|
for bad in ["-1", "null"] {
|
||||||
|
let body = line(&format!(r#"{{"used_percentage":{bad}}}"#), "null");
|
||||||
|
assert_eq!(
|
||||||
|
parse_feed_line(&body, 0).expect("valid")[0].1,
|
||||||
|
WindowReading::Absent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_or_unrelated_lines_are_refused() {
|
||||||
|
for body in ["", "not json", "{}", r#"{"rate_limits":{}}"#] {
|
||||||
|
let parsed = parse_feed_line(body, 0);
|
||||||
|
assert!(
|
||||||
|
parsed.is_none_or(|readings| readings
|
||||||
|
.iter()
|
||||||
|
.all(|(_, r)| *r == WindowReading::Absent)),
|
||||||
|
"{body:?} must not produce a number"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn window_kinds_have_distinct_account_free_identifiers() {
|
||||||
|
assert_ne!(
|
||||||
|
ClaudeWindowKind::FiveHour.profile_id(),
|
||||||
|
ClaudeWindowKind::SevenDay.profile_id()
|
||||||
|
);
|
||||||
|
for kind in ClaudeWindowKind::ALL {
|
||||||
|
assert!(!kind.profile_id().contains('@'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
//! The Claude Code transcript reader: token counts in, nothing else out.
|
||||||
|
//!
|
||||||
|
//! Claude Code writes one JSONL record per event to
|
||||||
|
//! `~/.claude/projects/<encoded-cwd>/<session>.jsonl`. Assistant records carry
|
||||||
|
//! the API's `usage` object verbatim, which is the only documented local
|
||||||
|
//! surface that reports what the harness actually spent.
|
||||||
|
//!
|
||||||
|
//! **These files contain the user's conversations.** This module is built so
|
||||||
|
//! that reading one cannot surface their content: the wire types below model
|
||||||
|
//! the record type, the model name, and the four token counters, and nothing
|
||||||
|
//! else. Serde discards every unmodelled field while parsing, so message text,
|
||||||
|
//! tool inputs, and file contents are never deserialized into a Lumbridge value
|
||||||
|
//! at all. That is a structural guarantee, not a convention — there is no
|
||||||
|
//! field here that could hold them.
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
/// A transcript line is a single JSON object. Anything longer than this is not
|
||||||
|
/// a record we can use, and buffering it would let one file grow this process.
|
||||||
|
pub(crate) const MAX_RECORD_BYTES: usize = 1024 * 1024;
|
||||||
|
|
||||||
|
/// The only fields this crate reads from a transcript record.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(crate) struct RecordWire {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub(crate) record_type: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) message: Option<MessageWire>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(crate) struct MessageWire {
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) model: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) usage: Option<UsageWire>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The API usage object as Claude Code records it.
|
||||||
|
///
|
||||||
|
/// `cache_creation`, `server_tool_use`, `iterations`, `service_tier`, `speed`,
|
||||||
|
/// and `inference_geo` are all present on the wire and all deliberately
|
||||||
|
/// unmodelled: a probe should not be able to read account or routing metadata
|
||||||
|
/// it has no use for.
|
||||||
|
///
|
||||||
|
/// The wire names are pinned with explicit renames so the Rust field names can
|
||||||
|
/// read naturally without the contract drifting from what Claude Code writes.
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
pub(crate) struct UsageWire {
|
||||||
|
#[serde(default, rename = "input_tokens")]
|
||||||
|
pub(crate) input: u64,
|
||||||
|
#[serde(default, rename = "output_tokens")]
|
||||||
|
pub(crate) output: u64,
|
||||||
|
#[serde(default, rename = "cache_creation_input_tokens")]
|
||||||
|
pub(crate) cache_creation: u64,
|
||||||
|
#[serde(default, rename = "cache_read_input_tokens")]
|
||||||
|
pub(crate) cache_read: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tokens the harness recorded, kept split by kind.
|
||||||
|
///
|
||||||
|
/// The four counters are priced very differently and mean different things, so
|
||||||
|
/// they are carried separately rather than collapsed at the point of parsing.
|
||||||
|
/// The ledger holds one number per profile; [`TokenTally::total`] is what it
|
||||||
|
/// gets, and the split stays available for a detail view.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||||
|
pub struct TokenTally {
|
||||||
|
pub input: u64,
|
||||||
|
pub output: u64,
|
||||||
|
pub cache_creation: u64,
|
||||||
|
pub cache_read: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TokenTally {
|
||||||
|
/// Every token the API processed.
|
||||||
|
///
|
||||||
|
/// Cache reads are included because they are billed and do count against a
|
||||||
|
/// subscription window, even though they are re-reads of context already
|
||||||
|
/// counted once. Excluding them would understate consumption; weighting
|
||||||
|
/// them by price would require a price list this adapter does not have and
|
||||||
|
/// would turn a counted fact into an estimate.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn total(self) -> u64 {
|
||||||
|
self.input
|
||||||
|
.saturating_add(self.output)
|
||||||
|
.saturating_add(self.cache_creation)
|
||||||
|
.saturating_add(self.cache_read)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn is_empty(self) -> bool {
|
||||||
|
self.total() == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn add(&mut self, other: Self) {
|
||||||
|
self.input = self.input.saturating_add(other.input);
|
||||||
|
self.output = self.output.saturating_add(other.output);
|
||||||
|
self.cache_creation = self.cache_creation.saturating_add(other.cache_creation);
|
||||||
|
self.cache_read = self.cache_read.saturating_add(other.cache_read);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What one transcript line contributed.
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub(crate) struct RecordTally {
|
||||||
|
pub(crate) tokens: TokenTally,
|
||||||
|
/// The model that produced the record, when it named one.
|
||||||
|
pub(crate) model: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads one transcript line.
|
||||||
|
///
|
||||||
|
/// Total: a line that is not JSON, not an assistant record, or carries no
|
||||||
|
/// usage contributes nothing. A malformed line is skipped rather than faulting
|
||||||
|
/// the probe, because a transcript is append-only and the last line of a file
|
||||||
|
/// being written is routinely incomplete.
|
||||||
|
pub(crate) fn parse_record(line: &str) -> Option<RecordTally> {
|
||||||
|
let record: RecordWire = serde_json::from_str(line).ok()?;
|
||||||
|
if record.record_type.as_deref() != Some("assistant") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let message = record.message?;
|
||||||
|
let usage = message.usage?;
|
||||||
|
let tokens = TokenTally {
|
||||||
|
input: usage.input,
|
||||||
|
output: usage.output,
|
||||||
|
cache_creation: usage.cache_creation,
|
||||||
|
cache_read: usage.cache_read,
|
||||||
|
};
|
||||||
|
if tokens.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(RecordTally {
|
||||||
|
tokens,
|
||||||
|
model: message.model,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sums the complete lines in a chunk, reporting how many bytes were consumed.
|
||||||
|
///
|
||||||
|
/// A trailing partial line is left unconsumed so the next read picks it up
|
||||||
|
/// whole. This is what makes the running total monotonic: every byte is
|
||||||
|
/// counted exactly once, and a file that is still being appended to never
|
||||||
|
/// double-counts or loses its last record.
|
||||||
|
pub(crate) fn consume_chunk(chunk: &str) -> (RecordTally, usize) {
|
||||||
|
let mut tally = RecordTally::default();
|
||||||
|
let mut consumed = 0;
|
||||||
|
for line in chunk.split_inclusive('\n') {
|
||||||
|
if !line.ends_with('\n') {
|
||||||
|
break; // A partial record. Leave it for the next read.
|
||||||
|
}
|
||||||
|
consumed += line.len();
|
||||||
|
if line.len() > MAX_RECORD_BYTES {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(record) = parse_record(line.trim_end()) {
|
||||||
|
tally.tokens.add(record.tokens);
|
||||||
|
if record.model.is_some() {
|
||||||
|
tally.model = record.model;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(tally, consumed)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{TokenTally, consume_chunk, parse_record};
|
||||||
|
|
||||||
|
/// Shaped exactly like a real assistant record, with the content fields a
|
||||||
|
/// real one carries so the test proves they are ignored rather than absent.
|
||||||
|
///
|
||||||
|
/// Deliberately one line: a transcript is one JSON object per line, and a
|
||||||
|
/// fixture that wrapped would not exercise the chunk boundary logic.
|
||||||
|
const ASSISTANT: &str = concat!(
|
||||||
|
r#"{"type":"assistant","uuid":"u1","sessionId":"s1","cwd":"/home/x","#,
|
||||||
|
r#""message":{"id":"msg_1","role":"assistant","model":"claude-opus-5","#,
|
||||||
|
r#""content":[{"type":"text","text":"SECRET CONVERSATION CONTENT"}],"#,
|
||||||
|
r#""usage":{"input_tokens":10,"output_tokens":200,"#,
|
||||||
|
r#""cache_creation_input_tokens":1000,"cache_read_input_tokens":50000,"#,
|
||||||
|
r#""service_tier":"standard","cache_creation":{"ephemeral_5m_input_tokens":7}}}}"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_assistant_record_yields_its_four_counters() {
|
||||||
|
let record = parse_record(ASSISTANT).expect("an assistant record with usage counts");
|
||||||
|
assert_eq!(
|
||||||
|
record.tokens,
|
||||||
|
TokenTally {
|
||||||
|
input: 10,
|
||||||
|
output: 200,
|
||||||
|
cache_creation: 1_000,
|
||||||
|
cache_read: 50_000,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(record.model.as_deref(), Some("claude-opus-5"));
|
||||||
|
assert_eq!(record.tokens.total(), 51_210);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn conversation_content_is_not_representable() {
|
||||||
|
// The record above carries message text. The parser's own output type
|
||||||
|
// has nowhere to put it, so this is a property of the types rather
|
||||||
|
// than of the parsing.
|
||||||
|
let record = parse_record(ASSISTANT).expect("parsed");
|
||||||
|
let rendered = format!("{record:?}");
|
||||||
|
assert!(
|
||||||
|
!rendered.contains("SECRET"),
|
||||||
|
"no debug rendering of a parsed record may include message content"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_assistant_records_contribute_nothing() {
|
||||||
|
for line in [
|
||||||
|
r#"{"type":"user","message":{"role":"user","content":"hello"}}"#,
|
||||||
|
r#"{"type":"attachment","attachment":{"content":"a file"}}"#,
|
||||||
|
r#"{"type":"system","subtype":"hook"}"#,
|
||||||
|
] {
|
||||||
|
assert!(parse_record(line).is_none(), "{line} must not be counted");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_assistant_record_without_usage_contributes_nothing() {
|
||||||
|
assert!(
|
||||||
|
parse_record(r#"{"type":"assistant","message":{"model":"claude-opus-5"}}"#).is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_lines_are_skipped_rather_than_faulting() {
|
||||||
|
for line in ["", "not json", "{", "[]", "\"a string\""] {
|
||||||
|
assert!(parse_record(line).is_none(), "{line:?} must not panic");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_partial_trailing_line_is_left_for_the_next_read() {
|
||||||
|
let chunk = format!("{ASSISTANT}\n{{\"type\":\"assis");
|
||||||
|
let (tally, consumed) = consume_chunk(&chunk);
|
||||||
|
assert_eq!(tally.tokens.total(), 51_210);
|
||||||
|
assert_eq!(
|
||||||
|
consumed,
|
||||||
|
ASSISTANT.len() + 1,
|
||||||
|
"only the complete record may be consumed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resuming_from_the_offset_counts_every_byte_exactly_once() {
|
||||||
|
let whole = format!("{ASSISTANT}\n{ASSISTANT}\n");
|
||||||
|
let (first, consumed) = consume_chunk(&whole[..ASSISTANT.len() + 4]);
|
||||||
|
let (second, _) = consume_chunk(&whole[consumed..]);
|
||||||
|
let mut total = first.tokens;
|
||||||
|
total.add(second.tokens);
|
||||||
|
assert_eq!(
|
||||||
|
total.total(),
|
||||||
|
2 * 51_210,
|
||||||
|
"a split read must not double-count or drop a record"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_oversized_line_is_consumed_but_not_parsed() {
|
||||||
|
let line = format!("{}\n", "x".repeat(super::MAX_RECORD_BYTES + 1));
|
||||||
|
let (tally, consumed) = consume_chunk(&line);
|
||||||
|
assert_eq!(tally.tokens.total(), 0);
|
||||||
|
assert_eq!(
|
||||||
|
consumed,
|
||||||
|
line.len(),
|
||||||
|
"the offset must still advance past it"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn totals_saturate_rather_than_overflowing() {
|
||||||
|
let tally = TokenTally {
|
||||||
|
input: u64::MAX,
|
||||||
|
output: u64::MAX,
|
||||||
|
cache_creation: 0,
|
||||||
|
cache_read: 0,
|
||||||
|
};
|
||||||
|
assert_eq!(tally.total(), u64::MAX);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
//! The only place a wall clock enters the usage path.
|
||||||
|
//!
|
||||||
|
//! [`lumbridge_core::UsageLedger::record`] rejects an observation older than
|
||||||
|
//! the newest one already recorded for that profile, because an append-only
|
||||||
|
//! stream cannot move backwards. A raw `SystemTime::now()` can move backwards:
|
||||||
|
//! an NTP step, a suspend/resume, or a manual clock change would poison a
|
||||||
|
//! profile's stream permanently. This clock anchors wall time once and then
|
||||||
|
//! advances it with a monotonic instant, so the timestamps it emits are
|
||||||
|
//! non-decreasing by construction while still being comparable to the Unix
|
||||||
|
//! reset times a provider reports.
|
||||||
|
|
||||||
|
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
/// A non-decreasing Unix-millisecond clock.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct MonotonicWallClock {
|
||||||
|
anchor_unix_ms: u64,
|
||||||
|
anchor: Instant,
|
||||||
|
last_emitted_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MonotonicWallClock {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MonotonicWallClock {
|
||||||
|
/// Anchors to the system clock once, now.
|
||||||
|
#[must_use]
|
||||||
|
pub fn start() -> Self {
|
||||||
|
let anchor_unix_ms = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.ok()
|
||||||
|
.and_then(|elapsed| u64::try_from(elapsed.as_millis()).ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
Self::start_at(anchor_unix_ms, Instant::now())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Anchors to a caller-supplied wall time. Exists so tests can drive the
|
||||||
|
/// clock without waiting for real time to pass.
|
||||||
|
#[must_use]
|
||||||
|
pub(crate) const fn start_at(anchor_unix_ms: u64, anchor: Instant) -> Self {
|
||||||
|
Self {
|
||||||
|
anchor_unix_ms,
|
||||||
|
anchor,
|
||||||
|
last_emitted_ms: anchor_unix_ms,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The current time in Unix milliseconds, never earlier than the last value
|
||||||
|
/// this clock returned.
|
||||||
|
pub fn now_ms(&mut self) -> u64 {
|
||||||
|
let elapsed_ms = u64::try_from(self.anchor.elapsed().as_millis()).unwrap_or(u64::MAX);
|
||||||
|
let candidate = self.anchor_unix_ms.saturating_add(elapsed_ms);
|
||||||
|
self.last_emitted_ms = self.last_emitted_ms.max(candidate);
|
||||||
|
self.last_emitted_ms
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn last_emitted_ms(&self) -> u64 {
|
||||||
|
self.last_emitted_ms
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::MonotonicWallClock;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_clock_starts_at_its_anchor() {
|
||||||
|
let mut clock = MonotonicWallClock::start_at(1_700_000_000_000, Instant::now());
|
||||||
|
assert!(clock.now_ms() >= 1_700_000_000_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn emitted_times_never_move_backwards() {
|
||||||
|
let mut clock = MonotonicWallClock::start();
|
||||||
|
let first = clock.now_ms();
|
||||||
|
let second = clock.now_ms();
|
||||||
|
assert!(second >= first);
|
||||||
|
assert_eq!(clock.last_emitted_ms(), second);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_zero_anchor_still_produces_a_usable_stream() {
|
||||||
|
let mut clock = MonotonicWallClock::start_at(0, Instant::now());
|
||||||
|
let first = clock.now_ms();
|
||||||
|
assert!(clock.now_ms() >= first);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,500 @@
|
|||||||
|
//! The first real Lumbridge usage adapter: Codex `account/rateLimits/read`.
|
||||||
|
//!
|
||||||
|
//! Codex is the first harness because its quota surface is prose-documented
|
||||||
|
//! upstream with a worked example, pinned by a checked-in JSON Schema, and
|
||||||
|
//! genuinely provider-originated: the app-server forwards the backend's
|
||||||
|
//! `x-codex-*-used-percent` response headers rather than computing a number.
|
||||||
|
//! Lumbridge obtains it by launching `codex app-server` and letting the harness
|
||||||
|
//! resolve its own credentials from `CODEX_HOME`. Lumbridge never reads an auth
|
||||||
|
//! file, and [`crate::jsonrpc`] makes asking for a token unrepresentable.
|
||||||
|
//!
|
||||||
|
//! Three threads, each with one job. The caller's thread starts the child, so a
|
||||||
|
//! missing harness is an immediate error rather than a silent fault. A reader
|
||||||
|
//! thread does the blocking pipe reads. A worker thread owns the protocol clock
|
||||||
|
//! and the writer.
|
||||||
|
//!
|
||||||
|
//! Shutdown is bounded and never waits on the far end. It kills the child's
|
||||||
|
//! whole process group, sets a stop flag, and joins only the worker, which
|
||||||
|
//! checks that flag every tick. The reader is deliberately never joined: it is
|
||||||
|
//! blocked in a pipe read, and while killing the group is what normally closes
|
||||||
|
//! that pipe, a descendant that escaped the group by calling `setsid` would
|
||||||
|
//! otherwise hold shutdown open forever. A leaked thread parked on a dead file
|
||||||
|
//! descriptor is a far better failure than a hung UI, since `Drop` calls
|
||||||
|
//! shutdown.
|
||||||
|
|
||||||
|
mod parse;
|
||||||
|
mod session;
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::mpsc::{self, Receiver, RecvTimeoutError, SyncSender, TryRecvError, TrySendError};
|
||||||
|
use std::thread::{self, JoinHandle};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use lumbridge_core::{AccountProfile, UsageObservation};
|
||||||
|
|
||||||
|
use crate::HarnessError;
|
||||||
|
use crate::child::{ProbeChild, ProbeKiller, ProbeReader, ProbeWriter};
|
||||||
|
use crate::clock::MonotonicWallClock;
|
||||||
|
use crate::jsonrpc::ServerRequestClass;
|
||||||
|
use crate::probe::{ProbeHealth, ProbeOutcome, UsageProbe};
|
||||||
|
|
||||||
|
use session::{CodexSession, SessionStep};
|
||||||
|
|
||||||
|
/// The shortest interval a probe may poll an account quota at. A quota window
|
||||||
|
/// is measured in minutes; polling faster only burns the user's process table.
|
||||||
|
const MIN_POLL_INTERVAL: Duration = Duration::from_secs(1);
|
||||||
|
/// How long the worker waits for a line before re-checking its poll timer.
|
||||||
|
const WORKER_TICK: Duration = Duration::from_millis(100);
|
||||||
|
const EVENT_QUEUE: usize = 64;
|
||||||
|
const LINE_QUEUE: usize = 64;
|
||||||
|
|
||||||
|
/// How the probe is launched.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct CodexProbeOptions {
|
||||||
|
/// The program to run. Defaults to `codex` on `PATH`.
|
||||||
|
pub program: String,
|
||||||
|
/// How often to request a fresh reading.
|
||||||
|
pub poll_interval: Duration,
|
||||||
|
/// Reported to the harness during the handshake. Not a credential.
|
||||||
|
pub client_name: String,
|
||||||
|
pub client_version: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CodexProbeOptions {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
program: "codex".to_owned(),
|
||||||
|
poll_interval: Duration::from_secs(60),
|
||||||
|
client_name: "lumbridge".to_owned(),
|
||||||
|
client_version: env!("CARGO_PKG_VERSION").to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replays a synthetic app-server transcript through the real session logic.
|
||||||
|
///
|
||||||
|
/// This is the same state machine the live probe runs, with the process, the
|
||||||
|
/// socket, and the clock removed: each frame carries the moment it is to be
|
||||||
|
/// treated as observed. It exists so an adapter can be verified end to end
|
||||||
|
/// against a fixture, and so a captured (synthetic) transcript can be checked
|
||||||
|
/// offline when upstream changes its wire shape.
|
||||||
|
///
|
||||||
|
/// Frames the session would answer are answered internally and dropped; only
|
||||||
|
/// the observations it decided to record come back.
|
||||||
|
#[must_use]
|
||||||
|
pub fn replay_transcript(frames: &[(&str, u64)]) -> Vec<UsageObservation> {
|
||||||
|
let Ok(mut session) = CodexSession::new() else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let _ = session.initialize("lumbridge-replay", "0");
|
||||||
|
let mut observations = Vec::new();
|
||||||
|
for (line, observed_at_ms) in frames {
|
||||||
|
// A replay polls whenever the session will let it, so a transcript
|
||||||
|
// does not have to encode the worker's timer.
|
||||||
|
let _ = session.poll_frame();
|
||||||
|
match session.on_line(line, *observed_at_ms) {
|
||||||
|
SessionStep::Observations(mut batch) => observations.append(&mut batch),
|
||||||
|
SessionStep::Idle
|
||||||
|
| SessionStep::Send(_)
|
||||||
|
| SessionStep::Health(_)
|
||||||
|
| SessionStep::Refused { .. } => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
observations
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the worker needs to run the handshake and its poll timer.
|
||||||
|
struct WorkerSetup {
|
||||||
|
client_name: String,
|
||||||
|
client_version: String,
|
||||||
|
poll_interval_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum WorkerEvent {
|
||||||
|
Observations(Vec<UsageObservation>),
|
||||||
|
Health(ProbeHealth),
|
||||||
|
RefusedServerRequest(ServerRequestClass),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A running Codex quota probe.
|
||||||
|
pub struct CodexProbe {
|
||||||
|
profiles: Vec<AccountProfile>,
|
||||||
|
events: Option<Receiver<WorkerEvent>>,
|
||||||
|
killer: ProbeKiller,
|
||||||
|
stop: Arc<AtomicBool>,
|
||||||
|
worker: Option<JoinHandle<()>>,
|
||||||
|
health: ProbeHealth,
|
||||||
|
refused_credential_requests: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CodexProbe {
|
||||||
|
/// Starts the probe, its reader, and its worker.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`HarnessError::ProbeIntervalTooShort`] below one second,
|
||||||
|
/// [`HarnessError::EmptyProgram`] for a blank program name, and
|
||||||
|
/// [`HarnessError::SpawnFailed`] when the harness cannot be launched.
|
||||||
|
pub fn start(options: CodexProbeOptions) -> Result<Self, HarnessError> {
|
||||||
|
if options.poll_interval < MIN_POLL_INTERVAL {
|
||||||
|
return Err(HarnessError::ProbeIntervalTooShort);
|
||||||
|
}
|
||||||
|
let CodexProbeOptions {
|
||||||
|
program,
|
||||||
|
poll_interval,
|
||||||
|
client_name,
|
||||||
|
client_version,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
let session = CodexSession::new()?;
|
||||||
|
let profiles = session.profiles().to_vec();
|
||||||
|
let (writer, reader, killer) = ProbeChild::spawn(&program, &["app-server"])?.into_parts();
|
||||||
|
|
||||||
|
let (line_sender, lines) = mpsc::sync_channel(LINE_QUEUE);
|
||||||
|
let (event_sender, events) = mpsc::sync_channel(EVENT_QUEUE);
|
||||||
|
let stop = Arc::new(AtomicBool::new(false));
|
||||||
|
|
||||||
|
// Deliberately not retained: the reader is never joined. See the module
|
||||||
|
// documentation for why waiting on a blocking pipe read is unsafe here.
|
||||||
|
thread::Builder::new()
|
||||||
|
.name("lumbridge-codex-probe-reader".to_owned())
|
||||||
|
.spawn(move || run_reader(reader, &line_sender))
|
||||||
|
.map_err(|error| HarnessError::SpawnFailed(error.kind()))?;
|
||||||
|
|
||||||
|
let setup = WorkerSetup {
|
||||||
|
client_name,
|
||||||
|
client_version,
|
||||||
|
poll_interval_ms: u64::try_from(poll_interval.as_millis()).unwrap_or(u64::MAX),
|
||||||
|
};
|
||||||
|
let worker_stop = Arc::clone(&stop);
|
||||||
|
let worker = thread::Builder::new()
|
||||||
|
.name("lumbridge-codex-probe".to_owned())
|
||||||
|
.spawn(move || {
|
||||||
|
run_worker(session, &setup, writer, &lines, &event_sender, &worker_stop);
|
||||||
|
})
|
||||||
|
.map_err(|error| HarnessError::SpawnFailed(error.kind()))?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
profiles,
|
||||||
|
events: Some(events),
|
||||||
|
killer,
|
||||||
|
stop,
|
||||||
|
worker: Some(worker),
|
||||||
|
health: ProbeHealth::Starting,
|
||||||
|
refused_credential_requests: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many times the harness asked this probe for a credential and was
|
||||||
|
/// refused. A non-zero count is not an error; it is evidence that the
|
||||||
|
/// refusal path ran.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn refused_credential_requests(&self) -> u64 {
|
||||||
|
self.refused_credential_requests
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UsageProbe for CodexProbe {
|
||||||
|
fn poll(&mut self) -> ProbeOutcome {
|
||||||
|
let Some(events) = self.events.as_ref() else {
|
||||||
|
return ProbeOutcome::idle(self.health);
|
||||||
|
};
|
||||||
|
let mut observations = Vec::new();
|
||||||
|
loop {
|
||||||
|
match events.try_recv() {
|
||||||
|
Ok(WorkerEvent::Observations(mut batch)) => observations.append(&mut batch),
|
||||||
|
Ok(WorkerEvent::Health(health)) => self.health = health,
|
||||||
|
Ok(WorkerEvent::RefusedServerRequest(class)) => {
|
||||||
|
if class == ServerRequestClass::CredentialRefresh {
|
||||||
|
self.refused_credential_requests =
|
||||||
|
self.refused_credential_requests.saturating_add(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(TryRecvError::Empty) => break,
|
||||||
|
Err(TryRecvError::Disconnected) => {
|
||||||
|
if !self.health.is_faulted() {
|
||||||
|
self.health = ProbeHealth::Stopped;
|
||||||
|
}
|
||||||
|
self.events = None;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ProbeOutcome::new(observations, self.health)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn profiles(&self) -> &[AccountProfile] {
|
||||||
|
&self.profiles
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shutdown(&mut self) {
|
||||||
|
// Order matters. Signal first so the worker cannot start another poll,
|
||||||
|
// then kill the whole group, then join only the worker: it wakes at
|
||||||
|
// most one tick later regardless of what the child is doing.
|
||||||
|
self.stop.store(true, Ordering::Relaxed);
|
||||||
|
self.killer.kill();
|
||||||
|
self.events = None;
|
||||||
|
if let Some(worker) = self.worker.take() {
|
||||||
|
let _ = worker.join();
|
||||||
|
}
|
||||||
|
if !self.health.is_faulted() {
|
||||||
|
self.health = ProbeHealth::Stopped;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for CodexProbe {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.shutdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Blocking reads, and nothing else.
|
||||||
|
fn run_reader(mut reader: ProbeReader, lines: &SyncSender<Result<String, HarnessError>>) {
|
||||||
|
loop {
|
||||||
|
let outcome = reader.read_line();
|
||||||
|
let terminal = outcome.is_err();
|
||||||
|
if lines.send(outcome).is_err() || terminal {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Owns the protocol clock and the writer. Holds no policy of its own.
|
||||||
|
fn run_worker(
|
||||||
|
mut session: CodexSession,
|
||||||
|
setup: &WorkerSetup,
|
||||||
|
mut writer: ProbeWriter,
|
||||||
|
lines: &Receiver<Result<String, HarnessError>>,
|
||||||
|
events: &SyncSender<WorkerEvent>,
|
||||||
|
stop: &AtomicBool,
|
||||||
|
) {
|
||||||
|
let mut clock = MonotonicWallClock::start();
|
||||||
|
let handshake = session.initialize(&setup.client_name, &setup.client_version);
|
||||||
|
if let Err(error) = writer.write_line(&handshake) {
|
||||||
|
let _ = events.try_send(WorkerEvent::Health(ProbeHealth::Faulted(error)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zero means "poll as soon as the handshake lets us", because
|
||||||
|
// `poll_frame` returns nothing until the session is ready.
|
||||||
|
let mut last_poll_ms = 0;
|
||||||
|
loop {
|
||||||
|
if stop.load(Ordering::Relaxed) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let now_ms = clock.now_ms();
|
||||||
|
if now_ms.saturating_sub(last_poll_ms) >= setup.poll_interval_ms
|
||||||
|
&& let Some(frame) = session.poll_frame()
|
||||||
|
{
|
||||||
|
last_poll_ms = now_ms;
|
||||||
|
if writer.write_line(&frame).is_err() {
|
||||||
|
let _ =
|
||||||
|
events.try_send(WorkerEvent::Health(ProbeHealth::Faulted(HarnessError::Eof)));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match lines.recv_timeout(WORKER_TICK) {
|
||||||
|
Ok(Ok(line)) => {
|
||||||
|
if !dispatch(&mut session, &mut writer, events, &line, clock.now_ms()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Err(error)) => {
|
||||||
|
let _ = events.try_send(WorkerEvent::Health(ProbeHealth::Faulted(error)));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(RecvTimeoutError::Timeout) => {}
|
||||||
|
Err(RecvTimeoutError::Disconnected) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies one line. Returns whether the worker should keep running.
|
||||||
|
fn dispatch(
|
||||||
|
session: &mut CodexSession,
|
||||||
|
writer: &mut ProbeWriter,
|
||||||
|
events: &SyncSender<WorkerEvent>,
|
||||||
|
line: &str,
|
||||||
|
now_ms: u64,
|
||||||
|
) -> bool {
|
||||||
|
match session.on_line(line.trim_end(), now_ms) {
|
||||||
|
SessionStep::Idle => true,
|
||||||
|
SessionStep::Send(frame) => {
|
||||||
|
if writer.write_line(&frame).is_err() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if session.is_ready() {
|
||||||
|
let _ = events.try_send(WorkerEvent::Health(ProbeHealth::Ready));
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
SessionStep::Refused { line, class } => {
|
||||||
|
let _ = events.try_send(WorkerEvent::RefusedServerRequest(class));
|
||||||
|
writer.write_line(&line).is_ok()
|
||||||
|
}
|
||||||
|
SessionStep::Observations(observations) => !matches!(
|
||||||
|
events.try_send(WorkerEvent::Observations(observations)),
|
||||||
|
Err(TrySendError::Disconnected(_))
|
||||||
|
),
|
||||||
|
SessionStep::Health(health) => {
|
||||||
|
let keep_running = !health.is_faulted();
|
||||||
|
let _ = events.try_send(WorkerEvent::Health(health));
|
||||||
|
keep_running
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{CodexProbe, CodexProbeOptions};
|
||||||
|
use crate::HarnessError;
|
||||||
|
use crate::probe::{ProbeHealth, UsageProbe};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// A synthetic launcher that ignores its arguments, backgrounds a helper
|
||||||
|
/// that inherits stdio, and waits. This is the shape of a wrapper script
|
||||||
|
/// that execs the real program as a grandchild, which is how the npm
|
||||||
|
/// distribution of Codex behaves.
|
||||||
|
fn launcher_script(tag: &str) -> Option<std::path::PathBuf> {
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"lumbridge-probe-launcher-{}-{tag}.sh",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
let mut file = std::fs::File::create(&path).ok()?;
|
||||||
|
file.write_all(b"#!/bin/sh\nsleep 600 &\nwait\n").ok()?;
|
||||||
|
drop(file);
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).ok()?;
|
||||||
|
}
|
||||||
|
Some(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn options(program: &str) -> CodexProbeOptions {
|
||||||
|
CodexProbeOptions {
|
||||||
|
program: program.to_owned(),
|
||||||
|
poll_interval: Duration::from_secs(1),
|
||||||
|
..CodexProbeOptions::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_too_frequent_poll_interval_is_refused() {
|
||||||
|
let options = CodexProbeOptions {
|
||||||
|
poll_interval: Duration::from_millis(10),
|
||||||
|
..CodexProbeOptions::default()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
CodexProbe::start(options).err(),
|
||||||
|
Some(HarnessError::ProbeIntervalTooShort)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_program_is_refused() {
|
||||||
|
assert_eq!(
|
||||||
|
CodexProbe::start(options(" ")).err(),
|
||||||
|
Some(HarnessError::EmptyProgram)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_missing_harness_fails_at_start_rather_than_silently() {
|
||||||
|
assert!(matches!(
|
||||||
|
CodexProbe::start(options("lumbridge-no-such-harness")).err(),
|
||||||
|
Some(HarnessError::SpawnFailed(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_silent_harness_can_still_be_shut_down() {
|
||||||
|
let Some(script) = launcher_script("silent") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(program) = script.to_str() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Ok(mut probe) = CodexProbe::start(options(program)) else {
|
||||||
|
let _ = std::fs::remove_file(&script);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
assert_eq!(probe.profiles().len(), 2);
|
||||||
|
assert_eq!(probe.poll().health(), ProbeHealth::Starting);
|
||||||
|
probe.shutdown();
|
||||||
|
assert!(matches!(
|
||||||
|
probe.poll().health(),
|
||||||
|
ProbeHealth::Stopped | ProbeHealth::Faulted(_)
|
||||||
|
));
|
||||||
|
let _ = std::fs::remove_file(&script);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shutdown_is_idempotent() {
|
||||||
|
let Some(script) = launcher_script("idempotent") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(program) = script.to_str() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Ok(mut probe) = CodexProbe::start(options(program)) else {
|
||||||
|
let _ = std::fs::remove_file(&script);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
probe.shutdown();
|
||||||
|
probe.shutdown();
|
||||||
|
let _ = std::fs::remove_file(&script);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shutdown_returns_promptly_behind_a_launcher_that_never_speaks() {
|
||||||
|
// The grandchild inherits our stdout and never writes, so the reader is
|
||||||
|
// blocked in a pipe read that will never complete on its own. Before
|
||||||
|
// the process-group kill and the bounded join, shutdown blocked here
|
||||||
|
// until the orphan exited — ten minutes, in this fixture.
|
||||||
|
let Some(script) = launcher_script("prompt") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(program) = script.to_str() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Ok(mut probe) = CodexProbe::start(options(program)) else {
|
||||||
|
let _ = std::fs::remove_file(&script);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
probe.shutdown();
|
||||||
|
let elapsed = started.elapsed();
|
||||||
|
let _ = std::fs::remove_file(&script);
|
||||||
|
assert!(
|
||||||
|
elapsed < Duration::from_secs(5),
|
||||||
|
"shutdown must not wait on the far end, took {elapsed:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_harness_that_exits_immediately_faults_the_probe() {
|
||||||
|
let Ok(mut probe) = CodexProbe::start(options("true")) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mut health = probe.poll().health();
|
||||||
|
for _ in 0..200 {
|
||||||
|
if health != ProbeHealth::Starting {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
|
health = probe.poll().health();
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
matches!(health, ProbeHealth::Faulted(_)),
|
||||||
|
"a harness that closes its output must surface as a fault, got {health:?}"
|
||||||
|
);
|
||||||
|
probe.shutdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
//! The Codex rate-limit parser: hostile input in, a closed decision out.
|
||||||
|
//!
|
||||||
|
//! Every skew, roll, overflow, and out-of-range case is decided here, by pure
|
||||||
|
//! functions over deserialized wire values with no process, no clock, and no
|
||||||
|
//! network. That is what makes the honesty rules testable.
|
||||||
|
//!
|
||||||
|
//! Wire shape adapted from the openai/codex app-server protocol (Apache-2.0),
|
||||||
|
//! commit `17e8101699c5062117d0d37f504313e8af53b043`:
|
||||||
|
//! `codex-rs/app-server/README.md` section "7) Rate limits (`ChatGPT`)" and the
|
||||||
|
//! checked-in schema `codex_app_server_protocol.v2.schemas.json`, where
|
||||||
|
//! `RateLimitWindow` requires only `usedPercent` and both
|
||||||
|
//! `windowDurationMins` and `resetsAt` are nullable. No upstream code is
|
||||||
|
//! copied; only the observable wire contract is mirrored.
|
||||||
|
|
||||||
|
use lumbridge_core::{AccountProfileId, UsageObservation, UsageProvenance, UsageUnit, UsageWindow};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
/// Percent is an integer 0..=100 on the wire, so the honest resolution of a
|
||||||
|
/// Codex reading is one percent even though the ledger stores permille.
|
||||||
|
const MAX_USED_PERCENT: i64 = 100;
|
||||||
|
const PERMILLE_PER_PERCENT: u64 = 10;
|
||||||
|
const MILLIS_PER_SECOND: i64 = 1_000;
|
||||||
|
const MILLIS_PER_MINUTE: i64 = 60_000;
|
||||||
|
/// A permille ceiling makes the window fraction a bounded quantity, which is
|
||||||
|
/// what lets the ledger derive an exhaustion estimate. It is not an invented
|
||||||
|
/// quota: a percentage is by definition a share of one whole.
|
||||||
|
const PERMILLE_CEILING: u64 = 1_000;
|
||||||
|
|
||||||
|
/// One quota window exactly as Codex sends it.
|
||||||
|
///
|
||||||
|
/// Only the three documented fields are deserialized. Sibling fields such as
|
||||||
|
/// `rateLimitResetCredits`, `individualLimit`, `accountId`, and
|
||||||
|
/// `rateLimitUpsell` are deliberately not modelled: a probe should not be able
|
||||||
|
/// to accidentally read account metadata it has no use for.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub(crate) struct RateLimitWindowWire {
|
||||||
|
/// Widened to `i64` so an out-of-range wire value reaches our own check
|
||||||
|
/// rather than failing inside serde with a less specific error.
|
||||||
|
pub(crate) used_percent: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) window_duration_mins: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) resets_at: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The snapshot. Every field is optional in the upstream schema, including
|
||||||
|
/// `primary`, so absence must be a first-class case rather than a parse error.
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub(crate) struct RateLimitSnapshotWire {
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) primary: Option<RateLimitWindowWire>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) secondary: Option<RateLimitWindowWire>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `account/rateLimits/read` result body, which is also the shape of the
|
||||||
|
/// `account/rateLimits/updated` notification's params.
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub(crate) struct RateLimitsResultWire {
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) rate_limits: RateLimitSnapshotWire,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why a syntactically valid window still cannot be recorded as a fact.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum UnusableReason {
|
||||||
|
/// A percentage outside 0..=100. The relay is not telling the truth.
|
||||||
|
PercentOutOfRange,
|
||||||
|
/// The window had already ended when the reading was taken. Decision 0012
|
||||||
|
/// requires an expired window to read as rolled over rather than as a
|
||||||
|
/// frozen percentage from a window that is gone.
|
||||||
|
WindowEnded,
|
||||||
|
/// A reset timestamp that cannot be represented as Unix milliseconds.
|
||||||
|
ResetsAtOutOfRange,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the parser decided about one window.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum WindowReading {
|
||||||
|
/// A usable share of the window, with a window attached when the wire
|
||||||
|
/// carried enough to build one.
|
||||||
|
Usable {
|
||||||
|
permille: u64,
|
||||||
|
window: Option<UsageWindow>,
|
||||||
|
},
|
||||||
|
/// The reading must be recorded as an explicit gap.
|
||||||
|
Unusable(UnusableReason),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decides what a single Codex window means at a point in time.
|
||||||
|
///
|
||||||
|
/// Total: every input produces a decision, and none of them panics.
|
||||||
|
pub(crate) fn parse_window(wire: &RateLimitWindowWire, observed_at_ms: u64) -> WindowReading {
|
||||||
|
if wire.used_percent < 0 || wire.used_percent > MAX_USED_PERCENT {
|
||||||
|
return WindowReading::Unusable(UnusableReason::PercentOutOfRange);
|
||||||
|
}
|
||||||
|
// Lossless: used_percent is already proven to be 0..=100.
|
||||||
|
let permille = u64::try_from(wire.used_percent).unwrap_or(0) * PERMILLE_PER_PERCENT;
|
||||||
|
|
||||||
|
let Some(resets_at_seconds) = wire.resets_at else {
|
||||||
|
// A percentage with no reset time is still a true percentage. It just
|
||||||
|
// cannot answer "when does this refill?".
|
||||||
|
return WindowReading::Usable {
|
||||||
|
permille,
|
||||||
|
window: None,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
let Some(resets_at_ms) = unix_seconds_to_millis(resets_at_seconds) else {
|
||||||
|
return WindowReading::Unusable(UnusableReason::ResetsAtOutOfRange);
|
||||||
|
};
|
||||||
|
if observed_at_ms >= resets_at_ms {
|
||||||
|
return WindowReading::Unusable(UnusableReason::WindowEnded);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The reset is a fact on its own. Codex declares the duration and the reset
|
||||||
|
// independently, so a missing or unreconcilable start must cost the start
|
||||||
|
// and nothing else: the reset still bounds the exhaustion forecast and
|
||||||
|
// still identifies which quota period this reading belongs to.
|
||||||
|
let window = wire
|
||||||
|
.window_duration_mins
|
||||||
|
.and_then(|minutes| window_start_ms(resets_at_ms, minutes))
|
||||||
|
// A start after the observation means the local clock disagrees with
|
||||||
|
// the provider's. That disagreement is about the start, not the reset.
|
||||||
|
.filter(|started_at_ms| *started_at_ms <= observed_at_ms)
|
||||||
|
.and_then(|started_at_ms| UsageWindow::new(started_at_ms, resets_at_ms).ok())
|
||||||
|
.unwrap_or_else(|| UsageWindow::until(resets_at_ms));
|
||||||
|
|
||||||
|
WindowReading::Usable {
|
||||||
|
permille,
|
||||||
|
window: Some(window),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unix_seconds_to_millis(seconds: i64) -> Option<u64> {
|
||||||
|
let millis = seconds.checked_mul(MILLIS_PER_SECOND)?;
|
||||||
|
u64::try_from(millis).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn window_start_ms(resets_at_ms: u64, duration_minutes: i64) -> Option<u64> {
|
||||||
|
if duration_minutes <= 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let duration_ms = u64::try_from(duration_minutes.checked_mul(MILLIS_PER_MINUTE)?).ok()?;
|
||||||
|
resets_at_ms.checked_sub(duration_ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turns a decision into the observation the ledger should hold.
|
||||||
|
///
|
||||||
|
/// A usable reading is `ProviderReported`: Codex forwards the backend's
|
||||||
|
/// `x-codex-*-used-percent` response headers rather than computing the value,
|
||||||
|
/// so the provider is the one making the claim. Anything else is an explicit
|
||||||
|
/// gap, never a zero.
|
||||||
|
pub(crate) fn to_observation(
|
||||||
|
profile: &AccountProfileId,
|
||||||
|
reading: WindowReading,
|
||||||
|
observed_at_ms: u64,
|
||||||
|
) -> UsageObservation {
|
||||||
|
let WindowReading::Usable { permille, window } = reading else {
|
||||||
|
return UsageObservation::unavailable(profile.clone(), observed_at_ms);
|
||||||
|
};
|
||||||
|
let Ok(observation) = UsageObservation::counted(
|
||||||
|
profile.clone(),
|
||||||
|
UsageUnit::WindowPermille,
|
||||||
|
permille,
|
||||||
|
UsageProvenance::ProviderReported,
|
||||||
|
observed_at_ms,
|
||||||
|
) else {
|
||||||
|
return UsageObservation::unavailable(profile.clone(), observed_at_ms);
|
||||||
|
};
|
||||||
|
let observation = observation
|
||||||
|
.with_limit(PERMILLE_CEILING)
|
||||||
|
.unwrap_or_else(|_| UsageObservation::unavailable(profile.clone(), observed_at_ms));
|
||||||
|
let Some(window) = window else {
|
||||||
|
return observation;
|
||||||
|
};
|
||||||
|
// A window that no longer contains the observation is rejected by core.
|
||||||
|
// Keeping the windowless observation preserves the provider's percentage
|
||||||
|
// instead of discarding a true fact over a timing disagreement.
|
||||||
|
observation
|
||||||
|
.clone()
|
||||||
|
.with_window(window)
|
||||||
|
.unwrap_or(observation)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{
|
||||||
|
RateLimitSnapshotWire, RateLimitWindowWire, RateLimitsResultWire, UnusableReason,
|
||||||
|
WindowReading, parse_window, to_observation,
|
||||||
|
};
|
||||||
|
use lumbridge_core::{AccountProfileId, UsageProvenance, UsageUnit, UsageWindow};
|
||||||
|
|
||||||
|
const HOUR_MS: u64 = 3_600_000;
|
||||||
|
const RESETS_AT_SECONDS: i64 = 1_730_947_200;
|
||||||
|
const RESETS_AT_MS: u64 = 1_730_947_200_000;
|
||||||
|
|
||||||
|
fn profile() -> AccountProfileId {
|
||||||
|
AccountProfileId::new("codex-app-server-primary").expect("a valid fixture ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wire(
|
||||||
|
used_percent: i64,
|
||||||
|
minutes: Option<i64>,
|
||||||
|
resets_at: Option<i64>,
|
||||||
|
) -> RateLimitWindowWire {
|
||||||
|
RateLimitWindowWire {
|
||||||
|
used_percent,
|
||||||
|
window_duration_mins: minutes,
|
||||||
|
resets_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_documented_example_parses_to_a_bounded_window() {
|
||||||
|
// The worked example from the upstream README, read one hour before it
|
||||||
|
// resets: { "usedPercent": 25, "windowDurationMins": 15, ... } widened
|
||||||
|
// to a duration long enough to contain the observation.
|
||||||
|
let reading = parse_window(
|
||||||
|
&wire(25, Some(300), Some(RESETS_AT_SECONDS)),
|
||||||
|
RESETS_AT_MS - HOUR_MS,
|
||||||
|
);
|
||||||
|
let WindowReading::Usable { permille, window } = reading else {
|
||||||
|
panic!("the documented example must be usable");
|
||||||
|
};
|
||||||
|
assert_eq!(permille, 250);
|
||||||
|
let window = window.expect("a duration and a reset build a window");
|
||||||
|
assert_eq!(window.resets_at_ms(), RESETS_AT_MS);
|
||||||
|
assert_eq!(window.started_at_ms(), Some(RESETS_AT_MS - 300 * 60_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_reading_taken_after_the_reset_is_a_rolled_window_not_a_frozen_percentage() {
|
||||||
|
let reading = parse_window(
|
||||||
|
&wire(93, Some(300), Some(RESETS_AT_SECONDS)),
|
||||||
|
RESETS_AT_MS + 1,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
reading,
|
||||||
|
WindowReading::Unusable(UnusableReason::WindowEnded)
|
||||||
|
);
|
||||||
|
let observation = to_observation(&profile(), reading, RESETS_AT_MS + 1);
|
||||||
|
assert_eq!(observation.provenance(), UsageProvenance::Unavailable);
|
||||||
|
assert_eq!(observation.consumed(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_absent_duration_keeps_the_percentage_and_the_reset() {
|
||||||
|
// Codex declares the duration and the reset independently. Dropping
|
||||||
|
// the reset for want of a duration would discard a fact the provider
|
||||||
|
// stated, and would let a burn baseline be drawn across two quota
|
||||||
|
// periods while the exhaustion guard never fired.
|
||||||
|
let reading = parse_window(
|
||||||
|
&wire(40, None, Some(RESETS_AT_SECONDS)),
|
||||||
|
RESETS_AT_MS - HOUR_MS,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
reading,
|
||||||
|
WindowReading::Usable {
|
||||||
|
permille: 400,
|
||||||
|
window: Some(UsageWindow::until(RESETS_AT_MS))
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_absent_reset_keeps_the_percentage_and_drops_the_window() {
|
||||||
|
let reading = parse_window(&wire(40, Some(300), None), 5_000);
|
||||||
|
assert_eq!(
|
||||||
|
reading,
|
||||||
|
WindowReading::Usable {
|
||||||
|
permille: 400,
|
||||||
|
window: None
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_start_after_the_observation_costs_the_start_and_not_the_reset() {
|
||||||
|
// A 15 minute window resetting far in the future starts after now:
|
||||||
|
// the local clock and the provider disagree about the start only.
|
||||||
|
let reading = parse_window(&wire(25, Some(15), Some(RESETS_AT_SECONDS)), 1_000);
|
||||||
|
assert_eq!(
|
||||||
|
reading,
|
||||||
|
WindowReading::Usable {
|
||||||
|
permille: 250,
|
||||||
|
window: Some(UsageWindow::until(RESETS_AT_MS))
|
||||||
|
},
|
||||||
|
"clock disagreement must not discard the provider's reset time"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_out_of_range_percentage_is_refused() {
|
||||||
|
for percent in [-1, 101, i64::MAX, i64::MIN] {
|
||||||
|
assert_eq!(
|
||||||
|
parse_window(&wire(percent, Some(300), Some(RESETS_AT_SECONDS)), 0),
|
||||||
|
WindowReading::Unusable(UnusableReason::PercentOutOfRange),
|
||||||
|
"percent {percent} must not be recorded"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unrepresentable_reset_is_refused_without_overflowing() {
|
||||||
|
for seconds in [-1, i64::MIN, i64::MAX] {
|
||||||
|
assert!(matches!(
|
||||||
|
parse_window(&wire(25, Some(300), Some(seconds)), 0),
|
||||||
|
WindowReading::Unusable(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_zero_or_negative_duration_does_not_underflow() {
|
||||||
|
for minutes in [0, -5, i64::MIN] {
|
||||||
|
let reading = parse_window(&wire(25, Some(minutes), Some(RESETS_AT_SECONDS)), 1_000);
|
||||||
|
assert_eq!(
|
||||||
|
reading,
|
||||||
|
WindowReading::Usable {
|
||||||
|
permille: 250,
|
||||||
|
window: Some(UsageWindow::until(RESETS_AT_MS))
|
||||||
|
},
|
||||||
|
"a nonsensical duration must not cost the reset"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_weekly_window_matches_the_shape_a_live_account_returns() {
|
||||||
|
// Observed from a real account: usedPercent 18, windowDurationMins
|
||||||
|
// 10080 (one week), resetsAt in Unix seconds, secondary null.
|
||||||
|
let week_minutes: i64 = 10_080;
|
||||||
|
let observed_at_ms = RESETS_AT_MS - 24 * HOUR_MS;
|
||||||
|
let reading = parse_window(
|
||||||
|
&wire(18, Some(week_minutes), Some(RESETS_AT_SECONDS)),
|
||||||
|
observed_at_ms,
|
||||||
|
);
|
||||||
|
let WindowReading::Usable { permille, window } = reading else {
|
||||||
|
panic!("a weekly window is usable");
|
||||||
|
};
|
||||||
|
assert_eq!(permille, 180);
|
||||||
|
let window = window.expect("a week-long window is still a window");
|
||||||
|
assert_eq!(
|
||||||
|
window.started_at_ms(),
|
||||||
|
Some(RESETS_AT_MS - u64::try_from(week_minutes).expect("a positive duration") * 60_000)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_usable_reading_becomes_a_provider_reported_permille_observation() {
|
||||||
|
let reading = parse_window(
|
||||||
|
&wire(25, Some(300), Some(RESETS_AT_SECONDS)),
|
||||||
|
RESETS_AT_MS - HOUR_MS,
|
||||||
|
);
|
||||||
|
let observation = to_observation(&profile(), reading, RESETS_AT_MS - HOUR_MS);
|
||||||
|
assert_eq!(observation.provenance(), UsageProvenance::ProviderReported);
|
||||||
|
assert_eq!(observation.unit(), Some(UsageUnit::WindowPermille));
|
||||||
|
assert_eq!(observation.consumed(), 250);
|
||||||
|
assert_eq!(observation.limit(), Some(1_000));
|
||||||
|
assert!(observation.window().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_absent_primary_window_deserializes_rather_than_failing() {
|
||||||
|
let body: RateLimitsResultWire = serde_json::from_str(
|
||||||
|
r#"{"rateLimits":{"primary":null,"secondary":null,"rateLimitReachedType":null}}"#,
|
||||||
|
)
|
||||||
|
.expect("every snapshot field is optional upstream");
|
||||||
|
assert!(body.rate_limits.primary.is_none());
|
||||||
|
assert!(body.rate_limits.secondary.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unmodelled_sibling_fields_are_ignored_not_rejected() {
|
||||||
|
// The upstream result also carries rateLimitResetCredits, accountId,
|
||||||
|
// individualLimit and rateLimitUpsell. A probe must tolerate them and
|
||||||
|
// must not model them.
|
||||||
|
let body: RateLimitsResultWire = serde_json::from_str(
|
||||||
|
r#"{"rateLimits":{"primary":{"usedPercent":25,"windowDurationMins":15,
|
||||||
|
"resetsAt":1730947200},"individualLimit":null,"accountId":"acct_x",
|
||||||
|
"planType":"plus"},"rateLimitResetCredits":{"availableCount":2}}"#,
|
||||||
|
)
|
||||||
|
.expect("unknown fields are ignored");
|
||||||
|
let primary = body.rate_limits.primary.expect("primary is present");
|
||||||
|
assert_eq!(primary.used_percent, 25);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_snapshot_with_no_windows_defaults_rather_than_panicking() {
|
||||||
|
let snapshot = RateLimitSnapshotWire::default();
|
||||||
|
assert!(snapshot.primary.is_none());
|
||||||
|
assert!(snapshot.secondary.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,584 @@
|
|||||||
|
//! The Codex probe's protocol logic, as a pure state machine.
|
||||||
|
//!
|
||||||
|
//! Every decision the probe makes about what to send, what to record, and what
|
||||||
|
//! to refuse happens here, driven by `&str` lines and an explicit timestamp.
|
||||||
|
//! No process, no socket, and no clock are involved, so the whole protocol is
|
||||||
|
//! testable from string fixtures. The thread in [`super::worker`] does nothing
|
||||||
|
//! but move bytes between a child process and this type.
|
||||||
|
|
||||||
|
use lumbridge_core::{AccountProfile, AccountProfileId, UsageObservation, UsageWindow};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use crate::HarnessError;
|
||||||
|
use crate::jsonrpc::{
|
||||||
|
AcceptedNotification, INVALID_REQUEST, Inbound, OutboundRequest, ServerRequestClass, classify,
|
||||||
|
encode_notification, encode_refusal, encode_request,
|
||||||
|
};
|
||||||
|
use crate::probe::ProbeHealth;
|
||||||
|
|
||||||
|
use super::parse::{
|
||||||
|
RateLimitSnapshotWire, RateLimitsResultWire, WindowReading, parse_window, to_observation,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Re-emit an unchanged reading at least this often so the ledger keeps a
|
||||||
|
/// baseline for its burn rate. Without a floor, a quiet account would hold one
|
||||||
|
/// lone observation and the footer could never derive a rate; without a
|
||||||
|
/// ceiling on how often we re-record, the bounded retention would be flushed
|
||||||
|
/// of real history by identical rows.
|
||||||
|
const MIN_REEMIT_INTERVAL_MS: u64 = 300_000;
|
||||||
|
|
||||||
|
/// The two windows Codex reports are two separate facts about two separate
|
||||||
|
/// quota periods. Merging them would mean silently choosing one.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub(crate) enum WindowSlot {
|
||||||
|
Primary,
|
||||||
|
Secondary,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WindowSlot {
|
||||||
|
const ALL: [Self; 2] = [Self::Primary, Self::Secondary];
|
||||||
|
|
||||||
|
/// Static, account-free identifiers. A profile ID is persisted and must
|
||||||
|
/// never carry an account identity.
|
||||||
|
const fn profile_id(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Primary => "codex-app-server-primary",
|
||||||
|
Self::Secondary => "codex-app-server-secondary",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Codex rate limits are account-scoped, not model-scoped. Naming the
|
||||||
|
/// scope is honest; inventing a model name would not be.
|
||||||
|
const fn scope(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Primary => "primary window",
|
||||||
|
Self::Secondary => "secondary window",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the session wants the caller to do next.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub(crate) enum SessionStep {
|
||||||
|
/// Nothing to do.
|
||||||
|
Idle,
|
||||||
|
/// Write this line to the child's stdin.
|
||||||
|
Send(String),
|
||||||
|
/// Record these observations.
|
||||||
|
Observations(Vec<UsageObservation>),
|
||||||
|
/// The probe's liveness changed.
|
||||||
|
Health(ProbeHealth),
|
||||||
|
/// A server-to-client request was refused. Write the line, and note why.
|
||||||
|
Refused {
|
||||||
|
line: String,
|
||||||
|
class: ServerRequestClass,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
enum Stage {
|
||||||
|
AwaitingInitialize,
|
||||||
|
Ready,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The last thing recorded for one profile, used to suppress identical rows.
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
struct LastRecorded {
|
||||||
|
permille: u64,
|
||||||
|
window: Option<UsageWindow>,
|
||||||
|
at_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct CodexSession {
|
||||||
|
profiles: Vec<AccountProfile>,
|
||||||
|
stage: Stage,
|
||||||
|
next_id: u64,
|
||||||
|
initialize_id: u64,
|
||||||
|
pending_read_id: Option<u64>,
|
||||||
|
last: [Option<LastRecorded>; 2],
|
||||||
|
refused_credential_requests: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CodexSession {
|
||||||
|
pub(crate) fn new() -> Result<Self, HarnessError> {
|
||||||
|
let profiles = WindowSlot::ALL
|
||||||
|
.into_iter()
|
||||||
|
.map(|slot| {
|
||||||
|
AccountProfile::new(
|
||||||
|
slot.profile_id(),
|
||||||
|
"Codex",
|
||||||
|
"ChatGPT",
|
||||||
|
slot.scope(),
|
||||||
|
"subscription",
|
||||||
|
)
|
||||||
|
.map_err(|_| HarnessError::EmptyProgram)
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
Ok(Self {
|
||||||
|
profiles,
|
||||||
|
stage: Stage::AwaitingInitialize,
|
||||||
|
next_id: 1,
|
||||||
|
initialize_id: 0,
|
||||||
|
pending_read_id: None,
|
||||||
|
last: [None, None],
|
||||||
|
refused_credential_requests: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn profiles(&self) -> &[AccountProfile] {
|
||||||
|
&self.profiles
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the handshake has completed and readings may be requested.
|
||||||
|
pub(crate) const fn is_ready(&self) -> bool {
|
||||||
|
matches!(self.stage, Stage::Ready)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) const fn refused_credential_requests(&self) -> u64 {
|
||||||
|
self.refused_credential_requests
|
||||||
|
}
|
||||||
|
|
||||||
|
fn take_id(&mut self) -> u64 {
|
||||||
|
let id = self.next_id;
|
||||||
|
self.next_id = self.next_id.saturating_add(1);
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The handshake frame. Codex requires `initialize` before any other call.
|
||||||
|
pub(crate) fn initialize(&mut self, client_name: &str, client_version: &str) -> String {
|
||||||
|
let id = self.take_id();
|
||||||
|
self.initialize_id = id;
|
||||||
|
encode_request(
|
||||||
|
id,
|
||||||
|
OutboundRequest::Initialize,
|
||||||
|
&json!({ "clientInfo": { "name": client_name, "version": client_version } }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Requests a fresh reading, unless one is already outstanding.
|
||||||
|
pub(crate) fn poll_frame(&mut self) -> Option<String> {
|
||||||
|
if self.stage != Stage::Ready || self.pending_read_id.is_some() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let id = self.take_id();
|
||||||
|
self.pending_read_id = Some(id);
|
||||||
|
Some(encode_request(
|
||||||
|
id,
|
||||||
|
OutboundRequest::ReadAccountRateLimits,
|
||||||
|
&json!({}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consumes one line from the child.
|
||||||
|
pub(crate) fn on_line(&mut self, line: &str, observed_at_ms: u64) -> SessionStep {
|
||||||
|
let Some(inbound) = classify(line) else {
|
||||||
|
return SessionStep::Health(ProbeHealth::Faulted(HarnessError::Malformed));
|
||||||
|
};
|
||||||
|
match inbound {
|
||||||
|
Inbound::ServerRequest { id, class } => {
|
||||||
|
if class == ServerRequestClass::CredentialRefresh {
|
||||||
|
self.refused_credential_requests =
|
||||||
|
self.refused_credential_requests.saturating_add(1);
|
||||||
|
}
|
||||||
|
SessionStep::Refused {
|
||||||
|
line: encode_refusal(&id),
|
||||||
|
class,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Inbound::Result { id, result } => self.on_result(id, &result, observed_at_ms),
|
||||||
|
Inbound::Failure { id, code } => self.on_failure(id, code, observed_at_ms),
|
||||||
|
// Matching the kind exhaustively means adding a second accepted
|
||||||
|
// notification cannot silently reuse the rate-limit path.
|
||||||
|
Inbound::Notification {
|
||||||
|
kind: AcceptedNotification::AccountRateLimitsUpdated,
|
||||||
|
params,
|
||||||
|
} => self.on_snapshot_body(¶ms, observed_at_ms, true),
|
||||||
|
Inbound::Ignored => SessionStep::Idle,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_result(&mut self, id: u64, result: &Value, observed_at_ms: u64) -> SessionStep {
|
||||||
|
if self.stage == Stage::AwaitingInitialize && id == self.initialize_id {
|
||||||
|
self.stage = Stage::Ready;
|
||||||
|
return SessionStep::Send(encode_notification("initialized", &json!({})));
|
||||||
|
}
|
||||||
|
if self.pending_read_id == Some(id) {
|
||||||
|
self.pending_read_id = None;
|
||||||
|
return self.on_snapshot_body(result, observed_at_ms, false);
|
||||||
|
}
|
||||||
|
SessionStep::Idle
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_failure(&mut self, id: u64, code: i64, observed_at_ms: u64) -> SessionStep {
|
||||||
|
if self.pending_read_id == Some(id) {
|
||||||
|
self.pending_read_id = None;
|
||||||
|
// Codex answers -32600 when the account cannot report a quota,
|
||||||
|
// which is a true statement about the account rather than a fault.
|
||||||
|
if code == INVALID_REQUEST {
|
||||||
|
let observations = self.unavailable_for_all(observed_at_ms);
|
||||||
|
self.forget_all();
|
||||||
|
return SessionStep::Observations(observations);
|
||||||
|
}
|
||||||
|
return SessionStep::Health(ProbeHealth::Faulted(HarnessError::Rejected(code)));
|
||||||
|
}
|
||||||
|
if id == self.initialize_id {
|
||||||
|
return SessionStep::Health(ProbeHealth::Faulted(HarnessError::Rejected(code)));
|
||||||
|
}
|
||||||
|
SessionStep::Idle
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turns a `rateLimits` value into observations.
|
||||||
|
///
|
||||||
|
/// `sparse` marks a push notification. Upstream documents that a rolling
|
||||||
|
/// update may omit values and that omission "does not clear a previously
|
||||||
|
/// observed value", so an absent window on a push records nothing at all.
|
||||||
|
/// It must not record an explicit gap, and it must not be merged into the
|
||||||
|
/// last full read either: a merged composite would look provider-reported
|
||||||
|
/// while being partly a memory.
|
||||||
|
fn on_snapshot_body(&mut self, body: &Value, observed_at_ms: u64, sparse: bool) -> SessionStep {
|
||||||
|
let Ok(body) = serde_json::from_value::<RateLimitsResultWire>(body.clone()) else {
|
||||||
|
return SessionStep::Health(ProbeHealth::Faulted(HarnessError::Malformed));
|
||||||
|
};
|
||||||
|
let observations = self.observations_from(&body.rate_limits, observed_at_ms, sparse);
|
||||||
|
if observations.is_empty() {
|
||||||
|
SessionStep::Idle
|
||||||
|
} else {
|
||||||
|
SessionStep::Observations(observations)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn observations_from(
|
||||||
|
&mut self,
|
||||||
|
snapshot: &RateLimitSnapshotWire,
|
||||||
|
observed_at_ms: u64,
|
||||||
|
sparse: bool,
|
||||||
|
) -> Vec<UsageObservation> {
|
||||||
|
let mut observations = Vec::new();
|
||||||
|
for slot in WindowSlot::ALL {
|
||||||
|
let index = slot as usize;
|
||||||
|
let wire = match slot {
|
||||||
|
WindowSlot::Primary => snapshot.primary.as_ref(),
|
||||||
|
WindowSlot::Secondary => snapshot.secondary.as_ref(),
|
||||||
|
};
|
||||||
|
let Some(wire) = wire else {
|
||||||
|
if !sparse {
|
||||||
|
// A full read that omits a window means the window is gone.
|
||||||
|
if self.last[index].take().is_some() {
|
||||||
|
observations.push(UsageObservation::unavailable(
|
||||||
|
self.profile_id(index),
|
||||||
|
observed_at_ms,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let reading = parse_window(wire, observed_at_ms);
|
||||||
|
if !self.should_record(index, reading, observed_at_ms) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
observations.push(to_observation(
|
||||||
|
&self.profile_id(index),
|
||||||
|
reading,
|
||||||
|
observed_at_ms,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
observations
|
||||||
|
}
|
||||||
|
|
||||||
|
fn profile_id(&self, index: usize) -> AccountProfileId {
|
||||||
|
self.profiles[index].id().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Suppresses an identical reading unless the re-emit floor has passed.
|
||||||
|
fn should_record(&mut self, index: usize, reading: WindowReading, observed_at_ms: u64) -> bool {
|
||||||
|
let WindowReading::Usable { permille, window } = reading else {
|
||||||
|
// A gap is only worth recording when it changes the story.
|
||||||
|
return self.last[index].take().is_some();
|
||||||
|
};
|
||||||
|
let unchanged = self.last[index].is_some_and(|last| {
|
||||||
|
last.permille == permille
|
||||||
|
&& last.window == window
|
||||||
|
&& observed_at_ms.saturating_sub(last.at_ms) < MIN_REEMIT_INTERVAL_MS
|
||||||
|
});
|
||||||
|
if unchanged {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.last[index] = Some(LastRecorded {
|
||||||
|
permille,
|
||||||
|
window,
|
||||||
|
at_ms: observed_at_ms,
|
||||||
|
});
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An explicit gap for every profile, stamped with when the gap was
|
||||||
|
/// observed.
|
||||||
|
///
|
||||||
|
/// Stamping these from the last successful reading — or from the epoch when
|
||||||
|
/// there has never been one — would put them behind the ledger's newest
|
||||||
|
/// entry, and an append-only stream rejects a backwards timestamp. The gap
|
||||||
|
/// would then be silently dropped and a stale percentage would keep
|
||||||
|
/// rendering as current, which is the exact failure decision 0012 exists to
|
||||||
|
/// prevent.
|
||||||
|
fn unavailable_for_all(&self, observed_at_ms: u64) -> Vec<UsageObservation> {
|
||||||
|
(0..self.profiles.len())
|
||||||
|
.map(|index| UsageObservation::unavailable(self.profile_id(index), observed_at_ms))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn forget_all(&mut self) {
|
||||||
|
self.last = [None, None];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{CodexSession, SessionStep, WindowSlot};
|
||||||
|
use crate::jsonrpc::ServerRequestClass;
|
||||||
|
use crate::probe::ProbeHealth;
|
||||||
|
use lumbridge_core::{UsageProvenance, UsageUnit};
|
||||||
|
|
||||||
|
const HOUR_MS: u64 = 3_600_000;
|
||||||
|
const RESETS_AT_SECONDS: i64 = 1_730_947_200;
|
||||||
|
const RESETS_AT_MS: u64 = 1_730_947_200_000;
|
||||||
|
|
||||||
|
fn session() -> CodexSession {
|
||||||
|
CodexSession::new().expect("the session profiles are valid")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ready() -> CodexSession {
|
||||||
|
let mut session = session();
|
||||||
|
let _ = session.initialize("lumbridge", "0.0.1");
|
||||||
|
let step = session.on_line(r#"{"jsonrpc":"2.0","id":1,"result":{}}"#, 0);
|
||||||
|
assert!(matches!(step, SessionStep::Send(_)), "handshake completes");
|
||||||
|
session
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_response(id: u64, percent: i64, minutes: i64) -> String {
|
||||||
|
format!(
|
||||||
|
r#"{{"jsonrpc":"2.0","id":{id},"result":{{"rateLimits":{{"primary":{{"usedPercent":{percent},"windowDurationMins":{minutes},"resetsAt":{RESETS_AT_SECONDS}}},"secondary":null}}}}}}"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_two_windows_are_two_separate_account_free_profiles() {
|
||||||
|
let session = session();
|
||||||
|
let ids = session
|
||||||
|
.profiles()
|
||||||
|
.iter()
|
||||||
|
.map(|profile| profile.id().as_str().to_owned())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(
|
||||||
|
ids,
|
||||||
|
["codex-app-server-primary", "codex-app-server-secondary"]
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!ids.iter().any(|id| id.contains('@')),
|
||||||
|
"a persisted profile ID must not carry an account identity"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_reading_is_requested_before_the_handshake_completes() {
|
||||||
|
let mut session = session();
|
||||||
|
let _ = session.initialize("lumbridge", "0.0.1");
|
||||||
|
assert!(
|
||||||
|
session.poll_frame().is_none(),
|
||||||
|
"the app-server requires initialize first"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_read_is_outstanding_at_a_time() {
|
||||||
|
let mut session = ready();
|
||||||
|
assert!(session.poll_frame().is_some());
|
||||||
|
assert!(
|
||||||
|
session.poll_frame().is_none(),
|
||||||
|
"a second read must wait for the first to answer"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_full_read_becomes_a_provider_reported_observation() {
|
||||||
|
let mut session = ready();
|
||||||
|
let frame = session.poll_frame().expect("ready sessions poll");
|
||||||
|
assert!(frame.contains("account/rateLimits/read"));
|
||||||
|
let step = session.on_line(&read_response(2, 25, 300), RESETS_AT_MS - HOUR_MS);
|
||||||
|
let SessionStep::Observations(observations) = step else {
|
||||||
|
panic!("a full read produces observations");
|
||||||
|
};
|
||||||
|
assert_eq!(observations.len(), 1, "only primary was present");
|
||||||
|
let observation = &observations[0];
|
||||||
|
assert_eq!(observation.provenance(), UsageProvenance::ProviderReported);
|
||||||
|
assert_eq!(observation.unit(), Some(UsageUnit::WindowPermille));
|
||||||
|
assert_eq!(observation.consumed(), 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unchanged_reading_is_not_re_recorded_every_poll() {
|
||||||
|
let mut session = ready();
|
||||||
|
let _ = session.poll_frame();
|
||||||
|
let at = RESETS_AT_MS - HOUR_MS;
|
||||||
|
assert!(matches!(
|
||||||
|
session.on_line(&read_response(2, 25, 300), at),
|
||||||
|
SessionStep::Observations(_)
|
||||||
|
));
|
||||||
|
let _ = session.poll_frame();
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
session.on_line(&read_response(3, 25, 300), at + 60_000),
|
||||||
|
SessionStep::Idle
|
||||||
|
),
|
||||||
|
"an identical reading must not flush the bounded retention"
|
||||||
|
);
|
||||||
|
let _ = session.poll_frame();
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
session.on_line(&read_response(4, 26, 300), at + 120_000),
|
||||||
|
SessionStep::Observations(_)
|
||||||
|
),
|
||||||
|
"a changed reading is always recorded"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unchanged_reading_is_re_emitted_after_the_floor_so_a_rate_stays_derivable() {
|
||||||
|
let mut session = ready();
|
||||||
|
let _ = session.poll_frame();
|
||||||
|
let at = RESETS_AT_MS - 4 * HOUR_MS;
|
||||||
|
let _ = session.on_line(&read_response(2, 25, 300), at);
|
||||||
|
let _ = session.poll_frame();
|
||||||
|
assert!(matches!(
|
||||||
|
session.on_line(&read_response(3, 25, 300), at + 300_001),
|
||||||
|
SessionStep::Observations(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_sparse_push_without_a_window_records_nothing() {
|
||||||
|
let mut session = ready();
|
||||||
|
let _ = session.poll_frame();
|
||||||
|
let at = RESETS_AT_MS - HOUR_MS;
|
||||||
|
let _ = session.on_line(&read_response(2, 25, 300), at);
|
||||||
|
// Upstream: nullable metadata absent from a rolling update "does not
|
||||||
|
// clear a previously observed value".
|
||||||
|
let push =
|
||||||
|
r#"{"jsonrpc":"2.0","method":"account/rateLimits/updated","params":{"rateLimits":{}}}"#;
|
||||||
|
assert!(
|
||||||
|
matches!(session.on_line(push, at + 1_000), SessionStep::Idle),
|
||||||
|
"a sparse push must neither clear nor merge"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_full_read_that_drops_a_window_records_an_explicit_gap() {
|
||||||
|
let mut session = ready();
|
||||||
|
let _ = session.poll_frame();
|
||||||
|
let at = RESETS_AT_MS - HOUR_MS;
|
||||||
|
let _ = session.on_line(&read_response(2, 25, 300), at);
|
||||||
|
let _ = session.poll_frame();
|
||||||
|
let empty =
|
||||||
|
r#"{"jsonrpc":"2.0","id":3,"result":{"rateLimits":{"primary":null,"secondary":null}}}"#;
|
||||||
|
let SessionStep::Observations(observations) = session.on_line(empty, at + 1_000) else {
|
||||||
|
panic!("a full read that loses a window is a change worth recording");
|
||||||
|
};
|
||||||
|
assert_eq!(observations.len(), 1);
|
||||||
|
assert_eq!(observations[0].provenance(), UsageProvenance::Unavailable);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_denial_stamps_its_gap_with_now_so_the_ledger_accepts_it() {
|
||||||
|
use lumbridge_core::UsageLedger;
|
||||||
|
|
||||||
|
let mut session = ready();
|
||||||
|
let _ = session.poll_frame();
|
||||||
|
let at = RESETS_AT_MS - HOUR_MS;
|
||||||
|
let SessionStep::Observations(good) = session.on_line(&read_response(2, 25, 300), at)
|
||||||
|
else {
|
||||||
|
panic!("the first read succeeds");
|
||||||
|
};
|
||||||
|
let _ = session.poll_frame();
|
||||||
|
let denied = r#"{"jsonrpc":"2.0","id":3,"error":{"code":-32600,"message":"chatgpt authentication required to read rate limits"}}"#;
|
||||||
|
let SessionStep::Observations(gaps) = session.on_line(denied, at + 60_000) else {
|
||||||
|
panic!("a denial is a fact about the account");
|
||||||
|
};
|
||||||
|
|
||||||
|
// The ledger is append-only, so a gap stamped behind the reading it
|
||||||
|
// supersedes would be refused and the stale reading would survive.
|
||||||
|
let mut ledger = UsageLedger::new();
|
||||||
|
for observation in good.into_iter().chain(gaps) {
|
||||||
|
ledger
|
||||||
|
.record(observation)
|
||||||
|
.expect("every observation must be accepted in order");
|
||||||
|
}
|
||||||
|
let projection = ledger.project(session.profiles()[0].id(), at + 60_000);
|
||||||
|
assert!(
|
||||||
|
!projection.is_available(),
|
||||||
|
"the denial must supersede the earlier reading"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_api_key_account_reports_unavailable_rather_than_a_fault() {
|
||||||
|
let mut session = ready();
|
||||||
|
let _ = session.poll_frame();
|
||||||
|
let denied = r#"{"jsonrpc":"2.0","id":2,"error":{"code":-32600,"message":"chatgpt authentication required to read rate limits"}}"#;
|
||||||
|
let SessionStep::Observations(observations) = session.on_line(denied, 5_000) else {
|
||||||
|
panic!("a quota-less account is a fact about the account");
|
||||||
|
};
|
||||||
|
assert_eq!(observations.len(), 2);
|
||||||
|
assert!(
|
||||||
|
observations
|
||||||
|
.iter()
|
||||||
|
.all(|observation| observation.provenance() == UsageProvenance::Unavailable)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn any_other_rejection_faults_the_probe() {
|
||||||
|
let mut session = ready();
|
||||||
|
let _ = session.poll_frame();
|
||||||
|
let denied = r#"{"jsonrpc":"2.0","id":2,"error":{"code":-32603,"message":"boom"}}"#;
|
||||||
|
assert!(matches!(
|
||||||
|
session.on_line(denied, 5_000),
|
||||||
|
SessionStep::Health(ProbeHealth::Faulted(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_credential_refresh_request_is_refused_and_counted() {
|
||||||
|
let mut session = ready();
|
||||||
|
let step = session.on_line(
|
||||||
|
r#"{"jsonrpc":"2.0","id":99,"method":"account/chatgptAuthTokens/refresh"}"#,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
let SessionStep::Refused { line, class } = step else {
|
||||||
|
panic!("a credential request must be refused");
|
||||||
|
};
|
||||||
|
assert_eq!(class, ServerRequestClass::CredentialRefresh);
|
||||||
|
assert!(line.contains("-32601"));
|
||||||
|
assert!(
|
||||||
|
!line.contains("access"),
|
||||||
|
"a refusal must not echo the request"
|
||||||
|
);
|
||||||
|
assert_eq!(session.refused_credential_requests(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_malformed_line_faults_rather_than_being_guessed_at() {
|
||||||
|
let mut session = ready();
|
||||||
|
assert!(matches!(
|
||||||
|
session.on_line("this is not json", 0),
|
||||||
|
SessionStep::Health(ProbeHealth::Faulted(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn window_slots_have_distinct_static_identifiers() {
|
||||||
|
assert_ne!(
|
||||||
|
WindowSlot::Primary.profile_id(),
|
||||||
|
WindowSlot::Secondary.profile_id()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
//! A deliberately deaf JSON-RPC client.
|
||||||
|
//!
|
||||||
|
//! The Codex app-server can send requests *to* its client, including
|
||||||
|
//! `account/chatgptAuthTokens/refresh`, which returns a bare access token.
|
||||||
|
//! AGENTS.md forbids Lumbridge from scraping a harness's private credentials.
|
||||||
|
//! Choosing not to call that method would be a convention; this module makes
|
||||||
|
//! it unrepresentable instead.
|
||||||
|
//!
|
||||||
|
//! There is no free-form method string anywhere in this crate. Outbound
|
||||||
|
//! requests come from a two-variant enum, and every inbound server request is
|
||||||
|
//! answered with `-32601 method not found` regardless of what it asks for.
|
||||||
|
//! Classification exists only so a refusal can be counted and named, which
|
||||||
|
//! makes "we were asked for a credential and refused" an auditable event
|
||||||
|
//! rather than an absence.
|
||||||
|
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
/// JSON-RPC's "method not found". The only reply this client ever sends.
|
||||||
|
const METHOD_NOT_FOUND: i64 = -32601;
|
||||||
|
/// Codex returns this for a request that needs an account it does not have.
|
||||||
|
pub(crate) const INVALID_REQUEST: i64 = -32600;
|
||||||
|
|
||||||
|
/// Every request this crate is able to send.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub(crate) enum OutboundRequest {
|
||||||
|
Initialize,
|
||||||
|
ReadAccountRateLimits,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OutboundRequest {
|
||||||
|
pub(crate) const fn method(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Initialize => "initialize",
|
||||||
|
Self::ReadAccountRateLimits => "account/rateLimits/read",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The notifications this client accepts. Anything else is ignored.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub(crate) enum AcceptedNotification {
|
||||||
|
AccountRateLimitsUpdated,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AcceptedNotification {
|
||||||
|
pub(crate) fn from_method(method: &str) -> Option<Self> {
|
||||||
|
match method {
|
||||||
|
"account/rateLimits/updated" => Some(Self::AccountRateLimitsUpdated),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a server-to-client request was asking for.
|
||||||
|
///
|
||||||
|
/// Purely descriptive. Every class is refused identically; the distinction
|
||||||
|
/// exists so a probe can report that it declined a credential request.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum ServerRequestClass {
|
||||||
|
/// A request that would hand Lumbridge a credential. Always refused.
|
||||||
|
CredentialRefresh,
|
||||||
|
/// A request to run something on the harness's behalf.
|
||||||
|
Execution,
|
||||||
|
/// A request for a human approval decision.
|
||||||
|
Approval,
|
||||||
|
/// A request for ambient information such as the current time.
|
||||||
|
Ambient,
|
||||||
|
/// Anything this client does not recognise.
|
||||||
|
Unrecognised,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServerRequestClass {
|
||||||
|
pub(crate) fn classify(method: &str) -> Self {
|
||||||
|
match method {
|
||||||
|
"account/chatgptAuthTokens/refresh" => Self::CredentialRefresh,
|
||||||
|
"item/tool/call" | "attestation/generate" => Self::Execution,
|
||||||
|
"item/permissions/requestApproval" => Self::Approval,
|
||||||
|
"currentTime/read" => Self::Ambient,
|
||||||
|
_ => Self::Unrecognised,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::CredentialRefresh => "credential refresh",
|
||||||
|
Self::Execution => "execution",
|
||||||
|
Self::Approval => "approval",
|
||||||
|
Self::Ambient => "ambient information",
|
||||||
|
Self::Unrecognised => "unrecognised request",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A classified inbound line.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub(crate) enum Inbound {
|
||||||
|
/// A successful response to one of our two requests.
|
||||||
|
Result { id: u64, result: Value },
|
||||||
|
/// A failed response to one of our two requests.
|
||||||
|
Failure { id: u64, code: i64 },
|
||||||
|
/// An accepted push notification.
|
||||||
|
Notification {
|
||||||
|
kind: AcceptedNotification,
|
||||||
|
params: Value,
|
||||||
|
},
|
||||||
|
/// The server asked us for something. It will be refused.
|
||||||
|
ServerRequest {
|
||||||
|
id: Value,
|
||||||
|
class: ServerRequestClass,
|
||||||
|
},
|
||||||
|
/// A well-formed frame with nothing for us in it.
|
||||||
|
Ignored,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encodes one of the two permitted requests.
|
||||||
|
pub(crate) fn encode_request(id: u64, request: OutboundRequest, params: &Value) -> String {
|
||||||
|
json!({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": id,
|
||||||
|
"method": request.method(),
|
||||||
|
"params": params,
|
||||||
|
})
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn encode_notification(method: &'static str, params: &Value) -> String {
|
||||||
|
json!({ "jsonrpc": "2.0", "method": method, "params": params }).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The refusal sent for every server-to-client request.
|
||||||
|
pub(crate) fn encode_refusal(id: &Value) -> String {
|
||||||
|
json!({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": id,
|
||||||
|
"error": { "code": METHOD_NOT_FOUND, "message": "method not found" },
|
||||||
|
})
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classifies one line of JSON-RPC without interpreting its payload.
|
||||||
|
///
|
||||||
|
/// Returns `None` when the line is not a JSON object at all, which the caller
|
||||||
|
/// reports as [`crate::HarnessError::Malformed`].
|
||||||
|
pub(crate) fn classify(line: &str) -> Option<Inbound> {
|
||||||
|
let frame: Value = serde_json::from_str(line).ok()?;
|
||||||
|
let object = frame.as_object()?;
|
||||||
|
let method = object.get("method").and_then(Value::as_str);
|
||||||
|
let id = object.get("id");
|
||||||
|
|
||||||
|
match (method, id) {
|
||||||
|
// A method with an id is the server asking us for something.
|
||||||
|
(Some(method), Some(id)) => Some(Inbound::ServerRequest {
|
||||||
|
id: id.clone(),
|
||||||
|
class: ServerRequestClass::classify(method),
|
||||||
|
}),
|
||||||
|
// A method without an id is a notification.
|
||||||
|
(Some(method), None) => Some(AcceptedNotification::from_method(method).map_or(
|
||||||
|
Inbound::Ignored,
|
||||||
|
|kind| Inbound::Notification {
|
||||||
|
kind,
|
||||||
|
params: object.get("params").cloned().unwrap_or(Value::Null),
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
// An id without a method is a response to one of ours.
|
||||||
|
(None, Some(id)) => {
|
||||||
|
let id = id.as_u64()?;
|
||||||
|
if let Some(error) = object.get("error") {
|
||||||
|
let code = error.get("code").and_then(Value::as_i64).unwrap_or(0);
|
||||||
|
return Some(Inbound::Failure { id, code });
|
||||||
|
}
|
||||||
|
Some(Inbound::Result {
|
||||||
|
id,
|
||||||
|
result: object.get("result").cloned().unwrap_or(Value::Null),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
(None, None) => Some(Inbound::Ignored),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{
|
||||||
|
AcceptedNotification, Inbound, OutboundRequest, ServerRequestClass, classify,
|
||||||
|
encode_refusal, encode_request,
|
||||||
|
};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_two_methods_can_be_sent() {
|
||||||
|
assert_eq!(OutboundRequest::Initialize.method(), "initialize");
|
||||||
|
assert_eq!(
|
||||||
|
OutboundRequest::ReadAccountRateLimits.method(),
|
||||||
|
"account/rateLimits/read"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_credential_refresh_request_is_classified_and_refused() {
|
||||||
|
let line = r#"{"jsonrpc":"2.0","id":9,"method":"account/chatgptAuthTokens/refresh"}"#;
|
||||||
|
let Some(Inbound::ServerRequest { id, class }) = classify(line) else {
|
||||||
|
panic!("a method with an id is a server request");
|
||||||
|
};
|
||||||
|
assert_eq!(class, ServerRequestClass::CredentialRefresh);
|
||||||
|
let refusal: Value =
|
||||||
|
serde_json::from_str(&encode_refusal(&id)).expect("the refusal is valid JSON");
|
||||||
|
assert_eq!(refusal["error"]["code"], json!(-32601));
|
||||||
|
assert!(
|
||||||
|
refusal.get("result").is_none(),
|
||||||
|
"a refusal must never carry a result"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_server_request_class_is_refused_the_same_way() {
|
||||||
|
for method in [
|
||||||
|
"account/chatgptAuthTokens/refresh",
|
||||||
|
"item/tool/call",
|
||||||
|
"item/permissions/requestApproval",
|
||||||
|
"currentTime/read",
|
||||||
|
"something/entirely/new",
|
||||||
|
] {
|
||||||
|
let line = format!(r#"{{"jsonrpc":"2.0","id":1,"method":"{method}"}}"#);
|
||||||
|
let Some(Inbound::ServerRequest { id, .. }) = classify(&line) else {
|
||||||
|
panic!("{method} must classify as a server request");
|
||||||
|
};
|
||||||
|
let refusal: Value = serde_json::from_str(&encode_refusal(&id)).expect("valid JSON");
|
||||||
|
assert_eq!(refusal["error"]["code"], json!(-32601));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_the_rate_limit_notification_is_accepted() {
|
||||||
|
let accepted = classify(
|
||||||
|
r#"{"jsonrpc":"2.0","method":"account/rateLimits/updated","params":{"rateLimits":{}}}"#,
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
accepted,
|
||||||
|
Some(Inbound::Notification {
|
||||||
|
kind: AcceptedNotification::AccountRateLimitsUpdated,
|
||||||
|
..
|
||||||
|
})
|
||||||
|
));
|
||||||
|
let ignored =
|
||||||
|
classify(r#"{"jsonrpc":"2.0","method":"thread/tokenUsage/updated","params":{}}"#);
|
||||||
|
assert!(matches!(ignored, Some(Inbound::Ignored)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn responses_and_failures_are_separated() {
|
||||||
|
assert!(matches!(
|
||||||
|
classify(r#"{"jsonrpc":"2.0","id":7,"result":{"rateLimits":{}}}"#),
|
||||||
|
Some(Inbound::Result { id: 7, .. })
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
classify(r#"{"jsonrpc":"2.0","id":7,"error":{"code":-32600,"message":"nope"}}"#),
|
||||||
|
Some(Inbound::Failure {
|
||||||
|
id: 7,
|
||||||
|
code: -32600
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_and_non_object_lines_are_rejected() {
|
||||||
|
for line in ["", "not json", "[1,2,3]", "\"a string\"", "{"] {
|
||||||
|
assert!(classify(line).is_none(), "{line:?} must not classify");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_request_encodes_without_a_free_form_method() {
|
||||||
|
let encoded = encode_request(1, OutboundRequest::ReadAccountRateLimits, &json!({}));
|
||||||
|
let frame: Value = serde_json::from_str(&encoded).expect("valid JSON");
|
||||||
|
assert_eq!(frame["method"], json!("account/rateLimits/read"));
|
||||||
|
assert_eq!(frame["jsonrpc"], json!("2.0"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
//! Documented status and usage probes for supervised coding harnesses.
|
||||||
|
//!
|
||||||
|
//! `lumbridge-core` owns the usage ledger and stays free of IO, clocks, and
|
||||||
|
//! async. This crate is the other side of that boundary: it runs processes,
|
||||||
|
//! reads a clock, parses untrusted wire text, and hands back
|
||||||
|
//! [`lumbridge_core::UsageObservation`] values. Nothing here interprets a
|
||||||
|
//! usage number; it only decides which observation is honest to record.
|
||||||
|
//!
|
||||||
|
//! The rule every probe follows: Lumbridge observes documented surfaces of a
|
||||||
|
//! harness it launched, and never reads that harness's credentials. A probe
|
||||||
|
//! that cannot obtain a reading records
|
||||||
|
//! [`lumbridge_core::UsageObservation::unavailable`] rather than a zero.
|
||||||
|
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
mod child;
|
||||||
|
pub mod claude;
|
||||||
|
mod clock;
|
||||||
|
pub mod codex;
|
||||||
|
mod jsonrpc;
|
||||||
|
mod probe;
|
||||||
|
|
||||||
|
pub use child::PROBE_ENV_ALLOWLIST;
|
||||||
|
pub use clock::MonotonicWallClock;
|
||||||
|
pub use jsonrpc::ServerRequestClass;
|
||||||
|
pub use probe::{ProbeHealth, ProbeOutcome, UsageProbe};
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
/// Failures a probe can report.
|
||||||
|
///
|
||||||
|
/// Every variant is [`Copy`] and carries only a static discriminator, an
|
||||||
|
/// [`std::io::ErrorKind`], or a number. No variant can capture a string from a
|
||||||
|
/// child process or the filesystem, so an error can never smuggle a path,
|
||||||
|
/// a credential, or attacker-chosen text into a log or a UI surface.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
|
||||||
|
pub enum HarnessError {
|
||||||
|
#[error("a probe program name must not be empty")]
|
||||||
|
EmptyProgram,
|
||||||
|
#[error("the environment variable {0} is not on the probe allowlist")]
|
||||||
|
EnvNotAllowlisted(&'static str),
|
||||||
|
#[error("a probe poll interval must be at least one second")]
|
||||||
|
ProbeIntervalTooShort,
|
||||||
|
#[error("the probe program could not be started ({0:?})")]
|
||||||
|
SpawnFailed(std::io::ErrorKind),
|
||||||
|
#[error("the probe program closed its output")]
|
||||||
|
Eof,
|
||||||
|
#[error("the probe program sent a {0} byte line, over the accepted limit")]
|
||||||
|
OversizedLine(usize),
|
||||||
|
#[error("the probe program sent a line that is not a JSON-RPC frame")]
|
||||||
|
Malformed,
|
||||||
|
#[error("the harness rejected the request with JSON-RPC code {0}")]
|
||||||
|
Rejected(i64),
|
||||||
|
#[error("the harness has no subscription account to report a quota for")]
|
||||||
|
NotAuthenticated,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HarnessError {
|
||||||
|
/// Whether this failure means "there is no number", as opposed to
|
||||||
|
/// "the probe broke".
|
||||||
|
///
|
||||||
|
/// Both record an unavailable observation, but only the second is a fault
|
||||||
|
/// worth surfacing as a broken probe.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn is_expected_gap(self) -> bool {
|
||||||
|
matches!(self, Self::NotAuthenticated)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
//! The contract between a harness probe and the usage ledger.
|
||||||
|
//!
|
||||||
|
//! This trait lives here rather than in `lumbridge-core` because implementing
|
||||||
|
//! it implies a fallible external call, and core stays IO-free, clock-free,
|
||||||
|
//! and async-free. [`lumbridge_core::UsageObservation`] is already the shared
|
||||||
|
//! type; nothing further needs to cross the boundary.
|
||||||
|
|
||||||
|
use lumbridge_core::{AccountProfile, UsageObservation};
|
||||||
|
|
||||||
|
use crate::HarnessError;
|
||||||
|
|
||||||
|
/// What a probe knows about its own liveness.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum ProbeHealth {
|
||||||
|
/// Launched, handshake not finished.
|
||||||
|
Starting,
|
||||||
|
/// Handshake complete and readings are expected.
|
||||||
|
Ready,
|
||||||
|
/// Running, but this account has no subscription quota to report. This is
|
||||||
|
/// a correct terminal state, not a fault: API-key users have no window.
|
||||||
|
NoQuotaAccount,
|
||||||
|
/// Broken. The observations it produced before this are still facts.
|
||||||
|
Faulted(HarnessError),
|
||||||
|
/// Deliberately shut down.
|
||||||
|
Stopped,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProbeHealth {
|
||||||
|
#[must_use]
|
||||||
|
pub const fn is_faulted(self) -> bool {
|
||||||
|
matches!(self, Self::Faulted(_))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A short phrase a UI may render beside the probe's profiles.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Starting => "probe starting",
|
||||||
|
Self::Ready => "probe ready",
|
||||||
|
Self::NoQuotaAccount => "no subscription quota on this account",
|
||||||
|
Self::Faulted(_) => "probe faulted",
|
||||||
|
Self::Stopped => "probe stopped",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One round of probe output.
|
||||||
|
///
|
||||||
|
/// It carries observations and liveness and nothing else. There is no variant
|
||||||
|
/// able to carry a command, a plan, or a capability request, so a probe cannot
|
||||||
|
/// become an actor on the workspace no matter what the harness sends it.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct ProbeOutcome {
|
||||||
|
observations: Vec<UsageObservation>,
|
||||||
|
health: ProbeHealth,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProbeOutcome {
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new(observations: Vec<UsageObservation>, health: ProbeHealth) -> Self {
|
||||||
|
Self {
|
||||||
|
observations,
|
||||||
|
health,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn idle(health: ProbeHealth) -> Self {
|
||||||
|
Self {
|
||||||
|
observations: Vec::new(),
|
||||||
|
health,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn observations(&self) -> &[UsageObservation] {
|
||||||
|
&self.observations
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Takes the observations out for recording, leaving the outcome empty.
|
||||||
|
#[must_use]
|
||||||
|
pub fn into_observations(self) -> Vec<UsageObservation> {
|
||||||
|
self.observations
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn health(&self) -> ProbeHealth {
|
||||||
|
self.health
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A source of usage observations for one or more account profiles.
|
||||||
|
pub trait UsageProbe {
|
||||||
|
/// Drains whatever the probe has ready.
|
||||||
|
///
|
||||||
|
/// Implementations must return promptly: this is called from the UI's
|
||||||
|
/// frame loop and must never wait on a child process, a socket, or a lock
|
||||||
|
/// held across IO.
|
||||||
|
fn poll(&mut self) -> ProbeOutcome;
|
||||||
|
|
||||||
|
/// The profiles this probe can produce observations for.
|
||||||
|
fn profiles(&self) -> &[AccountProfile];
|
||||||
|
|
||||||
|
/// Stops the probe and releases its child process.
|
||||||
|
fn shutdown(&mut self);
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
//! End-to-end: a Codex transcript through the real ledger and footer.
|
||||||
|
//!
|
||||||
|
//! The unit tests prove the adapter builds the right observations. These prove
|
||||||
|
//! the observations still obey every decision 0012 rule once they reach
|
||||||
|
//! `lumbridge-core`, which is the property that actually matters to a user
|
||||||
|
//! reading the footer.
|
||||||
|
//!
|
||||||
|
//! Every transcript below is hand-written from the documented wire shape in
|
||||||
|
//! openai/codex (Apache-2.0), commit
|
||||||
|
//! `17e8101699c5062117d0d37f504313e8af53b043`, `codex-rs/app-server/README.md`
|
||||||
|
//! section "7) Rate limits (`ChatGPT`)". No real account, transcript, or
|
||||||
|
//! credential is used, and all timestamps are small synthetic integers.
|
||||||
|
|
||||||
|
use lumbridge_core::{
|
||||||
|
AccountProfileId, FooterUsage, UsageConfidence, UsageLedger, UsageProvenance,
|
||||||
|
};
|
||||||
|
use lumbridge_harness::codex::replay_transcript;
|
||||||
|
|
||||||
|
const HOUR_MS: u64 = 3_600_000;
|
||||||
|
/// A synthetic reset five hours after the epoch, in seconds.
|
||||||
|
const RESETS_AT_SECONDS: u64 = 5 * 3_600;
|
||||||
|
const RESETS_AT_MS: u64 = 5 * HOUR_MS;
|
||||||
|
|
||||||
|
fn initialize_response() -> &'static str {
|
||||||
|
r#"{"jsonrpc":"2.0","id":1,"result":{"userAgent":"codex"}}"#
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_response(id: u64, percent: u64) -> String {
|
||||||
|
format!(
|
||||||
|
r#"{{"jsonrpc":"2.0","id":{id},"result":{{"rateLimits":{{"primary":{{"usedPercent":{percent},"windowDurationMins":300,"resetsAt":{RESETS_AT_SECONDS}}},"secondary":null,"rateLimitReachedType":null}}}}}}"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn primary() -> AccountProfileId {
|
||||||
|
AccountProfileId::new("codex-app-server-primary").expect("the probe's static ID is valid")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ledger_from(frames: &[(&str, u64)]) -> UsageLedger {
|
||||||
|
let mut ledger = UsageLedger::new();
|
||||||
|
for observation in replay_transcript(frames) {
|
||||||
|
ledger
|
||||||
|
.record(observation)
|
||||||
|
.expect("the adapter emits observations in time order");
|
||||||
|
}
|
||||||
|
ledger
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_two_reading_transcript_produces_a_provider_reported_footer() {
|
||||||
|
let first = read_response(2, 20);
|
||||||
|
let second = read_response(3, 35);
|
||||||
|
let ledger = ledger_from(&[
|
||||||
|
(initialize_response(), 0),
|
||||||
|
(first.as_str(), HOUR_MS),
|
||||||
|
(second.as_str(), 2 * HOUR_MS),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let projection = ledger.project(&primary(), 2 * HOUR_MS);
|
||||||
|
assert_eq!(projection.provenance(), UsageProvenance::ProviderReported);
|
||||||
|
assert_eq!(projection.confidence(), UsageConfidence::Exact);
|
||||||
|
assert_eq!(projection.consumed_permille(), Some(350));
|
||||||
|
assert_eq!(projection.resets_in_ms(), Some(3 * HOUR_MS));
|
||||||
|
// 200 permille burned over one hour, derived rather than reported.
|
||||||
|
assert_eq!(projection.burn_per_hour(), Some(150));
|
||||||
|
assert_eq!(projection.burn_provenance(), UsageProvenance::Estimated);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_single_reading_yields_a_percentage_but_no_rate() {
|
||||||
|
let only = read_response(2, 20);
|
||||||
|
let ledger = ledger_from(&[(initialize_response(), 0), (only.as_str(), HOUR_MS)]);
|
||||||
|
|
||||||
|
let projection = ledger.project(&primary(), HOUR_MS);
|
||||||
|
assert_eq!(projection.consumed_permille(), Some(200));
|
||||||
|
assert_eq!(
|
||||||
|
projection.burn_per_hour(),
|
||||||
|
None,
|
||||||
|
"one provider reading is not a rate"
|
||||||
|
);
|
||||||
|
assert_eq!(projection.exhaustion_in_ms(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_api_key_account_renders_the_unavailable_path_rather_than_a_zero() {
|
||||||
|
// Codex answers -32600 with "chatgpt authentication required to read rate
|
||||||
|
// limits" for an account that has no subscription quota.
|
||||||
|
let denied = r#"{"jsonrpc":"2.0","id":2,"error":{"code":-32600,"message":"chatgpt authentication required to read rate limits"}}"#;
|
||||||
|
let ledger = ledger_from(&[(initialize_response(), 0), (denied, HOUR_MS)]);
|
||||||
|
|
||||||
|
let projection = ledger.project(&primary(), HOUR_MS);
|
||||||
|
assert!(!projection.is_available());
|
||||||
|
assert_eq!(projection.consumed_permille(), None);
|
||||||
|
|
||||||
|
let profile = lumbridge_core::AccountProfile::new(
|
||||||
|
"codex-app-server-primary",
|
||||||
|
"Codex",
|
||||||
|
"ChatGPT",
|
||||||
|
"primary window",
|
||||||
|
"subscription",
|
||||||
|
)
|
||||||
|
.expect("a valid profile");
|
||||||
|
let footer = FooterUsage::new(&profile, &projection);
|
||||||
|
assert_eq!(footer.window(), "usage unavailable");
|
||||||
|
assert_eq!(footer.burn(), "burn rate unavailable");
|
||||||
|
assert_eq!(footer.trust(), "no usage source");
|
||||||
|
assert!(
|
||||||
|
!footer.to_string().contains('%'),
|
||||||
|
"an account with no quota must not render a percentage"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_reading_taken_after_the_reset_never_renders_a_frozen_percentage() {
|
||||||
|
let stale = read_response(2, 90);
|
||||||
|
let ledger = ledger_from(&[
|
||||||
|
(initialize_response(), 0),
|
||||||
|
// Observed one millisecond after the window it describes ended.
|
||||||
|
(stale.as_str(), RESETS_AT_MS + 1),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let projection = ledger.project(&primary(), RESETS_AT_MS + 1);
|
||||||
|
assert!(!projection.is_available());
|
||||||
|
assert_eq!(
|
||||||
|
projection.consumed_permille(),
|
||||||
|
None,
|
||||||
|
"an ended window must not keep rendering its last percentage"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_credential_refresh_request_produces_no_observation_at_all() {
|
||||||
|
let refresh = r#"{"jsonrpc":"2.0","id":77,"method":"account/chatgptAuthTokens/refresh"}"#;
|
||||||
|
let reading = read_response(2, 20);
|
||||||
|
let observations = replay_transcript(&[
|
||||||
|
(initialize_response(), 0),
|
||||||
|
(refresh, 1_000),
|
||||||
|
(reading.as_str(), HOUR_MS),
|
||||||
|
]);
|
||||||
|
assert_eq!(
|
||||||
|
observations.len(),
|
||||||
|
1,
|
||||||
|
"the refused credential request must not enter the usage stream"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_repeated_identical_reading_does_not_flood_the_ledger() {
|
||||||
|
let a = read_response(2, 20);
|
||||||
|
let b = read_response(3, 20);
|
||||||
|
let c = read_response(4, 20);
|
||||||
|
let ledger = ledger_from(&[
|
||||||
|
(initialize_response(), 0),
|
||||||
|
(a.as_str(), HOUR_MS),
|
||||||
|
(b.as_str(), HOUR_MS + 60_000),
|
||||||
|
(c.as_str(), HOUR_MS + 120_000),
|
||||||
|
]);
|
||||||
|
assert_eq!(
|
||||||
|
ledger.observation_count(&primary()),
|
||||||
|
1,
|
||||||
|
"identical readings inside the re-emit floor must not evict history"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_malformed_transcript_records_nothing_rather_than_guessing() {
|
||||||
|
let observations = replay_transcript(&[
|
||||||
|
(initialize_response(), 0),
|
||||||
|
("}{ not json", 1_000),
|
||||||
|
(
|
||||||
|
r#"{"jsonrpc":"2.0","id":2,"result":{"rateLimits":"a string"}}"#,
|
||||||
|
2_000,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
assert!(observations.is_empty());
|
||||||
|
}
|
||||||
+52
-5
@@ -28,7 +28,8 @@ messages should be real from the beginning.
|
|||||||
- `lumbridge-pty`: portable PTY and process-tree adapters.
|
- `lumbridge-pty`: portable PTY and process-tree adapters.
|
||||||
- `lumbridge-terminal`: VT parsing, scrollback, selection, search, render model.
|
- `lumbridge-terminal`: VT parsing, scrollback, selection, search, render model.
|
||||||
- `lumbridge-acp`: ACP client, capability negotiation, transcript normalization.
|
- `lumbridge-acp`: ACP client, capability negotiation, transcript normalization.
|
||||||
- `lumbridge-harness`: manifests, launch profiles, hooks, PTY fallback adapters.
|
- `lumbridge-harness`: manifests, launch profiles, hooks, PTY fallback adapters,
|
||||||
|
and documented status/usage probes.
|
||||||
- `lumbridge-provider`: BYOK providers and provider-neutral usage records.
|
- `lumbridge-provider`: BYOK providers and provider-neutral usage records.
|
||||||
- `lumbridge-storage`: SQLite migrations, event log, snapshots, retention.
|
- `lumbridge-storage`: SQLite migrations, event log, snapshots, retention.
|
||||||
- `lumbridge-remote`: OpenSSH/Tailscale command construction, framed stdio,
|
- `lumbridge-remote`: OpenSSH/Tailscale command construction, framed stdio,
|
||||||
@@ -41,10 +42,10 @@ messages should be real from the beginning.
|
|||||||
- `lumbridge`: installable application entry point.
|
- `lumbridge`: installable application entry point.
|
||||||
|
|
||||||
The scaffold currently contains `lumbridge-core`, `lumbridge-storage`,
|
The scaffold currently contains `lumbridge-core`, `lumbridge-storage`,
|
||||||
`lumbridge-buzz`, `lumbridge-pty`, `lumbridge-runtime`, `lumbridge-terminal`, and
|
`lumbridge-buzz`, `lumbridge-pty`, `lumbridge-runtime`, `lumbridge-terminal`,
|
||||||
the entry point. `lumbridge-core` also contains the first typed workspace
|
`lumbridge-harness`, and the entry point. `lumbridge-core` also contains the
|
||||||
command reducer. Larger runtime crates are added after their architecture spikes
|
first typed workspace command reducer and the usage ledger. Larger runtime
|
||||||
pass.
|
crates are added after their architecture spikes pass.
|
||||||
|
|
||||||
## Terminal path
|
## Terminal path
|
||||||
|
|
||||||
@@ -200,6 +201,52 @@ Observations include account profile, provider, harness, model, units, time
|
|||||||
window, reset time, provenance, confidence, and source timestamp. Projections are
|
window, reset time, provenance, confidence, and source timestamp. Projections are
|
||||||
derived views that can be recomputed as forecasting improves.
|
derived views that can be recomputed as forecasting improves.
|
||||||
|
|
||||||
|
`lumbridge-core` now implements that stream. A bounded per-profile `UsageLedger`
|
||||||
|
accepts observations in time order, and `project` derives the footer view:
|
||||||
|
window fraction, reset, burn rate, and exhaustion. Facts keep their reported
|
||||||
|
provenance; every derived value is labelled estimated. The projection withholds
|
||||||
|
a burn rate from a single sample, withholds a window fraction without a reported
|
||||||
|
ceiling, withholds an exhaustion estimate that falls after the reset, treats an
|
||||||
|
unavailable observation as invalidating older facts, and reports an expired
|
||||||
|
window as rolled over rather than freezing its last percentage. `FooterUsage`
|
||||||
|
renders the five product-spec questions and returns explicit unavailable phrases
|
||||||
|
instead of placeholder numbers. See decision 0012.
|
||||||
|
|
||||||
|
`lumbridge-harness` is the other side of that boundary: it runs processes,
|
||||||
|
reads a clock, and parses untrusted wire text, then hands back observations.
|
||||||
|
Its first adapter probes Codex's documented `account/rateLimits/read` surface
|
||||||
|
over the app-server's JSON-RPC stdio protocol. The client cannot express a
|
||||||
|
request outside a two-variant enum and answers every server-to-client request
|
||||||
|
with `-32601`, so a harness asking Lumbridge for a credential is refused by
|
||||||
|
construction. All protocol decisions live in a pure state machine driven by
|
||||||
|
`&str` lines and an explicit timestamp, with a `replay_transcript` entry point
|
||||||
|
that verifies the whole path against a fixture without a process, a clock, or a
|
||||||
|
network. See decision 0013.
|
||||||
|
|
||||||
|
Its second adapter reads Claude Code's session transcripts, the only local
|
||||||
|
surface that reports what that harness spent. It is a tail follower with a
|
||||||
|
per-file byte offset, so its total is monotonic, and it reports nothing until
|
||||||
|
it has read the existing backlog to the end — a partially-read backlog is
|
||||||
|
indistinguishable from a burst of spend, and the ledger would derive a rate
|
||||||
|
from it. The transcript carries no window or ceiling, so that profile reports
|
||||||
|
consumption against none. The parser models four token counters and nothing
|
||||||
|
else, so the conversations in those files are not representable in a Lumbridge
|
||||||
|
value. See decision 0014.
|
||||||
|
|
||||||
|
Claude Code's subscription windows come from a different surface: the CLI pipes
|
||||||
|
a `rate_limits` object carrying the five-hour and seven-day windows to whatever
|
||||||
|
`statusLine` command the user has configured, on every turn. A small installed
|
||||||
|
bridge writes those fields — and only those — to a local feed the probe tails,
|
||||||
|
so the windows are `ProviderReported` and no credential is ever read. Reading
|
||||||
|
Claude Code's OAuth token would be more capable and is what comparable tools
|
||||||
|
do; Lumbridge does not, because AGENTS.md forbids it. See decision 0015.
|
||||||
|
|
||||||
|
The GPUI shell now runs both probes; a probe contributes its own profiles on
|
||||||
|
top of the declared ones, so the Claude windows appear once they report. Its
|
||||||
|
footer strip
|
||||||
|
shows every declared harness at once, and a harness with no adapter renders
|
||||||
|
the honest gap.
|
||||||
|
|
||||||
Subscription balance is provider-specific and sometimes unavailable. BYOK calls
|
Subscription balance is provider-specific and sometimes unavailable. BYOK calls
|
||||||
usually expose token counts but cost still depends on cached tokens, reasoning,
|
usually expose token counts but cost still depends on cached tokens, reasoning,
|
||||||
tool calls, and current pricing. Adapters normalize facts without erasing their
|
tool calls, and current pricing. Adapters normalize facts without erasing their
|
||||||
|
|||||||
@@ -97,7 +97,13 @@ Terminal, Browser, Markdown, and Review panels beside the selection or
|
|||||||
reattaches an existing session. Every Terminal panel receives an independent
|
reattaches an existing session. Every Terminal panel receives an independent
|
||||||
actor-owned VT session; the three seed non-terminal surfaces keep deterministic
|
actor-owned VT session; the three seed non-terminal surfaces keep deterministic
|
||||||
updates running. Counters
|
updates running. Counters
|
||||||
separate external PTY batches/lines from total model updates. The GPUI footer
|
separate external PTY batches/lines from total model updates. The GPUI footer's
|
||||||
|
usage zone is no longer a constant string: it renders a `lumbridge-core`
|
||||||
|
projection for the selected panel's account profile, with a provenance chip and
|
||||||
|
explicit unavailable phrases, and its sidebar attention count and runtime rows
|
||||||
|
are derived from live panel and ledger state. The Floem shell still renders the
|
||||||
|
static footer fixtures, so the two candidates are no longer comparable on that
|
||||||
|
strip. The GPUI footer also
|
||||||
reports dispatch-to-element-build p50/p95 over a bounded 256-sample window. It
|
reports dispatch-to-element-build p50/p95 over a bounded 256-sample window. It
|
||||||
is deliberately not called key-to-present or frame-present latency: neither
|
is deliberately not called key-to-present or frame-present latency: neither
|
||||||
candidate exposes a reliable public cross-platform post-present callback. True
|
candidate exposes a reliable public cross-platform post-present callback. True
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# 0012: The footer renders a derived projection, never a stored percentage
|
||||||
|
|
||||||
|
Status: accepted. Supplemented by decisions 0013 and 0014, which supply the
|
||||||
|
first two real adapters. Every rule below still holds.
|
||||||
|
|
||||||
|
Decision 0002 established that usage observations carry provenance. This
|
||||||
|
decision fixes how those observations become the five answers the footer owes
|
||||||
|
the user, and what the footer must do when it cannot answer.
|
||||||
|
|
||||||
|
## The contract
|
||||||
|
|
||||||
|
`lumbridge-core` owns an append-only `UsageLedger` of `UsageObservation`s per
|
||||||
|
`AccountProfile`. An observation is a fact: consumed units, an optional ceiling,
|
||||||
|
an optional provider window, a provenance, and the time it was observed. The
|
||||||
|
ledger never edits an observation and refuses one that predates the newest entry
|
||||||
|
for that profile, because an append-only stream cannot move backwards.
|
||||||
|
|
||||||
|
`UsageLedger::project` derives the footer view. Reported facts keep their own
|
||||||
|
provenance. Anything the projection computes is labelled `Estimated` even when
|
||||||
|
every input was provider-reported, because a derived rate is not a reported
|
||||||
|
fact.
|
||||||
|
|
||||||
|
## Rules the projection enforces
|
||||||
|
|
||||||
|
- A burn rate needs at least two observations inside the same window, the same
|
||||||
|
unit, and a minimum elapsed sample. One reading yields no rate at all rather
|
||||||
|
than a rate of zero.
|
||||||
|
- A window fraction requires a reported ceiling. A self-hosted endpoint has no
|
||||||
|
quota, so it reports consumption and explicitly says no ceiling exists.
|
||||||
|
- An exhaustion estimate is withheld when the window resets first. Telling a
|
||||||
|
user they will run out after the quota has already refilled is worse than
|
||||||
|
telling them nothing.
|
||||||
|
- An `Unavailable` observation invalidates older facts for that profile instead
|
||||||
|
of letting a stale reading keep rendering as current.
|
||||||
|
- An expired window is reported as rolled over, not as a frozen percentage from
|
||||||
|
a window that ended.
|
||||||
|
- Staleness is surfaced, not hidden: past the staleness budget the value still
|
||||||
|
renders but its confidence drops to unknown and the footer says so.
|
||||||
|
|
||||||
|
## What the UI may do
|
||||||
|
|
||||||
|
A compact surface may substitute the short provenance label. It may not drop the
|
||||||
|
label, round an estimate into a reported fact, or substitute a placeholder
|
||||||
|
number for a missing one. `FooterUsage` therefore returns explicit phrases —
|
||||||
|
`usage unavailable`, `reset time unavailable`, `burn rate unavailable`,
|
||||||
|
`no usage source` — and the GPUI shell renders unavailable values in the quiet
|
||||||
|
tone with the provenance chip coloured by source rather than by value.
|
||||||
|
|
||||||
|
A pane with no harness attached shows the detached roll-up. It never inherits
|
||||||
|
another pane's account, because attributing one agent's spend to another is the
|
||||||
|
same class of untruth as inventing the number.
|
||||||
|
|
||||||
|
## Status of the numbers
|
||||||
|
|
||||||
|
The GPUI shell's observations are still a declared spike fixture. The path is
|
||||||
|
real: the fixture records through the same ledger and reads back through the
|
||||||
|
same projection, so the honest-gap behaviour above is exercised on every frame
|
||||||
|
rather than asserted only in tests.
|
||||||
|
|
||||||
|
The first real adapter now exists in `lumbridge-harness` (decision 0013) and its
|
||||||
|
integration tests drive a synthetic Codex transcript through this ledger and
|
||||||
|
this footer, proving every rule above survives the adapter path. Pointing the
|
||||||
|
running shell at a live account spawns a process against the user's own
|
||||||
|
subscription, so it stays a separate, user-consented step.
|
||||||
|
|
||||||
|
All arithmetic is integer arithmetic and fractions are carried as permille, so a
|
||||||
|
rendered percentage cannot drift from the stored fact.
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# 0013: The first usage adapter is the Codex app-server rate-limit probe
|
||||||
|
|
||||||
|
Status: accepted; wire contract verified against one live account.
|
||||||
|
|
||||||
|
Decision 0012 built a usage ledger and a footer that refuse to invent numbers,
|
||||||
|
but every observation reaching them was a declared fixture. This decision picks
|
||||||
|
the first real source and records the rules that choice establishes for the
|
||||||
|
adapters after it.
|
||||||
|
|
||||||
|
## Why Codex first
|
||||||
|
|
||||||
|
Of the seven captured harnesses, Codex is the only one whose quota surface is
|
||||||
|
all four of: prose-documented upstream with a worked example
|
||||||
|
(`codex-rs/app-server/README.md`, "7) Rate limits (ChatGPT)"), pinned by a
|
||||||
|
checked-in JSON Schema, genuinely provider-originated, and readable without
|
||||||
|
Lumbridge touching a credential. `account/rateLimits/read` returns
|
||||||
|
`usedPercent`, `windowDurationMins`, and `resetsAt`; the app-server parses
|
||||||
|
those out of the backend's `x-codex-*-used-percent` response headers.
|
||||||
|
|
||||||
|
Lumbridge launches `codex app-server` and lets the harness resolve its own
|
||||||
|
authentication from `CODEX_HOME`. That satisfies the product spec's
|
||||||
|
"observes only documented status, usage, and protocol surfaces" without any of
|
||||||
|
the credential handling AGENTS.md forbids. Upstream is Apache-2.0; only the
|
||||||
|
observable wire contract is mirrored, and its source, commit, and license are
|
||||||
|
recorded in the parser and the tests.
|
||||||
|
|
||||||
|
## The provenance test for every future adapter
|
||||||
|
|
||||||
|
Ask whether the harness **computes** the value or **forwards** it.
|
||||||
|
|
||||||
|
- Forwards a provider's own number: `ProviderReported`.
|
||||||
|
- Computes, counts, or estimates it: `HarnessReported`, or `LocallyMeasured`
|
||||||
|
when Lumbridge did the counting.
|
||||||
|
|
||||||
|
Codex forwards, so its windows are provider-reported. The ACP `usage_update`
|
||||||
|
notification, by contrast, is a *context window* gauge, and at least one
|
||||||
|
captured harness fills it with an explicit local estimate. It must never be fed
|
||||||
|
through the same path: a context window is not a quota, and rendering one as
|
||||||
|
"62% used" would imply a subscription limit that does not exist.
|
||||||
|
|
||||||
|
## Rules this adapter establishes
|
||||||
|
|
||||||
|
- **Refusal is structural, not procedural.** The app-server can send its client
|
||||||
|
`account/chatgptAuthTokens/refresh`, which returns a bare access token. The
|
||||||
|
client has no free-form method string: outbound requests come from a
|
||||||
|
two-variant enum, and every server-to-client request is answered `-32601`
|
||||||
|
regardless of what it asks for. Asking for a credential is unrepresentable
|
||||||
|
rather than merely declined, and refusals are counted so the path is
|
||||||
|
auditable.
|
||||||
|
- **An account with no quota is a fact, not a fault.** Codex answers `-32600`
|
||||||
|
for an API-key user. That records an unavailable observation for both
|
||||||
|
windows. Any other rejection faults the probe.
|
||||||
|
- **Two windows are two profiles.** `primary` and `secondary` get separate,
|
||||||
|
account-free profile identifiers. Merging them would mean silently choosing
|
||||||
|
one. A profile ID is persisted, so it never carries an account identity.
|
||||||
|
- **A sparse push neither clears nor merges.** Upstream documents that nullable
|
||||||
|
values absent from a rolling update "do not clear a previously observed
|
||||||
|
value". An absent window on a notification therefore records nothing. It is
|
||||||
|
also not merged into the last full read: a merged composite would present
|
||||||
|
itself as provider-reported while being partly a memory.
|
||||||
|
- **Clock disagreement costs the window, not the number.** A window whose
|
||||||
|
computed start is after the observation is dropped while the provider's
|
||||||
|
percentage is kept. A reading taken at or after `resetsAt` is unusable
|
||||||
|
entirely, because 0012 requires an ended window to read as rolled over.
|
||||||
|
- **Identical readings are suppressed, with a floor.** The ledger retains a
|
||||||
|
bounded history per profile; re-recording an unchanged percentage every poll
|
||||||
|
would evict real history and destroy the burn baseline. An unchanged reading
|
||||||
|
is re-emitted only after five minutes, which keeps a rate derivable without
|
||||||
|
flooding.
|
||||||
|
- **Errors cannot carry text.** `HarnessError` is `Copy` and holds only static
|
||||||
|
discriminators, an `io::ErrorKind`, and numbers. A spawn failure deliberately
|
||||||
|
discards the underlying error so a filesystem path cannot travel into a log,
|
||||||
|
and a rejection keeps the JSON-RPC code while discarding the backend message.
|
||||||
|
- **The child is contained.** Cleared environment with a literal allowlist,
|
||||||
|
pipes rather than a PTY, stderr discarded, and a per-line byte cap. The child
|
||||||
|
leads its own process group and the group is what gets killed, because a pipe
|
||||||
|
reports end of file only when every write end closes: a launcher that execs
|
||||||
|
the real program as a grandchild with inherited stdio — which is how the npm
|
||||||
|
distribution of Codex works — would otherwise leave a reader blocked on a pipe
|
||||||
|
nothing will ever write to. Shutdown signals, kills the group, and joins only
|
||||||
|
the worker. The reader is never joined, so a descendant that escapes the group
|
||||||
|
can leak a parked thread but cannot hang the caller, which matters because
|
||||||
|
`Drop` calls shutdown.
|
||||||
|
|
||||||
|
## Verified against a live account
|
||||||
|
|
||||||
|
One reading was taken with the user's consent on 2026-08-31, against
|
||||||
|
`codex-cli 0.145.0` installed as a standalone native binary. It confirmed the
|
||||||
|
whole contract: `usedPercent` an integer, `resetsAt` Unix seconds, `secondary`
|
||||||
|
null, and no unmodelled fields inside the window object. The result body also
|
||||||
|
carried `rateLimitResetCredits`, `rateLimitsByLimitId`, `planType`, `limitId`,
|
||||||
|
`limitName`, `individualLimit`, `credits`, and `spendControlReached`, all
|
||||||
|
correctly ignored by the parser. Two unsolicited notifications arrived during
|
||||||
|
the exchange — `configWarning` and `remoteControl/status/changed` — which
|
||||||
|
exercised the ignore path with real traffic. No server-to-client request
|
||||||
|
arrived, so the refusal path remains proven only by test.
|
||||||
|
|
||||||
|
The live account's `windowDurationMins` was **10080**: a weekly window, not the
|
||||||
|
fifteen-minute example in the upstream README. That exposed a real defect no
|
||||||
|
fixture built from the documented example would have caught —
|
||||||
|
`format_duration_ms` rendered it as "151h 30m" — and days were added to the
|
||||||
|
formatter as a result. Fixtures agree with documentation; only a real account
|
||||||
|
disagrees with your assumptions.
|
||||||
|
|
||||||
|
## What is still not true
|
||||||
|
|
||||||
|
The whole test suite runs without the `codex` binary, a `ChatGPT` account, or
|
||||||
|
network access, and that property must be preserved: CI must never depend on an
|
||||||
|
installed harness. A single live reading verifies the shape of one account on
|
||||||
|
one plan; it does not exercise the `secondary` window, the `-32600` no-quota
|
||||||
|
path, or a window that rolls mid-read. Those remain covered by synthetic
|
||||||
|
transcripts only.
|
||||||
|
|
||||||
|
Wiring the probe into the running shell means spawning a process against the
|
||||||
|
user's own subscription account on every launch. Until that is wired, the shell
|
||||||
|
keeps rendering its declared fixture.
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# 0014: Claude Code usage comes from its transcripts, and reports spend without a ceiling
|
||||||
|
|
||||||
|
Status: accepted for token spend. **Corrected by decision 0015**: this record
|
||||||
|
originally claimed Claude Code exposes no quota window. That was wrong — it
|
||||||
|
does, through the status line — and the claim is struck below rather than
|
||||||
|
quietly edited out, because the reasoning that followed from it shaped the
|
||||||
|
adapter.
|
||||||
|
|
||||||
|
Decision 0013 established the provenance test for a usage adapter: does the
|
||||||
|
harness forward a provider's number, or record its own? Claude Code is the case
|
||||||
|
that makes the second half of that test matter.
|
||||||
|
|
||||||
|
## Correction
|
||||||
|
|
||||||
|
The original text asserted that "Claude Code publishes no quota API and no
|
||||||
|
remaining-balance command" and that a user asking how much they have left
|
||||||
|
"gets the honest answer — Lumbridge can show what was spent, and cannot show
|
||||||
|
what remains, because Claude Code does not say."
|
||||||
|
|
||||||
|
**Both statements are false.** Claude Code 2.1.80 and later pipe a
|
||||||
|
`rate_limits` object with `five_hour` and `seven_day` windows to the configured
|
||||||
|
`statusLine` command on every turn, and the CLI documents that shape itself.
|
||||||
|
There is also an account usage endpoint. The correct claim is narrower and is
|
||||||
|
the only one this record still makes: *the transcript* carries spend and not
|
||||||
|
quota. Decision 0015 covers the window surface.
|
||||||
|
|
||||||
|
## The surface
|
||||||
|
|
||||||
|
What Claude Code writes to disk per session is the API's `usage` object for
|
||||||
|
every assistant turn,
|
||||||
|
appended to a per-session JSONL transcript under
|
||||||
|
`$CLAUDE_CONFIG_DIR/projects` (default `~/.claude/projects`). Each assistant
|
||||||
|
record carries `input_tokens`, `output_tokens`,
|
||||||
|
`cache_creation_input_tokens`, and `cache_read_input_tokens`, alongside the
|
||||||
|
model that produced it.
|
||||||
|
|
||||||
|
Summing those is how this adapter reports spend. **A subscription's window,
|
||||||
|
ceiling, and reset are not in the transcript**, so this profile reports
|
||||||
|
consumption against no ceiling rather than implying a quota — but that is a
|
||||||
|
fact about this file, not about Claude Code, and the quota is read separately
|
||||||
|
under decision 0015.
|
||||||
|
|
||||||
|
The `statusLine` hook does require mutating the user's `settings.json`, which
|
||||||
|
is why the transcript came first: reading a file the harness already writes
|
||||||
|
needs no change to the user's configuration. That was a good reason to build
|
||||||
|
this adapter, and a bad reason to conclude the other surface did not exist.
|
||||||
|
|
||||||
|
## Why harness-reported
|
||||||
|
|
||||||
|
Claude Code forwards the provider's per-response counts faithfully, so a single
|
||||||
|
record's numbers originate with the provider. But a running total is the
|
||||||
|
*harness's* record — an append-only log Lumbridge is reading, not a provider's
|
||||||
|
statement of account. Under 0013's rule the value is therefore
|
||||||
|
`HarnessReported`, and the footer says `harness · approximate`.
|
||||||
|
|
||||||
|
## Content blindness is structural, not procedural
|
||||||
|
|
||||||
|
**These files contain the user's conversations.** The parser models exactly
|
||||||
|
four integers, a record type, and a model name. Every other field — message
|
||||||
|
text, tool inputs, file contents, `service_tier`, `inference_geo`,
|
||||||
|
`server_tool_use` — is discarded by serde during parsing, because there is no
|
||||||
|
field in the wire types that could hold it. A test asserts that no debug
|
||||||
|
rendering of a parsed record can contain message content.
|
||||||
|
|
||||||
|
That is the same discipline as the Codex probe's refusal path: the safe
|
||||||
|
behaviour is made unrepresentable rather than merely avoided.
|
||||||
|
|
||||||
|
## What "tokens" counts
|
||||||
|
|
||||||
|
The reported number is `input + output + cache_creation + cache_read`.
|
||||||
|
|
||||||
|
Cache reads are included because they are billed and do count against a
|
||||||
|
subscription window, even though they are re-reads of context already counted
|
||||||
|
once. Excluding them would understate consumption. Weighting them by price
|
||||||
|
would require a price list this adapter does not have, and would turn a counted
|
||||||
|
fact into an estimate. In practice cache reads dominate the total — on the
|
||||||
|
transcripts this was verified against they were roughly 98% of it — so the
|
||||||
|
derived burn rate is largely a measure of context re-read per turn rather than
|
||||||
|
of new work. The split is preserved in `TokenTally` for a detail view that can
|
||||||
|
say so.
|
||||||
|
|
||||||
|
## Priming before reporting
|
||||||
|
|
||||||
|
The probe is a tail follower: it remembers a byte offset per transcript and
|
||||||
|
reads only what was appended since, so its running total is monotonic by
|
||||||
|
construction. The ledger rejects an observation that moves a profile backwards,
|
||||||
|
so this is a correctness requirement, not an optimisation.
|
||||||
|
|
||||||
|
It also **reports nothing until it has read every existing transcript to its
|
||||||
|
end**. A partially-read backlog is indistinguishable from an enormous burst of
|
||||||
|
spend, and the ledger will derive a burn rate from it: the first run against a
|
||||||
|
20 MB backlog with an 8 MB per-poll budget reported **forty-six billion tokens
|
||||||
|
an hour**, which was catch-up, not usage. The first reading must be a true
|
||||||
|
baseline before any rate derived from it can mean anything. A shrinking file
|
||||||
|
resets its own offset rather than trusting a position into different content.
|
||||||
|
|
||||||
|
## Bounds
|
||||||
|
|
||||||
|
A scan walks at most six directory levels and 4096 files, never follows
|
||||||
|
symlinks out of the tree, reads at most 8 MB per poll, and refuses a single
|
||||||
|
record over 1 MB while still advancing past it. A transcript's last line is
|
||||||
|
routinely a partial record still being written; it is left unconsumed for the
|
||||||
|
next read rather than parsed or skipped.
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# 0015: Claude Code's subscription windows come from the status line, not the credential
|
||||||
|
|
||||||
|
Status: accepted; bridge installed and verified end to end.
|
||||||
|
|
||||||
|
Decision 0014 concluded that Claude Code cannot report a remaining balance.
|
||||||
|
That was wrong, and the error was one of not looking rather than of reasoning:
|
||||||
|
the transcript genuinely carries no quota, and that fact was generalised into a
|
||||||
|
claim about the harness. Claude Code reports its quota through three other
|
||||||
|
channels.
|
||||||
|
|
||||||
|
## The four surfaces
|
||||||
|
|
||||||
|
| Surface | Carries | Cost | Needs a credential |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Status line `rate_limits`** | `five_hour`, `seven_day` — `used_percentage` + `resets_at` | none; relayed from headers the CLI already received | no |
|
||||||
|
| Account usage endpoint | the same windows, plus per-model scoped limits | tight budget; 429s under polling | **yes** |
|
||||||
|
| Stream `rate_limit_info` | `status`, `rateLimitType`, `unifiedWindows` | none | no, but requires driving the harness |
|
||||||
|
| `/usage` in a PTY | rendered text | a turn | no, but screen-scraped |
|
||||||
|
|
||||||
|
The status-line shape is documented inside the CLI itself:
|
||||||
|
|
||||||
|
```text
|
||||||
|
"five_hour": { // present only while the API reports it and its resets_at has not passed
|
||||||
|
"used_percentage": number, // Percentage of limit used (0-100)
|
||||||
|
"resets_at": number // Unix epoch seconds when this window resets
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`rate_limits_available` is the honest-gap signal, also documented: "False when
|
||||||
|
plan rate limits do not apply (API key, Bedrock, Vertex, or missing profile
|
||||||
|
scope)". An account with no subscription window says so, rather than leaving
|
||||||
|
Lumbridge to infer it from an absence.
|
||||||
|
|
||||||
|
## Why the status line and not the endpoint
|
||||||
|
|
||||||
|
The account usage endpoint is more authoritative — it answers without an active
|
||||||
|
session and exposes per-model scoped limits. It also requires reading Claude
|
||||||
|
Code's OAuth credential, and AGENTS.md says Lumbridge "must not scrape their
|
||||||
|
private credentials." Other tools in this space do read it; that is their call
|
||||||
|
to make, and it is a real capability difference.
|
||||||
|
|
||||||
|
Lumbridge keeps the rule. The status line is pushed *to* a command the user
|
||||||
|
installs, so no credential is ever touched, and the data is free because the
|
||||||
|
CLI is relaying rate-limit headers it already received on its own API calls.
|
||||||
|
The cost is that a window only appears once a session has run, and that
|
||||||
|
installation edits `settings.json`.
|
||||||
|
|
||||||
|
The endpoint is recorded here as a rejected-for-now alternative rather than an
|
||||||
|
unconsidered one. Reopening it means amending AGENTS.md first.
|
||||||
|
|
||||||
|
## Provenance
|
||||||
|
|
||||||
|
`ProviderReported`. Under decision 0013's test the CLI forwards the provider's
|
||||||
|
number rather than computing one — the same standing as Codex's forwarded
|
||||||
|
`x-codex-*` headers. The footer shows `provider · exact`.
|
||||||
|
|
||||||
|
## The bridge
|
||||||
|
|
||||||
|
`scripts/claude-statusline-bridge.sh` is installed as Claude Code's
|
||||||
|
`statusLine` command. It appends the rate-limit fields — and only those — to
|
||||||
|
`$XDG_DATA_HOME/lumbridge/claude-rate-limits.jsonl`, and prints a status line.
|
||||||
|
|
||||||
|
Three properties it must hold:
|
||||||
|
|
||||||
|
- **It cannot fail.** A status-line command that errors or hangs degrades the
|
||||||
|
user's session, so every step is guarded and it always exits 0 having
|
||||||
|
printed something.
|
||||||
|
- **It copies nothing else.** The payload also carries the session's cost,
|
||||||
|
transcript path, working directory, and model. The bridge builds a fresh
|
||||||
|
record from three numeric fields per window rather than filtering the
|
||||||
|
original, so there is no path by which the rest travels.
|
||||||
|
- **It stays bounded.** The feed rotates past 256 KB. Lines are appended rather
|
||||||
|
than overwritten so concurrent sessions do not clobber each other; the probe
|
||||||
|
reads the newest line, because each line is a complete snapshot and an older
|
||||||
|
one is superseded, never summed.
|
||||||
|
|
||||||
|
Installation is a separate, explicit, reversible script — not something a probe
|
||||||
|
does on the user's behalf. It backs up `settings.json`, writes through a
|
||||||
|
temporary file, refuses to replace a `statusLine` someone else configured, and
|
||||||
|
supports `--dry-run` and `--uninstall`.
|
||||||
|
|
||||||
|
## Window handling
|
||||||
|
|
||||||
|
Each window becomes its own account-free profile — `claude-code-five-hour` and
|
||||||
|
`claude-code-seven-day` — because two windows are two facts about two quota
|
||||||
|
periods, and merging them means silently picking one. The pane maps to the
|
||||||
|
five-hour window: it is the limit that actually stops work.
|
||||||
|
|
||||||
|
A reading's window start is derived from `resets_at` minus the documented
|
||||||
|
window length, which is what lets the ledger scope a burn rate to one quota
|
||||||
|
period. Two rules follow decision 0012 unchanged: a window whose `resets_at`
|
||||||
|
has passed is stale, so its reset is dropped and only the percentage survives;
|
||||||
|
and a start the local clock places in the future costs the start, not the
|
||||||
|
reset, which is the half that bounds a forecast.
|
||||||
|
|
||||||
|
A percentage above 100 — possible on a gateway spend limit once breached — is
|
||||||
|
clamped to the whole rather than stored as more than a full window.
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""Extract Claude Code's rate-limit windows from a status-line payload.
|
||||||
|
|
||||||
|
Invoked by `claude-statusline-bridge.sh` with the feed path as argv[1] and the
|
||||||
|
status-line JSON on stdin. Appends only the rate-limit fields to the feed and
|
||||||
|
prints a status line.
|
||||||
|
|
||||||
|
Must never fail: a status-line command that errors degrades the user's Claude
|
||||||
|
Code session, so every step is guarded and this always exits 0 having printed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Keep the feed bounded. The probe only needs the newest line; older ones are
|
||||||
|
# superseded snapshots, not history worth keeping.
|
||||||
|
MAX_FEED_BYTES = 256 * 1024
|
||||||
|
WINDOW_NAMES = ("five_hour", "seven_day")
|
||||||
|
PERCENT_KEYS = ("used_percentage", "utilization", "resets_at")
|
||||||
|
|
||||||
|
|
||||||
|
def extract(payload):
|
||||||
|
"""Return the record to append: rate-limit fields and nothing else.
|
||||||
|
|
||||||
|
The payload also carries the session's cost, transcript path, working
|
||||||
|
directory, and model. None of that is Lumbridge's business, and none of it
|
||||||
|
is copied — this builds a fresh dict rather than filtering the original.
|
||||||
|
"""
|
||||||
|
record = {"rate_limits_available": payload.get("rate_limits_available")}
|
||||||
|
limits = payload.get("rate_limits")
|
||||||
|
if not isinstance(limits, dict):
|
||||||
|
return record
|
||||||
|
windows = {}
|
||||||
|
for name in WINDOW_NAMES:
|
||||||
|
window = limits.get(name)
|
||||||
|
if not isinstance(window, dict):
|
||||||
|
continue
|
||||||
|
kept = {
|
||||||
|
key: window[key]
|
||||||
|
for key in PERCENT_KEYS
|
||||||
|
if isinstance(window.get(key), (int, float))
|
||||||
|
and not isinstance(window.get(key), bool)
|
||||||
|
}
|
||||||
|
if kept:
|
||||||
|
windows[name] = kept
|
||||||
|
if windows:
|
||||||
|
record["rate_limits"] = windows
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
def status_line(record):
|
||||||
|
if record.get("rate_limits_available") is False:
|
||||||
|
return "lumbridge · no plan limits on this account"
|
||||||
|
windows = record.get("rate_limits")
|
||||||
|
if not windows:
|
||||||
|
return "lumbridge · no window reported yet"
|
||||||
|
parts = []
|
||||||
|
for name, label in (("five_hour", "5h"), ("seven_day", "7d")):
|
||||||
|
window = windows.get(name)
|
||||||
|
if not window:
|
||||||
|
continue
|
||||||
|
percent = window.get("used_percentage", window.get("utilization"))
|
||||||
|
if percent is None:
|
||||||
|
continue
|
||||||
|
parts.append(f"{label} {max(0.0, 100.0 - float(percent)):.0f}% left")
|
||||||
|
if not parts:
|
||||||
|
return "lumbridge · no window reported yet"
|
||||||
|
return "lumbridge · " + " · ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def append(feed, record):
|
||||||
|
if not feed:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
# Rotate rather than grow without bound. The probe resets its read
|
||||||
|
# offset when the file shrinks, so a rotation costs one reading.
|
||||||
|
if os.path.exists(feed) and os.path.getsize(feed) > MAX_FEED_BYTES:
|
||||||
|
open(feed, "w").close()
|
||||||
|
# Append so concurrent Claude Code sessions do not clobber each other;
|
||||||
|
# a short line written in append mode lands intact.
|
||||||
|
with open(feed, "a") as handle:
|
||||||
|
handle.write(json.dumps(record, separators=(",", ":")) + "\n")
|
||||||
|
except Exception:
|
||||||
|
pass # A feed we cannot write is not worth breaking a session over.
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
feed = sys.argv[1] if len(sys.argv) > 1 else ""
|
||||||
|
try:
|
||||||
|
payload = json.load(sys.stdin)
|
||||||
|
except Exception:
|
||||||
|
print("lumbridge · unreadable status payload")
|
||||||
|
return 0
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
print("lumbridge · unreadable status payload")
|
||||||
|
return 0
|
||||||
|
record = extract(payload)
|
||||||
|
append(feed, record)
|
||||||
|
print(status_line(record))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
sys.exit(main())
|
||||||
|
except Exception:
|
||||||
|
# Last resort: never let a status line take a session down.
|
||||||
|
print("lumbridge")
|
||||||
|
sys.exit(0)
|
||||||
Executable
+31
@@ -0,0 +1,31 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Lumbridge status-line bridge for Claude Code.
|
||||||
|
#
|
||||||
|
# Claude Code 2.1.80+ pipes a JSON payload to the configured `statusLine`
|
||||||
|
# command on every turn. That payload carries `rate_limits.five_hour` and
|
||||||
|
# `rate_limits.seven_day` — the real subscription windows, relayed from
|
||||||
|
# rate-limit headers the CLI already received. Reading them here costs nothing
|
||||||
|
# and needs no credential, which is why Lumbridge takes this route rather than
|
||||||
|
# calling the account usage endpoint.
|
||||||
|
#
|
||||||
|
# This wrapper exists so a missing python3 degrades to a printed message
|
||||||
|
# instead of a broken status line. It `exec`s the parser so stdin stays the
|
||||||
|
# payload.
|
||||||
|
#
|
||||||
|
# Install with scripts/install-claude-statusline.sh; uninstall by removing the
|
||||||
|
# statusLine block it adds to settings.json.
|
||||||
|
|
||||||
|
set -u
|
||||||
|
|
||||||
|
FEED="${LUMBRIDGE_CLAUDE_FEED:-${XDG_DATA_HOME:-$HOME/.local/share}/lumbridge/claude-rate-limits.jsonl}"
|
||||||
|
HERE=$(dirname "$0")
|
||||||
|
|
||||||
|
if ! command -v python3 >/dev/null 2>&1; then
|
||||||
|
# No parser available. Say so rather than printing a number we cannot read.
|
||||||
|
echo "lumbridge · python3 not found"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$FEED")" 2>/dev/null || true
|
||||||
|
|
||||||
|
exec python3 "$HERE/claude-statusline-bridge.py" "$FEED"
|
||||||
Executable
+113
@@ -0,0 +1,113 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Installs the Lumbridge status-line bridge into Claude Code's settings.
|
||||||
|
#
|
||||||
|
# This is the one thing Lumbridge cannot do without touching the user's
|
||||||
|
# configuration, so it is a separate, explicit, reversible step rather than
|
||||||
|
# something a probe does on your behalf.
|
||||||
|
#
|
||||||
|
# It sets `statusLine` in $CLAUDE_CONFIG_DIR/settings.json (default
|
||||||
|
# ~/.claude/settings.json) to run scripts/claude-statusline-bridge.sh. Claude
|
||||||
|
# Code then pipes its rate-limit payload there on every turn, which is how
|
||||||
|
# Lumbridge learns the five-hour and seven-day subscription windows without
|
||||||
|
# ever reading a credential.
|
||||||
|
#
|
||||||
|
# install: scripts/install-claude-statusline.sh
|
||||||
|
# uninstall: scripts/install-claude-statusline.sh --uninstall
|
||||||
|
# preview: scripts/install-claude-statusline.sh --dry-run
|
||||||
|
#
|
||||||
|
# An existing statusLine is backed up and reported, never silently replaced.
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
MODE=install
|
||||||
|
case "${1:-}" in
|
||||||
|
--uninstall) MODE=uninstall ;;
|
||||||
|
--dry-run) MODE=dry-run ;;
|
||||||
|
"") ;;
|
||||||
|
*) echo "usage: $0 [--uninstall|--dry-run]" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CONFIG_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
|
||||||
|
SETTINGS="$CONFIG_DIR/settings.json"
|
||||||
|
HERE=$(cd "$(dirname "$0")" && pwd)
|
||||||
|
BRIDGE="$HERE/claude-statusline-bridge.sh"
|
||||||
|
|
||||||
|
if [ ! -x "$BRIDGE" ]; then
|
||||||
|
echo "bridge not executable: $BRIDGE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! command -v python3 >/dev/null 2>&1; then
|
||||||
|
echo "python3 is required to edit settings.json safely" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
python3 - "$SETTINGS" "$BRIDGE" "$MODE" <<'PYTHON'
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
settings_path, bridge, mode = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||||
|
desired = {"type": "command", "command": bridge}
|
||||||
|
|
||||||
|
settings = {}
|
||||||
|
if os.path.exists(settings_path):
|
||||||
|
try:
|
||||||
|
with open(settings_path) as handle:
|
||||||
|
settings = json.load(handle)
|
||||||
|
except Exception as error:
|
||||||
|
print(f"refusing to edit unreadable settings: {error}", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
if not isinstance(settings, dict):
|
||||||
|
print("refusing to edit: settings.json is not an object", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
current = settings.get("statusLine")
|
||||||
|
|
||||||
|
if mode == "uninstall":
|
||||||
|
if isinstance(current, dict) and current.get("command") == bridge:
|
||||||
|
settings.pop("statusLine", None)
|
||||||
|
action = "removed the Lumbridge status line"
|
||||||
|
else:
|
||||||
|
print("no Lumbridge status line installed; nothing to do")
|
||||||
|
raise SystemExit(0)
|
||||||
|
elif isinstance(current, dict) and current.get("command") == bridge:
|
||||||
|
print("Lumbridge status line already installed; nothing to do")
|
||||||
|
raise SystemExit(0)
|
||||||
|
else:
|
||||||
|
if current is not None:
|
||||||
|
# Someone else's status line is here. Report it and stop rather than
|
||||||
|
# replacing a thing the user set up.
|
||||||
|
print("a different statusLine is already configured:", file=sys.stderr)
|
||||||
|
print(f" {json.dumps(current)}", file=sys.stderr)
|
||||||
|
print(
|
||||||
|
"remove it first, or merge the bridge into it by hand:\n"
|
||||||
|
f" {bridge}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
raise SystemExit(1)
|
||||||
|
settings["statusLine"] = desired
|
||||||
|
action = "installed the Lumbridge status line"
|
||||||
|
|
||||||
|
if mode == "dry-run":
|
||||||
|
print("would write:")
|
||||||
|
print(json.dumps({"statusLine": settings.get("statusLine")}, indent=2))
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(settings_path), exist_ok=True)
|
||||||
|
if os.path.exists(settings_path):
|
||||||
|
backup = f"{settings_path}.lumbridge-backup-{int(time.time())}"
|
||||||
|
shutil.copy2(settings_path, backup)
|
||||||
|
print(f"backed up existing settings to {backup}")
|
||||||
|
|
||||||
|
# Write via a temporary file so an interrupted run cannot leave settings.json
|
||||||
|
# truncated — this file configures the user's editor session.
|
||||||
|
temporary = f"{settings_path}.lumbridge-tmp"
|
||||||
|
with open(temporary, "w") as handle:
|
||||||
|
json.dump(settings, handle, indent=2)
|
||||||
|
handle.write("\n")
|
||||||
|
os.replace(temporary, settings_path)
|
||||||
|
print(f"{action} in {settings_path}")
|
||||||
|
print("restart or start a Claude Code session for it to take effect")
|
||||||
|
PYTHON
|
||||||
@@ -44,11 +44,31 @@ if [[ ! -x "$binary_path" ]]; then
|
|||||||
cargo build --locked --manifest-path "$manifest_path"
|
cargo build --locked --manifest-path "$manifest_path"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
existing_id=$(wmctrl -l | awk -v title="$window_title" 'index($0, title) { print $1; exit }')
|
# GPUI's X11 client sets WM_NAME but not _NET_WM_NAME, so wmctrl's window list
|
||||||
|
# reports its title as N/A and a title match never succeeds. xdotool reads
|
||||||
|
# WM_NAME, and matching the launched PID avoids picking up another client that
|
||||||
|
# merely mentions the window title.
|
||||||
|
window_for_pid() {
|
||||||
|
local pid=$1 candidate
|
||||||
|
for candidate in $(xdotool search --name 'GPUI workspace' 2>/dev/null); do
|
||||||
|
if [[ "$(xdotool getwindowpid "$candidate" 2>/dev/null || true)" == "$pid" ]]; then
|
||||||
|
printf '%s\n' "$candidate"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
existing_id=''
|
||||||
|
app_pid=$(pgrep -f "^$binary_path$" | head -n 1 || true)
|
||||||
|
if [[ -n "$app_pid" ]]; then
|
||||||
|
existing_id=$(window_for_pid "$app_pid" || true)
|
||||||
|
fi
|
||||||
if [[ -z "$existing_id" ]]; then
|
if [[ -z "$existing_id" ]]; then
|
||||||
"$binary_path" >"$XDG_RUNTIME_DIR/lumbridge-gpui.log" 2>&1 &
|
"$binary_path" >"$XDG_RUNTIME_DIR/lumbridge-gpui.log" 2>&1 &
|
||||||
|
app_pid=$!
|
||||||
for _attempt in $(seq 1 80); do
|
for _attempt in $(seq 1 80); do
|
||||||
existing_id=$(wmctrl -l | awk -v title="$window_title" 'index($0, title) { print $1; exit }')
|
existing_id=$(window_for_pid "$app_pid" || true)
|
||||||
[[ -n "$existing_id" ]] && break
|
[[ -n "$existing_id" ]] && break
|
||||||
sleep 0.1
|
sleep 0.1
|
||||||
done
|
done
|
||||||
@@ -59,23 +79,43 @@ if [[ -z "$existing_id" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Gigabyte G34WQC is 3440x1440 at X=0. Lumbridge owns its left half; Bacon
|
# Gigabyte G34WQC is 3440x1440 at X=0. Lumbridge owns the whole panel: the
|
||||||
# remains visible beside it on the right half of the same monitor.
|
# workspace is a wall of agents and every pixel of width buys another column.
|
||||||
bacon_id=$(wmctrl -l | awk -v title="$bacon_title" 'index($0, title) { print $1; exit }')
|
# Set LUMBRIDGE_HALF=1 to fall back to the left half beside Bacon.
|
||||||
if [[ -n "$bacon_id" ]]; then
|
if [[ "${LUMBRIDGE_HALF:-0}" == "1" ]]; then
|
||||||
wmctrl -i -r "$bacon_id" -b remove,maximized_vert,maximized_horz
|
bacon_id=$(wmctrl -l | awk -v title="$bacon_title" 'index($0, title) { print $1; exit }')
|
||||||
# GNOME's X11 move coordinate is scaled on this display even though window
|
if [[ -n "$bacon_id" ]]; then
|
||||||
# sizes are not; 860 places the frame at physical X=1720.
|
wmctrl -i -r "$bacon_id" -b remove,maximized_vert,maximized_horz
|
||||||
wmctrl -i -r "$bacon_id" -e 0,860,0,1720,1400
|
# GNOME's X11 move coordinate is scaled on this display even though window
|
||||||
|
# sizes are not; 860 places the frame at physical X=1720.
|
||||||
|
wmctrl -i -r "$bacon_id" -e 0,860,0,1720,1400
|
||||||
|
fi
|
||||||
|
wmctrl -i -r "$existing_id" -b remove,fullscreen
|
||||||
|
wmctrl -i -r "$existing_id" -b remove,maximized_vert,maximized_horz
|
||||||
|
wmctrl -i -r "$existing_id" -e 0,0,0,1720,1400
|
||||||
|
wmctrl -i -a "$existing_id"
|
||||||
|
# GPUI's undecorated X11 client ignores direct position requests under GNOME.
|
||||||
|
# Use the window manager's move interaction to settle it into the left half.
|
||||||
|
xdotool key alt+F7
|
||||||
|
sleep 0.15
|
||||||
|
xdotool mousemove 860 660 click 1
|
||||||
|
echo "Lumbridge is on the left half of the Gigabyte display (window $existing_id)."
|
||||||
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Fullscreen drops the title bar and the shell owns all 3440x1440. Move it onto
|
||||||
|
# the Gigabyte first: a fullscreen request applies to whichever output the
|
||||||
|
# window is currently on, so placing it after would fullscreen the wrong panel.
|
||||||
|
wmctrl -i -r "$existing_id" -b remove,fullscreen
|
||||||
wmctrl -i -r "$existing_id" -b remove,maximized_vert,maximized_horz
|
wmctrl -i -r "$existing_id" -b remove,maximized_vert,maximized_horz
|
||||||
wmctrl -i -r "$existing_id" -e 0,0,0,1720,1400
|
wmctrl -i -r "$existing_id" -e 0,0,0,1720,1400
|
||||||
wmctrl -i -a "$existing_id"
|
wmctrl -i -a "$existing_id"
|
||||||
# GPUI's undecorated X11 client ignores direct position requests under GNOME.
|
|
||||||
# Use the window manager's move interaction to settle it into the left half.
|
|
||||||
xdotool key alt+F7
|
xdotool key alt+F7
|
||||||
sleep 0.15
|
sleep 0.15
|
||||||
xdotool mousemove 860 660 click 1
|
xdotool mousemove 860 660 click 1
|
||||||
|
sleep 0.2
|
||||||
|
wmctrl -i -r "$existing_id" -b add,fullscreen
|
||||||
|
sleep 0.3
|
||||||
|
|
||||||
echo "Lumbridge is visible in window $existing_id on the left half of the Gigabyte display."
|
geometry=$(xdotool getwindowgeometry "$existing_id" | awk '/Geometry/ { print $2 }')
|
||||||
|
echo "Lumbridge is fullscreen on the Gigabyte display at ${geometry} (window $existing_id)."
|
||||||
|
|||||||
Generated
+13
@@ -3043,6 +3043,17 @@ dependencies = [
|
|||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lumbridge-harness"
|
||||||
|
version = "0.0.1"
|
||||||
|
dependencies = [
|
||||||
|
"lumbridge-core",
|
||||||
|
"nix 0.28.0",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.20",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lumbridge-pty"
|
name = "lumbridge-pty"
|
||||||
version = "0.0.1"
|
version = "0.0.1"
|
||||||
@@ -3065,6 +3076,8 @@ name = "lumbridge-spike-gpui"
|
|||||||
version = "0.0.1"
|
version = "0.0.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gpui",
|
"gpui",
|
||||||
|
"lumbridge-core",
|
||||||
|
"lumbridge-harness",
|
||||||
"lumbridge-runtime",
|
"lumbridge-runtime",
|
||||||
"lumbridge-spike-model",
|
"lumbridge-spike-model",
|
||||||
"lumbridge-storage",
|
"lumbridge-storage",
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ publish = false
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
gpui = "0.2.2"
|
gpui = "0.2.2"
|
||||||
|
lumbridge-core = { path = "../../crates/lumbridge-core" }
|
||||||
|
lumbridge-harness = { path = "../../crates/lumbridge-harness" }
|
||||||
lumbridge-runtime = { path = "../../crates/lumbridge-runtime" }
|
lumbridge-runtime = { path = "../../crates/lumbridge-runtime" }
|
||||||
lumbridge-spike-model = { path = "../ui-shell-model" }
|
lumbridge-spike-model = { path = "../ui-shell-model" }
|
||||||
lumbridge-storage = { path = "../../crates/lumbridge-storage" }
|
lumbridge-storage = { path = "../../crates/lumbridge-storage" }
|
||||||
|
|||||||
+559
-101
@@ -1,4 +1,5 @@
|
|||||||
mod panel_registry;
|
mod panel_registry;
|
||||||
|
mod usage_feed;
|
||||||
|
|
||||||
use std::collections::{BTreeMap, VecDeque};
|
use std::collections::{BTreeMap, VecDeque};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
@@ -8,13 +9,14 @@ use gpui::{
|
|||||||
App, Application, Bounds, Context, FocusHandle, FontWeight, KeyBinding, KeyDownEvent, Pixels,
|
App, Application, Bounds, Context, FocusHandle, FontWeight, KeyBinding, KeyDownEvent, Pixels,
|
||||||
Size, Window, WindowBounds, WindowOptions, actions, div, prelude::*, px, relative, rgb, size,
|
Size, Window, WindowBounds, WindowOptions, actions, div, prelude::*, px, relative, rgb, size,
|
||||||
};
|
};
|
||||||
|
use lumbridge_core::UsageProvenance;
|
||||||
use lumbridge_runtime::{
|
use lumbridge_runtime::{
|
||||||
CommandConfig, PtyOptions, RuntimeActorError, RuntimeActorOptions, RuntimeCommand,
|
CommandConfig, PtyOptions, RuntimeActorError, RuntimeActorOptions, RuntimeCommand,
|
||||||
RuntimeEvent, RuntimeRegistry, RuntimeRegistryError, TerminalSize,
|
RuntimeEvent, RuntimeRegistry, RuntimeRegistryError, TerminalSize,
|
||||||
};
|
};
|
||||||
use lumbridge_spike_model::{
|
use lumbridge_spike_model::{
|
||||||
ActionOutcome, FOOTER_RIGHT, OutputSource, PaneId as FixturePaneId, PaneStatus, ShellAction,
|
ActionOutcome, OutputSource, PaneId as FixturePaneId, PaneStatus, ShellAction, ShellModel,
|
||||||
ShellModel, SurfaceKind, WORKSPACES,
|
SurfaceKind, WORKSPACES,
|
||||||
};
|
};
|
||||||
use lumbridge_storage::Store;
|
use lumbridge_storage::Store;
|
||||||
use lumbridge_terminal::{
|
use lumbridge_terminal::{
|
||||||
@@ -24,6 +26,7 @@ use lumbridge_terminal::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use panel_registry::{PanelId, PanelKind, PanelRegistry, SeedPane};
|
use panel_registry::{PanelId, PanelKind, PanelRegistry, SeedPane};
|
||||||
|
use usage_feed::{UsageFeed, UsageSegment};
|
||||||
|
|
||||||
const BG: u32 = 0x090c12;
|
const BG: u32 = 0x090c12;
|
||||||
const PANEL: u32 = 0x101620;
|
const PANEL: u32 = 0x101620;
|
||||||
@@ -43,7 +46,7 @@ const WORKSPACE_SNAPSHOT_ID: &str = "lumbridge-code-gpui-spike";
|
|||||||
const SIDEBAR_WIDTH: f32 = 248.0;
|
const SIDEBAR_WIDTH: f32 = 248.0;
|
||||||
const APP_HEADER_HEIGHT: f32 = 48.0;
|
const APP_HEADER_HEIGHT: f32 = 48.0;
|
||||||
const TAB_BAR_HEIGHT: f32 = 38.0;
|
const TAB_BAR_HEIGHT: f32 = 38.0;
|
||||||
const APP_FOOTER_HEIGHT: f32 = 32.0;
|
const APP_FOOTER_HEIGHT: f32 = 46.0;
|
||||||
const TERMINAL_CONTENT_VERTICAL_INSET: f32 = 24.0;
|
const TERMINAL_CONTENT_VERTICAL_INSET: f32 = 24.0;
|
||||||
const TERMINAL_HORIZONTAL_INSET: f32 = 32.0;
|
const TERMINAL_HORIZONTAL_INSET: f32 = 32.0;
|
||||||
const TERMINAL_CELL_WIDTH: f32 = 8.4;
|
const TERMINAL_CELL_WIDTH: f32 = 8.4;
|
||||||
@@ -84,10 +87,90 @@ struct LumbridgeShell {
|
|||||||
live_terminals: BTreeMap<PanelId, LiveTerminalState>,
|
live_terminals: BTreeMap<PanelId, LiveTerminalState>,
|
||||||
store: Option<Store>,
|
store: Option<Store>,
|
||||||
persistence_status: String,
|
persistence_status: String,
|
||||||
|
usage: UsageFeed,
|
||||||
|
/// Which surface tab each panel is showing. A panel is absent until the
|
||||||
|
/// user picks something other than the surface it provides natively.
|
||||||
|
surfaces: BTreeMap<PanelId, SurfaceTab>,
|
||||||
|
/// Which decision-shelf choice each panel has selected. Selecting one is
|
||||||
|
/// inert by design: it prepares nothing and runs nothing.
|
||||||
|
shelf_choice: BTreeMap<PanelId, usize>,
|
||||||
|
active_worktree: usize,
|
||||||
add_panel_chooser_open: bool,
|
add_panel_chooser_open: bool,
|
||||||
root_focus: FocusHandle,
|
root_focus: FocusHandle,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The surfaces a pane can be viewed through.
|
||||||
|
///
|
||||||
|
/// A pane is the durable unit of work and its surface is a view over that work.
|
||||||
|
/// Switching one never launches a process, moves the pane, or changes which
|
||||||
|
/// agent owns the session — it only changes what is drawn.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
enum SurfaceTab {
|
||||||
|
Terminal,
|
||||||
|
Browser,
|
||||||
|
Tools,
|
||||||
|
Context,
|
||||||
|
Goal,
|
||||||
|
Review,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SurfaceTab {
|
||||||
|
const ALL: [Self; 6] = [
|
||||||
|
Self::Terminal,
|
||||||
|
Self::Browser,
|
||||||
|
Self::Tools,
|
||||||
|
Self::Context,
|
||||||
|
Self::Goal,
|
||||||
|
Self::Review,
|
||||||
|
];
|
||||||
|
|
||||||
|
const fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Terminal => "TERMINAL",
|
||||||
|
Self::Browser => "BROWSER",
|
||||||
|
Self::Tools => "TOOLS",
|
||||||
|
Self::Context => "CONTEXT",
|
||||||
|
Self::Goal => "GOAL",
|
||||||
|
Self::Review => "REVIEW",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn ordinal(self) -> u64 {
|
||||||
|
match self {
|
||||||
|
Self::Terminal => 0,
|
||||||
|
Self::Browser => 1,
|
||||||
|
Self::Tools => 2,
|
||||||
|
Self::Context => 3,
|
||||||
|
Self::Goal => 4,
|
||||||
|
Self::Review => 5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The one surface a pane of this kind actually provides.
|
||||||
|
const fn native_for(kind: SurfaceKind) -> Self {
|
||||||
|
match kind {
|
||||||
|
SurfaceKind::Terminal => Self::Terminal,
|
||||||
|
SurfaceKind::Browser => Self::Browser,
|
||||||
|
SurfaceKind::Markdown => Self::Context,
|
||||||
|
SurfaceKind::Review => Self::Review,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why this pane cannot show this surface. Stated plainly, because the
|
||||||
|
/// product boundary says an unsupported surface is shown as unavailable
|
||||||
|
/// rather than simulated.
|
||||||
|
const fn unavailable_reason(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Terminal => "Only a terminal pane owns a live shell.",
|
||||||
|
Self::Browser => "No isolated web engine is wired yet.",
|
||||||
|
Self::Tools => "Tool calls arrive with the ACP client.",
|
||||||
|
Self::Context => "Context projection is not built yet.",
|
||||||
|
Self::Goal => "Goal tracking is not built yet.",
|
||||||
|
Self::Review => "Review is not wired to Git yet.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct PanelView {
|
struct PanelView {
|
||||||
id: PanelId,
|
id: PanelId,
|
||||||
@@ -488,6 +571,8 @@ impl LumbridgeShell {
|
|||||||
if this
|
if this
|
||||||
.update(cx, |shell, cx| {
|
.update(cx, |shell, cx| {
|
||||||
shell.dispatch(ShellAction::SyntheticStreamTick);
|
shell.dispatch(ShellAction::SyntheticStreamTick);
|
||||||
|
shell.usage.advance();
|
||||||
|
shell.usage.tick();
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})
|
})
|
||||||
.is_err()
|
.is_err()
|
||||||
@@ -554,6 +639,10 @@ impl LumbridgeShell {
|
|||||||
live_terminals,
|
live_terminals,
|
||||||
store,
|
store,
|
||||||
persistence_status,
|
persistence_status,
|
||||||
|
usage: UsageFeed::start(),
|
||||||
|
surfaces: BTreeMap::new(),
|
||||||
|
shelf_choice: BTreeMap::new(),
|
||||||
|
active_worktree: 0,
|
||||||
add_panel_chooser_open: false,
|
add_panel_chooser_open: false,
|
||||||
root_focus,
|
root_focus,
|
||||||
}
|
}
|
||||||
@@ -778,6 +867,54 @@ impl LumbridgeShell {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Changes which surface a pane is viewed through.
|
||||||
|
///
|
||||||
|
/// This is a view change only. It never touches the PTY, the agent, or the
|
||||||
|
/// pane's execution target, so a pane keeps running whatever it was running.
|
||||||
|
fn select_surface(
|
||||||
|
&mut self,
|
||||||
|
pane: PanelId,
|
||||||
|
tab: SurfaceTab,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
self.timing.mark_dispatch();
|
||||||
|
self.surfaces.insert(pane, tab);
|
||||||
|
if self.panels.select(pane) {
|
||||||
|
self.persist_panels();
|
||||||
|
}
|
||||||
|
window.focus(&self.root_focus);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records which suggestion the user picked. Deliberately does nothing else.
|
||||||
|
fn select_shelf_choice(
|
||||||
|
&mut self,
|
||||||
|
pane: PanelId,
|
||||||
|
index: usize,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
self.timing.mark_dispatch();
|
||||||
|
if self.shelf_choice.get(&pane) == Some(&index) {
|
||||||
|
self.shelf_choice.remove(&pane);
|
||||||
|
} else {
|
||||||
|
self.shelf_choice.insert(pane, index);
|
||||||
|
}
|
||||||
|
if self.panels.select(pane) {
|
||||||
|
self.persist_panels();
|
||||||
|
}
|
||||||
|
window.focus(&self.root_focus);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_worktree(&mut self, index: usize, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
self.timing.mark_dispatch();
|
||||||
|
self.active_worktree = index.min(WORKSPACES.len().saturating_sub(1));
|
||||||
|
window.focus(&self.root_focus);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
fn select_pane_at(&mut self, index: usize, window: &mut Window, cx: &mut Context<Self>) {
|
fn select_pane_at(&mut self, index: usize, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
self.timing.mark_dispatch();
|
self.timing.mark_dispatch();
|
||||||
if self.panels.select_attached_at(index) {
|
if self.panels.select_attached_at(index) {
|
||||||
@@ -1215,26 +1352,38 @@ impl LumbridgeShell {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let surface = match pane.kind {
|
let native = SurfaceTab::native_for(pane.kind);
|
||||||
SurfaceKind::Terminal => "TERMINAL",
|
let shown = self.surface_for(pane.id, pane.kind);
|
||||||
SurfaceKind::Markdown => "CONTEXT",
|
let tabs = SurfaceTab::ALL
|
||||||
SurfaceKind::Browser => "BROWSER",
|
|
||||||
SurfaceKind::Review => "REVIEW",
|
|
||||||
};
|
|
||||||
let tabs = ["TERMINAL", "BROWSER", "TOOLS", "CONTEXT", "GOAL", "REVIEW"]
|
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|label| {
|
.map(|tab| {
|
||||||
|
let active = tab == shown;
|
||||||
|
let provided = tab == native;
|
||||||
div()
|
div()
|
||||||
|
.id(("surface-tab", pane_id.get() * 16 + tab.ordinal()))
|
||||||
|
.cursor_pointer()
|
||||||
.h_full()
|
.h_full()
|
||||||
.flex()
|
.flex()
|
||||||
.items_center()
|
.items_center()
|
||||||
.px_3()
|
.px_3()
|
||||||
.text_xs()
|
.text_xs()
|
||||||
.text_color(rgb(if label == surface { ACCENT } else { MUTED }))
|
// Provided but unselected reads as available; unprovided
|
||||||
.when(label == surface, |view| {
|
// stays dim, so the tab strip shows what this pane can do
|
||||||
view.border_b_2().border_color(rgb(ACCENT))
|
// before you click rather than after.
|
||||||
})
|
.text_color(rgb(if active {
|
||||||
.child(label)
|
ACCENT
|
||||||
|
} else if provided {
|
||||||
|
TEXT
|
||||||
|
} else {
|
||||||
|
MUTED
|
||||||
|
}))
|
||||||
|
.when(active, |view| view.border_b_2().border_color(rgb(ACCENT)))
|
||||||
|
.hover(|view| view.bg(rgb(PANEL_ACTIVE)).text_color(rgb(TEXT)))
|
||||||
|
.child(tab.label())
|
||||||
|
.on_click(cx.listener(move |shell, _, window, cx| {
|
||||||
|
cx.stop_propagation();
|
||||||
|
shell.select_surface(pane_id, tab, window, cx);
|
||||||
|
}))
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
@@ -1304,6 +1453,12 @@ impl LumbridgeShell {
|
|||||||
.border_color(rgb(BORDER))
|
.border_color(rgb(BORDER))
|
||||||
.text_xs()
|
.text_xs()
|
||||||
.text_color(rgb(if can_detach { MUTED } else { BORDER }))
|
.text_color(rgb(if can_detach { MUTED } else { BORDER }))
|
||||||
|
.when(can_detach, |view| {
|
||||||
|
view.hover(|view| {
|
||||||
|
view.border_color(rgb(ATTENTION))
|
||||||
|
.text_color(rgb(ATTENTION))
|
||||||
|
})
|
||||||
|
})
|
||||||
.child(if can_detach {
|
.child(if can_detach {
|
||||||
"− DETACH"
|
"− DETACH"
|
||||||
} else {
|
} else {
|
||||||
@@ -1347,7 +1502,67 @@ impl LumbridgeShell {
|
|||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The surface a panel is currently being viewed through.
|
||||||
|
fn surface_for(&self, id: PanelId, kind: SurfaceKind) -> SurfaceTab {
|
||||||
|
self.surfaces
|
||||||
|
.get(&id)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or_else(|| SurfaceTab::native_for(kind))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a pane shows when asked for a surface it does not provide.
|
||||||
|
///
|
||||||
|
/// It says so, and it says what is still running underneath, because the
|
||||||
|
/// pane's process identity does not change when the view does.
|
||||||
|
fn unavailable_surface(&self, pane: &PanelView, shown: SurfaceTab) -> gpui::AnyElement {
|
||||||
|
let native = SurfaceTab::native_for(pane.kind);
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.size_full()
|
||||||
|
.min_w_0()
|
||||||
|
.min_h_0()
|
||||||
|
.overflow_hidden()
|
||||||
|
.bg(rgb(BG))
|
||||||
|
.border_y_1()
|
||||||
|
.border_color(rgb(BORDER))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap_2()
|
||||||
|
.p_4()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_sm()
|
||||||
|
.text_color(rgb(TEXT))
|
||||||
|
.child(format!("{} unavailable", shown.label())),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(rgb(MUTED))
|
||||||
|
.child(shown.unavailable_reason()),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.mt_2()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(rgb(SUCCESS))
|
||||||
|
.child(format!(
|
||||||
|
"{} is still this pane's live surface. Nothing was stopped.",
|
||||||
|
native.label()
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
fn pane_work_surface(&self, pane: &PanelView) -> gpui::AnyElement {
|
fn pane_work_surface(&self, pane: &PanelView) -> gpui::AnyElement {
|
||||||
|
let shown = self.surface_for(pane.id, pane.kind);
|
||||||
|
if shown != SurfaceTab::native_for(pane.kind) {
|
||||||
|
return self.unavailable_surface(pane, shown);
|
||||||
|
}
|
||||||
let external = pane.output_source == OutputSource::External;
|
let external = pane.output_source == OutputSource::External;
|
||||||
let content = if external {
|
let content = if external {
|
||||||
self.terminal_view(pane.id)
|
self.terminal_view(pane.id)
|
||||||
@@ -1387,35 +1602,22 @@ impl LumbridgeShell {
|
|||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn pane_decision_region(&self, pane: &PanelView) -> gpui::AnyElement {
|
fn pane_decision_region(&self, pane: &PanelView, cx: &mut Context<Self>) -> gpui::AnyElement {
|
||||||
let choice = |label: &'static str, detail: &'static str, attention: bool| {
|
let pane_id = pane.id;
|
||||||
div()
|
let picked = self.shelf_choice.get(&pane_id).copied();
|
||||||
.flex()
|
// Every choice names the capability it would need. None of them holds
|
||||||
.items_center()
|
// that capability: choosing prepares, the command plane executes.
|
||||||
.justify_between()
|
let choices: [(&'static str, &'static str, &'static str, bool); 3] = if pane.needs_input() {
|
||||||
.min_w_0()
|
|
||||||
.px_2()
|
|
||||||
.py_1()
|
|
||||||
.rounded(px(4.0))
|
|
||||||
.border_1()
|
|
||||||
.border_color(rgb(if attention { ATTENTION } else { BORDER }))
|
|
||||||
.bg(rgb(PANEL_ALT))
|
|
||||||
.text_xs()
|
|
||||||
.text_color(rgb(TEXT))
|
|
||||||
.child(label)
|
|
||||||
.child(div().truncate().text_color(rgb(MUTED)).child(detail))
|
|
||||||
};
|
|
||||||
let choices = if pane.needs_input() {
|
|
||||||
[
|
[
|
||||||
("Review request", "inspect scope", true),
|
("Review request", "inspect scope", "needs Observe", true),
|
||||||
("Steer…", "edit response", false),
|
("Steer…", "edit response", "needs Execute", false),
|
||||||
("Dismiss", "leave inert", false),
|
("Dismiss", "leave inert", "inert", false),
|
||||||
]
|
]
|
||||||
} else {
|
} else {
|
||||||
[
|
[
|
||||||
("Continue", "keep moving", false),
|
("Continue", "keep moving", "needs Execute", false),
|
||||||
("Review plan", "inspect commands", false),
|
("Review plan", "inspect commands", "needs Observe", false),
|
||||||
("Ask…", "refine prompt", false),
|
("Ask…", "refine prompt", "inert", false),
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
div()
|
div()
|
||||||
@@ -1447,8 +1649,55 @@ impl LumbridgeShell {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.child(div().flex().flex_col().gap_1().mt_2().children(
|
.child(div().flex().flex_col().gap_1().mt_2().children(
|
||||||
choices.map(|(label, detail, attention)| choice(label, detail, attention)),
|
choices.into_iter().enumerate().map(
|
||||||
|
|(index, (label, detail, _capability, attention))| {
|
||||||
|
let chosen = picked == Some(index);
|
||||||
|
div()
|
||||||
|
.id(("shelf-choice", pane_id.get() * 8 + index as u64))
|
||||||
|
.cursor_pointer()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.justify_between()
|
||||||
|
.min_w_0()
|
||||||
|
.px_2()
|
||||||
|
.py_1()
|
||||||
|
.rounded(px(4.0))
|
||||||
|
.border_1()
|
||||||
|
.border_color(rgb(if chosen {
|
||||||
|
ACCENT
|
||||||
|
} else if attention {
|
||||||
|
ATTENTION
|
||||||
|
} else {
|
||||||
|
BORDER
|
||||||
|
}))
|
||||||
|
.bg(rgb(if chosen { PANEL_ACTIVE } else { PANEL_ALT }))
|
||||||
|
.hover(|view| view.border_color(rgb(ACCENT)))
|
||||||
|
.text_xs()
|
||||||
|
.text_color(rgb(TEXT))
|
||||||
|
.child(label)
|
||||||
|
.child(div().truncate().text_color(rgb(MUTED)).child(detail))
|
||||||
|
.on_click(cx.listener(move |shell, _, window, cx| {
|
||||||
|
cx.stop_propagation();
|
||||||
|
shell.select_shelf_choice(pane_id, index, window, cx);
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
),
|
||||||
))
|
))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.mt_2()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(rgb(if picked.is_some() { ACCENT } else { MUTED }))
|
||||||
|
.child(picked.map_or_else(
|
||||||
|
|| "Pick one to see what it would need".to_owned(),
|
||||||
|
|index| {
|
||||||
|
format!(
|
||||||
|
"{} · {} · nothing has run",
|
||||||
|
choices[index].0, choices[index].2
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
)
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1461,6 +1710,7 @@ impl LumbridgeShell {
|
|||||||
let pane_id = pane.id;
|
let pane_id = pane.id;
|
||||||
div()
|
div()
|
||||||
.id(("workspace-panel", pane_id.get()))
|
.id(("workspace-panel", pane_id.get()))
|
||||||
|
.cursor_pointer()
|
||||||
.on_click(cx.listener(move |shell, _, window, cx| {
|
.on_click(cx.listener(move |shell, _, window, cx| {
|
||||||
shell.select_pane(pane_id, window, cx);
|
shell.select_pane(pane_id, window, cx);
|
||||||
}))
|
}))
|
||||||
@@ -1494,7 +1744,7 @@ impl LumbridgeShell {
|
|||||||
.flex_none()
|
.flex_none()
|
||||||
.min_h_0()
|
.min_h_0()
|
||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
.child(self.pane_decision_region(pane)),
|
.child(self.pane_decision_region(pane, cx)),
|
||||||
)
|
)
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
@@ -1795,6 +2045,209 @@ impl LumbridgeShell {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Attached panels whose agent is blocked on a human answer.
|
||||||
|
///
|
||||||
|
/// The count is derived rather than declared: a sidebar that claims one
|
||||||
|
/// pane needs input while no pane is blocked is the same class of untruth
|
||||||
|
/// as an invented usage number.
|
||||||
|
fn attention_panels(&self) -> Vec<PanelView> {
|
||||||
|
self.panels
|
||||||
|
.attached_ids()
|
||||||
|
.into_iter()
|
||||||
|
.map(|id| self.panel_view(id))
|
||||||
|
.filter(PanelView::needs_input)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where sessions are owned, and what each host is actually doing.
|
||||||
|
fn runtime_rows(&self) -> Vec<RuntimeRow> {
|
||||||
|
let running = self
|
||||||
|
.live_terminals
|
||||||
|
.values()
|
||||||
|
.filter(|terminal| matches!(terminal.status, LiveRuntimeStatus::Running { .. }))
|
||||||
|
.count();
|
||||||
|
let total = self.live_terminals.len();
|
||||||
|
let local = RuntimeRow {
|
||||||
|
label: "metal",
|
||||||
|
detail: format!("{running}/{total} live PTYs"),
|
||||||
|
tone: if running == 0 { MUTED } else { SUCCESS },
|
||||||
|
};
|
||||||
|
// Remote runtimes are a Phase 1 deliverable. Until the SSH stdio
|
||||||
|
// transport exists, the only honest status for a saved host is that
|
||||||
|
// nothing is connected to it.
|
||||||
|
let remote = RuntimeRow {
|
||||||
|
label: "amd-server",
|
||||||
|
detail: "saved host · no runtime connected".to_owned(),
|
||||||
|
tone: MUTED,
|
||||||
|
};
|
||||||
|
// The usage adapter is neither a host nor an endpoint, but its health
|
||||||
|
// belongs beside them: it is the reason the footer can or cannot answer.
|
||||||
|
let adapter = RuntimeRow {
|
||||||
|
label: "usage adapter",
|
||||||
|
detail: format!(
|
||||||
|
"{} · {}/{} profiles reporting",
|
||||||
|
self.usage.probe_status(),
|
||||||
|
self.usage.reporting_profile_count(),
|
||||||
|
self.usage.declared_profile_count()
|
||||||
|
),
|
||||||
|
tone: if self.usage.reporting_profile_count() == 0 {
|
||||||
|
MUTED
|
||||||
|
} else {
|
||||||
|
SUCCESS
|
||||||
|
},
|
||||||
|
};
|
||||||
|
vec![local, remote, adapter]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The standing of every harness, always, regardless of what is selected.
|
||||||
|
///
|
||||||
|
/// The old footer answered only for the selected pane, so the moment you
|
||||||
|
/// focused a plain shell your quota vanished. This strip keeps every
|
||||||
|
/// harness on screen and leads with what is left, because that is the
|
||||||
|
/// question. The selected harness expands with its rate and its trust.
|
||||||
|
fn footer_usage_zone(&self) -> impl IntoElement {
|
||||||
|
let active = self
|
||||||
|
.panels
|
||||||
|
.panel(self.panels.selected())
|
||||||
|
.and_then(|panel| self.usage.profile_for_seed(panel.seed))
|
||||||
|
.cloned();
|
||||||
|
let segments = self.usage.strip();
|
||||||
|
let orphan = active.is_none();
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.gap_4()
|
||||||
|
.when(orphan, |view| {
|
||||||
|
view.child(
|
||||||
|
div()
|
||||||
|
.flex_none()
|
||||||
|
.text_color(rgb(MUTED))
|
||||||
|
.child("no harness on this pane"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.children(segments.into_iter().map(|segment| {
|
||||||
|
let selected = active.as_ref() == Some(&segment.id);
|
||||||
|
usage_segment(segment, selected)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The provenance of a value is carried by the colour of its meter, so the
|
||||||
|
/// strip reads at a glance without a legend on every segment.
|
||||||
|
const fn provenance_color(provenance: UsageProvenance) -> u32 {
|
||||||
|
match provenance {
|
||||||
|
UsageProvenance::ProviderReported => SUCCESS,
|
||||||
|
UsageProvenance::HarnessReported => ACCENT,
|
||||||
|
UsageProvenance::LocallyMeasured => TEXT,
|
||||||
|
UsageProvenance::Estimated => ATTENTION,
|
||||||
|
UsageProvenance::Unavailable => MUTED,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A quota meter. The filled part is spent; the empty part is what is left,
|
||||||
|
/// which is the way round an engineer reads it.
|
||||||
|
fn usage_meter(consumed_permille: Option<u64>, color: u32) -> impl IntoElement {
|
||||||
|
let Some(permille) = consumed_permille else {
|
||||||
|
// No capsule. A full-length empty gauge reads as "plenty left" from
|
||||||
|
// across the room, which is the opposite of the truth. A hairline is
|
||||||
|
// visibly not a measurement.
|
||||||
|
return div()
|
||||||
|
.w(px(80.0))
|
||||||
|
.h(px(2.0))
|
||||||
|
.flex_none()
|
||||||
|
.bg(rgb(BORDER))
|
||||||
|
.into_any_element();
|
||||||
|
};
|
||||||
|
let clamped = u16::try_from(permille.min(1_000)).unwrap_or(1_000);
|
||||||
|
div()
|
||||||
|
.w(px(80.0))
|
||||||
|
.h(px(8.0))
|
||||||
|
.flex_none()
|
||||||
|
.rounded(px(4.0))
|
||||||
|
.bg(rgb(BORDER_QUIET))
|
||||||
|
.overflow_hidden()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.h_full()
|
||||||
|
.w(relative(f32::from(clamped) / 1_000.0))
|
||||||
|
.bg(rgb(color)),
|
||||||
|
)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn usage_segment(segment: UsageSegment, selected: bool) -> impl IntoElement {
|
||||||
|
let color = provenance_color(segment.provenance);
|
||||||
|
let name_color = if selected { TEXT } else { MUTED };
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.gap_2()
|
||||||
|
.when(selected, |view| {
|
||||||
|
view.px_2().py_1().rounded(px(4.0)).bg(rgb(PANEL_ACTIVE))
|
||||||
|
})
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex_none()
|
||||||
|
.text_color(rgb(name_color))
|
||||||
|
.child(segment.label),
|
||||||
|
)
|
||||||
|
.child(usage_meter(segment.consumed_permille, color))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex_none()
|
||||||
|
.text_color(rgb(if segment.headline.is_some() {
|
||||||
|
TEXT
|
||||||
|
} else {
|
||||||
|
MUTED
|
||||||
|
}))
|
||||||
|
.child(segment.headline.unwrap_or_else(|| "no reading".to_owned())),
|
||||||
|
)
|
||||||
|
.when_some(segment.reset, |view, reset| {
|
||||||
|
view.child(div().flex_none().text_color(rgb(MUTED)).child(reset))
|
||||||
|
})
|
||||||
|
// Only the harness you are looking at spends footer width on its rate
|
||||||
|
// and its trust. The rest stay one glance wide.
|
||||||
|
.when(selected, |view| {
|
||||||
|
view.child(footer_separator())
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex_none()
|
||||||
|
.text_color(rgb(provenance_color(segment.burn_provenance)))
|
||||||
|
.child(segment.burn),
|
||||||
|
)
|
||||||
|
.child(provenance_chip(&segment.trust, segment.provenance))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One host or endpoint row in the sidebar's runtime list.
|
||||||
|
struct RuntimeRow {
|
||||||
|
label: &'static str,
|
||||||
|
detail: String,
|
||||||
|
tone: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn footer_separator() -> impl IntoElement {
|
||||||
|
div().text_color(rgb(BORDER)).child("│")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The trust marker. Its colour is the provenance, never the value.
|
||||||
|
fn provenance_chip(text: &str, provenance: UsageProvenance) -> impl IntoElement {
|
||||||
|
let color = match provenance {
|
||||||
|
UsageProvenance::ProviderReported => SUCCESS,
|
||||||
|
UsageProvenance::HarnessReported => ACCENT,
|
||||||
|
UsageProvenance::LocallyMeasured => TEXT,
|
||||||
|
UsageProvenance::Estimated => ATTENTION,
|
||||||
|
UsageProvenance::Unavailable => MUTED,
|
||||||
|
};
|
||||||
|
div()
|
||||||
|
.px_2()
|
||||||
|
.py(px(1.0))
|
||||||
|
.rounded(px(3.0))
|
||||||
|
.border_1()
|
||||||
|
.border_color(rgb(color))
|
||||||
|
.text_color(rgb(color))
|
||||||
|
.child(text.to_owned())
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Render for LumbridgeShell {
|
impl Render for LumbridgeShell {
|
||||||
@@ -1827,6 +2280,7 @@ impl Render for LumbridgeShell {
|
|||||||
"{running_runtime_count}/{} LIVE PTYS",
|
"{running_runtime_count}/{} LIVE PTYS",
|
||||||
self.live_terminals.len()
|
self.live_terminals.len()
|
||||||
);
|
);
|
||||||
|
let attention_panels = self.attention_panels();
|
||||||
let sidebar = div()
|
let sidebar = div()
|
||||||
.flex()
|
.flex()
|
||||||
.flex_col()
|
.flex_col()
|
||||||
@@ -1841,11 +2295,30 @@ impl Render for LumbridgeShell {
|
|||||||
.pt_4()
|
.pt_4()
|
||||||
.pb_2()
|
.pb_2()
|
||||||
.text_xs()
|
.text_xs()
|
||||||
.text_color(rgb(ATTENTION))
|
.text_color(rgb(if attention_panels.is_empty() {
|
||||||
.child("ATTENTION · 1"),
|
MUTED
|
||||||
|
} else {
|
||||||
|
ATTENTION
|
||||||
|
}))
|
||||||
|
.child(format!("ATTENTION · {}", attention_panels.len())),
|
||||||
)
|
)
|
||||||
.child(
|
.when(attention_panels.is_empty(), |view| {
|
||||||
|
view.child(
|
||||||
|
div()
|
||||||
|
.mx_2()
|
||||||
|
.mb_3()
|
||||||
|
.px_3()
|
||||||
|
.py_2()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(rgb(MUTED))
|
||||||
|
.child("No pane is waiting on you"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.children(attention_panels.into_iter().map(|pane| {
|
||||||
|
let id = pane.id;
|
||||||
div()
|
div()
|
||||||
|
.id(("attention-panel", id.get()))
|
||||||
|
.cursor_pointer()
|
||||||
.mx_2()
|
.mx_2()
|
||||||
.mb_3()
|
.mb_3()
|
||||||
.px_3()
|
.px_3()
|
||||||
@@ -1854,20 +2327,19 @@ impl Render for LumbridgeShell {
|
|||||||
.bg(rgb(PANEL_ACTIVE))
|
.bg(rgb(PANEL_ACTIVE))
|
||||||
.border_1()
|
.border_1()
|
||||||
.border_color(rgb(ATTENTION))
|
.border_color(rgb(ATTENTION))
|
||||||
.child(
|
.hover(|view| view.bg(rgb(PANEL_ALT)))
|
||||||
div()
|
.child(div().text_sm().text_color(rgb(TEXT)).child(pane.title))
|
||||||
.text_sm()
|
|
||||||
.text_color(rgb(TEXT))
|
|
||||||
.child("Claude Code · UI"),
|
|
||||||
)
|
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.mt_1()
|
.mt_1()
|
||||||
.text_xs()
|
.text_xs()
|
||||||
.text_color(rgb(MUTED))
|
.text_color(rgb(MUTED))
|
||||||
.child("Waiting for a split decision"),
|
.child(pane.target),
|
||||||
),
|
)
|
||||||
)
|
.on_click(cx.listener(move |shell, _, window, cx| {
|
||||||
|
shell.select_pane(id, window, cx);
|
||||||
|
}))
|
||||||
|
}))
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.px_4()
|
.px_4()
|
||||||
@@ -1877,18 +2349,20 @@ impl Render for LumbridgeShell {
|
|||||||
.child("WORKTREES"),
|
.child("WORKTREES"),
|
||||||
)
|
)
|
||||||
.children(WORKSPACES.into_iter().enumerate().map(|(index, name)| {
|
.children(WORKSPACES.into_iter().enumerate().map(|(index, name)| {
|
||||||
|
let active = index == self.active_worktree;
|
||||||
div()
|
div()
|
||||||
|
.id(("worktree", index as u64))
|
||||||
|
.cursor_pointer()
|
||||||
.mx_2()
|
.mx_2()
|
||||||
.mb_1()
|
.mb_1()
|
||||||
.px_3()
|
.px_3()
|
||||||
.py_2()
|
.py_2()
|
||||||
.rounded(px(5.0))
|
.rounded(px(5.0))
|
||||||
.when(index == 0, |view| {
|
.when(active, |view| view.bg(rgb(PANEL_ALT)).text_color(rgb(TEXT)))
|
||||||
view.bg(rgb(PANEL_ALT)).text_color(rgb(TEXT))
|
.when(!active, |view| view.text_color(rgb(MUTED)))
|
||||||
})
|
.hover(|view| view.bg(rgb(PANEL_ACTIVE)).text_color(rgb(TEXT)))
|
||||||
.when(index != 0, |view| view.text_color(rgb(MUTED)))
|
|
||||||
.child(name)
|
.child(name)
|
||||||
.when(index == 0, |view| {
|
.when(active, |view| {
|
||||||
view.child(
|
view.child(
|
||||||
div()
|
div()
|
||||||
.mt_1()
|
.mt_1()
|
||||||
@@ -1897,6 +2371,9 @@ impl Render for LumbridgeShell {
|
|||||||
.child("main · metal"),
|
.child("main · metal"),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
.on_click(cx.listener(move |shell, _, window, cx| {
|
||||||
|
shell.select_worktree(index, window, cx);
|
||||||
|
}))
|
||||||
}))
|
}))
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
@@ -1907,30 +2384,13 @@ impl Render for LumbridgeShell {
|
|||||||
.text_color(rgb(MUTED))
|
.text_color(rgb(MUTED))
|
||||||
.child("RUNTIMES"),
|
.child("RUNTIMES"),
|
||||||
)
|
)
|
||||||
.child(
|
.children(self.runtime_rows().into_iter().map(|row| {
|
||||||
div()
|
div()
|
||||||
.px_4()
|
.px_4()
|
||||||
.py_1()
|
.py_1()
|
||||||
.text_sm()
|
.child(div().text_sm().text_color(rgb(row.tone)).child(row.label))
|
||||||
.text_color(rgb(SUCCESS))
|
.child(div().text_xs().text_color(rgb(MUTED)).child(row.detail))
|
||||||
.child(format!("metal · {runtime_summary}")),
|
}))
|
||||||
)
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.px_4()
|
|
||||||
.py_1()
|
|
||||||
.text_sm()
|
|
||||||
.text_color(rgb(MUTED))
|
|
||||||
.child("amd-server · connected"),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.px_4()
|
|
||||||
.py_1()
|
|
||||||
.text_sm()
|
|
||||||
.text_color(rgb(MUTED))
|
|
||||||
.child("spark-1 · sleeping"),
|
|
||||||
)
|
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.px_4()
|
.px_4()
|
||||||
@@ -1959,6 +2419,7 @@ impl Render for LumbridgeShell {
|
|||||||
.rounded(px(4.0))
|
.rounded(px(4.0))
|
||||||
.border_1()
|
.border_1()
|
||||||
.border_color(rgb(BORDER_QUIET))
|
.border_color(rgb(BORDER_QUIET))
|
||||||
|
.hover(|view| view.border_color(rgb(ACCENT)).text_color(rgb(TEXT)))
|
||||||
.text_xs()
|
.text_xs()
|
||||||
.text_color(rgb(MUTED))
|
.text_color(rgb(MUTED))
|
||||||
.child(format!("↪ {title}"))
|
.child(format!("↪ {title}"))
|
||||||
@@ -1994,6 +2455,8 @@ impl Render for LumbridgeShell {
|
|||||||
.bg(rgb(PANEL))
|
.bg(rgb(PANEL))
|
||||||
.border_b_1()
|
.border_b_1()
|
||||||
.border_color(rgb(BORDER_QUIET))
|
.border_color(rgb(BORDER_QUIET))
|
||||||
|
// One tab, because there is one workspace. Two more used to sit
|
||||||
|
// here looking like navigation and doing nothing.
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.h_full()
|
.h_full()
|
||||||
@@ -2003,21 +2466,7 @@ impl Render for LumbridgeShell {
|
|||||||
.border_b_2()
|
.border_b_2()
|
||||||
.border_color(rgb(ACCENT))
|
.border_color(rgb(ACCENT))
|
||||||
.text_sm()
|
.text_sm()
|
||||||
.child("Agent workspace"),
|
.child(WORKSPACES[self.active_worktree]),
|
||||||
)
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.px_3()
|
|
||||||
.text_sm()
|
|
||||||
.text_color(rgb(MUTED))
|
|
||||||
.child("Architecture.md"),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.px_3()
|
|
||||||
.text_sm()
|
|
||||||
.text_color(rgb(MUTED))
|
|
||||||
.child("Review"),
|
|
||||||
)
|
)
|
||||||
.child(div().flex_1())
|
.child(div().flex_1())
|
||||||
.child(div().px_3().text_xs().text_color(rgb(MUTED)).child(format!(
|
.child(div().px_3().text_xs().text_color(rgb(MUTED)).child(format!(
|
||||||
@@ -2145,7 +2594,7 @@ impl Render for LumbridgeShell {
|
|||||||
.ml_3()
|
.ml_3()
|
||||||
.text_sm()
|
.text_sm()
|
||||||
.text_color(rgb(MUTED))
|
.text_color(rgb(MUTED))
|
||||||
.child("Lumbridge Code / main"),
|
.child(format!("{} / main", WORKSPACES[self.active_worktree])),
|
||||||
)
|
)
|
||||||
.child(div().flex_1())
|
.child(div().flex_1())
|
||||||
.child(
|
.child(
|
||||||
@@ -2180,17 +2629,26 @@ impl Render for LumbridgeShell {
|
|||||||
.flex()
|
.flex()
|
||||||
.items_center()
|
.items_center()
|
||||||
.justify_between()
|
.justify_between()
|
||||||
.h(px(32.0))
|
.h(px(APP_FOOTER_HEIGHT))
|
||||||
.flex_none()
|
.flex_none()
|
||||||
.px_3()
|
.px_3()
|
||||||
|
.gap_4()
|
||||||
.bg(rgb(PANEL_ALT))
|
.bg(rgb(PANEL_ALT))
|
||||||
.border_t_1()
|
.border_t_1()
|
||||||
.border_color(rgb(BORDER_QUIET))
|
.border_color(rgb(BORDER_QUIET))
|
||||||
.text_xs()
|
.text_xs()
|
||||||
.text_color(rgb(MUTED))
|
.text_color(rgb(MUTED))
|
||||||
.child(footer_left)
|
.child(
|
||||||
.child(timing)
|
div()
|
||||||
.child(FOOTER_RIGHT),
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.flex_none()
|
||||||
|
.min_w_0()
|
||||||
|
.child(div().truncate().child(footer_left))
|
||||||
|
.child(div().truncate().child(timing)),
|
||||||
|
)
|
||||||
|
.child(div().flex_1())
|
||||||
|
.child(self.footer_usage_zone()),
|
||||||
)
|
)
|
||||||
.when(self.model.command_palette().is_open(), |view| {
|
.when(self.model.command_palette().is_open(), |view| {
|
||||||
view.child(self.command_palette())
|
view.child(self.command_palette())
|
||||||
|
|||||||
@@ -0,0 +1,555 @@
|
|||||||
|
//! Footer usage for the spike, fed by real probes through the real ledger.
|
||||||
|
//!
|
||||||
|
//! Profiles are *declared*; numbers are not. A profile exists as soon as the
|
||||||
|
//! shell knows a pane is running a harness, so the footer can name the account
|
||||||
|
//! it cannot read. Observations only ever come from a [`UsageProbe`]. A harness
|
||||||
|
//! with no adapter yet therefore renders the honest-gap path under its own
|
||||||
|
//! name — "Claude Code · Anthropic · … usage unavailable" — rather than
|
||||||
|
//! borrowing another pane's number or showing a zero.
|
||||||
|
//!
|
||||||
|
//! The only adapter that exists today is the Codex quota probe. It is started
|
||||||
|
//! at launch unless `LUMBRIDGE_CODEX_PROBE=0` is set, and if the `codex` binary
|
||||||
|
//! is absent the probe simply fails to start and its profiles keep rendering as
|
||||||
|
//! unavailable.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use lumbridge_core::{
|
||||||
|
AccountProfile, AccountProfileId, FooterUsage, UsageLedger, UsageProjection, UsageProvenance,
|
||||||
|
format_duration_ms,
|
||||||
|
};
|
||||||
|
use lumbridge_harness::claude::{ClaudeCodeProbe, ClaudeCodeProbeOptions};
|
||||||
|
use lumbridge_harness::codex::{CodexProbe, CodexProbeOptions};
|
||||||
|
use lumbridge_harness::{MonotonicWallClock, ProbeHealth, UsageProbe};
|
||||||
|
|
||||||
|
use crate::panel_registry::SeedPane;
|
||||||
|
|
||||||
|
/// Harnesses the seeded panes run, whether or not an adapter exists for them.
|
||||||
|
struct DeclaredProfile {
|
||||||
|
seed: SeedPane,
|
||||||
|
id: &'static str,
|
||||||
|
harness: &'static str,
|
||||||
|
provider: &'static str,
|
||||||
|
model: &'static str,
|
||||||
|
account: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Codex pane maps onto the probe's own primary-window profile, so a live
|
||||||
|
/// reading lands under the pane that produced it. The rest are declarations
|
||||||
|
/// with no adapter behind them yet.
|
||||||
|
const DECLARED: &[DeclaredProfile] = &[
|
||||||
|
DeclaredProfile {
|
||||||
|
seed: SeedPane::CodexRuntime,
|
||||||
|
id: "codex-app-server-primary",
|
||||||
|
harness: "Codex",
|
||||||
|
provider: "ChatGPT",
|
||||||
|
model: "primary window",
|
||||||
|
account: "subscription",
|
||||||
|
},
|
||||||
|
// The Claude pane maps to the five-hour window, not the token total: it is
|
||||||
|
// the limit that actually stops work, and it is what "how much do I have
|
||||||
|
// left" means. The transcript total is still declared by the probe and
|
||||||
|
// still shows in the strip once it reports.
|
||||||
|
DeclaredProfile {
|
||||||
|
seed: SeedPane::ClaudeUi,
|
||||||
|
id: "claude-code-five-hour",
|
||||||
|
harness: "Claude Code",
|
||||||
|
provider: "Anthropic",
|
||||||
|
model: "five-hour window",
|
||||||
|
account: "subscription",
|
||||||
|
},
|
||||||
|
DeclaredProfile {
|
||||||
|
seed: SeedPane::PiDocs,
|
||||||
|
id: "spark-1-local",
|
||||||
|
harness: "Pi",
|
||||||
|
provider: "spark-1",
|
||||||
|
model: "laguna-s-2.1",
|
||||||
|
account: "self-hosted",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
pub(crate) struct UsageFeed {
|
||||||
|
ledger: UsageLedger,
|
||||||
|
clock: MonotonicWallClock,
|
||||||
|
profiles: BTreeMap<AccountProfileId, AccountProfile>,
|
||||||
|
seeds: Vec<(SeedPane, AccountProfileId)>,
|
||||||
|
probes: Vec<Box<dyn UsageProbe>>,
|
||||||
|
health: BTreeMap<AccountProfileId, ProbeHealth>,
|
||||||
|
probe_status: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UsageFeed {
|
||||||
|
pub(crate) fn start() -> Self {
|
||||||
|
let mut profiles = BTreeMap::new();
|
||||||
|
let mut seeds = Vec::new();
|
||||||
|
for declared in DECLARED {
|
||||||
|
let Ok(profile) = AccountProfile::new(
|
||||||
|
declared.id,
|
||||||
|
declared.harness,
|
||||||
|
declared.provider,
|
||||||
|
declared.model,
|
||||||
|
declared.account,
|
||||||
|
) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
seeds.push((declared.seed, profile.id().clone()));
|
||||||
|
profiles.insert(profile.id().clone(), profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut feed = Self {
|
||||||
|
ledger: UsageLedger::new(),
|
||||||
|
clock: MonotonicWallClock::start(),
|
||||||
|
profiles,
|
||||||
|
seeds,
|
||||||
|
probes: Vec::new(),
|
||||||
|
health: BTreeMap::new(),
|
||||||
|
probe_status: "no usage adapter running".to_owned(),
|
||||||
|
};
|
||||||
|
feed.start_codex_probe();
|
||||||
|
feed.start_claude_probe();
|
||||||
|
feed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts the Codex quota probe unless the user opted out.
|
||||||
|
///
|
||||||
|
/// A failure here is not an error state for the shell: the profiles stay
|
||||||
|
/// declared and render as unavailable, which is exactly what they should do
|
||||||
|
/// when no adapter can run.
|
||||||
|
fn start_codex_probe(&mut self) {
|
||||||
|
if std::env::var_os("LUMBRIDGE_CODEX_PROBE").is_some_and(|value| value == "0") {
|
||||||
|
self.probe_status = "codex probe disabled".to_owned();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match CodexProbe::start(CodexProbeOptions::default()) {
|
||||||
|
Ok(probe) => {
|
||||||
|
for profile in probe.profiles() {
|
||||||
|
self.profiles
|
||||||
|
.entry(profile.id().clone())
|
||||||
|
.or_insert_with(|| profile.clone());
|
||||||
|
self.health
|
||||||
|
.insert(profile.id().clone(), ProbeHealth::Starting);
|
||||||
|
}
|
||||||
|
self.probes.push(Box::new(probe));
|
||||||
|
self.probe_status = "codex probe starting".to_owned();
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
self.probe_status = format!("codex probe unavailable · {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts the Claude Code transcript probe unless the user opted out.
|
||||||
|
///
|
||||||
|
/// This one reads files rather than launching anything, so it has no
|
||||||
|
/// harness to fail to find — a missing projects directory simply reports
|
||||||
|
/// nothing until Claude Code creates it.
|
||||||
|
fn start_claude_probe(&mut self) {
|
||||||
|
if std::env::var_os("LUMBRIDGE_CLAUDE_PROBE").is_some_and(|value| value == "0") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match ClaudeCodeProbe::start(ClaudeCodeProbeOptions::default()) {
|
||||||
|
Ok(probe) => {
|
||||||
|
for profile in probe.profiles() {
|
||||||
|
self.profiles
|
||||||
|
.entry(profile.id().clone())
|
||||||
|
.or_insert_with(|| profile.clone());
|
||||||
|
self.health
|
||||||
|
.insert(profile.id().clone(), ProbeHealth::Starting);
|
||||||
|
}
|
||||||
|
self.probes.push(Box::new(probe));
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
self.probe_status = format!("claude probe unavailable · {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which declared profile a seeded panel belongs to.
|
||||||
|
///
|
||||||
|
/// Panels created at runtime are plain shells with no harness, so they map
|
||||||
|
/// to nothing rather than borrowing another pane's account.
|
||||||
|
pub(crate) fn profile_for_seed(&self, seed: Option<SeedPane>) -> Option<&AccountProfileId> {
|
||||||
|
let seed = seed?;
|
||||||
|
self.seeds
|
||||||
|
.iter()
|
||||||
|
.find(|(candidate, _)| *candidate == seed)
|
||||||
|
.map(|(_, id)| id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drains every probe into the ledger. Returns whether anything changed.
|
||||||
|
pub(crate) fn tick(&mut self) -> bool {
|
||||||
|
let mut changed = false;
|
||||||
|
for probe in &mut self.probes {
|
||||||
|
let outcome = probe.poll();
|
||||||
|
let health = outcome.health();
|
||||||
|
for profile in probe.profiles() {
|
||||||
|
if self.health.insert(profile.id().clone(), health) != Some(health) {
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for observation in outcome.into_observations() {
|
||||||
|
// A rejected observation is a real signal, not a crash: the
|
||||||
|
// ledger refuses anything that would move a profile's stream
|
||||||
|
// backwards. Dropping it preserves the append-only invariant.
|
||||||
|
if self.ledger.record(observation).is_ok() {
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
self.probe_status = self.summarize_health();
|
||||||
|
}
|
||||||
|
changed
|
||||||
|
}
|
||||||
|
|
||||||
|
fn summarize_health(&self) -> String {
|
||||||
|
if self.probes.is_empty() {
|
||||||
|
return "no usage adapter running".to_owned();
|
||||||
|
}
|
||||||
|
let ready = self
|
||||||
|
.health
|
||||||
|
.values()
|
||||||
|
.filter(|health| matches!(health, ProbeHealth::Ready))
|
||||||
|
.count();
|
||||||
|
let faulted = self
|
||||||
|
.health
|
||||||
|
.values()
|
||||||
|
.filter(|health| health.is_faulted())
|
||||||
|
.count();
|
||||||
|
if faulted > 0 {
|
||||||
|
return format!("{} probes · {faulted} faulted", self.probes.len());
|
||||||
|
}
|
||||||
|
format!("{} probes · {ready} ready", self.probes.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn probe_status(&self) -> &str {
|
||||||
|
&self.probe_status
|
||||||
|
}
|
||||||
|
|
||||||
|
fn projection(&self, id: &AccountProfileId) -> UsageProjection {
|
||||||
|
self.ledger.project(id, self.clock.last_emitted_ms())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advances the feed's read clock. Kept separate from [`Self::tick`] so
|
||||||
|
/// rendering never mutates the clock mid-frame.
|
||||||
|
pub(crate) fn advance(&mut self) {
|
||||||
|
let _ = self.clock.now_ms();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every harness the footer should account for, in a stable order.
|
||||||
|
///
|
||||||
|
/// The seeded panes come first so the strip does not reorder itself as
|
||||||
|
/// readings arrive, then any probe profile that has actually reported. A
|
||||||
|
/// probe profile with nothing to say — Codex's secondary window on an
|
||||||
|
/// account that has none — is left out rather than shown as an empty rail,
|
||||||
|
/// because the strip is for harnesses the user is running, not for every
|
||||||
|
/// row a probe could theoretically fill.
|
||||||
|
pub(crate) fn strip(&self) -> Vec<UsageSegment> {
|
||||||
|
let mut ordered: Vec<&AccountProfileId> = self.seeds.iter().map(|(_, id)| id).collect();
|
||||||
|
for id in self.profiles.keys() {
|
||||||
|
if !ordered.contains(&id) && self.projection(id).is_available() {
|
||||||
|
ordered.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut segments: Vec<UsageSegment> = ordered
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|id| self.segment(id))
|
||||||
|
.collect();
|
||||||
|
// Two windows on one account would otherwise both read "CODEX". A
|
||||||
|
// strip that names two different quotas the same thing is worse than
|
||||||
|
// a longer label.
|
||||||
|
let duplicated: Vec<String> = segments
|
||||||
|
.iter()
|
||||||
|
.filter(|segment| {
|
||||||
|
segments
|
||||||
|
.iter()
|
||||||
|
.filter(|other| other.label == segment.label)
|
||||||
|
.count()
|
||||||
|
> 1
|
||||||
|
})
|
||||||
|
.map(|segment| segment.label.clone())
|
||||||
|
.collect();
|
||||||
|
for segment in &mut segments {
|
||||||
|
if duplicated.contains(&segment.label) {
|
||||||
|
segment.label = format!("{} {}", segment.label, segment.model.to_uppercase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
segments
|
||||||
|
}
|
||||||
|
|
||||||
|
fn segment(&self, id: &AccountProfileId) -> Option<UsageSegment> {
|
||||||
|
let profile = self.profiles.get(id)?;
|
||||||
|
let projection = self.projection(id);
|
||||||
|
let footer = FooterUsage::new(profile, &projection);
|
||||||
|
let consumed_permille = projection.consumed_permille();
|
||||||
|
Some(UsageSegment {
|
||||||
|
id: id.clone(),
|
||||||
|
label: profile.harness().to_uppercase(),
|
||||||
|
model: profile.model().to_owned(),
|
||||||
|
consumed_permille,
|
||||||
|
// "How much do I have left" is the question an engineer actually
|
||||||
|
// asks. Consumption stays available in the expanded detail.
|
||||||
|
// With a ceiling, lead with what is left. Without one, the honest
|
||||||
|
// headline is what was spent — a profile that reports real
|
||||||
|
// consumption but no quota must not read as "no reading".
|
||||||
|
//
|
||||||
|
// Show a decimal only when the value actually has one. Codex
|
||||||
|
// reports whole percents, so "81.0% left" would claim a tenth of a
|
||||||
|
// percent of resolution that no one measured.
|
||||||
|
headline: consumed_permille
|
||||||
|
.map(|permille| {
|
||||||
|
let left = 1_000_u64.saturating_sub(permille);
|
||||||
|
if left % 10 == 0 {
|
||||||
|
format!("{}% left", left / 10)
|
||||||
|
} else {
|
||||||
|
format!("{}.{}% left", left / 10, left % 10)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.or_else(|| {
|
||||||
|
let consumed = projection.consumed()?;
|
||||||
|
let unit = projection.unit()?;
|
||||||
|
Some(format!("{} used", unit.format_amount(consumed)))
|
||||||
|
}),
|
||||||
|
// A profile that reports spend but no ceiling and no reset should
|
||||||
|
// say so where the reset would go, rather than leaving a silent
|
||||||
|
// gap that reads as "we just haven't shown it yet".
|
||||||
|
reset: projection.resets_in_ms().map_or_else(
|
||||||
|
|| {
|
||||||
|
(projection.is_available() && projection.limit().is_none())
|
||||||
|
.then(|| "no quota reported".to_owned())
|
||||||
|
},
|
||||||
|
|remaining| Some(format!("resets {}", format_duration_ms(remaining))),
|
||||||
|
),
|
||||||
|
burn: footer.burn(),
|
||||||
|
burn_provenance: projection.burn_provenance(),
|
||||||
|
trust: footer.trust(),
|
||||||
|
provenance: projection.provenance(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Profiles that have produced at least one usable reading.
|
||||||
|
pub(crate) fn reporting_profile_count(&self) -> usize {
|
||||||
|
self.profiles
|
||||||
|
.keys()
|
||||||
|
.filter(|id| self.projection(id).is_available())
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn declared_profile_count(&self) -> usize {
|
||||||
|
self.profiles.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn shutdown(&mut self) {
|
||||||
|
for probe in &mut self.probes {
|
||||||
|
probe.shutdown();
|
||||||
|
}
|
||||||
|
self.probes.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for UsageFeed {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.shutdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One harness's standing in the footer strip.
|
||||||
|
///
|
||||||
|
/// `remaining` and `reset` are `None` when there is nothing to report. That is
|
||||||
|
/// deliberately not an empty string: the renderer has to decide what a gap
|
||||||
|
/// looks like rather than printing a blank where a number belongs.
|
||||||
|
pub(crate) struct UsageSegment {
|
||||||
|
pub(crate) id: AccountProfileId,
|
||||||
|
pub(crate) label: String,
|
||||||
|
pub(crate) model: String,
|
||||||
|
pub(crate) consumed_permille: Option<u64>,
|
||||||
|
/// What to lead with: how much is left when a ceiling is known, how much
|
||||||
|
/// was spent when it is not, and nothing at all when there is no reading.
|
||||||
|
pub(crate) headline: Option<String>,
|
||||||
|
pub(crate) reset: Option<String>,
|
||||||
|
pub(crate) burn: String,
|
||||||
|
pub(crate) burn_provenance: UsageProvenance,
|
||||||
|
pub(crate) trust: String,
|
||||||
|
pub(crate) provenance: UsageProvenance,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{DECLARED, UsageFeed};
|
||||||
|
use crate::panel_registry::SeedPane;
|
||||||
|
use lumbridge_core::UsageProvenance;
|
||||||
|
|
||||||
|
/// Never starts a probe, so the test cannot depend on an installed harness,
|
||||||
|
/// on the user's transcripts, or on a status-line bridge being present.
|
||||||
|
fn declared_only_feed() -> UsageFeed {
|
||||||
|
// SAFETY-FREE: this only sets environment variables for this process.
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var("LUMBRIDGE_CODEX_PROBE", "0");
|
||||||
|
std::env::set_var("LUMBRIDGE_CLAUDE_PROBE", "0");
|
||||||
|
}
|
||||||
|
UsageFeed::start()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_declared_harness_with_no_adapter_shows_a_gap_under_its_own_name() {
|
||||||
|
let feed = declared_only_feed();
|
||||||
|
let segment = feed
|
||||||
|
.strip()
|
||||||
|
.into_iter()
|
||||||
|
.find(|segment| segment.label.starts_with("CLAUDE CODE"))
|
||||||
|
.expect("Claude Code is declared");
|
||||||
|
assert!(
|
||||||
|
segment.headline.is_none(),
|
||||||
|
"a harness with no adapter must not show a number"
|
||||||
|
);
|
||||||
|
assert_eq!(segment.trust, "no usage source");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn panels_without_a_seed_have_no_profile() {
|
||||||
|
let feed = declared_only_feed();
|
||||||
|
assert!(feed.profile_for_seed(None).is_none());
|
||||||
|
assert!(
|
||||||
|
feed.profile_for_seed(Some(SeedPane::Architecture))
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nothing_reports_until_a_probe_produces_a_reading() {
|
||||||
|
let feed = declared_only_feed();
|
||||||
|
assert_eq!(feed.declared_profile_count(), DECLARED.len());
|
||||||
|
assert_eq!(
|
||||||
|
feed.reporting_profile_count(),
|
||||||
|
0,
|
||||||
|
"declaring a profile must not imply a reading"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_codex_pane_maps_onto_the_probe_profile_id() {
|
||||||
|
let feed = declared_only_feed();
|
||||||
|
let id = feed
|
||||||
|
.profile_for_seed(Some(SeedPane::CodexRuntime))
|
||||||
|
.expect("the Codex pane declares a profile");
|
||||||
|
assert_eq!(
|
||||||
|
id.as_str(),
|
||||||
|
"codex-app-server-primary",
|
||||||
|
"a live reading must land under the pane that produced it"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_strip_lists_every_declared_harness_even_with_no_readings() {
|
||||||
|
let feed = declared_only_feed();
|
||||||
|
let strip = feed.strip();
|
||||||
|
assert_eq!(strip.len(), DECLARED.len());
|
||||||
|
let labels: Vec<&str> = strip.iter().map(|segment| segment.label.as_str()).collect();
|
||||||
|
assert_eq!(labels, ["CODEX", "CLAUDE CODE", "PI"]);
|
||||||
|
for segment in &strip {
|
||||||
|
assert!(
|
||||||
|
segment.headline.is_none() && segment.reset.is_none(),
|
||||||
|
"a harness with no adapter must report no figure at all"
|
||||||
|
);
|
||||||
|
assert!(!segment.provenance.is_available());
|
||||||
|
assert_eq!(segment.provenance, UsageProvenance::Unavailable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_fractional_reading_keeps_its_decimal() {
|
||||||
|
use lumbridge_core::{UsageObservation, UsageUnit, UsageWindow};
|
||||||
|
|
||||||
|
let mut feed = declared_only_feed();
|
||||||
|
let id = feed
|
||||||
|
.profile_for_seed(Some(SeedPane::CodexRuntime))
|
||||||
|
.expect("declared")
|
||||||
|
.clone();
|
||||||
|
feed.advance();
|
||||||
|
let now_ms = feed.clock.last_emitted_ms();
|
||||||
|
feed.ledger
|
||||||
|
.record(
|
||||||
|
UsageObservation::counted(
|
||||||
|
id.clone(),
|
||||||
|
UsageUnit::WindowPermille,
|
||||||
|
185,
|
||||||
|
UsageProvenance::ProviderReported,
|
||||||
|
now_ms.saturating_sub(1_000),
|
||||||
|
)
|
||||||
|
.expect("available")
|
||||||
|
.with_limit(1_000)
|
||||||
|
.expect("a positive limit")
|
||||||
|
.with_window(UsageWindow::until(now_ms + 3_600_000))
|
||||||
|
.expect("inside the window"),
|
||||||
|
)
|
||||||
|
.expect("ordered");
|
||||||
|
let segment = feed
|
||||||
|
.strip()
|
||||||
|
.into_iter()
|
||||||
|
.find(|segment| segment.id == id)
|
||||||
|
.expect("present");
|
||||||
|
assert_eq!(segment.headline.as_deref(), Some("81.5% left"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_segment_reports_what_is_left_not_what_was_used() {
|
||||||
|
use lumbridge_core::{UsageObservation, UsageUnit, UsageWindow};
|
||||||
|
|
||||||
|
let mut feed = declared_only_feed();
|
||||||
|
let id = feed
|
||||||
|
.profile_for_seed(Some(SeedPane::CodexRuntime))
|
||||||
|
.expect("the Codex pane declares a profile")
|
||||||
|
.clone();
|
||||||
|
// The feed reads a real wall clock, so the fixture has to sit inside a
|
||||||
|
// window that is still open now rather than at an arbitrary epoch.
|
||||||
|
feed.advance();
|
||||||
|
let now_ms = feed.clock.last_emitted_ms();
|
||||||
|
feed.ledger
|
||||||
|
.record(
|
||||||
|
UsageObservation::counted(
|
||||||
|
id.clone(),
|
||||||
|
UsageUnit::WindowPermille,
|
||||||
|
180,
|
||||||
|
UsageProvenance::ProviderReported,
|
||||||
|
now_ms.saturating_sub(1_000),
|
||||||
|
)
|
||||||
|
.expect("available")
|
||||||
|
.with_limit(1_000)
|
||||||
|
.expect("a positive limit")
|
||||||
|
.with_window(UsageWindow::until(now_ms + 3_600_000))
|
||||||
|
.expect("inside the window"),
|
||||||
|
)
|
||||||
|
.expect("ordered");
|
||||||
|
|
||||||
|
let segment = feed
|
||||||
|
.strip()
|
||||||
|
.into_iter()
|
||||||
|
.find(|segment| segment.id == id)
|
||||||
|
.expect("the Codex segment is present");
|
||||||
|
assert_eq!(
|
||||||
|
segment.headline.as_deref(),
|
||||||
|
Some("82% left"),
|
||||||
|
"a whole-percent source must not render a tenth it never measured"
|
||||||
|
);
|
||||||
|
assert_eq!(segment.consumed_permille, Some(180));
|
||||||
|
assert_eq!(
|
||||||
|
segment.reset.as_deref(),
|
||||||
|
Some("resets 1h 0m"),
|
||||||
|
"a windowed reading reports its reset"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_segment_with_no_reading_offers_no_figure_to_render() {
|
||||||
|
let feed = declared_only_feed();
|
||||||
|
for segment in feed.strip() {
|
||||||
|
assert!(segment.headline.is_none());
|
||||||
|
assert!(segment.reset.is_none());
|
||||||
|
assert!(
|
||||||
|
!segment.burn.contains("/hr"),
|
||||||
|
"a rate needs readings this segment does not have"
|
||||||
|
);
|
||||||
|
assert_eq!(segment.consumed_permille, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -205,8 +205,17 @@ pub const WORKSPACES: [&str; 5] = [
|
|||||||
"ACP adapters",
|
"ACP adapters",
|
||||||
"Usage telemetry",
|
"Usage telemetry",
|
||||||
];
|
];
|
||||||
|
/// Static footer strings retained only by the Floem comparison shell.
|
||||||
|
///
|
||||||
|
/// These are layout fixtures, not usage. They are not a usage source and must
|
||||||
|
/// not be rendered as one: the GPUI shell now derives its footer from
|
||||||
|
/// `lumbridge_core::UsageLedger`, where every value carries a provenance and a
|
||||||
|
/// missing fact renders as missing. Delete these once Floem consumes the ledger
|
||||||
|
/// or the candidate is retired. See decision 0012.
|
||||||
pub const FOOTER_LEFT: &str = "6 panes · 3 remote · 1 needs input";
|
pub const FOOTER_LEFT: &str = "6 panes · 3 remote · 1 needs input";
|
||||||
|
/// See [`FOOTER_LEFT`]: a layout fixture, not a usage reading.
|
||||||
pub const FOOTER_CENTER: &str = "Codex · ChatGPT subscription · 62% window remaining";
|
pub const FOOTER_CENTER: &str = "Codex · ChatGPT subscription · 62% window remaining";
|
||||||
|
/// See [`FOOTER_LEFT`]: a layout fixture, not a usage reading.
|
||||||
pub const FOOTER_RIGHT: &str = "burn 8.4%/hr · resets in 2h 14m";
|
pub const FOOTER_RIGHT: &str = "burn 8.4%/hr · resets in 2h 14m";
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
|||||||
Reference in New Issue
Block a user