Skip to main content

ailoop_context/
history_store.rs

1//! [`HistoryStore`] trait + [`InMemoryHistoryStore`] /
2//! [`JsonFileHistoryStore`] backends.
3
4use std::path::{Path, PathBuf};
5use std::sync::Mutex;
6
7use async_trait::async_trait;
8
9use crate::snapshot::ConversationSnapshot;
10
11/// Persistence backend for conversation snapshots.
12///
13/// Implement this trait to durable-back a conversation in any store
14/// you like (Redis, Postgres, S3, sled, …). The crate ships
15/// [`InMemoryHistoryStore`] for tests and [`JsonFileHistoryStore`] for
16/// single-file local persistence.
17///
18/// ## Concurrency
19///
20/// The trait is `async`; impls are free to use any backend. The
21/// façade [`Conversation`](https://docs.rs/ailoop) does not serialize
22/// access to a single store across runs — a process running multiple
23/// conversations against the same backend should use one store per
24/// `RunId` (or implement its own locking). A single `Conversation`
25/// only writes from one task at a time, so per-conversation impls do
26/// not need internal mutexes for that reason alone.
27#[async_trait]
28pub trait HistoryStore: Send + Sync {
29    /// Error type surfaced from [`save`](Self::save) /
30    /// [`load`](Self::load). Use [`std::convert::Infallible`] when the
31    /// implementation cannot fail (see [`InMemoryHistoryStore`]).
32    type Error: std::error::Error + Send + Sync + 'static;
33
34    /// Persist `snapshot` so a later [`load`](Self::load) returns it.
35    /// Implementations should treat this as an overwrite — the façade
36    /// passes the full updated [`ConversationSnapshot`] every time
37    /// rather than diffing.
38    async fn save(&self, snapshot: &ConversationSnapshot) -> Result<(), Self::Error>;
39
40    /// Return the most recently persisted snapshot, or `Ok(None)` when
41    /// the store has never been written to. Returning `Ok(None)` lets
42    /// callers default-init the conversation on first run instead of
43    /// special-casing a missing payload.
44    async fn load(&self) -> Result<Option<ConversationSnapshot>, Self::Error>;
45}
46
47/// Volatile [`HistoryStore`] kept entirely in process memory. Intended
48/// for tests and short-lived sessions where durable persistence is not
49/// required.
50///
51/// # Panics
52///
53/// `save` / `load` `.expect()` on an internal [`Mutex`] — they only
54/// panic if the lock is poisoned by a previous panic in another
55/// task that held the guard.
56#[derive(Default)]
57pub struct InMemoryHistoryStore {
58    inner: Mutex<Option<ConversationSnapshot>>,
59}
60
61impl InMemoryHistoryStore {
62    /// Build an empty store. Equivalent to
63    /// [`InMemoryHistoryStore::default`].
64    pub fn new() -> Self {
65        Self::default()
66    }
67}
68
69#[async_trait]
70impl HistoryStore for InMemoryHistoryStore {
71    type Error = std::convert::Infallible;
72
73    async fn save(&self, snapshot: &ConversationSnapshot) -> Result<(), Self::Error> {
74        *self
75            .inner
76            .lock()
77            .expect("InMemoryHistoryStore mutex poisoned") = Some(snapshot.clone());
78        Ok(())
79    }
80
81    async fn load(&self) -> Result<Option<ConversationSnapshot>, Self::Error> {
82        Ok(self
83            .inner
84            .lock()
85            .expect("InMemoryHistoryStore mutex poisoned")
86            .clone())
87    }
88}
89
90/// JSON-backed [`HistoryStore`] that writes the full snapshot to a
91/// single file. `save` pretty-prints; `load` returns `Ok(None)` when
92/// the file does not exist (first run) so callers can seed an empty
93/// conversation without branching on the missing-file case.
94pub struct JsonFileHistoryStore {
95    path: PathBuf,
96}
97
98impl JsonFileHistoryStore {
99    /// Bind the store to `path`. The file is not opened until
100    /// [`save`](HistoryStore::save) or [`load`](HistoryStore::load)
101    /// runs; constructing the store cannot fail.
102    pub fn new(path: impl Into<PathBuf>) -> Self {
103        Self { path: path.into() }
104    }
105
106    /// Path the store reads from / writes to.
107    pub fn path(&self) -> &Path {
108        &self.path
109    }
110}
111
112/// [`HistoryStore::Error`] variant for [`JsonFileHistoryStore`].
113#[derive(Debug, thiserror::Error)]
114#[non_exhaustive]
115pub enum JsonFileHistoryStoreError {
116    /// Filesystem I/O failed (permission denied, disk full, EOF mid-
117    /// read, …). Wraps the underlying [`std::io::Error`].
118    #[error("failed to read/write snapshot file: {0}")]
119    Io(#[from] std::io::Error),
120
121    /// JSON encode/decode failed. Wraps the underlying
122    /// [`serde_json::Error`] — the most common cause is a snapshot
123    /// file written by a future incompatible version (e.g. an
124    /// unsupported [`ConversationSnapshot::VERSION`]) or hand-edited
125    /// to break the [`Message`](ailoop_core::Message) shape.
126    ///
127    /// [`ConversationSnapshot::VERSION`]: crate::ConversationSnapshot::VERSION
128    #[error("failed to encode/decode snapshot JSON: {0}")]
129    Json(#[from] serde_json::Error),
130}
131
132#[async_trait]
133impl HistoryStore for JsonFileHistoryStore {
134    type Error = JsonFileHistoryStoreError;
135
136    async fn save(&self, snapshot: &ConversationSnapshot) -> Result<(), Self::Error> {
137        let bytes = serde_json::to_vec_pretty(snapshot)?;
138        tokio::fs::write(&self.path, bytes).await?;
139        Ok(())
140    }
141
142    async fn load(&self) -> Result<Option<ConversationSnapshot>, Self::Error> {
143        match tokio::fs::read(&self.path).await {
144            Ok(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
145            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
146            Err(e) => Err(e.into()),
147        }
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use ailoop_core::Message;
155    use tempfile::tempdir;
156
157    fn sample_snapshot() -> ConversationSnapshot {
158        ConversationSnapshot::new(
159            vec![Message::user("hi"), Message::assistant_text("hello")],
160            vec![true, false],
161        )
162        .expect("valid lengths")
163    }
164
165    #[tokio::test]
166    async fn in_memory_store_round_trip() {
167        let store = InMemoryHistoryStore::new();
168        assert!(store.load().await.unwrap().is_none());
169
170        let snap = sample_snapshot();
171        store.save(&snap).await.unwrap();
172        let restored = store.load().await.unwrap().expect("load after save");
173        assert_eq!(restored, snap);
174    }
175
176    #[tokio::test]
177    async fn json_file_store_returns_none_when_missing() {
178        let dir = tempdir().unwrap();
179        let store = JsonFileHistoryStore::new(dir.path().join("missing.json"));
180        let restored = store.load().await.unwrap();
181        assert!(restored.is_none(), "missing file must yield Ok(None)");
182    }
183
184    #[tokio::test]
185    async fn json_file_store_round_trip() {
186        let dir = tempdir().unwrap();
187        let store = JsonFileHistoryStore::new(dir.path().join("snap.json"));
188        let snap = sample_snapshot();
189        store.save(&snap).await.unwrap();
190        let restored = store.load().await.unwrap().expect("load after save");
191        assert_eq!(restored, snap);
192    }
193}