Skip to main content

a2a_rs/application/
task_status_broadcast.rs

1//! Cross-port orchestration: update a task's status *and* broadcast it.
2//!
3//! This is the [capability-mixin] pattern applied at the port boundary
4//! (`.claude/rules/hexagonal_architecture.md` §9). Two narrow **accessor**
5//! ingredients ([`HasTaskLifecycle`], [`HasStreaming`]) expose the ports a host
6//! already holds; the [`TaskStatusBroadcast`] mixin provides the derived
7//! "update then broadcast" behavior as a blanket-impl'd default. Any assembly
8//! that exposes both ports — the request processor, the MCP bridge, a test
9//! rig — gains `update_and_broadcast` for free, and on nothing inner.
10//!
11//! Why a mixin and not just a method on the processor: the orchestration is
12//! defined independently of any one struct (reusable across hosts) and is
13//! testable against a minimal rig that wires only these two ports over
14//! in-memory adapters — see the tests below.
15//!
16//! [capability-mixin]: crate::port
17//!
18//! ## Layering note
19//!
20//! The accessor associated returns are bounded by **port traits**
21//! (`&dyn AsyncTaskLifecycle`, `&dyn AsyncStreamingHandler`), never concrete
22//! adapters, and the mixin default touches only those ports plus pure domain
23//! constructors (`TaskStatus::new`). The dependency arrow therefore still
24//! points inward even though the logic lives in a blanket impl.
25
26use async_trait::async_trait;
27
28use crate::domain::{
29    A2AError, Message, Task, TaskArtifactUpdateEvent, TaskId, TaskState, TaskStatusUpdateEvent,
30};
31use crate::port::{AsyncPushNotifier, AsyncStreamingHandler, AsyncTaskLifecycle};
32
33/// Ingredient: an assembly that can hand out a task-lifecycle port.
34///
35/// Note the return is a `&dyn` **port**, not a concrete adapter — that is what
36/// keeps any mixin built on this ingredient inside the dependency rule.
37pub trait HasTaskLifecycle {
38    fn lifecycle(&self) -> &dyn AsyncTaskLifecycle;
39}
40
41/// Ingredient: an assembly that can hand out a streaming port.
42pub trait HasStreaming {
43    fn streaming(&self) -> &dyn AsyncStreamingHandler;
44}
45
46/// Ingredient: an assembly that can hand out a push-notifier port.
47///
48/// Kept separate from [`HasStreaming`] on purpose: in-process streaming fan-out
49/// and out-of-band webhook delivery are distinct capabilities with distinct
50/// backends, so the mixin orchestrates both rather than fusing them into one
51/// adapter.
52pub trait HasPushNotifier {
53    fn push_notifier(&self) -> &dyn AsyncPushNotifier;
54}
55
56/// Derived capability: mutate task status through the lifecycle port, then
57/// broadcast the resulting status to streaming subscribers.
58///
59/// Blanket-implemented for every `Send + Sync` host that exposes both
60/// ingredients, so it never needs an explicit `impl`. A host that exposes only
61/// one ingredient does **not** get this method — that omission is a compile
62/// error at the call site, not a runtime surprise (see the `compile_fail` doc
63/// test on [`update_and_broadcast`]).
64///
65/// [`update_and_broadcast`]: TaskStatusBroadcast::update_and_broadcast
66#[async_trait]
67pub trait TaskStatusBroadcast:
68    HasTaskLifecycle + HasStreaming + HasPushNotifier + Send + Sync
69{
70    /// Update a task's status, then broadcast the new status to subscribers.
71    ///
72    /// The broadcast is best-effort relative to the store: the status is
73    /// persisted first (via the lifecycle port) and only then announced, so a
74    /// subscriber never sees a state the store hasn't committed.
75    ///
76    /// A host that exposes only *one* of the two ingredients does not get this
77    /// method — the missing supertrait makes the blanket impl inapplicable, so
78    /// the call fails to compile:
79    ///
80    /// ```compile_fail
81    /// use std::sync::Arc;
82    /// use a2a_rs::AsyncTaskLifecycle;
83    /// use a2a_rs::adapter::storage::InMemoryTaskStorage;
84    /// use a2a_rs::application::{HasTaskLifecycle, TaskStatusBroadcast};
85    /// use a2a_rs::domain::{TaskId, TaskState};
86    ///
87    /// // Exposes the lifecycle ingredient, but NOT `HasStreaming`.
88    /// struct HalfRig {
89    ///     store: Arc<InMemoryTaskStorage>,
90    /// }
91    /// impl HasTaskLifecycle for HalfRig {
92    ///     fn lifecycle(&self) -> &dyn AsyncTaskLifecycle {
93    ///         self.store.as_ref()
94    ///     }
95    /// }
96    ///
97    /// async fn use_it(rig: HalfRig, id: TaskId) {
98    ///     // `update_and_broadcast` does not exist on a one-ingredient host:
99    ///     rig.update_and_broadcast(&id, TaskState::Completed, None).await.unwrap();
100    /// }
101    /// ```
102    async fn update_and_broadcast(
103        &self,
104        id: &TaskId,
105        state: TaskState,
106        message: Option<Message>,
107    ) -> Result<Task, A2AError> {
108        let task = self.lifecycle().update_status(id, state, message).await?;
109        self.broadcast_current_status(id, &task).await?;
110        Ok(task)
111    }
112
113    /// Cancel a task through the lifecycle port, then broadcast the resulting
114    /// (terminal) status to subscribers.
115    ///
116    /// The counterpart to [`update_and_broadcast`](Self::update_and_broadcast)
117    /// for cancellation: `cancel` carries its own state transition and history
118    /// message, so it cannot be expressed as an `update_status` call, but the
119    /// "commit then announce" ordering is identical.
120    async fn cancel_and_broadcast(&self, id: &TaskId) -> Result<Task, A2AError> {
121        let task = self.lifecycle().cancel(id).await?;
122        self.broadcast_current_status(id, &task).await?;
123        Ok(task)
124    }
125
126    /// Broadcast an artifact update: fan it out to streaming subscribers, then
127    /// deliver it to the task's push endpoint (best-effort).
128    ///
129    /// The artifact counterpart to the status path. Hosts that produce artifacts
130    /// route through here so streaming and push stay consistent — exactly as the
131    /// status mutators do via [`broadcast_current_status`](Self::broadcast_current_status).
132    async fn broadcast_artifact(
133        &self,
134        id: &TaskId,
135        event: TaskArtifactUpdateEvent,
136    ) -> Result<(), A2AError> {
137        self.streaming()
138            .broadcast_artifact_update(id.as_str(), event.clone())
139            .await?;
140        self.notify_push_artifact(id, &event).await;
141        Ok(())
142    }
143
144    /// Announce a task's current status to streaming subscribers, then deliver a
145    /// push notification (best-effort).
146    ///
147    /// Shared by the mutate-then-broadcast methods above; not intended to be
148    /// overridden. The event is built from the freshly-committed `task` so the
149    /// announcement always reflects what the store now holds. Push delivery is
150    /// best-effort: a webhook that is down is logged but does not fail the
151    /// mutation that triggered it.
152    #[doc(hidden)]
153    async fn broadcast_current_status(&self, id: &TaskId, task: &Task) -> Result<(), A2AError> {
154        let event = TaskStatusUpdateEvent {
155            task_id: task.id.clone(),
156            context_id: task.context_id.clone(),
157            kind: "status-update".to_string(),
158            status: task.status.clone().into_option().unwrap_or_default(),
159            metadata: None,
160        };
161
162        self.streaming()
163            .broadcast_status_update(id.as_str(), event.clone())
164            .await?;
165        self.notify_push_status(id, &event).await;
166        Ok(())
167    }
168
169    /// Deliver a status push notification, swallowing (and logging) any delivery
170    /// error so it never fails the mutation.
171    #[doc(hidden)]
172    async fn notify_push_status(&self, id: &TaskId, event: &TaskStatusUpdateEvent) {
173        if let Err(_e) = self.push_notifier().notify_status(id.as_str(), event).await {
174            #[cfg(feature = "tracing")]
175            tracing::warn!(task_id = %id.as_str(), error = %_e, "push status notification failed");
176        }
177    }
178
179    /// Deliver an artifact push notification, swallowing (and logging) any
180    /// delivery error.
181    #[doc(hidden)]
182    async fn notify_push_artifact(&self, id: &TaskId, event: &TaskArtifactUpdateEvent) {
183        if let Err(_e) = self
184            .push_notifier()
185            .notify_artifact(id.as_str(), event)
186            .await
187        {
188            #[cfg(feature = "tracing")]
189            tracing::warn!(task_id = %id.as_str(), error = %_e, "push artifact notification failed");
190        }
191    }
192}
193
194/// The single blanket impl — the linchpin of the pattern. `?Sized` lets the
195/// mixin attach to a `dyn`-typed host as well as a concrete one.
196impl<T: HasTaskLifecycle + HasStreaming + HasPushNotifier + Send + Sync + ?Sized>
197    TaskStatusBroadcast for T
198{
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use crate::adapter::storage::InMemoryTaskStorage;
205    use crate::adapter::streaming::InMemoryStreamingHandler;
206    use crate::port::NoopPushNotifier;
207    use crate::port::streaming_handler::Subscriber;
208    use std::sync::{Arc, Mutex};
209
210    /// A "partial platform" test rig: it wires the three ingredients this mixin
211    /// needs — a persistence adapter, a separate streaming adapter, and a push
212    /// notifier — over in-memory implementations. Standing this up requires
213    /// neither the transport layer nor the full request processor, so the
214    /// orchestration is tested in isolation. The split between `store` and
215    /// `streaming` is the whole point: they are distinct ports now.
216    struct BroadcastRig {
217        store: Arc<InMemoryTaskStorage>,
218        streaming: InMemoryStreamingHandler,
219        push: NoopPushNotifier,
220    }
221
222    impl HasTaskLifecycle for BroadcastRig {
223        fn lifecycle(&self) -> &dyn AsyncTaskLifecycle {
224            self.store.as_ref()
225        }
226    }
227
228    impl HasStreaming for BroadcastRig {
229        fn streaming(&self) -> &dyn AsyncStreamingHandler {
230            &self.streaming
231        }
232    }
233
234    impl HasPushNotifier for BroadcastRig {
235        fn push_notifier(&self) -> &dyn AsyncPushNotifier {
236            &self.push
237        }
238    }
239
240    /// A streaming subscriber that records every status it is handed, so a test
241    /// can assert exactly which transitions reached subscribers.
242    #[derive(Clone, Default)]
243    struct Recorder {
244        states: Arc<Mutex<Vec<::buffa::EnumValue<TaskState>>>>,
245    }
246
247    #[async_trait]
248    impl Subscriber<TaskStatusUpdateEvent> for Recorder {
249        async fn on_update(&self, update: TaskStatusUpdateEvent) -> Result<(), A2AError> {
250            self.states.lock().unwrap().push(update.status.state);
251            Ok(())
252        }
253    }
254
255    fn rig(store: Arc<InMemoryTaskStorage>) -> BroadcastRig {
256        BroadcastRig {
257            store,
258            streaming: InMemoryStreamingHandler::new(),
259            push: NoopPushNotifier,
260        }
261    }
262
263    #[tokio::test]
264    async fn update_and_broadcast_persists_then_announces() {
265        let store = Arc::new(InMemoryTaskStorage::new());
266        let id = TaskId::try_from("task-1").unwrap();
267        let ctx = crate::domain::ContextId::try_from("ctx-1").unwrap();
268
269        store.create(&id, &ctx).await.unwrap();
270        store
271            .update_status(&id, TaskState::Working, None)
272            .await
273            .unwrap();
274
275        let rig = rig(store);
276
277        // The mixin method exists purely because the rig exposes ALL ingredients.
278        let task = rig
279            .update_and_broadcast(&id, TaskState::Completed, None)
280            .await
281            .unwrap();
282
283        assert_eq!(task.status.state, TaskState::Completed);
284    }
285
286    /// A direct lifecycle mutation must NOT announce anything: persistence and
287    /// streaming are fully separate adapters now. The subscriber lives on the
288    /// streaming handler, which the bare store mutation never touches, so the
289    /// recorder stays empty.
290    #[tokio::test]
291    async fn bare_update_status_does_not_broadcast() {
292        let store = Arc::new(InMemoryTaskStorage::new());
293        let id = TaskId::try_from("task-1").unwrap();
294        let ctx = crate::domain::ContextId::try_from("ctx-1").unwrap();
295
296        let streaming = InMemoryStreamingHandler::new();
297        let recorder = Recorder::default();
298        streaming
299            .add_status_subscriber(id.as_str(), Box::new(recorder.clone()))
300            .await
301            .unwrap();
302
303        store.create(&id, &ctx).await.unwrap();
304        store
305            .update_status(&id, TaskState::Working, None)
306            .await
307            .unwrap();
308        store.cancel(&id).await.unwrap();
309
310        assert!(
311            recorder.states.lock().unwrap().is_empty(),
312            "storage mutators must not self-broadcast"
313        );
314    }
315
316    /// Routed through the mixin, the same mutations DO reach subscribers — once
317    /// each, in order. (One announcement per call proves there is no lingering
318    /// self-broadcast doubling the events.) The recorder is registered on the
319    /// rig's *streaming* handler, which the mixin fans out to.
320    #[tokio::test]
321    async fn mixin_announces_each_mutation_once() {
322        let store = Arc::new(InMemoryTaskStorage::new());
323        let id = TaskId::try_from("task-1").unwrap();
324        let ctx = crate::domain::ContextId::try_from("ctx-1").unwrap();
325
326        store.create(&id, &ctx).await.unwrap();
327
328        let rig = rig(store);
329
330        let recorder = Recorder::default();
331        rig.streaming
332            .add_status_subscriber(id.as_str(), Box::new(recorder.clone()))
333            .await
334            .unwrap();
335
336        rig.update_and_broadcast(&id, TaskState::Working, None)
337            .await
338            .unwrap();
339        rig.cancel_and_broadcast(&id).await.unwrap();
340
341        assert_eq!(
342            *recorder.states.lock().unwrap(),
343            vec![
344                ::buffa::EnumValue::from(TaskState::Working),
345                ::buffa::EnumValue::from(TaskState::Canceled),
346            ],
347        );
348    }
349}