1use std::collections::{HashMap, VecDeque};
9
10use serde_json::{json, Value};
11
12use crate::event::Event;
13use crate::node::{StepNode, WorkflowContext};
14use crate::recorder::{RunStatus, StepStatus};
15use crate::registry::{NodeError, NodeRegistry};
16use crate::result::{PreparedAction, StepResult};
17use crate::spec::Branch;
18
19#[derive(Debug, thiserror::Error)]
21pub enum CompileError {
22 #[error(transparent)]
24 Node(#[from] NodeError),
25 #[error("branch '{branch_id}' has no ingress node (chain needs a source)")]
27 NoIngress {
29 branch_id: String,
31 },
32 #[error("branch '{branch_id}' has {count} ingress nodes; day-1 supports one")]
34 MultipleIngress {
36 branch_id: String,
38 count: usize,
40 },
41 #[error("branch '{branch_id}' edges form a cycle (DAG required)")]
43 Cycle {
45 branch_id: String,
47 },
48}
49
50pub(crate) struct CompiledBranch {
52 steps: Vec<CompiledStep>,
53}
54
55struct CompiledStep {
56 node_id: String,
57 node_type: String,
58 node: Box<dyn StepNode>,
59 fan_out_limit: usize,
60 action_capable: bool,
61}
62
63const MAX_RUN_FAN_OUT: usize = 1_000;
64
65fn fan_out_limit(config: &Value) -> usize {
66 ["count", "levels", "fanout"]
67 .into_iter()
68 .find_map(|key| config.get(key)?.as_u64())
69 .and_then(|value| usize::try_from(value).ok())
70 .unwrap_or(MAX_RUN_FAN_OUT)
71 .min(MAX_RUN_FAN_OUT)
72}
73
74fn is_material(node_type: &str) -> bool {
75 node_type.starts_with("execute.") || node_type.starts_with("notify.")
76}
77
78fn event_detail(event: &Event) -> Value {
79 json!({
80 "event_id": event.id,
81 "payload": event.payload,
82 "metadata": event.metadata,
83 })
84}
85
86#[derive(Debug, Clone, PartialEq, Eq)]
88pub enum Terminal {
89 Dropped {
91 node_id: String,
93 reason: String,
95 },
96 Completed,
98}
99
100#[derive(Debug, Clone)]
102pub struct RunOutcome {
103 pub steps_run: usize,
105 pub terminal: Terminal,
107 pub survivors: Vec<Event>,
109 pub actions: Vec<PreparedAction>,
111 pub matched: bool,
113 pub succeeded: bool,
115}
116
117impl CompiledBranch {
118 pub(crate) fn compile(branch: &Branch, registry: &NodeRegistry) -> Result<Self, CompileError> {
120 let order = topo_order(branch)?;
121
122 let ingress_nodes: Vec<&crate::spec::Node> = branch
124 .nodes
125 .iter()
126 .filter(|n| registry.is_ingress(&n.node_type))
127 .collect();
128 match ingress_nodes.len() {
129 0 => {
130 return Err(CompileError::NoIngress {
131 branch_id: branch.branch_id.clone(),
132 })
133 }
134 1 => {}
135 n => {
136 return Err(CompileError::MultipleIngress {
137 branch_id: branch.branch_id.clone(),
138 count: n,
139 })
140 }
141 }
142 let by_id: HashMap<&str, &crate::spec::Node> =
143 branch.nodes.iter().map(|n| (n.id.as_str(), n)).collect();
144
145 let mut steps = Vec::new();
146 for node_id in order {
147 let node = by_id[node_id.as_str()];
148 if registry.is_ingress(&node.node_type) {
149 continue; }
151 let built = registry.build_step(&node.node_type, &node.config)?;
152 steps.push(CompiledStep {
153 node_id: node.id.clone(),
154 node_type: node.node_type.clone(),
155 node: built,
156 fan_out_limit: fan_out_limit(&node.config),
157 action_capable: node.node_type.starts_with("execute.")
158 || registry
159 .capability(&node.node_type)
160 .is_some_and(|manifest| manifest.kind == crate::CapabilityKind::Action),
161 });
162 }
163
164 Ok(Self { steps })
165 }
166
167 pub(crate) async fn run_event(&self, ctx: &WorkflowContext, event: Event) -> RunOutcome {
172 let trigger = Value::Object(event.payload.clone());
173 let mut run = ctx.recorder.start(&ctx.trigger_kind, trigger).await;
174 let mut current = vec![event];
175 let mut steps_run = 0;
176 let mut material_steps = 0u32;
177 let mut actions = Vec::new();
178
179 for step in &self.steps {
180 let mut next = Vec::new();
181 let mut last_drop: Option<(String, Option<String>)> = None;
182 let mut fan_out_error = None;
183 for ev in ¤t {
184 match step.node.process(ev, ctx).await {
185 StepResult::Pass(event) => {
186 if is_material(&step.node_type) {
187 run.record_step(
188 &step.node_id,
189 &step.node_type,
190 StepStatus::Ok,
191 None,
192 event_detail(&event),
193 )
194 .await;
195 material_steps += 1;
196 }
197 next.push(event);
198 }
199 StepResult::Drop {
200 reason,
201 exit_reason,
202 } => last_drop = Some((reason, exit_reason)),
203 StepResult::FanOut(evs) => {
204 if evs.len() > step.fan_out_limit
205 || next.len().saturating_add(evs.len()) > MAX_RUN_FAN_OUT
206 {
207 fan_out_error = Some(format!(
208 "fan-out exceeded node limit {} or run limit {MAX_RUN_FAN_OUT}",
209 step.fan_out_limit
210 ));
211 break;
212 }
213 next.extend(evs);
214 }
215 StepResult::Action { event, action } => {
216 if !step.action_capable {
217 fan_out_error = Some(format!(
218 "node '{}' emitted an action without an action capability",
219 step.node_id
220 ));
221 break;
222 }
223 run.record_step(
224 &step.node_id,
225 &step.node_type,
226 StepStatus::Ok,
227 None,
228 event_detail(&event),
229 )
230 .await;
231 material_steps += 1;
232 actions.push(*action);
233 next.push(event);
234 }
235 }
236 }
237 steps_run += 1;
238 if let Some(reason) = fan_out_error {
239 run.record_step(
240 &step.node_id,
241 &step.node_type,
242 StepStatus::Error,
243 Some("fanout_limit_exceeded"),
244 json!({ "reason": reason }),
245 )
246 .await;
247 run.end(RunStatus::Error, Some("fanout_limit_exceeded"))
248 .await;
249 return RunOutcome {
250 steps_run,
251 terminal: Terminal::Dropped {
252 node_id: step.node_id.clone(),
253 reason,
254 },
255 survivors: Vec::new(),
256 actions: Vec::new(),
257 matched: false,
258 succeeded: false,
259 };
260 }
261 if next.is_empty() {
262 let (reason, exit_reason) = last_drop.unwrap_or_else(|| ("dropped".into(), None));
263
264 if step.node_type.starts_with("sink.") || material_steps > 0 {
265 run.end(RunStatus::Ok, Some("natural")).await;
266 } else if let Some(code) = exit_reason {
267 let step_status = if code.starts_with("invalid_") {
268 StepStatus::Error
269 } else {
270 StepStatus::Skipped
271 };
272 run.record_step(
273 &step.node_id,
274 &step.node_type,
275 step_status,
276 Some(&code),
277 json!({ "reason": reason }),
278 )
279 .await;
280 run.end(
281 if step_status == StepStatus::Error {
282 RunStatus::Error
283 } else {
284 RunStatus::Skipped
285 },
286 Some(&code),
287 )
288 .await;
289 } else {
290 run.mark_filtered(&step.node_id, &step.node_type, &reason)
291 .await;
292 run.end(RunStatus::Skipped, None).await;
293 }
294
295 let sink_completed = step.node_type.starts_with("sink.");
296 return RunOutcome {
297 steps_run,
298 terminal: if sink_completed {
299 Terminal::Completed
300 } else {
301 Terminal::Dropped {
302 node_id: step.node_id.clone(),
303 reason,
304 }
305 },
306 survivors: Vec::new(),
307 actions,
308 matched: sink_completed || material_steps > 0,
309 succeeded: sink_completed,
310 };
311 }
312 current = next;
313 }
314
315 run.end(
316 if material_steps > 0 {
317 RunStatus::Ok
318 } else {
319 RunStatus::Skipped
320 },
321 Some("natural"),
322 )
323 .await;
324 RunOutcome {
325 steps_run,
326 terminal: Terminal::Completed,
327 survivors: current,
328 actions,
329 matched: true,
330 succeeded: true,
331 }
332 }
333}
334
335fn topo_order(branch: &Branch) -> Result<Vec<String>, CompileError> {
338 let ids: Vec<&str> = branch.nodes.iter().map(|n| n.id.as_str()).collect();
339
340 let mut indegree: HashMap<&str, usize> = ids.iter().map(|id| (*id, 0)).collect();
341 let mut adj: HashMap<&str, Vec<&str>> = ids.iter().map(|id| (*id, Vec::new())).collect();
342
343 for edge in &branch.edges {
344 if let (Some(successors), Some(indegree)) = (
346 adj.get_mut(edge.source.as_str()),
347 indegree.get_mut(edge.target.as_str()),
348 ) {
349 successors.push(&edge.target);
350 *indegree += 1;
351 }
352 }
353
354 let mut queue: VecDeque<&str> = ids.iter().copied().filter(|id| indegree[id] == 0).collect();
356
357 let mut order = Vec::with_capacity(ids.len());
358 while let Some(id) = queue.pop_front() {
359 order.push(id.to_string());
360 for &next in &adj[id] {
361 let Some(d) = indegree.get_mut(next) else {
362 continue;
363 };
364 *d -= 1;
365 if *d == 0 {
366 queue.push_back(next);
367 }
368 }
369 }
370
371 if order.len() != ids.len() {
372 return Err(CompileError::Cycle {
373 branch_id: branch.branch_id.clone(),
374 });
375 }
376 Ok(order)
377}
378
379#[cfg(test)]
380mod tests {
381 use std::sync::Arc;
382
383 use async_trait::async_trait;
384 use serde_json::json;
385
386 use super::*;
387 use crate::node::StepNode;
388 use crate::spec::{Edge, Node};
389 use crate::state::MemoryState;
390
391 struct OverProducingMap;
392
393 struct Pass;
394
395 #[async_trait]
396 impl StepNode for Pass {
397 async fn process(&self, event: &Event, _: &WorkflowContext) -> StepResult {
398 StepResult::Pass(event.clone())
399 }
400 }
401
402 struct Drop;
403
404 #[async_trait]
405 impl StepNode for Drop {
406 async fn process(&self, _: &Event, _: &WorkflowContext) -> StepResult {
407 StepResult::drop("filtered")
408 }
409 }
410
411 #[async_trait]
412 impl StepNode for OverProducingMap {
413 fn produces_fan_out(&self) -> bool {
414 true
415 }
416
417 async fn process(&self, event: &Event, _: &WorkflowContext) -> StepResult {
418 StepResult::FanOut(vec![event.clone(), event.clone(), event.clone()])
419 }
420 }
421
422 fn build_over_producing(_: &Value) -> Result<Box<dyn StepNode>, NodeError> {
423 Ok(Box::new(OverProducingMap))
424 }
425
426 #[tokio::test]
427 async fn runtime_rejects_more_fanout_than_the_static_declaration() {
428 let mut registry = NodeRegistry::empty();
429 registry.register_ingress("ingress.event");
430 registry.register_step("map.test", build_over_producing);
431 registry.register_fan_out("map.test");
432 let branch = Branch {
433 branch_id: "root".into(),
434 nodes: vec![
435 Node {
436 id: "in".into(),
437 node_type: "ingress.event".into(),
438 config: json!({}),
439 },
440 Node {
441 id: "map".into(),
442 node_type: "map.test".into(),
443 config: json!({"count": 2}),
444 },
445 ],
446 edges: vec![Edge {
447 source: "in".into(),
448 target: "map".into(),
449 }],
450 };
451 let compiled = CompiledBranch::compile(&branch, ®istry).unwrap();
452 let context = WorkflowContext::new("root", Arc::new(MemoryState::new()));
453 let outcome = compiled
454 .run_event(&context, Event::from_json(json!({})))
455 .await;
456 assert!(matches!(
457 outcome.terminal,
458 Terminal::Dropped { ref reason, .. } if reason.contains("fan-out exceeded")
459 ));
460 assert!(outcome.survivors.is_empty());
461 }
462
463 #[tokio::test]
464 async fn a_late_filter_matches_without_claiming_success() {
465 let mut registry = NodeRegistry::empty();
466 registry.register_ingress("ingress.event");
467 registry.register_step("execute.pass", |_| Ok(Box::new(Pass)));
468 registry.register_step("filter.drop", |_| Ok(Box::new(Drop)));
469 let branch = Branch {
470 branch_id: "root".into(),
471 nodes: vec![
472 Node {
473 id: "in".into(),
474 node_type: "ingress.event".into(),
475 config: json!({}),
476 },
477 Node {
478 id: "material".into(),
479 node_type: "execute.pass".into(),
480 config: json!({}),
481 },
482 Node {
483 id: "drop".into(),
484 node_type: "filter.drop".into(),
485 config: json!({}),
486 },
487 ],
488 edges: vec![
489 Edge {
490 source: "in".into(),
491 target: "material".into(),
492 },
493 Edge {
494 source: "material".into(),
495 target: "drop".into(),
496 },
497 ],
498 };
499 let outcome = CompiledBranch::compile(&branch, ®istry)
500 .unwrap()
501 .run_event(
502 &WorkflowContext::new("root", Arc::new(MemoryState::new())),
503 Event::from_json(json!({})),
504 )
505 .await;
506 assert!(outcome.matched);
507 assert!(!outcome.succeeded);
508 assert!(matches!(outcome.terminal, Terminal::Dropped { .. }));
509 }
510}