Skip to main content

runifold_workflow/
workflow.rs

1use std::{fmt, sync::Arc};
2
3use runifold_agent::Agent;
4use runifold_core::{CapabilitySet, EffectClass, Usage};
5
6use crate::{
7    AgentStep, StepId, WorkflowBuildError, WorkflowCondition, WorkflowStep, WorkflowStepError,
8};
9
10pub(crate) enum WorkflowNodeKind {
11    Step(Arc<dyn WorkflowStep>),
12    Branch {
13        condition: Arc<dyn WorkflowCondition>,
14        when_true: Arc<dyn WorkflowStep>,
15        when_false: Arc<dyn WorkflowStep>,
16    },
17    Parallel(Arc<[ParallelBranch]>),
18    Race(Arc<[ParallelBranch]>),
19}
20
21pub(crate) struct WorkflowNode {
22    pub(crate) id: StepId,
23    pub(crate) capabilities: CapabilitySet,
24    pub(crate) kind: WorkflowNodeKind,
25}
26
27impl WorkflowNode {
28    pub(crate) async fn execute(
29        &self,
30        input: serde_json::Value,
31        run: &runifold_core::RunContext,
32    ) -> Result<(serde_json::Value, Option<bool>), WorkflowStepError> {
33        match &self.kind {
34            WorkflowNodeKind::Step(step) => {
35                step.execute(input, run).await.map(|output| (output, None))
36            }
37            WorkflowNodeKind::Branch {
38                condition,
39                when_true,
40                when_false,
41            } => {
42                let selected = condition.evaluate(&input)?;
43                let step = if selected { when_true } else { when_false };
44                step.execute(input, run)
45                    .await
46                    .map(|output| (output, Some(selected)))
47            }
48            WorkflowNodeKind::Parallel(_) | WorkflowNodeKind::Race(_) => {
49                unreachable!("concurrent nodes use their dedicated scheduler")
50            }
51        }
52    }
53}
54
55/// One explicitly budgeted branch of a parallel workflow node.
56pub struct ParallelBranch {
57    pub(crate) id: String,
58    pub(crate) step: Arc<dyn WorkflowStep>,
59    pub(crate) capabilities: CapabilitySet,
60    pub(crate) reservation: Usage,
61}
62
63impl ParallelBranch {
64    /// Creates a custom parallel branch with an explicit resource reservation.
65    pub fn step<S>(
66        id: impl Into<String>,
67        step: S,
68        capabilities: CapabilitySet,
69        reservation: Usage,
70    ) -> Self
71    where
72        S: WorkflowStep + 'static,
73    {
74        Self {
75            id: id.into(),
76            step: Arc::new(step),
77            capabilities,
78            reservation,
79        }
80    }
81
82    /// Creates an Agent-backed parallel branch.
83    pub fn agent(
84        id: impl Into<String>,
85        agent: Arc<Agent>,
86        capabilities: CapabilitySet,
87        reservation: Usage,
88    ) -> Self {
89        Self::step(id, AgentStep::new(agent), capabilities, reservation)
90    }
91}
92
93impl fmt::Debug for ParallelBranch {
94    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
95        formatter
96            .debug_struct("ParallelBranch")
97            .field("id", &self.id)
98            .field("capabilities", &self.capabilities)
99            .field("reservation", &self.reservation)
100            .finish_non_exhaustive()
101    }
102}
103
104/// Validated, immutable workflow definition.
105#[derive(Clone)]
106pub struct Workflow {
107    pub(crate) name: String,
108    pub(crate) version: u32,
109    pub(crate) nodes: Arc<[WorkflowNode]>,
110}
111
112impl Workflow {
113    /// Starts a fluent workflow definition.
114    pub fn builder(name: impl Into<String>) -> WorkflowBuilder {
115        WorkflowBuilder::new(name)
116    }
117
118    /// Returns the stable workflow name.
119    pub fn name(&self) -> &str {
120        &self.name
121    }
122
123    /// Returns the definition version used to validate checkpoints.
124    pub const fn version(&self) -> u32 {
125        self.version
126    }
127
128    /// Returns stable node identifiers in execution order.
129    pub fn step_ids(&self) -> impl ExactSizeIterator<Item = &StepId> {
130        self.nodes.iter().map(|node| &node.id)
131    }
132}
133
134impl fmt::Debug for Workflow {
135    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
136        formatter
137            .debug_struct("Workflow")
138            .field("name", &self.name)
139            .field("version", &self.version)
140            .field("steps", &self.step_ids().collect::<Vec<_>>())
141            .finish_non_exhaustive()
142    }
143}
144
145/// Fluent, validation-preserving workflow assembly.
146pub struct WorkflowBuilder {
147    name: String,
148    version: u32,
149    nodes: Vec<WorkflowNode>,
150    error: Option<WorkflowBuildError>,
151}
152
153impl WorkflowBuilder {
154    /// Creates an empty version-one workflow definition.
155    pub fn new(name: impl Into<String>) -> Self {
156        let name = name.into();
157        let error = name
158            .trim()
159            .is_empty()
160            .then_some(WorkflowBuildError::EmptyName);
161        Self {
162            name,
163            version: 1,
164            nodes: Vec::new(),
165            error,
166        }
167    }
168
169    /// Sets the durable workflow definition version.
170    #[must_use]
171    pub fn version(mut self, version: u32) -> Self {
172        if version == 0 && self.error.is_none() {
173            self.error = Some(WorkflowBuildError::InvalidVersion);
174        } else {
175            self.version = version;
176        }
177        self
178    }
179
180    /// Appends one custom executable step.
181    #[must_use]
182    pub fn step<S>(mut self, id: impl Into<String>, step: S, capabilities: CapabilitySet) -> Self
183    where
184        S: WorkflowStep + 'static,
185    {
186        self.push_node(id, capabilities, WorkflowNodeKind::Step(Arc::new(step)));
187        self
188    }
189
190    /// Appends one Agent-backed step.
191    #[must_use]
192    pub fn agent(
193        mut self,
194        id: impl Into<String>,
195        agent: Arc<Agent>,
196        capabilities: CapabilitySet,
197    ) -> Self {
198        self.push_node(
199            id,
200            capabilities,
201            WorkflowNodeKind::Step(Arc::new(AgentStep::new(agent))),
202        );
203        self
204    }
205
206    /// Appends a condition that executes exactly one of two steps.
207    #[must_use]
208    pub fn branch<C, T, F>(
209        mut self,
210        id: impl Into<String>,
211        condition: C,
212        when_true: T,
213        when_false: F,
214        capabilities: CapabilitySet,
215    ) -> Self
216    where
217        C: WorkflowCondition + 'static,
218        T: WorkflowStep + 'static,
219        F: WorkflowStep + 'static,
220    {
221        self.push_node(
222            id,
223            capabilities,
224            WorkflowNodeKind::Branch {
225                condition: Arc::new(condition),
226                when_true: Arc::new(when_true),
227                when_false: Arc::new(when_false),
228            },
229        );
230        self
231    }
232
233    /// Appends a deterministic fan-out/fan-in parallel node.
234    ///
235    /// Every branch receives the same canonical input. Outputs are joined into
236    /// an object keyed by branch identifier, independent of completion order.
237    #[must_use]
238    pub fn parallel(
239        mut self,
240        id: impl Into<String>,
241        branches: impl IntoIterator<Item = ParallelBranch>,
242    ) -> Self {
243        if let Some((id, branches)) = self.validate_concurrent_branches(id, branches) {
244            if branches.len() < 2 {
245                self.error = Some(WorkflowBuildError::TooFewParallelBranches(id));
246                return self;
247            }
248            self.nodes.push(WorkflowNode {
249                id,
250                capabilities: CapabilitySet::new(),
251                kind: WorkflowNodeKind::Parallel(branches.into()),
252            });
253        }
254        self
255    }
256
257    /// Appends a budget-bounded, first-success race.
258    ///
259    /// Race branches may request only `Pure` or `ReadOnly` capabilities.
260    /// Losing reservations are conservatively forfeited because remote work
261    /// may outlive local cancellation.
262    #[must_use]
263    pub fn race(
264        mut self,
265        id: impl Into<String>,
266        branches: impl IntoIterator<Item = ParallelBranch>,
267    ) -> Self {
268        if let Some((id, branches)) = self.validate_concurrent_branches(id, branches) {
269            if branches.len() < 2 {
270                self.error = Some(WorkflowBuildError::TooFewRaceBranches(id));
271                return self;
272            }
273            for branch in &branches {
274                if let Some(capability) = branch.capabilities.iter().find(|capability| {
275                    !matches!(capability.effect, EffectClass::Pure | EffectClass::ReadOnly)
276                }) {
277                    let branch = match StepId::parse(branch.id.clone()) {
278                        Ok(branch) => branch,
279                        Err(branch) => {
280                            self.error = Some(WorkflowBuildError::InvalidParallelBranchId(branch));
281                            return self;
282                        }
283                    };
284                    self.error = Some(WorkflowBuildError::UnsafeRaceCapability {
285                        step: id,
286                        branch,
287                        capability: capability.name.clone(),
288                    });
289                    return self;
290                }
291            }
292            self.nodes.push(WorkflowNode {
293                id,
294                capabilities: CapabilitySet::new(),
295                kind: WorkflowNodeKind::Race(branches.into()),
296            });
297        }
298        self
299    }
300
301    /// Validates and freezes this workflow definition.
302    ///
303    /// # Errors
304    ///
305    /// Returns [`WorkflowBuildError`] for invalid identity, version, duplicate
306    /// steps, or an empty definition.
307    pub fn build(self) -> Result<Workflow, WorkflowBuildError> {
308        if let Some(error) = self.error {
309            return Err(error);
310        }
311        if self.nodes.is_empty() {
312            return Err(WorkflowBuildError::NoSteps);
313        }
314        Ok(Workflow {
315            name: self.name,
316            version: self.version,
317            nodes: self.nodes.into(),
318        })
319    }
320
321    fn push_node(
322        &mut self,
323        id: impl Into<String>,
324        capabilities: CapabilitySet,
325        kind: WorkflowNodeKind,
326    ) {
327        if self.error.is_some() {
328            return;
329        }
330        let id = match StepId::parse(id) {
331            Ok(id) => id,
332            Err(id) => {
333                self.error = Some(WorkflowBuildError::InvalidStepId(id));
334                return;
335            }
336        };
337        if self.nodes.iter().any(|node| node.id == id) {
338            self.error = Some(WorkflowBuildError::DuplicateStep(id));
339            return;
340        }
341        self.nodes.push(WorkflowNode {
342            id,
343            capabilities,
344            kind,
345        });
346    }
347
348    fn validate_concurrent_branches(
349        &mut self,
350        id: impl Into<String>,
351        branches: impl IntoIterator<Item = ParallelBranch>,
352    ) -> Option<(StepId, Vec<ParallelBranch>)> {
353        if self.error.is_some() {
354            return None;
355        }
356        let id = match StepId::parse(id) {
357            Ok(id) => id,
358            Err(id) => {
359                self.error = Some(WorkflowBuildError::InvalidStepId(id));
360                return None;
361            }
362        };
363        if self.nodes.iter().any(|node| node.id == id) {
364            self.error = Some(WorkflowBuildError::DuplicateStep(id));
365            return None;
366        }
367        let mut validated = Vec::new();
368        for branch in branches {
369            let branch_id = match StepId::parse(branch.id) {
370                Ok(branch_id) => branch_id,
371                Err(branch_id) => {
372                    self.error = Some(WorkflowBuildError::InvalidParallelBranchId(branch_id));
373                    return None;
374                }
375            };
376            if validated
377                .iter()
378                .any(|existing: &ParallelBranch| existing.id == branch_id.as_str())
379            {
380                self.error = Some(WorkflowBuildError::DuplicateParallelBranch {
381                    step: id,
382                    branch: branch_id,
383                });
384                return None;
385            }
386            validated.push(ParallelBranch {
387                id: branch_id.to_string(),
388                ..branch
389            });
390        }
391        Some((id, validated))
392    }
393}
394
395impl fmt::Debug for WorkflowBuilder {
396    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
397        formatter
398            .debug_struct("WorkflowBuilder")
399            .field("name", &self.name)
400            .field("version", &self.version)
401            .field(
402                "steps",
403                &self.nodes.iter().map(|node| &node.id).collect::<Vec<_>>(),
404            )
405            .field("error", &self.error)
406            .finish()
407    }
408}