1use std::collections::BTreeMap;
16use std::sync::{Arc, OnceLock};
17
18use aion::{ActivityDispatch, ActivityDispatcher};
19use aion_package::ActionBodyContract;
20use aion_worker::shell::ShellAction;
21
22#[derive(Clone, Debug)]
24pub enum DeclaredBodyLookup {
25 None,
28 Declared(ActionBodyContract),
31 Ambiguous {
35 count: usize,
37 },
38 Unreadable(String),
41}
42
43pub trait DeclaredBodies: Send + Sync {
45 fn body_for(&self, task_queue: &str, action: &str) -> DeclaredBodyLookup;
47}
48
49#[derive(Clone, Default)]
56pub struct DeclaredBodySource {
57 inner: Arc<OnceLock<Arc<dyn DeclaredBodies>>>,
58}
59
60impl std::fmt::Debug for DeclaredBodySource {
61 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62 formatter
63 .debug_struct("DeclaredBodySource")
64 .field("installed", &self.inner.get().is_some())
65 .finish()
66 }
67}
68
69impl DeclaredBodySource {
70 pub fn install(&self, source: Arc<dyn DeclaredBodies>) {
73 if self.inner.set(source).is_err() {
74 tracing::warn!("declared body source already installed; ignoring duplicate set");
75 }
76 }
77
78 #[must_use]
82 pub fn body_for(&self, task_queue: &str, action: &str) -> DeclaredBodyLookup {
83 self.inner.get().map_or(DeclaredBodyLookup::None, |source| {
84 source.body_for(task_queue, action)
85 })
86 }
87}
88
89pub struct EngineDeclaredBodies {
91 engine: Arc<aion::Engine>,
92}
93
94impl EngineDeclaredBodies {
95 #[must_use]
97 pub const fn new(engine: Arc<aion::Engine>) -> Self {
98 Self { engine }
99 }
100}
101
102impl std::fmt::Debug for EngineDeclaredBodies {
103 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104 formatter.write_str("EngineDeclaredBodies")
105 }
106}
107
108impl DeclaredBodies for EngineDeclaredBodies {
109 fn body_for(&self, task_queue: &str, action: &str) -> DeclaredBodyLookup {
110 let contracts = match self.engine.worker_contracts_for_queue(task_queue) {
111 Ok(contracts) => contracts,
112 Err(error) => return DeclaredBodyLookup::Unreadable(error.to_string()),
113 };
114 let mut bodies: Vec<ActionBodyContract> = Vec::new();
119 for deployed in contracts {
120 for declared in &deployed.contract.actions {
121 if declared.name == action
122 && let Some(body) = &declared.body
123 && !bodies.contains(body)
124 {
125 bodies.push(body.clone());
126 }
127 }
128 }
129 match bodies.len() {
130 0 => DeclaredBodyLookup::None,
131 1 => match bodies.pop() {
132 Some(body) => DeclaredBodyLookup::Declared(body),
133 None => DeclaredBodyLookup::None,
136 },
137 count => DeclaredBodyLookup::Ambiguous { count },
138 }
139 }
140}
141
142pub struct DeclaredCommandDispatcher {
147 inner: Arc<dyn ActivityDispatcher>,
148 bodies: DeclaredBodySource,
149 tokio: tokio::runtime::Handle,
150}
151
152impl DeclaredCommandDispatcher {
153 #[must_use]
155 pub fn new(
156 inner: Arc<dyn ActivityDispatcher>,
157 bodies: DeclaredBodySource,
158 tokio: tokio::runtime::Handle,
159 ) -> Self {
160 Self {
161 inner,
162 bodies,
163 tokio,
164 }
165 }
166
167 fn run_declared_command(
170 &self,
171 request: &ActivityDispatch,
172 command: &str,
173 ) -> Result<String, String> {
174 let arguments = decode_arguments(&request.input)?;
175 let action = ShellAction::new(command).map_err(|error| {
176 format!("terminal:declared command failed to parse at dispatch: {error}")
180 })?;
181 let (context, cancellation) =
182 aion_worker::ActivityContext::new(request.activity_id.clone(), request.attempt);
183
184 tracing::info!(
185 operation = "declared_command_dispatch",
186 workflow_id = %request.workflow_id,
187 activity_id = %request.activity_id,
188 activity_name = %request.name,
189 task_queue = %request.task_queue,
190 attempt = request.attempt,
191 "executing declared action body at the server"
192 );
193
194 let outcome = self.tokio.block_on(action.run(&arguments, &context));
195 drop(cancellation);
199
200 match outcome {
201 Ok(result) => serde_json::to_string(&result).map_err(|error| {
202 format!("terminal:declared command result failed to encode: {error}")
203 }),
204 Err(failure) => {
205 let prefix = match failure.classification() {
206 aion_worker::Classification::Retryable => "retryable",
207 aion_worker::Classification::Terminal => "terminal",
208 };
209 Err(format!("{prefix}:{}", failure.message()))
210 }
211 }
212 }
213}
214
215impl std::fmt::Debug for DeclaredCommandDispatcher {
216 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
217 formatter
218 .debug_struct("DeclaredCommandDispatcher")
219 .field("bodies", &self.bodies)
220 .finish_non_exhaustive()
221 }
222}
223
224impl ActivityDispatcher for DeclaredCommandDispatcher {
225 fn dispatch(&self, request: ActivityDispatch) -> Result<String, String> {
226 match self.bodies.body_for(&request.task_queue, &request.name) {
227 DeclaredBodyLookup::None => self.inner.dispatch(request),
228 DeclaredBodyLookup::Unreadable(reason) => {
229 tracing::error!(
233 operation = "declared_command_dispatch",
234 workflow_id = %request.workflow_id,
235 activity_name = %request.name,
236 task_queue = %request.task_queue,
237 %reason,
238 "declared-body catalog read failed; delegating to the worker path"
239 );
240 self.inner.dispatch(request)
241 }
242 DeclaredBodyLookup::Ambiguous { count } => Err(format!(
243 "terminal:action `{}` on task queue `{}` declares {count} different bodies \
244 across retained package versions; refusing to guess which deploy this run \
245 meant — redeploy so one body remains",
246 request.name, request.task_queue
247 )),
248 DeclaredBodyLookup::Declared(ActionBodyContract::Run { command }) => {
249 self.run_declared_command(&request, &command)
250 }
251 }
252 }
253}
254
255fn decode_arguments(input: &str) -> Result<BTreeMap<String, serde_json::Value>, String> {
262 let value: serde_json::Value = serde_json::from_str(input)
263 .map_err(|error| format!("terminal:declared command input is not valid JSON: {error}"))?;
264 match value {
265 serde_json::Value::Object(members) => Ok(members.into_iter().collect()),
266 other => Err(format!(
267 "terminal:declared command input must be a JSON object binding the action's \
268 parameters by name; got {}",
269 json_kind(&other)
270 )),
271 }
272}
273
274const fn json_kind(value: &serde_json::Value) -> &'static str {
276 match value {
277 serde_json::Value::Null => "null",
278 serde_json::Value::Bool(_) => "a boolean",
279 serde_json::Value::Number(_) => "a number",
280 serde_json::Value::String(_) => "a string",
281 serde_json::Value::Array(_) => "an array",
282 serde_json::Value::Object(_) => "an object",
283 }
284}
285
286#[cfg(test)]
287mod tests {
288 use std::collections::BTreeMap;
289 use std::sync::{Arc, Mutex};
290
291 use aion::{ActivityDispatch, ActivityDispatcher};
292 use aion_core::{ActivityId, RunId, WorkflowId};
293 use aion_package::ActionBodyContract;
294
295 use super::{
296 DeclaredBodies, DeclaredBodyLookup, DeclaredBodySource, DeclaredCommandDispatcher,
297 decode_arguments,
298 };
299
300 type TestResult = Result<(), Box<dyn std::error::Error>>;
304
305 struct RecordingInner {
307 reached: Arc<Mutex<Vec<String>>>,
308 reply: Result<String, String>,
309 }
310
311 impl ActivityDispatcher for RecordingInner {
312 fn dispatch(&self, request: ActivityDispatch) -> Result<String, String> {
313 match self.reached.lock() {
314 Ok(mut names) => names.push(request.name),
315 Err(poisoned) => poisoned.into_inner().push(request.name),
316 }
317 self.reply.clone()
318 }
319 }
320
321 struct FixedBodies {
322 lookup: DeclaredBodyLookup,
323 }
324
325 impl DeclaredBodies for FixedBodies {
326 fn body_for(&self, _task_queue: &str, _action: &str) -> DeclaredBodyLookup {
327 self.lookup.clone()
328 }
329 }
330
331 fn request(name: &str, input: &str) -> ActivityDispatch {
332 ActivityDispatch {
333 namespace: "default".to_owned(),
334 task_queue: "shell".to_owned(),
335 node: None,
336 workflow_id: WorkflowId::new_v4(),
337 run_id: RunId::new_v4(),
338 activity_id: ActivityId::from_sequence_position(1),
339 name: name.to_owned(),
340 input: input.to_owned(),
341 config: "{}".to_owned(),
342 attempt: 1,
343 labels: BTreeMap::new(),
344 advisory: false,
345 }
346 }
347
348 fn dispatcher(
349 lookup: DeclaredBodyLookup,
350 reply: Result<String, String>,
351 ) -> (DeclaredCommandDispatcher, Arc<Mutex<Vec<String>>>) {
352 let reached = Arc::new(Mutex::new(Vec::new()));
353 let inner = RecordingInner {
354 reached: Arc::clone(&reached),
355 reply,
356 };
357 let bodies = DeclaredBodySource::default();
358 bodies.install(Arc::new(FixedBodies { lookup }));
359 let decorated = DeclaredCommandDispatcher::new(
360 Arc::new(inner),
361 bodies,
362 tokio::runtime::Handle::current(),
363 );
364 (decorated, reached)
365 }
366
367 fn reached_names(reached: &Arc<Mutex<Vec<String>>>) -> Vec<String> {
368 match reached.lock() {
369 Ok(names) => names.clone(),
370 Err(poisoned) => poisoned.into_inner().clone(),
371 }
372 }
373
374 #[tokio::test(flavor = "multi_thread")]
375 async fn a_bodiless_action_is_delegated_untouched() -> TestResult {
376 let (decorated, reached) =
377 dispatcher(DeclaredBodyLookup::None, Ok("\"worker-served\"".to_owned()));
378 let handle =
379 tokio::task::spawn_blocking(move || decorated.dispatch(request("plain", "{}")));
380 let result = handle.await?;
381 assert_eq!(result, Ok("\"worker-served\"".to_owned()));
382 assert_eq!(reached_names(&reached), vec!["plain".to_owned()]);
383 Ok(())
384 }
385
386 #[tokio::test(flavor = "multi_thread")]
387 async fn a_declared_body_executes_without_touching_the_worker_path() -> TestResult {
388 let (decorated, reached) = dispatcher(
389 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
390 command: "echo $greeting".to_owned(),
391 }),
392 Err("terminal:the worker path must never be reached".to_owned()),
393 );
394 let handle = tokio::task::spawn_blocking(move || {
395 decorated.dispatch(request(
396 "greet",
397 "{\"greeting\":\"hello from the contract\"}",
398 ))
399 });
400 let result = handle.await?;
401 let encoded = result.map_err(|error| format!("declared command failed: {error}"))?;
402 let outcome: serde_json::Value = serde_json::from_str(&encoded)?;
403 assert_eq!(outcome["stdout"], "hello from the contract");
404 assert_eq!(outcome["exit_code"], 0);
405 assert!(
406 reached_names(&reached).is_empty(),
407 "the worker path must not be consulted for a bodied action"
408 );
409 Ok(())
410 }
411
412 #[tokio::test(flavor = "multi_thread")]
413 async fn a_failing_declared_command_reports_retryable_with_its_stderr() -> TestResult {
414 let (decorated, _reached) = dispatcher(
415 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
416 command: "sh -c 'echo boom >&2; exit 7'".to_owned(),
417 }),
418 Ok("unused".to_owned()),
419 );
420 let handle =
421 tokio::task::spawn_blocking(move || decorated.dispatch(request("fails", "{}")));
422 let Err(error) = handle.await? else {
423 return Err("a non-zero exit must fail the dispatch".into());
424 };
425 assert!(
426 error.starts_with("retryable:"),
427 "a non-zero exit is retryable by default: {error}"
428 );
429 assert!(
430 error.contains("boom"),
431 "stderr must ride the failure: {error}"
432 );
433 Ok(())
434 }
435
436 #[tokio::test(flavor = "multi_thread")]
437 async fn ambiguous_bodies_refuse_terminally_by_name() -> TestResult {
438 let (decorated, reached) = dispatcher(
439 DeclaredBodyLookup::Ambiguous { count: 2 },
440 Ok(String::new()),
441 );
442 let handle = tokio::task::spawn_blocking(move || decorated.dispatch(request("torn", "{}")));
443 let Err(error) = handle.await? else {
444 return Err("ambiguous bodies must refuse".into());
445 };
446 assert!(error.starts_with("terminal:"), "{error}");
447 assert!(error.contains("torn"), "{error}");
448 assert!(reached_names(&reached).is_empty());
449 Ok(())
450 }
451
452 #[tokio::test(flavor = "multi_thread")]
453 async fn an_unreadable_catalog_delegates_to_the_worker_path() -> TestResult {
454 let (decorated, reached) = dispatcher(
455 DeclaredBodyLookup::Unreadable("catalog offline".to_owned()),
456 Ok("\"served anyway\"".to_owned()),
457 );
458 let handle =
459 tokio::task::spawn_blocking(move || decorated.dispatch(request("resilient", "{}")));
460 let result = handle.await?;
461 assert_eq!(result, Ok("\"served anyway\"".to_owned()));
462 assert_eq!(reached_names(&reached), vec!["resilient".to_owned()]);
463 Ok(())
464 }
465
466 #[test]
467 fn non_object_input_is_refused_terminally_by_shape() {
468 for (input, kind) in [
469 ("[1,2]", "an array"),
470 ("\"text\"", "a string"),
471 ("3", "a number"),
472 ("null", "null"),
473 ("true", "a boolean"),
474 ] {
475 let Err(error) = decode_arguments(input) else {
476 unreachable_refusal(input);
477 return;
478 };
479 assert!(error.starts_with("terminal:"), "{error}");
480 assert!(error.contains(kind), "{error} must name {kind}");
481 }
482 }
483
484 fn unreachable_refusal(input: &str) {
486 assert!(
487 input.is_empty(),
488 "input `{input}` must have been refused by shape"
489 );
490 }
491}