1use 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#[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 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 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 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 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 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 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 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 if storage_session.updated_at < memory_session.updated_at {
205 return false;
206 }
207 storage_session.updated_at > memory_session.updated_at
211 || (memory_session.pending_question.is_none() && storage_session.pending_question.is_some())
212}
213
214#[async_trait::async_trait]
218impl bamboo_domain::RuntimeSessionPersistence for SessionRepository {
219 async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
220 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 #[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 #[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}