Skip to main content

runifold_workflow/
workflow.rs

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