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}