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 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 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}