Skip to main content

bamboo_storage/
v2.rs

1//! Session storage V2 (folder-per-session + global index).
2//!
3//! Storage layout under `bamboo_home_dir`:
4//! - `sessions.json` (global index, O(1) session_id -> rel_path)
5//! - `sessions/<root_id>/session.json`
6//! - `sessions/<root_id>/children/<child_id>/session.json`
7//! - `.../attachments/` (files; session.json stores references, never base64)
8//!
9//! Notes:
10//! - This is a greenfield format (no migration). Old on-disk layouts are ignored.
11//! - The global index is a rebuildable cache, not the source of truth. Each
12//!   `session.json` is authoritative; the index only speeds up lookups. A
13//!   *missing* `sessions.json` starts an empty index; a *corrupt/unparseable*
14//!   one is backed up to `sessions.json.bak` and the index is rebuilt by
15//!   scanning `sessions/<root>/[children/<child>/]session.json` (see
16//!   [`SessionStoreV2::rebuild_index_from_disk`]) so a bad index is never
17//!   boot-fatal and never orphans intact sessions. Directory scanning is used
18//!   only for this recovery path, never in hot paths.
19
20use 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
43/// Filename of the runtime control-plane sidecar, stored alongside
44/// `session.json` in each session directory.
45const RUNTIME_SIDECAR_FILE: &str = "runtime.json";
46
47/// Filename of the append-only per-LLM-call token-usage log, stored alongside
48/// `session.json` in each session directory. One JSON line per call.
49const TOKEN_USAGE_FILE: &str = "token-usage.jsonl";
50
51/// Marker (under `bamboo_home_dir`) recording that the one-shot runtime sidecar
52/// migration has completed, so it is skipped on subsequent boots.
53const RUNTIME_SIDECAR_MIGRATION_MARKER: &str = ".runtime_sidecar_migrated";
54
55/// Build the sidecar snapshot: the full session minus its `messages` history.
56/// Every field except `messages` is authoritative in the sidecar; on load the
57/// message history is taken back from `session.json`.
58fn runtime_sidecar_snapshot(session: &Session) -> Session {
59    let mut snapshot = session.clone();
60    snapshot.messages.clear();
61    snapshot
62}
63
64/// Overlay the runtime sidecar onto the session loaded from `session.json`.
65///
66/// The sidecar holds the freshest control-plane (metadata, `agent_runtime_state`,
67/// title group, …) because every save — full or runtime-only — writes it. The
68/// large `messages` history is only ever written by full saves into
69/// `session.json`, so it is preserved from `main`.
70fn 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
80/// Reject a session id that could escape the storage directory (empty, or
81/// containing a path separator or `..`). Shared with [`crate::jsonl`] so every
82/// store applies the same guard. #31.
83pub(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/// Where a session's agent physically runs: the deployment kind plus the host.
95/// Mirrored into the index from `session.metadata["placement"]` so the frontend
96/// can show "which machine this session runs on" without loading session.json.
97#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
98pub struct SessionPlacement {
99    /// Deployment kind: `"local"` (this backend's own host), `"docker"`, or
100    /// `"ssh"` (a remote node the child was deployed to).
101    pub kind: String,
102    /// Host the agent runs on — the backend's hostname for `local`, or the
103    /// target host for a remote/ssh deployment.
104    pub host: String,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct SessionIndexEntry {
109    pub id: String,
110    pub kind: SessionKind,
111    /// Path relative to `bamboo_home_dir` (e.g. "sessions/<id>" or "sessions/<root>/children/<id>").
112    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    /// Raw session-level Gold config JSON mirrored from `session.metadata["gold_config"]`.
127    /// Kept as a string here to avoid making infrastructure depend on bamboo-engine.
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub gold_config_json: Option<String>,
130    /// If the session was created by a schedule, store the schedule id here for fast filtering.
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub created_by_schedule_id: Option<String>,
133    /// If the session was created by a specific schedule run, keep the run id here.
134    #[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    /// Whether the session currently has a pending question awaiting user response.
142    /// Mirrored into the index from `session.has_pending_question()` so the frontend
143    /// can display the question dialog badge without loading session.json.
144    #[serde(default)]
145    pub has_pending_question: bool,
146    /// Active plan mode runtime state mirrored into the index from
147    /// `session.agent_runtime_state.plan_mode`, so lightweight session-list/detail
148    /// APIs can surface plan mode without loading every session.json.
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub plan_mode: Option<bamboo_domain::PlanModeState>,
151    /// Per-session "bypass permissions" toggle mirrored into the index from
152    /// `session.agent_runtime_state.bypass_permissions`, so the session-list API
153    /// can surface it without loading every session.json.
154    #[serde(default)]
155    pub bypass_permissions: bool,
156    /// Last known run status for this session
157    /// ("pending" | "running" | "completed" | "error" | "cancelled" | "skipped").
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub last_run_status: Option<String>,
160    /// Last known terminal error message, if any.
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub last_run_error: Option<String>,
163    /// Last token usage information (updated after each LLM call).
164    ///
165    /// Stored in the global index so the frontend can display token usage without
166    /// loading full session.json for every row.
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub token_usage: Option<TokenBudgetUsage>,
169    /// SubAgent profile id for child sessions spawned by `SubAgent.create`.
170    /// Mirrored into the index from `session.metadata["subagent_type"]` so the
171    /// frontend can render role badges (e.g. "general-purpose", "plan") on the
172    /// child-session list without loading each session.json.
173    /// `None` for root sessions and for legacy children created before this
174    /// field was introduced.
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub subagent_type: Option<String>,
177    /// Child lifecycle: `Some("resident")` for a reusable resident agent (a
178    /// stable session reused for successive tasks); `None`/absent for the
179    /// default one-shot child. Mirrored from `session.metadata["lifecycle"]`.
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub lifecycle: Option<String>,
182    /// For a resident agent, the stable reuse key (scoped to `root_session_id`).
183    /// Mirrored from `session.metadata["resident_name"]`; lets a later
184    /// `SubAgent.create` find and reuse the resident without loading session.json.
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub resident_name: Option<String>,
187    /// Where this session's agent physically runs (deployment kind + host).
188    /// Mirrored from `session.metadata["placement"]`. `None` for legacy rows and
189    /// for local sessions that were never stamped; the session DTO layer defaults
190    /// `None` to the backend's own local host so the frontend always has a value.
191    #[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    /// Serializes on-disk index writes (and any multi-step operations that must be atomic-ish).
220    write_lock: Mutex<()>,
221}
222
223impl SessionStoreV2 {
224    /// Open (or create) the V2 session store rooted at `bamboo_home_dir`.
225    ///
226    /// Loading the global index (`sessions.json`) is fault-tolerant, because it
227    /// is only a cache over the authoritative per-session `session.json` files:
228    /// - **missing** → start with a fresh empty index (normal first boot);
229    /// - **valid** → use it as-is;
230    /// - **corrupt/unparseable** → back it up to `sessions.json.bak`, log an
231    ///   error, start empty, and rebuild the index from disk (see
232    ///   [`Self::rebuild_index_from_disk`]) so a single bad byte can never make
233    ///   the server refuse to boot or orphan intact sessions on disk.
234    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        // A corrupt index must not be boot-fatal: back it up and rebuild from
243        // the on-disk session tree after construction. Only a *corrupt* file
244        // triggers this; a *missing* one keeps the fresh-empty-index path.
245        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                    // Best-effort backup so the corrupt bytes are preserved for
252                    // forensics but no longer block the (about to be rebuilt)
253                    // index. If the backup rename fails we still rebuild — the
254                    // rebuild's fresh persist would overwrite the corrupt file
255                    // anyway, and the session.json files remain untouched.
256                    // NOTE: `fs::rename` clobbers any pre-existing
257                    // `sessions.json.bak` (only the latest corruption is kept) —
258                    // an accepted tradeoff for the recovery path.
259                    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            // Persist immediately so "index is mandatory" holds from boot.
279            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    /// Rebuild the global index by scanning the on-disk session tree.
306    ///
307    /// Called by [`Self::new`] after a corrupt `sessions.json` was backed up and
308    /// replaced with an empty index. The layout is deterministic:
309    /// - `sessions/<root_id>/session.json`
310    /// - `sessions/<root_id>/children/<child_id>/session.json`
311    ///
312    /// so every session is recoverable without the index. Each session is loaded
313    /// from its directory via [`Self::load_session_from_dir`] — which parses
314    /// `session.json` and **overlays the `runtime.json` sidecar exactly like
315    /// [`Storage::load_session`]**, so recovered index entries reflect the
316    /// freshest control-plane (a runtime-only save updates only the sidecar) and
317    /// agree with the FTS index that [`Self::rebuild_search_index`] builds via
318    /// `load_session`. The result is folded back in via
319    /// [`Self::upsert_index_from_session`] with the same `rel_path`
320    /// [`Self::save_session`] would compute — derived from the on-disk directory
321    /// names (the physical location), which is what `abs_path_from_rel` + load
322    /// rely on. A single unreadable/corrupt session is skipped with a warning,
323    /// and directory-level read errors are logged + tolerated (never
324    /// `?`-propagated) so one bad file/dir never re-introduces a boot-fatal
325    /// failure or aborts recovery of the rest.
326    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            // No sessions directory at all — nothing to recover.
332            Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
333            Err(error) => return Err(error),
334        };
335
336        loop {
337            // Directory-iteration errors are tolerated, not `?`-propagated: an
338            // error here would abort the whole rebuild and make `new()` return
339            // Err — the exact boot-fatal failure this rebuild exists to prevent.
340            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                // Non-UTF-8 directory name cannot be a valid session id.
358                continue;
359            };
360
361            // Recover the root session (if its session.json is present + valid).
362            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            // Recover its children (a flat `children/<child_id>/` layer).
373            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        // Re-materialize sessions.json even when nothing was recovered (we may
419        // have renamed the only copy to sessions.json.bak), so the "index file
420        // always exists after boot" invariant holds.
421        self.update_index(|_| Ok(())).await?;
422
423        tracing::info!("index rebuild from disk complete: recovered {recovered} session(s)");
424
425        // Rebuild the FTS index from the freshly recovered sessions.
426        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    /// Load a session from a known on-disk directory during index rebuild,
433    /// mirroring [`Storage::load_session`] but resolving the directory by scan
434    /// (the index is not yet populated during rebuild): parse `session.json`,
435    /// overlay the `runtime.json` sidecar via the shared
436    /// [`Self::read_runtime_sidecar_at`] + [`overlay_runtime_sidecar`] so the
437    /// freshest control-plane wins, then drop a stale Root token_budget. A
438    /// missing `session.json` yields `None` silently; a corrupt/unreadable one is
439    /// skipped with a warning; a sidecar read error degrades to "no sidecar"
440    /// rather than failing recovery. `id` is used only for log context.
441    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                // Nesting is allowed: a child's parent may itself be a child.
572                // All descendants live flat under the tree root's directory
573                // (`child_rel_path` keys on `root_id`, which stays constant for
574                // the whole tree), so depth needs no path change.
575                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        // Ensure expected subdirs (lazy; cheap).
583        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    /// Write the runtime control-plane sidecar: a full session snapshot with the
609    /// (potentially huge) `messages` history cleared. This is what makes
610    /// runtime-only saves O(1) in conversation length.
611    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    /// One-shot migration: create the runtime sidecar (`runtime.json`) for every
623    /// existing session that predates the message/control-plane split.
624    ///
625    /// Loading already tolerates a missing sidecar (it falls back to the embedded
626    /// control-plane in `session.json`), so this is an *optimization* migration,
627    /// not a correctness one — but running it once means the fast runtime-save
628    /// path is in effect immediately for legacy sessions, and the denormalized
629    /// `children` id vectors (now `#[serde(skip)]`) drop out of the sidecar.
630    ///
631    /// Idempotent and cheap on later boots: guarded by a marker file, and any
632    /// session that already has a sidecar is skipped. Returns the number of
633    /// sidecars created.
634    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            // Read session.json directly (not load_session) — there is no sidecar
650            // to overlay yet, and we want the raw embedded control-plane.
651            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        // Persist the marker last, atomically, so an interrupted migration simply
672        // re-runs (it is idempotent) instead of being falsely marked complete.
673        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    /// Read the runtime sidecar (a Session snapshot with empty `messages`), if it
684    /// exists. Returns `None` when the session has no sidecar yet (e.g. legacy
685    /// sessions not yet migrated). Path is resolved through the index.
686    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    /// Read + deserialize a runtime sidecar (`runtime.json`) from a known path.
694    /// A missing file yields `None`; a corrupt one is ignored with a warning
695    /// (the authoritative copy still lives in `session.json`). Shared by
696    /// [`Self::read_runtime_sidecar`] (index-resolved path) and the index
697    /// rebuild (directory-scanned path) so both overlay the sidecar identically.
698    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                // The control-plane path (`load_runtime_control_plane`) returns
706                // this directly, so migrate a stale Root token_budget here too (#230).
707                side.clear_stale_root_token_budget();
708                Ok(Some(side))
709            }
710            Err(error) => {
711                // A corrupt sidecar must never make a session unloadable — the
712                // authoritative copy still lives in session.json. Warn and ignore.
713                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        // Read the well-known runtime keys via the typed accessors, which prefer
744        // `runtime_metadata` and fall back to the legacy `metadata` strings.
745        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        // Placement (which machine the agent runs on) is stamped by the spawn
786        // path into `metadata["placement"]` as a JSON `{kind,host}` object for
787        // remote/deployed children; local sessions leave it unset and the DTO
788        // layer defaults them to this backend's own host.
789        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    /// Read an attachment by id, returning bytes + inferred MIME.
873    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            // Match "<id>.<ext>"
895            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        // Keep only the first System message if present; drop all other messages.
915        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        // Clearing history invalidates derived context state.
926        session.token_usage = None;
927        session.conversation_summary = None;
928        session.updated_at = Utc::now();
929
930        // Remove attachments on disk.
931        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        // All decisions are index-only.
942        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        // Helper to decide whether an entry is protected.
963        let is_protected = |e: &SessionIndexEntry| -> bool {
964            if !keep_pinned {
965                return false;
966            }
967            if e.pinned {
968                return true;
969            }
970            // A root with pinned child cannot be deleted.
971            if e.kind == SessionKind::Root && pinned_child_roots.contains(&e.id) {
972                return true;
973            }
974            false
975        };
976
977        // Determine deletions as a set of session ids (roots and/or children).
978        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                // First decide which roots can be deleted.
992                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 roots we keep, we may still delete some children (e.g., unpinned, or empty).
1003                for child in entries.iter().filter(|e| e.kind == SessionKind::Child) {
1004                    if delete_root_ids.contains(&child.root_session_id) {
1005                        continue; // will be deleted with root.
1006                    }
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        // Pre-compute full deleted id set for a truthful response payload.
1019        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        // Apply deletions (roots first; they delete children implicitly).
1030        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    /// Development-only: hard reset all sessions and the index.
1045    ///
1046    /// This is the supported "greenfield" mechanism. It deletes:
1047    /// - `bamboo_home_dir/sessions/`
1048    /// - `bamboo_home_dir/sessions.json` (rewritten to empty index)
1049    pub async fn dev_reset(&self) -> io::Result<()> {
1050        let _guard = self.write_lock.lock().await;
1051
1052        // Remove the sessions directory entirely.
1053        let _ = fs::remove_dir_all(&self.sessions_dir).await;
1054        fs::create_dir_all(&self.sessions_dir).await?;
1055
1056        // Reset in-memory index and persist.
1057        {
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    /// Delete a session. If the session is a root, deletes its entire directory (and all child sessions).
1067    /// If the session is a child, deletes only that child directory.
1068    ///
1069    /// `force=true` ignores pinned protection; callers must enforce confirmations at the API/UI layer.
1070    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
1155/// Atomically write `bytes` to `path`: write a uniquely-named temp file in the
1156/// same directory, fsync it to durable storage, then atomically rename over the
1157/// target. A crash (OOM, panic, power loss) mid-write can therefore never leave
1158/// `path` truncated or half-written — a reader sees either the old content or the
1159/// complete new content, never a torn write. The temp is cleaned up on a write
1160/// failure. Shared by the persistence layers (vs. a plain `fs::write` overwrite).
1161/// #35.
1162///
1163/// Residuals (tracked in #166): the rename + parent directory are not fsync'd, so
1164/// after a power loss the file may revert to the OLD complete content (still never
1165/// torn); a crash BETWEEN temp-create and rename leaks an orphan `*.tmp.*` (disk
1166/// litter, not corruption — no sweep yet); and [`atomic_rename`] is
1167/// remove-then-rename on Windows, where a crash in that window can lose the target.
1168pub(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        // fsync so the bytes are durable before the rename publishes them.
1176        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    // Best-effort atomic on Unix. On Windows, rename cannot overwrite.
1188    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    // data:<mime>;base64,<data...>
1206    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        // Refresh the runtime sidecar BEFORE session.json. If the process
1249        // crashes between the two writes, the sidecar then carries a
1250        // control-plane that is at least as fresh as session.json, and the
1251        // load-time overlay (sidecar wins for non-message fields) stays correct.
1252        // Writing session.json first could leave a stale sidecar that silently
1253        // reverts the just-saved control-plane on the next load.
1254        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        // Drop a stale pre-#180 Root token_budget cache so it re-resolves (#230).
1287        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        // Historical API deletes sessions. In V2, treat this as recursive and forced.
1293        self.delete_session_recursive(session_id, true).await
1294    }
1295
1296    async fn save_runtime_state(&self, session: &Session) -> io::Result<()> {
1297        // Fast path: write ONLY the small runtime sidecar (no messages), leaving
1298        // session.json — which carries the full conversation history — untouched.
1299        // This is O(1) in conversation length, unlike `save_session`.
1300        let Some(rel) = self.resolve_rel_path(&session.id).await else {
1301            // Session was never fully persisted yet — fall back to a full save so
1302            // session.json and the index get created.
1303            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        // Prefer the sidecar (cheap: no messages). Fall back to a full load for
1312        // sessions that predate the sidecar (not yet migrated).
1313        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        // Resolve the session's own directory. If it isn't indexed yet (no
1353        // initial save has happened), skip silently — this is an analysis
1354        // sidecar, never authoritative state.
1355        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        // Exactly one line per record, regardless of how the caller framed it.
1361        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        // `flush` is LOAD-BEARING, not cosmetic (issues #378/#486):
1371        // `tokio::fs::File::write_all` only copies the bytes into the File's
1372        // internal buffer and schedules the actual OS write on the blocking
1373        // thread pool — it does NOT wait for it. Dropping the File does not
1374        // wait either (the write still happens "eventually" on the pool, and
1375        // any error is silently discarded). So without this flush a caller
1376        // that appends and then promptly reads the file back — exactly what
1377        // `append_token_usage_record_writes_jsonl_in_session_dir` does — can
1378        // observe the file BEFORE a still-in-flight append lands, which on a
1379        // loaded CI runner (saturated blocking pool) manifested as the
1380        // one-off "1 line instead of 2 / lost second append" failure.
1381        // `flush().await` drives the pending background write to completion
1382        // (and surfaces its error) before we return.
1383        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        // #230: a Root persisted with a token_budget (pre-#180 stale cache) loads
1428        // with token_budget == None so it re-resolves; a Child's assigned
1429        // sub-budget survives the reload.
1430        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    // ── Runtime sidecar (③) ───────────────────────────────────────────────
1471
1472    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        // A trailing newline in the caller's line must not produce a blank line.
1499        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        // Storage is a unique per-test TempDir and every path resolves off the
1513        // instance `bamboo_home_dir` (not a process-global), so nothing outside
1514        // this test can write here — exactly two sequential appends ⇒ two lines.
1515        // If CI ever trips this again (#378), dump the file so the failure is
1516        // diagnosable (extra line + its source, or a lost append) instead of an
1517        // opaque count mismatch. Do NOT relax this to "records present": that
1518        // would mask a real double-write / lost-write regression.
1519        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        // Each line is valid standalone JSON.
1527        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        // No save_session → not indexed yet. Must not error, must not create a file.
1537        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        // Sidecar must NOT carry the message history.
1557        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        // Full save: 3 messages + run-A.
1568        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        // Runtime-only save: bump control-plane to run-B AND (deviously) add a
1574        // 4th in-memory message. The sidecar must persist run-B but IGNORE the
1575        // message, and session.json must be left byte-identical.
1576        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        // Load overlays the sidecar: run-B control-plane + original 3 messages.
1588        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        // Session was never saved: no index entry, no dir. save_runtime_state
1602        // must fall back to a full save so session.json + index get created.
1603        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        // Corrupt the sidecar.
1622        let sidecar_path = storage.runtime_json_path("sc-4").await?.unwrap();
1623        tokio::fs::write(&sidecar_path, b"{ not valid json").await?;
1624
1625        // Session still loads from session.json; corrupt sidecar is ignored.
1626        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        // #342: a corrupt sessions.json must NOT be boot-fatal. On construction
1635        // the store backs it up to sessions.json.bak, rebuilds the index by
1636        // scanning the on-disk session tree, and every intact session.json (root
1637        // AND child) becomes reachable again.
1638        let temp_dir = TempDir::new().map_err(io::Error::other)?;
1639        let bamboo_home = temp_dir.path().to_path_buf();
1640
1641        // Persist a root and a child under it, then drop the store.
1642        {
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        // Corrupt the global index (truncated / invalid JSON).
1651        let index_path = bamboo_home.join("sessions.json");
1652        tokio::fs::write(&index_path, b"{ not valid json").await?;
1653
1654        // (a) Re-opening on the same dir must SUCCEED, not hard-error.
1655        let recovered = SessionStoreV2::new(bamboo_home.clone()).await?;
1656
1657        // (b) Both sessions are indexed again, with the correct rel_paths, so
1658        // they actually resolve + load from disk.
1659        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        // (c) The corrupt index was preserved as sessions.json.bak, and a fresh
1685        // valid sessions.json was re-materialized.
1686        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        // #342 review: rebuild must overlay runtime.json (the freshest
1701        // control-plane) on top of session.json, exactly like load_session.
1702        // A runtime-only save updates ONLY the sidecar, so a session that
1703        // completed that way must be recovered as "completed", not the stale
1704        // "running" still baked into session.json.
1705        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            // Full save: session.json + sidecar both carry "running".
1712            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            // Runtime-only save: bump ONLY the sidecar to "completed".
1718            // session.json is left byte-identical (still "running").
1719            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            // Sanity: session.json on disk still carries the stale status.
1726            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        // Corrupt the index, then reopen → triggers rebuild-from-disk.
1734        tokio::fs::write(bamboo_home.join("sessions.json"), b"{ not valid json").await?;
1735        let recovered = SessionStoreV2::new(bamboo_home.clone()).await?;
1736
1737        // The rebuilt index entry must reflect the SIDECAR's fresh "completed",
1738        // NOT session.json's stale "running". Without the overlay fix the rebuild
1739        // reads session.json only and this is "running", so the test fails.
1740        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        // A *missing* sessions.json keeps the fresh-empty-index behavior: no
1755        // rebuild is triggered and no sessions.json.bak is produced.
1756        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    // ── ⑤ Runtime sidecar migration ──────────────────────────────────────
1769
1770    #[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        // Persist two sessions, then delete their sidecars to simulate the
1777        // legacy on-disk layout (session.json only).
1778        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        // Sidecars now exist and carry the control-plane (no messages).
1792        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        // Full load still returns the messages from session.json.
1798        assert_eq!(
1799            storage.load_session("mig-a").await?.unwrap().messages.len(),
1800            3
1801        );
1802
1803        // Marker written; a second run is a no-op.
1804        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        // Fresh save already writes a sidecar — migration must not double-count it.
1814        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        // And a re-run remains a no-op.
1820        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        // A legacy session.json whose embedded runtime state still carries the
1827        // old denormalized children id vectors. After migration the sidecar must
1828        // not contain them (they are now derived from the index).
1829        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        // Hand-write a legacy session.json containing children.active_ids and
1834        // remove the sidecar, simulating pre-split on-disk data.
1835        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        // Parent root + two children with distinct statuses, plus an unrelated
1863        // child under a different parent that must NOT appear.
1864        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        // pending child has no terminal status mirrored yet.
1887        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        // Nesting: a grandchild whose parent is itself a child (parent != root)
1969        // must persist (previously rejected with "no nesting") and load back
1970        // with its real parent lineage. All descendants live flat under the
1971        // tree root's directory.
1972        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        // The grandchild is indexed under the tree root, keyed by its real parent.
1986        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}