1use 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
10pub 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 _ => "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 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 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 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 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 let loaded = m.replay_many(&[r1, r2, r3]).await.unwrap();
270 assert_eq!(loaded.len(), 3, "one entry per requested run");
271
272 assert_eq!(loaded[0].0, r1);
274 assert_eq!(loaded[1].0, r2);
275 assert_eq!(loaded[2].0, r3);
276
277 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 assert_eq!(loaded[1].1.len(), 3);
284 assert!(matches!(loaded[1].1[1], Episode::LlmCall { tokens: 7, .. }));
285
286 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 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 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 assert_eq!(summaries.len(), 3);
452 assert!(summaries.iter().all(|s| s.agent.contains("alpha")));
453 }
454
455 #[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}