1use std::sync::Arc;
4use std::sync::atomic::{AtomicBool, Ordering};
5
6use aion_core::{ActivityEvent, ActivityId, Payload, WorkflowId};
7use tokio::sync::{Notify, mpsc};
8
9use crate::error::WorkerError;
10
11#[derive(Clone, Debug)]
13pub struct ActivityContext {
14 workflow_id: Option<WorkflowId>,
15 activity_id: ActivityId,
16 attempt: u32,
17 cancellation: Arc<CancellationState>,
18 heartbeat_sender: Option<mpsc::UnboundedSender<HeartbeatRequest>>,
19 event_sender: Option<mpsc::UnboundedSender<ActivityEvent>>,
27}
28
29#[derive(Clone, Debug)]
31pub struct ActivityCancellationHandle {
32 cancellation: Arc<CancellationState>,
33}
34
35#[derive(Clone, Debug, PartialEq, Eq)]
37pub struct HeartbeatRequest {
38 pub workflow_id: WorkflowId,
40 pub activity_id: ActivityId,
42 pub detail: Option<Payload>,
44}
45
46#[derive(Debug)]
47struct CancellationState {
48 cancelled: AtomicBool,
49 notify: Notify,
50}
51
52impl ActivityContext {
53 #[must_use]
55 pub fn new(activity_id: ActivityId, attempt: u32) -> (Self, ActivityCancellationHandle) {
56 Self::with_heartbeat_sender(activity_id, attempt, None)
57 }
58
59 #[must_use]
61 pub const fn activity_id(&self) -> &ActivityId {
62 &self.activity_id
63 }
64
65 #[must_use]
67 pub const fn attempt(&self) -> u32 {
68 self.attempt
69 }
70
71 pub fn heartbeat(&self, detail: Option<Payload>) -> Result<(), WorkerError> {
86 if let Some(sender) = &self.heartbeat_sender {
87 let workflow_id = self.workflow_id.clone().ok_or_else(|| {
88 WorkerError::registration(HeartbeatMissingWorkflow {
89 activity_id: self.activity_id.clone(),
90 })
91 })?;
92 sender
93 .send(HeartbeatRequest {
94 workflow_id,
95 activity_id: self.activity_id.clone(),
96 detail,
97 })
98 .map_err(|source| WorkerError::registration(HeartbeatSeamClosed { source }))?;
99 }
100 Ok(())
101 }
102
103 pub fn emit_event(&self, event: ActivityEvent) -> Result<(), WorkerError> {
122 if let Some(sender) = &self.event_sender {
123 sender
124 .send(event)
125 .map_err(|source| WorkerError::registration(EventSeamClosed { source }))?;
126 }
127 Ok(())
128 }
129
130 #[must_use]
132 pub fn is_cancelled(&self) -> bool {
133 self.cancellation.cancelled.load(Ordering::Acquire)
134 }
135
136 pub async fn cancelled(&self) {
138 while !self.is_cancelled() {
139 self.cancellation.notify.notified().await;
140 }
141 }
142
143 pub(crate) fn with_heartbeat_sender(
144 activity_id: ActivityId,
145 attempt: u32,
146 heartbeat_sender: Option<mpsc::UnboundedSender<HeartbeatRequest>>,
147 ) -> (Self, ActivityCancellationHandle) {
148 Self::for_workflow(None, activity_id, attempt, heartbeat_sender)
149 }
150
151 pub(crate) fn for_workflow(
152 workflow_id: Option<WorkflowId>,
153 activity_id: ActivityId,
154 attempt: u32,
155 heartbeat_sender: Option<mpsc::UnboundedSender<HeartbeatRequest>>,
156 ) -> (Self, ActivityCancellationHandle) {
157 Self::for_workflow_with_events(workflow_id, activity_id, attempt, heartbeat_sender, None)
158 }
159
160 pub(crate) fn for_workflow_with_events(
165 workflow_id: Option<WorkflowId>,
166 activity_id: ActivityId,
167 attempt: u32,
168 heartbeat_sender: Option<mpsc::UnboundedSender<HeartbeatRequest>>,
169 event_sender: Option<mpsc::UnboundedSender<ActivityEvent>>,
170 ) -> (Self, ActivityCancellationHandle) {
171 let cancellation = Arc::new(CancellationState {
172 cancelled: AtomicBool::new(false),
173 notify: Notify::new(),
174 });
175 let context = Self {
176 workflow_id,
177 activity_id,
178 attempt,
179 cancellation: Arc::clone(&cancellation),
180 heartbeat_sender,
181 event_sender,
182 };
183 let handle = ActivityCancellationHandle { cancellation };
184 (context, handle)
185 }
186}
187
188impl ActivityCancellationHandle {
189 pub fn cancel(&self) {
191 let was_cancelled = self.cancellation.cancelled.swap(true, Ordering::AcqRel);
192 if !was_cancelled {
193 self.cancellation.notify.notify_waiters();
194 }
195 }
196}
197
198#[derive(Debug, thiserror::Error)]
199#[error("activity heartbeat seam is closed: {source}")]
200struct HeartbeatSeamClosed {
201 source: mpsc::error::SendError<HeartbeatRequest>,
202}
203
204#[derive(Debug, thiserror::Error)]
205#[error("activity transcript event seam is closed: {source}")]
206struct EventSeamClosed {
207 source: mpsc::error::SendError<ActivityEvent>,
208}
209
210#[derive(Debug, thiserror::Error)]
211#[error("activity {activity_id} heartbeat is missing workflow id")]
212struct HeartbeatMissingWorkflow {
213 activity_id: ActivityId,
214}
215
216#[cfg(test)]
217mod tests {
218 use std::time::Duration;
219
220 use aion_core::ActivityId;
221
222 use super::ActivityContext;
223
224 #[tokio::test]
229 async fn emit_event_is_a_no_op_without_an_installed_seam() {
230 use aion_core::{ActivityEvent, ActivityEventKind, MessageRole, WorkflowId};
231 use chrono::Utc;
232 use uuid::Uuid;
233
234 let (context, _cancellation) =
235 ActivityContext::new(ActivityId::from_sequence_position(1), 0);
236 let event = ActivityEvent {
237 workflow_id: WorkflowId::new(Uuid::from_u128(1)),
238 activity_id: ActivityId::from_sequence_position(1),
239 attempt: 0,
240 agent_id: Uuid::from_u128(2),
241 agent_role: "orchestrator".to_owned(),
242 emitted_at: Utc::now(),
243 worker_seq: 1,
244 store_seq: None,
245 ephemeral: false,
246 kind: ActivityEventKind::Message {
247 role: MessageRole::Assistant,
248 text: "hello".to_owned(),
249 },
250 };
251 assert!(context.emit_event(event).is_ok());
252 }
253
254 #[tokio::test]
257 async fn emit_event_forwards_to_installed_seam() -> Result<(), Box<dyn std::error::Error>> {
258 use aion_core::{ActivityEvent, ActivityEventKind, MessageRole, WorkflowId};
259 use chrono::Utc;
260 use uuid::Uuid;
261
262 let (sender, mut drain) = super::mpsc::unbounded_channel();
263 let (context, _cancellation) = ActivityContext::for_workflow_with_events(
264 Some(WorkflowId::new(Uuid::from_u128(1))),
265 ActivityId::from_sequence_position(1),
266 0,
267 None,
268 Some(sender),
269 );
270 let event = ActivityEvent {
271 workflow_id: WorkflowId::new(Uuid::from_u128(1)),
272 activity_id: ActivityId::from_sequence_position(1),
273 attempt: 0,
274 agent_id: Uuid::from_u128(2),
275 agent_role: "orchestrator".to_owned(),
276 emitted_at: Utc::now(),
277 worker_seq: 7,
278 store_seq: None,
279 ephemeral: false,
280 kind: ActivityEventKind::Message {
281 role: MessageRole::Assistant,
282 text: "steer".to_owned(),
283 },
284 };
285 context.emit_event(event.clone())?;
286 let delivered = drain.recv().await.ok_or("event must be delivered")?;
287 assert_eq!(delivered.worker_seq, 7);
288 assert_eq!(delivered, event);
289 Ok(())
290 }
291
292 #[tokio::test]
293 async fn context_exposes_identity_attempt_and_cancellation_signal() {
294 let activity_id = ActivityId::from_sequence_position(42);
295 let (context, cancellation) = ActivityContext::new(activity_id.clone(), 3);
296
297 assert_eq!(context.activity_id(), &activity_id);
298 assert_eq!(context.attempt(), 3);
299 assert!(!context.is_cancelled());
300
301 cancellation.cancel();
302
303 assert!(context.is_cancelled());
304 let cancelled = tokio::time::timeout(Duration::from_millis(50), context.cancelled()).await;
305 assert!(cancelled.is_ok());
306 }
307}