Skip to main content

aion_worker/protocol/
task.rs

1//! `ActivityTask` decode and `TaskResult`/`TaskFailure` encode.
2
3use std::collections::BTreeMap;
4
5use aion_core::{ActivityId, Payload, RunId, WorkflowId};
6use aion_proto::ProtoActivityTask;
7
8use crate::error::WorkerError;
9
10/// SDK-level activity task envelope decoded from the AW-owned worker proto.
11#[derive(Clone, Debug, PartialEq, Eq)]
12pub struct ActivityTask {
13    /// Owning workflow id, required later when reporting this task's outcome.
14    pub workflow_id: WorkflowId,
15    /// Activity id correlating reports and heartbeats with this task.
16    pub activity_id: ActivityId,
17    /// Concrete workflow run that staged this task — the generation axis.
18    ///
19    /// REQUIRED, not optional: a continue-as-new chain reuses one workflow id
20    /// while activity ordinals and attempt numbers restart in every generation,
21    /// so `(workflow, activity, attempt)` alone does not name a dispatch. The
22    /// run is what every transcript event this task emits is keyed by, and
23    /// [`crate::ActivityContext`] hands it to the handler as a plain value —
24    /// a handler must never have to invent one. Decoding refuses a wire task
25    /// without it ([`MalformedActivityTask::MissingRunId`]), so a hand-built
26    /// test task names its generation exactly as a live dispatch does.
27    pub run_id: RunId,
28    /// Registered activity type name requested by the engine.
29    pub activity_type: String,
30    /// One-based delivery attempt stamped by the dispatching engine seam and
31    /// read from the wire. Zero is malformed and rejected at decode.
32    pub attempt: u32,
33    /// Opaque execution-generation token echoed verbatim on every outcome.
34    pub completion_token: String,
35    /// Stable external-effect key for this run and action site. Identical across
36    /// retries and available to handlers through [`crate::ActivityContext`].
37    pub idempotency_key: String,
38    /// Opaque activity input payload, preserving its content-type tag.
39    pub input: Payload,
40    /// Human-meaningful display labels the workflow attached to the activity
41    /// (for example `brief=IP-001`). Display metadata only — surfaced in the
42    /// worker's logs. `BTreeMap` keeps the rendered order stable; empty when
43    /// the workflow attached none.
44    pub labels: BTreeMap<String, String>,
45}
46
47impl TryFrom<ProtoActivityTask> for ActivityTask {
48    type Error = WorkerError;
49
50    fn try_from(value: ProtoActivityTask) -> Result<Self, Self::Error> {
51        let workflow_id = value
52            .workflow_id
53            .ok_or(MalformedActivityTask::MissingWorkflowId)
54            .and_then(|workflow_id| {
55                WorkflowId::try_from(workflow_id)
56                    .map_err(|source| MalformedActivityTask::InvalidWorkflowId { source })
57            })
58            .map_err(WorkerError::decode)?;
59        let activity_id = value
60            .activity_id
61            .ok_or(MalformedActivityTask::MissingActivityId)
62            .map(ActivityId::from)
63            .map_err(WorkerError::decode)?;
64        let run_id = value
65            .run_id
66            .ok_or(MalformedActivityTask::MissingRunId)
67            .and_then(|run_id| {
68                RunId::try_from(run_id)
69                    .map_err(|source| MalformedActivityTask::InvalidRunId { source })
70            })
71            .map_err(WorkerError::decode)?;
72        if value.activity_type.is_empty() {
73            return Err(WorkerError::decode(
74                MalformedActivityTask::MissingActivityType,
75            ));
76        }
77        let input = value
78            .input
79            .ok_or(MalformedActivityTask::MissingInput)
80            .and_then(|input| {
81                Payload::try_from(input)
82                    .map_err(|source| MalformedActivityTask::InvalidInput { source })
83            })
84            .map_err(WorkerError::decode)?;
85
86        if value.attempt == 0 {
87            // proto3 zero default = the producer failed to stamp the attempt.
88            return Err(WorkerError::decode(MalformedActivityTask::MissingAttempt));
89        }
90        if value.completion_token.is_empty() {
91            return Err(WorkerError::decode(
92                MalformedActivityTask::MissingCompletionToken,
93            ));
94        }
95        if value.idempotency_key.is_empty() {
96            return Err(WorkerError::decode(
97                MalformedActivityTask::MissingIdempotencyKey,
98            ));
99        }
100
101        Ok(Self {
102            workflow_id,
103            activity_id,
104            run_id,
105            activity_type: value.activity_type,
106            attempt: value.attempt,
107            completion_token: value.completion_token,
108            idempotency_key: value.idempotency_key,
109            input,
110            labels: value.labels.into_iter().collect(),
111        })
112    }
113}
114
115#[derive(Debug, thiserror::Error)]
116enum MalformedActivityTask {
117    #[error("activity task workflow_id is missing")]
118    MissingWorkflowId,
119    #[error("activity task workflow_id is invalid: {source}")]
120    InvalidWorkflowId { source: aion_proto::WireError },
121    #[error("activity task activity_id is missing")]
122    MissingActivityId,
123    #[error("activity task activity_type is missing")]
124    MissingActivityType,
125    #[error("activity task input payload is missing")]
126    MissingInput,
127    #[error("activity task attempt is missing or zero (producer failed to stamp it)")]
128    MissingAttempt,
129    #[error("activity task completion_token is missing (server registration era is incompatible)")]
130    MissingCompletionToken,
131    #[error("activity task idempotency_key is missing (server registration era is incompatible)")]
132    MissingIdempotencyKey,
133    #[error("activity task input payload is invalid: {source}")]
134    InvalidInput { source: aion_proto::WireError },
135    #[error("activity task run_id is missing (server registration era is incompatible)")]
136    MissingRunId,
137    #[error("activity task run_id is invalid: {source}")]
138    InvalidRunId { source: aion_proto::WireError },
139}
140
141#[cfg(test)]
142mod tests {
143    use aion_core::{ActivityId, ContentType, Payload, WorkflowId};
144    use aion_proto::{
145        ProtoActivityId, ProtoActivityTask, ProtoPayload, ProtoRunId, ProtoWorkflowId,
146    };
147    use serde_json::json;
148
149    use super::ActivityTask;
150    use crate::WorkerError;
151
152    #[test]
153    fn decodes_proto_activity_task_preserving_payload_content_type()
154    -> Result<(), Box<dyn std::error::Error>> {
155        let workflow_id = WorkflowId::new_v4();
156        let activity_id = ActivityId::from_sequence_position(42);
157        let run_id = aion_core::RunId::new_v4();
158        let input_value = json!({"amount": 1250, "currency": "USD"});
159        let input = Payload::from_json(&input_value)?;
160        let proto = ProtoActivityTask {
161            workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
162            activity_id: Some(ProtoActivityId::from(activity_id.clone())),
163            run_id: Some(ProtoRunId::from(run_id.clone())),
164            activity_type: String::from("charge-card"),
165            input: Some(ProtoPayload::from(input.clone())),
166            attempt: 3,
167            completion_token: String::from("generation-3"),
168            idempotency_key: String::from("effect-key"),
169            labels: [(String::from("brief"), String::from("IP-001"))]
170                .into_iter()
171                .collect(),
172        };
173
174        let task = ActivityTask::try_from(proto)?;
175
176        assert_eq!(task.workflow_id, workflow_id);
177        assert_eq!(task.activity_id, activity_id);
178        assert_eq!(task.run_id, run_id);
179        assert_eq!(task.activity_type, "charge-card");
180        assert_eq!(task.attempt, 3, "attempt must be read from the wire");
181        assert_eq!(task.input.content_type(), &ContentType::Json);
182        assert_eq!(task.input.bytes(), input.bytes());
183        assert_eq!(task.input.to_json()?, input_value);
184        assert_eq!(
185            task.labels.get("brief").map(String::as_str),
186            Some("IP-001"),
187            "display labels must decode from the wire"
188        );
189        Ok(())
190    }
191
192    #[test]
193    fn missing_required_field_maps_to_decode_error() {
194        let result = ActivityTask::try_from(ProtoActivityTask {
195            workflow_id: None,
196            activity_id: Some(ProtoActivityId::from(ActivityId::from_sequence_position(1))),
197            run_id: None,
198            activity_type: String::from("charge-card"),
199            input: Some(ProtoPayload::from(Payload::new(
200                ContentType::Json,
201                b"{}".to_vec(),
202            ))),
203            attempt: 1,
204            completion_token: String::from("generation-1"),
205            idempotency_key: String::from("effect-key"),
206            labels: std::collections::HashMap::new(),
207        });
208
209        assert!(matches!(result, Err(WorkerError::Decode { .. })));
210    }
211
212    #[test]
213    fn zero_attempt_is_a_malformed_task() -> Result<(), Box<dyn std::error::Error>> {
214        let result = ActivityTask::try_from(ProtoActivityTask {
215            workflow_id: Some(ProtoWorkflowId::from(WorkflowId::new_v4())),
216            activity_id: Some(ProtoActivityId::from(ActivityId::from_sequence_position(1))),
217            run_id: Some(ProtoRunId::from(aion_core::RunId::new_v4())),
218            activity_type: String::from("charge-card"),
219            input: Some(ProtoPayload::from(Payload::new(
220                ContentType::Json,
221                b"{}".to_vec(),
222            ))),
223            attempt: 0,
224            completion_token: String::from("generation-1"),
225            idempotency_key: String::from("effect-key"),
226            labels: std::collections::HashMap::new(),
227        });
228
229        let error = result
230            .err()
231            .ok_or("attempt 0 must be rejected as malformed")?;
232        assert!(matches!(error, WorkerError::Decode { .. }));
233        assert!(
234            error.to_string().contains("attempt"),
235            "error must name the attempt field: {error}"
236        );
237        Ok(())
238    }
239
240    #[test]
241    fn missing_fencing_fields_are_incompatible_tasks() -> Result<(), Box<dyn std::error::Error>> {
242        let valid = ProtoActivityTask {
243            workflow_id: Some(ProtoWorkflowId::from(WorkflowId::new_v4())),
244            activity_id: Some(ProtoActivityId::from(ActivityId::from_sequence_position(1))),
245            run_id: Some(ProtoRunId::from(aion_core::RunId::new_v4())),
246            activity_type: String::from("charge-card"),
247            input: Some(ProtoPayload::from(Payload::new(
248                ContentType::Json,
249                b"{}".to_vec(),
250            ))),
251            attempt: 1,
252            completion_token: String::from("generation-1"),
253            idempotency_key: String::from("effect-key"),
254            labels: std::collections::HashMap::new(),
255        };
256
257        let mut missing_token = valid.clone();
258        missing_token.completion_token.clear();
259        let token_error = ActivityTask::try_from(missing_token)
260            .err()
261            .ok_or("empty completion token must be rejected")?;
262        assert!(
263            token_error.to_string().contains("completion_token"),
264            "refusal must name the missing generation proof: {token_error}"
265        );
266
267        let mut missing_key = valid.clone();
268        missing_key.idempotency_key.clear();
269        let key_error = ActivityTask::try_from(missing_key)
270            .err()
271            .ok_or("empty idempotency key must be rejected")?;
272        assert!(
273            key_error.to_string().contains("idempotency_key"),
274            "refusal must name the missing external-effect key: {key_error}"
275        );
276
277        let mut missing_run = valid;
278        missing_run.run_id = None;
279        let run_error = ActivityTask::try_from(missing_run)
280            .err()
281            .ok_or("missing run id must be rejected")?;
282        assert!(
283            run_error.to_string().contains("run_id"),
284            "refusal must name the missing concrete run: {run_error}"
285        );
286        Ok(())
287    }
288}