Skip to main content

ninox_core/
store.rs

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