1use std::sync::Arc;
2use std::time::Duration;
3
4use crate::config::{Config, default_database_url};
5use crate::error::Error;
6use crate::executor::modes::{ConversationHandler, ResponseHandler};
7use crate::storage::backend::redact_database_urls;
8use crate::storage::{
9 ConversationStore, ConversationVersion, DatabaseBackend, ResponseStore, create_pool_with_schema_and_configs,
10};
11use crate::tool::{GatewayExecutor, GatewayExecutors};
12use crate::types::io::InputItem;
13use crate::types::messages::GatewayToolMap;
14use crate::types::request_response::{RequestPayload, ResponsePayload};
15
16const GATEWAY_TOOL_ALIASES_ENV: &str = "MESSAGES_GATEWAY_TOOL_ALIASES";
20
21#[derive(Debug)]
23pub struct RequestContext {
24 pub original_request: RequestPayload,
26 pub enriched_request: RequestPayload,
29 pub new_input_items: Vec<InputItem>,
31 pub response_id: String,
33 pub conversation_id: Option<String>,
35 pub conversation_version: Option<ConversationVersion>,
38}
39
40impl RequestContext {
41 pub(crate) fn inject_ids(&self, payload: &mut ResponsePayload) {
44 payload.id.clone_from(&self.response_id);
45 payload.conversation_id.clone_from(&self.conversation_id);
46 payload
47 .previous_response_id
48 .clone_from(&self.original_request.previous_response_id);
49 }
50}
51
52#[derive(Clone, Debug)]
58pub struct ExecutionContext {
59 pub conv_handler: ConversationHandler,
60 pub resp_handler: ResponseHandler,
61 pub client: Arc<reqwest::Client>,
62 pub gateway_executors: GatewayExecutors,
63 pub messages_gateway_tools: GatewayToolMap,
66 pub llm_base_url: String,
68 pub streaming_timeout: Duration,
71 storage_pool: Option<Arc<crate::storage::DbPool>>,
72}
73
74impl ExecutionContext {
75 #[must_use]
77 pub fn responses_url(&self) -> String {
78 format!("{}/v1/responses", self.llm_base_url)
79 }
80
81 #[must_use]
83 pub fn conversations_url(&self) -> String {
84 format!("{}/v1/conversations", self.llm_base_url)
85 }
86
87 #[must_use]
88 pub fn new(
89 conv_handler: ConversationHandler,
90 resp_handler: ResponseHandler,
91 client: Arc<reqwest::Client>,
92 llm_base_url: String,
93 ) -> Self {
94 let gateway_executors = GatewayExecutors::from_env(Arc::clone(&client));
95 Self {
96 conv_handler,
97 resp_handler,
98 client,
99 gateway_executors,
100 messages_gateway_tools: messages_gateway_tools_from_env(),
101 llm_base_url,
102 streaming_timeout: Duration::from_secs(30),
103 storage_pool: None,
104 }
105 }
106
107 #[must_use]
108 pub fn with_gateway_executor(mut self, executor: Arc<dyn GatewayExecutor>) -> Self {
109 self.gateway_executors.insert(executor);
110 self
111 }
112
113 pub async fn storage_ready(&self, timeout: Duration) -> bool {
115 let Some(pool) = &self.storage_pool else {
116 return true;
117 };
118 matches!(
119 tokio::time::timeout(timeout, crate::storage::schema::verify_persistence_ready(pool.as_ref())).await,
120 Ok(Ok(()))
121 )
122 }
123
124 #[must_use]
126 pub fn storage_pool(&self) -> Option<&crate::storage::DbPool> {
127 self.storage_pool.as_deref()
128 }
129
130 pub async fn from_config(cfg: &Config) -> Result<Self, Error> {
140 let default_db_url = cfg.db_url.is_none().then(default_database_url).transpose()?;
141 let db_url = cfg
142 .db_url
143 .as_deref()
144 .or(default_db_url.as_deref())
145 .ok_or_else(|| Error::Config("default database URL was not resolved".to_owned()))?;
146 let database_backend = DatabaseBackend::from_url(db_url)
147 .map_err(|error| Error::Config(format!("invalid DATABASE_URL: {error}")))?;
148 let pool = create_pool_with_schema_and_configs(Some(db_url), cfg.sqlite, cfg.postgres)
149 .await
150 .map_err(|error| database_open_error(database_backend, &error))?;
151 crate::storage::schema::verify_persistence_writable(pool.as_ref())
152 .await
153 .map_err(|error| database_open_error(database_backend, &error))?;
154
155 let conv_handler = ConversationHandler::new(ConversationStore::new(pool.clone()));
156 let resp_handler = ResponseHandler::new(ResponseStore::new(pool.clone()));
157 let client = Arc::new(reqwest::Client::new());
158 let gateway_executors = GatewayExecutors::from_config(Arc::clone(&client), &cfg.tools)
159 .map_err(|error| Error::Config(format!("failed to validate configured MCP server policies: {error}")))?;
160
161 Ok(Self {
162 conv_handler,
163 resp_handler,
164 client,
165 gateway_executors,
166 messages_gateway_tools: std::env::var(GATEWAY_TOOL_ALIASES_ENV)
167 .ok()
168 .as_deref()
169 .or(cfg.tools.messages_gateway_tool_aliases.as_deref())
170 .map(GatewayToolMap::from_env_str)
171 .unwrap_or_default(),
172 llm_base_url: cfg.llm_api_base.clone(),
173 streaming_timeout: Duration::from_secs(30),
174 storage_pool: Some(pool),
175 })
176 }
177}
178
179fn database_open_error(database_backend: DatabaseBackend, error: &sqlx::Error) -> Error {
180 let category = match error {
181 sqlx::Error::Configuration(_) | sqlx::Error::InvalidArgument(_) => "configuration error".to_owned(),
182 sqlx::Error::Database(database_error) => database_error
183 .code()
184 .filter(|code| code.len() <= 5 && code.bytes().all(|byte| byte.is_ascii_alphanumeric()))
185 .map_or_else(
186 || "database error".to_owned(),
187 |code| format!("database error (SQLSTATE {code})"),
188 ),
189 sqlx::Error::Io(_) => "database I/O error".to_owned(),
190 sqlx::Error::Tls(_) => "database TLS error".to_owned(),
191 sqlx::Error::Protocol(_) => "database protocol error".to_owned(),
192 sqlx::Error::PoolTimedOut => "connection pool timeout".to_owned(),
193 sqlx::Error::PoolClosed => "connection pool closed".to_owned(),
194 sqlx::Error::WorkerCrashed => "database worker crashed".to_owned(),
195 sqlx::Error::Migrate(_) => "database migration error".to_owned(),
196 _ => "database error".to_owned(),
197 };
198 let detail = redact_database_urls(&error.to_string());
199 Error::Config(format!(
200 "failed to open {} database: {category}: {detail}",
201 database_backend.display_name()
202 ))
203}
204
205fn messages_gateway_tools_from_env() -> GatewayToolMap {
207 std::env::var(GATEWAY_TOOL_ALIASES_ENV)
208 .ok()
209 .map(|raw| GatewayToolMap::from_env_str(&raw))
210 .unwrap_or_default()
211}
212
213#[cfg(test)]
214mod tests {
215 use std::sync::Arc;
216 use std::time::Duration;
217
218 use super::{ExecutionContext, database_open_error};
219 use crate::executor::{ConversationHandler, ResponseHandler};
220 use crate::storage::{ConversationStore, DatabaseBackend, ResponseStore, create_pool_with_schema};
221
222 #[test]
223 fn database_errors_are_actionable_without_exposing_credentials() {
224 let database_url = "postgresql://agentic-api:super'secret@postgres.example.com/agentic_api";
225 let backend = DatabaseBackend::from_url(database_url).expect("valid PostgreSQL URL");
226 let tls_error = sqlx::Error::Tls(Box::new(std::io::Error::other(format!(
227 "{database_url} certificate verify failed"
228 ))));
229 let pool_error = sqlx::Error::PoolTimedOut;
230 let tls_message = database_open_error(backend, &tls_error).to_string();
231 let pool_message = database_open_error(backend, &pool_error).to_string();
232
233 assert!(tls_message.contains("database TLS error"));
234 assert!(tls_message.contains("certificate verify failed"));
235 assert!(tls_message.contains("postgresql://[redacted]"));
236 assert_eq!(
237 pool_message,
238 "failed to open PostgreSQL database: connection pool timeout: \
239 pool timed out while waiting for an open connection"
240 );
241 assert!(!tls_message.contains("super-secret"));
242 assert!(!tls_message.contains("secret"));
243 assert!(!tls_message.contains("agentic-api"));
244 assert_ne!(tls_message, pool_message);
245
246 let mysql_error = sqlx::Error::Io(std::io::Error::other(
247 "mysql://gateway:mysql-secret@mysql.example.com/agentic_api refused",
248 ));
249 let mysql_message = database_open_error(DatabaseBackend::Other, &mysql_error).to_string();
250 assert!(mysql_message.contains("mysql://[redacted]"));
251 assert!(!mysql_message.contains("mysql-secret"));
252
253 let short_postgres_url = "postgres://gateway:postgres-secret@postgres.example.com/agentic_api";
254 let short_postgres_error = sqlx::Error::Io(std::io::Error::other(format!("{short_postgres_url} refused")));
255 let short_postgres_backend = DatabaseBackend::from_url(short_postgres_url).expect("valid PostgreSQL URL");
256 let short_postgres_message = database_open_error(short_postgres_backend, &short_postgres_error).to_string();
257 assert!(short_postgres_message.contains("failed to open PostgreSQL database"));
258 assert!(short_postgres_message.contains("postgres://[redacted]"));
259 assert!(!short_postgres_message.contains("postgres-secret"));
260 }
261
262 #[test]
263 fn uppercase_database_urls_are_classified_and_redacted() {
264 let database_url = "POSTGRESQL://gateway:postgres-secret@postgres.example.com/agentic_api";
265 let error = sqlx::Error::Io(std::io::Error::other(format!("{database_url} refused")));
266 let backend = DatabaseBackend::from_url(database_url).expect("valid uppercase PostgreSQL URL");
267 let message = database_open_error(backend, &error).to_string();
268
269 assert!(message.contains("failed to open PostgreSQL database"));
270 assert!(message.contains("postgresql://[redacted]"));
271 assert!(!message.contains("postgres-secret"));
272 }
273
274 #[tokio::test]
275 async fn storage_readiness_is_bounded_by_the_probe_timeout() {
276 let pool = create_pool_with_schema(Some("sqlite://?mode=memory"))
277 .await
278 .expect("create single-connection pool");
279 let held_connection = pool.acquire().await.expect("hold the only connection");
280 let mut context = ExecutionContext::new(
281 ConversationHandler::new(ConversationStore::disabled()),
282 ResponseHandler::new(ResponseStore::disabled()),
283 Arc::new(reqwest::Client::new()),
284 "http://localhost:8000".to_owned(),
285 );
286 context.storage_pool = Some(pool.clone());
287
288 assert!(!context.storage_ready(Duration::from_millis(10)).await);
289 drop(held_connection);
290 assert!(context.storage_ready(Duration::from_secs(1)).await);
291 }
292
293 #[tokio::test]
294 async fn storage_readiness_rejects_missing_persistence_tables() {
295 let pool = create_pool_with_schema(Some("sqlite://?mode=memory"))
296 .await
297 .expect("create persistence pool");
298 let mut context = ExecutionContext::new(
299 ConversationHandler::new(ConversationStore::disabled()),
300 ResponseHandler::new(ResponseStore::disabled()),
301 Arc::new(reqwest::Client::new()),
302 "http://localhost:8000".to_owned(),
303 );
304 context.storage_pool = Some(pool.clone());
305 assert!(context.storage_ready(Duration::from_secs(1)).await);
306
307 sqlx::query("DROP TABLE responses")
308 .execute(pool.as_ref())
309 .await
310 .expect("drop persistence table");
311
312 assert!(!context.storage_ready(Duration::from_secs(1)).await);
313 }
314
315 #[tokio::test]
316 async fn storage_readiness_rejects_read_only_persistence() {
317 let pool = create_pool_with_schema(Some("sqlite://?mode=memory"))
318 .await
319 .expect("create persistence pool");
320 let mut context = ExecutionContext::new(
321 ConversationHandler::new(ConversationStore::disabled()),
322 ResponseHandler::new(ResponseStore::disabled()),
323 Arc::new(reqwest::Client::new()),
324 "http://localhost:8000".to_owned(),
325 );
326 context.storage_pool = Some(pool.clone());
327 assert!(context.storage_ready(Duration::from_secs(1)).await);
328
329 sqlx::query("PRAGMA query_only = ON")
330 .execute(pool.as_ref())
331 .await
332 .expect("make persistence read-only");
333
334 assert!(!context.storage_ready(Duration::from_secs(1)).await);
335 }
336
337 #[tokio::test]
338 async fn storage_readiness_rolls_back_probe_rows() {
339 let pool = create_pool_with_schema(Some("sqlite://?mode=memory"))
340 .await
341 .expect("create persistence pool");
342
343 crate::storage::schema::verify_persistence_writable(pool.as_ref())
344 .await
345 .expect("run functional persistence probe");
346 for table in ["conversations", "items", "responses"] {
347 let row_count: i64 = sqlx::query_scalar(&format!("SELECT COUNT(*) FROM {table}"))
348 .fetch_one(pool.as_ref())
349 .await
350 .expect("count persistence rows");
351 assert_eq!(row_count, 0, "readiness probe leaked a row into {table}");
352 }
353 }
354}