Skip to main content

aion_worker/shell/
registry.rs

1//! Registering a declared command as a servable activity.
2//!
3//! [`ShellAction`] knows how to run a declared command; this is how a worker
4//! offers one to the server under an activity-type name, so an action whose
5//! body was written in an `.awl` document is served by exactly the same
6//! dispatch path as a hand-written Rust activity.
7
8use std::collections::BTreeMap;
9
10use crate::activity::{ActivityRegistry, HandlerFuture};
11use crate::error::WorkerError;
12
13use super::action::{ShellAction, ShellOutcome};
14
15/// The arguments a declared action is called with.
16///
17/// A declared action has no Rust input type to deserialize into: its
18/// parameters are named in the `.awl` document, so the call arrives as a
19/// JSON object and the command's own parameter references decide which
20/// members are used. A value that no reference names is simply unused; a
21/// reference with no value is refused by name at render time rather than
22/// silently rendering empty.
23pub type DeclaredArguments = BTreeMap<String, serde_json::Value>;
24
25/// Offering declared commands as activities.
26pub trait DeclaredCommands: Sized {
27    /// Register `action` to be served under `activity_type`.
28    ///
29    /// # Errors
30    ///
31    /// Returns [`WorkerError::Registration`] when `activity_type` already has
32    /// a registered handler. A declared action never silently displaces a
33    /// hand-written one: a name collision is a deployment mistake worth
34    /// refusing, because the wrong body would otherwise run under the right
35    /// name.
36    fn register_declared_command(
37        self,
38        activity_type: impl Into<String>,
39        action: ShellAction,
40    ) -> Result<Self, WorkerError>;
41}
42
43impl DeclaredCommands for ActivityRegistry {
44    fn register_declared_command(
45        self,
46        activity_type: impl Into<String>,
47        action: ShellAction,
48    ) -> Result<Self, WorkerError> {
49        self.register_activity(
50            activity_type,
51            move |arguments: DeclaredArguments, context| -> HandlerFuture<'_, ShellOutcome> {
52                // The action is cloned per call rather than shared behind a
53                // lock: it is an immutable parsed command, so a clone is a
54                // few allocations and every call stays independent of every
55                // other. Nothing about one run can affect another.
56                let action = action.clone();
57                Box::pin(async move { action.run(&arguments, context).await })
58            },
59        )
60    }
61}
62
63#[cfg(test)]
64mod tests {
65    use super::{DeclaredArguments, DeclaredCommands};
66    use crate::activity::ActivityRegistry;
67    use crate::runtime::{ActivityDispatcher, DispatchOutcome, decode_payload, encode_payload};
68    use crate::shell::action::{FailureMode, ShellAction, ShellOutcome};
69    use aion_core::{ActivityErrorKind, ActivityId, ContentType, WorkflowId};
70
71    /// What a test returns. Every fallible step is carried rather than
72    /// unwrapped, because the workspace denies panicking accessors in test
73    /// code as firmly as in library code.
74    type TestResult = Result<(), Box<dyn std::error::Error>>;
75
76    fn arguments(pairs: &[(&str, serde_json::Value)]) -> DeclaredArguments {
77        pairs
78            .iter()
79            .map(|(name, value)| ((*name).to_owned(), value.clone()))
80            .collect()
81    }
82
83    /// Dispatch `activity_type` through `registry` exactly as the serving
84    /// loop would, so these tests exercise the real registered path rather
85    /// than calling the action directly.
86    async fn dispatch(
87        registry: &ActivityRegistry,
88        activity_type: &str,
89        call: &DeclaredArguments,
90    ) -> Result<DispatchOutcome, Box<dyn std::error::Error>> {
91        // The task and the context name the SAME dispatch — one workflow, one
92        // run, one activity, one attempt. Two independently minted identities
93        // would be a contradiction the assertions could not see.
94        let workflow_id = WorkflowId::new_v4();
95        let run_id = aion_core::RunId::new_v4();
96        let activity_id = ActivityId::from_sequence_position(1);
97        let task = crate::protocol::ActivityTask {
98            workflow_id: workflow_id.clone(),
99            activity_id: activity_id.clone(),
100            run_id: run_id.clone(),
101            activity_type: activity_type.to_owned(),
102            attempt: 1,
103            completion_token: "declared-command-test".to_owned(),
104            idempotency_key: "declared-command-test".to_owned(),
105            input: encode_payload(call)?,
106            labels: std::collections::BTreeMap::new(),
107        };
108        let (context, cancellation) =
109            crate::ActivityContext::new(workflow_id, run_id, activity_id, 1);
110        drop(cancellation);
111        Ok(registry.dispatch(task, context).await?)
112    }
113
114    #[tokio::test]
115    async fn a_declared_command_is_served_through_the_normal_dispatch_path() -> TestResult {
116        let registry = ActivityRegistry::new()
117            .register_declared_command("greet", ShellAction::new("echo $greeting")?)?;
118
119        let outcome = dispatch(
120            &registry,
121            "greet",
122            &arguments(&[("greeting", serde_json::json!("hello there"))]),
123        )
124        .await?;
125
126        let DispatchOutcome::Completed { output } = outcome else {
127            return Err("a succeeding declared command must complete".into());
128        };
129        assert_eq!(output.content_type(), &ContentType::Json);
130        let result: ShellOutcome = decode_payload(&output)?;
131        assert_eq!(result.stdout, "hello there");
132        assert_eq!(result.exit_code, 0);
133        Ok(())
134    }
135
136    #[tokio::test]
137    async fn a_failing_declared_command_reports_its_classification() -> TestResult {
138        let registry = ActivityRegistry::new().register_declared_command(
139            "fails",
140            ShellAction::new("sh -c 'exit 4'")?.with_failure_mode(FailureMode::Terminal),
141        )?;
142
143        let outcome = dispatch(&registry, "fails", &arguments(&[])).await?;
144
145        let DispatchOutcome::Failed { failure } = outcome else {
146            return Err("a non-zero exit must fail the activity".into());
147        };
148        assert_eq!(failure.kind, ActivityErrorKind::Terminal);
149        assert!(
150            !failure.is_retryable(),
151            "a terminal declared-command failure must not be retryable"
152        );
153        Ok(())
154    }
155
156    #[tokio::test]
157    async fn a_declared_command_cannot_displace_a_registered_activity() -> TestResult {
158        let registry = ActivityRegistry::new()
159            .register_declared_command("shared", ShellAction::new("echo first")?)?;
160
161        let Err(error) =
162            registry.register_declared_command("shared", ShellAction::new("echo second")?)
163        else {
164            return Err("a duplicate activity type must be refused, not silently replaced".into());
165        };
166        assert!(
167            error.to_string().contains("shared"),
168            "the refusal must name the colliding activity type: {error}"
169        );
170        Ok(())
171    }
172
173    #[tokio::test]
174    async fn an_unnamed_argument_is_ignored_rather_than_smuggled_into_the_command() -> TestResult {
175        // A caller can send anything; only what the command names is used.
176        let registry = ActivityRegistry::new()
177            .register_declared_command("echo", ShellAction::new("echo $wanted")?)?;
178
179        let outcome = dispatch(
180            &registry,
181            "echo",
182            &arguments(&[
183                ("wanted", serde_json::json!("kept")),
184                ("ignored", serde_json::json!("; echo PWNED")),
185            ]),
186        )
187        .await?;
188
189        let DispatchOutcome::Completed { output } = outcome else {
190            return Err("the command must complete".into());
191        };
192        let result: ShellOutcome = decode_payload(&output)?;
193        assert_eq!(result.stdout, "kept");
194        Ok(())
195    }
196}