af-workflow 0.2.0

Spec-driven workflow chassis: typed node expressions composed into a branched DAG. Port of agent_core/workflow/v2.
Documentation
//! Node-type registry. Port of `platform/registry.py`.
//!
//! Maps a node type string (`transform.state_append`) to a factory that builds
//! a [`StepNode`] from its config. Products extend the registry with their own
//! business node types without touching this crate.

use std::collections::HashMap;

use serde_json::Value;

use crate::node::StepNode;

#[derive(Debug, thiserror::Error)]
pub enum NodeError {
    #[error("unknown node type '{0}' (not registered)")]
    UnknownType(String),
    #[error("node '{node_type}' has invalid config: {reason}")]
    InvalidConfig { node_type: String, reason: String },
}

/// Builds a step node from its (raw, unresolved) config value.
pub type StepFactory = fn(config: &Value) -> Result<Box<dyn StepNode>, NodeError>;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FieldType {
    String,
    Number,
    Bool,
    Array,
    Object,
    Any,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FieldSpec {
    pub key: String,
    pub ty: FieldType,
    pub required: bool,
}

impl FieldSpec {
    pub fn required(key: impl Into<String>, ty: FieldType) -> Self {
        Self {
            key: key.into(),
            ty,
            required: true,
        }
    }

    pub fn optional(key: impl Into<String>, ty: FieldType) -> Self {
        Self {
            key: key.into(),
            ty,
            required: false,
        }
    }
}

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct NodeSchema {
    pub fields: Vec<FieldSpec>,
}

/// Registry of step-node factories. Ingress types are tracked by name only —
/// the runner owns their execution.
#[derive(Debug)]
pub struct NodeRegistry {
    steps: HashMap<String, StepFactory>,
    ingress: std::collections::HashSet<String>,
    fan_out: std::collections::HashSet<String>,
    side_effect_guards: std::collections::HashSet<String>,
    schemas: HashMap<String, NodeSchema>,
}

impl NodeRegistry {
    /// Empty registry — no node types known.
    pub fn empty() -> Self {
        Self {
            steps: HashMap::new(),
            ingress: Default::default(),
            fan_out: Default::default(),
            side_effect_guards: Default::default(),
            schemas: HashMap::new(),
        }
    }

    /// Registry pre-loaded with the generic (non-business) node types.
    pub fn with_builtins() -> Self {
        let mut r = Self::empty();
        crate::builtins::register_builtins(&mut r);
        r
    }

    /// Register a step-node factory under `node_type`.
    pub fn register_step(&mut self, node_type: impl Into<String>, factory: StepFactory) {
        self.steps.insert(node_type.into(), factory);
    }

    /// Mark a node type as an ingress source.
    pub fn register_ingress(&mut self, node_type: impl Into<String>) {
        self.ingress.insert(node_type.into());
    }

    /// Mark a node type as fan-out-capable (its `process` may return `FanOut`).
    /// The validator (R4') bans these upstream of `execute.*`.
    pub fn register_fan_out(&mut self, node_type: impl Into<String>) {
        self.fan_out.insert(node_type.into());
    }

    pub fn is_fan_out_capable(&self, node_type: &str) -> bool {
        self.fan_out.contains(node_type)
    }

    /// Declare a product node as an authorization guard for `execute.*` side effects.
    pub fn register_side_effect_guard(&mut self, node_type: impl Into<String>) {
        self.side_effect_guards.insert(node_type.into());
    }

    pub fn is_side_effect_guard(&self, node_type: &str) -> bool {
        self.side_effect_guards.contains(node_type)
    }

    pub fn is_ingress(&self, node_type: &str) -> bool {
        self.ingress.contains(node_type) || node_type.starts_with("ingress.")
    }

    pub fn is_step(&self, node_type: &str) -> bool {
        self.steps.contains_key(node_type)
    }

    /// Build a step node instance from a spec node's type + config.
    pub fn build_step(
        &self,
        node_type: &str,
        config: &Value,
    ) -> Result<Box<dyn StepNode>, NodeError> {
        let factory = self
            .steps
            .get(node_type)
            .ok_or_else(|| NodeError::UnknownType(node_type.to_string()))?;
        factory(config)
    }

    pub fn known_step_types(&self) -> impl Iterator<Item = &str> {
        self.steps.keys().map(|s| s.as_str())
    }

    pub fn register_schema(&mut self, node_type: impl Into<String>, schema: NodeSchema) {
        self.schemas.insert(node_type.into(), schema);
    }

    pub fn schema(&self, node_type: &str) -> Option<&NodeSchema> {
        self.schemas.get(node_type)
    }
}