1use anyhow::Context;
27use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePool, SqlitePoolOptions};
28use std::path::Path;
29use std::sync::{Arc, OnceLock};
30
31pub(crate) mod control;
32pub(crate) mod dependencies;
33mod helpers;
34pub(crate) mod jobs;
35pub(crate) mod kv;
36mod metrics;
37mod pricing;
38pub(crate) mod replica;
39pub(crate) mod tasks;
40
41pub use control::{ChatCostSummary, ControlMessage, MemoryEntry, parse_since_duration};
42pub use dependencies::{
43 BlockerSnapshot, DepSource, NewDependency, ObservedPullRequest, TaskDependency,
44 WaitingOnDependency, blocker_failure_kind, blocker_is_satisfied, blocker_snapshot_key,
45 dep_label, worktree_refresh_key,
46};
47pub use helpers::{
48 StoreResult, get_cost_estimate, get_cost_estimate_result, get_recent_memory,
49 get_recent_memory_result, get_task_field, get_task_field_direct, get_token_usage,
50 get_token_usage_result, opt_store_get_task, opt_store_get_task_by_id, resolve_store_id,
51 store_increment, store_increment_by_id, store_log_activity, store_reset_counters,
52 store_reset_failure_counters, store_set, store_set_by_id, store_set_result,
53 store_set_result_by_id, store_touch_updated_at,
54};
55pub use jobs::JobState;
56pub use metrics::{
57 AgentStat, CostByGroup, CostPeriod, CostSummary, ErrorStat, HighReviewCycleTask,
58 InsertTaskMetric, MetricsSummary, SlowTaskInfo,
59};
60pub use pricing::{CostEstimate, ModelPricing, TokenUsage, pricing_for_model};
61pub use tasks::{
62 CliTaskRef, CliTaskSpec, CompleteRun, NewTask, RunTokenUsage, StartRun, StoreRoute, Task,
63 TaskActivity, TaskRun, TaskStatus, UpsertExternal, parse_cli_task_spec,
64};
65
66pub async fn default_db_path() -> anyhow::Result<std::path::PathBuf> {
68 crate::home::db_path().await
69}
70
71#[derive(Clone)]
73pub struct TaskStore {
74 pool: SqlitePool,
75 raft: Arc<OnceLock<crate::cluster::Node>>,
77 daemon: Arc<OnceLock<crate::cluster::local::Endpoint>>,
79}
80
81impl std::fmt::Debug for TaskStore {
82 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83 f.debug_struct("TaskStore").finish_non_exhaustive()
84 }
85}
86
87impl TaskStore {
88 pub async fn open(db_path: &Path) -> anyhow::Result<Self> {
92 let options = SqliteConnectOptions::new()
93 .filename(db_path)
94 .journal_mode(SqliteJournalMode::Wal)
95 .create_if_missing(true)
96 .busy_timeout(std::time::Duration::from_secs(5))
97 .pragma("foreign_keys", "ON");
98
99 let pool = SqlitePoolOptions::new()
100 .max_connections(5)
101 .connect_with(options)
102 .await
103 .with_context(|| format!("opening task store: {}", db_path.display()))?;
104
105 let store = Self {
106 pool,
107 raft: Arc::default(),
108 daemon: Arc::default(),
109 };
110 store.migrate().await?;
111 Ok(store)
112 }
113
114 pub async fn open_single(db_path: &Path) -> anyhow::Result<Self> {
120 let options = SqliteConnectOptions::new()
121 .filename(db_path)
122 .journal_mode(SqliteJournalMode::Wal)
123 .create_if_missing(true)
124 .busy_timeout(std::time::Duration::from_secs(5))
125 .pragma("foreign_keys", "ON");
126
127 let pool = SqlitePoolOptions::new()
128 .max_connections(1)
129 .connect_with(options)
130 .await
131 .with_context(|| format!("opening task store (single): {}", db_path.display()))?;
132
133 let store = Self {
134 pool,
135 raft: Arc::default(),
136 daemon: Arc::default(),
137 };
138 store.migrate().await?;
139 Ok(store)
140 }
141
142 #[cfg(test)]
144 pub async fn open_memory() -> anyhow::Result<Self> {
145 let options = SqliteConnectOptions::new()
146 .filename(":memory:")
147 .pragma("foreign_keys", "ON");
148 let pool = SqlitePoolOptions::new()
149 .max_connections(1)
150 .connect_with(options)
151 .await?;
152
153 let store = Self {
154 pool,
155 raft: Arc::default(),
156 daemon: Arc::default(),
157 };
158 store.migrate().await?;
159 Ok(store)
160 }
161
162 async fn migrate(&self) -> anyhow::Result<()> {
164 sqlx::migrate!("./migrations")
165 .run(&self.pool)
166 .await
167 .context("running task store migrations")?;
168 Ok(())
169 }
170
171 pub fn attach_node(&self, node: crate::cluster::Node) {
172 if self.raft.set(node).is_err() {
173 tracing::warn!("raft already attached to the task store");
174 }
175 }
176
177 pub fn is_leader(&self) -> bool {
180 self.raft.get().is_none_or(super::cluster::Node::is_leader)
181 }
182
183 pub(crate) fn replicates(&self) -> bool {
185 self.raft.get().is_some() || self.daemon.get().is_some()
186 }
187
188 pub fn node(&self) -> Option<&crate::cluster::Node> {
189 self.raft.get()
190 }
191
192 pub fn attach_daemon(&self, endpoint: crate::cluster::local::Endpoint) {
193 let _ = self.daemon.set(endpoint);
194 }
195
196 pub(crate) async fn write(
198 &self,
199 cmd: crate::cluster::Command,
200 ) -> anyhow::Result<crate::cluster::Reply> {
201 let Some(node) = self.raft.get() else {
202 if let Some(endpoint) = self.daemon.get() {
203 match crate::cluster::local::send(endpoint, &cmd).await {
204 Ok(reply) => return reply_result(reply),
205 Err(e) if e.is_connect() => {
207 tracing::debug!(err = %e, "daemon not listening, writing directly");
208 }
209 Err(e) => return Err(e.into()),
210 }
211 }
212
213 return self.apply_direct(cmd).await;
214 };
215 let req = crate::cluster::Request {
216 now: crate::cluster::now_stamp(),
217 cmd,
218 };
219 reply_result(node.write(req).await?)
220 }
221
222 pub(crate) async fn write_int(&self, cmd: crate::cluster::Command) -> anyhow::Result<i64> {
223 match self.write(cmd).await? {
224 crate::cluster::Reply::Int(n) => Ok(n),
225 other => anyhow::bail!("expected an integer reply, got {other:?}"),
226 }
227 }
228
229 pub(crate) async fn write_bool(&self, cmd: crate::cluster::Command) -> anyhow::Result<bool> {
230 match self.write(cmd).await? {
231 crate::cluster::Reply::Bool(b) => Ok(b),
232 other => anyhow::bail!("expected a boolean reply, got {other:?}"),
233 }
234 }
235
236 pub(crate) async fn apply_direct(
238 &self,
239 cmd: crate::cluster::Command,
240 ) -> anyhow::Result<crate::cluster::Reply> {
241 let mut attempt = 0;
242 loop {
243 match self.apply_once(cmd.clone()).await {
244 Err(e) if attempt < WRITE_LOCK_RETRIES && is_write_lock_error(&e) => {
246 attempt += 1;
247 tracing::warn!(attempt, err = %e, "database is locked, retrying the write");
248 tokio::time::sleep(std::time::Duration::from_millis(100 << (attempt - 1)))
249 .await;
250 }
251 result => return result,
252 }
253 }
254 }
255
256 async fn apply_once(
257 &self,
258 cmd: crate::cluster::Command,
259 ) -> anyhow::Result<crate::cluster::Reply> {
260 let mut tx = self.begin_write().await?;
261 let reply = cmd
262 .apply(&mut tx, &crate::cluster::now_stamp(), false)
263 .await?;
264 tx.commit().await?;
265 Ok(reply)
266 }
267
268 pub(crate) async fn begin_write(
274 &self,
275 ) -> sqlx::Result<sqlx::Transaction<'static, sqlx::Sqlite>> {
276 self.pool.begin_with("BEGIN IMMEDIATE").await
277 }
278
279 pub fn pool(&self) -> &SqlitePool {
281 &self.pool
282 }
283}
284
285const WRITE_LOCK_RETRIES: u32 = 4;
287
288fn is_write_lock_error(err: &anyhow::Error) -> bool {
290 err.chain().any(|cause| {
291 cause
292 .downcast_ref::<sqlx::Error>()
293 .and_then(|e| e.as_database_error())
294 .and_then(sqlx::error::DatabaseError::code)
295 .is_some_and(|code| code == "5" || code == "517")
296 })
297}
298
299fn reply_result(reply: crate::cluster::Reply) -> anyhow::Result<crate::cluster::Reply> {
300 match reply {
301 crate::cluster::Reply::Err(e) => Err(anyhow::anyhow!(e)),
302 other => Ok(other),
303 }
304}
305
306#[cfg(test)]
307mod tests;