1#[cfg(feature = "sqlx-storage")]
9use async_trait::async_trait;
10#[cfg(feature = "sqlx-storage")]
11use serde_json;
12#[cfg(feature = "sqlx-storage")]
13use sqlx::{
14 AnyPool, ConnectOptions, Row,
15 any::{AnyConnectOptions, AnyPoolOptions},
16};
17#[cfg(feature = "sqlx-storage")]
18use std::{
19 collections::HashMap,
20 str::FromStr,
21 time::{Duration, Instant},
22};
23
24#[cfg(feature = "sqlx-storage")]
25use crate::adapter::business::push_notification::{
26 PushNotificationRegistry, PushNotificationSender,
27};
28
29#[cfg(feature = "sqlx-storage")]
30#[cfg(feature = "http-client")]
31use crate::adapter::business::push_notification::HttpPushNotificationSender;
32#[cfg(feature = "sqlx-storage")]
33#[cfg(not(feature = "http-client"))]
34use crate::adapter::business::push_notification::NoopPushNotificationSender;
35
36#[cfg(feature = "sqlx-storage")]
37use crate::domain::{
38 A2AError, ContextId, ContextState, Conversation, Digest, Message, ReadRefresh, Remembered,
39 RetentionPolicy, Seq, SequencedMessage, StateKey, StateScope, Swept, Task, TaskId,
40 TaskPushNotificationConfig, TaskState, TaskStateExt, TaskStatus, VersionedTask,
41};
42#[cfg(feature = "sqlx-storage")]
43use crate::port::{
44 AsyncContextStateStore, AsyncConversationStore, AsyncEventLog, AsyncNotificationManager,
45 AsyncPushNotifier, AsyncRetention, AsyncTaskLifecycle, AsyncTaskQuery, AsyncTaskVersioning,
46 Replay, SeqEvent, UpdateEvent, context_state::scope_key,
47};
48
49#[cfg(feature = "sqlx-storage")]
50use std::sync::Arc;
51
52#[cfg(feature = "sqlx-storage")]
53pub struct SqlxTaskStorage {
61 pool: AnyPool,
63 dialect: Dialect,
66 push_notification_registry: Arc<PushNotificationRegistry>,
68 event_log_capacity: Option<u64>,
70 read_refresh: ReadRefresh,
73 claim_cache: Option<Arc<ClaimCache>>,
81}
82
83#[cfg(feature = "sqlx-storage")]
84use super::database_config::DatabaseType;
85#[cfg(feature = "sqlx-storage")]
86use super::dialect::Dialect;
87
88#[cfg(feature = "sqlx-storage")]
94#[cfg(feature = "sqlx-storage")]
101fn state_str(state: TaskState) -> &'static str {
102 match state {
103 TaskState::Submitted => "submitted",
104 TaskState::Working => "working",
105 TaskState::InputRequired => "input-required",
106 TaskState::Completed => "completed",
107 TaskState::Canceled => "canceled",
108 TaskState::Failed => "failed",
109 TaskState::Rejected => "rejected",
110 TaskState::AuthRequired => "auth-required",
111 TaskState::Unknown => "unknown",
112 }
113}
114
115#[cfg(feature = "sqlx-storage")]
122fn status_message_json(message: Option<&Message>) -> Option<String> {
123 message.map(|m| serde_json::to_string(m).unwrap_or_default())
124}
125
126const TASK_COLUMNS: &str = "id, context_id, status_state, status_message, metadata, artifacts";
127
128#[cfg(feature = "sqlx-storage")]
134pub const DEFAULT_EVENT_LOG_CAPACITY: u64 = 1024;
135
136#[cfg(feature = "sqlx-storage")]
142pub const DEFAULT_CLAIM_CACHE_TTL: Duration = Duration::from_secs(5);
143
144#[cfg(feature = "sqlx-storage")]
146const CLAIM_CACHE_CAPACITY: usize = 1024;
147
148#[cfg(feature = "sqlx-storage")]
171struct ClaimCache {
172 entries: std::sync::Mutex<HashMap<String, (ContextClaim, Instant)>>,
176 ttl: Duration,
177}
178
179#[cfg(feature = "sqlx-storage")]
180impl ClaimCache {
181 fn new(ttl: Duration) -> Self {
182 Self {
183 entries: std::sync::Mutex::new(HashMap::new()),
184 ttl,
185 }
186 }
187
188 fn get(&self, context_id: &str) -> Option<ContextClaim> {
193 let entries = self.entries.lock().ok()?;
194 let (claim, at) = entries.get(context_id)?;
195 (at.elapsed() < self.ttl).then(|| claim.clone())
196 }
197
198 fn insert(&self, context_id: &str, claim: &ContextClaim) {
199 let Ok(mut entries) = self.entries.lock() else {
200 return;
201 };
202 if entries.len() >= CLAIM_CACHE_CAPACITY {
203 entries.clear();
204 }
205 entries.insert(context_id.to_string(), (claim.clone(), Instant::now()));
206 }
207
208 fn forget(&self, context_id: &str) {
209 if let Ok(mut entries) = self.entries.lock() {
210 entries.remove(context_id);
211 }
212 }
213}
214
215#[cfg(feature = "sqlx-storage")]
218struct PoolSettings {
219 max_connections: u32,
220 acquire_timeout: Duration,
221 log_statements: bool,
222}
223
224#[cfg(feature = "sqlx-storage")]
225impl Default for PoolSettings {
226 fn default() -> Self {
231 Self {
232 max_connections: 10,
233 acquire_timeout: Duration::from_secs(30),
234 log_statements: false,
235 }
236 }
237}
238
239#[cfg(feature = "sqlx-storage")]
240impl PoolSettings {
241 fn connect_options(&self, url: &str) -> Result<AnyConnectOptions, A2AError> {
242 let options = AnyConnectOptions::from_str(url)
243 .map_err(|e| A2AError::DatabaseError(format!("Invalid database URL '{url}': {e}")))?;
244
245 Ok(if self.log_statements {
246 options
247 } else {
248 options.disable_statement_logging()
249 })
250 }
251}
252
253#[cfg(feature = "sqlx-storage")]
255pub struct SqlxStorageBuilder {
256 url: String,
257 pool: PoolSettings,
258 push_sender: Option<Arc<dyn PushNotificationSender>>,
259 additional_migrations: Vec<String>,
260 event_log_capacity: Option<u64>,
261 read_refresh: ReadRefresh,
262 claim_cache_ttl: Option<Duration>,
263}
264
265#[cfg(feature = "sqlx-storage")]
266impl SqlxStorageBuilder {
267 pub fn from_config(config: &super::database_config::DatabaseConfig) -> Self {
269 SqlxTaskStorage::builder(&config.url)
270 .max_connections(config.max_connections)
271 .acquire_timeout(Duration::from_secs(config.timeout_seconds))
272 .log_statements(config.enable_logging)
273 }
274
275 pub fn max_connections(mut self, max: u32) -> Self {
280 self.pool.max_connections = max;
281 self
282 }
283
284 pub fn acquire_timeout(mut self, timeout: Duration) -> Self {
287 self.pool.acquire_timeout = timeout;
288 self
289 }
290
291 pub fn log_statements(mut self, log: bool) -> Self {
294 self.pool.log_statements = log;
295 self
296 }
297
298 pub fn push_sender(mut self, sender: impl PushNotificationSender + 'static) -> Self {
301 self.push_sender = Some(Arc::new(sender));
302 self
303 }
304
305 pub fn event_log_capacity(mut self, capacity: Option<u64>) -> Self {
317 self.event_log_capacity = capacity.map(|capacity| capacity.max(1));
318 self
319 }
320
321 pub fn claim_cache(mut self, ttl: Option<Duration>) -> Self {
337 self.claim_cache_ttl = ttl;
338 self
339 }
340
341 pub fn read_refresh(mut self, read_refresh: ReadRefresh) -> Self {
348 self.read_refresh = read_refresh;
349 self
350 }
351
352 pub fn migrations<S: AsRef<str>>(mut self, migrations: impl IntoIterator<Item = S>) -> Self {
357 self.additional_migrations
358 .extend(migrations.into_iter().map(|s| s.as_ref().to_string()));
359 self
360 }
361
362 pub async fn connect(self) -> Result<SqlxTaskStorage, A2AError> {
364 if self.pool.max_connections == 0 {
365 return Err(A2AError::DatabaseError(
366 "max_connections must be greater than 0; a pool that hands out no connections \
367 fails every query"
368 .to_string(),
369 ));
370 }
371
372 let (pool, dialect) = SqlxTaskStorage::connect(&self.url, &self.pool).await?;
373 SqlxTaskStorage::run_additional_migrations(&pool, &self.additional_migrations).await?;
374
375 let push_registry = match self.push_sender {
376 Some(sender) => PushNotificationRegistry::from_shared(sender),
377 None => {
378 #[cfg(feature = "http-client")]
379 let sender = HttpPushNotificationSender::new();
380 #[cfg(not(feature = "http-client"))]
381 let sender = NoopPushNotificationSender::default();
382 PushNotificationRegistry::new(sender)
383 }
384 };
385
386 Ok(SqlxTaskStorage {
387 pool,
388 dialect,
389 push_notification_registry: Arc::new(push_registry),
390 event_log_capacity: self.event_log_capacity,
391 read_refresh: self.read_refresh,
392 claim_cache: self
393 .claim_cache_ttl
394 .map(|ttl| Arc::new(ClaimCache::new(ttl))),
395 })
396 }
397}
398
399#[cfg(feature = "sqlx-storage")]
406#[derive(Clone)]
407enum ContextClaim {
408 Open,
411 Owner(String),
413}
414
415#[cfg(feature = "sqlx-storage")]
416impl ContextClaim {
417 fn verdict(&self, context_id: &str, caller: Option<&str>) -> Result<(), A2AError> {
418 match self {
419 Self::Open => Ok(()),
420 Self::Owner(owner) if Some(owner.as_str()) == caller => Ok(()),
421 Self::Owner(_) => Err(A2AError::ContextAccessDenied {
422 context_id: context_id.to_string(),
423 }),
424 }
425 }
426}
427
428#[cfg(feature = "sqlx-storage")]
429impl SqlxTaskStorage {
430 fn dialect_for(database_url: &str) -> Result<Dialect, A2AError> {
436 let Some(database_type) = DatabaseType::from_url(database_url) else {
437 return Err(A2AError::DatabaseError(format!(
438 "Unrecognized database URL scheme in '{database_url}'. Expected sqlite: or \
439 postgres:, e.g. 'sqlite::memory:' or 'postgres://user:pass@localhost/a2a'"
440 )));
441 };
442
443 let Some(dialect) = Dialect::of(database_type) else {
444 return Err(A2AError::DatabaseError(format!(
445 "{database_type} is not supported by SqlxTaskStorage. It stores tasks in SQLite \
446 or PostgreSQL; there is no {database_type} schema."
447 )));
448 };
449
450 if !database_type.is_feature_enabled() {
451 return Err(A2AError::DatabaseError(format!(
452 "{database_type} detected from URL '{database_url}', but the '{}' feature is not \
453 enabled. Add `features = [\"{}\"]` to your a2a-rs dependency.",
454 database_type.feature_name(),
455 database_type.feature_name(),
456 )));
457 }
458
459 Ok(dialect)
460 }
461
462 fn pooled_url(database_url: &str) -> std::borrow::Cow<'_, str> {
472 static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
473
474 let anonymous_memory = (database_url.contains(":memory:")
475 || database_url.contains("mode=memory"))
476 && !database_url.contains("cache=shared");
477 if !anonymous_memory {
478 return std::borrow::Cow::Borrowed(database_url);
479 }
480
481 let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
482 std::borrow::Cow::Owned(format!(
483 "sqlite:file:a2a-in-memory-{n}?mode=memory&cache=shared"
484 ))
485 }
486
487 fn pool_options(dialect: Dialect) -> AnyPoolOptions {
500 match dialect {
501 Dialect::Sqlite => AnyPoolOptions::new().after_connect(|conn, _meta| {
502 Box::pin(async move {
503 sqlx::query("PRAGMA foreign_keys = ON")
504 .execute(conn)
505 .await?;
506 Ok(())
507 })
508 }),
509 Dialect::Postgres => AnyPoolOptions::new(),
510 }
511 }
512
513 async fn connect(
515 database_url: &str,
516 settings: &PoolSettings,
517 ) -> Result<(AnyPool, Dialect), A2AError> {
518 let dialect = Self::dialect_for(database_url)?;
519
520 sqlx::any::install_default_drivers();
524
525 let url = match dialect {
526 Dialect::Sqlite => Self::pooled_url(database_url),
527 Dialect::Postgres => std::borrow::Cow::Borrowed(database_url),
528 };
529 let pool = Self::pool_options(dialect)
530 .max_connections(settings.max_connections)
531 .acquire_timeout(settings.acquire_timeout)
532 .connect_with(settings.connect_options(&url)?)
533 .await
534 .map_err(|e| A2AError::DatabaseError(format!("Failed to connect to database: {e}")))?;
535
536 let migrations = Self::pool_options(dialect)
544 .max_connections(1)
545 .acquire_timeout(settings.acquire_timeout)
546 .connect_with(settings.connect_options(&url)?)
547 .await
548 .map_err(|e| {
549 A2AError::DatabaseError(format!("Failed to open the migration connection: {e}"))
550 })?;
551 let migrated = Self::run_base_migrations(migrations.clone(), dialect).await;
552 migrations.close().await;
553 migrated?;
554
555 Ok((pool, dialect))
556 }
557
558 pub async fn new(database_url: &str) -> Result<Self, A2AError> {
567 Self::builder(database_url).connect().await
568 }
569
570 pub fn builder(database_url: impl Into<String>) -> SqlxStorageBuilder {
583 SqlxStorageBuilder {
584 url: database_url.into(),
585 pool: PoolSettings::default(),
586 push_sender: None,
587 additional_migrations: Vec::new(),
588 event_log_capacity: Some(DEFAULT_EVENT_LOG_CAPACITY),
589 read_refresh: ReadRefresh::never(),
590 claim_cache_ttl: Some(DEFAULT_CLAIM_CACHE_TTL),
591 }
592 }
593
594 async fn run_base_migrations(pool: AnyPool, dialect: Dialect) -> Result<(), A2AError> {
617 if let Some(lock) = dialect.migration_lock() {
618 sqlx::raw_sql(lock).execute(&pool).await.map_err(|e| {
619 A2AError::DatabaseError(format!("Failed to take the migration lock: {e}"))
620 })?;
621 }
622 let [initial, push_configs, rest @ ..] = dialect.migrations();
627
628 Self::run_migration(pool.clone(), initial).await?;
629 Self::drop_legacy_push_configs(pool.clone(), dialect).await?;
632 Self::run_migration(pool.clone(), push_configs).await?;
633 for migration in rest {
634 Self::run_migration(pool.clone(), migration).await?;
635 }
636
637 sqlx::raw_sql(
641 "UPDATE task_history SET context_id = \
642 (SELECT context_id FROM tasks WHERE tasks.id = task_history.task_id) \
643 WHERE context_id IS NULL",
644 )
645 .execute(&pool)
646 .await
647 .map_err(|e| A2AError::DatabaseError(format!("Migration 004 backfill failed: {e}")))?;
648
649 Self::drop_dead_context_state_column(pool.clone(), dialect).await;
650
651 Ok(())
652 }
653
654 async fn drop_dead_context_state_column(pool: AnyPool, dialect: Dialect) {
661 let probe = sqlx::query(dialect.dead_context_state_column_probe())
662 .fetch_optional(&pool)
663 .await;
664 if !matches!(probe, Ok(Some(_))) {
665 return;
666 }
667
668 if let Err(e) = sqlx::raw_sql("ALTER TABLE contexts DROP COLUMN state")
669 .execute(&pool)
670 .await
671 {
672 #[cfg(feature = "tracing")]
673 tracing::debug!("left the unused contexts.state column in place: {e}");
674 #[cfg(not(feature = "tracing"))]
675 let _ = e;
676 }
677 }
678
679 async fn run_migration(
689 pool: AnyPool,
690 migration: super::dialect::Migration,
691 ) -> Result<(), A2AError> {
692 let mut attempt = sqlx::raw_sql(migration.sql).execute(&pool).await;
693 if attempt
694 .as_ref()
695 .err()
696 .is_some_and(super::dialect::is_concurrent_ddl_conflict)
697 {
698 attempt = sqlx::raw_sql(migration.sql).execute(&pool).await;
699 }
700
701 match attempt {
702 Ok(_) => Ok(()),
703 Err(e)
707 if migration.tolerates_existing_column
708 && e.to_string().contains("duplicate column name") =>
709 {
710 Ok(())
711 }
712 Err(e) => Err(A2AError::DatabaseError(format!(
713 "Migration {} failed: {e}",
714 migration.name
715 ))),
716 }
717 }
718
719 async fn drop_legacy_push_configs(pool: AnyPool, dialect: Dialect) -> Result<(), A2AError> {
726 let legacy = sqlx::query(dialect.legacy_push_config_probe())
727 .fetch_optional(&pool)
728 .await
729 .map_err(|e| {
730 A2AError::DatabaseError(format!("Failed to inspect push config table: {e}"))
731 })?;
732
733 if legacy.is_some() {
734 sqlx::raw_sql("DROP TABLE IF EXISTS push_notification_configs")
735 .execute(&pool)
736 .await
737 .map_err(|e| {
738 A2AError::DatabaseError(format!(
739 "Failed to drop the pre-v0.3 push config table: {e}"
740 ))
741 })?;
742 }
743 Ok(())
744 }
745
746 async fn run_additional_migrations(
748 pool: &AnyPool,
749 migrations: &[String],
750 ) -> Result<(), A2AError> {
751 for (i, migration_sql) in migrations.iter().enumerate() {
752 sqlx::raw_sql(migration_sql)
753 .execute(pool)
754 .await
755 .map_err(|e| {
756 A2AError::DatabaseError(format!("Additional migration {} failed: {}", i + 1, e))
757 })?;
758 }
759 Ok(())
760 }
761
762 fn sql<'a>(&self, sql: &'a str) -> std::borrow::Cow<'a, str> {
767 self.dialect.bind_params(sql)
768 }
769
770 fn row_to_task(row: &sqlx::any::AnyRow) -> Result<Task, A2AError> {
772 let task_id: String = row
773 .try_get("id")
774 .map_err(|e| A2AError::DatabaseError(format!("Failed to get task_id: {}", e)))?;
775 let context_id: String = row
776 .try_get("context_id")
777 .map_err(|e| A2AError::DatabaseError(format!("Failed to get context_id: {}", e)))?;
778 let status_state: String = row
779 .try_get("status_state")
780 .map_err(|e| A2AError::DatabaseError(format!("Failed to get status_state: {}", e)))?;
781 let status_message_json: Option<String> = row
782 .try_get("status_message")
783 .map_err(|e| A2AError::DatabaseError(format!("Failed to get status_message: {}", e)))?;
784 let metadata_json: Option<String> = row
785 .try_get("metadata")
786 .map_err(|e| A2AError::DatabaseError(format!("Failed to get metadata: {}", e)))?;
787 let artifacts_json: Option<String> = row
788 .try_get("artifacts")
789 .map_err(|e| A2AError::DatabaseError(format!("Failed to get artifacts: {}", e)))?;
790
791 let state = match status_state.as_str() {
793 "submitted" => TaskState::Submitted,
794 "working" => TaskState::Working,
795 "input-required" => TaskState::InputRequired,
796 "completed" => TaskState::Completed,
797 "canceled" => TaskState::Canceled,
798 "failed" => TaskState::Failed,
799 "rejected" => TaskState::Rejected,
800 "auth-required" => TaskState::AuthRequired,
801 "unknown" => TaskState::Unknown,
802 _ => TaskState::Unknown,
803 };
804
805 let status_message = if let Some(msg_str) = status_message_json {
807 Some(serde_json::from_str(&msg_str).map_err(|e| {
808 A2AError::DatabaseError(format!("Failed to parse status message: {}", e))
809 })?)
810 } else {
811 None
812 };
813
814 let metadata =
816 if let Some(meta_str) = metadata_json {
817 Some(serde_json::from_str(&meta_str).map_err(|e| {
818 A2AError::DatabaseError(format!("Failed to parse metadata: {}", e))
819 })?)
820 } else {
821 None
822 };
823
824 let artifacts = if let Some(artifacts_str) = artifacts_json {
826 Some(serde_json::from_str(&artifacts_str).map_err(|e| {
827 A2AError::DatabaseError(format!("Failed to parse artifacts: {}", e))
828 })?)
829 } else {
830 None
831 };
832
833 let now = chrono::Utc::now();
834 let task_status = TaskStatus {
835 state: ::buffa::EnumValue::from(state),
836 message: status_message.into(),
837 timestamp: ::buffa::MessageField::some(::buffa_types::google::protobuf::Timestamp {
838 seconds: now.timestamp(),
839 nanos: now.timestamp_subsec_nanos() as i32,
840 ..Default::default()
841 }),
842 ..Default::default()
843 };
844
845 let task = Task {
846 id: task_id.clone(),
847 context_id,
848 status: ::buffa::MessageField::some(task_status),
849 history: Vec::new(),
850 metadata: metadata.into(),
851 artifacts: artifacts.unwrap_or_default(),
852 ..Default::default()
853 };
854
855 Ok(task)
856 }
857
858 async fn load_task_history(
860 &self,
861 task_id: &str,
862 limit: Option<u32>,
863 ) -> Result<Vec<Message>, A2AError> {
864 let query_str = if let Some(limit) = limit {
875 format!(
876 "SELECT id, status_state, message FROM task_history \
877 WHERE task_id = ? AND message IS NOT NULL ORDER BY id DESC LIMIT {}",
878 limit
879 )
880 } else {
881 "SELECT id, status_state, message FROM task_history \
882 WHERE task_id = ? AND message IS NOT NULL ORDER BY id DESC"
883 .to_string()
884 };
885
886 let query_str = self.sql(&query_str);
887 let rows = sqlx::query(&query_str)
888 .bind(task_id)
889 .fetch_all(&self.pool)
890 .await
891 .map_err(|e| A2AError::DatabaseError(format!("Failed to load task history: {}", e)))?;
892
893 let mut history = Vec::new();
894 for row in rows {
895 let message_json: Option<String> = row.try_get("message").map_err(|e| {
896 A2AError::DatabaseError(format!("Failed to get message from history: {}", e))
897 })?;
898
899 if let Some(msg_str) = message_json {
900 let message: Message = serde_json::from_str(&msg_str).map_err(|e| {
901 A2AError::DatabaseError(format!("Failed to parse message from history: {}", e))
902 })?;
903 history.push(message);
904 }
905 }
906
907 history.reverse();
909 Ok(history)
910 }
911
912 async fn add_to_history(
914 &self,
915 task_id: &str,
916 state: TaskState,
917 message: Option<Message>,
918 ) -> Result<(), A2AError> {
919 let state_str = state_str(state);
920
921 let message_json = if let Some(msg) = message {
922 Some(serde_json::to_string(&msg).map_err(|e| {
923 A2AError::DatabaseError(format!("Failed to serialize message: {}", e))
924 })?)
925 } else {
926 None
927 };
928
929 let sql = self.sql(
933 "INSERT INTO task_history (task_id, context_id, status_state, message) \
934 VALUES (?, (SELECT context_id FROM tasks WHERE id = ?), ?, ?)",
935 );
936 sqlx::query(&sql)
937 .bind(task_id)
938 .bind(task_id)
939 .bind(state_str)
940 .bind(message_json)
941 .execute(&self.pool)
942 .await
943 .map_err(|e| A2AError::DatabaseError(format!("Failed to add task history: {}", e)))?;
944
945 Ok(())
946 }
947
948 async fn claim_or_check_context(
961 &self,
962 context_id: &str,
963 caller: Option<&str>,
964 ) -> Result<(), A2AError> {
965 if let Some(claim) = self.read_claim(context_id).await? {
966 return claim.verdict(context_id, caller);
967 }
968
969 sqlx::query(self.dialect.insert_context_if_absent())
972 .bind(context_id)
973 .bind(caller)
974 .execute(&self.pool)
975 .await
976 .map_err(|e| A2AError::DatabaseError(format!("Failed to register context: {}", e)))?;
977
978 match self.read_claim(context_id).await? {
983 Some(claim) => claim.verdict(context_id, caller),
984 None => Ok(()),
985 }
986 }
987
988 async fn read_claim(&self, context_id: &str) -> Result<Option<ContextClaim>, A2AError> {
997 if let Some(claim) = self.claim_cache.as_ref().and_then(|c| c.get(context_id)) {
998 return Ok(Some(claim));
999 }
1000
1001 let sql = self.sql("SELECT owner FROM contexts WHERE id = ?");
1002 let row = sqlx::query(&sql)
1003 .bind(context_id)
1004 .fetch_optional(&self.pool)
1005 .await
1006 .map_err(|e| A2AError::DatabaseError(format!("Failed to read context owner: {}", e)))?;
1007
1008 let Some(row) = row else {
1009 return Ok(None);
1010 };
1011 let owner: Option<String> = row
1012 .try_get("owner")
1013 .map_err(|e| A2AError::DatabaseError(format!("Failed to get context owner: {}", e)))?;
1014
1015 let claim = match owner {
1016 Some(owner) => ContextClaim::Owner(owner),
1017 None => ContextClaim::Open,
1018 };
1019 if let Some(cache) = self.claim_cache.as_ref() {
1020 cache.insert(context_id, &claim);
1021 }
1022 Ok(Some(claim))
1023 }
1024
1025 async fn refresh_user_state(&self, principal: &str) -> Result<(), A2AError> {
1036 let Some(cutoff) = self.read_refresh.cutoff(chrono::Utc::now()) else {
1037 return Ok(());
1038 };
1039
1040 sqlx::query(self.dialect.refresh_user_state())
1041 .bind(principal)
1042 .bind(self.dialect.format_timestamp(cutoff))
1043 .execute(&self.pool)
1044 .await
1045 .map_err(|e| A2AError::DatabaseError(format!("Failed to refresh user state: {}", e)))?;
1046 Ok(())
1047 }
1048
1049 pub fn push_notifier(&self) -> Arc<dyn AsyncPushNotifier> {
1056 self.push_notification_registry.clone()
1057 }
1058
1059 pub fn max_connections(&self) -> u32 {
1063 self.pool.options().get_max_connections()
1064 }
1065}
1066
1067#[cfg(feature = "sqlx-storage")]
1068#[async_trait]
1069impl AsyncTaskLifecycle for SqlxTaskStorage {
1070 async fn create(&self, id: &TaskId, context_id: &ContextId) -> Result<Task, A2AError> {
1071 let task_id = id.as_str();
1072 let context_id = context_id.as_str();
1073 let exists_sql = self.sql("SELECT id FROM tasks WHERE id = ?");
1075 let existing = sqlx::query(&exists_sql)
1076 .bind(task_id)
1077 .fetch_optional(&self.pool)
1078 .await
1079 .map_err(|e| {
1080 A2AError::DatabaseError(format!("Failed to check existing task: {}", e))
1081 })?;
1082
1083 if existing.is_some() {
1084 return Err(A2AError::TaskNotFound(format!(
1085 "Task {} already exists",
1086 task_id
1087 )));
1088 }
1089
1090 let task = Task::new(task_id.to_string(), context_id.to_string());
1092
1093 let metadata_json = task
1095 .metadata
1096 .as_option()
1097 .map(|m| serde_json::to_string(m).unwrap_or_default());
1098 let artifacts_json = serde_json::to_string(&task.artifacts).unwrap_or_default();
1099 let status_message_str = task
1100 .status
1101 .as_option()
1102 .and_then(|s| s.message.as_option())
1103 .map(|m| serde_json::to_string(m).unwrap_or_default());
1104
1105 let insert_sql = self.sql(
1107 "INSERT INTO tasks (id, context_id, status_state, status_message, metadata, artifacts) \
1108 VALUES (?, ?, ?, ?, ?, ?)",
1109 );
1110 sqlx::query(&insert_sql)
1111 .bind(&task.id)
1112 .bind(&task.context_id)
1113 .bind("submitted")
1114 .bind(status_message_str)
1115 .bind(metadata_json)
1116 .bind(artifacts_json)
1117 .execute(&self.pool)
1118 .await
1119 .map_err(|e| A2AError::DatabaseError(format!("Failed to create task: {}", e)))?;
1120
1121 self.add_to_history(task_id, TaskState::Submitted, None)
1123 .await?;
1124
1125 Ok(task)
1126 }
1127
1128 async fn update_status(
1129 &self,
1130 id: &TaskId,
1131 state: TaskState,
1132 message: Option<Message>,
1133 ) -> Result<Task, A2AError> {
1134 let task_id = id.as_str();
1135 let state_str = state_str(state);
1136
1137 let sql = self.sql(
1143 "UPDATE tasks SET status_state = ?, status_message = ?, version = version + 1 \
1144 WHERE id = ?",
1145 );
1146 let result = sqlx::query(&sql)
1147 .bind(state_str)
1148 .bind(status_message_json(message.as_ref()))
1149 .bind(task_id)
1150 .execute(&self.pool)
1151 .await
1152 .map_err(|e| A2AError::DatabaseError(format!("Failed to update task status: {}", e)))?;
1153
1154 if result.rows_affected() == 0 {
1155 return Err(A2AError::TaskNotFound(task_id.to_string()));
1156 }
1157
1158 self.add_to_history(task_id, state, message).await?;
1160
1161 self.get(id, None).await
1165 }
1166
1167 async fn exists(&self, id: &TaskId) -> Result<bool, A2AError> {
1168 let task_id = id.as_str();
1169 let sql = self.sql("SELECT id FROM tasks WHERE id = ?");
1170 let row = sqlx::query(&sql)
1171 .bind(task_id)
1172 .fetch_optional(&self.pool)
1173 .await
1174 .map_err(|e| {
1175 A2AError::DatabaseError(format!("Failed to check task existence: {}", e))
1176 })?;
1177
1178 Ok(row.is_some())
1179 }
1180
1181 async fn get(&self, id: &TaskId, history_length: Option<u32>) -> Result<Task, A2AError> {
1182 let task_id = id.as_str();
1183 let query_str = format!("SELECT {TASK_COLUMNS} FROM tasks WHERE id = ?");
1185 let sql = self.sql(&query_str);
1186 let row = sqlx::query(&sql)
1187 .bind(task_id)
1188 .fetch_optional(&self.pool)
1189 .await
1190 .map_err(|e| A2AError::DatabaseError(format!("Failed to get task: {}", e)))?;
1191
1192 let Some(row) = row else {
1193 return Err(A2AError::TaskNotFound(task_id.to_string()));
1194 };
1195
1196 let mut task = Self::row_to_task(&row)?;
1197
1198 if history_length.is_some() || history_length.is_none() {
1200 let history = self.load_task_history(task_id, history_length).await?;
1201 task.history = history;
1202 }
1203
1204 Ok(task)
1205 }
1206
1207 async fn cancel(&self, id: &TaskId) -> Result<Task, A2AError> {
1208 let task_id = id.as_str();
1209 let task = self.get(id, None).await?;
1211
1212 if !task.status.state.is_cancelable() {
1217 return Err(A2AError::TaskNotCancelable(format!(
1218 "Task {} has already finished in state {:?} and cannot be canceled",
1219 task_id, task.status.state
1220 )));
1221 }
1222
1223 let mut cancel_message = Message::agent_text(
1225 format!("Task {} canceled.", task_id),
1226 uuid::Uuid::new_v4().to_string(),
1227 );
1228 cancel_message.task_id = task_id.to_string();
1229 cancel_message.context_id = task.context_id.clone();
1230
1231 let sql = self.sql(
1236 "UPDATE tasks SET status_state = ?, status_message = ?, version = version + 1 \
1237 WHERE id = ?",
1238 );
1239 sqlx::query(&sql)
1240 .bind(state_str(TaskState::Canceled))
1241 .bind(status_message_json(Some(&cancel_message)))
1242 .bind(task_id)
1243 .execute(&self.pool)
1244 .await
1245 .map_err(|e| A2AError::DatabaseError(format!("Failed to cancel task: {}", e)))?;
1246
1247 self.add_to_history(task_id, TaskState::Canceled, Some(cancel_message))
1249 .await?;
1250
1251 self.get(id, None).await
1254 }
1255}
1256
1257#[cfg(feature = "sqlx-storage")]
1258impl SqlxTaskStorage {
1259 async fn current_version(&self, task_id: &str) -> Result<Option<u64>, A2AError> {
1261 let sql = self.sql("SELECT version FROM tasks WHERE id = ?");
1262 let row = sqlx::query(&sql)
1263 .bind(task_id)
1264 .fetch_optional(&self.pool)
1265 .await
1266 .map_err(|e| A2AError::DatabaseError(format!("Failed to read task version: {}", e)))?;
1267 match row {
1268 Some(row) => {
1269 let v: i64 = row.try_get("version").map_err(|e| {
1270 A2AError::DatabaseError(format!("Failed to get version column: {}", e))
1271 })?;
1272 Ok(Some(v as u64))
1273 }
1274 None => Ok(None),
1275 }
1276 }
1277}
1278
1279#[cfg(feature = "sqlx-storage")]
1280#[async_trait]
1281impl AsyncTaskVersioning for SqlxTaskStorage {
1282 async fn version(&self, id: &TaskId) -> Result<u64, A2AError> {
1283 self.current_version(id.as_str())
1284 .await?
1285 .ok_or_else(|| A2AError::TaskNotFound(id.as_str().to_string()))
1286 }
1287
1288 async fn get_versioned(
1289 &self,
1290 id: &TaskId,
1291 history_length: Option<u32>,
1292 ) -> Result<VersionedTask, A2AError> {
1293 let task = self.get(id, history_length).await?;
1294 let version = self.version(id).await?;
1295 Ok(VersionedTask::new(task, version))
1296 }
1297
1298 async fn update_status_checked(
1299 &self,
1300 id: &TaskId,
1301 expected: u64,
1302 state: TaskState,
1303 message: Option<Message>,
1304 ) -> Result<VersionedTask, A2AError> {
1305 let task_id = id.as_str();
1306 let state_str = state_str(state);
1307
1308 let sql = self.sql(
1311 "UPDATE tasks SET status_state = ?, status_message = ?, version = version + 1 \
1312 WHERE id = ? AND version = ?",
1313 );
1314 let result = sqlx::query(&sql)
1315 .bind(state_str)
1316 .bind(status_message_json(message.as_ref()))
1317 .bind(task_id)
1318 .bind(expected as i64)
1319 .execute(&self.pool)
1320 .await
1321 .map_err(|e| A2AError::DatabaseError(format!("Failed to update task status: {}", e)))?;
1322
1323 if result.rows_affected() == 0 {
1324 return match self.current_version(task_id).await? {
1326 Some(actual) => Err(A2AError::VersionConflict {
1327 id: task_id.to_string(),
1328 expected,
1329 actual,
1330 }),
1331 None => Err(A2AError::TaskNotFound(task_id.to_string())),
1332 };
1333 }
1334
1335 self.add_to_history(task_id, state, message).await?;
1336 let task = self.get(id, None).await?;
1337 Ok(VersionedTask::new(task, expected + 1))
1338 }
1339}
1340
1341#[cfg(feature = "sqlx-storage")]
1342#[async_trait]
1343impl AsyncTaskQuery for SqlxTaskStorage {
1344 async fn list(
1345 &self,
1346 params: &crate::domain::ListTasksParams,
1347 ) -> Result<crate::domain::ListTasksResult, A2AError> {
1348 use crate::domain::ListTasksResult;
1349
1350 let mut where_conditions = Vec::new();
1352
1353 if params.context_id.is_some() {
1355 where_conditions.push("context_id = ?".to_string());
1356 }
1357
1358 if params.status.is_some() {
1360 where_conditions.push("status_state = ?".to_string());
1361 }
1362
1363 let timestamp_str = if let Some(status_timestamp_after) = ¶ms.status_timestamp_after {
1367 let timestamp =
1369 chrono::DateTime::parse_from_rfc3339(status_timestamp_after).map_err(|e| {
1370 A2AError::DatabaseError(format!(
1371 "Invalid timestamp value: {} ({})",
1372 status_timestamp_after, e
1373 ))
1374 })?;
1375 where_conditions.push(self.dialect.updated_since_predicate().to_string());
1376 Some(
1377 self.dialect
1378 .format_timestamp(timestamp.with_timezone(&chrono::Utc)),
1379 )
1380 } else {
1381 None
1382 };
1383
1384 let where_clause = if where_conditions.is_empty() {
1386 String::new()
1387 } else {
1388 format!(" WHERE {}", where_conditions.join(" AND "))
1389 };
1390
1391 let count_sql = format!("SELECT COUNT(*) as count FROM tasks{}", where_clause);
1393 let count_query = self.sql(&count_sql);
1394 let mut count_q = sqlx::query(&count_query);
1395
1396 if let Some(ref context_id) = params.context_id {
1398 count_q = count_q.bind(context_id);
1399 }
1400 if let Some(ref status) = params.status {
1401 let state_str = state_str(*status);
1402 count_q = count_q.bind(state_str);
1403 }
1404 if let Some(ref ts) = timestamp_str {
1405 count_q = count_q.bind(ts);
1406 }
1407
1408 let count_row = count_q
1409 .fetch_one(&self.pool)
1410 .await
1411 .map_err(|e| A2AError::DatabaseError(format!("Failed to count tasks: {}", e)))?;
1412
1413 let total_size: i32 = count_row
1416 .try_get::<i64, _>("count")
1417 .map_err(|e| A2AError::DatabaseError(format!("Failed to get count: {}", e)))?
1418 .try_into()
1419 .unwrap_or(i32::MAX);
1420
1421 let page_size = params.page_size.unwrap_or(50).clamp(1, 100);
1423 let offset = if let Some(ref token) = params.page_token {
1424 token.parse::<i32>().unwrap_or(0)
1425 } else {
1426 0
1427 };
1428
1429 let main_sql = format!(
1431 "SELECT {TASK_COLUMNS} FROM tasks{} ORDER BY updated_at DESC LIMIT ? OFFSET ?",
1432 where_clause
1433 );
1434 let main_query = self.sql(&main_sql);
1435
1436 let mut main_q = sqlx::query(&main_query);
1437
1438 if let Some(ref context_id) = params.context_id {
1440 main_q = main_q.bind(context_id);
1441 }
1442 if let Some(ref status) = params.status {
1443 let state_str = state_str(*status);
1444 main_q = main_q.bind(state_str);
1445 }
1446 if let Some(ref ts) = timestamp_str {
1447 main_q = main_q.bind(ts);
1448 }
1449
1450 main_q = main_q.bind(page_size).bind(offset);
1452
1453 let rows = main_q
1454 .fetch_all(&self.pool)
1455 .await
1456 .map_err(|e| A2AError::DatabaseError(format!("Failed to list tasks: {}", e)))?;
1457
1458 let mut tasks: Vec<Task> = rows
1460 .iter()
1461 .filter_map(|row| Self::row_to_task(row).ok())
1462 .collect();
1463
1464 let history_length = params.history_length.unwrap_or(0);
1466 for task in &mut tasks {
1467 if history_length > 0 {
1468 let history = self
1469 .load_task_history(&task.id, Some(history_length as u32))
1470 .await?;
1471 task.history = history;
1472 } else {
1473 task.history.clear();
1474 }
1475
1476 if !params.include_artifacts.unwrap_or(false) {
1478 task.artifacts.clear();
1479 }
1480 }
1481
1482 let has_more = offset + page_size < total_size;
1484 let next_page_token = if has_more {
1485 (offset + page_size).to_string()
1486 } else {
1487 String::new()
1488 };
1489
1490 Ok(ListTasksResult {
1491 tasks,
1492 total_size,
1493 page_size,
1494 next_page_token,
1495 })
1496 }
1497}
1498
1499#[cfg(feature = "sqlx-storage")]
1500#[async_trait]
1501impl AsyncNotificationManager for SqlxTaskStorage {
1502 async fn get_config(
1503 &self,
1504 params: &crate::domain::GetTaskPushNotificationConfigParams,
1505 ) -> Result<crate::domain::TaskPushNotificationConfig, A2AError> {
1506 let by_id = self.sql(
1511 "SELECT id, task_id, url, token, authentication FROM push_notification_configs \
1512 WHERE task_id = ? AND id = ?",
1513 );
1514 let by_task = self.sql(
1515 "SELECT id, task_id, url, token, authentication FROM push_notification_configs \
1516 WHERE task_id = ? ORDER BY id LIMIT 1",
1517 );
1518 let row = match params.push_notification_config_id.as_ref() {
1519 Some(config_id) => sqlx::query(&by_id).bind(¶ms.id).bind(config_id),
1520 None => sqlx::query(&by_task).bind(¶ms.id),
1521 }
1522 .fetch_optional(&self.pool)
1523 .await
1524 .map_err(|e| A2AError::DatabaseError(format!("Failed to get push config: {}", e)))?;
1525
1526 if let Some(row) = row {
1527 let id: String = row
1528 .try_get("id")
1529 .map_err(|e| A2AError::DatabaseError(format!("Failed to get config id: {}", e)))?;
1530 let url: String = row
1531 .try_get("url")
1532 .map_err(|e| A2AError::DatabaseError(format!("Failed to get url: {}", e)))?;
1533 let token: Option<String> = row.try_get("token").ok();
1534 let auth_json: Option<String> = row.try_get("authentication").ok();
1535
1536 let auth_info = if let Some(auth_str) = auth_json {
1537 serde_json::from_str(&auth_str).ok()
1538 } else {
1539 None
1540 };
1541
1542 Ok(crate::domain::TaskPushNotificationConfig {
1543 task_id: params.id.clone(),
1544 id,
1545 url,
1546 token: token.unwrap_or_default(),
1547 authentication: auth_info.into(),
1548 tenant: "".to_string(),
1549 ..Default::default()
1550 })
1551 } else {
1552 Err(A2AError::TaskNotFound(format!(
1553 "Push notification config not found for task {}{}",
1554 params.id,
1555 params
1556 .push_notification_config_id
1557 .as_ref()
1558 .map(|id| format!(" with id {}", id))
1559 .unwrap_or_default()
1560 )))
1561 }
1562 }
1563
1564 async fn list_configs(
1565 &self,
1566 params: &crate::domain::ListTaskPushNotificationConfigsParams,
1567 ) -> Result<Vec<crate::domain::TaskPushNotificationConfig>, A2AError> {
1568 let sql = self.sql(
1570 "SELECT id, task_id, url, token, authentication FROM push_notification_configs \
1571 WHERE task_id = ?",
1572 );
1573 let rows = sqlx::query(&sql)
1574 .bind(¶ms.id)
1575 .fetch_all(&self.pool)
1576 .await
1577 .map_err(|e| A2AError::DatabaseError(format!("Failed to list push configs: {}", e)))?;
1578
1579 let configs: Vec<crate::domain::TaskPushNotificationConfig> = rows
1580 .iter()
1581 .filter_map(|row| {
1582 let id: String = row.try_get("id").ok()?;
1583 let url: String = row.try_get("url").ok()?;
1584 let token: Option<String> = row.try_get("token").ok().flatten();
1585 let auth_json: Option<String> = row.try_get("authentication").ok().flatten();
1586
1587 let auth_info = if let Some(auth_str) = auth_json {
1588 serde_json::from_str(&auth_str).ok()
1589 } else {
1590 None
1591 };
1592
1593 Some(crate::domain::TaskPushNotificationConfig {
1594 task_id: params.id.clone(),
1595 id,
1596 url,
1597 token: token.unwrap_or_default(),
1598 authentication: auth_info.into(),
1599 tenant: "".to_string(),
1600 ..Default::default()
1601 })
1602 })
1603 .collect();
1604
1605 Ok(configs)
1606 }
1607
1608 async fn delete_config(
1609 &self,
1610 params: &crate::domain::DeleteTaskPushNotificationConfigParams,
1611 ) -> Result<(), A2AError> {
1612 let all_for_task = self.sql("DELETE FROM push_notification_configs WHERE task_id = ?");
1616 let one = self.sql("DELETE FROM push_notification_configs WHERE task_id = ? AND id = ?");
1617 let query = if params.push_notification_config_id.is_empty() {
1618 sqlx::query(&all_for_task).bind(¶ms.id)
1619 } else {
1620 sqlx::query(&one)
1621 .bind(¶ms.id)
1622 .bind(¶ms.push_notification_config_id)
1623 };
1624 let _result = query
1625 .execute(&self.pool)
1626 .await
1627 .map_err(|e| A2AError::DatabaseError(format!("Failed to delete push config: {}", e)))?;
1628
1629 Ok(())
1631 }
1632
1633 async fn set_config(
1634 &self,
1635 config: &TaskPushNotificationConfig,
1636 ) -> Result<TaskPushNotificationConfig, A2AError> {
1637 let config_id = if config.id.is_empty() {
1639 uuid::Uuid::new_v4().to_string()
1640 } else {
1641 config.id.clone()
1642 };
1643
1644 let auth_json = config
1646 .authentication
1647 .as_option()
1648 .map(|auth| serde_json::to_string(auth).unwrap_or_default());
1649
1650 sqlx::query(self.dialect.upsert_push_config())
1652 .bind(&config_id)
1653 .bind(&config.task_id)
1654 .bind(&config.url)
1655 .bind(&config.token)
1656 .bind(auth_json)
1657 .execute(&self.pool)
1658 .await
1659 .map_err(|e| {
1660 A2AError::DatabaseError(format!("Failed to set push notification config: {}", e))
1661 })?;
1662
1663 self.push_notification_registry
1665 .register(&config.task_id, config.clone())
1666 .await?;
1667
1668 let mut result_config = config.clone();
1670 result_config.id = config_id;
1671 Ok(result_config)
1672 }
1673}
1674
1675#[cfg(feature = "sqlx-storage")]
1676impl Clone for SqlxTaskStorage {
1677 fn clone(&self) -> Self {
1678 Self {
1679 pool: self.pool.clone(),
1680 dialect: self.dialect,
1681 push_notification_registry: self.push_notification_registry.clone(),
1682 event_log_capacity: self.event_log_capacity,
1683 read_refresh: self.read_refresh,
1684 claim_cache: self.claim_cache.clone(),
1685 }
1686 }
1687}
1688
1689#[cfg(feature = "sqlx-storage")]
1690#[async_trait]
1691impl AsyncConversationStore for SqlxTaskStorage {
1692 async fn load(
1693 &self,
1694 context_id: &ContextId,
1695 caller: Option<&str>,
1696 limit: Option<u32>,
1697 ) -> Result<Conversation, A2AError> {
1698 let context_id = context_id.as_str();
1699 self.claim_or_check_context(context_id, caller).await?;
1704
1705 let digest_sql = self.sql(
1709 "SELECT covers_through_seq, summary, replaced_messages, model \
1710 FROM context_digests WHERE context_id = ? \
1711 ORDER BY covers_through_seq DESC LIMIT 1",
1712 );
1713 let digest_row = sqlx::query(&digest_sql)
1714 .bind(context_id)
1715 .fetch_optional(&self.pool)
1716 .await
1717 .map_err(|e| {
1718 A2AError::DatabaseError(format!("Failed to load context digest: {}", e))
1719 })?;
1720
1721 let digest = match digest_row {
1722 Some(row) => {
1723 let covers_through: i64 = row.try_get("covers_through_seq").map_err(|e| {
1724 A2AError::DatabaseError(format!("Failed to get digest watermark: {}", e))
1725 })?;
1726 let summary: String = row.try_get("summary").map_err(|e| {
1727 A2AError::DatabaseError(format!("Failed to get digest summary: {}", e))
1728 })?;
1729 let replaced_messages: i64 = row.try_get("replaced_messages").map_err(|e| {
1730 A2AError::DatabaseError(format!("Failed to get digest message count: {}", e))
1731 })?;
1732 let model: String = row.try_get("model").map_err(|e| {
1733 A2AError::DatabaseError(format!("Failed to get digest model: {}", e))
1734 })?;
1735 Some(Digest {
1736 covers_through: Seq::new(covers_through.max(0) as u64),
1737 summary,
1738 replaced_messages: replaced_messages.max(0) as u32,
1739 model,
1740 })
1741 }
1742 None => None,
1743 };
1744
1745 let watermark = digest
1746 .as_ref()
1747 .map(|digest| digest.covers_through.get())
1748 .unwrap_or(0) as i64;
1749
1750 let query = match limit {
1756 Some(limit) => format!(
1757 "SELECT id, message FROM task_history \
1758 WHERE context_id = ? AND id > ? AND message IS NOT NULL \
1759 ORDER BY id DESC LIMIT {}",
1760 limit
1761 ),
1762 None => "SELECT id, message FROM task_history \
1763 WHERE context_id = ? AND id > ? AND message IS NOT NULL \
1764 ORDER BY id DESC"
1765 .to_string(),
1766 };
1767 let query = self.sql(&query);
1768
1769 let rows = sqlx::query(&query)
1770 .bind(context_id)
1771 .bind(watermark)
1772 .fetch_all(&self.pool)
1773 .await
1774 .map_err(|e| A2AError::DatabaseError(format!("Failed to load conversation: {}", e)))?;
1775
1776 let mut tail = Vec::with_capacity(rows.len());
1777 for row in rows {
1778 let seq: i64 = row.try_get("id").map_err(|e| {
1779 A2AError::DatabaseError(format!("Failed to get history sequence: {}", e))
1780 })?;
1781 let message_json: String = row.try_get("message").map_err(|e| {
1782 A2AError::DatabaseError(format!("Failed to get history message: {}", e))
1783 })?;
1784 let message: Message = serde_json::from_str(&message_json).map_err(|e| {
1785 A2AError::DatabaseError(format!("Failed to parse history message: {}", e))
1786 })?;
1787 tail.push(SequencedMessage {
1788 seq: Seq::new(seq.max(0) as u64),
1789 message,
1790 });
1791 }
1792 tail.reverse();
1793
1794 Ok(Conversation { digest, tail })
1795 }
1796
1797 async fn compact(
1798 &self,
1799 context_id: &ContextId,
1800 caller: Option<&str>,
1801 digest: Digest,
1802 ) -> Result<(), A2AError> {
1803 let context_id = context_id.as_str();
1804 self.claim_or_check_context(context_id, caller).await?;
1805
1806 let sql = self.sql(
1807 "INSERT INTO context_digests \
1808 (context_id, covers_through_seq, summary, replaced_messages, model) \
1809 VALUES (?, ?, ?, ?, ?)",
1810 );
1811 sqlx::query(&sql)
1812 .bind(context_id)
1813 .bind(digest.covers_through.get() as i64)
1814 .bind(&digest.summary)
1815 .bind(digest.replaced_messages as i64)
1816 .bind(&digest.model)
1817 .execute(&self.pool)
1818 .await
1819 .map_err(|e| {
1820 A2AError::DatabaseError(format!("Failed to append context digest: {}", e))
1821 })?;
1822
1823 Ok(())
1824 }
1825}
1826
1827#[cfg(feature = "sqlx-storage")]
1832fn scope_column(scope: StateScope) -> Option<&'static str> {
1833 match scope {
1834 StateScope::User => Some("user"),
1835 StateScope::Context => Some("context"),
1836 StateScope::Temp => None,
1838 }
1839}
1840
1841#[cfg(feature = "sqlx-storage")]
1842#[async_trait]
1843impl AsyncContextStateStore for SqlxTaskStorage {
1844 async fn load_state(
1845 &self,
1846 context_id: &ContextId,
1847 caller: Option<&str>,
1848 ) -> Result<ContextState, A2AError> {
1849 let context_id = context_id.as_str();
1850 self.claim_or_check_context(context_id, caller).await?;
1854
1855 let sql = self.sql(
1860 "SELECT scope, name, value FROM context_state \
1861 WHERE (scope = 'context' AND scope_key = ?) \
1862 OR (scope = 'user' AND scope_key = ?)",
1863 );
1864 let rows = sqlx::query(&sql)
1865 .bind(context_id)
1866 .bind(caller)
1867 .fetch_all(&self.pool)
1868 .await
1869 .map_err(|e| A2AError::DatabaseError(format!("Failed to load context state: {}", e)))?;
1870
1871 let mut state = ContextState::new();
1872 for row in rows {
1873 let scope: String = row.try_get("scope").map_err(|e| {
1874 A2AError::DatabaseError(format!("Failed to get state scope: {}", e))
1875 })?;
1876 let name: String = row
1877 .try_get("name")
1878 .map_err(|e| A2AError::DatabaseError(format!("Failed to get state key: {}", e)))?;
1879 let value: String = row.try_get("value").map_err(|e| {
1880 A2AError::DatabaseError(format!("Failed to get state value: {}", e))
1881 })?;
1882
1883 let scope = match scope.as_str() {
1884 "user" => StateScope::User,
1885 "context" => StateScope::Context,
1886 _other => {
1890 #[cfg(feature = "tracing")]
1891 tracing::warn!("ignoring state row with unknown scope '{_other}'");
1892 continue;
1893 }
1894 };
1895 match StateKey::scoped(scope, &name) {
1896 Ok(key) => state.insert(key, value),
1897 Err(_e) => {
1898 #[cfg(feature = "tracing")]
1899 tracing::warn!("ignoring unusable state key '{name}': {_e}");
1900 }
1901 }
1902 }
1903
1904 if let Some(caller) = caller {
1905 self.refresh_user_state(caller).await?;
1906 }
1907 Ok(state)
1908 }
1909
1910 async fn remember(
1911 &self,
1912 context_id: &ContextId,
1913 caller: Option<&str>,
1914 key: &StateKey,
1915 value: &str,
1916 ) -> Result<Remembered, A2AError> {
1917 let context_id = context_id.as_str();
1918 self.claim_or_check_context(context_id, caller).await?;
1919
1920 let (Some(scope_key), Some(scope)) = (
1921 scope_key(key.scope(), context_id, caller, key)?,
1922 scope_column(key.scope()),
1923 ) else {
1924 return Ok(Remembered::NotStored);
1925 };
1926
1927 let mut tx = self.pool.begin().await.map_err(|e| {
1937 A2AError::DatabaseError(format!("Failed to open a state transaction: {}", e))
1938 })?;
1939
1940 let read = self
1941 .sql("SELECT value FROM context_state WHERE scope = ? AND scope_key = ? AND name = ?");
1942 let previous: Option<String> = sqlx::query(&read)
1943 .bind(scope)
1944 .bind(scope_key)
1945 .bind(key.name())
1946 .fetch_optional(&mut *tx)
1947 .await
1948 .map_err(|e| A2AError::DatabaseError(format!("Failed to read context state: {}", e)))?
1949 .map(|row| row.try_get("value"))
1950 .transpose()
1951 .map_err(|e| A2AError::DatabaseError(format!("Failed to read context state: {}", e)))?;
1952
1953 sqlx::query(self.dialect.upsert_context_state())
1954 .bind(scope)
1955 .bind(scope_key)
1956 .bind(key.name())
1957 .bind(value)
1958 .execute(&mut *tx)
1959 .await
1960 .map_err(|e| {
1961 A2AError::DatabaseError(format!("Failed to write context state: {}", e))
1962 })?;
1963
1964 tx.commit().await.map_err(|e| {
1965 A2AError::DatabaseError(format!("Failed to commit context state: {}", e))
1966 })?;
1967
1968 Ok(match previous {
1969 None => Remembered::Stored,
1970 Some(previous) if previous == value => Remembered::Unchanged,
1971 Some(previous) => Remembered::Replaced { previous },
1972 })
1973 }
1974
1975 async fn forget(
1976 &self,
1977 context_id: &ContextId,
1978 caller: Option<&str>,
1979 key: &StateKey,
1980 ) -> Result<bool, A2AError> {
1981 let context_id = context_id.as_str();
1982 self.claim_or_check_context(context_id, caller).await?;
1983
1984 let (Some(scope_key), Some(scope)) = (
1985 scope_key(key.scope(), context_id, caller, key)?,
1986 scope_column(key.scope()),
1987 ) else {
1988 return Ok(false);
1989 };
1990
1991 let sql =
1992 self.sql("DELETE FROM context_state WHERE scope = ? AND scope_key = ? AND name = ?");
1993 let deleted = sqlx::query(&sql)
1994 .bind(scope)
1995 .bind(scope_key)
1996 .bind(key.name())
1997 .execute(&self.pool)
1998 .await
1999 .map_err(|e| A2AError::DatabaseError(format!("Failed to drop context state: {}", e)))?;
2000 Ok(deleted.rows_affected() > 0)
2001 }
2002}
2003
2004#[cfg(feature = "sqlx-storage")]
2010const KIND_STATUS: &str = "status-update";
2011#[cfg(feature = "sqlx-storage")]
2012const KIND_ARTIFACT: &str = "artifact-update";
2013
2014#[cfg(feature = "sqlx-storage")]
2015#[async_trait]
2016impl AsyncEventLog for SqlxTaskStorage {
2017 async fn append(&self, task_id: &str, event: UpdateEvent) -> Result<SeqEvent, A2AError> {
2018 let (kind, payload) = match &event {
2019 UpdateEvent::StatusUpdate(update) => (KIND_STATUS, serde_json::to_string(update)),
2020 UpdateEvent::ArtifactUpdate(update) => (KIND_ARTIFACT, serde_json::to_string(update)),
2021 };
2022 let payload = payload.map_err(|e| {
2023 A2AError::DatabaseError(format!("Failed to serialize a stream event: {e}"))
2024 })?;
2025
2026 let sql = self.dialect.insert_task_event();
2027 let id: i64 = sqlx::query(sql)
2028 .bind(task_id)
2029 .bind(kind)
2030 .bind(&payload)
2031 .bind(task_id)
2032 .fetch_one(&self.pool)
2033 .await
2034 .and_then(|row| row.try_get("id"))
2035 .map_err(|e| {
2036 A2AError::DatabaseError(format!("Failed to log a stream event for {task_id}: {e}"))
2037 })?;
2038 let id = id as u64;
2039
2040 if let Some(capacity) = self.event_log_capacity
2041 && let Some(cutoff) = id.checked_sub(capacity)
2042 {
2043 let sql = self.sql("DELETE FROM task_events WHERE task_id = ? AND id <= ?");
2044 sqlx::query(&sql)
2045 .bind(task_id)
2046 .bind(cutoff as i64)
2047 .execute(&self.pool)
2048 .await
2049 .map_err(|e| {
2050 A2AError::DatabaseError(format!(
2051 "Failed to trim the stream log for {task_id}: {e}"
2052 ))
2053 })?;
2054 }
2055
2056 Ok(SeqEvent::new(id, event))
2057 }
2058
2059 async fn replay(&self, task_id: &str, from: u64) -> Result<Replay, A2AError> {
2060 let sql = self.sql("SELECT MIN(id) AS oldest FROM task_events WHERE task_id = ?");
2064 let oldest: Option<i64> = sqlx::query(&sql)
2065 .bind(task_id)
2066 .fetch_one(&self.pool)
2067 .await
2068 .and_then(|row| row.try_get("oldest"))
2069 .map_err(|e| {
2070 A2AError::DatabaseError(format!("Failed to read the stream log for {task_id}: {e}"))
2071 })?;
2072
2073 let sql = self.sql(
2074 "SELECT id, kind, payload FROM task_events \
2075 WHERE task_id = ? AND id > ? ORDER BY id",
2076 );
2077 let rows = sqlx::query(&sql)
2078 .bind(task_id)
2079 .bind(from as i64)
2080 .fetch_all(&self.pool)
2081 .await
2082 .map_err(|e| {
2083 A2AError::DatabaseError(format!("Failed to replay the log for {task_id}: {e}"))
2084 })?;
2085
2086 let events = rows
2087 .iter()
2088 .map(Self::row_to_seq_event)
2089 .collect::<Result<Vec<_>, _>>()?;
2090
2091 Ok(Replay::bounded_by(
2092 oldest.map(|oldest| oldest as u64),
2093 from,
2094 events,
2095 ))
2096 }
2097
2098 async fn discard(&self, task_id: &str) -> Result<(), A2AError> {
2099 let sql = self.sql("DELETE FROM task_events WHERE task_id = ?");
2100 sqlx::query(&sql)
2101 .bind(task_id)
2102 .execute(&self.pool)
2103 .await
2104 .map_err(|e| {
2105 A2AError::DatabaseError(format!(
2106 "Failed to discard the stream log for {task_id}: {e}"
2107 ))
2108 })?;
2109 Ok(())
2110 }
2111}
2112
2113#[cfg(feature = "sqlx-storage")]
2114impl SqlxTaskStorage {
2115 fn row_to_seq_event(row: &sqlx::any::AnyRow) -> Result<SeqEvent, A2AError> {
2117 let read = |column: &str| -> Result<String, A2AError> {
2118 row.try_get(column).map_err(|e| {
2119 A2AError::DatabaseError(format!("Failed to read stream event {column}: {e}"))
2120 })
2121 };
2122 let id: i64 = row
2123 .try_get("id")
2124 .map_err(|e| A2AError::DatabaseError(format!("Failed to read stream event id: {e}")))?;
2125 let kind = read("kind")?;
2126 let payload = read("payload")?;
2127
2128 fn parse(what: &'static str) -> impl Fn(serde_json::Error) -> A2AError {
2129 move |e| A2AError::DatabaseError(format!("Failed to parse a logged {what}: {e}"))
2130 }
2131 let event = match kind.as_str() {
2132 KIND_STATUS => UpdateEvent::StatusUpdate(
2133 serde_json::from_str(&payload).map_err(parse("status update"))?,
2134 ),
2135 KIND_ARTIFACT => UpdateEvent::ArtifactUpdate(
2136 serde_json::from_str(&payload).map_err(parse("artifact update"))?,
2137 ),
2138 other => {
2139 return Err(A2AError::DatabaseError(format!(
2140 "Unknown stream event kind {other:?} in task_events"
2141 )));
2142 }
2143 };
2144
2145 Ok(SeqEvent::new(id as u64, event))
2146 }
2147}
2148
2149#[cfg(feature = "sqlx-storage")]
2150#[async_trait]
2151impl AsyncRetention for SqlxTaskStorage {
2152 async fn sweep(
2153 &self,
2154 policy: &RetentionPolicy,
2155 now: chrono::DateTime<chrono::Utc>,
2156 ) -> Result<Swept, A2AError> {
2157 let mut swept = Swept::default();
2158
2159 if let Some(cutoff) = policy.context_cutoff(now) {
2160 for context_id in self.idle_contexts(cutoff).await? {
2161 swept += self.delete_context(&context_id).await?;
2162 }
2163 }
2164
2165 if let Some(cutoff) = policy.user_state_cutoff(now) {
2166 for principal in self.idle_principals(cutoff).await? {
2167 swept.state_keys += self.delete_user_state(&principal).await?;
2168 }
2169 }
2170
2171 Ok(swept)
2172 }
2173}
2174
2175#[cfg(feature = "sqlx-storage")]
2176impl SqlxTaskStorage {
2177 async fn idle_contexts(
2180 &self,
2181 cutoff: chrono::DateTime<chrono::Utc>,
2182 ) -> Result<Vec<String>, A2AError> {
2183 let rows = sqlx::query(self.dialect.idle_contexts())
2184 .bind(self.dialect.format_timestamp(cutoff))
2185 .fetch_all(&self.pool)
2186 .await
2187 .map_err(|e| A2AError::DatabaseError(format!("Failed to find idle contexts: {e}")))?;
2188
2189 rows.iter()
2190 .map(|row| {
2191 row.try_get("ctx").map_err(|e| {
2192 A2AError::DatabaseError(format!("Failed to read idle context id: {e}"))
2193 })
2194 })
2195 .collect()
2196 }
2197
2198 async fn idle_principals(
2200 &self,
2201 cutoff: chrono::DateTime<chrono::Utc>,
2202 ) -> Result<Vec<String>, A2AError> {
2203 let rows = sqlx::query(self.dialect.idle_principals())
2204 .bind(self.dialect.format_timestamp(cutoff))
2205 .fetch_all(&self.pool)
2206 .await
2207 .map_err(|e| A2AError::DatabaseError(format!("Failed to find idle principals: {e}")))?;
2208
2209 rows.iter()
2210 .map(|row| {
2211 row.try_get("scope_key").map_err(|e| {
2212 A2AError::DatabaseError(format!("Failed to read idle principal: {e}"))
2213 })
2214 })
2215 .collect()
2216 }
2217
2218 async fn delete_context(&self, context_id: &str) -> Result<Swept, A2AError> {
2231 let mut tx = self.pool.begin().await.map_err(|e| {
2232 A2AError::DatabaseError(format!("Failed to open a sweep transaction: {e}"))
2233 })?;
2234
2235 let fail = |table: &str, e: sqlx::Error| {
2236 A2AError::DatabaseError(format!("Failed to sweep {table} for {context_id}: {e}"))
2237 };
2238
2239 let sql = self.sql(
2240 "DELETE FROM push_notification_configs WHERE task_id IN (SELECT id FROM tasks WHERE context_id = ?)",
2241 );
2242 sqlx::query(&sql)
2243 .bind(context_id)
2244 .execute(&mut *tx)
2245 .await
2246 .map_err(|e| fail("push configs", e))?;
2247
2248 let sql = self.sql(
2255 "SELECT COUNT(*) AS count FROM task_history WHERE message IS NOT NULL AND (context_id = ? OR task_id IN (SELECT id FROM tasks WHERE context_id = ?))",
2256 );
2257 let messages: i64 = sqlx::query(&sql)
2258 .bind(context_id)
2259 .bind(context_id)
2260 .fetch_one(&mut *tx)
2261 .await
2262 .and_then(|row| row.try_get("count"))
2263 .map_err(|e| fail("history", e))?;
2264
2265 let sql = self.sql(
2269 "DELETE FROM task_history WHERE context_id = ? OR task_id IN (SELECT id FROM tasks WHERE context_id = ?)",
2270 );
2271 sqlx::query(&sql)
2272 .bind(context_id)
2273 .bind(context_id)
2274 .execute(&mut *tx)
2275 .await
2276 .map_err(|e| fail("history", e))?;
2277
2278 let sql = self.sql(
2281 "DELETE FROM task_events WHERE task_id IN (SELECT id FROM tasks WHERE context_id = ?)",
2282 );
2283 sqlx::query(&sql)
2284 .bind(context_id)
2285 .execute(&mut *tx)
2286 .await
2287 .map_err(|e| fail("stream events", e))?;
2288
2289 let sql = self.sql("DELETE FROM tasks WHERE context_id = ?");
2290 let tasks = sqlx::query(&sql)
2291 .bind(context_id)
2292 .execute(&mut *tx)
2293 .await
2294 .map_err(|e| fail("tasks", e))?;
2295
2296 let sql = self.sql("DELETE FROM context_digests WHERE context_id = ?");
2297 let digests = sqlx::query(&sql)
2298 .bind(context_id)
2299 .execute(&mut *tx)
2300 .await
2301 .map_err(|e| fail("digests", e))?;
2302
2303 let sql = self.sql("DELETE FROM context_state WHERE scope = 'context' AND scope_key = ?");
2304 let state_keys = sqlx::query(&sql)
2305 .bind(context_id)
2306 .execute(&mut *tx)
2307 .await
2308 .map_err(|e| fail("state", e))?;
2309
2310 let sql = self.sql("DELETE FROM contexts WHERE id = ?");
2311 sqlx::query(&sql)
2312 .bind(context_id)
2313 .execute(&mut *tx)
2314 .await
2315 .map_err(|e| fail("the context row", e))?;
2316
2317 tx.commit().await.map_err(|e| {
2318 A2AError::DatabaseError(format!("Failed to commit the sweep of {context_id}: {e}"))
2319 })?;
2320
2321 if let Some(cache) = self.claim_cache.as_ref() {
2326 cache.forget(context_id);
2327 }
2328
2329 Ok(Swept {
2330 contexts: 1,
2331 tasks: tasks.rows_affected(),
2332 messages: messages.max(0) as u64,
2333 digests: digests.rows_affected(),
2334 state_keys: state_keys.rows_affected(),
2335 })
2336 }
2337
2338 async fn delete_user_state(&self, principal: &str) -> Result<u64, A2AError> {
2340 let sql = self.sql("DELETE FROM context_state WHERE scope = 'user' AND scope_key = ?");
2341 let deleted = sqlx::query(&sql)
2342 .bind(principal)
2343 .execute(&self.pool)
2344 .await
2345 .map_err(|e| {
2346 A2AError::DatabaseError(format!("Failed to sweep user state for {principal}: {e}"))
2347 })?;
2348 Ok(deleted.rows_affected())
2349 }
2350}
2351
2352#[cfg(all(test, feature = "sqlx-storage"))]
2353mod tests {
2354 use super::*;
2355
2356 #[tokio::test]
2364 async fn the_dead_state_column_is_dropped_on_the_next_start() {
2365 let dir = tempfile::tempdir().unwrap();
2366 let url = format!("sqlite:{}?mode=rwc", dir.path().join("a2a.db").display());
2367
2368 let storage = SqlxTaskStorage::new(&url).await.unwrap();
2369 sqlx::raw_sql("ALTER TABLE contexts ADD COLUMN state TEXT NOT NULL DEFAULT '{}'")
2370 .execute(&storage.pool)
2371 .await
2372 .expect("put the pre-006 column back");
2373 assert!(has_state_column(&storage).await);
2374 drop(storage);
2375
2376 let restarted = SqlxTaskStorage::new(&url).await.unwrap();
2377 assert!(
2378 !has_state_column(&restarted).await,
2379 "the unused column should be gone after the migration runs"
2380 );
2381 }
2382
2383 #[tokio::test]
2389 async fn sqlite_connections_enforce_foreign_keys() {
2390 let storage = SqlxTaskStorage::new("sqlite::memory:").await.unwrap();
2391
2392 let on: i64 = sqlx::query("PRAGMA foreign_keys")
2393 .fetch_one(&storage.pool)
2394 .await
2395 .unwrap()
2396 .try_get(0)
2397 .unwrap();
2398 assert_eq!(on, 1);
2399 }
2400
2401 #[tokio::test]
2406 async fn the_url_cannot_speak_for_foreign_keys() {
2407 let dir = tempfile::tempdir().unwrap();
2408 let url = format!(
2409 "sqlite:{}?mode=rwc&foreign_keys=off",
2410 dir.path().join("a2a.db").display()
2411 );
2412
2413 let refused = SqlxTaskStorage::new(&url).await;
2414 assert!(
2415 refused.is_err(),
2416 "a SQLite-specific URL parameter should not reach the driver"
2417 );
2418 }
2419
2420 #[tokio::test]
2424 async fn deleting_a_task_cascades_to_its_history() {
2425 let storage = SqlxTaskStorage::new("sqlite::memory:").await.unwrap();
2426 let (task, context) = (tid("task-cascade"), cid("ctx-cascade"));
2427
2428 storage.create(&task, &context).await.unwrap();
2429 storage
2430 .update_status(&task, TaskState::Working, None)
2431 .await
2432 .unwrap();
2433 assert!(history_rows(&storage, "task-cascade").await > 0);
2434
2435 sqlx::query("DELETE FROM tasks WHERE id = ?")
2436 .bind("task-cascade")
2437 .execute(&storage.pool)
2438 .await
2439 .unwrap();
2440
2441 assert_eq!(
2442 history_rows(&storage, "task-cascade").await,
2443 0,
2444 "history should go with the task it references"
2445 );
2446 }
2447
2448 async fn history_rows(storage: &SqlxTaskStorage, task_id: &str) -> i64 {
2449 sqlx::query("SELECT COUNT(*) AS count FROM task_history WHERE task_id = ?")
2450 .bind(task_id)
2451 .fetch_one(&storage.pool)
2452 .await
2453 .unwrap()
2454 .try_get("count")
2455 .unwrap()
2456 }
2457
2458 fn tid(s: &str) -> TaskId {
2459 s.parse().unwrap()
2460 }
2461 fn cid(s: &str) -> ContextId {
2462 s.parse().unwrap()
2463 }
2464
2465 async fn has_state_column(storage: &SqlxTaskStorage) -> bool {
2466 sqlx::query(storage.dialect.dead_context_state_column_probe())
2467 .fetch_optional(&storage.pool)
2468 .await
2469 .unwrap()
2470 .is_some()
2471 }
2472
2473 #[test]
2476 fn a_cached_claim_is_only_returned_inside_its_window() {
2477 let cache = ClaimCache::new(Duration::from_secs(60));
2478 cache.insert("ctx-1", &ContextClaim::Owner("alice".to_string()));
2479 assert!(matches!(
2480 cache.get("ctx-1"),
2481 Some(ContextClaim::Owner(owner)) if owner == "alice"
2482 ));
2483
2484 let expired = ClaimCache::new(Duration::ZERO);
2485 expired.insert("ctx-1", &ContextClaim::Open);
2486 assert!(expired.get("ctx-1").is_none());
2487 }
2488
2489 #[test]
2490 fn forgetting_a_context_drops_what_was_cached_for_it() {
2491 let cache = ClaimCache::new(Duration::from_secs(60));
2492 cache.insert("ctx-1", &ContextClaim::Open);
2493 cache.forget("ctx-1");
2494 assert!(cache.get("ctx-1").is_none());
2495 }
2496
2497 #[test]
2500 fn a_full_cache_starts_over_rather_than_growing() {
2501 let cache = ClaimCache::new(Duration::from_secs(60));
2502 for i in 0..=CLAIM_CACHE_CAPACITY {
2503 cache.insert(&format!("ctx-{i}"), &ContextClaim::Open);
2504 }
2505 assert!(cache.entries.lock().unwrap().len() <= CLAIM_CACHE_CAPACITY);
2506 }
2507
2508 #[tokio::test]
2516 async fn a_cached_owner_outlives_a_row_changed_behind_the_store() {
2517 let dir = tempfile::tempdir().unwrap();
2518 let url = format!("sqlite:{}?mode=rwc", dir.path().join("a2a.db").display());
2519 let storage = SqlxTaskStorage::builder(&url)
2520 .max_connections(1)
2521 .claim_cache(Some(Duration::from_secs(60)))
2522 .connect()
2523 .await
2524 .unwrap();
2525
2526 let context = cid("ctx-cached");
2527 storage.load_state(&context, Some("alice")).await.unwrap();
2528
2529 sqlx::raw_sql("UPDATE contexts SET owner = 'bob' WHERE id = 'ctx-cached'")
2530 .execute(&storage.pool)
2531 .await
2532 .unwrap();
2533
2534 storage
2535 .load_state(&context, Some("alice"))
2536 .await
2537 .expect("the claim alice opened is still the cached one");
2538
2539 drop(storage);
2542
2543 let uncached = SqlxTaskStorage::builder(&url)
2544 .max_connections(1)
2545 .claim_cache(None)
2546 .connect()
2547 .await
2548 .unwrap();
2549 assert!(
2550 matches!(
2551 uncached.load_state(&context, Some("alice")).await,
2552 Err(A2AError::ContextAccessDenied { .. })
2553 ),
2554 "a store that reads every time sees the row as it now is"
2555 );
2556 }
2557
2558 #[tokio::test]
2568 async fn a_read_refreshes_a_bag_that_is_old_enough() {
2569 let day = Duration::from_secs(24 * 60 * 60);
2570 let storage = SqlxTaskStorage::builder("sqlite::memory:")
2571 .max_connections(1)
2572 .read_refresh(ReadRefresh::after(day))
2573 .connect()
2574 .await
2575 .unwrap();
2576
2577 let context = cid("ctx-refresh");
2578 let name = StateKey::scoped(StateScope::User, "name").unwrap();
2579 storage
2580 .remember(&context, Some("alice"), &name, "Emil")
2581 .await
2582 .unwrap();
2583 age_the_bag(&storage, "alice").await;
2584
2585 storage.load_state(&context, Some("alice")).await.unwrap();
2586
2587 let policy = RetentionPolicy::keep_everything().delete_user_state_idle_for(day);
2588 let swept = storage.sweep(&policy, chrono::Utc::now()).await.unwrap();
2589 assert_eq!(
2590 swept.state_keys, 0,
2591 "the read moved the bag out of the sweep's reach"
2592 );
2593 }
2594
2595 #[tokio::test]
2599 async fn without_a_refresh_the_same_read_leaves_the_bag_sweepable() {
2600 let day = Duration::from_secs(24 * 60 * 60);
2601 let storage = SqlxTaskStorage::builder("sqlite::memory:")
2602 .max_connections(1)
2603 .connect()
2604 .await
2605 .unwrap();
2606
2607 let context = cid("ctx-no-refresh");
2608 let name = StateKey::scoped(StateScope::User, "name").unwrap();
2609 storage
2610 .remember(&context, Some("alice"), &name, "Emil")
2611 .await
2612 .unwrap();
2613 age_the_bag(&storage, "alice").await;
2614
2615 storage.load_state(&context, Some("alice")).await.unwrap();
2616
2617 let policy = RetentionPolicy::keep_everything().delete_user_state_idle_for(day);
2618 let swept = storage.sweep(&policy, chrono::Utc::now()).await.unwrap();
2619 assert_eq!(swept.state_keys, 1);
2620 }
2621
2622 async fn age_the_bag(storage: &SqlxTaskStorage, principal: &str) {
2625 let sql = storage
2626 .sql("UPDATE context_state SET updated_at = ? WHERE scope = 'user' AND scope_key = ?");
2627 let long_ago = storage
2628 .dialect
2629 .format_timestamp(chrono::Utc::now() - chrono::TimeDelta::days(7));
2630 sqlx::query(&sql)
2631 .bind(long_ago)
2632 .bind(principal)
2633 .execute(&storage.pool)
2634 .await
2635 .unwrap();
2636 }
2637
2638 #[tokio::test]
2643 async fn a_swept_context_leaves_nothing_cached() {
2644 let storage = SqlxTaskStorage::builder("sqlite::memory:")
2645 .max_connections(1)
2646 .claim_cache(Some(Duration::from_secs(60)))
2647 .connect()
2648 .await
2649 .unwrap();
2650
2651 let context = cid("ctx-swept");
2652 storage.load_state(&context, Some("alice")).await.unwrap();
2653
2654 let policy =
2655 RetentionPolicy::keep_everything().delete_contexts_idle_for(Duration::from_secs(60));
2656 let swept = storage
2657 .sweep(&policy, chrono::Utc::now() + chrono::TimeDelta::days(30))
2658 .await
2659 .unwrap();
2660 assert_eq!(swept.contexts, 1);
2661
2662 storage
2663 .load_state(&context, Some("bob"))
2664 .await
2665 .expect("nobody holds a context that was swept");
2666 }
2667}