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#[cfg(not(target_arch = "wasm32"))]
22pub type WorkflowFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
23
24#[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 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 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 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}