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}