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}