Skip to main content

lean_ctx/core/savings_ledger/
push.rs

1//! Reusable savings push: sign this machine's whole ledger and POST it to a team
2//! server's ingest endpoint.
3//!
4//! Shared by the `lean-ctx savings push` CLI and the opt-in daemon auto-push
5//! ([`crate::core::savings_autopush`]) so there is exactly **one** push path.
6//! The batch is a cumulative whole-ledger snapshot (`period = "all"`), so
7//! re-pushing is idempotent on the server (the summary takes each signer's
8//! latest batch). It carries only counts, model names, tool names and chain
9//! hashes — never prompts or code.
10
11use super::SignedSavingsBatchV1;
12
13/// Outcome of a successful push (for human-readable reporting).
14#[derive(Debug, Clone, Copy)]
15pub struct PushOutcome {
16    pub net_saved_tokens: u64,
17    pub saved_usd: f64,
18}
19
20/// Why a push could not be completed.
21#[derive(Debug)]
22pub enum PushError {
23    /// The local ledger has no events yet — nothing to report.
24    Empty,
25    /// Signing the batch failed.
26    Sign(String),
27    /// The batch could not be serialized.
28    Serialize(String),
29    /// The server rejected the bearer token (HTTP 401/403).
30    Unauthorized,
31    /// The server returned a non-2xx status with a body.
32    Rejected { status: u16, body: String },
33    /// The server could not be reached.
34    Unreachable(String),
35}
36
37impl std::fmt::Display for PushError {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        match self {
40            Self::Empty => write!(f, "Savings ledger is empty — nothing to push."),
41            Self::Sign(e) => write!(f, "Signing failed: {e}"),
42            Self::Serialize(e) => write!(f, "Serialization failed: {e}"),
43            Self::Unauthorized => write!(f, "Team server denied the push (HTTP 401/403)."),
44            Self::Rejected { status, body } => {
45                write!(f, "Team server rejected the batch (HTTP {status}): {body}")
46            }
47            Self::Unreachable(e) => write!(f, "Failed to reach team server: {e}"),
48        }
49    }
50}
51
52impl std::error::Error for PushError {}
53
54/// Resolve the signing identity (same precedence as the ledger's attribution).
55pub fn agent_id() -> String {
56    std::env::var("LEAN_CTX_AGENT_ID")
57        .or_else(|_| std::env::var("LCTX_AGENT_ID"))
58        .unwrap_or_else(|_| "local".to_string())
59}
60
61/// The ingest endpoint for a team server base URL.
62pub fn ingest_endpoint(url: &str) -> String {
63    format!("{}/api/v1/savings/ingest", url.trim_end_matches('/'))
64}
65
66/// Build + sign the whole local ledger and POST it to `{url}/api/v1/savings/ingest`.
67///
68/// `token` is the team bearer token. Real servers gate ingest behind a valid
69/// token, so `None` only succeeds against an unauthenticated/dev server.
70pub fn push_batch(url: &str, token: Option<&str>) -> Result<PushOutcome, PushError> {
71    let agent = agent_id();
72    let mut batch = SignedSavingsBatchV1::build_all(&agent);
73    if batch.totals.total_events == 0 {
74        return Err(PushError::Empty);
75    }
76    batch.sign(&agent).map_err(PushError::Sign)?;
77
78    let endpoint = ingest_endpoint(url);
79    let body = serde_json::to_vec(&batch).map_err(|e| PushError::Serialize(e.to_string()))?;
80
81    let mut request = ureq::post(&endpoint).header("Content-Type", "application/json");
82    if let Some(tok) = token {
83        request = request.header("Authorization", &format!("Bearer {tok}"));
84    }
85
86    match request.send(&body[..]) {
87        Ok(resp) => {
88            let status = resp.status().as_u16();
89            if status == 401 || status == 403 {
90                return Err(PushError::Unauthorized);
91            }
92            if status == 200 {
93                Ok(PushOutcome {
94                    net_saved_tokens: batch.totals.net_saved_tokens,
95                    saved_usd: batch.totals.saved_usd,
96                })
97            } else {
98                let body = resp.into_body().read_to_string().unwrap_or_default();
99                Err(PushError::Rejected { status, body })
100            }
101        }
102        Err(e) => Err(PushError::Unreachable(e.to_string())),
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    #[test]
111    fn ingest_endpoint_trims_trailing_slash() {
112        assert_eq!(
113            ingest_endpoint("https://team.example.com/"),
114            "https://team.example.com/api/v1/savings/ingest"
115        );
116        assert_eq!(
117            ingest_endpoint("https://team.example.com"),
118            "https://team.example.com/api/v1/savings/ingest"
119        );
120    }
121
122    #[test]
123    fn push_error_display_is_actionable() {
124        assert!(PushError::Empty.to_string().contains("empty"));
125        assert!(PushError::Unauthorized.to_string().contains("401/403"));
126        assert!(
127            PushError::Rejected {
128                status: 500,
129                body: "boom".into()
130            }
131            .to_string()
132            .contains("500")
133        );
134    }
135}