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]
120 pub fn body_for(
121 &self,
122 task_queue: &str,
123 action: &str,
124 run: DispatchingRun<'_>,
125 ) -> DeclaredBodyLookup {
126 self.inner.get().map_or(DeclaredBodyLookup::None, |source| {
127 source.body_for(task_queue, action, run)
128 })
129 }
130}
131
132pub struct EngineDeclaredBodies {
134 engine: Arc<aion::Engine>,
135}
136
137impl EngineDeclaredBodies {
138 #[must_use]
140 pub const fn new(engine: Arc<aion::Engine>) -> Self {
141 Self { engine }
142 }
143}
144
145impl std::fmt::Debug for EngineDeclaredBodies {
146 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
147 formatter.write_str("EngineDeclaredBodies")
148 }
149}
150
151impl EngineDeclaredBodies {
152 fn version_of(&self, run: DispatchingRun<'_>) -> Option<ContentHash> {
160 match self.engine.registry().get(run.workflow_id, run.run_id) {
161 Ok(Some(handle)) => Some(handle.loaded_version().clone()),
162 Ok(None) => {
163 tracing::warn!(
164 operation = "declared_command_dispatch",
165 workflow_id = %run.workflow_id,
166 run_id = %run.run_id,
167 "no registry handle for the dispatching run; resolving its body \
168 from the whole queue instead of from its own package version"
169 );
170 None
171 }
172 Err(error) => {
173 tracing::error!(
174 operation = "declared_command_dispatch",
175 workflow_id = %run.workflow_id,
176 run_id = %run.run_id,
177 %error,
178 "registry unreadable while resolving the dispatching run's version; \
179 resolving its body from the whole queue instead"
180 );
181 None
182 }
183 }
184 }
185}
186
187impl DeclaredBodies for EngineDeclaredBodies {
188 fn body_for(
189 &self,
190 task_queue: &str,
191 action: &str,
192 run: DispatchingRun<'_>,
193 ) -> DeclaredBodyLookup {
194 let contracts = match self.engine.worker_contracts_for_queue(task_queue) {
195 Ok(contracts) => contracts,
196 Err(error) => return DeclaredBodyLookup::Unreadable(error.to_string()),
197 };
198 select_declared_body(&contracts, action, self.version_of(run).as_ref())
203 }
204}
205
206pub struct DeclaredCommandDispatcher {
211 inner: Arc<dyn ActivityDispatcher>,
212 bodies: DeclaredBodySource,
213 tokio: tokio::runtime::Handle,
214 workspace_root: WorkspaceRoot,
215 transcript: ActivityEventPublisher,
216}
217
218impl DeclaredCommandDispatcher {
219 #[must_use]
225 pub fn new(
226 inner: Arc<dyn ActivityDispatcher>,
227 bodies: DeclaredBodySource,
228 tokio: tokio::runtime::Handle,
229 workspace_root: WorkspaceRoot,
230 transcript: ActivityEventPublisher,
231 ) -> Self {
232 Self {
233 inner,
234 bodies,
235 tokio,
236 workspace_root,
237 transcript,
238 }
239 }
240
241 fn run_declared_command(
244 &self,
245 request: &ActivityDispatch,
246 command: &str,
247 ) -> Result<String, String> {
248 let arguments = decode_arguments(&request.input)?;
249 let expanded = self.workspace_root.expand(command).map_err(|error| {
255 format!(
256 "terminal:declared body for action `{name}` uses the {placeholder} \
257 placeholder and cannot dispatch: {error}",
258 name = request.name,
259 placeholder = WORKSPACE_ROOT_PLACEHOLDER,
260 )
261 })?;
262 if let Some(expansion) = &expanded {
263 tracing::info!(
264 operation = "declared_command_dispatch",
265 workflow_id = %request.workflow_id,
266 activity_id = %request.activity_id,
267 activity_name = %request.name,
268 task_queue = %request.task_queue,
269 attempt = request.attempt,
270 workspace_root = %expansion.workspace_root,
271 "expanded the workspace-root placeholder in the declared command"
272 );
273 }
274 let command = expanded
275 .as_ref()
276 .map_or(command, |expansion| expansion.command.as_str());
277 let action = ShellAction::new(command).map_err(|error| {
278 format!("terminal:declared command failed to parse at dispatch: {error}")
282 })?;
283 let (events, drain) = tokio::sync::mpsc::unbounded_channel();
288 let (context, cancellation) = aion_worker::ActivityContext::with_transcript(
289 request.workflow_id.clone(),
290 request.run_id.clone(),
291 request.activity_id.clone(),
292 request.attempt,
293 events,
294 );
295
296 tracing::info!(
297 operation = "declared_command_dispatch",
298 workflow_id = %request.workflow_id,
299 activity_id = %request.activity_id,
300 activity_name = %request.name,
301 task_queue = %request.task_queue,
302 attempt = request.attempt,
303 "executing declared action body at the server"
304 );
305
306 let transcript = self.transcript.clone();
307 let outcome = self.tokio.block_on(async move {
308 let pump = tokio::spawn(publish_declared_transcript(transcript, drain));
309 let outcome = action.run(&arguments, &context).await;
310 drop(context);
312 if let Err(error) = pump.await {
313 tracing::warn!(
314 %error,
315 operation = "declared_command_dispatch",
316 "declared command transcript: the publishing task ended abnormally; some \
317 output lines may not have been retained"
318 );
319 }
320 outcome
321 });
322 drop(cancellation);
326
327 match outcome {
328 Ok(result) => serde_json::to_string(&result).map_err(|error| {
329 format!("terminal:declared command result failed to encode: {error}")
330 }),
331 Err(failure) => {
332 let prefix = match failure.classification() {
333 aion_worker::Classification::Retryable => "retryable",
334 aion_worker::Classification::Terminal => "terminal",
335 };
336 Err(format!("{prefix}:{}", failure.message()))
337 }
338 }
339 }
340}
341
342impl std::fmt::Debug for DeclaredCommandDispatcher {
343 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
344 formatter
345 .debug_struct("DeclaredCommandDispatcher")
346 .field("bodies", &self.bodies)
347 .finish_non_exhaustive()
348 }
349}
350
351impl ActivityDispatcher for DeclaredCommandDispatcher {
352 fn dispatch(&self, request: ActivityDispatch) -> Result<String, String> {
353 let run = DispatchingRun {
354 workflow_id: &request.workflow_id,
355 run_id: &request.run_id,
356 };
357 match self
358 .bodies
359 .body_for(&request.task_queue, &request.name, run)
360 {
361 DeclaredBodyLookup::None => self.inner.dispatch(request),
362 DeclaredBodyLookup::Unreadable(reason) => {
363 tracing::error!(
367 operation = "declared_command_dispatch",
368 workflow_id = %request.workflow_id,
369 activity_name = %request.name,
370 task_queue = %request.task_queue,
371 %reason,
372 "declared-body catalog read failed; delegating to the worker path"
373 );
374 self.inner.dispatch(request)
375 }
376 DeclaredBodyLookup::Ambiguous { declaring } => Err(ambiguous_body_refusal(
377 &request.name,
378 &request.task_queue,
379 &declaring,
380 )),
381 DeclaredBodyLookup::Declared(ActionBodyContract::Run { command }) => {
382 self.run_declared_command(&request, &command)
383 }
384 }
385 }
386}
387
388fn decode_arguments(input: &str) -> Result<BTreeMap<String, serde_json::Value>, String> {
395 let value: serde_json::Value = serde_json::from_str(input)
396 .map_err(|error| format!("terminal:declared command input is not valid JSON: {error}"))?;
397 match value {
398 serde_json::Value::Object(members) => Ok(members.into_iter().collect()),
399 other => Err(format!(
400 "terminal:declared command input must be a JSON object binding the action's \
401 parameters by name; got {}",
402 json_kind(&other)
403 )),
404 }
405}
406
407const fn json_kind(value: &serde_json::Value) -> &'static str {
409 match value {
410 serde_json::Value::Null => "null",
411 serde_json::Value::Bool(_) => "a boolean",
412 serde_json::Value::Number(_) => "a number",
413 serde_json::Value::String(_) => "a string",
414 serde_json::Value::Array(_) => "an array",
415 serde_json::Value::Object(_) => "an object",
416 }
417}
418
419#[cfg(test)]
420mod tests {
421 use std::collections::BTreeMap;
422 use std::sync::{Arc, Mutex};
423
424 use aion::{ActivityDispatch, ActivityDispatcher};
425 use aion_core::{ActivityId, RunId, WorkflowId};
426 use aion_package::ActionBodyContract;
427
428 use aion_core::ActivityEventKind;
429 use aion_store::ActivityStreamKey;
430
431 use super::super::workspace_root::{WorkspaceRoot, WorkspaceRootError};
432 use super::{
433 ActivityEventPublisher, DeclaredBodies, DeclaredBodyLookup, DeclaredBodySource,
434 DeclaredCommandDispatcher, DeclaringVersion, DispatchingRun, decode_arguments,
435 };
436
437 type TestResult = Result<(), Box<dyn std::error::Error>>;
441
442 struct RecordingInner {
444 reached: Arc<Mutex<Vec<String>>>,
445 reply: Result<String, String>,
446 }
447
448 impl ActivityDispatcher for RecordingInner {
449 fn dispatch(&self, request: ActivityDispatch) -> Result<String, String> {
450 match self.reached.lock() {
451 Ok(mut names) => names.push(request.name),
452 Err(poisoned) => poisoned.into_inner().push(request.name),
453 }
454 self.reply.clone()
455 }
456 }
457
458 struct FixedBodies {
459 lookup: DeclaredBodyLookup,
460 }
461
462 impl DeclaredBodies for FixedBodies {
463 fn body_for(
464 &self,
465 _task_queue: &str,
466 _action: &str,
467 _run: DispatchingRun<'_>,
468 ) -> DeclaredBodyLookup {
469 self.lookup.clone()
470 }
471 }
472
473 struct RecordingBodies {
480 seen: Arc<Mutex<Vec<(WorkflowId, RunId)>>>,
481 }
482
483 impl DeclaredBodies for RecordingBodies {
484 fn body_for(
485 &self,
486 _task_queue: &str,
487 _action: &str,
488 run: DispatchingRun<'_>,
489 ) -> DeclaredBodyLookup {
490 let observed = (run.workflow_id.clone(), run.run_id.clone());
491 match self.seen.lock() {
492 Ok(mut seen) => seen.push(observed),
493 Err(poisoned) => poisoned.into_inner().push(observed),
494 }
495 DeclaredBodyLookup::None
496 }
497 }
498
499 fn request(name: &str, input: &str) -> ActivityDispatch {
500 ActivityDispatch {
501 namespace: "default".to_owned(),
502 task_queue: "shell".to_owned(),
503 node: None,
504 workflow_id: WorkflowId::new_v4(),
505 run_id: RunId::new_v4(),
506 activity_id: ActivityId::from_sequence_position(1),
507 name: name.to_owned(),
508 input: input.to_owned(),
509 config: "{}".to_owned(),
510 attempt: 1,
511 labels: BTreeMap::new(),
512 advisory: false,
513 }
514 }
515
516 fn dispatcher(
517 lookup: DeclaredBodyLookup,
518 reply: Result<String, String>,
519 ) -> (DeclaredCommandDispatcher, Arc<Mutex<Vec<String>>>) {
520 let (decorated, reached, _transcript) = dispatcher_with_root(
524 lookup,
525 reply,
526 WorkspaceRoot::from_resolution(Ok(std::env::temp_dir())),
527 );
528 (decorated, reached)
529 }
530
531 const TRANSCRIPT_CAPACITY: std::num::NonZeroUsize = match std::num::NonZeroUsize::new(64) {
535 Some(capacity) => capacity,
536 None => std::num::NonZeroUsize::MIN,
537 };
538
539 fn dispatcher_with_root(
540 lookup: DeclaredBodyLookup,
541 reply: Result<String, String>,
542 workspace_root: WorkspaceRoot,
543 ) -> (
544 DeclaredCommandDispatcher,
545 Arc<Mutex<Vec<String>>>,
546 ActivityEventPublisher,
547 ) {
548 let reached = Arc::new(Mutex::new(Vec::new()));
549 let inner = RecordingInner {
550 reached: Arc::clone(&reached),
551 reply,
552 };
553 let bodies = DeclaredBodySource::default();
554 bodies.install(Arc::new(FixedBodies { lookup }));
555 let store: Arc<dyn aion_store::ObservabilityStore> =
556 Arc::new(aion_store::InMemoryObservabilityStore::default());
557 let transcript = ActivityEventPublisher::new(store, TRANSCRIPT_CAPACITY);
558 let decorated = DeclaredCommandDispatcher::new(
559 Arc::new(inner),
560 bodies,
561 tokio::runtime::Handle::current(),
562 workspace_root,
563 transcript.clone(),
564 );
565 (decorated, reached, transcript)
566 }
567
568 fn reached_names(reached: &Arc<Mutex<Vec<String>>>) -> Vec<String> {
569 match reached.lock() {
570 Ok(names) => names.clone(),
571 Err(poisoned) => poisoned.into_inner().clone(),
572 }
573 }
574
575 #[tokio::test(flavor = "multi_thread")]
576 async fn a_bodiless_action_is_delegated_untouched() -> TestResult {
577 let (decorated, reached) =
578 dispatcher(DeclaredBodyLookup::None, Ok("\"worker-served\"".to_owned()));
579 let handle =
580 tokio::task::spawn_blocking(move || decorated.dispatch(request("plain", "{}")));
581 let result = handle.await?;
582 assert_eq!(result, Ok("\"worker-served\"".to_owned()));
583 assert_eq!(reached_names(&reached), vec!["plain".to_owned()]);
584 Ok(())
585 }
586
587 #[tokio::test(flavor = "multi_thread")]
588 async fn a_declared_body_executes_without_touching_the_worker_path() -> TestResult {
589 let (decorated, reached) = dispatcher(
590 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
591 command: "echo $greeting".to_owned(),
592 }),
593 Err("terminal:the worker path must never be reached".to_owned()),
594 );
595 let handle = tokio::task::spawn_blocking(move || {
596 decorated.dispatch(request(
597 "greet",
598 "{\"greeting\":\"hello from the contract\"}",
599 ))
600 });
601 let result = handle.await?;
602 let encoded = result.map_err(|error| format!("declared command failed: {error}"))?;
603 let outcome: serde_json::Value = serde_json::from_str(&encoded)?;
604 assert_eq!(outcome["stdout"], "hello from the contract");
605 assert_eq!(outcome["exit_code"], 0);
606 assert!(
607 reached_names(&reached).is_empty(),
608 "the worker path must not be consulted for a bodied action"
609 );
610 Ok(())
611 }
612
613 #[tokio::test(flavor = "multi_thread")]
622 async fn a_declared_body_publishes_its_output_onto_the_transcript() -> TestResult {
623 let (decorated, reached, transcript) = dispatcher_with_root(
624 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
625 command: "sh -c 'echo one; echo two; echo warned >&2'".to_owned(),
626 }),
627 Err("terminal:the worker path must never be reached".to_owned()),
628 WorkspaceRoot::from_resolution(Ok(std::env::temp_dir())),
629 );
630 let dispatch = request("noisy", "{}");
631 let key = ActivityStreamKey::new(
632 dispatch.workflow_id.clone(),
633 dispatch.run_id.clone(),
634 dispatch.activity_id.clone(),
635 dispatch.attempt,
636 );
637
638 let handle = tokio::task::spawn_blocking(move || decorated.dispatch(dispatch));
639 let encoded = handle
640 .await?
641 .map_err(|error| format!("declared command failed: {error}"))?;
642
643 let outcome: serde_json::Value = serde_json::from_str(&encoded)?;
645 assert_eq!(outcome["stdout"], "one\ntwo");
646 assert_eq!(outcome["stderr"], "warned");
647 assert!(reached_names(&reached).is_empty());
648
649 let retained = transcript.replay_from(&key, 0).await?;
651 let lines = retained
652 .iter()
653 .map(|record| match &record.event.kind {
654 ActivityEventKind::Message { text, .. } => {
655 (record.event.agent_role.clone(), text.clone())
656 }
657 other => (record.event.agent_role.clone(), format!("{other:?}")),
658 })
659 .collect::<Vec<_>>();
660 assert!(
661 lines.contains(&("command stdout".to_owned(), "one".to_owned()))
662 && lines.contains(&("command stdout".to_owned(), "two".to_owned())),
663 "each stdout line must be its own transcript event: {lines:?}"
664 );
665 assert!(
666 lines.contains(&("command stderr".to_owned(), "warned".to_owned())),
667 "stderr must be on the transcript, labelled by its stream: {lines:?}"
668 );
669 let sequences = retained
671 .iter()
672 .map(|record| record.store_seq)
673 .collect::<Vec<_>>();
674 assert_eq!(
675 sequences,
676 (0..u64::try_from(retained.len())?).collect::<Vec<_>>(),
677 "the sequencer assigns a gap-free durable order"
678 );
679 Ok(())
680 }
681
682 #[tokio::test(flavor = "multi_thread")]
683 async fn a_failing_declared_command_reports_retryable_with_its_stderr() -> TestResult {
684 let (decorated, _reached) = dispatcher(
685 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
686 command: "sh -c 'echo boom >&2; exit 7'".to_owned(),
687 }),
688 Ok("unused".to_owned()),
689 );
690 let handle =
691 tokio::task::spawn_blocking(move || decorated.dispatch(request("fails", "{}")));
692 let Err(error) = handle.await? else {
693 return Err("a non-zero exit must fail the dispatch".into());
694 };
695 assert!(
696 error.starts_with("retryable:"),
697 "a non-zero exit is retryable by default: {error}"
698 );
699 assert!(
700 error.contains("boom"),
701 "stderr must ride the failure: {error}"
702 );
703 Ok(())
704 }
705
706 #[test]
716 fn the_printed_hash_parses_back_as_a_content_hash() -> TestResult {
717 let version = aion_package::ContentHash::from_bytes([0x5a; 32]);
718 let routed = aion_package::ContentHash::from_bytes([0xa5; 32]);
719 let refusal = super::ambiguous_body_refusal(
720 "find_repositories",
721 "local",
722 &[
723 DeclaringVersion {
724 content_hash: version.to_string(),
725 workflow_types: vec!["sweeper".to_owned()],
726 route_active: false,
727 body: 0,
728 },
729 DeclaringVersion {
730 content_hash: routed.to_string(),
731 workflow_types: vec!["sweeper".to_owned()],
732 route_active: true,
733 body: 1,
734 },
735 ],
736 );
737 let Some(command) = refusal.split("`aion unload sweeper ").nth(1) else {
738 return Err(format!("no unload command in the refusal: {refusal}").into());
739 };
740 let Some(printed) = command.split('`').next() else {
741 return Err(format!("the unload command is unterminated: {refusal}").into());
742 };
743 let parsed: aion_package::ContentHash = printed.parse()?;
744 assert_eq!(
745 parsed, version,
746 "the printed hash must round-trip to the version it names"
747 );
748 Ok(())
749 }
750
751 #[tokio::test(flavor = "multi_thread")]
752 async fn ambiguous_bodies_refuse_terminally_by_name() -> TestResult {
753 let superseded = "1111111111111111111111111111111111111111111111111111111111111111";
754 let routed = "2222222222222222222222222222222222222222222222222222222222222222";
755 let (decorated, reached) = dispatcher(
756 DeclaredBodyLookup::Ambiguous {
757 declaring: vec![
758 DeclaringVersion {
759 content_hash: superseded.to_owned(),
760 workflow_types: vec!["sweeper".to_owned()],
761 route_active: false,
762 body: 0,
763 },
764 DeclaringVersion {
765 content_hash: routed.to_owned(),
766 workflow_types: vec!["sweeper".to_owned()],
767 route_active: true,
768 body: 1,
769 },
770 ],
771 },
772 Ok(String::new()),
773 );
774 let handle = tokio::task::spawn_blocking(move || decorated.dispatch(request("torn", "{}")));
775 let Err(error) = handle.await? else {
776 return Err("ambiguous bodies must refuse".into());
777 };
778 assert!(error.starts_with("terminal:"), "{error}");
779 assert!(error.contains("torn"), "{error}");
780 assert!(
783 error.contains(&format!("`aion unload sweeper {superseded}`")),
784 "the dispatch refusal must name the version to retire: {error}"
785 );
786 assert!(reached_names(&reached).is_empty());
787 Ok(())
788 }
789
790 #[tokio::test(flavor = "multi_thread")]
791 async fn a_placeholder_bearing_body_executes_with_the_expanded_root() -> TestResult {
792 let scratch = tempfile::tempdir()?;
793 let root = scratch.path().join("clones");
794 let root_text = root.to_string_lossy().into_owned();
795 let (decorated, reached, _transcript) = dispatcher_with_root(
796 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
797 command: "echo {workspace_root}".to_owned(),
798 }),
799 Err("terminal:the worker path must never be reached".to_owned()),
800 WorkspaceRoot::from_resolution(Ok(root.clone())),
801 );
802 let handle =
803 tokio::task::spawn_blocking(move || decorated.dispatch(request("provision", "{}")));
804 let result = handle.await?;
805 let encoded = result.map_err(|error| format!("declared command failed: {error}"))?;
806 let outcome: serde_json::Value = serde_json::from_str(&encoded)?;
807 assert_eq!(
808 outcome["stdout"], root_text,
809 "the command must observe the server-resolved root as its argv word"
810 );
811 assert_eq!(outcome["exit_code"], 0);
812 assert!(
813 root.is_dir(),
814 "dispatching a placeholder-bearing body must create the missing root"
815 );
816 assert!(reached_names(&reached).is_empty());
817 Ok(())
818 }
819
820 #[tokio::test(flavor = "multi_thread")]
821 async fn a_placeholder_bearing_body_refuses_terminally_when_the_root_is_unresolved()
822 -> TestResult {
823 let (decorated, reached, _transcript) = dispatcher_with_root(
824 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
825 command: "echo {workspace_root}".to_owned(),
826 }),
827 Ok("unused".to_owned()),
828 WorkspaceRoot::from_resolution(Err(WorkspaceRootError::Unresolvable {
829 reason: "cannot resolve Aion home: set AION_HOME or HOME".to_owned(),
830 })),
831 );
832 let handle =
833 tokio::task::spawn_blocking(move || decorated.dispatch(request("provision", "{}")));
834 let Err(error) = handle.await? else {
835 return Err("an unresolved root must refuse a placeholder-bearing body".into());
836 };
837 assert!(error.starts_with("terminal:"), "{error}");
838 assert!(
839 error.contains("provision"),
840 "the refusal must name the action: {error}"
841 );
842 assert!(
843 error.contains("cannot resolve Aion home"),
844 "the refusal must carry the resolution failure's reason: {error}"
845 );
846 assert!(
847 reached_names(&reached).is_empty(),
848 "a refused body must not fall through to the worker path"
849 );
850 Ok(())
851 }
852
853 #[tokio::test(flavor = "multi_thread")]
854 async fn a_shape_changing_root_refuses_terminally_naming_the_action() -> TestResult {
855 let (decorated, reached, _transcript) = dispatcher_with_root(
857 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
858 command: "echo {workspace_root}".to_owned(),
859 }),
860 Ok("unused".to_owned()),
861 WorkspaceRoot::from_resolution(Ok(std::path::PathBuf::from("/absolute/with$dollar"))),
862 );
863 let handle =
864 tokio::task::spawn_blocking(move || decorated.dispatch(request("provision", "{}")));
865 let Err(error) = handle.await? else {
866 return Err("a shape-changing root must refuse a placeholder-bearing body".into());
867 };
868 assert!(error.starts_with("terminal:"), "{error}");
869 assert!(
870 error.contains("provision"),
871 "the refusal must name the action: {error}"
872 );
873 assert!(
874 error.contains("would change the parsed shape"),
875 "the refusal must carry the shape-changing diagnosis: {error}"
876 );
877 assert!(
878 reached_names(&reached).is_empty(),
879 "a refused body must not fall through to the worker path"
880 );
881 Ok(())
882 }
883
884 #[tokio::test(flavor = "multi_thread")]
885 async fn an_uncreatable_root_refuses_terminally_naming_the_action() -> TestResult {
886 let scratch = tempfile::tempdir()?;
888 let file = scratch.path().join("occupied");
889 std::fs::write(&file, b"not a directory")?;
890 let (decorated, reached, _transcript) = dispatcher_with_root(
891 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
892 command: "echo {workspace_root}".to_owned(),
893 }),
894 Ok("unused".to_owned()),
895 WorkspaceRoot::from_resolution(Ok(file.join("clones"))),
896 );
897 let handle =
898 tokio::task::spawn_blocking(move || decorated.dispatch(request("provision", "{}")));
899 let Err(error) = handle.await? else {
900 return Err("an uncreatable root must refuse a placeholder-bearing body".into());
901 };
902 assert!(error.starts_with("terminal:"), "{error}");
903 assert!(
904 error.contains("provision"),
905 "the refusal must name the action: {error}"
906 );
907 assert!(
908 error.contains("could not be created"),
909 "the refusal must carry the creation-failure diagnosis: {error}"
910 );
911 assert!(
912 reached_names(&reached).is_empty(),
913 "a refused body must not fall through to the worker path"
914 );
915 Ok(())
916 }
917
918 #[tokio::test(flavor = "multi_thread")]
919 async fn a_body_without_the_placeholder_is_untouched_by_resolution_failure() -> TestResult {
920 let (decorated, _reached, _transcript) = dispatcher_with_root(
921 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
922 command: "echo $greeting".to_owned(),
923 }),
924 Ok("unused".to_owned()),
925 WorkspaceRoot::from_resolution(Err(WorkspaceRootError::Unresolvable {
926 reason: "cannot resolve Aion home: set AION_HOME or HOME".to_owned(),
927 })),
928 );
929 let handle = tokio::task::spawn_blocking(move || {
930 decorated.dispatch(request("greet", "{\"greeting\":\"still served\"}"))
931 });
932 let result = handle.await?;
933 let encoded = result.map_err(|error| format!("declared command failed: {error}"))?;
934 let outcome: serde_json::Value = serde_json::from_str(&encoded)?;
935 assert_eq!(outcome["stdout"], "still served");
936 Ok(())
937 }
938
939 #[tokio::test(flavor = "multi_thread")]
940 async fn an_unreadable_catalog_delegates_to_the_worker_path() -> TestResult {
941 let (decorated, reached) = dispatcher(
942 DeclaredBodyLookup::Unreadable("catalog offline".to_owned()),
943 Ok("\"served anyway\"".to_owned()),
944 );
945 let handle =
946 tokio::task::spawn_blocking(move || decorated.dispatch(request("resilient", "{}")));
947 let result = handle.await?;
948 assert_eq!(result, Ok("\"served anyway\"".to_owned()));
949 assert_eq!(reached_names(&reached), vec!["resilient".to_owned()]);
950 Ok(())
951 }
952
953 #[test]
954 fn non_object_input_is_refused_terminally_by_shape() {
955 for (input, kind) in [
956 ("[1,2]", "an array"),
957 ("\"text\"", "a string"),
958 ("3", "a number"),
959 ("null", "null"),
960 ("true", "a boolean"),
961 ] {
962 let Err(error) = decode_arguments(input) else {
963 unreachable_refusal(input);
964 return;
965 };
966 assert!(error.starts_with("terminal:"), "{error}");
967 assert!(error.contains(kind), "{error} must name {kind}");
968 }
969 }
970
971 fn unreachable_refusal(input: &str) {
973 assert!(
974 input.is_empty(),
975 "input `{input}` must have been refused by shape"
976 );
977 }
978
979 #[tokio::test(flavor = "multi_thread")]
987 async fn the_reader_is_asked_about_the_run_that_is_dispatching() -> TestResult {
988 let seen = Arc::new(Mutex::new(Vec::new()));
989 let bodies = DeclaredBodySource::default();
990 bodies.install(Arc::new(RecordingBodies {
991 seen: Arc::clone(&seen),
992 }));
993 let reached = Arc::new(Mutex::new(Vec::new()));
994 let decorated = DeclaredCommandDispatcher::new(
995 Arc::new(RecordingInner {
996 reached: Arc::clone(&reached),
997 reply: Ok("\"worker-served\"".to_owned()),
998 }),
999 bodies,
1000 tokio::runtime::Handle::current(),
1001 WorkspaceRoot::from_resolution(Ok(std::env::temp_dir())),
1002 ActivityEventPublisher::new(
1003 Arc::new(aion_store::InMemoryObservabilityStore::default()),
1004 TRANSCRIPT_CAPACITY,
1005 ),
1006 );
1007
1008 let dispatch = request("plain", "{}");
1009 let expected = (dispatch.workflow_id.clone(), dispatch.run_id.clone());
1010 let handle = tokio::task::spawn_blocking(move || decorated.dispatch(dispatch));
1011 handle
1012 .await?
1013 .map_err(|error| format!("dispatch failed: {error}"))?;
1014
1015 let observed = match seen.lock() {
1016 Ok(observed) => observed.clone(),
1017 Err(poisoned) => poisoned.into_inner().clone(),
1018 };
1019 assert_eq!(
1020 observed,
1021 vec![expected],
1022 "the body reader must be asked about the dispatching run itself"
1023 );
1024 Ok(())
1025 }
1026}