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