aion-rs 0.26.0

Transport-agnostic Aion workflow engine with durability, replay, timers, and supervision.
Documentation
//! The bridge from a DEPLOYED package's workloop declaration
//! ([`aion_package::WorkloopContract`], compiled from the `.awl` header and
//! bound into package identity) to the engine's runtime
//! [`WorkloopSpec`].
//!
//! # 🔴 WHY THIS EXISTS, AND WHY IT REFUSES RATHER THAN FILLS IN
//!
//! The two records look alike and are not the same thing. The contract is a
//! transport shape: every field is optional-on-the-wire so an older archive
//! still decodes, and a compiled document is the only thing that ever writes
//! one. The spec is an executable authority the engine acts on: it decides
//! when a loop fires, when it alarms, what is destroyed, and what generation 1
//! starts from — and `WorkloopSpec`'s constructors refuse every shape that
//! cannot be acted on.
//!
//! So this conversion is where the wire's optionality meets the runtime's
//! demands, and every gap between them is REFUSED with a diagnostic naming the
//! declaration, never defaulted. A workloop armed by a cadence nobody declared
//! is a loop firing on a schedule no author wrote; an invariant confirmed by
//! no route alarms forever. Both are worse than a deploy that will not start.

use std::collections::BTreeMap;
use std::time::Duration;

use aion_core::{CarryContract, InvariantSpec, ToleranceSpec, WorkloopArming, WorkloopSpec};
use aion_package::{InvariantContract, ToleranceContract, WorkloopContract};

use super::error::WorkloopError;

/// Build the runtime spec a deployed workloop is registered with.
///
/// # Errors
///
/// Refuses a contract that declares neither cadence nor arming signals, an
/// invariant with no tolerance or no confirming route, a zero or
/// unrepresentable retention window, and any other shape `WorkloopSpec`'s own
/// constructors refuse — each with the declaration named.
pub fn spec_from_contract(
    workflow_type: &str,
    contract: &WorkloopContract,
) -> Result<WorkloopSpec, WorkloopError> {
    let arming = arming_from(workflow_type, contract)?;
    let invariants = contract
        .invariants
        .iter()
        .map(|invariant| invariant_from(workflow_type, invariant))
        .collect::<Result<Vec<_>, _>>()?;
    // `retention_seconds` is REQUIRED on the wire because the language refuses
    // a workloop that declares no `retention` (C6/R8.1): retention that is not
    // declared is retention that does not happen. Zero is still refused below
    // by the spec's own constructor, because a zero window prunes everything.
    let retention = Duration::from_secs(contract.retention_seconds);
    let carry = CarryContract::new(
        contract
            .carries
            .iter()
            .map(|carry| (carry.name.clone(), carry.default.clone()))
            .collect::<BTreeMap<_, _>>(),
    )
    .map_err(|error| WorkloopError::Engine {
        reason: format!(
            "the deployed workloop declaration for `{workflow_type}` carries a seed the engine \
             cannot write into generation 1's start payload: {error}"
        ),
    })?;
    WorkloopSpec::with_carry(arming, invariants, retention, carry).map_err(|error| {
        WorkloopError::Engine {
            reason: format!(
                "the deployed workloop declaration for `{workflow_type}` cannot be armed: \
                 {error}. Nothing was started and no registration was written"
            ),
        }
    })
}

fn arming_from(
    workflow_type: &str,
    contract: &WorkloopContract,
) -> Result<WorkloopArming, WorkloopError> {
    let signals = contract.arms.clone();
    match (contract.cadence_seconds, signals.is_empty()) {
        (Some(seconds), true) => WorkloopArming::every(Duration::from_secs(seconds)),
        (Some(seconds), false) => {
            WorkloopArming::every_with_signals(Duration::from_secs(seconds), signals)
        }
        (None, false) => WorkloopArming::signal_only(signals),
        (None, true) => {
            return Err(WorkloopError::Engine {
                reason: format!(
                    "the deployed workloop declaration for `{workflow_type}` arms on neither a \
                     cadence nor a signal, so nothing would ever wake it. The language refuses \
                     this (C5), so an archive carrying it was built by a toolchain that did not \
                     check it"
                ),
            });
        }
    }
    .map_err(|error| WorkloopError::Engine {
        reason: format!(
            "the deployed workloop declaration for `{workflow_type}` cannot be armed: {error}"
        ),
    })
}

/// The refusal a tolerance the runtime cannot act on earns.
fn tolerance_refusal(workflow_type: &str, invariant: &str, reason: &str) -> WorkloopError {
    WorkloopError::Engine {
        reason: format!(
            "invariant `{invariant}` of the deployed workloop `{workflow_type}` declares a \
             tolerance the engine cannot act on: {reason}"
        ),
    }
}

fn invariant_from(
    workflow_type: &str,
    contract: &InvariantContract,
) -> Result<InvariantSpec, WorkloopError> {
    let mut windows = None;
    let mut unconfirmed_for = None;
    for tolerance in &contract.tolerances {
        match tolerance {
            ToleranceContract::Windows { count } => windows = Some(*count),
            ToleranceContract::UnconfirmedFor { seconds } => {
                unconfirmed_for = Some(Duration::from_secs(*seconds));
            }
        }
    }
    let tolerance = match (windows, unconfirmed_for) {
        (Some(count), Some(duration)) => ToleranceSpec::both(count, duration).map_err(|error| {
            tolerance_refusal(workflow_type, &contract.name, &error.to_string())
        })?,
        (Some(count), None) => ToleranceSpec::count(count),
        (None, Some(duration)) => ToleranceSpec::duration(duration).map_err(|error| {
            tolerance_refusal(workflow_type, &contract.name, &error.to_string())
        })?,
        // Tolerance has NO default (R2.3): an invariant without one would
        // alarm on a threshold nobody wrote.
        (None, None) => {
            return Err(WorkloopError::Engine {
                reason: format!(
                    "invariant `{}` of the deployed workloop `{workflow_type}` declares no \
                     tolerance, and tolerance has no default (R2.3) — the engine cannot invent \
                     the threshold at which it alarms",
                    contract.name
                ),
            });
        }
    };
    // `confirms` is REQUIRED on every invariant (C12): an invariant nothing can
    // confirm is not a weaker guarantee than none, it is a pager that never
    // stops. It is `Option` on the wire only because the PARSER must be able to
    // build a document that omits it so the checker can name what to add.
    let confirms = contract
        .confirms
        .clone()
        .ok_or_else(|| WorkloopError::Engine {
            reason: format!(
                "invariant `{}` of the deployed workloop `{workflow_type}` names no confirming \
             route, so nothing in the document could ever confirm it and it would alarm at \
             every window forever",
                contract.name
            ),
        })?;
    Ok(InvariantSpec {
        name: contract.name.clone(),
        record_type: contract.record_type.clone(),
        tolerance,
        confirms: vec![confirms],
    })
}