openquiver-runtime 0.1.0

Event-sourced executor: exec tokens, journal, run resumption.
Documentation
//! Values in flight.

use openquiver_acp::SessionHandle;
use openquiver_graph::Value;
use serde::{Deserialize, Serialize};

/// A value on its way from one pin to another.
///
/// Distinct from [`openquiver_graph::Value`], which is only ever a literal written
/// in a file. This one also carries the handles a run produces, and every one of
/// them has to survive being written to the journal and read back — which is why
/// a session appears here as its handle and never as the object.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum RunValue {
    Text(String),
    Number(#[serde(serialize_with = "finite")] f64),
    Bool(bool),
    Array(Vec<RunValue>),
    /// A connected agent, by the name it has in the project manifest.
    Agent(String),
    Session(SessionHandle),
    /// The *name* of a secret, never its value.
    ///
    /// Resolution happens at the point of use, so a secret never reaches the
    /// journal — and the journal is a file a colleague can be handed.
    Secret(String),
}

/// Refuses a number JSON cannot hold.
///
/// `serde_json` turns NaN and infinity into `null` without complaining, which
/// would write a `node.exited` that cannot be read back: a run recorded as
/// having succeeded, and permanently unresumable. Nothing in this milestone
/// produces one — the parser refuses non-finite literals — which is exactly when
/// to close the hole, before something does.
fn finite<S: serde::Serializer>(n: &f64, serializer: S) -> Result<S::Ok, S::Error> {
    if !n.is_finite() {
        return Err(serde::ser::Error::custom(format!(
            "{n} is not a number a journal can hold"
        )));
    }
    serializer.serialize_f64(*n)
}

impl From<&Value> for RunValue {
    fn from(value: &Value) -> Self {
        match value {
            Value::Text(text) => RunValue::Text(text.clone()),
            Value::Number(n) => RunValue::Number(*n),
            Value::Bool(b) => RunValue::Bool(*b),
            Value::Array(items) => RunValue::Array(items.iter().map(RunValue::from).collect()),
            Value::Secret(name) => RunValue::Secret(name.clone()),
        }
    }
}

impl RunValue {
    /// How this value reads on a `Text` pin.
    ///
    /// These are the conversions `openquiver_compile::accepts` lets through into
    /// `Text`, for every type this milestone can put on a wire. A value the
    /// compiler allowed and the runtime cannot render is a graph that passes
    /// `check` and dies mid-run, so the two have to be read together whenever
    /// either changes.
    ///
    /// `accepts` also passes `Wildcard`, which has no rendering because it has
    /// no resolved type yet. Nothing produces one — wildcard resolution is not
    /// built — and when it is, resolution has to happen before this point.
    pub fn as_text(&self) -> Option<String> {
        match self {
            RunValue::Text(text) => Some(text.clone()),
            // `to_string` on `f64` prints 3, not 3.0 — the same rendering the
            // formatter gives a literal, so a number reads the same in the file
            // and in the run.
            RunValue::Number(n) => Some(n.to_string()),
            RunValue::Bool(b) => Some(b.to_string()),
            _ => None,
        }
    }

    pub fn as_bool(&self) -> Option<bool> {
        match self {
            RunValue::Bool(b) => Some(*b),
            _ => None,
        }
    }

    pub fn as_agent(&self) -> Option<&str> {
        match self {
            RunValue::Agent(name) => Some(name),
            _ => None,
        }
    }

    pub fn as_session(&self) -> Option<&SessionHandle> {
        match self {
            RunValue::Session(handle) => Some(handle),
            _ => None,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::RunValue;

    #[test]
    fn a_number_json_cannot_hold_is_refused_rather_than_written_as_null() {
        for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
            let written = serde_json::to_value(RunValue::Number(bad));
            assert!(
                written.is_err(),
                "{bad} was written as {written:?}, which reads back as nothing"
            );
        }
    }

    #[test]
    fn an_ordinary_number_still_round_trips() {
        let written = serde_json::to_value(RunValue::Number(3.5)).expect("write");
        let read: RunValue = serde_json::from_value(written).expect("read");
        assert_eq!(read, RunValue::Number(3.5));
    }
}