use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
const RUN_RECORD_AUTHOR: &str = "system";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct WorkflowDef {
pub id: String,
#[serde(default)]
pub version: u32,
pub entry: String,
pub nodes: Vec<NodeDef>,
#[serde(default)]
pub edges: Vec<EdgeDef>,
}
impl WorkflowDef {
pub fn canonical_hash(&self) -> String {
let value = serde_json::to_value(self).expect("WorkflowDef serialises");
hex::encode(Sha256::digest(canonical_json_bytes(&value)))
}
}
pub fn canonical_json_bytes(value: &Value) -> Vec<u8> {
let canonical = canonicalise(value);
serde_json::to_vec(&canonical).expect("canonical JSON serialises")
}
fn canonicalise(value: &Value) -> Value {
match value {
Value::Object(map) => {
let sorted: std::collections::BTreeMap<String, Value> = map
.iter()
.map(|(k, v)| (k.clone(), canonicalise(v)))
.collect();
Value::Object(sorted.into_iter().collect())
}
Value::Array(items) => Value::Array(items.iter().map(canonicalise).collect()),
other => other.clone(),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub struct WorkflowRunRecord {
workflow_id: String,
version: u32,
content_hash: String,
author: String,
}
impl WorkflowRunRecord {
pub fn workflow_id(&self) -> &str {
&self.workflow_id
}
pub fn version(&self) -> u32 {
self.version
}
pub fn content_hash(&self) -> &str {
&self.content_hash
}
pub fn author(&self) -> &str {
&self.author
}
}
pub fn run_record(def: &WorkflowDef) -> WorkflowRunRecord {
WorkflowRunRecord {
workflow_id: def.id.clone(),
version: def.version,
content_hash: def.canonical_hash(),
author: RUN_RECORD_AUTHOR.to_string(),
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct NodeDef {
pub id: String,
#[serde(flatten)]
pub kind: NodeKind,
#[serde(default)]
pub input_from: Option<String>,
#[serde(default)]
pub output_to: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "kind", rename_all = "snake_case")]
#[non_exhaustive]
pub enum NodeKind {
Agent {
agent: AgentConfig,
},
Tool {
tool: String,
},
Subflow {
#[serde(rename = "ref")]
target: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct AgentConfig {
pub model: String,
pub system_prompt: String,
#[serde(default)]
pub tools: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct EdgeDef {
pub from: String,
#[serde(default)]
pub to: Option<String>,
#[serde(default)]
pub when: Option<crate::condition::Condition>,
#[serde(default)]
pub then: Option<String>,
#[serde(default, rename = "else")]
pub otherwise: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_a_minimal_linear_workflow() {
let json = serde_json::json!({
"id": "greet", "entry": "hello",
"nodes": [
{ "id": "hello", "kind": "agent", "input_from": "q", "output_to": "a",
"agent": { "model": "default", "system_prompt": "Be brief." } }
],
"edges": []
});
let def: WorkflowDef = serde_json::from_value(json).unwrap();
assert_eq!(def.id, "greet");
assert_eq!(def.entry, "hello");
assert_eq!(def.nodes.len(), 1);
assert!(matches!(def.nodes[0].kind, NodeKind::Agent { .. }));
}
#[test]
fn unknown_field_is_rejected() {
let json = serde_json::json!({
"id": "x", "entry": "n", "nodes": [], "bogus": true
});
let err = serde_json::from_value::<WorkflowDef>(json).unwrap_err();
assert!(err.to_string().contains("bogus"), "got: {err}");
}
#[test]
fn tool_and_subflow_kinds_parse() {
let t: NodeDef = serde_json::from_value(serde_json::json!({
"id": "t", "kind": "tool", "tool": "lookup"
}))
.unwrap();
assert!(matches!(t.kind, NodeKind::Tool { .. }));
let s: NodeDef = serde_json::from_value(serde_json::json!({
"id": "s", "kind": "subflow", "ref": "fraud"
}))
.unwrap();
assert!(matches!(s.kind, NodeKind::Subflow { .. }));
}
#[test]
fn node_def_currently_accepts_unknown_field() {
let n: NodeDef = serde_json::from_value(serde_json::json!({
"id": "t", "kind": "tool", "tool": "lookup", "bogus": true
}))
.unwrap();
assert!(matches!(n.kind, NodeKind::Tool { .. }));
}
#[test]
fn canonical_hash_is_stable_across_key_order() {
let a: WorkflowDef = serde_json::from_str(r#"{"id":"x","entry":"n","nodes":[]}"#).unwrap();
let b: WorkflowDef = serde_json::from_str(r#"{"entry":"n","id":"x","nodes":[]}"#).unwrap();
assert_eq!(a.canonical_hash(), b.canonical_hash());
}
#[test]
fn canonical_hash_stable_across_nested_object_key_order() {
let a: WorkflowDef = serde_json::from_str(
r#"{"id":"w","entry":"n","nodes":[
{"id":"n","kind":"agent","input_from":"q","output_to":"a",
"agent":{"model":"m","system_prompt":"p"}}]}"#,
)
.unwrap();
let b: WorkflowDef = serde_json::from_str(
r#"{"nodes":[
{"agent":{"system_prompt":"p","model":"m"},
"output_to":"a","input_from":"q","kind":"agent","id":"n"}],
"entry":"n","id":"w"}"#,
)
.unwrap();
assert_eq!(a.canonical_hash(), b.canonical_hash());
}
#[test]
fn canonical_hash_changes_with_content() {
let a: WorkflowDef = serde_json::from_str(r#"{"id":"x","entry":"n","nodes":[]}"#).unwrap();
let b: WorkflowDef = serde_json::from_str(r#"{"id":"y","entry":"n","nodes":[]}"#).unwrap();
assert_ne!(a.canonical_hash(), b.canonical_hash());
}
#[test]
fn canonical_json_bytes_stable_across_key_order() {
let a: Value = serde_json::json!({"a": 1, "b": {"x": 2, "y": 3}});
let b: Value = serde_json::json!({"b": {"y": 3, "x": 2}, "a": 1});
assert_eq!(canonical_json_bytes(&a), canonical_json_bytes(&b));
let different: Value = serde_json::json!({"a": 1, "b": {"x": 2, "y": 4}});
assert_ne!(canonical_json_bytes(&a), canonical_json_bytes(&different));
}
#[test]
fn run_record_binds_id_version_hash_and_system_author() {
let def: WorkflowDef =
serde_json::from_str(r#"{"id":"x","version":3,"entry":"n","nodes":[]}"#).unwrap();
let record = run_record(&def);
assert_eq!(record.workflow_id(), "x");
assert_eq!(record.version(), 3);
assert_eq!(record.content_hash(), def.canonical_hash());
assert_eq!(record.author(), "system");
}
}