Skip to main content

bamboo_engine/session_app/metadata/
mod.rs

1//! Authoritative writer for session metadata fields (`title`, `pinned`, …).
2//!
3//! All callers that mutate session metadata MUST go through this service.
4//! Each method follows a fixed pipeline so that title/pinned writes never
5//! diverge in subtle ways (load order, version bump, save semantics, event
6//! shape):
7//!
8//! 1. Trim / validate input (fail-fast before acquiring the lock).
9//! 2. `persistence.acquire_lock(session_id)` — serialise all writes for this
10//!    session so that commit order == publish order.
11//! 3. `storage.load_session(session_id)` — pick up the latest authoritative
12//!    copy from disk (not a runner-held session that may have stale metadata).
13//! 4. Re-check preconditions inside the lock (e.g. `title_generated` for
14//!    `apply_generated_title` when not forced; equality short-circuit for
15//!    setters that would be a no-op).
16//! 5. Mutate the field, bump `title_version` (for title) and always bump
17//!    `metadata_version`, set `updated_at`.
18//! 6. Plain `storage.save_session(&session)` — no merge needed because we
19//!    loaded the latest copy inside the lock and no other writer for this
20//!    session could have interleaved.
21//! 7. Refresh the in-memory cache (`state.sessions`).
22//! 8. Build the corresponding [`AgentEvent`] from the **final persisted
23//!    session** and publish via [`publish_replayable_session_event`].
24//!
25//! ## Authority rules
26//!
27//! - `set_title` / `apply_generated_title`: the only authoritative writers
28//!   for `title`, `title_version`, and `title_generated`. The runtime engine,
29//!   scheduler, and
30//!   tool execution paths are non-authoritative and must stay on
31//!   `merge_save_session` / `merge_save_runtime` without touching `title`
32//!   directly.
33//! - `set_pinned`: the only authoritative writer for `pinned`.
34//! - Only these methods bump `metadata_version`.  Runtime paths never bump it,
35//!   so `merge_save_session` / `merge_save_runtime` can use it as a staleness
36//!   signal for the entire authoritative metadata group.
37
38use bamboo_agent_core::{AgentEvent, Session, TitleSource};
39use chrono::Utc;
40
41use crate::app_context::AgentSessionContext;
42use crate::events::publish_replayable_session_event;
43use crate::model_config_helper::GOLD_CONFIG_METADATA_KEY;
44
45/// Errors returned by [`SessionMetadataService`].
46#[derive(Debug, thiserror::Error)]
47pub enum MetadataError {
48    #[error("session not found: {0}")]
49    NotFound(String),
50    #[error("storage error: {0}")]
51    Storage(String),
52    /// The caller's `If-Match` precondition (expected `metadata_version`) did
53    /// not match the current persisted version — a concurrent write won.
54    #[error("version conflict: expected {expected}, current {current}")]
55    VersionConflict { expected: u64, current: u64 },
56}
57
58/// Enforce an optional `If-Match` precondition against the freshly-loaded
59/// session, inside the per-session lock (so it is race-free against concurrent
60/// authoritative writes). The single `metadata_version` is the session ETag.
61fn ensure_if_match(session: &Session, if_match: Option<u64>) -> Result<(), MetadataError> {
62    if let Some(expected) = if_match {
63        if session.metadata_version != expected {
64            return Err(MetadataError::VersionConflict {
65                expected,
66                current: session.metadata_version,
67            });
68        }
69    }
70    Ok(())
71}
72
73/// Outcome of a metadata mutation.
74///
75/// `None` means the request was a no-op (the field already had the requested
76/// value, or a guard rejected the change). `Some(applied)` means the change
77/// was persisted and an event was published.
78pub type MetadataChange<T> = Option<T>;
79
80pub struct SessionMetadataService;
81
82impl SessionMetadataService {
83    /// Manual rename via PATCH. Always authoritative; always bumps
84    /// `title_version` and `metadata_version`. Returns `Ok(None)` when the
85    /// trimmed input equals the existing finalized title (no event emitted).
86    /// Renaming a pending session to the same visible text still finalizes the
87    /// lifecycle so an in-flight automatic request cannot overwrite it.
88    pub async fn set_title(
89        state: &dyn AgentSessionContext,
90        session_id: &str,
91        new_title: &str,
92        if_match: Option<u64>,
93    ) -> Result<MetadataChange<(String, u64)>, MetadataError> {
94        let trimmed = new_title.trim();
95        if trimmed.is_empty() {
96            return Err(MetadataError::Storage("title cannot be empty".into()));
97        }
98
99        // Lock: serialise all writes for this session.
100        let _guard = state.persistence().acquire_lock(session_id).await;
101
102        let mut session = load_latest(state, session_id).await?;
103        ensure_if_match(&session, if_match)?;
104        if session.title == trimmed && session.title_generated {
105            return Ok(None);
106        }
107
108        session.title = trimmed.to_string();
109        session.title_generated = true;
110        session.title_version = session.title_version.saturating_add(1);
111        session.metadata_version = session.metadata_version.saturating_add(1);
112        session.updated_at = Utc::now();
113
114        state
115            .persistence()
116            .storage()
117            .save_session(&session)
118            .await
119            .map_err(|e| MetadataError::Storage(format!("save_session: {e}")))?;
120        refresh_in_memory_cache(state, session_id, session.clone()).await;
121
122        let event = AgentEvent::SessionTitleUpdated {
123            session_id: session.id.clone(),
124            title: session.title.clone(),
125            title_version: session.title_version,
126            title_generated: session.title_generated,
127            source: TitleSource::Manual,
128            updated_at: session.updated_at,
129        };
130        publish_replayable_session_event(state, session_id, event).await;
131
132        Ok(Some((session.title, session.title_version)))
133    }
134
135    /// Auto/fallback rename produced by the title generator. Aborts (returns
136    /// `Ok(None)`) if the on-disk title lifecycle is already finalized and
137    /// `force` is false — this guards against races where the user renames
138    /// mid-LLM without inferring state from the visible title text.
139    /// On success bumps `title_version` and `metadata_version`, emits with
140    /// the supplied [`TitleSource`].
141    pub async fn apply_generated_title(
142        state: &dyn AgentSessionContext,
143        session_id: &str,
144        candidate: &str,
145        source: TitleSource,
146        force: bool,
147    ) -> Result<MetadataChange<(String, u64)>, MetadataError> {
148        let trimmed = candidate.trim();
149        if trimmed.is_empty() {
150            return Ok(None);
151        }
152
153        // Lock: serialise with any concurrent manual rename.
154        let _guard = state.persistence().acquire_lock(session_id).await;
155
156        let mut session = load_latest(state, session_id).await?;
157        if !force && session.title_generated {
158            return Ok(None);
159        }
160        if session.title == trimmed && session.title_generated {
161            return Ok(None);
162        }
163
164        session.title = trimmed.to_string();
165        session.title_generated = true;
166        session.title_version = session.title_version.saturating_add(1);
167        session.metadata_version = session.metadata_version.saturating_add(1);
168        session.updated_at = Utc::now();
169
170        state
171            .persistence()
172            .storage()
173            .save_session(&session)
174            .await
175            .map_err(|e| MetadataError::Storage(format!("save_session: {e}")))?;
176        refresh_in_memory_cache(state, session_id, session.clone()).await;
177
178        let event = AgentEvent::SessionTitleUpdated {
179            session_id: session.id.clone(),
180            title: session.title.clone(),
181            title_version: session.title_version,
182            title_generated: session.title_generated,
183            source,
184            updated_at: session.updated_at,
185        };
186        publish_replayable_session_event(state, session_id, event).await;
187
188        Ok(Some((session.title, session.title_version)))
189    }
190
191    /// Toggle the `pinned` flag. Returns `Ok(None)` if the requested value
192    /// matches the current state (no event emitted). Bumps `metadata_version`.
193    pub async fn set_pinned(
194        state: &dyn AgentSessionContext,
195        session_id: &str,
196        pinned: bool,
197        if_match: Option<u64>,
198    ) -> Result<MetadataChange<bool>, MetadataError> {
199        // Lock: serialise with runtime saves and other metadata writes.
200        let _guard = state.persistence().acquire_lock(session_id).await;
201
202        let mut session = load_latest(state, session_id).await?;
203        ensure_if_match(&session, if_match)?;
204        if session.pinned == pinned {
205            return Ok(None);
206        }
207
208        session.pinned = pinned;
209        session.metadata_version = session.metadata_version.saturating_add(1);
210        session.updated_at = Utc::now();
211
212        state
213            .persistence()
214            .storage()
215            .save_session(&session)
216            .await
217            .map_err(|e| MetadataError::Storage(format!("save_session: {e}")))?;
218        refresh_in_memory_cache(state, session_id, session.clone()).await;
219
220        let event = AgentEvent::SessionPinnedUpdated {
221            session_id: session.id.clone(),
222            pinned: session.pinned,
223            updated_at: session.updated_at,
224        };
225        publish_replayable_session_event(state, session_id, event).await;
226
227        Ok(Some(pinned))
228    }
229
230    /// Set or clear the session-level Gold configuration JSON.
231    ///
232    /// This is an authoritative session metadata write: it bumps
233    /// `metadata_version` so runtime saves with stale session structs do not
234    /// overwrite the user's current-session Gold settings.
235    pub async fn set_gold_config_json(
236        state: &dyn AgentSessionContext,
237        session_id: &str,
238        gold_config_json: Option<String>,
239        if_match: Option<u64>,
240    ) -> Result<MetadataChange<Option<String>>, MetadataError> {
241        let normalized = gold_config_json.and_then(|value| {
242            let trimmed = value.trim();
243            if trimmed.is_empty() {
244                None
245            } else {
246                Some(trimmed.to_string())
247            }
248        });
249
250        let _guard = state.persistence().acquire_lock(session_id).await;
251        let mut session = load_latest(state, session_id).await?;
252        ensure_if_match(&session, if_match)?;
253        let current = session
254            .metadata
255            .get(GOLD_CONFIG_METADATA_KEY)
256            .map(|value| value.trim().to_string())
257            .filter(|value| !value.is_empty());
258        if current == normalized {
259            return Ok(None);
260        }
261
262        if let Some(value) = normalized.as_ref() {
263            session
264                .metadata
265                .insert(GOLD_CONFIG_METADATA_KEY.to_string(), value.clone());
266        } else {
267            session.metadata.remove(GOLD_CONFIG_METADATA_KEY);
268        }
269        session.metadata_version = session.metadata_version.saturating_add(1);
270        session.updated_at = Utc::now();
271
272        state
273            .persistence()
274            .storage()
275            .save_session(&session)
276            .await
277            .map_err(|e| MetadataError::Storage(format!("save_session: {e}")))?;
278        refresh_in_memory_cache(state, session_id, session).await;
279
280        Ok(Some(normalized))
281    }
282}
283
284/// Load the latest session from persistent storage (bypasses the in-memory
285/// cache). Called while the per-session lock is held.
286async fn load_latest(
287    state: &dyn AgentSessionContext,
288    session_id: &str,
289) -> Result<Session, MetadataError> {
290    state
291        .persistence()
292        .storage()
293        .load_session(session_id)
294        .await
295        .map_err(|e| MetadataError::Storage(format!("load_session: {e}")))?
296        .ok_or_else(|| MetadataError::NotFound(session_id.to_string()))
297}
298
299/// Replace the in-memory cache entry with the freshly persisted session.
300async fn refresh_in_memory_cache(
301    state: &dyn AgentSessionContext,
302    session_id: &str,
303    session: Session,
304) {
305    state.sessions().insert(
306        session_id.to_string(),
307        std::sync::Arc::new(parking_lot::RwLock::new(session)),
308    );
309}