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
18pub type WorkflowFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
20
21impl Workflow {
22 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 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 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}