1use crate::error::RunLogError;
6use crate::store::{RunLogQuery, RunLogStore};
7use crate::types::{Cost, RunLog, RunStatus, Usage};
8use async_trait::async_trait;
9use chrono::{DateTime, Utc};
10use klieo_core::ids::RunId;
11use rusqlite::{params, Connection, OptionalExtension};
12use std::path::Path;
13use std::sync::Arc;
14use tokio::sync::Mutex;
15
16pub struct SqliteRunLogStore {
18 conn: Arc<Mutex<Connection>>,
19}
20
21impl SqliteRunLogStore {
22 pub async fn new(path: impl AsRef<Path>) -> Result<Self, RunLogError> {
32 let p = path.as_ref().to_path_buf();
33 let conn = tokio::task::spawn_blocking(move || -> Result<Connection, RunLogError> {
34 let c = Connection::open(&p).map_err(|e| RunLogError::Store(e.to_string()))?;
35 c.pragma_update(None, "journal_mode", "WAL")
41 .map_err(|e| RunLogError::Store(e.to_string()))?;
42 c.pragma_update(None, "synchronous", "NORMAL")
43 .map_err(|e| RunLogError::Store(e.to_string()))?;
44 c.execute_batch(
45 "CREATE TABLE IF NOT EXISTS runlog (
46 run_id TEXT PRIMARY KEY,
47 agent TEXT NOT NULL,
48 started_at TEXT NOT NULL,
49 finished_at TEXT,
50 status TEXT NOT NULL,
51 steps TEXT NOT NULL,
52 prompt_tokens INTEGER NOT NULL,
53 completion_tokens INTEGER NOT NULL,
54 cost_total_usd REAL,
55 cost_prompt_usd REAL,
56 cost_completion_usd REAL
57 );
58 CREATE INDEX IF NOT EXISTS runlog_agent_started
59 ON runlog (agent, started_at DESC);",
60 )
61 .map_err(|e| RunLogError::Store(e.to_string()))?;
62 ensure_cost_split_columns(&c)?;
63 Ok(c)
64 })
65 .await
66 .map_err(|e| RunLogError::Store(e.to_string()))??;
67 Ok(Self {
68 conn: Arc::new(Mutex::new(conn)),
69 })
70 }
71}
72
73fn ensure_cost_split_columns(c: &Connection) -> Result<(), RunLogError> {
77 let mut have_prompt = false;
78 let mut have_completion = false;
79 {
80 let mut stmt = c
81 .prepare("PRAGMA table_info(runlog)")
82 .map_err(|e| RunLogError::Store(e.to_string()))?;
83 let rows = stmt
84 .query_map([], |row| row.get::<_, String>(1))
85 .map_err(|e| RunLogError::Store(e.to_string()))?;
86 for r in rows {
87 let name = r.map_err(|e| RunLogError::Store(e.to_string()))?;
88 if name == "cost_prompt_usd" {
89 have_prompt = true;
90 } else if name == "cost_completion_usd" {
91 have_completion = true;
92 }
93 }
94 }
95 if !have_prompt {
96 add_column_if_missing(c, "ALTER TABLE runlog ADD COLUMN cost_prompt_usd REAL")?;
97 }
98 if !have_completion {
99 add_column_if_missing(c, "ALTER TABLE runlog ADD COLUMN cost_completion_usd REAL")?;
100 }
101 Ok(())
102}
103
104fn add_column_if_missing(c: &Connection, sql: &str) -> Result<(), RunLogError> {
112 match c.execute(sql, []) {
113 Ok(_) => Ok(()),
114 Err(e) => {
115 let msg = e.to_string();
116 if msg.contains("duplicate column name") {
117 Ok(())
118 } else {
119 Err(RunLogError::Store(msg))
120 }
121 }
122 }
123}
124
125fn status_to_str(s: RunStatus) -> &'static str {
126 match s {
127 RunStatus::Running => "running",
128 RunStatus::Completed => "completed",
129 RunStatus::Failed => "failed",
130 RunStatus::Cancelled => "cancelled",
131 }
132}
133
134fn status_from_str(s: &str) -> Result<RunStatus, RunLogError> {
135 match s {
136 "running" => Ok(RunStatus::Running),
137 "completed" => Ok(RunStatus::Completed),
138 "failed" => Ok(RunStatus::Failed),
139 "cancelled" => Ok(RunStatus::Cancelled),
140 other => Err(RunLogError::Store(format!("unknown status {other}"))),
141 }
142}
143
144fn parse_run_id(s: &str) -> Result<RunId, RunLogError> {
147 let u = ulid::Ulid::from_string(s)
148 .map_err(|e| RunLogError::Store(format!("bad run_id {s}: {e}")))?;
149 Ok(RunId(u))
150}
151
152#[async_trait]
153impl RunLogStore for SqliteRunLogStore {
154 async fn put(&self, run_log: &RunLog) -> Result<(), RunLogError> {
155 let conn = self.conn.clone();
156 let log = run_log.clone();
157 tokio::task::spawn_blocking(move || -> Result<(), RunLogError> {
158 let steps_json =
159 serde_json::to_string(&log.steps).map_err(|e| RunLogError::Store(e.to_string()))?;
160 let started = log.started_at.to_rfc3339();
161 let finished = log.finished_at.map(|d| d.to_rfc3339());
162 let status = status_to_str(log.status);
163 let cost_total = log.cost_estimate.map(|c| c.total_usd);
164 let cost_prompt = log.cost_estimate.map(|c| c.prompt_usd);
165 let cost_completion = log.cost_estimate.map(|c| c.completion_usd);
166 let g = conn.blocking_lock();
167 let mut stmt = g
168 .prepare_cached(
169 "INSERT OR REPLACE INTO runlog
170 (run_id, agent, started_at, finished_at, status, steps,
171 prompt_tokens, completion_tokens, cost_total_usd,
172 cost_prompt_usd, cost_completion_usd)
173 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
174 )
175 .map_err(|e| RunLogError::Store(e.to_string()))?;
176 stmt.execute(params![
177 log.run_id.to_string(),
178 log.agent,
179 started,
180 finished,
181 status,
182 steps_json,
183 log.tokens.prompt_tokens as i64,
184 log.tokens.completion_tokens as i64,
185 cost_total,
186 cost_prompt,
187 cost_completion,
188 ])
189 .map_err(|e| RunLogError::Store(e.to_string()))?;
190 Ok(())
191 })
192 .await
193 .map_err(|e| RunLogError::Store(e.to_string()))??;
194 Ok(())
195 }
196
197 async fn get(&self, run_id: RunId) -> Result<Option<RunLog>, RunLogError> {
198 let conn = self.conn.clone();
199 let id = run_id.to_string();
200 let row: Option<RunLog> =
201 tokio::task::spawn_blocking(move || -> Result<Option<RunLog>, RunLogError> {
202 let g = conn.blocking_lock();
203 let mut stmt = g
204 .prepare_cached(
205 "SELECT run_id, agent, started_at, finished_at, status, steps,
206 prompt_tokens, completion_tokens, cost_total_usd,
207 cost_prompt_usd, cost_completion_usd
208 FROM runlog WHERE run_id = ?1",
209 )
210 .map_err(|e| RunLogError::Store(e.to_string()))?;
211 let row = stmt
212 .query_row(params![id], row_to_runlog)
213 .optional()
214 .map_err(|e| RunLogError::Store(e.to_string()))?;
215 row.transpose()
216 })
217 .await
218 .map_err(|e| RunLogError::Store(e.to_string()))??;
219 Ok(row)
220 }
221
222 async fn list(&self, query: &RunLogQuery) -> Result<Vec<RunLog>, RunLogError> {
223 let conn = self.conn.clone();
224 let agent = query.agent.clone();
230 let status = query.status.map(|s| status_to_str(s).to_string());
231 let limit_i64 = query
235 .limit
236 .map(|n| i64::try_from(n).unwrap_or(i64::MAX))
237 .unwrap_or(-1);
238 let offset_i64 = query.offset as i64;
239 let rows = tokio::task::spawn_blocking(move || -> Result<Vec<RunLog>, RunLogError> {
240 let g = conn.blocking_lock();
241 let mut stmt = g
242 .prepare_cached(
243 "SELECT run_id, agent, started_at, finished_at, status, steps,
244 prompt_tokens, completion_tokens, cost_total_usd,
245 cost_prompt_usd, cost_completion_usd
246 FROM runlog
247 WHERE (?1 IS NULL OR agent = ?1)
248 AND (?2 IS NULL OR status = ?2)
249 ORDER BY started_at DESC LIMIT ?3 OFFSET ?4",
250 )
251 .map_err(|e| RunLogError::Store(e.to_string()))?;
252 let mapped = stmt
253 .query_map(params![agent, status, limit_i64, offset_i64], row_to_runlog)
254 .map_err(|e| RunLogError::Store(e.to_string()))?;
255 let out: Vec<Result<RunLog, RunLogError>> = mapped
256 .map(|r| r.map_err(|e| RunLogError::Store(e.to_string())))
257 .collect::<Result<Vec<_>, _>>()?;
258 out.into_iter().collect::<Result<Vec<_>, _>>()
259 })
260 .await
261 .map_err(|e| RunLogError::Store(e.to_string()))??;
262 Ok(rows)
263 }
264
265 async fn delete(&self, run_id: RunId) -> Result<(), RunLogError> {
266 let conn = self.conn.clone();
267 let id = run_id.to_string();
268 tokio::task::spawn_blocking(move || -> Result<(), RunLogError> {
269 let g = conn.blocking_lock();
270 let mut stmt = g
271 .prepare_cached("DELETE FROM runlog WHERE run_id = ?1")
272 .map_err(|e| RunLogError::Store(e.to_string()))?;
273 stmt.execute(params![id])
274 .map_err(|e| RunLogError::Store(e.to_string()))?;
275 Ok(())
276 })
277 .await
278 .map_err(|e| RunLogError::Store(e.to_string()))??;
279 Ok(())
280 }
281}
282
283fn row_to_runlog(row: &rusqlite::Row<'_>) -> rusqlite::Result<Result<RunLog, RunLogError>> {
284 let run_id_s: String = row.get(0)?;
285 let agent: String = row.get(1)?;
286 let started_s: String = row.get(2)?;
287 let finished_s: Option<String> = row.get(3)?;
288 let status_s: String = row.get(4)?;
289 let steps_s: String = row.get(5)?;
290 let prompt_tokens: i64 = row.get(6)?;
291 let completion_tokens: i64 = row.get(7)?;
292 let cost_total: Option<f64> = row.get(8)?;
293 let cost_prompt: Option<f64> = row.get(9)?;
300 let cost_completion: Option<f64> = row.get(10)?;
301
302 Ok((|| -> Result<RunLog, RunLogError> {
303 let run_id = parse_run_id(&run_id_s)?;
304 let started_at: DateTime<Utc> = DateTime::parse_from_rfc3339(&started_s)
305 .map_err(|e| RunLogError::Store(e.to_string()))?
306 .with_timezone(&Utc);
307 let finished_at = finished_s
308 .map(|s| {
309 DateTime::parse_from_rfc3339(&s)
310 .map(|d| d.with_timezone(&Utc))
311 .map_err(|e| RunLogError::Store(e.to_string()))
312 })
313 .transpose()?;
314 let status = status_from_str(&status_s)?;
315 let steps =
316 serde_json::from_str(&steps_s).map_err(|e| RunLogError::Store(e.to_string()))?;
317 Ok(RunLog {
318 run_id,
319 agent,
320 started_at,
321 finished_at,
322 status,
323 steps,
324 tokens: Usage {
325 prompt_tokens: prompt_tokens.max(0) as u32,
326 completion_tokens: completion_tokens.max(0) as u32,
327 },
328 cost_estimate: match (cost_total, cost_prompt, cost_completion) {
329 (Some(total), Some(prompt), Some(completion)) => Some(Cost {
330 prompt_usd: prompt,
331 completion_usd: completion,
332 cache_usd: 0.0,
337 total_usd: total,
338 }),
339 _ => None,
342 },
343 })
344 })())
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350 use crate::projector::project;
351 use tempfile::NamedTempFile;
352
353 #[tokio::test]
354 async fn legacy_row_with_only_total_returns_no_cost_estimate() {
355 let f = NamedTempFile::new().unwrap();
359 let store = SqliteRunLogStore::new(f.path()).await.unwrap();
360 let log = project(RunId::new(), "writer", &[]);
361 let rid = log.run_id;
362 store.put(&log).await.unwrap();
363 {
365 let g = store.conn.lock().await;
366 g.execute(
367 "UPDATE runlog SET cost_total_usd = 0.05,
368 cost_prompt_usd = NULL,
369 cost_completion_usd = NULL
370 WHERE run_id = ?1",
371 params![rid.to_string()],
372 )
373 .unwrap();
374 }
375 let back = store.get(rid).await.unwrap().expect("row exists");
376 assert!(
377 back.cost_estimate.is_none(),
378 "legacy row with NULL split must surface as None, got {:?}",
379 back.cost_estimate
380 );
381 }
382
383 #[tokio::test]
384 async fn ensure_cost_split_columns_is_idempotent_when_invoked_twice() {
385 let f = NamedTempFile::new().unwrap();
390 let _store1 = SqliteRunLogStore::new(f.path()).await.unwrap();
391 let store2 = SqliteRunLogStore::new(f.path()).await.unwrap();
393 let log = project(RunId::new(), "writer", &[]);
395 let rid = log.run_id;
396 store2.put(&log).await.unwrap();
397 assert!(store2.get(rid).await.unwrap().is_some());
398 }
399}