1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum ConfigRole {
13 Champion,
15 Challenger,
17 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#[derive(Debug, Clone)]
46pub struct AgentConfigRow {
47 pub id: ConfigId,
49 pub project_id: ProjectId,
51 pub adapter_id: AdapterId,
53 pub role: ConfigRole,
55 pub fingerprint: u64,
57 pub payload: AgentConfig,
59 pub created_at: DateTime<Utc>,
61}
62
63#[derive(Debug, Clone)]
65pub struct AgentConfigRepo<'a> {
66 storage: &'a Storage,
67}
68
69impl<'a> AgentConfigRepo<'a> {
70 pub fn new(storage: &'a Storage) -> Self {
72 Self { storage }
73 }
74
75 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) .bind(payload_json)
89 .bind(row.created_at.to_rfc3339())
90 .execute(self.storage.pool())
91 .await?;
92 Ok(())
93 }
94
95 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 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}