1use std::path::Path;
9use std::time::Duration;
10
11use async_trait::async_trait;
12use sqlx::Row;
13use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePool, SqlitePoolOptions};
14
15use crate::automation::{AutomationRun, RUN_HISTORY_LIMIT, RunSource, StoredAutomation};
16use crate::error::{CoreError, Result};
17use crate::registry::RegisteredWorkspace;
18
19#[async_trait]
21pub trait ConfigStore: Send + Sync {
22 async fn list_repos(&self) -> Result<Vec<RegisteredWorkspace>>;
24 async fn upsert_repo(&self, repo: &RegisteredWorkspace) -> Result<()>;
26 async fn delete_repo(&self, name: &str) -> Result<bool>;
28
29 async fn list_automations(&self) -> Result<Vec<StoredAutomation>>;
31
32 async fn upsert_automation(&self, automation: &StoredAutomation) -> Result<()>;
38
39 async fn delete_automation(&self, id: &str) -> Result<bool>;
41
42 async fn claim_automation_fire(&self, id: &str, fire_at: &str) -> Result<bool>;
54
55 async fn record_run(&self, run: &AutomationRun) -> Result<()>;
58
59 async fn list_runs(&self, automation_id: &str, limit: usize) -> Result<Vec<AutomationRun>>;
61}
62
63const SCHEMA: &str = "CREATE TABLE IF NOT EXISTS repos (\
64 name TEXT PRIMARY KEY,\
65 root TEXT NOT NULL,\
66 config TEXT NOT NULL\
67)";
68
69const AUTOMATIONS_SCHEMA: &str = "CREATE TABLE IF NOT EXISTS automations (\
73 id TEXT PRIMARY KEY,\
74 spec TEXT NOT NULL,\
75 webhook_secret TEXT,\
76 last_fired_at TEXT\
77)";
78
79const RUNS_SCHEMA: &str = "CREATE TABLE IF NOT EXISTS automation_runs (\
80 automation_id TEXT NOT NULL,\
81 fired_at TEXT NOT NULL,\
82 source TEXT NOT NULL,\
83 agent_id TEXT,\
84 error TEXT\
85)";
86
87const RUNS_INDEX: &str = "CREATE INDEX IF NOT EXISTS automation_runs_by_time ON automation_runs (automation_id, fired_at DESC)";
88
89pub(crate) fn encode_spec(automation: &StoredAutomation) -> Result<String> {
95 let mut spec = automation.automation.clone();
96 spec.last_fired_at = None;
97 Ok(serde_json::to_string(&spec)?)
98}
99
100pub(crate) fn encode_source(source: RunSource) -> Result<String> {
103 serde_json::to_value(source)?
104 .as_str()
105 .map(str::to_string)
106 .ok_or_else(|| CoreError::Store("run source did not serialize as a string".into()))
107}
108
109pub(crate) fn decode_automation<R>(row: &R) -> Result<StoredAutomation>
112where
113 R: Row,
114 for<'a> String: sqlx::Decode<'a, R::Database> + sqlx::Type<R::Database>,
115 for<'a> Option<String>: sqlx::Decode<'a, R::Database> + sqlx::Type<R::Database>,
116 for<'a> &'a str: sqlx::ColumnIndex<R>,
117{
118 let decode = |e: sqlx::Error| CoreError::Store(format!("automation decode: {e}"));
119 let spec: String = row.try_get("spec").map_err(decode)?;
120 let webhook_secret: Option<String> = row.try_get("webhook_secret").map_err(decode)?;
121 let last_fired_at: Option<String> = row.try_get("last_fired_at").map_err(decode)?;
122 let mut automation: crate::automation::Automation = serde_json::from_str(&spec)?;
123 automation.last_fired_at = last_fired_at;
124 Ok(StoredAutomation {
125 automation,
126 webhook_secret,
127 })
128}
129
130pub(crate) fn decode_run<R>(row: &R) -> Result<AutomationRun>
132where
133 R: Row,
134 for<'a> String: sqlx::Decode<'a, R::Database> + sqlx::Type<R::Database>,
135 for<'a> Option<String>: sqlx::Decode<'a, R::Database> + sqlx::Type<R::Database>,
136 for<'a> &'a str: sqlx::ColumnIndex<R>,
137{
138 let decode = |e: sqlx::Error| CoreError::Store(format!("run decode: {e}"));
139 let source: String = row.try_get("source").map_err(decode)?;
140 Ok(AutomationRun {
141 automation_id: row.try_get("automation_id").map_err(decode)?,
142 fired_at: row.try_get("fired_at").map_err(decode)?,
143 source: serde_json::from_value(serde_json::Value::String(source))?,
144 agent_id: row.try_get("agent_id").map_err(decode)?,
145 error: row.try_get("error").map_err(decode)?,
146 })
147}
148
149pub struct SqliteConfigStore {
151 pool: SqlitePool,
152}
153
154impl SqliteConfigStore {
155 pub async fn open(dir: &Path) -> Result<Self> {
158 std::fs::create_dir_all(dir)?;
159 let path = dir.join("events.db");
160 let opts = SqliteConnectOptions::new()
161 .filename(&path)
162 .create_if_missing(true)
163 .journal_mode(SqliteJournalMode::Wal)
164 .busy_timeout(Duration::from_secs(5));
165 let pool = SqlitePoolOptions::new()
166 .connect_with(opts)
167 .await
168 .map_err(|e| CoreError::Store(format!("opening config store: {e}")))?;
169 for ddl in [SCHEMA, AUTOMATIONS_SCHEMA, RUNS_SCHEMA, RUNS_INDEX] {
170 sqlx::query(ddl)
171 .execute(&pool)
172 .await
173 .map_err(|e| CoreError::Store(format!("initializing config schema: {e}")))?;
174 }
175 Ok(Self { pool })
176 }
177}
178
179#[async_trait]
180impl ConfigStore for SqliteConfigStore {
181 async fn list_repos(&self) -> Result<Vec<RegisteredWorkspace>> {
182 let rows = sqlx::query("SELECT name, root, config FROM repos ORDER BY name")
183 .fetch_all(&self.pool)
184 .await
185 .map_err(|e| CoreError::Store(format!("list_repos: {e}")))?;
186 let mut repos = Vec::with_capacity(rows.len());
187 for row in rows {
188 let decode = |e: sqlx::Error| CoreError::Store(format!("list_repos decode: {e}"));
189 let name: String = row.try_get("name").map_err(decode)?;
190 let root: String = row.try_get("root").map_err(decode)?;
191 let config_json: String = row.try_get("config").map_err(decode)?;
192 repos.push(RegisteredWorkspace {
193 name,
194 root: root.into(),
195 config: serde_json::from_str(&config_json)?,
196 });
197 }
198 Ok(repos)
199 }
200
201 async fn upsert_repo(&self, repo: &RegisteredWorkspace) -> Result<()> {
202 let config = serde_json::to_string(&repo.config)?;
203 let root = repo
205 .root
206 .to_str()
207 .ok_or_else(|| CoreError::Store(format!("non-UTF8 repo root path: {:?}", repo.root)))?;
208 sqlx::query(
209 "INSERT INTO repos (name, root, config) VALUES (?, ?, ?) \
210 ON CONFLICT(name) DO UPDATE SET root = excluded.root, config = excluded.config",
211 )
212 .bind(&repo.name)
213 .bind(root)
214 .bind(config)
215 .execute(&self.pool)
216 .await
217 .map_err(|e| CoreError::Store(format!("upsert_repo: {e}")))?;
218 Ok(())
219 }
220
221 async fn delete_repo(&self, name: &str) -> Result<bool> {
222 let res = sqlx::query("DELETE FROM repos WHERE name = ?")
223 .bind(name)
224 .execute(&self.pool)
225 .await
226 .map_err(|e| CoreError::Store(format!("delete_repo: {e}")))?;
227 Ok(res.rows_affected() > 0)
228 }
229
230 async fn list_automations(&self) -> Result<Vec<StoredAutomation>> {
231 let rows =
232 sqlx::query("SELECT spec, webhook_secret, last_fired_at FROM automations ORDER BY id")
233 .fetch_all(&self.pool)
234 .await
235 .map_err(|e| CoreError::Store(format!("list_automations: {e}")))?;
236 rows.iter().map(decode_automation).collect()
237 }
238
239 async fn upsert_automation(&self, automation: &StoredAutomation) -> Result<()> {
240 let spec = encode_spec(automation)?;
241 sqlx::query(
242 "INSERT INTO automations (id, spec, webhook_secret, last_fired_at) \
243 VALUES (?, ?, ?, NULL) \
244 ON CONFLICT(id) DO UPDATE SET spec = excluded.spec, \
245 webhook_secret = excluded.webhook_secret",
246 )
247 .bind(&automation.automation.id)
248 .bind(spec)
249 .bind(&automation.webhook_secret)
250 .execute(&self.pool)
251 .await
252 .map_err(|e| CoreError::Store(format!("upsert_automation: {e}")))?;
253 Ok(())
254 }
255
256 async fn delete_automation(&self, id: &str) -> Result<bool> {
257 sqlx::query("DELETE FROM automation_runs WHERE automation_id = ?")
258 .bind(id)
259 .execute(&self.pool)
260 .await
261 .map_err(|e| CoreError::Store(format!("delete_automation runs: {e}")))?;
262 let res = sqlx::query("DELETE FROM automations WHERE id = ?")
263 .bind(id)
264 .execute(&self.pool)
265 .await
266 .map_err(|e| CoreError::Store(format!("delete_automation: {e}")))?;
267 Ok(res.rows_affected() > 0)
268 }
269
270 async fn claim_automation_fire(&self, id: &str, fire_at: &str) -> Result<bool> {
271 let res = sqlx::query(
272 "UPDATE automations SET last_fired_at = ? \
273 WHERE id = ? AND (last_fired_at IS NULL OR last_fired_at < ?)",
274 )
275 .bind(fire_at)
276 .bind(id)
277 .bind(fire_at)
278 .execute(&self.pool)
279 .await
280 .map_err(|e| CoreError::Store(format!("claim_automation_fire: {e}")))?;
281 Ok(res.rows_affected() == 1)
282 }
283
284 async fn record_run(&self, run: &AutomationRun) -> Result<()> {
285 sqlx::query(
286 "INSERT INTO automation_runs (automation_id, fired_at, source, agent_id, error) \
287 VALUES (?, ?, ?, ?, ?)",
288 )
289 .bind(&run.automation_id)
290 .bind(&run.fired_at)
291 .bind(encode_source(run.source)?)
292 .bind(&run.agent_id)
293 .bind(&run.error)
294 .execute(&self.pool)
295 .await
296 .map_err(|e| CoreError::Store(format!("record_run: {e}")))?;
297
298 sqlx::query(
301 "DELETE FROM automation_runs WHERE automation_id = ? AND rowid NOT IN \
302 (SELECT rowid FROM automation_runs WHERE automation_id = ? \
303 ORDER BY fired_at DESC LIMIT ?)",
304 )
305 .bind(&run.automation_id)
306 .bind(&run.automation_id)
307 .bind(RUN_HISTORY_LIMIT as i64)
308 .execute(&self.pool)
309 .await
310 .map_err(|e| CoreError::Store(format!("trim runs: {e}")))?;
311 Ok(())
312 }
313
314 async fn list_runs(&self, automation_id: &str, limit: usize) -> Result<Vec<AutomationRun>> {
315 let rows = sqlx::query(
316 "SELECT automation_id, fired_at, source, agent_id, error FROM automation_runs \
317 WHERE automation_id = ? ORDER BY fired_at DESC LIMIT ?",
318 )
319 .bind(automation_id)
320 .bind(limit as i64)
321 .fetch_all(&self.pool)
322 .await
323 .map_err(|e| CoreError::Store(format!("list_runs: {e}")))?;
324 rows.iter().map(decode_run).collect()
325 }
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331
332 #[tokio::test]
333 async fn sqlite_config_store_satisfies_conformance() {
334 let dir = tempfile::tempdir().unwrap();
335 let store = SqliteConfigStore::open(dir.path()).await.unwrap();
336 crate::testkit::config_store_conformance(&store).await;
337 }
338
339 #[tokio::test]
340 async fn sqlite_config_store_satisfies_automation_conformance() {
341 let dir = tempfile::tempdir().unwrap();
342 let store = SqliteConfigStore::open(dir.path()).await.unwrap();
343 crate::testkit::automation_store_conformance(&store).await;
344 }
345
346 #[tokio::test]
347 async fn sqlite_config_store_claims_a_tick_once() {
348 let dir = tempfile::tempdir().unwrap();
349 let store = SqliteConfigStore::open(dir.path()).await.unwrap();
350 crate::testkit::automation_claim_conformance(&store).await;
351 }
352}