klieo-runlog 0.41.1

Tier 2 observability — RunLog aggregate + replay engine for klieo agents.
Documentation
//! `RunLogStore` trait and in-memory default implementation.

use crate::error::RunLogError;
use crate::types::RunLog;
use async_trait::async_trait;
use klieo_core::ids::RunId;
use std::collections::HashMap;
use tokio::sync::Mutex;

/// Pluggable RunLog persistence trait.
///
/// Implementations must be `Send + Sync`. The default in-process implementation
/// is [`InMemoryRunLogStore`]; a SQLite implementation lives behind the
/// `sqlite` feature.
///
/// ## Visibility / multi-tenancy
///
/// `RunLogStore` is **single-tenant by default**. `get(run_id)` returns any
/// run keyed by that id regardless of caller identity, and `list` enumerates
/// the global view filtered only by agent name. There is no built-in tenant
/// scope, ACL, or row-level authorisation.
///
/// Multi-tenant deployments MUST partition the store per tenant (separate
/// instance per tenant, or a wrapper that prefixes/filters `run_id` and
/// `agent`) **or** layer authorisation above the trait so `get`/`list`
/// callers cannot reach runs they are not entitled to read.
///
/// ### Cost side-channel
///
/// A populated `cost_estimate` lets a reader infer the prompt + completion
/// token volume of any run they can `get`. Combined with a published rate
/// table (e.g. the built-in [`crate::pricing::PriceTable::with_default_rates`]),
/// they can back-derive the `(provider, model, token-count)` tuple — a real
/// CWE-203 / CWE-200 information-disclosure surface in shared-store
/// deployments. Implementations targeting that threat model should redact
/// `cost_estimate` (or the whole `RunLog`) at the authorisation layer
/// before returning it to unauthorised callers.
#[async_trait]
pub trait RunLogStore: Send + Sync {
    /// Persist (insert or replace) a `RunLog` keyed by its `run_id`.
    async fn put(&self, run_log: &RunLog) -> Result<(), RunLogError>;

    /// Fetch a `RunLog` by `run_id`. `Ok(None)` when not present.
    async fn get(&self, run_id: RunId) -> Result<Option<RunLog>, RunLogError>;

    /// List the most recent `limit` runs, optionally filtered by agent name (exact match).
    async fn list(&self, agent: Option<&str>, limit: usize) -> Result<Vec<RunLog>, RunLogError>;

    /// Remove a run.
    async fn delete(&self, run_id: RunId) -> Result<(), RunLogError>;
}

/// In-process, in-memory `RunLogStore`. The default when no backend is wired.
#[derive(Default)]
pub struct InMemoryRunLogStore {
    inner: Mutex<HashMap<RunId, RunLog>>,
}

impl InMemoryRunLogStore {
    /// Construct an empty store.
    pub fn new() -> Self {
        Self::default()
    }
}

#[async_trait]
impl RunLogStore for InMemoryRunLogStore {
    async fn put(&self, run_log: &RunLog) -> Result<(), RunLogError> {
        let mut g = self.inner.lock().await;
        g.insert(run_log.run_id, run_log.clone());
        Ok(())
    }

    async fn get(&self, run_id: RunId) -> Result<Option<RunLog>, RunLogError> {
        let g = self.inner.lock().await;
        Ok(g.get(&run_id).cloned())
    }

    async fn list(&self, agent: Option<&str>, limit: usize) -> Result<Vec<RunLog>, RunLogError> {
        let g = self.inner.lock().await;
        let mut rows: Vec<RunLog> = g
            .values()
            .filter(|r| match agent {
                Some(a) => r.agent == a,
                None => true,
            })
            .cloned()
            .collect();
        rows.sort_by(|a, b| b.started_at.cmp(&a.started_at));
        rows.truncate(limit);
        Ok(rows)
    }

    async fn delete(&self, run_id: RunId) -> Result<(), RunLogError> {
        let mut g = self.inner.lock().await;
        g.remove(&run_id);
        Ok(())
    }
}