klieo-runlog 3.4.0

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, RunStatus};
use async_trait::async_trait;
use klieo_core::ids::RunId;
use std::collections::HashMap;
use tokio::sync::Mutex;

/// Filter + pagination for [`RunLogStore::list`].
///
/// `status` is part of the query (not a post-filter) so it composes correctly
/// with `limit`/`offset`: filtering after a row limit would silently drop
/// matching runs that fell outside the first page.
#[derive(Debug, Clone, Default)]
pub struct RunLogQuery {
    /// Exact agent-name match when set.
    pub agent: Option<String>,
    /// Run-status match when set.
    pub status: Option<RunStatus>,
    /// Maximum number of runs to return (most-recent first). `None` means no
    /// limit. The `Default` is `None`, so a `..Default::default()` query is
    /// never silently capped at zero rows.
    pub limit: Option<usize>,
    /// Number of leading runs to skip (pagination).
    pub offset: usize,
}

impl RunLogQuery {
    /// A query for the most-recent `limit` runs with no agent/status filter and
    /// no offset.
    pub fn recent(limit: usize) -> Self {
        Self {
            limit: Some(limit),
            ..Self::default()
        }
    }
}

/// 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 runs most-recent-first, filtered and paginated per [`RunLogQuery`].
    /// All filters (agent, status) are applied before `limit`/`offset` so the
    /// returned page reflects the full filtered set, not a post-filtered slice.
    async fn list(&self, query: &RunLogQuery) -> 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, query: &RunLogQuery) -> Result<Vec<RunLog>, RunLogError> {
        let g = self.inner.lock().await;
        let mut rows: Vec<RunLog> = g
            .values()
            .filter(|r| query.agent.as_deref().is_none_or(|a| r.agent == a))
            .filter(|r| query.status.is_none_or(|s| r.status == s))
            .cloned()
            .collect();
        rows.sort_by_key(|row| std::cmp::Reverse(row.started_at));
        Ok(rows
            .into_iter()
            .skip(query.offset)
            .take(query.limit.unwrap_or(usize::MAX))
            .collect())
    }

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