1use std::collections::BTreeMap;
48use std::future::Future;
49use std::sync::{Arc, OnceLock};
50
51use aion::{ActivityDispatch, ActivityDispatcher};
52use aion_package::{ActionBodyContract, ContentHash};
53use aion_worker::ActivityFailure;
54use aion_worker::shell::{ShellAction, ShellOutcome};
55
56use super::declared_body_ambiguity::{DeclaringVersion, ambiguous_body_refusal};
57use super::declared_body_cancel::DeclaredCommandAttempts;
58use super::declared_body_selection::select_declared_body;
59use super::declared_body_transcript::publish_declared_transcript;
60use super::workspace_root::{WORKSPACE_ROOT_PLACEHOLDER, WorkspaceRoot};
61use crate::activity_publisher::ActivityEventPublisher;
62
63#[derive(Clone, Debug)]
65pub enum DeclaredBodyLookup {
66 None,
69 Declared(ActionBodyContract),
72 Ambiguous {
76 declaring: Vec<DeclaringVersion>,
81 },
82 Unreadable(String),
85}
86
87#[derive(Clone, Copy, Debug)]
93pub struct DispatchingRun<'a> {
94 pub workflow_id: &'a aion_core::WorkflowId,
96 pub run_id: &'a aion_core::RunId,
98}
99
100pub trait DeclaredBodies: Send + Sync {
102 fn body_for(
105 &self,
106 task_queue: &str,
107 action: &str,
108 run: DispatchingRun<'_>,
109 ) -> DeclaredBodyLookup;
110}
111
112#[derive(Clone, Default)]
119pub struct DeclaredBodySource {
120 inner: Arc<OnceLock<Arc<dyn DeclaredBodies>>>,
121}
122
123impl std::fmt::Debug for DeclaredBodySource {
124 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125 formatter
126 .debug_struct("DeclaredBodySource")
127 .field("installed", &self.inner.get().is_some())
128 .finish()
129 }
130}
131
132impl DeclaredBodySource {
133 pub fn install(&self, source: Arc<dyn DeclaredBodies>) {
136 if self.inner.set(source).is_err() {
137 tracing::warn!("declared body source already installed; ignoring duplicate set");
138 }
139 }
140
141 #[must_use]
156 pub fn body_for(
157 &self,
158 task_queue: &str,
159 action: &str,
160 run: DispatchingRun<'_>,
161 ) -> DeclaredBodyLookup {
162 self.inner.get().map_or_else(
163 || {
164 tracing::error!(
165 operation = "declared_command_dispatch",
166 task_queue,
167 action,
168 workflow_id = %run.workflow_id,
169 run_id = %run.run_id,
170 "declared body source consulted before it was installed; the dispatch \
171 falls through to the worker path and will park if the queue's only \
172 service is its declared bodies (#266 boot-ordering defect)"
173 );
174 DeclaredBodyLookup::None
175 },
176 |source| source.body_for(task_queue, action, run),
177 )
178 }
179}
180
181pub struct EngineDeclaredBodies {
183 engine: Arc<aion::Engine>,
184}
185
186impl EngineDeclaredBodies {
187 #[must_use]
189 pub const fn new(engine: Arc<aion::Engine>) -> Self {
190 Self { engine }
191 }
192}
193
194impl std::fmt::Debug for EngineDeclaredBodies {
195 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
196 formatter.write_str("EngineDeclaredBodies")
197 }
198}
199
200impl EngineDeclaredBodies {
201 fn version_of(&self, run: DispatchingRun<'_>) -> Option<ContentHash> {
209 match self.engine.registry().get(run.workflow_id, run.run_id) {
210 Ok(Some(handle)) => Some(handle.loaded_version().clone()),
211 Ok(None) => {
212 tracing::warn!(
213 operation = "declared_command_dispatch",
214 workflow_id = %run.workflow_id,
215 run_id = %run.run_id,
216 "no registry handle for the dispatching run; resolving its body \
217 from the whole queue instead of from its own package version"
218 );
219 None
220 }
221 Err(error) => {
222 tracing::error!(
223 operation = "declared_command_dispatch",
224 workflow_id = %run.workflow_id,
225 run_id = %run.run_id,
226 %error,
227 "registry unreadable while resolving the dispatching run's version; \
228 resolving its body from the whole queue instead"
229 );
230 None
231 }
232 }
233 }
234}
235
236impl DeclaredBodies for EngineDeclaredBodies {
237 fn body_for(
238 &self,
239 task_queue: &str,
240 action: &str,
241 run: DispatchingRun<'_>,
242 ) -> DeclaredBodyLookup {
243 let contracts = match self.engine.worker_contracts_for_queue(task_queue) {
244 Ok(contracts) => contracts,
245 Err(error) => return DeclaredBodyLookup::Unreadable(error.to_string()),
246 };
247 select_declared_body(&contracts, action, self.version_of(run).as_ref())
252 }
253}
254
255pub struct DeclaredCommandDispatcher {
260 inner: Arc<dyn ActivityDispatcher>,
261 bodies: DeclaredBodySource,
262 attempts: DeclaredCommandAttempts,
263 tokio: tokio::runtime::Handle,
264 workspace_root: WorkspaceRoot,
265 transcript: ActivityEventPublisher,
266}
267
268impl DeclaredCommandDispatcher {
269 #[must_use]
280 pub fn new(
281 inner: Arc<dyn ActivityDispatcher>,
282 bodies: DeclaredBodySource,
283 attempts: DeclaredCommandAttempts,
284 tokio: tokio::runtime::Handle,
285 workspace_root: WorkspaceRoot,
286 transcript: ActivityEventPublisher,
287 ) -> Self {
288 Self {
289 inner,
290 bodies,
291 attempts,
292 tokio,
293 workspace_root,
294 transcript,
295 }
296 }
297
298 fn declared_action(
307 &self,
308 request: &ActivityDispatch,
309 command: &str,
310 ) -> Result<ShellAction, String> {
311 let expanded = self.workspace_root.expand(command).map_err(|error| {
312 format!(
313 "terminal:declared body for action `{name}` uses the {placeholder} \
314 placeholder and cannot dispatch: {error}",
315 name = request.name,
316 placeholder = WORKSPACE_ROOT_PLACEHOLDER,
317 )
318 })?;
319 if let Some(expansion) = &expanded {
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 workspace_root = %expansion.workspace_root,
328 "expanded the workspace-root placeholder in the declared command"
329 );
330 }
331 let command = expanded
332 .as_ref()
333 .map_or(command, |expansion| expansion.command.as_str());
334 ShellAction::new(command).map_err(|error| {
335 format!("terminal:declared command failed to parse at dispatch: {error}")
339 })
340 }
341
342 pub(super) fn join_cancel_path(
352 &self,
353 request: &ActivityDispatch,
354 cancellation: &aion_worker::ActivityCancellationHandle,
355 ) -> Result<super::DeclaredAttemptRegistration, String> {
356 self.attempts
357 .register(
358 super::AttemptKey::new(
359 request.workflow_id.clone(),
360 request.run_id.clone(),
361 request.activity_id.clone(),
362 request.attempt,
363 ),
364 cancellation.clone(),
365 )
366 .map_err(|error| match error {
367 crate::error::ServerError::DrainingRefusedDeclaredAttempt { .. } => {
373 tracing::info!(
374 operation = "declared_command_dispatch",
375 workflow_id = %request.workflow_id,
376 activity_id = %request.activity_id,
377 activity_name = %request.name,
378 task_queue = %request.task_queue,
379 attempt = request.attempt,
380 "declared command parked: this server is draining and starts no new work"
381 );
382 aion::PARKED_ACTIVITY_REASON.to_owned()
383 }
384 other => format!(
385 "terminal:declared body for action `{name}` cannot dispatch: the attempt \
386 could not join this server's cancel path, and a command a cancelled run \
387 could not stop must not be started: {other}",
388 name = request.name,
389 ),
390 })
391 }
392
393 fn run_declared_command(
396 &self,
397 request: &ActivityDispatch,
398 command: &str,
399 ) -> Result<String, String> {
400 let arguments = decode_arguments(&request.input)?;
401 let action = self.declared_action(request, command)?;
402 let (events, drain) = tokio::sync::mpsc::unbounded_channel();
407 let (context, cancellation) = aion_worker::ActivityContext::with_transcript(
408 request.workflow_id.clone(),
409 request.run_id.clone(),
410 request.activity_id.clone(),
411 request.attempt,
412 events,
413 );
414 let registration = self.join_cancel_path(request, &cancellation)?;
415
416 tracing::info!(
417 operation = "declared_command_dispatch",
418 workflow_id = %request.workflow_id,
419 activity_id = %request.activity_id,
420 activity_name = %request.name,
421 task_queue = %request.task_queue,
422 attempt = request.attempt,
423 "executing declared action body at the server"
424 );
425 crate::death_note::breadcrumb(&format!(
429 "declared-action start action={} workflow_id={} run_id={} activity_id={} attempt={}",
430 request.name, request.workflow_id, request.run_id, request.activity_id, request.attempt,
431 ));
432
433 let bound = aion::activity_timeout_from_config(&request.config);
439 let transcript = self.transcript.clone();
440 let ended = self.tokio.block_on(async move {
441 let pump = tokio::spawn(publish_declared_transcript(transcript, drain));
442 let ended = run_bounded(action.run(&arguments, &context), &cancellation, bound).await;
443 drop(context);
445 if let Err(error) = pump.await {
446 tracing::warn!(
447 %error,
448 operation = "declared_command_dispatch",
449 "declared command transcript: the publishing task ended abnormally; some \
450 output lines may not have been retained"
451 );
452 }
453 ended
454 });
455 drop(registration);
459
460 encode_end(request, ended, |outcome| {
464 serde_json::to_string(&outcome).map_err(|error| {
465 ActivityFailure::terminal(format!(
466 "declared command result failed to encode: {error}"
467 ))
468 })
469 })
470 }
471}
472
473pub(super) fn encode_end(
490 request: &ActivityDispatch,
491 ended: AttemptEnd,
492 shape: impl FnOnce(ShellOutcome) -> Result<String, ActivityFailure>,
493) -> Result<String, String> {
494 let outcome = match ended {
495 AttemptEnd::Ran(outcome) => outcome,
496 AttemptEnd::Expired { bound, ran_anyway } => {
497 if let Some(exit_code) = ran_anyway {
498 tracing::warn!(
504 operation = "declared_command_dispatch",
505 workflow_id = %request.workflow_id,
506 activity_id = %request.activity_id,
507 activity_name = %request.name,
508 attempt = request.attempt,
509 exit_code,
510 bound_ms = bound.as_millis(),
511 "the declared command finished while it was being stopped on its \
512 authored bound; its result is discarded in favour of the timeout"
513 );
514 }
515 return Err(aion::activity_timeout_reason(bound));
516 }
517 };
518
519 match outcome.and_then(shape) {
520 Ok(encoded) => Ok(encoded),
521 Err(failure) => {
522 let prefix = match failure.classification() {
523 aion_worker::Classification::Retryable => "retryable",
524 aion_worker::Classification::PolicyRefused => "policy_refused",
525 aion_worker::Classification::Terminal => "terminal",
526 };
527 Err(format!("{prefix}:{}", failure.message()))
528 }
529 }
530}
531
532#[derive(Debug)]
534pub(super) enum AttemptEnd {
535 Ran(Result<ShellOutcome, ActivityFailure>),
538 Expired {
541 bound: std::time::Duration,
543 ran_anyway: Option<i32>,
547 },
548}
549
550pub(super) async fn run_bounded(
574 run: impl Future<Output = Result<ShellOutcome, ActivityFailure>>,
575 cancellation: &aion_worker::ActivityCancellationHandle,
576 bound: Option<std::time::Duration>,
577) -> AttemptEnd {
578 let Some(bound) = bound else {
579 return AttemptEnd::Ran(run.await);
580 };
581 tokio::pin!(run);
582 match tokio::time::timeout(bound, &mut run).await {
583 Ok(outcome) => AttemptEnd::Ran(outcome),
584 Err(_elapsed) => {
585 cancellation.cancel();
586 AttemptEnd::Expired {
587 bound,
588 ran_anyway: run.await.ok().map(|outcome| outcome.exit_code),
589 }
590 }
591 }
592}
593
594impl DeclaredCommandDispatcher {
595 pub(super) fn transcript(&self) -> ActivityEventPublisher {
597 self.transcript.clone()
598 }
599
600 pub(super) fn tokio(&self) -> &tokio::runtime::Handle {
602 &self.tokio
603 }
604
605 pub(super) const fn workspace_root(&self) -> &WorkspaceRoot {
607 &self.workspace_root
608 }
609}
610
611impl std::fmt::Debug for DeclaredCommandDispatcher {
612 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
613 formatter
614 .debug_struct("DeclaredCommandDispatcher")
615 .field("bodies", &self.bodies)
616 .finish_non_exhaustive()
617 }
618}
619
620impl ActivityDispatcher for DeclaredCommandDispatcher {
621 fn dispatch(&self, request: ActivityDispatch) -> Result<String, String> {
622 let run = DispatchingRun {
623 workflow_id: &request.workflow_id,
624 run_id: &request.run_id,
625 };
626 match self
627 .bodies
628 .body_for(&request.task_queue, &request.name, run)
629 {
630 DeclaredBodyLookup::None => self.inner.dispatch(request),
631 DeclaredBodyLookup::Unreadable(reason) => {
632 tracing::error!(
636 operation = "declared_command_dispatch",
637 workflow_id = %request.workflow_id,
638 activity_name = %request.name,
639 task_queue = %request.task_queue,
640 %reason,
641 "declared-body catalog read failed; delegating to the worker path"
642 );
643 self.inner.dispatch(request)
644 }
645 DeclaredBodyLookup::Ambiguous { declaring } => Err(ambiguous_body_refusal(
646 &request.name,
647 &request.task_queue,
648 &declaring,
649 )),
650 DeclaredBodyLookup::Declared(ActionBodyContract::Run { command }) => {
651 self.run_declared_command(&request, &command)
652 }
653 DeclaredBodyLookup::Declared(ActionBodyContract::Command { capture, command }) => {
654 self.run_declared_command_body(&request, capture, *command)
655 }
656 }
657 }
658}
659
660pub(super) fn decode_arguments(input: &str) -> Result<BTreeMap<String, serde_json::Value>, String> {
667 let value: serde_json::Value = serde_json::from_str(input)
668 .map_err(|error| format!("terminal:declared command input is not valid JSON: {error}"))?;
669 match value {
670 serde_json::Value::Object(members) => Ok(members.into_iter().collect()),
671 other => Err(format!(
672 "terminal:declared command input must be a JSON object binding the action's \
673 parameters by name; got {}",
674 json_kind(&other)
675 )),
676 }
677}
678
679const fn json_kind(value: &serde_json::Value) -> &'static str {
681 match value {
682 serde_json::Value::Null => "null",
683 serde_json::Value::Bool(_) => "a boolean",
684 serde_json::Value::Number(_) => "a number",
685 serde_json::Value::String(_) => "a string",
686 serde_json::Value::Array(_) => "an array",
687 serde_json::Value::Object(_) => "an object",
688 }
689}
690
691#[cfg(test)]
696#[path = "declared_body_containment_tests.rs"]
697mod declared_body_containment_tests;
698
699#[cfg(test)]
700pub(super) mod tests {
701 use std::collections::BTreeMap;
702 use std::sync::{Arc, Mutex};
703
704 use aion::{ActivityDispatch, ActivityDispatcher};
705 use aion_core::{ActivityId, RunId, WorkflowId};
706 use aion_package::ActionBodyContract;
707
708 use aion_core::ActivityEventKind;
709 use aion_store::ActivityStreamKey;
710
711 use super::super::workspace_root::{WorkspaceRoot, WorkspaceRootError};
712 use super::{
713 ActivityEventPublisher, DeclaredBodies, DeclaredBodyLookup, DeclaredBodySource,
714 DeclaredCommandAttempts, DeclaredCommandDispatcher, DeclaringVersion, DispatchingRun,
715 decode_arguments,
716 };
717
718 pub(crate) type TestResult = Result<(), Box<dyn std::error::Error>>;
722
723 struct RecordingInner {
725 reached: Arc<Mutex<Vec<String>>>,
726 reply: Result<String, String>,
727 }
728
729 impl ActivityDispatcher for RecordingInner {
730 fn dispatch(&self, request: ActivityDispatch) -> Result<String, String> {
731 match self.reached.lock() {
732 Ok(mut names) => names.push(request.name),
733 Err(poisoned) => poisoned.into_inner().push(request.name),
734 }
735 self.reply.clone()
736 }
737 }
738
739 struct FixedBodies {
740 lookup: DeclaredBodyLookup,
741 }
742
743 impl DeclaredBodies for FixedBodies {
744 fn body_for(
745 &self,
746 _task_queue: &str,
747 _action: &str,
748 _run: DispatchingRun<'_>,
749 ) -> DeclaredBodyLookup {
750 self.lookup.clone()
751 }
752 }
753
754 struct RecordingBodies {
761 seen: Arc<Mutex<Vec<(WorkflowId, RunId)>>>,
762 }
763
764 impl DeclaredBodies for RecordingBodies {
765 fn body_for(
766 &self,
767 _task_queue: &str,
768 _action: &str,
769 run: DispatchingRun<'_>,
770 ) -> DeclaredBodyLookup {
771 let observed = (run.workflow_id.clone(), run.run_id.clone());
772 match self.seen.lock() {
773 Ok(mut seen) => seen.push(observed),
774 Err(poisoned) => poisoned.into_inner().push(observed),
775 }
776 DeclaredBodyLookup::None
777 }
778 }
779
780 pub(crate) fn request(name: &str, input: &str) -> ActivityDispatch {
781 ActivityDispatch {
782 namespace: "default".to_owned(),
783 task_queue: "shell".to_owned(),
784 node: None,
785 workflow_id: WorkflowId::new_v4(),
786 run_id: RunId::new_v4(),
787 activity_id: ActivityId::from_sequence_position(1),
788 name: name.to_owned(),
789 input: input.to_owned(),
790 config: "{}".to_owned(),
791 attempt: 1,
792 labels: BTreeMap::new(),
793 advisory: false,
794 }
795 }
796
797 fn dispatcher(
798 lookup: DeclaredBodyLookup,
799 reply: Result<String, String>,
800 ) -> (DeclaredCommandDispatcher, Arc<Mutex<Vec<String>>>) {
801 let (decorated, reached, _transcript) = dispatcher_with_root(
805 lookup,
806 reply,
807 WorkspaceRoot::from_resolution(Ok(std::env::temp_dir())),
808 );
809 (decorated, reached)
810 }
811
812 const TRANSCRIPT_CAPACITY: std::num::NonZeroUsize = match std::num::NonZeroUsize::new(64) {
816 Some(capacity) => capacity,
817 None => std::num::NonZeroUsize::MIN,
818 };
819
820 pub(crate) fn dispatcher_with_root(
826 lookup: DeclaredBodyLookup,
827 reply: Result<String, String>,
828 workspace_root: WorkspaceRoot,
829 ) -> (
830 DeclaredCommandDispatcher,
831 Arc<Mutex<Vec<String>>>,
832 ActivityEventPublisher,
833 ) {
834 dispatcher_with_attempts(
835 lookup,
836 reply,
837 workspace_root,
838 DeclaredCommandAttempts::new(crate::shutdown::DrainState::default()),
839 )
840 }
841
842 pub(super) fn dispatcher_with_attempts(
843 lookup: DeclaredBodyLookup,
844 reply: Result<String, String>,
845 workspace_root: WorkspaceRoot,
846 attempts: DeclaredCommandAttempts,
847 ) -> (
848 DeclaredCommandDispatcher,
849 Arc<Mutex<Vec<String>>>,
850 ActivityEventPublisher,
851 ) {
852 let reached = Arc::new(Mutex::new(Vec::new()));
853 let inner = RecordingInner {
854 reached: Arc::clone(&reached),
855 reply,
856 };
857 let bodies = DeclaredBodySource::default();
858 bodies.install(Arc::new(FixedBodies { lookup }));
859 let store: Arc<dyn aion_store::ObservabilityStore> =
860 Arc::new(aion_store::InMemoryObservabilityStore::default());
861 let transcript = ActivityEventPublisher::new(
862 store,
863 TRANSCRIPT_CAPACITY,
864 crate::activity_publisher::TranscriptBatchPolicy::UNBATCHED,
865 );
866 let decorated = DeclaredCommandDispatcher::new(
867 Arc::new(inner),
868 bodies,
869 attempts,
870 tokio::runtime::Handle::current(),
871 workspace_root,
872 transcript.clone(),
873 );
874 (decorated, reached, transcript)
875 }
876
877 pub(crate) fn reached_names(reached: &Arc<Mutex<Vec<String>>>) -> Vec<String> {
878 match reached.lock() {
879 Ok(names) => names.clone(),
880 Err(poisoned) => poisoned.into_inner().clone(),
881 }
882 }
883
884 #[tokio::test(flavor = "multi_thread")]
885 async fn a_bodiless_action_is_delegated_untouched() -> TestResult {
886 let (decorated, reached) =
887 dispatcher(DeclaredBodyLookup::None, Ok("\"worker-served\"".to_owned()));
888 let handle =
889 tokio::task::spawn_blocking(move || decorated.dispatch(request("plain", "{}")));
890 let result = handle.await?;
891 assert_eq!(result, Ok("\"worker-served\"".to_owned()));
892 assert_eq!(reached_names(&reached), vec!["plain".to_owned()]);
893 Ok(())
894 }
895
896 #[tokio::test(flavor = "multi_thread")]
897 async fn a_declared_body_executes_without_touching_the_worker_path() -> TestResult {
898 let (decorated, reached) = dispatcher(
899 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
900 command: "echo {{greeting}}".to_owned(),
901 }),
902 Err("terminal:the worker path must never be reached".to_owned()),
903 );
904 let handle = tokio::task::spawn_blocking(move || {
905 decorated.dispatch(request(
906 "greet",
907 "{\"greeting\":\"hello from the contract\"}",
908 ))
909 });
910 let result = handle.await?;
911 let encoded = result.map_err(|error| format!("declared command failed: {error}"))?;
912 let outcome: serde_json::Value = serde_json::from_str(&encoded)?;
913 assert_eq!(outcome["stdout"], "hello from the contract");
914 assert_eq!(outcome["exit_code"], 0);
915 assert!(
916 reached_names(&reached).is_empty(),
917 "the worker path must not be consulted for a bodied action"
918 );
919 Ok(())
920 }
921
922 #[tokio::test(flavor = "multi_thread")]
929 async fn a_draining_server_parks_a_declared_dispatch_without_starting_it() -> TestResult {
930 let marker =
931 std::env::temp_dir().join(format!("aion-drain-park-{}", uuid::Uuid::new_v4().simple()));
932 let drain = crate::shutdown::DrainState::default();
933 let attempts = DeclaredCommandAttempts::new(drain.clone());
934 let (decorated, reached, _transcript) = dispatcher_with_attempts(
935 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
936 command: format!("touch {}", marker.display()),
937 }),
938 Err("terminal:the worker path must never be reached".to_owned()),
939 WorkspaceRoot::from_resolution(Ok(std::env::temp_dir())),
940 attempts.clone(),
941 );
942 assert!(drain.begin(), "the first begin() must flip the latch");
943
944 let handle =
945 tokio::task::spawn_blocking(move || decorated.dispatch(request("touch_marker", "{}")));
946 let result = handle.await?;
947
948 assert_eq!(
949 result,
950 Err(aion::PARKED_ACTIVITY_REASON.to_owned()),
951 "a drained-over declared dispatch must wear the park sentinel, not a failure"
952 );
953 assert!(
954 !marker.exists(),
955 "the declared command must never start on a draining server"
956 );
957 assert!(
958 reached_names(&reached).is_empty(),
959 "the park must not fall through to the worker path"
960 );
961 assert!(
962 attempts
963 .executing()
964 .map_err(|error| format!("census read failed: {error}"))?
965 .is_empty(),
966 "a parked dispatch must leave no census entry to hold the drain gate open"
967 );
968 Ok(())
969 }
970
971 #[tokio::test(flavor = "multi_thread")]
980 async fn a_declared_body_publishes_its_output_onto_the_transcript() -> TestResult {
981 let (decorated, reached, transcript) = dispatcher_with_root(
982 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
983 command: "sh -c 'echo one; echo two; echo warned >&2'".to_owned(),
984 }),
985 Err("terminal:the worker path must never be reached".to_owned()),
986 WorkspaceRoot::from_resolution(Ok(std::env::temp_dir())),
987 );
988 let dispatch = request("noisy", "{}");
989 let key = ActivityStreamKey::new(
990 dispatch.workflow_id.clone(),
991 dispatch.run_id.clone(),
992 dispatch.activity_id.clone(),
993 dispatch.attempt,
994 );
995
996 let handle = tokio::task::spawn_blocking(move || decorated.dispatch(dispatch));
997 let encoded = handle
998 .await?
999 .map_err(|error| format!("declared command failed: {error}"))?;
1000
1001 let outcome: serde_json::Value = serde_json::from_str(&encoded)?;
1003 assert_eq!(outcome["stdout"], "one\ntwo");
1004 assert_eq!(outcome["stderr"], "warned");
1005 assert!(reached_names(&reached).is_empty());
1006
1007 let retained = transcript.replay_from(&key, 0).await?;
1009 let lines = retained
1010 .iter()
1011 .map(|record| match &record.event.kind {
1012 ActivityEventKind::Message { text, .. } => {
1013 (record.event.agent_role.clone(), text.clone())
1014 }
1015 other => (record.event.agent_role.clone(), format!("{other:?}")),
1016 })
1017 .collect::<Vec<_>>();
1018 assert!(
1019 lines.contains(&("command stdout".to_owned(), "one".to_owned()))
1020 && lines.contains(&("command stdout".to_owned(), "two".to_owned())),
1021 "each stdout line must be its own transcript event: {lines:?}"
1022 );
1023 assert!(
1024 lines.contains(&("command stderr".to_owned(), "warned".to_owned())),
1025 "stderr must be on the transcript, labelled by its stream: {lines:?}"
1026 );
1027 let sequences = retained
1029 .iter()
1030 .map(|record| record.store_seq)
1031 .collect::<Vec<_>>();
1032 assert_eq!(
1033 sequences,
1034 (0..u64::try_from(retained.len())?).collect::<Vec<_>>(),
1035 "the sequencer assigns a gap-free durable order"
1036 );
1037 Ok(())
1038 }
1039
1040 #[tokio::test(flavor = "multi_thread")]
1041 async fn a_failing_declared_command_reports_retryable_with_its_stderr() -> TestResult {
1042 let (decorated, _reached) = dispatcher(
1043 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
1044 command: "sh -c 'echo boom >&2; exit 7'".to_owned(),
1045 }),
1046 Ok("unused".to_owned()),
1047 );
1048 let handle =
1049 tokio::task::spawn_blocking(move || decorated.dispatch(request("fails", "{}")));
1050 let Err(error) = handle.await? else {
1051 return Err("a non-zero exit must fail the dispatch".into());
1052 };
1053 assert!(
1054 error.starts_with("retryable:"),
1055 "a non-zero exit is retryable by default: {error}"
1056 );
1057 assert!(
1058 error.contains("boom"),
1059 "stderr must ride the failure: {error}"
1060 );
1061 Ok(())
1062 }
1063
1064 #[test]
1074 fn the_printed_hash_parses_back_as_a_content_hash() -> TestResult {
1075 let version = aion_package::ContentHash::from_bytes([0x5a; 32]);
1076 let routed = aion_package::ContentHash::from_bytes([0xa5; 32]);
1077 let refusal = super::ambiguous_body_refusal(
1078 "find_repositories",
1079 "local",
1080 &[
1081 DeclaringVersion {
1082 content_hash: version.to_string(),
1083 workflow_types: vec!["sweeper".to_owned()],
1084 route_active: false,
1085 body: 0,
1086 },
1087 DeclaringVersion {
1088 content_hash: routed.to_string(),
1089 workflow_types: vec!["sweeper".to_owned()],
1090 route_active: true,
1091 body: 1,
1092 },
1093 ],
1094 );
1095 let Some(command) = refusal.split("`aion unload sweeper ").nth(1) else {
1096 return Err(format!("no unload command in the refusal: {refusal}").into());
1097 };
1098 let Some(printed) = command.split('`').next() else {
1099 return Err(format!("the unload command is unterminated: {refusal}").into());
1100 };
1101 let parsed: aion_package::ContentHash = printed.parse()?;
1102 assert_eq!(
1103 parsed, version,
1104 "the printed hash must round-trip to the version it names"
1105 );
1106 Ok(())
1107 }
1108
1109 #[tokio::test(flavor = "multi_thread")]
1110 async fn ambiguous_bodies_refuse_terminally_by_name() -> TestResult {
1111 let superseded = "1111111111111111111111111111111111111111111111111111111111111111";
1112 let routed = "2222222222222222222222222222222222222222222222222222222222222222";
1113 let (decorated, reached) = dispatcher(
1114 DeclaredBodyLookup::Ambiguous {
1115 declaring: vec![
1116 DeclaringVersion {
1117 content_hash: superseded.to_owned(),
1118 workflow_types: vec!["sweeper".to_owned()],
1119 route_active: false,
1120 body: 0,
1121 },
1122 DeclaringVersion {
1123 content_hash: routed.to_owned(),
1124 workflow_types: vec!["sweeper".to_owned()],
1125 route_active: true,
1126 body: 1,
1127 },
1128 ],
1129 },
1130 Ok(String::new()),
1131 );
1132 let handle = tokio::task::spawn_blocking(move || decorated.dispatch(request("torn", "{}")));
1133 let Err(error) = handle.await? else {
1134 return Err("ambiguous bodies must refuse".into());
1135 };
1136 assert!(error.starts_with("terminal:"), "{error}");
1137 assert!(error.contains("torn"), "{error}");
1138 assert!(
1141 error.contains(&format!("`aion unload sweeper {superseded}`")),
1142 "the dispatch refusal must name the version to retire: {error}"
1143 );
1144 assert!(reached_names(&reached).is_empty());
1145 Ok(())
1146 }
1147
1148 #[tokio::test(flavor = "multi_thread")]
1149 async fn a_placeholder_bearing_body_executes_with_the_expanded_root() -> TestResult {
1150 let scratch = tempfile::tempdir()?;
1151 let root = scratch.path().join("clones");
1152 let root_text = root.to_string_lossy().into_owned();
1153 let (decorated, reached, _transcript) = dispatcher_with_root(
1154 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
1155 command: "echo {workspace_root}".to_owned(),
1156 }),
1157 Err("terminal:the worker path must never be reached".to_owned()),
1158 WorkspaceRoot::from_resolution(Ok(root.clone())),
1159 );
1160 let handle =
1161 tokio::task::spawn_blocking(move || decorated.dispatch(request("provision", "{}")));
1162 let result = handle.await?;
1163 let encoded = result.map_err(|error| format!("declared command failed: {error}"))?;
1164 let outcome: serde_json::Value = serde_json::from_str(&encoded)?;
1165 assert_eq!(
1166 outcome["stdout"], root_text,
1167 "the command must observe the server-resolved root as its argv word"
1168 );
1169 assert_eq!(outcome["exit_code"], 0);
1170 assert!(
1171 root.is_dir(),
1172 "dispatching a placeholder-bearing body must create the missing root"
1173 );
1174 assert!(reached_names(&reached).is_empty());
1175 Ok(())
1176 }
1177
1178 #[tokio::test(flavor = "multi_thread")]
1179 async fn a_placeholder_bearing_body_refuses_terminally_when_the_root_is_unresolved()
1180 -> TestResult {
1181 let (decorated, reached, _transcript) = dispatcher_with_root(
1182 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
1183 command: "echo {workspace_root}".to_owned(),
1184 }),
1185 Ok("unused".to_owned()),
1186 WorkspaceRoot::from_resolution(Err(WorkspaceRootError::Unresolvable {
1187 reason: "cannot resolve Aion home: set AION_HOME or HOME".to_owned(),
1188 })),
1189 );
1190 let handle =
1191 tokio::task::spawn_blocking(move || decorated.dispatch(request("provision", "{}")));
1192 let Err(error) = handle.await? else {
1193 return Err("an unresolved root must refuse a placeholder-bearing body".into());
1194 };
1195 assert!(error.starts_with("terminal:"), "{error}");
1196 assert!(
1197 error.contains("provision"),
1198 "the refusal must name the action: {error}"
1199 );
1200 assert!(
1201 error.contains("cannot resolve Aion home"),
1202 "the refusal must carry the resolution failure's reason: {error}"
1203 );
1204 assert!(
1205 reached_names(&reached).is_empty(),
1206 "a refused body must not fall through to the worker path"
1207 );
1208 Ok(())
1209 }
1210
1211 #[tokio::test(flavor = "multi_thread")]
1212 async fn a_shape_changing_root_refuses_terminally_naming_the_action() -> TestResult {
1213 let (decorated, reached, _transcript) = dispatcher_with_root(
1218 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
1219 command: "echo {workspace_root}".to_owned(),
1220 }),
1221 Ok("unused".to_owned()),
1222 WorkspaceRoot::from_resolution(Ok(std::path::PathBuf::from("/absolute/with{brace"))),
1223 );
1224 let handle =
1225 tokio::task::spawn_blocking(move || decorated.dispatch(request("provision", "{}")));
1226 let Err(error) = handle.await? else {
1227 return Err("a shape-changing root must refuse a placeholder-bearing body".into());
1228 };
1229 assert!(error.starts_with("terminal:"), "{error}");
1230 assert!(
1231 error.contains("provision"),
1232 "the refusal must name the action: {error}"
1233 );
1234 assert!(
1235 error.contains("would change the parsed shape"),
1236 "the refusal must carry the shape-changing diagnosis: {error}"
1237 );
1238 assert!(
1239 reached_names(&reached).is_empty(),
1240 "a refused body must not fall through to the worker path"
1241 );
1242 Ok(())
1243 }
1244
1245 #[tokio::test(flavor = "multi_thread")]
1246 async fn an_uncreatable_root_refuses_terminally_naming_the_action() -> TestResult {
1247 let scratch = tempfile::tempdir()?;
1249 let file = scratch.path().join("occupied");
1250 std::fs::write(&file, b"not a directory")?;
1251 let (decorated, reached, _transcript) = dispatcher_with_root(
1252 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
1253 command: "echo {workspace_root}".to_owned(),
1254 }),
1255 Ok("unused".to_owned()),
1256 WorkspaceRoot::from_resolution(Ok(file.join("clones"))),
1257 );
1258 let handle =
1259 tokio::task::spawn_blocking(move || decorated.dispatch(request("provision", "{}")));
1260 let Err(error) = handle.await? else {
1261 return Err("an uncreatable root must refuse a placeholder-bearing body".into());
1262 };
1263 assert!(error.starts_with("terminal:"), "{error}");
1264 assert!(
1265 error.contains("provision"),
1266 "the refusal must name the action: {error}"
1267 );
1268 assert!(
1269 error.contains("could not be created"),
1270 "the refusal must carry the creation-failure diagnosis: {error}"
1271 );
1272 assert!(
1273 reached_names(&reached).is_empty(),
1274 "a refused body must not fall through to the worker path"
1275 );
1276 Ok(())
1277 }
1278
1279 #[tokio::test(flavor = "multi_thread")]
1280 async fn a_body_without_the_placeholder_is_untouched_by_resolution_failure() -> TestResult {
1281 let (decorated, _reached, _transcript) = dispatcher_with_root(
1282 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
1283 command: "echo {{greeting}}".to_owned(),
1284 }),
1285 Ok("unused".to_owned()),
1286 WorkspaceRoot::from_resolution(Err(WorkspaceRootError::Unresolvable {
1287 reason: "cannot resolve Aion home: set AION_HOME or HOME".to_owned(),
1288 })),
1289 );
1290 let handle = tokio::task::spawn_blocking(move || {
1291 decorated.dispatch(request("greet", "{\"greeting\":\"still served\"}"))
1292 });
1293 let result = handle.await?;
1294 let encoded = result.map_err(|error| format!("declared command failed: {error}"))?;
1295 let outcome: serde_json::Value = serde_json::from_str(&encoded)?;
1296 assert_eq!(outcome["stdout"], "still served");
1297 Ok(())
1298 }
1299
1300 #[tokio::test(flavor = "multi_thread")]
1301 async fn an_unreadable_catalog_delegates_to_the_worker_path() -> TestResult {
1302 let (decorated, reached) = dispatcher(
1303 DeclaredBodyLookup::Unreadable("catalog offline".to_owned()),
1304 Ok("\"served anyway\"".to_owned()),
1305 );
1306 let handle =
1307 tokio::task::spawn_blocking(move || decorated.dispatch(request("resilient", "{}")));
1308 let result = handle.await?;
1309 assert_eq!(result, Ok("\"served anyway\"".to_owned()));
1310 assert_eq!(reached_names(&reached), vec!["resilient".to_owned()]);
1311 Ok(())
1312 }
1313
1314 #[test]
1315 fn non_object_input_is_refused_terminally_by_shape() {
1316 for (input, kind) in [
1317 ("[1,2]", "an array"),
1318 ("\"text\"", "a string"),
1319 ("3", "a number"),
1320 ("null", "null"),
1321 ("true", "a boolean"),
1322 ] {
1323 let Err(error) = decode_arguments(input) else {
1324 unreachable_refusal(input);
1325 return;
1326 };
1327 assert!(error.starts_with("terminal:"), "{error}");
1328 assert!(error.contains(kind), "{error} must name {kind}");
1329 }
1330 }
1331
1332 fn unreachable_refusal(input: &str) {
1334 assert!(
1335 input.is_empty(),
1336 "input `{input}` must have been refused by shape"
1337 );
1338 }
1339
1340 #[tokio::test(flavor = "multi_thread")]
1348 async fn the_reader_is_asked_about_the_run_that_is_dispatching() -> TestResult {
1349 let seen = Arc::new(Mutex::new(Vec::new()));
1350 let bodies = DeclaredBodySource::default();
1351 bodies.install(Arc::new(RecordingBodies {
1352 seen: Arc::clone(&seen),
1353 }));
1354 let reached = Arc::new(Mutex::new(Vec::new()));
1355 let decorated = DeclaredCommandDispatcher::new(
1356 Arc::new(RecordingInner {
1357 reached: Arc::clone(&reached),
1358 reply: Ok("\"worker-served\"".to_owned()),
1359 }),
1360 bodies,
1361 DeclaredCommandAttempts::new(crate::shutdown::DrainState::default()),
1362 tokio::runtime::Handle::current(),
1363 WorkspaceRoot::from_resolution(Ok(std::env::temp_dir())),
1364 ActivityEventPublisher::new(
1365 Arc::new(aion_store::InMemoryObservabilityStore::default()),
1366 TRANSCRIPT_CAPACITY,
1367 crate::activity_publisher::TranscriptBatchPolicy::UNBATCHED,
1368 ),
1369 );
1370
1371 let dispatch = request("plain", "{}");
1372 let expected = (dispatch.workflow_id.clone(), dispatch.run_id.clone());
1373 let handle = tokio::task::spawn_blocking(move || decorated.dispatch(dispatch));
1374 handle
1375 .await?
1376 .map_err(|error| format!("dispatch failed: {error}"))?;
1377
1378 let observed = match seen.lock() {
1379 Ok(observed) => observed.clone(),
1380 Err(poisoned) => poisoned.into_inner().clone(),
1381 };
1382 assert_eq!(
1383 observed,
1384 vec![expected],
1385 "the body reader must be asked about the dispatching run itself"
1386 );
1387 Ok(())
1388 }
1389}