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 ("terminal_at", "ALTER TABLE sessions ADD COLUMN terminal_at INTEGER"),
56 ] {
57 if !Self::column_exists(&conn, "sessions", col)? {
58 conn.execute(ddl, [])?;
59 }
60 }
61 Ok(Self { conn: Mutex::new(conn) })
62 }
63
64 fn column_exists(conn: &Connection, table: &str, column: &str) -> Result<bool> {
65 let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
66 let exists = stmt
67 .query_map([], |r| r.get::<_, String>(1))?
68 .filter_map(|r| r.ok())
69 .any(|c| c == column);
70 Ok(exists)
71 }
72
73 pub fn upsert_session(&self, s: &Session) -> Result<()> {
74 let status = serde_json::to_string(&s.status)?.replace('"', "");
75 let conn = self.conn.lock().unwrap();
76 conn.execute(
77 "INSERT INTO sessions (id,orchestrator_id,name,repo,status,agent_type,
78 cost_usd,started_at,pr_number,pr_id,workspace_path,pid,model,context_tokens,
79 catalogue_path,context_used_pct,context_total_tokens,context_window_size,
80 claude_session_id,terminal_at)
81 VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20)
82 ON CONFLICT(id) DO UPDATE SET
83 repo=excluded.repo,
84 status=excluded.status,cost_usd=excluded.cost_usd,
85 started_at=excluded.started_at,
86 pr_number=excluded.pr_number,pr_id=excluded.pr_id,
87 workspace_path=excluded.workspace_path,pid=excluded.pid,
88 model=excluded.model,context_tokens=excluded.context_tokens,
89 catalogue_path=excluded.catalogue_path,
90 context_used_pct=excluded.context_used_pct,
91 context_total_tokens=excluded.context_total_tokens,
92 context_window_size=excluded.context_window_size,
93 claude_session_id=excluded.claude_session_id,
94 terminal_at=excluded.terminal_at",
95 params![
96 s.id, s.orchestrator_id, s.name, s.repo, status, s.agent_type,
97 s.cost_usd, s.started_at, s.pr_number, s.pr_id,
98 s.workspace_path, s.pid, s.model, s.context_tokens,
99 s.catalogue_path, s.context_used_pct, s.context_total_tokens,
100 s.context_window_size, s.claude_session_id, s.terminal_at
101 ],
102 )?;
103 Ok(())
104 }
105
106 pub fn list_sessions(&self) -> Result<Vec<Session>> {
107 let conn = self.conn.lock().unwrap();
108 let mut stmt = conn.prepare(
109 "SELECT id,orchestrator_id,name,repo,status,agent_type,cost_usd,
110 started_at,pr_number,pr_id,workspace_path,pid,model,context_tokens,
111 catalogue_path,context_used_pct,context_total_tokens,context_window_size,
112 claude_session_id,terminal_at
113 FROM sessions ORDER BY started_at DESC",
114 )?;
115 let rows = stmt.query_map([], |r| {
116 Ok((
117 r.get::<_, String>(0)?,
118 r.get::<_, Option<String>>(1)?,
119 r.get::<_, String>(2)?,
120 r.get::<_, String>(3)?,
121 r.get::<_, String>(4)?,
122 r.get::<_, String>(5)?,
123 r.get::<_, f64>(6)?,
124 r.get::<_, i64>(7)?,
125 r.get::<_, Option<u64>>(8)?,
126 r.get::<_, Option<i64>>(9)?,
127 r.get::<_, Option<String>>(10)?,
128 r.get::<_, Option<u32>>(11)?,
129 r.get::<_, Option<String>>(12)?,
130 r.get::<_, Option<i64>>(13)?,
131 r.get::<_, Option<String>>(14)?,
132 r.get::<_, Option<f64>>(15)?,
133 r.get::<_, Option<i64>>(16)?,
134 r.get::<_, Option<i64>>(17)?,
135 r.get::<_, Option<String>>(18)?,
136 r.get::<_, Option<i64>>(19)?,
137 ))
138 })?;
139 rows.map(|r| {
140 let (id, orchestrator_id, name, repo, status_str, agent_type,
141 cost_usd, started_at, pr_number, pr_id, workspace_path, pid,
142 model, context_tokens, catalogue_path, context_used_pct,
143 context_total_tokens, context_window_size, claude_session_id,
144 terminal_at) = r?;
145 let status = serde_json::from_str(&format!("\"{status_str}\""))
146 .unwrap_or(SessionStatus::Working);
147 Ok(Session {
148 id, orchestrator_id, name, repo, status, agent_type,
149 cost_usd, started_at, pr_number, pr_id, workspace_path, pid,
150 model, context_tokens: context_tokens.map(|v| v.max(0) as u64),
151 catalogue_path,
152 context_used_pct,
153 context_total_tokens: context_total_tokens.map(|v| v.max(0) as u64),
154 context_window_size: context_window_size.map(|v| v.max(0) as u64),
155 claude_session_id,
156 terminal_at,
157 })
158 })
159 .collect()
160 }
161
162 pub fn get_session(&self, id: &str) -> Result<Option<Session>> {
163 let conn = self.conn.lock().unwrap();
164 let mut stmt = conn.prepare(
165 "SELECT id,orchestrator_id,name,repo,status,agent_type,cost_usd,
166 started_at,pr_number,pr_id,workspace_path,pid,model,context_tokens,
167 catalogue_path,context_used_pct,context_total_tokens,context_window_size,
168 claude_session_id,terminal_at
169 FROM sessions WHERE id = ?1",
170 )?;
171 let mut rows = stmt.query_map([id], |r| {
172 Ok((
173 r.get::<_, String>(0)?,
174 r.get::<_, Option<String>>(1)?,
175 r.get::<_, String>(2)?,
176 r.get::<_, String>(3)?,
177 r.get::<_, String>(4)?,
178 r.get::<_, String>(5)?,
179 r.get::<_, f64>(6)?,
180 r.get::<_, i64>(7)?,
181 r.get::<_, Option<u64>>(8)?,
182 r.get::<_, Option<i64>>(9)?,
183 r.get::<_, Option<String>>(10)?,
184 r.get::<_, Option<u32>>(11)?,
185 r.get::<_, Option<String>>(12)?,
186 r.get::<_, Option<i64>>(13)?,
187 r.get::<_, Option<String>>(14)?,
188 r.get::<_, Option<f64>>(15)?,
189 r.get::<_, Option<i64>>(16)?,
190 r.get::<_, Option<i64>>(17)?,
191 r.get::<_, Option<String>>(18)?,
192 r.get::<_, Option<i64>>(19)?,
193 ))
194 })?;
195 match rows.next() {
196 None => Ok(None),
197 Some(r) => {
198 let (id, orchestrator_id, name, repo, status_str, agent_type,
199 cost_usd, started_at, pr_number, pr_id, workspace_path, pid,
200 model, context_tokens, catalogue_path, context_used_pct,
201 context_total_tokens, context_window_size, claude_session_id,
202 terminal_at) = r?;
203 let status = serde_json::from_str(&format!("\"{status_str}\""))
204 .unwrap_or(SessionStatus::Working);
205 Ok(Some(Session {
206 id, orchestrator_id, name, repo, status, agent_type,
207 cost_usd, started_at, pr_number, pr_id, workspace_path, pid,
208 model, context_tokens: context_tokens.map(|v| v.max(0) as u64),
209 catalogue_path,
210 context_used_pct,
211 context_total_tokens: context_total_tokens.map(|v| v.max(0) as u64),
212 context_window_size: context_window_size.map(|v| v.max(0) as u64),
213 claude_session_id,
214 terminal_at,
215 }))
216 }
217 }
218 }
219
220 pub fn cost_samples(&self, agent_type: &str, model: Option<&str>) -> Result<Vec<f64>> {
226 let sessions = self.list_sessions()?;
227 Ok(sessions
228 .into_iter()
229 .filter(|s| {
230 s.agent_type == agent_type
231 && s.model.as_deref() == model
232 && s.cost_usd > 0.0
233 })
234 .map(|s| s.cost_usd)
235 .collect())
236 }
237
238 pub fn upsert_orchestrator(&self, o: &Orchestrator) -> Result<()> {
239 let conn = self.conn.lock().unwrap();
240 conn.execute(
241 "INSERT INTO orchestrators(id,name,created_at) VALUES(?1,?2,?3)
242 ON CONFLICT(id) DO UPDATE SET name=excluded.name",
243 params![o.id, o.name, o.created_at],
244 )?;
245 Ok(())
246 }
247
248 pub fn sessions_by_orchestrator(&self, orchestrator_id: &str) -> Result<Vec<Session>> {
249 let sessions = self.list_sessions()?;
250 Ok(sessions.into_iter().filter(|s| s.orchestrator_id.as_deref() == Some(orchestrator_id)).collect())
251 }
252
253 pub fn delete_session(&self, id: &str) -> Result<()> {
254 let conn = self.conn.lock().unwrap();
255 conn.execute("DELETE FROM sessions WHERE id = ?1", [id])?;
256 Ok(())
257 }
258
259 pub fn delete_orchestrator(&self, id: &str) -> Result<()> {
260 let conn = self.conn.lock().unwrap();
261 conn.execute("DELETE FROM sessions WHERE orchestrator_id = ?1", [id])?;
262 conn.execute("DELETE FROM sessions WHERE id = ?1", [id])?;
263 conn.execute("DELETE FROM orchestrators WHERE id = ?1", [id])?;
264 Ok(())
265 }
266
267 pub fn upsert_pr(&self, pr: &PR) -> Result<()> {
268 let conn = self.conn.lock().unwrap();
269 conn.execute(
270 "INSERT OR REPLACE INTO prs
271 (id, number, title, url, body, session_id)
272 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
273 params![pr.id, pr.number, pr.title, pr.url, pr.body, pr.session_id],
274 )?;
275 Ok(())
276 }
277
278 pub fn get_pr(&self, id: PrId) -> Result<Option<PR>> {
279 let conn = self.conn.lock().unwrap();
280 let mut stmt = conn.prepare(
281 "SELECT id, number, title, url, body, session_id FROM prs WHERE id = ?1",
282 )?;
283 let mut rows = stmt.query_map([id], |r| {
284 Ok(PR {
285 id: r.get(0)?,
286 number: r.get(1)?,
287 title: r.get(2)?,
288 url: r.get(3)?,
289 body: r.get(4)?,
290 session_id: r.get(5)?,
291 })
292 })?;
293 rows.next().transpose().map_err(Into::into)
294 }
295
296 pub fn upsert_ci_status(&self, ci: &CIStatus) -> Result<()> {
297 let conn = self.conn.lock().unwrap();
298 conn.execute(
299 "INSERT OR REPLACE INTO ci_status
300 (pr_id, total, passing, failing, pending)
301 VALUES (?1, ?2, ?3, ?4, ?5)",
302 params![ci.pr_id, ci.total, ci.passing, ci.failing, ci.pending],
303 )?;
304 Ok(())
305 }
306
307 pub fn upsert_comment(&self, c: &Comment) -> Result<()> {
308 let conn = self.conn.lock().unwrap();
309 conn.execute(
310 "INSERT OR REPLACE INTO review_comments
311 (id, pr_id, author, body, path, line, created_at)
312 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
313 params![c.id, c.pr_id, c.author, c.body, c.path, c.line, c.created_at],
314 )?;
315 Ok(())
316 }
317
318 pub fn list_orchestrators(&self) -> Result<Vec<Orchestrator>> {
319 let conn = self.conn.lock().unwrap();
320 let mut stmt = conn.prepare(
321 "SELECT id,name,created_at FROM orchestrators ORDER BY created_at DESC",
322 )?;
323 let rows = stmt.query_map([], |r| {
324 Ok(Orchestrator {
325 id: r.get(0)?,
326 name: r.get(1)?,
327 created_at: r.get(2)?,
328 })
329 })?;
330 rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
331 }
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337 use tempfile::tempdir;
338
339 fn test_store() -> Store {
340 let dir = tempdir().unwrap();
341 let path = dir.path().join("t.db");
342 std::mem::forget(dir);
344 Store::open(path).unwrap()
345 }
346
347 #[test]
348 fn upsert_and_list_session() {
349 let store = test_store();
350 let session = Session {
351 id: "s1".into(), orchestrator_id: None, name: "worker-1".into(),
352 repo: "slievr/Athene".into(), status: SessionStatus::Working,
353 agent_type: "claude-code".into(), cost_usd: 0.0, started_at: 0,
354 pr_number: None, pr_id: None, workspace_path: None, pid: None,
355 model: None, context_tokens: None, catalogue_path: None,
356 context_used_pct: None, context_total_tokens: None, context_window_size: None,
357 claude_session_id: None,
358 terminal_at: None,
359 };
360 store.upsert_session(&session).unwrap();
361 let list = store.list_sessions().unwrap();
362 assert_eq!(list.len(), 1);
363 assert_eq!(list[0].id, "s1");
364 }
365
366 #[test]
367 fn upsert_updates_status() {
368 let store = test_store();
369 let mut s = Session {
370 id: "s1".into(), orchestrator_id: None, name: "w".into(),
371 repo: "r".into(), status: SessionStatus::Working,
372 agent_type: "c".into(), cost_usd: 0.0, started_at: 0,
373 pr_number: None, pr_id: None, workspace_path: None, pid: None,
374 model: None, context_tokens: None, catalogue_path: None,
375 context_used_pct: None, context_total_tokens: None, context_window_size: None,
376 claude_session_id: None,
377 terminal_at: None,
378 };
379 store.upsert_session(&s).unwrap();
380 s.status = SessionStatus::Done;
381 store.upsert_session(&s).unwrap();
382 let list = store.list_sessions().unwrap();
383 assert_eq!(list.len(), 1);
384 assert!(matches!(list[0].status, SessionStatus::Done));
385 }
386
387 #[test]
392 fn upsert_updates_repo() {
393 let store = test_store();
394 let mut s = Session {
395 id: "s1".into(), orchestrator_id: None, name: "w".into(),
396 repo: "OwnerA/repoA".into(), status: SessionStatus::Working,
397 agent_type: "c".into(), cost_usd: 0.0, started_at: 0,
398 pr_number: None, pr_id: None, workspace_path: None, pid: None,
399 model: None, context_tokens: None, catalogue_path: None,
400 context_used_pct: None, context_total_tokens: None, context_window_size: None,
401 claude_session_id: None,
402 terminal_at: None,
403 };
404 store.upsert_session(&s).unwrap();
405 s.repo = "OwnerB/repoB".into();
406 store.upsert_session(&s).unwrap();
407 let updated = store.get_session("s1").unwrap().unwrap();
408 assert_eq!(updated.repo, "OwnerB/repoB");
409 }
410
411 #[test]
412 fn get_session_by_id() {
413 let store = test_store();
414 let s = Session {
415 id: "s1".into(), orchestrator_id: None, name: "w".into(),
416 repo: "r".into(), status: SessionStatus::Working,
417 agent_type: "c".into(), cost_usd: 0.0, started_at: 0,
418 pr_number: None, pr_id: None, workspace_path: None, pid: None,
419 model: None, context_tokens: None, catalogue_path: None,
420 context_used_pct: None, context_total_tokens: None, context_window_size: None,
421 claude_session_id: None,
422 terminal_at: None,
423 };
424 store.upsert_session(&s).unwrap();
425 let found = store.get_session("s1").unwrap();
426 assert!(found.is_some());
427 assert_eq!(found.unwrap().name, "w");
428 assert!(store.get_session("missing").unwrap().is_none());
429 }
430
431 #[test]
432 fn model_and_context_tokens_round_trip() {
433 let store = test_store();
434 let s = Session {
435 id: "s1".into(), orchestrator_id: None, name: "w".into(),
436 repo: "r".into(), status: SessionStatus::Working,
437 agent_type: "claude-code".into(), cost_usd: 1.5, started_at: 0,
438 pr_number: None, pr_id: None, workspace_path: None, pid: None,
439 model: Some("claude-fable-5".into()), context_tokens: Some(214_000), catalogue_path: None,
440 context_used_pct: None, context_total_tokens: None, context_window_size: None,
441 claude_session_id: None,
442 terminal_at: None,
443 };
444 store.upsert_session(&s).unwrap();
445 let found = store.get_session("s1").unwrap().unwrap();
446 assert_eq!(found.model.as_deref(), Some("claude-fable-5"));
447 assert_eq!(found.context_tokens, Some(214_000));
448 }
449
450 #[test]
451 fn catalogue_path_round_trips() {
452 let store = test_store();
453 let s = Session {
454 id: "s2".into(), orchestrator_id: None, name: "w".into(),
455 repo: "r".into(), status: SessionStatus::Working,
456 agent_type: "claude-code".into(), cost_usd: 0.0, started_at: 0,
457 pr_number: None, pr_id: None, workspace_path: None, pid: None,
458 model: None, context_tokens: None,
459 catalogue_path: Some("/brains/x".into()),
460 context_used_pct: None, context_total_tokens: None, context_window_size: None,
461 claude_session_id: None,
462 terminal_at: None,
463 };
464 store.upsert_session(&s).unwrap();
465 let found = store.get_session("s2").unwrap().unwrap();
466 assert_eq!(found.catalogue_path.as_deref(), Some("/brains/x"));
467 assert_eq!(store.list_sessions().unwrap()[0].catalogue_path.as_deref(), Some("/brains/x"));
469 }
470
471 #[test]
472 fn claude_session_id_round_trips() {
473 let store = test_store();
474 let s = Session {
475 id: "s3".into(), orchestrator_id: None, name: "w".into(),
476 repo: "r".into(), status: SessionStatus::Working,
477 agent_type: "claude-code".into(), cost_usd: 0.0, started_at: 0,
478 pr_number: None, pr_id: None, workspace_path: None, pid: None,
479 model: None, context_tokens: None, catalogue_path: None,
480 context_used_pct: None, context_total_tokens: None, context_window_size: None,
481 claude_session_id: Some("b7e0b3a0-0000-4000-8000-000000000001".into()),
482 terminal_at: None,
483 };
484 store.upsert_session(&s).unwrap();
485 let found = store.get_session("s3").unwrap().unwrap();
486 assert_eq!(found.claude_session_id.as_deref(), Some("b7e0b3a0-0000-4000-8000-000000000001"));
487 assert_eq!(
489 store.list_sessions().unwrap().iter().find(|x| x.id == "s3").unwrap().claude_session_id.as_deref(),
490 Some("b7e0b3a0-0000-4000-8000-000000000001"),
491 );
492
493 let mut s2 = s.clone();
495 s2.id = "s4".into();
496 s2.claude_session_id = None;
497 store.upsert_session(&s2).unwrap();
498 assert_eq!(store.get_session("s4").unwrap().unwrap().claude_session_id, None);
499 }
500
501 #[test]
502 fn terminal_at_round_trips() {
503 let store = test_store();
504 let s = Session {
505 id: "s5".into(), orchestrator_id: None, name: "w".into(),
506 repo: "r".into(), status: SessionStatus::Done,
507 agent_type: "claude-code".into(), cost_usd: 0.0, started_at: 0,
508 pr_number: None, pr_id: None, workspace_path: None, pid: None,
509 model: None, context_tokens: None, catalogue_path: None,
510 context_used_pct: None, context_total_tokens: None, context_window_size: None,
511 claude_session_id: None,
512 terminal_at: Some(1_720_000_000_000),
513 };
514 store.upsert_session(&s).unwrap();
515 let found = store.get_session("s5").unwrap().unwrap();
516 assert_eq!(found.terminal_at, Some(1_720_000_000_000));
517 assert_eq!(
519 store.list_sessions().unwrap().iter().find(|x| x.id == "s5").unwrap().terminal_at,
520 Some(1_720_000_000_000),
521 );
522
523 let mut s2 = s.clone();
525 s2.id = "s6".into();
526 s2.terminal_at = None;
527 store.upsert_session(&s2).unwrap();
528 assert_eq!(store.get_session("s6").unwrap().unwrap().terminal_at, None);
529 }
530
531 #[test]
535 fn upsert_conflict_updates_started_at() {
536 let store = test_store();
537 let mut s = Session {
538 id: "s3".into(), orchestrator_id: None, name: "w".into(),
539 repo: "r".into(), status: SessionStatus::Working,
540 agent_type: "claude-code".into(), cost_usd: 0.0, started_at: 100,
541 pr_number: None, pr_id: None, workspace_path: None, pid: None,
542 model: None, context_tokens: None, catalogue_path: None,
543 context_used_pct: None, context_total_tokens: None, context_window_size: None,
544 claude_session_id: None,
545 terminal_at: None,
546 };
547 store.upsert_session(&s).unwrap();
548 s.started_at = 200;
549 store.upsert_session(&s).unwrap();
550 assert_eq!(store.get_session("s3").unwrap().unwrap().started_at, 200);
551 }
552
553 #[test]
554 fn get_pr_round_trips_and_misses_cleanly() {
555 let store = test_store();
556 assert!(store.get_pr(9).unwrap().is_none());
557 let pr = PR {
558 id: 9, number: 9, title: "t".into(),
559 url: "https://github.com/org/repo/pull/9".into(),
560 body: String::new(), session_id: "s1".into(),
561 };
562 store.upsert_pr(&pr).unwrap();
563 let found = store.get_pr(9).unwrap().unwrap();
564 assert_eq!(found.number, 9);
565 assert_eq!(found.session_id, "s1");
566 assert_eq!(found.url, pr.url);
567 }
568
569 #[test]
570 fn context_fields_round_trip() {
571 let store = test_store();
572 let s = Session {
573 id: "s1".into(), orchestrator_id: None, name: "w".into(),
574 repo: "r".into(), status: SessionStatus::Working,
575 agent_type: "claude-code".into(), cost_usd: 2.6, started_at: 0,
576 pr_number: None, pr_id: None, workspace_path: None, pid: None,
577 model: None, context_tokens: None, catalogue_path: None,
578 context_used_pct: Some(62.0),
579 context_total_tokens: Some(124_000),
580 context_window_size: Some(200_000),
581 claude_session_id: None,
582 terminal_at: None,
583 };
584 store.upsert_session(&s).unwrap();
585 let found = store.get_session("s1").unwrap().unwrap();
586 assert_eq!(found.context_used_pct, Some(62.0));
587 assert_eq!(found.context_total_tokens, Some(124_000));
588 assert_eq!(found.context_window_size, Some(200_000));
589 assert_eq!(store.list_sessions().unwrap()[0].context_used_pct, Some(62.0));
591 }
592
593 #[test]
594 fn context_fields_default_to_none() {
595 let store = test_store();
596 let s = Session {
597 id: "s2".into(), orchestrator_id: None, name: "w".into(),
598 repo: "r".into(), status: SessionStatus::Working,
599 agent_type: "claude-code".into(), cost_usd: 0.0, started_at: 0,
600 pr_number: None, pr_id: None, workspace_path: None, pid: None,
601 model: None, context_tokens: None, catalogue_path: None,
602 context_used_pct: None, context_total_tokens: None, context_window_size: None,
603 claude_session_id: None,
604 terminal_at: None,
605 };
606 store.upsert_session(&s).unwrap();
607 let found = store.get_session("s2").unwrap().unwrap();
608 assert_eq!(found.context_used_pct, None);
609 assert_eq!(found.context_total_tokens, None);
610 assert_eq!(found.context_window_size, None);
611 }
612
613 #[test]
614 fn cost_samples_filters_by_agent_and_model_and_excludes_zero() {
615 let store = test_store();
616 for (id, agent_type, model, cost) in [
617 ("a", "claude-code", Some("claude-fable-5"), 3.0),
618 ("b", "claude-code", Some("claude-fable-5"), 5.0),
619 ("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), ] {
623 store.upsert_session(&Session {
624 id: id.into(), orchestrator_id: None, name: id.into(),
625 repo: "r".into(), status: SessionStatus::Working,
626 agent_type: agent_type.into(), cost_usd: cost, started_at: 0,
627 pr_number: None, pr_id: None, workspace_path: None, pid: None,
628 model: model.map(String::from), context_tokens: None, catalogue_path: None,
629 context_used_pct: None, context_total_tokens: None, context_window_size: None,
630 claude_session_id: None,
631 terminal_at: None,
632 }).unwrap();
633 }
634 let samples = store.cost_samples("claude-code", Some("claude-fable-5")).unwrap();
635 assert_eq!(samples.len(), 2);
636 assert!((samples.iter().sum::<f64>() - 8.0).abs() < f64::EPSILON);
637 }
638}