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::PolicyRefused => "policy_refused",
484 aion_worker::Classification::Terminal => "terminal",
485 };
486 Err(format!("{prefix}:{}", failure.message()))
487 }
488 }
489}
490
491#[derive(Debug)]
493enum AttemptEnd {
494 Ran(Result<aion_worker::shell::ShellOutcome, aion_worker::ActivityFailure>),
497 Expired {
500 bound: std::time::Duration,
502 ran_anyway: Option<i32>,
506 },
507}
508
509async fn run_bounded(
533 action: &ShellAction,
534 arguments: &BTreeMap<String, serde_json::Value>,
535 context: &aion_worker::ActivityContext,
536 cancellation: &aion_worker::ActivityCancellationHandle,
537 bound: Option<std::time::Duration>,
538) -> AttemptEnd {
539 let run = action.run(arguments, context);
540 let Some(bound) = bound else {
541 return AttemptEnd::Ran(run.await);
542 };
543 tokio::pin!(run);
544 match tokio::time::timeout(bound, &mut run).await {
545 Ok(outcome) => AttemptEnd::Ran(outcome),
546 Err(_elapsed) => {
547 cancellation.cancel();
548 AttemptEnd::Expired {
549 bound,
550 ran_anyway: run.await.ok().map(|outcome| outcome.exit_code),
551 }
552 }
553 }
554}
555
556impl std::fmt::Debug for DeclaredCommandDispatcher {
557 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
558 formatter
559 .debug_struct("DeclaredCommandDispatcher")
560 .field("bodies", &self.bodies)
561 .finish_non_exhaustive()
562 }
563}
564
565impl ActivityDispatcher for DeclaredCommandDispatcher {
566 fn dispatch(&self, request: ActivityDispatch) -> Result<String, String> {
567 let run = DispatchingRun {
568 workflow_id: &request.workflow_id,
569 run_id: &request.run_id,
570 };
571 match self
572 .bodies
573 .body_for(&request.task_queue, &request.name, run)
574 {
575 DeclaredBodyLookup::None => self.inner.dispatch(request),
576 DeclaredBodyLookup::Unreadable(reason) => {
577 tracing::error!(
581 operation = "declared_command_dispatch",
582 workflow_id = %request.workflow_id,
583 activity_name = %request.name,
584 task_queue = %request.task_queue,
585 %reason,
586 "declared-body catalog read failed; delegating to the worker path"
587 );
588 self.inner.dispatch(request)
589 }
590 DeclaredBodyLookup::Ambiguous { declaring } => Err(ambiguous_body_refusal(
591 &request.name,
592 &request.task_queue,
593 &declaring,
594 )),
595 DeclaredBodyLookup::Declared(ActionBodyContract::Run { command }) => {
596 self.run_declared_command(&request, &command)
597 }
598 }
599 }
600}
601
602fn decode_arguments(input: &str) -> Result<BTreeMap<String, serde_json::Value>, String> {
609 let value: serde_json::Value = serde_json::from_str(input)
610 .map_err(|error| format!("terminal:declared command input is not valid JSON: {error}"))?;
611 match value {
612 serde_json::Value::Object(members) => Ok(members.into_iter().collect()),
613 other => Err(format!(
614 "terminal:declared command input must be a JSON object binding the action's \
615 parameters by name; got {}",
616 json_kind(&other)
617 )),
618 }
619}
620
621const fn json_kind(value: &serde_json::Value) -> &'static str {
623 match value {
624 serde_json::Value::Null => "null",
625 serde_json::Value::Bool(_) => "a boolean",
626 serde_json::Value::Number(_) => "a number",
627 serde_json::Value::String(_) => "a string",
628 serde_json::Value::Array(_) => "an array",
629 serde_json::Value::Object(_) => "an object",
630 }
631}
632
633#[cfg(test)]
638#[path = "declared_body_containment_tests.rs"]
639mod declared_body_containment_tests;
640
641#[cfg(test)]
642mod tests {
643 use std::collections::BTreeMap;
644 use std::sync::{Arc, Mutex};
645
646 use aion::{ActivityDispatch, ActivityDispatcher};
647 use aion_core::{ActivityId, RunId, WorkflowId};
648 use aion_package::ActionBodyContract;
649
650 use aion_core::ActivityEventKind;
651 use aion_store::ActivityStreamKey;
652
653 use super::super::workspace_root::{WorkspaceRoot, WorkspaceRootError};
654 use super::{
655 ActivityEventPublisher, DeclaredBodies, DeclaredBodyLookup, DeclaredBodySource,
656 DeclaredCommandAttempts, DeclaredCommandDispatcher, DeclaringVersion, DispatchingRun,
657 decode_arguments,
658 };
659
660 pub(super) type TestResult = Result<(), Box<dyn std::error::Error>>;
664
665 struct RecordingInner {
667 reached: Arc<Mutex<Vec<String>>>,
668 reply: Result<String, String>,
669 }
670
671 impl ActivityDispatcher for RecordingInner {
672 fn dispatch(&self, request: ActivityDispatch) -> Result<String, String> {
673 match self.reached.lock() {
674 Ok(mut names) => names.push(request.name),
675 Err(poisoned) => poisoned.into_inner().push(request.name),
676 }
677 self.reply.clone()
678 }
679 }
680
681 struct FixedBodies {
682 lookup: DeclaredBodyLookup,
683 }
684
685 impl DeclaredBodies for FixedBodies {
686 fn body_for(
687 &self,
688 _task_queue: &str,
689 _action: &str,
690 _run: DispatchingRun<'_>,
691 ) -> DeclaredBodyLookup {
692 self.lookup.clone()
693 }
694 }
695
696 struct RecordingBodies {
703 seen: Arc<Mutex<Vec<(WorkflowId, RunId)>>>,
704 }
705
706 impl DeclaredBodies for RecordingBodies {
707 fn body_for(
708 &self,
709 _task_queue: &str,
710 _action: &str,
711 run: DispatchingRun<'_>,
712 ) -> DeclaredBodyLookup {
713 let observed = (run.workflow_id.clone(), run.run_id.clone());
714 match self.seen.lock() {
715 Ok(mut seen) => seen.push(observed),
716 Err(poisoned) => poisoned.into_inner().push(observed),
717 }
718 DeclaredBodyLookup::None
719 }
720 }
721
722 pub(super) fn request(name: &str, input: &str) -> ActivityDispatch {
723 ActivityDispatch {
724 namespace: "default".to_owned(),
725 task_queue: "shell".to_owned(),
726 node: None,
727 workflow_id: WorkflowId::new_v4(),
728 run_id: RunId::new_v4(),
729 activity_id: ActivityId::from_sequence_position(1),
730 name: name.to_owned(),
731 input: input.to_owned(),
732 config: "{}".to_owned(),
733 attempt: 1,
734 labels: BTreeMap::new(),
735 advisory: false,
736 }
737 }
738
739 fn dispatcher(
740 lookup: DeclaredBodyLookup,
741 reply: Result<String, String>,
742 ) -> (DeclaredCommandDispatcher, Arc<Mutex<Vec<String>>>) {
743 let (decorated, reached, _transcript) = dispatcher_with_root(
747 lookup,
748 reply,
749 WorkspaceRoot::from_resolution(Ok(std::env::temp_dir())),
750 );
751 (decorated, reached)
752 }
753
754 const TRANSCRIPT_CAPACITY: std::num::NonZeroUsize = match std::num::NonZeroUsize::new(64) {
758 Some(capacity) => capacity,
759 None => std::num::NonZeroUsize::MIN,
760 };
761
762 pub(super) fn dispatcher_with_root(
768 lookup: DeclaredBodyLookup,
769 reply: Result<String, String>,
770 workspace_root: WorkspaceRoot,
771 ) -> (
772 DeclaredCommandDispatcher,
773 Arc<Mutex<Vec<String>>>,
774 ActivityEventPublisher,
775 ) {
776 dispatcher_with_attempts(
777 lookup,
778 reply,
779 workspace_root,
780 DeclaredCommandAttempts::new(),
781 )
782 }
783
784 pub(super) fn dispatcher_with_attempts(
785 lookup: DeclaredBodyLookup,
786 reply: Result<String, String>,
787 workspace_root: WorkspaceRoot,
788 attempts: DeclaredCommandAttempts,
789 ) -> (
790 DeclaredCommandDispatcher,
791 Arc<Mutex<Vec<String>>>,
792 ActivityEventPublisher,
793 ) {
794 let reached = Arc::new(Mutex::new(Vec::new()));
795 let inner = RecordingInner {
796 reached: Arc::clone(&reached),
797 reply,
798 };
799 let bodies = DeclaredBodySource::default();
800 bodies.install(Arc::new(FixedBodies { lookup }));
801 let store: Arc<dyn aion_store::ObservabilityStore> =
802 Arc::new(aion_store::InMemoryObservabilityStore::default());
803 let transcript = ActivityEventPublisher::new(
804 store,
805 TRANSCRIPT_CAPACITY,
806 crate::activity_publisher::TranscriptBatchPolicy::UNBATCHED,
807 );
808 let decorated = DeclaredCommandDispatcher::new(
809 Arc::new(inner),
810 bodies,
811 attempts,
812 tokio::runtime::Handle::current(),
813 workspace_root,
814 transcript.clone(),
815 );
816 (decorated, reached, transcript)
817 }
818
819 pub(super) fn reached_names(reached: &Arc<Mutex<Vec<String>>>) -> Vec<String> {
820 match reached.lock() {
821 Ok(names) => names.clone(),
822 Err(poisoned) => poisoned.into_inner().clone(),
823 }
824 }
825
826 #[tokio::test(flavor = "multi_thread")]
827 async fn a_bodiless_action_is_delegated_untouched() -> TestResult {
828 let (decorated, reached) =
829 dispatcher(DeclaredBodyLookup::None, Ok("\"worker-served\"".to_owned()));
830 let handle =
831 tokio::task::spawn_blocking(move || decorated.dispatch(request("plain", "{}")));
832 let result = handle.await?;
833 assert_eq!(result, Ok("\"worker-served\"".to_owned()));
834 assert_eq!(reached_names(&reached), vec!["plain".to_owned()]);
835 Ok(())
836 }
837
838 #[tokio::test(flavor = "multi_thread")]
839 async fn a_declared_body_executes_without_touching_the_worker_path() -> TestResult {
840 let (decorated, reached) = dispatcher(
841 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
842 command: "echo $greeting".to_owned(),
843 }),
844 Err("terminal:the worker path must never be reached".to_owned()),
845 );
846 let handle = tokio::task::spawn_blocking(move || {
847 decorated.dispatch(request(
848 "greet",
849 "{\"greeting\":\"hello from the contract\"}",
850 ))
851 });
852 let result = handle.await?;
853 let encoded = result.map_err(|error| format!("declared command failed: {error}"))?;
854 let outcome: serde_json::Value = serde_json::from_str(&encoded)?;
855 assert_eq!(outcome["stdout"], "hello from the contract");
856 assert_eq!(outcome["exit_code"], 0);
857 assert!(
858 reached_names(&reached).is_empty(),
859 "the worker path must not be consulted for a bodied action"
860 );
861 Ok(())
862 }
863
864 #[tokio::test(flavor = "multi_thread")]
873 async fn a_declared_body_publishes_its_output_onto_the_transcript() -> TestResult {
874 let (decorated, reached, transcript) = dispatcher_with_root(
875 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
876 command: "sh -c 'echo one; echo two; echo warned >&2'".to_owned(),
877 }),
878 Err("terminal:the worker path must never be reached".to_owned()),
879 WorkspaceRoot::from_resolution(Ok(std::env::temp_dir())),
880 );
881 let dispatch = request("noisy", "{}");
882 let key = ActivityStreamKey::new(
883 dispatch.workflow_id.clone(),
884 dispatch.run_id.clone(),
885 dispatch.activity_id.clone(),
886 dispatch.attempt,
887 );
888
889 let handle = tokio::task::spawn_blocking(move || decorated.dispatch(dispatch));
890 let encoded = handle
891 .await?
892 .map_err(|error| format!("declared command failed: {error}"))?;
893
894 let outcome: serde_json::Value = serde_json::from_str(&encoded)?;
896 assert_eq!(outcome["stdout"], "one\ntwo");
897 assert_eq!(outcome["stderr"], "warned");
898 assert!(reached_names(&reached).is_empty());
899
900 let retained = transcript.replay_from(&key, 0).await?;
902 let lines = retained
903 .iter()
904 .map(|record| match &record.event.kind {
905 ActivityEventKind::Message { text, .. } => {
906 (record.event.agent_role.clone(), text.clone())
907 }
908 other => (record.event.agent_role.clone(), format!("{other:?}")),
909 })
910 .collect::<Vec<_>>();
911 assert!(
912 lines.contains(&("command stdout".to_owned(), "one".to_owned()))
913 && lines.contains(&("command stdout".to_owned(), "two".to_owned())),
914 "each stdout line must be its own transcript event: {lines:?}"
915 );
916 assert!(
917 lines.contains(&("command stderr".to_owned(), "warned".to_owned())),
918 "stderr must be on the transcript, labelled by its stream: {lines:?}"
919 );
920 let sequences = retained
922 .iter()
923 .map(|record| record.store_seq)
924 .collect::<Vec<_>>();
925 assert_eq!(
926 sequences,
927 (0..u64::try_from(retained.len())?).collect::<Vec<_>>(),
928 "the sequencer assigns a gap-free durable order"
929 );
930 Ok(())
931 }
932
933 #[tokio::test(flavor = "multi_thread")]
934 async fn a_failing_declared_command_reports_retryable_with_its_stderr() -> TestResult {
935 let (decorated, _reached) = dispatcher(
936 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
937 command: "sh -c 'echo boom >&2; exit 7'".to_owned(),
938 }),
939 Ok("unused".to_owned()),
940 );
941 let handle =
942 tokio::task::spawn_blocking(move || decorated.dispatch(request("fails", "{}")));
943 let Err(error) = handle.await? else {
944 return Err("a non-zero exit must fail the dispatch".into());
945 };
946 assert!(
947 error.starts_with("retryable:"),
948 "a non-zero exit is retryable by default: {error}"
949 );
950 assert!(
951 error.contains("boom"),
952 "stderr must ride the failure: {error}"
953 );
954 Ok(())
955 }
956
957 #[test]
967 fn the_printed_hash_parses_back_as_a_content_hash() -> TestResult {
968 let version = aion_package::ContentHash::from_bytes([0x5a; 32]);
969 let routed = aion_package::ContentHash::from_bytes([0xa5; 32]);
970 let refusal = super::ambiguous_body_refusal(
971 "find_repositories",
972 "local",
973 &[
974 DeclaringVersion {
975 content_hash: version.to_string(),
976 workflow_types: vec!["sweeper".to_owned()],
977 route_active: false,
978 body: 0,
979 },
980 DeclaringVersion {
981 content_hash: routed.to_string(),
982 workflow_types: vec!["sweeper".to_owned()],
983 route_active: true,
984 body: 1,
985 },
986 ],
987 );
988 let Some(command) = refusal.split("`aion unload sweeper ").nth(1) else {
989 return Err(format!("no unload command in the refusal: {refusal}").into());
990 };
991 let Some(printed) = command.split('`').next() else {
992 return Err(format!("the unload command is unterminated: {refusal}").into());
993 };
994 let parsed: aion_package::ContentHash = printed.parse()?;
995 assert_eq!(
996 parsed, version,
997 "the printed hash must round-trip to the version it names"
998 );
999 Ok(())
1000 }
1001
1002 #[tokio::test(flavor = "multi_thread")]
1003 async fn ambiguous_bodies_refuse_terminally_by_name() -> TestResult {
1004 let superseded = "1111111111111111111111111111111111111111111111111111111111111111";
1005 let routed = "2222222222222222222222222222222222222222222222222222222222222222";
1006 let (decorated, reached) = dispatcher(
1007 DeclaredBodyLookup::Ambiguous {
1008 declaring: vec![
1009 DeclaringVersion {
1010 content_hash: superseded.to_owned(),
1011 workflow_types: vec!["sweeper".to_owned()],
1012 route_active: false,
1013 body: 0,
1014 },
1015 DeclaringVersion {
1016 content_hash: routed.to_owned(),
1017 workflow_types: vec!["sweeper".to_owned()],
1018 route_active: true,
1019 body: 1,
1020 },
1021 ],
1022 },
1023 Ok(String::new()),
1024 );
1025 let handle = tokio::task::spawn_blocking(move || decorated.dispatch(request("torn", "{}")));
1026 let Err(error) = handle.await? else {
1027 return Err("ambiguous bodies must refuse".into());
1028 };
1029 assert!(error.starts_with("terminal:"), "{error}");
1030 assert!(error.contains("torn"), "{error}");
1031 assert!(
1034 error.contains(&format!("`aion unload sweeper {superseded}`")),
1035 "the dispatch refusal must name the version to retire: {error}"
1036 );
1037 assert!(reached_names(&reached).is_empty());
1038 Ok(())
1039 }
1040
1041 #[tokio::test(flavor = "multi_thread")]
1042 async fn a_placeholder_bearing_body_executes_with_the_expanded_root() -> TestResult {
1043 let scratch = tempfile::tempdir()?;
1044 let root = scratch.path().join("clones");
1045 let root_text = root.to_string_lossy().into_owned();
1046 let (decorated, reached, _transcript) = dispatcher_with_root(
1047 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
1048 command: "echo {workspace_root}".to_owned(),
1049 }),
1050 Err("terminal:the worker path must never be reached".to_owned()),
1051 WorkspaceRoot::from_resolution(Ok(root.clone())),
1052 );
1053 let handle =
1054 tokio::task::spawn_blocking(move || decorated.dispatch(request("provision", "{}")));
1055 let result = handle.await?;
1056 let encoded = result.map_err(|error| format!("declared command failed: {error}"))?;
1057 let outcome: serde_json::Value = serde_json::from_str(&encoded)?;
1058 assert_eq!(
1059 outcome["stdout"], root_text,
1060 "the command must observe the server-resolved root as its argv word"
1061 );
1062 assert_eq!(outcome["exit_code"], 0);
1063 assert!(
1064 root.is_dir(),
1065 "dispatching a placeholder-bearing body must create the missing root"
1066 );
1067 assert!(reached_names(&reached).is_empty());
1068 Ok(())
1069 }
1070
1071 #[tokio::test(flavor = "multi_thread")]
1072 async fn a_placeholder_bearing_body_refuses_terminally_when_the_root_is_unresolved()
1073 -> TestResult {
1074 let (decorated, reached, _transcript) = dispatcher_with_root(
1075 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
1076 command: "echo {workspace_root}".to_owned(),
1077 }),
1078 Ok("unused".to_owned()),
1079 WorkspaceRoot::from_resolution(Err(WorkspaceRootError::Unresolvable {
1080 reason: "cannot resolve Aion home: set AION_HOME or HOME".to_owned(),
1081 })),
1082 );
1083 let handle =
1084 tokio::task::spawn_blocking(move || decorated.dispatch(request("provision", "{}")));
1085 let Err(error) = handle.await? else {
1086 return Err("an unresolved root must refuse a placeholder-bearing body".into());
1087 };
1088 assert!(error.starts_with("terminal:"), "{error}");
1089 assert!(
1090 error.contains("provision"),
1091 "the refusal must name the action: {error}"
1092 );
1093 assert!(
1094 error.contains("cannot resolve Aion home"),
1095 "the refusal must carry the resolution failure's reason: {error}"
1096 );
1097 assert!(
1098 reached_names(&reached).is_empty(),
1099 "a refused body must not fall through to the worker path"
1100 );
1101 Ok(())
1102 }
1103
1104 #[tokio::test(flavor = "multi_thread")]
1105 async fn a_shape_changing_root_refuses_terminally_naming_the_action() -> TestResult {
1106 let (decorated, reached, _transcript) = dispatcher_with_root(
1108 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
1109 command: "echo {workspace_root}".to_owned(),
1110 }),
1111 Ok("unused".to_owned()),
1112 WorkspaceRoot::from_resolution(Ok(std::path::PathBuf::from("/absolute/with$dollar"))),
1113 );
1114 let handle =
1115 tokio::task::spawn_blocking(move || decorated.dispatch(request("provision", "{}")));
1116 let Err(error) = handle.await? else {
1117 return Err("a shape-changing root must refuse a placeholder-bearing body".into());
1118 };
1119 assert!(error.starts_with("terminal:"), "{error}");
1120 assert!(
1121 error.contains("provision"),
1122 "the refusal must name the action: {error}"
1123 );
1124 assert!(
1125 error.contains("would change the parsed shape"),
1126 "the refusal must carry the shape-changing diagnosis: {error}"
1127 );
1128 assert!(
1129 reached_names(&reached).is_empty(),
1130 "a refused body must not fall through to the worker path"
1131 );
1132 Ok(())
1133 }
1134
1135 #[tokio::test(flavor = "multi_thread")]
1136 async fn an_uncreatable_root_refuses_terminally_naming_the_action() -> TestResult {
1137 let scratch = tempfile::tempdir()?;
1139 let file = scratch.path().join("occupied");
1140 std::fs::write(&file, b"not a directory")?;
1141 let (decorated, reached, _transcript) = dispatcher_with_root(
1142 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
1143 command: "echo {workspace_root}".to_owned(),
1144 }),
1145 Ok("unused".to_owned()),
1146 WorkspaceRoot::from_resolution(Ok(file.join("clones"))),
1147 );
1148 let handle =
1149 tokio::task::spawn_blocking(move || decorated.dispatch(request("provision", "{}")));
1150 let Err(error) = handle.await? else {
1151 return Err("an uncreatable root must refuse a placeholder-bearing body".into());
1152 };
1153 assert!(error.starts_with("terminal:"), "{error}");
1154 assert!(
1155 error.contains("provision"),
1156 "the refusal must name the action: {error}"
1157 );
1158 assert!(
1159 error.contains("could not be created"),
1160 "the refusal must carry the creation-failure diagnosis: {error}"
1161 );
1162 assert!(
1163 reached_names(&reached).is_empty(),
1164 "a refused body must not fall through to the worker path"
1165 );
1166 Ok(())
1167 }
1168
1169 #[tokio::test(flavor = "multi_thread")]
1170 async fn a_body_without_the_placeholder_is_untouched_by_resolution_failure() -> TestResult {
1171 let (decorated, _reached, _transcript) = dispatcher_with_root(
1172 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
1173 command: "echo $greeting".to_owned(),
1174 }),
1175 Ok("unused".to_owned()),
1176 WorkspaceRoot::from_resolution(Err(WorkspaceRootError::Unresolvable {
1177 reason: "cannot resolve Aion home: set AION_HOME or HOME".to_owned(),
1178 })),
1179 );
1180 let handle = tokio::task::spawn_blocking(move || {
1181 decorated.dispatch(request("greet", "{\"greeting\":\"still served\"}"))
1182 });
1183 let result = handle.await?;
1184 let encoded = result.map_err(|error| format!("declared command failed: {error}"))?;
1185 let outcome: serde_json::Value = serde_json::from_str(&encoded)?;
1186 assert_eq!(outcome["stdout"], "still served");
1187 Ok(())
1188 }
1189
1190 #[tokio::test(flavor = "multi_thread")]
1191 async fn an_unreadable_catalog_delegates_to_the_worker_path() -> TestResult {
1192 let (decorated, reached) = dispatcher(
1193 DeclaredBodyLookup::Unreadable("catalog offline".to_owned()),
1194 Ok("\"served anyway\"".to_owned()),
1195 );
1196 let handle =
1197 tokio::task::spawn_blocking(move || decorated.dispatch(request("resilient", "{}")));
1198 let result = handle.await?;
1199 assert_eq!(result, Ok("\"served anyway\"".to_owned()));
1200 assert_eq!(reached_names(&reached), vec!["resilient".to_owned()]);
1201 Ok(())
1202 }
1203
1204 #[test]
1205 fn non_object_input_is_refused_terminally_by_shape() {
1206 for (input, kind) in [
1207 ("[1,2]", "an array"),
1208 ("\"text\"", "a string"),
1209 ("3", "a number"),
1210 ("null", "null"),
1211 ("true", "a boolean"),
1212 ] {
1213 let Err(error) = decode_arguments(input) else {
1214 unreachable_refusal(input);
1215 return;
1216 };
1217 assert!(error.starts_with("terminal:"), "{error}");
1218 assert!(error.contains(kind), "{error} must name {kind}");
1219 }
1220 }
1221
1222 fn unreachable_refusal(input: &str) {
1224 assert!(
1225 input.is_empty(),
1226 "input `{input}` must have been refused by shape"
1227 );
1228 }
1229
1230 #[tokio::test(flavor = "multi_thread")]
1238 async fn the_reader_is_asked_about_the_run_that_is_dispatching() -> TestResult {
1239 let seen = Arc::new(Mutex::new(Vec::new()));
1240 let bodies = DeclaredBodySource::default();
1241 bodies.install(Arc::new(RecordingBodies {
1242 seen: Arc::clone(&seen),
1243 }));
1244 let reached = Arc::new(Mutex::new(Vec::new()));
1245 let decorated = DeclaredCommandDispatcher::new(
1246 Arc::new(RecordingInner {
1247 reached: Arc::clone(&reached),
1248 reply: Ok("\"worker-served\"".to_owned()),
1249 }),
1250 bodies,
1251 DeclaredCommandAttempts::new(),
1252 tokio::runtime::Handle::current(),
1253 WorkspaceRoot::from_resolution(Ok(std::env::temp_dir())),
1254 ActivityEventPublisher::new(
1255 Arc::new(aion_store::InMemoryObservabilityStore::default()),
1256 TRANSCRIPT_CAPACITY,
1257 crate::activity_publisher::TranscriptBatchPolicy::UNBATCHED,
1258 ),
1259 );
1260
1261 let dispatch = request("plain", "{}");
1262 let expected = (dispatch.workflow_id.clone(), dispatch.run_id.clone());
1263 let handle = tokio::task::spawn_blocking(move || decorated.dispatch(dispatch));
1264 handle
1265 .await?
1266 .map_err(|error| format!("dispatch failed: {error}"))?;
1267
1268 let observed = match seen.lock() {
1269 Ok(observed) => observed.clone(),
1270 Err(poisoned) => poisoned.into_inner().clone(),
1271 };
1272 assert_eq!(
1273 observed,
1274 vec![expected],
1275 "the body reader must be asked about the dispatching run itself"
1276 );
1277 Ok(())
1278 }
1279}