1use ag_agent::ReasoningLevel;
4use ag_session::SettingName;
5use async_trait::async_trait;
6use sqlx::SqlitePool;
7
8use crate::DbError;
9
10#[cfg_attr(test, mockall::automock)]
12#[async_trait]
13pub trait SettingRepository: Send + Sync {
14 async fn get_project_setting(
16 &self,
17 project_id: i64,
18 name: SettingName,
19 ) -> Result<Option<String>, DbError>;
20
21 async fn get_setting(&self, name: SettingName) -> Result<Option<String>, DbError>;
23
24 async fn load_active_project_id(&self) -> Result<Option<i64>, DbError>;
26
27 async fn load_project_reasoning_level(
29 &self,
30 project_id: i64,
31 name: SettingName,
32 ) -> Result<ReasoningLevel, DbError>;
33
34 async fn set_active_project_id(&self, project_id: i64) -> Result<(), DbError>;
36
37 async fn upsert_project_setting(
39 &self,
40 project_id: i64,
41 name: SettingName,
42 value: &str,
43 ) -> Result<(), DbError>;
44
45 async fn upsert_project_settings(
47 &self,
48 project_id: i64,
49 settings: Vec<(SettingName, String)>,
50 ) -> Result<(), DbError>;
51
52 async fn upsert_setting(&self, name: SettingName, value: &str) -> Result<(), DbError>;
54}
55
56#[derive(Clone)]
58pub(crate) struct SqliteSettingRepository(SqlitePool);
59
60impl SqliteSettingRepository {
61 pub(crate) fn new(pool: SqlitePool) -> Self {
63 Self(pool)
64 }
65}
66
67struct RequiredSettingValueRow {
69 value: String,
70}
71
72#[async_trait]
73impl SettingRepository for SqliteSettingRepository {
74 async fn get_project_setting(
75 &self,
76 project_id: i64,
77 name: SettingName,
78 ) -> Result<Option<String>, DbError> {
79 let setting_name = name.as_str();
80 let row = sqlx::query_as!(
81 RequiredSettingValueRow,
82 r#"
83SELECT value AS "value!: _"
84FROM project_setting
85WHERE project_id = ?
86 AND name = ?
87"#,
88 project_id,
89 setting_name
90 )
91 .fetch_optional(&self.0)
92 .await?;
93
94 Ok(row.map(|row| row.value))
95 }
96
97 async fn get_setting(&self, name: SettingName) -> Result<Option<String>, DbError> {
98 let setting_name = name.as_str();
99 let row = sqlx::query_as!(
100 RequiredSettingValueRow,
101 r#"
102SELECT value AS "value!: _"
103FROM setting
104WHERE name = ?
105"#,
106 setting_name
107 )
108 .fetch_optional(&self.0)
109 .await?;
110
111 Ok(row.map(|row| row.value))
112 }
113
114 async fn load_active_project_id(&self) -> Result<Option<i64>, DbError> {
115 let setting_value = self.get_setting(SettingName::ActiveProjectId).await?;
116
117 Ok(setting_value.and_then(|value| value.parse::<i64>().ok()))
118 }
119
120 async fn load_project_reasoning_level(
121 &self,
122 project_id: i64,
123 name: SettingName,
124 ) -> Result<ReasoningLevel, DbError> {
125 let setting_value = self.get_project_setting(project_id, name).await?;
126
127 let reasoning_level = setting_value
128 .as_deref()
129 .and_then(|value| value.parse::<ReasoningLevel>().ok())
130 .unwrap_or_default();
131
132 Ok(reasoning_level)
133 }
134
135 async fn set_active_project_id(&self, project_id: i64) -> Result<(), DbError> {
136 self.upsert_setting(SettingName::ActiveProjectId, &project_id.to_string())
137 .await
138 }
139
140 async fn upsert_project_setting(
141 &self,
142 project_id: i64,
143 name: SettingName,
144 value: &str,
145 ) -> Result<(), DbError> {
146 sqlx::query!(
147 r"
148INSERT INTO project_setting (project_id, name, value)
149VALUES (?, ?, ?)
150ON CONFLICT(project_id, name) DO UPDATE
151SET value = excluded.value
152",
153 project_id,
154 name.as_str(),
155 value
156 )
157 .execute(&self.0)
158 .await?;
159
160 Ok(())
161 }
162
163 async fn upsert_project_settings(
164 &self,
165 project_id: i64,
166 settings: Vec<(SettingName, String)>,
167 ) -> Result<(), DbError> {
168 let mut transaction = self.0.begin().await?;
169
170 for (name, value) in settings {
171 sqlx::query!(
172 r"
173INSERT INTO project_setting (project_id, name, value)
174VALUES (?, ?, ?)
175ON CONFLICT(project_id, name) DO UPDATE
176SET value = excluded.value
177",
178 project_id,
179 name.as_str(),
180 value
181 )
182 .execute(&mut *transaction)
183 .await?;
184 }
185
186 transaction.commit().await?;
187
188 Ok(())
189 }
190
191 async fn upsert_setting(&self, name: SettingName, value: &str) -> Result<(), DbError> {
192 sqlx::query!(
193 r"
194INSERT INTO setting (name, value)
195VALUES (?, ?)
196ON CONFLICT(name) DO UPDATE
197SET value = excluded.value
198",
199 name.as_str(),
200 value
201 )
202 .execute(&self.0)
203 .await?;
204
205 Ok(())
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use ag_agent::AgentModel;
212
213 use super::*;
214 use crate::AppRepositories;
215
216 #[tokio::test]
217 async fn test_upsert_project_settings_rolls_back_partial_batch() {
219 let (repositories, pool) = AppRepositories::in_memory_with_pool()
221 .await
222 .expect("db should open");
223 let project_id = repositories
224 .projects()
225 .upsert_project("/tmp/project", Some("main".to_string()))
226 .await
227 .expect("failed to insert project");
228 sqlx::query!(
229 r"
230CREATE TRIGGER fail_default_fast_agent_insert
231BEFORE INSERT ON project_setting
232WHEN NEW.name = 'DefaultFastAgent'
233BEGIN
234 SELECT RAISE(FAIL, 'forced setting failure');
235END;
236"
237 )
238 .execute(&pool)
239 .await
240 .expect("failed to install failure trigger");
241
242 let result = repositories
244 .settings()
245 .upsert_project_settings(
246 project_id,
247 vec![
248 (
249 SettingName::DefaultFastModel,
250 AgentModel::Gemini31Pro.as_str().to_string(),
251 ),
252 (SettingName::DefaultFastAgent, "antigravity".to_string()),
253 ],
254 )
255 .await;
256
257 assert!(result.is_err());
259 assert_eq!(
260 repositories
261 .settings()
262 .get_project_setting(project_id, SettingName::DefaultFastModel)
263 .await
264 .expect("failed to load fast model setting"),
265 None
266 );
267 assert_eq!(
268 repositories
269 .settings()
270 .get_project_setting(project_id, SettingName::DefaultFastAgent)
271 .await
272 .expect("failed to load fast agent setting"),
273 None
274 );
275 }
276}