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 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 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 #[tokio::test]
183 async fn record_then_replay_round_trips() {
184 let m = fresh().await;
185 let run = RunId::new();
186 m.record(
187 run,
188 Episode::Started {
189 agent: "test".into(),
190 },
191 )
192 .await
193 .unwrap();
194 m.record(
195 run,
196 Episode::LlmCall {
197 tokens: 42,
198 latency_ms: 10,
199 },
200 )
201 .await
202 .unwrap();
203 m.record(run, Episode::Completed).await.unwrap();
204 let replay = m.replay(run).await.unwrap();
205 assert_eq!(replay.len(), 3);
206 assert!(matches!(replay[0], Episode::Started { .. }));
207 assert!(matches!(replay[1], Episode::LlmCall { tokens: 42, .. }));
208 assert!(matches!(replay[2], Episode::Completed));
209 }
210
211 #[tokio::test]
212 async fn replay_unknown_run_returns_empty() {
213 let m = fresh().await;
214 let replay = m.replay(RunId::new()).await.unwrap();
215 assert!(replay.is_empty());
216 }
217
218 #[tokio::test]
219 async fn list_runs_returns_summary_per_run() {
220 let m = fresh().await;
221 let r1 = RunId::new();
222 let r2 = RunId::new();
223 m.record(
224 r1,
225 Episode::Started {
226 agent: "alpha".into(),
227 },
228 )
229 .await
230 .unwrap();
231 m.record(r1, Episode::Completed).await.unwrap();
232 m.record(
233 r2,
234 Episode::Started {
235 agent: "beta".into(),
236 },
237 )
238 .await
239 .unwrap();
240 let summaries = m.list_runs(RunFilter::default()).await.unwrap();
241 assert_eq!(summaries.len(), 2);
242 let agents: Vec<_> = summaries.iter().map(|s| s.agent.as_str()).collect();
243 assert!(agents.contains(&"alpha"));
244 assert!(agents.contains(&"beta"));
245 }
246
247 #[tokio::test]
248 async fn list_runs_filters_by_agent_substring() {
249 let m = fresh().await;
250 m.record(
251 RunId::new(),
252 Episode::Started {
253 agent: "alpha-1".into(),
254 },
255 )
256 .await
257 .unwrap();
258 m.record(
259 RunId::new(),
260 Episode::Started {
261 agent: "beta-1".into(),
262 },
263 )
264 .await
265 .unwrap();
266 let filter = RunFilter {
267 agent: Some("alpha".into()),
268 ..Default::default()
269 };
270 let summaries = m.list_runs(filter).await.unwrap();
271 assert_eq!(summaries.len(), 1);
272 assert_eq!(summaries[0].agent, "alpha-1");
273 }
274
275 #[tokio::test]
276 async fn list_runs_respects_limit() {
277 let m = fresh().await;
278 for _ in 0..5 {
279 m.record(RunId::new(), Episode::Started { agent: "x".into() })
280 .await
281 .unwrap();
282 }
283 let filter = RunFilter {
284 limit: Some(2),
285 ..Default::default()
286 };
287 let summaries = m.list_runs(filter).await.unwrap();
288 assert_eq!(summaries.len(), 2);
289 }
290
291 #[tokio::test]
292 async fn list_runs_limit_counts_post_filter() {
293 let m = fresh().await;
294 for _ in 0..5 {
296 m.record(
297 RunId::new(),
298 Episode::Started {
299 agent: "alpha".into(),
300 },
301 )
302 .await
303 .unwrap();
304 }
305 for _ in 0..5 {
306 m.record(
307 RunId::new(),
308 Episode::Started {
309 agent: "beta".into(),
310 },
311 )
312 .await
313 .unwrap();
314 }
315 let filter = RunFilter {
316 agent: Some("alpha".into()),
317 limit: Some(3),
318 ..Default::default()
319 };
320 let summaries = m.list_runs(filter).await.unwrap();
321 assert_eq!(summaries.len(), 3);
324 assert!(summaries.iter().all(|s| s.agent.contains("alpha")));
325 }
326
327 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
333 async fn concurrent_record_into_same_run_does_not_collide() {
334 let m = Arc::new(fresh().await);
335 let run = RunId::new();
336 let n = 100u32;
337 let mut handles = Vec::with_capacity(n as usize);
338 for _ in 0..n {
339 let m = Arc::clone(&m);
340 let run = run.clone();
341 handles.push(tokio::spawn(async move {
342 m.record(
343 run,
344 Episode::LlmCall {
345 tokens: 100,
346 latency_ms: 5,
347 },
348 )
349 .await
350 }));
351 }
352 for h in handles {
353 h.await.expect("task did not panic").expect("record ok");
354 }
355 let replayed = m.replay(run).await.unwrap();
356 assert_eq!(replayed.len(), n as usize, "every record must persist");
357 }
358}
359
360#[cfg(test)]
362use std::sync::Arc;