1use std::collections::BTreeMap;
26use std::sync::{Arc, OnceLock};
27
28use aion::{ActivityDispatch, ActivityDispatcher};
29use aion_package::{ActionBodyContract, ContentHash};
30use aion_worker::shell::ShellAction;
31
32use super::declared_body_ambiguity::{DeclaringVersion, ambiguous_body_refusal};
33use super::declared_body_selection::select_declared_body;
34use super::declared_body_transcript::publish_declared_transcript;
35use super::workspace_root::{WORKSPACE_ROOT_PLACEHOLDER, WorkspaceRoot};
36use crate::activity_publisher::ActivityEventPublisher;
37
38#[derive(Clone, Debug)]
40pub enum DeclaredBodyLookup {
41 None,
44 Declared(ActionBodyContract),
47 Ambiguous {
51 declaring: Vec<DeclaringVersion>,
56 },
57 Unreadable(String),
60}
61
62#[derive(Clone, Copy, Debug)]
68pub struct DispatchingRun<'a> {
69 pub workflow_id: &'a aion_core::WorkflowId,
71 pub run_id: &'a aion_core::RunId,
73}
74
75pub trait DeclaredBodies: Send + Sync {
77 fn body_for(
80 &self,
81 task_queue: &str,
82 action: &str,
83 run: DispatchingRun<'_>,
84 ) -> DeclaredBodyLookup;
85}
86
87#[derive(Clone, Default)]
94pub struct DeclaredBodySource {
95 inner: Arc<OnceLock<Arc<dyn DeclaredBodies>>>,
96}
97
98impl std::fmt::Debug for DeclaredBodySource {
99 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100 formatter
101 .debug_struct("DeclaredBodySource")
102 .field("installed", &self.inner.get().is_some())
103 .finish()
104 }
105}
106
107impl DeclaredBodySource {
108 pub fn install(&self, source: Arc<dyn DeclaredBodies>) {
111 if self.inner.set(source).is_err() {
112 tracing::warn!("declared body source already installed; ignoring duplicate set");
113 }
114 }
115
116 #[must_use]
131 pub fn body_for(
132 &self,
133 task_queue: &str,
134 action: &str,
135 run: DispatchingRun<'_>,
136 ) -> DeclaredBodyLookup {
137 self.inner.get().map_or_else(
138 || {
139 tracing::error!(
140 operation = "declared_command_dispatch",
141 task_queue,
142 action,
143 workflow_id = %run.workflow_id,
144 run_id = %run.run_id,
145 "declared body source consulted before it was installed; the dispatch \
146 falls through to the worker path and will park if the queue's only \
147 service is its declared bodies (#266 boot-ordering defect)"
148 );
149 DeclaredBodyLookup::None
150 },
151 |source| source.body_for(task_queue, action, run),
152 )
153 }
154}
155
156pub struct EngineDeclaredBodies {
158 engine: Arc<aion::Engine>,
159}
160
161impl EngineDeclaredBodies {
162 #[must_use]
164 pub const fn new(engine: Arc<aion::Engine>) -> Self {
165 Self { engine }
166 }
167}
168
169impl std::fmt::Debug for EngineDeclaredBodies {
170 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
171 formatter.write_str("EngineDeclaredBodies")
172 }
173}
174
175impl EngineDeclaredBodies {
176 fn version_of(&self, run: DispatchingRun<'_>) -> Option<ContentHash> {
184 match self.engine.registry().get(run.workflow_id, run.run_id) {
185 Ok(Some(handle)) => Some(handle.loaded_version().clone()),
186 Ok(None) => {
187 tracing::warn!(
188 operation = "declared_command_dispatch",
189 workflow_id = %run.workflow_id,
190 run_id = %run.run_id,
191 "no registry handle for the dispatching run; resolving its body \
192 from the whole queue instead of from its own package version"
193 );
194 None
195 }
196 Err(error) => {
197 tracing::error!(
198 operation = "declared_command_dispatch",
199 workflow_id = %run.workflow_id,
200 run_id = %run.run_id,
201 %error,
202 "registry unreadable while resolving the dispatching run's version; \
203 resolving its body from the whole queue instead"
204 );
205 None
206 }
207 }
208 }
209}
210
211impl DeclaredBodies for EngineDeclaredBodies {
212 fn body_for(
213 &self,
214 task_queue: &str,
215 action: &str,
216 run: DispatchingRun<'_>,
217 ) -> DeclaredBodyLookup {
218 let contracts = match self.engine.worker_contracts_for_queue(task_queue) {
219 Ok(contracts) => contracts,
220 Err(error) => return DeclaredBodyLookup::Unreadable(error.to_string()),
221 };
222 select_declared_body(&contracts, action, self.version_of(run).as_ref())
227 }
228}
229
230pub struct DeclaredCommandDispatcher {
235 inner: Arc<dyn ActivityDispatcher>,
236 bodies: DeclaredBodySource,
237 tokio: tokio::runtime::Handle,
238 workspace_root: WorkspaceRoot,
239 transcript: ActivityEventPublisher,
240}
241
242impl DeclaredCommandDispatcher {
243 #[must_use]
249 pub fn new(
250 inner: Arc<dyn ActivityDispatcher>,
251 bodies: DeclaredBodySource,
252 tokio: tokio::runtime::Handle,
253 workspace_root: WorkspaceRoot,
254 transcript: ActivityEventPublisher,
255 ) -> Self {
256 Self {
257 inner,
258 bodies,
259 tokio,
260 workspace_root,
261 transcript,
262 }
263 }
264
265 fn run_declared_command(
268 &self,
269 request: &ActivityDispatch,
270 command: &str,
271 ) -> Result<String, String> {
272 let arguments = decode_arguments(&request.input)?;
273 let expanded = self.workspace_root.expand(command).map_err(|error| {
279 format!(
280 "terminal:declared body for action `{name}` uses the {placeholder} \
281 placeholder and cannot dispatch: {error}",
282 name = request.name,
283 placeholder = WORKSPACE_ROOT_PLACEHOLDER,
284 )
285 })?;
286 if let Some(expansion) = &expanded {
287 tracing::info!(
288 operation = "declared_command_dispatch",
289 workflow_id = %request.workflow_id,
290 activity_id = %request.activity_id,
291 activity_name = %request.name,
292 task_queue = %request.task_queue,
293 attempt = request.attempt,
294 workspace_root = %expansion.workspace_root,
295 "expanded the workspace-root placeholder in the declared command"
296 );
297 }
298 let command = expanded
299 .as_ref()
300 .map_or(command, |expansion| expansion.command.as_str());
301 let action = ShellAction::new(command).map_err(|error| {
302 format!("terminal:declared command failed to parse at dispatch: {error}")
306 })?;
307 let (events, drain) = tokio::sync::mpsc::unbounded_channel();
312 let (context, cancellation) = aion_worker::ActivityContext::with_transcript(
313 request.workflow_id.clone(),
314 request.run_id.clone(),
315 request.activity_id.clone(),
316 request.attempt,
317 events,
318 );
319
320 tracing::info!(
321 operation = "declared_command_dispatch",
322 workflow_id = %request.workflow_id,
323 activity_id = %request.activity_id,
324 activity_name = %request.name,
325 task_queue = %request.task_queue,
326 attempt = request.attempt,
327 "executing declared action body at the server"
328 );
329 crate::death_note::breadcrumb(&format!(
333 "declared-action start action={} workflow_id={} run_id={} activity_id={} attempt={}",
334 request.name, request.workflow_id, request.run_id, request.activity_id, request.attempt,
335 ));
336
337 let transcript = self.transcript.clone();
338 let outcome = self.tokio.block_on(async move {
339 let pump = tokio::spawn(publish_declared_transcript(transcript, drain));
340 let outcome = action.run(&arguments, &context).await;
341 drop(context);
343 if let Err(error) = pump.await {
344 tracing::warn!(
345 %error,
346 operation = "declared_command_dispatch",
347 "declared command transcript: the publishing task ended abnormally; some \
348 output lines may not have been retained"
349 );
350 }
351 outcome
352 });
353 drop(cancellation);
357
358 match outcome {
359 Ok(result) => serde_json::to_string(&result).map_err(|error| {
360 format!("terminal:declared command result failed to encode: {error}")
361 }),
362 Err(failure) => {
363 let prefix = match failure.classification() {
364 aion_worker::Classification::Retryable => "retryable",
365 aion_worker::Classification::Terminal => "terminal",
366 };
367 Err(format!("{prefix}:{}", failure.message()))
368 }
369 }
370 }
371}
372
373impl std::fmt::Debug for DeclaredCommandDispatcher {
374 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
375 formatter
376 .debug_struct("DeclaredCommandDispatcher")
377 .field("bodies", &self.bodies)
378 .finish_non_exhaustive()
379 }
380}
381
382impl ActivityDispatcher for DeclaredCommandDispatcher {
383 fn dispatch(&self, request: ActivityDispatch) -> Result<String, String> {
384 let run = DispatchingRun {
385 workflow_id: &request.workflow_id,
386 run_id: &request.run_id,
387 };
388 match self
389 .bodies
390 .body_for(&request.task_queue, &request.name, run)
391 {
392 DeclaredBodyLookup::None => self.inner.dispatch(request),
393 DeclaredBodyLookup::Unreadable(reason) => {
394 tracing::error!(
398 operation = "declared_command_dispatch",
399 workflow_id = %request.workflow_id,
400 activity_name = %request.name,
401 task_queue = %request.task_queue,
402 %reason,
403 "declared-body catalog read failed; delegating to the worker path"
404 );
405 self.inner.dispatch(request)
406 }
407 DeclaredBodyLookup::Ambiguous { declaring } => Err(ambiguous_body_refusal(
408 &request.name,
409 &request.task_queue,
410 &declaring,
411 )),
412 DeclaredBodyLookup::Declared(ActionBodyContract::Run { command }) => {
413 self.run_declared_command(&request, &command)
414 }
415 }
416 }
417}
418
419fn decode_arguments(input: &str) -> Result<BTreeMap<String, serde_json::Value>, String> {
426 let value: serde_json::Value = serde_json::from_str(input)
427 .map_err(|error| format!("terminal:declared command input is not valid JSON: {error}"))?;
428 match value {
429 serde_json::Value::Object(members) => Ok(members.into_iter().collect()),
430 other => Err(format!(
431 "terminal:declared command input must be a JSON object binding the action's \
432 parameters by name; got {}",
433 json_kind(&other)
434 )),
435 }
436}
437
438const fn json_kind(value: &serde_json::Value) -> &'static str {
440 match value {
441 serde_json::Value::Null => "null",
442 serde_json::Value::Bool(_) => "a boolean",
443 serde_json::Value::Number(_) => "a number",
444 serde_json::Value::String(_) => "a string",
445 serde_json::Value::Array(_) => "an array",
446 serde_json::Value::Object(_) => "an object",
447 }
448}
449
450#[cfg(test)]
451mod tests {
452 use std::collections::BTreeMap;
453 use std::sync::{Arc, Mutex};
454
455 use aion::{ActivityDispatch, ActivityDispatcher};
456 use aion_core::{ActivityId, RunId, WorkflowId};
457 use aion_package::ActionBodyContract;
458
459 use aion_core::ActivityEventKind;
460 use aion_store::ActivityStreamKey;
461
462 use super::super::workspace_root::{WorkspaceRoot, WorkspaceRootError};
463 use super::{
464 ActivityEventPublisher, DeclaredBodies, DeclaredBodyLookup, DeclaredBodySource,
465 DeclaredCommandDispatcher, DeclaringVersion, DispatchingRun, decode_arguments,
466 };
467
468 type TestResult = Result<(), Box<dyn std::error::Error>>;
472
473 struct RecordingInner {
475 reached: Arc<Mutex<Vec<String>>>,
476 reply: Result<String, String>,
477 }
478
479 impl ActivityDispatcher for RecordingInner {
480 fn dispatch(&self, request: ActivityDispatch) -> Result<String, String> {
481 match self.reached.lock() {
482 Ok(mut names) => names.push(request.name),
483 Err(poisoned) => poisoned.into_inner().push(request.name),
484 }
485 self.reply.clone()
486 }
487 }
488
489 struct FixedBodies {
490 lookup: DeclaredBodyLookup,
491 }
492
493 impl DeclaredBodies for FixedBodies {
494 fn body_for(
495 &self,
496 _task_queue: &str,
497 _action: &str,
498 _run: DispatchingRun<'_>,
499 ) -> DeclaredBodyLookup {
500 self.lookup.clone()
501 }
502 }
503
504 struct RecordingBodies {
511 seen: Arc<Mutex<Vec<(WorkflowId, RunId)>>>,
512 }
513
514 impl DeclaredBodies for RecordingBodies {
515 fn body_for(
516 &self,
517 _task_queue: &str,
518 _action: &str,
519 run: DispatchingRun<'_>,
520 ) -> DeclaredBodyLookup {
521 let observed = (run.workflow_id.clone(), run.run_id.clone());
522 match self.seen.lock() {
523 Ok(mut seen) => seen.push(observed),
524 Err(poisoned) => poisoned.into_inner().push(observed),
525 }
526 DeclaredBodyLookup::None
527 }
528 }
529
530 fn request(name: &str, input: &str) -> ActivityDispatch {
531 ActivityDispatch {
532 namespace: "default".to_owned(),
533 task_queue: "shell".to_owned(),
534 node: None,
535 workflow_id: WorkflowId::new_v4(),
536 run_id: RunId::new_v4(),
537 activity_id: ActivityId::from_sequence_position(1),
538 name: name.to_owned(),
539 input: input.to_owned(),
540 config: "{}".to_owned(),
541 attempt: 1,
542 labels: BTreeMap::new(),
543 advisory: false,
544 }
545 }
546
547 fn dispatcher(
548 lookup: DeclaredBodyLookup,
549 reply: Result<String, String>,
550 ) -> (DeclaredCommandDispatcher, Arc<Mutex<Vec<String>>>) {
551 let (decorated, reached, _transcript) = dispatcher_with_root(
555 lookup,
556 reply,
557 WorkspaceRoot::from_resolution(Ok(std::env::temp_dir())),
558 );
559 (decorated, reached)
560 }
561
562 const TRANSCRIPT_CAPACITY: std::num::NonZeroUsize = match std::num::NonZeroUsize::new(64) {
566 Some(capacity) => capacity,
567 None => std::num::NonZeroUsize::MIN,
568 };
569
570 fn dispatcher_with_root(
571 lookup: DeclaredBodyLookup,
572 reply: Result<String, String>,
573 workspace_root: WorkspaceRoot,
574 ) -> (
575 DeclaredCommandDispatcher,
576 Arc<Mutex<Vec<String>>>,
577 ActivityEventPublisher,
578 ) {
579 let reached = Arc::new(Mutex::new(Vec::new()));
580 let inner = RecordingInner {
581 reached: Arc::clone(&reached),
582 reply,
583 };
584 let bodies = DeclaredBodySource::default();
585 bodies.install(Arc::new(FixedBodies { lookup }));
586 let store: Arc<dyn aion_store::ObservabilityStore> =
587 Arc::new(aion_store::InMemoryObservabilityStore::default());
588 let transcript = ActivityEventPublisher::new(
589 store,
590 TRANSCRIPT_CAPACITY,
591 crate::activity_publisher::TranscriptBatchPolicy::UNBATCHED,
592 );
593 let decorated = DeclaredCommandDispatcher::new(
594 Arc::new(inner),
595 bodies,
596 tokio::runtime::Handle::current(),
597 workspace_root,
598 transcript.clone(),
599 );
600 (decorated, reached, transcript)
601 }
602
603 fn reached_names(reached: &Arc<Mutex<Vec<String>>>) -> Vec<String> {
604 match reached.lock() {
605 Ok(names) => names.clone(),
606 Err(poisoned) => poisoned.into_inner().clone(),
607 }
608 }
609
610 #[tokio::test(flavor = "multi_thread")]
611 async fn a_bodiless_action_is_delegated_untouched() -> TestResult {
612 let (decorated, reached) =
613 dispatcher(DeclaredBodyLookup::None, Ok("\"worker-served\"".to_owned()));
614 let handle =
615 tokio::task::spawn_blocking(move || decorated.dispatch(request("plain", "{}")));
616 let result = handle.await?;
617 assert_eq!(result, Ok("\"worker-served\"".to_owned()));
618 assert_eq!(reached_names(&reached), vec!["plain".to_owned()]);
619 Ok(())
620 }
621
622 #[tokio::test(flavor = "multi_thread")]
623 async fn a_declared_body_executes_without_touching_the_worker_path() -> TestResult {
624 let (decorated, reached) = dispatcher(
625 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
626 command: "echo $greeting".to_owned(),
627 }),
628 Err("terminal:the worker path must never be reached".to_owned()),
629 );
630 let handle = tokio::task::spawn_blocking(move || {
631 decorated.dispatch(request(
632 "greet",
633 "{\"greeting\":\"hello from the contract\"}",
634 ))
635 });
636 let result = handle.await?;
637 let encoded = result.map_err(|error| format!("declared command failed: {error}"))?;
638 let outcome: serde_json::Value = serde_json::from_str(&encoded)?;
639 assert_eq!(outcome["stdout"], "hello from the contract");
640 assert_eq!(outcome["exit_code"], 0);
641 assert!(
642 reached_names(&reached).is_empty(),
643 "the worker path must not be consulted for a bodied action"
644 );
645 Ok(())
646 }
647
648 #[tokio::test(flavor = "multi_thread")]
657 async fn a_declared_body_publishes_its_output_onto_the_transcript() -> TestResult {
658 let (decorated, reached, transcript) = dispatcher_with_root(
659 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
660 command: "sh -c 'echo one; echo two; echo warned >&2'".to_owned(),
661 }),
662 Err("terminal:the worker path must never be reached".to_owned()),
663 WorkspaceRoot::from_resolution(Ok(std::env::temp_dir())),
664 );
665 let dispatch = request("noisy", "{}");
666 let key = ActivityStreamKey::new(
667 dispatch.workflow_id.clone(),
668 dispatch.run_id.clone(),
669 dispatch.activity_id.clone(),
670 dispatch.attempt,
671 );
672
673 let handle = tokio::task::spawn_blocking(move || decorated.dispatch(dispatch));
674 let encoded = handle
675 .await?
676 .map_err(|error| format!("declared command failed: {error}"))?;
677
678 let outcome: serde_json::Value = serde_json::from_str(&encoded)?;
680 assert_eq!(outcome["stdout"], "one\ntwo");
681 assert_eq!(outcome["stderr"], "warned");
682 assert!(reached_names(&reached).is_empty());
683
684 let retained = transcript.replay_from(&key, 0).await?;
686 let lines = retained
687 .iter()
688 .map(|record| match &record.event.kind {
689 ActivityEventKind::Message { text, .. } => {
690 (record.event.agent_role.clone(), text.clone())
691 }
692 other => (record.event.agent_role.clone(), format!("{other:?}")),
693 })
694 .collect::<Vec<_>>();
695 assert!(
696 lines.contains(&("command stdout".to_owned(), "one".to_owned()))
697 && lines.contains(&("command stdout".to_owned(), "two".to_owned())),
698 "each stdout line must be its own transcript event: {lines:?}"
699 );
700 assert!(
701 lines.contains(&("command stderr".to_owned(), "warned".to_owned())),
702 "stderr must be on the transcript, labelled by its stream: {lines:?}"
703 );
704 let sequences = retained
706 .iter()
707 .map(|record| record.store_seq)
708 .collect::<Vec<_>>();
709 assert_eq!(
710 sequences,
711 (0..u64::try_from(retained.len())?).collect::<Vec<_>>(),
712 "the sequencer assigns a gap-free durable order"
713 );
714 Ok(())
715 }
716
717 #[tokio::test(flavor = "multi_thread")]
718 async fn a_failing_declared_command_reports_retryable_with_its_stderr() -> TestResult {
719 let (decorated, _reached) = dispatcher(
720 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
721 command: "sh -c 'echo boom >&2; exit 7'".to_owned(),
722 }),
723 Ok("unused".to_owned()),
724 );
725 let handle =
726 tokio::task::spawn_blocking(move || decorated.dispatch(request("fails", "{}")));
727 let Err(error) = handle.await? else {
728 return Err("a non-zero exit must fail the dispatch".into());
729 };
730 assert!(
731 error.starts_with("retryable:"),
732 "a non-zero exit is retryable by default: {error}"
733 );
734 assert!(
735 error.contains("boom"),
736 "stderr must ride the failure: {error}"
737 );
738 Ok(())
739 }
740
741 #[test]
751 fn the_printed_hash_parses_back_as_a_content_hash() -> TestResult {
752 let version = aion_package::ContentHash::from_bytes([0x5a; 32]);
753 let routed = aion_package::ContentHash::from_bytes([0xa5; 32]);
754 let refusal = super::ambiguous_body_refusal(
755 "find_repositories",
756 "local",
757 &[
758 DeclaringVersion {
759 content_hash: version.to_string(),
760 workflow_types: vec!["sweeper".to_owned()],
761 route_active: false,
762 body: 0,
763 },
764 DeclaringVersion {
765 content_hash: routed.to_string(),
766 workflow_types: vec!["sweeper".to_owned()],
767 route_active: true,
768 body: 1,
769 },
770 ],
771 );
772 let Some(command) = refusal.split("`aion unload sweeper ").nth(1) else {
773 return Err(format!("no unload command in the refusal: {refusal}").into());
774 };
775 let Some(printed) = command.split('`').next() else {
776 return Err(format!("the unload command is unterminated: {refusal}").into());
777 };
778 let parsed: aion_package::ContentHash = printed.parse()?;
779 assert_eq!(
780 parsed, version,
781 "the printed hash must round-trip to the version it names"
782 );
783 Ok(())
784 }
785
786 #[tokio::test(flavor = "multi_thread")]
787 async fn ambiguous_bodies_refuse_terminally_by_name() -> TestResult {
788 let superseded = "1111111111111111111111111111111111111111111111111111111111111111";
789 let routed = "2222222222222222222222222222222222222222222222222222222222222222";
790 let (decorated, reached) = dispatcher(
791 DeclaredBodyLookup::Ambiguous {
792 declaring: vec![
793 DeclaringVersion {
794 content_hash: superseded.to_owned(),
795 workflow_types: vec!["sweeper".to_owned()],
796 route_active: false,
797 body: 0,
798 },
799 DeclaringVersion {
800 content_hash: routed.to_owned(),
801 workflow_types: vec!["sweeper".to_owned()],
802 route_active: true,
803 body: 1,
804 },
805 ],
806 },
807 Ok(String::new()),
808 );
809 let handle = tokio::task::spawn_blocking(move || decorated.dispatch(request("torn", "{}")));
810 let Err(error) = handle.await? else {
811 return Err("ambiguous bodies must refuse".into());
812 };
813 assert!(error.starts_with("terminal:"), "{error}");
814 assert!(error.contains("torn"), "{error}");
815 assert!(
818 error.contains(&format!("`aion unload sweeper {superseded}`")),
819 "the dispatch refusal must name the version to retire: {error}"
820 );
821 assert!(reached_names(&reached).is_empty());
822 Ok(())
823 }
824
825 #[tokio::test(flavor = "multi_thread")]
826 async fn a_placeholder_bearing_body_executes_with_the_expanded_root() -> TestResult {
827 let scratch = tempfile::tempdir()?;
828 let root = scratch.path().join("clones");
829 let root_text = root.to_string_lossy().into_owned();
830 let (decorated, reached, _transcript) = dispatcher_with_root(
831 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
832 command: "echo {workspace_root}".to_owned(),
833 }),
834 Err("terminal:the worker path must never be reached".to_owned()),
835 WorkspaceRoot::from_resolution(Ok(root.clone())),
836 );
837 let handle =
838 tokio::task::spawn_blocking(move || decorated.dispatch(request("provision", "{}")));
839 let result = handle.await?;
840 let encoded = result.map_err(|error| format!("declared command failed: {error}"))?;
841 let outcome: serde_json::Value = serde_json::from_str(&encoded)?;
842 assert_eq!(
843 outcome["stdout"], root_text,
844 "the command must observe the server-resolved root as its argv word"
845 );
846 assert_eq!(outcome["exit_code"], 0);
847 assert!(
848 root.is_dir(),
849 "dispatching a placeholder-bearing body must create the missing root"
850 );
851 assert!(reached_names(&reached).is_empty());
852 Ok(())
853 }
854
855 #[tokio::test(flavor = "multi_thread")]
856 async fn a_placeholder_bearing_body_refuses_terminally_when_the_root_is_unresolved()
857 -> TestResult {
858 let (decorated, reached, _transcript) = dispatcher_with_root(
859 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
860 command: "echo {workspace_root}".to_owned(),
861 }),
862 Ok("unused".to_owned()),
863 WorkspaceRoot::from_resolution(Err(WorkspaceRootError::Unresolvable {
864 reason: "cannot resolve Aion home: set AION_HOME or HOME".to_owned(),
865 })),
866 );
867 let handle =
868 tokio::task::spawn_blocking(move || decorated.dispatch(request("provision", "{}")));
869 let Err(error) = handle.await? else {
870 return Err("an unresolved root must refuse a placeholder-bearing body".into());
871 };
872 assert!(error.starts_with("terminal:"), "{error}");
873 assert!(
874 error.contains("provision"),
875 "the refusal must name the action: {error}"
876 );
877 assert!(
878 error.contains("cannot resolve Aion home"),
879 "the refusal must carry the resolution failure's reason: {error}"
880 );
881 assert!(
882 reached_names(&reached).is_empty(),
883 "a refused body must not fall through to the worker path"
884 );
885 Ok(())
886 }
887
888 #[tokio::test(flavor = "multi_thread")]
889 async fn a_shape_changing_root_refuses_terminally_naming_the_action() -> TestResult {
890 let (decorated, reached, _transcript) = dispatcher_with_root(
892 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
893 command: "echo {workspace_root}".to_owned(),
894 }),
895 Ok("unused".to_owned()),
896 WorkspaceRoot::from_resolution(Ok(std::path::PathBuf::from("/absolute/with$dollar"))),
897 );
898 let handle =
899 tokio::task::spawn_blocking(move || decorated.dispatch(request("provision", "{}")));
900 let Err(error) = handle.await? else {
901 return Err("a shape-changing root must refuse a placeholder-bearing body".into());
902 };
903 assert!(error.starts_with("terminal:"), "{error}");
904 assert!(
905 error.contains("provision"),
906 "the refusal must name the action: {error}"
907 );
908 assert!(
909 error.contains("would change the parsed shape"),
910 "the refusal must carry the shape-changing diagnosis: {error}"
911 );
912 assert!(
913 reached_names(&reached).is_empty(),
914 "a refused body must not fall through to the worker path"
915 );
916 Ok(())
917 }
918
919 #[tokio::test(flavor = "multi_thread")]
920 async fn an_uncreatable_root_refuses_terminally_naming_the_action() -> TestResult {
921 let scratch = tempfile::tempdir()?;
923 let file = scratch.path().join("occupied");
924 std::fs::write(&file, b"not a directory")?;
925 let (decorated, reached, _transcript) = dispatcher_with_root(
926 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
927 command: "echo {workspace_root}".to_owned(),
928 }),
929 Ok("unused".to_owned()),
930 WorkspaceRoot::from_resolution(Ok(file.join("clones"))),
931 );
932 let handle =
933 tokio::task::spawn_blocking(move || decorated.dispatch(request("provision", "{}")));
934 let Err(error) = handle.await? else {
935 return Err("an uncreatable root must refuse a placeholder-bearing body".into());
936 };
937 assert!(error.starts_with("terminal:"), "{error}");
938 assert!(
939 error.contains("provision"),
940 "the refusal must name the action: {error}"
941 );
942 assert!(
943 error.contains("could not be created"),
944 "the refusal must carry the creation-failure diagnosis: {error}"
945 );
946 assert!(
947 reached_names(&reached).is_empty(),
948 "a refused body must not fall through to the worker path"
949 );
950 Ok(())
951 }
952
953 #[tokio::test(flavor = "multi_thread")]
954 async fn a_body_without_the_placeholder_is_untouched_by_resolution_failure() -> TestResult {
955 let (decorated, _reached, _transcript) = dispatcher_with_root(
956 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
957 command: "echo $greeting".to_owned(),
958 }),
959 Ok("unused".to_owned()),
960 WorkspaceRoot::from_resolution(Err(WorkspaceRootError::Unresolvable {
961 reason: "cannot resolve Aion home: set AION_HOME or HOME".to_owned(),
962 })),
963 );
964 let handle = tokio::task::spawn_blocking(move || {
965 decorated.dispatch(request("greet", "{\"greeting\":\"still served\"}"))
966 });
967 let result = handle.await?;
968 let encoded = result.map_err(|error| format!("declared command failed: {error}"))?;
969 let outcome: serde_json::Value = serde_json::from_str(&encoded)?;
970 assert_eq!(outcome["stdout"], "still served");
971 Ok(())
972 }
973
974 #[tokio::test(flavor = "multi_thread")]
975 async fn an_unreadable_catalog_delegates_to_the_worker_path() -> TestResult {
976 let (decorated, reached) = dispatcher(
977 DeclaredBodyLookup::Unreadable("catalog offline".to_owned()),
978 Ok("\"served anyway\"".to_owned()),
979 );
980 let handle =
981 tokio::task::spawn_blocking(move || decorated.dispatch(request("resilient", "{}")));
982 let result = handle.await?;
983 assert_eq!(result, Ok("\"served anyway\"".to_owned()));
984 assert_eq!(reached_names(&reached), vec!["resilient".to_owned()]);
985 Ok(())
986 }
987
988 #[test]
989 fn non_object_input_is_refused_terminally_by_shape() {
990 for (input, kind) in [
991 ("[1,2]", "an array"),
992 ("\"text\"", "a string"),
993 ("3", "a number"),
994 ("null", "null"),
995 ("true", "a boolean"),
996 ] {
997 let Err(error) = decode_arguments(input) else {
998 unreachable_refusal(input);
999 return;
1000 };
1001 assert!(error.starts_with("terminal:"), "{error}");
1002 assert!(error.contains(kind), "{error} must name {kind}");
1003 }
1004 }
1005
1006 fn unreachable_refusal(input: &str) {
1008 assert!(
1009 input.is_empty(),
1010 "input `{input}` must have been refused by shape"
1011 );
1012 }
1013
1014 #[tokio::test(flavor = "multi_thread")]
1022 async fn the_reader_is_asked_about_the_run_that_is_dispatching() -> TestResult {
1023 let seen = Arc::new(Mutex::new(Vec::new()));
1024 let bodies = DeclaredBodySource::default();
1025 bodies.install(Arc::new(RecordingBodies {
1026 seen: Arc::clone(&seen),
1027 }));
1028 let reached = Arc::new(Mutex::new(Vec::new()));
1029 let decorated = DeclaredCommandDispatcher::new(
1030 Arc::new(RecordingInner {
1031 reached: Arc::clone(&reached),
1032 reply: Ok("\"worker-served\"".to_owned()),
1033 }),
1034 bodies,
1035 tokio::runtime::Handle::current(),
1036 WorkspaceRoot::from_resolution(Ok(std::env::temp_dir())),
1037 ActivityEventPublisher::new(
1038 Arc::new(aion_store::InMemoryObservabilityStore::default()),
1039 TRANSCRIPT_CAPACITY,
1040 crate::activity_publisher::TranscriptBatchPolicy::UNBATCHED,
1041 ),
1042 );
1043
1044 let dispatch = request("plain", "{}");
1045 let expected = (dispatch.workflow_id.clone(), dispatch.run_id.clone());
1046 let handle = tokio::task::spawn_blocking(move || decorated.dispatch(dispatch));
1047 handle
1048 .await?
1049 .map_err(|error| format!("dispatch failed: {error}"))?;
1050
1051 let observed = match seen.lock() {
1052 Ok(observed) => observed.clone(),
1053 Err(poisoned) => poisoned.into_inner().clone(),
1054 };
1055 assert_eq!(
1056 observed,
1057 vec![expected],
1058 "the body reader must be asked about the dispatching run itself"
1059 );
1060 Ok(())
1061 }
1062}