af-workflow 0.4.0

Spec-driven workflow chassis: typed node expressions composed into a branched DAG. Port of agent_core/workflow.
Documentation
//! Presentation-only workflow graph rendering for UI consumers.

use serde::{Deserialize, Serialize};
use serde_json::Value;

use crate::{HostError, Spec};

/// Node in the rendered UI graph.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct GraphNode {
    /// Stable identifier of this record.
    pub id: String,
    /// Branch the node belongs to, when not the root.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub branch: Option<String>,
    /// Full node type string.
    pub full_type: String,
    /// Discriminator naming the variant of this record.
    pub kind: String,
    /// Display title.
    pub title: String,
    /// Secondary label rendered under the title.
    pub subtitle: String,
}

/// Directed edge in the rendered UI graph.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct GraphEdge {
    /// Source node id.
    pub source: String,
    /// Target node id.
    pub target: String,
}

/// Presentation model of a spec for UI rendering.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct WorkflowGraph {
    /// Workflow spec identifier.
    pub spec_id: String,
    /// Nodes in this graph.
    pub nodes: Vec<GraphNode>,
    /// Directed edges between nodes.
    pub edges: Vec<GraphEdge>,
}

/// Coarse primitive kind (`ingress`, `guard`, `execute`, `sink`, `transform`, ...) for a node type.
pub fn primitive_kind(node_type: &str) -> &'static str {
    match node_type.split('.').next().unwrap_or(node_type) {
        "ingress" => "event",
        "filter" | "decision" => "filter",
        "risk" => "risk",
        "notify" => "channel",
        "sink" => "sink",
        _ => "tool",
    }
}

fn render_text(template: &str, instance_config: &Value) -> String {
    let mut output = template.to_string();
    if let Some(config) = instance_config.as_object() {
        for (key, value) in config {
            let value = value
                .as_str()
                .map(str::to_string)
                .unwrap_or_else(|| value.to_string());
            output = output.replace(&format!("$config.{key}"), &value);
        }
    }
    output
}

fn default_subtitle(config: &Value, instance_config: &Value) -> String {
    config
        .as_object()
        .into_iter()
        .flat_map(|object| object.iter().take(3))
        .map(|(key, value)| {
            let value = value
                .as_str()
                .map(|text| render_text(text, instance_config))
                .unwrap_or_else(|| value.to_string());
            format!("{key}={value}")
        })
        .collect::<Vec<_>>()
        .join(", ")
}

/// Render a spec with templates resolved against `instance_config`.
pub fn render_graph(spec: &Spec, instance_config: &Value) -> WorkflowGraph {
    let multi_branch = spec.branches.len() > 1;
    let mut nodes = Vec::new();
    let mut edges = Vec::new();

    for branch in &spec.branches {
        let prefix = if multi_branch {
            format!("{}__", branch.branch_id)
        } else {
            String::new()
        };
        for node in &branch.nodes {
            let display = spec.display.get(&node.id);
            let text = |key: &str| {
                display
                    .and_then(|value| value.get(key))
                    .and_then(Value::as_str)
                    .map(|value| render_text(value, instance_config))
            };
            nodes.push(GraphNode {
                id: format!("{prefix}{}", node.id),
                branch: multi_branch.then(|| branch.branch_id.clone()),
                full_type: node.node_type.clone(),
                kind: primitive_kind(&node.node_type).into(),
                title: text("title").unwrap_or_else(|| node.action().replace('_', " ")),
                subtitle: text("subtitle")
                    .unwrap_or_else(|| default_subtitle(&node.config, instance_config)),
            });
        }
        edges.extend(branch.edges.iter().map(|edge| GraphEdge {
            source: format!("{prefix}{}", edge.source),
            target: format!("{prefix}{}", edge.target),
        }));
    }

    WorkflowGraph {
        spec_id: spec.spec_id.clone(),
        nodes,
        edges,
    }
}

/// [`render_graph`] serialized to JSON.
pub fn render_graph_json(
    spec_json: &str,
    instance_config: &Value,
) -> Result<WorkflowGraph, HostError> {
    Ok(render_graph(&Spec::from_json(spec_json)?, instance_config))
}

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

    #[test]
    fn multi_branch_ids_and_display_are_ui_ready() {
        let graph = render_graph_json(
            r#"{"spec_id":"g","version":"1","display":{"in":{"title":"Run $config.name"}},"branches":[
              {"branch_id":"a","nodes":[{"id":"in","type":"ingress.event","config":{}}],"edges":[]},
              {"branch_id":"b","nodes":[{"id":"out","type":"notify.email","config":{"to":"$config.email"}}],"edges":[]}
            ]}"#,
            &json!({"name":"demo","email":"a@example.com"}),
        )
        .unwrap();
        assert_eq!(graph.nodes[0].id, "a__in");
        assert_eq!(graph.nodes[0].title, "Run demo");
        assert_eq!(graph.nodes[1].kind, "channel");
        assert!(graph.nodes[1].subtitle.contains("a@example.com"));
    }

    #[test]
    fn primitive_categories_are_stable() {
        assert_eq!(primitive_kind("ingress.event"), "event");
        assert_eq!(primitive_kind("decision.vote"), "filter");
        assert_eq!(primitive_kind("risk.guard"), "risk");
        assert_eq!(primitive_kind("sink.log"), "sink");
        assert_eq!(primitive_kind("transform.map"), "tool");
    }
}