bamboo_engine/session_app/metadata/
mod.rs1use 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#[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 #[error("version conflict: expected {expected}, current {current}")]
56 VersionConflict { expected: u64, current: u64 },
57}
58
59fn 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
74pub type MetadataChange<T> = Option<T>;
80
81pub struct SessionMetadataService;
82
83impl SessionMetadataService {
84 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 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 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 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 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 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 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 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
306async 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
321async 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}