Skip to main content

runifold_workflow/
execution.rs

1use std::{collections::BTreeMap, future::Future, pin::Pin, time::Instant};
2
3use runifold_core::{
4    ChildEvent, DomainEvent, EventId, LifecycleEvent, RetrySafety, RunContext, RunError,
5    RunErrorKind, RunEventKind, Usage,
6};
7use serde_json::Value;
8
9use crate::checkpoint::WorkflowCheckpointCursor;
10use crate::parallel::execute_parallel;
11use crate::race::execute_race;
12use crate::workflow::WorkflowNodeKind;
13use crate::{
14    ParallelBranchCheckpoint, StepId, Workflow, WorkflowCheckpoint, WorkflowCheckpointPhase,
15    WorkflowCheckpointState, WorkflowError, WorkflowOutcome, WorkflowResumePolicy,
16};
17
18/// A boxed, sendable workflow execution future.
19pub type WorkflowFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
20
21impl Workflow {
22    /// Executes this workflow from the first node.
23    pub fn run<'a>(
24        &'a self,
25        input: impl Into<Value> + Send + 'a,
26        run: &'a RunContext,
27    ) -> WorkflowFuture<'a, Result<WorkflowOutcome, WorkflowError>> {
28        let state = self.initial_state(input.into(), run.budget().usage());
29        Box::pin(async move { self.execute_state(state, run, None).await })
30    }
31
32    /// Executes with write-ahead workflow checkpoint persistence.
33    pub fn run_checkpointed<'a>(
34        &'a self,
35        input: impl Into<Value> + Send + 'a,
36        run: &'a RunContext,
37        checkpoint: &'a WorkflowCheckpoint,
38    ) -> WorkflowFuture<'a, Result<WorkflowOutcome, WorkflowError>> {
39        Box::pin(async move {
40            self.validate_authority(run)?;
41            let state = self.initial_state(input.into(), run.budget().usage());
42            let mut cursor = WorkflowCheckpointCursor::create(checkpoint, run, &state)?;
43            self.execute_state(state, run, Some(&mut cursor)).await
44        })
45    }
46
47    /// Resumes a persisted workflow execution.
48    pub fn resume<'a>(
49        &'a self,
50        checkpoint: &'a WorkflowCheckpoint,
51        run: &'a RunContext,
52        policy: WorkflowResumePolicy,
53    ) -> WorkflowFuture<'a, Result<WorkflowOutcome, WorkflowError>> {
54        Box::pin(async move {
55            let (envelope, mut state) = checkpoint.load()?;
56            self.validate_checkpoint_identity(&state)?;
57            if let Some(outcome) = state.outcome() {
58                validate_exact_usage(state.usage, run.budget().usage())?;
59                return Ok(outcome);
60            }
61            match &state.phase {
62                WorkflowCheckpointPhase::StepInFlight { step } => {
63                    if policy == WorkflowResumePolicy::RejectAmbiguous {
64                        return Err(WorkflowError::AmbiguousCheckpoint { step: step.clone() });
65                    }
66                    validate_usage_floor(state.usage, run.budget().usage())?;
67                    state.usage = run.budget().usage();
68                    state.phase = WorkflowCheckpointPhase::Ready;
69                }
70                WorkflowCheckpointPhase::ParallelInFlight { step, branches } => {
71                    let all_completed = branches
72                        .values()
73                        .all(|branch| matches!(branch, ParallelBranchCheckpoint::Completed { .. }));
74                    if !all_completed && policy == WorkflowResumePolicy::RejectAmbiguous {
75                        return Err(WorkflowError::AmbiguousCheckpoint { step: step.clone() });
76                    }
77                    if all_completed {
78                        validate_exact_usage(state.usage, run.budget().usage())?;
79                    } else {
80                        validate_usage_floor(state.usage, run.budget().usage())?;
81                        state.usage = run.budget().usage();
82                    }
83                }
84                WorkflowCheckpointPhase::RaceInFlight { step, branches } => {
85                    let has_winner = branches
86                        .values()
87                        .any(|branch| matches!(branch, ParallelBranchCheckpoint::Completed { .. }));
88                    let all_failed = branches
89                        .values()
90                        .all(|branch| matches!(branch, ParallelBranchCheckpoint::Failed { .. }));
91                    if !has_winner && !all_failed && policy == WorkflowResumePolicy::RejectAmbiguous
92                    {
93                        return Err(WorkflowError::AmbiguousCheckpoint { step: step.clone() });
94                    }
95                    if has_winner || all_failed {
96                        validate_exact_usage(state.usage, run.budget().usage())?;
97                    } else {
98                        validate_usage_floor(state.usage, run.budget().usage())?;
99                        state.usage = run.budget().usage();
100                    }
101                }
102                WorkflowCheckpointPhase::Ready => {
103                    validate_exact_usage(state.usage, run.budget().usage())?;
104                }
105                WorkflowCheckpointPhase::Completed { .. } => {
106                    unreachable!("completed workflow checkpoints return before phase recovery")
107                }
108            }
109            let mut cursor = WorkflowCheckpointCursor::loaded(checkpoint, envelope);
110            self.execute_state(state, run, Some(&mut cursor)).await
111        })
112    }
113
114    fn initial_state(&self, input: Value, usage: Usage) -> WorkflowCheckpointState {
115        WorkflowCheckpointState {
116            workflow: self.name.clone(),
117            workflow_version: self.version,
118            layout: self.step_ids().cloned().collect(),
119            next_index: 0,
120            value: input,
121            outputs: BTreeMap::new(),
122            usage,
123            phase: WorkflowCheckpointPhase::Ready,
124        }
125    }
126
127    async fn execute_state(
128        &self,
129        mut state: WorkflowCheckpointState,
130        run: &RunContext,
131        mut checkpoint: Option<&mut WorkflowCheckpointCursor>,
132    ) -> Result<WorkflowOutcome, WorkflowError> {
133        self.validate_authority(run)?;
134        let started = run
135            .record(
136                RunEventKind::Lifecycle(LifecycleEvent::Started),
137                run.caused_by(),
138            )?
139            .map(|event| event.meta.event_id);
140        let result = self
141            .run_loop(&mut state, run, started, &mut checkpoint)
142            .await;
143        run.record(terminal_event(&self.name, &result), started)?;
144        result
145    }
146
147    async fn run_loop(
148        &self,
149        state: &mut WorkflowCheckpointState,
150        run: &RunContext,
151        caused_by: Option<EventId>,
152        checkpoint: &mut Option<&mut WorkflowCheckpointCursor>,
153    ) -> Result<WorkflowOutcome, WorkflowError> {
154        while state.next_index < self.nodes.len() {
155            check_lifecycle(run)?;
156            let node = &self.nodes[state.next_index];
157            let output = match &node.kind {
158                WorkflowNodeKind::Parallel(branches) => {
159                    self.execute_parallel_node(node, branches, state, run, caused_by, checkpoint)
160                        .await?
161                }
162                WorkflowNodeKind::Race(branches) => {
163                    self.execute_race_node(node, branches, state, run, caused_by, checkpoint)
164                        .await?
165                }
166                _ => {
167                    self.execute_serial_node(node, state, run, caused_by, checkpoint)
168                        .await?
169                }
170            };
171            commit_node(state, &node.id, output, run, checkpoint)?;
172        }
173
174        let outcome = WorkflowOutcome {
175            output: state.value.clone(),
176            steps: state.outputs.clone(),
177            usage: run.budget().usage(),
178        };
179        state.usage = outcome.usage;
180        state.phase = WorkflowCheckpointPhase::Completed {
181            outcome: outcome.clone(),
182        };
183        save_checkpoint(checkpoint, state)?;
184        Ok(outcome)
185    }
186
187    async fn execute_serial_node(
188        &self,
189        node: &crate::workflow::WorkflowNode,
190        state: &mut WorkflowCheckpointState,
191        run: &RunContext,
192        caused_by: Option<EventId>,
193        checkpoint: &mut Option<&mut WorkflowCheckpointCursor>,
194    ) -> Result<Value, WorkflowError> {
195        state.phase = WorkflowCheckpointPhase::StepInFlight {
196            step: node.id.clone(),
197        };
198        state.usage = run.budget().usage();
199        save_checkpoint(checkpoint, state)?;
200
201        let step_started = record_domain(
202            run,
203            "step.started",
204            serde_json::json!({
205                "workflow": self.name,
206                "step": node.id,
207                "index": state.next_index,
208            }),
209            caused_by,
210        )?;
211        let mut child = run.child(node.capabilities.clone());
212        if let Some(event_id) = step_started {
213            child = child.with_cause(event_id);
214        }
215        run.record(
216            RunEventKind::Child(ChildEvent::Started {
217                child_run_id: child.run_id(),
218            }),
219            step_started,
220        )?;
221
222        let execution = node.execute(state.value.clone(), &child).await;
223        let (output, branch) = match execution {
224            Ok(result) => result,
225            Err(source) => {
226                run.record(
227                    RunEventKind::Child(ChildEvent::Failed {
228                        child_run_id: child.run_id(),
229                    }),
230                    step_started,
231                )?;
232                record_domain(
233                    run,
234                    "step.failed",
235                    serde_json::json!({
236                        "workflow": self.name,
237                        "step": node.id,
238                    }),
239                    step_started,
240                )?;
241                return Err(WorkflowError::Step {
242                    step: node.id.clone(),
243                    source: Box::new(source),
244                });
245            }
246        };
247
248        run.record(
249            RunEventKind::Child(ChildEvent::Completed {
250                child_run_id: child.run_id(),
251            }),
252            step_started,
253        )?;
254        record_domain(
255            run,
256            "step.completed",
257            serde_json::json!({
258                "workflow": self.name,
259                "step": node.id,
260                "branch": branch,
261            }),
262            step_started,
263        )?;
264
265        Ok(output)
266    }
267
268    async fn execute_parallel_node(
269        &self,
270        node: &crate::workflow::WorkflowNode,
271        branches: &[crate::ParallelBranch],
272        state: &mut WorkflowCheckpointState,
273        run: &RunContext,
274        caused_by: Option<EventId>,
275        checkpoint: &mut Option<&mut WorkflowCheckpointCursor>,
276    ) -> Result<Value, WorkflowError> {
277        let step_started = record_domain(
278            run,
279            "step.started",
280            serde_json::json!({
281                "workflow": self.name,
282                "step": node.id,
283                "index": state.next_index,
284                "kind": "parallel",
285            }),
286            caused_by,
287        )?;
288        let result = execute_parallel(
289            &self.name,
290            node,
291            branches,
292            state,
293            run,
294            step_started,
295            checkpoint,
296        )
297        .await;
298        let output = match result {
299            Ok(output) => output,
300            Err(error) => {
301                record_domain(
302                    run,
303                    "step.failed",
304                    serde_json::json!({
305                        "workflow": self.name,
306                        "step": node.id,
307                    }),
308                    step_started,
309                )?;
310                return Err(error);
311            }
312        };
313        record_domain(
314            run,
315            "step.completed",
316            serde_json::json!({
317                "workflow": self.name,
318                "step": node.id,
319                "kind": "parallel",
320            }),
321            step_started,
322        )?;
323        Ok(output)
324    }
325
326    async fn execute_race_node(
327        &self,
328        node: &crate::workflow::WorkflowNode,
329        branches: &[crate::ParallelBranch],
330        state: &mut WorkflowCheckpointState,
331        run: &RunContext,
332        caused_by: Option<EventId>,
333        checkpoint: &mut Option<&mut WorkflowCheckpointCursor>,
334    ) -> Result<Value, WorkflowError> {
335        let step_started = record_domain(
336            run,
337            "step.started",
338            serde_json::json!({
339                "workflow": self.name,
340                "step": node.id,
341                "index": state.next_index,
342                "kind": "race",
343            }),
344            caused_by,
345        )?;
346        let result = execute_race(
347            &self.name,
348            node,
349            branches,
350            state,
351            run,
352            step_started,
353            checkpoint,
354        )
355        .await;
356        match result {
357            Ok(output) => {
358                record_domain(
359                    run,
360                    "step.completed",
361                    serde_json::json!({
362                        "workflow": self.name,
363                        "step": node.id,
364                        "kind": "race",
365                    }),
366                    step_started,
367                )?;
368                Ok(output)
369            }
370            Err(error) => {
371                record_domain(
372                    run,
373                    "step.failed",
374                    serde_json::json!({
375                        "workflow": self.name,
376                        "step": node.id,
377                        "kind": "race",
378                    }),
379                    step_started,
380                )?;
381                Err(error)
382            }
383        }
384    }
385
386    fn validate_authority(&self, run: &RunContext) -> Result<(), WorkflowError> {
387        for node in self.nodes.iter() {
388            match &node.kind {
389                WorkflowNodeKind::Parallel(branches) | WorkflowNodeKind::Race(branches) => {
390                    for branch in branches.iter() {
391                        if let Some(missing) =
392                            branch.capabilities.first_missing_from(run.capabilities())
393                        {
394                            return Err(WorkflowError::AuthorityEscalation {
395                                step: node.id.clone(),
396                                capability: missing.name.clone(),
397                            });
398                        }
399                    }
400                }
401                _ => {
402                    if let Some(missing) = node.capabilities.first_missing_from(run.capabilities())
403                    {
404                        return Err(WorkflowError::AuthorityEscalation {
405                            step: node.id.clone(),
406                            capability: missing.name.clone(),
407                        });
408                    }
409                }
410            }
411        }
412        Ok(())
413    }
414
415    fn validate_checkpoint_identity(
416        &self,
417        state: &WorkflowCheckpointState,
418    ) -> Result<(), WorkflowError> {
419        let layout_matches = self.step_ids().eq(state.layout.iter());
420        let completed_layout = &state.layout[..state.next_index.min(state.layout.len())];
421        let outputs_match = state.outputs.len() == state.next_index
422            && completed_layout
423                .iter()
424                .all(|step| state.outputs.contains_key(step));
425        let phase_matches = match &state.phase {
426            WorkflowCheckpointPhase::Ready => state.next_index <= self.nodes.len(),
427            WorkflowCheckpointPhase::StepInFlight { step } => self
428                .nodes
429                .get(state.next_index)
430                .is_some_and(|node| node.id == *step),
431            WorkflowCheckpointPhase::ParallelInFlight { step, branches } => {
432                self.nodes.get(state.next_index).is_some_and(|node| {
433                    node.id == *step && parallel_layout_matches(&node.kind, branches)
434                })
435            }
436            WorkflowCheckpointPhase::RaceInFlight { step, branches } => self
437                .nodes
438                .get(state.next_index)
439                .is_some_and(|node| node.id == *step && race_layout_matches(&node.kind, branches)),
440            WorkflowCheckpointPhase::Completed { outcome } => {
441                state.next_index == self.nodes.len()
442                    && outcome.output == state.value
443                    && outcome.steps == state.outputs
444                    && outcome.usage == state.usage
445            }
446        };
447        if state.workflow != self.name
448            || state.workflow_version != self.version
449            || !layout_matches
450            || state.next_index > self.nodes.len()
451            || !outputs_match
452            || !phase_matches
453        {
454            return Err(WorkflowError::CheckpointIdentityMismatch);
455        }
456        Ok(())
457    }
458}
459
460fn commit_node(
461    state: &mut WorkflowCheckpointState,
462    step: &StepId,
463    output: Value,
464    run: &RunContext,
465    checkpoint: &mut Option<&mut WorkflowCheckpointCursor>,
466) -> Result<(), WorkflowError> {
467    state.outputs.insert(step.clone(), output.clone());
468    state.value = output;
469    state.next_index += 1;
470    state.usage = run.budget().usage();
471    state.phase = WorkflowCheckpointPhase::Ready;
472    save_checkpoint(checkpoint, state)
473}
474
475fn parallel_layout_matches(
476    kind: &WorkflowNodeKind,
477    checkpoint_branches: &BTreeMap<StepId, ParallelBranchCheckpoint>,
478) -> bool {
479    let WorkflowNodeKind::Parallel(branches) = kind else {
480        return false;
481    };
482    branches.len() == checkpoint_branches.len()
483        && branches.iter().all(|branch| {
484            checkpoint_branches
485                .keys()
486                .any(|checkpoint| checkpoint.as_str() == branch.id)
487        })
488}
489
490fn race_layout_matches(
491    kind: &WorkflowNodeKind,
492    checkpoint_branches: &BTreeMap<StepId, ParallelBranchCheckpoint>,
493) -> bool {
494    let WorkflowNodeKind::Race(branches) = kind else {
495        return false;
496    };
497    let completed = checkpoint_branches
498        .values()
499        .filter(|branch| matches!(branch, ParallelBranchCheckpoint::Completed { .. }))
500        .count();
501    let winner_is_terminal = completed == 0
502        || checkpoint_branches.values().all(|branch| {
503            matches!(
504                branch,
505                ParallelBranchCheckpoint::Completed { .. }
506                    | ParallelBranchCheckpoint::Failed { .. }
507                    | ParallelBranchCheckpoint::Cancelled
508            )
509        });
510    completed <= 1
511        && winner_is_terminal
512        && branches.len() == checkpoint_branches.len()
513        && branches.iter().all(|branch| {
514            checkpoint_branches
515                .keys()
516                .any(|checkpoint| checkpoint.as_str() == branch.id)
517        })
518}
519
520pub(crate) fn save_checkpoint(
521    checkpoint: &mut Option<&mut WorkflowCheckpointCursor>,
522    state: &WorkflowCheckpointState,
523) -> Result<(), WorkflowError> {
524    if let Some(checkpoint) = checkpoint.as_deref_mut() {
525        checkpoint.save(state)?;
526    }
527    Ok(())
528}
529
530fn check_lifecycle(run: &RunContext) -> Result<(), WorkflowError> {
531    if run.cancellation().is_cancelled() {
532        return Err(WorkflowError::Cancelled);
533    }
534    if run
535        .deadline()
536        .is_some_and(|deadline| deadline <= Instant::now())
537    {
538        return Err(WorkflowError::DeadlineExceeded);
539    }
540    Ok(())
541}
542
543pub(crate) fn record_domain(
544    run: &RunContext,
545    name: &str,
546    payload: Value,
547    caused_by: Option<EventId>,
548) -> Result<Option<EventId>, WorkflowError> {
549    Ok(run
550        .record(
551            RunEventKind::Domain(DomainEvent {
552                namespace: "runifold.workflow".into(),
553                name: name.into(),
554                payload,
555            }),
556            caused_by,
557        )?
558        .map(|event| event.meta.event_id))
559}
560
561fn terminal_event(workflow: &str, result: &Result<WorkflowOutcome, WorkflowError>) -> RunEventKind {
562    match result {
563        Ok(outcome) => RunEventKind::Lifecycle(LifecycleEvent::Completed {
564            output: serde_json::json!({
565                "workflow": workflow,
566                "steps": outcome.steps.len(),
567                "usage": outcome.usage,
568            }),
569        }),
570        Err(WorkflowError::Cancelled) => RunEventKind::Lifecycle(LifecycleEvent::Cancelled),
571        Err(error) => RunEventKind::Lifecycle(LifecycleEvent::Failed {
572            error: workflow_run_error(error),
573        }),
574    }
575}
576
577fn workflow_run_error(error: &WorkflowError) -> RunError {
578    let (kind, retry_safety) = match error {
579        WorkflowError::AuthorityEscalation { .. } => {
580            (RunErrorKind::CapabilityDenied, RetrySafety::Safe)
581        }
582        WorkflowError::Cancelled => (RunErrorKind::Cancelled, RetrySafety::Safe),
583        WorkflowError::DeadlineExceeded => (RunErrorKind::DeadlineExceeded, RetrySafety::Unknown),
584        WorkflowError::Budget(_) => (RunErrorKind::BudgetExceeded, RetrySafety::Safe),
585        WorkflowError::Build(_)
586        | WorkflowError::CheckpointIdentityMismatch
587        | WorkflowError::CheckpointUsageMismatch => (RunErrorKind::InvalidInput, RetrySafety::Safe),
588        WorkflowError::AmbiguousCheckpoint { .. }
589        | WorkflowError::Step { .. }
590        | WorkflowError::ParallelBranch { .. }
591        | WorkflowError::RaceAllFailed { .. }
592        | WorkflowError::BudgetReservation(_)
593        | WorkflowError::Journal(_)
594        | WorkflowError::Checkpoint(_) => (RunErrorKind::Invocation, RetrySafety::Unknown),
595    };
596    RunError {
597        kind,
598        message: error.to_string(),
599        retry_safety,
600        metadata: BTreeMap::new(),
601    }
602}
603
604fn validate_exact_usage(expected: Usage, actual: Usage) -> Result<(), WorkflowError> {
605    if expected != actual {
606        return Err(WorkflowError::CheckpointUsageMismatch);
607    }
608    Ok(())
609}
610
611fn validate_usage_floor(floor: Usage, actual: Usage) -> Result<(), WorkflowError> {
612    let covers = actual.tokens >= floor.tokens
613        && actual.cost_microusd >= floor.cost_microusd
614        && actual.duration_micros >= floor.duration_micros
615        && actual.turns >= floor.turns
616        && actual.tool_calls >= floor.tool_calls
617        && actual.delegations >= floor.delegations;
618    if !covers {
619        return Err(WorkflowError::CheckpointUsageMismatch);
620    }
621    Ok(())
622}