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