Skip to main content

klieo_runlog/
store_sqlite.rs

1//! SQLite-backed `RunLogStore`. Single-table schema with `steps` stored as JSON.
2//!
3//! Available behind the `sqlite` feature.
4
5use 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
16/// SQLite-backed `RunLogStore`.
17pub struct SqliteRunLogStore {
18    conn: Arc<Mutex<Connection>>,
19}
20
21impl SqliteRunLogStore {
22    /// Open or create the database file at `path` and ensure the schema exists.
23    ///
24    /// On open, ensures both `cost_prompt_usd` and `cost_completion_usd`
25    /// columns exist via `ALTER TABLE … ADD COLUMN` for forward-compatible
26    /// upgrade of pre-existing databases. Both columns are nullable so rows
27    /// written before the columns existed continue to read back as `cost_estimate = None`
28    /// (the prompt/completion split is genuinely unknown for rows written
29    /// before the split columns existed; fabricating `0.0` halves would
30    /// violate the audit-evidence invariant `prompt + completion == total`).
31    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            // WAL allows concurrent readers alongside a single writer and
36            // avoids the rollback-journal fsync per commit; `synchronous=NORMAL`
37            // is the documented safe pairing with WAL (durable across app
38            // crashes; loses at most the last committed transaction on OS
39            // crash/power loss, which is acceptable for replayable run logs).
40            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
73/// Add the `cost_prompt_usd` / `cost_completion_usd` columns to a pre-existing
74/// `runlog` table when they are missing. Idempotent and safe to call on a
75/// freshly created table.
76fn 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
104/// Run an `ALTER TABLE … ADD COLUMN` that is safe under concurrent first-open.
105///
106/// SQLite serialises writes via the file lock, so two processes racing the
107/// migration both call `PRAGMA table_info` simultaneously, see the column
108/// missing, and both attempt the ALTER. The second's ALTER fails with
109/// `SQLITE_ERROR (1)` and message containing `duplicate column name` — that
110/// outcome is benign (the column is already there) and is swallowed here.
111fn 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
144/// Parse a `RunId` from its `Display` form. `RunId` does not implement
145/// `FromStr` (Plan #11 trait freeze), so we parse via the underlying ULID.
146fn 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        // Bind optional filters as NULL-able params so a single statement covers
225        // every agent/status combination; the `?n IS NULL OR col = ?n` guard
226        // skips the predicate when the filter is unset. Status is pushed into the
227        // query so it composes with LIMIT/OFFSET (a post-filter would drop
228        // matching rows that fell outside the page).
229        let agent = query.agent.clone();
230        let status = query.status.map(|s| status_to_str(s).to_string());
231        // SQLite treats a negative LIMIT as "no limit". `None` -> -1; a `Some`
232        // larger than i64::MAX clamps to i64::MAX (a real cap), never wrapping
233        // negative into an accidental "no limit".
234        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    // Legacy rows written before the split columns existed have cost_total
294    // set but the split columns NULL. Audit evidence requires the invariant
295    // `prompt + completion == total`, so we refuse to fabricate a split:
296    // such rows surface as `cost_estimate = None` rather than producing an
297    // internally inconsistent `Cost`. Current writers always populate all
298    // three columns together.
299    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                    // No cache column is persisted; the stored total remains
333                    // authoritative (it already included any cache cost when
334                    // computed), so cache_usd reconstructs as 0 rather than
335                    // re-deriving a split that wasn't recorded.
336                    cache_usd: 0.0,
337                    total_usd: total,
338                }),
339                // Either nothing recorded, or a legacy row with only
340                // cost_total set — refuse to fabricate a split.
341                _ => 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        // Simulate a legacy row written before the split columns existed:
356        // cost_total_usd set, both split columns NULL. The reader must
357        // refuse to fabricate a split and surface cost_estimate = None.
358        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        // Mutate the row to look like a legacy record: total set, split NULL.
364        {
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        // Open the store twice on the same DB file. The second open re-runs
386        // ensure_cost_split_columns; the columns already exist, so it must
387        // be a silent no-op (and tolerate any duplicate-column race that
388        // would otherwise trip ALTER).
389        let f = NamedTempFile::new().unwrap();
390        let _store1 = SqliteRunLogStore::new(f.path()).await.unwrap();
391        // Second open must succeed without error.
392        let store2 = SqliteRunLogStore::new(f.path()).await.unwrap();
393        // And operations on it must round-trip cleanly.
394        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}