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