1use std::collections::{HashMap, HashSet};
21use std::io;
22use std::path::{Path, PathBuf};
23
24use base64::Engine;
25use chrono::{DateTime, Utc};
26use serde::{Deserialize, Serialize};
27use tokio::fs;
28use tokio::sync::{Mutex, RwLock};
29use uuid::Uuid;
30
31use bamboo_domain::ProviderModelRef;
32use bamboo_domain::ReasoningEffort;
33use bamboo_domain::{Role, Session, SessionKind, TokenBudgetUsage};
34
35use crate::search_index::{should_index_session, SessionSearchIndex};
36use bamboo_domain::AttachmentReader;
37use bamboo_domain::Storage;
38
39pub(crate) fn other_io_error(message: impl Into<String>) -> io::Error {
40 io::Error::other(message.into())
41}
42
43const RUNTIME_SIDECAR_FILE: &str = "runtime.json";
46
47const TOKEN_USAGE_FILE: &str = "token-usage.jsonl";
50
51const RUNTIME_SIDECAR_MIGRATION_MARKER: &str = ".runtime_sidecar_migrated";
54
55fn runtime_sidecar_snapshot(session: &Session) -> Session {
59 let mut snapshot = session.clone();
60 snapshot.messages.clear();
61 snapshot
62}
63
64fn overlay_runtime_sidecar(main: Session, sidecar: Option<Session>) -> Session {
71 match sidecar {
72 Some(mut side) => {
73 side.messages = main.messages;
74 side
75 }
76 None => main,
77 }
78}
79
80pub(crate) fn validate_session_id(session_id: &str) -> io::Result<()> {
84 if session_id.is_empty()
85 || session_id.contains('/')
86 || session_id.contains('\\')
87 || session_id.contains("..")
88 {
89 return Err(other_io_error(format!("invalid session id: {session_id}")));
90 }
91 Ok(())
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
98pub struct SessionPlacement {
99 pub kind: String,
102 pub host: String,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct SessionIndexEntry {
109 pub id: String,
110 pub kind: SessionKind,
111 pub rel_path: String,
113 pub title: String,
114 #[serde(default)]
115 pub title_version: u64,
116 pub pinned: bool,
117 pub parent_session_id: Option<String>,
118 pub root_session_id: String,
119 pub spawn_depth: u32,
120 #[serde(default)]
121 pub model: String,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub model_ref: Option<ProviderModelRef>,
124 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub reasoning_effort: Option<ReasoningEffort>,
126 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub gold_config_json: Option<String>,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub created_by_schedule_id: Option<String>,
133 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub schedule_run_id: Option<String>,
136 pub created_at: DateTime<Utc>,
137 pub updated_at: DateTime<Utc>,
138 pub last_activity_at: DateTime<Utc>,
139 pub message_count: usize,
140 pub has_attachments: bool,
141 #[serde(default)]
145 pub has_pending_question: bool,
146 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub plan_mode: Option<bamboo_domain::PlanModeState>,
151 #[serde(default)]
155 pub bypass_permissions: bool,
156 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub last_run_status: Option<String>,
160 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub last_run_error: Option<String>,
163 #[serde(default, skip_serializing_if = "Option::is_none")]
168 pub token_usage: Option<TokenBudgetUsage>,
169 #[serde(default, skip_serializing_if = "Option::is_none")]
176 pub subagent_type: Option<String>,
177 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub lifecycle: Option<String>,
182 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub resident_name: Option<String>,
187 #[serde(default, skip_serializing_if = "Option::is_none")]
192 pub placement: Option<SessionPlacement>,
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct SessionsIndex {
197 pub version: u32,
198 pub updated_at: DateTime<Utc>,
199 pub sessions: HashMap<String, SessionIndexEntry>,
200}
201
202impl SessionsIndex {
203 fn empty() -> Self {
204 Self {
205 version: 2,
206 updated_at: Utc::now(),
207 sessions: HashMap::new(),
208 }
209 }
210}
211
212#[derive(Debug)]
213pub struct SessionStoreV2 {
214 bamboo_home_dir: PathBuf,
215 sessions_dir: PathBuf,
216 index_path: PathBuf,
217 search_index: SessionSearchIndex,
218 index: RwLock<SessionsIndex>,
219 write_lock: Mutex<()>,
221}
222
223impl SessionStoreV2 {
224 pub async fn new(bamboo_home_dir: PathBuf) -> io::Result<Self> {
235 let sessions_dir = bamboo_home_dir.join("sessions");
236 let index_path = bamboo_home_dir.join("sessions.json");
237 let search_index = SessionSearchIndex::new(bamboo_home_dir.join("session_search.db"));
238
239 fs::create_dir_all(&sessions_dir).await?;
240 search_index.init().await?;
241
242 let mut needs_rebuild = false;
246 let index = if index_path.exists() {
247 let raw = fs::read_to_string(&index_path).await?;
248 match serde_json::from_str::<SessionsIndex>(&raw) {
249 Ok(index) => index,
250 Err(error) => {
251 let backup_path = bamboo_home_dir.join("sessions.json.bak");
260 match fs::rename(&index_path, &backup_path).await {
261 Ok(()) => tracing::error!(
262 "sessions.json is corrupt ({error}); backed up to {} and rebuilding \
263 the index by scanning the session tree",
264 backup_path.display()
265 ),
266 Err(rename_error) => tracing::error!(
267 "sessions.json is corrupt ({error}); failed to back it up to {} \
268 ({rename_error}); rebuilding the index from disk anyway",
269 backup_path.display()
270 ),
271 }
272 needs_rebuild = true;
273 SessionsIndex::empty()
274 }
275 }
276 } else {
277 let index = SessionsIndex::empty();
278 let tmp = index_path.with_extension(format!("json.tmp.{}", Uuid::new_v4()));
280 fs::write(
281 &tmp,
282 serde_json::to_vec_pretty(&index).map_err(|e| other_io_error(e.to_string()))?,
283 )
284 .await?;
285 atomic_rename(&tmp, &index_path).await?;
286 index
287 };
288
289 let storage = Self {
290 bamboo_home_dir,
291 sessions_dir,
292 index_path,
293 search_index,
294 index: RwLock::new(index),
295 write_lock: Mutex::new(()),
296 };
297
298 if needs_rebuild {
299 storage.rebuild_index_from_disk().await?;
300 }
301
302 Ok(storage)
303 }
304
305 async fn rebuild_index_from_disk(&self) -> io::Result<()> {
327 let mut recovered = 0usize;
328
329 let mut root_dirs = match fs::read_dir(&self.sessions_dir).await {
330 Ok(rd) => rd,
331 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
333 Err(error) => return Err(error),
334 };
335
336 loop {
337 let root_entry = match root_dirs.next_entry().await {
341 Ok(Some(entry)) => entry,
342 Ok(None) => break,
343 Err(error) => {
344 tracing::warn!("index rebuild: error scanning sessions dir: {error}");
345 break;
346 }
347 };
348 if !root_entry
349 .file_type()
350 .await
351 .map(|t| t.is_dir())
352 .unwrap_or(false)
353 {
354 continue;
355 }
356 let Ok(root_id) = root_entry.file_name().into_string() else {
357 continue;
359 };
360
361 if let Some(session) = Self::load_session_from_dir(&root_entry.path(), &root_id).await {
363 let rel_path = Self::root_rel_path(&root_id);
364 match self.upsert_index_from_session(&session, rel_path).await {
365 Ok(()) => recovered += 1,
366 Err(error) => {
367 tracing::warn!("index rebuild: failed to index root {root_id}: {error}")
368 }
369 }
370 }
371
372 let children_dir = root_entry.path().join("children");
374 let mut child_dirs = match fs::read_dir(&children_dir).await {
375 Ok(rd) => rd,
376 Err(error) if error.kind() == io::ErrorKind::NotFound => continue,
377 Err(error) => {
378 tracing::warn!("index rebuild: cannot read children of {root_id}: {error}");
379 continue;
380 }
381 };
382 loop {
383 let child_entry = match child_dirs.next_entry().await {
384 Ok(Some(entry)) => entry,
385 Ok(None) => break,
386 Err(error) => {
387 tracing::warn!(
388 "index rebuild: error scanning children of {root_id}: {error}"
389 );
390 break;
391 }
392 };
393 if !child_entry
394 .file_type()
395 .await
396 .map(|t| t.is_dir())
397 .unwrap_or(false)
398 {
399 continue;
400 }
401 let Ok(child_id) = child_entry.file_name().into_string() else {
402 continue;
403 };
404 if let Some(session) =
405 Self::load_session_from_dir(&child_entry.path(), &child_id).await
406 {
407 let rel_path = Self::child_rel_path(&root_id, &child_id);
408 match self.upsert_index_from_session(&session, rel_path).await {
409 Ok(()) => recovered += 1,
410 Err(error) => tracing::warn!(
411 "index rebuild: failed to index child {child_id}: {error}"
412 ),
413 }
414 }
415 }
416 }
417
418 self.update_index(|_| Ok(())).await?;
422
423 tracing::info!("index rebuild from disk complete: recovered {recovered} session(s)");
424
425 if let Err(error) = self.rebuild_search_index().await {
427 tracing::warn!("index rebuild: failed to rebuild search index: {error}");
428 }
429 Ok(())
430 }
431
432 async fn load_session_from_dir(abs_dir: &Path, id: &str) -> Option<Session> {
442 let raw = match fs::read_to_string(abs_dir.join("session.json")).await {
443 Ok(raw) => raw,
444 Err(error) if error.kind() == io::ErrorKind::NotFound => return None,
445 Err(error) => {
446 tracing::warn!("index rebuild: skipping unreadable session {id}: {error}");
447 return None;
448 }
449 };
450 let main: Session = match serde_json::from_str(&raw) {
451 Ok(session) => session,
452 Err(error) => {
453 tracing::warn!("index rebuild: skipping corrupt session {id}: {error}");
454 return None;
455 }
456 };
457 let sidecar =
458 match Self::read_runtime_sidecar_at(&abs_dir.join(RUNTIME_SIDECAR_FILE), id).await {
459 Ok(sidecar) => sidecar,
460 Err(error) => {
461 tracing::warn!("index rebuild: cannot read runtime sidecar for {id}: {error}");
462 None
463 }
464 };
465 let mut session = overlay_runtime_sidecar(main, sidecar);
466 session.clear_stale_root_token_budget();
467 Some(session)
468 }
469
470 pub fn search_index(&self) -> &SessionSearchIndex {
471 &self.search_index
472 }
473
474 pub fn bamboo_home_dir(&self) -> &Path {
475 &self.bamboo_home_dir
476 }
477
478 pub fn index_path(&self) -> &Path {
479 &self.index_path
480 }
481
482 pub async fn rebuild_search_index(&self) -> io::Result<()> {
483 let session_ids = {
484 let index = self.index.read().await;
485 index.sessions.keys().cloned().collect::<Vec<_>>()
486 };
487 for session_id in session_ids {
488 if let Some(session) = self.load_session(&session_id).await? {
489 if !should_index_session(session.updated_at) {
490 continue;
491 }
492 if let Err(error) = self.search_index.upsert_session(&session).await {
493 tracing::warn!(
494 "failed to rebuild search index entry for {}: {}",
495 session_id,
496 error
497 );
498 }
499 }
500 }
501 Ok(())
502 }
503
504 pub fn sessions_root_dir(&self) -> &Path {
505 &self.sessions_dir
506 }
507
508 fn root_rel_path(session_id: &str) -> String {
509 format!("sessions/{session_id}")
510 }
511
512 fn child_rel_path(root_id: &str, child_id: &str) -> String {
513 format!("sessions/{root_id}/children/{child_id}")
514 }
515
516 fn abs_path_from_rel(&self, rel: &str) -> PathBuf {
517 self.bamboo_home_dir.join(rel)
518 }
519
520 async fn persist_index_locked(&self, index: &SessionsIndex) -> io::Result<()> {
521 let tmp = self
522 .index_path
523 .with_extension(format!("json.tmp.{}", Uuid::new_v4()));
524 let bytes = serde_json::to_vec_pretty(index).map_err(|e| other_io_error(e.to_string()))?;
525 fs::write(&tmp, bytes).await?;
526 atomic_rename(&tmp, &self.index_path).await?;
527 Ok(())
528 }
529
530 async fn update_index<F, T>(&self, f: F) -> io::Result<T>
531 where
532 F: FnOnce(&mut SessionsIndex) -> io::Result<T>,
533 {
534 let _guard = self.write_lock.lock().await;
535 let mut index = self.index.write().await;
536 let out = f(&mut index)?;
537 index.updated_at = Utc::now();
538 self.persist_index_locked(&index).await?;
539 Ok(out)
540 }
541
542 pub async fn list_index_entries(&self) -> Vec<SessionIndexEntry> {
543 let index = self.index.read().await;
544 let mut items: Vec<_> = index.sessions.values().cloned().collect();
545 items.sort_by_key(|b| std::cmp::Reverse(b.updated_at));
546 items
547 }
548
549 pub async fn get_index_entry(&self, session_id: &str) -> Option<SessionIndexEntry> {
550 let index = self.index.read().await;
551 index.sessions.get(session_id).cloned()
552 }
553
554 pub async fn resolve_rel_path(&self, session_id: &str) -> Option<String> {
555 self.get_index_entry(session_id).await.map(|e| e.rel_path)
556 }
557
558 async fn ensure_session_dirs(&self, session: &Session) -> io::Result<String> {
559 validate_session_id(&session.id)?;
560
561 let rel_path = match session.kind {
562 SessionKind::Root => Self::root_rel_path(&session.id),
563 SessionKind::Child => {
564 let root_id = session.root_session_id.trim();
565 let parent_id = session.parent_session_id.as_deref().unwrap_or("").trim();
566 if root_id.is_empty() || parent_id.is_empty() {
567 return Err(other_io_error(
568 "child session missing root_session_id/parent_session_id",
569 ));
570 }
571 validate_session_id(root_id)?;
576 Self::child_rel_path(root_id, &session.id)
577 }
578 };
579
580 let abs_dir = self.abs_path_from_rel(&rel_path);
581 fs::create_dir_all(&abs_dir).await?;
582 fs::create_dir_all(abs_dir.join("attachments")).await?;
584 if session.kind == SessionKind::Root {
585 fs::create_dir_all(abs_dir.join("children")).await?;
586 }
587 Ok(rel_path)
588 }
589
590 async fn session_json_path(&self, session_id: &str) -> io::Result<Option<PathBuf>> {
591 if let Some(rel) = self.resolve_rel_path(session_id).await {
592 Ok(Some(self.abs_path_from_rel(&rel).join("session.json")))
593 } else {
594 Ok(None)
595 }
596 }
597
598 async fn runtime_json_path(&self, session_id: &str) -> io::Result<Option<PathBuf>> {
599 if let Some(rel) = self.resolve_rel_path(session_id).await {
600 Ok(Some(
601 self.abs_path_from_rel(&rel).join(RUNTIME_SIDECAR_FILE),
602 ))
603 } else {
604 Ok(None)
605 }
606 }
607
608 async fn write_runtime_sidecar(&self, abs_dir: &Path, session: &Session) -> io::Result<()> {
612 let path = abs_dir.join(RUNTIME_SIDECAR_FILE);
613 let snapshot = runtime_sidecar_snapshot(session);
614 let tmp = path.with_extension(format!("json.tmp.{}", Uuid::new_v4()));
615 let bytes =
616 serde_json::to_vec_pretty(&snapshot).map_err(|e| other_io_error(e.to_string()))?;
617 fs::write(&tmp, bytes).await?;
618 atomic_rename(&tmp, &path).await?;
619 Ok(())
620 }
621
622 pub async fn migrate_runtime_sidecars(&self) -> io::Result<usize> {
635 let marker = self.bamboo_home_dir.join(RUNTIME_SIDECAR_MIGRATION_MARKER);
636 if fs::try_exists(&marker).await.unwrap_or(false) {
637 return Ok(0);
638 }
639
640 let entries = self.list_index_entries().await;
641 let mut migrated = 0usize;
642 for entry in entries {
643 let abs_dir = self.abs_path_from_rel(&entry.rel_path);
644 let sidecar_path = abs_dir.join(RUNTIME_SIDECAR_FILE);
645 if fs::try_exists(&sidecar_path).await.unwrap_or(false) {
646 continue;
647 }
648 let session_path = abs_dir.join("session.json");
649 let raw = match fs::read_to_string(&session_path).await {
652 Ok(raw) => raw,
653 Err(error) if error.kind() == io::ErrorKind::NotFound => continue,
654 Err(error) => return Err(error),
655 };
656 let session: Session = match serde_json::from_str(&raw) {
657 Ok(session) => session,
658 Err(error) => {
659 tracing::warn!(
660 "runtime sidecar migration: skipping unreadable session {}: {}",
661 entry.id,
662 error
663 );
664 continue;
665 }
666 };
667 self.write_runtime_sidecar(&abs_dir, &session).await?;
668 migrated += 1;
669 }
670
671 let tmp = marker.with_extension(format!("tmp.{}", Uuid::new_v4()));
674 fs::write(&tmp, b"runtime-sidecar-v1\n").await?;
675 atomic_rename(&tmp, &marker).await?;
676
677 if migrated > 0 {
678 tracing::info!("runtime sidecar migration: created {migrated} sidecar(s)");
679 }
680 Ok(migrated)
681 }
682
683 async fn read_runtime_sidecar(&self, session_id: &str) -> io::Result<Option<Session>> {
687 let Some(path) = self.runtime_json_path(session_id).await? else {
688 return Ok(None);
689 };
690 Self::read_runtime_sidecar_at(&path, session_id).await
691 }
692
693 async fn read_runtime_sidecar_at(path: &Path, id: &str) -> io::Result<Option<Session>> {
699 if !path.exists() {
700 return Ok(None);
701 }
702 let raw = fs::read_to_string(path).await?;
703 match serde_json::from_str::<Session>(&raw) {
704 Ok(mut side) => {
705 side.clear_stale_root_token_budget();
708 Ok(Some(side))
709 }
710 Err(error) => {
711 tracing::warn!("ignoring corrupt runtime sidecar for {id}: {error}");
714 Ok(None)
715 }
716 }
717 }
718
719 async fn attachments_dir(&self, session_id: &str) -> io::Result<Option<PathBuf>> {
720 if let Some(rel) = self.resolve_rel_path(session_id).await {
721 Ok(Some(self.abs_path_from_rel(&rel).join("attachments")))
722 } else {
723 Ok(None)
724 }
725 }
726
727 async fn compute_has_attachments(&self, session_id: &str) -> bool {
728 let Ok(Some(dir)) = self.attachments_dir(session_id).await else {
729 return false;
730 };
731 let Ok(mut rd) = fs::read_dir(dir).await else {
732 return false;
733 };
734 rd.next_entry().await.ok().flatten().is_some()
735 }
736
737 async fn upsert_index_from_session(
738 &self,
739 session: &Session,
740 rel_path: String,
741 ) -> io::Result<()> {
742 let has_attachments = self.compute_has_attachments(&session.id).await;
743 let last_run_status = session
746 .last_run_status()
747 .filter(|value| !value.trim().is_empty());
748 let last_run_error = session
749 .last_run_error()
750 .filter(|value| !value.trim().is_empty());
751 let created_by_schedule_id = session
752 .metadata
753 .get("created_by_schedule_id")
754 .cloned()
755 .filter(|v| !v.trim().is_empty());
756 let schedule_run_id = session
757 .metadata
758 .get("schedule_run_id")
759 .cloned()
760 .filter(|v| !v.trim().is_empty());
761 let subagent_type = session.subagent_type().filter(|v| !v.trim().is_empty());
762 let lifecycle = session
763 .metadata
764 .get("lifecycle")
765 .cloned()
766 .filter(|v| !v.trim().is_empty());
767 let resident_name = session
768 .metadata
769 .get("resident_name")
770 .cloned()
771 .filter(|v| !v.trim().is_empty());
772 let gold_config_json = session
773 .metadata
774 .get("gold_config")
775 .cloned()
776 .filter(|v| !v.trim().is_empty());
777 let plan_mode = session
778 .agent_runtime_state
779 .as_ref()
780 .and_then(|state| state.plan_mode.clone());
781 let bypass_permissions = session
782 .agent_runtime_state
783 .as_ref()
784 .is_some_and(|state| state.bypass_permissions);
785 let placement = session
790 .metadata
791 .get("placement")
792 .and_then(|v| serde_json::from_str::<SessionPlacement>(v).ok());
793 self.update_index(|index| {
794 index.sessions.insert(
795 session.id.clone(),
796 SessionIndexEntry {
797 id: session.id.clone(),
798 kind: session.kind,
799 rel_path,
800 title: session.title.clone(),
801 title_version: session.title_version,
802 pinned: session.pinned,
803 parent_session_id: session.parent_session_id.clone(),
804 root_session_id: session.root_session_id.clone(),
805 spawn_depth: session.spawn_depth,
806 model: session.model.clone(),
807 model_ref: session.model_ref.clone(),
808 reasoning_effort: session.reasoning_effort,
809 gold_config_json,
810 created_by_schedule_id,
811 schedule_run_id,
812 created_at: session.created_at,
813 updated_at: session.updated_at,
814 last_activity_at: session.updated_at,
815 message_count: session.messages.len(),
816 has_attachments,
817 has_pending_question: session.has_pending_question(),
818 plan_mode,
819 bypass_permissions,
820 last_run_status,
821 last_run_error,
822 token_usage: session.token_usage.clone(),
823 subagent_type,
824 lifecycle,
825 resident_name,
826 placement,
827 },
828 );
829 Ok(())
830 })
831 .await?;
832 Ok(())
833 }
834
835 pub async fn write_image_attachment(
836 &self,
837 session: &Session,
838 raw_base64_or_data_url: &str,
839 mime_hint: Option<&str>,
840 ) -> io::Result<(String, String)> {
841 let (mime, base64_data) =
842 parse_data_url_base64(raw_base64_or_data_url).unwrap_or_else(|| {
843 (
844 mime_hint.unwrap_or("image/png").trim().to_string(),
845 raw_base64_or_data_url.trim().to_string(),
846 )
847 });
848
849 let bytes = base64::engine::general_purpose::STANDARD
850 .decode(base64_data.as_bytes())
851 .map_err(|e| other_io_error(format!("invalid base64 image data: {e}")))?;
852
853 let attachment_id = Uuid::new_v4().to_string();
854 let ext = mime_to_extension(mime.as_str()).unwrap_or("bin");
855
856 let rel_path = self.ensure_session_dirs(session).await?;
857 let abs_dir = self.abs_path_from_rel(&rel_path);
858 let attachments_dir = abs_dir.join("attachments");
859 fs::create_dir_all(&attachments_dir).await?;
860
861 let path = attachments_dir.join(format!("{attachment_id}.{ext}"));
862 let tmp = path.with_extension(format!("{ext}.tmp.{}", Uuid::new_v4()));
863 fs::write(&tmp, &bytes).await?;
864 atomic_rename(&tmp, &path).await?;
865
866 Ok((
867 attachment_id.clone(),
868 format!("bamboo-attachment://{}/{}", session.id, attachment_id),
869 ))
870 }
871
872 pub async fn read_attachment(
874 &self,
875 session_id: &str,
876 attachment_id: &str,
877 ) -> io::Result<Option<(Vec<u8>, String)>> {
878 validate_session_id(session_id)?;
879 validate_session_id(attachment_id)?;
880 let Some(dir) = self.attachments_dir(session_id).await? else {
881 return Ok(None);
882 };
883 if !dir.exists() {
884 return Ok(None);
885 }
886
887 let mut rd = fs::read_dir(&dir).await?;
888 while let Some(entry) = rd.next_entry().await? {
889 let file_name = entry.file_name();
890 let file_name = file_name.to_string_lossy();
891 if !file_name.starts_with(attachment_id) {
892 continue;
893 }
894 if file_name.len() <= attachment_id.len() + 1
896 || !file_name.as_bytes()[attachment_id.len()].eq(&b'.')
897 {
898 continue;
899 }
900 let ext = file_name.split('.').next_back().unwrap_or("bin");
901 let mime = extension_to_mime(ext).unwrap_or("application/octet-stream");
902 let bytes = fs::read(entry.path()).await?;
903 return Ok(Some((bytes, mime.to_string())));
904 }
905
906 Ok(None)
907 }
908
909 pub async fn clear_session(&self, session_id: &str) -> io::Result<bool> {
910 let Some(mut session) = self.load_session(session_id).await? else {
911 return Ok(false);
912 };
913
914 let system_msg = session
916 .messages
917 .iter()
918 .find(|m| matches!(m.role, Role::System))
919 .cloned();
920 session.messages.clear();
921 if let Some(system) = system_msg {
922 session.messages.push(system);
923 }
924
925 session.token_usage = None;
927 session.conversation_summary = None;
928 session.updated_at = Utc::now();
929
930 if let Ok(Some(dir)) = self.attachments_dir(session_id).await {
932 let _ = fs::remove_dir_all(&dir).await;
933 let _ = fs::create_dir_all(&dir).await;
934 }
935
936 self.save_session(&session).await?;
937 Ok(true)
938 }
939
940 pub async fn cleanup(&self, mode: CleanupMode, keep_pinned: bool) -> io::Result<CleanupResult> {
941 let entries = {
943 self.index
944 .read()
945 .await
946 .sessions
947 .values()
948 .cloned()
949 .collect::<Vec<_>>()
950 };
951
952 let pinned_child_roots: HashSet<String> = if keep_pinned {
953 entries
954 .iter()
955 .filter(|e| e.kind == SessionKind::Child && e.pinned)
956 .filter_map(|e| e.parent_session_id.clone())
957 .collect()
958 } else {
959 HashSet::new()
960 };
961
962 let is_protected = |e: &SessionIndexEntry| -> bool {
964 if !keep_pinned {
965 return false;
966 }
967 if e.pinned {
968 return true;
969 }
970 if e.kind == SessionKind::Root && pinned_child_roots.contains(&e.id) {
972 return true;
973 }
974 false
975 };
976
977 let mut delete_child_ids = HashSet::<String>::new();
979 let mut delete_root_ids = HashSet::<String>::new();
980
981 match mode {
982 CleanupMode::Children => {
983 for e in entries.iter().filter(|e| e.kind == SessionKind::Child) {
984 if is_protected(e) {
985 continue;
986 }
987 delete_child_ids.insert(e.id.clone());
988 }
989 }
990 CleanupMode::All | CleanupMode::Empty => {
991 for root in entries.iter().filter(|e| e.kind == SessionKind::Root) {
993 if is_protected(root) {
994 continue;
995 }
996 if mode == CleanupMode::Empty && root.message_count > 1 {
997 continue;
998 }
999 delete_root_ids.insert(root.id.clone());
1000 }
1001
1002 for child in entries.iter().filter(|e| e.kind == SessionKind::Child) {
1004 if delete_root_ids.contains(&child.root_session_id) {
1005 continue; }
1007 if is_protected(child) {
1008 continue;
1009 }
1010 if mode == CleanupMode::Empty && child.message_count > 1 {
1011 continue;
1012 }
1013 delete_child_ids.insert(child.id.clone());
1014 }
1015 }
1016 }
1017
1018 let mut deleted_ids = HashSet::<String>::new();
1020 for root_id in delete_root_ids.iter() {
1021 for e in entries.iter().filter(|e| e.root_session_id == *root_id) {
1022 deleted_ids.insert(e.id.clone());
1023 }
1024 }
1025 for child_id in delete_child_ids.iter() {
1026 deleted_ids.insert(child_id.clone());
1027 }
1028
1029 for root_id in delete_root_ids.iter() {
1031 let _ = self.delete_session_recursive(root_id, true).await?;
1032 }
1033 for child_id in delete_child_ids.iter() {
1034 let _ = self.delete_session_recursive(child_id, true).await?;
1035 }
1036 let mut deleted_session_ids: Vec<String> = deleted_ids.into_iter().collect();
1037 deleted_session_ids.sort();
1038 Ok(CleanupResult {
1039 deleted_count: deleted_session_ids.len(),
1040 deleted_session_ids,
1041 })
1042 }
1043
1044 pub async fn dev_reset(&self) -> io::Result<()> {
1050 let _guard = self.write_lock.lock().await;
1051
1052 let _ = fs::remove_dir_all(&self.sessions_dir).await;
1054 fs::create_dir_all(&self.sessions_dir).await?;
1055
1056 {
1058 let mut index = self.index.write().await;
1059 *index = SessionsIndex::empty();
1060 self.persist_index_locked(&index).await?;
1061 }
1062
1063 Ok(())
1064 }
1065
1066 pub async fn delete_session_recursive(
1071 &self,
1072 session_id: &str,
1073 force: bool,
1074 ) -> io::Result<bool> {
1075 let entry = self.get_index_entry(session_id).await;
1076 let Some(entry) = entry else {
1077 return Ok(false);
1078 };
1079
1080 if !force && entry.pinned {
1081 return Err(other_io_error(
1082 "refusing to delete pinned session without force",
1083 ));
1084 }
1085
1086 match entry.kind {
1087 SessionKind::Child => {
1088 let abs_dir = self.abs_path_from_rel(&entry.rel_path);
1089 let _ = fs::remove_dir_all(&abs_dir).await;
1090 self.update_index(|index| {
1091 index.sessions.remove(session_id);
1092 Ok(())
1093 })
1094 .await?;
1095 if let Err(error) = self.search_index.delete_session(session_id).await {
1096 tracing::warn!(
1097 "failed to delete session search index row for {}: {}",
1098 session_id,
1099 error
1100 );
1101 }
1102 Ok(true)
1103 }
1104 SessionKind::Root => {
1105 let root_id = entry.id.clone();
1106 let abs_dir = self.abs_path_from_rel(&entry.rel_path);
1107 let _ = fs::remove_dir_all(&abs_dir).await;
1108
1109 let to_remove_ids = {
1110 let index = self.index.read().await;
1111 index
1112 .sessions
1113 .values()
1114 .filter(|e| e.root_session_id == root_id)
1115 .map(|e| e.id.clone())
1116 .collect::<Vec<_>>()
1117 };
1118
1119 self.update_index(|index| {
1120 for id in &to_remove_ids {
1121 index.sessions.remove(id);
1122 }
1123 Ok(())
1124 })
1125 .await?;
1126
1127 for id in to_remove_ids {
1128 if let Err(error) = self.search_index.delete_session(&id).await {
1129 tracing::warn!(
1130 "failed to delete session search index row for {}: {}",
1131 id,
1132 error
1133 );
1134 }
1135 }
1136 Ok(true)
1137 }
1138 }
1139 }
1140}
1141
1142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1143pub enum CleanupMode {
1144 All,
1145 Empty,
1146 Children,
1147}
1148
1149#[derive(Debug, Clone, Serialize, Deserialize)]
1150pub struct CleanupResult {
1151 pub deleted_count: usize,
1152 pub deleted_session_ids: Vec<String>,
1153}
1154
1155pub(crate) async fn atomic_write(path: &Path, bytes: &[u8]) -> io::Result<()> {
1169 use tokio::io::AsyncWriteExt;
1170
1171 let tmp = path.with_extension(format!("tmp.{}", Uuid::new_v4()));
1172 let write_result = async {
1173 let mut file = fs::File::create(&tmp).await?;
1174 file.write_all(bytes).await?;
1175 file.sync_all().await
1177 }
1178 .await;
1179 if let Err(e) = write_result {
1180 let _ = fs::remove_file(&tmp).await;
1181 return Err(e);
1182 }
1183 atomic_rename(&tmp, path).await
1184}
1185
1186async fn atomic_rename(from: &Path, to: &Path) -> io::Result<()> {
1187 match fs::rename(from, to).await {
1189 Ok(()) => Ok(()),
1190 Err(err) => {
1191 if to.exists() {
1192 let _ = fs::remove_file(to).await;
1193 }
1194 fs::rename(from, to).await.map_err(|e| {
1195 other_io_error(format!(
1196 "failed to rename {:?} -> {:?}: {} (original: {})",
1197 from, to, e, err
1198 ))
1199 })
1200 }
1201 }
1202}
1203
1204fn parse_data_url_base64(url: &str) -> Option<(String, String)> {
1205 let trimmed = url.trim();
1207 if !trimmed.starts_with("data:") {
1208 return None;
1209 }
1210 let trimmed = trimmed.strip_prefix("data:")?;
1211 let (header, data) = trimmed.split_once(',')?;
1212 if !header.contains(";base64") {
1213 return None;
1214 }
1215 let mime = header.split(';').next()?.trim().to_string();
1216 Some((mime, data.trim().to_string()))
1217}
1218
1219fn mime_to_extension(mime: &str) -> Option<&'static str> {
1220 match mime.trim().to_ascii_lowercase().as_str() {
1221 "image/png" => Some("png"),
1222 "image/jpeg" => Some("jpg"),
1223 "image/webp" => Some("webp"),
1224 "image/gif" => Some("gif"),
1225 "image/bmp" => Some("bmp"),
1226 _ => None,
1227 }
1228}
1229
1230fn extension_to_mime(ext: &str) -> Option<&'static str> {
1231 match ext.trim().to_ascii_lowercase().as_str() {
1232 "png" => Some("image/png"),
1233 "jpg" | "jpeg" => Some("image/jpeg"),
1234 "webp" => Some("image/webp"),
1235 "gif" => Some("image/gif"),
1236 "bmp" => Some("image/bmp"),
1237 _ => None,
1238 }
1239}
1240
1241#[async_trait::async_trait]
1242impl Storage for SessionStoreV2 {
1243 async fn save_session(&self, session: &Session) -> io::Result<()> {
1244 let rel_path = self.ensure_session_dirs(session).await?;
1245 let abs_dir = self.abs_path_from_rel(&rel_path);
1246 let path = abs_dir.join("session.json");
1247
1248 self.write_runtime_sidecar(&abs_dir, session).await?;
1255
1256 let tmp = path.with_extension(format!("json.tmp.{}", Uuid::new_v4()));
1257 let bytes =
1258 serde_json::to_vec_pretty(session).map_err(|e| other_io_error(e.to_string()))?;
1259 fs::write(&tmp, bytes).await?;
1260 atomic_rename(&tmp, &path).await?;
1261
1262 self.upsert_index_from_session(session, rel_path).await?;
1263 if let Err(error) = self.search_index.upsert_session(session).await {
1264 tracing::warn!(
1265 "failed to update session search index for {}: {}",
1266 session.id,
1267 error
1268 );
1269 }
1270 Ok(())
1271 }
1272
1273 async fn load_session(&self, session_id: &str) -> io::Result<Option<Session>> {
1274 validate_session_id(session_id)?;
1275 let Some(path) = self.session_json_path(session_id).await? else {
1276 return Ok(None);
1277 };
1278 if !path.exists() {
1279 return Ok(None);
1280 }
1281 let raw = fs::read_to_string(path).await?;
1282 let session: Session = serde_json::from_str(&raw)
1283 .map_err(|e| other_io_error(format!("invalid session.json: {e}")))?;
1284 let sidecar = self.read_runtime_sidecar(session_id).await?;
1285 let mut session = overlay_runtime_sidecar(session, sidecar);
1286 session.clear_stale_root_token_budget();
1288 Ok(Some(session))
1289 }
1290
1291 async fn delete_session(&self, session_id: &str) -> io::Result<bool> {
1292 self.delete_session_recursive(session_id, true).await
1294 }
1295
1296 async fn save_runtime_state(&self, session: &Session) -> io::Result<()> {
1297 let Some(rel) = self.resolve_rel_path(&session.id).await else {
1301 return self.save_session(session).await;
1304 };
1305 let abs_dir = self.abs_path_from_rel(&rel);
1306 self.write_runtime_sidecar(&abs_dir, session).await
1307 }
1308
1309 async fn load_runtime_control_plane(&self, session_id: &str) -> io::Result<Option<Session>> {
1310 validate_session_id(session_id)?;
1311 if let Some(side) = self.read_runtime_sidecar(session_id).await? {
1314 return Ok(Some(side));
1315 }
1316 self.load_session(session_id).await
1317 }
1318
1319 async fn list_child_run_statuses(
1320 &self,
1321 parent_session_id: &str,
1322 ) -> io::Result<Vec<(String, Option<String>)>> {
1323 let index = self.index.read().await;
1324 Ok(index
1325 .sessions
1326 .values()
1327 .filter(|entry| {
1328 entry.kind == SessionKind::Child
1329 && entry.parent_session_id.as_deref() == Some(parent_session_id)
1330 })
1331 .map(|entry| (entry.id.clone(), entry.last_run_status.clone()))
1332 .collect())
1333 }
1334
1335 async fn list_sessions_by_run_status(
1336 &self,
1337 status: &str,
1338 ) -> io::Result<Vec<(String, Option<String>)>> {
1339 let index = self.index.read().await;
1340 Ok(index
1341 .sessions
1342 .values()
1343 .filter(|entry| entry.last_run_status.as_deref() == Some(status))
1344 .map(|entry| (entry.id.clone(), entry.parent_session_id.clone()))
1345 .collect())
1346 }
1347
1348 async fn append_token_usage_record(&self, session_id: &str, json_line: &str) -> io::Result<()> {
1349 use tokio::io::AsyncWriteExt;
1350
1351 validate_session_id(session_id)?;
1352 let Some(rel) = self.resolve_rel_path(session_id).await else {
1356 return Ok(());
1357 };
1358 let path = self.abs_path_from_rel(&rel).join(TOKEN_USAGE_FILE);
1359
1360 let mut line = json_line.trim_end_matches('\n').to_string();
1362 line.push('\n');
1363
1364 let mut file = fs::OpenOptions::new()
1365 .create(true)
1366 .append(true)
1367 .open(&path)
1368 .await?;
1369 file.write_all(line.as_bytes()).await?;
1370 file.flush().await?;
1384 Ok(())
1385 }
1386}
1387
1388#[async_trait::async_trait]
1389impl AttachmentReader for SessionStoreV2 {
1390 async fn read_attachment(
1391 &self,
1392 session_id: &str,
1393 attachment_id: &str,
1394 ) -> io::Result<Option<(Vec<u8>, String)>> {
1395 SessionStoreV2::read_attachment(self, session_id, attachment_id).await
1396 }
1397}
1398
1399#[cfg(test)]
1400mod tests {
1401 use super::*;
1402 use std::io;
1403 use tempfile::TempDir;
1404
1405 async fn create_temp_storage() -> io::Result<(SessionStoreV2, TempDir)> {
1406 let temp_dir = TempDir::new().map_err(io::Error::other)?;
1407 let bamboo_home = temp_dir.path().to_path_buf();
1408 let storage = SessionStoreV2::new(bamboo_home).await?;
1409 Ok((storage, temp_dir))
1410 }
1411
1412 #[tokio::test]
1413 async fn test_new_creates_sessions_directory() -> io::Result<()> {
1414 let temp_dir = TempDir::new().map_err(io::Error::other)?;
1415 let bamboo_home = temp_dir.path().to_path_buf();
1416 let sessions_dir = bamboo_home.join("sessions");
1417
1418 assert!(!sessions_dir.exists());
1419 let _storage = SessionStoreV2::new(bamboo_home).await?;
1420 assert!(sessions_dir.exists());
1421
1422 Ok(())
1423 }
1424
1425 #[tokio::test]
1426 async fn root_token_budget_cleared_on_load_child_preserved() -> io::Result<()> {
1427 let (storage, _dir) = create_temp_storage().await?;
1431
1432 let mut root = Session::new("root-1", "m");
1433 root.token_budget = Some(bamboo_domain::TokenBudget::for_model(1000));
1434 storage.save_session(&root).await?;
1435 let loaded = storage.load_session("root-1").await?.expect("root present");
1436 assert!(
1437 loaded.token_budget.is_none(),
1438 "stale Root token_budget must be cleared on load"
1439 );
1440
1441 let parent = Session::new("root-1", "m");
1442 let mut child = Session::new_child_of("child-1", &parent, "m", "c");
1443 child.token_budget = Some(bamboo_domain::TokenBudget::for_model(500));
1444 storage.save_session(&child).await?;
1445 let loaded_child = storage
1446 .load_session("child-1")
1447 .await?
1448 .expect("child present");
1449 assert!(
1450 loaded_child.token_budget.is_some(),
1451 "Child assigned sub-budget must be preserved on load"
1452 );
1453
1454 Ok(())
1455 }
1456
1457 #[tokio::test]
1458 async fn test_new_creates_index_file() -> io::Result<()> {
1459 let temp_dir = TempDir::new().map_err(io::Error::other)?;
1460 let bamboo_home = temp_dir.path().to_path_buf();
1461 let index_path = bamboo_home.join("sessions.json");
1462
1463 assert!(!index_path.exists());
1464 let _storage = SessionStoreV2::new(bamboo_home).await?;
1465 assert!(index_path.exists());
1466
1467 Ok(())
1468 }
1469
1470 use bamboo_domain::session::types::Message;
1473 use bamboo_domain::AgentRuntimeState;
1474
1475 fn session_with_history(id: &str, messages: usize, run_id: &str) -> Session {
1476 let mut s = Session::new(id.to_string(), "test-model".to_string());
1477 for i in 0..messages {
1478 s.add_message(Message::user(format!("msg-{i}")));
1479 }
1480 s.agent_runtime_state = Some(AgentRuntimeState::new(run_id));
1481 s
1482 }
1483
1484 async fn read_session_json_raw(storage: &SessionStoreV2, id: &str) -> String {
1485 let path = storage.session_json_path(id).await.unwrap().unwrap();
1486 tokio::fs::read_to_string(path).await.unwrap()
1487 }
1488
1489 #[tokio::test]
1490 async fn append_token_usage_record_writes_jsonl_in_session_dir() -> io::Result<()> {
1491 let (storage, _t) = create_temp_storage().await?;
1492 let s = session_with_history("tu-1", 1, "run-A");
1493 storage.save_session(&s).await?;
1494
1495 storage
1496 .append_token_usage_record("tu-1", r#"{"round":1,"cache_read_input_tokens":0}"#)
1497 .await?;
1498 storage
1500 .append_token_usage_record("tu-1", "{\"round\":2,\"cache_read_input_tokens\":9000}\n")
1501 .await?;
1502
1503 let rel = storage.resolve_rel_path("tu-1").await.unwrap();
1504 let path = storage.abs_path_from_rel(&rel).join(TOKEN_USAGE_FILE);
1505 assert!(
1506 path.exists(),
1507 "token-usage.jsonl should sit in the session dir"
1508 );
1509
1510 let contents = tokio::fs::read_to_string(&path).await?;
1511 let lines: Vec<&str> = contents.lines().collect();
1512 assert_eq!(
1520 lines.len(),
1521 2,
1522 "one line per appended record; actual token-usage.jsonl = {contents:?}"
1523 );
1524 assert!(lines[0].contains("\"round\":1"));
1525 assert!(lines[1].contains("\"round\":2"));
1526 for line in lines {
1528 serde_json::from_str::<serde_json::Value>(line).expect("each line is valid JSON");
1529 }
1530 Ok(())
1531 }
1532
1533 #[tokio::test]
1534 async fn append_token_usage_record_is_noop_for_unindexed_session() -> io::Result<()> {
1535 let (storage, _t) = create_temp_storage().await?;
1536 storage
1538 .append_token_usage_record("never-saved", r#"{"round":1}"#)
1539 .await?;
1540 assert!(storage.resolve_rel_path("never-saved").await.is_none());
1541 Ok(())
1542 }
1543
1544 #[tokio::test]
1545 async fn save_session_writes_runtime_sidecar() -> io::Result<()> {
1546 let (storage, _t) = create_temp_storage().await?;
1547 let s = session_with_history("sc-1", 2, "run-A");
1548 storage.save_session(&s).await?;
1549
1550 let sidecar_path = storage.runtime_json_path("sc-1").await?.unwrap();
1551 assert!(
1552 sidecar_path.exists(),
1553 "save_session must write runtime.json"
1554 );
1555
1556 let side = storage.read_runtime_sidecar("sc-1").await?.unwrap();
1558 assert!(side.messages.is_empty(), "sidecar messages must be cleared");
1559 assert_eq!(side.agent_runtime_state.as_ref().unwrap().run_id, "run-A");
1560 Ok(())
1561 }
1562
1563 #[tokio::test]
1564 async fn save_runtime_state_does_not_rewrite_session_json_messages() -> io::Result<()> {
1565 let (storage, _t) = create_temp_storage().await?;
1566
1567 let s = session_with_history("sc-2", 3, "run-A");
1569 storage.save_session(&s).await?;
1570 let raw_before = read_session_json_raw(&storage, "sc-2").await;
1571 assert!(raw_before.contains("msg-2"));
1572
1573 let mut s2 = s.clone();
1577 s2.agent_runtime_state = Some(AgentRuntimeState::new("run-B"));
1578 s2.add_message(Message::user("msg-3-should-not-persist"));
1579 storage.save_runtime_state(&s2).await?;
1580
1581 let raw_after = read_session_json_raw(&storage, "sc-2").await;
1582 assert_eq!(
1583 raw_before, raw_after,
1584 "save_runtime_state must not touch session.json"
1585 );
1586
1587 let loaded = storage.load_session("sc-2").await?.unwrap();
1589 assert_eq!(loaded.agent_runtime_state.as_ref().unwrap().run_id, "run-B");
1590 assert_eq!(
1591 loaded.messages.len(),
1592 3,
1593 "runtime-only save must not add a message"
1594 );
1595 Ok(())
1596 }
1597
1598 #[tokio::test]
1599 async fn save_runtime_state_falls_back_to_full_save_when_unpersisted() -> io::Result<()> {
1600 let (storage, _t) = create_temp_storage().await?;
1601 let s = session_with_history("sc-3", 1, "run-A");
1604 storage.save_runtime_state(&s).await?;
1605
1606 let loaded = storage.load_session("sc-3").await?;
1607 assert!(
1608 loaded.is_some(),
1609 "fallback full save must create the session"
1610 );
1611 assert_eq!(loaded.unwrap().messages.len(), 1);
1612 Ok(())
1613 }
1614
1615 #[tokio::test]
1616 async fn corrupt_sidecar_is_ignored_and_session_still_loads() -> io::Result<()> {
1617 let (storage, _t) = create_temp_storage().await?;
1618 let s = session_with_history("sc-4", 2, "run-A");
1619 storage.save_session(&s).await?;
1620
1621 let sidecar_path = storage.runtime_json_path("sc-4").await?.unwrap();
1623 tokio::fs::write(&sidecar_path, b"{ not valid json").await?;
1624
1625 let loaded = storage.load_session("sc-4").await?.unwrap();
1627 assert_eq!(loaded.messages.len(), 2);
1628 assert_eq!(loaded.agent_runtime_state.as_ref().unwrap().run_id, "run-A");
1629 Ok(())
1630 }
1631
1632 #[tokio::test]
1633 async fn corrupt_index_is_backed_up_and_rebuilt_from_disk() -> io::Result<()> {
1634 let temp_dir = TempDir::new().map_err(io::Error::other)?;
1639 let bamboo_home = temp_dir.path().to_path_buf();
1640
1641 {
1643 let storage = SessionStoreV2::new(bamboo_home.clone()).await?;
1644 let root = Session::new("root-1", "m");
1645 storage.save_session(&root).await?;
1646 let child = Session::new_child_of("child-1", &root, "m", "c");
1647 storage.save_session(&child).await?;
1648 }
1649
1650 let index_path = bamboo_home.join("sessions.json");
1652 tokio::fs::write(&index_path, b"{ not valid json").await?;
1653
1654 let recovered = SessionStoreV2::new(bamboo_home.clone()).await?;
1656
1657 assert_eq!(
1660 recovered.resolve_rel_path("root-1").await.as_deref(),
1661 Some("sessions/root-1"),
1662 "root must be recovered with its on-disk rel_path"
1663 );
1664 assert_eq!(
1665 recovered.resolve_rel_path("child-1").await.as_deref(),
1666 Some("sessions/root-1/children/child-1"),
1667 "child must be recovered with its on-disk rel_path"
1668 );
1669 assert!(
1670 recovered.get_index_entry("root-1").await.is_some(),
1671 "root index entry must exist after rebuild"
1672 );
1673 assert!(
1674 recovered.load_session("root-1").await?.is_some(),
1675 "recovered root must load from disk"
1676 );
1677 let loaded_child = recovered
1678 .load_session("child-1")
1679 .await?
1680 .expect("recovered child must load from disk");
1681 assert_eq!(loaded_child.parent_session_id.as_deref(), Some("root-1"));
1682 assert_eq!(loaded_child.root_session_id, "root-1");
1683
1684 assert!(
1687 bamboo_home.join("sessions.json.bak").exists(),
1688 "corrupt sessions.json must be backed up to sessions.json.bak"
1689 );
1690 assert!(
1691 index_path.exists(),
1692 "a fresh sessions.json must be written after rebuild"
1693 );
1694
1695 Ok(())
1696 }
1697
1698 #[tokio::test]
1699 async fn rebuild_overlays_runtime_sidecar_control_plane() -> io::Result<()> {
1700 let temp_dir = TempDir::new().map_err(io::Error::other)?;
1706 let bamboo_home = temp_dir.path().to_path_buf();
1707
1708 {
1709 let storage = SessionStoreV2::new(bamboo_home.clone()).await?;
1710
1711 let mut root = Session::new("rb-overlay", "m");
1713 root.metadata
1714 .insert("last_run_status".into(), "running".into());
1715 storage.save_session(&root).await?;
1716
1717 let mut updated = root.clone();
1720 updated
1721 .metadata
1722 .insert("last_run_status".into(), "completed".into());
1723 storage.save_runtime_state(&updated).await?;
1724
1725 let raw = read_session_json_raw(&storage, "rb-overlay").await;
1727 assert!(
1728 raw.contains("running"),
1729 "session.json must still carry the pre-sidecar status"
1730 );
1731 }
1732
1733 tokio::fs::write(bamboo_home.join("sessions.json"), b"{ not valid json").await?;
1735 let recovered = SessionStoreV2::new(bamboo_home.clone()).await?;
1736
1737 let entry = recovered
1741 .get_index_entry("rb-overlay")
1742 .await
1743 .expect("root recovered into rebuilt index");
1744 assert_eq!(
1745 entry.last_run_status.as_deref(),
1746 Some("completed"),
1747 "rebuild must overlay runtime.json control-plane, not the stale session.json"
1748 );
1749 Ok(())
1750 }
1751
1752 #[tokio::test]
1753 async fn missing_index_starts_empty_and_does_not_back_up() -> io::Result<()> {
1754 let temp_dir = TempDir::new().map_err(io::Error::other)?;
1757 let bamboo_home = temp_dir.path().to_path_buf();
1758
1759 let storage = SessionStoreV2::new(bamboo_home.clone()).await?;
1760 assert!(storage.list_index_entries().await.is_empty());
1761 assert!(
1762 !bamboo_home.join("sessions.json.bak").exists(),
1763 "a missing index must not produce a .bak backup"
1764 );
1765 Ok(())
1766 }
1767
1768 #[tokio::test]
1771 async fn migration_backfills_sidecars_for_legacy_sessions() -> io::Result<()> {
1772 let temp_dir = TempDir::new().map_err(io::Error::other)?;
1773 let bamboo_home = temp_dir.path().to_path_buf();
1774 let storage = SessionStoreV2::new(bamboo_home.clone()).await?;
1775
1776 let a = session_with_history("mig-a", 3, "run-A");
1779 let b = session_with_history("mig-b", 1, "run-B");
1780 storage.save_session(&a).await?;
1781 storage.save_session(&b).await?;
1782 for id in ["mig-a", "mig-b"] {
1783 let sidecar = storage.runtime_json_path(id).await?.unwrap();
1784 tokio::fs::remove_file(&sidecar).await?;
1785 assert!(!sidecar.exists());
1786 }
1787
1788 let migrated = storage.migrate_runtime_sidecars().await?;
1789 assert_eq!(migrated, 2, "both legacy sessions get a sidecar");
1790
1791 for (id, run) in [("mig-a", "run-A"), ("mig-b", "run-B")] {
1793 let side = storage.read_runtime_sidecar(id).await?.unwrap();
1794 assert!(side.messages.is_empty());
1795 assert_eq!(side.agent_runtime_state.as_ref().unwrap().run_id, run);
1796 }
1797 assert_eq!(
1799 storage.load_session("mig-a").await?.unwrap().messages.len(),
1800 3
1801 );
1802
1803 let marker = bamboo_home.join(RUNTIME_SIDECAR_MIGRATION_MARKER);
1805 assert!(marker.exists());
1806 assert_eq!(storage.migrate_runtime_sidecars().await?, 0);
1807 Ok(())
1808 }
1809
1810 #[tokio::test]
1811 async fn migration_is_idempotent_and_skips_existing_sidecars() -> io::Result<()> {
1812 let (storage, _t) = create_temp_storage().await?;
1813 storage
1815 .save_session(&session_with_history("mig-c", 2, "run-C"))
1816 .await?;
1817 let first = storage.migrate_runtime_sidecars().await?;
1818 assert_eq!(first, 0, "session saved in new format needs no migration");
1819 assert_eq!(storage.migrate_runtime_sidecars().await?, 0);
1821 Ok(())
1822 }
1823
1824 #[tokio::test]
1825 async fn migration_drops_legacy_denormalized_children_from_sidecar() -> io::Result<()> {
1826 let (storage, _t) = create_temp_storage().await?;
1830 let mut s = session_with_history("mig-legacy", 1, "run-L");
1831 storage.save_session(&s).await?;
1832
1833 let dir = storage.abs_path_from_rel(&storage.resolve_rel_path("mig-legacy").await.unwrap());
1836 s.agent_runtime_state = Some(AgentRuntimeState::new("run-L"));
1837 let mut value = serde_json::to_value(&s).unwrap();
1838 value["agent_runtime_state"]["children"]["active_ids"] = serde_json::json!(["ghost-child"]);
1839 tokio::fs::write(
1840 dir.join("session.json"),
1841 serde_json::to_vec_pretty(&value).unwrap(),
1842 )
1843 .await?;
1844 tokio::fs::remove_file(storage.runtime_json_path("mig-legacy").await?.unwrap()).await?;
1845
1846 assert_eq!(storage.migrate_runtime_sidecars().await?, 1);
1847
1848 let raw_sidecar =
1849 tokio::fs::read_to_string(storage.runtime_json_path("mig-legacy").await?.unwrap())
1850 .await?;
1851 assert!(
1852 !raw_sidecar.contains("ghost-child") && !raw_sidecar.contains("active_ids"),
1853 "legacy denormalized children must not survive migration: {raw_sidecar}"
1854 );
1855 Ok(())
1856 }
1857
1858 #[tokio::test]
1859 async fn list_child_run_statuses_filters_by_parent_and_reports_status() -> io::Result<()> {
1860 let (storage, _t) = create_temp_storage().await?;
1861
1862 let parent = Session::new("p-root".to_string(), "m".to_string());
1865 storage.save_session(&parent).await?;
1866 let other = Session::new("p-other".to_string(), "m".to_string());
1867 storage.save_session(&other).await?;
1868
1869 let mut c1 = Session::new_child("ch-done", "p-root", "m", "c1");
1870 c1.metadata
1871 .insert("last_run_status".to_string(), "completed".to_string());
1872 storage.save_session(&c1).await?;
1873
1874 let c2 = Session::new_child("ch-pending", "p-root", "m", "c2");
1875 storage.save_session(&c2).await?;
1876
1877 let foreign = Session::new_child("ch-foreign", "p-other", "m", "x");
1878 storage.save_session(&foreign).await?;
1879
1880 let mut got = storage.list_child_run_statuses("p-root").await?;
1881 got.sort_by(|a, b| a.0.cmp(&b.0));
1882 assert_eq!(got.len(), 2, "only p-root's children: {got:?}");
1883 assert_eq!(got[0].0, "ch-done");
1884 assert_eq!(got[0].1.as_deref(), Some("completed"));
1885 assert_eq!(got[1].0, "ch-pending");
1886 assert!(got[1].1.as_deref() != Some("completed"));
1888 Ok(())
1889 }
1890
1891 #[tokio::test]
1892 async fn list_sessions_by_run_status_matches_index_and_reports_parent() -> io::Result<()> {
1893 let (storage, _t) = create_temp_storage().await?;
1894
1895 let mut root = Session::new("r-susp".to_string(), "m".to_string());
1896 root.metadata
1897 .insert("last_run_status".to_string(), "suspended".to_string());
1898 storage.save_session(&root).await?;
1899
1900 let mut child = Session::new_child("ch-run", "r-susp", "m", "c");
1901 child
1902 .metadata
1903 .insert("last_run_status".to_string(), "running".to_string());
1904 storage.save_session(&child).await?;
1905
1906 let mut done = Session::new("r-done".to_string(), "m".to_string());
1907 done.metadata
1908 .insert("last_run_status".to_string(), "completed".to_string());
1909 storage.save_session(&done).await?;
1910
1911 let suspended = storage.list_sessions_by_run_status("suspended").await?;
1912 assert_eq!(suspended, vec![("r-susp".to_string(), None)]);
1913
1914 let running = storage.list_sessions_by_run_status("running").await?;
1915 assert_eq!(
1916 running,
1917 vec![("ch-run".to_string(), Some("r-susp".to_string()))]
1918 );
1919
1920 assert!(storage
1921 .list_sessions_by_run_status("timeout")
1922 .await?
1923 .is_empty());
1924 Ok(())
1925 }
1926
1927 #[tokio::test]
1928 async fn load_runtime_control_plane_reads_sidecar_without_messages() -> io::Result<()> {
1929 let (storage, _t) = create_temp_storage().await?;
1930 let s = session_with_history("sc-5", 5, "run-A");
1931 storage.save_session(&s).await?;
1932
1933 let cp = storage.load_runtime_control_plane("sc-5").await?.unwrap();
1934 assert!(
1935 cp.messages.is_empty(),
1936 "control-plane load must skip the message history"
1937 );
1938 assert_eq!(cp.agent_runtime_state.as_ref().unwrap().run_id, "run-A");
1939 Ok(())
1940 }
1941
1942 #[tokio::test]
1943 async fn test_save_and_load_session() -> io::Result<()> {
1944 let (storage, _temp_dir) = create_temp_storage().await?;
1945 let session = Session::new("session-1", "test-model");
1946
1947 storage.save_session(&session).await?;
1948 let loaded = storage.load_session(&session.id).await?;
1949
1950 assert!(loaded.is_some());
1951 let loaded = loaded.unwrap();
1952 assert_eq!(loaded.id, session.id);
1953 assert_eq!(loaded.model, session.model);
1954
1955 Ok(())
1956 }
1957
1958 #[tokio::test]
1959 async fn test_load_session_returns_none_when_not_found() -> io::Result<()> {
1960 let (storage, _temp_dir) = create_temp_storage().await?;
1961 let loaded = storage.load_session("nonexistent").await?;
1962 assert!(loaded.is_none());
1963 Ok(())
1964 }
1965
1966 #[tokio::test]
1967 async fn nested_grandchild_persists_under_root() -> io::Result<()> {
1968 let (storage, _t) = create_temp_storage().await?;
1973 let root = Session::new("root-1", "m");
1974 storage.save_session(&root).await?;
1975 let child = Session::new_child_of("child-1", &root, "m", "c");
1976 storage.save_session(&child).await?;
1977 let grandchild = Session::new_child_of("gc-1", &child, "m", "g");
1978 storage.save_session(&grandchild).await?;
1979
1980 let loaded = storage.load_session("gc-1").await?.expect("grandchild");
1981 assert_eq!(loaded.parent_session_id.as_deref(), Some("child-1"));
1982 assert_eq!(loaded.root_session_id, "root-1");
1983 assert_eq!(loaded.spawn_depth, 2);
1984
1985 let entry = storage.get_index_entry("gc-1").await.expect("indexed");
1987 assert_eq!(entry.parent_session_id.as_deref(), Some("child-1"));
1988 assert_eq!(entry.root_session_id, "root-1");
1989 Ok(())
1990 }
1991
1992 #[tokio::test]
1993 async fn test_list_index_entries_empty() -> io::Result<()> {
1994 let (storage, _temp_dir) = create_temp_storage().await?;
1995 let entries = storage.list_index_entries().await;
1996 assert!(entries.is_empty());
1997 Ok(())
1998 }
1999
2000 #[tokio::test]
2001 async fn test_list_index_entries_with_sessions() -> io::Result<()> {
2002 let (storage, _temp_dir) = create_temp_storage().await?;
2003
2004 let session1 = Session::new("session-1", "model-1");
2005 let session2 = Session::new("session-2", "model-2");
2006
2007 storage.save_session(&session1).await?;
2008 storage.save_session(&session2).await?;
2009
2010 let entries = storage.list_index_entries().await;
2011 assert_eq!(entries.len(), 2);
2012
2013 Ok(())
2014 }
2015
2016 #[tokio::test]
2017 async fn test_get_index_entry() -> io::Result<()> {
2018 let (storage, _temp_dir) = create_temp_storage().await?;
2019 let session = Session::new("session-1", "test-model");
2020
2021 storage.save_session(&session).await?;
2022
2023 let entry = storage.get_index_entry(&session.id).await;
2024 assert!(entry.is_some());
2025 let entry = entry.unwrap();
2026 assert_eq!(entry.id, session.id);
2027
2028 Ok(())
2029 }
2030
2031 #[tokio::test]
2032 async fn test_get_index_entry_returns_none_when_not_found() -> io::Result<()> {
2033 let (storage, _temp_dir) = create_temp_storage().await?;
2034 let entry = storage.get_index_entry("nonexistent").await;
2035 assert!(entry.is_none());
2036 Ok(())
2037 }
2038
2039 #[tokio::test]
2040 async fn test_delete_session() -> io::Result<()> {
2041 let (storage, _temp_dir) = create_temp_storage().await?;
2042 let session = Session::new("session-1", "test-model");
2043
2044 storage.save_session(&session).await?;
2045 assert!(storage.load_session(&session.id).await?.is_some());
2046
2047 let deleted = storage.delete_session(&session.id).await?;
2048 assert!(deleted);
2049 assert!(storage.load_session(&session.id).await?.is_none());
2050
2051 Ok(())
2052 }
2053
2054 #[tokio::test]
2055 async fn test_delete_session_returns_false_when_not_found() -> io::Result<()> {
2056 let (storage, _temp_dir) = create_temp_storage().await?;
2057 let deleted = storage.delete_session("nonexistent").await?;
2058 assert!(!deleted);
2059 Ok(())
2060 }
2061
2062 #[test]
2063 fn test_validate_session_id_empty() {
2064 assert!(validate_session_id("").is_err());
2065 }
2066
2067 #[test]
2068 fn test_validate_session_id_with_slash() {
2069 assert!(validate_session_id("session/1").is_err());
2070 }
2071
2072 #[test]
2073 fn test_validate_session_id_with_backslash() {
2074 assert!(validate_session_id("session\\1").is_err());
2075 }
2076
2077 #[test]
2078 fn test_validate_session_id_with_double_dot() {
2079 assert!(validate_session_id("session..1").is_err());
2080 }
2081
2082 #[test]
2083 fn test_validate_session_id_valid() {
2084 assert!(validate_session_id("session-123").is_ok());
2085 }
2086
2087 #[test]
2088 fn test_root_rel_path() {
2089 let path = SessionStoreV2::root_rel_path("session-123");
2090 assert_eq!(path, "sessions/session-123");
2091 }
2092
2093 #[test]
2094 fn test_child_rel_path() {
2095 let path = SessionStoreV2::child_rel_path("root-1", "child-2");
2096 assert_eq!(path, "sessions/root-1/children/child-2");
2097 }
2098
2099 #[test]
2100 fn test_mime_to_extension() {
2101 assert_eq!(mime_to_extension("image/png"), Some("png"));
2102 assert_eq!(mime_to_extension("image/jpeg"), Some("jpg"));
2103 assert_eq!(mime_to_extension("image/webp"), Some("webp"));
2104 assert_eq!(mime_to_extension("image/gif"), Some("gif"));
2105 assert_eq!(mime_to_extension("image/bmp"), Some("bmp"));
2106 assert_eq!(mime_to_extension("unknown/type"), None);
2107 }
2108
2109 #[test]
2110 fn test_extension_to_mime() {
2111 assert_eq!(extension_to_mime("png"), Some("image/png"));
2112 assert_eq!(extension_to_mime("jpg"), Some("image/jpeg"));
2113 assert_eq!(extension_to_mime("jpeg"), Some("image/jpeg"));
2114 assert_eq!(extension_to_mime("webp"), Some("image/webp"));
2115 assert_eq!(extension_to_mime("gif"), Some("image/gif"));
2116 assert_eq!(extension_to_mime("bmp"), Some("image/bmp"));
2117 assert_eq!(extension_to_mime("unknown"), None);
2118 }
2119
2120 #[test]
2121 fn test_extension_to_mime_case_insensitive() {
2122 assert_eq!(extension_to_mime("PNG"), Some("image/png"));
2123 assert_eq!(extension_to_mime("JPG"), Some("image/jpeg"));
2124 assert_eq!(extension_to_mime("JPEG"), Some("image/jpeg"));
2125 }
2126
2127 #[test]
2128 fn test_extension_to_mime_with_whitespace() {
2129 assert_eq!(extension_to_mime(" png "), Some("image/png"));
2130 assert_eq!(extension_to_mime("\tjpg\t"), Some("image/jpeg"));
2131 }
2132}