use rusqlite::Connection;
use crate::error::Result;
pub struct Store {
conn: Connection,
}
#[derive(Debug, Clone, PartialEq)]
pub struct StepRecord {
pub step: u32,
pub decision: String,
pub result: String,
pub prompt: String,
pub tool_call: String,
pub tokens: u64,
}
impl StepRecord {
pub fn new(step: u32, decision: impl Into<String>, result: impl Into<String>) -> Self {
Self {
step,
decision: decision.into(),
result: result.into(),
prompt: String::new(),
tool_call: String::new(),
tokens: 0,
}
}
pub fn with_trace(
mut self,
prompt: impl Into<String>,
tool_call: impl Into<String>,
tokens: u64,
) -> Self {
self.prompt = prompt.into();
self.tool_call = tool_call.into();
self.tokens = tokens;
self
}
}
impl Store {
pub fn open(path: impl AsRef<std::path::Path>) -> Result<Self> {
Self::from_conn(Connection::open(path)?)
}
pub fn memory() -> Result<Self> {
Self::from_conn(Connection::open_in_memory()?)
}
fn from_conn(conn: Connection) -> Result<Self> {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
goal TEXT NOT NULL,
file TEXT NOT NULL,
outcome TEXT
);
CREATE TABLE IF NOT EXISTS steps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id INTEGER NOT NULL REFERENCES runs(id),
step INTEGER NOT NULL,
decision TEXT NOT NULL,
result TEXT NOT NULL,
prompt TEXT NOT NULL DEFAULT '',
tool_call TEXT NOT NULL DEFAULT '',
tokens INTEGER NOT NULL DEFAULT 0
);",
)?;
for col in [
"prompt TEXT NOT NULL DEFAULT ''",
"tool_call TEXT NOT NULL DEFAULT ''",
"tokens INTEGER NOT NULL DEFAULT 0",
] {
let _ = conn.execute(&format!("ALTER TABLE steps ADD COLUMN {col}"), []);
}
Ok(Self { conn })
}
pub fn start_run(&self, goal: &str, file: &str) -> Result<i64> {
self.conn
.execute("INSERT INTO runs (goal, file) VALUES (?1, ?2)", (goal, file))?;
Ok(self.conn.last_insert_rowid())
}
pub fn record(&self, run_id: i64, step: &StepRecord) -> Result<()> {
self.conn.execute(
"INSERT INTO steps (run_id, step, decision, result, prompt, tool_call, tokens)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
(
run_id,
step.step,
&step.decision,
&step.result,
&step.prompt,
&step.tool_call,
step.tokens,
),
)?;
Ok(())
}
pub fn finish_run(&self, run_id: i64, outcome: &str) -> Result<()> {
self.conn
.execute("UPDATE runs SET outcome = ?1 WHERE id = ?2", (outcome, run_id))?;
Ok(())
}
pub fn last_step(&self, run_id: i64) -> Result<u32> {
let n: i64 = self.conn.query_row(
"SELECT COALESCE(MAX(step), 0) FROM steps WHERE run_id = ?1",
[run_id],
|r| r.get(0),
)?;
Ok(n as u32)
}
pub fn steps(&self, run_id: i64) -> Result<Vec<StepRecord>> {
let mut stmt = self.conn.prepare(
"SELECT step, decision, result, prompt, tool_call, tokens
FROM steps WHERE run_id = ?1 ORDER BY step ASC, id ASC",
)?;
let rows = stmt.query_map([run_id], |r| {
Ok(StepRecord {
step: r.get::<_, i64>(0)? as u32,
decision: r.get(1)?,
result: r.get(2)?,
prompt: r.get(3)?,
tool_call: r.get(4)?,
tokens: r.get::<_, i64>(5)? as u64,
})
})?;
Ok(rows.collect::<std::result::Result<_, _>>()?)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn full_trace_persists_and_reads_back() {
let store = Store::memory().unwrap();
let run = store.start_run("goal", "out.txt").unwrap();
store
.record(
run,
&StepRecord::new(1, "wrote file", "content v1")
.with_trace("the prompt", r#"{"content":"content v1"}"#, 128),
)
.unwrap();
store
.record(run, &StepRecord::new(2, "verified", "ok"))
.unwrap();
store.finish_run(run, "success").unwrap();
let steps = store.steps(run).unwrap();
assert_eq!(steps.len(), 2);
assert_eq!(steps[0].decision, "wrote file");
assert_eq!(steps[0].prompt, "the prompt");
assert_eq!(steps[0].tokens, 128);
assert_eq!(steps[1].result, "ok");
assert_eq!(store.last_step(run).unwrap(), 2);
}
#[test]
fn migrates_a_0_1_0_steps_table_in_place() {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(
"CREATE TABLE runs (id INTEGER PRIMARY KEY AUTOINCREMENT, goal TEXT NOT NULL, file TEXT NOT NULL, outcome TEXT);
CREATE TABLE steps (id INTEGER PRIMARY KEY AUTOINCREMENT, run_id INTEGER NOT NULL, step INTEGER NOT NULL, decision TEXT NOT NULL, result TEXT NOT NULL);
INSERT INTO runs (goal, file) VALUES ('g', 'f');
INSERT INTO steps (run_id, step, decision, result) VALUES (1, 1, 'wrote file', 'old');",
)
.unwrap();
let store = Store::from_conn(conn).unwrap();
let steps = store.steps(1).unwrap();
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].result, "old");
assert_eq!(steps[0].prompt, "");
assert_eq!(steps[0].tokens, 0);
}
}