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