use serde::{Deserialize, Serialize};
use crate::checkpoint::RunState;
use crate::types::{RuntimeError, SessionEvent};
use async_trait::async_trait;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Durability {
ProcessLocal,
CrashDurable,
}
impl Durability {
pub fn survives_process_loss(&self) -> bool {
matches!(self, Durability::CrashDurable)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManagedSessionState {
pub events: Vec<SessionEvent>,
pub run_state: RunState,
}
#[async_trait]
pub trait ManagedStateStore: Send + Sync + std::fmt::Debug {
fn durability(&self) -> Durability;
async fn save(&self, session_id: &str, state: ManagedSessionState) -> Result<(), RuntimeError>;
async fn load(&self, session_id: &str) -> Result<Option<ManagedSessionState>, RuntimeError>;
async fn delete(&self, session_id: &str) -> Result<(), RuntimeError>;
async fn session_ids(&self) -> Result<Vec<String>, RuntimeError>;
}
#[derive(Debug, Default)]
pub struct InMemoryManagedStateStore {
sessions: Arc<RwLock<HashMap<String, ManagedSessionState>>>,
}
impl InMemoryManagedStateStore {
pub fn new() -> Self {
Self::default()
}
}
#[async_trait]
impl ManagedStateStore for InMemoryManagedStateStore {
fn durability(&self) -> Durability {
Durability::ProcessLocal
}
async fn save(&self, session_id: &str, state: ManagedSessionState) -> Result<(), RuntimeError> {
self.sessions.write().await.insert(session_id.to_string(), state);
Ok(())
}
async fn load(&self, session_id: &str) -> Result<Option<ManagedSessionState>, RuntimeError> {
Ok(self.sessions.read().await.get(session_id).cloned())
}
async fn delete(&self, session_id: &str) -> Result<(), RuntimeError> {
self.sessions.write().await.remove(session_id);
Ok(())
}
async fn session_ids(&self) -> Result<Vec<String>, RuntimeError> {
Ok(self.sessions.read().await.keys().cloned().collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::checkpoint::RunState;
use crate::types::SessionStatus;
fn state() -> ManagedSessionState {
ManagedSessionState {
events: Vec::new(),
run_state: RunState {
seq: 7,
pending_tool_ids: vec!["call-1".to_string()],
status: SessionStatus::Running,
},
}
}
#[tokio::test]
async fn the_in_memory_store_reports_its_own_guarantee() {
let store = InMemoryManagedStateStore::new();
assert_eq!(store.durability(), Durability::ProcessLocal);
assert!(
!store.durability().survives_process_loss(),
"a caller requiring resume-after-restart must be able to detect that it is absent"
);
}
#[tokio::test]
async fn a_saved_snapshot_round_trips() {
let store = InMemoryManagedStateStore::new();
store.save("session-1", state()).await.unwrap();
let loaded = store.load("session-1").await.unwrap().expect("saved state must load");
assert_eq!(loaded.run_state, state().run_state);
assert_eq!(loaded.events.len(), state().events.len());
assert_eq!(store.session_ids().await.unwrap(), vec!["session-1".to_string()]);
}
#[tokio::test]
async fn an_unknown_session_loads_as_none_and_deletes_without_error() {
let store = InMemoryManagedStateStore::new();
assert!(store.load("missing").await.unwrap().is_none());
assert!(store.delete("missing").await.is_ok(), "deletion is idempotent");
}
#[tokio::test]
async fn saving_twice_replaces_the_snapshot() {
let store = InMemoryManagedStateStore::new();
store.save("session-1", state()).await.unwrap();
let mut later = state();
later.run_state.seq = 9;
store.save("session-1", later.clone()).await.unwrap();
let loaded = store.load("session-1").await.unwrap().expect("state must load");
assert_eq!(loaded.run_state.seq, later.run_state.seq);
assert_eq!(store.session_ids().await.unwrap().len(), 1, "not appended twice");
}
#[tokio::test]
async fn a_new_store_shares_nothing_with_the_old_one() {
let first = InMemoryManagedStateStore::new();
first.save("session-1", state()).await.unwrap();
let second = InMemoryManagedStateStore::new();
assert!(
second.load("session-1").await.unwrap().is_none(),
"process-local state does not cross process boundaries"
);
assert!(second.session_ids().await.unwrap().is_empty());
}
}
#[derive(Debug)]
pub struct FileManagedStateStore {
root: std::path::PathBuf,
}
impl FileManagedStateStore {
pub fn new(root: impl Into<std::path::PathBuf>) -> Self {
Self { root: root.into() }
}
fn path_for(&self, session_id: &str) -> std::path::PathBuf {
let safe: String = session_id
.chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
.collect();
self.root.join(format!("{safe}.json"))
}
fn failed(action: &str, error: impl std::fmt::Display) -> RuntimeError {
RuntimeError::CheckpointFailed {
message: format!("could not {action} session state: {error}"),
}
}
}
#[async_trait::async_trait]
impl ManagedStateStore for FileManagedStateStore {
fn durability(&self) -> Durability {
Durability::CrashDurable
}
async fn save(&self, session_id: &str, state: ManagedSessionState) -> Result<(), RuntimeError> {
use std::io::Write;
let path = self.path_for(session_id);
let text = serde_json::to_vec_pretty(&state).map_err(|e| Self::failed("encode", e))?;
std::fs::create_dir_all(&self.root)
.map_err(|e| Self::failed("create the directory for", e))?;
let temporary = path.with_extension("json.tmp");
let mut file = std::fs::File::create(&temporary)
.map_err(|e| Self::failed("open a temporary file for", e))?;
file.write_all(&text).map_err(|e| Self::failed("write", e))?;
file.sync_all().map_err(|e| Self::failed("sync", e))?;
drop(file);
std::fs::rename(&temporary, &path).map_err(|e| Self::failed("commit", e))
}
async fn load(&self, session_id: &str) -> Result<Option<ManagedSessionState>, RuntimeError> {
match std::fs::read(self.path_for(session_id)) {
Ok(bytes) => {
serde_json::from_slice(&bytes).map(Some).map_err(|e| Self::failed("decode", e))
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(Self::failed("read", error)),
}
}
async fn delete(&self, session_id: &str) -> Result<(), RuntimeError> {
match std::fs::remove_file(self.path_for(session_id)) {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(Self::failed("delete", error)),
}
}
async fn session_ids(&self) -> Result<Vec<String>, RuntimeError> {
let entries = match std::fs::read_dir(&self.root) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(error) => return Err(Self::failed("list", error)),
};
let mut ids = Vec::new();
for entry in entries {
let entry = entry.map_err(|e| Self::failed("list", e))?;
let name = entry.file_name().to_string_lossy().to_string();
if let Some(id) = name.strip_suffix(".json") {
ids.push(id.to_string());
}
}
ids.sort();
Ok(ids)
}
}