aion-worker 0.27.1

Rust remote-worker SDK for executing Aion activities over the gRPC worker protocol.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
//! A declared command executed as an activity.
//!
//! [`ShellAction`] turns a command declared in an `.awl` worker block into a
//! runnable activity, so an action can do real work without a hand-written
//! Rust crate. The command is parsed once at construction and executed as a
//! direct `execve` with no shell interposed — see [`super::template`] for why
//! that is the security property rather than a convenience.

use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};
use tokio::process::Command;

use super::exit::Ending;
use super::failure::{spawn_failure, unreadable_ending_clause};
use super::template::{CommandTemplate, SubstitutionError, TemplateError};
use super::world::place_in_declared_world;
use crate::activity::ActivityFailure;
use crate::command_transcript::CommandTranscript;
use crate::context::ActivityContext;
use crate::process::{CancellableCommandOutput, run_cancellable_command};

/// What a declared action produces when its command succeeds.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct ShellOutcome {
    /// The command's exit status code.
    pub exit_code: i32,
    /// Everything the command wrote to standard output, trailing newline
    /// trimmed so a one-line result is usable directly.
    pub stdout: String,
    /// Everything the command wrote to standard error, trailing newline
    /// trimmed. Captured even on success, because a command that succeeds
    /// while warning is a thing an operator needs to see.
    pub stderr: String,
}

/// How a declared action treats a non-zero exit.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum FailureMode {
    /// A non-zero exit is retryable: the engine may run the command again
    /// under the action's retry policy. This is the default because the
    /// common causes of a failing command — a busy resource, an unreachable
    /// host, a transient permission state — are the ones that succeed on a
    /// second attempt.
    #[default]
    Retryable,
    /// A non-zero exit is terminal: the command will never be retried. Use
    /// when re-running the command could not possibly change the outcome, or
    /// when running it twice would itself be harmful.
    Terminal,
}

/// A declared command, ready to run as an activity.
#[derive(Debug, Clone)]
pub struct ShellAction {
    template: CommandTemplate,
    failure_mode: FailureMode,
    environment: BTreeMap<String, String>,
    working_directory: Option<std::path::PathBuf>,
}

impl ShellAction {
    /// Parse `command` into a runnable action.
    ///
    /// # Errors
    ///
    /// Returns [`TemplateError`] when the command is empty, has an unterminated
    /// quote or `${`, contains a `$` naming no parameter, or puts a parameter
    /// in program position.
    pub fn new(command: &str) -> Result<Self, TemplateError> {
        Ok(Self {
            template: CommandTemplate::parse(command)?,
            failure_mode: FailureMode::default(),
            environment: BTreeMap::new(),
            working_directory: None,
        })
    }

    /// Set how a non-zero exit is classified.
    #[must_use]
    pub const fn with_failure_mode(mut self, failure_mode: FailureMode) -> Self {
        self.failure_mode = failure_mode;
        self
    }

    /// Supply the environment the command runs with.
    ///
    /// Additive to nothing: the host's environment is cleared regardless, so
    /// these entries plus [`super::world::INHERITED_VARIABLE`] are the
    /// command's whole environment. An entry named `PATH` here replaces the
    /// inherited one, because [`place_in_declared_world`] applies these after
    /// the inherited value.
    #[must_use]
    pub fn with_environment(mut self, environment: BTreeMap<String, String>) -> Self {
        self.environment = environment;
        self
    }

    /// Run the command in `directory` instead of wherever the host happens to
    /// be.
    ///
    /// Worth setting whenever a command names a relative path. Absent, the
    /// command inherits the host process's working directory, which is a
    /// property of how the host was launched and not something a package
    /// author can see — so a relative path in a declared command means
    /// something the author cannot predict.
    #[must_use]
    pub fn with_working_directory(mut self, directory: impl Into<std::path::PathBuf>) -> Self {
        self.working_directory = Some(directory.into());
        self
    }

    /// The parameter names this command references, sorted.
    ///
    /// A caller can compare these against an action's declared parameters to
    /// catch a reference to a parameter that does not exist.
    #[must_use]
    pub fn referenced_parameters(&self) -> Vec<String> {
        self.template.referenced_parameters()
    }

    /// Run the command with `arguments` bound to its parameter references.
    ///
    /// Cancellation is tree-wide: the command runs in its own process group,
    /// and cancelling the activity terminates that whole group rather than
    /// only the direct child, so a command that spawned children does not
    /// leave them running.
    ///
    /// Output is streamed onto `context`'s transcript seam line by line as the
    /// command writes it (see [`CommandTranscript`]), so an operator can read
    /// what a command step is printing mid-run. The returned [`ShellOutcome`]
    /// is unaffected: it still carries the command's complete stdout and stderr.
    ///
    /// # Errors
    ///
    /// Returns an [`ActivityFailure`] when a referenced parameter is missing or
    /// unrepresentable (terminal — re-running cannot supply it), when the
    /// command cannot be spawned or observed (terminal — the program is absent
    /// or unrunnable), when the activity is already cancelled before the
    /// command starts (terminal — nothing runs at all), when the command is
    /// cancelled while running (terminal), when the host
    /// reports an ending that is neither an exit nor a signal (terminal —
    /// there is no honest status to report, and see
    /// [`super::failure::ending_permits_retry`] for why an ending this host
    /// cannot read is never retried at either executor), or when it exits
    /// non-zero or is
    /// ended by a signal (classified by the action's [`FailureMode`], and the
    /// signal is named rather than rendered as an exit code alone).
    pub async fn run(
        &self,
        arguments: &BTreeMap<String, serde_json::Value>,
        context: &ActivityContext,
    ) -> Result<ShellOutcome, ActivityFailure> {
        // Read BEFORE anything is executed. A cancellation that has already
        // landed must not start a program and then kill it: the point of
        // cancelling is that the work does not happen. The residual window is
        // stated rather than claimed away — a cancellation landing after this
        // read still starts the program, and is answered by the termination
        // ladder in the ordinary way.
        if context.is_cancelled() {
            return Err(ActivityFailure::terminal(
                "this command was cancelled before it started, so it never ran",
            ));
        }
        let argv = self
            .template
            .render(arguments)
            .map_err(|error| substitution_failure(&error))?;
        let (program, rest) = argv
            .split_first()
            // `CommandTemplate::parse` refuses an empty command, so a rendered
            // argv always has a program. Handled rather than indexed so a
            // future change to the parser cannot turn this into a panic.
            .ok_or_else(|| ActivityFailure::terminal("the declared command rendered no program"))?;

        let mut command = Command::new(program);
        command.args(rest);
        // Closed stdin, a cleared host environment, PATH, then this action's
        // environment in order — the one world every declared command runs in,
        // established by the one function that states it (see
        // [`super::world`]).
        place_in_declared_world(
            &mut command,
            self.environment
                .iter()
                .map(|(name, value)| (name.as_str(), value.as_str())),
        );
        if let Some(directory) = &self.working_directory {
            command.current_dir(directory);
        }

        let transcript = CommandTranscript::new(context);
        match run_cancellable_command(command, context.cancelled(), &transcript).await {
            Ok(CancellableCommandOutput::Completed(output)) => {
                let ending = Ending::of(output.status);
                let stdout = trim_trailing_newline(&String::from_utf8_lossy(&output.stdout));
                let stderr = trim_trailing_newline(&String::from_utf8_lossy(&output.stderr));
                let Some(exit_code) = ending.reported_code() else {
                    // Neither an exit code nor a signal: there is no number
                    // this side could report without inventing one, and an
                    // invented number is what an operator would act on.
                    // TERMINAL, by the one classification both executors read
                    // — `ending_permits_retry` is exactly this absence of a
                    // reported code, and its doc carries the reasoning.
                    return Err(ActivityFailure::terminal(format!(
                        "the command `{program}` {ended}{unreadable}",
                        ended = ending.described(),
                        unreadable = unreadable_ending_clause(ending),
                    )));
                };
                let outcome = ShellOutcome {
                    exit_code,
                    stdout,
                    stderr,
                };
                if ending.succeeded() {
                    Ok(outcome)
                } else {
                    Err(self.exit_failure(program, ending, &outcome))
                }
            }
            Ok(CancellableCommandOutput::Cancelled) => Err(ActivityFailure::terminal(format!(
                "the command `{program}` was cancelled: its process group was terminated and \
                 proven gone, so nothing it started is still running"
            ))),
            Err(error) => Err(spawn_failure(program, None, &error)),
        }
    }

    /// Build the failure for a command that ran and did not exit zero.
    fn exit_failure(
        &self,
        program: &str,
        ending: Ending,
        outcome: &ShellOutcome,
    ) -> ActivityFailure {
        // The message carries HOW the command ended — an exit code, or the
        // signal that ended it — and its own standard error, because a
        // failing command's own words are the most useful thing an operator
        // can be handed; without them the failure says only that something
        // went wrong.
        let ended = ending.described();
        let message = if outcome.stderr.is_empty() {
            format!("the command `{program}` {ended} and wrote nothing to standard error")
        } else {
            format!(
                "the command `{program}` {ended} and wrote to standard error: {}",
                outcome.stderr
            )
        };
        match self.failure_mode {
            FailureMode::Retryable => ActivityFailure::retryable(message),
            FailureMode::Terminal => ActivityFailure::terminal(message),
        }
    }
}

/// Classify a substitution failure. All are terminal: the same call with the
/// same arguments would fail identically however many times it is retried.
fn substitution_failure(error: &SubstitutionError) -> ActivityFailure {
    ActivityFailure::terminal(error.to_string())
}

/// Trim exactly one trailing newline, and its carriage return if present.
///
/// A command's output almost always ends in a newline that belongs to the
/// terminal rather than to the value, so `echo hello` yields `hello` and not
/// `hello\n`. Only one is removed: a command that deliberately emits blank
/// trailing lines keeps them.
pub(super) fn trim_trailing_newline(text: &str) -> String {
    text.strip_suffix('\n')
        .map_or(text, |trimmed| {
            trimmed.strip_suffix('\r').unwrap_or(trimmed)
        })
        .to_owned()
}

#[cfg(test)]
mod tests {
    use super::{FailureMode, ShellAction, trim_trailing_newline};
    use crate::activity::Classification;
    use crate::context::ActivityContext;
    use aion_core::{ActivityId, RunId, WorkflowId};
    use std::collections::BTreeMap;

    fn context() -> (ActivityContext, crate::context::ActivityCancellationHandle) {
        ActivityContext::new(
            WorkflowId::new_v4(),
            RunId::new_v4(),
            ActivityId::from_sequence_position(1),
            1,
        )
    }

    fn arguments(pairs: &[(&str, serde_json::Value)]) -> BTreeMap<String, serde_json::Value> {
        pairs
            .iter()
            .map(|(name, value)| ((*name).to_owned(), value.clone()))
            .collect()
    }

    /// What a test returns. Every fallible step is carried rather than
    /// unwrapped, because the workspace denies panicking accessors in test
    /// code as firmly as in library code.
    type TestResult = Result<(), Box<dyn std::error::Error>>;

    #[tokio::test]
    async fn a_succeeding_command_returns_its_output() -> TestResult {
        let action = ShellAction::new("echo hello")?;
        let (context, _handle) = context();
        let outcome = action.run(&BTreeMap::new(), &context).await?;
        assert_eq!(outcome.exit_code, 0);
        assert_eq!(outcome.stdout, "hello");
        assert_eq!(outcome.stderr, "");
        Ok(())
    }

    #[tokio::test]
    async fn a_parameter_value_reaches_the_program_as_one_argument() -> TestResult {
        let action = ShellAction::new("echo {{greeting}}")?;
        let (context, _handle) = context();
        let outcome = action
            .run(
                &arguments(&[("greeting", serde_json::json!("hello there world"))]),
                &context,
            )
            .await?;
        assert_eq!(outcome.stdout, "hello there world");
        Ok(())
    }

    #[tokio::test]
    async fn a_hostile_value_is_inert_because_no_shell_ever_sees_it() -> TestResult {
        // If a shell were interposed, this would execute a second command.
        // Because argv goes straight to execve, it is printed as text.
        let action = ShellAction::new("echo {{value}}")?;
        let (context, _handle) = context();
        let outcome = action
            .run(
                &arguments(&[("value", serde_json::json!("hi; echo PWNED"))]),
                &context,
            )
            .await?;
        assert_eq!(outcome.stdout, "hi; echo PWNED");
        assert!(
            !outcome.stdout.contains("PWNED\n"),
            "the injected command must never have run"
        );
        Ok(())
    }

    #[tokio::test]
    async fn a_nonzero_exit_is_retryable_by_default_and_carries_stderr() -> TestResult {
        let action = ShellAction::new("sh -c 'echo trouble >&2; exit 3'")?;
        let (context, _handle) = context();
        let Err(failure) = action.run(&BTreeMap::new(), &context).await else {
            return Err("a non-zero exit must fail the activity".into());
        };
        assert_eq!(failure.classification(), &Classification::Retryable);
        assert!(
            failure.message().contains("trouble"),
            "the failure must carry the command's own words: {}",
            failure.message()
        );
        assert!(failure.message().contains('3'), "the exit code is reported");
        Ok(())
    }

    #[tokio::test]
    async fn a_nonzero_exit_is_terminal_when_the_action_says_so() -> TestResult {
        let action = ShellAction::new("sh -c 'exit 1'")?.with_failure_mode(FailureMode::Terminal);
        let (context, _handle) = context();
        let Err(failure) = action.run(&BTreeMap::new(), &context).await else {
            return Err("a non-zero exit must fail the activity".into());
        };
        assert_eq!(failure.classification(), &Classification::Terminal);
        Ok(())
    }

    #[tokio::test]
    async fn a_missing_parameter_fails_terminally_before_anything_runs() -> TestResult {
        let action = ShellAction::new("echo {{absent}}")?;
        let (context, _handle) = context();
        let Err(failure) = action.run(&BTreeMap::new(), &context).await else {
            return Err("a missing parameter must fail the activity".into());
        };
        assert_eq!(failure.classification(), &Classification::Terminal);
        assert!(failure.message().contains("absent"));
        Ok(())
    }

    #[tokio::test]
    async fn an_absent_program_fails_terminally() -> TestResult {
        let action = ShellAction::new("aion-no-such-program-exists-anywhere")?;
        let (context, _handle) = context();
        let Err(failure) = action.run(&BTreeMap::new(), &context).await else {
            return Err("an absent program must fail the activity".into());
        };
        assert_eq!(failure.classification(), &Classification::Terminal);
        Ok(())
    }

    #[tokio::test]
    async fn cancellation_stops_the_command_and_fails_terminally() -> TestResult {
        let action = ShellAction::new("sleep 30")?;
        let (context, handle) = context();
        let run = tokio::spawn(async move {
            let (context, _keep) = (context, ());
            action.run(&BTreeMap::new(), &context).await
        });
        // Give the command a moment to actually be running before cancelling,
        // so this exercises termination rather than a pre-start short circuit.
        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
        handle.cancel();
        let Err(failure) = run.await? else {
            return Err("a cancelled command must fail the activity".into());
        };
        assert_eq!(failure.classification(), &Classification::Terminal);
        assert!(failure.message().contains("cancelled"));
        Ok(())
    }

    /// A cancellation that had ALREADY landed executes nothing: the program
    /// never runs, rather than running and then being killed.
    ///
    /// Measured by a side effect on the filesystem rather than by the failure,
    /// because a spawn-then-kill would report a cancellation too. The second
    /// arm is the positive control — without it, a missing `touch` would make
    /// the first arm pass vacuously.
    #[tokio::test]
    async fn a_cancellation_already_standing_runs_nothing_at_all() -> TestResult {
        let directory = tempfile::tempdir()?;
        let witness = directory.path().join("cancelled");
        let control = directory.path().join("control");

        // Both contexts are taken before either is used, so neither shadows
        // the helper the other still needs.
        let (cancelled_context, handle) = context();
        let (control_context, _control_handle) = context();

        let action = ShellAction::new(&format!("touch {}", witness.display()))?;
        handle.cancel();
        let Err(failure) = action.run(&BTreeMap::new(), &cancelled_context).await else {
            return Err("a cancelled command must fail the activity".into());
        };
        assert_eq!(failure.classification(), &Classification::Terminal);
        assert!(
            failure.message().contains("cancelled"),
            "a cancellation must be reported as one: {}",
            failure.message()
        );
        assert!(
            !witness.exists(),
            "a cancellation that had already landed still executed the program"
        );

        let control_action = ShellAction::new(&format!("touch {}", control.display()))?;
        control_action
            .run(&BTreeMap::new(), &control_context)
            .await?;
        assert!(
            control.exists(),
            "the control failed: `touch` did not run even without a cancellation, so the first \
             arm proves nothing"
        );
        Ok(())
    }

    #[tokio::test]
    async fn the_hosts_environment_does_not_cross_into_a_declared_command() -> TestResult {
        // A host process legitimately holds credentials. A deployed package
        // must not be able to read them by declaring `run "env"`. This is the
        // test that catches a regression to inherited environment.
        //
        // It reads a variable the host ALREADY has rather than setting one:
        // `std::env::set_var` is `unsafe` (process-global, racy against every
        // other thread) and the workspace denies unsafe outright. Any existing
        // variable other than the deliberately-inherited PATH proves the same
        // property.
        let Some(present) = std::env::vars_os()
            .filter_map(|(name, _)| name.into_string().ok())
            .find(|name| name != "PATH" && !name.is_empty() && !name.contains('='))
        else {
            tracing::info!(
                "skipping: the host has no environment variable besides PATH to prove \
                 non-inheritance with"
            );
            return Ok(());
        };
        let action = ShellAction::new(&format!("sh -c 'echo \"[${{{present}:-absent}}]\"'"))?;
        let (context, _handle) = context();
        let outcome = action.run(&BTreeMap::new(), &context).await?;
        assert_eq!(
            outcome.stdout, "[absent]",
            "the host's `{present}` leaked into a declared command"
        );
        Ok(())
    }

    #[tokio::test]
    async fn an_operator_supplied_variable_does_reach_the_command() -> TestResult {
        // The other half: clearing must not make the environment unusable.
        let mut environment = BTreeMap::new();
        environment.insert("DECLARED_GREETING".to_owned(), "supplied".to_owned());
        let action = ShellAction::new("sh -c 'echo \"[$DECLARED_GREETING]\"'")?
            .with_environment(environment);
        let (context, _handle) = context();
        let outcome = action.run(&BTreeMap::new(), &context).await?;
        assert_eq!(outcome.stdout, "[supplied]");
        Ok(())
    }

    #[tokio::test]
    async fn a_command_runs_in_its_declared_working_directory() -> TestResult {
        let action = ShellAction::new("pwd")?.with_working_directory("/");
        let (context, _handle) = context();
        let outcome = action.run(&BTreeMap::new(), &context).await?;
        assert_eq!(outcome.stdout, "/");
        Ok(())
    }

    #[tokio::test]
    async fn a_command_reading_stdin_gets_end_of_file_rather_than_the_hosts() -> TestResult {
        // Inherited stdin would hang the activity forever on an interactive
        // boot. Closed stdin makes `cat` finish immediately with nothing.
        let action = ShellAction::new("cat")?;
        let (context, _handle) = context();
        let outcome = action.run(&BTreeMap::new(), &context).await?;
        assert_eq!(outcome.exit_code, 0);
        assert_eq!(outcome.stdout, "");
        Ok(())
    }

    #[tokio::test]
    async fn awkward_values_survive_as_exactly_one_argument_each() -> TestResult {
        // Cally's misparse question, not the injection one: a value that a
        // careless argv build would split, truncate, or lose entirely. Each is
        // printed back with a delimiter so a silent split is visible.
        for (value, expected) in [
            ("two\nlines", "[two\nlines]"),
            ("", "[]"),
            ("   leading and trailing   ", "[   leading and trailing   ]"),
            ("tab\there", "[tab\there]"),
            ("quote\"inside", "[quote\"inside]"),
            ("single'inside", "[single'inside]"),
            ("back\\slash", "[back\\slash]"),
        ] {
            let action = ShellAction::new("printf [%s] {{value}}")?;
            let (context, _handle) = context();
            let outcome = action
                .run(&arguments(&[("value", serde_json::json!(value))]), &context)
                .await?;
            assert_eq!(
                outcome.stdout, expected,
                "value {value:?} did not arrive as exactly one argument"
            );
        }
        Ok(())
    }

    #[tokio::test]
    async fn a_value_that_looks_like_a_flag_is_still_passed_as_a_value() -> TestResult {
        // A leading `-` is the misparse Apollo's law points at: the ARGV is
        // correct (one element), but the PROGRAM may read it as an option.
        // The executor cannot fix that — only the author can, by writing
        // `--` in the declaration. Both halves are pinned here so the
        // behaviour is recorded rather than discovered.
        let exposed = ShellAction::new("printf %s {{value}}")?;
        let (exposed_context, _exposed_handle) = context();
        let outcome = exposed
            .run(
                &arguments(&[("value", serde_json::json!("-n"))]),
                &exposed_context,
            )
            .await;
        // `printf %s -n` — the program's own parse decides; the executor
        // delivered exactly what was declared either way.
        assert!(
            outcome.is_ok(),
            "the executor must deliver the value and let the program parse it"
        );

        // With `--` the author has told the program where options end, and the
        // value is unambiguously a value.
        let guarded = ShellAction::new("printf -- [%s] {{value}}")?;
        let (guarded_context, _guarded_handle) = context();
        let guarded_outcome = guarded
            .run(
                &arguments(&[("value", serde_json::json!("-n"))]),
                &guarded_context,
            )
            .await?;
        assert_eq!(guarded_outcome.stdout, "[-n]");
        Ok(())
    }

    /// THE JOIN THIS BUILD MAKES, on the real declared-action path: a command
    /// writing to BOTH streams has those lines on the activity's transcript seam
    /// while it is still running — the same seam, envelope and stream key an
    /// agent step publishes on.
    ///
    /// The run future is polled concurrently with the reads, so a capture that
    /// only materialized at exit would resolve the run arm first and fail the
    /// test by name. The command then sleeps until the test cancels it, which
    /// also holds the pre-existing cancellation contract under streaming.
    #[tokio::test]
    async fn a_declared_command_streams_both_streams_before_it_exits() -> TestResult {
        use aion_core::{RunId, WorkflowId};

        let (sender, mut events) = tokio::sync::mpsc::unbounded_channel();
        let (context, cancellation) = ActivityContext::with_transcript(
            WorkflowId::new_v4(),
            RunId::new_v4(),
            ActivityId::from_sequence_position(9),
            3,
            sender,
        );
        let action = ShellAction::new("sh -c 'echo working; echo warning >&2; sleep 30'")?;
        let no_arguments = BTreeMap::new();
        let run = action.run(&no_arguments, &context);
        tokio::pin!(run);

        let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10);
        let mut observed: Vec<(String, String)> = Vec::new();
        while !observed.iter().any(|(role, _)| role.contains("stdout"))
            || !observed.iter().any(|(role, _)| role.contains("stderr"))
        {
            tokio::select! {
                biased;
                outcome = &mut run => {
                    drop(outcome);
                    return Err(format!(
                        "the command completed before its output reached the transcript: \
                         {observed:?}"
                    )
                    .into());
                }
                event = events.recv() => {
                    let event = event.ok_or("the transcript seam closed mid-command")?;
                    assert_eq!(event.attempt, 3, "the event carries the attempt it belongs to");
                    assert_eq!(
                        event.activity_id,
                        ActivityId::from_sequence_position(9),
                        "the event is keyed to the activity that is running"
                    );
                    let aion_core::ActivityEventKind::Message { text, .. } = event.kind else {
                        return Err("an output line must be a Message".into());
                    };
                    observed.push((event.agent_role, text));
                }
                () = tokio::time::sleep_until(deadline) => {
                    return Err(format!(
                        "the running command's output never reached the transcript: {observed:?}"
                    )
                    .into());
                }
            }
        }

        assert!(
            observed.contains(&("command stdout".to_owned(), "working".to_owned())),
            "stdout must arrive labelled by its stream: {observed:?}"
        );
        assert!(
            observed.contains(&("command stderr".to_owned(), "warning".to_owned())),
            "stderr must arrive labelled by its stream: {observed:?}"
        );

        cancellation.cancel();
        let Err(failure) = run.await else {
            return Err("a cancelled command must fail the activity".into());
        };
        assert_eq!(failure.classification(), &Classification::Terminal);
        Ok(())
    }

    #[test]
    fn only_one_trailing_newline_is_trimmed() {
        assert_eq!(trim_trailing_newline("hello\n"), "hello");
        assert_eq!(trim_trailing_newline("hello\r\n"), "hello");
        assert_eq!(trim_trailing_newline("hello\n\n"), "hello\n");
        assert_eq!(trim_trailing_newline("hello"), "hello");
        assert_eq!(trim_trailing_newline(""), "");
    }
}