Skip to main content

aion_server/worker/
declared_body.rs

1//! Server-side execution of declared action bodies.
2//!
3//! An action whose deployed contract carries an [`ActionBodyContract`] is
4//! executed BY THE SERVER, with no connected worker: the dispatch is
5//! intercepted at the [`ActivityDispatcher`] seam before task-queue routing,
6//! the declared command runs through the worker SDK's own executor
7//! ([`aion_worker::shell::ShellAction`] — argv-element substitution, no
8//! shell, process-group containment), and the result flows back through the
9//! engine's normal completion path. The engine still schedules, records, and
10//! replays the activity exactly as if a worker had served it.
11//!
12//! Actions with no declared body are delegated to the wrapped production
13//! dispatcher unchanged, so remote workers keep working exactly as before.
14
15use std::collections::BTreeMap;
16use std::sync::{Arc, OnceLock};
17
18use aion::{ActivityDispatch, ActivityDispatcher};
19use aion_package::ActionBodyContract;
20use aion_worker::shell::ShellAction;
21
22/// What a declared-body lookup found for one `(task_queue, action)` address.
23#[derive(Clone, Debug)]
24pub enum DeclaredBodyLookup {
25    /// No retained contract declares a body for this action — it is a
26    /// requirement on an out-of-band worker and must be delegated.
27    None,
28    /// Exactly one distinct body is declared across every retained package
29    /// version. Safe to execute.
30    Declared(ActionBodyContract),
31    /// Retained package versions declare DIFFERENT bodies for this action.
32    /// Executing one of them would guess which deploy the running workflow
33    /// meant, so the dispatch is refused by name instead.
34    Ambiguous {
35        /// How many distinct bodies were found.
36        count: usize,
37    },
38    /// The catalog could not be read. The reader reports why; the dispatch
39    /// is delegated so a readable worker path can still serve it.
40    Unreadable(String),
41}
42
43/// A reader over the deployed contracts' declared action bodies.
44pub trait DeclaredBodies: Send + Sync {
45    /// Look up the declared body for `action` on `task_queue`.
46    fn body_for(&self, task_queue: &str, action: &str) -> DeclaredBodyLookup;
47}
48
49/// Shared, install-once handle the dispatcher holds from construction and the
50/// boot path fills in once the engine exists.
51///
52/// Mirrors [`super::QueueDeclarationSource`]: the dispatcher is built before
53/// the engine, so the seam it consults is handed over afterwards through a
54/// clone of this handle rather than by rebuilding the dispatcher.
55#[derive(Clone, Default)]
56pub struct DeclaredBodySource {
57    inner: Arc<OnceLock<Arc<dyn DeclaredBodies>>>,
58}
59
60impl std::fmt::Debug for DeclaredBodySource {
61    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        formatter
63            .debug_struct("DeclaredBodySource")
64            .field("installed", &self.inner.get().is_some())
65            .finish()
66    }
67}
68
69impl DeclaredBodySource {
70    /// Install the reader. A second install is ignored and logged: the source
71    /// is process-wide and must not silently change identity.
72    pub fn install(&self, source: Arc<dyn DeclaredBodies>) {
73        if self.inner.set(source).is_err() {
74            tracing::warn!("declared body source already installed; ignoring duplicate set");
75        }
76    }
77
78    /// Look up the declared body, or [`DeclaredBodyLookup::None`] when no
79    /// reader is installed yet — before the engine exists nothing has been
80    /// deployed, so there is no body a dispatch could be missing.
81    #[must_use]
82    pub fn body_for(&self, task_queue: &str, action: &str) -> DeclaredBodyLookup {
83        self.inner.get().map_or(DeclaredBodyLookup::None, |source| {
84            source.body_for(task_queue, action)
85        })
86    }
87}
88
89/// Reads declared bodies out of the engine's live workflow catalog.
90pub struct EngineDeclaredBodies {
91    engine: Arc<aion::Engine>,
92}
93
94impl EngineDeclaredBodies {
95    /// Build a reader over `engine`'s catalog.
96    #[must_use]
97    pub const fn new(engine: Arc<aion::Engine>) -> Self {
98        Self { engine }
99    }
100}
101
102impl std::fmt::Debug for EngineDeclaredBodies {
103    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104        formatter.write_str("EngineDeclaredBodies")
105    }
106}
107
108impl DeclaredBodies for EngineDeclaredBodies {
109    fn body_for(&self, task_queue: &str, action: &str) -> DeclaredBodyLookup {
110        let contracts = match self.engine.worker_contracts_for_queue(task_queue) {
111            Ok(contracts) => contracts,
112            Err(error) => return DeclaredBodyLookup::Unreadable(error.to_string()),
113        };
114        // Distinct package versions legitimately coexist (content-hash
115        // namespacing), and several may declare this same action. Identical
116        // bodies collapse to one; DIFFERENT bodies refuse rather than guess
117        // which deploy the running workflow meant.
118        let mut bodies: Vec<ActionBodyContract> = Vec::new();
119        for deployed in contracts {
120            for declared in &deployed.contract.actions {
121                if declared.name == action
122                    && let Some(body) = &declared.body
123                    && !bodies.contains(body)
124                {
125                    bodies.push(body.clone());
126                }
127            }
128        }
129        match bodies.len() {
130            0 => DeclaredBodyLookup::None,
131            1 => match bodies.pop() {
132                Some(body) => DeclaredBodyLookup::Declared(body),
133                // Unreachable by the length check; classified rather than
134                // panicked so a refactor cannot turn this into an abort.
135                None => DeclaredBodyLookup::None,
136            },
137            count => DeclaredBodyLookup::Ambiguous { count },
138        }
139    }
140}
141
142/// The dispatcher decorator that executes declared bodies at the server.
143///
144/// Wraps the production dispatcher. Consults the declared-body source before
145/// every dispatch; delegates untouched whenever the action carries no body.
146pub struct DeclaredCommandDispatcher {
147    inner: Arc<dyn ActivityDispatcher>,
148    bodies: DeclaredBodySource,
149    tokio: tokio::runtime::Handle,
150}
151
152impl DeclaredCommandDispatcher {
153    /// Wrap `inner`, consulting `bodies` before every dispatch.
154    #[must_use]
155    pub fn new(
156        inner: Arc<dyn ActivityDispatcher>,
157        bodies: DeclaredBodySource,
158        tokio: tokio::runtime::Handle,
159    ) -> Self {
160        Self {
161            inner,
162            bodies,
163            tokio,
164        }
165    }
166
167    /// Execute one declared command attempt and encode the outcome onto the
168    /// FFI string contract (`retryable:`/`terminal:` on the error side).
169    fn run_declared_command(
170        &self,
171        request: &ActivityDispatch,
172        command: &str,
173    ) -> Result<String, String> {
174        let arguments = decode_arguments(&request.input)?;
175        let action = ShellAction::new(command).map_err(|error| {
176            // The AWL checker refuses these at compile time, so reaching this
177            // arm means a defective contract got deployed — name the defect
178            // rather than hiding it behind a generic dispatch failure.
179            format!("terminal:declared command failed to parse at dispatch: {error}")
180        })?;
181        let (context, cancellation) =
182            aion_worker::ActivityContext::new(request.activity_id.clone(), request.attempt);
183
184        tracing::info!(
185            operation = "declared_command_dispatch",
186            workflow_id = %request.workflow_id,
187            activity_id = %request.activity_id,
188            activity_name = %request.name,
189            task_queue = %request.task_queue,
190            attempt = request.attempt,
191            "executing declared action body at the server"
192        );
193
194        let outcome = self.tokio.block_on(action.run(&arguments, &context));
195        // Held, not wired: nothing cancels a single declared-command attempt
196        // today. Dropped only after the run so a future wiring cannot race a
197        // handle that died early.
198        drop(cancellation);
199
200        match outcome {
201            Ok(result) => serde_json::to_string(&result).map_err(|error| {
202                format!("terminal:declared command result failed to encode: {error}")
203            }),
204            Err(failure) => {
205                let prefix = match failure.classification() {
206                    aion_worker::Classification::Retryable => "retryable",
207                    aion_worker::Classification::Terminal => "terminal",
208                };
209                Err(format!("{prefix}:{}", failure.message()))
210            }
211        }
212    }
213}
214
215impl std::fmt::Debug for DeclaredCommandDispatcher {
216    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
217        formatter
218            .debug_struct("DeclaredCommandDispatcher")
219            .field("bodies", &self.bodies)
220            .finish_non_exhaustive()
221    }
222}
223
224impl ActivityDispatcher for DeclaredCommandDispatcher {
225    fn dispatch(&self, request: ActivityDispatch) -> Result<String, String> {
226        match self.bodies.body_for(&request.task_queue, &request.name) {
227            DeclaredBodyLookup::None => self.inner.dispatch(request),
228            DeclaredBodyLookup::Unreadable(reason) => {
229                // Delegated, not refused: a catalog read failure must not
230                // strand a queue that live workers could still serve. Loud so
231                // an operator sees a bodied action falling through.
232                tracing::error!(
233                    operation = "declared_command_dispatch",
234                    workflow_id = %request.workflow_id,
235                    activity_name = %request.name,
236                    task_queue = %request.task_queue,
237                    %reason,
238                    "declared-body catalog read failed; delegating to the worker path"
239                );
240                self.inner.dispatch(request)
241            }
242            DeclaredBodyLookup::Ambiguous { count } => Err(format!(
243                "terminal:action `{}` on task queue `{}` declares {count} different bodies \
244                 across retained package versions; refusing to guess which deploy this run \
245                 meant — redeploy so one body remains",
246                request.name, request.task_queue
247            )),
248            DeclaredBodyLookup::Declared(ActionBodyContract::Run { command }) => {
249                self.run_declared_command(&request, &command)
250            }
251        }
252    }
253}
254
255/// Decode the dispatch's JSON input into the declared action's arguments.
256///
257/// A declared action's parameters are named in its `.awl` declaration, so the
258/// input must be a JSON object; anything else cannot bind to `$name`
259/// references and is refused by shape. Retrying cannot change the input, so
260/// the refusal is terminal.
261fn decode_arguments(input: &str) -> Result<BTreeMap<String, serde_json::Value>, String> {
262    let value: serde_json::Value = serde_json::from_str(input)
263        .map_err(|error| format!("terminal:declared command input is not valid JSON: {error}"))?;
264    match value {
265        serde_json::Value::Object(members) => Ok(members.into_iter().collect()),
266        other => Err(format!(
267            "terminal:declared command input must be a JSON object binding the action's \
268             parameters by name; got {}",
269            json_kind(&other)
270        )),
271    }
272}
273
274/// A JSON value's kind, named for a refusal message.
275const fn json_kind(value: &serde_json::Value) -> &'static str {
276    match value {
277        serde_json::Value::Null => "null",
278        serde_json::Value::Bool(_) => "a boolean",
279        serde_json::Value::Number(_) => "a number",
280        serde_json::Value::String(_) => "a string",
281        serde_json::Value::Array(_) => "an array",
282        serde_json::Value::Object(_) => "an object",
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use std::collections::BTreeMap;
289    use std::sync::{Arc, Mutex};
290
291    use aion::{ActivityDispatch, ActivityDispatcher};
292    use aion_core::{ActivityId, RunId, WorkflowId};
293    use aion_package::ActionBodyContract;
294
295    use super::{
296        DeclaredBodies, DeclaredBodyLookup, DeclaredBodySource, DeclaredCommandDispatcher,
297        decode_arguments,
298    };
299
300    /// What a test returns. Every fallible step is carried rather than
301    /// unwrapped, because the workspace denies panicking accessors in test
302    /// code as firmly as in library code.
303    type TestResult = Result<(), Box<dyn std::error::Error>>;
304
305    /// Inner dispatcher that records whether it was reached.
306    struct RecordingInner {
307        reached: Arc<Mutex<Vec<String>>>,
308        reply: Result<String, String>,
309    }
310
311    impl ActivityDispatcher for RecordingInner {
312        fn dispatch(&self, request: ActivityDispatch) -> Result<String, String> {
313            match self.reached.lock() {
314                Ok(mut names) => names.push(request.name),
315                Err(poisoned) => poisoned.into_inner().push(request.name),
316            }
317            self.reply.clone()
318        }
319    }
320
321    struct FixedBodies {
322        lookup: DeclaredBodyLookup,
323    }
324
325    impl DeclaredBodies for FixedBodies {
326        fn body_for(&self, _task_queue: &str, _action: &str) -> DeclaredBodyLookup {
327            self.lookup.clone()
328        }
329    }
330
331    fn request(name: &str, input: &str) -> ActivityDispatch {
332        ActivityDispatch {
333            namespace: "default".to_owned(),
334            task_queue: "shell".to_owned(),
335            node: None,
336            workflow_id: WorkflowId::new_v4(),
337            run_id: RunId::new_v4(),
338            activity_id: ActivityId::from_sequence_position(1),
339            name: name.to_owned(),
340            input: input.to_owned(),
341            config: "{}".to_owned(),
342            attempt: 1,
343            labels: BTreeMap::new(),
344            advisory: false,
345        }
346    }
347
348    fn dispatcher(
349        lookup: DeclaredBodyLookup,
350        reply: Result<String, String>,
351    ) -> (DeclaredCommandDispatcher, Arc<Mutex<Vec<String>>>) {
352        let reached = Arc::new(Mutex::new(Vec::new()));
353        let inner = RecordingInner {
354            reached: Arc::clone(&reached),
355            reply,
356        };
357        let bodies = DeclaredBodySource::default();
358        bodies.install(Arc::new(FixedBodies { lookup }));
359        let decorated = DeclaredCommandDispatcher::new(
360            Arc::new(inner),
361            bodies,
362            tokio::runtime::Handle::current(),
363        );
364        (decorated, reached)
365    }
366
367    fn reached_names(reached: &Arc<Mutex<Vec<String>>>) -> Vec<String> {
368        match reached.lock() {
369            Ok(names) => names.clone(),
370            Err(poisoned) => poisoned.into_inner().clone(),
371        }
372    }
373
374    #[tokio::test(flavor = "multi_thread")]
375    async fn a_bodiless_action_is_delegated_untouched() -> TestResult {
376        let (decorated, reached) =
377            dispatcher(DeclaredBodyLookup::None, Ok("\"worker-served\"".to_owned()));
378        let handle =
379            tokio::task::spawn_blocking(move || decorated.dispatch(request("plain", "{}")));
380        let result = handle.await?;
381        assert_eq!(result, Ok("\"worker-served\"".to_owned()));
382        assert_eq!(reached_names(&reached), vec!["plain".to_owned()]);
383        Ok(())
384    }
385
386    #[tokio::test(flavor = "multi_thread")]
387    async fn a_declared_body_executes_without_touching_the_worker_path() -> TestResult {
388        let (decorated, reached) = dispatcher(
389            DeclaredBodyLookup::Declared(ActionBodyContract::Run {
390                command: "echo $greeting".to_owned(),
391            }),
392            Err("terminal:the worker path must never be reached".to_owned()),
393        );
394        let handle = tokio::task::spawn_blocking(move || {
395            decorated.dispatch(request(
396                "greet",
397                "{\"greeting\":\"hello from the contract\"}",
398            ))
399        });
400        let result = handle.await?;
401        let encoded = result.map_err(|error| format!("declared command failed: {error}"))?;
402        let outcome: serde_json::Value = serde_json::from_str(&encoded)?;
403        assert_eq!(outcome["stdout"], "hello from the contract");
404        assert_eq!(outcome["exit_code"], 0);
405        assert!(
406            reached_names(&reached).is_empty(),
407            "the worker path must not be consulted for a bodied action"
408        );
409        Ok(())
410    }
411
412    #[tokio::test(flavor = "multi_thread")]
413    async fn a_failing_declared_command_reports_retryable_with_its_stderr() -> TestResult {
414        let (decorated, _reached) = dispatcher(
415            DeclaredBodyLookup::Declared(ActionBodyContract::Run {
416                command: "sh -c 'echo boom >&2; exit 7'".to_owned(),
417            }),
418            Ok("unused".to_owned()),
419        );
420        let handle =
421            tokio::task::spawn_blocking(move || decorated.dispatch(request("fails", "{}")));
422        let Err(error) = handle.await? else {
423            return Err("a non-zero exit must fail the dispatch".into());
424        };
425        assert!(
426            error.starts_with("retryable:"),
427            "a non-zero exit is retryable by default: {error}"
428        );
429        assert!(
430            error.contains("boom"),
431            "stderr must ride the failure: {error}"
432        );
433        Ok(())
434    }
435
436    #[tokio::test(flavor = "multi_thread")]
437    async fn ambiguous_bodies_refuse_terminally_by_name() -> TestResult {
438        let (decorated, reached) = dispatcher(
439            DeclaredBodyLookup::Ambiguous { count: 2 },
440            Ok(String::new()),
441        );
442        let handle = tokio::task::spawn_blocking(move || decorated.dispatch(request("torn", "{}")));
443        let Err(error) = handle.await? else {
444            return Err("ambiguous bodies must refuse".into());
445        };
446        assert!(error.starts_with("terminal:"), "{error}");
447        assert!(error.contains("torn"), "{error}");
448        assert!(reached_names(&reached).is_empty());
449        Ok(())
450    }
451
452    #[tokio::test(flavor = "multi_thread")]
453    async fn an_unreadable_catalog_delegates_to_the_worker_path() -> TestResult {
454        let (decorated, reached) = dispatcher(
455            DeclaredBodyLookup::Unreadable("catalog offline".to_owned()),
456            Ok("\"served anyway\"".to_owned()),
457        );
458        let handle =
459            tokio::task::spawn_blocking(move || decorated.dispatch(request("resilient", "{}")));
460        let result = handle.await?;
461        assert_eq!(result, Ok("\"served anyway\"".to_owned()));
462        assert_eq!(reached_names(&reached), vec!["resilient".to_owned()]);
463        Ok(())
464    }
465
466    #[test]
467    fn non_object_input_is_refused_terminally_by_shape() {
468        for (input, kind) in [
469            ("[1,2]", "an array"),
470            ("\"text\"", "a string"),
471            ("3", "a number"),
472            ("null", "null"),
473            ("true", "a boolean"),
474        ] {
475            let Err(error) = decode_arguments(input) else {
476                unreachable_refusal(input);
477                return;
478            };
479            assert!(error.starts_with("terminal:"), "{error}");
480            assert!(error.contains(kind), "{error} must name {kind}");
481        }
482    }
483
484    /// Fails the calling test without a panicking accessor.
485    fn unreachable_refusal(input: &str) {
486        assert!(
487            input.is_empty(),
488            "input `{input}` must have been refused by shape"
489        );
490    }
491}