Skip to main content

af_workflow/
executor.rs

1//! Compile a spec branch into a runnable step chain and drive events through it.
2//!
3//! Port of the execution half of `platform/compiler.py`. The spec DAG is
4//! linear day-1 (per R9): one ingress head, then a topologically-ordered chain
5//! of step nodes. The ingress node is the event *source* (owned by the runner,
6//! next phase); compilation separates it from the steps it feeds.
7
8use 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/// Why a branch could not be compiled.
20#[derive(Debug, thiserror::Error)]
21pub enum CompileError {
22    /// A node could not be built.
23    #[error(transparent)]
24    Node(#[from] NodeError),
25    /// Branch '`branch_id`' has no ingress node (chain needs a source).
26    #[error("branch '{branch_id}' has no ingress node (chain needs a source)")]
27    /// The branch has no ingress node.
28    NoIngress {
29        /// Branch id.
30        branch_id: String,
31    },
32    /// Branch '`branch_id`' has `count` ingress nodes; day-1 supports one.
33    #[error("branch '{branch_id}' has {count} ingress nodes; day-1 supports one")]
34    /// The branch has more than one ingress node.
35    MultipleIngress {
36        /// Branch id.
37        branch_id: String,
38        /// Ingress nodes found.
39        count: usize,
40    },
41    /// Branch '`branch_id`' edges form a cycle (DAG required).
42    #[error("branch '{branch_id}' edges form a cycle (DAG required)")]
43    /// The branch is not a DAG.
44    Cycle {
45        /// Branch id.
46        branch_id: String,
47    },
48}
49
50/// A compiled, runnable branch: an ingress source + an ordered step chain.
51pub(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/// Where a run ended.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub enum Terminal {
89    /// All events fell out (every branch of the chain Dropped).
90    Dropped {
91        /// Node that dropped the last event.
92        node_id: String,
93        /// Why.
94        reason: String,
95    },
96    /// The chain ran to the end with at least one surviving event.
97    Completed,
98}
99
100/// Summary of one event driven through a compiled branch.
101#[derive(Debug, Clone)]
102pub struct RunOutcome {
103    /// Steps the event passed through.
104    pub steps_run: usize,
105    /// Where the run ended.
106    pub terminal: Terminal,
107    /// Events still alive at the end (empty if everything dropped).
108    pub survivors: Vec<Event>,
109    /// External effects the steps prepared, in emission order.
110    pub actions: Vec<PreparedAction>,
111    /// At least one event passed a material step or reached the branch end.
112    pub matched: bool,
113    /// The branch reached a sink or retained an event through its final step.
114    pub succeeded: bool,
115}
116
117impl CompiledBranch {
118    /// Compile a spec branch against a node registry.
119    pub(crate) fn compile(branch: &Branch, registry: &NodeRegistry) -> Result<Self, CompileError> {
120        let order = topo_order(branch)?;
121
122        // Split ingress head from step chain.
123        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; // ingress is the source, not a step
150            }
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    /// Drive one event through the step chain.
168    ///
169    /// `Pass` forwards, `Drop` removes that event, `FanOut` multiplies it. The
170    /// run completes when the chain is exhausted or every event has dropped.
171    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 &current {
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
335/// Kahn topological sort over a branch's edges. Nodes with no edges keep spec
336/// order. Errors on a cycle.
337fn 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        // Dangling edges are caught by Spec::validate_structure; ignore here.
345        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    // Seed queue in spec order to keep deterministic output.
355    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, &registry).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, &registry)
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}