1use std::collections::{HashMap, HashSet};
2
3use crate::{
4 ValueRef, WorkflowPlan, WorkflowRunDefinition, WorkflowSecretHandle, WorkflowStepDefinition,
5 WorkflowStepKind,
6};
7use thiserror::Error;
8
9use super::schema::{validate_schema, validate_schema_shape as validate_shape};
10
11#[derive(Debug, Error, Clone, PartialEq, Eq)]
12pub enum WorkflowCompileError {
13 #[error("unsupported workflow_schema {0}")]
14 UnsupportedSchema(u32),
15 #[error("workflow id and revision are required")]
16 MissingIdentity,
17 #[error("invalid workflow budget: {0}")]
18 InvalidBudget(String),
19 #[error("duplicate or empty step id: {0}")]
20 DuplicateStep(String),
21 #[error("unknown step reference: {0}")]
22 UnknownStep(String),
23 #[error("invalid step {step}: {message}")]
24 InvalidStep { step: String, message: String },
25 #[error("cyclic step dependency: {0}")]
26 Cycle(String),
27 #[error("invalid schema: {0}")]
28 InvalidSchema(String),
29}
30
31#[derive(Debug, Clone)]
32pub struct CompiledWorkflow {
33 pub definition: WorkflowRunDefinition,
34 pub steps: HashMap<String, WorkflowStepDefinition>,
35}
36
37impl CompiledWorkflow {
38 pub fn compile(definition: WorkflowRunDefinition) -> Result<Self, WorkflowCompileError> {
39 if definition.workflow_schema != 1 {
40 return Err(WorkflowCompileError::UnsupportedSchema(
41 definition.workflow_schema,
42 ));
43 }
44 if definition.id.trim().is_empty() || definition.revision == 0 {
45 return Err(WorkflowCompileError::MissingIdentity);
46 }
47 validate_budget(&definition)?;
48 validate_schema_shape(&definition.input_schema)?;
49 if let Some(schema) = &definition.output_schema {
50 validate_schema_shape(schema)?;
51 }
52 let mut steps = HashMap::new();
53 for step in &definition.steps {
54 if step.id.trim().is_empty() || steps.insert(step.id.clone(), step.clone()).is_some() {
55 return Err(WorkflowCompileError::DuplicateStep(step.id.clone()));
56 }
57 validate_step(step)?;
58 }
59 if steps.is_empty() {
60 return Err(WorkflowCompileError::InvalidStep {
61 step: "<definition>".to_string(),
62 message: "at least one step is required".to_string(),
63 });
64 }
65 validate_plan(&definition.plan, &steps)?;
66 validate_dependencies(&definition.plan, &steps)?;
67 validate_execution_bindings(&definition, &steps)?;
68 Ok(Self { definition, steps })
69 }
70
71 pub fn validate_input(&self, value: &serde_json::Value) -> Result<(), String> {
72 validate_schema(&self.definition.input_schema, value)
73 }
74}
75
76fn validate_schema_shape(schema: &serde_json::Value) -> Result<(), WorkflowCompileError> {
77 validate_shape(schema, "$").map_err(WorkflowCompileError::InvalidSchema)
78}
79
80fn validate_budget(definition: &WorkflowRunDefinition) -> Result<(), WorkflowCompileError> {
81 let budget = &definition.budgets;
82 if budget.max_concurrency == 0 || budget.max_concurrency > 64 {
83 return Err(WorkflowCompileError::InvalidBudget(
84 "max_concurrency must be 1..=64".to_string(),
85 ));
86 }
87 if budget.max_steps == 0
88 || budget.max_agents > budget.max_steps
89 || budget.max_nesting_depth == 0
90 || budget.wall_time_ms == 0
91 {
92 return Err(WorkflowCompileError::InvalidBudget(
93 "step/agent/depth/wall budgets must be bounded and coherent".to_string(),
94 ));
95 }
96 Ok(())
97}
98
99fn validate_step(step: &WorkflowStepDefinition) -> Result<(), WorkflowCompileError> {
100 let required = match &step.kind {
101 WorkflowStepKind::Tool { tool, .. } => tool,
102 WorkflowStepKind::Agent {
103 agent,
104 structured_output_attempts,
105 ..
106 } => {
107 if *structured_output_attempts == 0 || *structured_output_attempts > 8 {
108 return Err(WorkflowCompileError::InvalidStep {
109 step: step.id.clone(),
110 message: "structured_output_attempts must be 1..=8".to_string(),
111 });
112 }
113 agent
114 }
115 WorkflowStepKind::Workflow {
116 workflow_id,
117 revision,
118 ..
119 } => {
120 if *revision == 0 {
121 return Err(WorkflowCompileError::InvalidStep {
122 step: step.id.clone(),
123 message: "nested workflow revision must be fixed and non-zero".to_string(),
124 });
125 }
126 workflow_id
127 }
128 };
129 if required.trim().is_empty() {
130 return Err(WorkflowCompileError::InvalidStep {
131 step: step.id.clone(),
132 message: "target cannot be empty".to_string(),
133 });
134 }
135 if let Some(schema) = &step.output_schema {
136 validate_schema_shape(schema)?;
137 }
138 Ok(())
139}
140
141fn validate_plan(
142 plan: &WorkflowPlan,
143 steps: &HashMap<String, WorkflowStepDefinition>,
144) -> Result<(), WorkflowCompileError> {
145 let mut seen = HashSet::new();
146 validate_plan_inner(plan, steps, &mut seen)?;
147 let unreferenced = steps
148 .keys()
149 .filter(|step| !seen.contains(*step))
150 .cloned()
151 .collect::<Vec<_>>();
152 if !unreferenced.is_empty() {
153 return Err(WorkflowCompileError::InvalidStep {
154 step: "<plan>".to_string(),
155 message: format!("unreferenced steps: {}", unreferenced.join(", ")),
156 });
157 }
158 Ok(())
159}
160
161fn validate_plan_inner(
162 plan: &WorkflowPlan,
163 steps: &HashMap<String, WorkflowStepDefinition>,
164 seen: &mut HashSet<String>,
165) -> Result<(), WorkflowCompileError> {
166 match plan {
167 WorkflowPlan::Step { step } => {
168 if !steps.contains_key(step) {
169 return Err(WorkflowCompileError::UnknownStep(step.clone()));
170 }
171 if !seen.insert(step.clone()) {
172 return Err(WorkflowCompileError::InvalidStep {
173 step: step.clone(),
174 message: "step appears more than once in the execution plan".to_string(),
175 });
176 }
177 }
178 WorkflowPlan::Sequence { nodes } | WorkflowPlan::Parallel { nodes } => {
179 if nodes.is_empty() {
180 return Err(WorkflowCompileError::InvalidStep {
181 step: "<plan>".to_string(),
182 message: "sequence/parallel requires nodes".to_string(),
183 });
184 }
185 for node in nodes {
186 validate_plan_inner(node, steps, seen)?;
187 }
188 }
189 WorkflowPlan::Choice {
190 condition,
191 then_branch,
192 else_branch,
193 } => {
194 validate_ref(condition, steps)?;
195 validate_plan_inner(then_branch, steps, seen)?;
196 validate_plan_inner(else_branch, steps, seen)?;
197 }
198 WorkflowPlan::Map { source, item, body } => {
199 if item.trim().is_empty() {
200 return Err(WorkflowCompileError::InvalidStep {
201 step: "<map>".to_string(),
202 message: "map item name cannot be empty".to_string(),
203 });
204 }
205 validate_ref(source, steps)?;
206 validate_plan_inner(body, steps, seen)?;
207 }
208 WorkflowPlan::Retry {
209 node, max_attempts, ..
210 } => {
211 if *max_attempts == 0 {
212 return Err(WorkflowCompileError::InvalidStep {
213 step: "<retry>".to_string(),
214 message: "max_attempts must be greater than zero".to_string(),
215 });
216 }
217 validate_plan_inner(node, steps, seen)?;
218 }
219 }
220 Ok(())
221}
222
223fn validate_execution_bindings(
224 definition: &WorkflowRunDefinition,
225 steps: &HashMap<String, WorkflowStepDefinition>,
226) -> Result<(), WorkflowCompileError> {
227 type ItemSchemas = HashMap<String, serde_json::Value>;
228
229 fn reference_schema(
230 owner: &str,
231 reference: &ValueRef,
232 definition: &WorkflowRunDefinition,
233 steps: &HashMap<String, WorkflowStepDefinition>,
234 available: &HashSet<String>,
235 items: &ItemSchemas,
236 ) -> Result<serde_json::Value, WorkflowCompileError> {
237 let invalid = |message: String| WorkflowCompileError::InvalidStep {
238 step: owner.to_string(),
239 message,
240 };
241 match reference {
242 ValueRef::Args { pointer } => schema_pointer(&definition.input_schema, pointer)
243 .cloned()
244 .ok_or_else(|| invalid("args reference has an invalid schema pointer".to_string())),
245 ValueRef::Step { step, pointer } => {
246 if !available.contains(step) {
247 return Err(invalid(format!(
248 "step reference '{step}' is not available before this step"
249 )));
250 }
251 let schema = steps
252 .get(step)
253 .and_then(|step| step.output_schema.as_ref())
254 .ok_or_else(|| {
255 invalid(format!(
256 "step reference '{step}' requires a declared output_schema"
257 ))
258 })?;
259 schema_pointer(schema, pointer).cloned().ok_or_else(|| {
260 invalid(format!(
261 "step reference '{step}' has an invalid schema pointer"
262 ))
263 })
264 }
265 ValueRef::Item { name, pointer } => {
266 let schema = items.get(name).ok_or_else(|| {
267 invalid(format!(
268 "map item reference '{name}' is outside its map body"
269 ))
270 })?;
271 schema_pointer(schema, pointer).cloned().ok_or_else(|| {
272 invalid(format!(
273 "map item reference '{name}' has an invalid schema pointer"
274 ))
275 })
276 }
277 ValueRef::Literal { value } => Ok(schema_for_literal(value)),
278 }
279 }
280
281 fn walk_template(
282 owner: &str,
283 value: &serde_json::Value,
284 definition: &WorkflowRunDefinition,
285 steps: &HashMap<String, WorkflowStepDefinition>,
286 available: &HashSet<String>,
287 items: &ItemSchemas,
288 ) -> Result<(), WorkflowCompileError> {
289 match value {
290 serde_json::Value::Object(object) if object.contains_key("$secret") => {
291 let handle: WorkflowSecretHandle =
292 serde_json::from_value(value.clone()).map_err(|error| {
293 WorkflowCompileError::InvalidStep {
294 step: owner.to_string(),
295 message: format!("malformed secret capability handle: {error}"),
296 }
297 })?;
298 if handle.capability.trim().is_empty() {
299 return Err(WorkflowCompileError::InvalidStep {
300 step: owner.to_string(),
301 message: "secret capability handle cannot be empty".to_string(),
302 });
303 }
304 Ok(())
305 }
306 serde_json::Value::Object(object) if object.contains_key("from") => {
307 let reference: ValueRef =
308 serde_json::from_value(value.clone()).map_err(|error| {
309 WorkflowCompileError::InvalidStep {
310 step: owner.to_string(),
311 message: format!("malformed value reference: {error}"),
312 }
313 })?;
314 reference_schema(owner, &reference, definition, steps, available, items)?;
315 Ok(())
316 }
317 serde_json::Value::Object(object) => object.values().try_for_each(|child| {
318 walk_template(owner, child, definition, steps, available, items)
319 }),
320 serde_json::Value::Array(array) => array.iter().try_for_each(|child| {
321 walk_template(owner, child, definition, steps, available, items)
322 }),
323 _ => Ok(()),
324 }
325 }
326
327 fn walk_plan(
328 plan: &WorkflowPlan,
329 definition: &WorkflowRunDefinition,
330 steps: &HashMap<String, WorkflowStepDefinition>,
331 available: &HashSet<String>,
332 items: &ItemSchemas,
333 ) -> Result<HashSet<String>, WorkflowCompileError> {
334 match plan {
335 WorkflowPlan::Step { step } => {
336 let definition_step = &steps[step];
337 let template = match &definition_step.kind {
338 WorkflowStepKind::Tool { args, .. }
339 | WorkflowStepKind::Workflow { args, .. } => args,
340 WorkflowStepKind::Agent { prompt, .. } => prompt,
341 };
342 walk_template(step, template, definition, steps, available, items)?;
343 let mut result = available.clone();
344 result.insert(step.clone());
345 Ok(result)
346 }
347 WorkflowPlan::Sequence { nodes } => {
348 nodes.iter().try_fold(available.clone(), |available, node| {
349 walk_plan(node, definition, steps, &available, items)
350 })
351 }
352 WorkflowPlan::Parallel { nodes } => {
353 let mut result = available.clone();
354 for node in nodes {
355 result.extend(walk_plan(node, definition, steps, available, items)?);
358 }
359 Ok(result)
360 }
361 WorkflowPlan::Choice {
362 condition,
363 then_branch,
364 else_branch,
365 } => {
366 let schema =
367 reference_schema("<choice>", condition, definition, steps, available, items)?;
368 if schema.get("type").and_then(serde_json::Value::as_str) != Some("boolean") {
369 return Err(WorkflowCompileError::InvalidStep {
370 step: "<choice>".to_string(),
371 message: "choice condition schema must declare type boolean".to_string(),
372 });
373 }
374 let then_available = walk_plan(then_branch, definition, steps, available, items)?;
377 let else_available = walk_plan(else_branch, definition, steps, available, items)?;
378 Ok(then_available
379 .intersection(&else_available)
380 .cloned()
381 .collect())
382 }
383 WorkflowPlan::Map { source, item, body } => {
384 let item_schema =
385 reference_schema("<map>", source, definition, steps, available, items)?
386 .get("items")
387 .cloned()
388 .ok_or_else(|| WorkflowCompileError::InvalidStep {
389 step: "<map>".to_string(),
390 message: "map source schema must declare array items".to_string(),
391 })?;
392 let mut nested_items = items.clone();
393 nested_items.insert(item.clone(), item_schema);
394 walk_plan(body, definition, steps, available, &nested_items)?;
399 Ok(available.clone())
400 }
401 WorkflowPlan::Retry { node, .. } => {
402 walk_plan(node, definition, steps, available, items)
403 }
404 }
405 }
406
407 walk_plan(
408 &definition.plan,
409 definition,
410 steps,
411 &HashSet::new(),
412 &HashMap::new(),
413 )?;
414 Ok(())
415}
416
417fn schema_for_literal(value: &serde_json::Value) -> serde_json::Value {
418 let kind = match value {
419 serde_json::Value::Null => "null",
420 serde_json::Value::Bool(_) => "boolean",
421 serde_json::Value::Number(number) if number.is_i64() || number.is_u64() => "integer",
422 serde_json::Value::Number(_) => "number",
423 serde_json::Value::String(_) => "string",
424 serde_json::Value::Array(_) => "array",
425 serde_json::Value::Object(_) => "object",
426 };
427 serde_json::json!({"type": kind})
428}
429
430fn schema_pointer<'a>(
431 schema: &'a serde_json::Value,
432 pointer: &str,
433) -> Option<&'a serde_json::Value> {
434 if pointer.is_empty() {
435 return Some(schema);
436 }
437 let mut current = schema;
438 for token in pointer.strip_prefix('/')?.split('/') {
439 let token = token.replace("~1", "/").replace("~0", "~");
440 current = if token.parse::<usize>().is_ok() {
441 current.get("items")?
442 } else {
443 current.get("properties")?.get(&token)?
444 };
445 }
446 Some(current)
447}
448
449fn validate_ref(
450 value_ref: &ValueRef,
451 steps: &HashMap<String, WorkflowStepDefinition>,
452) -> Result<(), WorkflowCompileError> {
453 if let ValueRef::Step { step, .. } = value_ref {
454 if !steps.contains_key(step) {
455 return Err(WorkflowCompileError::UnknownStep(step.clone()));
456 }
457 }
458 Ok(())
459}
460
461fn validate_dependencies(
462 plan: &WorkflowPlan,
463 steps: &HashMap<String, WorkflowStepDefinition>,
464) -> Result<(), WorkflowCompileError> {
465 let mut edges: HashMap<String, HashSet<String>> = HashMap::new();
466 collect_plan_edges(plan, HashSet::new(), &mut edges);
467 for (id, step) in steps {
468 collect_value_step_refs(step, |dependency| {
469 edges
470 .entry(id.clone())
471 .or_default()
472 .insert(dependency.to_string());
473 });
474 }
475 fn visit(
476 node: &str,
477 edges: &HashMap<String, HashSet<String>>,
478 visiting: &mut HashSet<String>,
479 visited: &mut HashSet<String>,
480 ) -> Result<(), WorkflowCompileError> {
481 if visited.contains(node) {
482 return Ok(());
483 }
484 if !visiting.insert(node.to_string()) {
485 return Err(WorkflowCompileError::Cycle(node.to_string()));
486 }
487 if let Some(dependencies) = edges.get(node) {
488 for dependency in dependencies {
489 visit(dependency, edges, visiting, visited)?;
490 }
491 }
492 visiting.remove(node);
493 visited.insert(node.to_string());
494 Ok(())
495 }
496 let mut visited = HashSet::new();
497 for id in steps.keys() {
498 visit(id, &edges, &mut HashSet::new(), &mut visited)?;
499 }
500 Ok(())
501}
502
503fn collect_plan_edges(
504 plan: &WorkflowPlan,
505 previous: HashSet<String>,
506 edges: &mut HashMap<String, HashSet<String>>,
507) -> HashSet<String> {
508 match plan {
509 WorkflowPlan::Step { step } => {
510 edges.entry(step.clone()).or_default().extend(previous);
511 HashSet::from([step.clone()])
512 }
513 WorkflowPlan::Sequence { nodes } => nodes.iter().fold(previous, |prior, node| {
514 collect_plan_edges(node, prior, edges)
515 }),
516 WorkflowPlan::Parallel { nodes } => {
517 let mut tails = HashSet::new();
518 for node in nodes {
519 tails.extend(collect_plan_edges(node, previous.clone(), edges));
520 }
521 tails
522 }
523 WorkflowPlan::Choice {
524 condition,
525 then_branch,
526 else_branch,
527 } => {
528 let mut incoming = previous;
529 if let ValueRef::Step { step, .. } = condition {
530 incoming.insert(step.clone());
531 }
532 let mut tails = collect_plan_edges(then_branch, incoming.clone(), edges);
533 tails.extend(collect_plan_edges(else_branch, incoming, edges));
534 tails
535 }
536 WorkflowPlan::Map { body, .. } | WorkflowPlan::Retry { node: body, .. } => {
537 collect_plan_edges(body, previous, edges)
538 }
539 }
540}
541
542fn collect_value_step_refs(step: &WorkflowStepDefinition, mut found: impl FnMut(&str)) {
543 let value = match &step.kind {
544 WorkflowStepKind::Tool { args, .. } | WorkflowStepKind::Workflow { args, .. } => args,
545 WorkflowStepKind::Agent { prompt, .. } => prompt,
546 };
547 fn walk(value: &serde_json::Value, found: &mut impl FnMut(&str)) {
548 match value {
549 serde_json::Value::Object(object) => {
550 if object.get("from").and_then(serde_json::Value::as_str) == Some("step") {
551 if let Some(step) = object.get("step").and_then(serde_json::Value::as_str) {
552 found(step);
553 }
554 }
555 for child in object.values() {
556 walk(child, found);
557 }
558 }
559 serde_json::Value::Array(array) => {
560 for child in array {
561 walk(child, found);
562 }
563 }
564 _ => {}
565 }
566 }
567 walk(value, &mut found);
568}
569
570#[cfg(test)]
571mod choice_tests {
572 use super::*;
573 use serde_json::{json, Value};
574
575 fn step(id: &str, args: Value) -> WorkflowStepDefinition {
576 WorkflowStepDefinition {
577 id: id.to_string(),
578 kind: WorkflowStepKind::Tool {
579 tool: "echo".to_string(),
580 args,
581 capabilities: Vec::new(),
582 },
583 failure: Default::default(),
584 output_schema: Some(json!({"type":"boolean"})),
585 }
586 }
587
588 fn choice(condition: Value, then_branch: Value, else_branch: Value) -> Value {
589 json!({"type":"choice","condition":condition,"then_branch":then_branch,"else_branch":else_branch})
590 }
591
592 fn leaf(id: &str) -> Value {
593 json!({"type":"step","step":id})
594 }
595
596 fn definition(plan: Value, steps: Vec<WorkflowStepDefinition>) -> WorkflowRunDefinition {
597 WorkflowRunDefinition {
598 workflow_schema: 1,
599 id: "conditional".to_string(),
600 revision: 1,
601 input_schema: json!({"type":"object","properties":{"approved":{"type":"boolean"},"rows":{"type":"array","items":{"type":"boolean"}}},"additionalProperties":false}),
602 output_schema: None,
603 steps,
604 plan: serde_json::from_value(plan).expect("Choice plan loads"),
605 budgets: Default::default(),
606 }
607 }
608
609 #[test]
610 fn workflow_choice_loads_json_yaml_and_requires_both_branches() {
611 let plan = choice(
612 json!({"from":"literal","value":true}),
613 leaf("yes"),
614 leaf("no"),
615 );
616 let json_plan: WorkflowPlan = serde_json::from_value(plan.clone()).unwrap();
617 let yaml_plan: WorkflowPlan = serde_yaml::from_str(
618 "type: choice\ncondition: {from: literal, value: true}\nthen_branch: {type: step, step: yes}\nelse_branch: {type: step, step: no}\n",
619 ).unwrap();
620 assert_eq!(json_plan, yaml_plan);
621 for missing in ["then_branch", "else_branch", "condition"] {
622 let mut incomplete = plan.clone();
623 incomplete.as_object_mut().unwrap().remove(missing);
624 assert!(serde_json::from_value::<WorkflowPlan>(incomplete).is_err());
625 }
626 }
627
628 #[test]
629 fn workflow_choice_accepts_boolean_args_step_item_and_literal() {
630 for condition in [
631 json!({"from":"args","pointer":"/approved"}),
632 json!({"from":"literal","value":false}),
633 ] {
634 CompiledWorkflow::compile(definition(
635 choice(condition, leaf("yes"), leaf("no")),
636 vec![step("yes", json!(true)), step("no", json!(false))],
637 ))
638 .unwrap();
639 }
640 let from_step = definition(
641 json!({"type":"sequence","nodes":[leaf("before"), choice(json!({"from":"step","step":"before"}), leaf("yes"), leaf("no"))]}),
642 vec![
643 step("before", json!(true)),
644 step("yes", json!(true)),
645 step("no", json!(false)),
646 ],
647 );
648 CompiledWorkflow::compile(from_step).unwrap();
649 let from_item = definition(
650 json!({"type":"map","source":{"from":"args","pointer":"/rows"},"item":"row","body":choice(json!({"from":"item","name":"row"}), leaf("yes"), leaf("no"))}),
651 vec![step("yes", json!(true)), step("no", json!(false))],
652 );
653 CompiledWorkflow::compile(from_item).unwrap();
654 }
655
656 #[test]
657 fn workflow_choice_rejects_missing_and_non_boolean_reference_schemas() {
658 for condition in [
659 json!({"from":"literal","value":"false"}),
660 json!({"from":"literal","value":0}),
661 json!({"from":"literal","value":null}),
662 json!({"from":"args","pointer":"/missing"}),
663 json!({"from":"item","name":"outside"}),
664 json!({"from":"step","step":"unknown"}),
665 ] {
666 assert!(CompiledWorkflow::compile(definition(
667 choice(condition, leaf("yes"), leaf("no")),
668 vec![step("yes", json!(true)), step("no", json!(false))],
669 ))
670 .is_err());
671 }
672 for schema in [
673 json!({}),
674 json!({"type":"string"}),
675 json!({"type":"integer"}),
676 ] {
677 let mut flow = definition(
678 choice(
679 json!({"from":"args","pointer":"/approved"}),
680 leaf("yes"),
681 leaf("no"),
682 ),
683 vec![step("yes", json!(true)), step("no", json!(false))],
684 );
685 flow.input_schema["properties"]["approved"] = schema;
686 assert!(CompiledWorkflow::compile(flow).is_err());
687 }
688 let mut flow = definition(
689 json!({"type":"sequence","nodes":[leaf("before"), choice(json!({"from":"step","step":"before"}), leaf("yes"), leaf("no"))]}),
690 vec![
691 step("before", json!(true)),
692 step("yes", json!(true)),
693 step("no", json!(false)),
694 ],
695 );
696 flow.steps[0].output_schema = None;
697 assert!(CompiledWorkflow::compile(flow).is_err());
698 }
699
700 #[test]
701 fn workflow_choice_rejects_forward_sibling_and_join_output_references() {
702 let condition = json!({"from":"literal","value":true});
703 let branch = choice(condition.clone(), leaf("yes"), leaf("no"));
704 for flow in [
705 definition(
706 json!({"type":"sequence","nodes":[choice(json!({"from":"step","step":"later"}),leaf("yes"),leaf("no")),leaf("later")]}),
707 vec![
708 step("yes", json!(true)),
709 step("no", json!(false)),
710 step("later", json!(true)),
711 ],
712 ),
713 definition(
714 branch.clone(),
715 vec![
716 step("yes", json!(true)),
717 step("no", json!({"from":"step","step":"yes"})),
718 ],
719 ),
720 definition(
721 json!({"type":"sequence","nodes":[branch,leaf("after")]}),
722 vec![
723 step("yes", json!(true)),
724 step("no", json!(false)),
725 step("after", json!({"from":"step","step":"yes"})),
726 ],
727 ),
728 ] {
729 assert!(CompiledWorkflow::compile(flow).is_err());
730 }
731 }
732
733 #[test]
734 fn workflow_choice_preserves_incoming_and_branch_local_bindings() {
735 let from_before = json!({"from":"step","step":"before"});
736 let flow = definition(
737 json!({"type":"sequence","nodes":[leaf("before"),choice(from_before.clone(),json!({"type":"sequence","nodes":[leaf("yes"),leaf("yes-after")]}),leaf("no")),leaf("after")]}),
738 vec![
739 step("before", json!(true)),
740 step("yes", from_before.clone()),
741 step("yes-after", json!({"from":"step","step":"yes"})),
742 step("no", from_before.clone()),
743 step("after", from_before),
744 ],
745 );
746 CompiledWorkflow::compile(flow).unwrap();
747 }
748
749 #[test]
750 fn workflow_choice_rejects_duplicate_steps_and_invalid_unselected_plan() {
751 let duplicate = definition(
752 choice(
753 json!({"from":"literal","value":true}),
754 leaf("same"),
755 leaf("same"),
756 ),
757 vec![step("same", json!(true))],
758 );
759 assert!(CompiledWorkflow::compile(duplicate).is_err());
760 let empty_branch = definition(
761 choice(
762 json!({"from":"literal","value":true}),
763 leaf("yes"),
764 json!({"type":"sequence","nodes":[]}),
765 ),
766 vec![step("yes", json!(true))],
767 );
768 assert!(CompiledWorkflow::compile(empty_branch).is_err());
769 }
770}