Skip to main content

aion_worker/
context.rs

1//! `ActivityContext` heartbeat, cancellation, attempt, and identifier support.
2
3use 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/// Handler-facing context for one activity execution.
12#[derive(Clone, Debug)]
13pub struct ActivityContext {
14    /// The workflow this activity belongs to.
15    workflow_id: WorkflowId,
16    /// The concrete run this activity was dispatched by — the generation axis.
17    ///
18    /// REQUIRED, never optional: [`ActivityContext::run_id`] is the only source
19    /// a handler has for the run axis its transcript events must carry, and
20    /// [`aion_core::ActivityEvent::run_id`] is itself a required field. An
21    /// optional accessor would leave a handler with no honest way to build a
22    /// required field, so the run is supplied at construction or the context is
23    /// never built — the refusal lives at the dispatch boundary, not here.
24    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    /// NOI-5b agent-observability event seam (additive, OPTIONAL). A running
31    /// activity (or the harness adapter driving it) emits neutral
32    /// [`ActivityEvent`]s here; the worker runtime drains them and forwards them
33    /// to the server's transcript sequencer over the same transport activity
34    /// results take. A context created WITHOUT this seam — every isolated unit
35    /// test and every activity that emits nothing — is a no-op, byte-identical to
36    /// today, exactly as the `heartbeat_sender` seam is.
37    event_sender: Option<mpsc::UnboundedSender<ActivityEvent>>,
38}
39
40/// Internal handle used by the worker runtime to signal cooperative cancellation.
41#[derive(Clone, Debug)]
42pub struct ActivityCancellationHandle {
43    cancellation: Arc<CancellationState>,
44}
45
46/// Heartbeat request emitted by [`ActivityContext::heartbeat`].
47#[derive(Clone, Debug, PartialEq, Eq)]
48pub struct HeartbeatRequest {
49    /// Workflow owning the activity whose progress is being reported.
50    pub workflow_id: WorkflowId,
51    /// Activity whose progress is being reported.
52    pub activity_id: ActivityId,
53    /// Opaque progress detail supplied by the handler.
54    pub detail: Option<Payload>,
55}
56
57#[derive(Debug)]
58struct CancellationState {
59    cancelled: AtomicBool,
60    notify: Notify,
61}
62
63impl ActivityContext {
64    /// Creates a context and the internal handle that can signal cancellation.
65    ///
66    /// The full dispatch identity is required: an activity execution always
67    /// belongs to one `(workflow, run, activity, attempt)`, and a handler reads
68    /// the workflow and run back to stamp the transcript events it emits.
69    #[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    /// Creates a context whose transcript seam is live, for a host that owns
80    /// the receiving end of `events`.
81    ///
82    /// This is the seam [`Self::emit_event`] publishes on: every event a handler
83    /// emits carries this context's `(workflow_id, run_id, activity_id, attempt)`
84    /// identity, which is exactly the key the server's transcript sequencer
85    /// files it under. A host that executes an activity IN PROCESS (the server's
86    /// declared-command path) uses this to hand its own publisher the same
87    /// stream a remote worker's drain would have delivered.
88    #[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    /// Returns this activity's identifier.
108    #[must_use]
109    pub const fn activity_id(&self) -> &ActivityId {
110        &self.activity_id
111    }
112
113    /// Returns the workflow this activity belongs to.
114    #[must_use]
115    pub const fn workflow_id(&self) -> &WorkflowId {
116        &self.workflow_id
117    }
118
119    /// Returns this activity's attempt number.
120    #[must_use]
121    pub const fn attempt(&self) -> u32 {
122        self.attempt
123    }
124
125    /// Returns the concrete run this activity was dispatched by.
126    ///
127    /// A handler that emits transcript events through [`Self::emit_event`]
128    /// stamps this onto every [`ActivityEvent`] it builds: the transcript
129    /// keyspace is keyed on `(workflow, run, activity, attempt)`, and without
130    /// the run two generations of one continue-as-new chain write to the same
131    /// stream. Always present — the run is part of the dispatch identity, so
132    /// the handler is never handed an absence it cannot resolve.
133    #[must_use]
134    pub const fn run_id(&self) -> &RunId {
135        &self.run_id
136    }
137
138    /// Returns the stable external-effect key delivered with this task.
139    ///
140    /// Live worker tasks always return `Some`; manually constructed unit-test
141    /// contexts return `None` because they are not attached to a server task.
142    #[must_use]
143    pub fn idempotency_key(&self) -> Option<&str> {
144        self.idempotency_key.as_deref()
145    }
146
147    /// Emits a cooperative heartbeat request for this activity.
148    ///
149    /// This is the PROGRESS channel: handlers call it to attach a progress
150    /// payload to the activity's liveness record. LIVENESS itself is owned by
151    /// the worker runtime, which automatically heartbeats every in-flight
152    /// activity within the server-assigned heartbeat window — a handler that
153    /// never calls this still stays live for as long as it genuinely runs.
154    /// Contexts created without a live heartbeat sender remain no-op contexts
155    /// for isolated unit tests.
156    ///
157    /// # Errors
158    ///
159    /// Returns [`WorkerError`] when an installed heartbeat seam has been closed.
160    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    /// Emit a neutral agent-observability [`ActivityEvent`] onto the transcript
174    /// seam (NOI-5b).
175    ///
176    /// Additive and OPTIONAL: on a context created without a live event seam
177    /// (every isolated unit test, and every activity that does not run an
178    /// instrumented agent) this is a no-op returning `Ok(())`, so behaviour is
179    /// byte-identical to today. When a seam is installed the worker runtime drains
180    /// these events and forwards them to the server's transcript sequencer, which
181    /// stamps the commit-allocated `store_seq` — the producer never assigns it.
182    ///
183    /// Harness-neutral: the payload is a pure `aion-core` [`ActivityEvent`]; the
184    /// per-harness mapping lives in the worker-side adapter, never here.
185    ///
186    /// # Errors
187    ///
188    /// Returns [`WorkerError`] when an installed event seam has been closed (the
189    /// runtime drain end was dropped) — a dropped transcript event is surfaced,
190    /// never silently swallowed.
191    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    /// Returns true once cooperative cancellation has been signalled.
201    #[must_use]
202    pub fn is_cancelled(&self) -> bool {
203        self.cancellation.cancelled.load(Ordering::Acquire)
204    }
205
206    /// Resolves when cooperative cancellation is signalled.
207    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    /// Build a context with BOTH the heartbeat seam and the NOI-5b transcript
251    /// event seam installed. The runtime uses this when an activity is driven with
252    /// observability enabled; the pre-existing constructors default the event seam
253    /// to `None` so every current call site is unchanged.
254    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    /// Signals cooperative cancellation to the handler-facing context.
284    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    /// NOI-5b: a context WITHOUT an event seam is a no-op — `emit_event` returns
313    /// `Ok(())` and drops the event, byte-identical to a context that predates the
314    /// seam. This is the additive guarantee: an activity that emits nothing (and
315    /// every isolated unit test) is unaffected.
316    #[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        // The dispatch identity is always readable — a handler stamping a
331        // transcript event never has to invent the run axis.
332        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    /// With an event seam installed, `emit_event` forwards the neutral event to
354    /// the runtime drain end — the additive worker->server ingestion seam.
355    #[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        // A dispatched context exposes the run its events must be stamped with.
373        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}