Skip to main content

evolve_storage/
agent_configs.rs

1//! Repository for the `agent_configs` table.
2
3use crate::error::StorageError;
4use crate::pool::Storage;
5use chrono::{DateTime, Utc};
6use evolve_core::agent_config::AgentConfig;
7use evolve_core::ids::{AdapterId, ConfigId, ProjectId};
8use uuid::Uuid;
9
10/// The role an AgentConfig plays for its project.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum ConfigRole {
13    /// Currently-deployed default.
14    Champion,
15    /// Variant being A/B-tested against the champion.
16    Challenger,
17    /// Retired — kept for the promotion log.
18    Historical,
19}
20
21impl ConfigRole {
22    fn as_str(self) -> &'static str {
23        match self {
24            Self::Champion => "champion",
25            Self::Challenger => "challenger",
26            Self::Historical => "historical",
27        }
28    }
29
30    fn from_str(s: &str) -> Result<Self, StorageError> {
31        Ok(match s {
32            "champion" => Self::Champion,
33            "challenger" => Self::Challenger,
34            "historical" => Self::Historical,
35            other => {
36                return Err(StorageError::Sqlx(sqlx::Error::Decode(
37                    format!("unknown config role {other:?}").into(),
38                )));
39            }
40        })
41    }
42}
43
44/// A stored AgentConfig row.
45#[derive(Debug, Clone)]
46pub struct AgentConfigRow {
47    /// Row id.
48    pub id: ConfigId,
49    /// Owning project.
50    pub project_id: ProjectId,
51    /// Adapter this config targets.
52    pub adapter_id: AdapterId,
53    /// Role at time of insertion (can be promoted/retired later).
54    pub role: ConfigRole,
55    /// Stable hash of the payload (from [`AgentConfig::fingerprint`]).
56    pub fingerprint: u64,
57    /// The config itself.
58    pub payload: AgentConfig,
59    /// When this row was inserted.
60    pub created_at: DateTime<Utc>,
61}
62
63/// Repository for `agent_configs`.
64#[derive(Debug, Clone)]
65pub struct AgentConfigRepo<'a> {
66    storage: &'a Storage,
67}
68
69impl<'a> AgentConfigRepo<'a> {
70    /// Construct a new repo borrowing the storage handle.
71    pub fn new(storage: &'a Storage) -> Self {
72        Self { storage }
73    }
74
75    /// Insert a new config row. Caller owns the id.
76    pub async fn insert(&self, row: &AgentConfigRow) -> Result<(), StorageError> {
77        let payload_json = serde_json::to_string(&row.payload)?;
78        sqlx::query(
79            "INSERT INTO agent_configs
80                (id, project_id, adapter_id, role, fingerprint, payload_json, created_at)
81             VALUES (?, ?, ?, ?, ?, ?, ?)",
82        )
83        .bind(row.id.to_string())
84        .bind(row.project_id.to_string())
85        .bind(row.adapter_id.as_str())
86        .bind(row.role.as_str())
87        .bind(row.fingerprint as i64) // bit-cast u64 -> i64; see Phase 2 design decisions
88        .bind(payload_json)
89        .bind(row.created_at.to_rfc3339())
90        .execute(self.storage.pool())
91        .await?;
92        Ok(())
93    }
94
95    /// Fetch by id.
96    pub async fn get_by_id(&self, id: ConfigId) -> Result<Option<AgentConfigRow>, StorageError> {
97        let row: Option<(String, String, String, String, i64, String, String)> = sqlx::query_as(
98            "SELECT id, project_id, adapter_id, role, fingerprint, payload_json, created_at
99             FROM agent_configs WHERE id = ?",
100        )
101        .bind(id.to_string())
102        .fetch_optional(self.storage.pool())
103        .await?;
104        row.map(row_to_agent_config).transpose()
105    }
106
107    /// Return the most recently created row for `(project, role)`.
108    pub async fn latest_for_project_role(
109        &self,
110        project_id: ProjectId,
111        role: ConfigRole,
112    ) -> Result<Option<AgentConfigRow>, StorageError> {
113        let row: Option<(String, String, String, String, i64, String, String)> = sqlx::query_as(
114            "SELECT id, project_id, adapter_id, role, fingerprint, payload_json, created_at
115             FROM agent_configs
116             WHERE project_id = ? AND role = ?
117             ORDER BY created_at DESC
118             LIMIT 1",
119        )
120        .bind(project_id.to_string())
121        .bind(role.as_str())
122        .fetch_optional(self.storage.pool())
123        .await?;
124        row.map(row_to_agent_config).transpose()
125    }
126}
127
128fn row_to_agent_config(
129    (id, project_id, adapter_id, role, fingerprint, payload_json, created_at): (
130        String,
131        String,
132        String,
133        String,
134        i64,
135        String,
136        String,
137    ),
138) -> Result<AgentConfigRow, StorageError> {
139    Ok(AgentConfigRow {
140        id: ConfigId::from_uuid(Uuid::parse_str(&id)?),
141        project_id: ProjectId::from_uuid(Uuid::parse_str(&project_id)?),
142        adapter_id: AdapterId::new(adapter_id),
143        role: ConfigRole::from_str(&role)?,
144        fingerprint: fingerprint as u64,
145        payload: serde_json::from_str(&payload_json)?,
146        created_at: DateTime::parse_from_rfc3339(&created_at)
147            .map_err(|e| StorageError::Sqlx(sqlx::Error::Decode(Box::new(e))))?
148            .with_timezone(&Utc),
149    })
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use crate::projects::{Project, ProjectRepo};
156
157    async fn seeded_storage() -> (Storage, ProjectId) {
158        let storage = Storage::in_memory_for_tests().await.unwrap();
159        let project = Project {
160            id: ProjectId::new(),
161            adapter_id: AdapterId::new("claude-code"),
162            root_path: "/tmp/agent-config-repo-test".into(),
163            name: "test".into(),
164            created_at: Utc::now(),
165            champion_config_id: None,
166        };
167        ProjectRepo::new(&storage).insert(&project).await.unwrap();
168        let pid = project.id;
169        (storage, pid)
170    }
171
172    fn sample_row(project_id: ProjectId, role: ConfigRole) -> AgentConfigRow {
173        let payload = AgentConfig::default_for("claude-code");
174        AgentConfigRow {
175            id: ConfigId::new(),
176            project_id,
177            adapter_id: AdapterId::new("claude-code"),
178            role,
179            fingerprint: payload.fingerprint(),
180            payload,
181            created_at: Utc::now(),
182        }
183    }
184
185    #[tokio::test]
186    async fn insert_and_get_by_id_roundtrips_full_payload() {
187        let (storage, pid) = seeded_storage().await;
188        let repo = AgentConfigRepo::new(&storage);
189        let row = sample_row(pid, ConfigRole::Champion);
190        repo.insert(&row).await.unwrap();
191        let back = repo.get_by_id(row.id).await.unwrap().unwrap();
192        assert_eq!(back.id, row.id);
193        assert_eq!(back.role, ConfigRole::Champion);
194        assert_eq!(back.fingerprint, row.fingerprint);
195        assert_eq!(back.payload, row.payload);
196    }
197
198    #[tokio::test]
199    async fn latest_for_project_role_returns_most_recent() {
200        let (storage, pid) = seeded_storage().await;
201        let repo = AgentConfigRepo::new(&storage);
202
203        let older = AgentConfigRow {
204            created_at: Utc::now() - chrono::Duration::hours(2),
205            ..sample_row(pid, ConfigRole::Champion)
206        };
207        let newer = sample_row(pid, ConfigRole::Champion);
208        repo.insert(&older).await.unwrap();
209        repo.insert(&newer).await.unwrap();
210
211        let latest = repo
212            .latest_for_project_role(pid, ConfigRole::Champion)
213            .await
214            .unwrap()
215            .unwrap();
216        assert_eq!(latest.id, newer.id);
217    }
218
219    #[tokio::test]
220    async fn latest_for_project_role_returns_none_when_no_rows() {
221        let (storage, pid) = seeded_storage().await;
222        let repo = AgentConfigRepo::new(&storage);
223        assert!(
224            repo.latest_for_project_role(pid, ConfigRole::Challenger)
225                .await
226                .unwrap()
227                .is_none()
228        );
229    }
230
231    #[tokio::test]
232    async fn cascade_delete_removes_configs() {
233        let (storage, pid) = seeded_storage().await;
234        let repo = AgentConfigRepo::new(&storage);
235        let row = sample_row(pid, ConfigRole::Champion);
236        repo.insert(&row).await.unwrap();
237
238        ProjectRepo::new(&storage).delete(pid).await.unwrap();
239        assert!(repo.get_by_id(row.id).await.unwrap().is_none());
240    }
241}