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