1use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use sha2::{Digest, Sha256};
7
8const RUN_RECORD_AUTHOR: &str = "system";
11
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
14#[serde(deny_unknown_fields)]
15pub struct WorkflowDef {
16 pub id: String,
18 #[serde(default)]
20 pub version: u32,
21 pub entry: String,
23 pub nodes: Vec<NodeDef>,
25 #[serde(default)]
27 pub edges: Vec<EdgeDef>,
28}
29
30impl WorkflowDef {
31 pub fn canonical_hash(&self) -> String {
36 let value = serde_json::to_value(self).expect("WorkflowDef serialises");
37 hex::encode(Sha256::digest(canonical_json_bytes(&value)))
38 }
39}
40
41pub fn canonical_json_bytes(value: &Value) -> Vec<u8> {
49 let canonical = canonicalise(value);
50 serde_json::to_vec(&canonical).expect("canonical JSON serialises")
51}
52
53fn canonicalise(value: &Value) -> Value {
56 match value {
57 Value::Object(map) => {
58 let sorted: std::collections::BTreeMap<String, Value> = map
59 .iter()
60 .map(|(k, v)| (k.clone(), canonicalise(v)))
61 .collect();
62 Value::Object(sorted.into_iter().collect())
63 }
64 Value::Array(items) => Value::Array(items.iter().map(canonicalise).collect()),
65 other => other.clone(),
66 }
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
82#[non_exhaustive]
83pub struct WorkflowRunRecord {
84 workflow_id: String,
85 version: u32,
86 content_hash: String,
87 author: String,
88}
89
90impl WorkflowRunRecord {
91 pub fn workflow_id(&self) -> &str {
93 &self.workflow_id
94 }
95
96 pub fn version(&self) -> u32 {
98 self.version
99 }
100
101 pub fn content_hash(&self) -> &str {
103 &self.content_hash
104 }
105
106 pub fn author(&self) -> &str {
108 &self.author
109 }
110}
111
112pub fn run_record(def: &WorkflowDef) -> WorkflowRunRecord {
116 WorkflowRunRecord {
117 workflow_id: def.id.clone(),
118 version: def.version,
119 content_hash: def.canonical_hash(),
120 author: RUN_RECORD_AUTHOR.to_string(),
121 }
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
134pub struct NodeDef {
135 pub id: String,
137 #[serde(flatten)]
139 pub kind: NodeKind,
140 #[serde(default)]
142 pub input_from: Option<String>,
143 #[serde(default)]
145 pub output_to: Option<String>,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
150#[serde(tag = "kind", rename_all = "snake_case")]
151#[non_exhaustive]
152pub enum NodeKind {
153 Agent {
155 agent: AgentConfig,
157 },
158 Tool {
160 tool: String,
162 },
163 Subflow {
165 #[serde(rename = "ref")]
167 target: String,
168 },
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
173#[serde(deny_unknown_fields)]
174pub struct AgentConfig {
175 pub model: String,
177 pub system_prompt: String,
179 #[serde(default)]
181 pub tools: Vec<String>,
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
186#[serde(deny_unknown_fields)]
187pub struct EdgeDef {
188 pub from: String,
190 #[serde(default)]
193 pub to: Option<String>,
194 #[serde(default)]
197 pub when: Option<crate::condition::Condition>,
198 #[serde(default)]
200 pub then: Option<String>,
201 #[serde(default, rename = "else")]
203 pub otherwise: Option<String>,
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209
210 #[test]
211 fn parses_a_minimal_linear_workflow() {
212 let json = serde_json::json!({
213 "id": "greet", "entry": "hello",
214 "nodes": [
215 { "id": "hello", "kind": "agent", "input_from": "q", "output_to": "a",
216 "agent": { "model": "default", "system_prompt": "Be brief." } }
217 ],
218 "edges": []
219 });
220 let def: WorkflowDef = serde_json::from_value(json).unwrap();
221 assert_eq!(def.id, "greet");
222 assert_eq!(def.entry, "hello");
223 assert_eq!(def.nodes.len(), 1);
224 assert!(matches!(def.nodes[0].kind, NodeKind::Agent { .. }));
225 }
226
227 #[test]
228 fn unknown_field_is_rejected() {
229 let json = serde_json::json!({
230 "id": "x", "entry": "n", "nodes": [], "bogus": true
231 });
232 let err = serde_json::from_value::<WorkflowDef>(json).unwrap_err();
233 assert!(err.to_string().contains("bogus"), "got: {err}");
234 }
235
236 #[test]
237 fn tool_and_subflow_kinds_parse() {
238 let t: NodeDef = serde_json::from_value(serde_json::json!({
239 "id": "t", "kind": "tool", "tool": "lookup"
240 }))
241 .unwrap();
242 assert!(matches!(t.kind, NodeKind::Tool { .. }));
243 let s: NodeDef = serde_json::from_value(serde_json::json!({
244 "id": "s", "kind": "subflow", "ref": "fraud"
245 }))
246 .unwrap();
247 assert!(matches!(s.kind, NodeKind::Subflow { .. }));
248 }
249
250 #[test]
251 fn node_def_currently_accepts_unknown_field() {
252 let n: NodeDef = serde_json::from_value(serde_json::json!({
258 "id": "t", "kind": "tool", "tool": "lookup", "bogus": true
259 }))
260 .unwrap();
261 assert!(matches!(n.kind, NodeKind::Tool { .. }));
262 }
263
264 #[test]
265 fn canonical_hash_is_stable_across_key_order() {
266 let a: WorkflowDef = serde_json::from_str(r#"{"id":"x","entry":"n","nodes":[]}"#).unwrap();
267 let b: WorkflowDef = serde_json::from_str(r#"{"entry":"n","id":"x","nodes":[]}"#).unwrap();
268 assert_eq!(a.canonical_hash(), b.canonical_hash());
269 }
270
271 #[test]
272 fn canonical_hash_stable_across_nested_object_key_order() {
273 let a: WorkflowDef = serde_json::from_str(
277 r#"{"id":"w","entry":"n","nodes":[
278 {"id":"n","kind":"agent","input_from":"q","output_to":"a",
279 "agent":{"model":"m","system_prompt":"p"}}]}"#,
280 )
281 .unwrap();
282 let b: WorkflowDef = serde_json::from_str(
283 r#"{"nodes":[
284 {"agent":{"system_prompt":"p","model":"m"},
285 "output_to":"a","input_from":"q","kind":"agent","id":"n"}],
286 "entry":"n","id":"w"}"#,
287 )
288 .unwrap();
289 assert_eq!(a.canonical_hash(), b.canonical_hash());
290 }
291
292 #[test]
293 fn canonical_hash_changes_with_content() {
294 let a: WorkflowDef = serde_json::from_str(r#"{"id":"x","entry":"n","nodes":[]}"#).unwrap();
295 let b: WorkflowDef = serde_json::from_str(r#"{"id":"y","entry":"n","nodes":[]}"#).unwrap();
296 assert_ne!(a.canonical_hash(), b.canonical_hash());
297 }
298
299 #[test]
300 fn canonical_json_bytes_stable_across_key_order() {
301 let a: Value = serde_json::json!({"a": 1, "b": {"x": 2, "y": 3}});
302 let b: Value = serde_json::json!({"b": {"y": 3, "x": 2}, "a": 1});
303 assert_eq!(canonical_json_bytes(&a), canonical_json_bytes(&b));
304 let different: Value = serde_json::json!({"a": 1, "b": {"x": 2, "y": 4}});
305 assert_ne!(canonical_json_bytes(&a), canonical_json_bytes(&different));
306 }
307
308 #[test]
309 fn run_record_binds_id_version_hash_and_system_author() {
310 let def: WorkflowDef =
311 serde_json::from_str(r#"{"id":"x","version":3,"entry":"n","nodes":[]}"#).unwrap();
312 let record = run_record(&def);
313 assert_eq!(record.workflow_id(), "x");
314 assert_eq!(record.version(), 3);
315 assert_eq!(record.content_hash(), def.canonical_hash());
316 assert_eq!(record.author(), "system");
317 }
318}