Thanks to visit codestin.com
Credit goes to docs.rs

Skip to main content

ctx_graph/
lib.rs

1use anyhow::{Context, Result};
2use rusqlite::{Connection, OptionalExtension, params};
3use serde::{Deserialize, Serialize};
4use std::path::Path;
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct SymbolHit {
8    pub id: i64,
9    pub file_path: String,
10    pub name: String,
11    pub kind: String,
12    pub signature: String,
13}
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct SnippetHit {
17    pub snippet_id: i64,
18    pub file_path: String,
19    pub symbol_name: Option<String>,
20    pub content: String,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct FailureRecord {
25    pub message: String,
26    pub root_cause: Option<String>,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct MemoryDirective {
31    pub id: i64,
32    pub key: String,
33    pub body: String,
34    pub scope: String,
35    pub source: String,
36    pub created_at: String,
37    pub updated_at: String,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct RunInsert {
42    pub command: String,
43    pub status: String,
44    pub agent: Option<String>,
45    pub exit_code: Option<i32>,
46    pub duration_ms: Option<u64>,
47    pub original_tokens: Option<usize>,
48    pub packed_tokens: Option<usize>,
49    pub reduction_pct: Option<f64>,
50    pub fallback_used: bool,
51    pub pack_path: Option<String>,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct RunRecord {
56    pub id: i64,
57    pub command: String,
58    pub status: String,
59    pub agent: Option<String>,
60    pub exit_code: Option<i32>,
61    pub duration_ms: Option<u64>,
62    pub original_tokens: Option<usize>,
63    pub packed_tokens: Option<usize>,
64    pub reduction_pct: Option<f64>,
65    pub fallback_used: bool,
66    pub pack_path: Option<String>,
67    pub created_at: String,
68}
69
70pub struct GraphStore {
71    conn: Connection,
72}
73
74impl GraphStore {
75    pub fn open(path: &Path) -> Result<Self> {
76        if let Some(parent) = path.parent() {
77            std::fs::create_dir_all(parent).with_context(|| {
78                format!("failed to create graph parent dir {}", parent.display())
79            })?;
80        }
81
82        let conn = Connection::open(path)
83            .with_context(|| format!("failed to open sqlite db at {}", path.display()))?;
84
85        Ok(Self { conn })
86    }
87
88    pub fn init_schema(&self) -> Result<()> {
89        self.conn
90            .execute_batch(include_str!("schema.sql"))
91            .context("failed to initialize sqlite schema")?;
92        self.migrate_runs_table()
93    }
94
95    pub fn index_file(&self, path: &str) -> Result<()> {
96        self.conn
97            .execute(
98                "INSERT INTO files(path, updated_at) VALUES (?1, CURRENT_TIMESTAMP)
99                 ON CONFLICT(path) DO UPDATE SET updated_at = CURRENT_TIMESTAMP",
100                params![path],
101            )
102            .context("failed to index file")?;
103        Ok(())
104    }
105
106    pub fn remove_file(&mut self, path: &str) -> Result<bool> {
107        let Some(file_id) = self.file_id(path)? else {
108            return Ok(false);
109        };
110
111        let tx = self
112            .conn
113            .transaction()
114            .context("failed to start graph prune transaction")?;
115        tx.execute(
116            "DELETE FROM edges
117             WHERE src_symbol_id IN (SELECT id FROM symbols WHERE file_id = ?1)
118                OR dst_symbol_id IN (SELECT id FROM symbols WHERE file_id = ?1)",
119            params![file_id],
120        )
121        .context("failed to delete file edges")?;
122        tx.execute(
123            "DELETE FROM embeddings_metadata
124             WHERE snippet_id IN (SELECT id FROM snippets WHERE file_id = ?1)",
125            params![file_id],
126        )
127        .context("failed to delete file embedding metadata")?;
128        tx.execute("DELETE FROM snippets WHERE file_id = ?1", params![file_id])
129            .context("failed to delete file snippets")?;
130        tx.execute("DELETE FROM symbols WHERE file_id = ?1", params![file_id])
131            .context("failed to delete file symbols")?;
132        tx.execute("DELETE FROM files WHERE id = ?1", params![file_id])
133            .context("failed to delete file row")?;
134        tx.commit()
135            .context("failed to commit graph prune transaction")?;
136        Ok(true)
137    }
138
139    pub fn query_files(&self, term: &str) -> Result<Vec<String>> {
140        let pattern = format!("%{}%", term);
141        let mut stmt = self
142            .conn
143            .prepare("SELECT path FROM files WHERE path LIKE ?1 ORDER BY path ASC")
144            .context("failed to prepare query")?;
145
146        let mut rows = stmt
147            .query(params![pattern])
148            .context("failed to query files")?;
149        let mut out = Vec::new();
150
151        while let Some(row) = rows.next().context("failed to read row")? {
152            out.push(row.get::<_, String>(0).context("failed to decode path")?);
153        }
154
155        Ok(out)
156    }
157
158    pub fn upsert_symbol(
159        &self,
160        file_path: &str,
161        name: &str,
162        kind: &str,
163        signature: &str,
164    ) -> Result<i64> {
165        self.index_file(file_path)?;
166        let file_id = self
167            .file_id(file_path)?
168            .context("file id should exist after index_file")?;
169
170        self.conn
171            .execute(
172                "INSERT INTO symbols(file_id, name, kind, signature, updated_at)
173                 VALUES (?1, ?2, ?3, ?4, CURRENT_TIMESTAMP)
174                 ON CONFLICT(file_id, name, kind) DO UPDATE SET
175                   signature = excluded.signature,
176                   updated_at = CURRENT_TIMESTAMP",
177                params![file_id, name, kind, signature],
178            )
179            .context("failed to upsert symbol")?;
180
181        self.conn
182            .query_row(
183                "SELECT id FROM symbols WHERE file_id = ?1 AND name = ?2 AND kind = ?3",
184                params![file_id, name, kind],
185                |row| row.get::<_, i64>(0),
186            )
187            .context("failed to fetch upserted symbol id")
188    }
189
190    pub fn search_symbols(&self, term: &str) -> Result<Vec<SymbolHit>> {
191        let pattern = format!("%{}%", term);
192        let mut stmt = self
193            .conn
194            .prepare(
195                "SELECT s.id, f.path, s.name, s.kind, COALESCE(s.signature, '')
196                 FROM symbols s
197                 JOIN files f ON f.id = s.file_id
198                 WHERE s.name LIKE ?1 OR s.signature LIKE ?1 OR f.path LIKE ?1
199                 ORDER BY s.updated_at DESC, s.id DESC",
200            )
201            .context("failed to prepare search_symbols")?;
202
203        let rows = stmt
204            .query_map(params![pattern], |row| {
205                Ok(SymbolHit {
206                    id: row.get(0)?,
207                    file_path: row.get(1)?,
208                    name: row.get(2)?,
209                    kind: row.get(3)?,
210                    signature: row.get(4)?,
211                })
212            })
213            .context("failed to run search_symbols")?;
214
215        let mut out = Vec::new();
216        for row in rows {
217            out.push(row.context("failed to decode symbol row")?);
218        }
219        Ok(out)
220    }
221
222    pub fn find_symbols_by_exact_name(&self, name: &str, limit: usize) -> Result<Vec<SymbolHit>> {
223        let mut stmt = self
224            .conn
225            .prepare(
226                "SELECT s.id, f.path, s.name, s.kind, COALESCE(s.signature, '')
227                 FROM symbols s
228                 JOIN files f ON f.id = s.file_id
229                 WHERE s.name = ?1
230                 ORDER BY s.updated_at DESC, s.id DESC
231                 LIMIT ?2",
232            )
233            .context("failed to prepare find_symbols_by_exact_name")?;
234
235        let rows = stmt
236            .query_map(params![name, limit as i64], |row| {
237                Ok(SymbolHit {
238                    id: row.get(0)?,
239                    file_path: row.get(1)?,
240                    name: row.get(2)?,
241                    kind: row.get(3)?,
242                    signature: row.get(4)?,
243                })
244            })
245            .context("failed to run find_symbols_by_exact_name")?;
246
247        let mut out = Vec::new();
248        for row in rows {
249            out.push(row.context("failed to decode exact symbol row")?);
250        }
251        Ok(out)
252    }
253
254    pub fn link_symbols(
255        &self,
256        src_symbol_id: i64,
257        dst_symbol_id: i64,
258        edge_type: &str,
259        metadata_json: Option<&str>,
260    ) -> Result<()> {
261        self.conn
262            .execute(
263                "INSERT INTO edges(src_symbol_id, dst_symbol_id, type, metadata_json)
264                 VALUES (?1, ?2, ?3, ?4)
265                 ON CONFLICT(src_symbol_id, dst_symbol_id, type) DO UPDATE SET
266                   metadata_json = excluded.metadata_json",
267                params![src_symbol_id, dst_symbol_id, edge_type, metadata_json],
268            )
269            .context("failed to link symbols")?;
270        Ok(())
271    }
272
273    pub fn related_symbols(&self, symbol_name: &str, limit: usize) -> Result<Vec<SymbolHit>> {
274        let mut stmt = self
275            .conn
276            .prepare(
277                "SELECT DISTINCT dst.id, f.path, dst.name, dst.kind, COALESCE(dst.signature, '')
278                 FROM symbols src
279                 JOIN edges e ON e.src_symbol_id = src.id
280                 JOIN symbols dst ON dst.id = e.dst_symbol_id
281                 JOIN files f ON f.id = dst.file_id
282                 WHERE src.name = ?1
283                 LIMIT ?2",
284            )
285            .context("failed to prepare related_symbols")?;
286
287        let rows = stmt
288            .query_map(params![symbol_name, limit as i64], |row| {
289                Ok(SymbolHit {
290                    id: row.get(0)?,
291                    file_path: row.get(1)?,
292                    name: row.get(2)?,
293                    kind: row.get(3)?,
294                    signature: row.get(4)?,
295                })
296            })
297            .context("failed to execute related_symbols")?;
298
299        let mut out = Vec::new();
300        for row in rows {
301            out.push(row.context("failed to decode related symbol row")?);
302        }
303
304        Ok(out)
305    }
306
307    pub fn add_snippet(
308        &self,
309        file_path: &str,
310        symbol_name: Option<&str>,
311        content: &str,
312    ) -> Result<i64> {
313        self.index_file(file_path)?;
314        let file_id = self
315            .file_id(file_path)?
316            .context("file id should exist after index_file")?;
317        let symbol_id = if let Some(name) = symbol_name {
318            self.conn
319                .query_row(
320                    "SELECT id FROM symbols WHERE file_id = ?1 AND name = ?2 LIMIT 1",
321                    params![file_id, name],
322                    |row| row.get::<_, i64>(0),
323                )
324                .optional()
325                .context("failed to fetch symbol id for snippet")?
326        } else {
327            None
328        };
329
330        self.conn
331            .execute(
332                "INSERT INTO snippets(file_id, symbol_id, content, created_at)
333                 VALUES (?1, ?2, ?3, CURRENT_TIMESTAMP)",
334                params![file_id, symbol_id, content],
335            )
336            .context("failed to insert snippet")?;
337
338        Ok(self.conn.last_insert_rowid())
339    }
340
341    pub fn search_snippets(&self, term: &str, limit: usize) -> Result<Vec<SnippetHit>> {
342        let escaped = term.replace('"', "\"");
343        let query = if escaped.trim().is_empty() {
344            "*".to_string()
345        } else {
346            escaped
347        };
348
349        let mut stmt = self
350            .conn
351            .prepare(
352                "SELECT s.id, f.path, sym.name, s.content
353                 FROM snippets_fts fts
354                 JOIN snippets s ON s.id = fts.rowid
355                 JOIN files f ON f.id = s.file_id
356                 LEFT JOIN symbols sym ON sym.id = s.symbol_id
357                 WHERE snippets_fts MATCH ?1
358                 LIMIT ?2",
359            )
360            .context("failed to prepare search_snippets")?;
361
362        let rows = stmt
363            .query_map(params![query, limit as i64], |row| {
364                Ok(SnippetHit {
365                    snippet_id: row.get(0)?,
366                    file_path: row.get(1)?,
367                    symbol_name: row.get(2)?,
368                    content: row.get(3)?,
369                })
370            })
371            .context("failed to query snippets fts")?;
372
373        let mut out = Vec::new();
374        for row in rows {
375            out.push(row.context("failed to decode snippet row")?);
376        }
377        Ok(out)
378    }
379
380    pub fn record_run(&self, command: &str, status: &str) -> Result<i64> {
381        self.record_invocation_run(&RunInsert {
382            command: command.to_string(),
383            status: status.to_string(),
384            agent: None,
385            exit_code: None,
386            duration_ms: None,
387            original_tokens: None,
388            packed_tokens: None,
389            reduction_pct: None,
390            fallback_used: false,
391            pack_path: None,
392        })
393    }
394
395    pub fn record_invocation_run(&self, run: &RunInsert) -> Result<i64> {
396        self.conn
397            .execute(
398                "INSERT INTO runs(
399                   command, status, agent, exit_code, duration_ms, original_tokens,
400                   packed_tokens, reduction_pct, fallback_used, pack_path, created_at
401                 )
402                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, CURRENT_TIMESTAMP)",
403                params![
404                    run.command,
405                    run.status,
406                    run.agent,
407                    run.exit_code,
408                    run.duration_ms.map(|value| value as i64),
409                    run.original_tokens.map(|value| value as i64),
410                    run.packed_tokens.map(|value| value as i64),
411                    run.reduction_pct,
412                    if run.fallback_used { 1 } else { 0 },
413                    run.pack_path,
414                ],
415            )
416            .context("failed to insert run")?;
417        Ok(self.conn.last_insert_rowid())
418    }
419
420    pub fn recent_runs(&self, limit: usize) -> Result<Vec<RunRecord>> {
421        let mut stmt = self
422            .conn
423            .prepare(
424                "SELECT id, command, status, agent, exit_code, duration_ms, original_tokens,
425                        packed_tokens, reduction_pct, fallback_used, pack_path, created_at
426                 FROM runs
427                 ORDER BY id DESC
428                 LIMIT ?1",
429            )
430            .context("failed to prepare recent_runs")?;
431
432        let rows = stmt
433            .query_map(params![limit as i64], |row| {
434                let duration_ms = row.get::<_, Option<i64>>(5)?.map(|value| value as u64);
435                let original_tokens = row.get::<_, Option<i64>>(6)?.map(|value| value as usize);
436                let packed_tokens = row.get::<_, Option<i64>>(7)?.map(|value| value as usize);
437                let fallback_used = row.get::<_, i64>(9)? != 0;
438
439                Ok(RunRecord {
440                    id: row.get(0)?,
441                    command: row.get(1)?,
442                    status: row.get(2)?,
443                    agent: row.get(3)?,
444                    exit_code: row.get(4)?,
445                    duration_ms,
446                    original_tokens,
447                    packed_tokens,
448                    reduction_pct: row.get(8)?,
449                    fallback_used,
450                    pack_path: row.get(10)?,
451                    created_at: row.get(11)?,
452                })
453            })
454            .context("failed to query recent_runs")?;
455
456        let mut out = Vec::new();
457        for row in rows {
458            out.push(row.context("failed to decode run row")?);
459        }
460        Ok(out)
461    }
462
463    pub fn record_failure(
464        &self,
465        run_id: i64,
466        message: &str,
467        root_cause: Option<&str>,
468    ) -> Result<i64> {
469        self.conn
470            .execute(
471                "INSERT INTO failures(run_id, message, root_cause, created_at)
472                 VALUES (?1, ?2, ?3, CURRENT_TIMESTAMP)",
473                params![run_id, message, root_cause],
474            )
475            .context("failed to insert failure")?;
476        Ok(self.conn.last_insert_rowid())
477    }
478
479    pub fn recent_failures(&self, limit: usize) -> Result<Vec<FailureRecord>> {
480        let mut stmt = self
481            .conn
482            .prepare(
483                "SELECT message, root_cause
484                 FROM failures
485                 ORDER BY id DESC
486                 LIMIT ?1",
487            )
488            .context("failed to prepare recent_failures")?;
489
490        let rows = stmt
491            .query_map(params![limit as i64], |row| {
492                Ok(FailureRecord {
493                    message: row.get(0)?,
494                    root_cause: row.get(1)?,
495                })
496            })
497            .context("failed to query recent_failures")?;
498
499        let mut out = Vec::new();
500        for row in rows {
501            out.push(row.context("failed to decode failure row")?);
502        }
503        Ok(out)
504    }
505
506    pub fn record_decision(&self, title: &str, summary: &str) -> Result<i64> {
507        self.conn
508            .execute(
509                "INSERT INTO tasks(title, summary, created_at) VALUES (?1, ?2, CURRENT_TIMESTAMP)",
510                params![title, summary],
511            )
512            .context("failed to insert task decision")?;
513        let task_id = self.conn.last_insert_rowid();
514
515        self.conn
516            .execute(
517                "INSERT INTO notes(task_id, body, created_at) VALUES (?1, ?2, CURRENT_TIMESTAMP)",
518                params![task_id, summary],
519            )
520            .context("failed to insert decision note")?;
521
522        Ok(task_id)
523    }
524
525    pub fn recent_decisions(&self, limit: usize) -> Result<Vec<String>> {
526        let mut stmt = self
527            .conn
528            .prepare(
529                "SELECT t.title, n.body
530                 FROM notes n
531                 JOIN tasks t ON t.id = n.task_id
532                 ORDER BY n.id DESC
533                 LIMIT ?1",
534            )
535            .context("failed to prepare recent_decisions")?;
536
537        let rows = stmt
538            .query_map(params![limit as i64], |row| {
539                let title: String = row.get(0)?;
540                let body: String = row.get(1)?;
541                Ok(format!("{title}: {body}"))
542            })
543            .context("failed to query recent_decisions")?;
544
545        let mut out = Vec::new();
546        for row in rows {
547            out.push(row.context("failed to decode decision row")?);
548        }
549        Ok(out)
550    }
551
552    pub fn upsert_memory_directive(
553        &self,
554        key: &str,
555        body: &str,
556        scope: &str,
557        source: &str,
558    ) -> Result<i64> {
559        self.conn
560            .execute(
561                "INSERT INTO memory_directives(key, body, scope, source, created_at, updated_at)
562                 VALUES (?1, ?2, ?3, ?4, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
563                 ON CONFLICT(key) DO UPDATE SET
564                   body = excluded.body,
565                   scope = excluded.scope,
566                   source = excluded.source,
567                   updated_at = CURRENT_TIMESTAMP",
568                params![key, body, scope, source],
569            )
570            .context("failed to upsert memory directive")?;
571
572        self.conn
573            .query_row(
574                "SELECT id FROM memory_directives WHERE key = ?1",
575                params![key],
576                |row| row.get::<_, i64>(0),
577            )
578            .context("failed to fetch memory directive id")
579    }
580
581    pub fn get_memory_directive(&self, key: &str) -> Result<Option<MemoryDirective>> {
582        self.conn
583            .query_row(
584                "SELECT id, key, body, scope, source, created_at, updated_at
585                 FROM memory_directives
586                 WHERE key = ?1",
587                params![key],
588                |row| {
589                    Ok(MemoryDirective {
590                        id: row.get(0)?,
591                        key: row.get(1)?,
592                        body: row.get(2)?,
593                        scope: row.get(3)?,
594                        source: row.get(4)?,
595                        created_at: row.get(5)?,
596                        updated_at: row.get(6)?,
597                    })
598                },
599            )
600            .optional()
601            .context("failed to fetch memory directive by key")
602    }
603
604    pub fn list_memory_directives(
605        &self,
606        scope: Option<&str>,
607        limit: usize,
608    ) -> Result<Vec<MemoryDirective>> {
609        let mut out = Vec::new();
610        if let Some(scope_filter) = scope {
611            let mut stmt = self
612                .conn
613                .prepare(
614                    "SELECT id, key, body, scope, source, created_at, updated_at
615                     FROM memory_directives
616                     WHERE scope = ?1
617                     ORDER BY updated_at DESC, id DESC
618                     LIMIT ?2",
619                )
620                .context("failed to prepare scoped memory directives query")?;
621
622            let rows = stmt
623                .query_map(params![scope_filter, limit as i64], |row| {
624                    Ok(MemoryDirective {
625                        id: row.get(0)?,
626                        key: row.get(1)?,
627                        body: row.get(2)?,
628                        scope: row.get(3)?,
629                        source: row.get(4)?,
630                        created_at: row.get(5)?,
631                        updated_at: row.get(6)?,
632                    })
633                })
634                .context("failed to query scoped memory directives")?;
635
636            for row in rows {
637                out.push(row.context("failed to decode scoped memory directive row")?);
638            }
639            return Ok(out);
640        }
641
642        let mut stmt = self
643            .conn
644            .prepare(
645                "SELECT id, key, body, scope, source, created_at, updated_at
646                 FROM memory_directives
647                 ORDER BY updated_at DESC, id DESC
648                 LIMIT ?1",
649            )
650            .context("failed to prepare memory directives query")?;
651
652        let rows = stmt
653            .query_map(params![limit as i64], |row| {
654                Ok(MemoryDirective {
655                    id: row.get(0)?,
656                    key: row.get(1)?,
657                    body: row.get(2)?,
658                    scope: row.get(3)?,
659                    source: row.get(4)?,
660                    created_at: row.get(5)?,
661                    updated_at: row.get(6)?,
662                })
663            })
664            .context("failed to query memory directives")?;
665
666        for row in rows {
667            out.push(row.context("failed to decode memory directive row")?);
668        }
669
670        Ok(out)
671    }
672
673    pub fn search_memory_directives(
674        &self,
675        query: &str,
676        limit: usize,
677    ) -> Result<Vec<MemoryDirective>> {
678        let query = query.trim();
679        if query.is_empty() {
680            return self.list_memory_directives(None, limit);
681        }
682
683        let terms = query
684            .split_whitespace()
685            .filter(|t| t.len() >= 2)
686            .map(|t| t.to_lowercase())
687            .collect::<Vec<_>>();
688
689        if terms.is_empty() {
690            return self.list_memory_directives(None, limit);
691        }
692
693        let mut weighted = Vec::new();
694        for directive in self.list_memory_directives(None, 500)? {
695            let hay = format!(
696                "{} {} {} {}",
697                directive.key, directive.body, directive.scope, directive.source
698            )
699            .to_lowercase();
700            let score = terms.iter().filter(|t| hay.contains(t.as_str())).count();
701            if score > 0 {
702                weighted.push((score, directive));
703            }
704        }
705
706        weighted.sort_by(|a, b| {
707            b.0.cmp(&a.0)
708                .then_with(|| b.1.updated_at.cmp(&a.1.updated_at))
709                .then_with(|| b.1.id.cmp(&a.1.id))
710        });
711
712        Ok(weighted
713            .into_iter()
714            .take(limit)
715            .map(|(_, directive)| directive)
716            .collect())
717    }
718
719    pub fn delete_memory_directive(&self, key: &str) -> Result<bool> {
720        let affected = self
721            .conn
722            .execute("DELETE FROM memory_directives WHERE key = ?1", params![key])
723            .context("failed to delete memory directive")?;
724        Ok(affected > 0)
725    }
726
727    pub fn delete_memory_directives_by_prefix(&self, prefix: &str) -> Result<usize> {
728        let pattern = format!("{prefix}.%");
729        let affected = self
730            .conn
731            .execute(
732                "DELETE FROM memory_directives WHERE key = ?1 OR key LIKE ?2",
733                params![prefix, pattern],
734            )
735            .context("failed to delete memory directives by prefix")?;
736        Ok(affected)
737    }
738
739    fn migrate_runs_table(&self) -> Result<()> {
740        self.ensure_column("runs", "agent", "ALTER TABLE runs ADD COLUMN agent TEXT")?;
741        self.ensure_column(
742            "runs",
743            "exit_code",
744            "ALTER TABLE runs ADD COLUMN exit_code INTEGER",
745        )?;
746        self.ensure_column(
747            "runs",
748            "duration_ms",
749            "ALTER TABLE runs ADD COLUMN duration_ms INTEGER",
750        )?;
751        self.ensure_column(
752            "runs",
753            "original_tokens",
754            "ALTER TABLE runs ADD COLUMN original_tokens INTEGER",
755        )?;
756        self.ensure_column(
757            "runs",
758            "packed_tokens",
759            "ALTER TABLE runs ADD COLUMN packed_tokens INTEGER",
760        )?;
761        self.ensure_column(
762            "runs",
763            "reduction_pct",
764            "ALTER TABLE runs ADD COLUMN reduction_pct REAL",
765        )?;
766        self.ensure_column(
767            "runs",
768            "fallback_used",
769            "ALTER TABLE runs ADD COLUMN fallback_used INTEGER NOT NULL DEFAULT 0",
770        )?;
771        self.ensure_column(
772            "runs",
773            "pack_path",
774            "ALTER TABLE runs ADD COLUMN pack_path TEXT",
775        )?;
776        self.conn
777            .execute_batch(
778                "CREATE INDEX IF NOT EXISTS idx_runs_agent_created_at ON runs(agent, created_at);",
779            )
780            .context("failed to create runs agent index")?;
781        Ok(())
782    }
783
784    fn ensure_column(&self, table: &str, column: &str, ddl: &str) -> Result<()> {
785        let mut stmt = self
786            .conn
787            .prepare(&format!("PRAGMA table_info({table})"))
788            .context("failed to inspect table columns")?;
789        let columns = stmt
790            .query_map([], |row| row.get::<_, String>(1))
791            .context("failed to query table columns")?
792            .collect::<std::result::Result<Vec<_>, _>>()?;
793
794        if !columns.iter().any(|existing| existing == column) {
795            self.conn
796                .execute_batch(ddl)
797                .with_context(|| format!("failed to add column {table}.{column}"))?;
798        }
799        Ok(())
800    }
801
802    fn file_id(&self, file_path: &str) -> Result<Option<i64>> {
803        self.conn
804            .query_row(
805                "SELECT id FROM files WHERE path = ?1",
806                params![file_path],
807                |row| row.get::<_, i64>(0),
808            )
809            .optional()
810            .context("failed to fetch file id")
811    }
812}