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