1use crate::types::*;
2use anyhow::Result;
3use rusqlite::{params, Connection};
4use std::{path::Path, sync::Mutex};
5
6pub struct Store {
7 conn: Mutex<Connection>,
8}
9
10
11impl Store {
12 pub fn open(path: impl AsRef<Path>) -> Result<Self> {
13 let conn = Connection::open(path)?;
14 conn.execute_batch("
15 PRAGMA journal_mode=WAL;
16 CREATE TABLE IF NOT EXISTS sessions (
17 id TEXT PRIMARY KEY, orchestrator_id TEXT,
18 name TEXT NOT NULL, repo TEXT NOT NULL,
19 status TEXT NOT NULL, agent_type TEXT NOT NULL,
20 cost_usd REAL NOT NULL DEFAULT 0, started_at INTEGER NOT NULL,
21 pr_number INTEGER, pr_id INTEGER,
22 workspace_path TEXT, pid INTEGER,
23 model TEXT, context_tokens INTEGER
24 );
25 CREATE TABLE IF NOT EXISTS orchestrators (
26 id TEXT PRIMARY KEY, name TEXT NOT NULL, created_at INTEGER NOT NULL
27 );
28 CREATE TABLE IF NOT EXISTS prs (
29 id INTEGER PRIMARY KEY, number INTEGER NOT NULL,
30 title TEXT NOT NULL, url TEXT NOT NULL,
31 body TEXT NOT NULL, session_id TEXT NOT NULL
32 );
33 CREATE TABLE IF NOT EXISTS ci_status (
34 pr_id INTEGER PRIMARY KEY, total INTEGER NOT NULL,
35 passing INTEGER NOT NULL, failing INTEGER NOT NULL,
36 pending INTEGER NOT NULL
37 );
38 CREATE TABLE IF NOT EXISTS review_comments (
39 id INTEGER PRIMARY KEY, pr_id INTEGER NOT NULL,
40 author TEXT NOT NULL, body TEXT NOT NULL,
41 path TEXT, line INTEGER, created_at INTEGER NOT NULL
42 );
43 ")?;
44 for (col, ddl) in [
47 ("model", "ALTER TABLE sessions ADD COLUMN model TEXT"),
48 ("context_tokens", "ALTER TABLE sessions ADD COLUMN context_tokens INTEGER"),
49 ("catalogue_path", "ALTER TABLE sessions ADD COLUMN catalogue_path TEXT"),
50 ] {
51 if !Self::column_exists(&conn, "sessions", col)? {
52 conn.execute(ddl, [])?;
53 }
54 }
55 Ok(Self { conn: Mutex::new(conn) })
56 }
57
58 fn column_exists(conn: &Connection, table: &str, column: &str) -> Result<bool> {
59 let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
60 let exists = stmt
61 .query_map([], |r| r.get::<_, String>(1))?
62 .filter_map(|r| r.ok())
63 .any(|c| c == column);
64 Ok(exists)
65 }
66
67 pub fn upsert_session(&self, s: &Session) -> Result<()> {
68 let status = serde_json::to_string(&s.status)?.replace('"', "");
69 let conn = self.conn.lock().unwrap();
70 conn.execute(
71 "INSERT INTO sessions (id,orchestrator_id,name,repo,status,agent_type,
72 cost_usd,started_at,pr_number,pr_id,workspace_path,pid,model,context_tokens,
73 catalogue_path)
74 VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15)
75 ON CONFLICT(id) DO UPDATE SET
76 status=excluded.status,cost_usd=excluded.cost_usd,
77 started_at=excluded.started_at,
78 pr_number=excluded.pr_number,pr_id=excluded.pr_id,
79 workspace_path=excluded.workspace_path,pid=excluded.pid,
80 model=excluded.model,context_tokens=excluded.context_tokens,
81 catalogue_path=excluded.catalogue_path",
82 params![
83 s.id, s.orchestrator_id, s.name, s.repo, status, s.agent_type,
84 s.cost_usd, s.started_at, s.pr_number, s.pr_id,
85 s.workspace_path, s.pid, s.model, s.context_tokens,
86 s.catalogue_path
87 ],
88 )?;
89 Ok(())
90 }
91
92 pub fn list_sessions(&self) -> Result<Vec<Session>> {
93 let conn = self.conn.lock().unwrap();
94 let mut stmt = conn.prepare(
95 "SELECT id,orchestrator_id,name,repo,status,agent_type,cost_usd,
96 started_at,pr_number,pr_id,workspace_path,pid,model,context_tokens,
97 catalogue_path
98 FROM sessions ORDER BY started_at DESC",
99 )?;
100 let rows = stmt.query_map([], |r| {
101 Ok((
102 r.get::<_, String>(0)?,
103 r.get::<_, Option<String>>(1)?,
104 r.get::<_, String>(2)?,
105 r.get::<_, String>(3)?,
106 r.get::<_, String>(4)?,
107 r.get::<_, String>(5)?,
108 r.get::<_, f64>(6)?,
109 r.get::<_, i64>(7)?,
110 r.get::<_, Option<u64>>(8)?,
111 r.get::<_, Option<i64>>(9)?,
112 r.get::<_, Option<String>>(10)?,
113 r.get::<_, Option<u32>>(11)?,
114 r.get::<_, Option<String>>(12)?,
115 r.get::<_, Option<i64>>(13)?,
116 r.get::<_, Option<String>>(14)?,
117 ))
118 })?;
119 rows.map(|r| {
120 let (id, orchestrator_id, name, repo, status_str, agent_type,
121 cost_usd, started_at, pr_number, pr_id, workspace_path, pid,
122 model, context_tokens, catalogue_path) = r?;
123 let status = serde_json::from_str(&format!("\"{status_str}\""))
124 .unwrap_or(SessionStatus::Working);
125 Ok(Session {
126 id, orchestrator_id, name, repo, status, agent_type,
127 cost_usd, started_at, pr_number, pr_id, workspace_path, pid,
128 model, context_tokens: context_tokens.map(|v| v.max(0) as u64),
129 catalogue_path,
130 })
131 })
132 .collect()
133 }
134
135 pub fn get_session(&self, id: &str) -> Result<Option<Session>> {
136 let conn = self.conn.lock().unwrap();
137 let mut stmt = conn.prepare(
138 "SELECT id,orchestrator_id,name,repo,status,agent_type,cost_usd,
139 started_at,pr_number,pr_id,workspace_path,pid,model,context_tokens,
140 catalogue_path
141 FROM sessions WHERE id = ?1",
142 )?;
143 let mut rows = stmt.query_map([id], |r| {
144 Ok((
145 r.get::<_, String>(0)?,
146 r.get::<_, Option<String>>(1)?,
147 r.get::<_, String>(2)?,
148 r.get::<_, String>(3)?,
149 r.get::<_, String>(4)?,
150 r.get::<_, String>(5)?,
151 r.get::<_, f64>(6)?,
152 r.get::<_, i64>(7)?,
153 r.get::<_, Option<u64>>(8)?,
154 r.get::<_, Option<i64>>(9)?,
155 r.get::<_, Option<String>>(10)?,
156 r.get::<_, Option<u32>>(11)?,
157 r.get::<_, Option<String>>(12)?,
158 r.get::<_, Option<i64>>(13)?,
159 r.get::<_, Option<String>>(14)?,
160 ))
161 })?;
162 match rows.next() {
163 None => Ok(None),
164 Some(r) => {
165 let (id, orchestrator_id, name, repo, status_str, agent_type,
166 cost_usd, started_at, pr_number, pr_id, workspace_path, pid,
167 model, context_tokens, catalogue_path) = r?;
168 let status = serde_json::from_str(&format!("\"{status_str}\""))
169 .unwrap_or(SessionStatus::Working);
170 Ok(Some(Session {
171 id, orchestrator_id, name, repo, status, agent_type,
172 cost_usd, started_at, pr_number, pr_id, workspace_path, pid,
173 model, context_tokens: context_tokens.map(|v| v.max(0) as u64),
174 catalogue_path,
175 }))
176 }
177 }
178 }
179
180 pub fn cost_samples(&self, agent_type: &str, model: Option<&str>) -> Result<Vec<f64>> {
186 let sessions = self.list_sessions()?;
187 Ok(sessions
188 .into_iter()
189 .filter(|s| {
190 s.agent_type == agent_type
191 && s.model.as_deref() == model
192 && s.cost_usd > 0.0
193 })
194 .map(|s| s.cost_usd)
195 .collect())
196 }
197
198 pub fn upsert_orchestrator(&self, o: &Orchestrator) -> Result<()> {
199 let conn = self.conn.lock().unwrap();
200 conn.execute(
201 "INSERT INTO orchestrators(id,name,created_at) VALUES(?1,?2,?3)
202 ON CONFLICT(id) DO UPDATE SET name=excluded.name",
203 params![o.id, o.name, o.created_at],
204 )?;
205 Ok(())
206 }
207
208 pub fn sessions_by_orchestrator(&self, orchestrator_id: &str) -> Result<Vec<Session>> {
209 let sessions = self.list_sessions()?;
210 Ok(sessions.into_iter().filter(|s| s.orchestrator_id.as_deref() == Some(orchestrator_id)).collect())
211 }
212
213 pub fn delete_session(&self, id: &str) -> Result<()> {
214 let conn = self.conn.lock().unwrap();
215 conn.execute("DELETE FROM sessions WHERE id = ?1", [id])?;
216 Ok(())
217 }
218
219 pub fn delete_orchestrator(&self, id: &str) -> Result<()> {
220 let conn = self.conn.lock().unwrap();
221 conn.execute("DELETE FROM sessions WHERE orchestrator_id = ?1", [id])?;
222 conn.execute("DELETE FROM sessions WHERE id = ?1", [id])?;
223 conn.execute("DELETE FROM orchestrators WHERE id = ?1", [id])?;
224 Ok(())
225 }
226
227 pub fn upsert_pr(&self, pr: &PR) -> Result<()> {
228 let conn = self.conn.lock().unwrap();
229 conn.execute(
230 "INSERT OR REPLACE INTO prs
231 (id, number, title, url, body, session_id)
232 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
233 params![pr.id, pr.number, pr.title, pr.url, pr.body, pr.session_id],
234 )?;
235 Ok(())
236 }
237
238 pub fn get_pr(&self, id: PrId) -> Result<Option<PR>> {
239 let conn = self.conn.lock().unwrap();
240 let mut stmt = conn.prepare(
241 "SELECT id, number, title, url, body, session_id FROM prs WHERE id = ?1",
242 )?;
243 let mut rows = stmt.query_map([id], |r| {
244 Ok(PR {
245 id: r.get(0)?,
246 number: r.get(1)?,
247 title: r.get(2)?,
248 url: r.get(3)?,
249 body: r.get(4)?,
250 session_id: r.get(5)?,
251 })
252 })?;
253 rows.next().transpose().map_err(Into::into)
254 }
255
256 pub fn upsert_ci_status(&self, ci: &CIStatus) -> Result<()> {
257 let conn = self.conn.lock().unwrap();
258 conn.execute(
259 "INSERT OR REPLACE INTO ci_status
260 (pr_id, total, passing, failing, pending)
261 VALUES (?1, ?2, ?3, ?4, ?5)",
262 params![ci.pr_id, ci.total, ci.passing, ci.failing, ci.pending],
263 )?;
264 Ok(())
265 }
266
267 pub fn upsert_comment(&self, c: &Comment) -> Result<()> {
268 let conn = self.conn.lock().unwrap();
269 conn.execute(
270 "INSERT OR REPLACE INTO review_comments
271 (id, pr_id, author, body, path, line, created_at)
272 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
273 params![c.id, c.pr_id, c.author, c.body, c.path, c.line, c.created_at],
274 )?;
275 Ok(())
276 }
277
278 pub fn list_orchestrators(&self) -> Result<Vec<Orchestrator>> {
279 let conn = self.conn.lock().unwrap();
280 let mut stmt = conn.prepare(
281 "SELECT id,name,created_at FROM orchestrators ORDER BY created_at DESC",
282 )?;
283 let rows = stmt.query_map([], |r| {
284 Ok(Orchestrator {
285 id: r.get(0)?,
286 name: r.get(1)?,
287 created_at: r.get(2)?,
288 })
289 })?;
290 rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
291 }
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297 use tempfile::tempdir;
298
299 fn test_store() -> Store {
300 let dir = tempdir().unwrap();
301 let path = dir.path().join("t.db");
302 std::mem::forget(dir);
304 Store::open(path).unwrap()
305 }
306
307 #[test]
308 fn upsert_and_list_session() {
309 let store = test_store();
310 let session = Session {
311 id: "s1".into(), orchestrator_id: None, name: "worker-1".into(),
312 repo: "slievr/Athene".into(), status: SessionStatus::Working,
313 agent_type: "claude-code".into(), cost_usd: 0.0, started_at: 0,
314 pr_number: None, pr_id: None, workspace_path: None, pid: None,
315 model: None, context_tokens: None, catalogue_path: None,
316 };
317 store.upsert_session(&session).unwrap();
318 let list = store.list_sessions().unwrap();
319 assert_eq!(list.len(), 1);
320 assert_eq!(list[0].id, "s1");
321 }
322
323 #[test]
324 fn upsert_updates_status() {
325 let store = test_store();
326 let mut s = Session {
327 id: "s1".into(), orchestrator_id: None, name: "w".into(),
328 repo: "r".into(), status: SessionStatus::Working,
329 agent_type: "c".into(), cost_usd: 0.0, started_at: 0,
330 pr_number: None, pr_id: None, workspace_path: None, pid: None,
331 model: None, context_tokens: None, catalogue_path: None,
332 };
333 store.upsert_session(&s).unwrap();
334 s.status = SessionStatus::Done;
335 store.upsert_session(&s).unwrap();
336 let list = store.list_sessions().unwrap();
337 assert_eq!(list.len(), 1);
338 assert!(matches!(list[0].status, SessionStatus::Done));
339 }
340
341 #[test]
342 fn get_session_by_id() {
343 let store = test_store();
344 let s = Session {
345 id: "s1".into(), orchestrator_id: None, name: "w".into(),
346 repo: "r".into(), status: SessionStatus::Working,
347 agent_type: "c".into(), cost_usd: 0.0, started_at: 0,
348 pr_number: None, pr_id: None, workspace_path: None, pid: None,
349 model: None, context_tokens: None, catalogue_path: None,
350 };
351 store.upsert_session(&s).unwrap();
352 let found = store.get_session("s1").unwrap();
353 assert!(found.is_some());
354 assert_eq!(found.unwrap().name, "w");
355 assert!(store.get_session("missing").unwrap().is_none());
356 }
357
358 #[test]
359 fn model_and_context_tokens_round_trip() {
360 let store = test_store();
361 let s = Session {
362 id: "s1".into(), orchestrator_id: None, name: "w".into(),
363 repo: "r".into(), status: SessionStatus::Working,
364 agent_type: "claude-code".into(), cost_usd: 1.5, started_at: 0,
365 pr_number: None, pr_id: None, workspace_path: None, pid: None,
366 model: Some("claude-fable-5".into()), context_tokens: Some(214_000), catalogue_path: None,
367 };
368 store.upsert_session(&s).unwrap();
369 let found = store.get_session("s1").unwrap().unwrap();
370 assert_eq!(found.model.as_deref(), Some("claude-fable-5"));
371 assert_eq!(found.context_tokens, Some(214_000));
372 }
373
374 #[test]
375 fn catalogue_path_round_trips() {
376 let store = test_store();
377 let s = Session {
378 id: "s2".into(), orchestrator_id: None, name: "w".into(),
379 repo: "r".into(), status: SessionStatus::Working,
380 agent_type: "claude-code".into(), cost_usd: 0.0, started_at: 0,
381 pr_number: None, pr_id: None, workspace_path: None, pid: None,
382 model: None, context_tokens: None,
383 catalogue_path: Some("/brains/x".into()),
384 };
385 store.upsert_session(&s).unwrap();
386 let found = store.get_session("s2").unwrap().unwrap();
387 assert_eq!(found.catalogue_path.as_deref(), Some("/brains/x"));
388 assert_eq!(store.list_sessions().unwrap()[0].catalogue_path.as_deref(), Some("/brains/x"));
390 }
391
392 #[test]
396 fn upsert_conflict_updates_started_at() {
397 let store = test_store();
398 let mut s = Session {
399 id: "s3".into(), orchestrator_id: None, name: "w".into(),
400 repo: "r".into(), status: SessionStatus::Working,
401 agent_type: "claude-code".into(), cost_usd: 0.0, started_at: 100,
402 pr_number: None, pr_id: None, workspace_path: None, pid: None,
403 model: None, context_tokens: None, catalogue_path: None,
404 };
405 store.upsert_session(&s).unwrap();
406 s.started_at = 200;
407 store.upsert_session(&s).unwrap();
408 assert_eq!(store.get_session("s3").unwrap().unwrap().started_at, 200);
409 }
410
411 #[test]
412 fn get_pr_round_trips_and_misses_cleanly() {
413 let store = test_store();
414 assert!(store.get_pr(9).unwrap().is_none());
415 let pr = PR {
416 id: 9, number: 9, title: "t".into(),
417 url: "https://github.com/org/repo/pull/9".into(),
418 body: String::new(), session_id: "s1".into(),
419 };
420 store.upsert_pr(&pr).unwrap();
421 let found = store.get_pr(9).unwrap().unwrap();
422 assert_eq!(found.number, 9);
423 assert_eq!(found.session_id, "s1");
424 assert_eq!(found.url, pr.url);
425 }
426
427 #[test]
428 fn cost_samples_filters_by_agent_and_model_and_excludes_zero() {
429 let store = test_store();
430 for (id, agent_type, model, cost) in [
431 ("a", "claude-code", Some("claude-fable-5"), 3.0),
432 ("b", "claude-code", Some("claude-fable-5"), 5.0),
433 ("c", "claude-code", Some("claude-fable-5"), 0.0), ("d", "claude-code", Some("claude-opus-4-8"), 2.0), ("e", "codex", Some("claude-fable-5"), 4.0), ] {
437 store.upsert_session(&Session {
438 id: id.into(), orchestrator_id: None, name: id.into(),
439 repo: "r".into(), status: SessionStatus::Working,
440 agent_type: agent_type.into(), cost_usd: cost, started_at: 0,
441 pr_number: None, pr_id: None, workspace_path: None, pid: None,
442 model: model.map(String::from), context_tokens: None, catalogue_path: None,
443 }).unwrap();
444 }
445 let samples = store.cost_samples("claude-code", Some("claude-fable-5")).unwrap();
446 assert_eq!(samples.len(), 2);
447 assert!((samples.iter().sum::<f64>() - 8.0).abs() < f64::EPSILON);
448 }
449}