use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
use crate::store::StoreError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum BudgetKind {
OneShot,
AgentLoop,
Total,
}
impl BudgetKind {
pub fn as_str(self) -> &'static str {
match self {
BudgetKind::OneShot => "OneShot",
BudgetKind::AgentLoop => "AgentLoop",
BudgetKind::Total => "Total",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BudgetRecord {
pub run_id: String,
pub kind: String,
pub cap_usd_micros: i64,
pub spent_usd_micros: i64,
pub halted: bool,
pub halted_at: Option<i64>,
}
pub struct BudgetStore<'a> {
pool: &'a SqlitePool,
}
impl<'a> BudgetStore<'a> {
pub fn new(pool: &'a SqlitePool) -> Self {
Self { pool }
}
pub async fn ensure_default(
&self,
run_id: &str,
kind: &str,
cap_usd_micros: i64,
) -> Result<(), StoreError> {
sqlx::query(
"INSERT OR IGNORE INTO budgets \
(run_id, kind, cap_usd_micros, spent_usd_micros, halted, halted_at) \
VALUES (?, ?, ?, 0, 0, NULL)",
)
.bind(run_id)
.bind(kind)
.bind(cap_usd_micros)
.execute(self.pool)
.await?;
Ok(())
}
pub async fn upsert(&self, b: &BudgetRecord) -> Result<(), StoreError> {
let halted = i64::from(b.halted);
sqlx::query!(
r#"
INSERT INTO budgets (
run_id, kind, cap_usd_micros, spent_usd_micros, halted, halted_at
) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(run_id, kind) DO UPDATE SET
cap_usd_micros = excluded.cap_usd_micros,
spent_usd_micros = excluded.spent_usd_micros,
halted = excluded.halted,
halted_at = excluded.halted_at
"#,
b.run_id,
b.kind,
b.cap_usd_micros,
b.spent_usd_micros,
halted,
b.halted_at,
)
.execute(self.pool)
.await?;
Ok(())
}
pub async fn get(&self, run_id: &str, kind: &str) -> Result<Option<BudgetRecord>, StoreError> {
let row = sqlx::query!(
r#"
SELECT run_id AS "run_id!", kind AS "kind!",
cap_usd_micros AS "cap_usd_micros!: i64",
spent_usd_micros AS "spent_usd_micros!: i64",
halted AS "halted!: i64",
halted_at
FROM budgets WHERE run_id = ? AND kind = ?
"#,
run_id,
kind
)
.fetch_optional(self.pool)
.await?;
Ok(row.map(|r| BudgetRecord {
run_id: r.run_id,
kind: r.kind,
cap_usd_micros: r.cap_usd_micros,
spent_usd_micros: r.spent_usd_micros,
halted: r.halted != 0,
halted_at: r.halted_at,
}))
}
pub async fn list_for_run(&self, run_id: &str) -> Result<Vec<BudgetRecord>, StoreError> {
let rows = sqlx::query!(
r#"
SELECT run_id AS "run_id!", kind AS "kind!",
cap_usd_micros AS "cap_usd_micros!: i64",
spent_usd_micros AS "spent_usd_micros!: i64",
halted AS "halted!: i64",
halted_at
FROM budgets WHERE run_id = ?
"#,
run_id
)
.fetch_all(self.pool)
.await?;
Ok(rows
.into_iter()
.map(|r| BudgetRecord {
run_id: r.run_id,
kind: r.kind,
cap_usd_micros: r.cap_usd_micros,
spent_usd_micros: r.spent_usd_micros,
halted: r.halted != 0,
halted_at: r.halted_at,
})
.collect())
}
pub async fn add_spend(
&self,
run_id: &str,
kind: &str,
micros: i64,
) -> Result<i64, StoreError> {
let row = sqlx::query!(
r#"
UPDATE budgets
SET spent_usd_micros = spent_usd_micros + ?
WHERE run_id = ? AND kind = ?
RETURNING spent_usd_micros AS "spent!: i64"
"#,
micros,
run_id,
kind
)
.fetch_one(self.pool)
.await?;
Ok(row.spent)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::testutil::{fresh_store, sample_run};
fn b(run_id: &str, kind: &str) -> BudgetRecord {
BudgetRecord {
run_id: run_id.to_string(),
kind: kind.to_string(),
cap_usd_micros: 10_000_000,
spent_usd_micros: 0,
halted: false,
halted_at: None,
}
}
#[tokio::test]
async fn upsert_then_get_roundtrips() {
let (_tmp, s) = fresh_store().await;
s.runs().insert(&sample_run("run")).await.expect("run");
let row = b("run", "Total");
s.budgets().upsert(&row).await.expect("upsert");
let got = s.budgets().get("run", "Total").await.expect("get").expect("row");
assert_eq!(got, row);
}
#[tokio::test]
async fn list_for_run_returns_all_kinds() {
let (_tmp, s) = fresh_store().await;
s.runs().insert(&sample_run("run")).await.expect("run");
s.budgets().upsert(&b("run", "OneShot")).await.expect("a");
s.budgets().upsert(&b("run", "AgentLoop")).await.expect("b");
s.budgets().upsert(&b("run", "Total")).await.expect("c");
let got = s.budgets().list_for_run("run").await.expect("list");
assert_eq!(got.len(), 3);
}
#[tokio::test]
async fn add_spend_returns_new_total() {
let (_tmp, s) = fresh_store().await;
s.runs().insert(&sample_run("run")).await.expect("run");
s.budgets().upsert(&b("run", "Total")).await.expect("upsert");
let after_a = s.budgets().add_spend("run", "Total", 100).await.expect("a");
let after_b = s.budgets().add_spend("run", "Total", 250).await.expect("b");
assert_eq!(after_a, 100);
assert_eq!(after_b, 350);
}
#[tokio::test]
async fn halted_flag_roundtrips() {
let (_tmp, s) = fresh_store().await;
s.runs().insert(&sample_run("run")).await.expect("run");
let mut row = b("run", "Total");
row.halted = true;
row.halted_at = Some(9_999);
s.budgets().upsert(&row).await.expect("upsert");
let got = s.budgets().get("run", "Total").await.expect("get").expect("row");
assert!(got.halted);
assert_eq!(got.halted_at, Some(9_999));
}
#[tokio::test]
async fn cascade_from_run_delete() {
let (_tmp, s) = fresh_store().await;
s.runs().insert(&sample_run("doomed")).await.expect("run");
s.budgets().upsert(&b("doomed", "Total")).await.expect("upsert");
s.runs().delete("doomed").await.expect("del");
assert!(
s.budgets().get("doomed", "Total").await.expect("get").is_none(),
"budget should cascade-delete with parent run"
);
}
}