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 PRAGMA busy_timeout=5000;
17 CREATE TABLE IF NOT EXISTS sessions (
18 id TEXT PRIMARY KEY, orchestrator_id TEXT,
19 name TEXT NOT NULL, repo TEXT NOT NULL,
20 status TEXT NOT NULL, agent_type TEXT NOT NULL,
21 cost_usd REAL NOT NULL DEFAULT 0, started_at INTEGER NOT NULL,
22 pr_number INTEGER, pr_id INTEGER,
23 workspace_path TEXT, pid INTEGER,
24 model TEXT, context_tokens INTEGER
25 );
26 CREATE TABLE IF NOT EXISTS orchestrators (
27 id TEXT PRIMARY KEY, name TEXT NOT NULL, created_at INTEGER NOT NULL
28 );
29 CREATE TABLE IF NOT EXISTS prs (
30 id INTEGER PRIMARY KEY, number INTEGER NOT NULL,
31 title TEXT NOT NULL, url TEXT NOT NULL,
32 body TEXT NOT NULL, session_id TEXT NOT NULL
33 );
34 CREATE TABLE IF NOT EXISTS ci_status (
35 pr_id INTEGER PRIMARY KEY, total INTEGER NOT NULL,
36 passing INTEGER NOT NULL, failing INTEGER NOT NULL,
37 pending INTEGER NOT NULL
38 );
39 CREATE TABLE IF NOT EXISTS review_comments (
40 id INTEGER PRIMARY KEY, pr_id INTEGER NOT NULL,
41 author TEXT NOT NULL, body TEXT NOT NULL,
42 path TEXT, line INTEGER, created_at INTEGER NOT NULL
43 );
44 ")?;
45 for (col, ddl) in [
48 ("model", "ALTER TABLE sessions ADD COLUMN model TEXT"),
49 ("context_tokens", "ALTER TABLE sessions ADD COLUMN context_tokens INTEGER"),
50 ("catalogue_path", "ALTER TABLE sessions ADD COLUMN catalogue_path TEXT"),
51 ("context_used_pct", "ALTER TABLE sessions ADD COLUMN context_used_pct REAL"),
52 ("context_total_tokens", "ALTER TABLE sessions ADD COLUMN context_total_tokens INTEGER"),
53 ("context_window_size", "ALTER TABLE sessions ADD COLUMN context_window_size INTEGER"),
54 ("claude_session_id", "ALTER TABLE sessions ADD COLUMN claude_session_id TEXT"),
55 ("summary", "ALTER TABLE sessions ADD COLUMN summary TEXT"),
56 ("terminal_at", "ALTER TABLE sessions ADD COLUMN terminal_at INTEGER"),
57 ("gate_status", "ALTER TABLE sessions ADD COLUMN gate_status TEXT"),
58 ] {
59 if !Self::column_exists(&conn, "sessions", col)? {
60 conn.execute(ddl, [])?;
61 }
62 }
63 Ok(Self { conn: Mutex::new(conn) })
64 }
65
66 fn column_exists(conn: &Connection, table: &str, column: &str) -> Result<bool> {
67 let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
68 let exists = stmt
69 .query_map([], |r| r.get::<_, String>(1))?
70 .filter_map(|r| r.ok())
71 .any(|c| c == column);
72 Ok(exists)
73 }
74
75 pub fn upsert_session(&self, s: &Session) -> Result<()> {
76 let status = serde_json::to_string(&s.status)?.replace('"', "");
77 let gate_status = s.gate_status.as_ref()
78 .map(serde_json::to_string)
79 .transpose()?;
80 let conn = self.conn.lock().unwrap();
81 conn.execute(
82 "INSERT INTO sessions (id,orchestrator_id,name,repo,status,agent_type,
83 cost_usd,started_at,pr_number,pr_id,workspace_path,pid,model,context_tokens,
84 catalogue_path,context_used_pct,context_total_tokens,context_window_size,
85 claude_session_id,summary,terminal_at,gate_status)
86 VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20,?21,?22)
87 ON CONFLICT(id) DO UPDATE SET
88 repo=excluded.repo,
89 status=excluded.status,cost_usd=excluded.cost_usd,
90 started_at=excluded.started_at,
91 pr_number=excluded.pr_number,pr_id=excluded.pr_id,
92 workspace_path=excluded.workspace_path,pid=excluded.pid,
93 model=excluded.model,context_tokens=excluded.context_tokens,
94 catalogue_path=excluded.catalogue_path,
95 context_used_pct=excluded.context_used_pct,
96 context_total_tokens=excluded.context_total_tokens,
97 context_window_size=excluded.context_window_size,
98 claude_session_id=excluded.claude_session_id,
99 summary=excluded.summary,
100 terminal_at=excluded.terminal_at,
101 gate_status=excluded.gate_status",
102 params![
103 s.id, s.orchestrator_id, s.name, s.repo, status, s.agent_type,
104 s.cost_usd, s.started_at, s.pr_number, s.pr_id,
105 s.workspace_path, s.pid, s.model, s.context_tokens,
106 s.catalogue_path, s.context_used_pct, s.context_total_tokens,
107 s.context_window_size, s.claude_session_id, s.summary, s.terminal_at,
108 gate_status
109 ],
110 )?;
111 Ok(())
112 }
113
114 pub fn list_sessions(&self) -> Result<Vec<Session>> {
115 let conn = self.conn.lock().unwrap();
116 let mut stmt = conn.prepare(
117 "SELECT id,orchestrator_id,name,repo,status,agent_type,cost_usd,
118 started_at,pr_number,pr_id,workspace_path,pid,model,context_tokens,
119 catalogue_path,context_used_pct,context_total_tokens,context_window_size,
120 claude_session_id,summary,terminal_at,gate_status
121 FROM sessions ORDER BY started_at DESC",
122 )?;
123 let rows = stmt.query_map([], |r| {
124 Ok((
125 r.get::<_, String>(0)?,
126 r.get::<_, Option<String>>(1)?,
127 r.get::<_, String>(2)?,
128 r.get::<_, String>(3)?,
129 r.get::<_, String>(4)?,
130 r.get::<_, String>(5)?,
131 r.get::<_, f64>(6)?,
132 r.get::<_, i64>(7)?,
133 r.get::<_, Option<u64>>(8)?,
134 r.get::<_, Option<i64>>(9)?,
135 r.get::<_, Option<String>>(10)?,
136 r.get::<_, Option<u32>>(11)?,
137 r.get::<_, Option<String>>(12)?,
138 r.get::<_, Option<i64>>(13)?,
139 r.get::<_, Option<String>>(14)?,
140 r.get::<_, Option<f64>>(15)?,
141 r.get::<_, Option<i64>>(16)?,
142 r.get::<_, Option<i64>>(17)?,
143 r.get::<_, Option<String>>(18)?,
144 r.get::<_, Option<String>>(19)?,
145 r.get::<_, Option<i64>>(20)?,
146 r.get::<_, Option<String>>(21)?,
147 ))
148 })?;
149 rows.map(|r| {
150 let (id, orchestrator_id, name, repo, status_str, agent_type,
151 cost_usd, started_at, pr_number, pr_id, workspace_path, pid,
152 model, context_tokens, catalogue_path, context_used_pct,
153 context_total_tokens, context_window_size, claude_session_id,
154 summary, terminal_at, gate_status_str) = r?;
155 let status = serde_json::from_str(&format!("\"{status_str}\""))
156 .unwrap_or(SessionStatus::Working);
157 let gate_status = gate_status_str
158 .and_then(|s| serde_json::from_str(&s).ok());
159 Ok(Session {
160 id, orchestrator_id, name, repo, status, agent_type,
161 cost_usd, started_at, pr_number, pr_id, workspace_path, pid,
162 model, context_tokens: context_tokens.map(|v| v.max(0) as u64),
163 catalogue_path,
164 context_used_pct,
165 context_total_tokens: context_total_tokens.map(|v| v.max(0) as u64),
166 context_window_size: context_window_size.map(|v| v.max(0) as u64),
167 claude_session_id,
168 summary,
169 terminal_at,
170 gate_status,
171 })
172 })
173 .collect()
174 }
175
176 pub fn get_session(&self, id: &str) -> Result<Option<Session>> {
177 let conn = self.conn.lock().unwrap();
178 let mut stmt = conn.prepare(
179 "SELECT id,orchestrator_id,name,repo,status,agent_type,cost_usd,
180 started_at,pr_number,pr_id,workspace_path,pid,model,context_tokens,
181 catalogue_path,context_used_pct,context_total_tokens,context_window_size,
182 claude_session_id,summary,terminal_at,gate_status
183 FROM sessions WHERE id = ?1",
184 )?;
185 let mut rows = stmt.query_map([id], |r| {
186 Ok((
187 r.get::<_, String>(0)?,
188 r.get::<_, Option<String>>(1)?,
189 r.get::<_, String>(2)?,
190 r.get::<_, String>(3)?,
191 r.get::<_, String>(4)?,
192 r.get::<_, String>(5)?,
193 r.get::<_, f64>(6)?,
194 r.get::<_, i64>(7)?,
195 r.get::<_, Option<u64>>(8)?,
196 r.get::<_, Option<i64>>(9)?,
197 r.get::<_, Option<String>>(10)?,
198 r.get::<_, Option<u32>>(11)?,
199 r.get::<_, Option<String>>(12)?,
200 r.get::<_, Option<i64>>(13)?,
201 r.get::<_, Option<String>>(14)?,
202 r.get::<_, Option<f64>>(15)?,
203 r.get::<_, Option<i64>>(16)?,
204 r.get::<_, Option<i64>>(17)?,
205 r.get::<_, Option<String>>(18)?,
206 r.get::<_, Option<String>>(19)?,
207 r.get::<_, Option<i64>>(20)?,
208 r.get::<_, Option<String>>(21)?,
209 ))
210 })?;
211 match rows.next() {
212 None => Ok(None),
213 Some(r) => {
214 let (id, orchestrator_id, name, repo, status_str, agent_type,
215 cost_usd, started_at, pr_number, pr_id, workspace_path, pid,
216 model, context_tokens, catalogue_path, context_used_pct,
217 context_total_tokens, context_window_size, claude_session_id,
218 summary, terminal_at, gate_status_str) = r?;
219 let status = serde_json::from_str(&format!("\"{status_str}\""))
220 .unwrap_or(SessionStatus::Working);
221 let gate_status = gate_status_str
222 .and_then(|s| serde_json::from_str(&s).ok());
223 Ok(Some(Session {
224 id, orchestrator_id, name, repo, status, agent_type,
225 cost_usd, started_at, pr_number, pr_id, workspace_path, pid,
226 model, context_tokens: context_tokens.map(|v| v.max(0) as u64),
227 catalogue_path,
228 context_used_pct,
229 context_total_tokens: context_total_tokens.map(|v| v.max(0) as u64),
230 context_window_size: context_window_size.map(|v| v.max(0) as u64),
231 claude_session_id,
232 summary,
233 terminal_at,
234 gate_status,
235 }))
236 }
237 }
238 }
239
240 pub fn cost_samples(&self, agent_type: &str, model: Option<&str>) -> Result<Vec<f64>> {
246 let sessions = self.list_sessions()?;
247 Ok(sessions
248 .into_iter()
249 .filter(|s| {
250 s.agent_type == agent_type
251 && s.model.as_deref() == model
252 && s.cost_usd > 0.0
253 })
254 .map(|s| s.cost_usd)
255 .collect())
256 }
257
258 pub fn upsert_orchestrator(&self, o: &Orchestrator) -> Result<()> {
259 let conn = self.conn.lock().unwrap();
260 conn.execute(
261 "INSERT INTO orchestrators(id,name,created_at) VALUES(?1,?2,?3)
262 ON CONFLICT(id) DO UPDATE SET name=excluded.name",
263 params![o.id, o.name, o.created_at],
264 )?;
265 Ok(())
266 }
267
268 pub fn sessions_by_orchestrator(&self, orchestrator_id: &str) -> Result<Vec<Session>> {
269 let sessions = self.list_sessions()?;
270 Ok(sessions.into_iter().filter(|s| s.orchestrator_id.as_deref() == Some(orchestrator_id)).collect())
271 }
272
273 pub fn delete_session(&self, id: &str) -> Result<()> {
274 let conn = self.conn.lock().unwrap();
275 conn.execute("DELETE FROM sessions WHERE id = ?1", [id])?;
276 Ok(())
277 }
278
279 pub fn delete_orchestrator(&self, id: &str) -> Result<()> {
280 let conn = self.conn.lock().unwrap();
281 conn.execute("DELETE FROM sessions WHERE orchestrator_id = ?1", [id])?;
282 conn.execute("DELETE FROM sessions WHERE id = ?1", [id])?;
283 conn.execute("DELETE FROM orchestrators WHERE id = ?1", [id])?;
284 Ok(())
285 }
286
287 pub fn upsert_pr(&self, pr: &PR) -> Result<()> {
288 let conn = self.conn.lock().unwrap();
289 conn.execute(
290 "INSERT OR REPLACE INTO prs
291 (id, number, title, url, body, session_id)
292 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
293 params![pr.id, pr.number, pr.title, pr.url, pr.body, pr.session_id],
294 )?;
295 Ok(())
296 }
297
298 pub fn get_pr(&self, id: PrId) -> Result<Option<PR>> {
299 let conn = self.conn.lock().unwrap();
300 let mut stmt = conn.prepare(
301 "SELECT id, number, title, url, body, session_id FROM prs WHERE id = ?1",
302 )?;
303 let mut rows = stmt.query_map([id], |r| {
304 Ok(PR {
305 id: r.get(0)?,
306 number: r.get(1)?,
307 title: r.get(2)?,
308 url: r.get(3)?,
309 body: r.get(4)?,
310 session_id: r.get(5)?,
311 })
312 })?;
313 rows.next().transpose().map_err(Into::into)
314 }
315
316 pub fn upsert_ci_status(&self, ci: &CIStatus) -> Result<()> {
317 let conn = self.conn.lock().unwrap();
318 conn.execute(
319 "INSERT OR REPLACE INTO ci_status
320 (pr_id, total, passing, failing, pending)
321 VALUES (?1, ?2, ?3, ?4, ?5)",
322 params![ci.pr_id, ci.total, ci.passing, ci.failing, ci.pending],
323 )?;
324 Ok(())
325 }
326
327 pub fn upsert_comment(&self, c: &Comment) -> Result<()> {
328 let conn = self.conn.lock().unwrap();
329 conn.execute(
330 "INSERT OR REPLACE INTO review_comments
331 (id, pr_id, author, body, path, line, created_at)
332 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
333 params![c.id, c.pr_id, c.author, c.body, c.path, c.line, c.created_at],
334 )?;
335 Ok(())
336 }
337
338 pub fn list_comments(&self) -> Result<Vec<Comment>> {
342 let conn = self.conn.lock().unwrap();
343 let mut stmt = conn.prepare(
344 "SELECT id, pr_id, author, body, path, line, created_at
345 FROM review_comments ORDER BY created_at ASC",
346 )?;
347 let rows = stmt.query_map([], |r| {
348 Ok(Comment {
349 id: r.get(0)?,
350 pr_id: r.get(1)?,
351 author: r.get(2)?,
352 body: r.get(3)?,
353 path: r.get(4)?,
354 line: r.get(5)?,
355 created_at: r.get(6)?,
356 })
357 })?;
358 rows.collect::<rusqlite::Result<Vec<_>>>().map_err(Into::into)
359 }
360
361 pub fn list_orchestrators(&self) -> Result<Vec<Orchestrator>> {
362 let conn = self.conn.lock().unwrap();
363 let mut stmt = conn.prepare(
364 "SELECT id,name,created_at FROM orchestrators ORDER BY created_at DESC",
365 )?;
366 let rows = stmt.query_map([], |r| {
367 Ok(Orchestrator {
368 id: r.get(0)?,
369 name: r.get(1)?,
370 created_at: r.get(2)?,
371 })
372 })?;
373 rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
374 }
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380 use tempfile::tempdir;
381
382 fn test_store() -> Store {
383 let dir = tempdir().unwrap();
384 let path = dir.path().join("t.db");
385 std::mem::forget(dir);
387 Store::open(path).unwrap()
388 }
389
390 #[test]
391 fn upsert_and_list_session() {
392 let store = test_store();
393 let session = Session {
394 id: "s1".into(), orchestrator_id: None, name: "worker-1".into(),
395 repo: "slievr/Athene".into(), status: SessionStatus::Working,
396 agent_type: "claude-code".into(), cost_usd: 0.0, started_at: 0,
397 pr_number: None, pr_id: None, workspace_path: None, pid: None,
398 model: None, context_tokens: None, catalogue_path: None,
399 context_used_pct: None, context_total_tokens: None, context_window_size: None,
400 claude_session_id: None,
401 summary: None,
402 terminal_at: None, gate_status: None,
403 };
404 store.upsert_session(&session).unwrap();
405 let list = store.list_sessions().unwrap();
406 assert_eq!(list.len(), 1);
407 assert_eq!(list[0].id, "s1");
408 }
409
410 #[test]
411 fn upsert_updates_status() {
412 let store = test_store();
413 let mut s = Session {
414 id: "s1".into(), orchestrator_id: None, name: "w".into(),
415 repo: "r".into(), status: SessionStatus::Working,
416 agent_type: "c".into(), cost_usd: 0.0, started_at: 0,
417 pr_number: None, pr_id: None, workspace_path: None, pid: None,
418 model: None, context_tokens: None, catalogue_path: None,
419 context_used_pct: None, context_total_tokens: None, context_window_size: None,
420 claude_session_id: None,
421 summary: None,
422 terminal_at: None, gate_status: None,
423 };
424 store.upsert_session(&s).unwrap();
425 s.status = SessionStatus::Done;
426 store.upsert_session(&s).unwrap();
427 let list = store.list_sessions().unwrap();
428 assert_eq!(list.len(), 1);
429 assert!(matches!(list[0].status, SessionStatus::Done));
430 }
431
432 #[test]
437 fn upsert_updates_repo() {
438 let store = test_store();
439 let mut s = Session {
440 id: "s1".into(), orchestrator_id: None, name: "w".into(),
441 repo: "OwnerA/repoA".into(), status: SessionStatus::Working,
442 agent_type: "c".into(), cost_usd: 0.0, started_at: 0,
443 pr_number: None, pr_id: None, workspace_path: None, pid: None,
444 model: None, context_tokens: None, catalogue_path: None,
445 context_used_pct: None, context_total_tokens: None, context_window_size: None,
446 claude_session_id: None,
447 summary: None,
448 terminal_at: None, gate_status: None,
449 };
450 store.upsert_session(&s).unwrap();
451 s.repo = "OwnerB/repoB".into();
452 store.upsert_session(&s).unwrap();
453 let updated = store.get_session("s1").unwrap().unwrap();
454 assert_eq!(updated.repo, "OwnerB/repoB");
455 }
456
457 #[test]
458 fn get_session_by_id() {
459 let store = test_store();
460 let s = Session {
461 id: "s1".into(), orchestrator_id: None, name: "w".into(),
462 repo: "r".into(), status: SessionStatus::Working,
463 agent_type: "c".into(), cost_usd: 0.0, started_at: 0,
464 pr_number: None, pr_id: None, workspace_path: None, pid: None,
465 model: None, context_tokens: None, catalogue_path: None,
466 context_used_pct: None, context_total_tokens: None, context_window_size: None,
467 claude_session_id: None,
468 summary: None,
469 terminal_at: None, gate_status: None,
470 };
471 store.upsert_session(&s).unwrap();
472 let found = store.get_session("s1").unwrap();
473 assert!(found.is_some());
474 assert_eq!(found.unwrap().name, "w");
475 assert!(store.get_session("missing").unwrap().is_none());
476 }
477
478 #[test]
479 fn model_and_context_tokens_round_trip() {
480 let store = test_store();
481 let s = Session {
482 id: "s1".into(), orchestrator_id: None, name: "w".into(),
483 repo: "r".into(), status: SessionStatus::Working,
484 agent_type: "claude-code".into(), cost_usd: 1.5, started_at: 0,
485 pr_number: None, pr_id: None, workspace_path: None, pid: None,
486 model: Some("claude-fable-5".into()), context_tokens: Some(214_000), catalogue_path: None,
487 context_used_pct: None, context_total_tokens: None, context_window_size: None,
488 claude_session_id: None,
489 summary: None,
490 terminal_at: None, gate_status: None,
491 };
492 store.upsert_session(&s).unwrap();
493 let found = store.get_session("s1").unwrap().unwrap();
494 assert_eq!(found.model.as_deref(), Some("claude-fable-5"));
495 assert_eq!(found.context_tokens, Some(214_000));
496 }
497
498 #[test]
499 fn catalogue_path_round_trips() {
500 let store = test_store();
501 let s = Session {
502 id: "s2".into(), orchestrator_id: None, name: "w".into(),
503 repo: "r".into(), status: SessionStatus::Working,
504 agent_type: "claude-code".into(), cost_usd: 0.0, started_at: 0,
505 pr_number: None, pr_id: None, workspace_path: None, pid: None,
506 model: None, context_tokens: None,
507 catalogue_path: Some("/brains/x".into()),
508 context_used_pct: None, context_total_tokens: None, context_window_size: None,
509 claude_session_id: None,
510 summary: None,
511 terminal_at: None, gate_status: None,
512 };
513 store.upsert_session(&s).unwrap();
514 let found = store.get_session("s2").unwrap().unwrap();
515 assert_eq!(found.catalogue_path.as_deref(), Some("/brains/x"));
516 assert_eq!(store.list_sessions().unwrap()[0].catalogue_path.as_deref(), Some("/brains/x"));
518 }
519
520 #[test]
521 fn summary_round_trips() {
522 let store = test_store();
523 let s = Session {
524 id: "s2b".into(), orchestrator_id: None, name: "w".into(),
525 repo: "r".into(), status: SessionStatus::Working,
526 agent_type: "claude-code".into(), cost_usd: 0.0, started_at: 0,
527 pr_number: None, pr_id: None, workspace_path: None, pid: None,
528 model: None, context_tokens: None, catalogue_path: None,
529 context_used_pct: None, context_total_tokens: None, context_window_size: None,
530 claude_session_id: None,
531 summary: Some("Fix flaky CI on the auth suite".into()),
532 terminal_at: None, gate_status: None,
533 };
534 store.upsert_session(&s).unwrap();
535 let found = store.get_session("s2b").unwrap().unwrap();
536 assert_eq!(found.summary.as_deref(), Some("Fix flaky CI on the auth suite"));
537 assert_eq!(
539 store.list_sessions().unwrap().iter().find(|x| x.id == "s2b").unwrap().summary.as_deref(),
540 Some("Fix flaky CI on the auth suite"),
541 );
542
543 let mut s2 = s.clone();
545 s2.id = "s2c".into();
546 s2.summary = None;
547 store.upsert_session(&s2).unwrap();
548 assert_eq!(store.get_session("s2c").unwrap().unwrap().summary, None);
549 }
550
551 #[test]
552 fn claude_session_id_round_trips() {
553 let store = test_store();
554 let s = Session {
555 id: "s3".into(), orchestrator_id: None, name: "w".into(),
556 repo: "r".into(), status: SessionStatus::Working,
557 agent_type: "claude-code".into(), cost_usd: 0.0, started_at: 0,
558 pr_number: None, pr_id: None, workspace_path: None, pid: None,
559 model: None, context_tokens: None, catalogue_path: None,
560 context_used_pct: None, context_total_tokens: None, context_window_size: None,
561 claude_session_id: Some("b7e0b3a0-0000-4000-8000-000000000001".into()),
562 summary: None,
563 terminal_at: None, gate_status: None,
564 };
565 store.upsert_session(&s).unwrap();
566 let found = store.get_session("s3").unwrap().unwrap();
567 assert_eq!(found.claude_session_id.as_deref(), Some("b7e0b3a0-0000-4000-8000-000000000001"));
568 assert_eq!(
570 store.list_sessions().unwrap().iter().find(|x| x.id == "s3").unwrap().claude_session_id.as_deref(),
571 Some("b7e0b3a0-0000-4000-8000-000000000001"),
572 );
573
574 let mut s2 = s.clone();
576 s2.id = "s4".into();
577 s2.claude_session_id = None;
578 store.upsert_session(&s2).unwrap();
579 assert_eq!(store.get_session("s4").unwrap().unwrap().claude_session_id, None);
580 }
581
582 #[test]
583 fn terminal_at_round_trips() {
584 let store = test_store();
585 let s = Session {
586 id: "s5".into(), orchestrator_id: None, name: "w".into(),
587 repo: "r".into(), status: SessionStatus::Done,
588 agent_type: "claude-code".into(), cost_usd: 0.0, started_at: 0,
589 pr_number: None, pr_id: None, workspace_path: None, pid: None,
590 model: None, context_tokens: None, catalogue_path: None,
591 context_used_pct: None, context_total_tokens: None, context_window_size: None,
592 claude_session_id: None,
593 summary: None,
594 terminal_at: Some(1_720_000_000_000), gate_status: None,
595 };
596 store.upsert_session(&s).unwrap();
597 let found = store.get_session("s5").unwrap().unwrap();
598 assert_eq!(found.terminal_at, Some(1_720_000_000_000));
599 assert_eq!(
601 store.list_sessions().unwrap().iter().find(|x| x.id == "s5").unwrap().terminal_at,
602 Some(1_720_000_000_000),
603 );
604
605 let mut s2 = s.clone();
607 s2.id = "s6".into();
608 s2.terminal_at = None;
609 store.upsert_session(&s2).unwrap();
610 assert_eq!(store.get_session("s6").unwrap().unwrap().terminal_at, None);
611 }
612
613 #[test]
617 fn upsert_conflict_updates_started_at() {
618 let store = test_store();
619 let mut s = Session {
620 id: "s3".into(), orchestrator_id: None, name: "w".into(),
621 repo: "r".into(), status: SessionStatus::Working,
622 agent_type: "claude-code".into(), cost_usd: 0.0, started_at: 100,
623 pr_number: None, pr_id: None, workspace_path: None, pid: None,
624 model: None, context_tokens: None, catalogue_path: None,
625 context_used_pct: None, context_total_tokens: None, context_window_size: None,
626 claude_session_id: None,
627 summary: None,
628 terminal_at: None, gate_status: None,
629 };
630 store.upsert_session(&s).unwrap();
631 s.started_at = 200;
632 store.upsert_session(&s).unwrap();
633 assert_eq!(store.get_session("s3").unwrap().unwrap().started_at, 200);
634 }
635
636 #[test]
637 fn get_pr_round_trips_and_misses_cleanly() {
638 let store = test_store();
639 assert!(store.get_pr(9).unwrap().is_none());
640 let pr = PR {
641 id: 9, number: 9, title: "t".into(),
642 url: "https://github.com/org/repo/pull/9".into(),
643 body: String::new(), session_id: "s1".into(),
644 };
645 store.upsert_pr(&pr).unwrap();
646 let found = store.get_pr(9).unwrap().unwrap();
647 assert_eq!(found.number, 9);
648 assert_eq!(found.session_id, "s1");
649 assert_eq!(found.url, pr.url);
650 }
651
652 #[test]
657 fn list_comments_orders_by_created_at_and_upsert_dedupes() {
658 let store = test_store();
659 let later = Comment {
660 id: 2, pr_id: 1, author: "bob".into(), body: "second".into(),
661 path: None, line: None, created_at: 2_000,
662 };
663 let earlier = Comment {
664 id: 1, pr_id: 1, author: "alice".into(), body: "first".into(),
665 path: Some("src/lib.rs".into()), line: Some(10), created_at: 1_000,
666 };
667 store.upsert_comment(&later).unwrap();
668 store.upsert_comment(&earlier).unwrap();
669
670 let comments = store.list_comments().unwrap();
671 assert_eq!(comments.len(), 2);
672 assert_eq!(comments[0].id, 1, "earlier created_at sorts first");
673 assert_eq!(comments[1].id, 2);
674
675 store.upsert_comment(&earlier).unwrap();
677 assert_eq!(store.list_comments().unwrap().len(), 2);
678 }
679
680 #[test]
681 fn context_fields_round_trip() {
682 let store = test_store();
683 let s = Session {
684 id: "s1".into(), orchestrator_id: None, name: "w".into(),
685 repo: "r".into(), status: SessionStatus::Working,
686 agent_type: "claude-code".into(), cost_usd: 2.6, started_at: 0,
687 pr_number: None, pr_id: None, workspace_path: None, pid: None,
688 model: None, context_tokens: None, catalogue_path: None,
689 context_used_pct: Some(62.0),
690 context_total_tokens: Some(124_000),
691 context_window_size: Some(200_000),
692 claude_session_id: None,
693 summary: None,
694 terminal_at: None, gate_status: None,
695 };
696 store.upsert_session(&s).unwrap();
697 let found = store.get_session("s1").unwrap().unwrap();
698 assert_eq!(found.context_used_pct, Some(62.0));
699 assert_eq!(found.context_total_tokens, Some(124_000));
700 assert_eq!(found.context_window_size, Some(200_000));
701 assert_eq!(store.list_sessions().unwrap()[0].context_used_pct, Some(62.0));
703 }
704
705 #[test]
706 fn context_fields_default_to_none() {
707 let store = test_store();
708 let s = Session {
709 id: "s2".into(), orchestrator_id: None, name: "w".into(),
710 repo: "r".into(), status: SessionStatus::Working,
711 agent_type: "claude-code".into(), cost_usd: 0.0, started_at: 0,
712 pr_number: None, pr_id: None, workspace_path: None, pid: None,
713 model: None, context_tokens: None, catalogue_path: None,
714 context_used_pct: None, context_total_tokens: None, context_window_size: None,
715 claude_session_id: None,
716 summary: None,
717 terminal_at: None, gate_status: None,
718 };
719 store.upsert_session(&s).unwrap();
720 let found = store.get_session("s2").unwrap().unwrap();
721 assert_eq!(found.context_used_pct, None);
722 assert_eq!(found.context_total_tokens, None);
723 assert_eq!(found.context_window_size, None);
724 }
725
726 #[test]
727 fn cost_samples_filters_by_agent_and_model_and_excludes_zero() {
728 let store = test_store();
729 for (id, agent_type, model, cost) in [
730 ("a", "claude-code", Some("claude-fable-5"), 3.0),
731 ("b", "claude-code", Some("claude-fable-5"), 5.0),
732 ("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), ] {
736 store.upsert_session(&Session {
737 id: id.into(), orchestrator_id: None, name: id.into(),
738 repo: "r".into(), status: SessionStatus::Working,
739 agent_type: agent_type.into(), cost_usd: cost, started_at: 0,
740 pr_number: None, pr_id: None, workspace_path: None, pid: None,
741 model: model.map(String::from), context_tokens: None, catalogue_path: None,
742 context_used_pct: None, context_total_tokens: None, context_window_size: None,
743 claude_session_id: None, summary: None,
744 terminal_at: None, gate_status: None,
745 }).unwrap();
746 }
747 let samples = store.cost_samples("claude-code", Some("claude-fable-5")).unwrap();
748 assert_eq!(samples.len(), 2);
749 assert!((samples.iter().sum::<f64>() - 8.0).abs() < f64::EPSILON);
750 }
751
752 #[test]
753 fn upsert_and_fetch_session_round_trips_gate_status() {
754 let store = Store::open(tempdir().unwrap().keep().join("t.db")).unwrap();
755 let mut session = crate::types::Session {
756 id: "s1".into(), orchestrator_id: None, name: "w".into(),
757 repo: "r".into(), status: crate::types::SessionStatus::PrOpen,
758 agent_type: "c".into(), cost_usd: 0.0, started_at: 0,
759 pr_number: Some(1), pr_id: Some(1), workspace_path: None, pid: None,
760 model: None, context_tokens: None, catalogue_path: None,
761 context_used_pct: None, context_total_tokens: None, context_window_size: None,
762 claude_session_id: None, summary: None, terminal_at: None,
763 gate_status: None,
764 };
765 session.gate_status = Some(crate::types::GateStatus {
766 ci: crate::types::GateCheck::Failing,
767 review: crate::types::GateCheck::Passing,
768 mergeable: crate::types::GateCheck::Unknown,
769 since: 12_345,
770 });
771 store.upsert_session(&session).unwrap();
772
773 let fetched = store.get_session("s1").unwrap().unwrap();
774 assert_eq!(fetched.gate_status, session.gate_status);
775
776 let listed = store.list_sessions().unwrap();
777 assert_eq!(listed[0].gate_status, session.gate_status);
778 }
779
780 #[test]
781 fn legacy_row_without_gate_status_column_defaults_to_none() {
782 let store = Store::open(tempdir().unwrap().keep().join("t.db")).unwrap();
786 let session = crate::types::Session {
787 id: "s2".into(), orchestrator_id: None, name: "w".into(),
788 repo: "r".into(), status: crate::types::SessionStatus::Working,
789 agent_type: "c".into(), cost_usd: 0.0, started_at: 0,
790 pr_number: None, pr_id: None, workspace_path: None, pid: None,
791 model: None, context_tokens: None, catalogue_path: None,
792 context_used_pct: None, context_total_tokens: None, context_window_size: None,
793 claude_session_id: None, summary: None, terminal_at: None,
794 gate_status: None,
795 };
796 store.upsert_session(&session).unwrap();
797 let fetched = store.get_session("s2").unwrap().unwrap();
798 assert_eq!(fetched.gate_status, None);
799 }
800}