1pub mod agent_runs;
2pub mod runs;
3
4use std::path::Path;
5
6use anyhow::Context;
7use rusqlite::Connection;
8use rusqlite_migration::{M, Migrations};
9
10pub static MIGRATIONS: std::sync::LazyLock<Migrations<'static>> = std::sync::LazyLock::new(|| {
11 Migrations::new(vec![
12 M::up(
13 "CREATE TABLE runs (
14 id TEXT PRIMARY KEY,
15 issue_number INTEGER NOT NULL,
16 status TEXT NOT NULL DEFAULT 'pending',
17 pr_number INTEGER,
18 branch TEXT,
19 worktree_path TEXT,
20 cost_usd REAL NOT NULL DEFAULT 0.0,
21 auto_merge INTEGER NOT NULL DEFAULT 0,
22 started_at TEXT NOT NULL DEFAULT (datetime('now')),
23 finished_at TEXT,
24 error_message TEXT
25);
26
27CREATE TABLE agent_runs (
28 id INTEGER PRIMARY KEY AUTOINCREMENT,
29 run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
30 agent TEXT NOT NULL,
31 cycle INTEGER NOT NULL DEFAULT 1,
32 status TEXT NOT NULL DEFAULT 'pending',
33 cost_usd REAL NOT NULL DEFAULT 0.0,
34 turns INTEGER NOT NULL DEFAULT 0,
35 started_at TEXT NOT NULL DEFAULT (datetime('now')),
36 finished_at TEXT,
37 output_summary TEXT,
38 error_message TEXT
39);
40
41CREATE TABLE review_findings (
42 id INTEGER PRIMARY KEY AUTOINCREMENT,
43 agent_run_id INTEGER NOT NULL REFERENCES agent_runs(id) ON DELETE CASCADE,
44 severity TEXT NOT NULL CHECK (severity IN ('critical', 'warning', 'info')),
45 category TEXT NOT NULL,
46 file_path TEXT,
47 line_number INTEGER,
48 message TEXT NOT NULL,
49 resolved INTEGER NOT NULL DEFAULT 0
50);
51
52CREATE INDEX idx_runs_status ON runs(status);
53CREATE INDEX idx_runs_issue ON runs(issue_number);
54CREATE INDEX idx_agent_runs_run ON agent_runs(run_id);
55CREATE INDEX idx_findings_agent_run ON review_findings(agent_run_id);
56CREATE INDEX idx_findings_severity ON review_findings(severity);",
57 ),
58 M::up("ALTER TABLE runs ADD COLUMN complexity TEXT NOT NULL DEFAULT 'full';"),
59 M::up("ALTER TABLE runs ADD COLUMN issue_source TEXT NOT NULL DEFAULT 'github';"),
60 M::up("ALTER TABLE agent_runs ADD COLUMN raw_output TEXT;"),
61 ])
62});
63
64pub fn open(path: &Path) -> anyhow::Result<Connection> {
65 let mut conn = Connection::open(path).context("opening database")?;
66 configure(&conn)?;
67 MIGRATIONS.to_latest(&mut conn).context("running database migrations")?;
68 Ok(conn)
69}
70
71pub fn open_in_memory() -> anyhow::Result<Connection> {
72 let mut conn = Connection::open_in_memory().context("opening in-memory database")?;
73 configure(&conn)?;
74 MIGRATIONS.to_latest(&mut conn).context("running database migrations")?;
75 Ok(conn)
76}
77
78fn configure(conn: &Connection) -> anyhow::Result<()> {
79 conn.pragma_update(None, "journal_mode", "WAL")?;
80 conn.pragma_update(None, "synchronous", "NORMAL")?;
81 conn.pragma_update(None, "busy_timeout", "5000")?;
82 conn.pragma_update(None, "foreign_keys", "ON")?;
83 Ok(())
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
88pub enum RunStatus {
89 Pending,
90 Implementing,
91 Reviewing,
92 Fixing,
93 Merging,
94 Complete,
95 Failed,
96}
97
98impl std::fmt::Display for RunStatus {
99 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100 f.write_str(match self {
101 Self::Pending => "pending",
102 Self::Implementing => "implementing",
103 Self::Reviewing => "reviewing",
104 Self::Fixing => "fixing",
105 Self::Merging => "merging",
106 Self::Complete => "complete",
107 Self::Failed => "failed",
108 })
109 }
110}
111
112impl std::str::FromStr for RunStatus {
113 type Err = anyhow::Error;
114
115 fn from_str(s: &str) -> Result<Self, Self::Err> {
116 match s {
117 "pending" => Ok(Self::Pending),
118 "implementing" => Ok(Self::Implementing),
119 "reviewing" => Ok(Self::Reviewing),
120 "fixing" => Ok(Self::Fixing),
121 "merging" => Ok(Self::Merging),
122 "complete" => Ok(Self::Complete),
123 "failed" => Ok(Self::Failed),
124 other => anyhow::bail!("unknown run status: {other}"),
125 }
126 }
127}
128
129#[derive(Debug, Clone)]
131pub struct Run {
132 pub id: String,
133 pub issue_number: u32,
134 pub status: RunStatus,
135 pub pr_number: Option<u32>,
136 pub branch: Option<String>,
137 pub worktree_path: Option<String>,
138 pub cost_usd: f64,
139 pub auto_merge: bool,
140 pub started_at: String,
141 pub finished_at: Option<String>,
142 pub error_message: Option<String>,
143 pub complexity: String,
144 pub issue_source: String,
145}
146
147#[derive(Debug, Clone)]
149pub struct AgentRun {
150 pub id: i64,
151 pub run_id: String,
152 pub agent: String,
153 pub cycle: u32,
154 pub status: String,
155 pub cost_usd: f64,
156 pub turns: u32,
157 pub started_at: String,
158 pub finished_at: Option<String>,
159 pub output_summary: Option<String>,
160 pub error_message: Option<String>,
161 pub raw_output: Option<String>,
162}
163
164#[derive(Debug, Clone)]
166pub struct ReviewFinding {
167 pub id: i64,
168 pub agent_run_id: i64,
169 pub severity: String,
170 pub category: String,
171 pub file_path: Option<String>,
172 pub line_number: Option<u32>,
173 pub message: String,
174 pub resolved: bool,
175}
176
177#[cfg(test)]
178mod tests {
179 use proptest::prelude::*;
180
181 use super::*;
182
183 const ALL_STATUSES: [RunStatus; 7] = [
184 RunStatus::Pending,
185 RunStatus::Implementing,
186 RunStatus::Reviewing,
187 RunStatus::Fixing,
188 RunStatus::Merging,
189 RunStatus::Complete,
190 RunStatus::Failed,
191 ];
192
193 proptest! {
194 #[test]
195 fn run_status_display_fromstr_roundtrip(idx in 0..7usize) {
196 let status = ALL_STATUSES[idx];
197 let s = status.to_string();
198 let parsed: RunStatus = s.parse().unwrap();
199 assert_eq!(status, parsed);
200 }
201
202 #[test]
203 fn arbitrary_strings_never_panic_on_parse(s in "\\PC{1,50}") {
204 let _ = s.parse::<RunStatus>();
206 }
207 }
208
209 #[test]
210 fn migrations_validate() {
211 MIGRATIONS.validate().unwrap();
212 }
213
214 #[test]
215 fn open_in_memory_succeeds() {
216 let conn = open_in_memory().unwrap();
217 let count: i64 = conn.query_row("SELECT COUNT(*) FROM runs", [], |row| row.get(0)).unwrap();
219 assert_eq!(count, 0);
220 }
221
222 #[test]
223 fn run_status_display_roundtrip() {
224 let statuses = [
225 RunStatus::Pending,
226 RunStatus::Implementing,
227 RunStatus::Reviewing,
228 RunStatus::Fixing,
229 RunStatus::Merging,
230 RunStatus::Complete,
231 RunStatus::Failed,
232 ];
233 for status in statuses {
234 let s = status.to_string();
235 let parsed: RunStatus = s.parse().unwrap();
236 assert_eq!(status, parsed);
237 }
238 }
239
240 #[test]
241 fn run_status_unknown_returns_error() {
242 let result = "banana".parse::<RunStatus>();
243 assert!(result.is_err());
244 }
245}