use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use rusqlite::{params, Connection, OptionalExtension, ToSql, TransactionBehavior};
use crate::id::WaveId;
use crate::profile::{
AccessProfile, AccountAccessProfile, EmailAddress, ProfileId, ProviderRoute, RouteScope,
};
use crate::provider_auth::Provider;
use crate::store::rows::{map_wave_row, now_unix};
use crate::store::token_crypto;
use crate::store::{
AccountLimitRow, BusMessage, CredentialState, PmSnapshotRow, ProviderAccount,
ProviderAccountId, ProviderAccountSelection, RoutingState, RunEventRow, StoreError,
StoreResult,
};
use crate::trace::{
AgentLaunchRow, AgentTurnRow, ContextAsset, ContextAssetKind, ContextAssetRow, ContextChannel,
ContextDecision, ContextDecisionKind, ContextDecisionRow, ContextScope,
};
use crate::wave::Wave;
mod child_sessions;
mod ci_incidents;
mod interaction_reviews;
mod interactive_handoffs;
mod provider_deliveries;
#[derive(Debug, Clone)]
pub struct SqliteStore {
conn: Arc<Mutex<Connection>>,
}
pub(crate) fn read_nonterminal_task_worktrees(path: &Path) -> StoreResult<Vec<PathBuf>> {
let conn = Connection::open_with_flags(
path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
)?;
conn.execute_batch("PRAGMA query_only = ON; PRAGMA busy_timeout = 5000;")?;
let mut statement = conn.prepare(
"SELECT worktree FROM task_sessions \
WHERE status NOT IN ('completed', 'abandoned')",
)?;
let rows = statement.query_map([], |row| row.get::<_, String>(0))?;
rows.map(|row| row.map(PathBuf::from).map_err(StoreError::from))
.collect()
}
pub const BUS_WINDOW_SECS: i64 = 60 * 60;
fn bus_high_water(conn: &Connection) -> StoreResult<i64> {
let high_water = conn.query_row(
"SELECT COALESCE((SELECT seq FROM sqlite_sequence WHERE name = 'bus_messages'), 0)",
[],
|row| row.get(0),
)?;
Ok(high_water)
}
fn migrate_plaintext_provider_tokens(conn: &mut Connection) -> StoreResult<()> {
let mut scan = conn.prepare(
"SELECT provider, access_token, refresh_token
FROM provider_tokens
WHERE encrypted = 0",
)?;
let rows = scan.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, Option<String>>(2)?,
))
})?;
let mut pending = Vec::new();
for row in rows {
pending.push(row?);
}
drop(scan);
if pending.is_empty() {
return Ok(());
}
let tx = conn.transaction()?;
for (provider, access_token, refresh_token) in pending {
let encrypted_access = token_crypto::encrypt_token(&access_token).map_err(|error| {
StoreError::InvalidData(format!(
"failed to encrypt existing access token for provider '{provider}': {error}"
))
})?;
let encrypted_refresh =
token_crypto::encrypt_optional(refresh_token.as_deref()).map_err(|error| {
StoreError::InvalidData(format!(
"failed to encrypt existing refresh token for provider '{provider}': {error}"
))
})?;
tx.execute(
"UPDATE provider_tokens
SET access_token = ?1,
refresh_token = ?2,
encrypted = 1
WHERE provider = ?3",
params![encrypted_access, encrypted_refresh, provider],
)?;
}
tx.commit()?;
Ok(())
}
type TokenRow = (
String,
String,
Option<String>,
Option<String>,
Option<i64>,
Option<String>,
i64,
String,
bool,
);
fn read_token_row(row: &rusqlite::Row) -> rusqlite::Result<TokenRow> {
Ok((
row.get(0)?,
row.get(1)?,
row.get(2)?,
row.get(3)?,
row.get(4)?,
row.get(5)?,
row.get(6)?,
row.get(7)?,
row.get(8)?,
))
}
fn decrypt_token_row(row: TokenRow) -> StoreResult<super::ProviderToken> {
let (
provider,
access_token,
refresh_token,
oauth_client_id,
expires_at,
login,
updated_at,
ct,
encrypted,
) = row;
let access_token =
token_crypto::decrypt_if_needed(&access_token, encrypted).map_err(|error| {
StoreError::InvalidData(format!(
"failed to decrypt access token for provider '{provider}': {error}"
))
})?;
let refresh_token = refresh_token
.as_deref()
.map(|token| token_crypto::decrypt_if_needed(token, encrypted))
.transpose()
.map_err(|error| {
StoreError::InvalidData(format!(
"failed to decrypt refresh token for provider '{provider}': {error}"
))
})?;
Ok(super::ProviderToken {
provider,
access_token,
refresh_token,
oauth_client_id,
expires_at,
login,
updated_at,
credential_type: super::CredentialType::from_db(&ct),
})
}
fn read_provider_account(row: &rusqlite::Row) -> rusqlite::Result<StoreResult<ProviderAccount>> {
let provider = row.get(0)?;
let account_id = row.get::<_, String>(1)?;
let home = row
.get::<_, Option<String>>(2)?
.map(std::path::PathBuf::from);
let login_email = row.get::<_, Option<String>>(3)?;
let credential_state = row.get::<_, String>(4)?;
let routing_state = row.get::<_, String>(5)?;
let plan = row.get(6)?;
let paid_through = row.get::<_, Option<i32>>(7)?;
let utilization_percent = row.get(8)?;
let cooldown_until = row.get(9)?;
let cooldown_reason = row.get(10)?;
let last_selected_at = row.get(11)?;
let created_at = row.get(12)?;
let updated_at = row.get(13)?;
Ok((|| {
let account_id = ProviderAccountId::parse(&account_id).map_err(StoreError::InvalidData)?;
let login_email = login_email
.map(|value| EmailAddress::parse(&value))
.transpose()
.map_err(StoreError::InvalidData)?;
let credential_state =
CredentialState::from_db(&credential_state).map_err(StoreError::InvalidData)?;
let routing_state =
RoutingState::from_db(&routing_state).map_err(StoreError::InvalidData)?;
let paid_through = paid_through
.map(time::Date::from_julian_day)
.transpose()
.map_err(|error| StoreError::InvalidData(error.to_string()))?;
Ok(ProviderAccount {
provider,
account_id,
home,
login_email,
credential_state,
routing_state,
plan,
paid_through,
utilization_percent,
cooldown_until,
cooldown_reason,
last_selected_at,
created_at,
updated_at,
})
})())
}
fn read_account_limit_row(row: &rusqlite::Row) -> rusqlite::Result<StoreResult<AccountLimitRow>> {
let account_id = row.get::<_, String>(1)?;
let account_id = match ProviderAccountId::parse(&account_id) {
Ok(account_id) => account_id,
Err(error) => return Ok(Err(StoreError::InvalidData(error))),
};
Ok(Ok(AccountLimitRow {
provider: row.get(0)?,
account_id,
window: row.get(2)?,
used_percent: row.get(3)?,
resets_at: row.get(4)?,
plan: row.get(5)?,
observed_at: row.get(6)?,
source: row.get(7)?,
}))
}
fn read_access_profile(row: &rusqlite::Row) -> rusqlite::Result<StoreResult<AccessProfile>> {
let profile_id = row.get::<_, String>(0)?;
let chrome_directory = row.get(1)?;
let expected_login = row.get::<_, String>(2)?;
let created_at = row.get(3)?;
let updated_at = row.get(4)?;
Ok(ProfileId::parse(&profile_id)
.map_err(StoreError::InvalidData)
.and_then(|id| {
EmailAddress::parse(&expected_login)
.map_err(StoreError::InvalidData)
.map(|expected_login| AccessProfile {
id,
chrome_directory,
expected_login,
created_at,
updated_at,
})
}))
}
fn read_account_access_profile(
row: &rusqlite::Row,
) -> rusqlite::Result<StoreResult<AccountAccessProfile>> {
let provider = row.get::<_, String>(0)?;
let account_id = row.get::<_, String>(1)?;
let position = row.get::<_, i64>(2)? as usize;
let profile_id = row.get::<_, String>(3)?;
Ok(provider
.parse::<Provider>()
.map_err(|error| StoreError::InvalidData(error.to_string()))
.and_then(|provider| {
ProviderAccountId::parse(&account_id)
.map_err(StoreError::InvalidData)
.map(|account_id| (provider, account_id))
})
.and_then(|(provider, account_id)| {
ProfileId::parse(&profile_id)
.map_err(StoreError::InvalidData)
.map(|profile_id| AccountAccessProfile {
provider,
account_id,
position,
profile_id,
})
}))
}
fn read_provider_route_account(row: &rusqlite::Row) -> rusqlite::Result<ProviderAccountId> {
ProviderAccountId::parse(&row.get::<_, String>(0)?).map_err(|error| {
rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, error.into())
})
}
impl SqliteStore {
pub fn new(path: &Path) -> StoreResult<Self> {
Self::open(path, super::FrontierAdvance::Forbidden)
}
pub(crate) fn open_as_promotion_boundary(path: &Path) -> StoreResult<Self> {
Self::open(path, super::FrontierAdvance::Authorized)
}
fn open(path: &Path, advance: super::FrontierAdvance) -> StoreResult<Self> {
Self::open_with(
path,
crate::build_info::migration_authority(),
&super::machine_home_dir(),
advance,
)
}
fn open_with(
path: &Path,
authority: crate::build_info::MigrationAuthority,
home: &Path,
advance: super::FrontierAdvance,
) -> StoreResult<Self> {
let existing_database = std::fs::metadata(path).is_ok_and(|metadata| metadata.len() > 0);
let may_apply_migrations = super::may_apply_migrations(path, authority, home, advance)
.map_err(|error| {
StoreError::InvalidData(format!("resolve migration authority: {error}"))
})?;
if !may_apply_migrations && !existing_database {
return Err(StoreError::InvalidData(format!(
"shared store {} is not initialized and an ordinary lf may not create it; \
only `lf install promote` from an authorized build initializes the shared store",
path.display()
)));
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|err| {
StoreError::InvalidData(format!("failed to create db dir: {err}"))
})?;
}
let mut conn = Connection::open(path)?;
conn.execute_batch(
"PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000; PRAGMA foreign_keys = ON;",
)?;
if !may_apply_migrations {
super::migrations::validate_sqlite(&conn)?;
if let Some(pending) = super::migrations::pending_shared_migration(&conn)? {
return Err(StoreError::InvalidData(format!(
"shared store {} is at an older frontier than this lf (pending {pending}); \
an ordinary lf must not advance it — run `lf install promote` from an \
authorized build to advance the shared store",
path.display()
)));
}
} else if existing_database {
super::migrations::apply_sqlite_with_backup(&conn, path)?;
} else {
super::migrations::apply_sqlite(&conn)?;
}
validate_run_events_schema(&conn)?;
if may_apply_migrations {
migrate_plaintext_provider_tokens(&mut conn)?;
}
Ok(Self {
conn: Arc::new(Mutex::new(conn)),
})
}
pub(crate) fn open_run_ledger_read_only(path: &Path) -> StoreResult<Self> {
let conn = Connection::open_with_flags(
path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
)?;
conn.execute_batch("PRAGMA query_only = ON; PRAGMA busy_timeout = 5000;")?;
validate_run_events_schema(&conn)?;
Ok(Self {
conn: Arc::new(Mutex::new(conn)),
})
}
pub fn put_pm_snapshot(&self, snapshot: &PmSnapshotRow) -> StoreResult<()> {
let conn = self.conn.lock().expect("store mutex poisoned");
conn.execute(
"INSERT INTO pm_snapshots (repo, wave, provider, initiative, synced_at, payload)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(repo, wave) DO UPDATE SET
provider = excluded.provider,
initiative = excluded.initiative,
synced_at = excluded.synced_at,
payload = excluded.payload",
params![
snapshot.repo,
snapshot.wave,
snapshot.provider,
snapshot.initiative,
snapshot.synced_at,
snapshot.payload
],
)?;
Ok(())
}
pub fn pm_snapshot(&self, repo: &str, wave: &str) -> StoreResult<Option<PmSnapshotRow>> {
let conn = self.conn.lock().expect("store mutex poisoned");
conn.query_row(
"SELECT repo, wave, provider, initiative, synced_at, payload
FROM pm_snapshots WHERE repo = ?1 AND wave = ?2",
params![repo, wave],
|row| {
Ok(PmSnapshotRow {
repo: row.get(0)?,
wave: row.get(1)?,
provider: row.get(2)?,
initiative: row.get(3)?,
synced_at: row.get(4)?,
payload: row.get(5)?,
})
},
)
.optional()
.map_err(StoreError::from)
}
fn read_waves(&self, repo: Option<&str>) -> StoreResult<Vec<Wave>> {
let conn = self.conn.lock().expect("store mutex poisoned");
let query = if repo.is_some() {
"SELECT id, name, repo, created_at, parent_wave_id
FROM waves WHERE repo = ?1 ORDER BY created_at DESC"
} else {
"SELECT id, name, repo, created_at, parent_wave_id
FROM waves ORDER BY created_at DESC"
};
let params: Vec<Box<dyn ToSql>> = if let Some(repo) = repo {
vec![Box::new(repo.to_string())]
} else {
vec![]
};
let mut stmt = conn.prepare(query)?;
let params_iter = params.iter().map(|v| v.as_ref() as &dyn ToSql);
let rows = stmt.query_map(rusqlite::params_from_iter(params_iter), |row| {
Ok(map_wave_row(row))
})?;
let mut waves = Vec::new();
for wave in rows {
waves.push(wave??);
}
Ok(waves)
}
fn upsert_wave(&self, wave: &Wave) -> StoreResult<()> {
let conn = self.conn.lock().expect("store mutex poisoned");
let created_at = wave
.created_at()
.map(|dt| dt.unix_timestamp())
.unwrap_or_else(now_unix);
conn.execute(
"INSERT INTO waves (id, name, repo, created_at, parent_wave_id)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
repo = excluded.repo,
created_at = excluded.created_at,
parent_wave_id = excluded.parent_wave_id",
params![
wave.id(),
wave.name(),
wave.repo(),
created_at,
wave.parent_wave_id(),
],
)?;
Ok(())
}
}
fn validate_run_events_schema(conn: &Connection) -> StoreResult<()> {
conn.prepare(
"SELECT run_id, process_id, parent_process_id, seq, ts, repo, worktree,
wave, node, event, command, flow, skill, step_index, error,
input_tokens, output_tokens, cache_read_tokens, cost_usd,
duration_secs, provider, model
FROM run_events LIMIT 0",
)?;
Ok(())
}
impl SqliteStore {
pub fn health_check(&self) -> StoreResult<()> {
let conn = self.conn.lock().expect("store mutex poisoned");
conn.query_row("SELECT 1", [], |_| Ok(()))?;
Ok(())
}
pub fn schema_version(&self) -> StoreResult<String> {
let conn = self.conn.lock().expect("store mutex poisoned");
super::migrations::latest_version_sqlite(&conn)
}
pub fn get_provider_token(&self, provider: &str) -> StoreResult<Option<super::ProviderToken>> {
let conn = self.conn.lock().expect("store mutex poisoned");
let mut stmt = conn.prepare(
"SELECT provider, access_token, refresh_token, oauth_client_id, expires_at, login, updated_at, credential_type, encrypted
FROM provider_tokens WHERE provider = ?1",
)?;
let row = stmt
.query_row(params![provider], read_token_row)
.optional()?;
row.map(decrypt_token_row).transpose()
}
pub fn upsert_provider_token(&self, token: &super::ProviderToken) -> StoreResult<()> {
let conn = self.conn.lock().expect("store mutex poisoned");
let encrypted_access =
token_crypto::encrypt_token(&token.access_token).map_err(|error| {
StoreError::InvalidData(format!(
"failed to encrypt access token for provider '{}': {error}",
token.provider
))
})?;
let encrypted_refresh = token_crypto::encrypt_optional(token.refresh_token.as_deref())
.map_err(|error| {
StoreError::InvalidData(format!(
"failed to encrypt refresh token for provider '{}': {error}",
token.provider
))
})?;
conn.execute(
"INSERT INTO provider_tokens (provider, access_token, refresh_token, oauth_client_id, expires_at, login, updated_at, credential_type, encrypted)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 1)
ON CONFLICT(provider) DO UPDATE SET
access_token = excluded.access_token,
refresh_token = excluded.refresh_token,
oauth_client_id = excluded.oauth_client_id,
expires_at = excluded.expires_at,
login = excluded.login,
updated_at = excluded.updated_at,
credential_type = excluded.credential_type,
encrypted = excluded.encrypted",
params![
token.provider,
encrypted_access,
encrypted_refresh,
token.oauth_client_id,
token.expires_at,
token.login,
token.updated_at,
token.credential_type.as_str(),
],
)?;
Ok(())
}
pub fn delete_provider_token(&self, provider: &str) -> StoreResult<()> {
let conn = self.conn.lock().expect("store mutex poisoned");
conn.execute(
"DELETE FROM provider_tokens WHERE provider = ?1",
params![provider],
)?;
Ok(())
}
pub fn list_provider_tokens(&self) -> StoreResult<Vec<super::ProviderToken>> {
let conn = self.conn.lock().expect("store mutex poisoned");
let mut stmt = conn.prepare(
"SELECT provider, access_token, refresh_token, oauth_client_id, expires_at, login, updated_at, credential_type, encrypted
FROM provider_tokens ORDER BY provider",
)?;
let rows = stmt.query_map([], read_token_row)?;
let mut tokens = Vec::new();
for row in rows {
tokens.push(decrypt_token_row(row?)?);
}
Ok(tokens)
}
pub fn upsert_provider_account(&self, account: &ProviderAccount) -> StoreResult<()> {
let conn = self.conn.lock().expect("store mutex poisoned");
conn.execute(
"INSERT INTO provider_accounts (
provider, account_id, home, login_email, credential_state,
routing_state, plan, paid_through, utilization_percent,
cooldown_until, cooldown_reason, last_selected_at, created_at,
updated_at
) VALUES (
?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13,
?14
)
ON CONFLICT(provider, account_id) DO UPDATE SET
home = excluded.home,
login_email = excluded.login_email,
credential_state = excluded.credential_state,
routing_state = excluded.routing_state,
plan = excluded.plan,
paid_through = excluded.paid_through,
utilization_percent = excluded.utilization_percent,
cooldown_until = excluded.cooldown_until,
cooldown_reason = excluded.cooldown_reason,
last_selected_at = excluded.last_selected_at,
updated_at = excluded.updated_at",
params![
account.provider,
account.account_id.as_str(),
account
.home
.as_ref()
.map(|path| path.to_string_lossy().to_string()),
account.login_email.as_ref().map(EmailAddress::as_str),
account.credential_state.as_str(),
account.routing_state.as_str(),
account.plan,
account.paid_through.map(time::Date::to_julian_day),
account.utilization_percent,
account.cooldown_until,
account.cooldown_reason,
account.last_selected_at,
account.created_at,
account.updated_at,
],
)?;
Ok(())
}
pub fn get_provider_account(
&self,
provider: &str,
account_id: &ProviderAccountId,
) -> StoreResult<Option<ProviderAccount>> {
let conn = self.conn.lock().expect("store mutex poisoned");
let mut statement = conn.prepare(
"SELECT provider, account_id, home, login_email, credential_state,
routing_state, plan, paid_through, utilization_percent,
cooldown_until, cooldown_reason, last_selected_at,
created_at, updated_at
FROM provider_accounts
WHERE provider = ?1 AND account_id = ?2",
)?;
statement
.query_row(
params![provider, account_id.as_str()],
read_provider_account,
)
.optional()?
.transpose()
}
pub fn list_provider_accounts(
&self,
provider: Option<&str>,
) -> StoreResult<Vec<ProviderAccount>> {
let conn = self.conn.lock().expect("store mutex poisoned");
let sql = match provider {
Some(_) => {
"SELECT provider, account_id, home, login_email, credential_state,
routing_state, plan, paid_through, utilization_percent,
cooldown_until, cooldown_reason, last_selected_at,
created_at, updated_at
FROM provider_accounts
WHERE provider = ?1
ORDER BY provider, account_id"
}
None => {
"SELECT provider, account_id, home, login_email, credential_state,
routing_state, plan, paid_through, utilization_percent,
cooldown_until, cooldown_reason, last_selected_at,
created_at, updated_at
FROM provider_accounts
ORDER BY provider, account_id"
}
};
let mut statement = conn.prepare(sql)?;
let mut accounts = Vec::new();
match provider {
Some(provider) => {
let rows = statement.query_map([provider], read_provider_account)?;
for row in rows {
accounts.push(row??);
}
}
None => {
let rows = statement.query_map([], read_provider_account)?;
for row in rows {
accounts.push(row??);
}
}
}
Ok(accounts)
}
pub fn update_provider_account_lifecycle(&self, account: &ProviderAccount) -> StoreResult<()> {
let conn = self.conn.lock().expect("store mutex poisoned");
let changed = conn.execute(
"UPDATE provider_accounts
SET login_email = ?3,
routing_state = ?4,
plan = ?5,
paid_through = ?6,
updated_at = ?7
WHERE provider = ?1 AND account_id = ?2",
params![
account.provider,
account.account_id.as_str(),
account.login_email.as_ref().map(EmailAddress::as_str),
account.routing_state.as_str(),
account.plan,
account.paid_through.map(time::Date::to_julian_day),
account.updated_at,
],
)?;
if changed == 0 {
return Err(StoreError::NotFound);
}
Ok(())
}
pub fn reset_provider_account_health(
&self,
provider: &str,
account_id: &ProviderAccountId,
) -> StoreResult<()> {
self.record_provider_account_health(provider, account_id, None, None, None)
}
pub fn record_provider_account_health(
&self,
provider: &str,
account_id: &ProviderAccountId,
utilization_percent: Option<u8>,
cooldown_until: Option<i64>,
cooldown_reason: Option<&str>,
) -> StoreResult<()> {
let conn = self.conn.lock().expect("store mutex poisoned");
let changed = conn.execute(
"UPDATE provider_accounts
SET utilization_percent = ?3,
cooldown_until = ?4,
cooldown_reason = ?5,
updated_at = ?6
WHERE provider = ?1 AND account_id = ?2",
params![
provider,
account_id.as_str(),
utilization_percent,
cooldown_until,
cooldown_reason,
now_unix(),
],
)?;
if changed == 0 {
return Err(StoreError::NotFound);
}
Ok(())
}
pub fn upsert_provider_account_limits(
&self,
provider: &str,
account_id: &ProviderAccountId,
windows: &[crate::store::AccountLimitWindow],
source: &str,
) -> StoreResult<()> {
let conn = self.conn.lock().expect("store mutex poisoned");
let now = now_unix();
for window in windows {
conn.execute(
"INSERT INTO provider_account_limits
(provider, account_id, window, used_percent, resets_at, plan, observed_at, source)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
ON CONFLICT(provider, account_id, window) DO UPDATE SET
used_percent = excluded.used_percent,
resets_at = excluded.resets_at,
plan = excluded.plan,
observed_at = excluded.observed_at,
source = excluded.source",
params![
provider,
account_id.as_str(),
window.window,
window.used_percent,
window.resets_at,
window.plan,
now,
source,
],
)?;
}
Ok(())
}
pub fn provider_account_limits(
&self,
provider: Option<&str>,
) -> StoreResult<Vec<crate::store::AccountLimitRow>> {
let conn = self.conn.lock().expect("store mutex poisoned");
let mut statement = conn.prepare(
"SELECT provider, account_id, window, used_percent, resets_at, plan, observed_at, source
FROM provider_account_limits
WHERE ?1 IS NULL OR provider = ?1
ORDER BY provider, account_id, window",
)?;
let rows = statement.query_map([provider], read_account_limit_row)?;
let mut limits = Vec::new();
for row in rows {
limits.push(row??);
}
Ok(limits)
}
pub fn upsert_access_profile(&self, profile: &AccessProfile) -> StoreResult<()> {
let conn = self.conn.lock().expect("store mutex poisoned");
conn.execute(
"INSERT INTO access_profiles (
profile_id, chrome_directory, expected_login, created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(profile_id) DO UPDATE SET
chrome_directory = excluded.chrome_directory,
expected_login = excluded.expected_login,
updated_at = excluded.updated_at",
params![
profile.id.as_str(),
profile.chrome_directory,
profile.expected_login.as_str(),
profile.created_at,
profile.updated_at,
],
)?;
Ok(())
}
pub fn get_access_profile(&self, profile_id: &ProfileId) -> StoreResult<Option<AccessProfile>> {
let conn = self.conn.lock().expect("store mutex poisoned");
conn.query_row(
"SELECT profile_id, chrome_directory, expected_login, created_at, updated_at
FROM access_profiles WHERE profile_id = ?1",
[profile_id.as_str()],
read_access_profile,
)
.optional()?
.transpose()
}
pub fn list_access_profiles(&self) -> StoreResult<Vec<AccessProfile>> {
let conn = self.conn.lock().expect("store mutex poisoned");
let mut statement = conn.prepare(
"SELECT profile_id, chrome_directory, expected_login, created_at, updated_at
FROM access_profiles ORDER BY profile_id",
)?;
let rows = statement.query_map([], read_access_profile)?;
rows.map(|row| row?).collect()
}
pub fn set_account_access_profiles(
&self,
provider: Provider,
account_id: &ProviderAccountId,
profile_ids: &[ProfileId],
) -> StoreResult<()> {
let unique = profile_ids.iter().collect::<std::collections::HashSet<_>>();
if unique.len() != profile_ids.len() {
return Err(StoreError::InvalidData(
"account access profiles must be unique".to_string(),
));
}
let mut conn = self.conn.lock().expect("store mutex poisoned");
let transaction = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
transaction.execute(
"DELETE FROM account_access_profiles WHERE provider = ?1 AND account_id = ?2",
params![provider.as_str(), account_id.as_str()],
)?;
for (position, profile_id) in profile_ids.iter().enumerate() {
transaction.execute(
"INSERT INTO account_access_profiles (
provider, account_id, position, profile_id
) VALUES (?1, ?2, ?3, ?4)",
params![
provider.as_str(),
account_id.as_str(),
position as i64,
profile_id.as_str(),
],
)?;
}
transaction.commit()?;
Ok(())
}
pub fn list_account_access_profiles(
&self,
provider: Option<Provider>,
account_id: Option<&ProviderAccountId>,
) -> StoreResult<Vec<AccountAccessProfile>> {
let conn = self.conn.lock().expect("store mutex poisoned");
let provider = provider.map(|value| value.as_str());
let account_id = account_id.map(ProviderAccountId::as_str);
let mut statement = conn.prepare(
"SELECT provider, account_id, position, profile_id
FROM account_access_profiles
WHERE (?1 IS NULL OR provider = ?1)
AND (?2 IS NULL OR account_id = ?2)
ORDER BY provider, account_id, position",
)?;
let rows =
statement.query_map(params![provider, account_id], read_account_access_profile)?;
rows.map(|row| row?).collect()
}
pub fn set_provider_route(&self, route: &ProviderRoute) -> StoreResult<()> {
if route.accounts.is_empty() {
return Err(StoreError::InvalidData(
"provider route needs at least one account".to_string(),
));
}
let unique = route
.accounts
.iter()
.collect::<std::collections::HashSet<_>>();
if unique.len() != route.accounts.len() {
return Err(StoreError::InvalidData(
"provider route accounts must be unique".to_string(),
));
}
let mut conn = self.conn.lock().expect("store mutex poisoned");
let transaction = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
transaction.execute(
"DELETE FROM provider_routes
WHERE scope = ?1 AND scope_id = ?2 AND provider = ?3",
params![
route.scope.kind(),
route.scope.id(),
route.provider.as_str()
],
)?;
for (position, account_id) in route.accounts.iter().enumerate() {
transaction.execute(
"INSERT INTO provider_routes (
scope, scope_id, provider, position, account_id, created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
route.scope.kind(),
route.scope.id(),
route.provider.as_str(),
position as i64,
account_id.as_str(),
route.created_at,
route.updated_at,
],
)?;
}
transaction.commit()?;
Ok(())
}
pub fn provider_route(
&self,
scope: &RouteScope,
provider: Provider,
) -> StoreResult<Option<ProviderRoute>> {
let conn = self.conn.lock().expect("store mutex poisoned");
let mut statement = conn.prepare(
"SELECT account_id, created_at, updated_at
FROM provider_routes
WHERE scope = ?1 AND scope_id = ?2 AND provider = ?3
ORDER BY position",
)?;
let rows = statement.query_map(
params![scope.kind(), scope.id(), provider.as_str()],
|row| {
Ok((
read_provider_route_account(row)?,
row.get::<_, i64>(1)?,
row.get::<_, i64>(2)?,
))
},
)?;
let mut accounts = Vec::new();
let mut created_at = 0;
let mut updated_at = 0;
for row in rows {
let (account_id, row_created_at, row_updated_at) = row?;
accounts.push(account_id);
created_at = if created_at == 0 {
row_created_at
} else {
created_at.min(row_created_at)
};
updated_at = updated_at.max(row_updated_at);
}
Ok((!accounts.is_empty()).then(|| ProviderRoute {
scope: scope.clone(),
provider,
accounts,
created_at,
updated_at,
}))
}
pub fn pin_provider_session_route(
&self,
provider: Provider,
provider_session_id: &str,
account_id: &ProviderAccountId,
) -> StoreResult<()> {
let conn = self.conn.lock().expect("store mutex poisoned");
conn.execute(
"INSERT INTO provider_session_accounts (
provider, provider_session_id, account_id, created_at
) VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(provider, provider_session_id) DO UPDATE SET
account_id = excluded.account_id,
created_at = excluded.created_at",
params![
provider.as_str(),
provider_session_id,
account_id.as_str(),
now_unix(),
],
)?;
Ok(())
}
pub fn provider_session_account(
&self,
provider: Provider,
provider_session_id: &str,
) -> StoreResult<Option<ProviderAccountId>> {
let conn = self.conn.lock().expect("store mutex poisoned");
conn.query_row(
"SELECT account_id FROM provider_session_accounts
WHERE provider = ?1 AND provider_session_id = ?2",
params![provider.as_str(), provider_session_id],
|row| row.get::<_, String>(0),
)
.optional()?
.as_deref()
.map(ProviderAccountId::parse)
.transpose()
.map_err(StoreError::InvalidData)
}
pub fn select_provider_account(
&self,
provider: Provider,
candidates: &[ProviderAccountId],
provider_session_id: Option<&str>,
) -> StoreResult<Option<ProviderAccountSelection>> {
if candidates.is_empty() {
return Ok(None);
}
let mut conn = self.conn.lock().expect("store mutex poisoned");
let transaction = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let now = now_unix();
let today = time::OffsetDateTime::now_utc().date();
let newest_selection = transaction.query_row(
"SELECT COALESCE(MAX(last_selected_at), 0)
FROM provider_accounts WHERE provider = ?1",
[provider.as_str()],
|row| row.get::<_, i64>(0),
)?;
let selection_time = now.max(newest_selection + 1);
let requested = match provider_session_id {
Some(session_id) => transaction
.query_row(
"SELECT account_id FROM provider_session_accounts
WHERE provider = ?1 AND provider_session_id = ?2",
params![provider.as_str(), session_id],
|row| row.get::<_, String>(0),
)
.optional()?,
None => None,
};
let limits = {
let mut statement = transaction.prepare(
"SELECT provider, account_id, window, used_percent, resets_at, plan, observed_at, source
FROM provider_account_limits
WHERE provider = ?1
ORDER BY account_id, window",
)?;
let rows = statement.query_map([provider.as_str()], read_account_limit_row)?;
let mut limits = Vec::new();
for row in rows {
limits.push(row??);
}
limits
};
let mut account_statement = transaction.prepare(
"SELECT provider, account_id, home, login_email, credential_state,
routing_state, plan, paid_through, utilization_percent,
cooldown_until, cooldown_reason, last_selected_at,
created_at, updated_at
FROM provider_accounts
WHERE provider = ?1 AND account_id = ?2",
)?;
let mut available = Vec::new();
for account_id in candidates {
let account = account_statement
.query_row(
params![provider.as_str(), account_id.as_str()],
read_provider_account,
)
.optional()?
.transpose()?;
if let Some(account) = account.filter(|account| {
account.eligible_for_automatic_routing(today)
&& account.cooldown_until.is_none_or(|until| until <= now)
}) {
available.push(account);
}
}
drop(account_statement);
let resumed = requested.as_ref().and_then(|account_id| {
available
.iter()
.position(|account| account.account_id.as_str() == account_id)
});
let (mut account, resume_requested_session) = match resumed {
Some(index) => (available.remove(index), true),
None => {
crate::provider_account::order_accounts_by_strain(&mut available, &limits, now);
match available.into_iter().next() {
Some(selection) => (selection, false),
None => {
transaction.commit()?;
return Ok(None);
}
}
}
};
transaction.execute(
"UPDATE provider_accounts
SET last_selected_at = ?3, updated_at = ?3
WHERE provider = ?1 AND account_id = ?2",
params![
provider.as_str(),
account.account_id.as_str(),
selection_time
],
)?;
transaction.commit()?;
account.last_selected_at = Some(selection_time);
account.updated_at = selection_time;
Ok(Some(ProviderAccountSelection {
account,
resume_requested_session,
}))
}
pub fn list_waves(&self, repo: Option<&str>) -> StoreResult<Vec<Wave>> {
self.read_waves(repo)
}
pub fn list_child_waves(&self, parent: &WaveId) -> StoreResult<Vec<Wave>> {
let conn = self.conn.lock().expect("store mutex poisoned");
let mut stmt = conn.prepare(
"SELECT id, name, repo, created_at, parent_wave_id
FROM waves WHERE parent_wave_id = ?1 ORDER BY created_at ASC",
)?;
let rows = stmt.query_map(params![parent], |row| Ok(map_wave_row(row)))?;
let mut waves = Vec::new();
for wave in rows {
waves.push(wave??);
}
Ok(waves)
}
pub fn get_wave(&self, wave_id: &WaveId) -> StoreResult<Option<Wave>> {
let conn = self.conn.lock().expect("store mutex poisoned");
let mut stmt = conn.prepare(
"SELECT id, name, repo, created_at, parent_wave_id FROM waves WHERE id = ?1",
)?;
let wave = stmt
.query_row(params![wave_id], |row| Ok(map_wave_row(row)))
.optional()?;
wave.transpose()
}
pub fn get_wave_by_name(&self, name: &str) -> StoreResult<Option<Wave>> {
let conn = self.conn.lock().expect("store mutex poisoned");
let mut stmt = conn.prepare(
"SELECT id, name, repo, created_at, parent_wave_id FROM waves WHERE name = ?1",
)?;
let wave = stmt
.query_row(params![name], |row| Ok(map_wave_row(row)))
.optional()?;
wave.transpose()
}
pub fn create_wave(&self, wave: &Wave) -> StoreResult<()> {
self.upsert_wave(wave)
}
pub fn update_wave(&self, wave: &Wave) -> StoreResult<()> {
self.upsert_wave(wave)
}
pub fn delete_wave(&self, wave_id: &WaveId) -> StoreResult<()> {
let conn = self.conn.lock().expect("store mutex poisoned");
conn.execute("DELETE FROM waves WHERE id = ?1", params![wave_id])?;
Ok(())
}
pub fn publish_bus(
&self,
channel: &str,
byline: &str,
text: &str,
at: i64,
) -> StoreResult<i64> {
self.sweep_bus(at - BUS_WINDOW_SECS)?;
let conn = self.conn.lock().expect("store mutex poisoned");
conn.execute(
"INSERT INTO bus_messages (channel, byline, text, at) VALUES (?1, ?2, ?3, ?4)",
params![channel, byline, text, at],
)?;
Ok(conn.last_insert_rowid())
}
pub fn sweep_bus(&self, cutoff: i64) -> StoreResult<usize> {
let conn = self.conn.lock().expect("store mutex poisoned");
Ok(conn.execute("DELETE FROM bus_messages WHERE at < ?1", params![cutoff])?)
}
pub fn read_bus_after(&self, cursor: i64) -> StoreResult<Vec<BusMessage>> {
let conn = self.conn.lock().expect("store mutex poisoned");
let mut stmt = conn.prepare(
"SELECT id, channel, byline, text, at FROM bus_messages
WHERE id > ?1 ORDER BY id",
)?;
let rows = stmt.query_map(params![cursor], |row| {
Ok(BusMessage {
id: row.get(0)?,
channel: row.get(1)?,
byline: row.get(2)?,
text: row.get(3)?,
at: row.get(4)?,
})
})?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(StoreError::from)
}
pub fn bus_head(&self) -> StoreResult<i64> {
let conn = self.conn.lock().expect("store mutex poisoned");
bus_high_water(&conn)
}
pub fn bus_floor(&self) -> StoreResult<Option<i64>> {
let conn = self.conn.lock().expect("store mutex poisoned");
let oldest: Option<i64> =
conn.query_row("SELECT MIN(id) FROM bus_messages", [], |row| row.get(0))?;
if oldest.is_some() {
return Ok(oldest);
}
let high_water = bus_high_water(&conn)?;
Ok((high_water > 0).then_some(high_water + 1))
}
pub fn bus_cursor(&self, subscriber: &str) -> StoreResult<Option<i64>> {
let conn = self.conn.lock().expect("store mutex poisoned");
let cursor = conn
.query_row(
"SELECT cursor FROM bus_cursors WHERE subscriber = ?1",
params![subscriber],
|row| row.get(0),
)
.optional()?;
Ok(cursor)
}
pub fn set_bus_cursor(&self, subscriber: &str, cursor: i64) -> StoreResult<()> {
let conn = self.conn.lock().expect("store mutex poisoned");
conn.execute(
"INSERT INTO bus_cursors (subscriber, cursor) VALUES (?1, ?2)
ON CONFLICT(subscriber) DO UPDATE SET cursor = excluded.cursor",
params![subscriber, cursor],
)?;
Ok(())
}
pub fn blob_tokens(&self, sha: &str) -> StoreResult<Option<(i64, i64, i64)>> {
let conn = self.conn.lock().expect("store mutex poisoned");
let row = conn
.query_row(
"SELECT lines, bytes, tokens FROM blob_tokens WHERE sha = ?1",
params![sha],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)
.optional()?;
Ok(row)
}
pub fn put_blob_tokens(
&self,
sha: &str,
lines: i64,
bytes: i64,
tokens: i64,
) -> StoreResult<()> {
let conn = self.conn.lock().expect("store mutex poisoned");
conn.execute(
"INSERT INTO blob_tokens (sha, lines, bytes, tokens) VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(sha) DO NOTHING",
params![sha, lines, bytes, tokens],
)?;
Ok(())
}
pub fn insert_run_event(&self, row: &RunEventRow) -> StoreResult<()> {
let conn = self.conn.lock().expect("store mutex poisoned");
conn.execute(
"INSERT INTO run_events (
run_id, process_id, parent_process_id, seq, ts, repo, worktree, wave, node, event, command,
flow, skill, step_index, error, input_tokens, output_tokens,
cache_read_tokens, cost_usd, duration_secs, provider, model
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22)",
params![
row.run_id,
row.process_id,
row.parent_process_id,
row.seq,
row.ts,
row.repo,
row.worktree,
row.wave,
row.node,
row.event,
row.command,
row.flow,
row.skill,
row.step_index,
row.error,
row.input_tokens,
row.output_tokens,
row.cache_read_tokens,
row.cost_usd,
row.duration_secs,
row.provider,
row.model,
],
)?;
Ok(())
}
pub fn list_run_events_since(&self, since_unix: i64) -> StoreResult<Vec<RunEventRow>> {
self.query_run_events(
"SELECT run_id, process_id, parent_process_id, seq, ts, repo, worktree, wave, node, event, command,
flow, skill, step_index, error, input_tokens, output_tokens,
cache_read_tokens, cost_usd, duration_secs, provider, model
FROM run_events WHERE ts >= ?1 ORDER BY ts, run_id, seq",
params![since_unix],
)
}
pub fn process_is_recorded(&self, process_id: &str) -> StoreResult<bool> {
let conn = self.conn.lock().expect("store mutex poisoned");
let mut stmt = conn.prepare("SELECT 1 FROM run_events WHERE process_id = ?1 LIMIT 1")?;
Ok(stmt.exists(params![process_id])?)
}
pub fn run_events_matching(&self, run_id: &str) -> StoreResult<Vec<RunEventRow>> {
let prefix = format!("{}%", run_id.replace(['%', '_'], ""));
self.query_run_events(
"SELECT run_id, process_id, parent_process_id, seq, ts, repo, worktree, wave, node, event, command,
flow, skill, step_index, error, input_tokens, output_tokens,
cache_read_tokens, cost_usd, duration_secs, provider, model
FROM run_events WHERE run_id LIKE ?1 ORDER BY ts, seq",
params![prefix],
)
}
pub fn run_events_matching_exec(&self, exec_id: &str) -> StoreResult<Vec<RunEventRow>> {
let prefix = format!("{}%", exec_id.replace(['%', '_'], ""));
self.query_run_events(
"SELECT run_id, process_id, parent_process_id, seq, ts, repo, worktree, wave, node, event, command,
flow, skill, step_index, error, input_tokens, output_tokens,
cache_read_tokens, cost_usd, duration_secs, provider, model
FROM run_events WHERE process_id LIKE ?1 ORDER BY ts, seq",
params![prefix],
)
}
fn query_run_events(
&self,
sql: &str,
params: impl rusqlite::Params,
) -> StoreResult<Vec<RunEventRow>> {
let conn = self.conn.lock().expect("store mutex poisoned");
let mut stmt = conn.prepare(sql)?;
let rows = stmt.query_map(params, |row| {
Ok(RunEventRow {
run_id: row.get(0)?,
process_id: row.get(1)?,
parent_process_id: row.get(2)?,
seq: row.get(3)?,
ts: row.get(4)?,
repo: row.get(5)?,
worktree: row.get(6)?,
wave: row.get(7)?,
node: row.get(8)?,
event: row.get(9)?,
command: row.get(10)?,
flow: row.get(11)?,
skill: row.get(12)?,
step_index: row.get(13)?,
error: row.get(14)?,
input_tokens: row.get(15)?,
output_tokens: row.get(16)?,
cache_read_tokens: row.get(17)?,
cost_usd: row.get(18)?,
duration_secs: row.get(19)?,
provider: row.get(20)?,
model: row.get(21)?,
})
})?;
let mut events = Vec::new();
for row in rows {
events.push(row?);
}
Ok(events)
}
pub fn insert_trace_capture(
&self,
launch: &AgentLaunchRow,
turn: &AgentTurnRow,
assets: &[ContextAssetRow],
decisions: &[ContextDecisionRow],
) -> StoreResult<()> {
let mut conn = self.conn.lock().expect("store mutex poisoned");
let tx = conn.transaction()?;
tx.execute(
"INSERT INTO agent_launches (
id, run_id, process_id, started_at, ended_at, repo, worktree, wave, flow,
skill, project, task, provider, model, surface, capture_status,
incomplete_reason, outcome, artifact_dir, conversation_path,
provider_events_path, provider_session_id, provider_session_path,
conversation_event_count, conversation_bytes
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13,
?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25)",
params![
launch.id,
launch.run_id,
launch.process_id,
launch.started_at,
launch.ended_at,
launch.repo,
launch.worktree,
launch.wave,
launch.flow,
launch.skill,
launch.project,
launch.task,
launch.provider,
launch.model,
launch.surface,
launch.capture_status,
launch.incomplete_reason,
launch.outcome,
launch.artifact_dir,
launch.conversation_path,
launch.provider_events_path,
launch.provider_session_id,
launch.provider_session_path,
launch.conversation_event_count,
launch.conversation_bytes,
],
)?;
insert_agent_turn(&tx, turn)?;
insert_context_rows(&tx, assets, decisions)?;
tx.commit()?;
Ok(())
}
pub fn insert_agent_turn_capture(
&self,
turn: &AgentTurnRow,
assets: &[ContextAssetRow],
decisions: &[ContextDecisionRow],
) -> StoreResult<()> {
let mut conn = self.conn.lock().expect("store mutex poisoned");
let tx = conn.transaction()?;
insert_agent_turn(&tx, turn)?;
insert_context_rows(&tx, assets, decisions)?;
tx.commit()?;
Ok(())
}
pub fn finish_agent_turn_capture(&self, turn: &AgentTurnRow) -> StoreResult<()> {
let conn = self.conn.lock().expect("store mutex poisoned");
update_agent_turn(&conn, turn)?;
Ok(())
}
pub fn update_agent_launch_receipt(&self, launch: &AgentLaunchRow) -> StoreResult<()> {
let conn = self.conn.lock().expect("store mutex poisoned");
conn.execute(
"UPDATE agent_launches
SET provider_session_id = ?2, provider_session_path = ?3
WHERE id = ?1",
params![
launch.id,
launch.provider_session_id,
launch.provider_session_path,
],
)?;
Ok(())
}
pub fn finish_trace_capture(
&self,
launch: &AgentLaunchRow,
turn: &AgentTurnRow,
) -> StoreResult<()> {
let mut conn = self.conn.lock().expect("store mutex poisoned");
let tx = conn.transaction()?;
tx.execute(
"UPDATE agent_launches SET
ended_at = ?2, capture_status = ?3, incomplete_reason = ?4, outcome = ?5,
conversation_event_count = ?6, conversation_bytes = ?7,
provider_session_id = ?8, provider_session_path = ?9
WHERE id = ?1",
params![
launch.id,
launch.ended_at,
launch.capture_status,
launch.incomplete_reason,
launch.outcome,
launch.conversation_event_count,
launch.conversation_bytes,
launch.provider_session_id,
launch.provider_session_path,
],
)?;
update_agent_turn(&tx, turn)?;
tx.commit()?;
Ok(())
}
pub fn reconcile_launch_capture(
&self,
launch_id: &str,
capture_status: &str,
incomplete_reason: Option<&str>,
ended_at_fallback: i64,
) -> StoreResult<()> {
let conn = self.conn.lock().expect("store mutex poisoned");
conn.execute(
"UPDATE agent_launches
SET capture_status = ?2, incomplete_reason = ?3,
ended_at = COALESCE(ended_at, ?4)
WHERE id = ?1",
params![
launch_id,
capture_status,
incomplete_reason,
ended_at_fallback
],
)?;
Ok(())
}
pub fn trace_capture_required_after(&self) -> StoreResult<i64> {
let conn = self.conn.lock().expect("store mutex poisoned");
Ok(conn.query_row(
"SELECT required_after FROM trace_capture_meta WHERE id = 1",
[],
|row| row.get(0),
)?)
}
pub fn agent_launches_matching(&self, run_id: &str) -> StoreResult<Vec<AgentLaunchRow>> {
let prefix = format!("{}%", run_id.replace(['%', '_'], ""));
self.query_agent_launches(
"SELECT id, run_id, process_id, started_at, ended_at, repo, worktree, wave, flow,
skill, project, task, provider, model, surface, capture_status,
incomplete_reason, outcome, artifact_dir, conversation_path,
provider_events_path, provider_session_id, provider_session_path,
conversation_event_count, conversation_bytes
FROM agent_launches WHERE run_id LIKE ?1 ORDER BY started_at, rowid",
params![prefix],
)
}
pub fn agent_launches_since(&self, since: i64) -> StoreResult<Vec<AgentLaunchRow>> {
self.query_agent_launches(
"SELECT id, run_id, process_id, started_at, ended_at, repo, worktree, wave, flow,
skill, project, task, provider, model, surface, capture_status,
incomplete_reason, outcome, artifact_dir, conversation_path,
provider_events_path, provider_session_id, provider_session_path,
conversation_event_count, conversation_bytes
FROM agent_launches WHERE started_at >= ?1 ORDER BY started_at, rowid",
params![since],
)
}
fn query_agent_launches(
&self,
sql: &str,
params: impl rusqlite::Params,
) -> StoreResult<Vec<AgentLaunchRow>> {
let conn = self.conn.lock().expect("store mutex poisoned");
let mut stmt = conn.prepare(sql)?;
let rows = stmt.query_map(params, |row| {
Ok(AgentLaunchRow {
id: row.get(0)?,
run_id: row.get(1)?,
process_id: row.get(2)?,
started_at: row.get(3)?,
ended_at: row.get(4)?,
repo: row.get(5)?,
worktree: row.get(6)?,
wave: row.get(7)?,
flow: row.get(8)?,
skill: row.get(9)?,
project: row.get(10)?,
task: row.get(11)?,
provider: row.get(12)?,
model: row.get(13)?,
surface: row.get(14)?,
capture_status: row.get(15)?,
incomplete_reason: row.get(16)?,
outcome: row.get(17)?,
artifact_dir: row.get(18)?,
conversation_path: row.get(19)?,
provider_events_path: row.get(20)?,
provider_session_id: row.get(21)?,
provider_session_path: row.get(22)?,
conversation_event_count: row.get(23)?,
conversation_bytes: row.get(24)?,
})
})?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(StoreError::from)
}
pub fn agent_turns_for_launches(
&self,
launch_ids: &[String],
) -> StoreResult<Vec<AgentTurnRow>> {
if launch_ids.is_empty() {
return Ok(Vec::new());
}
let conn = self.conn.lock().expect("store mutex poisoned");
let mut turns = Vec::new();
for launch_ids in launch_ids.chunks(500) {
let placeholders = in_placeholders(launch_ids.len());
let sql = format!(
"SELECT id, launch_id, ordinal, provider_turn_id, started_at, ended_at, status,
input_op, context_coverage, tokenizer, system_prompt_path, task_prompt_path,
system_tokens, task_tokens, supplied_context_tokens, provider_input_tokens,
provider_total_input_tokens, peak_input_tokens, context_window_tokens,
provider_output_tokens, reasoning_tokens, cache_read_tokens,
cache_write_tokens, cost_usd, context_gather_ms, context_render_ms,
context_persist_ms, first_event_seq, last_event_seq
FROM agent_turns WHERE launch_id IN ({placeholders})
ORDER BY started_at, rowid, ordinal"
);
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(launch_ids), map_agent_turn)?;
turns.extend(rows.collect::<Result<Vec<_>, _>>()?);
}
turns.sort_by_key(|turn| (turn.started_at, turn.ordinal));
Ok(turns)
}
pub fn agent_turn(&self, id: &str) -> StoreResult<Option<AgentTurnRow>> {
let conn = self.conn.lock().expect("store mutex poisoned");
let mut stmt = conn.prepare(
"SELECT id, launch_id, ordinal, provider_turn_id, started_at, ended_at, status,
input_op, context_coverage, tokenizer, system_prompt_path, task_prompt_path,
system_tokens, task_tokens, supplied_context_tokens, provider_input_tokens,
provider_total_input_tokens, peak_input_tokens, context_window_tokens,
provider_output_tokens, reasoning_tokens, cache_read_tokens,
cache_write_tokens, cost_usd, context_gather_ms, context_render_ms,
context_persist_ms, first_event_seq, last_event_seq
FROM agent_turns WHERE id=?1",
)?;
let row = stmt.query_row(params![id], map_agent_turn).optional()?;
Ok(row)
}
pub fn context_assets_for_turns(
&self,
turn_ids: &[String],
) -> StoreResult<Vec<ContextAssetRow>> {
if turn_ids.is_empty() {
return Ok(Vec::new());
}
let conn = self.conn.lock().expect("store mutex poisoned");
let mut assets = Vec::new();
for turn_ids in turn_ids.chunks(500) {
let placeholders = in_placeholders(turn_ids.len());
let sql = format!(
"SELECT turn_id, position, channel, kind, scope, label, source_path, included_by,
content_sha256, byte_start, byte_end, bytes, isolated_tokens, attributed_tokens
FROM context_assets WHERE turn_id IN ({placeholders})
ORDER BY turn_id, position"
);
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(turn_ids), |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, i64>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
row.get::<_, String>(4)?,
row.get::<_, String>(5)?,
row.get::<_, Option<String>>(6)?,
row.get::<_, String>(7)?,
row.get::<_, String>(8)?,
row.get::<_, i64>(9)?,
row.get::<_, i64>(10)?,
row.get::<_, i64>(11)?,
row.get::<_, i64>(12)?,
row.get::<_, i64>(13)?,
))
})?;
for row in rows {
let (
turn_id,
position,
channel,
kind,
scope,
label,
source_path,
included_by,
content_sha256,
byte_start,
byte_end,
bytes,
isolated_tokens,
attributed_tokens,
) = row?;
assets.push(ContextAssetRow {
turn_id,
asset: ContextAsset {
position: position as u32,
channel: ContextChannel::parse(&channel)?,
kind: ContextAssetKind::parse(&kind)?,
scope: ContextScope::parse(&scope)?,
label,
source_path,
included_by,
content_sha256,
byte_start: byte_start as u64,
byte_end: byte_end as u64,
bytes: bytes as u64,
isolated_tokens: isolated_tokens as u64,
attributed_tokens: attributed_tokens as u64,
},
});
}
}
assets.sort_by_key(|row| (row.turn_id.clone(), row.asset.position));
Ok(assets)
}
pub fn context_decisions_for_turns(
&self,
turn_ids: &[String],
) -> StoreResult<Vec<ContextDecisionRow>> {
if turn_ids.is_empty() {
return Ok(Vec::new());
}
let conn = self.conn.lock().expect("store mutex poisoned");
let mut decisions = Vec::new();
for turn_ids in turn_ids.chunks(500) {
let placeholders = in_placeholders(turn_ids.len());
let sql = format!(
"SELECT turn_id, position, kind, scope, label, source_path, decision, reason,
original_bytes, original_tokens, asset_position
FROM context_decisions WHERE turn_id IN ({placeholders})
ORDER BY turn_id, position"
);
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(turn_ids), |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, i64>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
row.get::<_, String>(4)?,
row.get::<_, Option<String>>(5)?,
row.get::<_, String>(6)?,
row.get::<_, String>(7)?,
row.get::<_, Option<i64>>(8)?,
row.get::<_, Option<i64>>(9)?,
row.get::<_, Option<i64>>(10)?,
))
})?;
for row in rows {
let (
turn_id,
position,
kind,
scope,
label,
source_path,
decision,
reason,
original_bytes,
original_tokens,
asset_position,
) = row?;
decisions.push(ContextDecisionRow {
turn_id,
decision: ContextDecision {
position: position as u32,
kind: ContextAssetKind::parse(&kind)?,
scope: ContextScope::parse(&scope)?,
label,
source_path,
decision: ContextDecisionKind::parse(&decision)?,
reason,
original_bytes: original_bytes.map(|value| value as u64),
original_tokens: original_tokens.map(|value| value as u64),
asset_position: asset_position.map(|value| value as u32),
},
});
}
}
decisions.sort_by_key(|row| (row.turn_id.clone(), row.decision.position));
Ok(decisions)
}
}
fn in_placeholders(count: usize) -> String {
std::iter::repeat_n("?", count)
.collect::<Vec<_>>()
.join(", ")
}
fn insert_agent_turn(tx: &rusqlite::Transaction<'_>, turn: &AgentTurnRow) -> StoreResult<()> {
tx.execute(
"INSERT INTO agent_turns (
id, launch_id, ordinal, provider_turn_id, started_at, ended_at, status, input_op,
context_coverage, tokenizer, system_prompt_path, task_prompt_path, system_tokens,
task_tokens, supplied_context_tokens, provider_input_tokens,
provider_total_input_tokens, peak_input_tokens, context_window_tokens,
provider_output_tokens, reasoning_tokens, cache_read_tokens, cache_write_tokens,
cost_usd, context_gather_ms, context_render_ms, context_persist_ms,
first_event_seq, last_event_seq
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14,
?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27,
?28, ?29)",
params![
turn.id,
turn.launch_id,
turn.ordinal,
turn.provider_turn_id,
turn.started_at,
turn.ended_at,
turn.status,
turn.input_op,
turn.context_coverage,
turn.tokenizer,
turn.system_prompt_path,
turn.task_prompt_path,
turn.system_tokens,
turn.task_tokens,
turn.supplied_context_tokens,
turn.provider_input_tokens,
turn.provider_total_input_tokens,
turn.peak_input_tokens,
turn.context_window_tokens,
turn.provider_output_tokens,
turn.reasoning_tokens,
turn.cache_read_tokens,
turn.cache_write_tokens,
turn.cost_usd,
turn.context_gather_ms,
turn.context_render_ms,
turn.context_persist_ms,
turn.first_event_seq,
turn.last_event_seq,
],
)?;
Ok(())
}
fn insert_context_rows(
tx: &rusqlite::Transaction<'_>,
assets: &[ContextAssetRow],
decisions: &[ContextDecisionRow],
) -> StoreResult<()> {
for row in assets {
let asset = &row.asset;
tx.execute(
"INSERT INTO context_assets (
turn_id, position, channel, kind, scope, label, source_path, included_by,
content_sha256, byte_start, byte_end, bytes, isolated_tokens, attributed_tokens
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
params![
row.turn_id,
i64::from(asset.position),
asset.channel.as_str(),
asset.kind.as_str(),
asset.scope.as_str(),
asset.label,
asset.source_path,
asset.included_by,
asset.content_sha256,
asset.byte_start as i64,
asset.byte_end as i64,
asset.bytes as i64,
asset.isolated_tokens as i64,
asset.attributed_tokens as i64,
],
)?;
}
for row in decisions {
let decision = &row.decision;
tx.execute(
"INSERT INTO context_decisions (
turn_id, position, kind, scope, label, source_path, decision, reason,
original_bytes, original_tokens, asset_position
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
params![
row.turn_id,
i64::from(decision.position),
decision.kind.as_str(),
decision.scope.as_str(),
decision.label,
decision.source_path,
decision.decision.as_str(),
decision.reason,
decision.original_bytes.map(|value| value as i64),
decision.original_tokens.map(|value| value as i64),
decision.asset_position.map(i64::from),
],
)?;
}
Ok(())
}
fn update_agent_turn(conn: &rusqlite::Connection, turn: &AgentTurnRow) -> StoreResult<()> {
conn.execute(
"UPDATE agent_turns SET
provider_turn_id = ?2, ended_at = ?3, status = ?4,
provider_input_tokens = ?5, provider_total_input_tokens = ?6,
peak_input_tokens = ?7, context_window_tokens = ?8,
provider_output_tokens = ?9, reasoning_tokens = ?10,
cache_read_tokens = ?11, cache_write_tokens = ?12, cost_usd = ?13,
first_event_seq = ?14, last_event_seq = ?15
WHERE id = ?1",
params![
turn.id,
turn.provider_turn_id,
turn.ended_at,
turn.status,
turn.provider_input_tokens,
turn.provider_total_input_tokens,
turn.peak_input_tokens,
turn.context_window_tokens,
turn.provider_output_tokens,
turn.reasoning_tokens,
turn.cache_read_tokens,
turn.cache_write_tokens,
turn.cost_usd,
turn.first_event_seq,
turn.last_event_seq,
],
)?;
Ok(())
}
fn map_agent_turn(row: &rusqlite::Row<'_>) -> rusqlite::Result<AgentTurnRow> {
Ok(AgentTurnRow {
id: row.get(0)?,
launch_id: row.get(1)?,
ordinal: row.get(2)?,
provider_turn_id: row.get(3)?,
started_at: row.get(4)?,
ended_at: row.get(5)?,
status: row.get(6)?,
input_op: row.get(7)?,
context_coverage: row.get(8)?,
tokenizer: row.get(9)?,
system_prompt_path: row.get(10)?,
task_prompt_path: row.get(11)?,
system_tokens: row.get(12)?,
task_tokens: row.get(13)?,
supplied_context_tokens: row.get(14)?,
provider_input_tokens: row.get(15)?,
provider_total_input_tokens: row.get(16)?,
peak_input_tokens: row.get(17)?,
context_window_tokens: row.get(18)?,
provider_output_tokens: row.get(19)?,
reasoning_tokens: row.get(20)?,
cache_read_tokens: row.get(21)?,
cache_write_tokens: row.get(22)?,
cost_usd: row.get(23)?,
context_gather_ms: row.get(24)?,
context_render_ms: row.get(25)?,
context_persist_ms: row.get(26)?,
first_event_seq: row.get(27)?,
last_event_seq: row.get(28)?,
})
}
#[cfg(test)]
mod frontier_tests {
use super::SqliteStore;
use crate::build_info::MigrationAuthority::{self, Published, ValidationOnly};
use crate::store::migrations::{
apply_all_but_head, latest_applied_version_sqlite, latest_known_version,
};
use crate::store::FrontierAdvance::{self, Authorized, Forbidden};
use std::path::{Path, PathBuf};
struct SharedHome {
_dir: tempfile::TempDir,
home: PathBuf,
}
impl SharedHome {
fn new() -> Self {
let dir = tempfile::tempdir().unwrap();
let home = dir.path().to_path_buf();
Self { _dir: dir, home }
}
fn shared_db(&self) -> PathBuf {
self.home.join(".lf/loopflow.db")
}
}
fn open(
path: &Path,
authority: MigrationAuthority,
home: &Path,
advance: FrontierAdvance,
) -> crate::store::StoreResult<SqliteStore> {
SqliteStore::open_with(path, authority, home, advance)
}
fn frontier(path: &Path) -> Option<String> {
let conn = rusqlite::Connection::open(path).unwrap();
latest_applied_version_sqlite(&conn).unwrap()
}
fn seed_shared_store_at_prior_head(path: &Path) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let conn = rusqlite::Connection::open(path).unwrap();
conn.execute_batch("PRAGMA foreign_keys = ON").unwrap();
apply_all_but_head(&conn).unwrap();
}
#[test]
fn an_ordinary_open_never_creates_or_initializes_an_absent_shared_store() {
let shared = SharedHome::new();
let path = shared.shared_db();
let error = open(&path, Published, &shared.home, Forbidden)
.expect_err("an ordinary open must not initialize the shared store");
assert!(
error.to_string().contains("only `lf install promote`"),
"the refusal must name the authorized boundary: {error}"
);
assert!(
!path.exists(),
"an ordinary open must not create the shared store file"
);
}
#[test]
fn an_ordinary_open_ahead_of_the_shared_frontier_refuses_without_advancing() {
let shared = SharedHome::new();
let path = shared.shared_db();
seed_shared_store_at_prior_head(&path);
let installed_frontier = frontier(&path).unwrap();
assert_eq!(installed_frontier, "0.11.027_accounts_first");
assert_ne!(installed_frontier, latest_known_version());
let error = open(&path, Published, &shared.home, Forbidden)
.expect_err("an ordinary open ahead of the frontier must refuse");
assert!(
error.to_string().contains("lf install promote"),
"the refusal must name the authorized boundary: {error}"
);
assert!(
error.to_string().contains(&installed_frontier)
|| error.to_string().contains(&latest_known_version()),
"the refusal names the pending frontier: {error}"
);
assert_eq!(
frontier(&path).as_deref(),
Some(installed_frontier.as_str()),
"a refused open must not advance the shared frontier"
);
let conn = rusqlite::Connection::open(&path).unwrap();
assert!(
crate::store::migrations::old_reader_recognizes(&conn),
"the old installed reader must still recognize the untouched store"
);
}
#[test]
fn the_promotion_boundary_initializes_and_advances_the_shared_store() {
let shared = SharedHome::new();
let path = shared.shared_db();
open(&path, Published, &shared.home, Authorized).expect("boundary initializes");
assert_eq!(
frontier(&path).as_deref(),
Some(latest_known_version().as_str())
);
open(&path, Published, &shared.home, Forbidden)
.expect("an ordinary open validates the initialized store");
assert_eq!(
frontier(&path).as_deref(),
Some(latest_known_version().as_str())
);
let advanced = SharedHome::new();
let advanced_path = advanced.shared_db();
seed_shared_store_at_prior_head(&advanced_path);
assert_eq!(
frontier(&advanced_path).as_deref(),
Some("0.11.027_accounts_first")
);
open(&advanced_path, Published, &advanced.home, Authorized).expect("boundary advances");
assert_eq!(
frontier(&advanced_path).as_deref(),
Some(latest_known_version().as_str())
);
}
#[test]
fn validation_only_is_walled_from_the_shared_store_but_not_private_ones() {
let shared = SharedHome::new();
let path = shared.shared_db();
open(&path, ValidationOnly, &shared.home, Authorized)
.expect_err("a validation-only build must never initialize the shared store");
assert!(!path.exists());
let private = shared.home.join(".lf-dev/branch/loopflow.db");
open(&private, ValidationOnly, &shared.home, Forbidden).expect("private DB initializes");
assert_eq!(
frontier(&private).as_deref(),
Some(latest_known_version().as_str())
);
}
}