verdant-cache-runtime 0.3.1

Live cache runtime for the verdant agent-loop cache: content-addressed payload store + DDG
Documentation
//! `RemoteStore` — `Store` impl that talks to a `verdant-server`
//! instance over HTTP. Wired so `LiveCache::new(RemoteStore::new(...))`
//! Just Works at every existing call site (verdant-mcp,
//! verdant-proxy), letting a binary swap a local on-disk cache for a
//! shared multi-user cache by configuration alone.
//!
//! Trust model: the server revalidates file roots server-side on
//! every read (see verdant-server's `storage.rs::revalidate_roots`),
//! and the wire `LookupResponse::Hit` now carries the recorded
//! `file_roots` so the client can independently revalidate against
//! its own checkout. This catches the cross-machine drift case where
//! Bob's tree differs from Alice's: the server's filesystem says the
//! entry is fresh, but Bob's local copy has diverged, so client-side
//! revalidation through `LiveCache::lookup_revalidate` must
//! invalidate. The server's check and the client's check are both
//! required for a defensible trust model.

use crate::store::{FileRootSerde, Key, Payload, PayloadMeta, Store, StoreError};
use base64::Engine;
use std::io;
use std::time::Duration;
use verdant_wire::{
    InvalidateUpstreamRequest, InvalidateUpstreamResponse, LookupRequest, LookupResponse,
    PersistRequest, PersistResponse, StatsResponse,
};

#[derive(Debug, Clone)]
pub struct RemoteStoreConfig {
    pub base_url: String,
    pub bearer_token: String,
    pub timeout: Duration,
    /// When true every persist sends `promote_to_shared: true`. The
    /// server still gates promotion on its own root revalidation;
    /// this just opts the client in to requesting it. Defaults to
    /// false so a misconfigured client cannot accidentally publish
    /// per-user bytes into `_shared`.
    pub auto_promote_shared: bool,
    /// Mirrors `LookupRequest::allow_shared`. Default true.
    pub allow_shared_lookup: bool,
}

impl RemoteStoreConfig {
    pub fn new(base_url: impl Into<String>, bearer_token: impl Into<String>) -> Self {
        Self {
            base_url: base_url.into(),
            bearer_token: bearer_token.into(),
            timeout: Duration::from_secs(30),
            auto_promote_shared: false,
            allow_shared_lookup: true,
        }
    }
}

#[derive(Debug)]
pub struct RemoteStore {
    cfg: RemoteStoreConfig,
    agent: ureq::Agent,
}

impl RemoteStore {
    pub fn new(cfg: RemoteStoreConfig) -> Self {
        let agent = ureq::AgentBuilder::new().timeout(cfg.timeout).build();
        Self { cfg, agent }
    }

    fn url(&self, path: &str) -> String {
        let base = self.cfg.base_url.trim_end_matches('/');
        format!("{base}{path}")
    }

    fn auth_header(&self) -> String {
        format!("Bearer {}", self.cfg.bearer_token)
    }

    fn post<R, T>(&self, path: &str, body: &R) -> Result<T, StoreError>
    where
        R: serde::Serialize,
        T: serde::de::DeserializeOwned,
    {
        let outcome = self
            .agent
            .post(&self.url(path))
            .set("Authorization", &self.auth_header())
            .set("Content-Type", "application/json")
            .send_json(serde_json::to_value(body).map_err(StoreError::Meta)?);
        let resp = match outcome {
            Ok(r) => r,
            // 4xx and 5xx are surfaced by ureq as Status errors but the
            // server body still carries the structured wire response
            // (e.g. PersistResponse::QuotaExceeded on 429). Parse it
            // anyway so callers see the typed variant instead of a
            // raw HTTP code.
            Err(ureq::Error::Status(_, r)) => r,
            Err(e) => return Err(remote_err_to_store(e)),
        };
        let parsed: T = resp.into_json().map_err(StoreError::Io)?;
        Ok(parsed)
    }

    pub fn get_stats(&self) -> Result<StatsResponse, StoreError> {
        let resp = self
            .agent
            .get(&self.url("/v1/cache/stats"))
            .set("Authorization", &self.auth_header())
            .call()
            .map_err(remote_err_to_store)?;
        let parsed: StatsResponse = resp.into_json().map_err(StoreError::Io)?;
        Ok(parsed)
    }
}

fn remote_err_to_store(e: ureq::Error) -> StoreError {
    StoreError::Io(io::Error::other(e.to_string()))
}

impl Store for RemoteStore {
    fn persist_with_upstreams(
        &self,
        key: &Key,
        bytes: &[u8],
        tool_kind: &str,
        file_roots: Vec<FileRootSerde>,
        upstream_keys: Vec<String>,
    ) -> Result<(), StoreError> {
        let payload_b64 = base64::engine::general_purpose::STANDARD.encode(bytes);
        let req = PersistRequest {
            key: key.0.clone(),
            tool_kind: tool_kind.to_string(),
            payload: payload_b64,
            file_roots: file_roots
                .into_iter()
                .map(|f| verdant_wire::FileRootSpec {
                    path: f.path,
                    expected_hash: f.expected_hash,
                })
                .collect(),
            upstream_keys,
            promote_to_shared: self.cfg.auto_promote_shared,
        };
        let resp: PersistResponse = self.post("/v1/cache/persist", &req)?;
        match resp {
            PersistResponse::Stored { .. } => Ok(()),
            PersistResponse::Rejected { reason } => Err(StoreError::Io(io::Error::new(
                io::ErrorKind::InvalidData,
                format!("server rejected persist: {reason}"),
            ))),
            PersistResponse::QuotaExceeded {
                bytes_used,
                bytes_quota,
            } => Err(StoreError::Io(io::Error::other(format!(
                "server quota exceeded: used {bytes_used} of {bytes_quota}"
            )))),
        }
    }

    fn lookup(&self, key: &Key) -> Result<Option<Payload>, StoreError> {
        let req = LookupRequest {
            key: key.0.clone(),
            allow_shared: self.cfg.allow_shared_lookup,
        };
        let resp: LookupResponse = self.post("/v1/cache/lookup", &req)?;
        match resp {
            LookupResponse::Hit {
                payload,
                tool_kind,
                bytes,
                file_roots,
                upstream_keys,
                ..
            } => {
                let raw = base64::engine::general_purpose::STANDARD
                    .decode(&payload)
                    .map_err(|e| {
                        StoreError::Io(io::Error::new(
                            io::ErrorKind::InvalidData,
                            format!("base64 decode: {e}"),
                        ))
                    })?;
                let payload_hash = blake3::hash(&raw).to_hex().to_string();
                let file_roots = file_roots
                    .into_iter()
                    .map(|f| FileRootSerde {
                        path: f.path,
                        expected_hash: f.expected_hash,
                    })
                    .collect();
                let meta = PayloadMeta {
                    payload_hash,
                    bytes,
                    tool_kind,
                    file_roots,
                    upstream_keys,
                };
                Ok(Some(Payload { bytes: raw, meta }))
            }
            LookupResponse::Miss | LookupResponse::Invalidated => Ok(None),
        }
    }

    fn remove(&self, key: &Key) -> Result<(), StoreError> {
        // Server has no explicit remove. The closest semantic is the
        // upstream-invalidation hook, which drops the key plus
        // anything that declared it as an upstream — matching the
        // local `Store::remove` contract used by `LiveCache::mark_dirty`.
        let req = InvalidateUpstreamRequest { key: key.0.clone() };
        let _: InvalidateUpstreamResponse = self.post("/v1/cache/invalidate-upstream", &req)?;
        Ok(())
    }

    fn total_bytes(&self) -> Result<u64, StoreError> {
        let s = self.get_stats()?;
        Ok(s.user_bytes_used)
    }

    fn evict_to_cap(&self, _cap_bytes: u64) -> Result<usize, StoreError> {
        // Eviction is server-side policy in the multi-user model;
        // the client can no longer make local decisions about which
        // entries to drop because the cache is shared. Return 0 to
        // signal "nothing dropped here" without erroring (callers
        // that periodically call this in the local-cache code path
        // should not start failing when swapped to RemoteStore).
        Ok(0)
    }

    fn iter_meta(&self) -> Result<Vec<(Key, PayloadMeta)>, StoreError> {
        // No bulk iteration over the wire in M4 step 7. LiveCache
        // rehydration relies on this to rebuild the local registry,
        // but with a remote store the per-lookup path is the source
        // of truth and a registry rebuild is not needed: every
        // `lookup` round-trips to the server. Returning empty is
        // safe and correct.
        Ok(Vec::new())
    }

    fn contains(&self, key: &Key) -> bool {
        matches!(self.lookup(key), Ok(Some(_)))
    }
}