Skip to main content

runifold_workflow/
execution.rs

1use std::{collections::BTreeMap, future::Future, pin::Pin};
2
3use runifold_core::{
4    ChildEvent, DomainEvent, EventId, Instant, 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, WorkflowInterruptDecision, WorkflowInterruptOutcome,
16    WorkflowInterruptRequest, WorkflowOutcome, WorkflowResumePolicy, WorkflowWait,
17    WorkflowWaitOutcome, WorkflowWake,
18};
19
20/// A boxed, sendable workflow execution future.
21#[cfg(not(target_arch = "wasm32"))]
22pub type WorkflowFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
23
24/// A boxed workflow execution future on single-threaded WASM.
25#[cfg(target_arch = "wasm32")]
26pub type WorkflowFuture<'a, T> = Pin<Box<dyn Future<Output = T> + 'a>>;
27
28pub(crate) enum WorkflowExecution {
29    Completed(WorkflowOutcome),
30    Suspended(WorkflowWait),
31}
32
33impl WorkflowExecution {
34    fn require_completed(self) -> Result<WorkflowOutcome, WorkflowError> {
35        match self {
36            Self::Completed(outcome) => Ok(outcome),
37            Self::Suspended(_) => Err(WorkflowError::DurableWaitRequiresWorker),
38        }
39    }
40}
41
42impl Workflow {
43    /// Executes this workflow from the first node.
44    pub fn run<'a>(
45        &'a self,
46        input: impl Into<Value> + Send + 'a,
47        run: &'a RunContext,
48    ) -> WorkflowFuture<'a, Result<WorkflowOutcome, WorkflowError>> {
49        let state = self.initial_state(input.into(), run.budget().usage());
50        Box::pin(async move {
51            self.execute_state(state, run, None)
52                .await?
53                .require_completed()
54        })
55    }
56
57    /// Executes with write-ahead workflow checkpoint persistence.
58    pub fn run_checkpointed<'a>(
59        &'a self,
60        input: impl Into<Value> + Send + 'a,
61        run: &'a RunContext,
62        checkpoint: &'a WorkflowCheckpoint,
63    ) -> WorkflowFuture<'a, Result<WorkflowOutcome, WorkflowError>> {
64        Box::pin(async move {
65            self.validate_authority(run)?;
66            let state = self.initial_state(input.into(), run.budget().usage());
67            let mut cursor = WorkflowCheckpointCursor::create(checkpoint, run, &state).await?;
68            self.execute_state(state, run, Some(&mut cursor))
69                .await?
70                .require_completed()
71        })
72    }
73
74    pub(crate) fn run_checkpointed_controlled<'a>(
75        &'a self,
76        input: Value,
77        run: &'a RunContext,
78        checkpoint: &'a WorkflowCheckpoint,
79    ) -> WorkflowFuture<'a, Result<WorkflowExecution, WorkflowError>> {
80        Box::pin(async move {
81            self.validate_authority(run)?;
82            let state = self.initial_state(input, run.budget().usage());
83            let mut cursor = WorkflowCheckpointCursor::create(checkpoint, run, &state).await?;
84            self.execute_state(state, run, Some(&mut cursor)).await
85        })
86    }
87
88    /// Resumes a persisted workflow execution.
89    pub fn resume<'a>(
90        &'a self,
91        checkpoint: &'a WorkflowCheckpoint,
92        run: &'a RunContext,
93        policy: WorkflowResumePolicy,
94    ) -> WorkflowFuture<'a, Result<WorkflowOutcome, WorkflowError>> {
95        Box::pin(async move {
96            self.resume_controlled(checkpoint, run, policy, None)
97                .await?
98                .require_completed()
99        })
100    }
101
102    pub(crate) fn resume_controlled<'a>(
103        &'a self,
104        checkpoint: &'a WorkflowCheckpoint,
105        run: &'a RunContext,
106        policy: WorkflowResumePolicy,
107        wake: Option<WorkflowWake>,
108    ) -> WorkflowFuture<'a, Result<WorkflowExecution, WorkflowError>> {
109        Box::pin(async move {
110            let (envelope, mut state) = checkpoint.load_async().await?;
111            self.validate_checkpoint_identity(&state)?;
112            if let Some(outcome) = state.outcome() {
113                validate_exact_usage(state.usage, run.budget().usage())?;
114                return Ok(WorkflowExecution::Completed(outcome));
115            }
116            let mut waiting_wake = None;
117            match &state.phase {
118                WorkflowCheckpointPhase::StepInFlight { step } => {
119                    if policy == WorkflowResumePolicy::RejectAmbiguous {
120                        return Err(WorkflowError::AmbiguousCheckpoint { step: step.clone() });
121                    }
122                    validate_usage_floor(state.usage, run.budget().usage())?;
123                    state.usage = run.budget().usage();
124                    state.phase = WorkflowCheckpointPhase::Ready;
125                }
126                WorkflowCheckpointPhase::Waiting { wait, .. } => {
127                    validate_exact_usage(state.usage, run.budget().usage())?;
128                    let wake = wake.ok_or(WorkflowError::DurableWaitRequiresWorker)?;
129                    if !wake.matches(wait) {
130                        return Err(WorkflowError::WakeMismatch);
131                    }
132                    waiting_wake = Some((wait.clone(), wake));
133                }
134                WorkflowCheckpointPhase::ParallelInFlight { step, branches } => {
135                    let all_completed = branches
136                        .values()
137                        .all(|branch| matches!(branch, ParallelBranchCheckpoint::Completed { .. }));
138                    if !all_completed && policy == WorkflowResumePolicy::RejectAmbiguous {
139                        return Err(WorkflowError::AmbiguousCheckpoint { step: step.clone() });
140                    }
141                    if all_completed {
142                        validate_exact_usage(state.usage, run.budget().usage())?;
143                    } else {
144                        validate_usage_floor(state.usage, run.budget().usage())?;
145                        state.usage = run.budget().usage();
146                    }
147                }
148                WorkflowCheckpointPhase::RaceInFlight { step, branches } => {
149                    let has_winner = branches
150                        .values()
151                        .any(|branch| matches!(branch, ParallelBranchCheckpoint::Completed { .. }));
152                    let all_failed = branches
153                        .values()
154                        .all(|branch| matches!(branch, ParallelBranchCheckpoint::Failed { .. }));
155                    if !has_winner && !all_failed && policy == WorkflowResumePolicy::RejectAmbiguous
156                    {
157                        return Err(WorkflowError::AmbiguousCheckpoint { step: step.clone() });
158                    }
159                    if has_winner || all_failed {
160                        validate_exact_usage(state.usage, run.budget().usage())?;
161                    } else {
162                        validate_usage_floor(state.usage, run.budget().usage())?;
163                        state.usage = run.budget().usage();
164                    }
165                }
166                WorkflowCheckpointPhase::Ready => {
167                    validate_exact_usage(state.usage, run.budget().usage())?;
168                }
169                WorkflowCheckpointPhase::Completed { .. } => {
170                    unreachable!("completed workflow checkpoints return before phase recovery")
171                }
172            }
173            let mut cursor = WorkflowCheckpointCursor::loaded(checkpoint, envelope);
174            if let Some((wait, wake)) = waiting_wake {
175                let node = &self.nodes[state.next_index];
176                let output = wake_output(&wait, wake, &state.value)?;
177                commit_node(&mut state, &node.id, output, run, &mut Some(&mut cursor)).await?;
178            }
179            self.execute_state(state, run, Some(&mut cursor)).await
180        })
181    }
182
183    fn initial_state(&self, input: Value, usage: Usage) -> WorkflowCheckpointState {
184        WorkflowCheckpointState {
185            workflow: self.name.clone(),
186            workflow_version: self.version,
187            layout: self.step_ids().cloned().collect(),
188            next_index: 0,
189            value: input,
190            outputs: BTreeMap::new(),
191            usage,
192            phase: WorkflowCheckpointPhase::Ready,
193        }
194    }
195
196    async fn execute_state(
197        &self,
198        mut state: WorkflowCheckpointState,
199        run: &RunContext,
200        mut checkpoint: Option<&mut WorkflowCheckpointCursor>,
201    ) -> Result<WorkflowExecution, WorkflowError> {
202        self.validate_authority(run)?;
203        let started = run
204            .record(
205                RunEventKind::Lifecycle(LifecycleEvent::Started),
206                run.caused_by(),
207            )?
208            .map(|event| event.meta.event_id);
209        let result = self
210            .run_loop(&mut state, run, started, &mut checkpoint)
211            .await;
212        run.record(terminal_event(&self.name, &result), started)?;
213        result
214    }
215
216    async fn run_loop(
217        &self,
218        state: &mut WorkflowCheckpointState,
219        run: &RunContext,
220        caused_by: Option<EventId>,
221        checkpoint: &mut Option<&mut WorkflowCheckpointCursor>,
222    ) -> Result<WorkflowExecution, WorkflowError> {
223        while state.next_index < self.nodes.len() {
224            check_lifecycle(run)?;
225            let node = &self.nodes[state.next_index];
226            let output = match &node.kind {
227                WorkflowNodeKind::Parallel(branches) => {
228                    self.execute_parallel_node(node, branches, state, run, caused_by, checkpoint)
229                        .await?
230                }
231                WorkflowNodeKind::Race(branches) => {
232                    self.execute_race_node(node, branches, state, run, caused_by, checkpoint)
233                        .await?
234                }
235                WorkflowNodeKind::Timer(wait)
236                | WorkflowNodeKind::Signal(wait)
237                | WorkflowNodeKind::SignalOrTimeout(wait) => {
238                    state.phase = WorkflowCheckpointPhase::Waiting {
239                        step: node.id.clone(),
240                        wait: wait.clone(),
241                    };
242                    state.usage = run.budget().usage();
243                    save_checkpoint(checkpoint, state).await?;
244                    record_domain(
245                        run,
246                        "workflow.suspended",
247                        serde_json::json!({
248                            "workflow": self.name,
249                            "step": node.id,
250                            "wait": wait,
251                        }),
252                        caused_by,
253                    )?;
254                    return Ok(WorkflowExecution::Suspended(wait.clone()));
255                }
256                WorkflowNodeKind::Interrupt(prompt) => {
257                    let wait = WorkflowWait::Interrupt {
258                        request: WorkflowInterruptRequest::new(
259                            prompt.clone(),
260                            state.value.clone(),
261                        )?,
262                    };
263                    state.phase = WorkflowCheckpointPhase::Waiting {
264                        step: node.id.clone(),
265                        wait: wait.clone(),
266                    };
267                    state.usage = run.budget().usage();
268                    save_checkpoint(checkpoint, state).await?;
269                    record_domain(
270                        run,
271                        "workflow.interrupted",
272                        serde_json::json!({
273                            "workflow": self.name,
274                            "step": node.id,
275                            "wait": wait,
276                        }),
277                        caused_by,
278                    )?;
279                    return Ok(WorkflowExecution::Suspended(wait));
280                }
281                _ => {
282                    self.execute_serial_node(node, state, run, caused_by, checkpoint)
283                        .await?
284                }
285            };
286            commit_node(state, &node.id, output, run, checkpoint).await?;
287        }
288
289        let outcome = WorkflowOutcome {
290            output: state.value.clone(),
291            steps: state.outputs.clone(),
292            usage: run.budget().usage(),
293        };
294        state.usage = outcome.usage;
295        state.phase = WorkflowCheckpointPhase::Completed {
296            outcome: outcome.clone(),
297        };
298        save_checkpoint(checkpoint, state).await?;
299        Ok(WorkflowExecution::Completed(outcome))
300    }
301
302    async fn execute_serial_node(
303        &self,
304        node: &crate::workflow::WorkflowNode,
305        state: &mut WorkflowCheckpointState,
306        run: &RunContext,
307        caused_by: Option<EventId>,
308        checkpoint: &mut Option<&mut WorkflowCheckpointCursor>,
309    ) -> Result<Value, WorkflowError> {
310        state.phase = WorkflowCheckpointPhase::StepInFlight {
311            step: node.id.clone(),
312        };
313        state.usage = run.budget().usage();
314        save_checkpoint(checkpoint, state).await?;
315
316        let step_started = record_domain(
317            run,
318            "step.started",
319            serde_json::json!({
320                "workflow": self.name,
321                "step": node.id,
322                "index": state.next_index,
323            }),
324            caused_by,
325        )?;
326        let mut child = run.child(node.capabilities.clone()).map_err(|error| {
327            WorkflowError::AuthorityEscalation {
328                step: node.id.clone(),
329                capability: error.capability,
330            }
331        })?;
332        if let Some(event_id) = step_started {
333            child = child.with_cause(event_id);
334        }
335        run.record(
336            RunEventKind::Child(ChildEvent::Started {
337                child_run_id: child.run_id(),
338            }),
339            step_started,
340        )?;
341
342        let execution = node.execute(state.value.clone(), &child).await;
343        let (output, branch) = match execution {
344            Ok(result) => result,
345            Err(source) => {
346                run.record(
347                    RunEventKind::Child(ChildEvent::Failed {
348                        child_run_id: child.run_id(),
349                    }),
350                    step_started,
351                )?;
352                record_domain(
353                    run,
354                    "step.failed",
355                    serde_json::json!({
356                        "workflow": self.name,
357                        "step": node.id,
358                    }),
359                    step_started,
360                )?;
361                return Err(WorkflowError::Step {
362                    step: node.id.clone(),
363                    source: Box::new(source),
364                });
365            }
366        };
367
368        run.record(
369            RunEventKind::Child(ChildEvent::Completed {
370                child_run_id: child.run_id(),
371            }),
372            step_started,
373        )?;
374        record_domain(
375            run,
376            "step.completed",
377            serde_json::json!({
378                "workflow": self.name,
379                "step": node.id,
380                "branch": branch,
381            }),
382            step_started,
383        )?;
384
385        Ok(output)
386    }
387
388    async fn execute_parallel_node(
389        &self,
390        node: &crate::workflow::WorkflowNode,
391        branches: &[crate::ParallelBranch],
392        state: &mut WorkflowCheckpointState,
393        run: &RunContext,
394        caused_by: Option<EventId>,
395        checkpoint: &mut Option<&mut WorkflowCheckpointCursor>,
396    ) -> Result<Value, WorkflowError> {
397        let step_started = record_domain(
398            run,
399            "step.started",
400            serde_json::json!({
401                "workflow": self.name,
402                "step": node.id,
403                "index": state.next_index,
404                "kind": "parallel",
405            }),
406            caused_by,
407        )?;
408        let result = execute_parallel(
409            &self.name,
410            node,
411            branches,
412            state,
413            run,
414            step_started,
415            checkpoint,
416        )
417        .await;
418        let output = match result {
419            Ok(output) => output,
420            Err(error) => {
421                record_domain(
422                    run,
423                    "step.failed",
424                    serde_json::json!({
425                        "workflow": self.name,
426                        "step": node.id,
427                    }),
428                    step_started,
429                )?;
430                return Err(error);
431            }
432        };
433        record_domain(
434            run,
435            "step.completed",
436            serde_json::json!({
437                "workflow": self.name,
438                "step": node.id,
439                "kind": "parallel",
440            }),
441            step_started,
442        )?;
443        Ok(output)
444    }
445
446    async fn execute_race_node(
447        &self,
448        node: &crate::workflow::WorkflowNode,
449        branches: &[crate::ParallelBranch],
450        state: &mut WorkflowCheckpointState,
451        run: &RunContext,
452        caused_by: Option<EventId>,
453        checkpoint: &mut Option<&mut WorkflowCheckpointCursor>,
454    ) -> Result<Value, WorkflowError> {
455        let step_started = record_domain(
456            run,
457            "step.started",
458            serde_json::json!({
459                "workflow": self.name,
460                "step": node.id,
461                "index": state.next_index,
462                "kind": "race",
463            }),
464            caused_by,
465        )?;
466        let result = execute_race(
467            &self.name,
468            node,
469            branches,
470            state,
471            run,
472            step_started,
473            checkpoint,
474        )
475        .await;
476        match result {
477            Ok(output) => {
478                record_domain(
479                    run,
480                    "step.completed",
481                    serde_json::json!({
482                        "workflow": self.name,
483                        "step": node.id,
484                        "kind": "race",
485                    }),
486                    step_started,
487                )?;
488                Ok(output)
489            }
490            Err(error) => {
491                record_domain(
492                    run,
493                    "step.failed",
494                    serde_json::json!({
495                        "workflow": self.name,
496                        "step": node.id,
497                        "kind": "race",
498                    }),
499                    step_started,
500                )?;
501                Err(error)
502            }
503        }
504    }
505
506    fn validate_authority(&self, run: &RunContext) -> Result<(), WorkflowError> {
507        for node in self.nodes.iter() {
508            match &node.kind {
509                WorkflowNodeKind::Parallel(branches) | WorkflowNodeKind::Race(branches) => {
510                    for branch in branches.iter() {
511                        if let Some(missing) =
512                            branch.capabilities.first_missing_from(run.capabilities())
513                        {
514                            return Err(WorkflowError::AuthorityEscalation {
515                                step: node.id.clone(),
516                                capability: missing.name.clone(),
517                            });
518                        }
519                    }
520                }
521                _ => {
522                    if let Some(missing) = node.capabilities.first_missing_from(run.capabilities())
523                    {
524                        return Err(WorkflowError::AuthorityEscalation {
525                            step: node.id.clone(),
526                            capability: missing.name.clone(),
527                        });
528                    }
529                }
530            }
531        }
532        Ok(())
533    }
534
535    fn validate_checkpoint_identity(
536        &self,
537        state: &WorkflowCheckpointState,
538    ) -> Result<(), WorkflowError> {
539        let layout_matches = self.step_ids().eq(state.layout.iter());
540        let completed_layout = &state.layout[..state.next_index.min(state.layout.len())];
541        let outputs_match = state.outputs.len() == state.next_index
542            && completed_layout
543                .iter()
544                .all(|step| state.outputs.contains_key(step));
545        let phase_matches = match &state.phase {
546            WorkflowCheckpointPhase::Ready => state.next_index <= self.nodes.len(),
547            WorkflowCheckpointPhase::StepInFlight { step } => self
548                .nodes
549                .get(state.next_index)
550                .is_some_and(|node| node.id == *step),
551            WorkflowCheckpointPhase::Waiting { step, wait } => {
552                self.nodes.get(state.next_index).is_some_and(|node| {
553                    if node.id != *step {
554                        return false;
555                    }
556                    matches!(
557                        &node.kind,
558                        WorkflowNodeKind::Timer(expected)
559                            | WorkflowNodeKind::Signal(expected)
560                            | WorkflowNodeKind::SignalOrTimeout(expected) if expected == wait
561                    ) || matches!(
562                        (&node.kind, wait),
563                        (
564                            WorkflowNodeKind::Interrupt(prompt),
565                            WorkflowWait::Interrupt { request }
566                        ) if request.prompt == *prompt && request.proposal == state.value
567                    )
568                })
569            }
570            WorkflowCheckpointPhase::ParallelInFlight { step, branches } => {
571                self.nodes.get(state.next_index).is_some_and(|node| {
572                    node.id == *step && parallel_layout_matches(&node.kind, branches)
573                })
574            }
575            WorkflowCheckpointPhase::RaceInFlight { step, branches } => self
576                .nodes
577                .get(state.next_index)
578                .is_some_and(|node| node.id == *step && race_layout_matches(&node.kind, branches)),
579            WorkflowCheckpointPhase::Completed { outcome } => {
580                state.next_index == self.nodes.len()
581                    && outcome.output == state.value
582                    && outcome.steps == state.outputs
583                    && outcome.usage == state.usage
584            }
585        };
586        if state.workflow != self.name
587            || state.workflow_version != self.version
588            || !layout_matches
589            || state.next_index > self.nodes.len()
590            || !outputs_match
591            || !phase_matches
592        {
593            return Err(WorkflowError::CheckpointIdentityMismatch);
594        }
595        Ok(())
596    }
597}
598
599async fn commit_node(
600    state: &mut WorkflowCheckpointState,
601    step: &StepId,
602    output: Value,
603    run: &RunContext,
604    checkpoint: &mut Option<&mut WorkflowCheckpointCursor>,
605) -> Result<(), WorkflowError> {
606    state.outputs.insert(step.clone(), output.clone());
607    state.value = output;
608    state.next_index += 1;
609    state.usage = run.budget().usage();
610    state.phase = WorkflowCheckpointPhase::Ready;
611    save_checkpoint(checkpoint, state).await
612}
613
614fn wake_output(
615    wait: &WorkflowWait,
616    wake: WorkflowWake,
617    current: &Value,
618) -> Result<Value, WorkflowError> {
619    match (wait, wake) {
620        (WorkflowWait::Timer { .. }, WorkflowWake::Timer) => Ok(current.clone()),
621        (WorkflowWait::Signal { .. }, WorkflowWake::Signal { payload, .. }) => Ok(payload),
622        (
623            WorkflowWait::SignalOrTimeout { .. },
624            WorkflowWake::Signal {
625                signal_id,
626                name,
627                payload,
628            },
629        ) => Ok(serde_json::to_value(WorkflowWaitOutcome::Signal {
630            signal_id,
631            name,
632            payload,
633        })?),
634        (WorkflowWait::SignalOrTimeout { .. }, WorkflowWake::Timeout) => {
635            Ok(serde_json::to_value(WorkflowWaitOutcome::TimedOut)?)
636        }
637        (WorkflowWait::Interrupt { request }, WorkflowWake::Signal { name, payload, .. })
638            if name == request.signal_name() =>
639        {
640            let decision: WorkflowInterruptDecision = serde_json::from_value(payload)?;
641            decision.validate()?;
642            let outcome = match decision {
643                WorkflowInterruptDecision::Approve => WorkflowInterruptOutcome::Approved {
644                    value: request.proposal.clone(),
645                },
646                WorkflowInterruptDecision::Edit { value } => {
647                    WorkflowInterruptOutcome::Edited { value }
648                }
649                WorkflowInterruptDecision::Reject { reason } => {
650                    WorkflowInterruptOutcome::Rejected { reason }
651                }
652            };
653            Ok(serde_json::to_value(outcome)?)
654        }
655        _ => Err(WorkflowError::WakeMismatch),
656    }
657}
658
659fn parallel_layout_matches(
660    kind: &WorkflowNodeKind,
661    checkpoint_branches: &BTreeMap<StepId, ParallelBranchCheckpoint>,
662) -> bool {
663    let WorkflowNodeKind::Parallel(branches) = kind else {
664        return false;
665    };
666    branches.len() == checkpoint_branches.len()
667        && branches.iter().all(|branch| {
668            checkpoint_branches
669                .keys()
670                .any(|checkpoint| checkpoint.as_str() == branch.id)
671        })
672}
673
674fn race_layout_matches(
675    kind: &WorkflowNodeKind,
676    checkpoint_branches: &BTreeMap<StepId, ParallelBranchCheckpoint>,
677) -> bool {
678    let WorkflowNodeKind::Race(branches) = kind else {
679        return false;
680    };
681    let completed = checkpoint_branches
682        .values()
683        .filter(|branch| matches!(branch, ParallelBranchCheckpoint::Completed { .. }))
684        .count();
685    let winner_is_terminal = completed == 0
686        || checkpoint_branches.values().all(|branch| {
687            matches!(
688                branch,
689                ParallelBranchCheckpoint::Completed { .. }
690                    | ParallelBranchCheckpoint::Failed { .. }
691                    | ParallelBranchCheckpoint::Cancelled
692            )
693        });
694    completed <= 1
695        && winner_is_terminal
696        && branches.len() == checkpoint_branches.len()
697        && branches.iter().all(|branch| {
698            checkpoint_branches
699                .keys()
700                .any(|checkpoint| checkpoint.as_str() == branch.id)
701        })
702}
703
704pub(crate) async fn save_checkpoint(
705    checkpoint: &mut Option<&mut WorkflowCheckpointCursor>,
706    state: &WorkflowCheckpointState,
707) -> Result<(), WorkflowError> {
708    if let Some(checkpoint) = checkpoint.as_deref_mut() {
709        checkpoint.save(state).await?;
710    }
711    Ok(())
712}
713
714fn check_lifecycle(run: &RunContext) -> Result<(), WorkflowError> {
715    if run.cancellation().is_cancelled() {
716        return Err(WorkflowError::Cancelled);
717    }
718    if run
719        .deadline()
720        .is_some_and(|deadline| deadline <= Instant::now())
721    {
722        return Err(WorkflowError::DeadlineExceeded);
723    }
724    Ok(())
725}
726
727pub(crate) fn record_domain(
728    run: &RunContext,
729    name: &str,
730    payload: Value,
731    caused_by: Option<EventId>,
732) -> Result<Option<EventId>, WorkflowError> {
733    Ok(run
734        .record(
735            RunEventKind::Domain(DomainEvent {
736                namespace: "runifold.workflow".into(),
737                name: name.into(),
738                payload,
739            }),
740            caused_by,
741        )?
742        .map(|event| event.meta.event_id))
743}
744
745fn terminal_event(
746    workflow: &str,
747    result: &Result<WorkflowExecution, WorkflowError>,
748) -> RunEventKind {
749    match result {
750        Ok(WorkflowExecution::Completed(outcome)) => {
751            RunEventKind::Lifecycle(LifecycleEvent::Completed {
752                output: serde_json::json!({
753                    "workflow": workflow,
754                    "steps": outcome.steps.len(),
755                    "usage": outcome.usage,
756                }),
757            })
758        }
759        Ok(WorkflowExecution::Suspended(wait)) => {
760            RunEventKind::Lifecycle(LifecycleEvent::Completed {
761                output: serde_json::json!({
762                    "workflow": workflow,
763                    "state": "suspended",
764                    "wait": wait,
765                }),
766            })
767        }
768        Err(WorkflowError::Cancelled) => RunEventKind::Lifecycle(LifecycleEvent::Cancelled),
769        Err(error) => RunEventKind::Lifecycle(LifecycleEvent::Failed {
770            error: workflow_run_error(error),
771        }),
772    }
773}
774
775fn workflow_run_error(error: &WorkflowError) -> RunError {
776    let (kind, retry_safety) = match error {
777        WorkflowError::AuthorityEscalation { .. } => {
778            (RunErrorKind::CapabilityDenied, RetrySafety::Safe)
779        }
780        WorkflowError::Cancelled => (RunErrorKind::Cancelled, RetrySafety::Safe),
781        WorkflowError::DeadlineExceeded => (RunErrorKind::DeadlineExceeded, RetrySafety::Unknown),
782        WorkflowError::DurableWaitRequiresWorker | WorkflowError::WakeMismatch => {
783            (RunErrorKind::InvalidInput, RetrySafety::Safe)
784        }
785        WorkflowError::Budget(_) => (RunErrorKind::BudgetExceeded, RetrySafety::Safe),
786        WorkflowError::Build(_)
787        | WorkflowError::Wait(_)
788        | WorkflowError::CheckpointIdentityMismatch
789        | WorkflowError::CheckpointUsageMismatch => (RunErrorKind::InvalidInput, RetrySafety::Safe),
790        WorkflowError::AmbiguousCheckpoint { .. }
791        | WorkflowError::Serialization(_)
792        | WorkflowError::Step { .. }
793        | WorkflowError::ParallelBranch { .. }
794        | WorkflowError::RaceAllFailed { .. }
795        | WorkflowError::ChildRun(_)
796        | WorkflowError::Journal(_)
797        | WorkflowError::Checkpoint(_) => (RunErrorKind::Invocation, RetrySafety::Unknown),
798    };
799    RunError {
800        kind,
801        message: error.to_string(),
802        retry_safety,
803        metadata: BTreeMap::new(),
804    }
805}
806
807fn validate_exact_usage(expected: Usage, actual: Usage) -> Result<(), WorkflowError> {
808    if expected != actual {
809        return Err(WorkflowError::CheckpointUsageMismatch);
810    }
811    Ok(())
812}
813
814fn validate_usage_floor(floor: Usage, actual: Usage) -> Result<(), WorkflowError> {
815    let covers = actual.tokens >= floor.tokens
816        && actual.cost_microusd >= floor.cost_microusd
817        && actual.duration_micros >= floor.duration_micros
818        && actual.turns >= floor.turns
819        && actual.tool_calls >= floor.tool_calls
820        && actual.delegations >= floor.delegations;
821    if !covers {
822        return Err(WorkflowError::CheckpointUsageMismatch);
823    }
824    Ok(())
825}