Skip to main content

bamboo_engine/
session_repository.rs

1//! Canonical session coordinator owned by the framework.
2//!
3//! [`SessionRepository`] bundles the three tiers a Bamboo session lives in — the
4//! in-memory [`SessionCache`], the durable [`Storage`], and the
5//! merge-on-write [`LockedSessionStore`] — and provides the one canonical
6//! load/save coordination (cache → storage → backfill, and dual-write).
7//!
8//! This is a *framework* capability, not a server one: previously the
9//! coordination lived only as inherent methods on the server's `AppState`,
10//! which meant anything outside the HTTP server (the SDK, in-process embedders)
11//! could not load or persist sessions consistently. `SessionRepository` lets any
12//! caller that holds the three tiers share the exact same behaviour; the
13//! server's `AppState` now delegates to it.
14
15use std::sync::Arc;
16
17use bamboo_agent_core::storage::Storage;
18use bamboo_agent_core::Session;
19use bamboo_storage::LockedSessionStore;
20
21use crate::{read_cached_session, SessionCache};
22
23/// Framework-owned coordinator over a session's cache / storage / persistence
24/// tiers. Cheap to clone (all fields are `Arc`).
25#[derive(Clone)]
26pub struct SessionRepository {
27    cache: SessionCache,
28    storage: Arc<dyn Storage>,
29    persistence: Arc<LockedSessionStore>,
30}
31
32impl SessionRepository {
33    pub fn new(
34        cache: SessionCache,
35        storage: Arc<dyn Storage>,
36        persistence: Arc<LockedSessionStore>,
37    ) -> Self {
38        Self {
39            cache,
40            storage,
41            persistence,
42        }
43    }
44
45    pub fn cache(&self) -> &SessionCache {
46        &self.cache
47    }
48
49    pub fn storage(&self) -> &Arc<dyn Storage> {
50        &self.storage
51    }
52
53    pub fn persistence(&self) -> &Arc<LockedSessionStore> {
54        &self.persistence
55    }
56
57    /// Load a session from the memory cache, falling back to durable storage
58    /// (and back-filling the cache on a storage hit). `None` if absent in both.
59    pub async fn load(&self, session_id: &str) -> Option<Session> {
60        if let Some(session) = read_cached_session(&self.cache, session_id) {
61            return Some(session);
62        }
63
64        match self.storage.load_session(session_id).await {
65            Ok(Some(session)) => {
66                self.cache.insert(
67                    session_id.to_string(),
68                    Arc::new(parking_lot::RwLock::new(session.clone())),
69                );
70                Some(session)
71            }
72            _ => None,
73        }
74    }
75
76    /// Like [`load`](Self::load), but surfaces storage errors instead of
77    /// swallowing them to `None`. Cache hit short-circuits; a storage hit
78    /// back-fills the cache.
79    pub async fn try_load(&self, session_id: &str) -> std::io::Result<Option<Session>> {
80        if let Some(session) = read_cached_session(&self.cache, session_id) {
81            return Ok(Some(session));
82        }
83        let loaded = self.storage.load_session(session_id).await?;
84        if let Some(ref session) = loaded {
85            self.cache.insert(
86                session_id.to_string(),
87                Arc::new(parking_lot::RwLock::new(session.clone())),
88            );
89        }
90        Ok(loaded)
91    }
92
93    /// Persist the session (merge-on-write) and refresh the cache, surfacing
94    /// storage errors. Use [`save_and_cache`](Self::save_and_cache) for the
95    /// fire-and-forget variant that logs and continues on failure.
96    pub async fn save(&self, session: &mut Session) -> std::io::Result<()> {
97        self.persistence.merge_save_runtime(session).await?;
98        self.cache.insert(
99            session.id.clone(),
100            Arc::new(parking_lot::RwLock::new(session.clone())),
101        );
102        Ok(())
103    }
104
105    /// Load a session, creating a fresh `Session::new(id, model)` if absent.
106    pub async fn load_or_create(&self, session_id: &str, model: &str) -> Session {
107        if let Some(session) = self.load(session_id).await {
108            return session;
109        }
110        Session::new(session_id.to_string(), model.to_string())
111    }
112
113    /// Load a session, reconciling the memory and storage copies via a
114    /// preference heuristic: storage wins when it is strictly newer, or when it
115    /// is the same age but still carries a pending question memory lost. Storage
116    /// is **never** preferred when it is strictly older than memory.
117    ///
118    /// The cache is refreshed cache-aside but with a no-regression guarantee:
119    /// `load_merged` never overwrites a newer cached session with an older
120    /// storage copy, so it is safe to call from hot read paths.
121    pub async fn load_merged(&self, session_id: &str) -> Option<Session> {
122        let memory_session = read_cached_session(&self.cache, session_id);
123        let storage_session = self
124            .storage
125            .load_session(session_id)
126            .await
127            .unwrap_or_default();
128
129        match (memory_session, storage_session) {
130            (Some(memory), Some(storage)) => {
131                let prefer_storage = should_prefer_storage(&memory, &storage);
132                let diverged = prefer_storage || memory.messages.len() != storage.messages.len();
133                let chosen_len = if prefer_storage {
134                    storage.messages.len()
135                } else {
136                    memory.messages.len()
137                };
138                macro_rules! merged_log {
139                    ($level:ident) => {
140                        tracing::$level!(
141                            "[{}] load_session_merged: memory={} msgs (updated_at={}), storage={} msgs (updated_at={}), prefer_storage={} -> chose {} msgs",
142                            session_id,
143                            memory.messages.len(),
144                            memory.updated_at,
145                            storage.messages.len(),
146                            storage.updated_at,
147                            prefer_storage,
148                            chosen_len,
149                        )
150                    };
151                }
152                if diverged {
153                    merged_log!(debug);
154                } else {
155                    merged_log!(trace);
156                }
157                let memory_updated_at = memory.updated_at;
158                let chosen = if prefer_storage { storage } else { memory };
159                // Cache-aside refresh with a hard no-regression invariant: only
160                // write back when we actually reconciled *to storage* (a memory
161                // win is already the cached copy; re-inserting it would needlessly
162                // replace a possibly-live Arc) AND the reconciled copy is not
163                // older than what memory already holds. This is what makes
164                // `load_merged` safe on hot read paths — it can never clobber a
165                // freshly-updated session with a stale storage copy.
166                if prefer_storage && chosen.updated_at >= memory_updated_at {
167                    self.cache.insert(
168                        session_id.to_string(),
169                        Arc::new(parking_lot::RwLock::new(chosen.clone())),
170                    );
171                }
172                Some(chosen)
173            }
174            (Some(memory), None) => Some(memory),
175            (None, Some(storage)) => {
176                self.cache.insert(
177                    session_id.to_string(),
178                    Arc::new(parking_lot::RwLock::new(storage.clone())),
179                );
180                Some(storage)
181            }
182            (None, None) => None,
183        }
184    }
185
186    /// Persist the session (merge-on-write, preserving concurrent UI edits to
187    /// the authoritative metadata group) and refresh the in-memory cache.
188    pub async fn save_and_cache(&self, session: &mut Session) {
189        if let Err(error) = self.persistence.merge_save_runtime(session).await {
190            tracing::warn!("[{}] Failed to save session: {}", session.id, error);
191        }
192        self.cache.insert(
193            session.id.clone(),
194            Arc::new(parking_lot::RwLock::new(session.clone())),
195        );
196    }
197}
198
199fn should_prefer_storage(memory_session: &Session, storage_session: &Session) -> bool {
200    // Never reconcile *backwards* to a strictly-older storage copy: if memory is
201    // newer it is authoritative (e.g. it just answered and cleared a pending
202    // question while storage still holds the stale one). Respecting `updated_at`
203    // here is what stops `load_merged` from returning — and caching — stale data.
204    if storage_session.updated_at < memory_session.updated_at {
205        return false;
206    }
207    // Storage is same-age or newer: prefer it when strictly newer, or when it
208    // still carries a pending question that the (same-age) memory copy lost, so
209    // a genuine clarification is never dropped.
210    storage_session.updated_at > memory_session.updated_at
211        || (memory_session.pending_question.is_none() && storage_session.pending_question.is_some())
212}
213
214/// `SessionRepository` is the canonical `RuntimeSessionPersistence`: the runtime
215/// can persist a session through the same coordinator (merge-on-write + cache
216/// refresh) instead of a bespoke adapter.
217#[async_trait::async_trait]
218impl bamboo_domain::RuntimeSessionPersistence for SessionRepository {
219    async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
220        // Runtime authorization reads through this same cache. Refresh it even
221        // when durable storage fails so a current activation can never observe
222        // a previous run's skill allowlist. The error is still returned to the
223        // caller and durable state remains unchanged.
224        let result = self.persistence.merge_save_runtime(session).await;
225        self.cache.insert(
226            session.id.clone(),
227            Arc::new(parking_lot::RwLock::new(session.clone())),
228        );
229        result
230    }
231
232    async fn append_token_usage_record(
233        &self,
234        session_id: &str,
235        json_line: &str,
236    ) -> std::io::Result<()> {
237        self.storage
238            .append_token_usage_record(session_id, json_line)
239            .await
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246    use bamboo_agent_core::storage::Storage;
247    use chrono::Utc;
248    use std::collections::HashMap;
249    use std::sync::Mutex;
250
251    #[derive(Default)]
252    struct MapStorage {
253        sessions: Mutex<HashMap<String, Session>>,
254    }
255
256    struct FailingSaveStorage {
257        persisted: Mutex<Option<Session>>,
258    }
259
260    #[async_trait::async_trait]
261    impl Storage for MapStorage {
262        async fn save_session(&self, session: &Session) -> std::io::Result<()> {
263            self.sessions
264                .lock()
265                .unwrap()
266                .insert(session.id.clone(), session.clone());
267            Ok(())
268        }
269        async fn load_session(&self, session_id: &str) -> std::io::Result<Option<Session>> {
270            Ok(self.sessions.lock().unwrap().get(session_id).cloned())
271        }
272        async fn delete_session(&self, session_id: &str) -> std::io::Result<bool> {
273            Ok(self.sessions.lock().unwrap().remove(session_id).is_some())
274        }
275    }
276
277    #[async_trait::async_trait]
278    impl Storage for FailingSaveStorage {
279        async fn save_session(&self, _session: &Session) -> std::io::Result<()> {
280            Err(std::io::Error::other("injected save failure"))
281        }
282
283        async fn load_session(&self, _session_id: &str) -> std::io::Result<Option<Session>> {
284            Ok(self.persisted.lock().unwrap().clone())
285        }
286
287        async fn delete_session(&self, _session_id: &str) -> std::io::Result<bool> {
288            Ok(false)
289        }
290    }
291
292    fn test_repo(storage: Arc<dyn Storage>) -> SessionRepository {
293        let cache: SessionCache = Arc::new(dashmap::DashMap::new());
294        let persistence = Arc::new(LockedSessionStore::new(storage.clone()));
295        SessionRepository::new(cache, storage, persistence)
296    }
297
298    fn cache_put(repo: &SessionRepository, session: &Session) {
299        repo.cache().insert(
300            session.id.clone(),
301            Arc::new(parking_lot::RwLock::new(session.clone())),
302        );
303    }
304
305    /// Regression guard: a strictly-newer in-memory session (e.g. one that just
306    /// answered and cleared its pending question) must win over a strictly-older
307    /// storage copy that still carries the pending question — both in the value
308    /// returned AND in the cache (no clobber).
309    #[tokio::test]
310    async fn load_merged_does_not_regress_to_older_storage() {
311        let storage: Arc<dyn Storage> = Arc::new(MapStorage::default());
312        let repo = test_repo(storage.clone());
313        let id = "s1";
314
315        let mut stale = Session::new(id.to_string(), "m");
316        stale.set_pending_question(
317            "tc1".into(),
318            "kind".into(),
319            "q?".into(),
320            vec!["OK".into()],
321            true,
322        );
323        stale.updated_at = Utc::now() - chrono::Duration::seconds(10);
324        storage.save_session(&stale).await.unwrap();
325
326        let mut fresh = Session::new(id.to_string(), "m");
327        fresh.updated_at = Utc::now();
328        cache_put(&repo, &fresh);
329
330        let merged = repo.load_merged(id).await.expect("session exists");
331        assert!(
332            merged.pending_question.is_none(),
333            "must return the newer answered memory copy, not the stale storage one"
334        );
335        let cached = read_cached_session(repo.cache(), id).expect("cached");
336        assert!(
337            cached.pending_question.is_none(),
338            "load_merged must never regress the cache to a stale storage copy"
339        );
340    }
341
342    /// The pending-question recovery still works when storage is the same age:
343    /// if memory lost a pending question that same-age storage retains, prefer
344    /// storage so a genuine clarification is not dropped.
345    #[tokio::test]
346    async fn load_merged_recovers_pending_question_from_same_age_storage() {
347        let storage: Arc<dyn Storage> = Arc::new(MapStorage::default());
348        let repo = test_repo(storage.clone());
349        let id = "s2";
350        let ts = Utc::now();
351
352        let mut with_pending = Session::new(id.to_string(), "m");
353        with_pending.set_pending_question(
354            "tc".into(),
355            "k".into(),
356            "q".into(),
357            vec!["OK".into()],
358            true,
359        );
360        with_pending.updated_at = ts;
361        storage.save_session(&with_pending).await.unwrap();
362
363        let mut lost = with_pending.clone();
364        lost.clear_pending_question();
365        lost.updated_at = ts;
366        cache_put(&repo, &lost);
367
368        let merged = repo.load_merged(id).await.expect("session exists");
369        assert!(
370            merged.pending_question.is_some(),
371            "same-age storage carrying a pending question must still be recovered"
372        );
373    }
374
375    #[tokio::test]
376    async fn runtime_publish_refreshes_cache_even_when_storage_fails() {
377        let id = "runtime-selection";
378        let mut previous = Session::new(id.to_string(), "m");
379        previous.metadata.insert(
380            "skill_runtime_selected_skill_ids".to_string(),
381            "[\"plan\"]".to_string(),
382        );
383        let storage: Arc<dyn Storage> = Arc::new(FailingSaveStorage {
384            persisted: Mutex::new(Some(previous.clone())),
385        });
386        let repo = test_repo(storage.clone());
387        cache_put(&repo, &previous);
388
389        let mut current = previous.clone();
390        current.metadata.insert(
391            "skill_runtime_selected_skill_ids".to_string(),
392            "[\"review\"]".to_string(),
393        );
394        current.updated_at = Utc::now();
395
396        let result =
397            bamboo_domain::RuntimeSessionPersistence::save_runtime_session(&repo, &mut current)
398                .await;
399        assert!(result.is_err(), "durable failure must still be surfaced");
400
401        let cached = repo.load(id).await.expect("cached current session");
402        assert_eq!(
403            cached
404                .metadata
405                .get("skill_runtime_selected_skill_ids")
406                .map(String::as_str),
407            Some("[\"review\"]")
408        );
409        let allowlist = bamboo_skills::access_control::extract_skill_allowlist(&cached.metadata)
410            .expect("runtime authorization allowlist");
411        assert!(allowlist.contains("review"));
412        assert!(!allowlist.contains("plan"));
413        let durable = storage
414            .load_session(id)
415            .await
416            .expect("load durable state")
417            .expect("previous durable session");
418        assert_eq!(
419            durable
420                .metadata
421                .get("skill_runtime_selected_skill_ids")
422                .map(String::as_str),
423            Some("[\"plan\"]")
424        );
425    }
426}