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 replay_many(&self, runs: &[RunId]) -> Result<Vec<(RunId, Vec<Episode>)>, MemoryError> {
95        if runs.is_empty() {
96            return Ok(Vec::new());
97        }
98        let run_id_strs: Vec<String> = runs.iter().map(|run| run.to_string()).collect();
99        let placeholders = vec!["?"; run_id_strs.len()].join(",");
100        let sql = format!(
101            "SELECT run_id, payload FROM episodes \
102             WHERE run_id IN ({placeholders}) ORDER BY run_id ASC, seq ASC"
103        );
104        let bind = run_id_strs.clone();
105        let rows: Vec<(String, String)> = self
106            .db
107            .execute(move |conn| {
108                let mut stmt = conn.prepare(&sql)?;
109                let params = rusqlite::params_from_iter(bind.iter());
110                let results = stmt
111                    .query_map(params, |r| {
112                        Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
113                    })?
114                    .collect::<Result<Vec<_>, _>>()?;
115                Ok(results)
116            })
117            .await?;
118
119        let mut episodes_by_run: std::collections::HashMap<String, Vec<Episode>> =
120            std::collections::HashMap::new();
121        for (run_id_str, payload) in rows {
122            let episode = serde_json::from_str::<Episode>(&payload)
123                .map_err(|e| MemoryError::Serialization(e.to_string()))?;
124            episodes_by_run.entry(run_id_str).or_default().push(episode);
125        }
126
127        // Preserve the requested order; runs with no rows yield an empty Vec
128        // so the caller's node set stays complete.
129        let mut out = Vec::with_capacity(runs.len());
130        for (run, run_id_str) in runs.iter().zip(run_id_strs) {
131            let episodes = episodes_by_run.remove(&run_id_str).unwrap_or_default();
132            out.push((*run, episodes));
133        }
134        Ok(out)
135    }
136
137    async fn list_runs(&self, filter: RunFilter) -> Result<Vec<RunSummary>, MemoryError> {
138        let RunFilter {
139            agent: agent_filter,
140            since,
141            until,
142            limit,
143        } = filter;
144        let limit_i64 = limit.map(|n| n as i64).unwrap_or(i64::MAX);
145        let since_str = since.map(|t| t.to_rfc3339());
146        let until_str = until.map(|t| t.to_rfc3339());
147
148        // Push agent-substring filter into the HAVING clause so SQL's
149        // LIMIT counts only matching rows, preserving the documented
150        // "max results" semantics.
151        let rows: Vec<(String, String, Option<String>, i64, Option<String>)> = self
152            .db
153            .execute(move |conn| {
154                let mut stmt = conn.prepare(
155                    r#"
156                    SELECT
157                        run_id,
158                        MIN(ts) AS started_at,
159                        MAX(CASE WHEN kind IN ('completed', 'failed') THEN ts END) AS finished_ts,
160                        COUNT(*) AS episode_count,
161                        (SELECT json_extract(payload, '$.Started.agent')
162                         FROM episodes e2
163                         WHERE e2.run_id = episodes.run_id AND e2.kind = 'started'
164                         ORDER BY seq ASC LIMIT 1) AS agent
165                    FROM episodes
166                    WHERE (?1 IS NULL OR ts >= ?1)
167                      AND (?2 IS NULL OR ts <= ?2)
168                    GROUP BY run_id
169                    HAVING (?4 IS NULL OR (agent IS NOT NULL AND agent LIKE '%' || ?4 || '%'))
170                    ORDER BY started_at DESC
171                    LIMIT ?3
172                    "#,
173                )?;
174                let iter = stmt.query_map(
175                    rusqlite::params![&since_str, &until_str, limit_i64, &agent_filter],
176                    |row| {
177                        Ok((
178                            row.get::<_, String>(0)?,
179                            row.get::<_, String>(1)?,
180                            row.get::<_, Option<String>>(2)?,
181                            row.get::<_, i64>(3)?,
182                            row.get::<_, Option<String>>(4)?,
183                        ))
184                    },
185                )?;
186                iter.collect::<Result<Vec<_>, _>>()
187            })
188            .await?;
189
190        let mut out = Vec::new();
191        for (run_id_str, started_at, finished_ts, count, agent) in rows {
192            let agent_name = agent.unwrap_or_default();
193            let started_at = started_at
194                .parse::<DateTime<Utc>>()
195                .map_err(|e| MemoryError::Serialization(format!("started_at: {e}")))?;
196            // Only completed/failed runs report `finished_at` (matches the trait
197            // doc + the Neo4j backend); an in-progress run leaves it `None`.
198            let finished_at = finished_ts
199                .map(|ts| ts.parse::<DateTime<Utc>>())
200                .transpose()
201                .map_err(|e| MemoryError::Serialization(format!("finished_at: {e}")))?;
202            let run_id = run_id_str
203                .parse::<ulid::Ulid>()
204                .map(klieo_core::ids::RunId)
205                .map_err(|e| MemoryError::Serialization(format!("run_id: {e}")))?;
206            out.push(RunSummary {
207                run_id,
208                agent: agent_name,
209                started_at,
210                finished_at,
211                episode_count: count as u32,
212            });
213        }
214        Ok(out)
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221    use klieo_core::ids::RunId;
222
223    async fn fresh() -> SqliteEpisodic {
224        let db = DbHandle::open(":memory:").await.unwrap();
225        SqliteEpisodic::new(db)
226    }
227
228    use std::sync::Arc;
229
230    #[tokio::test]
231    async fn record_then_replay_round_trips() {
232        let m = fresh().await;
233        let run = RunId::new();
234        m.record(
235            run,
236            Episode::Started {
237                agent: "test".into(),
238            },
239        )
240        .await
241        .unwrap();
242        m.record(run, Episode::llm_call(42, 10)).await.unwrap();
243        m.record(run, Episode::Completed).await.unwrap();
244        let replay = m.replay(run).await.unwrap();
245        assert_eq!(replay.len(), 3);
246        assert!(matches!(replay[0], Episode::Started { .. }));
247        assert!(matches!(replay[1], Episode::LlmCall { tokens: 42, .. }));
248        assert!(matches!(replay[2], Episode::Completed));
249    }
250
251    #[tokio::test]
252    async fn replay_many_returns_episodes_for_each_run() {
253        let m = fresh().await;
254        let r1 = RunId::new();
255        let r2 = RunId::new();
256        let r3 = RunId::new();
257
258        m.record(r1, Episode::Started { agent: "a1".into() })
259            .await
260            .unwrap();
261        m.record(r1, Episode::Completed).await.unwrap();
262        m.record(r2, Episode::Started { agent: "a2".into() })
263            .await
264            .unwrap();
265        m.record(r2, Episode::llm_call(7, 3)).await.unwrap();
266        m.record(r2, Episode::Completed).await.unwrap();
267        // r3 has no episodes recorded.
268
269        let loaded = m.replay_many(&[r1, r2, r3]).await.unwrap();
270        assert_eq!(loaded.len(), 3, "one entry per requested run");
271
272        // Requested order is preserved.
273        assert_eq!(loaded[0].0, r1);
274        assert_eq!(loaded[1].0, r2);
275        assert_eq!(loaded[2].0, r3);
276
277        // r1 episodes in sequence order.
278        assert_eq!(loaded[0].1.len(), 2);
279        assert!(matches!(loaded[0].1[0], Episode::Started { .. }));
280        assert!(matches!(loaded[0].1[1], Episode::Completed));
281
282        // r2 episodes in sequence order.
283        assert_eq!(loaded[1].1.len(), 3);
284        assert!(matches!(loaded[1].1[1], Episode::LlmCall { tokens: 7, .. }));
285
286        // r3 has no episodes → empty Vec, not omitted.
287        assert!(
288            loaded[2].1.is_empty(),
289            "run with no episodes returns empty Vec"
290        );
291    }
292
293    #[tokio::test]
294    async fn replay_many_with_no_runs_returns_empty() {
295        let m = fresh().await;
296        let loaded = m.replay_many(&[]).await.unwrap();
297        assert!(loaded.is_empty());
298    }
299
300    #[tokio::test]
301    async fn replay_unknown_run_returns_empty() {
302        let m = fresh().await;
303        let replay = m.replay(RunId::new()).await.unwrap();
304        assert!(replay.is_empty());
305    }
306
307    #[tokio::test]
308    async fn list_runs_returns_summary_per_run() {
309        let m = fresh().await;
310        let r1 = RunId::new();
311        let r2 = RunId::new();
312        m.record(
313            r1,
314            Episode::Started {
315                agent: "alpha".into(),
316            },
317        )
318        .await
319        .unwrap();
320        m.record(r1, Episode::Completed).await.unwrap();
321        m.record(
322            r2,
323            Episode::Started {
324                agent: "beta".into(),
325            },
326        )
327        .await
328        .unwrap();
329        let summaries = m.list_runs(RunFilter::default()).await.unwrap();
330        assert_eq!(summaries.len(), 2);
331        let agents: Vec<_> = summaries.iter().map(|s| s.agent.as_str()).collect();
332        assert!(agents.contains(&"alpha"));
333        assert!(agents.contains(&"beta"));
334        // alpha completed → finished_at set; beta is still in-progress → None.
335        let alpha = summaries.iter().find(|s| s.agent == "alpha").unwrap();
336        let beta = summaries.iter().find(|s| s.agent == "beta").unwrap();
337        assert!(
338            alpha.finished_at.is_some(),
339            "completed run reports finished_at"
340        );
341        assert!(
342            beta.finished_at.is_none(),
343            "in-progress run leaves finished_at None"
344        );
345    }
346
347    #[tokio::test]
348    async fn list_runs_reports_finished_at_for_failed_run() {
349        let m = fresh().await;
350        let r = RunId::new();
351        m.record(
352            r,
353            Episode::Started {
354                agent: "gamma".into(),
355            },
356        )
357        .await
358        .unwrap();
359        m.record(
360            r,
361            Episode::Failed {
362                error: "boom".into(),
363            },
364        )
365        .await
366        .unwrap();
367        let summaries = m.list_runs(RunFilter::default()).await.unwrap();
368        let gamma = summaries.iter().find(|s| s.agent == "gamma").unwrap();
369        assert!(
370            gamma.finished_at.is_some(),
371            "failed run also reports finished_at"
372        );
373    }
374
375    #[tokio::test]
376    async fn list_runs_filters_by_agent_substring() {
377        let m = fresh().await;
378        m.record(
379            RunId::new(),
380            Episode::Started {
381                agent: "alpha-1".into(),
382            },
383        )
384        .await
385        .unwrap();
386        m.record(
387            RunId::new(),
388            Episode::Started {
389                agent: "beta-1".into(),
390            },
391        )
392        .await
393        .unwrap();
394        let filter = RunFilter {
395            agent: Some("alpha".into()),
396            ..Default::default()
397        };
398        let summaries = m.list_runs(filter).await.unwrap();
399        assert_eq!(summaries.len(), 1);
400        assert_eq!(summaries[0].agent, "alpha-1");
401    }
402
403    #[tokio::test]
404    async fn list_runs_respects_limit() {
405        let m = fresh().await;
406        for _ in 0..5 {
407            m.record(RunId::new(), Episode::Started { agent: "x".into() })
408                .await
409                .unwrap();
410        }
411        let filter = RunFilter {
412            limit: Some(2),
413            ..Default::default()
414        };
415        let summaries = m.list_runs(filter).await.unwrap();
416        assert_eq!(summaries.len(), 2);
417    }
418
419    #[tokio::test]
420    async fn list_runs_limit_counts_post_filter() {
421        let m = fresh().await;
422        // 5 alpha runs + 5 beta runs.
423        for _ in 0..5 {
424            m.record(
425                RunId::new(),
426                Episode::Started {
427                    agent: "alpha".into(),
428                },
429            )
430            .await
431            .unwrap();
432        }
433        for _ in 0..5 {
434            m.record(
435                RunId::new(),
436                Episode::Started {
437                    agent: "beta".into(),
438                },
439            )
440            .await
441            .unwrap();
442        }
443        let filter = RunFilter {
444            agent: Some("alpha".into()),
445            limit: Some(3),
446            ..Default::default()
447        };
448        let summaries = m.list_runs(filter).await.unwrap();
449        // Limit is "max post-filter", so we should get exactly 3 alpha
450        // runs even though there are 5 matching.
451        assert_eq!(summaries.len(), 3);
452        assert!(summaries.iter().all(|s| s.agent.contains("alpha")));
453    }
454
455    /// W2.A8 / round-1 HIGH: concurrent `record()` calls into the same
456    /// run must not race on the SELECT-MAX(seq)+INSERT path. Before
457    /// switching to BEGIN IMMEDIATE this test was flaky — two DEFERRED
458    /// transactions would both read the same MAX, both attempt INSERT
459    /// seq=N+1, and one would fail on PRIMARY KEY (run_id, seq).
460    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
461    async fn concurrent_record_into_same_run_does_not_collide() {
462        let m = Arc::new(fresh().await);
463        let run = RunId::new();
464        let n = 100u32;
465        let mut handles = Vec::with_capacity(n as usize);
466        for _ in 0..n {
467            let m = Arc::clone(&m);
468            handles.push(tokio::spawn(async move {
469                m.record(run, Episode::llm_call(100, 5)).await
470            }));
471        }
472        for h in handles {
473            h.await.expect("task did not panic").expect("record ok");
474        }
475        let replayed = m.replay(run).await.unwrap();
476        assert_eq!(replayed.len(), n as usize, "every record must persist");
477    }
478}