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