1use std::collections::BTreeMap;
48use std::sync::{Arc, OnceLock};
49
50use aion::{ActivityDispatch, ActivityDispatcher};
51use aion_package::{ActionBodyContract, ContentHash};
52use aion_worker::shell::ShellAction;
53
54use super::declared_body_ambiguity::{DeclaringVersion, ambiguous_body_refusal};
55use super::declared_body_cancel::DeclaredCommandAttempts;
56use super::declared_body_selection::select_declared_body;
57use super::declared_body_transcript::publish_declared_transcript;
58use super::workspace_root::{WORKSPACE_ROOT_PLACEHOLDER, WorkspaceRoot};
59use crate::activity_publisher::ActivityEventPublisher;
60
61#[derive(Clone, Debug)]
63pub enum DeclaredBodyLookup {
64 None,
67 Declared(ActionBodyContract),
70 Ambiguous {
74 declaring: Vec<DeclaringVersion>,
79 },
80 Unreadable(String),
83}
84
85#[derive(Clone, Copy, Debug)]
91pub struct DispatchingRun<'a> {
92 pub workflow_id: &'a aion_core::WorkflowId,
94 pub run_id: &'a aion_core::RunId,
96}
97
98pub trait DeclaredBodies: Send + Sync {
100 fn body_for(
103 &self,
104 task_queue: &str,
105 action: &str,
106 run: DispatchingRun<'_>,
107 ) -> DeclaredBodyLookup;
108}
109
110#[derive(Clone, Default)]
117pub struct DeclaredBodySource {
118 inner: Arc<OnceLock<Arc<dyn DeclaredBodies>>>,
119}
120
121impl std::fmt::Debug for DeclaredBodySource {
122 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123 formatter
124 .debug_struct("DeclaredBodySource")
125 .field("installed", &self.inner.get().is_some())
126 .finish()
127 }
128}
129
130impl DeclaredBodySource {
131 pub fn install(&self, source: Arc<dyn DeclaredBodies>) {
134 if self.inner.set(source).is_err() {
135 tracing::warn!("declared body source already installed; ignoring duplicate set");
136 }
137 }
138
139 #[must_use]
154 pub fn body_for(
155 &self,
156 task_queue: &str,
157 action: &str,
158 run: DispatchingRun<'_>,
159 ) -> DeclaredBodyLookup {
160 self.inner.get().map_or_else(
161 || {
162 tracing::error!(
163 operation = "declared_command_dispatch",
164 task_queue,
165 action,
166 workflow_id = %run.workflow_id,
167 run_id = %run.run_id,
168 "declared body source consulted before it was installed; the dispatch \
169 falls through to the worker path and will park if the queue's only \
170 service is its declared bodies (#266 boot-ordering defect)"
171 );
172 DeclaredBodyLookup::None
173 },
174 |source| source.body_for(task_queue, action, run),
175 )
176 }
177}
178
179pub struct EngineDeclaredBodies {
181 engine: Arc<aion::Engine>,
182}
183
184impl EngineDeclaredBodies {
185 #[must_use]
187 pub const fn new(engine: Arc<aion::Engine>) -> Self {
188 Self { engine }
189 }
190}
191
192impl std::fmt::Debug for EngineDeclaredBodies {
193 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
194 formatter.write_str("EngineDeclaredBodies")
195 }
196}
197
198impl EngineDeclaredBodies {
199 fn version_of(&self, run: DispatchingRun<'_>) -> Option<ContentHash> {
207 match self.engine.registry().get(run.workflow_id, run.run_id) {
208 Ok(Some(handle)) => Some(handle.loaded_version().clone()),
209 Ok(None) => {
210 tracing::warn!(
211 operation = "declared_command_dispatch",
212 workflow_id = %run.workflow_id,
213 run_id = %run.run_id,
214 "no registry handle for the dispatching run; resolving its body \
215 from the whole queue instead of from its own package version"
216 );
217 None
218 }
219 Err(error) => {
220 tracing::error!(
221 operation = "declared_command_dispatch",
222 workflow_id = %run.workflow_id,
223 run_id = %run.run_id,
224 %error,
225 "registry unreadable while resolving the dispatching run's version; \
226 resolving its body from the whole queue instead"
227 );
228 None
229 }
230 }
231 }
232}
233
234impl DeclaredBodies for EngineDeclaredBodies {
235 fn body_for(
236 &self,
237 task_queue: &str,
238 action: &str,
239 run: DispatchingRun<'_>,
240 ) -> DeclaredBodyLookup {
241 let contracts = match self.engine.worker_contracts_for_queue(task_queue) {
242 Ok(contracts) => contracts,
243 Err(error) => return DeclaredBodyLookup::Unreadable(error.to_string()),
244 };
245 select_declared_body(&contracts, action, self.version_of(run).as_ref())
250 }
251}
252
253pub struct DeclaredCommandDispatcher {
258 inner: Arc<dyn ActivityDispatcher>,
259 bodies: DeclaredBodySource,
260 attempts: DeclaredCommandAttempts,
261 tokio: tokio::runtime::Handle,
262 workspace_root: WorkspaceRoot,
263 transcript: ActivityEventPublisher,
264}
265
266impl DeclaredCommandDispatcher {
267 #[must_use]
278 pub fn new(
279 inner: Arc<dyn ActivityDispatcher>,
280 bodies: DeclaredBodySource,
281 attempts: DeclaredCommandAttempts,
282 tokio: tokio::runtime::Handle,
283 workspace_root: WorkspaceRoot,
284 transcript: ActivityEventPublisher,
285 ) -> Self {
286 Self {
287 inner,
288 bodies,
289 attempts,
290 tokio,
291 workspace_root,
292 transcript,
293 }
294 }
295
296 fn declared_action(
305 &self,
306 request: &ActivityDispatch,
307 command: &str,
308 ) -> Result<ShellAction, String> {
309 let expanded = self.workspace_root.expand(command).map_err(|error| {
310 format!(
311 "terminal:declared body for action `{name}` uses the {placeholder} \
312 placeholder and cannot dispatch: {error}",
313 name = request.name,
314 placeholder = WORKSPACE_ROOT_PLACEHOLDER,
315 )
316 })?;
317 if let Some(expansion) = &expanded {
318 tracing::info!(
319 operation = "declared_command_dispatch",
320 workflow_id = %request.workflow_id,
321 activity_id = %request.activity_id,
322 activity_name = %request.name,
323 task_queue = %request.task_queue,
324 attempt = request.attempt,
325 workspace_root = %expansion.workspace_root,
326 "expanded the workspace-root placeholder in the declared command"
327 );
328 }
329 let command = expanded
330 .as_ref()
331 .map_or(command, |expansion| expansion.command.as_str());
332 ShellAction::new(command).map_err(|error| {
333 format!("terminal:declared command failed to parse at dispatch: {error}")
337 })
338 }
339
340 fn join_cancel_path(
350 &self,
351 request: &ActivityDispatch,
352 cancellation: &aion_worker::ActivityCancellationHandle,
353 ) -> Result<super::DeclaredAttemptRegistration, String> {
354 self.attempts
355 .register(
356 super::AttemptKey::new(
357 request.workflow_id.clone(),
358 request.run_id.clone(),
359 request.activity_id.clone(),
360 request.attempt,
361 ),
362 cancellation.clone(),
363 )
364 .map_err(|error| {
365 format!(
366 "terminal:declared body for action `{name}` cannot dispatch: the attempt \
367 could not join this server's cancel path, and a command a cancelled run \
368 could not stop must not be started: {error}",
369 name = request.name,
370 )
371 })
372 }
373
374 fn run_declared_command(
377 &self,
378 request: &ActivityDispatch,
379 command: &str,
380 ) -> Result<String, String> {
381 let arguments = decode_arguments(&request.input)?;
382 let action = self.declared_action(request, command)?;
383 let (events, drain) = tokio::sync::mpsc::unbounded_channel();
388 let (context, cancellation) = aion_worker::ActivityContext::with_transcript(
389 request.workflow_id.clone(),
390 request.run_id.clone(),
391 request.activity_id.clone(),
392 request.attempt,
393 events,
394 );
395 let registration = self.join_cancel_path(request, &cancellation)?;
396
397 tracing::info!(
398 operation = "declared_command_dispatch",
399 workflow_id = %request.workflow_id,
400 activity_id = %request.activity_id,
401 activity_name = %request.name,
402 task_queue = %request.task_queue,
403 attempt = request.attempt,
404 "executing declared action body at the server"
405 );
406 crate::death_note::breadcrumb(&format!(
410 "declared-action start action={} workflow_id={} run_id={} activity_id={} attempt={}",
411 request.name, request.workflow_id, request.run_id, request.activity_id, request.attempt,
412 ));
413
414 let bound = aion::activity_timeout_from_config(&request.config);
420 let transcript = self.transcript.clone();
421 let ended = self.tokio.block_on(async move {
422 let pump = tokio::spawn(publish_declared_transcript(transcript, drain));
423 let ended = run_bounded(&action, &arguments, &context, &cancellation, bound).await;
424 drop(context);
426 if let Err(error) = pump.await {
427 tracing::warn!(
428 %error,
429 operation = "declared_command_dispatch",
430 "declared command transcript: the publishing task ended abnormally; some \
431 output lines may not have been retained"
432 );
433 }
434 ended
435 });
436 drop(registration);
440
441 encode_end(request, ended)
442 }
443}
444
445fn encode_end(request: &ActivityDispatch, ended: AttemptEnd) -> Result<String, String> {
452 let outcome = match ended {
453 AttemptEnd::Ran(outcome) => outcome,
454 AttemptEnd::Expired { bound, ran_anyway } => {
455 if let Some(exit_code) = ran_anyway {
456 tracing::warn!(
462 operation = "declared_command_dispatch",
463 workflow_id = %request.workflow_id,
464 activity_id = %request.activity_id,
465 activity_name = %request.name,
466 attempt = request.attempt,
467 exit_code,
468 bound_ms = bound.as_millis(),
469 "the declared command finished while it was being stopped on its \
470 authored bound; its result is discarded in favour of the timeout"
471 );
472 }
473 return Err(aion::activity_timeout_reason(bound));
474 }
475 };
476
477 match outcome {
478 Ok(result) => serde_json::to_string(&result)
479 .map_err(|error| format!("terminal:declared command result failed to encode: {error}")),
480 Err(failure) => {
481 let prefix = match failure.classification() {
482 aion_worker::Classification::Retryable => "retryable",
483 aion_worker::Classification::Terminal => "terminal",
484 };
485 Err(format!("{prefix}:{}", failure.message()))
486 }
487 }
488}
489
490#[derive(Debug)]
492enum AttemptEnd {
493 Ran(Result<aion_worker::shell::ShellOutcome, aion_worker::ActivityFailure>),
496 Expired {
499 bound: std::time::Duration,
501 ran_anyway: Option<i32>,
505 },
506}
507
508async fn run_bounded(
532 action: &ShellAction,
533 arguments: &BTreeMap<String, serde_json::Value>,
534 context: &aion_worker::ActivityContext,
535 cancellation: &aion_worker::ActivityCancellationHandle,
536 bound: Option<std::time::Duration>,
537) -> AttemptEnd {
538 let run = action.run(arguments, context);
539 let Some(bound) = bound else {
540 return AttemptEnd::Ran(run.await);
541 };
542 tokio::pin!(run);
543 match tokio::time::timeout(bound, &mut run).await {
544 Ok(outcome) => AttemptEnd::Ran(outcome),
545 Err(_elapsed) => {
546 cancellation.cancel();
547 AttemptEnd::Expired {
548 bound,
549 ran_anyway: run.await.ok().map(|outcome| outcome.exit_code),
550 }
551 }
552 }
553}
554
555impl std::fmt::Debug for DeclaredCommandDispatcher {
556 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
557 formatter
558 .debug_struct("DeclaredCommandDispatcher")
559 .field("bodies", &self.bodies)
560 .finish_non_exhaustive()
561 }
562}
563
564impl ActivityDispatcher for DeclaredCommandDispatcher {
565 fn dispatch(&self, request: ActivityDispatch) -> Result<String, String> {
566 let run = DispatchingRun {
567 workflow_id: &request.workflow_id,
568 run_id: &request.run_id,
569 };
570 match self
571 .bodies
572 .body_for(&request.task_queue, &request.name, run)
573 {
574 DeclaredBodyLookup::None => self.inner.dispatch(request),
575 DeclaredBodyLookup::Unreadable(reason) => {
576 tracing::error!(
580 operation = "declared_command_dispatch",
581 workflow_id = %request.workflow_id,
582 activity_name = %request.name,
583 task_queue = %request.task_queue,
584 %reason,
585 "declared-body catalog read failed; delegating to the worker path"
586 );
587 self.inner.dispatch(request)
588 }
589 DeclaredBodyLookup::Ambiguous { declaring } => Err(ambiguous_body_refusal(
590 &request.name,
591 &request.task_queue,
592 &declaring,
593 )),
594 DeclaredBodyLookup::Declared(ActionBodyContract::Run { command }) => {
595 self.run_declared_command(&request, &command)
596 }
597 }
598 }
599}
600
601fn decode_arguments(input: &str) -> Result<BTreeMap<String, serde_json::Value>, String> {
608 let value: serde_json::Value = serde_json::from_str(input)
609 .map_err(|error| format!("terminal:declared command input is not valid JSON: {error}"))?;
610 match value {
611 serde_json::Value::Object(members) => Ok(members.into_iter().collect()),
612 other => Err(format!(
613 "terminal:declared command input must be a JSON object binding the action's \
614 parameters by name; got {}",
615 json_kind(&other)
616 )),
617 }
618}
619
620const fn json_kind(value: &serde_json::Value) -> &'static str {
622 match value {
623 serde_json::Value::Null => "null",
624 serde_json::Value::Bool(_) => "a boolean",
625 serde_json::Value::Number(_) => "a number",
626 serde_json::Value::String(_) => "a string",
627 serde_json::Value::Array(_) => "an array",
628 serde_json::Value::Object(_) => "an object",
629 }
630}
631
632#[cfg(test)]
637#[path = "declared_body_containment_tests.rs"]
638mod declared_body_containment_tests;
639
640#[cfg(test)]
641mod tests {
642 use std::collections::BTreeMap;
643 use std::sync::{Arc, Mutex};
644
645 use aion::{ActivityDispatch, ActivityDispatcher};
646 use aion_core::{ActivityId, RunId, WorkflowId};
647 use aion_package::ActionBodyContract;
648
649 use aion_core::ActivityEventKind;
650 use aion_store::ActivityStreamKey;
651
652 use super::super::workspace_root::{WorkspaceRoot, WorkspaceRootError};
653 use super::{
654 ActivityEventPublisher, DeclaredBodies, DeclaredBodyLookup, DeclaredBodySource,
655 DeclaredCommandAttempts, DeclaredCommandDispatcher, DeclaringVersion, DispatchingRun,
656 decode_arguments,
657 };
658
659 pub(super) type TestResult = Result<(), Box<dyn std::error::Error>>;
663
664 struct RecordingInner {
666 reached: Arc<Mutex<Vec<String>>>,
667 reply: Result<String, String>,
668 }
669
670 impl ActivityDispatcher for RecordingInner {
671 fn dispatch(&self, request: ActivityDispatch) -> Result<String, String> {
672 match self.reached.lock() {
673 Ok(mut names) => names.push(request.name),
674 Err(poisoned) => poisoned.into_inner().push(request.name),
675 }
676 self.reply.clone()
677 }
678 }
679
680 struct FixedBodies {
681 lookup: DeclaredBodyLookup,
682 }
683
684 impl DeclaredBodies for FixedBodies {
685 fn body_for(
686 &self,
687 _task_queue: &str,
688 _action: &str,
689 _run: DispatchingRun<'_>,
690 ) -> DeclaredBodyLookup {
691 self.lookup.clone()
692 }
693 }
694
695 struct RecordingBodies {
702 seen: Arc<Mutex<Vec<(WorkflowId, RunId)>>>,
703 }
704
705 impl DeclaredBodies for RecordingBodies {
706 fn body_for(
707 &self,
708 _task_queue: &str,
709 _action: &str,
710 run: DispatchingRun<'_>,
711 ) -> DeclaredBodyLookup {
712 let observed = (run.workflow_id.clone(), run.run_id.clone());
713 match self.seen.lock() {
714 Ok(mut seen) => seen.push(observed),
715 Err(poisoned) => poisoned.into_inner().push(observed),
716 }
717 DeclaredBodyLookup::None
718 }
719 }
720
721 pub(super) fn request(name: &str, input: &str) -> ActivityDispatch {
722 ActivityDispatch {
723 namespace: "default".to_owned(),
724 task_queue: "shell".to_owned(),
725 node: None,
726 workflow_id: WorkflowId::new_v4(),
727 run_id: RunId::new_v4(),
728 activity_id: ActivityId::from_sequence_position(1),
729 name: name.to_owned(),
730 input: input.to_owned(),
731 config: "{}".to_owned(),
732 attempt: 1,
733 labels: BTreeMap::new(),
734 advisory: false,
735 }
736 }
737
738 fn dispatcher(
739 lookup: DeclaredBodyLookup,
740 reply: Result<String, String>,
741 ) -> (DeclaredCommandDispatcher, Arc<Mutex<Vec<String>>>) {
742 let (decorated, reached, _transcript) = dispatcher_with_root(
746 lookup,
747 reply,
748 WorkspaceRoot::from_resolution(Ok(std::env::temp_dir())),
749 );
750 (decorated, reached)
751 }
752
753 const TRANSCRIPT_CAPACITY: std::num::NonZeroUsize = match std::num::NonZeroUsize::new(64) {
757 Some(capacity) => capacity,
758 None => std::num::NonZeroUsize::MIN,
759 };
760
761 pub(super) fn dispatcher_with_root(
767 lookup: DeclaredBodyLookup,
768 reply: Result<String, String>,
769 workspace_root: WorkspaceRoot,
770 ) -> (
771 DeclaredCommandDispatcher,
772 Arc<Mutex<Vec<String>>>,
773 ActivityEventPublisher,
774 ) {
775 dispatcher_with_attempts(
776 lookup,
777 reply,
778 workspace_root,
779 DeclaredCommandAttempts::new(),
780 )
781 }
782
783 pub(super) fn dispatcher_with_attempts(
784 lookup: DeclaredBodyLookup,
785 reply: Result<String, String>,
786 workspace_root: WorkspaceRoot,
787 attempts: DeclaredCommandAttempts,
788 ) -> (
789 DeclaredCommandDispatcher,
790 Arc<Mutex<Vec<String>>>,
791 ActivityEventPublisher,
792 ) {
793 let reached = Arc::new(Mutex::new(Vec::new()));
794 let inner = RecordingInner {
795 reached: Arc::clone(&reached),
796 reply,
797 };
798 let bodies = DeclaredBodySource::default();
799 bodies.install(Arc::new(FixedBodies { lookup }));
800 let store: Arc<dyn aion_store::ObservabilityStore> =
801 Arc::new(aion_store::InMemoryObservabilityStore::default());
802 let transcript = ActivityEventPublisher::new(
803 store,
804 TRANSCRIPT_CAPACITY,
805 crate::activity_publisher::TranscriptBatchPolicy::UNBATCHED,
806 );
807 let decorated = DeclaredCommandDispatcher::new(
808 Arc::new(inner),
809 bodies,
810 attempts,
811 tokio::runtime::Handle::current(),
812 workspace_root,
813 transcript.clone(),
814 );
815 (decorated, reached, transcript)
816 }
817
818 pub(super) fn reached_names(reached: &Arc<Mutex<Vec<String>>>) -> Vec<String> {
819 match reached.lock() {
820 Ok(names) => names.clone(),
821 Err(poisoned) => poisoned.into_inner().clone(),
822 }
823 }
824
825 #[tokio::test(flavor = "multi_thread")]
826 async fn a_bodiless_action_is_delegated_untouched() -> TestResult {
827 let (decorated, reached) =
828 dispatcher(DeclaredBodyLookup::None, Ok("\"worker-served\"".to_owned()));
829 let handle =
830 tokio::task::spawn_blocking(move || decorated.dispatch(request("plain", "{}")));
831 let result = handle.await?;
832 assert_eq!(result, Ok("\"worker-served\"".to_owned()));
833 assert_eq!(reached_names(&reached), vec!["plain".to_owned()]);
834 Ok(())
835 }
836
837 #[tokio::test(flavor = "multi_thread")]
838 async fn a_declared_body_executes_without_touching_the_worker_path() -> TestResult {
839 let (decorated, reached) = dispatcher(
840 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
841 command: "echo $greeting".to_owned(),
842 }),
843 Err("terminal:the worker path must never be reached".to_owned()),
844 );
845 let handle = tokio::task::spawn_blocking(move || {
846 decorated.dispatch(request(
847 "greet",
848 "{\"greeting\":\"hello from the contract\"}",
849 ))
850 });
851 let result = handle.await?;
852 let encoded = result.map_err(|error| format!("declared command failed: {error}"))?;
853 let outcome: serde_json::Value = serde_json::from_str(&encoded)?;
854 assert_eq!(outcome["stdout"], "hello from the contract");
855 assert_eq!(outcome["exit_code"], 0);
856 assert!(
857 reached_names(&reached).is_empty(),
858 "the worker path must not be consulted for a bodied action"
859 );
860 Ok(())
861 }
862
863 #[tokio::test(flavor = "multi_thread")]
872 async fn a_declared_body_publishes_its_output_onto_the_transcript() -> TestResult {
873 let (decorated, reached, transcript) = dispatcher_with_root(
874 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
875 command: "sh -c 'echo one; echo two; echo warned >&2'".to_owned(),
876 }),
877 Err("terminal:the worker path must never be reached".to_owned()),
878 WorkspaceRoot::from_resolution(Ok(std::env::temp_dir())),
879 );
880 let dispatch = request("noisy", "{}");
881 let key = ActivityStreamKey::new(
882 dispatch.workflow_id.clone(),
883 dispatch.run_id.clone(),
884 dispatch.activity_id.clone(),
885 dispatch.attempt,
886 );
887
888 let handle = tokio::task::spawn_blocking(move || decorated.dispatch(dispatch));
889 let encoded = handle
890 .await?
891 .map_err(|error| format!("declared command failed: {error}"))?;
892
893 let outcome: serde_json::Value = serde_json::from_str(&encoded)?;
895 assert_eq!(outcome["stdout"], "one\ntwo");
896 assert_eq!(outcome["stderr"], "warned");
897 assert!(reached_names(&reached).is_empty());
898
899 let retained = transcript.replay_from(&key, 0).await?;
901 let lines = retained
902 .iter()
903 .map(|record| match &record.event.kind {
904 ActivityEventKind::Message { text, .. } => {
905 (record.event.agent_role.clone(), text.clone())
906 }
907 other => (record.event.agent_role.clone(), format!("{other:?}")),
908 })
909 .collect::<Vec<_>>();
910 assert!(
911 lines.contains(&("command stdout".to_owned(), "one".to_owned()))
912 && lines.contains(&("command stdout".to_owned(), "two".to_owned())),
913 "each stdout line must be its own transcript event: {lines:?}"
914 );
915 assert!(
916 lines.contains(&("command stderr".to_owned(), "warned".to_owned())),
917 "stderr must be on the transcript, labelled by its stream: {lines:?}"
918 );
919 let sequences = retained
921 .iter()
922 .map(|record| record.store_seq)
923 .collect::<Vec<_>>();
924 assert_eq!(
925 sequences,
926 (0..u64::try_from(retained.len())?).collect::<Vec<_>>(),
927 "the sequencer assigns a gap-free durable order"
928 );
929 Ok(())
930 }
931
932 #[tokio::test(flavor = "multi_thread")]
933 async fn a_failing_declared_command_reports_retryable_with_its_stderr() -> TestResult {
934 let (decorated, _reached) = dispatcher(
935 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
936 command: "sh -c 'echo boom >&2; exit 7'".to_owned(),
937 }),
938 Ok("unused".to_owned()),
939 );
940 let handle =
941 tokio::task::spawn_blocking(move || decorated.dispatch(request("fails", "{}")));
942 let Err(error) = handle.await? else {
943 return Err("a non-zero exit must fail the dispatch".into());
944 };
945 assert!(
946 error.starts_with("retryable:"),
947 "a non-zero exit is retryable by default: {error}"
948 );
949 assert!(
950 error.contains("boom"),
951 "stderr must ride the failure: {error}"
952 );
953 Ok(())
954 }
955
956 #[test]
966 fn the_printed_hash_parses_back_as_a_content_hash() -> TestResult {
967 let version = aion_package::ContentHash::from_bytes([0x5a; 32]);
968 let routed = aion_package::ContentHash::from_bytes([0xa5; 32]);
969 let refusal = super::ambiguous_body_refusal(
970 "find_repositories",
971 "local",
972 &[
973 DeclaringVersion {
974 content_hash: version.to_string(),
975 workflow_types: vec!["sweeper".to_owned()],
976 route_active: false,
977 body: 0,
978 },
979 DeclaringVersion {
980 content_hash: routed.to_string(),
981 workflow_types: vec!["sweeper".to_owned()],
982 route_active: true,
983 body: 1,
984 },
985 ],
986 );
987 let Some(command) = refusal.split("`aion unload sweeper ").nth(1) else {
988 return Err(format!("no unload command in the refusal: {refusal}").into());
989 };
990 let Some(printed) = command.split('`').next() else {
991 return Err(format!("the unload command is unterminated: {refusal}").into());
992 };
993 let parsed: aion_package::ContentHash = printed.parse()?;
994 assert_eq!(
995 parsed, version,
996 "the printed hash must round-trip to the version it names"
997 );
998 Ok(())
999 }
1000
1001 #[tokio::test(flavor = "multi_thread")]
1002 async fn ambiguous_bodies_refuse_terminally_by_name() -> TestResult {
1003 let superseded = "1111111111111111111111111111111111111111111111111111111111111111";
1004 let routed = "2222222222222222222222222222222222222222222222222222222222222222";
1005 let (decorated, reached) = dispatcher(
1006 DeclaredBodyLookup::Ambiguous {
1007 declaring: vec![
1008 DeclaringVersion {
1009 content_hash: superseded.to_owned(),
1010 workflow_types: vec!["sweeper".to_owned()],
1011 route_active: false,
1012 body: 0,
1013 },
1014 DeclaringVersion {
1015 content_hash: routed.to_owned(),
1016 workflow_types: vec!["sweeper".to_owned()],
1017 route_active: true,
1018 body: 1,
1019 },
1020 ],
1021 },
1022 Ok(String::new()),
1023 );
1024 let handle = tokio::task::spawn_blocking(move || decorated.dispatch(request("torn", "{}")));
1025 let Err(error) = handle.await? else {
1026 return Err("ambiguous bodies must refuse".into());
1027 };
1028 assert!(error.starts_with("terminal:"), "{error}");
1029 assert!(error.contains("torn"), "{error}");
1030 assert!(
1033 error.contains(&format!("`aion unload sweeper {superseded}`")),
1034 "the dispatch refusal must name the version to retire: {error}"
1035 );
1036 assert!(reached_names(&reached).is_empty());
1037 Ok(())
1038 }
1039
1040 #[tokio::test(flavor = "multi_thread")]
1041 async fn a_placeholder_bearing_body_executes_with_the_expanded_root() -> TestResult {
1042 let scratch = tempfile::tempdir()?;
1043 let root = scratch.path().join("clones");
1044 let root_text = root.to_string_lossy().into_owned();
1045 let (decorated, reached, _transcript) = dispatcher_with_root(
1046 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
1047 command: "echo {workspace_root}".to_owned(),
1048 }),
1049 Err("terminal:the worker path must never be reached".to_owned()),
1050 WorkspaceRoot::from_resolution(Ok(root.clone())),
1051 );
1052 let handle =
1053 tokio::task::spawn_blocking(move || decorated.dispatch(request("provision", "{}")));
1054 let result = handle.await?;
1055 let encoded = result.map_err(|error| format!("declared command failed: {error}"))?;
1056 let outcome: serde_json::Value = serde_json::from_str(&encoded)?;
1057 assert_eq!(
1058 outcome["stdout"], root_text,
1059 "the command must observe the server-resolved root as its argv word"
1060 );
1061 assert_eq!(outcome["exit_code"], 0);
1062 assert!(
1063 root.is_dir(),
1064 "dispatching a placeholder-bearing body must create the missing root"
1065 );
1066 assert!(reached_names(&reached).is_empty());
1067 Ok(())
1068 }
1069
1070 #[tokio::test(flavor = "multi_thread")]
1071 async fn a_placeholder_bearing_body_refuses_terminally_when_the_root_is_unresolved()
1072 -> TestResult {
1073 let (decorated, reached, _transcript) = dispatcher_with_root(
1074 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
1075 command: "echo {workspace_root}".to_owned(),
1076 }),
1077 Ok("unused".to_owned()),
1078 WorkspaceRoot::from_resolution(Err(WorkspaceRootError::Unresolvable {
1079 reason: "cannot resolve Aion home: set AION_HOME or HOME".to_owned(),
1080 })),
1081 );
1082 let handle =
1083 tokio::task::spawn_blocking(move || decorated.dispatch(request("provision", "{}")));
1084 let Err(error) = handle.await? else {
1085 return Err("an unresolved root must refuse a placeholder-bearing body".into());
1086 };
1087 assert!(error.starts_with("terminal:"), "{error}");
1088 assert!(
1089 error.contains("provision"),
1090 "the refusal must name the action: {error}"
1091 );
1092 assert!(
1093 error.contains("cannot resolve Aion home"),
1094 "the refusal must carry the resolution failure's reason: {error}"
1095 );
1096 assert!(
1097 reached_names(&reached).is_empty(),
1098 "a refused body must not fall through to the worker path"
1099 );
1100 Ok(())
1101 }
1102
1103 #[tokio::test(flavor = "multi_thread")]
1104 async fn a_shape_changing_root_refuses_terminally_naming_the_action() -> TestResult {
1105 let (decorated, reached, _transcript) = dispatcher_with_root(
1107 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
1108 command: "echo {workspace_root}".to_owned(),
1109 }),
1110 Ok("unused".to_owned()),
1111 WorkspaceRoot::from_resolution(Ok(std::path::PathBuf::from("/absolute/with$dollar"))),
1112 );
1113 let handle =
1114 tokio::task::spawn_blocking(move || decorated.dispatch(request("provision", "{}")));
1115 let Err(error) = handle.await? else {
1116 return Err("a shape-changing root must refuse a placeholder-bearing body".into());
1117 };
1118 assert!(error.starts_with("terminal:"), "{error}");
1119 assert!(
1120 error.contains("provision"),
1121 "the refusal must name the action: {error}"
1122 );
1123 assert!(
1124 error.contains("would change the parsed shape"),
1125 "the refusal must carry the shape-changing diagnosis: {error}"
1126 );
1127 assert!(
1128 reached_names(&reached).is_empty(),
1129 "a refused body must not fall through to the worker path"
1130 );
1131 Ok(())
1132 }
1133
1134 #[tokio::test(flavor = "multi_thread")]
1135 async fn an_uncreatable_root_refuses_terminally_naming_the_action() -> TestResult {
1136 let scratch = tempfile::tempdir()?;
1138 let file = scratch.path().join("occupied");
1139 std::fs::write(&file, b"not a directory")?;
1140 let (decorated, reached, _transcript) = dispatcher_with_root(
1141 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
1142 command: "echo {workspace_root}".to_owned(),
1143 }),
1144 Ok("unused".to_owned()),
1145 WorkspaceRoot::from_resolution(Ok(file.join("clones"))),
1146 );
1147 let handle =
1148 tokio::task::spawn_blocking(move || decorated.dispatch(request("provision", "{}")));
1149 let Err(error) = handle.await? else {
1150 return Err("an uncreatable root must refuse a placeholder-bearing body".into());
1151 };
1152 assert!(error.starts_with("terminal:"), "{error}");
1153 assert!(
1154 error.contains("provision"),
1155 "the refusal must name the action: {error}"
1156 );
1157 assert!(
1158 error.contains("could not be created"),
1159 "the refusal must carry the creation-failure diagnosis: {error}"
1160 );
1161 assert!(
1162 reached_names(&reached).is_empty(),
1163 "a refused body must not fall through to the worker path"
1164 );
1165 Ok(())
1166 }
1167
1168 #[tokio::test(flavor = "multi_thread")]
1169 async fn a_body_without_the_placeholder_is_untouched_by_resolution_failure() -> TestResult {
1170 let (decorated, _reached, _transcript) = dispatcher_with_root(
1171 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
1172 command: "echo $greeting".to_owned(),
1173 }),
1174 Ok("unused".to_owned()),
1175 WorkspaceRoot::from_resolution(Err(WorkspaceRootError::Unresolvable {
1176 reason: "cannot resolve Aion home: set AION_HOME or HOME".to_owned(),
1177 })),
1178 );
1179 let handle = tokio::task::spawn_blocking(move || {
1180 decorated.dispatch(request("greet", "{\"greeting\":\"still served\"}"))
1181 });
1182 let result = handle.await?;
1183 let encoded = result.map_err(|error| format!("declared command failed: {error}"))?;
1184 let outcome: serde_json::Value = serde_json::from_str(&encoded)?;
1185 assert_eq!(outcome["stdout"], "still served");
1186 Ok(())
1187 }
1188
1189 #[tokio::test(flavor = "multi_thread")]
1190 async fn an_unreadable_catalog_delegates_to_the_worker_path() -> TestResult {
1191 let (decorated, reached) = dispatcher(
1192 DeclaredBodyLookup::Unreadable("catalog offline".to_owned()),
1193 Ok("\"served anyway\"".to_owned()),
1194 );
1195 let handle =
1196 tokio::task::spawn_blocking(move || decorated.dispatch(request("resilient", "{}")));
1197 let result = handle.await?;
1198 assert_eq!(result, Ok("\"served anyway\"".to_owned()));
1199 assert_eq!(reached_names(&reached), vec!["resilient".to_owned()]);
1200 Ok(())
1201 }
1202
1203 #[test]
1204 fn non_object_input_is_refused_terminally_by_shape() {
1205 for (input, kind) in [
1206 ("[1,2]", "an array"),
1207 ("\"text\"", "a string"),
1208 ("3", "a number"),
1209 ("null", "null"),
1210 ("true", "a boolean"),
1211 ] {
1212 let Err(error) = decode_arguments(input) else {
1213 unreachable_refusal(input);
1214 return;
1215 };
1216 assert!(error.starts_with("terminal:"), "{error}");
1217 assert!(error.contains(kind), "{error} must name {kind}");
1218 }
1219 }
1220
1221 fn unreachable_refusal(input: &str) {
1223 assert!(
1224 input.is_empty(),
1225 "input `{input}` must have been refused by shape"
1226 );
1227 }
1228
1229 #[tokio::test(flavor = "multi_thread")]
1237 async fn the_reader_is_asked_about_the_run_that_is_dispatching() -> TestResult {
1238 let seen = Arc::new(Mutex::new(Vec::new()));
1239 let bodies = DeclaredBodySource::default();
1240 bodies.install(Arc::new(RecordingBodies {
1241 seen: Arc::clone(&seen),
1242 }));
1243 let reached = Arc::new(Mutex::new(Vec::new()));
1244 let decorated = DeclaredCommandDispatcher::new(
1245 Arc::new(RecordingInner {
1246 reached: Arc::clone(&reached),
1247 reply: Ok("\"worker-served\"".to_owned()),
1248 }),
1249 bodies,
1250 DeclaredCommandAttempts::new(),
1251 tokio::runtime::Handle::current(),
1252 WorkspaceRoot::from_resolution(Ok(std::env::temp_dir())),
1253 ActivityEventPublisher::new(
1254 Arc::new(aion_store::InMemoryObservabilityStore::default()),
1255 TRANSCRIPT_CAPACITY,
1256 crate::activity_publisher::TranscriptBatchPolicy::UNBATCHED,
1257 ),
1258 );
1259
1260 let dispatch = request("plain", "{}");
1261 let expected = (dispatch.workflow_id.clone(), dispatch.run_id.clone());
1262 let handle = tokio::task::spawn_blocking(move || decorated.dispatch(dispatch));
1263 handle
1264 .await?
1265 .map_err(|error| format!("dispatch failed: {error}"))?;
1266
1267 let observed = match seen.lock() {
1268 Ok(observed) => observed.clone(),
1269 Err(poisoned) => poisoned.into_inner().clone(),
1270 };
1271 assert_eq!(
1272 observed,
1273 vec![expected],
1274 "the body reader must be asked about the dispatching run itself"
1275 );
1276 Ok(())
1277 }
1278}