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, ContentHash};
20use aion_worker::shell::ShellAction;
21
22use super::declared_body_ambiguity::{DeclaringVersion, ambiguous_body_refusal};
23use super::declared_body_selection::select_declared_body;
24use super::workspace_root::{WORKSPACE_ROOT_PLACEHOLDER, WorkspaceRoot};
25
26/// What a declared-body lookup found for one `(task_queue, action)` address.
27#[derive(Clone, Debug)]
28pub enum DeclaredBodyLookup {
29    /// No retained contract declares a body for this action — it is a
30    /// requirement on an out-of-band worker and must be delegated.
31    None,
32    /// Exactly one distinct body is declared across every retained package
33    /// version. Safe to execute.
34    Declared(ActionBodyContract),
35    /// Retained package versions declare DIFFERENT bodies for this action.
36    /// Executing one of them would guess which deploy the running workflow
37    /// meant, so the dispatch is refused by name instead.
38    Ambiguous {
39        /// Every retained version that declares a body for this action, in
40        /// catalog order. Carried rather than counted because the refusal has
41        /// to name the versions the operator must retire — a bare count leaves
42        /// them holding a terminal error with no way to act on it.
43        declaring: Vec<DeclaringVersion>,
44    },
45    /// The catalog could not be read. The reader reports why; the dispatch
46    /// is delegated so a readable worker path can still serve it.
47    Unreadable(String),
48}
49
50/// Which run a declared-body lookup is being made for.
51///
52/// A body is a property of the run's own package version, not of the queue, so
53/// the lookup cannot answer correctly without knowing whose dispatch it is —
54/// see [`super::declared_body_selection`].
55#[derive(Clone, Copy, Debug)]
56pub struct DispatchingRun<'a> {
57    /// The workflow the activity belongs to.
58    pub workflow_id: &'a aion_core::WorkflowId,
59    /// The concrete run within that workflow.
60    pub run_id: &'a aion_core::RunId,
61}
62
63/// A reader over the deployed contracts' declared action bodies.
64pub trait DeclaredBodies: Send + Sync {
65    /// Look up the declared body for `action` on `task_queue`, as the run
66    /// issuing the dispatch sees it.
67    fn body_for(
68        &self,
69        task_queue: &str,
70        action: &str,
71        run: DispatchingRun<'_>,
72    ) -> DeclaredBodyLookup;
73}
74
75/// Shared, install-once handle the dispatcher holds from construction and the
76/// boot path fills in once the engine exists.
77///
78/// Mirrors [`super::QueueDeclarationSource`]: the dispatcher is built before
79/// the engine, so the seam it consults is handed over afterwards through a
80/// clone of this handle rather than by rebuilding the dispatcher.
81#[derive(Clone, Default)]
82pub struct DeclaredBodySource {
83    inner: Arc<OnceLock<Arc<dyn DeclaredBodies>>>,
84}
85
86impl std::fmt::Debug for DeclaredBodySource {
87    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88        formatter
89            .debug_struct("DeclaredBodySource")
90            .field("installed", &self.inner.get().is_some())
91            .finish()
92    }
93}
94
95impl DeclaredBodySource {
96    /// Install the reader. A second install is ignored and logged: the source
97    /// is process-wide and must not silently change identity.
98    pub fn install(&self, source: Arc<dyn DeclaredBodies>) {
99        if self.inner.set(source).is_err() {
100            tracing::warn!("declared body source already installed; ignoring duplicate set");
101        }
102    }
103
104    /// Look up the declared body, or [`DeclaredBodyLookup::None`] when no
105    /// reader is installed yet — before the engine exists nothing has been
106    /// deployed, so there is no body a dispatch could be missing.
107    #[must_use]
108    pub fn body_for(
109        &self,
110        task_queue: &str,
111        action: &str,
112        run: DispatchingRun<'_>,
113    ) -> DeclaredBodyLookup {
114        self.inner.get().map_or(DeclaredBodyLookup::None, |source| {
115            source.body_for(task_queue, action, run)
116        })
117    }
118}
119
120/// Reads declared bodies out of the engine's live workflow catalog.
121pub struct EngineDeclaredBodies {
122    engine: Arc<aion::Engine>,
123}
124
125impl EngineDeclaredBodies {
126    /// Build a reader over `engine`'s catalog.
127    #[must_use]
128    pub const fn new(engine: Arc<aion::Engine>) -> Self {
129        Self { engine }
130    }
131}
132
133impl std::fmt::Debug for EngineDeclaredBodies {
134    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135        formatter.write_str("EngineDeclaredBodies")
136    }
137}
138
139impl EngineDeclaredBodies {
140    /// The package version `run` is pinned to, or `None` when the registry
141    /// cannot name it.
142    ///
143    /// Two ways to reach `None`, and both are reported rather than swallowed:
144    /// the run has no handle (it left the registry), or the registry could not
145    /// be read at all. Neither is a reason to guess a body — the caller falls
146    /// back to the queue-wide reading, which refuses on disagreement.
147    fn version_of(&self, run: DispatchingRun<'_>) -> Option<ContentHash> {
148        match self.engine.registry().get(run.workflow_id, run.run_id) {
149            Ok(Some(handle)) => Some(handle.loaded_version().clone()),
150            Ok(None) => {
151                tracing::warn!(
152                    operation = "declared_command_dispatch",
153                    workflow_id = %run.workflow_id,
154                    run_id = %run.run_id,
155                    "no registry handle for the dispatching run; resolving its body \
156                     from the whole queue instead of from its own package version"
157                );
158                None
159            }
160            Err(error) => {
161                tracing::error!(
162                    operation = "declared_command_dispatch",
163                    workflow_id = %run.workflow_id,
164                    run_id = %run.run_id,
165                    %error,
166                    "registry unreadable while resolving the dispatching run's version; \
167                     resolving its body from the whole queue instead"
168                );
169                None
170            }
171        }
172    }
173}
174
175impl DeclaredBodies for EngineDeclaredBodies {
176    fn body_for(
177        &self,
178        task_queue: &str,
179        action: &str,
180        run: DispatchingRun<'_>,
181    ) -> DeclaredBodyLookup {
182        let contracts = match self.engine.worker_contracts_for_queue(task_queue) {
183            Ok(contracts) => contracts,
184            Err(error) => return DeclaredBodyLookup::Unreadable(error.to_string()),
185        };
186        // The RAW retained set is the right input here, unlike worker admission
187        // (see `Engine::worker_contracts_for_queue`): a run pinned to a version
188        // nothing else can reach still has to execute that version's body. What
189        // narrows the answer is the run's own identity, not reachability.
190        select_declared_body(&contracts, action, self.version_of(run).as_ref())
191    }
192}
193
194/// The dispatcher decorator that executes declared bodies at the server.
195///
196/// Wraps the production dispatcher. Consults the declared-body source before
197/// every dispatch; delegates untouched whenever the action carries no body.
198pub struct DeclaredCommandDispatcher {
199    inner: Arc<dyn ActivityDispatcher>,
200    bodies: DeclaredBodySource,
201    tokio: tokio::runtime::Handle,
202    workspace_root: WorkspaceRoot,
203}
204
205impl DeclaredCommandDispatcher {
206    /// Wrap `inner`, consulting `bodies` before every dispatch and expanding
207    /// `{workspace_root}` in declared commands with the server-resolved
208    /// `workspace_root`.
209    #[must_use]
210    pub fn new(
211        inner: Arc<dyn ActivityDispatcher>,
212        bodies: DeclaredBodySource,
213        tokio: tokio::runtime::Handle,
214        workspace_root: WorkspaceRoot,
215    ) -> Self {
216        Self {
217            inner,
218            bodies,
219            tokio,
220            workspace_root,
221        }
222    }
223
224    /// Execute one declared command attempt and encode the outcome onto the
225    /// FFI string contract (`retryable:`/`terminal:` on the error side).
226    fn run_declared_command(
227        &self,
228        request: &ActivityDispatch,
229        command: &str,
230    ) -> Result<String, String> {
231        let arguments = decode_arguments(&request.input)?;
232        // Ratification condition (#139): a body that USES the placeholder is
233        // refused terminally, by name, when the root cannot resolve to an
234        // absolute directory that exists — no fallback to cwd, temp, or
235        // anything else. A body without the placeholder never reaches the
236        // resolution at all (`expand` returns `Ok(None)` untouched).
237        let expanded = self.workspace_root.expand(command).map_err(|error| {
238            format!(
239                "terminal:declared body for action `{name}` uses the {placeholder} \
240                 placeholder and cannot dispatch: {error}",
241                name = request.name,
242                placeholder = WORKSPACE_ROOT_PLACEHOLDER,
243            )
244        })?;
245        if let Some(expansion) = &expanded {
246            tracing::info!(
247                operation = "declared_command_dispatch",
248                workflow_id = %request.workflow_id,
249                activity_id = %request.activity_id,
250                activity_name = %request.name,
251                task_queue = %request.task_queue,
252                attempt = request.attempt,
253                workspace_root = %expansion.workspace_root,
254                "expanded the workspace-root placeholder in the declared command"
255            );
256        }
257        let command = expanded
258            .as_ref()
259            .map_or(command, |expansion| expansion.command.as_str());
260        let action = ShellAction::new(command).map_err(|error| {
261            // The AWL checker refuses these at compile time, so reaching this
262            // arm means a defective contract got deployed — name the defect
263            // rather than hiding it behind a generic dispatch failure.
264            format!("terminal:declared command failed to parse at dispatch: {error}")
265        })?;
266        let (context, cancellation) =
267            aion_worker::ActivityContext::new(request.activity_id.clone(), request.attempt);
268
269        tracing::info!(
270            operation = "declared_command_dispatch",
271            workflow_id = %request.workflow_id,
272            activity_id = %request.activity_id,
273            activity_name = %request.name,
274            task_queue = %request.task_queue,
275            attempt = request.attempt,
276            "executing declared action body at the server"
277        );
278
279        let outcome = self.tokio.block_on(action.run(&arguments, &context));
280        // Held, not wired: nothing cancels a single declared-command attempt
281        // today. Dropped only after the run so a future wiring cannot race a
282        // handle that died early.
283        drop(cancellation);
284
285        match outcome {
286            Ok(result) => serde_json::to_string(&result).map_err(|error| {
287                format!("terminal:declared command result failed to encode: {error}")
288            }),
289            Err(failure) => {
290                let prefix = match failure.classification() {
291                    aion_worker::Classification::Retryable => "retryable",
292                    aion_worker::Classification::Terminal => "terminal",
293                };
294                Err(format!("{prefix}:{}", failure.message()))
295            }
296        }
297    }
298}
299
300impl std::fmt::Debug for DeclaredCommandDispatcher {
301    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
302        formatter
303            .debug_struct("DeclaredCommandDispatcher")
304            .field("bodies", &self.bodies)
305            .finish_non_exhaustive()
306    }
307}
308
309impl ActivityDispatcher for DeclaredCommandDispatcher {
310    fn dispatch(&self, request: ActivityDispatch) -> Result<String, String> {
311        let run = DispatchingRun {
312            workflow_id: &request.workflow_id,
313            run_id: &request.run_id,
314        };
315        match self
316            .bodies
317            .body_for(&request.task_queue, &request.name, run)
318        {
319            DeclaredBodyLookup::None => self.inner.dispatch(request),
320            DeclaredBodyLookup::Unreadable(reason) => {
321                // Delegated, not refused: a catalog read failure must not
322                // strand a queue that live workers could still serve. Loud so
323                // an operator sees a bodied action falling through.
324                tracing::error!(
325                    operation = "declared_command_dispatch",
326                    workflow_id = %request.workflow_id,
327                    activity_name = %request.name,
328                    task_queue = %request.task_queue,
329                    %reason,
330                    "declared-body catalog read failed; delegating to the worker path"
331                );
332                self.inner.dispatch(request)
333            }
334            DeclaredBodyLookup::Ambiguous { declaring } => Err(ambiguous_body_refusal(
335                &request.name,
336                &request.task_queue,
337                &declaring,
338            )),
339            DeclaredBodyLookup::Declared(ActionBodyContract::Run { command }) => {
340                self.run_declared_command(&request, &command)
341            }
342        }
343    }
344}
345
346/// Decode the dispatch's JSON input into the declared action's arguments.
347///
348/// A declared action's parameters are named in its `.awl` declaration, so the
349/// input must be a JSON object; anything else cannot bind to `$name`
350/// references and is refused by shape. Retrying cannot change the input, so
351/// the refusal is terminal.
352fn decode_arguments(input: &str) -> Result<BTreeMap<String, serde_json::Value>, String> {
353    let value: serde_json::Value = serde_json::from_str(input)
354        .map_err(|error| format!("terminal:declared command input is not valid JSON: {error}"))?;
355    match value {
356        serde_json::Value::Object(members) => Ok(members.into_iter().collect()),
357        other => Err(format!(
358            "terminal:declared command input must be a JSON object binding the action's \
359             parameters by name; got {}",
360            json_kind(&other)
361        )),
362    }
363}
364
365/// A JSON value's kind, named for a refusal message.
366const fn json_kind(value: &serde_json::Value) -> &'static str {
367    match value {
368        serde_json::Value::Null => "null",
369        serde_json::Value::Bool(_) => "a boolean",
370        serde_json::Value::Number(_) => "a number",
371        serde_json::Value::String(_) => "a string",
372        serde_json::Value::Array(_) => "an array",
373        serde_json::Value::Object(_) => "an object",
374    }
375}
376
377#[cfg(test)]
378mod tests {
379    use std::collections::BTreeMap;
380    use std::sync::{Arc, Mutex};
381
382    use aion::{ActivityDispatch, ActivityDispatcher};
383    use aion_core::{ActivityId, RunId, WorkflowId};
384    use aion_package::ActionBodyContract;
385
386    use super::super::workspace_root::{WorkspaceRoot, WorkspaceRootError};
387    use super::{
388        DeclaredBodies, DeclaredBodyLookup, DeclaredBodySource, DeclaredCommandDispatcher,
389        DeclaringVersion, DispatchingRun, decode_arguments,
390    };
391
392    /// What a test returns. Every fallible step is carried rather than
393    /// unwrapped, because the workspace denies panicking accessors in test
394    /// code as firmly as in library code.
395    type TestResult = Result<(), Box<dyn std::error::Error>>;
396
397    /// Inner dispatcher that records whether it was reached.
398    struct RecordingInner {
399        reached: Arc<Mutex<Vec<String>>>,
400        reply: Result<String, String>,
401    }
402
403    impl ActivityDispatcher for RecordingInner {
404        fn dispatch(&self, request: ActivityDispatch) -> Result<String, String> {
405            match self.reached.lock() {
406                Ok(mut names) => names.push(request.name),
407                Err(poisoned) => poisoned.into_inner().push(request.name),
408            }
409            self.reply.clone()
410        }
411    }
412
413    struct FixedBodies {
414        lookup: DeclaredBodyLookup,
415    }
416
417    impl DeclaredBodies for FixedBodies {
418        fn body_for(
419            &self,
420            _task_queue: &str,
421            _action: &str,
422            _run: DispatchingRun<'_>,
423        ) -> DeclaredBodyLookup {
424            self.lookup.clone()
425        }
426    }
427
428    /// A reader that records whose dispatch it was asked about.
429    ///
430    /// The selection rule is unit-tested on its own inputs, which proves the
431    /// rule and nothing about the plumbing. This double closes that gap: it
432    /// captures the [`DispatchingRun`] the dispatcher hands over, so the
433    /// identity can be compared against the request it came from.
434    struct RecordingBodies {
435        seen: Arc<Mutex<Vec<(WorkflowId, RunId)>>>,
436    }
437
438    impl DeclaredBodies for RecordingBodies {
439        fn body_for(
440            &self,
441            _task_queue: &str,
442            _action: &str,
443            run: DispatchingRun<'_>,
444        ) -> DeclaredBodyLookup {
445            let observed = (run.workflow_id.clone(), run.run_id.clone());
446            match self.seen.lock() {
447                Ok(mut seen) => seen.push(observed),
448                Err(poisoned) => poisoned.into_inner().push(observed),
449            }
450            DeclaredBodyLookup::None
451        }
452    }
453
454    fn request(name: &str, input: &str) -> ActivityDispatch {
455        ActivityDispatch {
456            namespace: "default".to_owned(),
457            task_queue: "shell".to_owned(),
458            node: None,
459            workflow_id: WorkflowId::new_v4(),
460            run_id: RunId::new_v4(),
461            activity_id: ActivityId::from_sequence_position(1),
462            name: name.to_owned(),
463            input: input.to_owned(),
464            config: "{}".to_owned(),
465            attempt: 1,
466            labels: BTreeMap::new(),
467            advisory: false,
468        }
469    }
470
471    fn dispatcher(
472        lookup: DeclaredBodyLookup,
473        reply: Result<String, String>,
474    ) -> (DeclaredCommandDispatcher, Arc<Mutex<Vec<String>>>) {
475        // These tests exercise bodies without the placeholder, so the root's
476        // value is never read; it is an explicit existing directory rather
477        // than a default so nothing here depends on resolution.
478        dispatcher_with_root(
479            lookup,
480            reply,
481            WorkspaceRoot::from_resolution(Ok(std::env::temp_dir())),
482        )
483    }
484
485    fn dispatcher_with_root(
486        lookup: DeclaredBodyLookup,
487        reply: Result<String, String>,
488        workspace_root: WorkspaceRoot,
489    ) -> (DeclaredCommandDispatcher, Arc<Mutex<Vec<String>>>) {
490        let reached = Arc::new(Mutex::new(Vec::new()));
491        let inner = RecordingInner {
492            reached: Arc::clone(&reached),
493            reply,
494        };
495        let bodies = DeclaredBodySource::default();
496        bodies.install(Arc::new(FixedBodies { lookup }));
497        let decorated = DeclaredCommandDispatcher::new(
498            Arc::new(inner),
499            bodies,
500            tokio::runtime::Handle::current(),
501            workspace_root,
502        );
503        (decorated, reached)
504    }
505
506    fn reached_names(reached: &Arc<Mutex<Vec<String>>>) -> Vec<String> {
507        match reached.lock() {
508            Ok(names) => names.clone(),
509            Err(poisoned) => poisoned.into_inner().clone(),
510        }
511    }
512
513    #[tokio::test(flavor = "multi_thread")]
514    async fn a_bodiless_action_is_delegated_untouched() -> TestResult {
515        let (decorated, reached) =
516            dispatcher(DeclaredBodyLookup::None, Ok("\"worker-served\"".to_owned()));
517        let handle =
518            tokio::task::spawn_blocking(move || decorated.dispatch(request("plain", "{}")));
519        let result = handle.await?;
520        assert_eq!(result, Ok("\"worker-served\"".to_owned()));
521        assert_eq!(reached_names(&reached), vec!["plain".to_owned()]);
522        Ok(())
523    }
524
525    #[tokio::test(flavor = "multi_thread")]
526    async fn a_declared_body_executes_without_touching_the_worker_path() -> TestResult {
527        let (decorated, reached) = dispatcher(
528            DeclaredBodyLookup::Declared(ActionBodyContract::Run {
529                command: "echo $greeting".to_owned(),
530            }),
531            Err("terminal:the worker path must never be reached".to_owned()),
532        );
533        let handle = tokio::task::spawn_blocking(move || {
534            decorated.dispatch(request(
535                "greet",
536                "{\"greeting\":\"hello from the contract\"}",
537            ))
538        });
539        let result = handle.await?;
540        let encoded = result.map_err(|error| format!("declared command failed: {error}"))?;
541        let outcome: serde_json::Value = serde_json::from_str(&encoded)?;
542        assert_eq!(outcome["stdout"], "hello from the contract");
543        assert_eq!(outcome["exit_code"], 0);
544        assert!(
545            reached_names(&reached).is_empty(),
546            "the worker path must not be consulted for a bodied action"
547        );
548        Ok(())
549    }
550
551    #[tokio::test(flavor = "multi_thread")]
552    async fn a_failing_declared_command_reports_retryable_with_its_stderr() -> TestResult {
553        let (decorated, _reached) = dispatcher(
554            DeclaredBodyLookup::Declared(ActionBodyContract::Run {
555                command: "sh -c 'echo boom >&2; exit 7'".to_owned(),
556            }),
557            Ok("unused".to_owned()),
558        );
559        let handle =
560            tokio::task::spawn_blocking(move || decorated.dispatch(request("fails", "{}")));
561        let Err(error) = handle.await? else {
562            return Err("a non-zero exit must fail the dispatch".into());
563        };
564        assert!(
565            error.starts_with("retryable:"),
566            "a non-zero exit is retryable by default: {error}"
567        );
568        assert!(
569            error.contains("boom"),
570            "stderr must ride the failure: {error}"
571        );
572        Ok(())
573    }
574
575    /// The hash the refusal prints must be one the deploy API will accept, or
576    /// the remedy is a command that cannot run — the exact failure the old
577    /// "redeploy so one body remains" wording had.
578    ///
579    /// The oracle is the deploy API's own parser, not a length or a shape:
580    /// `EngineDeclaredBodies` renders the version with `ContentHash::to_string`,
581    /// so this takes a real hash through that rendering, pulls the token back
582    /// out of the printed command, and parses it the way
583    /// `decode_version_target` does.
584    #[test]
585    fn the_printed_hash_parses_back_as_a_content_hash() -> TestResult {
586        let version = aion_package::ContentHash::from_bytes([0x5a; 32]);
587        let routed = aion_package::ContentHash::from_bytes([0xa5; 32]);
588        let refusal = super::ambiguous_body_refusal(
589            "find_repositories",
590            "local",
591            &[
592                DeclaringVersion {
593                    content_hash: version.to_string(),
594                    workflow_types: vec!["sweeper".to_owned()],
595                    route_active: false,
596                    body: 0,
597                },
598                DeclaringVersion {
599                    content_hash: routed.to_string(),
600                    workflow_types: vec!["sweeper".to_owned()],
601                    route_active: true,
602                    body: 1,
603                },
604            ],
605        );
606        let Some(command) = refusal.split("`aion unload sweeper ").nth(1) else {
607            return Err(format!("no unload command in the refusal: {refusal}").into());
608        };
609        let Some(printed) = command.split('`').next() else {
610            return Err(format!("the unload command is unterminated: {refusal}").into());
611        };
612        let parsed: aion_package::ContentHash = printed.parse()?;
613        assert_eq!(
614            parsed, version,
615            "the printed hash must round-trip to the version it names"
616        );
617        Ok(())
618    }
619
620    #[tokio::test(flavor = "multi_thread")]
621    async fn ambiguous_bodies_refuse_terminally_by_name() -> TestResult {
622        let superseded = "1111111111111111111111111111111111111111111111111111111111111111";
623        let routed = "2222222222222222222222222222222222222222222222222222222222222222";
624        let (decorated, reached) = dispatcher(
625            DeclaredBodyLookup::Ambiguous {
626                declaring: vec![
627                    DeclaringVersion {
628                        content_hash: superseded.to_owned(),
629                        workflow_types: vec!["sweeper".to_owned()],
630                        route_active: false,
631                        body: 0,
632                    },
633                    DeclaringVersion {
634                        content_hash: routed.to_owned(),
635                        workflow_types: vec!["sweeper".to_owned()],
636                        route_active: true,
637                        body: 1,
638                    },
639                ],
640            },
641            Ok(String::new()),
642        );
643        let handle = tokio::task::spawn_blocking(move || decorated.dispatch(request("torn", "{}")));
644        let Err(error) = handle.await? else {
645            return Err("ambiguous bodies must refuse".into());
646        };
647        assert!(error.starts_with("terminal:"), "{error}");
648        assert!(error.contains("torn"), "{error}");
649        // The refusal must reach the dispatcher carrying an act-on-able remedy,
650        // not just a count: the operator reads this string and nothing else.
651        assert!(
652            error.contains(&format!("`aion unload sweeper {superseded}`")),
653            "the dispatch refusal must name the version to retire: {error}"
654        );
655        assert!(reached_names(&reached).is_empty());
656        Ok(())
657    }
658
659    #[tokio::test(flavor = "multi_thread")]
660    async fn a_placeholder_bearing_body_executes_with_the_expanded_root() -> TestResult {
661        let scratch = tempfile::tempdir()?;
662        let root = scratch.path().join("clones");
663        let root_text = root.to_string_lossy().into_owned();
664        let (decorated, reached) = dispatcher_with_root(
665            DeclaredBodyLookup::Declared(ActionBodyContract::Run {
666                command: "echo {workspace_root}".to_owned(),
667            }),
668            Err("terminal:the worker path must never be reached".to_owned()),
669            WorkspaceRoot::from_resolution(Ok(root.clone())),
670        );
671        let handle =
672            tokio::task::spawn_blocking(move || decorated.dispatch(request("provision", "{}")));
673        let result = handle.await?;
674        let encoded = result.map_err(|error| format!("declared command failed: {error}"))?;
675        let outcome: serde_json::Value = serde_json::from_str(&encoded)?;
676        assert_eq!(
677            outcome["stdout"], root_text,
678            "the command must observe the server-resolved root as its argv word"
679        );
680        assert_eq!(outcome["exit_code"], 0);
681        assert!(
682            root.is_dir(),
683            "dispatching a placeholder-bearing body must create the missing root"
684        );
685        assert!(reached_names(&reached).is_empty());
686        Ok(())
687    }
688
689    #[tokio::test(flavor = "multi_thread")]
690    async fn a_placeholder_bearing_body_refuses_terminally_when_the_root_is_unresolved()
691    -> TestResult {
692        let (decorated, reached) = dispatcher_with_root(
693            DeclaredBodyLookup::Declared(ActionBodyContract::Run {
694                command: "echo {workspace_root}".to_owned(),
695            }),
696            Ok("unused".to_owned()),
697            WorkspaceRoot::from_resolution(Err(WorkspaceRootError::Unresolvable {
698                reason: "cannot resolve Aion home: set AION_HOME or HOME".to_owned(),
699            })),
700        );
701        let handle =
702            tokio::task::spawn_blocking(move || decorated.dispatch(request("provision", "{}")));
703        let Err(error) = handle.await? else {
704            return Err("an unresolved root must refuse a placeholder-bearing body".into());
705        };
706        assert!(error.starts_with("terminal:"), "{error}");
707        assert!(
708            error.contains("provision"),
709            "the refusal must name the action: {error}"
710        );
711        assert!(
712            error.contains("cannot resolve Aion home"),
713            "the refusal must carry the resolution failure's reason: {error}"
714        );
715        assert!(
716            reached_names(&reached).is_empty(),
717            "a refused body must not fall through to the worker path"
718        );
719        Ok(())
720    }
721
722    #[tokio::test(flavor = "multi_thread")]
723    async fn a_shape_changing_root_refuses_terminally_naming_the_action() -> TestResult {
724        // `$` in the root would open a parameter reference after splicing.
725        let (decorated, reached) = dispatcher_with_root(
726            DeclaredBodyLookup::Declared(ActionBodyContract::Run {
727                command: "echo {workspace_root}".to_owned(),
728            }),
729            Ok("unused".to_owned()),
730            WorkspaceRoot::from_resolution(Ok(std::path::PathBuf::from("/absolute/with$dollar"))),
731        );
732        let handle =
733            tokio::task::spawn_blocking(move || decorated.dispatch(request("provision", "{}")));
734        let Err(error) = handle.await? else {
735            return Err("a shape-changing root must refuse a placeholder-bearing body".into());
736        };
737        assert!(error.starts_with("terminal:"), "{error}");
738        assert!(
739            error.contains("provision"),
740            "the refusal must name the action: {error}"
741        );
742        assert!(
743            error.contains("would change the parsed shape"),
744            "the refusal must carry the shape-changing diagnosis: {error}"
745        );
746        assert!(
747            reached_names(&reached).is_empty(),
748            "a refused body must not fall through to the worker path"
749        );
750        Ok(())
751    }
752
753    #[tokio::test(flavor = "multi_thread")]
754    async fn an_uncreatable_root_refuses_terminally_naming_the_action() -> TestResult {
755        // A root beneath a regular file cannot be created by any retry.
756        let scratch = tempfile::tempdir()?;
757        let file = scratch.path().join("occupied");
758        std::fs::write(&file, b"not a directory")?;
759        let (decorated, reached) = dispatcher_with_root(
760            DeclaredBodyLookup::Declared(ActionBodyContract::Run {
761                command: "echo {workspace_root}".to_owned(),
762            }),
763            Ok("unused".to_owned()),
764            WorkspaceRoot::from_resolution(Ok(file.join("clones"))),
765        );
766        let handle =
767            tokio::task::spawn_blocking(move || decorated.dispatch(request("provision", "{}")));
768        let Err(error) = handle.await? else {
769            return Err("an uncreatable root must refuse a placeholder-bearing body".into());
770        };
771        assert!(error.starts_with("terminal:"), "{error}");
772        assert!(
773            error.contains("provision"),
774            "the refusal must name the action: {error}"
775        );
776        assert!(
777            error.contains("could not be created"),
778            "the refusal must carry the creation-failure diagnosis: {error}"
779        );
780        assert!(
781            reached_names(&reached).is_empty(),
782            "a refused body must not fall through to the worker path"
783        );
784        Ok(())
785    }
786
787    #[tokio::test(flavor = "multi_thread")]
788    async fn a_body_without_the_placeholder_is_untouched_by_resolution_failure() -> TestResult {
789        let (decorated, _reached) = dispatcher_with_root(
790            DeclaredBodyLookup::Declared(ActionBodyContract::Run {
791                command: "echo $greeting".to_owned(),
792            }),
793            Ok("unused".to_owned()),
794            WorkspaceRoot::from_resolution(Err(WorkspaceRootError::Unresolvable {
795                reason: "cannot resolve Aion home: set AION_HOME or HOME".to_owned(),
796            })),
797        );
798        let handle = tokio::task::spawn_blocking(move || {
799            decorated.dispatch(request("greet", "{\"greeting\":\"still served\"}"))
800        });
801        let result = handle.await?;
802        let encoded = result.map_err(|error| format!("declared command failed: {error}"))?;
803        let outcome: serde_json::Value = serde_json::from_str(&encoded)?;
804        assert_eq!(outcome["stdout"], "still served");
805        Ok(())
806    }
807
808    #[tokio::test(flavor = "multi_thread")]
809    async fn an_unreadable_catalog_delegates_to_the_worker_path() -> TestResult {
810        let (decorated, reached) = dispatcher(
811            DeclaredBodyLookup::Unreadable("catalog offline".to_owned()),
812            Ok("\"served anyway\"".to_owned()),
813        );
814        let handle =
815            tokio::task::spawn_blocking(move || decorated.dispatch(request("resilient", "{}")));
816        let result = handle.await?;
817        assert_eq!(result, Ok("\"served anyway\"".to_owned()));
818        assert_eq!(reached_names(&reached), vec!["resilient".to_owned()]);
819        Ok(())
820    }
821
822    #[test]
823    fn non_object_input_is_refused_terminally_by_shape() {
824        for (input, kind) in [
825            ("[1,2]", "an array"),
826            ("\"text\"", "a string"),
827            ("3", "a number"),
828            ("null", "null"),
829            ("true", "a boolean"),
830        ] {
831            let Err(error) = decode_arguments(input) else {
832                unreachable_refusal(input);
833                return;
834            };
835            assert!(error.starts_with("terminal:"), "{error}");
836            assert!(error.contains(kind), "{error} must name {kind}");
837        }
838    }
839
840    /// Fails the calling test without a panicking accessor.
841    fn unreachable_refusal(input: &str) {
842        assert!(
843            input.is_empty(),
844            "input `{input}` must have been refused by shape"
845        );
846    }
847
848    /// The selection rule cannot be right if it is asked about the wrong run.
849    ///
850    /// `select_declared_body` is unit-tested on inputs the test itself
851    /// constructs, which proves the rule and nothing about the plumbing. This
852    /// asserts the other half: the identity the dispatcher hands the reader is
853    /// the identity of the dispatch it is serving, not a placeholder and not
854    /// another run's.
855    #[tokio::test(flavor = "multi_thread")]
856    async fn the_reader_is_asked_about_the_run_that_is_dispatching() -> TestResult {
857        let seen = Arc::new(Mutex::new(Vec::new()));
858        let bodies = DeclaredBodySource::default();
859        bodies.install(Arc::new(RecordingBodies {
860            seen: Arc::clone(&seen),
861        }));
862        let reached = Arc::new(Mutex::new(Vec::new()));
863        let decorated = DeclaredCommandDispatcher::new(
864            Arc::new(RecordingInner {
865                reached: Arc::clone(&reached),
866                reply: Ok("\"worker-served\"".to_owned()),
867            }),
868            bodies,
869            tokio::runtime::Handle::current(),
870            WorkspaceRoot::from_resolution(Ok(std::env::temp_dir())),
871        );
872
873        let dispatch = request("plain", "{}");
874        let expected = (dispatch.workflow_id.clone(), dispatch.run_id.clone());
875        let handle = tokio::task::spawn_blocking(move || decorated.dispatch(dispatch));
876        handle
877            .await?
878            .map_err(|error| format!("dispatch failed: {error}"))?;
879
880        let observed = match seen.lock() {
881            Ok(observed) => observed.clone(),
882            Err(poisoned) => poisoned.into_inner().clone(),
883        };
884        assert_eq!(
885            observed,
886            vec![expected],
887            "the body reader must be asked about the dispatching run itself"
888        );
889        Ok(())
890    }
891}