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 append_token_usage_record(
283 &self,
284 session_id: &str,
285 json_line: &str,
286 ) -> std::io::Result<()> {
287 self.storage
288 .append_token_usage_record(session_id, json_line)
289 .await
290 }
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296 use bamboo_agent_core::storage::Storage;
297 use chrono::Utc;
298 use std::collections::HashMap;
299 use std::sync::Mutex;
300
301 #[derive(Default)]
302 struct MapStorage {
303 sessions: Mutex<HashMap<String, Session>>,
304 }
305
306 struct FailingSaveStorage {
307 persisted: Mutex<Option<Session>>,
308 }
309
310 #[async_trait::async_trait]
311 impl Storage for MapStorage {
312 async fn save_session(&self, session: &Session) -> std::io::Result<()> {
313 self.sessions
314 .lock()
315 .unwrap()
316 .insert(session.id.clone(), session.clone());
317 Ok(())
318 }
319 async fn load_session(&self, session_id: &str) -> std::io::Result<Option<Session>> {
320 Ok(self.sessions.lock().unwrap().get(session_id).cloned())
321 }
322 async fn delete_session(&self, session_id: &str) -> std::io::Result<bool> {
323 Ok(self.sessions.lock().unwrap().remove(session_id).is_some())
324 }
325 }
326
327 #[async_trait::async_trait]
328 impl Storage for FailingSaveStorage {
329 async fn save_session(&self, _session: &Session) -> std::io::Result<()> {
330 Err(std::io::Error::other("injected save failure"))
331 }
332
333 async fn load_session(&self, _session_id: &str) -> std::io::Result<Option<Session>> {
334 Ok(self.persisted.lock().unwrap().clone())
335 }
336
337 async fn delete_session(&self, _session_id: &str) -> std::io::Result<bool> {
338 Ok(false)
339 }
340 }
341
342 fn test_repo(storage: Arc<dyn Storage>) -> SessionRepository {
343 let cache: SessionCache = Arc::new(dashmap::DashMap::new());
344 let persistence = Arc::new(LockedSessionStore::new(storage.clone()));
345 SessionRepository::new(cache, storage, persistence)
346 }
347
348 fn cache_put(repo: &SessionRepository, session: &Session) {
349 repo.cache().insert(
350 session.id.clone(),
351 Arc::new(parking_lot::RwLock::new(session.clone())),
352 );
353 }
354
355 #[tokio::test]
356 async fn narrow_runtime_metadata_transaction_preserves_live_and_durable_non_owned_state() {
357 let storage: Arc<dyn Storage> = Arc::new(MapStorage::default());
358 let repo = test_repo(storage.clone());
359 let id = "narrow-metadata";
360 let mut durable = Session::new(id, "durable-model");
361 durable.add_message(bamboo_agent_core::Message::user("durable user turn"));
362 durable
363 .metadata
364 .insert("external.durable".to_string(), "keep".to_string());
365 storage.save_session(&durable).await.expect("seed durable");
366
367 let mut live = durable.clone();
368 live.add_message(bamboo_agent_core::Message::assistant(
369 "in-flight assistant tool call",
370 None,
371 ));
372 live.model = "live-model".to_string();
373 live.metadata
374 .insert("external.live".to_string(), "keep".to_string());
375 cache_put(&repo, &live);
376
377 repo.update_runtime_session(id, &["workflow.owned"], |latest| {
378 latest
379 .metadata
380 .insert("workflow.owned".to_string(), "active".to_string());
381 })
382 .await
383 .expect("transaction")
384 .expect("session exists");
385
386 let saved = storage
387 .load_session(id)
388 .await
389 .expect("load durable")
390 .expect("durable exists");
391 assert_eq!(
392 saved.messages.len(),
393 1,
394 "transaction never writes stale live messages"
395 );
396 assert_eq!(
397 saved.metadata.get("external.durable").map(String::as_str),
398 Some("keep")
399 );
400 assert_eq!(
401 saved.metadata.get("workflow.owned").map(String::as_str),
402 Some("active")
403 );
404
405 let cached = read_cached_session(repo.cache(), id).expect("live cache");
406 assert_eq!(
407 cached.messages.len(),
408 2,
409 "cache live tool call is not replaced"
410 );
411 assert_eq!(cached.model, "live-model");
412 assert_eq!(
413 cached.metadata.get("external.live").map(String::as_str),
414 Some("keep")
415 );
416 assert_eq!(
417 cached.metadata.get("workflow.owned").map(String::as_str),
418 Some("active")
419 );
420 }
421
422 #[tokio::test]
427 async fn load_merged_does_not_regress_to_older_storage() {
428 let storage: Arc<dyn Storage> = Arc::new(MapStorage::default());
429 let repo = test_repo(storage.clone());
430 let id = "s1";
431
432 let mut stale = Session::new(id.to_string(), "m");
433 stale.set_pending_question(
434 "tc1".into(),
435 "kind".into(),
436 "q?".into(),
437 vec!["OK".into()],
438 true,
439 );
440 stale.updated_at = Utc::now() - chrono::Duration::seconds(10);
441 storage.save_session(&stale).await.unwrap();
442
443 let mut fresh = Session::new(id.to_string(), "m");
444 fresh.updated_at = Utc::now();
445 cache_put(&repo, &fresh);
446
447 let merged = repo.load_merged(id).await.expect("session exists");
448 assert!(
449 merged.pending_question.is_none(),
450 "must return the newer answered memory copy, not the stale storage one"
451 );
452 let cached = read_cached_session(repo.cache(), id).expect("cached");
453 assert!(
454 cached.pending_question.is_none(),
455 "load_merged must never regress the cache to a stale storage copy"
456 );
457 }
458
459 #[tokio::test]
463 async fn load_merged_recovers_pending_question_from_same_age_storage() {
464 let storage: Arc<dyn Storage> = Arc::new(MapStorage::default());
465 let repo = test_repo(storage.clone());
466 let id = "s2";
467 let ts = Utc::now();
468
469 let mut with_pending = Session::new(id.to_string(), "m");
470 with_pending.set_pending_question(
471 "tc".into(),
472 "k".into(),
473 "q".into(),
474 vec!["OK".into()],
475 true,
476 );
477 with_pending.updated_at = ts;
478 storage.save_session(&with_pending).await.unwrap();
479
480 let mut lost = with_pending.clone();
481 lost.clear_pending_question();
482 lost.updated_at = ts;
483 cache_put(&repo, &lost);
484
485 let merged = repo.load_merged(id).await.expect("session exists");
486 assert!(
487 merged.pending_question.is_some(),
488 "same-age storage carrying a pending question must still be recovered"
489 );
490 }
491
492 #[tokio::test]
493 async fn runtime_publish_refreshes_cache_even_when_storage_fails() {
494 let id = "runtime-selection";
495 let mut previous = Session::new(id.to_string(), "m");
496 previous.metadata.insert(
497 "skill_runtime_selected_skill_ids".to_string(),
498 "[\"plan\"]".to_string(),
499 );
500 let storage: Arc<dyn Storage> = Arc::new(FailingSaveStorage {
501 persisted: Mutex::new(Some(previous.clone())),
502 });
503 let repo = test_repo(storage.clone());
504 cache_put(&repo, &previous);
505
506 let mut current = previous.clone();
507 current.metadata.insert(
508 "skill_runtime_selected_skill_ids".to_string(),
509 "[\"review\"]".to_string(),
510 );
511 current.updated_at = Utc::now();
512
513 let result =
514 bamboo_domain::RuntimeSessionPersistence::save_runtime_session(&repo, &mut current)
515 .await;
516 assert!(result.is_err(), "durable failure must still be surfaced");
517
518 let cached = repo.load(id).await.expect("cached current session");
519 assert_eq!(
520 cached
521 .metadata
522 .get("skill_runtime_selected_skill_ids")
523 .map(String::as_str),
524 Some("[\"review\"]")
525 );
526 let allowlist = bamboo_skills::access_control::extract_skill_allowlist(&cached.metadata)
527 .expect("runtime authorization allowlist");
528 assert!(allowlist.contains("review"));
529 assert!(!allowlist.contains("plan"));
530 let durable = storage
531 .load_session(id)
532 .await
533 .expect("load durable state")
534 .expect("previous durable session");
535 assert_eq!(
536 durable
537 .metadata
538 .get("skill_runtime_selected_skill_ids")
539 .map(String::as_str),
540 Some("[\"plan\"]")
541 );
542 }
543}