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    use std::sync::Arc;
183
184    #[tokio::test]
185    async fn record_then_replay_round_trips() {
186        let m = fresh().await;
187        let run = RunId::new();
188        m.record(
189            run,
190            Episode::Started {
191                agent: "test".into(),
192            },
193        )
194        .await
195        .unwrap();
196        m.record(run, Episode::llm_call(42, 10)).await.unwrap();
197        m.record(run, Episode::Completed).await.unwrap();
198        let replay = m.replay(run).await.unwrap();
199        assert_eq!(replay.len(), 3);
200        assert!(matches!(replay[0], Episode::Started { .. }));
201        assert!(matches!(replay[1], Episode::LlmCall { tokens: 42, .. }));
202        assert!(matches!(replay[2], Episode::Completed));
203    }
204
205    #[tokio::test]
206    async fn replay_unknown_run_returns_empty() {
207        let m = fresh().await;
208        let replay = m.replay(RunId::new()).await.unwrap();
209        assert!(replay.is_empty());
210    }
211
212    #[tokio::test]
213    async fn list_runs_returns_summary_per_run() {
214        let m = fresh().await;
215        let r1 = RunId::new();
216        let r2 = RunId::new();
217        m.record(
218            r1,
219            Episode::Started {
220                agent: "alpha".into(),
221            },
222        )
223        .await
224        .unwrap();
225        m.record(r1, Episode::Completed).await.unwrap();
226        m.record(
227            r2,
228            Episode::Started {
229                agent: "beta".into(),
230            },
231        )
232        .await
233        .unwrap();
234        let summaries = m.list_runs(RunFilter::default()).await.unwrap();
235        assert_eq!(summaries.len(), 2);
236        let agents: Vec<_> = summaries.iter().map(|s| s.agent.as_str()).collect();
237        assert!(agents.contains(&"alpha"));
238        assert!(agents.contains(&"beta"));
239    }
240
241    #[tokio::test]
242    async fn list_runs_filters_by_agent_substring() {
243        let m = fresh().await;
244        m.record(
245            RunId::new(),
246            Episode::Started {
247                agent: "alpha-1".into(),
248            },
249        )
250        .await
251        .unwrap();
252        m.record(
253            RunId::new(),
254            Episode::Started {
255                agent: "beta-1".into(),
256            },
257        )
258        .await
259        .unwrap();
260        let filter = RunFilter {
261            agent: Some("alpha".into()),
262            ..Default::default()
263        };
264        let summaries = m.list_runs(filter).await.unwrap();
265        assert_eq!(summaries.len(), 1);
266        assert_eq!(summaries[0].agent, "alpha-1");
267    }
268
269    #[tokio::test]
270    async fn list_runs_respects_limit() {
271        let m = fresh().await;
272        for _ in 0..5 {
273            m.record(RunId::new(), Episode::Started { agent: "x".into() })
274                .await
275                .unwrap();
276        }
277        let filter = RunFilter {
278            limit: Some(2),
279            ..Default::default()
280        };
281        let summaries = m.list_runs(filter).await.unwrap();
282        assert_eq!(summaries.len(), 2);
283    }
284
285    #[tokio::test]
286    async fn list_runs_limit_counts_post_filter() {
287        let m = fresh().await;
288        // 5 alpha runs + 5 beta runs.
289        for _ in 0..5 {
290            m.record(
291                RunId::new(),
292                Episode::Started {
293                    agent: "alpha".into(),
294                },
295            )
296            .await
297            .unwrap();
298        }
299        for _ in 0..5 {
300            m.record(
301                RunId::new(),
302                Episode::Started {
303                    agent: "beta".into(),
304                },
305            )
306            .await
307            .unwrap();
308        }
309        let filter = RunFilter {
310            agent: Some("alpha".into()),
311            limit: Some(3),
312            ..Default::default()
313        };
314        let summaries = m.list_runs(filter).await.unwrap();
315        // Limit is "max post-filter", so we should get exactly 3 alpha
316        // runs even though there are 5 matching.
317        assert_eq!(summaries.len(), 3);
318        assert!(summaries.iter().all(|s| s.agent.contains("alpha")));
319    }
320
321    /// W2.A8 / round-1 HIGH: concurrent `record()` calls into the same
322    /// run must not race on the SELECT-MAX(seq)+INSERT path. Before
323    /// switching to BEGIN IMMEDIATE this test was flaky — two DEFERRED
324    /// transactions would both read the same MAX, both attempt INSERT
325    /// seq=N+1, and one would fail on PRIMARY KEY (run_id, seq).
326    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
327    async fn concurrent_record_into_same_run_does_not_collide() {
328        let m = Arc::new(fresh().await);
329        let run = RunId::new();
330        let n = 100u32;
331        let mut handles = Vec::with_capacity(n as usize);
332        for _ in 0..n {
333            let m = Arc::clone(&m);
334            handles.push(tokio::spawn(async move {
335                m.record(run, Episode::llm_call(100, 5)).await
336            }));
337        }
338        for h in handles {
339            h.await.expect("task did not panic").expect("record ok");
340        }
341        let replayed = m.replay(run).await.unwrap();
342        assert_eq!(replayed.len(), n as usize, "every record must persist");
343    }
344}