Skip to main content

dataflow_rs/engine/
workflow_executor.rs

1//! # Workflow Execution Module
2//!
3//! This module handles the execution of workflows and their associated tasks.
4//! It provides a clean separation between workflow orchestration and task execution.
5
6use crate::engine::error::{
7    DataflowError, ErrorContextConfig, ErrorInfo, Result, service_error_code,
8};
9use crate::engine::executor::{
10    ArenaContext, evaluate_condition, evaluate_condition_in_arena, with_arena,
11};
12use crate::engine::functions::BoxedFunctionHandler;
13use crate::engine::message::{AuditTrail, Change, Message};
14use crate::engine::observer::{
15    ExecutionObserver, MessageFinished, MessageStarted, TaskEvent, WorkflowFinished,
16    WorkflowStarted,
17};
18use crate::engine::task::Task;
19use crate::engine::task_context::TaskIdentity;
20use crate::engine::task_executor::TaskExecutor;
21use crate::engine::task_outcome::TaskOutcome;
22use crate::engine::trace::{ExecutionStep, ExecutionTrace, StepTiming, duration_us_between};
23use crate::engine::utils::{
24    compute_path_parts, set_nested_value, set_nested_value_parts, strip_hash_prefix,
25};
26use crate::engine::workflow::{LoopConfig, Workflow};
27use chrono::{DateTime, Utc};
28use core::time::Duration;
29use datalogic_rs::{Engine, Logic};
30use datavalue::OwnedDataValue;
31use log::{debug, error, info, warn};
32use serde_json::Value;
33use std::collections::HashMap;
34use std::sync::Arc;
35
36/// Result of handling a task, including possible control flow signals
37enum TaskControlFlow {
38    /// Continue executing the next task
39    Continue,
40    /// Stop executing further tasks in this workflow (filter halt)
41    HaltWorkflow,
42}
43
44/// Constants shared by every task in one pass over a workflow's task list.
45///
46/// Tracks one workflow's observer span across however many sweeps it runs.
47///
48/// `started_at` doubles as "has `workflow_started` been emitted": a looping
49/// workflow opens the span on its first admitted sweep and closes it once, so
50/// the observer sees one pair for the whole loop rather than one per sweep.
51#[derive(Default)]
52struct WorkflowSpan {
53    started_at: Option<DateTime<Utc>>,
54    sweeps: u32,
55}
56
57/// Bundles the per-message timestamp with the loop counter so that threading
58/// the counter through the task loop did not push `run_tasks_slice_in_arena`
59/// and `handle_task_result` past clippy's argument-count threshold.
60#[derive(Clone, Copy)]
61struct PassCtx {
62    /// The single `Utc::now()` read for this `process_message` call, shared by
63    /// every `AuditTrail` it produces.
64    now: DateTime<Utc>,
65    /// Loop counter of the sweep this pass is, or `None` for a workflow
66    /// without a `loop`.
67    loop_counter: Option<i64>,
68}
69
70impl PassCtx {
71    /// The single pass of a workflow without a `loop`.
72    #[inline]
73    fn once(now: DateTime<Utc>) -> Self {
74        Self {
75            now,
76            loop_counter: None,
77        }
78    }
79
80    /// Record the executed-step trace entry for one task.
81    ///
82    /// Emitted identically by both task loops — only `mapping_contexts`
83    /// differs, since the sync stretch collects per-mapping snapshots for `map`
84    /// tasks and the async boundary never has any. Shared so a field added to
85    /// the step cannot be added to one loop and forgotten in the other.
86    fn note_executed(
87        self,
88        trace: Option<&mut ExecutionTrace>,
89        workflow_id: &str,
90        task_id: &str,
91        message: &Message,
92        clocks: TaskClocks,
93        mapping_contexts: Option<Vec<Value>>,
94    ) {
95        let Some(t) = trace else {
96            return;
97        };
98        let started_at = clocks.trace_start.unwrap_or(self.now);
99        t.add_executed_step(
100            workflow_id,
101            task_id,
102            message,
103            StepTiming {
104                started_at,
105                duration_us: duration_us_between(started_at, Utc::now()),
106            },
107            mapping_contexts,
108            self.loop_counter,
109        );
110    }
111}
112
113/// The two clock reads and the error watermark a task takes before its body
114/// runs.
115///
116/// Bundled and sampled in one place because *when* they are read is the whole
117/// contract: a `trace_start` taken after the body would mistime the step, and
118/// an `errors_before` taken after it would drop every error the task
119/// contributed. Both task loops spelled the three out identically, where a
120/// one-sided edit had nothing to catch it.
121#[derive(Clone, Copy)]
122struct TaskClocks {
123    /// `Utc::now()` at task start, but only when a trace is live.
124    trace_start: Option<DateTime<Utc>>,
125    /// `trace_start`, or the observer's own clock read when only an observer is
126    /// attached. `None` on the plain path, which is what keeps the documented
127    /// one-`Utc::now()`-per-message invariant.
128    obs_start: Option<DateTime<Utc>>,
129    /// `message.errors.len()` immediately before the body ran, so the errors
130    /// this task contributed are exactly the tail beyond this index.
131    errors_before: usize,
132}
133
134/// What the group gate and the two conditions decided about one task.
135enum Admission {
136    /// A terminal group closed at this task — the workflow halts before it.
137    Halt,
138    /// A group's condition was false; resume at this absolute task index.
139    Jump(usize),
140    /// The task's own condition was false; move on to the next task.
141    Skip,
142    /// Run it.
143    Run,
144}
145
146/// Run the group gate and both conditions for one task, recording the skips
147/// they imply.
148///
149/// This is the identical opening of both task loops: close spans that ended
150/// before this task (a terminal one halts), open the spans that start here (a
151/// false condition jumps past the span without consulting the member tasks'
152/// own conditions), then the task's own condition. Only the *evaluation*
153/// differs between the loops — the owned context on the async path, the shared
154/// arena view on the sync one — so it arrives as `eval`. Both flavours already
155/// map a `None` condition to `true`, so `eval` takes the `Option` directly.
156fn admit_task(
157    workflow: &Workflow,
158    task: &Task,
159    abs: usize,
160    gate: &mut GroupGate,
161    mut trace: Option<&mut ExecutionTrace>,
162    pass: PassCtx,
163    mut eval: impl FnMut(Option<&Arc<Logic>>) -> Result<bool>,
164) -> Result<Admission> {
165    if gate.close_through(abs) {
166        return Ok(Admission::Halt);
167    }
168
169    if let Some(target) = gate.enter(task, &mut eval)? {
170        note_group_skip(
171            trace.as_deref_mut(),
172            workflow,
173            abs,
174            target,
175            pass.loop_counter,
176        );
177        return Ok(Admission::Jump(target));
178    }
179
180    if !eval(task.compiled_condition.as_ref())? {
181        note_task_skip(trace, &workflow.id, &task.id, pass.loop_counter);
182        return Ok(Admission::Skip);
183    }
184
185    Ok(Admission::Run)
186}
187
188/// The two per-*task* values `handle_task_result` needs beyond the shared
189/// [`PassCtx`].
190///
191/// Bundled rather than passed separately because `handle_task_result` already
192/// sits at clippy's `too_many_arguments` threshold, and `PassCtx` cannot carry
193/// them — it is per-pass and shared by every task in a sweep.
194#[derive(Clone, Copy)]
195struct TaskPass {
196    /// The task-level `continue_on_error` flag.
197    continue_on_error: bool,
198    /// The task-level `terminal` flag — halt the workflow once this task has
199    /// run, whatever it returned.
200    terminal: bool,
201    /// `message.errors.len()` immediately before this task ran, so the errors it
202    /// contributed can be identified as the tail beyond this index.
203    errors_before: usize,
204}
205
206/// One slice of a workflow's task list, plus the group state that spans slices.
207///
208/// Bundled into a single parameter because `run_tasks_slice_in_arena` already
209/// sits at clippy's `too_many_arguments` threshold, and because the three
210/// travel together: an absolute task index is `offset + i`, and `gate` is the
211/// only thing that has to survive from one slice to the next.
212struct TaskSlice<'a, 'arena> {
213    /// The tasks to run — a sub-slice of `workflow.tasks`.
214    tasks: &'arena [Task],
215    /// Index of `tasks[0]` within `workflow.tasks`.
216    offset: usize,
217    /// Group state for the whole pass, shared across every slice in it.
218    gate: &'a mut GroupGate,
219}
220
221/// Result of running one slice of a workflow's task list.
222enum SliceOutcome {
223    /// The slice ran to its end.
224    Completed,
225    /// A task halted the workflow — `TaskOutcome::Halt`, `Task::terminal`, or
226    /// the end of a terminal group.
227    Halted,
228    /// A group condition was false and its span ends beyond this slice, so the
229    /// caller must resume at this absolute task index.
230    JumpTo(usize),
231}
232
233/// Tracks which task groups are currently open during one pass over a
234/// workflow's task list.
235///
236/// Group spans are recorded at parse time on the task that opens them
237/// (`Task::group_starts`), so the executor keeps walking a flat `&[Task]`.
238/// This gate turns those spans back into control flow: evaluate a group's
239/// condition **once** on entry, jump past the span when it is false, and halt
240/// when a terminal group closes.
241///
242/// A workflow using no groups never pushes, so the gate costs one
243/// `Vec::is_empty` check per task and never allocates.
244#[derive(Default)]
245struct GroupGate {
246    /// `(end, terminal)` for each open group, outermost first.
247    open: Vec<(usize, bool)>,
248}
249
250impl GroupGate {
251    /// Close every open group whose span ends at or before `idx`, returning
252    /// `true` if any of them was `terminal`.
253    ///
254    /// Driven by `end` rather than by a per-task close count because a jump can
255    /// skip straight past the task that would have carried the count: with
256    /// `group A { group B { t1 } }` and `B`'s condition false, nothing in `A`
257    /// ever executes, yet `A` was entered and — if terminal — must still halt.
258    fn close_through(&mut self, idx: usize) -> bool {
259        let mut terminal = false;
260        while let Some(&(end, is_terminal)) = self.open.last() {
261            if end > idx {
262                break;
263            }
264            self.open.pop();
265            terminal |= is_terminal;
266        }
267        terminal
268    }
269
270    /// Evaluate the groups opening at `task`, outermost first. Returns
271    /// `Some(end)` when one's condition is false and the cursor must jump past
272    /// its span; the groups outside it stay open.
273    fn enter<F>(&mut self, task: &Task, mut eval: F) -> Result<Option<usize>>
274    where
275        F: FnMut(Option<&Arc<Logic>>) -> Result<bool>,
276    {
277        for group in &task.group_starts {
278            if !eval(group.compiled_condition.as_ref())? {
279                return Ok(Some(group.end));
280            }
281            self.open.push((group.end, group.terminal));
282        }
283        Ok(None)
284    }
285}
286
287/// Record the skip of every task in `workflow.tasks[from..to]` — the span of a
288/// group whose condition was false.
289///
290/// The trace stays task-granular rather than growing a group-level step, so
291/// `StepResult` and the npm wire type it mirrors are unchanged.
292fn note_group_skip(
293    mut trace: Option<&mut ExecutionTrace>,
294    workflow: &Workflow,
295    from: usize,
296    to: usize,
297    loop_counter: Option<i64>,
298) {
299    for task in &workflow.tasks[from..to.min(workflow.tasks.len())] {
300        note_task_skip(trace.as_deref_mut(), &workflow.id, &task.id, loop_counter);
301    }
302}
303
304/// Result of one pass over a workflow's task list.
305enum PassOutcome {
306    /// The workflow condition evaluated false — no task ran.
307    ConditionFalse,
308    /// Every task ran (or was individually skipped) to the end of the list.
309    Completed,
310    /// A task returned [`TaskOutcome::Halt`].
311    Halted,
312}
313
314/// Return the index of the first task at or after `start` that is *not* a
315/// synchronous built-in. Used to chunk `workflow.tasks` into sync-only
316/// stretches that can share a single `ArenaContext`.
317fn next_async_boundary(tasks: &[Task], start: usize) -> usize {
318    let mut i = start;
319    while i < tasks.len() && tasks[i].function.is_sync_builtin() {
320        i += 1;
321    }
322    i
323}
324
325/// Log and (if tracing) record a whole-workflow skip. `reason` is only for the
326/// debug log — `ExecutionStep::workflow_skipped` doesn't carry one, so a
327/// rollout-bucket exclusion and a false condition are indistinguishable in the
328/// trace, same as before this was factored out of its four call sites.
329fn note_workflow_skip(trace: Option<&mut ExecutionTrace>, workflow_id: &str, reason: &str) {
330    debug!("Skipping workflow {} - {}", workflow_id, reason);
331    if let Some(t) = trace {
332        t.add_step(ExecutionStep::workflow_skipped(workflow_id));
333    }
334}
335
336/// Log and (if tracing) record a single task's condition skip.
337///
338/// The async task loop and the shared-arena one both reach this point with the
339/// same state, and previously spelled the block out twice — every field added
340/// to the skipped step had to be added in both places, with nothing to catch a
341/// one-sided edit. Companion to [`note_workflow_skip`] above.
342fn note_task_skip(
343    trace: Option<&mut ExecutionTrace>,
344    workflow_id: &str,
345    task_id: &str,
346    loop_counter: Option<i64>,
347) {
348    debug!("Skipping task {} - condition not met", task_id);
349    if let Some(t) = trace {
350        t.add_step(
351            ExecutionStep::task_skipped(workflow_id, task_id).with_loop_counter(loop_counter),
352        );
353    }
354}
355
356/// Whether `workflow` serves this message's routing bucket.
357///
358/// A workflow with no `rollout`, or a message with no bucket, is admitted. The
359/// missing-bucket case admits deliberately: every message any existing caller
360/// builds has no bucket, and the wasm entry points have no way to set one, so
361/// rejecting would silently stop those workflows running.
362///
363/// Nested `match` rather than a let-chain: MSRV is 1.85. See
364/// `write_progress_metadata` below for the same reason.
365fn rollout_admits(workflow: &Workflow, message: &Message) -> bool {
366    match workflow.rollout {
367        None => true,
368        Some(r) => match message.routing_bucket() {
369            None => true,
370            Some(b) => r.accepts(b),
371        },
372    }
373}
374
375/// Whether `workflow` may join a shared-arena run of consecutive fully-sync
376/// workflows.
377///
378/// A looping workflow is excluded even when every task is a sync built-in: its
379/// sweeps run through `execute_inner`, which opens a fresh arena scope per
380/// sweep. Bump arenas never free mid-scope, so sweeping inside one shared
381/// scope would grow memory with the iteration count.
382fn joins_sync_run(workflow: &Workflow) -> bool {
383    workflow.fully_sync && workflow.loop_config.is_none()
384}
385
386/// Resolve the counter's pre-split write path, once per looping workflow.
387///
388/// `LogicCompiler` pre-splits `temp_data.{counter}` at build time. A workflow
389/// constructed directly rather than through `Engine::builder` never got that
390/// pass, so the parts are computed here instead — once, ahead of the sweep
391/// loop, rather than re-formatted and re-split on every sweep.
392///
393/// An unnamed counter resolves to an empty slice, which `set_nested_value_parts`
394/// treats as a no-op: the loop is still bounded, the value simply is not
395/// exposed to JSONLogic (the audit trail carries it either way).
396fn resolve_counter_parts(config: &LoopConfig) -> Arc<[Arc<str>]> {
397    match &config.counter {
398        Some(counter) if config.counter_parts.is_empty() => {
399            compute_path_parts("temp_data", counter)
400        }
401        _ => Arc::clone(&config.counter_parts),
402    }
403}
404
405/// Build a fresh `metadata.progress` object value.
406fn new_progress_object(workflow_id: &str, task_id: &str, status: u16) -> OwnedDataValue {
407    OwnedDataValue::Object(vec![
408        (
409            "workflow_id".to_string(),
410            OwnedDataValue::String(workflow_id.to_string()),
411        ),
412        (
413            "task_id".to_string(),
414            OwnedDataValue::String(task_id.to_string()),
415        ),
416        (
417            "status_code".to_string(),
418            OwnedDataValue::from(u64::from(status)),
419        ),
420    ])
421}
422
423/// Overwrite a string slot by reusing its existing buffer where possible.
424///
425/// The ids written per task are drawn from a small, repeating set — in a loop
426/// they are outright constant across every sweep — so the common case is
427/// writing the value that is already there. Comparing first turns that case
428/// into a no-op, and the mismatch case still reuses the allocation.
429fn overwrite_str_in_place(slot: &mut OwnedDataValue, value: &str) {
430    match slot {
431        OwnedDataValue::String(existing) => {
432            if existing != value {
433                existing.clear();
434                existing.push_str(value);
435            }
436        }
437        _ => *slot = OwnedDataValue::String(value.to_string()),
438    }
439}
440
441/// Overwrite the three fields of an existing 3-key `progress` object without
442/// reallocating it. Returns `false` when the object's shape diverges from
443/// `{workflow_id, task_id, status_code}`, in which case the caller replaces
444/// the slot wholesale (partial overwrites here are harmless — the whole slot
445/// gets replaced).
446fn overwrite_progress_in_place(
447    fields: &mut [(String, OwnedDataValue)],
448    workflow_id: &str,
449    task_id: &str,
450    status: u16,
451) -> bool {
452    if fields.len() != 3 {
453        return false;
454    }
455    let mut matched = 0;
456    for (k, v) in fields.iter_mut() {
457        match k.as_str() {
458            "workflow_id" => {
459                overwrite_str_in_place(v, workflow_id);
460                matched += 1;
461            }
462            "task_id" => {
463                overwrite_str_in_place(v, task_id);
464                matched += 1;
465            }
466            "status_code" => {
467                *v = OwnedDataValue::from(u64::from(status));
468                matched += 1;
469            }
470            _ => {}
471        }
472    }
473    matched == 3
474}
475
476/// Write `metadata.progress = {workflow_id, task_id, status_code}` with a
477/// single tree walk. From the second task of a message onward the slot
478/// already holds the expected 3-key object, so the three values are
479/// overwritten in place, reusing the id `String` buffers — no allocation at
480/// all once the shape settles. First write (or any shape divergence)
481/// replaces the slot wholesale; a context whose `metadata` is missing or
482/// non-Object falls back to the generic `set_nested_value` writer, which
483/// creates intermediate containers as needed.
484fn write_progress_metadata(
485    context: &mut OwnedDataValue,
486    workflow_id: &str,
487    task_id: &str,
488    status: u16,
489) {
490    // Nested `if let` rather than a let-chain: let-chains are stable only from
491    // Rust 1.88 and this crate's MSRV is 1.85. Keep it that way.
492    if let OwnedDataValue::Object(top) = context {
493        if let Some((_, OwnedDataValue::Object(meta))) =
494            top.iter_mut().find(|(k, _)| k == "metadata")
495        {
496            match meta.iter_mut().find(|(k, _)| k == "progress") {
497                Some((_, slot)) => {
498                    if let OwnedDataValue::Object(fields) = slot {
499                        if overwrite_progress_in_place(fields, workflow_id, task_id, status) {
500                            return;
501                        }
502                    }
503                    *slot = new_progress_object(workflow_id, task_id, status);
504                }
505                None => {
506                    meta.push((
507                        "progress".to_string(),
508                        new_progress_object(workflow_id, task_id, status),
509                    ));
510                }
511            }
512            return;
513        }
514    }
515    set_nested_value(
516        context,
517        "metadata.progress",
518        new_progress_object(workflow_id, task_id, status),
519    );
520}
521
522/// Build one context record for a failed task.
523///
524/// `workflow_id`, `task_id` and `status` come from the executor rather than from
525/// the `ErrorInfo`: `validation` builds its entries with `ErrorInfo::simple_ref`,
526/// which leaves both ids `None`, and `ErrorInfo` carries no status at all.
527///
528/// The error `message` and the operator-only `detail` are deliberately omitted —
529/// this value lands in `Message.context`, which is serialized back to callers.
530fn new_error_record(workflow_id: &str, task_id: &str, code: &str, status: u16) -> OwnedDataValue {
531    OwnedDataValue::Object(vec![
532        ("workflow_id".to_string(), OwnedDataValue::from(workflow_id)),
533        ("task_id".to_string(), OwnedDataValue::from(task_id)),
534        ("code".to_string(), OwnedDataValue::from(code)),
535        (
536            "status".to_string(),
537            OwnedDataValue::from(u64::from(status)),
538        ),
539    ])
540}
541
542/// Take `node` as an `Object`, replacing whatever non-`Object` sat there.
543///
544/// Normalise first, then destructure — the inverse order (match, then assign in
545/// the fallback arm and re-match the same binding) is NLL problem case #3 and
546/// does not compile.
547fn as_object_slot(node: &mut OwnedDataValue) -> &mut Vec<(String, OwnedDataValue)> {
548    if !matches!(node, OwnedDataValue::Object(_)) {
549        *node = OwnedDataValue::Object(Vec::new());
550    }
551    match node {
552        OwnedDataValue::Object(fields) => fields,
553        _ => unreachable!("just normalised to an Object"),
554    }
555}
556
557/// Append one record per entry in `new_errors` to the configured context path,
558/// keeping at most `cfg.limit` of them.
559///
560/// Hand-walks to the slot the way [`write_progress_metadata`] does. The generic
561/// [`set_nested_value`] cannot express an append — it indexes arrays by numeric
562/// segment and `Null`-pads the gap — and silently no-ops when a non-numeric
563/// segment meets an `Array`. A slot holding something other than an `Array` is
564/// replaced wholesale rather than skipped, so the shape a workflow author reads
565/// is predictable even if a `map` task wrote over the path first.
566///
567/// The array is created lazily, only when there is something to push, so a
568/// message whose tasks all succeed keeps the exact wire shape it had before the
569/// option existed — the key is absent, not `[]`.
570fn append_error_records(
571    context: &mut OwnedDataValue,
572    cfg: &ErrorContextConfig,
573    workflow_id: &str,
574    task_id: &str,
575    status: u16,
576    new_errors: &[ErrorInfo],
577) {
578    if new_errors.is_empty() {
579        return;
580    }
581    // Walk to the parent of the final segment, creating containers as needed,
582    // then take the slot itself.
583    let Some((last, parents)) = cfg.path_parts.split_last() else {
584        return;
585    };
586
587    let mut node = context;
588    for part in parents {
589        let key = strip_hash_prefix(part);
590        if !matches!(node, OwnedDataValue::Object(_)) {
591            // A scalar or array on the way down cannot hold a named child. The
592            // host declared the engine owns this path, so resolve the conflict
593            // in favour of the records rather than dropping them — but say so:
594            // whatever was written here is being discarded.
595            warn!(
596                "error context path `{}` runs through a non-object at `{}` — replacing it",
597                cfg.path, key
598            );
599        }
600        let fields = as_object_slot(node);
601        let idx = match fields.iter().position(|(k, _)| k == key) {
602            Some(i) => i,
603            None => {
604                fields.push((key.to_string(), OwnedDataValue::Object(Vec::new())));
605                fields.len() - 1
606            }
607        };
608        node = &mut fields[idx].1;
609    }
610
611    let key = strip_hash_prefix(last);
612    let fields = as_object_slot(node);
613    let idx = match fields.iter().position(|(k, _)| k == key) {
614        Some(i) => i,
615        None => {
616            fields.push((key.to_string(), OwnedDataValue::Array(Vec::new())));
617            fields.len() - 1
618        }
619    };
620    let slot = &mut fields[idx].1;
621    if !matches!(slot, OwnedDataValue::Array(_)) {
622        warn!(
623            "error context path `{}` held a non-array — replacing it",
624            cfg.path
625        );
626        *slot = OwnedDataValue::Array(Vec::new());
627    }
628    let OwnedDataValue::Array(items) = slot else {
629        unreachable!("just ensured an Array");
630    };
631
632    for error in new_errors {
633        items.push(new_error_record(workflow_id, task_id, &error.code, status));
634    }
635    // Keep-newest: a looping workflow with a failing body would otherwise grow
636    // this list once per sweep, and `Message.context` is deep-cloned into every
637    // trace snapshot.
638    if items.len() > cfg.limit {
639        items.drain(..items.len() - cfg.limit);
640    }
641}
642
643/// Handles the execution of workflows and their tasks
644///
645/// The `WorkflowExecutor` is responsible for:
646/// - Evaluating workflow conditions
647/// - Orchestrating task execution within workflows
648/// - Managing workflow-level error handling
649/// - Recording audit trails
650pub struct WorkflowExecutor {
651    /// Task executor for executing individual tasks
652    task_executor: Arc<TaskExecutor>,
653    /// Shared datalogic engine for condition evaluation
654    engine: Arc<Engine>,
655    /// Optional per-task observer. `None` keeps the instrumentation — and its
656    /// clock reads — entirely out of the dispatch path.
657    observer: Option<Arc<dyn ExecutionObserver>>,
658    /// Optional context path where per-task failure codes are mirrored. `None`
659    /// keeps the whole mechanism out of the dispatch path.
660    error_context: Option<Arc<ErrorContextConfig>>,
661}
662
663impl WorkflowExecutor {
664    /// Create a new WorkflowExecutor
665    pub fn new(task_executor: Arc<TaskExecutor>, engine: Arc<Engine>) -> Self {
666        Self {
667            task_executor,
668            engine,
669            observer: None,
670            error_context: None,
671        }
672    }
673
674    /// Attach an observer to an existing executor. Replaces any previous one.
675    pub fn with_observer(mut self, observer: Arc<dyn ExecutionObserver>) -> Self {
676        self.observer = Some(observer);
677        self
678    }
679
680    /// The registered observer, if any.
681    ///
682    /// Used by `Engine::with_new_workflows` to carry the observer across a hot
683    /// reload — without it, metrics would stop silently at the first reload.
684    pub fn observer(&self) -> Option<&Arc<dyn ExecutionObserver>> {
685        self.observer.as_ref()
686    }
687
688    /// Attach an error-context path to an existing executor. Replaces any
689    /// previous one.
690    pub(crate) fn with_error_context(mut self, cfg: Arc<ErrorContextConfig>) -> Self {
691        self.error_context = Some(cfg);
692        self
693    }
694
695    /// The configured error-context path, if any.
696    ///
697    /// Used by `Engine`'s executor rebuilds to carry the setting across a hot
698    /// reload or a `with_observer` call — without it, failure codes would stop
699    /// being recorded silently.
700    pub(crate) fn error_context(&self) -> Option<&Arc<ErrorContextConfig>> {
701        self.error_context.as_ref()
702    }
703
704    /// Emit a task event, deriving the status from the dispatch result.
705    ///
706    /// Called before `handle_task_result`, which takes `result` by value and
707    /// whose `?` propagates on a hard failure — emitting afterwards would
708    /// silently drop exactly the tasks a host most wants timed.
709    #[inline]
710    fn emit_task_event(
711        &self,
712        workflow: &Workflow,
713        task: &Task,
714        result: &Result<(TaskOutcome, Vec<Change>)>,
715        started_at: Option<DateTime<Utc>>,
716    ) {
717        if let Some(observer) = self.observer.as_ref() {
718            let status = match result {
719                Ok((outcome, _)) => outcome.audit_status(),
720                Err(_) => Some(500),
721            };
722            let duration = started_at
723                .map(|s| Duration::from_micros(duration_us_between(s, Utc::now())))
724                .unwrap_or_default();
725            observer.task_finished(&TaskEvent {
726                workflow_id: &workflow.id,
727                task_id: &task.id,
728                function: task.function.function_name(),
729                status,
730                duration,
731            });
732        }
733    }
734
735    /// Emit `workflow_started` the first time a workflow is admitted, and
736    /// remember when — so a looping workflow reports one pair for the whole
737    /// loop rather than one per sweep.
738    fn begin_workflow(&self, span: &mut WorkflowSpan, workflow: &Workflow) {
739        span.sweeps += 1;
740        // Unobserved: no clock read, and `started_at` stays `None` so
741        // `end_workflow` is a no-op too. The crate's documented
742        // one-`Utc::now()`-per-message invariant holds unchanged.
743        let Some(observer) = self.observer.as_ref() else {
744            return;
745        };
746        if span.started_at.is_some() {
747            return;
748        }
749        span.started_at = Some(Utc::now());
750        observer.workflow_started(&WorkflowStarted {
751            workflow_id: &workflow.id,
752        });
753    }
754
755    /// Close a span opened by [`Self::begin_workflow`]. A no-op when the
756    /// workflow was never admitted, so a skipped workflow emits nothing.
757    fn end_workflow(&self, span: &WorkflowSpan, workflow: &Workflow, halted: bool) {
758        let Some(observer) = self.observer.as_ref() else {
759            return;
760        };
761        let Some(started) = span.started_at else {
762            return;
763        };
764        observer.workflow_finished(&WorkflowFinished {
765            workflow_id: &workflow.id,
766            duration: Duration::from_micros(duration_us_between(started, Utc::now())),
767            sweeps: span.sweeps,
768            halted,
769        });
770    }
771
772    /// Clock read for the observer, only when one is attached.
773    ///
774    /// Gated so that `process_message`'s documented "one `Utc::now()` per
775    /// message" holds for every caller that has not opted in.
776    #[inline]
777    fn observer_clock(&self) -> Option<DateTime<Utc>> {
778        self.observer.as_ref().map(|_| Utc::now())
779    }
780
781    /// Sample the per-task clocks and error watermark. See [`TaskClocks`] for
782    /// why the three are taken together, and here rather than in each loop.
783    #[inline]
784    fn open_task_clocks(&self, tracing: bool, message: &Message) -> TaskClocks {
785        // Clock reads only when a trace is live or an observer is attached, so
786        // the plain path keeps its documented one-`Utc::now()`-per-message
787        // invariant.
788        let trace_start = if tracing { Some(Utc::now()) } else { None };
789        TaskClocks {
790            trace_start,
791            obs_start: trace_start.or_else(|| self.observer_clock()),
792            // Sampled before the body runs: `validation` and
793            // `TaskContext::add_error` both push during it, so the tail beyond
794            // this index is exactly what this task contributed.
795            errors_before: message.errors.len(),
796        }
797    }
798
799    /// Get a clone of the task_functions Arc for reuse in new engines
800    pub fn task_functions(&self) -> Arc<HashMap<String, BoxedFunctionHandler>> {
801        self.task_executor.task_functions()
802    }
803
804    /// Borrow the handler registry, for enumeration keyed to this executor's
805    /// lifetime. See `TaskExecutor::registry`.
806    pub fn registry(&self) -> &HashMap<String, BoxedFunctionHandler> {
807        self.task_executor.registry()
808    }
809
810    /// Execute a workflow if its condition is met
811    ///
812    /// This method:
813    /// 1. Evaluates the workflow condition
814    /// 2. Executes tasks sequentially if condition is met
815    /// 3. Handles error recovery based on workflow configuration
816    /// 4. Updates message metadata and audit trail
817    ///
818    /// # Arguments
819    /// * `workflow` - The workflow to execute
820    /// * `message` - The message being processed
821    ///
822    /// # Returns
823    /// * `Result<bool>` - Ok(true) if workflow was executed, Ok(false) if skipped, Err on failure
824    pub async fn execute(
825        &self,
826        workflow: &Workflow,
827        message: &mut Message,
828        now: DateTime<Utc>,
829    ) -> Result<bool> {
830        self.execute_inner(workflow, message, None, now).await
831    }
832
833    /// Execute a workflow with step-by-step tracing
834    ///
835    /// Similar to `execute` but records execution steps for debugging.
836    pub async fn execute_with_trace(
837        &self,
838        workflow: &Workflow,
839        message: &mut Message,
840        trace: &mut ExecutionTrace,
841        now: DateTime<Utc>,
842    ) -> Result<bool> {
843        self.execute_inner(workflow, message, Some(trace), now)
844            .await
845    }
846
847    /// Run `workflow` against `message`: the rollout gate, then either a single
848    /// pass over the task list or — for a workflow carrying a `loop` — a
849    /// bounded sweep loop.
850    ///
851    /// `trace` is `None` for the production path and `Some(&mut trace)` for the
852    /// debug path; stepping is the only behavioural difference between them.
853    async fn execute_inner(
854        &self,
855        workflow: &Workflow,
856        message: &mut Message,
857        mut trace: Option<&mut ExecutionTrace>,
858        now: DateTime<Utc>,
859    ) -> Result<bool> {
860        // Traffic-split gate, ahead of any arena work so an excluded workflow
861        // costs no `ArenaContext::from_owned` walk. Reuses the existing skipped
862        // path verbatim, so an excluded workflow is indistinguishable from a
863        // false condition.
864        if !rollout_admits(workflow, message) {
865            note_workflow_skip(trace.as_deref_mut(), &workflow.id, "outside rollout bucket");
866            return Ok(false);
867        }
868
869        if let Some(loop_config) = workflow.loop_config.as_ref() {
870            return self
871                .execute_loop(workflow, loop_config, message, trace, now)
872                .await;
873        }
874
875        // Opened inside `execute_pass` the moment the condition admits, so a
876        // skipped workflow leaves it closed and emits nothing.
877        let mut span = WorkflowSpan::default();
878        let outcome = self
879            .execute_pass(
880                workflow,
881                message,
882                trace.as_deref_mut(),
883                PassCtx::once(now),
884                &mut span,
885            )
886            .await;
887        self.end_workflow(&span, workflow, matches!(outcome, Ok(PassOutcome::Halted)));
888
889        match outcome {
890            Ok(PassOutcome::ConditionFalse) => {
891                // Last use of `trace` on this path — no reborrow needed.
892                note_workflow_skip(trace, &workflow.id, "condition not met");
893                Ok(false)
894            }
895            Ok(_) => {
896                info!("Successfully completed workflow: {}", workflow.id);
897                Ok(true)
898            }
899            Err(e) => {
900                // Single-channel contract: every error appears in
901                // `message.errors`. The `Result::Err` return only signals to
902                // the caller that we stopped before processing further
903                // workflows. The workflow-level wrapper records workflow
904                // context that the underlying task error doesn't carry.
905                if self.record_workflow_error(workflow, message, &e) {
906                    Err(e)
907                } else {
908                    Ok(true)
909                }
910            }
911        }
912    }
913
914    /// Drive a looping workflow: repeat [`Self::execute_pass`] while the
915    /// counter is below `max` and the workflow condition holds.
916    ///
917    /// Per-sweep order — write counter, check bound, check condition, run
918    /// tasks, advance counter — is the documented contract. The counter is in
919    /// `temp_data` before the first condition evaluation, so a condition that
920    /// indexes by it works on sweep 0.
921    ///
922    /// Returns `Ok(false)` only when no sweep ever ran, which is what a
923    /// condition-skipped workflow reports.
924    async fn execute_loop(
925        &self,
926        workflow: &Workflow,
927        config: &LoopConfig,
928        message: &mut Message,
929        mut trace: Option<&mut ExecutionTrace>,
930        now: DateTime<Utc>,
931    ) -> Result<bool> {
932        let mut counter = config.init;
933        let mut sweeps_run: u32 = 0;
934        let counter_parts = resolve_counter_parts(config);
935        // One span for the whole loop: per-sweep events would explode
936        // cardinality, so the sweep count goes on the single finished event.
937        let mut span = WorkflowSpan::default();
938        let mut halted = false;
939
940        loop {
941            // Written before the bound and condition checks so a condition
942            // indexing by the counter — the per-item pattern — resolves on the
943            // very first sweep. No arena refresh is needed: `execute_pass`
944            // builds its `ArenaContext` from `message.context` after this write.
945            set_nested_value_parts(
946                &mut message.context,
947                &counter_parts,
948                OwnedDataValue::from_i64(counter),
949            );
950
951            // `>=`, not `>`, and that is load-bearing for termination rather
952            // than a style choice. `increment >= 1` is validated at build time
953            // and the advance below saturates, so the counter strictly
954            // increases until it pins at `i64::MAX` — which satisfies
955            // `>= config.max` for every representable `max`. With `>` a loop
956            // whose counter saturates would spin forever.
957            if counter >= config.max {
958                // Normal completion: `max` is always author-supplied, so
959                // reaching it is the stated bound rather than a runaway. A
960                // condition that was still true wanted to keep going, which is
961                // worth a log line but not an error.
962                if workflow.compiled_condition.is_some() {
963                    warn!(
964                        "Workflow {} stopped at its loop bound (max {}) with the condition \
965                         still true after {} sweep(s)",
966                        workflow.id, config.max, sweeps_run
967                    );
968                }
969                break;
970            }
971
972            let pass = PassCtx {
973                now,
974                loop_counter: Some(counter),
975            };
976
977            match self
978                .execute_pass(workflow, message, trace.as_deref_mut(), pass, &mut span)
979                .await
980            {
981                Ok(PassOutcome::ConditionFalse) => {
982                    if sweeps_run == 0 {
983                        // Never entered: indistinguishable from a plain
984                        // condition-skipped workflow, and reported as one.
985                        note_workflow_skip(trace.as_deref_mut(), &workflow.id, "condition not met");
986                    } else {
987                        debug!(
988                            "Workflow {} loop exited at counter {} - condition no longer met",
989                            workflow.id, counter
990                        );
991                    }
992                    break;
993                }
994                Ok(PassOutcome::Halted) => {
995                    sweeps_run += 1;
996                    debug!(
997                        "Workflow {} loop halted at counter {}",
998                        workflow.id, counter
999                    );
1000                    halted = true;
1001                    break;
1002                }
1003                Ok(PassOutcome::Completed) => {
1004                    sweeps_run += 1;
1005                }
1006                Err(e) => {
1007                    sweeps_run += 1;
1008                    // Same single-channel contract as the non-looping path. On
1009                    // `continue_on_error` the loop advances past the failing
1010                    // sweep rather than abandoning the rest — the per-item case
1011                    // wants item 8 processed after item 7 failed.
1012                    if self.record_workflow_error(workflow, message, &e) {
1013                        // Closed on the error path too: an observer measuring
1014                        // engine overhead must not lose the span of the
1015                        // workflow that actually failed.
1016                        self.end_workflow(&span, workflow, halted);
1017                        return Err(e);
1018                    }
1019                }
1020            }
1021
1022            counter = counter.saturating_add(config.increment);
1023        }
1024
1025        self.end_workflow(&span, workflow, halted);
1026
1027        if sweeps_run > 0 {
1028            info!(
1029                "Successfully completed workflow: {} ({} loop sweep(s))",
1030                workflow.id, sweeps_run
1031            );
1032        }
1033        Ok(sweeps_run > 0)
1034    }
1035
1036    /// One pass over `workflow.tasks`: evaluate the workflow condition, then
1037    /// run the task list once. This is the whole of a non-looping workflow, and
1038    /// one sweep of a looping one.
1039    ///
1040    /// The workflow condition is folded into the *first* sync stretch's arena
1041    /// scope: one `ArenaContext::from_owned` walk serves both the condition
1042    /// eval and the leading run of sync built-in tasks. The owned path
1043    /// (`eval_to_owned`) deep-borrowed the entire context — including the
1044    /// heavy `data.input` payload — for the condition, and `execute_tasks`
1045    /// then walked the same context again to build the first stretch's arena
1046    /// form. Mixed sync+async workflows now pay one walk where they paid two.
1047    /// No `.await` occurs inside the scope, preserving the `!Send` arena
1048    /// invariant.
1049    async fn execute_pass(
1050        &self,
1051        workflow: &Workflow,
1052        message: &mut Message,
1053        mut trace: Option<&mut ExecutionTrace>,
1054        pass: PassCtx,
1055        span: &mut WorkflowSpan,
1056    ) -> Result<PassOutcome> {
1057        /// Outcome of the folded condition-plus-first-stretch arena scope.
1058        enum FirstStretch {
1059            /// Workflow condition evaluated false — skip the workflow.
1060            Skipped,
1061            /// A filter task halted the workflow inside the first stretch.
1062            Halted,
1063            /// Continue with the remaining tasks, resuming at this index —
1064            /// the first async boundary, or further on when a skipped group's
1065            /// span reached past it.
1066            Continue(usize),
1067        }
1068
1069        let tasks = &workflow.tasks;
1070        let first_boundary = next_async_boundary(tasks, 0);
1071        // One gate for the whole pass: a group can open in the folded first
1072        // stretch and close somewhere in the async tail.
1073        let mut gate = GroupGate::default();
1074
1075        let first: Result<FirstStretch> =
1076            if workflow.compiled_condition.is_none() && first_boundary == 0 {
1077                // No condition and the workflow leads with an async task —
1078                // nothing to fold; don't build an arena context for nothing.
1079                // Unconditional, so the workflow runs and the span opens here.
1080                self.begin_workflow(span, workflow);
1081                Ok(FirstStretch::Continue(0))
1082            } else {
1083                with_arena(|arena| -> Result<FirstStretch> {
1084                    let mut arena_ctx = ArenaContext::from_owned(&message.context, arena);
1085
1086                    let should_execute = match workflow.compiled_condition.as_ref() {
1087                        None => true,
1088                        Some(compiled) => evaluate_condition_in_arena(
1089                            &self.engine,
1090                            Some(compiled),
1091                            arena_ctx.as_data_value(),
1092                            arena,
1093                        )?,
1094                    };
1095                    if !should_execute {
1096                        return Ok(FirstStretch::Skipped);
1097                    }
1098                    // Admitted: this is the earliest point the workflow is
1099                    // known to run, so it is where the span opens. Emitting any
1100                    // earlier would report a workflow its condition rejected.
1101                    self.begin_workflow(span, workflow);
1102                    if first_boundary == 0 {
1103                        return Ok(FirstStretch::Continue(0));
1104                    }
1105                    let outcome = self.run_tasks_slice_in_arena(
1106                        TaskSlice {
1107                            tasks: &tasks[..first_boundary],
1108                            offset: 0,
1109                            gate: &mut gate,
1110                        },
1111                        workflow,
1112                        message,
1113                        &mut arena_ctx,
1114                        trace.as_deref_mut(),
1115                        pass,
1116                    )?;
1117                    Ok(match outcome {
1118                        SliceOutcome::Halted => FirstStretch::Halted,
1119                        SliceOutcome::JumpTo(target) => FirstStretch::Continue(target),
1120                        SliceOutcome::Completed => FirstStretch::Continue(first_boundary),
1121                    })
1122                })
1123            };
1124
1125        // Drive the remaining (async-containing) tail. The workflow-level error
1126        // contract lives in the caller, which is the one place that knows
1127        // whether this pass was a whole workflow or one sweep of a loop.
1128        match first? {
1129            FirstStretch::Skipped => Ok(PassOutcome::ConditionFalse),
1130            FirstStretch::Halted => Ok(PassOutcome::Halted),
1131            FirstStretch::Continue(resume_at) => {
1132                let halted = self
1133                    .execute_tasks(workflow, message, trace, pass, resume_at, &mut gate)
1134                    .await?;
1135                Ok(if halted {
1136                    PassOutcome::Halted
1137                } else {
1138                    PassOutcome::Completed
1139                })
1140            }
1141        }
1142    }
1143
1144    /// Record a `WORKFLOW_ERROR` to `message.errors` and log at the level
1145    /// `continue_on_error` implies. Returns `true` when the caller should stop
1146    /// processing further workflows (i.e. `continue_on_error` is `false`).
1147    ///
1148    /// Shared by `execute_inner` (returns from its own `Result<bool>`) and
1149    /// `execute_sync_workflow_run` (returns from its `with_arena` closure or
1150    /// continues the loop) — the recording and log-level decision are
1151    /// identical; only what happens next differs by call site.
1152    fn record_workflow_error(
1153        &self,
1154        workflow: &Workflow,
1155        message: &mut Message,
1156        e: &DataflowError,
1157    ) -> bool {
1158        message.errors.push(
1159            ErrorInfo::builder(
1160                "WORKFLOW_ERROR",
1161                format!("Workflow {} error: {}", workflow.id, e),
1162            )
1163            .workflow_id(&workflow.id)
1164            .build(),
1165        );
1166
1167        if workflow.continue_on_error {
1168            warn!(
1169                "Workflow {} encountered error but continuing: {:?}",
1170                workflow.id, e
1171            );
1172            false
1173        } else {
1174            error!("Workflow {} failed: {:?}", workflow.id, e);
1175            true
1176        }
1177    }
1178
1179    /// Execute the tasks of a workflow from index `start` onward.
1180    ///
1181    /// Groups consecutive synchronous built-in tasks into a single
1182    /// `with_arena` scope so the arena form of `message.context` is built
1183    /// once at the start of the stretch and reused across `parse_json`,
1184    /// `map`, `validation`, `log`, and `filter`. Async tasks (HTTP, Kafka,
1185    /// custom handlers) break the stretch — the arena flushes any pending
1186    /// state back to `OwnedDataValue` automatically (since each sync task
1187    /// already mutates `message.context` in place) and the next stretch
1188    /// rebuilds the arena form.
1189    ///
1190    /// `start` is non-zero when `execute_inner` already ran the leading sync
1191    /// stretch inside the folded condition scope.
1192    ///
1193    /// When `trace` is `Some`, the loop also records `ExecutionStep` entries
1194    /// after each task (skipped/executed) including per-mapping snapshots
1195    /// for `Map` tasks.
1196    ///
1197    /// Returns `Ok(true)` when a task halted the workflow.
1198    async fn execute_tasks(
1199        &self,
1200        workflow: &Workflow,
1201        message: &mut Message,
1202        mut trace: Option<&mut ExecutionTrace>,
1203        pass: PassCtx,
1204        start: usize,
1205        gate: &mut GroupGate,
1206    ) -> Result<bool> {
1207        let tasks = &workflow.tasks;
1208        let mut idx = start;
1209        while idx < tasks.len() {
1210            let stretch_end = next_async_boundary(tasks, idx);
1211
1212            if stretch_end > idx {
1213                // Run [idx, stretch_end) as a sync stretch inside one arena.
1214                match self.run_sync_stretch(
1215                    TaskSlice {
1216                        tasks: &tasks[idx..stretch_end],
1217                        offset: idx,
1218                        gate,
1219                    },
1220                    workflow,
1221                    message,
1222                    trace.as_deref_mut(),
1223                    pass,
1224                )? {
1225                    SliceOutcome::Halted => return Ok(true),
1226                    // A group opening inside the stretch was skipped and its
1227                    // span reaches past the stretch — resume where it ends.
1228                    SliceOutcome::JumpTo(target) => {
1229                        idx = target;
1230                        continue;
1231                    }
1232                    SliceOutcome::Completed => idx = stretch_end,
1233                }
1234            }
1235
1236            if idx < tasks.len() {
1237                // Single async task (or non-sync-builtin) at `idx`.
1238                let task = &tasks[idx];
1239
1240                match admit_task(
1241                    workflow,
1242                    task,
1243                    idx,
1244                    gate,
1245                    trace.as_deref_mut(),
1246                    pass,
1247                    |compiled| evaluate_condition(&self.engine, compiled, &message.context),
1248                )? {
1249                    Admission::Halt => return Ok(true),
1250                    Admission::Jump(target) => {
1251                        idx = target;
1252                        continue;
1253                    }
1254                    Admission::Skip => {
1255                        idx += 1;
1256                        continue;
1257                    }
1258                    Admission::Run => {}
1259                }
1260
1261                let clocks = self.open_task_clocks(trace.is_some(), message);
1262
1263                let result = self
1264                    .task_executor
1265                    .execute_in_workflow(
1266                        task,
1267                        message,
1268                        Some(TaskIdentity {
1269                            workflow_id: &workflow.id_arc,
1270                            task_id: &task.id_arc,
1271                        }),
1272                        pass.loop_counter,
1273                    )
1274                    .await;
1275
1276                // Before `handle_task_result`, whose `?` would drop failed tasks.
1277                self.emit_task_event(workflow, task, &result, clocks.obs_start);
1278
1279                // No arena refresh here: no `ArenaContext` is live on this path,
1280                // and `run_sync_stretch` rebuilds one from `message.context` at
1281                // the start of the next stretch.
1282                let control_flow = self.handle_task_result(
1283                    result,
1284                    &workflow.id_arc,
1285                    &task.id_arc,
1286                    TaskPass {
1287                        continue_on_error: task.continue_on_error,
1288                        terminal: task.terminal,
1289                        errors_before: clocks.errors_before,
1290                    },
1291                    message,
1292                    pass,
1293                )?;
1294
1295                // Async tasks at the boundary have no per-mapping snapshots —
1296                // they're either HTTP/Kafka/Enrich or a custom handler.
1297                pass.note_executed(
1298                    trace.as_deref_mut(),
1299                    &workflow.id,
1300                    &task.id,
1301                    message,
1302                    clocks,
1303                    None,
1304                );
1305
1306                if matches!(control_flow, TaskControlFlow::HaltWorkflow) {
1307                    return Ok(true);
1308                }
1309                idx += 1;
1310            }
1311        }
1312
1313        // A terminal group closing on the last task still has to halt: for a
1314        // workflow carrying a `loop`, halting breaks the loop where completing
1315        // would start another sweep.
1316        Ok(gate.close_through(tasks.len()))
1317    }
1318
1319    /// Execute a contiguous run of sync-builtin tasks inside one
1320    /// `with_arena` scope. The arena context is built once at the start and
1321    /// refreshed in place after each mutating task. Returns `Ok(true)` if a
1322    /// filter task halted the workflow.
1323    ///
1324    /// This is the single-workflow entry; the cross-workflow path
1325    /// (`execute_sync_workflow_run`) shares the same task loop via
1326    /// `run_tasks_slice_in_arena` but carries one `ArenaContext` across several
1327    /// workflows.
1328    fn run_sync_stretch(
1329        &self,
1330        slice: TaskSlice<'_, '_>,
1331        workflow: &Workflow,
1332        message: &mut Message,
1333        trace: Option<&mut ExecutionTrace>,
1334        pass: PassCtx,
1335    ) -> Result<SliceOutcome> {
1336        with_arena(|arena| -> Result<SliceOutcome> {
1337            let mut arena_ctx = ArenaContext::from_owned(&message.context, arena);
1338            self.run_tasks_slice_in_arena(slice, workflow, message, &mut arena_ctx, trace, pass)
1339        })
1340    }
1341
1342    /// Run `tasks` against an already-built `ArenaContext`, evaluating each
1343    /// task's condition in-arena and refreshing the cache after each mutating
1344    /// task. Returns `Ok(true)` if a filter task halted the workflow.
1345    ///
1346    /// Factored out of `run_sync_stretch` so both the single-workflow stretch
1347    /// and the cross-workflow shared-arena run (`execute_sync_workflow_run`)
1348    /// share one implementation. The caller owns the `ArenaContext` lifetime,
1349    /// so the cross-workflow path can reuse the same arena form of
1350    /// `message.context` across consecutive workflows instead of rebuilding it.
1351    fn run_tasks_slice_in_arena<'arena>(
1352        &self,
1353        slice: TaskSlice<'_, 'arena>,
1354        workflow: &Workflow,
1355        message: &mut Message,
1356        arena_ctx: &mut ArenaContext<'arena>,
1357        mut trace: Option<&mut ExecutionTrace>,
1358        pass: PassCtx,
1359    ) -> Result<SliceOutcome> {
1360        let arena = arena_ctx.arena();
1361        let TaskSlice {
1362            tasks,
1363            offset,
1364            gate,
1365        } = slice;
1366        let slice_end = offset + tasks.len();
1367
1368        let mut i = 0;
1369        while i < tasks.len() {
1370            let task = &tasks[i];
1371            let abs = offset + i;
1372
1373            // Conditions evaluate against the arena form so we don't re-borrow
1374            // the thread-local `RefCell`. A `None` compiled condition (the
1375            // compiler folds the default literal `true` to `None`) skips both
1376            // the eval and the per-task arena context slice build.
1377            match admit_task(
1378                workflow,
1379                task,
1380                abs,
1381                gate,
1382                trace.as_deref_mut(),
1383                pass,
1384                |compiled| {
1385                    evaluate_condition_in_arena(
1386                        &self.engine,
1387                        compiled,
1388                        arena_ctx.as_data_value(),
1389                        arena,
1390                    )
1391                },
1392            )? {
1393                Admission::Halt => return Ok(SliceOutcome::Halted),
1394                Admission::Jump(target) => {
1395                    // A span reaching past this slice is the caller's to
1396                    // resume — it owns the tasks beyond `slice_end`.
1397                    if target >= slice_end {
1398                        return Ok(SliceOutcome::JumpTo(target));
1399                    }
1400                    i = target - offset;
1401                    continue;
1402                }
1403                Admission::Skip => {
1404                    i += 1;
1405                    continue;
1406                }
1407                Admission::Run => {}
1408            }
1409
1410            // Per-task snapshot buffer — only used for Map tasks in trace
1411            // mode, and only when the trace's policy wants them. Allocating an
1412            // empty Vec is cheap and the buffer stays empty for non-Map tasks.
1413            let mut mapping_snapshots: Vec<Value> = Vec::new();
1414            let want_mapping_contexts = trace
1415                .as_deref()
1416                .is_some_and(|t| t.options().mapping_contexts);
1417            let mapping_snapshots_buf = if want_mapping_contexts {
1418                Some(&mut mapping_snapshots)
1419            } else {
1420                None
1421            };
1422
1423            let clocks = self.open_task_clocks(trace.is_some(), message);
1424
1425            let result =
1426                self.execute_sync_task_in_arena(task, message, arena_ctx, mapping_snapshots_buf);
1427
1428            // Before `handle_task_result`, whose `?` would drop failed tasks.
1429            self.emit_task_event(workflow, task, &result, clocks.obs_start);
1430
1431            let flow = self.handle_task_result(
1432                result,
1433                &workflow.id_arc,
1434                &task.id_arc,
1435                TaskPass {
1436                    continue_on_error: task.continue_on_error,
1437                    terminal: task.terminal,
1438                    errors_before: clocks.errors_before,
1439                },
1440                message,
1441                pass,
1442            );
1443
1444            // Refresh the slots `handle_task_result` wrote so the next task —
1445            // and, in the cross-workflow path, the next workflow's condition —
1446            // sees them, without re-arenaing unrelated metadata children
1447            // (mapped `metadata.routing.*`, chained workflow state, …) after
1448            // every task.
1449            //
1450            // Deliberately *before* the `?`. An `Err` here does not necessarily
1451            // end this arena scope: `execute_sync_workflow_run` continues into
1452            // the next workflow carrying this same `ArenaContext` whenever the
1453            // failing task had `continue_on_error: false` but its workflow had
1454            // `continue_on_error: true`, and that workflow's condition would
1455            // otherwise be evaluated against a stale `metadata.progress`.
1456            arena_ctx.refresh_for_path(&message.context, "metadata.progress");
1457            // Gated on a failure actually being recorded: this walk deep-copies
1458            // the target subtree into the arena, so running it after every
1459            // successful task would be a permanent cost on the hot path.
1460            if let Some(cfg) = self.error_context_refresh(message, clocks.errors_before) {
1461                arena_ctx.refresh_for_path_parts(&message.context, &cfg.path_parts);
1462            }
1463
1464            let control_flow = flow?;
1465
1466            pass.note_executed(
1467                trace.as_deref_mut(),
1468                &workflow.id,
1469                &task.id,
1470                message,
1471                clocks,
1472                // A `map` task in trace mode collects one snapshot per mapping;
1473                // every other sync built-in leaves the buffer empty.
1474                Some(mapping_snapshots).filter(|s| !s.is_empty()),
1475            );
1476
1477            if matches!(control_flow, TaskControlFlow::HaltWorkflow) {
1478                return Ok(SliceOutcome::Halted);
1479            }
1480            i += 1;
1481        }
1482        Ok(SliceOutcome::Completed)
1483    }
1484
1485    /// Drive a message through `workflows` in order, grouping maximal runs of
1486    /// consecutive `fully_sync` workflows into a single shared-arena scope
1487    /// (`execute_sync_workflow_run`) and falling back to the per-workflow
1488    /// `.await` path (`execute_inner`) for any workflow containing an async
1489    /// task.
1490    ///
1491    /// A thin `&[&Workflow]` wrapper over `Self::run_all_borrowed`, which is
1492    /// the actual shared entry all four `Engine::process_message*` variants
1493    /// call directly (against `&[Workflow]` from the engine's own registry,
1494    /// with no per-message `Vec<&Workflow>` collect). This method exists for
1495    /// a caller that already holds borrowed references.
1496    pub async fn run_all(
1497        &self,
1498        workflows: &[&Workflow],
1499        message: &mut Message,
1500        trace: Option<&mut ExecutionTrace>,
1501        now: DateTime<Utc>,
1502    ) -> Result<()> {
1503        self.run_all_borrowed(workflows, message, trace, now).await
1504    }
1505
1506    /// Generic driver behind [`Self::run_all`]: accepts any slice whose
1507    /// elements borrow as `Workflow` — `&[Workflow]` directly from the
1508    /// engine's registry (no per-message `Vec<&Workflow>` collect) or the
1509    /// `&[&Workflow]` shape the public entry keeps for compatibility.
1510    pub(crate) async fn run_all_borrowed<W: std::borrow::Borrow<Workflow>>(
1511        &self,
1512        workflows: &[W],
1513        message: &mut Message,
1514        trace: Option<&mut ExecutionTrace>,
1515        now: DateTime<Utc>,
1516    ) -> Result<()> {
1517        let started_at = self.observer_clock();
1518        if let Some(observer) = self.observer.as_ref() {
1519            observer.message_started(&MessageStarted {
1520                message_id: message.id(),
1521                workflows_considered: workflows.len(),
1522            });
1523        }
1524        let outcome = self.run_all_inner(workflows, message, trace, now).await;
1525        if let Some(observer) = self.observer.as_ref() {
1526            observer.message_finished(&MessageFinished {
1527                message_id: message.id(),
1528                duration: started_at
1529                    .map(|s| Duration::from_micros(duration_us_between(s, Utc::now())))
1530                    .unwrap_or_default(),
1531                errors: message.errors().len(),
1532                stopped_early: outcome.is_err(),
1533            });
1534        }
1535        outcome
1536    }
1537
1538    /// The driver proper. Split out so `message_finished` fires on the early
1539    /// `Err` path too — an observer measuring a run must see the runs that
1540    /// stopped, which are the interesting ones.
1541    async fn run_all_inner<W: std::borrow::Borrow<Workflow>>(
1542        &self,
1543        workflows: &[W],
1544        message: &mut Message,
1545        mut trace: Option<&mut ExecutionTrace>,
1546        now: DateTime<Utc>,
1547    ) -> Result<()> {
1548        let mut i = 0;
1549        while i < workflows.len() {
1550            if joins_sync_run(workflows[i].borrow()) {
1551                // Extend over the maximal run of consecutive fully-sync
1552                // workflows and execute them in one shared arena scope.
1553                let mut j = i + 1;
1554                while j < workflows.len() && joins_sync_run(workflows[j].borrow()) {
1555                    j += 1;
1556                }
1557                self.execute_sync_workflow_run(
1558                    &workflows[i..j],
1559                    message,
1560                    trace.as_deref_mut(),
1561                    now,
1562                )?;
1563                i = j;
1564            } else {
1565                // Mixed sync+async (or fully-async) workflow: the existing
1566                // driver interleaves per-stretch arenas with `.await`.
1567                self.execute_inner(workflows[i].borrow(), message, trace.as_deref_mut(), now)
1568                    .await?;
1569                i += 1;
1570            }
1571        }
1572        Ok(())
1573    }
1574
1575    /// Execute a maximal run of consecutive fully-sync workflows inside ONE
1576    /// shared `with_arena` scope. The message context is deep-walked into the
1577    /// arena once for the whole run, then carried — with the existing
1578    /// incremental `refresh_for_path` after each mutating task — across
1579    /// workflow boundaries, instead of being rebuilt per workflow.
1580    ///
1581    /// Per-workflow semantics are preserved exactly: each workflow's condition
1582    /// is evaluated (in-arena), a false condition skips only that workflow, a
1583    /// filter-halt stops only that workflow, and task errors are wrapped with
1584    /// the workflow id and honor `continue_on_error` (continue, or propagate
1585    /// `Err` out of the run to stop the whole message) — mirroring
1586    /// `execute_inner`.
1587    ///
1588    /// **Tokio safety:** this method is synchronous and the `fully_sync`
1589    /// precondition guarantees every task is a sync built-in, so no `.await`
1590    /// occurs while the `!Send` arena borrow is live. The borrow checker
1591    /// enforces this — the shared `ArenaContext` cannot escape the closure.
1592    fn execute_sync_workflow_run<W: std::borrow::Borrow<Workflow>>(
1593        &self,
1594        workflows: &[W],
1595        message: &mut Message,
1596        mut trace: Option<&mut ExecutionTrace>,
1597        now: DateTime<Utc>,
1598    ) -> Result<()> {
1599        // `joins_sync_run` keeps looping workflows out of this path, so every
1600        // workflow here runs exactly one pass and carries no loop counter.
1601        debug_assert!(
1602            workflows.iter().all(|w| joins_sync_run(w.borrow())),
1603            "only non-looping fully-sync workflows may join a shared-arena run"
1604        );
1605        let pass = PassCtx::once(now);
1606
1607        with_arena(|arena| -> Result<()> {
1608            let mut arena_ctx = ArenaContext::from_owned(&message.context, arena);
1609
1610            for workflow in workflows {
1611                let workflow: &Workflow = workflow.borrow();
1612
1613                // Same gate as `execute_inner`. This is the site a fully-sync
1614                // workflow actually reaches: `fully_sync` routes every
1615                // map/log/validation/filter-only workflow here and never through
1616                // `execute_inner`, so gating only there would silently not apply
1617                // to most workflows.
1618                if !rollout_admits(workflow, message) {
1619                    note_workflow_skip(
1620                        trace.as_deref_mut(),
1621                        &workflow.id,
1622                        "outside rollout bucket",
1623                    );
1624                    continue;
1625                }
1626
1627                // Workflow condition in-arena: a folded `None` skips the eval;
1628                // a real condition reuses the carried context instead of the
1629                // owned-path `eval_to_owned` deep-walk.
1630                let should_execute = match workflow.compiled_condition.as_ref() {
1631                    None => true,
1632                    Some(compiled) => evaluate_condition_in_arena(
1633                        &self.engine,
1634                        Some(compiled),
1635                        arena_ctx.as_data_value(),
1636                        arena,
1637                    )?,
1638                };
1639
1640                if !should_execute {
1641                    note_workflow_skip(trace.as_deref_mut(), &workflow.id, "condition not met");
1642                    continue;
1643                }
1644
1645                // Admitted, so the span opens here — the sync run's own gate
1646                // site, which `execute_inner` never sees for these workflows.
1647                let mut span = WorkflowSpan::default();
1648                self.begin_workflow(&mut span, workflow);
1649
1650                // Group state is per-workflow: this run carries one arena
1651                // across several workflows, but never a group.
1652                let mut gate = GroupGate::default();
1653                match self.run_tasks_slice_in_arena(
1654                    TaskSlice {
1655                        tasks: &workflow.tasks,
1656                        offset: 0,
1657                        gate: &mut gate,
1658                    },
1659                    workflow,
1660                    message,
1661                    &mut arena_ctx,
1662                    trace.as_deref_mut(),
1663                    pass,
1664                ) {
1665                    // A halt stops only this workflow; carry on with the next
1666                    // one (and keep the shared arena context). The slice spans
1667                    // the whole task list, so a jump can only land at its end.
1668                    Ok(outcome) => {
1669                        self.end_workflow(&span, workflow, matches!(outcome, SliceOutcome::Halted));
1670                        info!("Successfully completed workflow: {}", workflow.id);
1671                    }
1672                    Err(e) => {
1673                        // Closed before the early return, for the same reason
1674                        // as the loop path: the failing workflow's span is the
1675                        // one an observer most wants.
1676                        self.end_workflow(&span, workflow, false);
1677                        // Single-channel contract — mirror `execute_inner`.
1678                        if self.record_workflow_error(workflow, message, &e) {
1679                            return Err(e);
1680                        }
1681                    }
1682                }
1683            }
1684            Ok(())
1685        })
1686    }
1687
1688    /// Dispatch a single sync-builtin task via the consolidated
1689    /// `FunctionConfig::try_execute_in_arena`. `next_async_boundary` guarantees
1690    /// the stretch contents are sync built-ins, so the `None` arm is
1691    /// unreachable in practice.
1692    ///
1693    /// `mapping_snapshots` is only consulted by the `Map` variant; non-Map
1694    /// sync builtins ignore it. Pass `None` from the production path.
1695    fn execute_sync_task_in_arena<'arena>(
1696        &self,
1697        task: &'arena Task,
1698        message: &mut Message,
1699        arena_ctx: &mut ArenaContext<'arena>,
1700        mapping_snapshots: Option<&mut Vec<Value>>,
1701    ) -> Result<(TaskOutcome, Vec<Change>)> {
1702        debug!(
1703            "Executing sync task in arena: {} ({})",
1704            task.id,
1705            task.function.function_name()
1706        );
1707        debug_assert!(
1708            task.function.is_sync_builtin(),
1709            "execute_sync_task_in_arena called with non-sync-builtin task: {}",
1710            task.function.function_name()
1711        );
1712        // In debug builds the assert above catches mis-dispatch; in release
1713        // we still surface the invariant violation as a recoverable engine
1714        // error rather than panicking via `unreachable!`.
1715        task.function
1716            .try_execute_in_arena(message, arena_ctx, &self.engine, mapping_snapshots)
1717            .ok_or_else(|| {
1718                DataflowError::Task(format!(
1719                    "execute_sync_task_in_arena dispatched to non-sync-builtin task '{}' \
1720                     (engine bug — sync-stretch should only contain sync-builtin tasks)",
1721                    task.function.function_name()
1722                ))
1723            })?
1724    }
1725
1726    /// Mirror every error this task contributed to `message.errors` into the
1727    /// configured context path.
1728    ///
1729    /// Taking the delta beyond `task.errors_before` rather than recording at each
1730    /// push site is what makes coverage match `errors()` exactly. Two of the four
1731    /// per-task producers never reach a failure arm at all: the `validation`
1732    /// built-in appends its per-rule failures and then returns `Status(400)`,
1733    /// which lands in the *success* arm, and `TaskContext::add_error` can fire on
1734    /// a task that succeeds outright. Neither is visible to a host that wraps
1735    /// handlers either, since the sync built-ins never reach the registry.
1736    ///
1737    /// Must run *after* the two pushes `handle_task_result` performs itself
1738    /// (`TASK_STATUS_ERROR` and the task error), or they fall outside the delta —
1739    /// which would silently drop every `map` failure, since `map` returns
1740    /// `Status(500)` without touching `errors` itself.
1741    ///
1742    /// Returns nothing — the sync stretch reads [`Self::error_context_refresh`]
1743    /// instead, so the arena refresh stays off the no-failure path.
1744    #[inline]
1745    fn mirror_task_errors(
1746        &self,
1747        message: &mut Message,
1748        workflow_id: &str,
1749        task_id: &str,
1750        status: u16,
1751        task: TaskPass,
1752    ) {
1753        let Some(cfg) = self.error_context.as_ref() else {
1754            return;
1755        };
1756        // Disjoint borrows of two fields of `Message`, so the split is needed to
1757        // read the error tail while mutating the context.
1758        let Message {
1759            context, errors, ..
1760        } = message;
1761        let new_errors = errors.get(task.errors_before..).unwrap_or(&[]);
1762        append_error_records(context, cfg, workflow_id, task_id, status, new_errors);
1763    }
1764
1765    /// Whether the sync stretch must refresh the arena for the error-context
1766    /// path after this task — i.e. the option is on and the task contributed at
1767    /// least one error.
1768    #[inline]
1769    fn error_context_refresh<'a>(
1770        &'a self,
1771        message: &Message,
1772        errors_before: usize,
1773    ) -> Option<&'a Arc<ErrorContextConfig>> {
1774        let cfg = self.error_context.as_ref()?;
1775        if message.errors.len() > errors_before {
1776            Some(cfg)
1777        } else {
1778            None
1779        }
1780    }
1781
1782    /// Handle the result of a task execution.
1783    ///
1784    /// `workflow_id_arc` and `task_id_arc` are the compile-time cached
1785    /// `Arc<str>` mirrors of `workflow.id` / `task.id`; we Arc-clone them into
1786    /// each `AuditTrail` rather than reallocating from the `&str` form.
1787    fn handle_task_result(
1788        &self,
1789        result: Result<(TaskOutcome, Vec<Change>)>,
1790        workflow_id_arc: &Arc<str>,
1791        task_id_arc: &Arc<str>,
1792        task: TaskPass,
1793        message: &mut Message,
1794        pass: PassCtx,
1795    ) -> Result<TaskControlFlow> {
1796        let workflow_id: &str = workflow_id_arc;
1797        let task_id: &str = task_id_arc;
1798        let continue_on_error = task.continue_on_error;
1799        match result {
1800            Ok((TaskOutcome::Skip, _)) => {
1801                // No audit trail, no progress write, and no error-context record
1802                // — the task has explicitly opted out of the per-task record
1803                // (filter gate set to `Skip`). `audit_status()` is `None` here,
1804                // so a record would need a fabricated `status`, breaking the
1805                // fixed four-key shape that makes the path predictable to branch
1806                // on. Reaching this with errors recorded takes a handler that
1807                // calls `add_error` and *then* skips; the entry is still on
1808                // `message.errors()`.
1809                debug!("Task {} signaled skip", task_id);
1810                Ok(TaskControlFlow::Continue)
1811            }
1812            Ok((outcome, changes)) => {
1813                // `Skip` already returned above; the remaining variants all
1814                // record an audit entry. `audit_status()` is `Some` for
1815                // Success/Status/Halt — expect is for documentation only.
1816                let status = outcome
1817                    .audit_status()
1818                    .expect("Skip handled above; remaining variants emit audit status");
1819                // `Task::terminal` reaches the same halt as `TaskOutcome::Halt`,
1820                // but it is applied *after* the status classification below —
1821                // see the `flow` fold. Deciding here would make halting the
1822                // first branch of the chain, so a terminal task returning 500
1823                // would stop without recording `TASK_STATUS_ERROR` and without
1824                // propagating when `continue_on_error` is false.
1825                let halt_requested = outcome.halts_workflow() || task.terminal;
1826
1827                // Record audit trail. workflow_id_arc/task_id_arc are populated
1828                // by LogicCompiler at engine construction; cloning them is a
1829                // refcount bump, not a string copy. `now` is shared with all
1830                // other AuditTrails in this process_message call.
1831                message.audit_trail.push(AuditTrail {
1832                    timestamp: pass.now,
1833                    workflow_id: Arc::clone(workflow_id_arc),
1834                    task_id: Arc::clone(task_id_arc),
1835                    status: status as usize,
1836                    changes,
1837                    loop_counter: pass.loop_counter,
1838                });
1839
1840                // Update progress metadata for workflow chaining. Always
1841                // emitted: when multiple workflows are registered in the same
1842                // engine, downstream workflows route on
1843                // `metadata.progress.{workflow_id,task_id,status_code}` to
1844                // advance through linear sequences. After the first task the
1845                // slot already holds the expected 3-key object, so the write
1846                // overwrites the three values in place — only the two id
1847                // `String` allocs remain. (This beat both three separate
1848                // `set_nested_value` calls and the batched slot replace on
1849                // the realistic workload.)
1850                write_progress_metadata(&mut message.context, workflow_id, task_id, status);
1851
1852                // Decide the control flow first rather than returning from
1853                // inside each branch, so the error-context mirror below runs on
1854                // exactly one path. The halt and `!continue_on_error` exits would
1855                // otherwise each need their own call, and a future exit added
1856                // without one would silently stop recording.
1857                let flow = if (400..500).contains(&status) {
1858                    warn!("Task {} returned client error status: {}", task_id, status);
1859                    Ok(TaskControlFlow::Continue)
1860                } else if status >= 500 {
1861                    error!("Task {} returned server error status: {}", task_id, status);
1862                    // Single-channel contract: surface 5xx outcomes through
1863                    // `message.errors` as well as the audit trail, so callers
1864                    // that scan `errors()` see a 5xx-status task even when
1865                    // the workflow continues past it.
1866                    message.errors.push(
1867                        ErrorInfo::builder(
1868                            "TASK_STATUS_ERROR",
1869                            format!("Task {} returned status {}", task_id, status),
1870                        )
1871                        .workflow_id(workflow_id)
1872                        .task_id(task_id)
1873                        .build(),
1874                    );
1875                    if continue_on_error {
1876                        Ok(TaskControlFlow::Continue)
1877                    } else {
1878                        Err(DataflowError::Task(format!(
1879                            "Task {} failed with status {}",
1880                            task_id, status
1881                        )))
1882                    }
1883                } else {
1884                    Ok(TaskControlFlow::Continue)
1885                };
1886
1887                // Upgrade a `Continue` to a halt, leaving the 5xx `Err` and the
1888                // recording above untouched. `TaskOutcome::Halt`'s own status is
1889                // 299 — neither 4xx nor 5xx — so its behaviour is unchanged.
1890                let flow = match flow {
1891                    Ok(TaskControlFlow::Continue) if halt_requested => {
1892                        info!("Task {} halted workflow {}", task_id, workflow_id);
1893                        Ok(TaskControlFlow::HaltWorkflow)
1894                    }
1895                    other => other,
1896                };
1897
1898                // After the `TASK_STATUS_ERROR` push above, so it lands inside
1899                // this task's delta.
1900                self.mirror_task_errors(message, workflow_id, task_id, status, task);
1901                flow
1902            }
1903            Err(e) => {
1904                error!("Task {} failed: {:?}", task_id, e);
1905
1906                // Record error in audit trail (Arc clones are refcount bumps).
1907                message.audit_trail.push(AuditTrail {
1908                    timestamp: pass.now,
1909                    workflow_id: Arc::clone(workflow_id_arc),
1910                    task_id: Arc::clone(task_id_arc),
1911                    status: 500,
1912                    changes: vec![],
1913                    loop_counter: pass.loop_counter,
1914                });
1915
1916                // Same invariant as the Ok arm: `metadata.progress` is written
1917                // after every task, unconditionally, so a downstream workflow
1918                // gating on it still sees this task ran even though it errored.
1919                write_progress_metadata(&mut message.context, workflow_id, task_id, 500);
1920
1921                // Add error to message. A service-classified error contributes
1922                // its own `kind` as the code and carries its operator-only
1923                // `detail`; everything else takes its variant's code.
1924                // Deliberately lifted at the task site only: the two
1925                // `WORKFLOW_ERROR` wrappers wrap the same propagated error, so
1926                // lifting there too would put two entries with the same
1927                // `code` on the message — making "count errors by code"
1928                // double-count — and would stop `WORKFLOW_ERROR` reliably
1929                // meaning "a workflow stopped".
1930                //
1931                // `format!("{}", e)` stays caller-safe because `Service`'s
1932                // `Display` is `{message}` — the detail is never interpolated.
1933                let mut info = ErrorInfo::builder(
1934                    service_error_code(&e),
1935                    format!("Task {} error: {}", task_id, e),
1936                )
1937                .workflow_id(workflow_id)
1938                .task_id(task_id);
1939                // Nested `if let`, not a let-chain: MSRV is 1.85.
1940                if let Some(detail) = e.detail() {
1941                    info = info.detail(detail);
1942                }
1943                message.errors.push(info.build());
1944
1945                // `500` matches the audit entry and the progress write above: a
1946                // handler `Err` has no status of its own.
1947                self.mirror_task_errors(message, workflow_id, task_id, 500, task);
1948
1949                if !continue_on_error {
1950                    Err(e)
1951                } else if task.terminal {
1952                    // `terminal` is about position, not outcome: the author said
1953                    // "nothing after this runs". The error stays on
1954                    // `message.errors()` either way.
1955                    info!(
1956                        "Terminal task {} halted workflow {} after failing",
1957                        task_id, workflow_id
1958                    );
1959                    Ok(TaskControlFlow::HaltWorkflow)
1960                } else {
1961                    Ok(TaskControlFlow::Continue)
1962                }
1963            }
1964        }
1965    }
1966}
1967
1968#[cfg(test)]
1969mod tests {
1970    use super::*;
1971    use crate::engine::compiler::LogicCompiler;
1972    use serde_json::json;
1973    use std::collections::HashMap;
1974
1975    /// Test-only helper: build an `OwnedDataValue` from a `json!` literal.
1976    fn dv(v: serde_json::Value) -> OwnedDataValue {
1977        OwnedDataValue::from(&v)
1978    }
1979
1980    /// Compile `json` into a single runnable workflow plus its engine.
1981    fn compiled(json: &str) -> (Workflow, Arc<datalogic_rs::Engine>) {
1982        let compiler = LogicCompiler::new();
1983        let workflow = Workflow::from_json(json).expect("workflow should parse");
1984        let compiled = compiler
1985            .compile_workflows(vec![workflow])
1986            .expect("workflow should compile");
1987        (
1988            compiled.into_iter().next().expect("one workflow"),
1989            compiler.into_engine(),
1990        )
1991    }
1992
1993    /// A `WorkflowExecutor` over an empty handler registry.
1994    fn executor(engine: Arc<datalogic_rs::Engine>) -> WorkflowExecutor {
1995        let task_executor = Arc::new(TaskExecutor::new(
1996            Arc::new(HashMap::new()),
1997            Arc::clone(&engine),
1998        ));
1999        WorkflowExecutor::new(task_executor, engine)
2000    }
2001
2002    /// A `WorkflowExecutor` that mirrors failure codes to `metadata.errors`.
2003    fn executor_with_error_context(engine: Arc<datalogic_rs::Engine>) -> WorkflowExecutor {
2004        let cfg = ErrorContextConfig::new("metadata.errors".to_string(), 32)
2005            .expect("metadata.errors is a valid path");
2006        executor(engine).with_error_context(Arc::new(cfg))
2007    }
2008
2009    #[tokio::test]
2010    async fn appending_records_mid_stretch_keeps_the_arena_cache_consistent() {
2011        // A failing `validation` followed by a `map`, both sync built-ins, so
2012        // they share one `ArenaContext`. Two things are under test:
2013        //
2014        // 1. the `map` reads the record appended by the `validation`, which only
2015        //    works if the append refreshed the arena; and
2016        // 2. `apply_mutation_parts_write_through`'s `#[cfg(test)]`
2017        //    `assert_matches_owned` runs on the `map`'s write, giving free
2018        //    differential verification that the refresh left the arena cache
2019        //    identical to a from-scratch rebuild of the owned context. That
2020        //    assertion is compiled out for the `tests/` binaries, so it can only
2021        //    be exercised from here.
2022        let (workflow, engine) = compiled(
2023            r#"{ "id": "w", "name": "w", "tasks": [
2024                { "id": "check", "name": "check", "continue_on_error": true,
2025                  "function": {"name": "validation", "input": {"rules": [
2026                      {"logic": false, "message": "nope"}]}}},
2027                { "id": "react", "name": "react",
2028                  "function": {"name": "map", "input": {"mappings": [
2029                      {"path": "data.seen", "logic": {"var": "metadata.errors.0.code"}}]}}}
2030            ]}"#,
2031        );
2032        let mut message = Message::from_value(&json!({}));
2033
2034        executor_with_error_context(engine)
2035            .execute(&workflow, &mut message, Utc::now())
2036            .await
2037            .expect("continue_on_error keeps the workflow running");
2038
2039        assert_eq!(
2040            message.context["data"].get("seen"),
2041            Some(&dv(json!("VALIDATION_ERROR"))),
2042            "the map must read the record the validation appended in the same stretch"
2043        );
2044    }
2045
2046    #[tokio::test]
2047    async fn the_error_context_path_is_untouched_when_every_task_succeeds() {
2048        let (workflow, engine) = compiled(&format!(
2049            r#"{{ "id": "w", "name": "w", "tasks": [{COUNTER_BODY}] }}"#
2050        ));
2051        let mut message = Message::from_value(&json!({}));
2052
2053        executor_with_error_context(engine)
2054            .execute(&workflow, &mut message, Utc::now())
2055            .await
2056            .expect("workflow should complete");
2057
2058        assert_eq!(
2059            message.context["metadata"].get("errors"),
2060            None,
2061            "a clean run leaves the key absent, not an empty array"
2062        );
2063    }
2064
2065    /// Every `loop_counter` recorded on the audit trail, in order.
2066    fn counters(message: &Message) -> Vec<Option<i64>> {
2067        message
2068            .audit_trail
2069            .iter()
2070            .map(|entry| entry.loop_counter)
2071            .collect()
2072    }
2073
2074    /// A one-task `map` workflow body writing `data.n` from the counter.
2075    const COUNTER_BODY: &str = r#"{"id": "t", "name": "t", "function": {"name": "map",
2076        "input": {"mappings": [{"path": "data.n", "logic": {"var": "temp_data.i"}}]}}}"#;
2077
2078    #[tokio::test]
2079    async fn loop_without_a_condition_runs_exactly_max_sweeps() {
2080        let (workflow, engine) = compiled(&format!(
2081            r#"{{ "id": "w", "name": "w", "loop": {{"counter": "i", "max": 3}},
2082                  "tasks": [{COUNTER_BODY}] }}"#
2083        ));
2084        let mut message = Message::from_value(&json!({}));
2085
2086        let executed = executor(engine)
2087            .execute(&workflow, &mut message, Utc::now())
2088            .await
2089            .expect("loop should complete");
2090
2091        assert!(executed);
2092        // One audit entry per sweep, each stamped with its counter.
2093        assert_eq!(counters(&message), vec![Some(0), Some(1), Some(2)]);
2094        // The counter is left at the bound the loop stopped on.
2095        assert_eq!(message.context["temp_data"].get("i"), Some(&dv(json!(3))));
2096        // The body observed each value; the last one survives.
2097        assert_eq!(message.context["data"].get("n"), Some(&dv(json!(2))));
2098    }
2099
2100    #[tokio::test]
2101    async fn loop_exits_early_when_the_condition_goes_false() {
2102        // Bounded at 10 but the condition stops it at 4.
2103        let (workflow, engine) = compiled(&format!(
2104            r#"{{ "id": "w", "name": "w",
2105                  "condition": {{"<": [{{"var": "temp_data.i"}}, 4]}},
2106                  "loop": {{"counter": "i", "max": 10}},
2107                  "tasks": [{COUNTER_BODY}] }}"#
2108        ));
2109        let mut message = Message::from_value(&json!({}));
2110
2111        executor(engine)
2112            .execute(&workflow, &mut message, Utc::now())
2113            .await
2114            .expect("loop should complete");
2115
2116        assert_eq!(counters(&message), vec![Some(0), Some(1), Some(2), Some(3)]);
2117    }
2118
2119    #[tokio::test]
2120    async fn loop_whose_condition_is_false_on_the_first_sweep_is_a_plain_skip() {
2121        let (workflow, engine) = compiled(&format!(
2122            r#"{{ "id": "w", "name": "w", "condition": false,
2123                  "loop": {{"counter": "i", "max": 5}},
2124                  "tasks": [{COUNTER_BODY}] }}"#
2125        ));
2126        let mut message = Message::from_value(&json!({}));
2127
2128        let executed = executor(engine)
2129            .execute(&workflow, &mut message, Utc::now())
2130            .await
2131            .expect("a skip is not an error");
2132
2133        assert!(!executed, "a never-entered loop reports as skipped");
2134        assert!(message.audit_trail.is_empty());
2135    }
2136
2137    #[tokio::test]
2138    async fn filter_halt_breaks_the_whole_loop_not_just_one_sweep() {
2139        let (workflow, engine) = compiled(
2140            r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "max": 10},
2141                 "tasks": [
2142                   {"id": "gate", "name": "gate", "function": {"name": "filter",
2143                     "input": {"condition": {"<": [{"var": "temp_data.i"}, 2]},
2144                               "on_reject": "halt"}}},
2145                   {"id": "body", "name": "body", "function": {"name": "map",
2146                     "input": {"mappings": [
2147                        {"path": "data.n", "logic": {"var": "temp_data.i"}}]}}}] }"#,
2148        );
2149        let mut message = Message::from_value(&json!({}));
2150
2151        executor(engine)
2152            .execute(&workflow, &mut message, Utc::now())
2153            .await
2154            .expect("a halt is not an error");
2155
2156        // Sweeps 0 and 1 run both tasks; sweep 2's gate halts and ends the
2157        // loop rather than moving on to sweep 3.
2158        let ids: Vec<&str> = message
2159            .audit_trail
2160            .iter()
2161            .map(|entry| entry.task_id.as_ref())
2162            .collect();
2163        assert_eq!(ids, ["gate", "body", "gate", "body", "gate"]);
2164        assert_eq!(
2165            counters(&message),
2166            vec![Some(0), Some(0), Some(1), Some(1), Some(2)]
2167        );
2168    }
2169
2170    #[tokio::test]
2171    async fn init_and_increment_drive_the_counter() {
2172        let (workflow, engine) = compiled(&format!(
2173            r#"{{ "id": "w", "name": "w",
2174                  "loop": {{"counter": "i", "init": 10, "increment": 5, "max": 25}},
2175                  "tasks": [{COUNTER_BODY}] }}"#
2176        ));
2177        let mut message = Message::from_value(&json!({}));
2178
2179        executor(engine)
2180            .execute(&workflow, &mut message, Utc::now())
2181            .await
2182            .expect("loop should complete");
2183
2184        assert_eq!(counters(&message), vec![Some(10), Some(15), Some(20)]);
2185    }
2186
2187    #[tokio::test]
2188    async fn a_loop_without_a_named_counter_still_records_it_on_the_audit_trail() {
2189        let (workflow, engine) = compiled(
2190            r#"{ "id": "w", "name": "w", "loop": {"max": 2},
2191                 "tasks": [{"id": "t", "name": "t",
2192                            "function": {"name": "map", "input": {"mappings": []}}}] }"#,
2193        );
2194        let mut message = Message::from_value(&json!({}));
2195
2196        executor(engine)
2197            .execute(&workflow, &mut message, Utc::now())
2198            .await
2199            .expect("loop should complete");
2200
2201        assert_eq!(counters(&message), vec![Some(0), Some(1)]);
2202        // Nothing was written to temp_data — the counter was never named.
2203        assert_eq!(message.context["temp_data"], dv(json!({})));
2204    }
2205
2206    #[tokio::test]
2207    async fn a_non_looping_workflow_records_no_loop_counter() {
2208        let (workflow, engine) = compiled(
2209            r#"{ "id": "w", "name": "w",
2210                 "tasks": [{"id": "t", "name": "t",
2211                            "function": {"name": "map", "input": {"mappings": []}}}] }"#,
2212        );
2213        let mut message = Message::from_value(&json!({}));
2214
2215        executor(engine)
2216            .execute(&workflow, &mut message, Utc::now())
2217            .await
2218            .expect("should complete");
2219
2220        assert_eq!(counters(&message), vec![None]);
2221    }
2222
2223    #[tokio::test]
2224    async fn progress_metadata_is_written_on_every_sweep() {
2225        // `metadata.progress` is load-bearing for cross-workflow chaining; a
2226        // loop must not gate it.
2227        let (workflow, engine) = compiled(&format!(
2228            r#"{{ "id": "w", "name": "w", "loop": {{"counter": "i", "max": 3}},
2229                  "tasks": [{COUNTER_BODY}] }}"#
2230        ));
2231        let mut message = Message::from_value(&json!({}));
2232
2233        executor(engine)
2234            .execute(&workflow, &mut message, Utc::now())
2235            .await
2236            .expect("loop should complete");
2237
2238        let progress = message.context["metadata"]
2239            .get("progress")
2240            .expect("progress must be written");
2241        assert_eq!(progress.get("workflow_id"), Some(&dv(json!("w"))));
2242        assert_eq!(progress.get("task_id"), Some(&dv(json!("t"))));
2243        assert_eq!(progress.get("status_code"), Some(&dv(json!(200))));
2244    }
2245
2246    #[tokio::test]
2247    async fn the_engine_owns_the_counter_even_if_a_body_task_writes_it() {
2248        // A body task writing the counter path is overwritten at the next
2249        // increment, so termination reasoning stays local to LoopConfig.
2250        let (workflow, engine) = compiled(
2251            r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "max": 3},
2252                 "tasks": [{"id": "t", "name": "t", "function": {"name": "map",
2253                    "input": {"mappings": [{"path": "temp_data.i", "logic": 99}]}}}] }"#,
2254        );
2255        let mut message = Message::from_value(&json!({}));
2256
2257        executor(engine)
2258            .execute(&workflow, &mut message, Utc::now())
2259            .await
2260            .expect("loop should complete");
2261
2262        assert_eq!(
2263            counters(&message),
2264            vec![Some(0), Some(1), Some(2)],
2265            "the body's write must not stall or skew the loop"
2266        );
2267    }
2268
2269    /// Run a bare counting loop with the given bounds and return the counter
2270    /// values the sweeps actually recorded.
2271    async fn counter_sequence(init: i64, increment: i64, max: i64) -> Vec<Option<i64>> {
2272        let (workflow, engine) = compiled(&format!(
2273            r#"{{ "id": "w", "name": "w",
2274                  "loop": {{"counter": "i", "init": {init},
2275                            "increment": {increment}, "max": {max}}},
2276                  "tasks": [{{"id": "t", "name": "t",
2277                              "function": {{"name": "map", "input": {{"mappings": []}}}}}}] }}"#
2278        ));
2279        let mut message = Message::from_value(&json!({}));
2280        executor(engine)
2281            .execute(&workflow, &mut message, Utc::now())
2282            .await
2283            .expect("loop should complete");
2284        counters(&message)
2285    }
2286
2287    #[tokio::test]
2288    async fn counter_sequence_matrix_over_init_increment_and_max() {
2289        // The half-open `counter < max` bound, swept across signs and step
2290        // sizes. Each expected list is the exact sequence of sweeps.
2291        let cases: Vec<(i64, i64, i64, Vec<i64>)> = vec![
2292            // Defaults: 0-based, step 1 — the array-index case.
2293            (0, 1, 1, vec![0]),
2294            (0, 1, 2, vec![0, 1]),
2295            (0, 1, 5, vec![0, 1, 2, 3, 4]),
2296            // Non-unit steps, including a range the step does not divide.
2297            (0, 2, 6, vec![0, 2, 4]),
2298            (0, 3, 10, vec![0, 3, 6, 9]),
2299            (0, 5, 3, vec![0]),
2300            (0, 100, 1, vec![0]),
2301            // Non-zero starts.
2302            (10, 5, 25, vec![10, 15, 20]),
2303            (3, 1, 6, vec![3, 4, 5]),
2304            // Negative and mixed-sign ranges.
2305            (-3, 1, 2, vec![-3, -2, -1, 0, 1]),
2306            (-4, 2, 1, vec![-4, -2, 0]),
2307            (-10, 5, -5, vec![-10]),
2308        ];
2309
2310        for (init, increment, max, expected) in cases {
2311            let got = counter_sequence(init, increment, max).await;
2312            let expected: Vec<Option<i64>> = expected.into_iter().map(Some).collect();
2313            assert_eq!(got, expected, "init={init} increment={increment} max={max}");
2314        }
2315    }
2316
2317    #[tokio::test]
2318    async fn the_counter_advance_saturates_instead_of_overflowing() {
2319        // A huge increment must end the loop, not wrap into a negative counter
2320        // and spin. Both the giant-step and the near-i64::MAX start are
2321        // exercised, since either could overflow a plain `+`.
2322        assert_eq!(
2323            counter_sequence(0, i64::MAX, 5).await,
2324            vec![Some(0)],
2325            "one sweep, then the advance saturates past max"
2326        );
2327        assert_eq!(
2328            counter_sequence(i64::MAX - 1, 1, i64::MAX).await,
2329            vec![Some(i64::MAX - 1)],
2330            "the last representable sweep still terminates"
2331        );
2332        assert_eq!(
2333            counter_sequence(i64::MAX - 2, i64::MAX, i64::MAX).await,
2334            vec![Some(i64::MAX - 2)]
2335        );
2336    }
2337
2338    #[tokio::test]
2339    async fn a_task_condition_is_re_evaluated_against_the_counter_every_sweep() {
2340        // Per-sweep task conditions are the mechanism for "do this only on
2341        // some iterations"; a stale condition cache would break it.
2342        let (workflow, engine) = compiled(
2343            r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "max": 4},
2344                 "tasks": [
2345                   {"id": "evens", "name": "evens",
2346                    "condition": {"==": [{"%": [{"var": "temp_data.i"}, 2]}, 0]},
2347                    "function": {"name": "map", "input": {"mappings": []}}},
2348                   {"id": "always", "name": "always",
2349                    "function": {"name": "map", "input": {"mappings": []}}}] }"#,
2350        );
2351        let mut message = Message::from_value(&json!({}));
2352
2353        executor(engine)
2354            .execute(&workflow, &mut message, Utc::now())
2355            .await
2356            .expect("loop should complete");
2357
2358        let entries: Vec<(&str, Option<i64>)> = message
2359            .audit_trail
2360            .iter()
2361            .map(|e| (e.task_id.as_ref(), e.loop_counter))
2362            .collect();
2363        assert_eq!(
2364            entries,
2365            [
2366                ("evens", Some(0)),
2367                ("always", Some(0)),
2368                ("always", Some(1)),
2369                ("evens", Some(2)),
2370                ("always", Some(2)),
2371                ("always", Some(3)),
2372            ],
2373            "the gated task runs only on even counters"
2374        );
2375    }
2376
2377    #[tokio::test]
2378    async fn a_filter_skip_does_not_keep_the_loop_alive_or_record_entries() {
2379        // `TaskOutcome::Skip` records no audit entry and no progress write.
2380        // The loop is driven by its bound, not by whether tasks recorded
2381        // anything, so it still runs exactly `max` sweeps.
2382        let (workflow, engine) = compiled(
2383            r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "max": 3},
2384                 "tasks": [{"id": "gate", "name": "gate", "function": {"name": "filter",
2385                    "input": {"condition": false, "on_reject": "skip"}}}] }"#,
2386        );
2387        let mut message = Message::from_value(&json!({}));
2388
2389        let executed = executor(engine)
2390            .execute(&workflow, &mut message, Utc::now())
2391            .await
2392            .expect("skip is not an error");
2393
2394        assert!(executed, "sweeps ran even though every task skipped");
2395        assert!(message.audit_trail.is_empty(), "Skip records no entry");
2396        assert_eq!(
2397            message.context["temp_data"].get("i"),
2398            Some(&dv(json!(3))),
2399            "the loop still ran to its bound"
2400        );
2401    }
2402
2403    #[tokio::test]
2404    async fn a_4xx_task_status_is_recorded_per_sweep_without_stopping_the_loop() {
2405        // A failing `validation` yields 400: warned, recorded, loop continues.
2406        let (workflow, engine) = compiled(
2407            r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "max": 3},
2408                 "tasks": [{"id": "check", "name": "check", "function": {"name": "validation",
2409                    "input": {"rules": [{"logic": {"==": [1, 2]}, "message": "nope"}]}}}] }"#,
2410        );
2411        let mut message = Message::from_value(&json!({}));
2412
2413        executor(engine)
2414            .execute(&workflow, &mut message, Utc::now())
2415            .await
2416            .expect("a 4xx does not stop the workflow");
2417
2418        assert_eq!(counters(&message), vec![Some(0), Some(1), Some(2)]);
2419        assert!(
2420            message.audit_trail.iter().all(|e| e.status == 400),
2421            "every sweep recorded the 4xx"
2422        );
2423    }
2424
2425    #[tokio::test]
2426    async fn the_rollout_gate_excludes_a_looping_workflow_before_any_sweep() {
2427        // The gate runs ahead of the loop, so an excluded workflow writes no
2428        // counter at all — it must be indistinguishable from a plain skip.
2429        let (workflow, engine) = compiled(
2430            r#"{ "id": "w", "name": "w",
2431                 "rollout": {"bucket_start": 0, "bucket_end": 50},
2432                 "loop": {"counter": "i", "max": 5},
2433                 "tasks": [{"id": "t", "name": "t",
2434                            "function": {"name": "map", "input": {"mappings": []}}}] }"#,
2435        );
2436        let mut message = Message::builder().routing_bucket(75).build();
2437
2438        let executed = executor(engine)
2439            .execute(&workflow, &mut message, Utc::now())
2440            .await
2441            .expect("an excluded workflow is not an error");
2442
2443        assert!(!executed);
2444        assert!(message.audit_trail.is_empty());
2445        assert_eq!(
2446            message.context["temp_data"].get("i"),
2447            None,
2448            "no counter is written for an excluded workflow"
2449        );
2450    }
2451
2452    #[tokio::test]
2453    async fn a_nested_counter_path_is_created_and_advanced() {
2454        let (workflow, engine) = compiled(
2455            r#"{ "id": "w", "name": "w",
2456                 "loop": {"counter": "cursor.index", "max": 3},
2457                 "tasks": [{"id": "t", "name": "t", "function": {"name": "map",
2458                    "input": {"mappings": [
2459                       {"path": "data.seen", "logic": {"var": "temp_data.cursor.index"}}]}}}] }"#,
2460        );
2461        let mut message = Message::from_value(&json!({}));
2462
2463        executor(engine)
2464            .execute(&workflow, &mut message, Utc::now())
2465            .await
2466            .expect("loop should complete");
2467
2468        assert_eq!(
2469            message.context["temp_data"]["cursor"].get("index"),
2470            Some(&dv(json!(3)))
2471        );
2472        assert_eq!(
2473            message.context["data"].get("seen"),
2474            Some(&dv(json!(2))),
2475            "the body read the nested counter"
2476        );
2477    }
2478
2479    #[tokio::test]
2480    async fn writing_the_counter_preserves_unrelated_temp_data() {
2481        let (workflow, engine) = compiled(
2482            r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "max": 2},
2483                 "tasks": [{"id": "t", "name": "t",
2484                            "function": {"name": "map", "input": {"mappings": []}}}] }"#,
2485        );
2486        let mut message = Message::builder()
2487            .temp_data(dv(json!({"keep": "me", "nested": {"a": 1}})))
2488            .build();
2489
2490        executor(engine)
2491            .execute(&workflow, &mut message, Utc::now())
2492            .await
2493            .expect("loop should complete");
2494
2495        assert_eq!(
2496            message.context["temp_data"].get("keep"),
2497            Some(&dv(json!("me")))
2498        );
2499        assert_eq!(
2500            message.context["temp_data"]["nested"].get("a"),
2501            Some(&dv(json!(1)))
2502        );
2503        assert_eq!(message.context["temp_data"].get("i"), Some(&dv(json!(2))));
2504    }
2505
2506    #[tokio::test]
2507    async fn the_counter_overwrites_a_pre_existing_value_at_that_path() {
2508        // The engine owns the path: whatever was there before the loop is
2509        // replaced by `init` on the first sweep.
2510        let (workflow, engine) = compiled(
2511            r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "init": 5, "max": 7},
2512                 "tasks": [{"id": "t", "name": "t",
2513                            "function": {"name": "map", "input": {"mappings": []}}}] }"#,
2514        );
2515        let mut message = Message::builder()
2516            .temp_data(dv(json!({"i": "not a number"})))
2517            .build();
2518
2519        executor(engine)
2520            .execute(&workflow, &mut message, Utc::now())
2521            .await
2522            .expect("loop should complete");
2523
2524        assert_eq!(counters(&message), vec![Some(5), Some(6)]);
2525        assert_eq!(message.context["temp_data"].get("i"), Some(&dv(json!(7))));
2526    }
2527
2528    #[tokio::test]
2529    async fn a_loop_records_audit_entries_with_capture_changes_off() {
2530        // `capture_changes(false)` suppresses the per-change diff, not the
2531        // audit entries themselves — so the loop counter is still recorded.
2532        let (workflow, engine) = compiled(
2533            r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "max": 2},
2534                 "tasks": [{"id": "t", "name": "t", "function": {"name": "map",
2535                    "input": {"mappings": [
2536                       {"path": "data.n", "logic": {"var": "temp_data.i"}}]}}}] }"#,
2537        );
2538        let mut message = Message::builder().capture_changes(false).build();
2539
2540        executor(engine)
2541            .execute(&workflow, &mut message, Utc::now())
2542            .await
2543            .expect("loop should complete");
2544
2545        assert_eq!(counters(&message), vec![Some(0), Some(1)]);
2546        assert!(
2547            message.audit_trail.iter().all(|e| e.changes.is_empty()),
2548            "no diffs captured, but the entries are still there"
2549        );
2550    }
2551
2552    #[tokio::test]
2553    async fn two_loops_sharing_a_counter_name_do_not_interfere() {
2554        // Each loop re-initialises the path it owns, so the second starts from
2555        // its own `init` rather than inheriting where the first stopped.
2556        let first = r#"{ "id": "a", "name": "a", "priority": 0,
2557             "loop": {"counter": "i", "max": 2},
2558             "tasks": [{"id": "t", "name": "t",
2559                        "function": {"name": "map", "input": {"mappings": []}}}] }"#;
2560        let second = r#"{ "id": "b", "name": "b", "priority": 1,
2561             "loop": {"counter": "i", "init": 10, "max": 12},
2562             "tasks": [{"id": "t", "name": "t",
2563                        "function": {"name": "map", "input": {"mappings": []}}}] }"#;
2564
2565        let compiler = LogicCompiler::new();
2566        let workflows = compiler
2567            .compile_workflows(vec![
2568                Workflow::from_json(first).unwrap(),
2569                Workflow::from_json(second).unwrap(),
2570            ])
2571            .expect("should compile");
2572        let exec = executor(compiler.into_engine());
2573        let mut message = Message::from_value(&json!({}));
2574
2575        exec.run_all_borrowed(&workflows, &mut message, None, Utc::now())
2576            .await
2577            .expect("both loops should complete");
2578
2579        let per_workflow: Vec<(&str, Option<i64>)> = message
2580            .audit_trail
2581            .iter()
2582            .map(|e| (e.workflow_id.as_ref(), e.loop_counter))
2583            .collect();
2584        assert_eq!(
2585            per_workflow,
2586            [
2587                ("a", Some(0)),
2588                ("a", Some(1)),
2589                ("b", Some(10)),
2590                ("b", Some(11)),
2591            ]
2592        );
2593    }
2594
2595    #[tokio::test]
2596    async fn a_looping_workflow_between_sync_workflows_does_not_break_the_sync_run() {
2597        // Regression guard for the `joins_sync_run` change: a loop workflow is
2598        // excluded from the shared-arena run, which must split the run around
2599        // it rather than dropping its neighbours.
2600        let sync_wf = |id: &str, priority: u32| {
2601            format!(
2602                r#"{{ "id": "{id}", "name": "{id}", "priority": {priority},
2603                      "tasks": [{{"id": "t", "name": "t", "function": {{"name": "map",
2604                        "input": {{"mappings": [
2605                          {{"path": "data.{id}", "logic": true}}]}}}}}}] }}"#
2606            )
2607        };
2608        let loop_wf = r#"{ "id": "mid", "name": "mid", "priority": 1,
2609             "loop": {"counter": "i", "max": 2},
2610             "tasks": [{"id": "t", "name": "t", "function": {"name": "map",
2611                "input": {"mappings": [{"path": "data.mid", "logic": true}]}}}] }"#;
2612
2613        let compiler = LogicCompiler::new();
2614        let workflows = compiler
2615            .compile_workflows(vec![
2616                Workflow::from_json(&sync_wf("before", 0)).unwrap(),
2617                Workflow::from_json(loop_wf).unwrap(),
2618                Workflow::from_json(&sync_wf("after", 2)).unwrap(),
2619            ])
2620            .expect("should compile");
2621        // All three are sync-only, but the loop must not join a shared run.
2622        assert!(workflows.iter().all(|w| w.fully_sync));
2623        assert!(!joins_sync_run(&workflows[1]));
2624
2625        let exec = executor(compiler.into_engine());
2626        let mut message = Message::from_value(&json!({}));
2627
2628        exec.run_all_borrowed(&workflows, &mut message, None, Utc::now())
2629            .await
2630            .expect("all three should run");
2631
2632        for id in ["before", "mid", "after"] {
2633            assert_eq!(
2634                message.context["data"].get(id),
2635                Some(&dv(json!(true))),
2636                "workflow {id} must have run"
2637            );
2638        }
2639        let order: Vec<(&str, Option<i64>)> = message
2640            .audit_trail
2641            .iter()
2642            .map(|e| (e.workflow_id.as_ref(), e.loop_counter))
2643            .collect();
2644        assert_eq!(
2645            order,
2646            [
2647                ("before", None),
2648                ("mid", Some(0)),
2649                ("mid", Some(1)),
2650                ("after", None),
2651            ],
2652            "priority order is preserved across the split"
2653        );
2654    }
2655
2656    #[tokio::test]
2657    async fn consecutive_non_looping_sync_workflows_still_share_one_run() {
2658        // The other half of the same regression: without a loop in the way,
2659        // every fully-sync workflow still groups as it always did.
2660        let compiler = LogicCompiler::new();
2661        let workflows = compiler
2662            .compile_workflows(vec![
2663                Workflow::from_json(
2664                    r#"{ "id": "a", "name": "a", "priority": 0, "tasks": [{"id": "t", "name": "t",
2665                         "function": {"name": "map", "input": {"mappings": [
2666                           {"path": "data.a", "logic": 1}]}}}] }"#,
2667                )
2668                .unwrap(),
2669                Workflow::from_json(
2670                    r#"{ "id": "b", "name": "b", "priority": 1,
2671                         "condition": {"==": [{"var": "data.a"}, 1]},
2672                         "tasks": [{"id": "t", "name": "t",
2673                         "function": {"name": "map", "input": {"mappings": [
2674                           {"path": "data.b", "logic": 2}]}}}] }"#,
2675                )
2676                .unwrap(),
2677            ])
2678            .expect("should compile");
2679        assert!(workflows.iter().all(joins_sync_run));
2680
2681        let exec = executor(compiler.into_engine());
2682        let mut message = Message::from_value(&json!({}));
2683        exec.run_all_borrowed(&workflows, &mut message, None, Utc::now())
2684            .await
2685            .expect("both should run");
2686
2687        // `b`'s condition reads what `a` wrote, which only works if the shared
2688        // arena context was refreshed across the workflow boundary.
2689        assert_eq!(message.context["data"].get("b"), Some(&dv(json!(2))));
2690        assert_eq!(counters(&message), vec![None, None]);
2691    }
2692
2693    #[tokio::test]
2694    async fn a_loop_body_can_index_an_array_by_its_counter() {
2695        // The per-item pattern, using only core operators.
2696        let (workflow, engine) = compiled(
2697            r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "max": 3},
2698                 "tasks": [{"id": "pick", "name": "pick", "function": {"name": "map",
2699                    "input": {"mappings": [
2700                       {"path": "data.picked",
2701                        "logic": {"merge": [{"var": "data.picked"},
2702                                            [{"val": [["data", "items",
2703                                                       {"var": "temp_data.i"}]]}]]}}]}}}] }"#,
2704        );
2705        let mut message = Message::builder()
2706            .data(dv(json!({"items": ["a", "b", "c"], "picked": []})))
2707            .build();
2708
2709        executor(engine)
2710            .execute(&workflow, &mut message, Utc::now())
2711            .await
2712            .expect("loop should complete");
2713
2714        assert_eq!(
2715            serde_json::Value::from(&message.context["data"]["picked"]),
2716            json!(["a", "b", "c"]),
2717            "each sweep appended the item at its own index"
2718        );
2719    }
2720
2721    #[tokio::test]
2722    async fn test_workflow_executor_skip_condition() {
2723        // Create a workflow with a false condition
2724        let workflow_json = r#"{
2725            "id": "test_workflow",
2726            "name": "Test Workflow",
2727            "condition": false,
2728            "tasks": [{
2729                "id": "dummy_task",
2730                "name": "Dummy Task",
2731                "function": {
2732                    "name": "map",
2733                    "input": {"mappings": []}
2734                }
2735            }]
2736        }"#;
2737
2738        let compiler = LogicCompiler::new();
2739        let mut workflow = Workflow::from_json(workflow_json).unwrap();
2740
2741        // Compile the workflow condition
2742        let workflows = compiler.compile_workflows(vec![workflow.clone()]).unwrap();
2743        if let Some(compiled_workflow) = workflows.iter().find(|w| w.id == "test_workflow") {
2744            workflow = compiled_workflow.clone();
2745        }
2746
2747        let engine = compiler.into_engine();
2748        let task_executor = Arc::new(TaskExecutor::new(
2749            Arc::new(HashMap::new()),
2750            Arc::clone(&engine),
2751        ));
2752        let workflow_executor = WorkflowExecutor::new(task_executor, engine);
2753
2754        let mut message = Message::from_value(&json!({}));
2755
2756        // Execute workflow - should be skipped due to false condition
2757        let executed = workflow_executor
2758            .execute(&workflow, &mut message, Utc::now())
2759            .await
2760            .unwrap();
2761        assert!(!executed);
2762        assert_eq!(message.audit_trail.len(), 0);
2763    }
2764
2765    #[tokio::test]
2766    async fn test_workflow_executor_execute_success() {
2767        // Create a workflow with a true condition
2768        let workflow_json = r#"{
2769            "id": "test_workflow",
2770            "name": "Test Workflow",
2771            "condition": true,
2772            "tasks": [{
2773                "id": "dummy_task",
2774                "name": "Dummy Task",
2775                "function": {
2776                    "name": "map",
2777                    "input": {"mappings": []}
2778                }
2779            }]
2780        }"#;
2781
2782        let compiler = LogicCompiler::new();
2783        let mut workflow = Workflow::from_json(workflow_json).unwrap();
2784
2785        // Compile the workflow
2786        let workflows = compiler.compile_workflows(vec![workflow.clone()]).unwrap();
2787        if let Some(compiled_workflow) = workflows.iter().find(|w| w.id == "test_workflow") {
2788            workflow = compiled_workflow.clone();
2789        }
2790
2791        let engine = compiler.into_engine();
2792        let task_executor = Arc::new(TaskExecutor::new(
2793            Arc::new(HashMap::new()),
2794            Arc::clone(&engine),
2795        ));
2796        let workflow_executor = WorkflowExecutor::new(task_executor, engine);
2797
2798        let mut message = Message::from_value(&json!({}));
2799
2800        // Execute workflow - should succeed with empty task list
2801        let executed = workflow_executor
2802            .execute(&workflow, &mut message, Utc::now())
2803            .await
2804            .unwrap();
2805        assert!(executed);
2806    }
2807}