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