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_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 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 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 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 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 let loaded = m.replay_many(&[r1, r2, r3]).await.unwrap();
307 assert_eq!(loaded.len(), 3, "one entry per requested run");
308
309 assert_eq!(loaded[0].0, r1);
311 assert_eq!(loaded[1].0, r2);
312 assert_eq!(loaded[2].0, r3);
313
314 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 assert_eq!(loaded[1].1.len(), 3);
321 assert!(matches!(loaded[1].1[1], Episode::LlmCall { tokens: 7, .. }));
322
323 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 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 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 assert_eq!(summaries.len(), 3);
489 assert!(summaries.iter().all(|s| s.agent.contains("alpha")));
490 }
491
492 #[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}