Skip to main content

bamboo_domain/workflow/
compiler.rs

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::Map { source, item, body } => {
190            if item.trim().is_empty() {
191                return Err(WorkflowCompileError::InvalidStep {
192                    step: "<map>".to_string(),
193                    message: "map item name cannot be empty".to_string(),
194                });
195            }
196            validate_ref(source, steps)?;
197            validate_plan_inner(body, steps, seen)?;
198        }
199        WorkflowPlan::Retry {
200            node, max_attempts, ..
201        } => {
202            if *max_attempts == 0 {
203                return Err(WorkflowCompileError::InvalidStep {
204                    step: "<retry>".to_string(),
205                    message: "max_attempts must be greater than zero".to_string(),
206                });
207            }
208            validate_plan_inner(node, steps, seen)?;
209        }
210    }
211    Ok(())
212}
213
214fn validate_execution_bindings(
215    definition: &WorkflowRunDefinition,
216    steps: &HashMap<String, WorkflowStepDefinition>,
217) -> Result<(), WorkflowCompileError> {
218    type ItemSchemas = HashMap<String, serde_json::Value>;
219
220    fn reference_schema(
221        owner: &str,
222        reference: &ValueRef,
223        definition: &WorkflowRunDefinition,
224        steps: &HashMap<String, WorkflowStepDefinition>,
225        available: &HashSet<String>,
226        items: &ItemSchemas,
227    ) -> Result<serde_json::Value, WorkflowCompileError> {
228        let invalid = |message: String| WorkflowCompileError::InvalidStep {
229            step: owner.to_string(),
230            message,
231        };
232        match reference {
233            ValueRef::Args { pointer } => schema_pointer(&definition.input_schema, pointer)
234                .cloned()
235                .ok_or_else(|| invalid("args reference has an invalid schema pointer".to_string())),
236            ValueRef::Step { step, pointer } => {
237                if !available.contains(step) {
238                    return Err(invalid(format!(
239                        "step reference '{step}' is not available before this step"
240                    )));
241                }
242                let schema = steps
243                    .get(step)
244                    .and_then(|step| step.output_schema.as_ref())
245                    .ok_or_else(|| {
246                        invalid(format!(
247                            "step reference '{step}' requires a declared output_schema"
248                        ))
249                    })?;
250                schema_pointer(schema, pointer).cloned().ok_or_else(|| {
251                    invalid(format!(
252                        "step reference '{step}' has an invalid schema pointer"
253                    ))
254                })
255            }
256            ValueRef::Item { name, pointer } => {
257                let schema = items.get(name).ok_or_else(|| {
258                    invalid(format!(
259                        "map item reference '{name}' is outside its map body"
260                    ))
261                })?;
262                schema_pointer(schema, pointer).cloned().ok_or_else(|| {
263                    invalid(format!(
264                        "map item reference '{name}' has an invalid schema pointer"
265                    ))
266                })
267            }
268            ValueRef::Literal { value } => Ok(schema_for_literal(value)),
269        }
270    }
271
272    fn walk_template(
273        owner: &str,
274        value: &serde_json::Value,
275        definition: &WorkflowRunDefinition,
276        steps: &HashMap<String, WorkflowStepDefinition>,
277        available: &HashSet<String>,
278        items: &ItemSchemas,
279    ) -> Result<(), WorkflowCompileError> {
280        match value {
281            serde_json::Value::Object(object) if object.contains_key("$secret") => {
282                let handle: WorkflowSecretHandle =
283                    serde_json::from_value(value.clone()).map_err(|error| {
284                        WorkflowCompileError::InvalidStep {
285                            step: owner.to_string(),
286                            message: format!("malformed secret capability handle: {error}"),
287                        }
288                    })?;
289                if handle.capability.trim().is_empty() {
290                    return Err(WorkflowCompileError::InvalidStep {
291                        step: owner.to_string(),
292                        message: "secret capability handle cannot be empty".to_string(),
293                    });
294                }
295                Ok(())
296            }
297            serde_json::Value::Object(object) if object.contains_key("from") => {
298                let reference: ValueRef =
299                    serde_json::from_value(value.clone()).map_err(|error| {
300                        WorkflowCompileError::InvalidStep {
301                            step: owner.to_string(),
302                            message: format!("malformed value reference: {error}"),
303                        }
304                    })?;
305                reference_schema(owner, &reference, definition, steps, available, items)?;
306                Ok(())
307            }
308            serde_json::Value::Object(object) => object.values().try_for_each(|child| {
309                walk_template(owner, child, definition, steps, available, items)
310            }),
311            serde_json::Value::Array(array) => array.iter().try_for_each(|child| {
312                walk_template(owner, child, definition, steps, available, items)
313            }),
314            _ => Ok(()),
315        }
316    }
317
318    fn walk_plan(
319        plan: &WorkflowPlan,
320        definition: &WorkflowRunDefinition,
321        steps: &HashMap<String, WorkflowStepDefinition>,
322        available: &HashSet<String>,
323        items: &ItemSchemas,
324    ) -> Result<HashSet<String>, WorkflowCompileError> {
325        match plan {
326            WorkflowPlan::Step { step } => {
327                let definition_step = &steps[step];
328                let template = match &definition_step.kind {
329                    WorkflowStepKind::Tool { args, .. }
330                    | WorkflowStepKind::Workflow { args, .. } => args,
331                    WorkflowStepKind::Agent { prompt, .. } => prompt,
332                };
333                walk_template(step, template, definition, steps, available, items)?;
334                let mut result = available.clone();
335                result.insert(step.clone());
336                Ok(result)
337            }
338            WorkflowPlan::Sequence { nodes } => {
339                nodes.iter().try_fold(available.clone(), |available, node| {
340                    walk_plan(node, definition, steps, &available, items)
341                })
342            }
343            WorkflowPlan::Parallel { nodes } => {
344                let mut result = available.clone();
345                for node in nodes {
346                    // Every sibling receives the same pre-parallel availability;
347                    // no sibling may consume another sibling's output.
348                    result.extend(walk_plan(node, definition, steps, available, items)?);
349                }
350                Ok(result)
351            }
352            WorkflowPlan::Map { source, item, body } => {
353                let item_schema =
354                    reference_schema("<map>", source, definition, steps, available, items)?
355                        .get("items")
356                        .cloned()
357                        .ok_or_else(|| WorkflowCompileError::InvalidStep {
358                            step: "<map>".to_string(),
359                            message: "map source schema must declare array items".to_string(),
360                        })?;
361                let mut nested_items = items.clone();
362                nested_items.insert(item.clone(), item_schema);
363                // Map body step outputs are materialized under per-item scoped
364                // runtime ids (`step@map[index]`), not as root-level step ids.
365                // Validate the body, but do not leak its availability to nodes
366                // that execute after the map.
367                walk_plan(body, definition, steps, available, &nested_items)?;
368                Ok(available.clone())
369            }
370            WorkflowPlan::Retry { node, .. } => {
371                walk_plan(node, definition, steps, available, items)
372            }
373        }
374    }
375
376    walk_plan(
377        &definition.plan,
378        definition,
379        steps,
380        &HashSet::new(),
381        &HashMap::new(),
382    )?;
383    Ok(())
384}
385
386fn schema_for_literal(value: &serde_json::Value) -> serde_json::Value {
387    let kind = match value {
388        serde_json::Value::Null => "null",
389        serde_json::Value::Bool(_) => "boolean",
390        serde_json::Value::Number(number) if number.is_i64() || number.is_u64() => "integer",
391        serde_json::Value::Number(_) => "number",
392        serde_json::Value::String(_) => "string",
393        serde_json::Value::Array(_) => "array",
394        serde_json::Value::Object(_) => "object",
395    };
396    serde_json::json!({"type": kind})
397}
398
399fn schema_pointer<'a>(
400    schema: &'a serde_json::Value,
401    pointer: &str,
402) -> Option<&'a serde_json::Value> {
403    if pointer.is_empty() {
404        return Some(schema);
405    }
406    let mut current = schema;
407    for token in pointer.strip_prefix('/')?.split('/') {
408        let token = token.replace("~1", "/").replace("~0", "~");
409        current = if token.parse::<usize>().is_ok() {
410            current.get("items")?
411        } else {
412            current.get("properties")?.get(&token)?
413        };
414    }
415    Some(current)
416}
417
418fn validate_ref(
419    value_ref: &ValueRef,
420    steps: &HashMap<String, WorkflowStepDefinition>,
421) -> Result<(), WorkflowCompileError> {
422    if let ValueRef::Step { step, .. } = value_ref {
423        if !steps.contains_key(step) {
424            return Err(WorkflowCompileError::UnknownStep(step.clone()));
425        }
426    }
427    Ok(())
428}
429
430fn validate_dependencies(
431    plan: &WorkflowPlan,
432    steps: &HashMap<String, WorkflowStepDefinition>,
433) -> Result<(), WorkflowCompileError> {
434    let mut edges: HashMap<String, HashSet<String>> = HashMap::new();
435    collect_plan_edges(plan, HashSet::new(), &mut edges);
436    for (id, step) in steps {
437        collect_value_step_refs(step, |dependency| {
438            edges
439                .entry(id.clone())
440                .or_default()
441                .insert(dependency.to_string());
442        });
443    }
444    fn visit(
445        node: &str,
446        edges: &HashMap<String, HashSet<String>>,
447        visiting: &mut HashSet<String>,
448        visited: &mut HashSet<String>,
449    ) -> Result<(), WorkflowCompileError> {
450        if visited.contains(node) {
451            return Ok(());
452        }
453        if !visiting.insert(node.to_string()) {
454            return Err(WorkflowCompileError::Cycle(node.to_string()));
455        }
456        if let Some(dependencies) = edges.get(node) {
457            for dependency in dependencies {
458                visit(dependency, edges, visiting, visited)?;
459            }
460        }
461        visiting.remove(node);
462        visited.insert(node.to_string());
463        Ok(())
464    }
465    let mut visited = HashSet::new();
466    for id in steps.keys() {
467        visit(id, &edges, &mut HashSet::new(), &mut visited)?;
468    }
469    Ok(())
470}
471
472fn collect_plan_edges(
473    plan: &WorkflowPlan,
474    previous: HashSet<String>,
475    edges: &mut HashMap<String, HashSet<String>>,
476) -> HashSet<String> {
477    match plan {
478        WorkflowPlan::Step { step } => {
479            edges.entry(step.clone()).or_default().extend(previous);
480            HashSet::from([step.clone()])
481        }
482        WorkflowPlan::Sequence { nodes } => nodes.iter().fold(previous, |prior, node| {
483            collect_plan_edges(node, prior, edges)
484        }),
485        WorkflowPlan::Parallel { nodes } => {
486            let mut tails = HashSet::new();
487            for node in nodes {
488                tails.extend(collect_plan_edges(node, previous.clone(), edges));
489            }
490            tails
491        }
492        WorkflowPlan::Map { body, .. } | WorkflowPlan::Retry { node: body, .. } => {
493            collect_plan_edges(body, previous, edges)
494        }
495    }
496}
497
498fn collect_value_step_refs(step: &WorkflowStepDefinition, mut found: impl FnMut(&str)) {
499    let value = match &step.kind {
500        WorkflowStepKind::Tool { args, .. } | WorkflowStepKind::Workflow { args, .. } => args,
501        WorkflowStepKind::Agent { prompt, .. } => prompt,
502    };
503    fn walk(value: &serde_json::Value, found: &mut impl FnMut(&str)) {
504        match value {
505            serde_json::Value::Object(object) => {
506                if object.get("from").and_then(serde_json::Value::as_str) == Some("step") {
507                    if let Some(step) = object.get("step").and_then(serde_json::Value::as_str) {
508                        found(step);
509                    }
510                }
511                for child in object.values() {
512                    walk(child, found);
513                }
514            }
515            serde_json::Value::Array(array) => {
516                for child in array {
517                    walk(child, found);
518                }
519            }
520            _ => {}
521        }
522    }
523    walk(value, &mut found);
524}