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 {
262 inner: Arc<dyn ActivityDispatcher>,
263 bodies: DeclaredBodySource,
264 executor: Arc<DeclaredCommandExecutor>,
265}
266
267pub struct DeclaredCommandExecutor {
275 attempts: DeclaredCommandAttempts,
276 tokio: tokio::runtime::Handle,
277 workspace_root: WorkspaceRoot,
278 transcript: ActivityEventPublisher,
279}
280
281impl DeclaredCommandExecutor {
282 #[must_use]
287 pub fn new(
288 attempts: DeclaredCommandAttempts,
289 tokio: tokio::runtime::Handle,
290 workspace_root: WorkspaceRoot,
291 transcript: ActivityEventPublisher,
292 ) -> Self {
293 Self {
294 attempts,
295 tokio,
296 workspace_root,
297 transcript,
298 }
299 }
300
301 pub fn execute(
311 &self,
312 request: &ActivityDispatch,
313 contract: &ActionBodyContract,
314 ) -> Result<String, String> {
315 match contract {
316 ActionBodyContract::Run { command } => self.run_declared_command(request, command),
317 ActionBodyContract::Command { capture, command } => {
318 self.run_declared_command_body(request, *capture, *command.clone())
319 }
320 }
321 }
322}
323
324impl DeclaredCommandDispatcher {
325 #[must_use]
336 pub fn new(
337 inner: Arc<dyn ActivityDispatcher>,
338 bodies: DeclaredBodySource,
339 attempts: DeclaredCommandAttempts,
340 tokio: tokio::runtime::Handle,
341 workspace_root: WorkspaceRoot,
342 transcript: ActivityEventPublisher,
343 ) -> Self {
344 Self {
345 inner,
346 bodies,
347 executor: Arc::new(DeclaredCommandExecutor::new(
348 attempts,
349 tokio,
350 workspace_root,
351 transcript,
352 )),
353 }
354 }
355
356 #[must_use]
359 pub fn executor(&self) -> Arc<DeclaredCommandExecutor> {
360 Arc::clone(&self.executor)
361 }
362}
363
364impl DeclaredCommandExecutor {
365 fn declared_action(
374 &self,
375 request: &ActivityDispatch,
376 command: &str,
377 ) -> Result<ShellAction, String> {
378 let expanded = self.workspace_root.expand(command).map_err(|error| {
379 format!(
380 "terminal:declared body for action `{name}` uses the {placeholder} \
381 placeholder and cannot dispatch: {error}",
382 name = request.name,
383 placeholder = WORKSPACE_ROOT_PLACEHOLDER,
384 )
385 })?;
386 if let Some(expansion) = &expanded {
387 tracing::info!(
388 operation = "declared_command_dispatch",
389 workflow_id = %request.workflow_id,
390 activity_id = %request.activity_id,
391 activity_name = %request.name,
392 task_queue = %request.task_queue,
393 attempt = request.attempt,
394 workspace_root = %expansion.workspace_root,
395 "expanded the workspace-root placeholder in the declared command"
396 );
397 }
398 let command = expanded
399 .as_ref()
400 .map_or(command, |expansion| expansion.command.as_str());
401 ShellAction::new(command).map_err(|error| {
402 format!("terminal:declared command failed to parse at dispatch: {error}")
406 })
407 }
408
409 pub(super) fn join_cancel_path(
419 &self,
420 request: &ActivityDispatch,
421 cancellation: &aion_worker::ActivityCancellationHandle,
422 ) -> Result<super::DeclaredAttemptRegistration, String> {
423 self.attempts
424 .register(
425 super::AttemptKey::new(
426 request.workflow_id.clone(),
427 request.run_id.clone(),
428 request.activity_id.clone(),
429 request.attempt,
430 ),
431 cancellation.clone(),
432 )
433 .map_err(|error| match error {
434 crate::error::ServerError::DrainingRefusedDeclaredAttempt { .. } => {
440 tracing::info!(
441 operation = "declared_command_dispatch",
442 workflow_id = %request.workflow_id,
443 activity_id = %request.activity_id,
444 activity_name = %request.name,
445 task_queue = %request.task_queue,
446 attempt = request.attempt,
447 "declared command parked: this server is draining and starts no new work"
448 );
449 aion::PARKED_ACTIVITY_REASON.to_owned()
450 }
451 other => format!(
452 "terminal:declared body for action `{name}` cannot dispatch: the attempt \
453 could not join this server's cancel path, and a command a cancelled run \
454 could not stop must not be started: {other}",
455 name = request.name,
456 ),
457 })
458 }
459
460 fn run_declared_command(
463 &self,
464 request: &ActivityDispatch,
465 command: &str,
466 ) -> Result<String, String> {
467 let arguments = decode_arguments(&request.input)?;
468 let action = self.declared_action(request, command)?;
469 let (events, drain) = tokio::sync::mpsc::unbounded_channel();
474 let (context, cancellation) = aion_worker::ActivityContext::with_transcript(
475 request.workflow_id.clone(),
476 request.run_id.clone(),
477 request.activity_id.clone(),
478 request.attempt,
479 events,
480 );
481 let registration = self.join_cancel_path(request, &cancellation)?;
482
483 tracing::info!(
484 operation = "declared_command_dispatch",
485 workflow_id = %request.workflow_id,
486 activity_id = %request.activity_id,
487 activity_name = %request.name,
488 task_queue = %request.task_queue,
489 attempt = request.attempt,
490 "executing declared action body at the server"
491 );
492 crate::death_note::breadcrumb(&format!(
496 "declared-action start action={} workflow_id={} run_id={} activity_id={} attempt={}",
497 request.name, request.workflow_id, request.run_id, request.activity_id, request.attempt,
498 ));
499
500 let bound = aion::activity_timeout_from_config(&request.config);
506 let transcript = self.transcript.clone();
507 let ended = self.tokio.block_on(async move {
508 let pump = tokio::spawn(publish_declared_transcript(transcript, drain));
509 let ended = run_bounded(action.run(&arguments, &context), &cancellation, bound).await;
510 drop(context);
512 if let Err(error) = pump.await {
513 tracing::warn!(
514 %error,
515 operation = "declared_command_dispatch",
516 "declared command transcript: the publishing task ended abnormally; some \
517 output lines may not have been retained"
518 );
519 }
520 ended
521 });
522 drop(registration);
526
527 encode_end(request, ended, |outcome| {
531 serde_json::to_string(&outcome).map_err(|error| {
532 ActivityFailure::terminal(format!(
533 "declared command result failed to encode: {error}"
534 ))
535 })
536 })
537 }
538}
539
540pub(super) fn encode_end(
557 request: &ActivityDispatch,
558 ended: AttemptEnd,
559 shape: impl FnOnce(ShellOutcome) -> Result<String, ActivityFailure>,
560) -> Result<String, String> {
561 let outcome = match ended {
562 AttemptEnd::Ran(outcome) => outcome,
563 AttemptEnd::Expired { bound, ran_anyway } => {
564 if let Some(exit_code) = ran_anyway {
565 tracing::warn!(
571 operation = "declared_command_dispatch",
572 workflow_id = %request.workflow_id,
573 activity_id = %request.activity_id,
574 activity_name = %request.name,
575 attempt = request.attempt,
576 exit_code,
577 bound_ms = bound.as_millis(),
578 "the declared command finished while it was being stopped on its \
579 authored bound; its result is discarded in favour of the timeout"
580 );
581 }
582 return Err(aion::activity_timeout_reason(bound));
583 }
584 };
585
586 match outcome.and_then(shape) {
587 Ok(encoded) => Ok(encoded),
588 Err(failure) => {
589 let prefix = match failure.classification() {
590 aion_worker::Classification::Retryable => "retryable",
591 aion_worker::Classification::PolicyRefused => "policy_refused",
592 aion_worker::Classification::Terminal => "terminal",
593 };
594 Err(format!("{prefix}:{}", failure.message()))
595 }
596 }
597}
598
599#[derive(Debug)]
601pub(super) enum AttemptEnd {
602 Ran(Result<ShellOutcome, ActivityFailure>),
605 Expired {
608 bound: std::time::Duration,
610 ran_anyway: Option<i32>,
614 },
615}
616
617pub(super) async fn run_bounded(
641 run: impl Future<Output = Result<ShellOutcome, ActivityFailure>>,
642 cancellation: &aion_worker::ActivityCancellationHandle,
643 bound: Option<std::time::Duration>,
644) -> AttemptEnd {
645 let Some(bound) = bound else {
646 return AttemptEnd::Ran(run.await);
647 };
648 tokio::pin!(run);
649 match tokio::time::timeout(bound, &mut run).await {
650 Ok(outcome) => AttemptEnd::Ran(outcome),
651 Err(_elapsed) => {
652 cancellation.cancel();
653 AttemptEnd::Expired {
654 bound,
655 ran_anyway: run.await.ok().map(|outcome| outcome.exit_code),
656 }
657 }
658 }
659}
660
661impl DeclaredCommandExecutor {
662 pub(super) fn transcript(&self) -> ActivityEventPublisher {
664 self.transcript.clone()
665 }
666
667 pub(super) fn tokio(&self) -> &tokio::runtime::Handle {
669 &self.tokio
670 }
671
672 pub(super) const fn workspace_root(&self) -> &WorkspaceRoot {
674 &self.workspace_root
675 }
676}
677
678impl std::fmt::Debug for DeclaredCommandDispatcher {
679 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
680 formatter
681 .debug_struct("DeclaredCommandDispatcher")
682 .field("bodies", &self.bodies)
683 .finish_non_exhaustive()
684 }
685}
686
687impl ActivityDispatcher for DeclaredCommandDispatcher {
688 fn dispatch(&self, request: ActivityDispatch) -> Result<String, String> {
689 let run = DispatchingRun {
690 workflow_id: &request.workflow_id,
691 run_id: &request.run_id,
692 };
693 match self
694 .bodies
695 .body_for(&request.task_queue, &request.name, run)
696 {
697 DeclaredBodyLookup::None => self.inner.dispatch(request),
698 DeclaredBodyLookup::Unreadable(reason) => {
699 tracing::error!(
703 operation = "declared_command_dispatch",
704 workflow_id = %request.workflow_id,
705 activity_name = %request.name,
706 task_queue = %request.task_queue,
707 %reason,
708 "declared-body catalog read failed; delegating to the worker path"
709 );
710 self.inner.dispatch(request)
711 }
712 DeclaredBodyLookup::Ambiguous { declaring } => Err(ambiguous_body_refusal(
713 &request.name,
714 &request.task_queue,
715 &declaring,
716 )),
717 DeclaredBodyLookup::Declared(contract) => self.executor.execute(&request, &contract),
718 }
719 }
720}
721
722pub(super) fn decode_arguments(input: &str) -> Result<BTreeMap<String, serde_json::Value>, String> {
729 let value: serde_json::Value = serde_json::from_str(input)
730 .map_err(|error| format!("terminal:declared command input is not valid JSON: {error}"))?;
731 match value {
732 serde_json::Value::Object(members) => Ok(members.into_iter().collect()),
733 other => Err(format!(
734 "terminal:declared command input must be a JSON object binding the action's \
735 parameters by name; got {}",
736 json_kind(&other)
737 )),
738 }
739}
740
741const fn json_kind(value: &serde_json::Value) -> &'static str {
743 match value {
744 serde_json::Value::Null => "null",
745 serde_json::Value::Bool(_) => "a boolean",
746 serde_json::Value::Number(_) => "a number",
747 serde_json::Value::String(_) => "a string",
748 serde_json::Value::Array(_) => "an array",
749 serde_json::Value::Object(_) => "an object",
750 }
751}
752
753#[cfg(test)]
758#[path = "declared_body_containment_tests.rs"]
759mod declared_body_containment_tests;
760
761#[cfg(test)]
762pub(super) mod tests {
763 use std::collections::BTreeMap;
764 use std::sync::{Arc, Mutex};
765
766 use aion::{ActivityDispatch, ActivityDispatcher};
767 use aion_core::{ActivityId, RunId, WorkflowId};
768 use aion_package::ActionBodyContract;
769
770 use aion_core::ActivityEventKind;
771 use aion_store::ActivityStreamKey;
772
773 use super::super::workspace_root::{WorkspaceRoot, WorkspaceRootError};
774 use super::{
775 ActivityEventPublisher, DeclaredBodies, DeclaredBodyLookup, DeclaredBodySource,
776 DeclaredCommandAttempts, DeclaredCommandDispatcher, DeclaringVersion, DispatchingRun,
777 decode_arguments,
778 };
779
780 pub(crate) type TestResult = Result<(), Box<dyn std::error::Error>>;
784
785 struct RecordingInner {
787 reached: Arc<Mutex<Vec<String>>>,
788 reply: Result<String, String>,
789 }
790
791 impl ActivityDispatcher for RecordingInner {
792 fn dispatch(&self, request: ActivityDispatch) -> Result<String, String> {
793 match self.reached.lock() {
794 Ok(mut names) => names.push(request.name),
795 Err(poisoned) => poisoned.into_inner().push(request.name),
796 }
797 self.reply.clone()
798 }
799 }
800
801 struct FixedBodies {
802 lookup: DeclaredBodyLookup,
803 }
804
805 impl DeclaredBodies for FixedBodies {
806 fn body_for(
807 &self,
808 _task_queue: &str,
809 _action: &str,
810 _run: DispatchingRun<'_>,
811 ) -> DeclaredBodyLookup {
812 self.lookup.clone()
813 }
814 }
815
816 struct RecordingBodies {
823 seen: Arc<Mutex<Vec<(WorkflowId, RunId)>>>,
824 }
825
826 impl DeclaredBodies for RecordingBodies {
827 fn body_for(
828 &self,
829 _task_queue: &str,
830 _action: &str,
831 run: DispatchingRun<'_>,
832 ) -> DeclaredBodyLookup {
833 let observed = (run.workflow_id.clone(), run.run_id.clone());
834 match self.seen.lock() {
835 Ok(mut seen) => seen.push(observed),
836 Err(poisoned) => poisoned.into_inner().push(observed),
837 }
838 DeclaredBodyLookup::None
839 }
840 }
841
842 pub(crate) fn request(name: &str, input: &str) -> ActivityDispatch {
843 ActivityDispatch {
844 namespace: "default".to_owned(),
845 task_queue: "shell".to_owned(),
846 node: None,
847 workflow_id: WorkflowId::new_v4(),
848 run_id: RunId::new_v4(),
849 activity_id: ActivityId::from_sequence_position(1),
850 name: name.to_owned(),
851 input: input.to_owned(),
852 config: "{}".to_owned(),
853 attempt: 1,
854 labels: BTreeMap::new(),
855 advisory: false,
856 }
857 }
858
859 fn dispatcher(
860 lookup: DeclaredBodyLookup,
861 reply: Result<String, String>,
862 ) -> (DeclaredCommandDispatcher, Arc<Mutex<Vec<String>>>) {
863 let (decorated, reached, _transcript) = dispatcher_with_root(
867 lookup,
868 reply,
869 WorkspaceRoot::from_resolution(Ok(std::env::temp_dir())),
870 );
871 (decorated, reached)
872 }
873
874 const TRANSCRIPT_CAPACITY: std::num::NonZeroUsize = match std::num::NonZeroUsize::new(64) {
878 Some(capacity) => capacity,
879 None => std::num::NonZeroUsize::MIN,
880 };
881
882 pub(crate) fn dispatcher_with_root(
888 lookup: DeclaredBodyLookup,
889 reply: Result<String, String>,
890 workspace_root: WorkspaceRoot,
891 ) -> (
892 DeclaredCommandDispatcher,
893 Arc<Mutex<Vec<String>>>,
894 ActivityEventPublisher,
895 ) {
896 dispatcher_with_attempts(
897 lookup,
898 reply,
899 workspace_root,
900 DeclaredCommandAttempts::new(crate::shutdown::DrainState::default()),
901 )
902 }
903
904 pub(super) fn dispatcher_with_attempts(
905 lookup: DeclaredBodyLookup,
906 reply: Result<String, String>,
907 workspace_root: WorkspaceRoot,
908 attempts: DeclaredCommandAttempts,
909 ) -> (
910 DeclaredCommandDispatcher,
911 Arc<Mutex<Vec<String>>>,
912 ActivityEventPublisher,
913 ) {
914 let reached = Arc::new(Mutex::new(Vec::new()));
915 let inner = RecordingInner {
916 reached: Arc::clone(&reached),
917 reply,
918 };
919 let bodies = DeclaredBodySource::default();
920 bodies.install(Arc::new(FixedBodies { lookup }));
921 let store: Arc<dyn aion_store::ObservabilityStore> =
922 Arc::new(aion_store::InMemoryObservabilityStore::default());
923 let transcript = ActivityEventPublisher::new(
924 store,
925 TRANSCRIPT_CAPACITY,
926 crate::activity_publisher::TranscriptBatchPolicy::UNBATCHED,
927 );
928 let decorated = DeclaredCommandDispatcher::new(
929 Arc::new(inner),
930 bodies,
931 attempts,
932 tokio::runtime::Handle::current(),
933 workspace_root,
934 transcript.clone(),
935 );
936 (decorated, reached, transcript)
937 }
938
939 pub(crate) fn reached_names(reached: &Arc<Mutex<Vec<String>>>) -> Vec<String> {
940 match reached.lock() {
941 Ok(names) => names.clone(),
942 Err(poisoned) => poisoned.into_inner().clone(),
943 }
944 }
945
946 #[tokio::test(flavor = "multi_thread")]
947 async fn a_bodiless_action_is_delegated_untouched() -> TestResult {
948 let (decorated, reached) =
949 dispatcher(DeclaredBodyLookup::None, Ok("\"worker-served\"".to_owned()));
950 let handle =
951 tokio::task::spawn_blocking(move || decorated.dispatch(request("plain", "{}")));
952 let result = handle.await?;
953 assert_eq!(result, Ok("\"worker-served\"".to_owned()));
954 assert_eq!(reached_names(&reached), vec!["plain".to_owned()]);
955 Ok(())
956 }
957
958 #[tokio::test(flavor = "multi_thread")]
959 async fn a_declared_body_executes_without_touching_the_worker_path() -> TestResult {
960 let (decorated, reached) = dispatcher(
961 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
962 command: "echo {{greeting}}".to_owned(),
963 }),
964 Err("terminal:the worker path must never be reached".to_owned()),
965 );
966 let handle = tokio::task::spawn_blocking(move || {
967 decorated.dispatch(request(
968 "greet",
969 "{\"greeting\":\"hello from the contract\"}",
970 ))
971 });
972 let result = handle.await?;
973 let encoded = result.map_err(|error| format!("declared command failed: {error}"))?;
974 let outcome: serde_json::Value = serde_json::from_str(&encoded)?;
975 assert_eq!(outcome["stdout"], "hello from the contract");
976 assert_eq!(outcome["exit_code"], 0);
977 assert!(
978 reached_names(&reached).is_empty(),
979 "the worker path must not be consulted for a bodied action"
980 );
981 Ok(())
982 }
983
984 #[tokio::test(flavor = "multi_thread")]
991 async fn a_draining_server_parks_a_declared_dispatch_without_starting_it() -> TestResult {
992 let marker =
993 std::env::temp_dir().join(format!("aion-drain-park-{}", uuid::Uuid::new_v4().simple()));
994 let drain = crate::shutdown::DrainState::default();
995 let attempts = DeclaredCommandAttempts::new(drain.clone());
996 let (decorated, reached, _transcript) = dispatcher_with_attempts(
997 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
998 command: format!("touch {}", marker.display()),
999 }),
1000 Err("terminal:the worker path must never be reached".to_owned()),
1001 WorkspaceRoot::from_resolution(Ok(std::env::temp_dir())),
1002 attempts.clone(),
1003 );
1004 assert!(drain.begin(), "the first begin() must flip the latch");
1005
1006 let handle =
1007 tokio::task::spawn_blocking(move || decorated.dispatch(request("touch_marker", "{}")));
1008 let result = handle.await?;
1009
1010 assert_eq!(
1011 result,
1012 Err(aion::PARKED_ACTIVITY_REASON.to_owned()),
1013 "a drained-over declared dispatch must wear the park sentinel, not a failure"
1014 );
1015 assert!(
1016 !marker.exists(),
1017 "the declared command must never start on a draining server"
1018 );
1019 assert!(
1020 reached_names(&reached).is_empty(),
1021 "the park must not fall through to the worker path"
1022 );
1023 assert!(
1024 attempts
1025 .executing()
1026 .map_err(|error| format!("census read failed: {error}"))?
1027 .is_empty(),
1028 "a parked dispatch must leave no census entry to hold the drain gate open"
1029 );
1030 Ok(())
1031 }
1032
1033 #[tokio::test(flavor = "multi_thread")]
1042 async fn a_declared_body_publishes_its_output_onto_the_transcript() -> TestResult {
1043 let (decorated, reached, transcript) = dispatcher_with_root(
1044 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
1045 command: "sh -c 'echo one; echo two; echo warned >&2'".to_owned(),
1046 }),
1047 Err("terminal:the worker path must never be reached".to_owned()),
1048 WorkspaceRoot::from_resolution(Ok(std::env::temp_dir())),
1049 );
1050 let dispatch = request("noisy", "{}");
1051 let key = ActivityStreamKey::new(
1052 dispatch.workflow_id.clone(),
1053 dispatch.run_id.clone(),
1054 dispatch.activity_id.clone(),
1055 dispatch.attempt,
1056 );
1057
1058 let handle = tokio::task::spawn_blocking(move || decorated.dispatch(dispatch));
1059 let encoded = handle
1060 .await?
1061 .map_err(|error| format!("declared command failed: {error}"))?;
1062
1063 let outcome: serde_json::Value = serde_json::from_str(&encoded)?;
1065 assert_eq!(outcome["stdout"], "one\ntwo");
1066 assert_eq!(outcome["stderr"], "warned");
1067 assert!(reached_names(&reached).is_empty());
1068
1069 let retained = transcript.replay_from(&key, 0).await?;
1071 let lines = retained
1072 .iter()
1073 .map(|record| match &record.event.kind {
1074 ActivityEventKind::Message { text, .. } => {
1075 (record.event.agent_role.clone(), text.clone())
1076 }
1077 other => (record.event.agent_role.clone(), format!("{other:?}")),
1078 })
1079 .collect::<Vec<_>>();
1080 assert!(
1081 lines.contains(&("command stdout".to_owned(), "one".to_owned()))
1082 && lines.contains(&("command stdout".to_owned(), "two".to_owned())),
1083 "each stdout line must be its own transcript event: {lines:?}"
1084 );
1085 assert!(
1086 lines.contains(&("command stderr".to_owned(), "warned".to_owned())),
1087 "stderr must be on the transcript, labelled by its stream: {lines:?}"
1088 );
1089 let sequences = retained
1091 .iter()
1092 .map(|record| record.store_seq)
1093 .collect::<Vec<_>>();
1094 assert_eq!(
1095 sequences,
1096 (0..u64::try_from(retained.len())?).collect::<Vec<_>>(),
1097 "the sequencer assigns a gap-free durable order"
1098 );
1099 Ok(())
1100 }
1101
1102 #[tokio::test(flavor = "multi_thread")]
1103 async fn a_failing_declared_command_reports_retryable_with_its_stderr() -> TestResult {
1104 let (decorated, _reached) = dispatcher(
1105 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
1106 command: "sh -c 'echo boom >&2; exit 7'".to_owned(),
1107 }),
1108 Ok("unused".to_owned()),
1109 );
1110 let handle =
1111 tokio::task::spawn_blocking(move || decorated.dispatch(request("fails", "{}")));
1112 let Err(error) = handle.await? else {
1113 return Err("a non-zero exit must fail the dispatch".into());
1114 };
1115 assert!(
1116 error.starts_with("retryable:"),
1117 "a non-zero exit is retryable by default: {error}"
1118 );
1119 assert!(
1120 error.contains("boom"),
1121 "stderr must ride the failure: {error}"
1122 );
1123 Ok(())
1124 }
1125
1126 #[test]
1136 fn the_printed_hash_parses_back_as_a_content_hash() -> TestResult {
1137 let version = aion_package::ContentHash::from_bytes([0x5a; 32]);
1138 let routed = aion_package::ContentHash::from_bytes([0xa5; 32]);
1139 let refusal = super::ambiguous_body_refusal(
1140 "find_repositories",
1141 "local",
1142 &[
1143 DeclaringVersion {
1144 content_hash: version.to_string(),
1145 workflow_types: vec!["sweeper".to_owned()],
1146 route_active: false,
1147 body: 0,
1148 },
1149 DeclaringVersion {
1150 content_hash: routed.to_string(),
1151 workflow_types: vec!["sweeper".to_owned()],
1152 route_active: true,
1153 body: 1,
1154 },
1155 ],
1156 );
1157 let Some(command) = refusal.split("`aion unload sweeper ").nth(1) else {
1158 return Err(format!("no unload command in the refusal: {refusal}").into());
1159 };
1160 let Some(printed) = command.split('`').next() else {
1161 return Err(format!("the unload command is unterminated: {refusal}").into());
1162 };
1163 let parsed: aion_package::ContentHash = printed.parse()?;
1164 assert_eq!(
1165 parsed, version,
1166 "the printed hash must round-trip to the version it names"
1167 );
1168 Ok(())
1169 }
1170
1171 #[tokio::test(flavor = "multi_thread")]
1172 async fn ambiguous_bodies_refuse_terminally_by_name() -> TestResult {
1173 let superseded = "1111111111111111111111111111111111111111111111111111111111111111";
1174 let routed = "2222222222222222222222222222222222222222222222222222222222222222";
1175 let (decorated, reached) = dispatcher(
1176 DeclaredBodyLookup::Ambiguous {
1177 declaring: vec![
1178 DeclaringVersion {
1179 content_hash: superseded.to_owned(),
1180 workflow_types: vec!["sweeper".to_owned()],
1181 route_active: false,
1182 body: 0,
1183 },
1184 DeclaringVersion {
1185 content_hash: routed.to_owned(),
1186 workflow_types: vec!["sweeper".to_owned()],
1187 route_active: true,
1188 body: 1,
1189 },
1190 ],
1191 },
1192 Ok(String::new()),
1193 );
1194 let handle = tokio::task::spawn_blocking(move || decorated.dispatch(request("torn", "{}")));
1195 let Err(error) = handle.await? else {
1196 return Err("ambiguous bodies must refuse".into());
1197 };
1198 assert!(error.starts_with("terminal:"), "{error}");
1199 assert!(error.contains("torn"), "{error}");
1200 assert!(
1203 error.contains(&format!("`aion unload sweeper {superseded}`")),
1204 "the dispatch refusal must name the version to retire: {error}"
1205 );
1206 assert!(reached_names(&reached).is_empty());
1207 Ok(())
1208 }
1209
1210 #[tokio::test(flavor = "multi_thread")]
1211 async fn a_placeholder_bearing_body_executes_with_the_expanded_root() -> TestResult {
1212 let scratch = tempfile::tempdir()?;
1213 let root = scratch.path().join("clones");
1214 let root_text = root.to_string_lossy().into_owned();
1215 let (decorated, reached, _transcript) = dispatcher_with_root(
1216 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
1217 command: "echo {workspace_root}".to_owned(),
1218 }),
1219 Err("terminal:the worker path must never be reached".to_owned()),
1220 WorkspaceRoot::from_resolution(Ok(root.clone())),
1221 );
1222 let handle =
1223 tokio::task::spawn_blocking(move || decorated.dispatch(request("provision", "{}")));
1224 let result = handle.await?;
1225 let encoded = result.map_err(|error| format!("declared command failed: {error}"))?;
1226 let outcome: serde_json::Value = serde_json::from_str(&encoded)?;
1227 assert_eq!(
1228 outcome["stdout"], root_text,
1229 "the command must observe the server-resolved root as its argv word"
1230 );
1231 assert_eq!(outcome["exit_code"], 0);
1232 assert!(
1233 root.is_dir(),
1234 "dispatching a placeholder-bearing body must create the missing root"
1235 );
1236 assert!(reached_names(&reached).is_empty());
1237 Ok(())
1238 }
1239
1240 #[tokio::test(flavor = "multi_thread")]
1241 async fn a_placeholder_bearing_body_refuses_terminally_when_the_root_is_unresolved()
1242 -> TestResult {
1243 let (decorated, reached, _transcript) = dispatcher_with_root(
1244 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
1245 command: "echo {workspace_root}".to_owned(),
1246 }),
1247 Ok("unused".to_owned()),
1248 WorkspaceRoot::from_resolution(Err(WorkspaceRootError::Unresolvable {
1249 reason: "cannot resolve Aion home: set AION_HOME or HOME".to_owned(),
1250 })),
1251 );
1252 let handle =
1253 tokio::task::spawn_blocking(move || decorated.dispatch(request("provision", "{}")));
1254 let Err(error) = handle.await? else {
1255 return Err("an unresolved root must refuse a placeholder-bearing body".into());
1256 };
1257 assert!(error.starts_with("terminal:"), "{error}");
1258 assert!(
1259 error.contains("provision"),
1260 "the refusal must name the action: {error}"
1261 );
1262 assert!(
1263 error.contains("cannot resolve Aion home"),
1264 "the refusal must carry the resolution failure's reason: {error}"
1265 );
1266 assert!(
1267 reached_names(&reached).is_empty(),
1268 "a refused body must not fall through to the worker path"
1269 );
1270 Ok(())
1271 }
1272
1273 #[tokio::test(flavor = "multi_thread")]
1274 async fn a_shape_changing_root_refuses_terminally_naming_the_action() -> TestResult {
1275 let (decorated, reached, _transcript) = dispatcher_with_root(
1280 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
1281 command: "echo {workspace_root}".to_owned(),
1282 }),
1283 Ok("unused".to_owned()),
1284 WorkspaceRoot::from_resolution(Ok(std::path::PathBuf::from("/absolute/with{brace"))),
1285 );
1286 let handle =
1287 tokio::task::spawn_blocking(move || decorated.dispatch(request("provision", "{}")));
1288 let Err(error) = handle.await? else {
1289 return Err("a shape-changing root must refuse a placeholder-bearing body".into());
1290 };
1291 assert!(error.starts_with("terminal:"), "{error}");
1292 assert!(
1293 error.contains("provision"),
1294 "the refusal must name the action: {error}"
1295 );
1296 assert!(
1297 error.contains("would change the parsed shape"),
1298 "the refusal must carry the shape-changing diagnosis: {error}"
1299 );
1300 assert!(
1301 reached_names(&reached).is_empty(),
1302 "a refused body must not fall through to the worker path"
1303 );
1304 Ok(())
1305 }
1306
1307 #[tokio::test(flavor = "multi_thread")]
1308 async fn an_uncreatable_root_refuses_terminally_naming_the_action() -> TestResult {
1309 let scratch = tempfile::tempdir()?;
1311 let file = scratch.path().join("occupied");
1312 std::fs::write(&file, b"not a directory")?;
1313 let (decorated, reached, _transcript) = dispatcher_with_root(
1314 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
1315 command: "echo {workspace_root}".to_owned(),
1316 }),
1317 Ok("unused".to_owned()),
1318 WorkspaceRoot::from_resolution(Ok(file.join("clones"))),
1319 );
1320 let handle =
1321 tokio::task::spawn_blocking(move || decorated.dispatch(request("provision", "{}")));
1322 let Err(error) = handle.await? else {
1323 return Err("an uncreatable root must refuse a placeholder-bearing body".into());
1324 };
1325 assert!(error.starts_with("terminal:"), "{error}");
1326 assert!(
1327 error.contains("provision"),
1328 "the refusal must name the action: {error}"
1329 );
1330 assert!(
1331 error.contains("could not be created"),
1332 "the refusal must carry the creation-failure diagnosis: {error}"
1333 );
1334 assert!(
1335 reached_names(&reached).is_empty(),
1336 "a refused body must not fall through to the worker path"
1337 );
1338 Ok(())
1339 }
1340
1341 #[tokio::test(flavor = "multi_thread")]
1342 async fn a_body_without_the_placeholder_is_untouched_by_resolution_failure() -> TestResult {
1343 let (decorated, _reached, _transcript) = dispatcher_with_root(
1344 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
1345 command: "echo {{greeting}}".to_owned(),
1346 }),
1347 Ok("unused".to_owned()),
1348 WorkspaceRoot::from_resolution(Err(WorkspaceRootError::Unresolvable {
1349 reason: "cannot resolve Aion home: set AION_HOME or HOME".to_owned(),
1350 })),
1351 );
1352 let handle = tokio::task::spawn_blocking(move || {
1353 decorated.dispatch(request("greet", "{\"greeting\":\"still served\"}"))
1354 });
1355 let result = handle.await?;
1356 let encoded = result.map_err(|error| format!("declared command failed: {error}"))?;
1357 let outcome: serde_json::Value = serde_json::from_str(&encoded)?;
1358 assert_eq!(outcome["stdout"], "still served");
1359 Ok(())
1360 }
1361
1362 #[tokio::test(flavor = "multi_thread")]
1363 async fn an_unreadable_catalog_delegates_to_the_worker_path() -> TestResult {
1364 let (decorated, reached) = dispatcher(
1365 DeclaredBodyLookup::Unreadable("catalog offline".to_owned()),
1366 Ok("\"served anyway\"".to_owned()),
1367 );
1368 let handle =
1369 tokio::task::spawn_blocking(move || decorated.dispatch(request("resilient", "{}")));
1370 let result = handle.await?;
1371 assert_eq!(result, Ok("\"served anyway\"".to_owned()));
1372 assert_eq!(reached_names(&reached), vec!["resilient".to_owned()]);
1373 Ok(())
1374 }
1375
1376 #[test]
1377 fn non_object_input_is_refused_terminally_by_shape() {
1378 for (input, kind) in [
1379 ("[1,2]", "an array"),
1380 ("\"text\"", "a string"),
1381 ("3", "a number"),
1382 ("null", "null"),
1383 ("true", "a boolean"),
1384 ] {
1385 let Err(error) = decode_arguments(input) else {
1386 unreachable_refusal(input);
1387 return;
1388 };
1389 assert!(error.starts_with("terminal:"), "{error}");
1390 assert!(error.contains(kind), "{error} must name {kind}");
1391 }
1392 }
1393
1394 fn unreachable_refusal(input: &str) {
1396 assert!(
1397 input.is_empty(),
1398 "input `{input}` must have been refused by shape"
1399 );
1400 }
1401
1402 #[tokio::test(flavor = "multi_thread")]
1410 async fn the_reader_is_asked_about_the_run_that_is_dispatching() -> TestResult {
1411 let seen = Arc::new(Mutex::new(Vec::new()));
1412 let bodies = DeclaredBodySource::default();
1413 bodies.install(Arc::new(RecordingBodies {
1414 seen: Arc::clone(&seen),
1415 }));
1416 let reached = Arc::new(Mutex::new(Vec::new()));
1417 let decorated = DeclaredCommandDispatcher::new(
1418 Arc::new(RecordingInner {
1419 reached: Arc::clone(&reached),
1420 reply: Ok("\"worker-served\"".to_owned()),
1421 }),
1422 bodies,
1423 DeclaredCommandAttempts::new(crate::shutdown::DrainState::default()),
1424 tokio::runtime::Handle::current(),
1425 WorkspaceRoot::from_resolution(Ok(std::env::temp_dir())),
1426 ActivityEventPublisher::new(
1427 Arc::new(aion_store::InMemoryObservabilityStore::default()),
1428 TRANSCRIPT_CAPACITY,
1429 crate::activity_publisher::TranscriptBatchPolicy::UNBATCHED,
1430 ),
1431 );
1432
1433 let dispatch = request("plain", "{}");
1434 let expected = (dispatch.workflow_id.clone(), dispatch.run_id.clone());
1435 let handle = tokio::task::spawn_blocking(move || decorated.dispatch(dispatch));
1436 handle
1437 .await?
1438 .map_err(|error| format!("dispatch failed: {error}"))?;
1439
1440 let observed = match seen.lock() {
1441 Ok(observed) => observed.clone(),
1442 Err(poisoned) => poisoned.into_inner().clone(),
1443 };
1444 assert_eq!(
1445 observed,
1446 vec![expected],
1447 "the body reader must be asked about the dispatching run itself"
1448 );
1449 Ok(())
1450 }
1451}