klieo-workflow 3.8.2

Declarative no-code workflow substrate for the klieo agent framework.
Documentation
//! Schema-drift gate for the published `WorkflowDef` wire contract.
//!
//! Serializes real `WorkflowDef` values and validates each against
//! `docs/schemas/workflow/workflow-def.schema.json` (the same file the Python
//! SDK validates its emitted JSON against — one source of truth). A failing
//! assertion means the Rust type and the published schema have diverged;
//! resolve in the same change (additive relaxation = minor, breaking shape =
//! major). Never silence the gate by deleting a fixture.
//!
//! The fixtures are exhaustive by design — serialize-validate only catches
//! drift a fixture exercises: every node kind (agent/tool/subflow), both edge
//! shapes (unconditional + conditional), every `Op`, and optional fields
//! (`version`, `input_from`, `output_to`, agent `tools`, `exists` null value)
//! populated.

use jsonschema::JSONSchema;
use klieo_workflow::{AgentConfig, Condition, EdgeDef, NodeDef, NodeKind, Op, WorkflowDef};
use serde_json::{json, Value};
use std::path::PathBuf;

const SCHEMA_PATH: &str = "docs/schemas/workflow/workflow-def.schema.json";

fn workspace_root() -> PathBuf {
    // CARGO_MANIFEST_DIR is <root>/crates/klieo-workflow; schemas live at root.
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("..")
        .join("..")
}

fn load_schema() -> Value {
    let path = workspace_root().join(SCHEMA_PATH);
    let text = std::fs::read_to_string(&path)
        .unwrap_or_else(|err| panic!("read schema {}: {err}", path.display()));
    serde_json::from_str(&text)
        .unwrap_or_else(|err| panic!("parse schema {}: {err}", path.display()))
}

fn compile_schema() -> JSONSchema {
    JSONSchema::compile(&load_schema()).expect("workflow schema compiles as valid JSON Schema")
}

fn violations(schema: &JSONSchema, instance: &Value) -> Vec<String> {
    match schema.validate(instance) {
        Ok(()) => Vec::new(),
        Err(errors) => errors.map(|error| error.to_string()).collect(),
    }
}

fn agent_node(id: &str) -> NodeDef {
    NodeDef {
        id: id.into(),
        kind: NodeKind::Agent {
            agent: AgentConfig {
                model: "default".into(),
                system_prompt: "be brief".into(),
                tools: vec!["lookup".into()],
            },
        },
        input_from: Some("q".into()),
        output_to: Some("a".into()),
    }
}

fn tool_node(id: &str) -> NodeDef {
    NodeDef {
        id: id.into(),
        kind: NodeKind::Tool {
            tool: "lookup".into(),
        },
        input_from: Some("x".into()),
        output_to: Some("y".into()),
    }
}

fn subflow_node(id: &str, target: &str) -> NodeDef {
    NodeDef {
        id: id.into(),
        kind: NodeKind::Subflow {
            target: target.into(),
        },
        input_from: None,
        output_to: None,
    }
}

fn unconditional_edge(from: &str, to: &str) -> EdgeDef {
    EdgeDef {
        from: from.into(),
        to: Some(to.into()),
        when: None,
        then: None,
        otherwise: None,
    }
}

fn conditional_edge(from: &str, op: Op) -> EdgeDef {
    let value = if matches!(op, Op::Exists) {
        Value::Null
    } else {
        json!(0.8)
    };
    EdgeDef {
        from: from.into(),
        to: None,
        when: Some(Condition {
            field: "risk".into(),
            op,
            value,
        }),
        then: Some("hi".into()),
        otherwise: Some("lo".into()),
    }
}

fn node_kind_fixtures() -> Vec<(String, WorkflowDef)> {
    vec![
        (
            "agent_unconditional".to_string(),
            WorkflowDef {
                id: "w-agent".into(),
                version: 2,
                entry: "a".into(),
                nodes: vec![agent_node("a"), agent_node("b")],
                edges: vec![unconditional_edge("a", "b")],
            },
        ),
        (
            "tool_node".to_string(),
            WorkflowDef {
                id: "w-tool".into(),
                version: 0,
                entry: "t".into(),
                nodes: vec![tool_node("t")],
                edges: vec![],
            },
        ),
        (
            "subflow_node".to_string(),
            WorkflowDef {
                id: "w-sub".into(),
                version: 0,
                entry: "s".into(),
                nodes: vec![subflow_node("s", "fraud")],
                edges: vec![],
            },
        ),
    ]
}

fn conditional_fixture(op: Op) -> (String, WorkflowDef) {
    (
        format!("conditional_{op:?}"),
        WorkflowDef {
            id: "w-cond".into(),
            version: 1,
            entry: "s".into(),
            nodes: vec![
                subflow_node("s", "seed"),
                subflow_node("hi", "high"),
                subflow_node("lo", "low"),
            ],
            edges: vec![conditional_edge("s", op)],
        },
    )
}

fn fixtures() -> Vec<(String, WorkflowDef)> {
    // Driven by the crate's canonical `Op::ALL` (compile-enforced complete),
    // never a hand-mirrored list — a new `Op` is fixture-tested automatically.
    let mut out = node_kind_fixtures();
    out.extend(Op::ALL.map(conditional_fixture));
    out
}

#[test]
fn schema_compiles_as_valid_json_schema() {
    compile_schema();
}

#[test]
fn every_fixture_validates_against_published_schema() {
    let schema = compile_schema();
    for (name, def) in fixtures() {
        let instance = serde_json::to_value(&def).expect("WorkflowDef serialises to json");
        let violations = violations(&schema, &instance);
        assert!(
            violations.is_empty(),
            "fixture `{name}` violates the published schema: {violations:?}\ninstance = {instance}",
        );
    }
}

#[test]
fn schema_rejects_a_workflow_missing_required_fields() {
    let schema = compile_schema();
    // Missing required `entry`, plus an unknown top-level key.
    let bad = json!({ "id": "x", "nodes": [], "bogus": true });
    assert!(!violations(&schema, &bad).is_empty());
}

#[test]
fn schema_rejects_an_unknown_node_kind() {
    let schema = compile_schema();
    let bad = json!({
        "id": "x", "entry": "n",
        "nodes": [{ "id": "n", "kind": "loop", "body": "x" }]
    });
    assert!(!violations(&schema, &bad).is_empty());
}