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