af-workflow 0.4.0

Spec-driven workflow chassis: typed node expressions composed into a branched DAG. Port of agent_core/workflow.
Documentation
//! Workflow spec data model.
//!
//! A workflow is a set of **branches**, each a DAG of **nodes** wired by
//! **edges**. A node is a typed expression: its `node_type` is a namespaced
//! verb (`ingress.cron`, `transform.state_append`, `decision.consensus_voting`,
//! `guard.permission`, `execute.publish`, `sink.log`, …) and
//! its `config` is the expression's parameters. Composing these typed
//! expressions into a DAG is the whole programming model — the same shape as the
//! Python `agent_core/workflow` JSON specs, so existing builtin specs
//! deserialize unchanged.
//!
//! This module is the *data model* only. The executor (scheduling branches,
//! resolving edge order, dispatching node types, threading run state) is the
//! next porting phase and will live in a sibling `runtime` module.

use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};

/// A complete workflow specification.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Spec {
    /// Workflow spec identifier.
    pub spec_id: String,
    /// Semantic version string.
    pub version: String,

    /// Human-readable description.
    #[serde(default)]
    pub description: String,

    /// Alternate ids this spec answers to.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub aliases: Vec<String>,

    /// JSON-Schema for per-instance configuration, if the spec is templated.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub instance_config_schema: Option<serde_json::Value>,

    /// Presentation metadata, keyed by node id. Opaque to the engine.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub display: BTreeMap<String, serde_json::Value>,

    /// Branches; the first is the root.
    pub branches: Vec<Branch>,
}

/// One DAG within a spec. The root branch is conventionally `__root__`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Branch {
    /// Workflow branch this record belongs to.
    pub branch_id: String,
    /// Nodes in this graph.
    pub nodes: Vec<Node>,
    /// Directed edges between nodes.
    #[serde(default)]
    pub edges: Vec<Edge>,
}

/// A typed node expression.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Node {
    /// Stable identifier of this record.
    pub id: String,
    /// Namespaced node type, e.g. `transform.state_append`. The wire key is
    /// `type`; we rename to avoid the Rust keyword.
    #[serde(rename = "type")]
    pub node_type: String,
    /// Expression parameters. Shape depends on `node_type`; validated by the
    /// executor against the node-type registry, not here.
    #[serde(default)]
    pub config: serde_json::Value,
}

impl Node {
    /// The category half of the node type (`transform` in `transform.foo`).
    pub fn category(&self) -> &str {
        self.node_type.split('.').next().unwrap_or(&self.node_type)
    }

    /// The action half (`foo` in `transform.foo`), or `""` if untyped.
    pub fn action(&self) -> &str {
        self.node_type.split_once('.').map(|(_, a)| a).unwrap_or("")
    }
}

/// A directed wire from one node's output to another's input.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Edge {
    /// Source node id.
    pub source: String,
    /// Target node id.
    pub target: String,
}

/// A spec is structurally invalid.
#[derive(Debug, thiserror::Error)]
pub enum SpecError {
    /// Spec parse error.
    #[error("spec parse error: {0}")]
    Parse(#[from] serde_json::Error),
    /// Invalid spec '`spec_id`': `reason`.
    #[error("invalid spec '{spec_id}': {reason}")]
    Invalid {
        /// Spec id.
        spec_id: String,
        /// What is wrong.
        reason: String,
    },
}

impl Spec {
    /// Parse a spec from JSON.
    pub fn from_json(s: &str) -> Result<Self, SpecError> {
        Ok(serde_json::from_str(s)?)
    }

    /// Structural validation that does not require the node-type registry:
    /// every edge endpoint must reference a node id that exists in its branch.
    /// (Cycle detection and node-type resolution belong to the executor phase.)
    pub fn validate_structure(&self) -> Result<(), SpecError> {
        for branch in &self.branches {
            let ids: std::collections::HashSet<&str> =
                branch.nodes.iter().map(|n| n.id.as_str()).collect();
            for edge in &branch.edges {
                for (role, endpoint) in [("source", &edge.source), ("target", &edge.target)] {
                    if !ids.contains(endpoint.as_str()) {
                        return Err(SpecError::Invalid {
                            spec_id: self.spec_id.clone(),
                            reason: format!(
                                "branch '{}' edge {} '{}' references unknown node",
                                branch.branch_id, role, endpoint
                            ),
                        });
                    }
                }
            }
        }
        Ok(())
    }
}

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

    #[test]
    fn node_type_split() {
        let n = Node {
            id: "x".into(),
            node_type: "transform.state_append".into(),
            config: serde_json::json!({}),
        };
        assert_eq!(n.category(), "transform");
        assert_eq!(n.action(), "state_append");
    }

    #[test]
    fn validate_catches_dangling_edge() {
        let spec = Spec {
            spec_id: "t".into(),
            version: "1.0".into(),
            description: String::new(),
            aliases: vec![],
            instance_config_schema: None,
            display: Default::default(),
            branches: vec![Branch {
                branch_id: "__root__".into(),
                nodes: vec![Node {
                    id: "a".into(),
                    node_type: "ingress.cron".into(),
                    config: serde_json::json!({}),
                }],
                edges: vec![Edge {
                    source: "a".into(),
                    target: "ghost".into(),
                }],
            }],
        };
        assert!(spec.validate_structure().is_err());
    }
}