Skip to main content

oxide_batch/
flow.rs

1//! Durable execution of bounded M3 flows and the M4 tasklet-only local
2//! parallel-split and partition slices.
3
4use std::collections::{BTreeMap, BTreeSet};
5use std::error::Error;
6use std::fmt;
7use std::num::NonZeroU64;
8use std::panic::{AssertUnwindSafe, catch_unwind};
9use std::sync::Arc;
10use std::sync::atomic::{AtomicBool, Ordering};
11use std::time::SystemTime;
12
13use futures_util::{FutureExt, StreamExt};
14use serde_json::Value;
15use sha2::{Digest, Sha256};
16
17use crate::runtime::{invoke_after_step, invoke_before_step, invoke_tasklet};
18use crate::{
19    BatchStatus, BoxFuture, Clock, CompiledExecutionPlan, ExecutionAttempt, ExecutionCorrelation,
20    ExecutionCounts, ExitCode, ExitStatus, FailureCategory, FailureSummary, FlowDecision,
21    FlowDecisionRequest, FlowDecisionSequence, FlowNode, FlowSelectionError, FlowStepState,
22    FlowTarget, FlowTransitionKind, IdGenerator, JobExecution, JobExecutionId, JobInstance,
23    JobInstanceId, JobInstanceKey, JobName, JobParameters, JobRepository, LifecycleTransition,
24    ListenerContext, ListenerFailure, ListenerFailureKind, ListenerPhase, NodeId, PartitionKey,
25    PartitionPlanEntry, RepositoryCapability, RepositoryError, StartLimit, StepExecution,
26    StepExecutionId, StepName, StepPartition, StopPollInterval, StopTiming, StopToken,
27    TaskletContext, TaskletExecutionOutcome, TaskletFailure, TaskletOutcome, TaskletStep,
28    TerminalKind,
29};
30
31pub(crate) fn decision_matches_manifest(manifest: &Value, request: &FlowDecisionRequest) -> bool {
32    let Some(document) = manifest.as_object() else {
33        return false;
34    };
35    let format = document.get("format").and_then(Value::as_u64);
36    if !matches!(
37        format,
38        Some(value)
39            if value == u64::from(oxide_batch_core::MANIFEST_FORMAT_FLOW)
40                || value == u64::from(oxide_batch_core::MANIFEST_FORMAT_LOCAL_SCALE)
41    ) {
42        return false;
43    }
44    let source_is_declared = document
45        .get("nodes")
46        .and_then(Value::as_array)
47        .is_some_and(|nodes| {
48            nodes.iter().any(|node| {
49                let kind = node.get("kind").and_then(Value::as_str);
50                let kind_matches = match request.kind() {
51                    FlowTransitionKind::Decider => kind == Some("decision"),
52                    FlowTransitionKind::SplitAggregate => kind == Some("join"),
53                    FlowTransitionKind::StepExit | FlowTransitionKind::CompletedStepReuse => {
54                        matches!(kind, Some("step" | "partitioned_step"))
55                    }
56                    // Absorbs any transition kind added later: an unrecognized
57                    // kind matches no declared node, so the decision is
58                    // rejected rather than accepted against a guessed node.
59                    _ => false,
60                };
61                node.get("id").and_then(Value::as_str) == Some(request.source_node_id().as_str())
62                    && kind_matches
63            })
64        });
65    if !source_is_declared {
66        return false;
67    }
68    document
69        .get("transitions")
70        .and_then(Value::as_array)
71        .and_then(|transitions| {
72            transitions.iter().find(|transition| {
73                transition.get("source").and_then(Value::as_str)
74                    == Some(request.source_node_id().as_str())
75                    && transition
76                        .get("pattern")
77                        .and_then(Value::as_str)
78                        .and_then(|pattern| crate::ExitPattern::new(pattern).ok())
79                        .is_some_and(|pattern| pattern.matches(request.observed_outcome()))
80            })
81        })
82        .and_then(|transition| transition.get("target"))
83        .is_some_and(|target| manifest_target_matches(target, request.target()))
84}
85
86fn manifest_target_matches(value: &Value, target: &FlowTarget) -> bool {
87    match target {
88        FlowTarget::Node(node) => value.get("node").and_then(Value::as_str) == Some(node.as_str()),
89        FlowTarget::Terminal(terminal) => {
90            value.get("terminal").and_then(Value::as_str) == Some(terminal.as_str())
91        }
92    }
93}
94
95/// Durable preceding-step data supplied to a decider.
96#[derive(Clone, Debug, Eq, PartialEq)]
97pub struct DecisionStepInput {
98    node_id: NodeId,
99    execution_id: StepExecutionId,
100    status: BatchStatus,
101    exit_status: ExitStatus,
102    counts: ExecutionCounts,
103    context: Option<crate::ExecutionContext>,
104}
105
106impl DecisionStepInput {
107    fn from_state(state: &FlowStepState) -> Self {
108        Self {
109            node_id: state.node_id().clone(),
110            execution_id: state.execution().id(),
111            status: state.execution().metadata().status(),
112            exit_status: state.execution().metadata().exit_status().clone(),
113            counts: state.execution().metadata().counts(),
114            context: state.context().cloned(),
115        }
116    }
117
118    /// Borrows the preceding stable logical ID.
119    #[must_use]
120    pub const fn node_id(&self) -> &NodeId {
121        &self.node_id
122    }
123
124    /// Returns the durable step-attempt identifier.
125    #[must_use]
126    pub const fn execution_id(&self) -> StepExecutionId {
127        self.execution_id
128    }
129
130    /// Returns the durable lifecycle status.
131    #[must_use]
132    pub const fn status(&self) -> BatchStatus {
133        self.status
134    }
135
136    /// Borrows the durable exit outcome.
137    #[must_use]
138    pub const fn exit_status(&self) -> &ExitStatus {
139        &self.exit_status
140    }
141
142    /// Returns the durable counters.
143    #[must_use]
144    pub const fn counts(&self) -> ExecutionCounts {
145        self.counts
146    }
147
148    /// Borrows committed step context when available.
149    #[must_use]
150    pub const fn context(&self) -> Option<&crate::ExecutionContext> {
151        self.context.as_ref()
152    }
153}
154
155/// Immutable, sensitivity-aware input supplied to one decider invocation.
156pub struct DecisionInput<'a> {
157    job_instance_id: JobInstanceId,
158    job_execution_id: JobExecutionId,
159    attempt: ExecutionAttempt,
160    plan_fingerprint: [u8; 32],
161    node_id: &'a NodeId,
162    parameters: &'a JobParameters,
163    preceding_step: Option<DecisionStepInput>,
164}
165
166impl fmt::Debug for DecisionInput<'_> {
167    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
168        formatter
169            .debug_struct("DecisionInput")
170            .field("job_instance_id", &self.job_instance_id)
171            .field("job_execution_id", &self.job_execution_id)
172            .field("attempt", &self.attempt)
173            .field("node_id", &self.node_id)
174            .field("parameters", &"<redacted>")
175            .field("preceding_step", &self.preceding_step)
176            .finish_non_exhaustive()
177    }
178}
179
180impl DecisionInput<'_> {
181    /// Returns the logical job instance.
182    #[must_use]
183    pub const fn job_instance_id(&self) -> JobInstanceId {
184        self.job_instance_id
185    }
186
187    /// Returns the current execution attempt.
188    #[must_use]
189    pub const fn job_execution_id(&self) -> JobExecutionId {
190        self.job_execution_id
191    }
192
193    /// Returns the positive attempt number.
194    #[must_use]
195    pub const fn attempt(&self) -> ExecutionAttempt {
196        self.attempt
197    }
198
199    /// Returns the exact plan fingerprint.
200    #[must_use]
201    pub const fn plan_fingerprint(&self) -> &[u8; 32] {
202        &self.plan_fingerprint
203    }
204
205    /// Borrows the decision-node logical ID.
206    #[must_use]
207    pub const fn node_id(&self) -> &NodeId {
208        self.node_id
209    }
210
211    /// Borrows typed parameters; their debug representation remains redacted.
212    #[must_use]
213    pub const fn parameters(&self) -> &JobParameters {
214        self.parameters
215    }
216
217    /// Borrows the preceding durable step observation, when one exists.
218    #[must_use]
219    pub const fn preceding_step(&self) -> Option<&DecisionStepInput> {
220        self.preceding_step.as_ref()
221    }
222}
223
224/// A value-redacted decider failure.
225#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
226pub struct DeciderError;
227
228impl DeciderError {
229    /// Constructs a redacted failure.
230    #[must_use]
231    pub const fn new() -> Self {
232        Self
233    }
234}
235
236impl fmt::Display for DeciderError {
237    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
238        formatter.write_str("flow decider failed")
239    }
240}
241
242impl Error for DeciderError {}
243
244/// A deterministic, side-effect-free M3 flow decider.
245pub trait JobExecutionDecider: Send + Sync {
246    /// Selects one bounded flow-facing exit outcome from durable input.
247    fn decide<'a>(
248        &'a self,
249        input: DecisionInput<'a>,
250    ) -> BoxFuture<'a, Result<ExitStatus, DeciderError>>;
251}
252
253async fn invoke_decider(
254    decider: &dyn JobExecutionDecider,
255    input: DecisionInput<'_>,
256) -> Result<ExitStatus, FlowFailure> {
257    let future = catch_unwind(AssertUnwindSafe(|| decider.decide(input)))
258        .map_err(|_| FlowFailure::DeciderPanic)?;
259    match AssertUnwindSafe(future).catch_unwind().await {
260        Ok(Ok(outcome)) => Ok(outcome),
261        Ok(Err(_)) => Err(FlowFailure::DeciderError),
262        Err(_) => Err(FlowFailure::DeciderPanic),
263    }
264}
265
266/// A launch-scoped factory for one tasklet inside a bounded split branch.
267///
268/// A factory is invoked once per branch step and launch attempt before durable
269/// work begins. This keeps component instances branch-owned instead of
270/// silently sharing one tasklet across concurrent children.
271#[derive(Clone)]
272pub struct TaskletStepFactory {
273    step_name: StepName,
274    create: Arc<dyn Fn() -> TaskletStep + Send + Sync>,
275}
276
277/// Deterministic inputs supplied once when a partition plan does not yet exist.
278#[derive(Clone, Copy, Debug)]
279pub struct PartitionPlanRequest<'a> {
280    plan_fingerprint: &'a [u8; 32],
281    job_instance_id: JobInstanceId,
282    node_id: &'a NodeId,
283    partition_count: crate::PartitionCount,
284}
285
286impl<'a> PartitionPlanRequest<'a> {
287    /// Borrows the exact restart-relevant plan fingerprint.
288    #[must_use]
289    pub const fn plan_fingerprint(self) -> &'a [u8; 32] {
290        self.plan_fingerprint
291    }
292
293    /// Returns the stable job-instance identity.
294    #[must_use]
295    pub const fn job_instance_id(self) -> JobInstanceId {
296        self.job_instance_id
297    }
298
299    /// Borrows the partition-manager logical ID.
300    #[must_use]
301    pub const fn node_id(self) -> &'a NodeId {
302        self.node_id
303    }
304
305    /// Returns the exact declared partition count.
306    #[must_use]
307    pub const fn partition_count(self) -> crate::PartitionCount {
308        self.partition_count
309    }
310}
311
312/// Redacted rejection from an application partitioner.
313#[derive(Clone, Copy, Debug, Eq, PartialEq)]
314#[non_exhaustive]
315pub enum PartitionFactoryError {
316    /// The partitioner could not produce a valid complete plan.
317    Rejected,
318}
319
320impl fmt::Display for PartitionFactoryError {
321    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
322        formatter.write_str("partition factory rejected the requested plan")
323    }
324}
325
326impl Error for PartitionFactoryError {}
327
328/// Launch-scoped deterministic constructor for one complete partition plan.
329type PartitionPlanConstructor = dyn for<'a> Fn(PartitionPlanRequest<'a>) -> Result<Vec<PartitionPlanEntry>, PartitionFactoryError>
330    + Send
331    + Sync;
332
333/// Launch-scoped deterministic constructor for one complete partition plan.
334#[derive(Clone)]
335pub struct PartitionPlanFactory {
336    create: Arc<PartitionPlanConstructor>,
337}
338
339impl PartitionPlanFactory {
340    /// Wraps one deterministic, bounded plan constructor.
341    #[must_use]
342    pub fn new(
343        create: impl for<'a> Fn(
344            PartitionPlanRequest<'a>,
345        ) -> Result<Vec<PartitionPlanEntry>, PartitionFactoryError>
346        + Send
347        + Sync
348        + 'static,
349    ) -> Self {
350        Self {
351            create: Arc::new(create),
352        }
353    }
354
355    fn create(
356        &self,
357        request: PartitionPlanRequest<'_>,
358    ) -> Result<Vec<PartitionPlanEntry>, PartitionFactoryError> {
359        (self.create)(request)
360    }
361}
362
363impl fmt::Debug for PartitionPlanFactory {
364    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
365        formatter.write_str("PartitionPlanFactory(<redacted>)")
366    }
367}
368
369/// Owned, bounded input supplied to one partition worker factory.
370#[derive(Clone, Eq, PartialEq)]
371pub struct PartitionWorkerInput {
372    key: PartitionKey,
373    ordinal: u32,
374    context: crate::ExecutionContext,
375}
376
377impl PartitionWorkerInput {
378    fn from_partition(partition: &StepPartition) -> Self {
379        Self {
380            key: partition.key().clone(),
381            ordinal: partition.ordinal(),
382            context: partition.context().clone(),
383        }
384    }
385
386    /// Borrows the byte-exact durable partition key.
387    #[must_use]
388    pub const fn key(&self) -> &PartitionKey {
389        &self.key
390    }
391
392    /// Returns the stable one-based partition ordinal.
393    #[must_use]
394    pub const fn ordinal(&self) -> u32 {
395        self.ordinal
396    }
397
398    /// Borrows the bounded durable partition context.
399    #[must_use]
400    pub const fn context(&self) -> &crate::ExecutionContext {
401        &self.context
402    }
403}
404
405impl fmt::Debug for PartitionWorkerInput {
406    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
407        formatter
408            .debug_struct("PartitionWorkerInput")
409            .field("key", &self.key)
410            .field("ordinal", &self.ordinal)
411            .field("context", &"<redacted>")
412            .finish()
413    }
414}
415
416/// Per-child constructor that creates an independently owned tasklet step.
417#[derive(Clone)]
418pub struct PartitionTaskletFactory {
419    step_name: StepName,
420    create: Arc<dyn Fn(PartitionWorkerInput) -> TaskletStep + Send + Sync>,
421}
422
423impl PartitionTaskletFactory {
424    /// Declares the worker name and its per-partition constructor.
425    #[must_use]
426    pub fn new(
427        step_name: StepName,
428        create: impl Fn(PartitionWorkerInput) -> TaskletStep + Send + Sync + 'static,
429    ) -> Self {
430        Self {
431            step_name,
432            create: Arc::new(create),
433        }
434    }
435
436    /// Borrows the worker step name promised by the factory.
437    #[must_use]
438    pub const fn step_name(&self) -> &StepName {
439        &self.step_name
440    }
441
442    fn create(&self, input: PartitionWorkerInput) -> TaskletStep {
443        (self.create)(input)
444    }
445}
446
447impl fmt::Debug for PartitionTaskletFactory {
448    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
449        formatter
450            .debug_struct("PartitionTaskletFactory")
451            .field("step_name", &self.step_name)
452            .finish_non_exhaustive()
453    }
454}
455
456#[derive(Clone, Debug)]
457struct PartitionedTaskletBinding {
458    partitioner: PartitionPlanFactory,
459    worker: PartitionTaskletFactory,
460}
461
462impl TaskletStepFactory {
463    /// Declares the step name and its launch-scoped constructor.
464    #[must_use]
465    pub fn new(
466        step_name: StepName,
467        create: impl Fn() -> TaskletStep + Send + Sync + 'static,
468    ) -> Self {
469        Self {
470            step_name,
471            create: Arc::new(create),
472        }
473    }
474
475    /// Borrows the step name promised by this factory.
476    #[must_use]
477    pub const fn step_name(&self) -> &StepName {
478        &self.step_name
479    }
480
481    fn create(&self) -> TaskletStep {
482        (self.create)()
483    }
484}
485
486impl fmt::Debug for TaskletStepFactory {
487    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
488        formatter
489            .debug_struct("TaskletStepFactory")
490            .field("step_name", &self.step_name)
491            .finish_non_exhaustive()
492    }
493}
494
495/// An executable binding for one compiled format-2 or bounded split flow.
496pub struct FlowJob {
497    name: JobName,
498    plan: CompiledExecutionPlan,
499    steps: BTreeMap<NodeId, TaskletStep>,
500    deciders: BTreeMap<NodeId, Arc<dyn JobExecutionDecider>>,
501    split_tasklets: BTreeMap<NodeId, TaskletStepFactory>,
502    partitioned_tasklets: BTreeMap<NodeId, PartitionedTaskletBinding>,
503}
504
505impl fmt::Debug for FlowJob {
506    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
507        formatter
508            .debug_struct("FlowJob")
509            .field("name", &self.name)
510            .field("definition", self.plan.definition_identity())
511            .field("step_count", &self.steps.len())
512            .field("decider_count", &self.deciders.len())
513            .field("split_tasklet_count", &self.split_tasklets.len())
514            .field(
515                "partitioned_tasklet_count",
516                &self.partitioned_tasklets.len(),
517            )
518            .finish()
519    }
520}
521
522impl FlowJob {
523    /// Starts binding executable components to a compiled format-2 or format-3 plan.
524    ///
525    /// # Errors
526    ///
527    /// Rejects a compatibility plan or a plan identified under another job.
528    pub fn new(name: JobName, plan: CompiledExecutionPlan) -> Result<Self, FlowJobError> {
529        if !matches!(
530            plan.manifest_format(),
531            oxide_batch_core::MANIFEST_FORMAT_FLOW | oxide_batch_core::MANIFEST_FORMAT_LOCAL_SCALE
532        ) {
533            return Err(FlowJobError::UnsupportedManifest {
534                format: plan.manifest_format(),
535            });
536        }
537        if plan.definition_identity().job_name() != Some(&name) {
538            return Err(FlowJobError::JobNameMismatch);
539        }
540        Ok(Self {
541            name,
542            plan,
543            steps: BTreeMap::new(),
544            deciders: BTreeMap::new(),
545            split_tasklets: BTreeMap::new(),
546            partitioned_tasklets: BTreeMap::new(),
547        })
548    }
549
550    /// Binds one tasklet step to a compiled step node.
551    ///
552    /// # Errors
553    ///
554    /// Rejects an unknown node, a decision node, a name mismatch, or a second
555    /// binding for the same logical ID.
556    pub fn with_tasklet_step(
557        mut self,
558        node_id: NodeId,
559        step: TaskletStep,
560    ) -> Result<Self, FlowJobError> {
561        self.bind_tasklet_step(node_id, step)?;
562        Ok(self)
563    }
564
565    pub(crate) fn bind_tasklet_step(
566        &mut self,
567        node_id: NodeId,
568        step: TaskletStep,
569    ) -> Result<(), FlowJobError> {
570        let Some(FlowNode::Step(compiled)) = self.plan.node(&node_id) else {
571            return Err(FlowJobError::WrongNodeKind { node: node_id });
572        };
573        if !matches!(compiled.components(), crate::StepComponents::Tasklet(_)) {
574            return Err(FlowJobError::ComponentMismatch { node: node_id });
575        }
576        if compiled.step_name() != step.name() {
577            return Err(FlowJobError::StepNameMismatch { node: node_id });
578        }
579        if self.steps.insert(node_id.clone(), step).is_some() {
580            return Err(FlowJobError::DuplicateBinding { node: node_id });
581        }
582        Ok(())
583    }
584
585    pub(crate) fn bind_chunk_tasklet(
586        &mut self,
587        node_id: NodeId,
588        step: TaskletStep,
589    ) -> Result<(), FlowJobError> {
590        let Some(FlowNode::Step(compiled)) = self.plan.node(&node_id) else {
591            return Err(FlowJobError::WrongNodeKind { node: node_id });
592        };
593        if !matches!(compiled.components(), crate::StepComponents::Chunk { .. }) {
594            return Err(FlowJobError::ComponentMismatch { node: node_id });
595        }
596        if compiled.step_name() != step.name() {
597            return Err(FlowJobError::StepNameMismatch { node: node_id });
598        }
599        if self.steps.insert(node_id.clone(), step).is_some() {
600            return Err(FlowJobError::DuplicateBinding { node: node_id });
601        }
602        Ok(())
603    }
604
605    /// Binds one deterministic decider to a compiled decision node.
606    ///
607    /// # Errors
608    ///
609    /// Rejects an unknown/step node or a duplicate binding.
610    pub fn with_decider(
611        mut self,
612        node_id: NodeId,
613        decider: Arc<dyn JobExecutionDecider>,
614    ) -> Result<Self, FlowJobError> {
615        if !matches!(self.plan.node(&node_id), Some(FlowNode::Decision(_))) {
616            return Err(FlowJobError::WrongNodeKind { node: node_id });
617        }
618        if self.deciders.insert(node_id.clone(), decider).is_some() {
619            return Err(FlowJobError::DuplicateBinding { node: node_id });
620        }
621        Ok(self)
622    }
623
624    /// Binds a launch-scoped tasklet factory to one embedded split step.
625    ///
626    /// # Errors
627    ///
628    /// Rejects an unknown/non-tasklet branch step, a declared-name mismatch,
629    /// or a second binding for the same logical ID.
630    pub fn with_split_tasklet_factory(
631        mut self,
632        node_id: NodeId,
633        factory: TaskletStepFactory,
634    ) -> Result<Self, FlowJobError> {
635        let Some(compiled) = split_step(&self.plan, &node_id) else {
636            return Err(FlowJobError::WrongNodeKind { node: node_id });
637        };
638        if !matches!(compiled.components(), crate::StepComponents::Tasklet(_)) {
639            return Err(FlowJobError::ComponentMismatch { node: node_id });
640        }
641        if compiled.step_name() != factory.step_name() {
642            return Err(FlowJobError::StepNameMismatch { node: node_id });
643        }
644        if self
645            .split_tasklets
646            .insert(node_id.clone(), factory)
647            .is_some()
648        {
649            return Err(FlowJobError::DuplicateBinding { node: node_id });
650        }
651        Ok(self)
652    }
653
654    /// Binds a deterministic plan factory and independent tasklet factory to
655    /// one compiled local partition manager.
656    ///
657    /// # Errors
658    ///
659    /// Rejects an unknown/non-tasklet partition node, worker-name mismatch,
660    /// or duplicate binding.
661    pub fn with_partitioned_tasklet(
662        mut self,
663        node_id: NodeId,
664        partitioner: PartitionPlanFactory,
665        worker: PartitionTaskletFactory,
666    ) -> Result<Self, FlowJobError> {
667        let Some(FlowNode::PartitionedStep(compiled)) = self.plan.node(&node_id) else {
668            return Err(FlowJobError::WrongNodeKind { node: node_id });
669        };
670        if !matches!(
671            compiled.worker().components(),
672            crate::StepComponents::Tasklet(_)
673        ) {
674            return Err(FlowJobError::ComponentMismatch { node: node_id });
675        }
676        if compiled.worker().step_name() != worker.step_name() {
677            return Err(FlowJobError::StepNameMismatch { node: node_id });
678        }
679        if self
680            .partitioned_tasklets
681            .insert(
682                node_id.clone(),
683                PartitionedTaskletBinding {
684                    partitioner,
685                    worker,
686                },
687            )
688            .is_some()
689        {
690            return Err(FlowJobError::DuplicateBinding { node: node_id });
691        }
692        Ok(self)
693    }
694
695    /// Validates that every compiled node has exactly one executable binding.
696    ///
697    /// # Errors
698    ///
699    /// Returns the first missing node in canonical identifier order.
700    pub fn validate(&self) -> Result<(), FlowJobError> {
701        for (id, node) in self.plan.nodes() {
702            if let FlowNode::Split(split) = node {
703                for step in split.branches().iter().flat_map(crate::SplitBranch::steps) {
704                    if !self.split_tasklets.contains_key(step.id()) {
705                        return Err(FlowJobError::MissingBinding {
706                            node: step.id().clone(),
707                        });
708                    }
709                }
710                continue;
711            }
712            let present = match node {
713                FlowNode::Step(_) => self.steps.contains_key(id),
714                FlowNode::Decision(_) => self.deciders.contains_key(id),
715                FlowNode::Split(_) | FlowNode::Join(_) => true,
716                FlowNode::PartitionedStep(_) => self.partitioned_tasklets.contains_key(id),
717                // A `FlowNode` variant added after this build accepts no
718                // binding this builder knows how to take, so it is reported
719                // unbound rather than passed as satisfied.
720                _ => false,
721            };
722            if !present {
723                return Err(FlowJobError::MissingBinding { node: id.clone() });
724            }
725        }
726        Ok(())
727    }
728
729    fn materialize_split_tasklets(&self) -> Result<BTreeMap<NodeId, TaskletStep>, FlowJobError> {
730        let mut tasklets = BTreeMap::new();
731        for (node, factory) in &self.split_tasklets {
732            let step = catch_unwind(AssertUnwindSafe(|| factory.create()))
733                .map_err(|_| FlowJobError::FactoryPanic { node: node.clone() })?;
734            if step.name() != factory.step_name() {
735                return Err(FlowJobError::StepNameMismatch { node: node.clone() });
736            }
737            tasklets.insert(node.clone(), step);
738        }
739        Ok(tasklets)
740    }
741
742    /// Borrows the compiled plan.
743    #[must_use]
744    pub const fn compiled_plan(&self) -> &CompiledExecutionPlan {
745        &self.plan
746    }
747}
748
749/// An executable component assembly that does not match its compiled plan.
750#[derive(Clone, Debug, Eq, PartialEq)]
751#[non_exhaustive]
752pub enum FlowJobError {
753    /// General flows require canonical manifest format 2.
754    UnsupportedManifest {
755        /// Observed manifest format.
756        format: u16,
757    },
758    /// The facade job name and manifest identity differ.
759    JobNameMismatch,
760    /// The node is absent or has the other executable kind.
761    WrongNodeKind {
762        /// Logical node that could not accept the binding.
763        node: NodeId,
764    },
765    /// The durable step name differs from the compiled declaration.
766    StepNameMismatch {
767        /// Logical node whose durable step name differed.
768        node: NodeId,
769    },
770    /// A node was bound more than once.
771    DuplicateBinding {
772        /// Logical node bound more than once.
773        node: NodeId,
774    },
775    /// A compiled node has no executable component.
776    MissingBinding {
777        /// Logical node without executable code.
778        node: NodeId,
779    },
780    /// The bound executable does not match the compiled step declaration.
781    ComponentMismatch {
782        /// Mismatched logical node.
783        node: NodeId,
784    },
785    /// A branch component factory panicked before durable launch.
786    FactoryPanic {
787        /// Logical branch step whose factory panicked.
788        node: NodeId,
789    },
790}
791
792impl fmt::Display for FlowJobError {
793    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
794        match self {
795            Self::UnsupportedManifest { format } => {
796                write!(
797                    formatter,
798                    "flow execution requires manifest format 2 or bounded format 3, found {format}"
799                )
800            }
801            Self::JobNameMismatch => formatter.write_str("flow job name does not match its plan"),
802            Self::WrongNodeKind { node } => {
803                write!(
804                    formatter,
805                    "node {} has no matching executable kind",
806                    node.as_str()
807                )
808            }
809            Self::StepNameMismatch { node } => {
810                write!(
811                    formatter,
812                    "node {} was bound to a different step name",
813                    node.as_str()
814                )
815            }
816            Self::DuplicateBinding { node } => {
817                write!(
818                    formatter,
819                    "node {} has more than one executable binding",
820                    node.as_str()
821                )
822            }
823            Self::MissingBinding { node } => {
824                write!(
825                    formatter,
826                    "node {} has no executable binding",
827                    node.as_str()
828                )
829            }
830            Self::ComponentMismatch { node } => write!(
831                formatter,
832                "node {} executable components do not match the compiled declaration",
833                node.as_str()
834            ),
835            Self::FactoryPanic { node } => write!(
836                formatter,
837                "node {} component factory panicked",
838                node.as_str()
839            ),
840        }
841    }
842}
843
844impl Error for FlowJobError {}
845
846fn split_step<'a>(
847    plan: &'a CompiledExecutionPlan,
848    node_id: &NodeId,
849) -> Option<&'a crate::StepNode> {
850    plan.nodes().find_map(|(_, node)| match node {
851        FlowNode::Split(split) => split
852            .branches()
853            .iter()
854            .flat_map(crate::SplitBranch::steps)
855            .find(|step| step.id() == node_id),
856        _ => None,
857    })
858}
859
860/// Why a durable flow attempt ended.
861#[derive(Clone, Debug, Eq, PartialEq)]
862#[non_exhaustive]
863pub enum FlowExecutionOutcome {
864    /// A `Complete` terminal was reached.
865    Completed,
866    /// A `Stop` terminal or cooperative stop was reached.
867    Stopped,
868    /// The durable outcome is ambiguous and no transition was selected.
869    Unknown,
870    /// A step, decider, listener, mapping, or start control failed closed.
871    Failed(FlowFailure),
872}
873
874/// Stable, value-redacted flow failure classification.
875#[derive(Clone, Debug, Eq, PartialEq)]
876#[non_exhaustive]
877pub enum FlowFailure {
878    /// A tasklet returned an error or panicked.
879    Tasklet(TaskletFailure),
880    /// A step listener returned an error or panicked.
881    Listener(TaskletFailure),
882    /// A decider returned an error.
883    DeciderError,
884    /// A decider panicked at the framework boundary.
885    DeciderPanic,
886    /// The deterministic partitioner rejected its bounded request.
887    PartitionerError,
888    /// The deterministic partitioner panicked at the framework boundary.
889    PartitionerPanic,
890    /// A per-child component factory panicked before tasklet invocation.
891    PartitionFactoryPanic,
892    /// A produced exit outcome had no mapping.
893    UnmappedExitOutcome {
894        /// Source logical node.
895        node: NodeId,
896        /// Unmapped bounded outcome.
897        code: ExitCode,
898    },
899    /// The instance-wide start limit was exhausted.
900    StartLimitExceeded {
901        /// Logical step whose start was rejected.
902        node: NodeId,
903        /// Configured instance-wide maximum.
904        limit: StartLimit,
905    },
906    /// The compiled flow deliberately selected a `Fail` terminal.
907    FailTerminal,
908}
909
910/// Final durable observations from one flow attempt.
911#[derive(Clone, Debug, Eq, PartialEq)]
912pub struct FlowLaunchReport {
913    instance: JobInstance,
914    job_execution: JobExecution,
915    step_executions: Vec<StepExecution>,
916    decisions: Vec<FlowDecision>,
917    outcome: FlowExecutionOutcome,
918    listener_failures: Vec<ListenerFailure>,
919}
920
921/// Stable, post-commit observations for the bounded flow runtime.
922#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
923#[non_exhaustive]
924pub enum FlowEventKind {
925    /// One step's terminal lifecycle result committed before transition selection.
926    StepResultCommitted,
927    /// A selected result and target committed before the target starts.
928    DecisionCommitted,
929    /// A completed historical step supplied the source result on restart.
930    CompletedStepReused,
931    /// The instance-wide logical-step start limit rejected another start.
932    StartLimitExceeded,
933}
934
935impl FlowEventKind {
936    /// Maps this flow observation into telemetry schema version 1.
937    #[must_use]
938    pub const fn telemetry_kind(self) -> crate::TelemetryEventKind {
939        match self {
940            Self::StepResultCommitted => crate::TelemetryEventKind::FlowStepResultCommitted,
941            Self::DecisionCommitted => crate::TelemetryEventKind::FlowDecisionCommitted,
942            Self::CompletedStepReused => crate::TelemetryEventKind::FlowCompletedStepReused,
943            Self::StartLimitExceeded => crate::TelemetryEventKind::StepStartLimitExceeded,
944        }
945    }
946
947    /// Returns the stable dotted event name.
948    #[must_use]
949    pub const fn as_str(self) -> &'static str {
950        match self {
951            Self::StepResultCommitted => "flow.step_result_committed",
952            Self::DecisionCommitted => "flow.decision_committed",
953            Self::CompletedStepReused => "flow.completed_step_reused",
954            Self::StartLimitExceeded => "step.start_limit_exceeded",
955        }
956    }
957}
958
959impl fmt::Display for FlowEventKind {
960    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
961        formatter.write_str(self.as_str())
962    }
963}
964
965/// A value-redacted flow observation emitted only after its named decision.
966#[derive(Clone, Debug, Eq, PartialEq)]
967pub struct FlowEvent {
968    kind: FlowEventKind,
969    job_name: JobName,
970    job_instance_id: JobInstanceId,
971    job_execution_id: JobExecutionId,
972    job_attempt: ExecutionAttempt,
973    source_node_id: NodeId,
974    source_step_execution_id: Option<StepExecutionId>,
975    target: Option<FlowTarget>,
976    occurred_at: SystemTime,
977}
978
979impl FlowEvent {
980    /// Returns the telemetry schema version carried by this event mapping.
981    #[must_use]
982    pub const fn schema_version(&self) -> u16 {
983        crate::TELEMETRY_SCHEMA_VERSION
984    }
985    #[allow(clippy::too_many_arguments)]
986    const fn new(
987        kind: FlowEventKind,
988        job_name: JobName,
989        job_instance_id: JobInstanceId,
990        job_execution_id: JobExecutionId,
991        job_attempt: ExecutionAttempt,
992        source_node_id: NodeId,
993        source_step_execution_id: Option<StepExecutionId>,
994        target: Option<FlowTarget>,
995        occurred_at: SystemTime,
996    ) -> Self {
997        Self {
998            kind,
999            job_name,
1000            job_instance_id,
1001            job_execution_id,
1002            job_attempt,
1003            source_node_id,
1004            source_step_execution_id,
1005            target,
1006            occurred_at,
1007        }
1008    }
1009
1010    /// Returns the stable event category.
1011    #[must_use]
1012    pub const fn kind(&self) -> FlowEventKind {
1013        self.kind
1014    }
1015
1016    /// Borrows the bounded job definition name.
1017    #[must_use]
1018    pub const fn job_name(&self) -> &JobName {
1019        &self.job_name
1020    }
1021
1022    /// Returns the logical job instance.
1023    #[must_use]
1024    pub const fn job_instance_id(&self) -> JobInstanceId {
1025        self.job_instance_id
1026    }
1027
1028    /// Returns the attempt that emitted this observation.
1029    #[must_use]
1030    pub const fn job_execution_id(&self) -> JobExecutionId {
1031        self.job_execution_id
1032    }
1033
1034    /// Returns the instance-scoped job attempt ordinal.
1035    #[must_use]
1036    pub const fn job_attempt(&self) -> ExecutionAttempt {
1037        self.job_attempt
1038    }
1039
1040    /// Borrows the bounded logical source node.
1041    #[must_use]
1042    pub const fn source_node_id(&self) -> &NodeId {
1043        &self.source_node_id
1044    }
1045
1046    /// Returns the durable source step when this event follows step work.
1047    #[must_use]
1048    pub const fn source_step_execution_id(&self) -> Option<StepExecutionId> {
1049        self.source_step_execution_id
1050    }
1051
1052    /// Borrows the selected target for a committed decision.
1053    #[must_use]
1054    pub const fn target(&self) -> Option<&FlowTarget> {
1055        self.target.as_ref()
1056    }
1057
1058    /// Returns the injected facade-clock observation instant.
1059    #[must_use]
1060    pub const fn occurred_at(&self) -> SystemTime {
1061        self.occurred_at
1062    }
1063}
1064
1065/// A non-authoritative observer of committed flow decisions.
1066pub trait FlowEventSink: Send + Sync {
1067    /// Observes one bounded event. Failure or panic cannot change traversal.
1068    fn emit(&self, event: &FlowEvent);
1069}
1070
1071impl FlowLaunchReport {
1072    /// Borrows the selected logical instance.
1073    #[must_use]
1074    pub const fn instance(&self) -> &JobInstance {
1075        &self.instance
1076    }
1077    /// Borrows the final job-attempt snapshot.
1078    #[must_use]
1079    pub const fn job_execution(&self) -> &JobExecution {
1080        &self.job_execution
1081    }
1082    /// Borrows step attempts created by this execution in traversal order.
1083    #[must_use]
1084    pub fn step_executions(&self) -> &[StepExecution] {
1085        &self.step_executions
1086    }
1087    /// Borrows committed selected transitions in sequence order.
1088    #[must_use]
1089    pub fn decisions(&self) -> &[FlowDecision] {
1090        &self.decisions
1091    }
1092    /// Borrows the classified terminal outcome.
1093    #[must_use]
1094    pub const fn outcome(&self) -> &FlowExecutionOutcome {
1095        &self.outcome
1096    }
1097    /// Borrows redacted step-listener failures.
1098    #[must_use]
1099    pub fn listener_failures(&self) -> &[ListenerFailure] {
1100        &self.listener_failures
1101    }
1102}
1103
1104/// A flow operation that could not produce a trustworthy final report.
1105#[derive(Clone, Debug, Eq, PartialEq)]
1106#[non_exhaustive]
1107pub enum FlowRuntimeError {
1108    /// The executable assembly is incomplete or mismatched.
1109    Job(FlowJobError),
1110    /// A repository transaction failed.
1111    Repository(RepositoryError),
1112    /// The decision sequence exceeded `u64`.
1113    DecisionSequenceExhausted,
1114    /// A facade count could not be represented safely.
1115    CountExhausted,
1116    /// Process shutdown stopped intake before this launch was accepted.
1117    ShuttingDown,
1118    /// The connected repository does not declare a capability the plan needs.
1119    UndeclaredCapability {
1120        /// The requirement the plan carries.
1121        capability: RepositoryCapability,
1122        /// Shape version of the descriptor that was negotiated.
1123        descriptor_version: u32,
1124    },
1125    /// The connected repository cannot supply the manifest connection budget.
1126    InsufficientPoolCapacity {
1127        /// Required finite connection count.
1128        required: u32,
1129        /// Repository-reported finite capacity.
1130        configured: u32,
1131    },
1132    /// A durable ambiguous child blocks assignment and parent aggregation.
1133    UnresolvedPartitionOutcome {
1134        /// Parent partitioned step execution left non-terminal.
1135        step_execution_id: StepExecutionId,
1136    },
1137    /// The application partitioner failed before a complete plan committed.
1138    PartitionerRejected {
1139        /// Whether the failure crossed a panic boundary.
1140        panicked: bool,
1141    },
1142}
1143
1144impl fmt::Display for FlowRuntimeError {
1145    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1146        match self {
1147            Self::Job(error) => write!(formatter, "flow job is invalid: {error}"),
1148            Self::Repository(error) => {
1149                write!(formatter, "flow repository operation failed: {error}")
1150            }
1151            Self::DecisionSequenceExhausted => {
1152                formatter.write_str("flow decision sequence is exhausted")
1153            }
1154            Self::CountExhausted => formatter.write_str("flow execution count is exhausted"),
1155            Self::ShuttingDown => formatter.write_str("runtime intake is shutting down"),
1156            Self::UndeclaredCapability { capability, .. } => write!(
1157                formatter,
1158                "flow requires {capability}, which the connected repository does not declare"
1159            ),
1160            Self::InsufficientPoolCapacity {
1161                required,
1162                configured,
1163            } => write!(
1164                formatter,
1165                "flow requires {required} repository connections but only {configured} are configured"
1166            ),
1167            Self::UnresolvedPartitionOutcome { step_execution_id } => write!(
1168                formatter,
1169                "partition parent step execution {step_execution_id} is blocked by an unknown child outcome"
1170            ),
1171            Self::PartitionerRejected { panicked } => {
1172                if *panicked {
1173                    formatter.write_str("partition factory panicked")
1174                } else {
1175                    formatter.write_str("partition factory rejected the plan")
1176                }
1177            }
1178        }
1179    }
1180}
1181
1182impl Error for FlowRuntimeError {
1183    fn source(&self) -> Option<&(dyn Error + 'static)> {
1184        match self {
1185            Self::Job(error) => Some(error),
1186            Self::Repository(error) => Some(error),
1187            Self::DecisionSequenceExhausted
1188            | Self::CountExhausted
1189            | Self::ShuttingDown
1190            | Self::UndeclaredCapability { .. }
1191            | Self::InsufficientPoolCapacity { .. }
1192            | Self::UnresolvedPartitionOutcome { .. }
1193            | Self::PartitionerRejected { .. } => None,
1194        }
1195    }
1196}
1197
1198/// The repository capabilities a compiled plan requires to run at all.
1199///
1200/// This is the plan half of the requirement set; the launcher half is added by
1201/// [`FlowLauncher::required_capabilities`]. Capabilities that only an operator
1202/// action needs appear in neither, because they are negotiated where that
1203/// action is applied: a plan that is never purged does not need the deployment
1204/// to support purging.
1205fn plan_capabilities(plan: &CompiledExecutionPlan) -> BTreeSet<RepositoryCapability> {
1206    plan.nodes()
1207        .filter_map(|(_, node)| match node {
1208            FlowNode::PartitionedStep(_) => Some(RepositoryCapability::StepPartitions),
1209            _ => None,
1210        })
1211        .collect()
1212}
1213
1214impl From<RepositoryError> for FlowRuntimeError {
1215    fn from(error: RepositoryError) -> Self {
1216        Self::Repository(error)
1217    }
1218}
1219
1220impl From<FlowJobError> for FlowRuntimeError {
1221    fn from(error: FlowJobError) -> Self {
1222        Self::Job(error)
1223    }
1224}
1225
1226/// Async-first launcher for durable sequential, conditional, and bounded split flows.
1227pub struct FlowLauncher<'a> {
1228    repository: &'a dyn JobRepository,
1229    clock: &'a dyn Clock,
1230    ids: &'a dyn IdGenerator,
1231    event_sink: Option<&'a dyn FlowEventSink>,
1232    execution_control: Option<(crate::OwnerToken, StopPollInterval)>,
1233    shutdown_signal: Option<&'a crate::ShutdownSignal>,
1234}
1235
1236impl<'a> FlowLauncher<'a> {
1237    /// Constructs a launcher from explicit infrastructure ports.
1238    #[must_use]
1239    pub const fn new(
1240        repository: &'a dyn JobRepository,
1241        clock: &'a dyn Clock,
1242        ids: &'a dyn IdGenerator,
1243    ) -> Self {
1244        Self {
1245            repository,
1246            clock,
1247            ids,
1248            event_sink: None,
1249            execution_control: None,
1250            shutdown_signal: None,
1251        }
1252    }
1253
1254    /// Attaches a non-authoritative flow-event sink.
1255    #[must_use]
1256    pub const fn with_event_sink(mut self, event_sink: &'a dyn FlowEventSink) -> Self {
1257        self.event_sink = Some(event_sink);
1258        self
1259    }
1260
1261    /// Enables durable ownership evidence and operator-stop polling.
1262    #[must_use]
1263    pub const fn with_execution_control(
1264        mut self,
1265        owner: crate::OwnerToken,
1266        interval: StopPollInterval,
1267    ) -> Self {
1268        self.execution_control = Some((owner, interval));
1269        self
1270    }
1271
1272    /// Attaches the application-owned process-shutdown intake and cancellation signal.
1273    #[must_use]
1274    pub const fn with_shutdown_signal(mut self, signal: &'a crate::ShutdownSignal) -> Self {
1275        self.shutdown_signal = Some(signal);
1276        self
1277    }
1278
1279    /// Executes one durable sequential, conditional, or bounded split attempt.
1280    ///
1281    /// The source step result is committed before its transition, and the
1282    /// transition is committed before the next target starts. Expected user
1283    /// failures are returned in [`FlowLaunchReport`]; infrastructure failures
1284    /// that prevent a trustworthy final snapshot return [`FlowRuntimeError`].
1285    ///
1286    /// # Errors
1287    ///
1288    /// Returns [`FlowRuntimeError`] when the job bindings are invalid or the
1289    /// repository cannot durably create, update, or read the execution.
1290    #[allow(clippy::too_many_lines)]
1291    pub async fn launch(
1292        &self,
1293        job: &FlowJob,
1294        parameters: &JobParameters,
1295        stop_token: &StopToken,
1296    ) -> Result<FlowLaunchReport, FlowRuntimeError> {
1297        self.ensure_accepting()?;
1298        job.validate()?;
1299        self.validate_repository_capabilities(job.compiled_plan())?;
1300        self.validate_repository_capacity(job.compiled_plan())?;
1301        let split_tasklets = job.materialize_split_tasklets()?;
1302        let key = JobInstanceKey::new(job.name.clone(), parameters);
1303        let (instance, mut execution, attempt) = self
1304            .create_job_execution(&key, job.plan.definition_identity())
1305            .await?;
1306        execution = self.start_job(&execution).await?;
1307        self.poll_execution_control(execution.id(), stop_token)
1308            .await?;
1309        self.observe_process_shutdown(stop_token);
1310
1311        let mut node_id = job.plan.entry().clone();
1312        let mut preceding: Option<FlowStepState> = None;
1313        let mut steps = Vec::new();
1314        let mut decisions = Vec::new();
1315        let mut listener_failures = Vec::new();
1316
1317        loop {
1318            self.observe_process_shutdown(stop_token);
1319            if stop_token.is_stop_requested() {
1320                let final_job = self
1321                    .finish_job(&execution, BatchStatus::Stopped, None)
1322                    .await?;
1323                return Ok(FlowLaunchReport {
1324                    instance,
1325                    job_execution: final_job,
1326                    step_executions: steps,
1327                    decisions,
1328                    outcome: FlowExecutionOutcome::Stopped,
1329                    listener_failures,
1330                });
1331            }
1332
1333            let node = job.plan.node(&node_id).ok_or_else(|| {
1334                FlowRuntimeError::Job(FlowJobError::MissingBinding {
1335                    node: node_id.clone(),
1336                })
1337            })?;
1338            let (
1339                transition_node,
1340                observed,
1341                source_step,
1342                kind,
1343                input_digest,
1344                reused,
1345                source_failure,
1346            ) = match node {
1347                FlowNode::Step(compiled) => {
1348                    let historical = self.latest_step(instance.id(), &node_id).await?;
1349                    if let Some(history) = historical.as_ref()
1350                        && history.execution().metadata().status() == BatchStatus::Completed
1351                        && !compiled.start_controls().allow_start_if_complete()
1352                    {
1353                        let digest = step_input_digest(job.plan.fingerprint(), history);
1354                        let reused = self
1355                            .reusable_decision(
1356                                instance.id(),
1357                                &node_id,
1358                                job.plan.fingerprint(),
1359                                &digest,
1360                                FlowTransitionKind::StepExit,
1361                            )
1362                            .await?;
1363                        preceding = Some(history.clone());
1364                        (
1365                            node_id.clone(),
1366                            history.execution().metadata().exit_status().clone(),
1367                            Some(history.execution().id()),
1368                            FlowTransitionKind::CompletedStepReuse,
1369                            digest,
1370                            reused.map(|decision| decision.id()),
1371                            None,
1372                        )
1373                    } else {
1374                        let tasklet = job.steps.get(&node_id).ok_or_else(|| {
1375                            FlowRuntimeError::Job(FlowJobError::MissingBinding {
1376                                node: node_id.clone(),
1377                            })
1378                        })?;
1379                        let created = match self
1380                            .create_step(
1381                                execution.id(),
1382                                compiled.step_name(),
1383                                &node_id,
1384                                compiled.start_controls().start_limit(),
1385                            )
1386                            .await
1387                        {
1388                            Ok(created) => created,
1389                            Err(FlowRuntimeError::Repository(
1390                                RepositoryError::StartLimitExceeded { limit, .. },
1391                            )) => {
1392                                self.emit_flow_event(&FlowEvent::new(
1393                                    FlowEventKind::StartLimitExceeded,
1394                                    job.name.clone(),
1395                                    instance.id(),
1396                                    execution.id(),
1397                                    attempt,
1398                                    node_id.clone(),
1399                                    None,
1400                                    None,
1401                                    self.clock.now(),
1402                                ));
1403                                let failure =
1404                                    self.next_failure_summary(FailureCategory::IllegalTransition)?;
1405                                let final_job = self
1406                                    .finish_job(&execution, BatchStatus::Failed, Some(failure))
1407                                    .await?;
1408                                return Ok(FlowLaunchReport {
1409                                    instance,
1410                                    job_execution: final_job,
1411                                    step_executions: steps,
1412                                    decisions,
1413                                    outcome: FlowExecutionOutcome::Failed(
1414                                        FlowFailure::StartLimitExceeded {
1415                                            node: node_id,
1416                                            limit,
1417                                        },
1418                                    ),
1419                                    listener_failures,
1420                                });
1421                            }
1422                            Err(error) => return Err(error),
1423                        };
1424                        let correlation = correlation(
1425                            &job.name,
1426                            instance.id(),
1427                            execution.id(),
1428                            attempt,
1429                            compiled.step_name(),
1430                            created.id(),
1431                            steps.len(),
1432                        )?;
1433                        let run = self
1434                            .run_step(
1435                                &node_id,
1436                                tasklet,
1437                                created,
1438                                parameters,
1439                                stop_token,
1440                                &correlation,
1441                            )
1442                            .await?;
1443                        listener_failures.extend(run.listener_failures);
1444                        steps.push(run.execution.clone());
1445                        if run.outcome == TaskletExecutionOutcome::Unknown {
1446                            let final_job = self
1447                                .finish_job(&execution, BatchStatus::Unknown, run.failure)
1448                                .await?;
1449                            return Ok(FlowLaunchReport {
1450                                instance,
1451                                job_execution: final_job,
1452                                step_executions: steps,
1453                                decisions,
1454                                outcome: FlowExecutionOutcome::Unknown,
1455                                listener_failures,
1456                            });
1457                        }
1458                        if matches!(run.outcome, TaskletExecutionOutcome::Stopped(_)) {
1459                            let final_job = self
1460                                .finish_job(&execution, BatchStatus::Stopped, None)
1461                                .await?;
1462                            return Ok(FlowLaunchReport {
1463                                instance,
1464                                job_execution: final_job,
1465                                step_executions: steps,
1466                                decisions,
1467                                outcome: FlowExecutionOutcome::Stopped,
1468                                listener_failures,
1469                            });
1470                        }
1471                        let state = self.latest_step(instance.id(), &node_id).await?.ok_or(
1472                            FlowRuntimeError::Repository(RepositoryError::FlowStateCorrupt),
1473                        )?;
1474                        let digest = step_input_digest(job.plan.fingerprint(), &state);
1475                        preceding = Some(state);
1476                        (
1477                            node_id.clone(),
1478                            run.exit_status,
1479                            Some(run.execution.id()),
1480                            FlowTransitionKind::StepExit,
1481                            digest,
1482                            None,
1483                            run.flow_failure,
1484                        )
1485                    }
1486                }
1487                FlowNode::Decision(compiled) => {
1488                    let digest = decision_input_digest(
1489                        job.plan.fingerprint(),
1490                        &node_id,
1491                        compiled.revision().as_str(),
1492                        compiled.input_version().get(),
1493                        instance.id(),
1494                        parameters,
1495                        preceding.as_ref(),
1496                    );
1497                    if let Some(prior) = self
1498                        .reusable_decision(
1499                            instance.id(),
1500                            &node_id,
1501                            job.plan.fingerprint(),
1502                            &digest,
1503                            FlowTransitionKind::Decider,
1504                        )
1505                        .await?
1506                    {
1507                        (
1508                            node_id.clone(),
1509                            ExitStatus::new(prior.observed_outcome().clone()),
1510                            None,
1511                            FlowTransitionKind::Decider,
1512                            digest,
1513                            Some(prior.id()),
1514                            None,
1515                        )
1516                    } else {
1517                        let decider = job.deciders.get(&node_id).ok_or_else(|| {
1518                            FlowRuntimeError::Job(FlowJobError::MissingBinding {
1519                                node: node_id.clone(),
1520                            })
1521                        })?;
1522                        let input = DecisionInput {
1523                            job_instance_id: instance.id(),
1524                            job_execution_id: execution.id(),
1525                            attempt,
1526                            plan_fingerprint: *job.plan.fingerprint(),
1527                            node_id: &node_id,
1528                            parameters,
1529                            preceding_step: preceding.as_ref().map(DecisionStepInput::from_state),
1530                        };
1531                        match invoke_decider(decider.as_ref(), input).await {
1532                            Ok(outcome) => (
1533                                node_id.clone(),
1534                                outcome,
1535                                None,
1536                                FlowTransitionKind::Decider,
1537                                digest,
1538                                None,
1539                                None,
1540                            ),
1541                            Err(flow_failure) => {
1542                                let failure =
1543                                    self.next_failure_summary(FailureCategory::UserComponent)?;
1544                                let final_job = self
1545                                    .finish_job(&execution, BatchStatus::Failed, Some(failure))
1546                                    .await?;
1547                                return Ok(FlowLaunchReport {
1548                                    instance,
1549                                    job_execution: final_job,
1550                                    step_executions: steps,
1551                                    decisions,
1552                                    outcome: FlowExecutionOutcome::Failed(flow_failure),
1553                                    listener_failures,
1554                                });
1555                            }
1556                        }
1557                    }
1558                }
1559                FlowNode::Split(split) => {
1560                    let run = self
1561                        .run_split(
1562                            job,
1563                            split,
1564                            &split_tasklets,
1565                            instance.id(),
1566                            execution.id(),
1567                            attempt,
1568                            parameters,
1569                            stop_token,
1570                        )
1571                        .await?;
1572                    steps.extend(run.step_executions);
1573                    listener_failures.extend(run.listener_failures);
1574                    preceding = None;
1575                    if run.status == BatchStatus::Unknown {
1576                        let final_job = self
1577                            .finish_job(&execution, BatchStatus::Unknown, run.failure)
1578                            .await?;
1579                        return Ok(FlowLaunchReport {
1580                            instance,
1581                            job_execution: final_job,
1582                            step_executions: steps,
1583                            decisions,
1584                            outcome: FlowExecutionOutcome::Unknown,
1585                            listener_failures,
1586                        });
1587                    }
1588                    if run.status == BatchStatus::Stopped {
1589                        let final_job = self
1590                            .finish_job(&execution, BatchStatus::Stopped, None)
1591                            .await?;
1592                        return Ok(FlowLaunchReport {
1593                            instance,
1594                            job_execution: final_job,
1595                            step_executions: steps,
1596                            decisions,
1597                            outcome: FlowExecutionOutcome::Stopped,
1598                            listener_failures,
1599                        });
1600                    }
1601                    let reused = self
1602                        .reusable_decision(
1603                            instance.id(),
1604                            split.join(),
1605                            job.plan.fingerprint(),
1606                            &run.input_digest,
1607                            FlowTransitionKind::SplitAggregate,
1608                        )
1609                        .await?;
1610                    (
1611                        split.join().clone(),
1612                        run.exit_status,
1613                        None,
1614                        FlowTransitionKind::SplitAggregate,
1615                        run.input_digest,
1616                        reused.map(|decision| decision.id()),
1617                        run.flow_failure,
1618                    )
1619                }
1620                FlowNode::PartitionedStep(compiled) => {
1621                    let historical = self.latest_step(instance.id(), &node_id).await?;
1622                    if let Some(history) = historical.as_ref()
1623                        && history.execution().metadata().status() == BatchStatus::Completed
1624                        && !compiled.start_controls().allow_start_if_complete()
1625                    {
1626                        let digest = step_input_digest(job.plan.fingerprint(), history);
1627                        let reused = self
1628                            .reusable_decision(
1629                                instance.id(),
1630                                &node_id,
1631                                job.plan.fingerprint(),
1632                                &digest,
1633                                FlowTransitionKind::StepExit,
1634                            )
1635                            .await?;
1636                        preceding = Some(history.clone());
1637                        (
1638                            node_id.clone(),
1639                            history.execution().metadata().exit_status().clone(),
1640                            Some(history.execution().id()),
1641                            FlowTransitionKind::CompletedStepReuse,
1642                            digest,
1643                            reused.map(|decision| decision.id()),
1644                            None,
1645                        )
1646                    } else {
1647                        let binding = job.partitioned_tasklets.get(&node_id).ok_or_else(|| {
1648                            FlowRuntimeError::Job(FlowJobError::MissingBinding {
1649                                node: node_id.clone(),
1650                            })
1651                        })?;
1652                        let run = self
1653                            .run_partitioned_step(
1654                                job,
1655                                compiled,
1656                                binding,
1657                                historical.as_ref(),
1658                                instance.id(),
1659                                execution.id(),
1660                                attempt,
1661                                parameters,
1662                                stop_token,
1663                            )
1664                            .await?;
1665                        listener_failures.extend(run.listener_failures);
1666                        steps.extend(run.worker_executions);
1667                        steps.push(run.parent.clone());
1668                        if run.status == BatchStatus::Stopped {
1669                            let final_job = self
1670                                .finish_job(&execution, BatchStatus::Stopped, None)
1671                                .await?;
1672                            return Ok(FlowLaunchReport {
1673                                instance,
1674                                job_execution: final_job,
1675                                step_executions: steps,
1676                                decisions,
1677                                outcome: FlowExecutionOutcome::Stopped,
1678                                listener_failures,
1679                            });
1680                        }
1681                        let state = self.latest_step(instance.id(), &node_id).await?.ok_or(
1682                            FlowRuntimeError::Repository(RepositoryError::FlowStateCorrupt),
1683                        )?;
1684                        let digest = step_input_digest(job.plan.fingerprint(), &state);
1685                        preceding = Some(state);
1686                        (
1687                            node_id.clone(),
1688                            run.exit_status,
1689                            Some(run.parent.id()),
1690                            FlowTransitionKind::StepExit,
1691                            digest,
1692                            None,
1693                            run.flow_failure,
1694                        )
1695                    }
1696                }
1697                // A `FlowNode` variant added after this build cannot be
1698                // dispatched here, so it is refused exactly as `Join` is
1699                // rather than executed as a guessed node kind.
1700                FlowNode::Join(_) | _ => {
1701                    return Err(FlowRuntimeError::Job(FlowJobError::UnsupportedManifest {
1702                        format: job.plan.manifest_format(),
1703                    }));
1704                }
1705            };
1706
1707            let target = match job.plan.select_target(&transition_node, observed.code()) {
1708                Ok(target) => target.clone(),
1709                Err(FlowSelectionError::UnmappedExitOutcome { node, code }) => {
1710                    let failure = self.next_failure_summary(FailureCategory::InvalidDefinition)?;
1711                    let final_job = self
1712                        .finish_job(&execution, BatchStatus::Failed, Some(failure))
1713                        .await?;
1714                    return Ok(FlowLaunchReport {
1715                        instance,
1716                        job_execution: final_job,
1717                        step_executions: steps,
1718                        decisions,
1719                        outcome: FlowExecutionOutcome::Failed(FlowFailure::UnmappedExitOutcome {
1720                            node,
1721                            code,
1722                        }),
1723                        listener_failures,
1724                    });
1725                }
1726                Err(FlowSelectionError::UnknownNode { .. }) => {
1727                    return Err(FlowRuntimeError::Job(FlowJobError::MissingBinding {
1728                        node: transition_node,
1729                    }));
1730                }
1731                // A `FlowSelectionError` variant added after this build names a
1732                // selection this runtime cannot interpret. No transition is
1733                // taken and no decision is persisted; the launch stops as an
1734                // unsupported manifest rather than following a guessed target.
1735                Err(_) => {
1736                    return Err(FlowRuntimeError::Job(FlowJobError::UnsupportedManifest {
1737                        format: job.plan.manifest_format(),
1738                    }));
1739                }
1740            };
1741            let sequence = next_sequence(decisions.len())?;
1742            let request = FlowDecisionRequest::new(
1743                execution.id(),
1744                sequence,
1745                transition_node.clone(),
1746                source_step,
1747                kind,
1748                observed.code().clone(),
1749                target.clone(),
1750                *job.plan.fingerprint(),
1751                input_digest,
1752                reused,
1753                self.clock.now(),
1754            );
1755            let decision = self.append_decision(&request).await?;
1756            self.emit_flow_event(&FlowEvent::new(
1757                FlowEventKind::DecisionCommitted,
1758                job.name.clone(),
1759                instance.id(),
1760                execution.id(),
1761                attempt,
1762                transition_node.clone(),
1763                source_step,
1764                Some(target.clone()),
1765                decision.decided_at(),
1766            ));
1767            if kind == FlowTransitionKind::CompletedStepReuse {
1768                self.emit_flow_event(&FlowEvent::new(
1769                    FlowEventKind::CompletedStepReused,
1770                    job.name.clone(),
1771                    instance.id(),
1772                    execution.id(),
1773                    attempt,
1774                    transition_node.clone(),
1775                    source_step,
1776                    Some(target.clone()),
1777                    decision.decided_at(),
1778                ));
1779            }
1780            decisions.push(decision);
1781
1782            match target {
1783                FlowTarget::Node(next) => node_id = next,
1784                FlowTarget::Terminal(terminal) => {
1785                    let (status, outcome) = match terminal {
1786                        TerminalKind::Complete => {
1787                            (BatchStatus::Completed, FlowExecutionOutcome::Completed)
1788                        }
1789                        TerminalKind::Stop => (BatchStatus::Stopped, FlowExecutionOutcome::Stopped),
1790                        // `TerminalKind::Fail`, and any terminal this build does
1791                        // not know: `TerminalKind` is `#[non_exhaustive]`, and an
1792                        // unrecognized terminal fails the job rather than
1793                        // completing or stopping it.
1794                        _ => (
1795                            BatchStatus::Failed,
1796                            FlowExecutionOutcome::Failed(
1797                                source_failure.unwrap_or(FlowFailure::FailTerminal),
1798                            ),
1799                        ),
1800                    };
1801                    let failure = if status == BatchStatus::Failed {
1802                        Some(self.next_failure_summary(FailureCategory::UserComponent)?)
1803                    } else {
1804                        None
1805                    };
1806                    let final_job = self.finish_job(&execution, status, failure).await?;
1807                    return Ok(FlowLaunchReport {
1808                        instance,
1809                        job_execution: final_job,
1810                        step_executions: steps,
1811                        decisions,
1812                        outcome,
1813                        listener_failures,
1814                    });
1815                }
1816            }
1817        }
1818    }
1819
1820    #[allow(clippy::too_many_arguments)]
1821    async fn run_split(
1822        &self,
1823        job: &FlowJob,
1824        split: &crate::SplitNode,
1825        tasklets: &BTreeMap<NodeId, TaskletStep>,
1826        instance_id: JobInstanceId,
1827        execution_id: JobExecutionId,
1828        attempt: ExecutionAttempt,
1829        parameters: &JobParameters,
1830        parent_stop: &StopToken,
1831    ) -> Result<SplitRun, FlowRuntimeError> {
1832        let (split_stop_source, split_stop) = crate::StopSource::new();
1833        if parent_stop.is_stop_requested() {
1834            split_stop_source.request_stop();
1835        }
1836
1837        let mut ordinal_base = 0_usize;
1838        let branches = futures_util::stream::iter(split.branches().iter().enumerate().map(
1839            |(index, branch)| {
1840                let base = ordinal_base;
1841                ordinal_base = ordinal_base.saturating_add(branch.steps().len());
1842                let split_stop = split_stop.clone();
1843                async move {
1844                    self.run_split_branch(
1845                        job,
1846                        index,
1847                        base,
1848                        branch,
1849                        tasklets,
1850                        instance_id,
1851                        execution_id,
1852                        attempt,
1853                        parameters,
1854                        &split_stop,
1855                    )
1856                    .await
1857                }
1858            },
1859        ))
1860        .buffer_unordered(usize::from(split.budget().max_parallel_branches()));
1861        tokio::pin!(branches);
1862
1863        let mut joined = Vec::with_capacity(split.branches().len());
1864        let mut first_error = None;
1865        let mut parent_stop_observed = parent_stop.is_stop_requested();
1866        loop {
1867            tokio::select! {
1868                result = branches.next() => {
1869                    let Some(result) = result else { break; };
1870                    match result {
1871                        Ok(branch) => {
1872                            if split.failure_policy() == crate::LocalFailurePolicy::CancelSiblings
1873                                && matches!(branch.status, BatchStatus::Failed | BatchStatus::Unknown)
1874                            {
1875                                split_stop_source.request_stop();
1876                            }
1877                            joined.push(branch);
1878                        }
1879                        Err(error) => {
1880                            split_stop_source.request_stop();
1881                            if first_error.is_none() {
1882                                first_error = Some(error);
1883                            }
1884                        }
1885                    }
1886                }
1887                () = parent_stop.cancelled(), if !parent_stop_observed => {
1888                    parent_stop_observed = true;
1889                    split_stop_source.request_stop();
1890                }
1891            }
1892        }
1893        if let Some(error) = first_error {
1894            return Err(error);
1895        }
1896        joined.sort_by_key(|branch| branch.index);
1897        if joined.len() != split.branches().len() {
1898            return Err(FlowRuntimeError::Repository(
1899                RepositoryError::FlowStateCorrupt,
1900            ));
1901        }
1902
1903        let status = joined
1904            .iter()
1905            .map(|branch| branch.status)
1906            .max_by_key(|status| split_status_severity(*status))
1907            .ok_or(FlowRuntimeError::Repository(
1908                RepositoryError::FlowStateCorrupt,
1909            ))?;
1910        let selected = joined.iter().find(|branch| branch.status == status).ok_or(
1911            FlowRuntimeError::Repository(RepositoryError::FlowStateCorrupt),
1912        )?;
1913        let exit_status = selected.exit_status.clone();
1914        let failure = selected.failure;
1915        let flow_failure = selected.flow_failure.clone();
1916        let input_digest = split_input_digest(job.plan.fingerprint(), split.join(), &joined);
1917        let mut step_executions = Vec::new();
1918        let mut listener_failures = Vec::new();
1919        for branch in joined {
1920            step_executions.extend(branch.step_executions);
1921            listener_failures.extend(branch.listener_failures);
1922        }
1923        Ok(SplitRun {
1924            status,
1925            exit_status,
1926            failure,
1927            flow_failure,
1928            input_digest,
1929            step_executions,
1930            listener_failures,
1931        })
1932    }
1933
1934    #[allow(clippy::too_many_arguments, clippy::too_many_lines)]
1935    async fn run_split_branch(
1936        &self,
1937        job: &FlowJob,
1938        index: usize,
1939        ordinal_base: usize,
1940        branch: &crate::SplitBranch,
1941        tasklets: &BTreeMap<NodeId, TaskletStep>,
1942        instance_id: JobInstanceId,
1943        execution_id: JobExecutionId,
1944        attempt: ExecutionAttempt,
1945        parameters: &JobParameters,
1946        stop: &StopToken,
1947    ) -> Result<SplitBranchRun, FlowRuntimeError> {
1948        let mut durable_states = Vec::with_capacity(branch.steps().len());
1949        let mut step_executions = Vec::new();
1950        let mut listener_failures = Vec::new();
1951        let mut status = BatchStatus::Completed;
1952        let mut exit_status = ExitStatus::completed();
1953        let mut failure = None;
1954        let mut flow_failure = None;
1955
1956        for (offset, compiled) in branch.steps().iter().enumerate() {
1957            if stop.is_stop_requested() {
1958                status = BatchStatus::Stopped;
1959                exit_status = ExitStatus::stopped();
1960                break;
1961            }
1962            let historical = self.latest_step(instance_id, compiled.id()).await?;
1963            if let Some(history) = historical
1964                && history.execution().metadata().status() == BatchStatus::Completed
1965                && !compiled.start_controls().allow_start_if_complete()
1966            {
1967                exit_status = history.execution().metadata().exit_status().clone();
1968                durable_states.push(history);
1969                continue;
1970            }
1971
1972            let tasklet = tasklets.get(compiled.id()).ok_or_else(|| {
1973                FlowRuntimeError::Job(FlowJobError::MissingBinding {
1974                    node: compiled.id().clone(),
1975                })
1976            })?;
1977            let created = match self
1978                .create_step(
1979                    execution_id,
1980                    compiled.step_name(),
1981                    compiled.id(),
1982                    compiled.start_controls().start_limit(),
1983                )
1984                .await
1985            {
1986                Ok(created) => created,
1987                Err(FlowRuntimeError::Repository(RepositoryError::StartLimitExceeded {
1988                    limit,
1989                    ..
1990                })) => {
1991                    self.emit_flow_event(&FlowEvent::new(
1992                        FlowEventKind::StartLimitExceeded,
1993                        job.name.clone(),
1994                        instance_id,
1995                        execution_id,
1996                        attempt,
1997                        compiled.id().clone(),
1998                        None,
1999                        None,
2000                        self.clock.now(),
2001                    ));
2002                    status = BatchStatus::Failed;
2003                    exit_status = ExitStatus::failed();
2004                    failure = Some(self.next_failure_summary(FailureCategory::IllegalTransition)?);
2005                    flow_failure = Some(FlowFailure::StartLimitExceeded {
2006                        node: compiled.id().clone(),
2007                        limit,
2008                    });
2009                    break;
2010                }
2011                Err(error) => return Err(error),
2012            };
2013            let correlation = correlation(
2014                &job.name,
2015                instance_id,
2016                execution_id,
2017                attempt,
2018                compiled.step_name(),
2019                created.id(),
2020                ordinal_base.saturating_add(offset),
2021            )?;
2022            let run = self
2023                .run_step(
2024                    compiled.id(),
2025                    tasklet,
2026                    created,
2027                    parameters,
2028                    stop,
2029                    &correlation,
2030                )
2031                .await?;
2032            status = status_for_tasklet(run.outcome);
2033            exit_status = run.exit_status;
2034            failure = run.failure;
2035            flow_failure = run.flow_failure;
2036            listener_failures.extend(run.listener_failures);
2037            step_executions.push(run.execution);
2038            let state = self.latest_step(instance_id, compiled.id()).await?.ok_or(
2039                FlowRuntimeError::Repository(RepositoryError::FlowStateCorrupt),
2040            )?;
2041            durable_states.push(state);
2042            if status != BatchStatus::Completed {
2043                break;
2044            }
2045        }
2046
2047        Ok(SplitBranchRun {
2048            index,
2049            status,
2050            exit_status,
2051            failure,
2052            flow_failure,
2053            states: durable_states,
2054            step_executions,
2055            listener_failures,
2056        })
2057    }
2058
2059    #[allow(clippy::too_many_arguments, clippy::too_many_lines)]
2060    async fn run_partitioned_step(
2061        &self,
2062        job: &FlowJob,
2063        compiled: &crate::PartitionedStepNode,
2064        binding: &PartitionedTaskletBinding,
2065        historical: Option<&FlowStepState>,
2066        instance_id: JobInstanceId,
2067        execution_id: JobExecutionId,
2068        attempt: ExecutionAttempt,
2069        parameters: &JobParameters,
2070        parent_stop: &StopToken,
2071    ) -> Result<PartitionRun, FlowRuntimeError> {
2072        let created = self
2073            .create_step(
2074                execution_id,
2075                compiled.step_name(),
2076                compiled.id(),
2077                compiled.start_controls().start_limit(),
2078            )
2079            .await?;
2080        let parent = self.start_step(&created).await?;
2081        let plan_result: Result<Vec<StepPartition>, FlowRuntimeError> =
2082            if let Some(history) = historical {
2083                let source = self.partition_plan(history.execution().id()).await?;
2084                if source.is_empty() {
2085                    self.create_partition_plan(
2086                        binding,
2087                        compiled,
2088                        instance_id,
2089                        parent.id(),
2090                        job.plan.fingerprint(),
2091                    )
2092                    .await
2093                } else {
2094                    self.restart_partition_plan(history.execution().id(), parent.id())
2095                        .await
2096                }
2097            } else {
2098                self.create_partition_plan(
2099                    binding,
2100                    compiled,
2101                    instance_id,
2102                    parent.id(),
2103                    job.plan.fingerprint(),
2104                )
2105                .await
2106            };
2107        let plan = match plan_result {
2108            Ok(plan) => plan,
2109            Err(FlowRuntimeError::PartitionerRejected { panicked }) => {
2110                let failure = self.next_failure_summary(FailureCategory::UserComponent)?;
2111                let parent = self
2112                    .finish_step(
2113                        &parent,
2114                        TaskletExecutionOutcome::Failed(TaskletFailure::Error),
2115                        &ExitStatus::failed(),
2116                        Some(failure),
2117                        false,
2118                    )
2119                    .await?;
2120                self.emit_flow_event(&FlowEvent::new(
2121                    FlowEventKind::StepResultCommitted,
2122                    job.name.clone(),
2123                    instance_id,
2124                    execution_id,
2125                    attempt,
2126                    compiled.id().clone(),
2127                    Some(parent.id()),
2128                    None,
2129                    self.clock.now(),
2130                ));
2131                return Ok(PartitionRun {
2132                    status: BatchStatus::Failed,
2133                    exit_status: ExitStatus::failed(),
2134                    parent,
2135                    flow_failure: Some(if panicked {
2136                        FlowFailure::PartitionerPanic
2137                    } else {
2138                        FlowFailure::PartitionerError
2139                    }),
2140                    worker_executions: Vec::new(),
2141                    listener_failures: Vec::new(),
2142                });
2143            }
2144            Err(error) => return Err(error),
2145        };
2146
2147        if plan
2148            .iter()
2149            .any(|partition| partition.status() == BatchStatus::Unknown)
2150        {
2151            return Err(FlowRuntimeError::UnresolvedPartitionOutcome {
2152                step_execution_id: parent.id(),
2153            });
2154        }
2155
2156        let pending = plan
2157            .into_iter()
2158            .filter(|partition| partition.status() != BatchStatus::Completed)
2159            .collect::<Vec<_>>();
2160        let (partition_stop_source, partition_stop) = crate::StopSource::new();
2161        if parent_stop.is_stop_requested() {
2162            partition_stop_source.request_stop();
2163        }
2164        let workers = futures_util::stream::iter(pending.into_iter().map(|partition| {
2165            let partition_stop = partition_stop.clone();
2166            async move {
2167                self.run_partition_worker(
2168                    job,
2169                    compiled,
2170                    binding,
2171                    partition,
2172                    instance_id,
2173                    execution_id,
2174                    attempt,
2175                    parameters,
2176                    &partition_stop,
2177                )
2178                .await
2179            }
2180        }))
2181        .buffer_unordered(usize::from(compiled.budget().max_partition_workers()));
2182        tokio::pin!(workers);
2183
2184        let mut joined = Vec::new();
2185        let mut first_error = None;
2186        let mut parent_stop_observed = parent_stop.is_stop_requested();
2187        loop {
2188            tokio::select! {
2189                result = workers.next() => {
2190                    let Some(result) = result else { break; };
2191                    match result {
2192                        Ok(worker) => {
2193                            if compiled.failure_policy() == crate::LocalFailurePolicy::CancelSiblings
2194                                && matches!(worker.partition.status(), BatchStatus::Failed | BatchStatus::Unknown)
2195                            {
2196                                partition_stop_source.request_stop();
2197                            }
2198                            joined.push(worker);
2199                        }
2200                        Err(error) => {
2201                            partition_stop_source.request_stop();
2202                            if first_error.is_none() {
2203                                first_error = Some(error);
2204                            }
2205                        }
2206                    }
2207                }
2208                () = parent_stop.cancelled(), if !parent_stop_observed => {
2209                    parent_stop_observed = true;
2210                    partition_stop_source.request_stop();
2211                }
2212            }
2213        }
2214        if let Some(error) = first_error {
2215            return Err(error);
2216        }
2217
2218        let durable = self.partition_plan(parent.id()).await?;
2219        if durable
2220            .iter()
2221            .any(|partition| partition.status() == BatchStatus::Unknown)
2222        {
2223            return Err(FlowRuntimeError::UnresolvedPartitionOutcome {
2224                step_execution_id: parent.id(),
2225            });
2226        }
2227        let parent = self
2228            .aggregate_partition_parent(parent.id(), parent.version())
2229            .await?;
2230        self.emit_flow_event(&FlowEvent::new(
2231            FlowEventKind::StepResultCommitted,
2232            job.name.clone(),
2233            instance_id,
2234            execution_id,
2235            attempt,
2236            compiled.id().clone(),
2237            Some(parent.id()),
2238            None,
2239            self.clock.now(),
2240        ));
2241
2242        joined.sort_by(|left, right| left.partition.key().cmp(right.partition.key()));
2243        let selected = joined
2244            .iter()
2245            .find(|worker| worker.partition.status() == parent.metadata().status());
2246        let flow_failure = selected.and_then(|worker| worker.flow_failure.clone());
2247        let mut worker_executions = Vec::with_capacity(joined.len());
2248        let mut listener_failures = Vec::new();
2249        for worker in joined {
2250            worker_executions.push(worker.execution);
2251            listener_failures.extend(worker.listener_failures);
2252        }
2253        Ok(PartitionRun {
2254            status: parent.metadata().status(),
2255            exit_status: parent.metadata().exit_status().clone(),
2256            parent,
2257            flow_failure,
2258            worker_executions,
2259            listener_failures,
2260        })
2261    }
2262
2263    async fn create_partition_plan(
2264        &self,
2265        binding: &PartitionedTaskletBinding,
2266        compiled: &crate::PartitionedStepNode,
2267        instance_id: JobInstanceId,
2268        parent_id: StepExecutionId,
2269        fingerprint: &[u8; 32],
2270    ) -> Result<Vec<StepPartition>, FlowRuntimeError> {
2271        let request = PartitionPlanRequest {
2272            plan_fingerprint: fingerprint,
2273            job_instance_id: instance_id,
2274            node_id: compiled.id(),
2275            partition_count: compiled.partition_count(),
2276        };
2277        let entries = match catch_unwind(AssertUnwindSafe(|| binding.partitioner.create(request))) {
2278            Ok(Ok(entries)) => entries,
2279            Ok(Err(_)) => return Err(FlowRuntimeError::PartitionerRejected { panicked: false }),
2280            Err(_) => return Err(FlowRuntimeError::PartitionerRejected { panicked: true }),
2281        };
2282        if entries.len() != usize::from(compiled.partition_count().get()) {
2283            return Err(FlowRuntimeError::PartitionerRejected { panicked: false });
2284        }
2285        let mut unit = self.repository.begin().await?;
2286        let created = unit.create_step_partition_plan(parent_id, &entries).await?;
2287        match unit.commit().await {
2288            Ok(()) => Ok(created),
2289            Err(RepositoryError::CommitOutcomeUnknown) => {
2290                let durable = self.partition_plan(parent_id).await?;
2291                if durable == created {
2292                    Ok(durable)
2293                } else {
2294                    Err(RepositoryError::CommitOutcomeUnknown.into())
2295                }
2296            }
2297            Err(error) => Err(error.into()),
2298        }
2299    }
2300
2301    async fn restart_partition_plan(
2302        &self,
2303        source_parent_id: StepExecutionId,
2304        target_parent_id: StepExecutionId,
2305    ) -> Result<Vec<StepPartition>, FlowRuntimeError> {
2306        let mut unit = self.repository.begin().await?;
2307        let copied = unit
2308            .restart_step_partition_plan(source_parent_id, target_parent_id)
2309            .await?;
2310        match unit.commit().await {
2311            Ok(()) => Ok(copied),
2312            Err(RepositoryError::CommitOutcomeUnknown) => {
2313                let durable = self.partition_plan(target_parent_id).await?;
2314                if durable == copied {
2315                    Ok(durable)
2316                } else {
2317                    Err(RepositoryError::CommitOutcomeUnknown.into())
2318                }
2319            }
2320            Err(error) => Err(error.into()),
2321        }
2322    }
2323
2324    #[allow(clippy::too_many_arguments)]
2325    async fn run_partition_worker(
2326        &self,
2327        job: &FlowJob,
2328        compiled: &crate::PartitionedStepNode,
2329        binding: &PartitionedTaskletBinding,
2330        partition: StepPartition,
2331        instance_id: JobInstanceId,
2332        execution_id: JobExecutionId,
2333        attempt: ExecutionAttempt,
2334        parameters: &JobParameters,
2335        stop: &StopToken,
2336    ) -> Result<PartitionWorkerRun, FlowRuntimeError> {
2337        let (worker, assigned) = self
2338            .create_and_assign_partition_worker(execution_id, compiled, &partition)
2339            .await?;
2340        let correlation = correlation(
2341            &job.name,
2342            instance_id,
2343            execution_id,
2344            attempt,
2345            compiled.worker().step_name(),
2346            worker.id(),
2347            usize::try_from(partition.ordinal()).unwrap_or(usize::MAX),
2348        )?;
2349
2350        let run = if stop.is_stop_requested() {
2351            self.finish_uninvoked_partition_worker(
2352                worker,
2353                TaskletExecutionOutcome::Stopped(StopTiming::BeforeStart),
2354                ExitStatus::stopped(),
2355                None,
2356            )
2357            .await?
2358        } else {
2359            let input = PartitionWorkerInput::from_partition(&assigned);
2360            match catch_unwind(AssertUnwindSafe(|| binding.worker.create(input))) {
2361                Ok(tasklet_step) if tasklet_step.name() == binding.worker.step_name() => {
2362                    self.run_step(
2363                        compiled.worker().id(),
2364                        &tasklet_step,
2365                        worker,
2366                        parameters,
2367                        stop,
2368                        &correlation,
2369                    )
2370                    .await?
2371                }
2372                Ok(_) | Err(_) => {
2373                    let failure = self.next_failure_summary(FailureCategory::UserComponent)?;
2374                    let mut run = self
2375                        .finish_uninvoked_partition_worker(
2376                            worker,
2377                            TaskletExecutionOutcome::Failed(TaskletFailure::Panic),
2378                            ExitStatus::failed(),
2379                            Some(failure),
2380                        )
2381                        .await?;
2382                    run.flow_failure = Some(FlowFailure::PartitionFactoryPanic);
2383                    run
2384                }
2385            }
2386        };
2387        let completed = self
2388            .publish_partition_result(&assigned, run.execution.id())
2389            .await?;
2390        Ok(PartitionWorkerRun {
2391            partition: completed,
2392            execution: run.execution,
2393            flow_failure: run.flow_failure,
2394            listener_failures: run.listener_failures,
2395        })
2396    }
2397
2398    async fn create_and_assign_partition_worker(
2399        &self,
2400        execution_id: JobExecutionId,
2401        compiled: &crate::PartitionedStepNode,
2402        partition: &StepPartition,
2403    ) -> Result<(StepExecution, StepPartition), FlowRuntimeError> {
2404        let (worker_name, worker_node_id) = partition_worker_identity(compiled, partition)?;
2405        let mut unit = self.repository.begin().await?;
2406        let worker = unit
2407            .create_flow_step_execution(
2408                execution_id,
2409                &worker_name,
2410                &worker_node_id,
2411                compiled.worker().start_controls().start_limit(),
2412            )
2413            .await?;
2414        let assigned = unit
2415            .assign_step_partition(partition.id(), partition.version(), worker.id())
2416            .await?;
2417        unit.commit().await?;
2418        Ok((worker, assigned))
2419    }
2420
2421    async fn finish_uninvoked_partition_worker(
2422        &self,
2423        worker: StepExecution,
2424        outcome: TaskletExecutionOutcome,
2425        exit_status: ExitStatus,
2426        failure: Option<FailureSummary>,
2427    ) -> Result<StepRun, FlowRuntimeError> {
2428        let started = self.start_step(&worker).await?;
2429        let execution = self
2430            .finish_step(&started, outcome, &exit_status, failure, false)
2431            .await?;
2432        Ok(StepRun {
2433            execution,
2434            outcome,
2435            exit_status,
2436            failure,
2437            flow_failure: match outcome {
2438                TaskletExecutionOutcome::Failed(value) => Some(FlowFailure::Tasklet(value)),
2439                _ => None,
2440            },
2441            listener_failures: Vec::new(),
2442        })
2443    }
2444
2445    async fn publish_partition_result(
2446        &self,
2447        partition: &StepPartition,
2448        worker_id: StepExecutionId,
2449    ) -> Result<StepPartition, FlowRuntimeError> {
2450        let mut unit = self.repository.begin().await?;
2451        let completed = unit
2452            .complete_step_partition(partition.id(), partition.version(), worker_id)
2453            .await?;
2454        match unit.commit().await {
2455            Ok(()) => Ok(completed),
2456            Err(RepositoryError::CommitOutcomeUnknown) => {
2457                let durable = self.partition_plan(partition.step_execution_id()).await?;
2458                let current = durable
2459                    .into_iter()
2460                    .find(|candidate| candidate.id() == partition.id())
2461                    .ok_or(RepositoryError::PartitionStateCorrupt)?;
2462                if current == completed {
2463                    Ok(current)
2464                } else {
2465                    Err(RepositoryError::CommitOutcomeUnknown.into())
2466                }
2467            }
2468            Err(error) => Err(error.into()),
2469        }
2470    }
2471
2472    async fn partition_plan(
2473        &self,
2474        parent_id: StepExecutionId,
2475    ) -> Result<Vec<StepPartition>, FlowRuntimeError> {
2476        let mut unit = self.repository.begin().await?;
2477        let plan = unit.step_partition_plan(parent_id).await?;
2478        unit.rollback().await?;
2479        Ok(plan)
2480    }
2481
2482    async fn aggregate_partition_parent(
2483        &self,
2484        parent_id: StepExecutionId,
2485        expected_version: crate::ExecutionVersion,
2486    ) -> Result<StepExecution, FlowRuntimeError> {
2487        let mut unit = self.repository.begin().await?;
2488        let aggregated = unit
2489            .aggregate_step_partitions(parent_id, expected_version, self.clock.now())
2490            .await?;
2491        match unit.commit().await {
2492            Ok(()) => Ok(aggregated),
2493            Err(RepositoryError::CommitOutcomeUnknown) => {
2494                let mut recovery = self.repository.begin().await?;
2495                let inspected = recovery
2496                    .aggregate_step_partitions(parent_id, expected_version, self.clock.now())
2497                    .await?;
2498                recovery.commit().await?;
2499                Ok(inspected)
2500            }
2501            Err(error) => Err(error.into()),
2502        }
2503    }
2504
2505    fn ensure_accepting(&self) -> Result<(), FlowRuntimeError> {
2506        self.shutdown_signal.map_or(Ok(()), |signal| {
2507            signal
2508                .ensure_accepting()
2509                .map_err(|_| FlowRuntimeError::ShuttingDown)
2510        })
2511    }
2512
2513    /// Negotiates the plan's capability requirements against the descriptor the
2514    /// connected adapter publishes.
2515    ///
2516    /// This runs before any durable work, so a deployment that cannot provide
2517    /// what the plan needs is rejected instead of failing part-way through an
2518    /// execution that has already written lifecycle rows. A requirement is
2519    /// never downgraded to a weaker guarantee to let the launch proceed.
2520    fn validate_repository_capabilities(
2521        &self,
2522        plan: &CompiledExecutionPlan,
2523    ) -> Result<(), FlowRuntimeError> {
2524        let descriptor = self.repository.descriptor();
2525        for capability in self.required_capabilities(plan) {
2526            descriptor
2527                .require(capability)
2528                .map_err(|_| FlowRuntimeError::UndeclaredCapability {
2529                    capability,
2530                    descriptor_version: descriptor.descriptor_version(),
2531                })?;
2532        }
2533        Ok(())
2534    }
2535
2536    /// The repository capabilities this launch requires.
2537    ///
2538    /// A requirement has two sources and both must be negotiated: the compiled
2539    /// plan, and this launcher's configuration. Reading only the plan would
2540    /// miss [`RepositoryCapability::ExecutionOwnership`], which no plan
2541    /// mentions because it is enabled by
2542    /// [`with_execution_control`](Self::with_execution_control) rather than
2543    /// declared by a definition.
2544    ///
2545    /// The result is a set, so a capability required by both sources is
2546    /// negotiated once and the rejection order is deterministic.
2547    fn required_capabilities(
2548        &self,
2549        plan: &CompiledExecutionPlan,
2550    ) -> BTreeSet<RepositoryCapability> {
2551        let mut required = plan_capabilities(plan);
2552        if self.execution_control.is_some() {
2553            // Durable ownership claims and stop observation run against every
2554            // execution this launcher starts, so the deployment must provide
2555            // them before the first of those writes rather than at it.
2556            required.insert(RepositoryCapability::ExecutionOwnership);
2557        }
2558        required
2559    }
2560
2561    fn validate_repository_capacity(
2562        &self,
2563        plan: &CompiledExecutionPlan,
2564    ) -> Result<(), FlowRuntimeError> {
2565        let configured = self.repository.connection_capacity();
2566        let required = plan
2567            .nodes()
2568            .filter_map(|(_, node)| match node {
2569                FlowNode::Split(split) => Some(split.budget().repository_pool_size()),
2570                FlowNode::PartitionedStep(partitioned) => {
2571                    Some(partitioned.budget().repository_pool_size())
2572                }
2573                _ => None,
2574            })
2575            .max()
2576            .unwrap_or(1);
2577        if configured < required {
2578            return Err(FlowRuntimeError::InsufficientPoolCapacity {
2579                required,
2580                configured,
2581            });
2582        }
2583        Ok(())
2584    }
2585
2586    fn observe_process_shutdown(&self, stop: &StopToken) {
2587        if self
2588            .shutdown_signal
2589            .is_some_and(crate::ShutdownSignal::is_shutdown_requested)
2590        {
2591            stop.request_stop();
2592        }
2593    }
2594
2595    async fn poll_execution_control(
2596        &self,
2597        execution_id: JobExecutionId,
2598        stop: &StopToken,
2599    ) -> Result<(), FlowRuntimeError> {
2600        let Some((owner, _)) = self.execution_control else {
2601            return Ok(());
2602        };
2603        let mut unit = self.repository.begin().await?;
2604        let control = unit
2605            .observe_execution_control(execution_id, &owner, self.clock.now())
2606            .await?;
2607        unit.commit().await?;
2608        if !control.owner_matches() {
2609            return Err(RepositoryError::ExecutionOwned { id: execution_id }.into());
2610        }
2611        if control.stop_requested() {
2612            stop.request_stop();
2613        }
2614        Ok(())
2615    }
2616
2617    async fn invoke_with_execution_control(
2618        &self,
2619        execution_id: JobExecutionId,
2620        tasklet: &dyn crate::Tasklet,
2621        context: TaskletContext<'_>,
2622        stop: &StopToken,
2623    ) -> Result<Result<TaskletOutcome, TaskletFailure>, FlowRuntimeError> {
2624        if self.execution_control.is_none() && self.shutdown_signal.is_none() {
2625            return Ok(invoke_tasklet(tasklet, context).await);
2626        }
2627        let invocation = invoke_tasklet(tasklet, context);
2628        tokio::pin!(invocation);
2629        let mut shutdown_observed = false;
2630        loop {
2631            tokio::select! {
2632                result = &mut invocation => return Ok(result),
2633                () = async {
2634                    match self.execution_control {
2635                        Some((_, interval)) => tokio::time::sleep(interval.get()).await,
2636                        None => std::future::pending().await,
2637                    }
2638                } => {
2639                    self.poll_execution_control(execution_id, stop).await?;
2640                }
2641                () = async {
2642                    match self.shutdown_signal {
2643                        Some(signal) => signal.cancelled().await,
2644                        None => std::future::pending().await,
2645                    }
2646                }, if !shutdown_observed => {
2647                    shutdown_observed = true;
2648                    stop.request_stop();
2649                }
2650            }
2651        }
2652    }
2653
2654    async fn create_job_execution(
2655        &self,
2656        key: &JobInstanceKey,
2657        definition: &crate::DefinitionIdentity,
2658    ) -> Result<(JobInstance, JobExecution, ExecutionAttempt), FlowRuntimeError> {
2659        let mut unit = self.repository.begin().await?;
2660        let instance = unit
2661            .select_or_create_job_instance(key)
2662            .await?
2663            .instance()
2664            .clone();
2665        let execution = unit
2666            .create_job_execution_with_definition(instance.id(), definition)
2667            .await?;
2668        let execution = if let Some((owner, _)) = self.execution_control {
2669            unit.claim_execution_owner(
2670                execution.id(),
2671                execution.version(),
2672                &owner,
2673                self.clock.now(),
2674            )
2675            .await?
2676        } else {
2677            execution
2678        };
2679        let attempt = NonZeroU64::new(
2680            u64::try_from(unit.job_executions(instance.id()).await?.len())
2681                .map_err(|_| FlowRuntimeError::CountExhausted)?,
2682        )
2683        .map(ExecutionAttempt::new)
2684        .ok_or(FlowRuntimeError::CountExhausted)?;
2685        unit.commit().await?;
2686        Ok((instance, execution, attempt))
2687    }
2688
2689    async fn start_job(&self, execution: &JobExecution) -> Result<JobExecution, FlowRuntimeError> {
2690        let mut unit = self.repository.begin().await?;
2691        let started = unit
2692            .transition_job_execution(
2693                execution.id(),
2694                execution.version(),
2695                LifecycleTransition::new(BatchStatus::Started, self.clock.now()),
2696            )
2697            .await?;
2698        unit.commit().await?;
2699        Ok(started)
2700    }
2701
2702    async fn finish_job(
2703        &self,
2704        execution: &JobExecution,
2705        status: BatchStatus,
2706        failure: Option<FailureSummary>,
2707    ) -> Result<JobExecution, FlowRuntimeError> {
2708        let exit = exit_for_status(status);
2709        let mut unit = self.repository.begin().await?;
2710        let execution = if self.execution_control.is_some() {
2711            unit.get_job_execution(execution.id())
2712                .await?
2713                .ok_or(RepositoryError::JobExecutionNotFound { id: execution.id() })?
2714        } else {
2715            execution.clone()
2716        };
2717        let enriched = unit
2718            .enrich_job_exit_status(execution.id(), execution.version(), &exit)
2719            .await?;
2720        let transition = terminal_transition(status, self.clock.now(), failure)?;
2721        let finished = unit
2722            .transition_job_execution(enriched.id(), enriched.version(), transition)
2723            .await?;
2724        unit.commit().await?;
2725        Ok(finished)
2726    }
2727
2728    async fn create_step(
2729        &self,
2730        job_execution_id: JobExecutionId,
2731        step_name: &StepName,
2732        node_id: &NodeId,
2733        limit: StartLimit,
2734    ) -> Result<StepExecution, FlowRuntimeError> {
2735        let mut unit = self.repository.begin().await?;
2736        let step = unit
2737            .create_flow_step_execution(job_execution_id, step_name, node_id, limit)
2738            .await?;
2739        unit.commit().await?;
2740        Ok(step)
2741    }
2742
2743    async fn latest_step(
2744        &self,
2745        instance_id: JobInstanceId,
2746        node_id: &NodeId,
2747    ) -> Result<Option<FlowStepState>, FlowRuntimeError> {
2748        let mut unit = self.repository.begin().await?;
2749        let state = unit.latest_flow_step(instance_id, node_id).await?;
2750        unit.rollback().await?;
2751        Ok(state)
2752    }
2753
2754    async fn reusable_decision(
2755        &self,
2756        instance_id: JobInstanceId,
2757        node_id: &NodeId,
2758        fingerprint: &[u8; 32],
2759        digest: &[u8; 32],
2760        kind: FlowTransitionKind,
2761    ) -> Result<Option<FlowDecision>, FlowRuntimeError> {
2762        let mut unit = self.repository.begin().await?;
2763        let decision = unit
2764            .find_reusable_flow_decision(instance_id, node_id, fingerprint, digest, kind)
2765            .await?;
2766        unit.rollback().await?;
2767        Ok(decision)
2768    }
2769
2770    async fn append_decision(
2771        &self,
2772        request: &FlowDecisionRequest,
2773    ) -> Result<FlowDecision, FlowRuntimeError> {
2774        let mut unit = self.repository.begin().await?;
2775        let decision = unit.append_flow_decision(request).await?;
2776        unit.commit().await?;
2777        Ok(decision)
2778    }
2779
2780    #[allow(clippy::too_many_lines)]
2781    async fn run_step(
2782        &self,
2783        node_id: &NodeId,
2784        step: &TaskletStep,
2785        created: StepExecution,
2786        parameters: &JobParameters,
2787        stop_token: &StopToken,
2788        correlation: &ExecutionCorrelation,
2789    ) -> Result<StepRun, FlowRuntimeError> {
2790        let context = ListenerContext::new(correlation, parameters, stop_token);
2791        for (index, listener) in step.listeners().iter().enumerate() {
2792            if let Err(kind) = invoke_before_step(listener.as_ref(), context).await {
2793                let summary = self.next_failure_summary(FailureCategory::UserComponent)?;
2794                let failure = ListenerFailure::new(ListenerPhase::BeforeStep, index, kind, summary);
2795                let outcome = if kind == ListenerFailureKind::Panic {
2796                    TaskletExecutionOutcome::Failed(TaskletFailure::ListenerPanic)
2797                } else {
2798                    TaskletExecutionOutcome::Failed(TaskletFailure::ListenerError)
2799                };
2800                let execution = self
2801                    .finish_step(
2802                        &created,
2803                        outcome,
2804                        &ExitStatus::failed(),
2805                        Some(summary),
2806                        false,
2807                    )
2808                    .await?;
2809                self.emit_flow_event(&FlowEvent::new(
2810                    FlowEventKind::StepResultCommitted,
2811                    correlation.job_name().clone(),
2812                    correlation.job_instance_id(),
2813                    correlation.job_execution_id(),
2814                    correlation.job_attempt(),
2815                    node_id.clone(),
2816                    Some(execution.id()),
2817                    None,
2818                    self.clock.now(),
2819                ));
2820                return Ok(StepRun {
2821                    execution,
2822                    outcome,
2823                    exit_status: ExitStatus::failed(),
2824                    failure: Some(summary),
2825                    flow_failure: Some(FlowFailure::Listener(match outcome {
2826                        TaskletExecutionOutcome::Failed(value) => value,
2827                        _ => TaskletFailure::ListenerError,
2828                    })),
2829                    listener_failures: vec![failure],
2830                });
2831            }
2832        }
2833
2834        let started = self.start_step(&created).await?;
2835        let terminal_rollback = AtomicBool::new(false);
2836        let tasklet_context = TaskletContext::new_for_flow(
2837            parameters,
2838            started.job_execution_id(),
2839            started.id(),
2840            stop_token,
2841            correlation,
2842            &terminal_rollback,
2843        );
2844        let invoked = self
2845            .invoke_with_execution_control(
2846                correlation.job_execution_id(),
2847                step.tasklet(),
2848                tasklet_context,
2849                stop_token,
2850            )
2851            .await?;
2852        let (mut outcome, mut exit, tasklet_failure) = match invoked {
2853            Ok(TaskletOutcome::Completed) if !stop_token.is_stop_requested() => (
2854                TaskletExecutionOutcome::Completed,
2855                ExitStatus::completed(),
2856                None,
2857            ),
2858            Ok(TaskletOutcome::CompletedWith(exit)) if !stop_token.is_stop_requested() => {
2859                (TaskletExecutionOutcome::Completed, exit, None)
2860            }
2861            Ok(
2862                TaskletOutcome::Completed
2863                | TaskletOutcome::CompletedWith(_)
2864                | TaskletOutcome::Stopped,
2865            ) => (
2866                TaskletExecutionOutcome::Stopped(StopTiming::DuringExecution),
2867                ExitStatus::stopped(),
2868                None,
2869            ),
2870            Ok(TaskletOutcome::StoppedAfterBlockingWork) => (
2871                TaskletExecutionOutcome::Stopped(StopTiming::AfterBlockingWork),
2872                ExitStatus::stopped(),
2873                None,
2874            ),
2875            Ok(TaskletOutcome::CommitOutcomeUnknown) => (
2876                TaskletExecutionOutcome::Unknown,
2877                ExitStatus::unknown(),
2878                None,
2879            ),
2880            Err(failure) => (
2881                TaskletExecutionOutcome::Failed(failure),
2882                ExitStatus::failed(),
2883                Some(failure),
2884            ),
2885        };
2886        let tasklet_summary = tasklet_failure
2887            .map(|_| self.next_failure_summary(FailureCategory::UserComponent))
2888            .transpose()?;
2889        let mut failures = Vec::new();
2890        for (index, listener) in step.listeners().iter().enumerate().rev() {
2891            if let Err(kind) = invoke_after_step(listener.as_ref(), context, outcome).await {
2892                let summary = self.next_failure_summary(FailureCategory::UserComponent)?;
2893                failures.push(ListenerFailure::new(
2894                    ListenerPhase::AfterStep,
2895                    index,
2896                    kind,
2897                    summary,
2898                ));
2899            }
2900        }
2901        if let Some(first) = failures.first() {
2902            outcome = if first.kind() == ListenerFailureKind::Panic {
2903                TaskletExecutionOutcome::Failed(TaskletFailure::ListenerPanic)
2904            } else {
2905                TaskletExecutionOutcome::Failed(TaskletFailure::ListenerError)
2906            };
2907            exit = ExitStatus::failed();
2908        }
2909        let failure = failures
2910            .first()
2911            .map(|failure| failure.summary())
2912            .or(tasklet_summary);
2913        let durable = self.reload_step(started.id()).await?;
2914        let execution = self
2915            .finish_step(
2916                &durable,
2917                outcome,
2918                &exit,
2919                failure,
2920                terminal_rollback.load(Ordering::Acquire),
2921            )
2922            .await?;
2923        self.emit_flow_event(&FlowEvent::new(
2924            FlowEventKind::StepResultCommitted,
2925            correlation.job_name().clone(),
2926            correlation.job_instance_id(),
2927            correlation.job_execution_id(),
2928            correlation.job_attempt(),
2929            node_id.clone(),
2930            Some(execution.id()),
2931            None,
2932            self.clock.now(),
2933        ));
2934        Ok(StepRun {
2935            execution,
2936            outcome,
2937            exit_status: exit,
2938            failure,
2939            flow_failure: match outcome {
2940                TaskletExecutionOutcome::Failed(value) => Some(
2941                    if matches!(
2942                        value,
2943                        TaskletFailure::ListenerError | TaskletFailure::ListenerPanic
2944                    ) {
2945                        FlowFailure::Listener(value)
2946                    } else {
2947                        FlowFailure::Tasklet(value)
2948                    },
2949                ),
2950                _ => None,
2951            },
2952            listener_failures: failures,
2953        })
2954    }
2955
2956    async fn start_step(&self, step: &StepExecution) -> Result<StepExecution, FlowRuntimeError> {
2957        let mut unit = self.repository.begin().await?;
2958        let started = unit
2959            .transition_step_execution(
2960                step.id(),
2961                step.version(),
2962                LifecycleTransition::new(BatchStatus::Started, self.clock.now()),
2963            )
2964            .await?;
2965        unit.commit().await?;
2966        Ok(started)
2967    }
2968
2969    async fn reload_step(&self, id: StepExecutionId) -> Result<StepExecution, FlowRuntimeError> {
2970        let mut unit = self.repository.begin().await?;
2971        let step = unit
2972            .get_step_execution(id)
2973            .await?
2974            .ok_or(RepositoryError::StepExecutionNotFound { id })?;
2975        unit.rollback().await?;
2976        Ok(step)
2977    }
2978
2979    async fn finish_step(
2980        &self,
2981        step: &StepExecution,
2982        outcome: TaskletExecutionOutcome,
2983        exit: &ExitStatus,
2984        failure: Option<FailureSummary>,
2985        terminal_rollback: bool,
2986    ) -> Result<StepExecution, FlowRuntimeError> {
2987        let status = status_for_tasklet(outcome);
2988        let mut unit = self.repository.begin().await?;
2989        let enriched = unit
2990            .enrich_step_exit_status(step.id(), step.version(), exit)
2991            .await?;
2992        let mut transition = terminal_transition(status, self.clock.now(), failure)?;
2993        if terminal_rollback {
2994            transition = transition.with_terminal_rollback();
2995        }
2996        let finished = unit
2997            .transition_step_execution(enriched.id(), enriched.version(), transition)
2998            .await?;
2999        match unit.commit().await {
3000            Ok(()) => Ok(finished),
3001            Err(RepositoryError::CommitOutcomeUnknown) => {
3002                let durable = self.reload_step(finished.id()).await?;
3003                if durable == finished {
3004                    Ok(durable)
3005                } else {
3006                    Err(RepositoryError::CommitOutcomeUnknown.into())
3007                }
3008            }
3009            Err(error) => Err(error.into()),
3010        }
3011    }
3012
3013    fn next_failure_summary(
3014        &self,
3015        category: FailureCategory,
3016    ) -> Result<FailureSummary, FlowRuntimeError> {
3017        Ok(FailureSummary::new(
3018            category,
3019            self.ids
3020                .next_failure_id()
3021                .map_err(RepositoryError::Identifier)?,
3022        ))
3023    }
3024
3025    fn emit_flow_event(&self, event: &FlowEvent) {
3026        let Some(sink) = self.event_sink else {
3027            return;
3028        };
3029        let _ = catch_unwind(AssertUnwindSafe(|| sink.emit(event)));
3030    }
3031}
3032
3033fn partition_worker_identity(
3034    compiled: &crate::PartitionedStepNode,
3035    partition: &StepPartition,
3036) -> Result<(StepName, NodeId), FlowRuntimeError> {
3037    let mut digest = Sha256::new();
3038    digest.update(b"oxide-batch.local-partition-worker.v1\0");
3039    digest.update(compiled.id().as_str().as_bytes());
3040    digest.update([0]);
3041    digest.update(partition.key().as_str().as_bytes());
3042    let token = format!(
3043        "__ob_partition_worker_{}",
3044        oxide_batch_repository::hex_digest(&digest.finalize())
3045    );
3046    let step_name =
3047        StepName::new(token.clone()).map_err(|_| RepositoryError::PartitionStateCorrupt)?;
3048    let node_id = NodeId::new(token).map_err(|_| RepositoryError::PartitionStateCorrupt)?;
3049    Ok((step_name, node_id))
3050}
3051
3052struct StepRun {
3053    execution: StepExecution,
3054    outcome: TaskletExecutionOutcome,
3055    exit_status: ExitStatus,
3056    failure: Option<FailureSummary>,
3057    flow_failure: Option<FlowFailure>,
3058    listener_failures: Vec<ListenerFailure>,
3059}
3060
3061struct SplitBranchRun {
3062    index: usize,
3063    status: BatchStatus,
3064    exit_status: ExitStatus,
3065    failure: Option<FailureSummary>,
3066    flow_failure: Option<FlowFailure>,
3067    states: Vec<FlowStepState>,
3068    step_executions: Vec<StepExecution>,
3069    listener_failures: Vec<ListenerFailure>,
3070}
3071
3072struct SplitRun {
3073    status: BatchStatus,
3074    exit_status: ExitStatus,
3075    failure: Option<FailureSummary>,
3076    flow_failure: Option<FlowFailure>,
3077    input_digest: [u8; 32],
3078    step_executions: Vec<StepExecution>,
3079    listener_failures: Vec<ListenerFailure>,
3080}
3081
3082struct PartitionWorkerRun {
3083    partition: StepPartition,
3084    execution: StepExecution,
3085    flow_failure: Option<FlowFailure>,
3086    listener_failures: Vec<ListenerFailure>,
3087}
3088
3089struct PartitionRun {
3090    status: BatchStatus,
3091    exit_status: ExitStatus,
3092    parent: StepExecution,
3093    flow_failure: Option<FlowFailure>,
3094    worker_executions: Vec<StepExecution>,
3095    listener_failures: Vec<ListenerFailure>,
3096}
3097
3098const fn split_status_severity(status: BatchStatus) -> u8 {
3099    match status {
3100        BatchStatus::Completed => 0,
3101        BatchStatus::Stopped => 1,
3102        BatchStatus::Failed => 2,
3103        BatchStatus::Unknown => 3,
3104        // The non-terminal and abandoned statuses, and any status this build
3105        // does not know: `BatchStatus` is `#[non_exhaustive]`, and an
3106        // unrecognized status takes the highest severity so it dominates the
3107        // aggregate rather than being mistaken for a completed branch.
3108        _ => 4,
3109    }
3110}
3111
3112fn split_input_digest(
3113    fingerprint: &[u8; 32],
3114    join: &NodeId,
3115    branches: &[SplitBranchRun],
3116) -> [u8; 32] {
3117    let mut hash = Sha256::new();
3118    hash.update(b"oxide-batch.split-aggregate-input.v1\0");
3119    hash.update(fingerprint);
3120    hash_field(&mut hash, join.as_str().as_bytes());
3121    for branch in branches {
3122        hash.update(
3123            u64::try_from(branch.index)
3124                .unwrap_or(u64::MAX)
3125                .to_be_bytes(),
3126        );
3127        hash_field(&mut hash, branch.status.to_string().as_bytes());
3128        hash_field(&mut hash, branch.exit_status.code().as_str().as_bytes());
3129        for state in &branch.states {
3130            hash.update(step_input_digest(fingerprint, state));
3131        }
3132    }
3133    hash.finalize().into()
3134}
3135
3136fn next_sequence(length: usize) -> Result<FlowDecisionSequence, FlowRuntimeError> {
3137    let next = u64::try_from(length)
3138        .ok()
3139        .and_then(|value| value.checked_add(1))
3140        .ok_or(FlowRuntimeError::DecisionSequenceExhausted)?;
3141    // The engine keeps the runtime error its callers already match on; the
3142    // constructor's own domain error never escapes, because `next` is at
3143    // least one here.
3144    FlowDecisionSequence::new(next).map_err(|_| FlowRuntimeError::DecisionSequenceExhausted)
3145}
3146
3147fn correlation(
3148    job_name: &JobName,
3149    instance_id: JobInstanceId,
3150    job_execution_id: JobExecutionId,
3151    attempt: ExecutionAttempt,
3152    step_name: &StepName,
3153    step_execution_id: StepExecutionId,
3154    completed_steps: usize,
3155) -> Result<ExecutionCorrelation, FlowRuntimeError> {
3156    let step_attempt = u64::try_from(completed_steps)
3157        .ok()
3158        .and_then(|value| value.checked_add(1))
3159        .and_then(NonZeroU64::new)
3160        .map(ExecutionAttempt::new)
3161        .ok_or(FlowRuntimeError::CountExhausted)?;
3162    Ok(ExecutionCorrelation::new(
3163        job_name.clone(),
3164        instance_id,
3165        job_execution_id,
3166        attempt,
3167        step_name.clone(),
3168        step_execution_id,
3169        step_attempt,
3170    ))
3171}
3172
3173fn status_for_tasklet(outcome: TaskletExecutionOutcome) -> BatchStatus {
3174    match outcome {
3175        TaskletExecutionOutcome::Completed => BatchStatus::Completed,
3176        TaskletExecutionOutcome::Stopped(_) => BatchStatus::Stopped,
3177        TaskletExecutionOutcome::Failed(_) => BatchStatus::Failed,
3178        TaskletExecutionOutcome::Unknown => BatchStatus::Unknown,
3179    }
3180}
3181
3182fn exit_for_status(status: BatchStatus) -> ExitStatus {
3183    match status {
3184        BatchStatus::Completed => ExitStatus::completed(),
3185        BatchStatus::Stopped => ExitStatus::stopped(),
3186        BatchStatus::Unknown => ExitStatus::unknown(),
3187        _ => ExitStatus::failed(),
3188    }
3189}
3190
3191fn terminal_transition(
3192    status: BatchStatus,
3193    at: SystemTime,
3194    failure: Option<FailureSummary>,
3195) -> Result<LifecycleTransition, FlowRuntimeError> {
3196    if status == BatchStatus::Failed {
3197        let failure = failure.ok_or(FlowRuntimeError::CountExhausted)?;
3198        Ok(LifecycleTransition::failed(at, failure))
3199    } else {
3200        Ok(LifecycleTransition::new(status, at))
3201    }
3202}
3203
3204fn step_input_digest(fingerprint: &[u8; 32], state: &FlowStepState) -> [u8; 32] {
3205    let mut hash = Sha256::new();
3206    hash.update(b"oxide-batch.flow-step-input.v1\0");
3207    hash.update(fingerprint);
3208    hash_field(&mut hash, state.node_id().as_str().as_bytes());
3209    hash.update(state.execution().id().get().to_be_bytes());
3210    hash.update(state.execution().version().get().to_be_bytes());
3211    hash_field(
3212        &mut hash,
3213        state.execution().metadata().status().to_string().as_bytes(),
3214    );
3215    hash_field(
3216        &mut hash,
3217        state
3218            .execution()
3219            .metadata()
3220            .exit_status()
3221            .code()
3222            .as_str()
3223            .as_bytes(),
3224    );
3225    hash_counts(&mut hash, state.execution().metadata().counts());
3226    if let Some(context) = state.context()
3227        && let Ok(bytes) = context.to_json()
3228    {
3229        hash.update(Sha256::digest(bytes));
3230    }
3231    hash.finalize().into()
3232}
3233
3234fn decision_input_digest(
3235    fingerprint: &[u8; 32],
3236    node_id: &NodeId,
3237    revision: &str,
3238    input_version: u32,
3239    instance_id: JobInstanceId,
3240    parameters: &JobParameters,
3241    preceding: Option<&FlowStepState>,
3242) -> [u8; 32] {
3243    let mut hash = Sha256::new();
3244    hash.update(b"oxide-batch.decider-input.v1\0");
3245    hash.update(fingerprint);
3246    hash_field(&mut hash, node_id.as_str().as_bytes());
3247    hash_field(&mut hash, revision.as_bytes());
3248    hash.update(input_version.to_be_bytes());
3249    hash.update(instance_id.get().to_be_bytes());
3250    // JobParameters intentionally owns this sensitive canonical projection;
3251    // only its one-way digest is retained in the flow record.
3252    hash.update(parameters.flow_input_digest());
3253    if let Some(state) = preceding {
3254        hash.update(step_input_digest(fingerprint, state));
3255    }
3256    hash.finalize().into()
3257}
3258
3259fn hash_field(hash: &mut Sha256, value: &[u8]) {
3260    let length = u64::try_from(value.len()).unwrap_or(u64::MAX);
3261    hash.update(length.to_be_bytes());
3262    hash.update(value);
3263}
3264
3265fn hash_counts(hash: &mut Sha256, counts: ExecutionCounts) {
3266    for count in [
3267        counts.read(),
3268        counts.processed(),
3269        counts.written(),
3270        counts.filtered(),
3271        counts.committed(),
3272        counts.rolled_back(),
3273    ] {
3274        hash.update(count.to_be_bytes());
3275    }
3276}