Skip to main content

klieo_runlog/
store.rs

1//! `RunLogStore` trait and in-memory default implementation.
2
3use crate::error::RunLogError;
4use crate::types::{RunLog, RunStatus};
5use async_trait::async_trait;
6use klieo_core::ids::RunId;
7use std::collections::HashMap;
8use tokio::sync::Mutex;
9
10/// Filter + pagination for [`RunLogStore::list`].
11///
12/// `status` is part of the query (not a post-filter) so it composes correctly
13/// with `limit`/`offset`: filtering after a row limit would silently drop
14/// matching runs that fell outside the first page.
15#[derive(Debug, Clone, Default)]
16pub struct RunLogQuery {
17    /// Exact agent-name match when set.
18    pub agent: Option<String>,
19    /// Run-status match when set.
20    pub status: Option<RunStatus>,
21    /// Maximum number of runs to return (most-recent first). `None` means no
22    /// limit. The `Default` is `None`, so a `..Default::default()` query is
23    /// never silently capped at zero rows.
24    pub limit: Option<usize>,
25    /// Number of leading runs to skip (pagination).
26    pub offset: usize,
27}
28
29impl RunLogQuery {
30    /// A query for the most-recent `limit` runs with no agent/status filter and
31    /// no offset.
32    pub fn recent(limit: usize) -> Self {
33        Self {
34            limit: Some(limit),
35            ..Self::default()
36        }
37    }
38}
39
40/// Pluggable RunLog persistence trait.
41///
42/// Implementations must be `Send + Sync`. The default in-process implementation
43/// is [`InMemoryRunLogStore`]; a SQLite implementation lives behind the
44/// `sqlite` feature.
45///
46/// ## Visibility / multi-tenancy
47///
48/// `RunLogStore` is **single-tenant by default**. `get(run_id)` returns any
49/// run keyed by that id regardless of caller identity, and `list` enumerates
50/// the global view filtered only by agent name. There is no built-in tenant
51/// scope, ACL, or row-level authorisation.
52///
53/// Multi-tenant deployments MUST partition the store per tenant (separate
54/// instance per tenant, or a wrapper that prefixes/filters `run_id` and
55/// `agent`) **or** layer authorisation above the trait so `get`/`list`
56/// callers cannot reach runs they are not entitled to read.
57///
58/// ### Cost side-channel
59///
60/// A populated `cost_estimate` lets a reader infer the prompt + completion
61/// token volume of any run they can `get`. Combined with a published rate
62/// table (e.g. the built-in [`crate::pricing::PriceTable::with_default_rates`]),
63/// they can back-derive the `(provider, model, token-count)` tuple — a real
64/// CWE-203 / CWE-200 information-disclosure surface in shared-store
65/// deployments. Implementations targeting that threat model should redact
66/// `cost_estimate` (or the whole `RunLog`) at the authorisation layer
67/// before returning it to unauthorised callers.
68#[async_trait]
69pub trait RunLogStore: Send + Sync {
70    /// Persist (insert or replace) a `RunLog` keyed by its `run_id`.
71    async fn put(&self, run_log: &RunLog) -> Result<(), RunLogError>;
72
73    /// Fetch a `RunLog` by `run_id`. `Ok(None)` when not present.
74    async fn get(&self, run_id: RunId) -> Result<Option<RunLog>, RunLogError>;
75
76    /// List runs most-recent-first, filtered and paginated per [`RunLogQuery`].
77    /// All filters (agent, status) are applied before `limit`/`offset` so the
78    /// returned page reflects the full filtered set, not a post-filtered slice.
79    async fn list(&self, query: &RunLogQuery) -> Result<Vec<RunLog>, RunLogError>;
80
81    /// Remove a run.
82    async fn delete(&self, run_id: RunId) -> Result<(), RunLogError>;
83}
84
85/// In-process, in-memory `RunLogStore`. The default when no backend is wired.
86#[derive(Default)]
87pub struct InMemoryRunLogStore {
88    inner: Mutex<HashMap<RunId, RunLog>>,
89}
90
91impl InMemoryRunLogStore {
92    /// Construct an empty store.
93    pub fn new() -> Self {
94        Self::default()
95    }
96}
97
98#[async_trait]
99impl RunLogStore for InMemoryRunLogStore {
100    async fn put(&self, run_log: &RunLog) -> Result<(), RunLogError> {
101        let mut g = self.inner.lock().await;
102        g.insert(run_log.run_id, run_log.clone());
103        Ok(())
104    }
105
106    async fn get(&self, run_id: RunId) -> Result<Option<RunLog>, RunLogError> {
107        let g = self.inner.lock().await;
108        Ok(g.get(&run_id).cloned())
109    }
110
111    async fn list(&self, query: &RunLogQuery) -> Result<Vec<RunLog>, RunLogError> {
112        let g = self.inner.lock().await;
113        let mut rows: Vec<RunLog> = g
114            .values()
115            .filter(|r| query.agent.as_deref().is_none_or(|a| r.agent == a))
116            .filter(|r| query.status.is_none_or(|s| r.status == s))
117            .cloned()
118            .collect();
119        rows.sort_by_key(|row| std::cmp::Reverse(row.started_at));
120        Ok(rows
121            .into_iter()
122            .skip(query.offset)
123            .take(query.limit.unwrap_or(usize::MAX))
124            .collect())
125    }
126
127    async fn delete(&self, run_id: RunId) -> Result<(), RunLogError> {
128        let mut g = self.inner.lock().await;
129        g.remove(&run_id);
130        Ok(())
131    }
132}