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| match error {
365 crate::error::ServerError::DrainingRefusedDeclaredAttempt { .. } => {
371 tracing::info!(
372 operation = "declared_command_dispatch",
373 workflow_id = %request.workflow_id,
374 activity_id = %request.activity_id,
375 activity_name = %request.name,
376 task_queue = %request.task_queue,
377 attempt = request.attempt,
378 "declared command parked: this server is draining and starts no new work"
379 );
380 aion::PARKED_ACTIVITY_REASON.to_owned()
381 }
382 other => format!(
383 "terminal:declared body for action `{name}` cannot dispatch: the attempt \
384 could not join this server's cancel path, and a command a cancelled run \
385 could not stop must not be started: {other}",
386 name = request.name,
387 ),
388 })
389 }
390
391 fn run_declared_command(
394 &self,
395 request: &ActivityDispatch,
396 command: &str,
397 ) -> Result<String, String> {
398 let arguments = decode_arguments(&request.input)?;
399 let action = self.declared_action(request, command)?;
400 let (events, drain) = tokio::sync::mpsc::unbounded_channel();
405 let (context, cancellation) = aion_worker::ActivityContext::with_transcript(
406 request.workflow_id.clone(),
407 request.run_id.clone(),
408 request.activity_id.clone(),
409 request.attempt,
410 events,
411 );
412 let registration = self.join_cancel_path(request, &cancellation)?;
413
414 tracing::info!(
415 operation = "declared_command_dispatch",
416 workflow_id = %request.workflow_id,
417 activity_id = %request.activity_id,
418 activity_name = %request.name,
419 task_queue = %request.task_queue,
420 attempt = request.attempt,
421 "executing declared action body at the server"
422 );
423 crate::death_note::breadcrumb(&format!(
427 "declared-action start action={} workflow_id={} run_id={} activity_id={} attempt={}",
428 request.name, request.workflow_id, request.run_id, request.activity_id, request.attempt,
429 ));
430
431 let bound = aion::activity_timeout_from_config(&request.config);
437 let transcript = self.transcript.clone();
438 let ended = self.tokio.block_on(async move {
439 let pump = tokio::spawn(publish_declared_transcript(transcript, drain));
440 let ended = run_bounded(&action, &arguments, &context, &cancellation, bound).await;
441 drop(context);
443 if let Err(error) = pump.await {
444 tracing::warn!(
445 %error,
446 operation = "declared_command_dispatch",
447 "declared command transcript: the publishing task ended abnormally; some \
448 output lines may not have been retained"
449 );
450 }
451 ended
452 });
453 drop(registration);
457
458 encode_end(request, ended)
459 }
460}
461
462fn encode_end(request: &ActivityDispatch, ended: AttemptEnd) -> Result<String, String> {
469 let outcome = match ended {
470 AttemptEnd::Ran(outcome) => outcome,
471 AttemptEnd::Expired { bound, ran_anyway } => {
472 if let Some(exit_code) = ran_anyway {
473 tracing::warn!(
479 operation = "declared_command_dispatch",
480 workflow_id = %request.workflow_id,
481 activity_id = %request.activity_id,
482 activity_name = %request.name,
483 attempt = request.attempt,
484 exit_code,
485 bound_ms = bound.as_millis(),
486 "the declared command finished while it was being stopped on its \
487 authored bound; its result is discarded in favour of the timeout"
488 );
489 }
490 return Err(aion::activity_timeout_reason(bound));
491 }
492 };
493
494 match outcome {
495 Ok(result) => serde_json::to_string(&result)
496 .map_err(|error| format!("terminal:declared command result failed to encode: {error}")),
497 Err(failure) => {
498 let prefix = match failure.classification() {
499 aion_worker::Classification::Retryable => "retryable",
500 aion_worker::Classification::PolicyRefused => "policy_refused",
501 aion_worker::Classification::Terminal => "terminal",
502 };
503 Err(format!("{prefix}:{}", failure.message()))
504 }
505 }
506}
507
508#[derive(Debug)]
510enum AttemptEnd {
511 Ran(Result<aion_worker::shell::ShellOutcome, aion_worker::ActivityFailure>),
514 Expired {
517 bound: std::time::Duration,
519 ran_anyway: Option<i32>,
523 },
524}
525
526async fn run_bounded(
550 action: &ShellAction,
551 arguments: &BTreeMap<String, serde_json::Value>,
552 context: &aion_worker::ActivityContext,
553 cancellation: &aion_worker::ActivityCancellationHandle,
554 bound: Option<std::time::Duration>,
555) -> AttemptEnd {
556 let run = action.run(arguments, context);
557 let Some(bound) = bound else {
558 return AttemptEnd::Ran(run.await);
559 };
560 tokio::pin!(run);
561 match tokio::time::timeout(bound, &mut run).await {
562 Ok(outcome) => AttemptEnd::Ran(outcome),
563 Err(_elapsed) => {
564 cancellation.cancel();
565 AttemptEnd::Expired {
566 bound,
567 ran_anyway: run.await.ok().map(|outcome| outcome.exit_code),
568 }
569 }
570 }
571}
572
573impl std::fmt::Debug for DeclaredCommandDispatcher {
574 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
575 formatter
576 .debug_struct("DeclaredCommandDispatcher")
577 .field("bodies", &self.bodies)
578 .finish_non_exhaustive()
579 }
580}
581
582impl ActivityDispatcher for DeclaredCommandDispatcher {
583 fn dispatch(&self, request: ActivityDispatch) -> Result<String, String> {
584 let run = DispatchingRun {
585 workflow_id: &request.workflow_id,
586 run_id: &request.run_id,
587 };
588 match self
589 .bodies
590 .body_for(&request.task_queue, &request.name, run)
591 {
592 DeclaredBodyLookup::None => self.inner.dispatch(request),
593 DeclaredBodyLookup::Unreadable(reason) => {
594 tracing::error!(
598 operation = "declared_command_dispatch",
599 workflow_id = %request.workflow_id,
600 activity_name = %request.name,
601 task_queue = %request.task_queue,
602 %reason,
603 "declared-body catalog read failed; delegating to the worker path"
604 );
605 self.inner.dispatch(request)
606 }
607 DeclaredBodyLookup::Ambiguous { declaring } => Err(ambiguous_body_refusal(
608 &request.name,
609 &request.task_queue,
610 &declaring,
611 )),
612 DeclaredBodyLookup::Declared(ActionBodyContract::Run { command }) => {
613 self.run_declared_command(&request, &command)
614 }
615 }
616 }
617}
618
619fn decode_arguments(input: &str) -> Result<BTreeMap<String, serde_json::Value>, String> {
626 let value: serde_json::Value = serde_json::from_str(input)
627 .map_err(|error| format!("terminal:declared command input is not valid JSON: {error}"))?;
628 match value {
629 serde_json::Value::Object(members) => Ok(members.into_iter().collect()),
630 other => Err(format!(
631 "terminal:declared command input must be a JSON object binding the action's \
632 parameters by name; got {}",
633 json_kind(&other)
634 )),
635 }
636}
637
638const fn json_kind(value: &serde_json::Value) -> &'static str {
640 match value {
641 serde_json::Value::Null => "null",
642 serde_json::Value::Bool(_) => "a boolean",
643 serde_json::Value::Number(_) => "a number",
644 serde_json::Value::String(_) => "a string",
645 serde_json::Value::Array(_) => "an array",
646 serde_json::Value::Object(_) => "an object",
647 }
648}
649
650#[cfg(test)]
655#[path = "declared_body_containment_tests.rs"]
656mod declared_body_containment_tests;
657
658#[cfg(test)]
659mod tests {
660 use std::collections::BTreeMap;
661 use std::sync::{Arc, Mutex};
662
663 use aion::{ActivityDispatch, ActivityDispatcher};
664 use aion_core::{ActivityId, RunId, WorkflowId};
665 use aion_package::ActionBodyContract;
666
667 use aion_core::ActivityEventKind;
668 use aion_store::ActivityStreamKey;
669
670 use super::super::workspace_root::{WorkspaceRoot, WorkspaceRootError};
671 use super::{
672 ActivityEventPublisher, DeclaredBodies, DeclaredBodyLookup, DeclaredBodySource,
673 DeclaredCommandAttempts, DeclaredCommandDispatcher, DeclaringVersion, DispatchingRun,
674 decode_arguments,
675 };
676
677 pub(super) type TestResult = Result<(), Box<dyn std::error::Error>>;
681
682 struct RecordingInner {
684 reached: Arc<Mutex<Vec<String>>>,
685 reply: Result<String, String>,
686 }
687
688 impl ActivityDispatcher for RecordingInner {
689 fn dispatch(&self, request: ActivityDispatch) -> Result<String, String> {
690 match self.reached.lock() {
691 Ok(mut names) => names.push(request.name),
692 Err(poisoned) => poisoned.into_inner().push(request.name),
693 }
694 self.reply.clone()
695 }
696 }
697
698 struct FixedBodies {
699 lookup: DeclaredBodyLookup,
700 }
701
702 impl DeclaredBodies for FixedBodies {
703 fn body_for(
704 &self,
705 _task_queue: &str,
706 _action: &str,
707 _run: DispatchingRun<'_>,
708 ) -> DeclaredBodyLookup {
709 self.lookup.clone()
710 }
711 }
712
713 struct RecordingBodies {
720 seen: Arc<Mutex<Vec<(WorkflowId, RunId)>>>,
721 }
722
723 impl DeclaredBodies for RecordingBodies {
724 fn body_for(
725 &self,
726 _task_queue: &str,
727 _action: &str,
728 run: DispatchingRun<'_>,
729 ) -> DeclaredBodyLookup {
730 let observed = (run.workflow_id.clone(), run.run_id.clone());
731 match self.seen.lock() {
732 Ok(mut seen) => seen.push(observed),
733 Err(poisoned) => poisoned.into_inner().push(observed),
734 }
735 DeclaredBodyLookup::None
736 }
737 }
738
739 pub(super) fn request(name: &str, input: &str) -> ActivityDispatch {
740 ActivityDispatch {
741 namespace: "default".to_owned(),
742 task_queue: "shell".to_owned(),
743 node: None,
744 workflow_id: WorkflowId::new_v4(),
745 run_id: RunId::new_v4(),
746 activity_id: ActivityId::from_sequence_position(1),
747 name: name.to_owned(),
748 input: input.to_owned(),
749 config: "{}".to_owned(),
750 attempt: 1,
751 labels: BTreeMap::new(),
752 advisory: false,
753 }
754 }
755
756 fn dispatcher(
757 lookup: DeclaredBodyLookup,
758 reply: Result<String, String>,
759 ) -> (DeclaredCommandDispatcher, Arc<Mutex<Vec<String>>>) {
760 let (decorated, reached, _transcript) = dispatcher_with_root(
764 lookup,
765 reply,
766 WorkspaceRoot::from_resolution(Ok(std::env::temp_dir())),
767 );
768 (decorated, reached)
769 }
770
771 const TRANSCRIPT_CAPACITY: std::num::NonZeroUsize = match std::num::NonZeroUsize::new(64) {
775 Some(capacity) => capacity,
776 None => std::num::NonZeroUsize::MIN,
777 };
778
779 pub(super) fn dispatcher_with_root(
785 lookup: DeclaredBodyLookup,
786 reply: Result<String, String>,
787 workspace_root: WorkspaceRoot,
788 ) -> (
789 DeclaredCommandDispatcher,
790 Arc<Mutex<Vec<String>>>,
791 ActivityEventPublisher,
792 ) {
793 dispatcher_with_attempts(
794 lookup,
795 reply,
796 workspace_root,
797 DeclaredCommandAttempts::new(crate::shutdown::DrainState::default()),
798 )
799 }
800
801 pub(super) fn dispatcher_with_attempts(
802 lookup: DeclaredBodyLookup,
803 reply: Result<String, String>,
804 workspace_root: WorkspaceRoot,
805 attempts: DeclaredCommandAttempts,
806 ) -> (
807 DeclaredCommandDispatcher,
808 Arc<Mutex<Vec<String>>>,
809 ActivityEventPublisher,
810 ) {
811 let reached = Arc::new(Mutex::new(Vec::new()));
812 let inner = RecordingInner {
813 reached: Arc::clone(&reached),
814 reply,
815 };
816 let bodies = DeclaredBodySource::default();
817 bodies.install(Arc::new(FixedBodies { lookup }));
818 let store: Arc<dyn aion_store::ObservabilityStore> =
819 Arc::new(aion_store::InMemoryObservabilityStore::default());
820 let transcript = ActivityEventPublisher::new(
821 store,
822 TRANSCRIPT_CAPACITY,
823 crate::activity_publisher::TranscriptBatchPolicy::UNBATCHED,
824 );
825 let decorated = DeclaredCommandDispatcher::new(
826 Arc::new(inner),
827 bodies,
828 attempts,
829 tokio::runtime::Handle::current(),
830 workspace_root,
831 transcript.clone(),
832 );
833 (decorated, reached, transcript)
834 }
835
836 pub(super) fn reached_names(reached: &Arc<Mutex<Vec<String>>>) -> Vec<String> {
837 match reached.lock() {
838 Ok(names) => names.clone(),
839 Err(poisoned) => poisoned.into_inner().clone(),
840 }
841 }
842
843 #[tokio::test(flavor = "multi_thread")]
844 async fn a_bodiless_action_is_delegated_untouched() -> TestResult {
845 let (decorated, reached) =
846 dispatcher(DeclaredBodyLookup::None, Ok("\"worker-served\"".to_owned()));
847 let handle =
848 tokio::task::spawn_blocking(move || decorated.dispatch(request("plain", "{}")));
849 let result = handle.await?;
850 assert_eq!(result, Ok("\"worker-served\"".to_owned()));
851 assert_eq!(reached_names(&reached), vec!["plain".to_owned()]);
852 Ok(())
853 }
854
855 #[tokio::test(flavor = "multi_thread")]
856 async fn a_declared_body_executes_without_touching_the_worker_path() -> TestResult {
857 let (decorated, reached) = dispatcher(
858 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
859 command: "echo {{greeting}}".to_owned(),
860 }),
861 Err("terminal:the worker path must never be reached".to_owned()),
862 );
863 let handle = tokio::task::spawn_blocking(move || {
864 decorated.dispatch(request(
865 "greet",
866 "{\"greeting\":\"hello from the contract\"}",
867 ))
868 });
869 let result = handle.await?;
870 let encoded = result.map_err(|error| format!("declared command failed: {error}"))?;
871 let outcome: serde_json::Value = serde_json::from_str(&encoded)?;
872 assert_eq!(outcome["stdout"], "hello from the contract");
873 assert_eq!(outcome["exit_code"], 0);
874 assert!(
875 reached_names(&reached).is_empty(),
876 "the worker path must not be consulted for a bodied action"
877 );
878 Ok(())
879 }
880
881 #[tokio::test(flavor = "multi_thread")]
888 async fn a_draining_server_parks_a_declared_dispatch_without_starting_it() -> TestResult {
889 let marker =
890 std::env::temp_dir().join(format!("aion-drain-park-{}", uuid::Uuid::new_v4().simple()));
891 let drain = crate::shutdown::DrainState::default();
892 let attempts = DeclaredCommandAttempts::new(drain.clone());
893 let (decorated, reached, _transcript) = dispatcher_with_attempts(
894 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
895 command: format!("touch {}", marker.display()),
896 }),
897 Err("terminal:the worker path must never be reached".to_owned()),
898 WorkspaceRoot::from_resolution(Ok(std::env::temp_dir())),
899 attempts.clone(),
900 );
901 assert!(drain.begin(), "the first begin() must flip the latch");
902
903 let handle =
904 tokio::task::spawn_blocking(move || decorated.dispatch(request("touch_marker", "{}")));
905 let result = handle.await?;
906
907 assert_eq!(
908 result,
909 Err(aion::PARKED_ACTIVITY_REASON.to_owned()),
910 "a drained-over declared dispatch must wear the park sentinel, not a failure"
911 );
912 assert!(
913 !marker.exists(),
914 "the declared command must never start on a draining server"
915 );
916 assert!(
917 reached_names(&reached).is_empty(),
918 "the park must not fall through to the worker path"
919 );
920 assert!(
921 attempts
922 .executing()
923 .map_err(|error| format!("census read failed: {error}"))?
924 .is_empty(),
925 "a parked dispatch must leave no census entry to hold the drain gate open"
926 );
927 Ok(())
928 }
929
930 #[tokio::test(flavor = "multi_thread")]
939 async fn a_declared_body_publishes_its_output_onto_the_transcript() -> TestResult {
940 let (decorated, reached, transcript) = dispatcher_with_root(
941 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
942 command: "sh -c 'echo one; echo two; echo warned >&2'".to_owned(),
943 }),
944 Err("terminal:the worker path must never be reached".to_owned()),
945 WorkspaceRoot::from_resolution(Ok(std::env::temp_dir())),
946 );
947 let dispatch = request("noisy", "{}");
948 let key = ActivityStreamKey::new(
949 dispatch.workflow_id.clone(),
950 dispatch.run_id.clone(),
951 dispatch.activity_id.clone(),
952 dispatch.attempt,
953 );
954
955 let handle = tokio::task::spawn_blocking(move || decorated.dispatch(dispatch));
956 let encoded = handle
957 .await?
958 .map_err(|error| format!("declared command failed: {error}"))?;
959
960 let outcome: serde_json::Value = serde_json::from_str(&encoded)?;
962 assert_eq!(outcome["stdout"], "one\ntwo");
963 assert_eq!(outcome["stderr"], "warned");
964 assert!(reached_names(&reached).is_empty());
965
966 let retained = transcript.replay_from(&key, 0).await?;
968 let lines = retained
969 .iter()
970 .map(|record| match &record.event.kind {
971 ActivityEventKind::Message { text, .. } => {
972 (record.event.agent_role.clone(), text.clone())
973 }
974 other => (record.event.agent_role.clone(), format!("{other:?}")),
975 })
976 .collect::<Vec<_>>();
977 assert!(
978 lines.contains(&("command stdout".to_owned(), "one".to_owned()))
979 && lines.contains(&("command stdout".to_owned(), "two".to_owned())),
980 "each stdout line must be its own transcript event: {lines:?}"
981 );
982 assert!(
983 lines.contains(&("command stderr".to_owned(), "warned".to_owned())),
984 "stderr must be on the transcript, labelled by its stream: {lines:?}"
985 );
986 let sequences = retained
988 .iter()
989 .map(|record| record.store_seq)
990 .collect::<Vec<_>>();
991 assert_eq!(
992 sequences,
993 (0..u64::try_from(retained.len())?).collect::<Vec<_>>(),
994 "the sequencer assigns a gap-free durable order"
995 );
996 Ok(())
997 }
998
999 #[tokio::test(flavor = "multi_thread")]
1000 async fn a_failing_declared_command_reports_retryable_with_its_stderr() -> TestResult {
1001 let (decorated, _reached) = dispatcher(
1002 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
1003 command: "sh -c 'echo boom >&2; exit 7'".to_owned(),
1004 }),
1005 Ok("unused".to_owned()),
1006 );
1007 let handle =
1008 tokio::task::spawn_blocking(move || decorated.dispatch(request("fails", "{}")));
1009 let Err(error) = handle.await? else {
1010 return Err("a non-zero exit must fail the dispatch".into());
1011 };
1012 assert!(
1013 error.starts_with("retryable:"),
1014 "a non-zero exit is retryable by default: {error}"
1015 );
1016 assert!(
1017 error.contains("boom"),
1018 "stderr must ride the failure: {error}"
1019 );
1020 Ok(())
1021 }
1022
1023 #[test]
1033 fn the_printed_hash_parses_back_as_a_content_hash() -> TestResult {
1034 let version = aion_package::ContentHash::from_bytes([0x5a; 32]);
1035 let routed = aion_package::ContentHash::from_bytes([0xa5; 32]);
1036 let refusal = super::ambiguous_body_refusal(
1037 "find_repositories",
1038 "local",
1039 &[
1040 DeclaringVersion {
1041 content_hash: version.to_string(),
1042 workflow_types: vec!["sweeper".to_owned()],
1043 route_active: false,
1044 body: 0,
1045 },
1046 DeclaringVersion {
1047 content_hash: routed.to_string(),
1048 workflow_types: vec!["sweeper".to_owned()],
1049 route_active: true,
1050 body: 1,
1051 },
1052 ],
1053 );
1054 let Some(command) = refusal.split("`aion unload sweeper ").nth(1) else {
1055 return Err(format!("no unload command in the refusal: {refusal}").into());
1056 };
1057 let Some(printed) = command.split('`').next() else {
1058 return Err(format!("the unload command is unterminated: {refusal}").into());
1059 };
1060 let parsed: aion_package::ContentHash = printed.parse()?;
1061 assert_eq!(
1062 parsed, version,
1063 "the printed hash must round-trip to the version it names"
1064 );
1065 Ok(())
1066 }
1067
1068 #[tokio::test(flavor = "multi_thread")]
1069 async fn ambiguous_bodies_refuse_terminally_by_name() -> TestResult {
1070 let superseded = "1111111111111111111111111111111111111111111111111111111111111111";
1071 let routed = "2222222222222222222222222222222222222222222222222222222222222222";
1072 let (decorated, reached) = dispatcher(
1073 DeclaredBodyLookup::Ambiguous {
1074 declaring: vec![
1075 DeclaringVersion {
1076 content_hash: superseded.to_owned(),
1077 workflow_types: vec!["sweeper".to_owned()],
1078 route_active: false,
1079 body: 0,
1080 },
1081 DeclaringVersion {
1082 content_hash: routed.to_owned(),
1083 workflow_types: vec!["sweeper".to_owned()],
1084 route_active: true,
1085 body: 1,
1086 },
1087 ],
1088 },
1089 Ok(String::new()),
1090 );
1091 let handle = tokio::task::spawn_blocking(move || decorated.dispatch(request("torn", "{}")));
1092 let Err(error) = handle.await? else {
1093 return Err("ambiguous bodies must refuse".into());
1094 };
1095 assert!(error.starts_with("terminal:"), "{error}");
1096 assert!(error.contains("torn"), "{error}");
1097 assert!(
1100 error.contains(&format!("`aion unload sweeper {superseded}`")),
1101 "the dispatch refusal must name the version to retire: {error}"
1102 );
1103 assert!(reached_names(&reached).is_empty());
1104 Ok(())
1105 }
1106
1107 #[tokio::test(flavor = "multi_thread")]
1108 async fn a_placeholder_bearing_body_executes_with_the_expanded_root() -> TestResult {
1109 let scratch = tempfile::tempdir()?;
1110 let root = scratch.path().join("clones");
1111 let root_text = root.to_string_lossy().into_owned();
1112 let (decorated, reached, _transcript) = dispatcher_with_root(
1113 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
1114 command: "echo {workspace_root}".to_owned(),
1115 }),
1116 Err("terminal:the worker path must never be reached".to_owned()),
1117 WorkspaceRoot::from_resolution(Ok(root.clone())),
1118 );
1119 let handle =
1120 tokio::task::spawn_blocking(move || decorated.dispatch(request("provision", "{}")));
1121 let result = handle.await?;
1122 let encoded = result.map_err(|error| format!("declared command failed: {error}"))?;
1123 let outcome: serde_json::Value = serde_json::from_str(&encoded)?;
1124 assert_eq!(
1125 outcome["stdout"], root_text,
1126 "the command must observe the server-resolved root as its argv word"
1127 );
1128 assert_eq!(outcome["exit_code"], 0);
1129 assert!(
1130 root.is_dir(),
1131 "dispatching a placeholder-bearing body must create the missing root"
1132 );
1133 assert!(reached_names(&reached).is_empty());
1134 Ok(())
1135 }
1136
1137 #[tokio::test(flavor = "multi_thread")]
1138 async fn a_placeholder_bearing_body_refuses_terminally_when_the_root_is_unresolved()
1139 -> TestResult {
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(Err(WorkspaceRootError::Unresolvable {
1146 reason: "cannot resolve Aion home: set AION_HOME or HOME".to_owned(),
1147 })),
1148 );
1149 let handle =
1150 tokio::task::spawn_blocking(move || decorated.dispatch(request("provision", "{}")));
1151 let Err(error) = handle.await? else {
1152 return Err("an unresolved root must refuse a placeholder-bearing body".into());
1153 };
1154 assert!(error.starts_with("terminal:"), "{error}");
1155 assert!(
1156 error.contains("provision"),
1157 "the refusal must name the action: {error}"
1158 );
1159 assert!(
1160 error.contains("cannot resolve Aion home"),
1161 "the refusal must carry the resolution failure's reason: {error}"
1162 );
1163 assert!(
1164 reached_names(&reached).is_empty(),
1165 "a refused body must not fall through to the worker path"
1166 );
1167 Ok(())
1168 }
1169
1170 #[tokio::test(flavor = "multi_thread")]
1171 async fn a_shape_changing_root_refuses_terminally_naming_the_action() -> TestResult {
1172 let (decorated, reached, _transcript) = dispatcher_with_root(
1177 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
1178 command: "echo {workspace_root}".to_owned(),
1179 }),
1180 Ok("unused".to_owned()),
1181 WorkspaceRoot::from_resolution(Ok(std::path::PathBuf::from("/absolute/with{brace"))),
1182 );
1183 let handle =
1184 tokio::task::spawn_blocking(move || decorated.dispatch(request("provision", "{}")));
1185 let Err(error) = handle.await? else {
1186 return Err("a shape-changing root must refuse a placeholder-bearing body".into());
1187 };
1188 assert!(error.starts_with("terminal:"), "{error}");
1189 assert!(
1190 error.contains("provision"),
1191 "the refusal must name the action: {error}"
1192 );
1193 assert!(
1194 error.contains("would change the parsed shape"),
1195 "the refusal must carry the shape-changing diagnosis: {error}"
1196 );
1197 assert!(
1198 reached_names(&reached).is_empty(),
1199 "a refused body must not fall through to the worker path"
1200 );
1201 Ok(())
1202 }
1203
1204 #[tokio::test(flavor = "multi_thread")]
1205 async fn an_uncreatable_root_refuses_terminally_naming_the_action() -> TestResult {
1206 let scratch = tempfile::tempdir()?;
1208 let file = scratch.path().join("occupied");
1209 std::fs::write(&file, b"not a directory")?;
1210 let (decorated, reached, _transcript) = dispatcher_with_root(
1211 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
1212 command: "echo {workspace_root}".to_owned(),
1213 }),
1214 Ok("unused".to_owned()),
1215 WorkspaceRoot::from_resolution(Ok(file.join("clones"))),
1216 );
1217 let handle =
1218 tokio::task::spawn_blocking(move || decorated.dispatch(request("provision", "{}")));
1219 let Err(error) = handle.await? else {
1220 return Err("an uncreatable root must refuse a placeholder-bearing body".into());
1221 };
1222 assert!(error.starts_with("terminal:"), "{error}");
1223 assert!(
1224 error.contains("provision"),
1225 "the refusal must name the action: {error}"
1226 );
1227 assert!(
1228 error.contains("could not be created"),
1229 "the refusal must carry the creation-failure diagnosis: {error}"
1230 );
1231 assert!(
1232 reached_names(&reached).is_empty(),
1233 "a refused body must not fall through to the worker path"
1234 );
1235 Ok(())
1236 }
1237
1238 #[tokio::test(flavor = "multi_thread")]
1239 async fn a_body_without_the_placeholder_is_untouched_by_resolution_failure() -> TestResult {
1240 let (decorated, _reached, _transcript) = dispatcher_with_root(
1241 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
1242 command: "echo {{greeting}}".to_owned(),
1243 }),
1244 Ok("unused".to_owned()),
1245 WorkspaceRoot::from_resolution(Err(WorkspaceRootError::Unresolvable {
1246 reason: "cannot resolve Aion home: set AION_HOME or HOME".to_owned(),
1247 })),
1248 );
1249 let handle = tokio::task::spawn_blocking(move || {
1250 decorated.dispatch(request("greet", "{\"greeting\":\"still served\"}"))
1251 });
1252 let result = handle.await?;
1253 let encoded = result.map_err(|error| format!("declared command failed: {error}"))?;
1254 let outcome: serde_json::Value = serde_json::from_str(&encoded)?;
1255 assert_eq!(outcome["stdout"], "still served");
1256 Ok(())
1257 }
1258
1259 #[tokio::test(flavor = "multi_thread")]
1260 async fn an_unreadable_catalog_delegates_to_the_worker_path() -> TestResult {
1261 let (decorated, reached) = dispatcher(
1262 DeclaredBodyLookup::Unreadable("catalog offline".to_owned()),
1263 Ok("\"served anyway\"".to_owned()),
1264 );
1265 let handle =
1266 tokio::task::spawn_blocking(move || decorated.dispatch(request("resilient", "{}")));
1267 let result = handle.await?;
1268 assert_eq!(result, Ok("\"served anyway\"".to_owned()));
1269 assert_eq!(reached_names(&reached), vec!["resilient".to_owned()]);
1270 Ok(())
1271 }
1272
1273 #[test]
1274 fn non_object_input_is_refused_terminally_by_shape() {
1275 for (input, kind) in [
1276 ("[1,2]", "an array"),
1277 ("\"text\"", "a string"),
1278 ("3", "a number"),
1279 ("null", "null"),
1280 ("true", "a boolean"),
1281 ] {
1282 let Err(error) = decode_arguments(input) else {
1283 unreachable_refusal(input);
1284 return;
1285 };
1286 assert!(error.starts_with("terminal:"), "{error}");
1287 assert!(error.contains(kind), "{error} must name {kind}");
1288 }
1289 }
1290
1291 fn unreachable_refusal(input: &str) {
1293 assert!(
1294 input.is_empty(),
1295 "input `{input}` must have been refused by shape"
1296 );
1297 }
1298
1299 #[tokio::test(flavor = "multi_thread")]
1307 async fn the_reader_is_asked_about_the_run_that_is_dispatching() -> TestResult {
1308 let seen = Arc::new(Mutex::new(Vec::new()));
1309 let bodies = DeclaredBodySource::default();
1310 bodies.install(Arc::new(RecordingBodies {
1311 seen: Arc::clone(&seen),
1312 }));
1313 let reached = Arc::new(Mutex::new(Vec::new()));
1314 let decorated = DeclaredCommandDispatcher::new(
1315 Arc::new(RecordingInner {
1316 reached: Arc::clone(&reached),
1317 reply: Ok("\"worker-served\"".to_owned()),
1318 }),
1319 bodies,
1320 DeclaredCommandAttempts::new(crate::shutdown::DrainState::default()),
1321 tokio::runtime::Handle::current(),
1322 WorkspaceRoot::from_resolution(Ok(std::env::temp_dir())),
1323 ActivityEventPublisher::new(
1324 Arc::new(aion_store::InMemoryObservabilityStore::default()),
1325 TRANSCRIPT_CAPACITY,
1326 crate::activity_publisher::TranscriptBatchPolicy::UNBATCHED,
1327 ),
1328 );
1329
1330 let dispatch = request("plain", "{}");
1331 let expected = (dispatch.workflow_id.clone(), dispatch.run_id.clone());
1332 let handle = tokio::task::spawn_blocking(move || decorated.dispatch(dispatch));
1333 handle
1334 .await?
1335 .map_err(|error| format!("dispatch failed: {error}"))?;
1336
1337 let observed = match seen.lock() {
1338 Ok(observed) => observed.clone(),
1339 Err(poisoned) => poisoned.into_inner().clone(),
1340 };
1341 assert_eq!(
1342 observed,
1343 vec![expected],
1344 "the body reader must be asked about the dispatching run itself"
1345 );
1346 Ok(())
1347 }
1348}