Skip to main content

klieo_memory_sqlite/
episodic.rs

1//! `SqliteEpisodic` — `EpisodicMemory` over a SQLite table.
2
3use crate::connection::DbHandle;
4use async_trait::async_trait;
5use chrono::{DateTime, Utc};
6use klieo_core::error::MemoryError;
7use klieo_core::ids::RunId;
8use klieo_core::memory::{Episode, EpisodicMemory, RunFilter, RunSummary};
9
10/// SQLite-backed episodic event log.
11pub struct SqliteEpisodic {
12    db: DbHandle,
13}
14
15impl SqliteEpisodic {
16    pub(crate) fn new(db: DbHandle) -> Self {
17        Self { db }
18    }
19}
20
21fn episode_kind(ep: &Episode) -> &'static str {
22    match ep {
23        Episode::Started { .. } => "started",
24        Episode::LlmCall { .. } => "llm_call",
25        Episode::ToolCall { .. } => "tool_call",
26        Episode::BusPublish { .. } => "bus_publish",
27        Episode::BusReceive { .. } => "bus_receive",
28        Episode::Completed => "completed",
29        Episode::Failed { .. } => "failed",
30        Episode::SummaryCheckpoint { .. } => "summary_checkpoint",
31        // `Episode` is `#[non_exhaustive]`; future additive variants
32        // get a generic kind label until explicitly handled.
33        _ => "unknown",
34    }
35}
36
37#[async_trait]
38impl EpisodicMemory for SqliteEpisodic {
39    async fn record(&self, run: RunId, event: Episode) -> Result<(), MemoryError> {
40        let kind = episode_kind(&event).to_string();
41        let payload =
42            serde_json::to_string(&event).map_err(|e| MemoryError::Serialization(e.to_string()))?;
43        let now = Utc::now().to_rfc3339();
44        let run_id = run.to_string();
45        self.db
46            .execute(move |conn| {
47                // W2.A8: BEGIN IMMEDIATE so the write lock is acquired
48                // before the MAX(seq) read. The default DEFERRED tx
49                // promotes to a writer only at INSERT time, leaving a
50                // race window where two concurrent `record()` calls
51                // both read the same MAX, both try INSERT seq=N+1, and
52                // the second fails on PRIMARY KEY (run_id, seq) once
53                // the writer lock changes hands.
54                let tx = conn.transaction_with_behavior(
55                    rusqlite::TransactionBehavior::Immediate,
56                )?;
57                let next_seq: i64 = tx.query_row(
58                    "SELECT COALESCE(MAX(seq), 0) + 1 FROM episodes WHERE run_id = ?1",
59                    rusqlite::params![&run_id],
60                    |r| r.get(0),
61                )?;
62                tx.execute(
63                    "INSERT INTO episodes (run_id, seq, kind, payload, ts) VALUES (?1, ?2, ?3, ?4, ?5)",
64                    rusqlite::params![&run_id, next_seq, &kind, &payload, &now],
65                )?;
66                tx.commit()?;
67                Ok(())
68            })
69            .await
70    }
71
72    async fn replay(&self, run: RunId) -> Result<Vec<Episode>, MemoryError> {
73        let run_id = run.to_string();
74        let payloads: Vec<String> = self
75            .db
76            .execute(move |conn| {
77                let mut stmt = conn
78                    .prepare("SELECT payload FROM episodes WHERE run_id = ?1 ORDER BY seq ASC")?;
79                let results = stmt
80                    .query_map(rusqlite::params![&run_id], |r| r.get::<_, String>(0))?
81                    .collect::<Result<Vec<_>, _>>()?;
82                Ok(results)
83            })
84            .await?;
85        payloads
86            .into_iter()
87            .map(|p| {
88                serde_json::from_str::<Episode>(&p)
89                    .map_err(|e| MemoryError::Serialization(e.to_string()))
90            })
91            .collect()
92    }
93
94    async fn list_runs(&self, filter: RunFilter) -> Result<Vec<RunSummary>, MemoryError> {
95        let RunFilter {
96            agent: agent_filter,
97            since,
98            until,
99            limit,
100        } = filter;
101        let limit_i64 = limit.map(|n| n as i64).unwrap_or(i64::MAX);
102        let since_str = since.map(|t| t.to_rfc3339());
103        let until_str = until.map(|t| t.to_rfc3339());
104
105        // Push agent-substring filter into the HAVING clause so SQL's
106        // LIMIT counts only matching rows, preserving the documented
107        // "max results" semantics.
108        let rows: Vec<(String, String, String, i64, Option<String>)> = self
109            .db
110            .execute(move |conn| {
111                let mut stmt = conn.prepare(
112                    r#"
113                    SELECT
114                        run_id,
115                        MIN(ts) AS started_at,
116                        MAX(ts) AS last_ts,
117                        COUNT(*) AS episode_count,
118                        (SELECT json_extract(payload, '$.Started.agent')
119                         FROM episodes e2
120                         WHERE e2.run_id = episodes.run_id AND e2.kind = 'started'
121                         ORDER BY seq ASC LIMIT 1) AS agent
122                    FROM episodes
123                    WHERE (?1 IS NULL OR ts >= ?1)
124                      AND (?2 IS NULL OR ts <= ?2)
125                    GROUP BY run_id
126                    HAVING (?4 IS NULL OR (agent IS NOT NULL AND agent LIKE '%' || ?4 || '%'))
127                    ORDER BY started_at DESC
128                    LIMIT ?3
129                    "#,
130                )?;
131                let iter = stmt.query_map(
132                    rusqlite::params![&since_str, &until_str, limit_i64, &agent_filter],
133                    |row| {
134                        Ok((
135                            row.get::<_, String>(0)?,
136                            row.get::<_, String>(1)?,
137                            row.get::<_, String>(2)?,
138                            row.get::<_, i64>(3)?,
139                            row.get::<_, Option<String>>(4)?,
140                        ))
141                    },
142                )?;
143                iter.collect::<Result<Vec<_>, _>>()
144            })
145            .await?;
146
147        let mut out = Vec::new();
148        for (run_id_str, started_at, last_ts, count, agent) in rows {
149            let agent_name = agent.unwrap_or_default();
150            let started_at = started_at
151                .parse::<DateTime<Utc>>()
152                .map_err(|e| MemoryError::Serialization(format!("started_at: {e}")))?;
153            let last_ts = last_ts
154                .parse::<DateTime<Utc>>()
155                .map_err(|e| MemoryError::Serialization(format!("last_ts: {e}")))?;
156            let run_id = run_id_str
157                .parse::<ulid::Ulid>()
158                .map(klieo_core::ids::RunId)
159                .map_err(|e| MemoryError::Serialization(format!("run_id: {e}")))?;
160            out.push(RunSummary {
161                run_id,
162                agent: agent_name,
163                started_at,
164                finished_at: Some(last_ts),
165                episode_count: count as u32,
166            });
167        }
168        Ok(out)
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use klieo_core::ids::RunId;
176
177    async fn fresh() -> SqliteEpisodic {
178        let db = DbHandle::open(":memory:").await.unwrap();
179        SqliteEpisodic::new(db)
180    }
181
182    #[tokio::test]
183    async fn record_then_replay_round_trips() {
184        let m = fresh().await;
185        let run = RunId::new();
186        m.record(
187            run,
188            Episode::Started {
189                agent: "test".into(),
190            },
191        )
192        .await
193        .unwrap();
194        m.record(
195            run,
196            Episode::LlmCall {
197                tokens: 42,
198                latency_ms: 10,
199            },
200        )
201        .await
202        .unwrap();
203        m.record(run, Episode::Completed).await.unwrap();
204        let replay = m.replay(run).await.unwrap();
205        assert_eq!(replay.len(), 3);
206        assert!(matches!(replay[0], Episode::Started { .. }));
207        assert!(matches!(replay[1], Episode::LlmCall { tokens: 42, .. }));
208        assert!(matches!(replay[2], Episode::Completed));
209    }
210
211    #[tokio::test]
212    async fn replay_unknown_run_returns_empty() {
213        let m = fresh().await;
214        let replay = m.replay(RunId::new()).await.unwrap();
215        assert!(replay.is_empty());
216    }
217
218    #[tokio::test]
219    async fn list_runs_returns_summary_per_run() {
220        let m = fresh().await;
221        let r1 = RunId::new();
222        let r2 = RunId::new();
223        m.record(
224            r1,
225            Episode::Started {
226                agent: "alpha".into(),
227            },
228        )
229        .await
230        .unwrap();
231        m.record(r1, Episode::Completed).await.unwrap();
232        m.record(
233            r2,
234            Episode::Started {
235                agent: "beta".into(),
236            },
237        )
238        .await
239        .unwrap();
240        let summaries = m.list_runs(RunFilter::default()).await.unwrap();
241        assert_eq!(summaries.len(), 2);
242        let agents: Vec<_> = summaries.iter().map(|s| s.agent.as_str()).collect();
243        assert!(agents.contains(&"alpha"));
244        assert!(agents.contains(&"beta"));
245    }
246
247    #[tokio::test]
248    async fn list_runs_filters_by_agent_substring() {
249        let m = fresh().await;
250        m.record(
251            RunId::new(),
252            Episode::Started {
253                agent: "alpha-1".into(),
254            },
255        )
256        .await
257        .unwrap();
258        m.record(
259            RunId::new(),
260            Episode::Started {
261                agent: "beta-1".into(),
262            },
263        )
264        .await
265        .unwrap();
266        let filter = RunFilter {
267            agent: Some("alpha".into()),
268            ..Default::default()
269        };
270        let summaries = m.list_runs(filter).await.unwrap();
271        assert_eq!(summaries.len(), 1);
272        assert_eq!(summaries[0].agent, "alpha-1");
273    }
274
275    #[tokio::test]
276    async fn list_runs_respects_limit() {
277        let m = fresh().await;
278        for _ in 0..5 {
279            m.record(RunId::new(), Episode::Started { agent: "x".into() })
280                .await
281                .unwrap();
282        }
283        let filter = RunFilter {
284            limit: Some(2),
285            ..Default::default()
286        };
287        let summaries = m.list_runs(filter).await.unwrap();
288        assert_eq!(summaries.len(), 2);
289    }
290
291    #[tokio::test]
292    async fn list_runs_limit_counts_post_filter() {
293        let m = fresh().await;
294        // 5 alpha runs + 5 beta runs.
295        for _ in 0..5 {
296            m.record(
297                RunId::new(),
298                Episode::Started {
299                    agent: "alpha".into(),
300                },
301            )
302            .await
303            .unwrap();
304        }
305        for _ in 0..5 {
306            m.record(
307                RunId::new(),
308                Episode::Started {
309                    agent: "beta".into(),
310                },
311            )
312            .await
313            .unwrap();
314        }
315        let filter = RunFilter {
316            agent: Some("alpha".into()),
317            limit: Some(3),
318            ..Default::default()
319        };
320        let summaries = m.list_runs(filter).await.unwrap();
321        // Limit is "max post-filter", so we should get exactly 3 alpha
322        // runs even though there are 5 matching.
323        assert_eq!(summaries.len(), 3);
324        assert!(summaries.iter().all(|s| s.agent.contains("alpha")));
325    }
326
327    /// W2.A8 / round-1 HIGH: concurrent `record()` calls into the same
328    /// run must not race on the SELECT-MAX(seq)+INSERT path. Before
329    /// switching to BEGIN IMMEDIATE this test was flaky — two DEFERRED
330    /// transactions would both read the same MAX, both attempt INSERT
331    /// seq=N+1, and one would fail on PRIMARY KEY (run_id, seq).
332    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
333    async fn concurrent_record_into_same_run_does_not_collide() {
334        let m = Arc::new(fresh().await);
335        let run = RunId::new();
336        let n = 100u32;
337        let mut handles = Vec::with_capacity(n as usize);
338        for _ in 0..n {
339            let m = Arc::clone(&m);
340            let run = run.clone();
341            handles.push(tokio::spawn(async move {
342                m.record(
343                    run,
344                    Episode::LlmCall {
345                        tokens: 100,
346                        latency_ms: 5,
347                    },
348                )
349                .await
350            }));
351        }
352        for h in handles {
353            h.await.expect("task did not panic").expect("record ok");
354        }
355        let replayed = m.replay(run).await.unwrap();
356        assert_eq!(replayed.len(), n as usize, "every record must persist");
357    }
358}
359
360// `Arc` is needed only in the concurrent test above.
361#[cfg(test)]
362use std::sync::Arc;