use anyhow::Context;
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePool, SqlitePoolOptions};
use std::path::Path;
use std::sync::{Arc, OnceLock};
pub(crate) mod control;
pub(crate) mod dependencies;
mod helpers;
pub(crate) mod jobs;
pub(crate) mod kv;
mod metrics;
mod pricing;
pub(crate) mod replica;
pub(crate) mod tasks;
pub use control::{ChatCostSummary, ControlMessage, MemoryEntry, parse_since_duration};
pub use dependencies::{
BlockerSnapshot, DepSource, NewDependency, ObservedPullRequest, TaskDependency,
WaitingOnDependency, blocker_failure_kind, blocker_is_satisfied, blocker_snapshot_key,
dep_label, worktree_refresh_key,
};
pub use helpers::{
StoreResult, get_cost_estimate, get_cost_estimate_result, get_recent_memory,
get_recent_memory_result, get_task_field, get_task_field_direct, get_token_usage,
get_token_usage_result, opt_store_get_task, opt_store_get_task_by_id, resolve_store_id,
store_increment, store_increment_by_id, store_log_activity, store_reset_counters,
store_reset_failure_counters, store_set, store_set_by_id, store_set_result,
store_set_result_by_id, store_touch_updated_at,
};
pub use jobs::JobState;
pub use metrics::{
AgentStat, CostByGroup, CostPeriod, CostSummary, ErrorStat, HighReviewCycleTask,
InsertTaskMetric, MetricsSummary, SlowTaskInfo,
};
pub use pricing::{CostEstimate, ModelPricing, TokenUsage, pricing_for_model};
pub use tasks::{
CliTaskRef, CliTaskSpec, CompleteRun, NewTask, RunTokenUsage, StartRun, StoreRoute, Task,
TaskActivity, TaskRun, TaskStatus, UpsertExternal, parse_cli_task_spec,
};
pub async fn default_db_path() -> anyhow::Result<std::path::PathBuf> {
crate::home::db_path().await
}
#[derive(Clone)]
pub struct TaskStore {
pool: SqlitePool,
raft: Arc<OnceLock<crate::cluster::Node>>,
daemon: Arc<OnceLock<crate::cluster::local::Endpoint>>,
}
impl std::fmt::Debug for TaskStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TaskStore").finish_non_exhaustive()
}
}
impl TaskStore {
pub async fn open(db_path: &Path) -> anyhow::Result<Self> {
let options = SqliteConnectOptions::new()
.filename(db_path)
.journal_mode(SqliteJournalMode::Wal)
.create_if_missing(true)
.busy_timeout(std::time::Duration::from_secs(5))
.pragma("foreign_keys", "ON");
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect_with(options)
.await
.with_context(|| format!("opening task store: {}", db_path.display()))?;
let store = Self {
pool,
raft: Arc::default(),
daemon: Arc::default(),
};
store.migrate().await?;
Ok(store)
}
pub async fn open_single(db_path: &Path) -> anyhow::Result<Self> {
let options = SqliteConnectOptions::new()
.filename(db_path)
.journal_mode(SqliteJournalMode::Wal)
.create_if_missing(true)
.busy_timeout(std::time::Duration::from_secs(5))
.pragma("foreign_keys", "ON");
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect_with(options)
.await
.with_context(|| format!("opening task store (single): {}", db_path.display()))?;
let store = Self {
pool,
raft: Arc::default(),
daemon: Arc::default(),
};
store.migrate().await?;
Ok(store)
}
#[cfg(test)]
pub async fn open_memory() -> anyhow::Result<Self> {
let options = SqliteConnectOptions::new()
.filename(":memory:")
.pragma("foreign_keys", "ON");
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect_with(options)
.await?;
let store = Self {
pool,
raft: Arc::default(),
daemon: Arc::default(),
};
store.migrate().await?;
Ok(store)
}
async fn migrate(&self) -> anyhow::Result<()> {
sqlx::migrate!("./migrations")
.run(&self.pool)
.await
.context("running task store migrations")?;
Ok(())
}
pub fn attach_node(&self, node: crate::cluster::Node) {
if self.raft.set(node).is_err() {
tracing::warn!("raft already attached to the task store");
}
}
pub fn is_leader(&self) -> bool {
self.raft.get().is_none_or(super::cluster::Node::is_leader)
}
pub(crate) fn replicates(&self) -> bool {
self.raft.get().is_some() || self.daemon.get().is_some()
}
pub fn node(&self) -> Option<&crate::cluster::Node> {
self.raft.get()
}
pub fn attach_daemon(&self, endpoint: crate::cluster::local::Endpoint) {
let _ = self.daemon.set(endpoint);
}
pub(crate) async fn write(
&self,
cmd: crate::cluster::Command,
) -> anyhow::Result<crate::cluster::Reply> {
let Some(node) = self.raft.get() else {
if let Some(endpoint) = self.daemon.get() {
match crate::cluster::local::send(endpoint, &cmd).await {
Ok(reply) => return reply_result(reply),
Err(e) if e.is_connect() => {
tracing::debug!(err = %e, "daemon not listening, writing directly");
}
Err(e) => return Err(e.into()),
}
}
return self.apply_direct(cmd).await;
};
let req = crate::cluster::Request {
now: crate::cluster::now_stamp(),
cmd,
};
reply_result(node.write(req).await?)
}
pub(crate) async fn write_int(&self, cmd: crate::cluster::Command) -> anyhow::Result<i64> {
match self.write(cmd).await? {
crate::cluster::Reply::Int(n) => Ok(n),
other => anyhow::bail!("expected an integer reply, got {other:?}"),
}
}
pub(crate) async fn write_bool(&self, cmd: crate::cluster::Command) -> anyhow::Result<bool> {
match self.write(cmd).await? {
crate::cluster::Reply::Bool(b) => Ok(b),
other => anyhow::bail!("expected a boolean reply, got {other:?}"),
}
}
pub(crate) async fn apply_direct(
&self,
cmd: crate::cluster::Command,
) -> anyhow::Result<crate::cluster::Reply> {
let mut attempt = 0;
loop {
match self.apply_once(cmd.clone()).await {
Err(e) if attempt < WRITE_LOCK_RETRIES && is_write_lock_error(&e) => {
attempt += 1;
tracing::warn!(attempt, err = %e, "database is locked, retrying the write");
tokio::time::sleep(std::time::Duration::from_millis(100 << (attempt - 1)))
.await;
}
result => return result,
}
}
}
async fn apply_once(
&self,
cmd: crate::cluster::Command,
) -> anyhow::Result<crate::cluster::Reply> {
let mut tx = self.begin_write().await?;
let reply = cmd
.apply(&mut tx, &crate::cluster::now_stamp(), false)
.await?;
tx.commit().await?;
Ok(reply)
}
pub(crate) async fn begin_write(
&self,
) -> sqlx::Result<sqlx::Transaction<'static, sqlx::Sqlite>> {
self.pool.begin_with("BEGIN IMMEDIATE").await
}
pub fn pool(&self) -> &SqlitePool {
&self.pool
}
}
const WRITE_LOCK_RETRIES: u32 = 4;
fn is_write_lock_error(err: &anyhow::Error) -> bool {
err.chain().any(|cause| {
cause
.downcast_ref::<sqlx::Error>()
.and_then(|e| e.as_database_error())
.and_then(sqlx::error::DatabaseError::code)
.is_some_and(|code| code == "5" || code == "517")
})
}
fn reply_result(reply: crate::cluster::Reply) -> anyhow::Result<crate::cluster::Reply> {
match reply {
crate::cluster::Reply::Err(e) => Err(anyhow::anyhow!(e)),
other => Ok(other),
}
}
#[cfg(test)]
mod tests;