Skip to main content

evolve_storage/
sessions.rs

1//! Repository for the `sessions` table.
2
3use crate::error::StorageError;
4use crate::pool::Storage;
5use chrono::{DateTime, Utc};
6use evolve_core::ids::{ConfigId, ExperimentId, ProjectId, SessionId};
7use uuid::Uuid;
8
9/// Which variant was active when this session ran.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum SessionVariant {
12    /// The project's champion config was applied.
13    Champion,
14    /// The active experiment's challenger config was applied.
15    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/// One recorded user session.
40#[derive(Debug, Clone)]
41pub struct Session {
42    /// Session identity.
43    pub id: SessionId,
44    /// Owning project.
45    pub project_id: ProjectId,
46    /// Experiment active when the session started, if any.
47    pub experiment_id: Option<ExperimentId>,
48    /// Which variant was deployed for this session.
49    pub variant: SessionVariant,
50    /// The exact config row that was active.
51    pub config_id: ConfigId,
52    /// Start time.
53    pub started_at: DateTime<Utc>,
54    /// End time.
55    pub ended_at: DateTime<Utc>,
56    /// Opaque adapter-specific reference (e.g., transcript filename).
57    pub adapter_session_ref: Option<String>,
58}
59
60/// Raw tuple shape returned by `SELECT`s on `sessions`. Factored out to keep
61/// call sites under `clippy::type_complexity`.
62type SessionRow = (
63    String,
64    String,
65    Option<String>,
66    String,
67    String,
68    String,
69    String,
70    Option<String>,
71);
72
73/// Repository for `sessions`.
74#[derive(Debug, Clone)]
75pub struct SessionRepo<'a> {
76    storage: &'a Storage,
77}
78
79impl<'a> SessionRepo<'a> {
80    /// Construct a new repo borrowing the storage handle.
81    pub fn new(storage: &'a Storage) -> Self {
82        Self { storage }
83    }
84
85    /// Insert a new session row.
86    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    /// List most recent sessions for a project (descending by `started_at`).
107    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    /// All sessions belonging to a specific experiment.
128    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}