klieo-runlog 3.3.1

Tier 2 observability — RunLog aggregate + replay engine for klieo agents.
Documentation
//! `SqliteRunLogStore` CRUD smoke tests. Gated on the `sqlite` feature.

#![cfg(feature = "sqlite")]

use klieo_core::ids::RunId;
use klieo_runlog::projector::project;
use klieo_runlog::store::{RunLogQuery, RunLogStore};
use klieo_runlog::store_sqlite::SqliteRunLogStore;
use klieo_runlog::types::{Cost, RunStatus};
use tempfile::NamedTempFile;

fn tmp_db() -> NamedTempFile {
    NamedTempFile::new().unwrap()
}

#[tokio::test]
async fn put_then_get_round_trip() {
    let f = tmp_db();
    let store = SqliteRunLogStore::new(f.path()).await.unwrap();
    let log = project(RunId::new(), "writer", &[]);
    let rid = log.run_id;
    store.put(&log).await.unwrap();
    let back = store.get(rid).await.unwrap().unwrap();
    assert_eq!(back.agent, "writer");
}

#[tokio::test]
async fn list_orders_recent_first_and_respects_limit() {
    let f = tmp_db();
    let store = SqliteRunLogStore::new(f.path()).await.unwrap();
    for _ in 0..5 {
        store
            .put(&project(RunId::new(), "writer", &[]))
            .await
            .unwrap();
    }
    let rows = store.list(&RunLogQuery::recent(3)).await.unwrap();
    assert_eq!(rows.len(), 3);
    for w in rows.windows(2) {
        assert!(w[0].started_at >= w[1].started_at);
    }
}

#[tokio::test]
async fn list_status_filter_pushed_into_query_composes_with_limit() {
    let f = tmp_db();
    let store = SqliteRunLogStore::new(f.path()).await.unwrap();
    // Two completed runs created BEFORE three running runs; a status filter
    // applied after a LIMIT 2 would miss the completed ones.
    for _ in 0..2 {
        let mut log = project(RunId::new(), "w", &[]);
        log.status = RunStatus::Completed;
        store.put(&log).await.unwrap();
    }
    for _ in 0..3 {
        store.put(&project(RunId::new(), "w", &[])).await.unwrap();
    }
    let done = store
        .list(&RunLogQuery {
            status: Some(RunStatus::Completed),
            limit: Some(2),
            ..Default::default()
        })
        .await
        .unwrap();
    assert_eq!(done.len(), 2);
    assert!(done.iter().all(|r| r.status == RunStatus::Completed));
}

#[tokio::test]
async fn list_offset_paginates() {
    let f = tmp_db();
    let store = SqliteRunLogStore::new(f.path()).await.unwrap();
    for _ in 0..5 {
        store.put(&project(RunId::new(), "w", &[])).await.unwrap();
    }
    let page2 = store
        .list(&RunLogQuery {
            limit: Some(2),
            offset: 2,
            ..Default::default()
        })
        .await
        .unwrap();
    assert_eq!(page2.len(), 2, "offset+limit returns the second page");
}

#[tokio::test]
async fn list_filtered_by_agent() {
    let f = tmp_db();
    let store = SqliteRunLogStore::new(f.path()).await.unwrap();
    store
        .put(&project(RunId::new(), "writer", &[]))
        .await
        .unwrap();
    store
        .put(&project(RunId::new(), "editor", &[]))
        .await
        .unwrap();
    let rows = store
        .list(&RunLogQuery {
            agent: Some("writer".into()),
            limit: Some(10),
            ..Default::default()
        })
        .await
        .unwrap();
    assert_eq!(rows.len(), 1);
    assert_eq!(rows[0].agent, "writer");
}

#[tokio::test]
async fn default_query_is_unbounded_not_zero() {
    let f = tmp_db();
    let store = SqliteRunLogStore::new(f.path()).await.unwrap();
    for _ in 0..5 {
        store.put(&project(RunId::new(), "w", &[])).await.unwrap();
    }
    // limit None maps to SQLite `LIMIT -1` (no limit); the old usize default of
    // 0 would have produced `LIMIT 0` and returned nothing.
    let rows = store.list(&RunLogQuery::default()).await.unwrap();
    assert_eq!(
        rows.len(),
        5,
        "a no-limit query returns every row, not zero"
    );
}

#[tokio::test]
async fn delete_removes_log() {
    let f = tmp_db();
    let store = SqliteRunLogStore::new(f.path()).await.unwrap();
    let log = project(RunId::new(), "writer", &[]);
    let rid = log.run_id;
    store.put(&log).await.unwrap();
    store.delete(rid).await.unwrap();
    assert!(store.get(rid).await.unwrap().is_none());
}

#[tokio::test]
async fn cost_estimate_round_trips_prompt_and_completion_split() {
    let f = tmp_db();
    let store = SqliteRunLogStore::new(f.path()).await.unwrap();
    let mut log = project(RunId::new(), "writer", &[]);
    log.cost_estimate = Some(Cost::new(0.005, 0.015, 0.0));
    let rid = log.run_id;
    store.put(&log).await.unwrap();
    let back = store.get(rid).await.unwrap().expect("row exists");
    let cost = back.cost_estimate.expect("cost preserved");
    assert!((cost.prompt_usd - 0.005).abs() < 1e-12);
    assert!((cost.completion_usd - 0.015).abs() < 1e-12);
    assert!((cost.total_usd - 0.020).abs() < 1e-12);
}

#[tokio::test]
async fn cost_estimate_with_cache_keeps_total_authoritative_on_round_trip() {
    // No cache column is persisted, so a stored cache_usd reconstructs as 0 on
    // read-back while the stored total_usd (which already included cache cost)
    // remains authoritative.
    let f = tmp_db();
    let store = SqliteRunLogStore::new(f.path()).await.unwrap();
    let mut log = project(RunId::new(), "writer", &[]);
    log.cost_estimate = Some(Cost::new(0.005, 0.015, 0.001));
    let rid = log.run_id;
    store.put(&log).await.unwrap();
    let cost = store
        .get(rid)
        .await
        .unwrap()
        .expect("row exists")
        .cost_estimate
        .expect("cost preserved");
    assert!(
        (cost.cache_usd).abs() < 1e-12,
        "cache_usd reconstructs as 0"
    );
    assert!(
        (cost.total_usd - 0.021).abs() < 1e-12,
        "stored total (incl. cache) stays authoritative"
    );
}