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 update_runtime_session<F>(
109 &self,
110 session_id: &str,
111 metadata_keys: &[&str],
112 mutate: F,
113 ) -> std::io::Result<Option<Session>>
114 where
115 F: FnOnce(&mut Session),
116 {
117 let saved = self
118 .persistence
119 .update_runtime_config(session_id, mutate)
120 .await?;
121 if let (Some(saved), Some(cached)) = (saved.as_ref(), self.cache.get(session_id)) {
122 let mut cached = cached.write();
123 for key in metadata_keys {
124 if let Some(value) = saved.metadata.get(*key) {
125 cached.metadata.insert((*key).to_string(), value.clone());
126 } else {
127 cached.metadata.remove(*key);
128 }
129 }
130 }
131 Ok(saved)
132 }
133
134 pub async fn load_or_create(&self, session_id: &str, model: &str) -> Session {
136 if let Some(session) = self.load(session_id).await {
137 return session;
138 }
139 Session::new(session_id.to_string(), model.to_string())
140 }
141
142 pub async fn load_merged(&self, session_id: &str) -> Option<Session> {
151 let memory_session = read_cached_session(&self.cache, session_id);
152 let storage_session = self
153 .storage
154 .load_session(session_id)
155 .await
156 .unwrap_or_default();
157
158 match (memory_session, storage_session) {
159 (Some(memory), Some(storage)) => {
160 let prefer_storage = should_prefer_storage(&memory, &storage);
161 let diverged = prefer_storage || memory.messages.len() != storage.messages.len();
162 let chosen_len = if prefer_storage {
163 storage.messages.len()
164 } else {
165 memory.messages.len()
166 };
167 macro_rules! merged_log {
168 ($level:ident) => {
169 tracing::$level!(
170 "[{}] load_session_merged: memory={} msgs (updated_at={}), storage={} msgs (updated_at={}), prefer_storage={} -> chose {} msgs",
171 session_id,
172 memory.messages.len(),
173 memory.updated_at,
174 storage.messages.len(),
175 storage.updated_at,
176 prefer_storage,
177 chosen_len,
178 )
179 };
180 }
181 if diverged {
182 merged_log!(debug);
183 } else {
184 merged_log!(trace);
185 }
186 let memory_updated_at = memory.updated_at;
187 let chosen = if prefer_storage { storage } else { memory };
188 if prefer_storage && chosen.updated_at >= memory_updated_at {
196 self.cache.insert(
197 session_id.to_string(),
198 Arc::new(parking_lot::RwLock::new(chosen.clone())),
199 );
200 }
201 Some(chosen)
202 }
203 (Some(memory), None) => Some(memory),
204 (None, Some(storage)) => {
205 self.cache.insert(
206 session_id.to_string(),
207 Arc::new(parking_lot::RwLock::new(storage.clone())),
208 );
209 Some(storage)
210 }
211 (None, None) => None,
212 }
213 }
214
215 pub async fn save_and_cache(&self, session: &mut Session) {
218 if let Err(error) = self.persistence.merge_save_runtime(session).await {
219 tracing::warn!("[{}] Failed to save session: {}", session.id, error);
220 }
221 self.cache.insert(
222 session.id.clone(),
223 Arc::new(parking_lot::RwLock::new(session.clone())),
224 );
225 }
226}
227
228fn should_prefer_storage(memory_session: &Session, storage_session: &Session) -> bool {
229 if storage_session.updated_at < memory_session.updated_at {
234 return false;
235 }
236 storage_session.updated_at > memory_session.updated_at
240 || (memory_session.pending_question.is_none() && storage_session.pending_question.is_some())
241}
242
243#[async_trait::async_trait]
247impl bamboo_domain::RuntimeSessionPersistence for SessionRepository {
248 async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
249 let result = self.persistence.merge_save_runtime(session).await;
254 self.cache.insert(
255 session.id.clone(),
256 Arc::new(parking_lot::RwLock::new(session.clone())),
257 );
258 result
259 }
260
261 async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
262 let result = self.persistence.checkpoint_runtime_session(session).await;
269 if result.is_ok() {
270 self.cache.insert(
271 session.id.clone(),
272 Arc::new(parking_lot::RwLock::new(session.clone())),
273 );
274 }
275 result
276 }
277
278 async fn load_runtime_session(&self, session_id: &str) -> std::io::Result<Option<Session>> {
279 self.try_load(session_id).await
280 }
281
282 async fn clear_legacy_pending_messages(
283 &self,
284 session_id: &str,
285 expected: &[serde_json::Value],
286 ) -> std::io::Result<bool> {
287 let cleared = bamboo_domain::RuntimeSessionPersistence::clear_legacy_pending_messages(
292 self.persistence.as_ref(),
293 session_id,
294 expected,
295 )
296 .await?;
297 if !cleared {
298 return Ok(false);
299 }
300
301 let latest = self
304 .storage
305 .load_session(session_id)
306 .await?
307 .ok_or_else(|| {
308 std::io::Error::new(
309 std::io::ErrorKind::NotFound,
310 format!("session disappeared after clearing legacy inbox: {session_id}"),
311 )
312 })?;
313 self.cache.insert(
314 session_id.to_string(),
315 Arc::new(parking_lot::RwLock::new(latest)),
316 );
317 Ok(true)
318 }
319
320 async fn append_token_usage_record(
321 &self,
322 session_id: &str,
323 json_line: &str,
324 ) -> std::io::Result<()> {
325 self.storage
326 .append_token_usage_record(session_id, json_line)
327 .await
328 }
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334 use bamboo_agent_core::storage::Storage;
335 use chrono::Utc;
336 use std::collections::HashMap;
337 use std::sync::Mutex;
338
339 #[derive(Default)]
340 struct MapStorage {
341 sessions: Mutex<HashMap<String, Session>>,
342 }
343
344 struct FailingSaveStorage {
345 persisted: Mutex<Option<Session>>,
346 }
347
348 #[async_trait::async_trait]
349 impl Storage for MapStorage {
350 async fn save_session(&self, session: &Session) -> std::io::Result<()> {
351 self.sessions
352 .lock()
353 .unwrap()
354 .insert(session.id.clone(), session.clone());
355 Ok(())
356 }
357 async fn load_session(&self, session_id: &str) -> std::io::Result<Option<Session>> {
358 Ok(self.sessions.lock().unwrap().get(session_id).cloned())
359 }
360 async fn delete_session(&self, session_id: &str) -> std::io::Result<bool> {
361 Ok(self.sessions.lock().unwrap().remove(session_id).is_some())
362 }
363 }
364
365 #[async_trait::async_trait]
366 impl Storage for FailingSaveStorage {
367 async fn save_session(&self, _session: &Session) -> std::io::Result<()> {
368 Err(std::io::Error::other("injected save failure"))
369 }
370
371 async fn load_session(&self, _session_id: &str) -> std::io::Result<Option<Session>> {
372 Ok(self.persisted.lock().unwrap().clone())
373 }
374
375 async fn delete_session(&self, _session_id: &str) -> std::io::Result<bool> {
376 Ok(false)
377 }
378 }
379
380 fn test_repo(storage: Arc<dyn Storage>) -> SessionRepository {
381 let cache: SessionCache = Arc::new(dashmap::DashMap::new());
382 let persistence = Arc::new(LockedSessionStore::new(storage.clone()));
383 SessionRepository::new(cache, storage, persistence)
384 }
385
386 fn cache_put(repo: &SessionRepository, session: &Session) {
387 repo.cache().insert(
388 session.id.clone(),
389 Arc::new(parking_lot::RwLock::new(session.clone())),
390 );
391 }
392
393 #[tokio::test]
394 async fn narrow_runtime_metadata_transaction_preserves_live_and_durable_non_owned_state() {
395 let storage: Arc<dyn Storage> = Arc::new(MapStorage::default());
396 let repo = test_repo(storage.clone());
397 let id = "narrow-metadata";
398 let mut durable = Session::new(id, "durable-model");
399 durable.add_message(bamboo_agent_core::Message::user("durable user turn"));
400 durable
401 .metadata
402 .insert("external.durable".to_string(), "keep".to_string());
403 storage.save_session(&durable).await.expect("seed durable");
404
405 let mut live = durable.clone();
406 live.add_message(bamboo_agent_core::Message::assistant(
407 "in-flight assistant tool call",
408 None,
409 ));
410 live.model = "live-model".to_string();
411 live.metadata
412 .insert("external.live".to_string(), "keep".to_string());
413 cache_put(&repo, &live);
414
415 repo.update_runtime_session(id, &["workflow.owned"], |latest| {
416 latest
417 .metadata
418 .insert("workflow.owned".to_string(), "active".to_string());
419 })
420 .await
421 .expect("transaction")
422 .expect("session exists");
423
424 let saved = storage
425 .load_session(id)
426 .await
427 .expect("load durable")
428 .expect("durable exists");
429 assert_eq!(
430 saved.messages.len(),
431 1,
432 "transaction never writes stale live messages"
433 );
434 assert_eq!(
435 saved.metadata.get("external.durable").map(String::as_str),
436 Some("keep")
437 );
438 assert_eq!(
439 saved.metadata.get("workflow.owned").map(String::as_str),
440 Some("active")
441 );
442
443 let cached = read_cached_session(repo.cache(), id).expect("live cache");
444 assert_eq!(
445 cached.messages.len(),
446 2,
447 "cache live tool call is not replaced"
448 );
449 assert_eq!(cached.model, "live-model");
450 assert_eq!(
451 cached.metadata.get("external.live").map(String::as_str),
452 Some("keep")
453 );
454 assert_eq!(
455 cached.metadata.get("workflow.owned").map(String::as_str),
456 Some("active")
457 );
458 }
459
460 #[tokio::test]
465 async fn load_merged_does_not_regress_to_older_storage() {
466 let storage: Arc<dyn Storage> = Arc::new(MapStorage::default());
467 let repo = test_repo(storage.clone());
468 let id = "s1";
469
470 let mut stale = Session::new(id.to_string(), "m");
471 stale.set_pending_question(
472 "tc1".into(),
473 "kind".into(),
474 "q?".into(),
475 vec!["OK".into()],
476 true,
477 );
478 stale.updated_at = Utc::now() - chrono::Duration::seconds(10);
479 storage.save_session(&stale).await.unwrap();
480
481 let mut fresh = Session::new(id.to_string(), "m");
482 fresh.updated_at = Utc::now();
483 cache_put(&repo, &fresh);
484
485 let merged = repo.load_merged(id).await.expect("session exists");
486 assert!(
487 merged.pending_question.is_none(),
488 "must return the newer answered memory copy, not the stale storage one"
489 );
490 let cached = read_cached_session(repo.cache(), id).expect("cached");
491 assert!(
492 cached.pending_question.is_none(),
493 "load_merged must never regress the cache to a stale storage copy"
494 );
495 }
496
497 #[tokio::test]
501 async fn load_merged_recovers_pending_question_from_same_age_storage() {
502 let storage: Arc<dyn Storage> = Arc::new(MapStorage::default());
503 let repo = test_repo(storage.clone());
504 let id = "s2";
505 let ts = Utc::now();
506
507 let mut with_pending = Session::new(id.to_string(), "m");
508 with_pending.set_pending_question(
509 "tc".into(),
510 "k".into(),
511 "q".into(),
512 vec!["OK".into()],
513 true,
514 );
515 with_pending.updated_at = ts;
516 storage.save_session(&with_pending).await.unwrap();
517
518 let mut lost = with_pending.clone();
519 lost.clear_pending_question();
520 lost.updated_at = ts;
521 cache_put(&repo, &lost);
522
523 let merged = repo.load_merged(id).await.expect("session exists");
524 assert!(
525 merged.pending_question.is_some(),
526 "same-age storage carrying a pending question must still be recovered"
527 );
528 }
529
530 #[tokio::test]
531 async fn runtime_publish_refreshes_cache_even_when_storage_fails() {
532 let id = "runtime-selection";
533 let mut previous = Session::new(id.to_string(), "m");
534 previous.metadata.insert(
535 "skill_runtime_selected_skill_ids".to_string(),
536 "[\"plan\"]".to_string(),
537 );
538 let storage: Arc<dyn Storage> = Arc::new(FailingSaveStorage {
539 persisted: Mutex::new(Some(previous.clone())),
540 });
541 let repo = test_repo(storage.clone());
542 cache_put(&repo, &previous);
543
544 let mut current = previous.clone();
545 current.metadata.insert(
546 "skill_runtime_selected_skill_ids".to_string(),
547 "[\"review\"]".to_string(),
548 );
549 current.updated_at = Utc::now();
550
551 let result =
552 bamboo_domain::RuntimeSessionPersistence::save_runtime_session(&repo, &mut current)
553 .await;
554 assert!(result.is_err(), "durable failure must still be surfaced");
555
556 let cached = repo.load(id).await.expect("cached current session");
557 assert_eq!(
558 cached
559 .metadata
560 .get("skill_runtime_selected_skill_ids")
561 .map(String::as_str),
562 Some("[\"review\"]")
563 );
564 let allowlist = bamboo_skills::access_control::extract_skill_allowlist(&cached.metadata)
565 .expect("runtime authorization allowlist");
566 assert!(allowlist.contains("review"));
567 assert!(!allowlist.contains("plan"));
568 let durable = storage
569 .load_session(id)
570 .await
571 .expect("load durable state")
572 .expect("previous durable session");
573 assert_eq!(
574 durable
575 .metadata
576 .get("skill_runtime_selected_skill_ids")
577 .map(String::as_str),
578 Some("[\"plan\"]")
579 );
580 }
581
582 #[tokio::test]
583 async fn legacy_clear_uses_durable_cas_and_never_erases_concurrent_append_from_stale_cache() {
584 let storage: Arc<dyn Storage> = Arc::new(MapStorage::default());
585 let repo = test_repo(storage.clone());
586 let id = "legacy-cas-race";
587 let expected = vec![serde_json::json!({"content": "first"})];
588
589 let mut stale_cache = Session::new(id, "m");
590 stale_cache.set_pending_injected_messages(expected.clone());
591 cache_put(&repo, &stale_cache);
592
593 let mut durable = stale_cache.clone();
594 durable.set_pending_injected_messages(vec![
595 serde_json::json!({"content": "first"}),
596 serde_json::json!({"content": "concurrent"}),
597 ]);
598 storage.save_session(&durable).await.unwrap();
599
600 let cleared = bamboo_domain::RuntimeSessionPersistence::clear_legacy_pending_messages(
601 &repo, id, &expected,
602 )
603 .await
604 .unwrap();
605 assert!(!cleared, "the durable compare-and-clear must reject drift");
606 assert_eq!(
607 storage
608 .load_session(id)
609 .await
610 .unwrap()
611 .unwrap()
612 .pending_injected_messages()
613 .unwrap(),
614 durable.pending_injected_messages().unwrap(),
615 "the concurrent durable append must remain intact"
616 );
617 assert_eq!(
618 read_cached_session(repo.cache(), id)
619 .unwrap()
620 .pending_injected_messages()
621 .unwrap(),
622 expected,
623 "a failed CAS must not mutate the existing cache"
624 );
625 }
626
627 #[tokio::test]
628 async fn successful_legacy_clear_refreshes_stale_cache_from_durable_state() {
629 let storage: Arc<dyn Storage> = Arc::new(MapStorage::default());
630 let repo = test_repo(storage.clone());
631 let id = "legacy-cas-success";
632 let expected = vec![serde_json::json!({"content": "first"})];
633
634 let mut stale_cache = Session::new(id, "stale-model");
635 stale_cache.set_pending_injected_messages(expected.clone());
636 cache_put(&repo, &stale_cache);
637
638 let mut durable = Session::new(id, "durable-model");
639 durable.set_pending_injected_messages(expected.clone());
640 durable
641 .metadata
642 .insert("durable-only".to_string(), "keep".to_string());
643 storage.save_session(&durable).await.unwrap();
644
645 assert!(
646 bamboo_domain::RuntimeSessionPersistence::clear_legacy_pending_messages(
647 &repo, id, &expected,
648 )
649 .await
650 .unwrap()
651 );
652 let cached = read_cached_session(repo.cache(), id).unwrap();
653 assert!(!cached.has_pending_injected_messages());
654 assert_eq!(cached.model, "durable-model");
655 assert_eq!(
656 cached.metadata.get("durable-only").map(String::as_str),
657 Some("keep")
658 );
659 }
660}