1use crate::error::StorageError;
4use crate::pool::Storage;
5use chrono::{DateTime, Utc};
6use evolve_core::ids::{ConfigId, ExperimentId, ProjectId, SessionId};
7use uuid::Uuid;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
11#[serde(rename_all = "lowercase")]
12pub enum SessionVariant {
13 Champion,
15 Challenger,
17}
18
19impl SessionVariant {
20 fn as_str(self) -> &'static str {
21 match self {
22 Self::Champion => "champion",
23 Self::Challenger => "challenger",
24 }
25 }
26
27 fn from_str(s: &str) -> Result<Self, StorageError> {
28 Ok(match s {
29 "champion" => Self::Champion,
30 "challenger" => Self::Challenger,
31 other => {
32 return Err(StorageError::Sqlx(sqlx::Error::Decode(
33 format!("unknown session variant {other:?}").into(),
34 )));
35 }
36 })
37 }
38}
39
40#[derive(Debug, Clone)]
42pub struct Session {
43 pub id: SessionId,
45 pub project_id: ProjectId,
47 pub experiment_id: Option<ExperimentId>,
49 pub variant: SessionVariant,
51 pub config_id: ConfigId,
53 pub started_at: DateTime<Utc>,
55 pub ended_at: DateTime<Utc>,
57 pub adapter_session_ref: Option<String>,
59}
60
61type SessionRow = (
64 String,
65 String,
66 Option<String>,
67 String,
68 String,
69 String,
70 String,
71 Option<String>,
72);
73
74#[derive(Debug, Clone)]
76pub struct SessionRepo<'a> {
77 storage: &'a Storage,
78}
79
80impl<'a> SessionRepo<'a> {
81 pub fn new(storage: &'a Storage) -> Self {
83 Self { storage }
84 }
85
86 pub async fn insert(&self, s: &Session) -> Result<(), StorageError> {
88 sqlx::query(
89 "INSERT INTO sessions
90 (id, project_id, experiment_id, variant, config_id,
91 started_at, ended_at, adapter_session_ref)
92 VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
93 )
94 .bind(s.id.to_string())
95 .bind(s.project_id.to_string())
96 .bind(s.experiment_id.map(|e| e.to_string()))
97 .bind(s.variant.as_str())
98 .bind(s.config_id.to_string())
99 .bind(s.started_at.to_rfc3339())
100 .bind(s.ended_at.to_rfc3339())
101 .bind(s.adapter_session_ref.as_deref())
102 .execute(self.storage.pool())
103 .await?;
104 Ok(())
105 }
106
107 pub async fn list_recent(
109 &self,
110 project_id: ProjectId,
111 limit: u32,
112 ) -> Result<Vec<Session>, StorageError> {
113 let rows: Vec<SessionRow> = sqlx::query_as(
114 "SELECT id, project_id, experiment_id, variant, config_id,
115 started_at, ended_at, adapter_session_ref
116 FROM sessions
117 WHERE project_id = ?
118 ORDER BY started_at DESC
119 LIMIT ?",
120 )
121 .bind(project_id.to_string())
122 .bind(limit as i64)
123 .fetch_all(self.storage.pool())
124 .await?;
125 rows.into_iter().map(row_to_session).collect()
126 }
127
128 pub async fn list_for_experiment(
130 &self,
131 experiment_id: ExperimentId,
132 ) -> Result<Vec<Session>, StorageError> {
133 let rows: Vec<SessionRow> = sqlx::query_as(
134 "SELECT id, project_id, experiment_id, variant, config_id,
135 started_at, ended_at, adapter_session_ref
136 FROM sessions
137 WHERE experiment_id = ?
138 ORDER BY started_at DESC",
139 )
140 .bind(experiment_id.to_string())
141 .fetch_all(self.storage.pool())
142 .await?;
143 rows.into_iter().map(row_to_session).collect()
144 }
145}
146
147fn row_to_session(
148 (id, project_id, experiment_id, variant, config_id, started_at, ended_at, ref_): SessionRow,
149) -> Result<Session, StorageError> {
150 let parse_ts = |s: &str| {
151 DateTime::parse_from_rfc3339(s)
152 .map(|d| d.with_timezone(&Utc))
153 .map_err(|e| StorageError::Sqlx(sqlx::Error::Decode(Box::new(e))))
154 };
155 Ok(Session {
156 id: SessionId::from_uuid(Uuid::parse_str(&id)?),
157 project_id: ProjectId::from_uuid(Uuid::parse_str(&project_id)?),
158 experiment_id: experiment_id
159 .map(|s| Uuid::parse_str(&s).map(ExperimentId::from_uuid))
160 .transpose()?,
161 variant: SessionVariant::from_str(&variant)?,
162 config_id: ConfigId::from_uuid(Uuid::parse_str(&config_id)?),
163 started_at: parse_ts(&started_at)?,
164 ended_at: parse_ts(&ended_at)?,
165 adapter_session_ref: ref_,
166 })
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172 use crate::agent_configs::{AgentConfigRepo, AgentConfigRow, ConfigRole};
173 use crate::experiments::{Experiment, ExperimentRepo, ExperimentStatus};
174 use crate::projects::{Project, ProjectRepo};
175 use evolve_core::agent_config::AgentConfig;
176 use evolve_core::ids::AdapterId;
177
178 async fn seeded() -> (Storage, ProjectId, ConfigId, ConfigId, ExperimentId) {
179 let storage = Storage::in_memory_for_tests().await.unwrap();
180 let pid = ProjectId::new();
181 ProjectRepo::new(&storage)
182 .insert(&Project {
183 id: pid,
184 adapter_id: AdapterId::new("claude-code"),
185 root_path: "/tmp/sessions-test".into(),
186 name: "s".into(),
187 created_at: Utc::now(),
188 champion_config_id: None,
189 })
190 .await
191 .unwrap();
192
193 let champ = AgentConfigRow {
194 id: ConfigId::new(),
195 project_id: pid,
196 adapter_id: AdapterId::new("claude-code"),
197 role: ConfigRole::Champion,
198 fingerprint: 1,
199 payload: AgentConfig::default_for("claude-code"),
200 created_at: Utc::now(),
201 };
202 let chall = AgentConfigRow {
203 id: ConfigId::new(),
204 role: ConfigRole::Challenger,
205 ..champ.clone()
206 };
207 let cfg = AgentConfigRepo::new(&storage);
208 cfg.insert(&champ).await.unwrap();
209 cfg.insert(&chall).await.unwrap();
210
211 let eid = ExperimentId::new();
212 ExperimentRepo::new(&storage)
213 .insert(&Experiment {
214 id: eid,
215 project_id: pid,
216 champion_config_id: champ.id,
217 challenger_config_id: chall.id,
218 status: ExperimentStatus::Running,
219 traffic_share: 0.1,
220 started_at: Utc::now(),
221 decided_at: None,
222 decision_posterior: None,
223 })
224 .await
225 .unwrap();
226
227 (storage, pid, champ.id, chall.id, eid)
228 }
229
230 #[tokio::test]
231 async fn list_recent_orders_newest_first_and_respects_limit() {
232 let (storage, pid, champ, _chall, _eid) = seeded().await;
233 let repo = SessionRepo::new(&storage);
234
235 for i in 0..5 {
236 let s = Session {
237 id: SessionId::new(),
238 project_id: pid,
239 experiment_id: None,
240 variant: SessionVariant::Champion,
241 config_id: champ,
242 started_at: Utc::now() - chrono::Duration::minutes(i * 10),
243 ended_at: Utc::now() - chrono::Duration::minutes(i * 10)
244 + chrono::Duration::minutes(5),
245 adapter_session_ref: Some(format!("transcript-{i}.jsonl")),
246 };
247 repo.insert(&s).await.unwrap();
248 }
249 let rows = repo.list_recent(pid, 3).await.unwrap();
250 assert_eq!(rows.len(), 3);
251 assert!(rows[0].started_at >= rows[1].started_at);
252 assert!(rows[1].started_at >= rows[2].started_at);
253 }
254
255 #[tokio::test]
256 async fn list_for_experiment_returns_only_tagged_sessions() {
257 let (storage, pid, champ, chall, eid) = seeded().await;
258 let repo = SessionRepo::new(&storage);
259
260 let tagged = Session {
261 id: SessionId::new(),
262 project_id: pid,
263 experiment_id: Some(eid),
264 variant: SessionVariant::Challenger,
265 config_id: chall,
266 started_at: Utc::now(),
267 ended_at: Utc::now(),
268 adapter_session_ref: None,
269 };
270 let untagged = Session {
271 id: SessionId::new(),
272 project_id: pid,
273 experiment_id: None,
274 variant: SessionVariant::Champion,
275 config_id: champ,
276 started_at: Utc::now(),
277 ended_at: Utc::now(),
278 adapter_session_ref: None,
279 };
280 repo.insert(&tagged).await.unwrap();
281 repo.insert(&untagged).await.unwrap();
282
283 let got = repo.list_for_experiment(eid).await.unwrap();
284 assert_eq!(got.len(), 1);
285 assert_eq!(got[0].id, tagged.id);
286 }
287}