Skip to main content

aion/workloop/
declared.rs

1//! The bridge from a DEPLOYED package's workloop declaration
2//! ([`aion_package::WorkloopContract`], compiled from the `.awl` header and
3//! bound into package identity) to the engine's runtime
4//! [`WorkloopSpec`].
5//!
6//! # 🔴 WHY THIS EXISTS, AND WHY IT REFUSES RATHER THAN FILLS IN
7//!
8//! The two records look alike and are not the same thing. The contract is a
9//! transport shape: every field is optional-on-the-wire so an older archive
10//! still decodes, and a compiled document is the only thing that ever writes
11//! one. The spec is an executable authority the engine acts on: it decides
12//! when a loop fires, when it alarms, what is destroyed, and what generation 1
13//! starts from — and `WorkloopSpec`'s constructors refuse every shape that
14//! cannot be acted on.
15//!
16//! So this conversion is where the wire's optionality meets the runtime's
17//! demands, and every gap between them is REFUSED with a diagnostic naming the
18//! declaration, never defaulted. A workloop armed by a cadence nobody declared
19//! is a loop firing on a schedule no author wrote; an invariant confirmed by
20//! no route alarms forever. Both are worse than a deploy that will not start.
21
22use std::collections::BTreeMap;
23use std::time::Duration;
24
25use aion_core::{CarryContract, InvariantSpec, ToleranceSpec, WorkloopArming, WorkloopSpec};
26use aion_package::{InvariantContract, ToleranceContract, WorkloopContract};
27
28use super::error::WorkloopError;
29
30/// Build the runtime spec a deployed workloop is registered with.
31///
32/// # Errors
33///
34/// Refuses a contract that declares neither cadence nor arming signals, an
35/// invariant with no tolerance or no confirming route, a zero or
36/// unrepresentable retention window, and any other shape `WorkloopSpec`'s own
37/// constructors refuse — each with the declaration named.
38pub fn spec_from_contract(
39    workflow_type: &str,
40    contract: &WorkloopContract,
41) -> Result<WorkloopSpec, WorkloopError> {
42    let arming = arming_from(workflow_type, contract)?;
43    let invariants = contract
44        .invariants
45        .iter()
46        .map(|invariant| invariant_from(workflow_type, invariant))
47        .collect::<Result<Vec<_>, _>>()?;
48    // `retention_seconds` is REQUIRED on the wire because the language refuses
49    // a workloop that declares no `retention` (C6/R8.1): retention that is not
50    // declared is retention that does not happen. Zero is still refused below
51    // by the spec's own constructor, because a zero window prunes everything.
52    let retention = Duration::from_secs(contract.retention_seconds);
53    let carry = CarryContract::new(
54        contract
55            .carries
56            .iter()
57            .map(|carry| (carry.name.clone(), carry.default.clone()))
58            .collect::<BTreeMap<_, _>>(),
59    )
60    .map_err(|error| WorkloopError::Engine {
61        reason: format!(
62            "the deployed workloop declaration for `{workflow_type}` carries a seed the engine \
63             cannot write into generation 1's start payload: {error}"
64        ),
65    })?;
66    WorkloopSpec::with_carry(arming, invariants, retention, carry).map_err(|error| {
67        WorkloopError::Engine {
68            reason: format!(
69                "the deployed workloop declaration for `{workflow_type}` cannot be armed: \
70                 {error}. Nothing was started and no registration was written"
71            ),
72        }
73    })
74}
75
76fn arming_from(
77    workflow_type: &str,
78    contract: &WorkloopContract,
79) -> Result<WorkloopArming, WorkloopError> {
80    let signals = contract.arms.clone();
81    match (contract.cadence_seconds, signals.is_empty()) {
82        (Some(seconds), true) => WorkloopArming::every(Duration::from_secs(seconds)),
83        (Some(seconds), false) => {
84            WorkloopArming::every_with_signals(Duration::from_secs(seconds), signals)
85        }
86        (None, false) => WorkloopArming::signal_only(signals),
87        (None, true) => {
88            return Err(WorkloopError::Engine {
89                reason: format!(
90                    "the deployed workloop declaration for `{workflow_type}` arms on neither a \
91                     cadence nor a signal, so nothing would ever wake it. The language refuses \
92                     this (C5), so an archive carrying it was built by a toolchain that did not \
93                     check it"
94                ),
95            });
96        }
97    }
98    .map_err(|error| WorkloopError::Engine {
99        reason: format!(
100            "the deployed workloop declaration for `{workflow_type}` cannot be armed: {error}"
101        ),
102    })
103}
104
105/// The refusal a tolerance the runtime cannot act on earns.
106fn tolerance_refusal(workflow_type: &str, invariant: &str, reason: &str) -> WorkloopError {
107    WorkloopError::Engine {
108        reason: format!(
109            "invariant `{invariant}` of the deployed workloop `{workflow_type}` declares a \
110             tolerance the engine cannot act on: {reason}"
111        ),
112    }
113}
114
115fn invariant_from(
116    workflow_type: &str,
117    contract: &InvariantContract,
118) -> Result<InvariantSpec, WorkloopError> {
119    let mut windows = None;
120    let mut unconfirmed_for = None;
121    for tolerance in &contract.tolerances {
122        match tolerance {
123            ToleranceContract::Windows { count } => windows = Some(*count),
124            ToleranceContract::UnconfirmedFor { seconds } => {
125                unconfirmed_for = Some(Duration::from_secs(*seconds));
126            }
127        }
128    }
129    let tolerance = match (windows, unconfirmed_for) {
130        (Some(count), Some(duration)) => ToleranceSpec::both(count, duration).map_err(|error| {
131            tolerance_refusal(workflow_type, &contract.name, &error.to_string())
132        })?,
133        (Some(count), None) => ToleranceSpec::count(count),
134        (None, Some(duration)) => ToleranceSpec::duration(duration).map_err(|error| {
135            tolerance_refusal(workflow_type, &contract.name, &error.to_string())
136        })?,
137        // Tolerance has NO default (R2.3): an invariant without one would
138        // alarm on a threshold nobody wrote.
139        (None, None) => {
140            return Err(WorkloopError::Engine {
141                reason: format!(
142                    "invariant `{}` of the deployed workloop `{workflow_type}` declares no \
143                     tolerance, and tolerance has no default (R2.3) — the engine cannot invent \
144                     the threshold at which it alarms",
145                    contract.name
146                ),
147            });
148        }
149    };
150    // `confirms` is REQUIRED on every invariant (C12): an invariant nothing can
151    // confirm is not a weaker guarantee than none, it is a pager that never
152    // stops. It is `Option` on the wire only because the PARSER must be able to
153    // build a document that omits it so the checker can name what to add.
154    let confirms = contract
155        .confirms
156        .clone()
157        .ok_or_else(|| WorkloopError::Engine {
158            reason: format!(
159                "invariant `{}` of the deployed workloop `{workflow_type}` names no confirming \
160             route, so nothing in the document could ever confirm it and it would alarm at \
161             every window forever",
162                contract.name
163            ),
164        })?;
165    Ok(InvariantSpec {
166        name: contract.name.clone(),
167        record_type: contract.record_type.clone(),
168        tolerance,
169        confirms: vec![confirms],
170    })
171}