Skip to main content

a2a_rs/adapter/storage/
task_storage.rs

1//! In-memory task storage implementation
2
3// This module is already conditionally compiled with #[cfg(feature = "server")] in mod.rs
4
5use std::collections::HashMap;
6use std::sync::Arc;
7
8use async_trait::async_trait;
9use tokio::sync::Mutex; // Changed from std::sync::Mutex
10
11use crate::adapter::business::push_notification::{
12    PushNotificationRegistry, PushNotificationSender,
13};
14
15#[cfg(feature = "http-client")]
16use crate::adapter::business::push_notification::HttpPushNotificationSender;
17#[cfg(not(feature = "http-client"))]
18use crate::adapter::business::push_notification::NoopPushNotificationSender;
19use crate::domain::{
20    A2AError, ContextId, Message, Task, TaskId, TaskPushNotificationConfig, TaskState,
21    TaskStateExt, VersionedTask,
22};
23use crate::port::{
24    AsyncNotificationManager, AsyncPushNotifier, AsyncTaskLifecycle, AsyncTaskQuery,
25    AsyncTaskVersioning,
26};
27
28/// Simple in-memory task storage for testing and example purposes.
29///
30/// Persistence-only: streaming fan-out lives in
31/// [`InMemoryStreamingHandler`](crate::adapter::InMemoryStreamingHandler) and
32/// push-webhook delivery behind the [`AsyncPushNotifier`] port (this struct hands
33/// out its registry via [`push_notifier`](Self::push_notifier)). The store still
34/// owns push-config CRUD ([`AsyncNotificationManager`]) because that is config
35/// *persistence*.
36pub struct InMemoryTaskStorage {
37    /// Tasks stored by ID
38    pub(crate) tasks: Arc<Mutex<HashMap<String, Task>>>,
39    /// Per-task optimistic-concurrency version, bumped on every mutation.
40    ///
41    /// A separate map keyed by the same task id. Mutators always lock `tasks`
42    /// first and `versions` second, so the two stay consistent and never
43    /// deadlock (see [`AsyncTaskVersioning`]).
44    pub(crate) versions: Arc<Mutex<HashMap<String, u64>>>,
45    /// Push notification registry (config store + delivery backend)
46    pub(crate) push_notification_registry: Arc<PushNotificationRegistry>,
47}
48
49impl InMemoryTaskStorage {
50    /// Create a new empty task storage
51    pub fn new() -> Self {
52        // Use the appropriate push notification sender based on available features
53        #[cfg(feature = "http-client")]
54        let push_sender = HttpPushNotificationSender::new();
55        #[cfg(not(feature = "http-client"))]
56        let push_sender = NoopPushNotificationSender;
57
58        let push_registry = PushNotificationRegistry::new(push_sender);
59
60        Self {
61            tasks: Arc::new(Mutex::new(HashMap::new())),
62            versions: Arc::new(Mutex::new(HashMap::new())),
63            push_notification_registry: Arc::new(push_registry),
64        }
65    }
66
67    /// Create a new task storage with a custom push notification sender
68    pub fn with_push_sender(push_sender: impl PushNotificationSender + 'static) -> Self {
69        let push_registry = PushNotificationRegistry::new(push_sender);
70
71        Self {
72            tasks: Arc::new(Mutex::new(HashMap::new())),
73            versions: Arc::new(Mutex::new(HashMap::new())),
74            push_notification_registry: Arc::new(push_registry),
75        }
76    }
77
78    /// Bump (or initialize) the stored version for a task, returning the new
79    /// value. Callers already hold the `tasks` lock; this acquires `versions`
80    /// second, preserving the global lock order.
81    async fn bump_version(&self, task_id: &str) -> u64 {
82        let mut versions = self.versions.lock().await;
83        let v = versions.entry(task_id.to_string()).or_insert(0);
84        *v += 1;
85        *v
86    }
87
88    /// Hand out this store's push-notification registry as an
89    /// [`AsyncPushNotifier`].
90    ///
91    /// The returned notifier shares the same config registry the store writes to
92    /// via [`AsyncNotificationManager::set_config`], so a config registered on
93    /// the store is immediately visible to the notifier at the composition edge.
94    pub fn push_notifier(&self) -> Arc<dyn AsyncPushNotifier> {
95        self.push_notification_registry.clone()
96    }
97}
98
99impl Default for InMemoryTaskStorage {
100    fn default() -> Self {
101        Self::new()
102    }
103}
104
105#[async_trait]
106impl AsyncTaskLifecycle for InMemoryTaskStorage {
107    async fn create(&self, id: &TaskId, context_id: &ContextId) -> Result<Task, A2AError> {
108        let task_id = id.as_str();
109        let context_id = context_id.as_str();
110        let mut tasks_guard = self.tasks.lock().await;
111
112        if tasks_guard.contains_key(task_id) {
113            return Err(A2AError::TaskNotFound(format!(
114                "Task {} already exists",
115                task_id
116            )));
117        }
118
119        let task = Task::new(task_id.to_string(), context_id.to_string());
120        tasks_guard.insert(task_id.to_string(), task.clone());
121        self.bump_version(task_id).await; // version 0 -> 1
122
123        Ok(task)
124    }
125
126    async fn update_status(
127        &self,
128        id: &TaskId,
129        state: TaskState,
130        message: Option<Message>,
131    ) -> Result<Task, A2AError> {
132        let task_id = id.as_str();
133        let mut tasks_guard = self.tasks.lock().await;
134
135        let task = tasks_guard
136            .get_mut(task_id)
137            .ok_or_else(|| A2AError::TaskNotFound(task_id.to_string()))?;
138
139        // Update the task status with the optional message
140        task.update_status(state, message);
141        let updated = task.clone();
142        self.bump_version(task_id).await;
143
144        // Persistence only: announcing the change to streaming subscribers is
145        // the orchestration layer's job (see `TaskStatusBroadcast`), not a side
146        // effect of the mutator.
147        Ok(updated)
148    }
149
150    async fn exists(&self, id: &TaskId) -> Result<bool, A2AError> {
151        let task_id = id.as_str();
152        let tasks_guard = self.tasks.lock().await;
153        Ok(tasks_guard.contains_key(task_id))
154    }
155
156    async fn get(&self, id: &TaskId, history_length: Option<u32>) -> Result<Task, A2AError> {
157        let task_id = id.as_str();
158        // Get the task
159        let task = {
160            let tasks_guard = self.tasks.lock().await;
161
162            let Some(task) = tasks_guard.get(task_id) else {
163                return Err(A2AError::TaskNotFound(task_id.to_string()));
164            };
165
166            // Apply history length limitation if specified
167            task.with_limited_history(history_length)
168        }; // Lock is dropped here
169
170        Ok(task)
171    }
172
173    async fn cancel(&self, id: &TaskId) -> Result<Task, A2AError> {
174        let task_id = id.as_str();
175        let mut tasks_guard = self.tasks.lock().await;
176
177        let Some(task) = tasks_guard.get(task_id) else {
178            return Err(A2AError::TaskNotFound(task_id.to_string()));
179        };
180
181        let mut updated_task = task.clone();
182
183        // Anything that has not finished can be canceled — a queued
184        // (`Submitted`) task most of all, and an `InputRequired` one, where
185        // cancelling is how a client says "never mind". See
186        // `TaskState::is_cancelable`.
187        if !updated_task.status.state.is_cancelable() {
188            return Err(A2AError::TaskNotCancelable(format!(
189                "Task {} has already finished in state {:?} and cannot be canceled",
190                task_id, updated_task.status.state
191            )));
192        }
193
194        // Create a cancellation message to add to history
195        let cancel_message = Message {
196            role: ::buffa::EnumValue::from(crate::domain::Role::Agent),
197            parts: vec![crate::domain::Part::text(format!(
198                "Task {} canceled.",
199                task_id
200            ))],
201            message_id: uuid::Uuid::new_v4().to_string(),
202            task_id: task_id.to_string(),
203            context_id: updated_task.context_id.clone(),
204            ..Default::default()
205        };
206
207        // Update the status with the cancellation message to track in history
208        updated_task.update_status(TaskState::Canceled, Some(cancel_message));
209        tasks_guard.insert(task_id.to_string(), updated_task.clone());
210        self.bump_version(task_id).await;
211
212        // Persistence only: the orchestration layer announces the cancellation
213        // to streaming subscribers (see `TaskStatusBroadcast`).
214        Ok(updated_task)
215    }
216}
217
218#[async_trait]
219impl AsyncTaskVersioning for InMemoryTaskStorage {
220    async fn version(&self, id: &TaskId) -> Result<u64, A2AError> {
221        let task_id = id.as_str();
222        let tasks_guard = self.tasks.lock().await;
223        if !tasks_guard.contains_key(task_id) {
224            return Err(A2AError::TaskNotFound(task_id.to_string()));
225        }
226        let versions = self.versions.lock().await;
227        Ok(versions.get(task_id).copied().unwrap_or(0))
228    }
229
230    async fn get_versioned(
231        &self,
232        id: &TaskId,
233        history_length: Option<u32>,
234    ) -> Result<VersionedTask, A2AError> {
235        let task_id = id.as_str();
236        let tasks_guard = self.tasks.lock().await;
237        let Some(task) = tasks_guard.get(task_id) else {
238            return Err(A2AError::TaskNotFound(task_id.to_string()));
239        };
240        let task = task.with_limited_history(history_length);
241        let versions = self.versions.lock().await;
242        let version = versions.get(task_id).copied().unwrap_or(0);
243        Ok(VersionedTask::new(task, version))
244    }
245
246    async fn update_status_checked(
247        &self,
248        id: &TaskId,
249        expected: u64,
250        state: TaskState,
251        message: Option<Message>,
252    ) -> Result<VersionedTask, A2AError> {
253        let task_id = id.as_str();
254        // Lock order: tasks, then versions — the compare-and-swap holds both so
255        // the check and the bump are atomic against every other mutator.
256        let mut tasks_guard = self.tasks.lock().await;
257        let task = tasks_guard
258            .get_mut(task_id)
259            .ok_or_else(|| A2AError::TaskNotFound(task_id.to_string()))?;
260        let mut versions = self.versions.lock().await;
261        let current = versions.get(task_id).copied().unwrap_or(0);
262        if current != expected {
263            return Err(A2AError::VersionConflict {
264                id: task_id.to_string(),
265                expected,
266                actual: current,
267            });
268        }
269        task.update_status(state, message);
270        let new_version = current + 1;
271        versions.insert(task_id.to_string(), new_version);
272        Ok(VersionedTask::new(task.clone(), new_version))
273    }
274}
275
276#[async_trait]
277impl AsyncTaskQuery for InMemoryTaskStorage {
278    async fn list(
279        &self,
280        params: &crate::domain::ListTasksParams,
281    ) -> Result<crate::domain::ListTasksResult, A2AError> {
282        use crate::domain::ListTasksResult;
283
284        let tasks_guard = self.tasks.lock().await;
285
286        // Filter tasks based on parameters
287        let mut filtered_tasks: Vec<_> = tasks_guard
288            .values()
289            .filter(|task| {
290                // Filter by context_id if provided
291                if let Some(ref context_id) = params.context_id
292                    && &task.context_id != context_id
293                {
294                    return false;
295                }
296
297                // Filter by status if provided
298                if let Some(ref status) = params.status
299                    && &task.status.state != status
300                {
301                    return false;
302                }
303
304                // Filter by status_timestamp_after if provided
305                if let Some(status_timestamp_after) = &params.status_timestamp_after
306                    && let Ok(after_dt) =
307                        chrono::DateTime::parse_from_rfc3339(status_timestamp_after)
308                    && let Some(timestamp) = task.status.timestamp_utc()
309                    && timestamp <= after_dt.with_timezone(&chrono::Utc)
310                {
311                    return false;
312                }
313
314                true
315            })
316            .cloned()
317            .collect();
318
319        // Sort by timestamp (most recent first)
320        filtered_tasks.sort_by(|a, b| {
321            let a_time = a
322                .status
323                .timestamp_utc()
324                .map(|t| t.timestamp_millis())
325                .unwrap_or(0);
326            let b_time = b
327                .status
328                .timestamp_utc()
329                .map(|t| t.timestamp_millis())
330                .unwrap_or(0);
331            b_time.cmp(&a_time)
332        });
333
334        let total_size = filtered_tasks.len() as i32;
335
336        // Handle pagination
337        let page_size = params.page_size.unwrap_or(50).clamp(1, 100) as usize;
338        let page_start = if let Some(ref token) = params.page_token {
339            // Parse page token as a number (simple implementation)
340            token.parse::<usize>().unwrap_or(0)
341        } else {
342            0
343        };
344
345        let page_end = (page_start + page_size).min(filtered_tasks.len());
346        let has_more = page_end < filtered_tasks.len();
347
348        // Get the page of tasks
349        let mut page_tasks: Vec<_> = filtered_tasks[page_start..page_end].to_vec();
350
351        // Apply history length limit
352        let history_length = params.history_length.unwrap_or(0);
353        for task in &mut page_tasks {
354            *task = task.with_limited_history(Some(history_length as u32));
355
356            // Remove artifacts if not requested
357            if !params.include_artifacts.unwrap_or(false) {
358                task.artifacts.clear();
359            }
360        }
361
362        // Generate next page token
363        let next_page_token = if has_more {
364            page_end.to_string()
365        } else {
366            String::new()
367        };
368
369        Ok(ListTasksResult {
370            tasks: page_tasks,
371            total_size,
372            page_size: page_size as i32,
373            next_page_token,
374        })
375    }
376}
377
378// AsyncNotificationManager implementation.
379//
380// In-memory storage keeps a single config per task in the push-notification
381// registry, so the multi-config CRUD surface is expressed in those terms.
382#[async_trait]
383impl AsyncNotificationManager for InMemoryTaskStorage {
384    async fn set_config(
385        &self,
386        config: &TaskPushNotificationConfig,
387    ) -> Result<TaskPushNotificationConfig, A2AError> {
388        #[cfg(feature = "tracing")]
389        tracing::info!(
390            task_id = %config.task_id,
391            url = %config.url,
392            "🚀 Registering push notification config for task"
393        );
394
395        // Register with the push notification registry
396        self.push_notification_registry
397            .register(&config.task_id, config.clone())
398            .await?;
399
400        #[cfg(feature = "tracing")]
401        tracing::info!(
402            task_id = %config.task_id,
403            "✅ Push notification config registered successfully"
404        );
405
406        Ok(config.clone())
407    }
408
409    async fn get_config(
410        &self,
411        params: &crate::domain::GetTaskPushNotificationConfigParams,
412    ) -> Result<TaskPushNotificationConfig, A2AError> {
413        match self
414            .push_notification_registry
415            .get_config(&params.id)
416            .await?
417        {
418            Some(config) => Ok(config),
419            None => Err(A2AError::PushNotificationNotSupported),
420        }
421    }
422
423    async fn list_configs(
424        &self,
425        params: &crate::domain::ListTaskPushNotificationConfigsParams,
426    ) -> Result<Vec<TaskPushNotificationConfig>, A2AError> {
427        // In-memory storage supports one config per task; return it as a
428        // single-item vec (or empty if none registered).
429        match self
430            .push_notification_registry
431            .get_config(&params.id)
432            .await?
433        {
434            Some(config) => Ok(vec![config]),
435            None => Ok(vec![]),
436        }
437    }
438
439    async fn delete_config(
440        &self,
441        params: &crate::domain::DeleteTaskPushNotificationConfigParams,
442    ) -> Result<(), A2AError> {
443        // In-memory storage keeps a single config per task, so config_id is
444        // not used for lookup. Idempotent per the v1.0.0 spec.
445        self.push_notification_registry
446            .unregister(&params.id)
447            .await?;
448        Ok(())
449    }
450}
451
452impl Clone for InMemoryTaskStorage {
453    fn clone(&self) -> Self {
454        Self {
455            tasks: self.tasks.clone(),
456            versions: self.versions.clone(),
457            push_notification_registry: self.push_notification_registry.clone(),
458        }
459    }
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465    use crate::domain::ContextId;
466
467    fn tid(s: &str) -> TaskId {
468        s.parse().unwrap()
469    }
470    fn cid(s: &str) -> ContextId {
471        s.parse().unwrap()
472    }
473
474    #[tokio::test]
475    async fn versioning_tracks_and_guards_mutations() {
476        let store = InMemoryTaskStorage::new();
477        store.create(&tid("t1"), &cid("c1")).await.unwrap();
478        assert_eq!(store.version(&tid("t1")).await.unwrap(), 1);
479
480        // Unversioned mutations bump the version, keeping the two views in sync.
481        store
482            .update_status(&tid("t1"), TaskState::Working, None)
483            .await
484            .unwrap();
485        let snap = store.get_versioned(&tid("t1"), None).await.unwrap();
486        assert_eq!(snap.version, 2);
487
488        // Stale conditional update is rejected and leaves the task unchanged.
489        let err = store
490            .update_status_checked(&tid("t1"), 1, TaskState::Completed, None)
491            .await
492            .unwrap_err();
493        assert!(matches!(
494            err,
495            A2AError::VersionConflict {
496                expected: 1,
497                actual: 2,
498                ..
499            }
500        ));
501        assert_eq!(
502            store.get(&tid("t1"), None).await.unwrap().status.state,
503            TaskState::Working
504        );
505
506        // Current-version conditional update succeeds and bumps.
507        let ok = store
508            .update_status_checked(&tid("t1"), 2, TaskState::Completed, None)
509            .await
510            .unwrap();
511        assert_eq!(ok.version, 3);
512        assert_eq!(ok.task.status.state, TaskState::Completed);
513    }
514}