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 idempotency_key: Option<String>,
18 cancellation: Arc<CancellationState>,
19 heartbeat_sender: Option<mpsc::UnboundedSender<HeartbeatRequest>>,
20 event_sender: Option<mpsc::UnboundedSender<ActivityEvent>>,
28}
29
30#[derive(Clone, Debug)]
32pub struct ActivityCancellationHandle {
33 cancellation: Arc<CancellationState>,
34}
35
36#[derive(Clone, Debug, PartialEq, Eq)]
38pub struct HeartbeatRequest {
39 pub workflow_id: WorkflowId,
41 pub activity_id: ActivityId,
43 pub detail: Option<Payload>,
45}
46
47#[derive(Debug)]
48struct CancellationState {
49 cancelled: AtomicBool,
50 notify: Notify,
51}
52
53impl ActivityContext {
54 #[must_use]
56 pub fn new(activity_id: ActivityId, attempt: u32) -> (Self, ActivityCancellationHandle) {
57 Self::with_heartbeat_sender(activity_id, attempt, None)
58 }
59
60 #[must_use]
62 pub const fn activity_id(&self) -> &ActivityId {
63 &self.activity_id
64 }
65
66 #[must_use]
68 pub const fn attempt(&self) -> u32 {
69 self.attempt
70 }
71
72 #[must_use]
77 pub fn idempotency_key(&self) -> Option<&str> {
78 self.idempotency_key.as_deref()
79 }
80
81 pub fn heartbeat(&self, detail: Option<Payload>) -> Result<(), WorkerError> {
96 if let Some(sender) = &self.heartbeat_sender {
97 let workflow_id = self.workflow_id.clone().ok_or_else(|| {
98 WorkerError::registration(HeartbeatMissingWorkflow {
99 activity_id: self.activity_id.clone(),
100 })
101 })?;
102 sender
103 .send(HeartbeatRequest {
104 workflow_id,
105 activity_id: self.activity_id.clone(),
106 detail,
107 })
108 .map_err(|source| WorkerError::registration(HeartbeatSeamClosed { source }))?;
109 }
110 Ok(())
111 }
112
113 pub fn emit_event(&self, event: ActivityEvent) -> Result<(), WorkerError> {
132 if let Some(sender) = &self.event_sender {
133 sender
134 .send(event)
135 .map_err(|source| WorkerError::registration(EventSeamClosed { source }))?;
136 }
137 Ok(())
138 }
139
140 #[must_use]
142 pub fn is_cancelled(&self) -> bool {
143 self.cancellation.cancelled.load(Ordering::Acquire)
144 }
145
146 pub async fn cancelled(&self) {
148 while !self.is_cancelled() {
149 self.cancellation.notify.notified().await;
150 }
151 }
152
153 pub(crate) fn with_heartbeat_sender(
154 activity_id: ActivityId,
155 attempt: u32,
156 heartbeat_sender: Option<mpsc::UnboundedSender<HeartbeatRequest>>,
157 ) -> (Self, ActivityCancellationHandle) {
158 Self::for_workflow(None, activity_id, attempt, heartbeat_sender)
159 }
160
161 pub(crate) fn for_workflow(
162 workflow_id: Option<WorkflowId>,
163 activity_id: ActivityId,
164 attempt: u32,
165 heartbeat_sender: Option<mpsc::UnboundedSender<HeartbeatRequest>>,
166 ) -> (Self, ActivityCancellationHandle) {
167 Self::for_workflow_with_events(
168 workflow_id,
169 activity_id,
170 attempt,
171 None,
172 heartbeat_sender,
173 None,
174 )
175 }
176
177 pub(crate) fn for_task(
178 workflow_id: WorkflowId,
179 activity_id: ActivityId,
180 attempt: u32,
181 idempotency_key: String,
182 heartbeat_sender: Option<mpsc::UnboundedSender<HeartbeatRequest>>,
183 ) -> (Self, ActivityCancellationHandle) {
184 Self::for_workflow_with_events(
185 Some(workflow_id),
186 activity_id,
187 attempt,
188 Some(idempotency_key),
189 heartbeat_sender,
190 None,
191 )
192 }
193
194 pub(crate) fn for_workflow_with_events(
199 workflow_id: Option<WorkflowId>,
200 activity_id: ActivityId,
201 attempt: u32,
202 idempotency_key: Option<String>,
203 heartbeat_sender: Option<mpsc::UnboundedSender<HeartbeatRequest>>,
204 event_sender: Option<mpsc::UnboundedSender<ActivityEvent>>,
205 ) -> (Self, ActivityCancellationHandle) {
206 let cancellation = Arc::new(CancellationState {
207 cancelled: AtomicBool::new(false),
208 notify: Notify::new(),
209 });
210 let context = Self {
211 workflow_id,
212 activity_id,
213 attempt,
214 idempotency_key,
215 cancellation: Arc::clone(&cancellation),
216 heartbeat_sender,
217 event_sender,
218 };
219 let handle = ActivityCancellationHandle { cancellation };
220 (context, handle)
221 }
222}
223
224impl ActivityCancellationHandle {
225 pub fn cancel(&self) {
227 let was_cancelled = self.cancellation.cancelled.swap(true, Ordering::AcqRel);
228 if !was_cancelled {
229 self.cancellation.notify.notify_waiters();
230 }
231 }
232}
233
234#[derive(Debug, thiserror::Error)]
235#[error("activity heartbeat seam is closed: {source}")]
236struct HeartbeatSeamClosed {
237 source: mpsc::error::SendError<HeartbeatRequest>,
238}
239
240#[derive(Debug, thiserror::Error)]
241#[error("activity transcript event seam is closed: {source}")]
242struct EventSeamClosed {
243 source: mpsc::error::SendError<ActivityEvent>,
244}
245
246#[derive(Debug, thiserror::Error)]
247#[error("activity {activity_id} heartbeat is missing workflow id")]
248struct HeartbeatMissingWorkflow {
249 activity_id: ActivityId,
250}
251
252#[cfg(test)]
253mod tests {
254 use std::time::Duration;
255
256 use aion_core::ActivityId;
257
258 use super::ActivityContext;
259
260 #[tokio::test]
265 async fn emit_event_is_a_no_op_without_an_installed_seam() {
266 use aion_core::{ActivityEvent, ActivityEventKind, MessageRole, WorkflowId};
267 use chrono::Utc;
268 use uuid::Uuid;
269
270 let (context, _cancellation) =
271 ActivityContext::new(ActivityId::from_sequence_position(1), 0);
272 let event = ActivityEvent {
273 workflow_id: WorkflowId::new(Uuid::from_u128(1)),
274 activity_id: ActivityId::from_sequence_position(1),
275 attempt: 0,
276 agent_id: Uuid::from_u128(2),
277 agent_role: "orchestrator".to_owned(),
278 emitted_at: Utc::now(),
279 worker_seq: 1,
280 store_seq: None,
281 ephemeral: false,
282 kind: ActivityEventKind::Message {
283 role: MessageRole::Assistant,
284 text: "hello".to_owned(),
285 },
286 };
287 assert!(context.emit_event(event).is_ok());
288 }
289
290 #[tokio::test]
293 async fn emit_event_forwards_to_installed_seam() -> Result<(), Box<dyn std::error::Error>> {
294 use aion_core::{ActivityEvent, ActivityEventKind, MessageRole, WorkflowId};
295 use chrono::Utc;
296 use uuid::Uuid;
297
298 let (sender, mut drain) = super::mpsc::unbounded_channel();
299 let (context, _cancellation) = ActivityContext::for_workflow_with_events(
300 Some(WorkflowId::new(Uuid::from_u128(1))),
301 ActivityId::from_sequence_position(1),
302 0,
303 None,
304 None,
305 Some(sender),
306 );
307 let event = ActivityEvent {
308 workflow_id: WorkflowId::new(Uuid::from_u128(1)),
309 activity_id: ActivityId::from_sequence_position(1),
310 attempt: 0,
311 agent_id: Uuid::from_u128(2),
312 agent_role: "orchestrator".to_owned(),
313 emitted_at: Utc::now(),
314 worker_seq: 7,
315 store_seq: None,
316 ephemeral: false,
317 kind: ActivityEventKind::Message {
318 role: MessageRole::Assistant,
319 text: "steer".to_owned(),
320 },
321 };
322 context.emit_event(event.clone())?;
323 let delivered = drain.recv().await.ok_or("event must be delivered")?;
324 assert_eq!(delivered.worker_seq, 7);
325 assert_eq!(delivered, event);
326 Ok(())
327 }
328
329 #[test]
330 fn live_task_context_exposes_the_server_idempotency_key() {
331 let (context, cancellation) = ActivityContext::for_task(
332 aion_core::WorkflowId::new_v4(),
333 ActivityId::from_sequence_position(7),
334 3,
335 String::from("effect-key"),
336 None,
337 );
338
339 assert_eq!(context.idempotency_key(), Some("effect-key"));
340 assert_eq!(context.attempt(), 3);
341 drop(cancellation);
342 }
343
344 #[tokio::test]
345 async fn context_exposes_identity_attempt_and_cancellation_signal() {
346 let activity_id = ActivityId::from_sequence_position(42);
347 let (context, cancellation) = ActivityContext::new(activity_id.clone(), 3);
348
349 assert_eq!(context.activity_id(), &activity_id);
350 assert_eq!(context.attempt(), 3);
351 assert!(!context.is_cancelled());
352
353 cancellation.cancel();
354
355 assert!(context.is_cancelled());
356 let cancelled = tokio::time::timeout(Duration::from_millis(50), context.cancelled()).await;
357 assert!(cancelled.is_ok());
358 }
359}