use std::collections::BTreeMap;
use redb::{Database, ReadableDatabase, ReadableTable, TableDefinition};
use super::jsonio;
use super::model::Session;
const SESSIONS: TableDefinition<&str, &[u8]> = TableDefinition::new("sessions");
pub struct StoreDb {
db: Database,
}
impl StoreDb {
pub fn open(port: u16) -> Result<StoreDb, String> {
Self::open_path(&super::paths::store_db_file(port))
}
pub fn open_path(path: &std::path::Path) -> Result<StoreDb, String> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?;
}
let db = Database::create(path).map_err(|e| format!("open redb {}: {e}", path.display()))?;
let txn = db.begin_write().map_err(|e| e.to_string())?;
{
let _ = txn.open_table(SESSIONS).map_err(|e| e.to_string())?;
}
txn.commit().map_err(|e| e.to_string())?;
Ok(StoreDb { db })
}
#[cfg(test)]
pub fn load_sessions(&self) -> BTreeMap<String, Session> {
let mut out = BTreeMap::new();
let Ok(txn) = self.db.begin_read() else { return out };
let Ok(table) = txn.open_table(SESSIONS) else { return out };
let Ok(iter) = table.iter() else { return out };
for (k, v) in iter.flatten() {
if let Ok(session) = jsonio::parse_session_json(&String::from_utf8_lossy(v.value())) {
out.insert(k.value().to_string(), session);
}
}
out
}
pub fn get(&self, id: &str) -> Option<Session> {
let txn = self.db.begin_read().ok()?;
let table = txn.open_table(SESSIONS).ok()?;
let row = table.get(id).ok()??;
jsonio::parse_session_json(&String::from_utf8_lossy(row.value())).ok()
}
pub fn sync(&self, dirty: &[(String, String)]) -> Result<(), String> {
let txn = self.db.begin_write().map_err(|e| e.to_string())?;
{
let mut table = txn.open_table(SESSIONS).map_err(|e| e.to_string())?;
for (id, json) in dirty {
table.insert(id.as_str(), json.as_bytes()).map_err(|e| e.to_string())?;
}
}
txn.commit().map_err(|e| e.to_string())?;
Ok(())
}
pub fn load_working_set(&self, mem_cap: usize, disk_cap: usize, now: u64) -> BTreeMap<String, Session> {
let mut running: Vec<(String, Session)> = Vec::new();
let mut newest: BTreeMap<(u64, String), Session> = BTreeMap::new();
let mut finished: Vec<(u64, String)> = Vec::new();
{
let Ok(txn) = self.db.begin_read() else { return BTreeMap::new() };
let Ok(table) = txn.open_table(SESSIONS) else { return BTreeMap::new() };
let Ok(iter) = table.iter() else { return BTreeMap::new() };
for (k, v) in iter.flatten() {
let id = k.value().to_string();
let Ok(session) = jsonio::parse_session_json(&String::from_utf8_lossy(v.value())) else { continue };
if session.lifecycle_status(now) == super::model::SessionLifecycle::Running {
running.push((id, session));
continue;
}
finished.push((session.started_at, id.clone()));
newest.insert((session.started_at, id), session);
if newest.len() > mem_cap {
newest.pop_first();
}
}
}
if finished.len() > disk_cap {
finished.sort_unstable();
let stale: Vec<&(u64, String)> = finished.iter().take(finished.len() - disk_cap).collect();
if let Ok(txn) = self.db.begin_write() {
if let Ok(mut table) = txn.open_table(SESSIONS) {
for (_, id) in &stale {
let _ = table.remove(id.as_str());
}
}
let _ = txn.commit();
}
}
let mut out: BTreeMap<String, Session> = running.into_iter().collect();
out.extend(newest.into_iter().map(|((_, id), s)| (id, s)));
out
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::daemon::model::{DaemonMode, ProcKind, ProcRecord, ProcStatus, Session, Store};
#[test]
fn load_szwwuv_backfills_graph_needs_when_present_in_store() {
let path = super::super::paths::store_db_file(7274);
if !path.exists() {
return;
}
let db = match StoreDb::open_path(&path) {
Ok(db) => db,
Err(e) if e.contains("Cannot acquire lock") => return,
Err(e) => panic!("open store: {e}"),
};
let sessions = db.load_sessions();
let Some(session) = sessions.get("szwwuv") else {
return;
};
assert!(session.workflow.is_none(), "szwwuv persisted before workflow DAG was stored");
let def_needs =
crate::daemon::workflow::needs_from_harness_profile_for_test(session).expect("def needs from arith profile");
assert_eq!(def_needs.get("summarize"), Some(&vec!["add".to_string(), "multiply".to_string()]));
let meta = crate::daemon::workflow::effective_workflow_meta(session).expect("graph");
let summarize = meta.nodes.iter().find(|n| n.id == "summarize").expect("summarize");
assert_eq!(
summarize.needs,
vec!["add".to_string(), "multiply".to_string()],
"profile={:?} kind={:?} repo={}",
session.profile,
session.kind,
session.repo
);
}
fn session(id: &str) -> Session {
Session {
id: id.into(),
started_at: 1,
ended_at: None,
profile: Some("default".into()),
kind: None,
repo: "/r".into(),
branch: "main".into(),
skills: Vec::new(),
procs: vec![ProcRecord {
index: 0,
previous_attempt: None,
label: "s".into(),
kind: ProcKind::Skill,
status: ProcStatus::Ok,
skill_name: None,
harness: None,
model: None,
started_at: Some(1),
note: None,
detail: None,
fail_reason: None,
elapsed: Some(1.0),
lines: Vec::new(),
container_name: None,
container_runtime: None,
cast_path: None,
diff_path: None,
skill_source: None,
route: None,
result_path: None,
annotate_target: None,
}],
last_seen_at: 1,
client_connected: false,
run_pid: None,
workflow: None,
parent_session: None,
supervisor: Default::default(),
}
}
fn temp_db_path() -> std::path::PathBuf {
std::env::temp_dir().join(format!("scsh-db-test-{}.redb", crate::runtime::random_nonce_6()))
}
fn finished_at(id: &str, t: u64) -> Session {
Session { started_at: t, last_seen_at: t, ended_at: Some(t + 1), ..session(id) }
}
#[test]
fn sync_persists_and_keeps_rows_for_evicted_sessions() {
let path = temp_db_path();
let db = StoreDb::open_path(&path).unwrap();
let dirty = vec![
("aaaaaa".to_string(), jsonio::session_json_store(&session("aaaaaa"))),
("bbbbbb".to_string(), jsonio::session_json_store(&session("bbbbbb"))),
];
db.sync(&dirty).unwrap();
let loaded = db.load_sessions();
assert_eq!(loaded.len(), 2);
assert_eq!(loaded["aaaaaa"].procs[0].elapsed, Some(1.0));
db.sync(&[]).unwrap();
let loaded = db.load_sessions();
assert_eq!(loaded.len(), 2);
assert_eq!(db.get("bbbbbb").map(|s| s.branch), Some("main".into()), "the archived row reads back");
assert!(db.get("zzzzzz").is_none());
let _ = std::fs::remove_file(&path);
}
#[test]
fn reopen_sees_prior_writes() {
let path = temp_db_path();
{
let db = StoreDb::open_path(&path).unwrap();
db.sync(&[("persb".to_string(), jsonio::session_json_store(&session("persb")))]).unwrap();
} let reopened = StoreDb::open_path(&path).unwrap();
let store = Store { sessions: reopened.load_sessions(), ..Store::new(DaemonMode::Persistent, 7274, 0) };
assert_eq!(store.sessions.len(), 1);
assert_eq!(store.sessions["persb"].branch, "main");
drop(reopened);
let _ = std::fs::remove_file(&path);
}
#[test]
fn load_working_set_caps_memory_keeps_running_and_prunes_the_archive() {
let path = temp_db_path();
let db = StoreDb::open_path(&path).unwrap();
let mut dirty: Vec<(String, String)> = (1..=5u64)
.map(|i| {
let id = format!("done-{i:02}");
let json = jsonio::session_json_store(&finished_at(&id, i * 10));
(id, json)
})
.collect();
let live = Session { started_at: 2, last_seen_at: 2, ended_at: None, ..session("live-1") };
dirty.push(("live-1".to_string(), jsonio::session_json_store(&live)));
db.sync(&dirty).unwrap();
let now = 60;
let working = db.load_working_set(2, 4, now);
let mut got: Vec<&str> = working.keys().map(String::as_str).collect();
got.sort_unstable();
assert_eq!(got, vec!["done-04", "done-05", "live-1"]);
let remaining = db.load_sessions();
assert!(!remaining.contains_key("done-01"), "oldest finished row beyond disk_cap is pruned");
for kept in ["done-02", "done-03", "done-04", "done-05", "live-1"] {
assert!(remaining.contains_key(kept), "{kept} survives");
}
let _ = std::fs::remove_file(&path);
}
}