1use std::sync::Arc;
4use std::sync::atomic::{AtomicBool, Ordering};
5
6use aion_core::{ActivityEvent, ActivityId, Payload, RunId, WorkflowId};
7use tokio::sync::{Notify, mpsc};
8
9use crate::error::WorkerError;
10
11#[derive(Clone, Debug)]
13pub struct ActivityContext {
14 workflow_id: WorkflowId,
16 run_id: RunId,
25 activity_id: ActivityId,
26 attempt: u32,
27 idempotency_key: Option<String>,
28 cancellation: Arc<CancellationState>,
29 heartbeat_sender: Option<mpsc::UnboundedSender<HeartbeatRequest>>,
30 event_sender: Option<mpsc::UnboundedSender<ActivityEvent>>,
38}
39
40#[derive(Clone, Debug)]
42pub struct ActivityCancellationHandle {
43 cancellation: Arc<CancellationState>,
44}
45
46#[derive(Clone, Debug, PartialEq, Eq)]
48pub struct HeartbeatRequest {
49 pub workflow_id: WorkflowId,
51 pub activity_id: ActivityId,
53 pub detail: Option<Payload>,
55}
56
57#[derive(Debug)]
58struct CancellationState {
59 cancelled: AtomicBool,
60 notify: Notify,
61}
62
63impl ActivityContext {
64 #[must_use]
70 pub fn new(
71 workflow_id: WorkflowId,
72 run_id: RunId,
73 activity_id: ActivityId,
74 attempt: u32,
75 ) -> (Self, ActivityCancellationHandle) {
76 Self::for_workflow(workflow_id, run_id, activity_id, attempt, None)
77 }
78
79 #[must_use]
89 pub fn with_transcript(
90 workflow_id: WorkflowId,
91 run_id: RunId,
92 activity_id: ActivityId,
93 attempt: u32,
94 events: mpsc::UnboundedSender<ActivityEvent>,
95 ) -> (Self, ActivityCancellationHandle) {
96 Self::for_workflow_with_events(
97 workflow_id,
98 run_id,
99 activity_id,
100 attempt,
101 None,
102 None,
103 Some(events),
104 )
105 }
106
107 #[must_use]
109 pub const fn activity_id(&self) -> &ActivityId {
110 &self.activity_id
111 }
112
113 #[must_use]
115 pub const fn workflow_id(&self) -> &WorkflowId {
116 &self.workflow_id
117 }
118
119 #[must_use]
121 pub const fn attempt(&self) -> u32 {
122 self.attempt
123 }
124
125 #[must_use]
134 pub const fn run_id(&self) -> &RunId {
135 &self.run_id
136 }
137
138 #[must_use]
143 pub fn idempotency_key(&self) -> Option<&str> {
144 self.idempotency_key.as_deref()
145 }
146
147 pub fn heartbeat(&self, detail: Option<Payload>) -> Result<(), WorkerError> {
161 if let Some(sender) = &self.heartbeat_sender {
162 sender
163 .send(HeartbeatRequest {
164 workflow_id: self.workflow_id.clone(),
165 activity_id: self.activity_id.clone(),
166 detail,
167 })
168 .map_err(|source| WorkerError::registration(HeartbeatSeamClosed { source }))?;
169 }
170 Ok(())
171 }
172
173 pub fn emit_event(&self, event: ActivityEvent) -> Result<(), WorkerError> {
192 if let Some(sender) = &self.event_sender {
193 sender
194 .send(event)
195 .map_err(|source| WorkerError::registration(EventSeamClosed { source }))?;
196 }
197 Ok(())
198 }
199
200 #[must_use]
202 pub fn is_cancelled(&self) -> bool {
203 self.cancellation.cancelled.load(Ordering::Acquire)
204 }
205
206 pub async fn cancelled(&self) {
208 while !self.is_cancelled() {
209 self.cancellation.notify.notified().await;
210 }
211 }
212
213 pub(crate) fn for_workflow(
214 workflow_id: WorkflowId,
215 run_id: RunId,
216 activity_id: ActivityId,
217 attempt: u32,
218 heartbeat_sender: Option<mpsc::UnboundedSender<HeartbeatRequest>>,
219 ) -> (Self, ActivityCancellationHandle) {
220 Self::for_workflow_with_events(
221 workflow_id,
222 run_id,
223 activity_id,
224 attempt,
225 None,
226 heartbeat_sender,
227 None,
228 )
229 }
230
231 pub(crate) fn for_task(
232 workflow_id: WorkflowId,
233 run_id: RunId,
234 activity_id: ActivityId,
235 attempt: u32,
236 idempotency_key: String,
237 heartbeat_sender: Option<mpsc::UnboundedSender<HeartbeatRequest>>,
238 ) -> (Self, ActivityCancellationHandle) {
239 Self::for_workflow_with_events(
240 workflow_id,
241 run_id,
242 activity_id,
243 attempt,
244 Some(idempotency_key),
245 heartbeat_sender,
246 None,
247 )
248 }
249
250 pub(crate) fn for_workflow_with_events(
255 workflow_id: WorkflowId,
256 run_id: RunId,
257 activity_id: ActivityId,
258 attempt: u32,
259 idempotency_key: Option<String>,
260 heartbeat_sender: Option<mpsc::UnboundedSender<HeartbeatRequest>>,
261 event_sender: Option<mpsc::UnboundedSender<ActivityEvent>>,
262 ) -> (Self, ActivityCancellationHandle) {
263 let cancellation = Arc::new(CancellationState {
264 cancelled: AtomicBool::new(false),
265 notify: Notify::new(),
266 });
267 let context = Self {
268 workflow_id,
269 run_id,
270 activity_id,
271 attempt,
272 idempotency_key,
273 cancellation: Arc::clone(&cancellation),
274 heartbeat_sender,
275 event_sender,
276 };
277 let handle = ActivityCancellationHandle { cancellation };
278 (context, handle)
279 }
280}
281
282impl ActivityCancellationHandle {
283 pub fn cancel(&self) {
285 let was_cancelled = self.cancellation.cancelled.swap(true, Ordering::AcqRel);
286 if !was_cancelled {
287 self.cancellation.notify.notify_waiters();
288 }
289 }
290}
291
292#[derive(Debug, thiserror::Error)]
293#[error("activity heartbeat seam is closed: {source}")]
294struct HeartbeatSeamClosed {
295 source: mpsc::error::SendError<HeartbeatRequest>,
296}
297
298#[derive(Debug, thiserror::Error)]
299#[error("activity transcript event seam is closed: {source}")]
300struct EventSeamClosed {
301 source: mpsc::error::SendError<ActivityEvent>,
302}
303
304#[cfg(test)]
305mod tests {
306 use std::time::Duration;
307
308 use aion_core::ActivityId;
309
310 use super::ActivityContext;
311
312 #[tokio::test]
317 async fn emit_event_is_a_no_op_without_an_installed_seam() {
318 use aion_core::{ActivityEvent, ActivityEventKind, MessageRole, RunId, WorkflowId};
319 use chrono::Utc;
320 use uuid::Uuid;
321
322 let workflow_id = WorkflowId::new(Uuid::from_u128(1));
323 let run_id = RunId::new(Uuid::from_u128(0x11));
324 let (context, _cancellation) = ActivityContext::new(
325 workflow_id.clone(),
326 run_id.clone(),
327 ActivityId::from_sequence_position(1),
328 0,
329 );
330 assert_eq!(context.run_id(), &run_id);
333 assert_eq!(context.workflow_id(), &workflow_id);
334 let event = ActivityEvent {
335 workflow_id,
336 run_id,
337 activity_id: ActivityId::from_sequence_position(1),
338 attempt: 0,
339 agent_id: Uuid::from_u128(2),
340 agent_role: "orchestrator".to_owned(),
341 emitted_at: Utc::now(),
342 worker_seq: 1,
343 store_seq: None,
344 ephemeral: false,
345 kind: ActivityEventKind::Message {
346 role: MessageRole::Assistant,
347 text: "hello".to_owned(),
348 },
349 };
350 assert!(context.emit_event(event).is_ok());
351 }
352
353 #[tokio::test]
356 async fn emit_event_forwards_to_installed_seam() -> Result<(), Box<dyn std::error::Error>> {
357 use aion_core::{ActivityEvent, ActivityEventKind, MessageRole, RunId, WorkflowId};
358 use chrono::Utc;
359 use uuid::Uuid;
360
361 let run_id = RunId::new(Uuid::from_u128(0x11));
362 let (sender, mut drain) = super::mpsc::unbounded_channel();
363 let (context, _cancellation) = ActivityContext::for_workflow_with_events(
364 WorkflowId::new(Uuid::from_u128(1)),
365 run_id.clone(),
366 ActivityId::from_sequence_position(1),
367 0,
368 None,
369 None,
370 Some(sender),
371 );
372 assert_eq!(context.run_id(), &run_id);
374 let event = ActivityEvent {
375 workflow_id: WorkflowId::new(Uuid::from_u128(1)),
376 run_id: run_id.clone(),
377 activity_id: ActivityId::from_sequence_position(1),
378 attempt: 0,
379 agent_id: Uuid::from_u128(2),
380 agent_role: "orchestrator".to_owned(),
381 emitted_at: Utc::now(),
382 worker_seq: 7,
383 store_seq: None,
384 ephemeral: false,
385 kind: ActivityEventKind::Message {
386 role: MessageRole::Assistant,
387 text: "steer".to_owned(),
388 },
389 };
390 context.emit_event(event.clone())?;
391 let delivered = drain.recv().await.ok_or("event must be delivered")?;
392 assert_eq!(delivered.worker_seq, 7);
393 assert_eq!(delivered, event);
394 Ok(())
395 }
396
397 #[test]
398 fn live_task_context_exposes_the_server_idempotency_key() {
399 let run_id = aion_core::RunId::new_v4();
400 let (context, cancellation) = ActivityContext::for_task(
401 aion_core::WorkflowId::new_v4(),
402 run_id.clone(),
403 ActivityId::from_sequence_position(7),
404 3,
405 String::from("effect-key"),
406 None,
407 );
408
409 assert_eq!(context.idempotency_key(), Some("effect-key"));
410 assert_eq!(context.attempt(), 3);
411 assert_eq!(context.run_id(), &run_id);
412 drop(cancellation);
413 }
414
415 #[tokio::test]
416 async fn context_exposes_identity_attempt_and_cancellation_signal() {
417 let activity_id = ActivityId::from_sequence_position(42);
418 let (context, cancellation) = ActivityContext::new(
419 aion_core::WorkflowId::new_v4(),
420 aion_core::RunId::new_v4(),
421 activity_id.clone(),
422 3,
423 );
424
425 assert_eq!(context.activity_id(), &activity_id);
426 assert_eq!(context.attempt(), 3);
427 assert!(!context.is_cancelled());
428
429 cancellation.cancel();
430
431 assert!(context.is_cancelled());
432 let cancelled = tokio::time::timeout(Duration::from_millis(50), context.cancelled()).await;
433 assert!(cancelled.is_ok());
434 }
435}