1use std::collections::BTreeMap;
16use std::sync::{Arc, OnceLock};
17
18use aion::{ActivityDispatch, ActivityDispatcher};
19use aion_package::{ActionBodyContract, ContentHash};
20use aion_worker::shell::ShellAction;
21
22use super::declared_body_ambiguity::{DeclaringVersion, ambiguous_body_refusal};
23use super::declared_body_selection::select_declared_body;
24use super::workspace_root::{WORKSPACE_ROOT_PLACEHOLDER, WorkspaceRoot};
25
26#[derive(Clone, Debug)]
28pub enum DeclaredBodyLookup {
29 None,
32 Declared(ActionBodyContract),
35 Ambiguous {
39 declaring: Vec<DeclaringVersion>,
44 },
45 Unreadable(String),
48}
49
50#[derive(Clone, Copy, Debug)]
56pub struct DispatchingRun<'a> {
57 pub workflow_id: &'a aion_core::WorkflowId,
59 pub run_id: &'a aion_core::RunId,
61}
62
63pub trait DeclaredBodies: Send + Sync {
65 fn body_for(
68 &self,
69 task_queue: &str,
70 action: &str,
71 run: DispatchingRun<'_>,
72 ) -> DeclaredBodyLookup;
73}
74
75#[derive(Clone, Default)]
82pub struct DeclaredBodySource {
83 inner: Arc<OnceLock<Arc<dyn DeclaredBodies>>>,
84}
85
86impl std::fmt::Debug for DeclaredBodySource {
87 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88 formatter
89 .debug_struct("DeclaredBodySource")
90 .field("installed", &self.inner.get().is_some())
91 .finish()
92 }
93}
94
95impl DeclaredBodySource {
96 pub fn install(&self, source: Arc<dyn DeclaredBodies>) {
99 if self.inner.set(source).is_err() {
100 tracing::warn!("declared body source already installed; ignoring duplicate set");
101 }
102 }
103
104 #[must_use]
108 pub fn body_for(
109 &self,
110 task_queue: &str,
111 action: &str,
112 run: DispatchingRun<'_>,
113 ) -> DeclaredBodyLookup {
114 self.inner.get().map_or(DeclaredBodyLookup::None, |source| {
115 source.body_for(task_queue, action, run)
116 })
117 }
118}
119
120pub struct EngineDeclaredBodies {
122 engine: Arc<aion::Engine>,
123}
124
125impl EngineDeclaredBodies {
126 #[must_use]
128 pub const fn new(engine: Arc<aion::Engine>) -> Self {
129 Self { engine }
130 }
131}
132
133impl std::fmt::Debug for EngineDeclaredBodies {
134 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135 formatter.write_str("EngineDeclaredBodies")
136 }
137}
138
139impl EngineDeclaredBodies {
140 fn version_of(&self, run: DispatchingRun<'_>) -> Option<ContentHash> {
148 match self.engine.registry().get(run.workflow_id, run.run_id) {
149 Ok(Some(handle)) => Some(handle.loaded_version().clone()),
150 Ok(None) => {
151 tracing::warn!(
152 operation = "declared_command_dispatch",
153 workflow_id = %run.workflow_id,
154 run_id = %run.run_id,
155 "no registry handle for the dispatching run; resolving its body \
156 from the whole queue instead of from its own package version"
157 );
158 None
159 }
160 Err(error) => {
161 tracing::error!(
162 operation = "declared_command_dispatch",
163 workflow_id = %run.workflow_id,
164 run_id = %run.run_id,
165 %error,
166 "registry unreadable while resolving the dispatching run's version; \
167 resolving its body from the whole queue instead"
168 );
169 None
170 }
171 }
172 }
173}
174
175impl DeclaredBodies for EngineDeclaredBodies {
176 fn body_for(
177 &self,
178 task_queue: &str,
179 action: &str,
180 run: DispatchingRun<'_>,
181 ) -> DeclaredBodyLookup {
182 let contracts = match self.engine.worker_contracts_for_queue(task_queue) {
183 Ok(contracts) => contracts,
184 Err(error) => return DeclaredBodyLookup::Unreadable(error.to_string()),
185 };
186 select_declared_body(&contracts, action, self.version_of(run).as_ref())
191 }
192}
193
194pub struct DeclaredCommandDispatcher {
199 inner: Arc<dyn ActivityDispatcher>,
200 bodies: DeclaredBodySource,
201 tokio: tokio::runtime::Handle,
202 workspace_root: WorkspaceRoot,
203}
204
205impl DeclaredCommandDispatcher {
206 #[must_use]
210 pub fn new(
211 inner: Arc<dyn ActivityDispatcher>,
212 bodies: DeclaredBodySource,
213 tokio: tokio::runtime::Handle,
214 workspace_root: WorkspaceRoot,
215 ) -> Self {
216 Self {
217 inner,
218 bodies,
219 tokio,
220 workspace_root,
221 }
222 }
223
224 fn run_declared_command(
227 &self,
228 request: &ActivityDispatch,
229 command: &str,
230 ) -> Result<String, String> {
231 let arguments = decode_arguments(&request.input)?;
232 let expanded = self.workspace_root.expand(command).map_err(|error| {
238 format!(
239 "terminal:declared body for action `{name}` uses the {placeholder} \
240 placeholder and cannot dispatch: {error}",
241 name = request.name,
242 placeholder = WORKSPACE_ROOT_PLACEHOLDER,
243 )
244 })?;
245 if let Some(expansion) = &expanded {
246 tracing::info!(
247 operation = "declared_command_dispatch",
248 workflow_id = %request.workflow_id,
249 activity_id = %request.activity_id,
250 activity_name = %request.name,
251 task_queue = %request.task_queue,
252 attempt = request.attempt,
253 workspace_root = %expansion.workspace_root,
254 "expanded the workspace-root placeholder in the declared command"
255 );
256 }
257 let command = expanded
258 .as_ref()
259 .map_or(command, |expansion| expansion.command.as_str());
260 let action = ShellAction::new(command).map_err(|error| {
261 format!("terminal:declared command failed to parse at dispatch: {error}")
265 })?;
266 let (context, cancellation) =
267 aion_worker::ActivityContext::new(request.activity_id.clone(), request.attempt);
268
269 tracing::info!(
270 operation = "declared_command_dispatch",
271 workflow_id = %request.workflow_id,
272 activity_id = %request.activity_id,
273 activity_name = %request.name,
274 task_queue = %request.task_queue,
275 attempt = request.attempt,
276 "executing declared action body at the server"
277 );
278
279 let outcome = self.tokio.block_on(action.run(&arguments, &context));
280 drop(cancellation);
284
285 match outcome {
286 Ok(result) => serde_json::to_string(&result).map_err(|error| {
287 format!("terminal:declared command result failed to encode: {error}")
288 }),
289 Err(failure) => {
290 let prefix = match failure.classification() {
291 aion_worker::Classification::Retryable => "retryable",
292 aion_worker::Classification::Terminal => "terminal",
293 };
294 Err(format!("{prefix}:{}", failure.message()))
295 }
296 }
297 }
298}
299
300impl std::fmt::Debug for DeclaredCommandDispatcher {
301 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
302 formatter
303 .debug_struct("DeclaredCommandDispatcher")
304 .field("bodies", &self.bodies)
305 .finish_non_exhaustive()
306 }
307}
308
309impl ActivityDispatcher for DeclaredCommandDispatcher {
310 fn dispatch(&self, request: ActivityDispatch) -> Result<String, String> {
311 let run = DispatchingRun {
312 workflow_id: &request.workflow_id,
313 run_id: &request.run_id,
314 };
315 match self
316 .bodies
317 .body_for(&request.task_queue, &request.name, run)
318 {
319 DeclaredBodyLookup::None => self.inner.dispatch(request),
320 DeclaredBodyLookup::Unreadable(reason) => {
321 tracing::error!(
325 operation = "declared_command_dispatch",
326 workflow_id = %request.workflow_id,
327 activity_name = %request.name,
328 task_queue = %request.task_queue,
329 %reason,
330 "declared-body catalog read failed; delegating to the worker path"
331 );
332 self.inner.dispatch(request)
333 }
334 DeclaredBodyLookup::Ambiguous { declaring } => Err(ambiguous_body_refusal(
335 &request.name,
336 &request.task_queue,
337 &declaring,
338 )),
339 DeclaredBodyLookup::Declared(ActionBodyContract::Run { command }) => {
340 self.run_declared_command(&request, &command)
341 }
342 }
343 }
344}
345
346fn decode_arguments(input: &str) -> Result<BTreeMap<String, serde_json::Value>, String> {
353 let value: serde_json::Value = serde_json::from_str(input)
354 .map_err(|error| format!("terminal:declared command input is not valid JSON: {error}"))?;
355 match value {
356 serde_json::Value::Object(members) => Ok(members.into_iter().collect()),
357 other => Err(format!(
358 "terminal:declared command input must be a JSON object binding the action's \
359 parameters by name; got {}",
360 json_kind(&other)
361 )),
362 }
363}
364
365const fn json_kind(value: &serde_json::Value) -> &'static str {
367 match value {
368 serde_json::Value::Null => "null",
369 serde_json::Value::Bool(_) => "a boolean",
370 serde_json::Value::Number(_) => "a number",
371 serde_json::Value::String(_) => "a string",
372 serde_json::Value::Array(_) => "an array",
373 serde_json::Value::Object(_) => "an object",
374 }
375}
376
377#[cfg(test)]
378mod tests {
379 use std::collections::BTreeMap;
380 use std::sync::{Arc, Mutex};
381
382 use aion::{ActivityDispatch, ActivityDispatcher};
383 use aion_core::{ActivityId, RunId, WorkflowId};
384 use aion_package::ActionBodyContract;
385
386 use super::super::workspace_root::{WorkspaceRoot, WorkspaceRootError};
387 use super::{
388 DeclaredBodies, DeclaredBodyLookup, DeclaredBodySource, DeclaredCommandDispatcher,
389 DeclaringVersion, DispatchingRun, decode_arguments,
390 };
391
392 type TestResult = Result<(), Box<dyn std::error::Error>>;
396
397 struct RecordingInner {
399 reached: Arc<Mutex<Vec<String>>>,
400 reply: Result<String, String>,
401 }
402
403 impl ActivityDispatcher for RecordingInner {
404 fn dispatch(&self, request: ActivityDispatch) -> Result<String, String> {
405 match self.reached.lock() {
406 Ok(mut names) => names.push(request.name),
407 Err(poisoned) => poisoned.into_inner().push(request.name),
408 }
409 self.reply.clone()
410 }
411 }
412
413 struct FixedBodies {
414 lookup: DeclaredBodyLookup,
415 }
416
417 impl DeclaredBodies for FixedBodies {
418 fn body_for(
419 &self,
420 _task_queue: &str,
421 _action: &str,
422 _run: DispatchingRun<'_>,
423 ) -> DeclaredBodyLookup {
424 self.lookup.clone()
425 }
426 }
427
428 struct RecordingBodies {
435 seen: Arc<Mutex<Vec<(WorkflowId, RunId)>>>,
436 }
437
438 impl DeclaredBodies for RecordingBodies {
439 fn body_for(
440 &self,
441 _task_queue: &str,
442 _action: &str,
443 run: DispatchingRun<'_>,
444 ) -> DeclaredBodyLookup {
445 let observed = (run.workflow_id.clone(), run.run_id.clone());
446 match self.seen.lock() {
447 Ok(mut seen) => seen.push(observed),
448 Err(poisoned) => poisoned.into_inner().push(observed),
449 }
450 DeclaredBodyLookup::None
451 }
452 }
453
454 fn request(name: &str, input: &str) -> ActivityDispatch {
455 ActivityDispatch {
456 namespace: "default".to_owned(),
457 task_queue: "shell".to_owned(),
458 node: None,
459 workflow_id: WorkflowId::new_v4(),
460 run_id: RunId::new_v4(),
461 activity_id: ActivityId::from_sequence_position(1),
462 name: name.to_owned(),
463 input: input.to_owned(),
464 config: "{}".to_owned(),
465 attempt: 1,
466 labels: BTreeMap::new(),
467 advisory: false,
468 }
469 }
470
471 fn dispatcher(
472 lookup: DeclaredBodyLookup,
473 reply: Result<String, String>,
474 ) -> (DeclaredCommandDispatcher, Arc<Mutex<Vec<String>>>) {
475 dispatcher_with_root(
479 lookup,
480 reply,
481 WorkspaceRoot::from_resolution(Ok(std::env::temp_dir())),
482 )
483 }
484
485 fn dispatcher_with_root(
486 lookup: DeclaredBodyLookup,
487 reply: Result<String, String>,
488 workspace_root: WorkspaceRoot,
489 ) -> (DeclaredCommandDispatcher, Arc<Mutex<Vec<String>>>) {
490 let reached = Arc::new(Mutex::new(Vec::new()));
491 let inner = RecordingInner {
492 reached: Arc::clone(&reached),
493 reply,
494 };
495 let bodies = DeclaredBodySource::default();
496 bodies.install(Arc::new(FixedBodies { lookup }));
497 let decorated = DeclaredCommandDispatcher::new(
498 Arc::new(inner),
499 bodies,
500 tokio::runtime::Handle::current(),
501 workspace_root,
502 );
503 (decorated, reached)
504 }
505
506 fn reached_names(reached: &Arc<Mutex<Vec<String>>>) -> Vec<String> {
507 match reached.lock() {
508 Ok(names) => names.clone(),
509 Err(poisoned) => poisoned.into_inner().clone(),
510 }
511 }
512
513 #[tokio::test(flavor = "multi_thread")]
514 async fn a_bodiless_action_is_delegated_untouched() -> TestResult {
515 let (decorated, reached) =
516 dispatcher(DeclaredBodyLookup::None, Ok("\"worker-served\"".to_owned()));
517 let handle =
518 tokio::task::spawn_blocking(move || decorated.dispatch(request("plain", "{}")));
519 let result = handle.await?;
520 assert_eq!(result, Ok("\"worker-served\"".to_owned()));
521 assert_eq!(reached_names(&reached), vec!["plain".to_owned()]);
522 Ok(())
523 }
524
525 #[tokio::test(flavor = "multi_thread")]
526 async fn a_declared_body_executes_without_touching_the_worker_path() -> TestResult {
527 let (decorated, reached) = dispatcher(
528 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
529 command: "echo $greeting".to_owned(),
530 }),
531 Err("terminal:the worker path must never be reached".to_owned()),
532 );
533 let handle = tokio::task::spawn_blocking(move || {
534 decorated.dispatch(request(
535 "greet",
536 "{\"greeting\":\"hello from the contract\"}",
537 ))
538 });
539 let result = handle.await?;
540 let encoded = result.map_err(|error| format!("declared command failed: {error}"))?;
541 let outcome: serde_json::Value = serde_json::from_str(&encoded)?;
542 assert_eq!(outcome["stdout"], "hello from the contract");
543 assert_eq!(outcome["exit_code"], 0);
544 assert!(
545 reached_names(&reached).is_empty(),
546 "the worker path must not be consulted for a bodied action"
547 );
548 Ok(())
549 }
550
551 #[tokio::test(flavor = "multi_thread")]
552 async fn a_failing_declared_command_reports_retryable_with_its_stderr() -> TestResult {
553 let (decorated, _reached) = dispatcher(
554 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
555 command: "sh -c 'echo boom >&2; exit 7'".to_owned(),
556 }),
557 Ok("unused".to_owned()),
558 );
559 let handle =
560 tokio::task::spawn_blocking(move || decorated.dispatch(request("fails", "{}")));
561 let Err(error) = handle.await? else {
562 return Err("a non-zero exit must fail the dispatch".into());
563 };
564 assert!(
565 error.starts_with("retryable:"),
566 "a non-zero exit is retryable by default: {error}"
567 );
568 assert!(
569 error.contains("boom"),
570 "stderr must ride the failure: {error}"
571 );
572 Ok(())
573 }
574
575 #[test]
585 fn the_printed_hash_parses_back_as_a_content_hash() -> TestResult {
586 let version = aion_package::ContentHash::from_bytes([0x5a; 32]);
587 let routed = aion_package::ContentHash::from_bytes([0xa5; 32]);
588 let refusal = super::ambiguous_body_refusal(
589 "find_repositories",
590 "local",
591 &[
592 DeclaringVersion {
593 content_hash: version.to_string(),
594 workflow_types: vec!["sweeper".to_owned()],
595 route_active: false,
596 body: 0,
597 },
598 DeclaringVersion {
599 content_hash: routed.to_string(),
600 workflow_types: vec!["sweeper".to_owned()],
601 route_active: true,
602 body: 1,
603 },
604 ],
605 );
606 let Some(command) = refusal.split("`aion unload sweeper ").nth(1) else {
607 return Err(format!("no unload command in the refusal: {refusal}").into());
608 };
609 let Some(printed) = command.split('`').next() else {
610 return Err(format!("the unload command is unterminated: {refusal}").into());
611 };
612 let parsed: aion_package::ContentHash = printed.parse()?;
613 assert_eq!(
614 parsed, version,
615 "the printed hash must round-trip to the version it names"
616 );
617 Ok(())
618 }
619
620 #[tokio::test(flavor = "multi_thread")]
621 async fn ambiguous_bodies_refuse_terminally_by_name() -> TestResult {
622 let superseded = "1111111111111111111111111111111111111111111111111111111111111111";
623 let routed = "2222222222222222222222222222222222222222222222222222222222222222";
624 let (decorated, reached) = dispatcher(
625 DeclaredBodyLookup::Ambiguous {
626 declaring: vec![
627 DeclaringVersion {
628 content_hash: superseded.to_owned(),
629 workflow_types: vec!["sweeper".to_owned()],
630 route_active: false,
631 body: 0,
632 },
633 DeclaringVersion {
634 content_hash: routed.to_owned(),
635 workflow_types: vec!["sweeper".to_owned()],
636 route_active: true,
637 body: 1,
638 },
639 ],
640 },
641 Ok(String::new()),
642 );
643 let handle = tokio::task::spawn_blocking(move || decorated.dispatch(request("torn", "{}")));
644 let Err(error) = handle.await? else {
645 return Err("ambiguous bodies must refuse".into());
646 };
647 assert!(error.starts_with("terminal:"), "{error}");
648 assert!(error.contains("torn"), "{error}");
649 assert!(
652 error.contains(&format!("`aion unload sweeper {superseded}`")),
653 "the dispatch refusal must name the version to retire: {error}"
654 );
655 assert!(reached_names(&reached).is_empty());
656 Ok(())
657 }
658
659 #[tokio::test(flavor = "multi_thread")]
660 async fn a_placeholder_bearing_body_executes_with_the_expanded_root() -> TestResult {
661 let scratch = tempfile::tempdir()?;
662 let root = scratch.path().join("clones");
663 let root_text = root.to_string_lossy().into_owned();
664 let (decorated, reached) = dispatcher_with_root(
665 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
666 command: "echo {workspace_root}".to_owned(),
667 }),
668 Err("terminal:the worker path must never be reached".to_owned()),
669 WorkspaceRoot::from_resolution(Ok(root.clone())),
670 );
671 let handle =
672 tokio::task::spawn_blocking(move || decorated.dispatch(request("provision", "{}")));
673 let result = handle.await?;
674 let encoded = result.map_err(|error| format!("declared command failed: {error}"))?;
675 let outcome: serde_json::Value = serde_json::from_str(&encoded)?;
676 assert_eq!(
677 outcome["stdout"], root_text,
678 "the command must observe the server-resolved root as its argv word"
679 );
680 assert_eq!(outcome["exit_code"], 0);
681 assert!(
682 root.is_dir(),
683 "dispatching a placeholder-bearing body must create the missing root"
684 );
685 assert!(reached_names(&reached).is_empty());
686 Ok(())
687 }
688
689 #[tokio::test(flavor = "multi_thread")]
690 async fn a_placeholder_bearing_body_refuses_terminally_when_the_root_is_unresolved()
691 -> TestResult {
692 let (decorated, reached) = dispatcher_with_root(
693 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
694 command: "echo {workspace_root}".to_owned(),
695 }),
696 Ok("unused".to_owned()),
697 WorkspaceRoot::from_resolution(Err(WorkspaceRootError::Unresolvable {
698 reason: "cannot resolve Aion home: set AION_HOME or HOME".to_owned(),
699 })),
700 );
701 let handle =
702 tokio::task::spawn_blocking(move || decorated.dispatch(request("provision", "{}")));
703 let Err(error) = handle.await? else {
704 return Err("an unresolved root must refuse a placeholder-bearing body".into());
705 };
706 assert!(error.starts_with("terminal:"), "{error}");
707 assert!(
708 error.contains("provision"),
709 "the refusal must name the action: {error}"
710 );
711 assert!(
712 error.contains("cannot resolve Aion home"),
713 "the refusal must carry the resolution failure's reason: {error}"
714 );
715 assert!(
716 reached_names(&reached).is_empty(),
717 "a refused body must not fall through to the worker path"
718 );
719 Ok(())
720 }
721
722 #[tokio::test(flavor = "multi_thread")]
723 async fn a_shape_changing_root_refuses_terminally_naming_the_action() -> TestResult {
724 let (decorated, reached) = dispatcher_with_root(
726 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
727 command: "echo {workspace_root}".to_owned(),
728 }),
729 Ok("unused".to_owned()),
730 WorkspaceRoot::from_resolution(Ok(std::path::PathBuf::from("/absolute/with$dollar"))),
731 );
732 let handle =
733 tokio::task::spawn_blocking(move || decorated.dispatch(request("provision", "{}")));
734 let Err(error) = handle.await? else {
735 return Err("a shape-changing root must refuse a placeholder-bearing body".into());
736 };
737 assert!(error.starts_with("terminal:"), "{error}");
738 assert!(
739 error.contains("provision"),
740 "the refusal must name the action: {error}"
741 );
742 assert!(
743 error.contains("would change the parsed shape"),
744 "the refusal must carry the shape-changing diagnosis: {error}"
745 );
746 assert!(
747 reached_names(&reached).is_empty(),
748 "a refused body must not fall through to the worker path"
749 );
750 Ok(())
751 }
752
753 #[tokio::test(flavor = "multi_thread")]
754 async fn an_uncreatable_root_refuses_terminally_naming_the_action() -> TestResult {
755 let scratch = tempfile::tempdir()?;
757 let file = scratch.path().join("occupied");
758 std::fs::write(&file, b"not a directory")?;
759 let (decorated, reached) = dispatcher_with_root(
760 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
761 command: "echo {workspace_root}".to_owned(),
762 }),
763 Ok("unused".to_owned()),
764 WorkspaceRoot::from_resolution(Ok(file.join("clones"))),
765 );
766 let handle =
767 tokio::task::spawn_blocking(move || decorated.dispatch(request("provision", "{}")));
768 let Err(error) = handle.await? else {
769 return Err("an uncreatable root must refuse a placeholder-bearing body".into());
770 };
771 assert!(error.starts_with("terminal:"), "{error}");
772 assert!(
773 error.contains("provision"),
774 "the refusal must name the action: {error}"
775 );
776 assert!(
777 error.contains("could not be created"),
778 "the refusal must carry the creation-failure diagnosis: {error}"
779 );
780 assert!(
781 reached_names(&reached).is_empty(),
782 "a refused body must not fall through to the worker path"
783 );
784 Ok(())
785 }
786
787 #[tokio::test(flavor = "multi_thread")]
788 async fn a_body_without_the_placeholder_is_untouched_by_resolution_failure() -> TestResult {
789 let (decorated, _reached) = dispatcher_with_root(
790 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
791 command: "echo $greeting".to_owned(),
792 }),
793 Ok("unused".to_owned()),
794 WorkspaceRoot::from_resolution(Err(WorkspaceRootError::Unresolvable {
795 reason: "cannot resolve Aion home: set AION_HOME or HOME".to_owned(),
796 })),
797 );
798 let handle = tokio::task::spawn_blocking(move || {
799 decorated.dispatch(request("greet", "{\"greeting\":\"still served\"}"))
800 });
801 let result = handle.await?;
802 let encoded = result.map_err(|error| format!("declared command failed: {error}"))?;
803 let outcome: serde_json::Value = serde_json::from_str(&encoded)?;
804 assert_eq!(outcome["stdout"], "still served");
805 Ok(())
806 }
807
808 #[tokio::test(flavor = "multi_thread")]
809 async fn an_unreadable_catalog_delegates_to_the_worker_path() -> TestResult {
810 let (decorated, reached) = dispatcher(
811 DeclaredBodyLookup::Unreadable("catalog offline".to_owned()),
812 Ok("\"served anyway\"".to_owned()),
813 );
814 let handle =
815 tokio::task::spawn_blocking(move || decorated.dispatch(request("resilient", "{}")));
816 let result = handle.await?;
817 assert_eq!(result, Ok("\"served anyway\"".to_owned()));
818 assert_eq!(reached_names(&reached), vec!["resilient".to_owned()]);
819 Ok(())
820 }
821
822 #[test]
823 fn non_object_input_is_refused_terminally_by_shape() {
824 for (input, kind) in [
825 ("[1,2]", "an array"),
826 ("\"text\"", "a string"),
827 ("3", "a number"),
828 ("null", "null"),
829 ("true", "a boolean"),
830 ] {
831 let Err(error) = decode_arguments(input) else {
832 unreachable_refusal(input);
833 return;
834 };
835 assert!(error.starts_with("terminal:"), "{error}");
836 assert!(error.contains(kind), "{error} must name {kind}");
837 }
838 }
839
840 fn unreachable_refusal(input: &str) {
842 assert!(
843 input.is_empty(),
844 "input `{input}` must have been refused by shape"
845 );
846 }
847
848 #[tokio::test(flavor = "multi_thread")]
856 async fn the_reader_is_asked_about_the_run_that_is_dispatching() -> TestResult {
857 let seen = Arc::new(Mutex::new(Vec::new()));
858 let bodies = DeclaredBodySource::default();
859 bodies.install(Arc::new(RecordingBodies {
860 seen: Arc::clone(&seen),
861 }));
862 let reached = Arc::new(Mutex::new(Vec::new()));
863 let decorated = DeclaredCommandDispatcher::new(
864 Arc::new(RecordingInner {
865 reached: Arc::clone(&reached),
866 reply: Ok("\"worker-served\"".to_owned()),
867 }),
868 bodies,
869 tokio::runtime::Handle::current(),
870 WorkspaceRoot::from_resolution(Ok(std::env::temp_dir())),
871 );
872
873 let dispatch = request("plain", "{}");
874 let expected = (dispatch.workflow_id.clone(), dispatch.run_id.clone());
875 let handle = tokio::task::spawn_blocking(move || decorated.dispatch(dispatch));
876 handle
877 .await?
878 .map_err(|error| format!("dispatch failed: {error}"))?;
879
880 let observed = match seen.lock() {
881 Ok(observed) => observed.clone(),
882 Err(poisoned) => poisoned.into_inner().clone(),
883 };
884 assert_eq!(
885 observed,
886 vec![expected],
887 "the body reader must be asked about the dispatching run itself"
888 );
889 Ok(())
890 }
891}