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