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}