aion-server 0.27.1

Aion workflow server library: HTTP, gRPC, WebSocket, and worker endpoints. Run it with the `aion` binary from the aion-cli crate.
Documentation
//! The server executing a DECLARED command body.
//!
//! Builds on the fixtures [`super::super::declared_body::tests`] already
//! shares with its containment suite, so both body forms are exercised
//! against the same dispatcher, the same drain state and the same transcript
//! sequencer — a difference in these results is a difference in the BODY, not
//! in how the two were set up.

use aion::ActivityDispatcher;
use aion_package::contract::CommandBodyCapture;
use aion_package::{
    ActionBodyContract, ArgvSlot, CommandLineContract, CommandParameterContract,
    DeclaredCommandContract, EnvBindingContract, FillPiece, FillTemplate,
};

use super::super::declared_body::DeclaredBodyLookup;
use super::super::declared_body::tests::{
    TestResult, dispatcher_with_root, reached_names, request,
};
use super::super::workspace_root::{WorkspaceRoot, WorkspaceRootError};

fn hole(parameter: &str) -> FillTemplate {
    FillTemplate {
        pieces: vec![FillPiece::Hole {
            parameter: parameter.to_owned(),
        }],
    }
}

fn slot(fill: FillTemplate, label: &str) -> ArgvSlot {
    ArgvSlot {
        fill,
        label: label.to_owned(),
        admits_leading_dash: true,
    }
}

fn contract(program: &[&str], args: Vec<ArgvSlot>) -> DeclaredCommandContract {
    let mut slots: Vec<ArgvSlot> = program
        .iter()
        .map(|word| slot(FillTemplate::literal((*word).to_owned()), word))
        .collect();
    slots.extend(args);
    DeclaredCommandContract {
        name: "say_hello".to_owned(),
        parameters: vec![CommandParameterContract {
            name: "name".to_owned(),
            default: None,
        }],
        lines: vec![CommandLineContract { slots }],
        env: Vec::new(),
        cwd: None,
        prior_form_refusal: None,
    }
}

fn lookup(capture: CommandBodyCapture, command: DeclaredCommandContract) -> DeclaredBodyLookup {
    DeclaredBodyLookup::Declared(ActionBodyContract::Command {
        capture,
        command: Box::new(command),
    })
}

/// The greeter used by most of these: `printf -- %s <name>`.
fn greeter() -> DeclaredCommandContract {
    contract(
        &["printf"],
        vec![
            slot(FillTemplate::literal("--"), "--"),
            slot(FillTemplate::literal("%s"), "%s"),
            slot(hole("name"), "who"),
        ],
    )
}

#[tokio::test(flavor = "multi_thread")]
async fn a_declared_command_body_executes_without_touching_the_worker_path() -> TestResult {
    let (decorated, reached, _transcript) = dispatcher_with_root(
        lookup(CommandBodyCapture::Text, greeter()),
        Err("terminal:the worker path must never be reached".to_owned()),
        WorkspaceRoot::from_resolution(Ok(std::env::temp_dir())),
    );
    let handle = tokio::task::spawn_blocking(move || {
        decorated.dispatch(request("greet", "{\"name\":\"hello from the contract\"}"))
    });
    let encoded = handle
        .await?
        .map_err(|error| format!("declared command failed: {error}"))?;
    let result: serde_json::Value = serde_json::from_str(&encoded)?;
    assert_eq!(
        result,
        serde_json::json!("hello from the contract"),
        "a text capture yields the command's trimmed stdout as one string"
    );
    assert!(
        reached_names(&reached).is_empty(),
        "the worker path must not be consulted for a bodied action"
    );
    Ok(())
}

/// The property the whole surface exists for, asserted on the SERVER path:
/// a fill that would be several commands under a shell is one inert argv word,
/// because no shell ever sees it.
#[tokio::test(flavor = "multi_thread")]
async fn a_hostile_fill_arrives_as_exactly_one_literal_argv_word() -> TestResult {
    let (decorated, _reached, _transcript) = dispatcher_with_root(
        lookup(CommandBodyCapture::Text, greeter()),
        Err("terminal:the worker path must never be reached".to_owned()),
        WorkspaceRoot::from_resolution(Ok(std::env::temp_dir())),
    );
    let handle = tokio::task::spawn_blocking(move || {
        decorated.dispatch(request(
            "greet",
            "{\"name\":\"$(boom); rm -rf / && echo PWNED\"}",
        ))
    });
    let encoded = handle
        .await?
        .map_err(|error| format!("declared command failed: {error}"))?;
    let result: serde_json::Value = serde_json::from_str(&encoded)?;
    assert_eq!(result, serde_json::json!("$(boom); rm -rf / && echo PWNED"));
    Ok(())
}

#[tokio::test(flavor = "multi_thread")]
async fn a_json_capture_decodes_the_commands_output() -> TestResult {
    let command = contract(
        &["printf"],
        vec![
            slot(FillTemplate::literal("--"), "--"),
            slot(FillTemplate::literal("{\"name\":\"%s\"}"), "%s"),
            slot(hole("name"), "who"),
        ],
    );
    let (decorated, _reached, _transcript) = dispatcher_with_root(
        lookup(CommandBodyCapture::Json, command),
        Err("terminal:the worker path must never be reached".to_owned()),
        WorkspaceRoot::from_resolution(Ok(std::env::temp_dir())),
    );
    let handle = tokio::task::spawn_blocking(move || {
        decorated.dispatch(request("record", "{\"name\":\"Ada\"}"))
    });
    let encoded = handle
        .await?
        .map_err(|error| format!("declared command failed: {error}"))?;
    let result: serde_json::Value = serde_json::from_str(&encoded)?;
    assert_eq!(result, serde_json::json!({ "name": "Ada" }));
    Ok(())
}

#[tokio::test(flavor = "multi_thread")]
async fn a_failing_declared_command_reports_retryable_with_its_stderr() -> TestResult {
    let mut command = contract(
        &["sh"],
        vec![
            slot(FillTemplate::literal("-c"), "-c"),
            slot(FillTemplate::literal("echo boom >&2; exit 7"), "body"),
        ],
    );
    command.parameters.clear();
    let (decorated, _reached, _transcript) = dispatcher_with_root(
        lookup(CommandBodyCapture::Text, command),
        Ok("unused".to_owned()),
        WorkspaceRoot::from_resolution(Ok(std::env::temp_dir())),
    );
    let handle = tokio::task::spawn_blocking(move || decorated.dispatch(request("fails", "{}")));
    let Err(error) = handle.await? else {
        return Err("a non-zero exit must fail the dispatch".into());
    };
    assert!(
        error.starts_with("retryable:"),
        "a non-zero exit is retryable by default: {error}"
    );
    assert!(
        error.contains("boom"),
        "stderr must ride the failure: {error}"
    );
    Ok(())
}

#[tokio::test(flavor = "multi_thread")]
async fn a_declared_env_binding_reaches_the_child() -> TestResult {
    let mut command = contract(
        &["sh"],
        vec![
            slot(FillTemplate::literal("-c"), "-c"),
            slot(FillTemplate::literal("printf %s \"$DECLARED_WHO\""), "body"),
        ],
    );
    command.env = vec![EnvBindingContract {
        name: "DECLARED_WHO".to_owned(),
        value: "Ada".to_owned(),
    }];
    let (decorated, _reached, _transcript) = dispatcher_with_root(
        lookup(CommandBodyCapture::Text, command),
        Ok("unused".to_owned()),
        WorkspaceRoot::from_resolution(Ok(std::env::temp_dir())),
    );
    let handle = tokio::task::spawn_blocking(move || {
        decorated.dispatch(request("greet", "{\"name\":\"Ada\"}"))
    });
    let encoded = handle
        .await?
        .map_err(|error| format!("declared command failed: {error}"))?;
    let result: serde_json::Value = serde_json::from_str(&encoded)?;
    assert_eq!(result, serde_json::json!("Ada"));
    Ok(())
}

/// The body ruling holds on the SERVER path too: lines run in order and
/// stdout concatenates.
#[tokio::test(flavor = "multi_thread")]
async fn a_multi_line_body_concatenates_stdout_on_the_server_path() -> TestResult {
    let mut command = contract(
        &["echo"],
        vec![slot(FillTemplate::literal("first"), "word")],
    );
    command.parameters.clear();
    command.lines.push(CommandLineContract {
        slots: vec![
            slot(FillTemplate::literal("echo"), "echo"),
            slot(FillTemplate::literal("second"), "word"),
        ],
    });
    let (decorated, _reached, _transcript) = dispatcher_with_root(
        lookup(CommandBodyCapture::Text, command),
        Ok("unused".to_owned()),
        WorkspaceRoot::from_resolution(Ok(std::env::temp_dir())),
    );
    let handle = tokio::task::spawn_blocking(move || decorated.dispatch(request("both", "{}")));
    let encoded = handle
        .await?
        .map_err(|error| format!("declared command failed: {error}"))?;
    let result: serde_json::Value = serde_json::from_str(&encoded)?;
    assert_eq!(result, serde_json::json!("first\nsecond"));
    Ok(())
}

/// A declared `cwd` carrying `{workspace_root}` runs where the SERVER's root
/// resolves, and the root is created if it was missing — the same ratified
/// behaviour the string-body path has.
#[tokio::test(flavor = "multi_thread")]
async fn a_workspace_rooted_cwd_runs_in_the_created_root() -> TestResult {
    let scratch = tempfile::tempdir()?;
    let root = scratch.path().join("clones");
    let mut command = contract(&["pwd"], Vec::new());
    command.parameters.clear();
    command.cwd = Some(super::super::workspace_root::WORKSPACE_ROOT_PLACEHOLDER.to_owned());
    let (decorated, _reached, _transcript) = dispatcher_with_root(
        lookup(CommandBodyCapture::Text, command),
        Ok("unused".to_owned()),
        WorkspaceRoot::from_resolution(Ok(root.clone())),
    );
    let handle = tokio::task::spawn_blocking(move || decorated.dispatch(request("locate", "{}")));
    let encoded = handle
        .await?
        .map_err(|error| format!("declared command failed: {error}"))?;
    let result: serde_json::Value = serde_json::from_str(&encoded)?;
    let observed = result.as_str().ok_or("a text capture yields a string")?;
    assert!(
        std::path::Path::new(observed).ends_with("clones"),
        "the command must run in the server-resolved root, got {observed}"
    );
    assert!(
        root.is_dir(),
        "dispatching a placeholder-bearing cwd must create the missing root"
    );
    Ok(())
}

#[tokio::test(flavor = "multi_thread")]
async fn a_workspace_rooted_cwd_refuses_terminally_when_the_root_is_unresolved() -> TestResult {
    let mut command = contract(&["pwd"], Vec::new());
    command.parameters.clear();
    command.cwd = Some(super::super::workspace_root::WORKSPACE_ROOT_PLACEHOLDER.to_owned());
    let (decorated, reached, _transcript) = dispatcher_with_root(
        lookup(CommandBodyCapture::Text, command),
        Ok("unused".to_owned()),
        WorkspaceRoot::from_resolution(Err(WorkspaceRootError::Unresolvable {
            reason: "cannot resolve Aion home: set AION_HOME or HOME".to_owned(),
        })),
    );
    let handle = tokio::task::spawn_blocking(move || decorated.dispatch(request("locate", "{}")));
    let Err(error) = handle.await? else {
        return Err("an unresolved root must refuse a placeholder-bearing cwd".into());
    };
    assert!(error.starts_with("terminal:"), "{error}");
    assert!(
        error.contains("locate"),
        "the refusal must name the action: {error}"
    );
    assert!(
        error.contains("cannot resolve Aion home"),
        "the refusal must carry the resolution failure's reason: {error}"
    );
    assert!(
        reached_names(&reached).is_empty(),
        "a refused body must not fall through to the worker path"
    );
    Ok(())
}

/// A command parameter with neither a supplied value nor a default is refused
/// TERMINALLY before anything is spawned: the same dispatch input would be
/// missing it on every retry.
#[tokio::test(flavor = "multi_thread")]
async fn a_parameter_nothing_fills_refuses_terminally_before_spawning() -> TestResult {
    let (decorated, _reached, _transcript) = dispatcher_with_root(
        lookup(CommandBodyCapture::Text, greeter()),
        Ok("unused".to_owned()),
        WorkspaceRoot::from_resolution(Ok(std::env::temp_dir())),
    );
    let handle = tokio::task::spawn_blocking(move || decorated.dispatch(request("greet", "{}")));
    let Err(error) = handle.await? else {
        return Err("an unfilled parameter must refuse".into());
    };
    assert!(error.starts_with("terminal:"), "{error}");
    assert!(error.contains("name"), "{error}");
    Ok(())
}