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, from_datalogic_eval, service_error_code,
8};
9use crate::engine::executor::{
10    ArenaContext, eval_to_owned, evaluate_condition, evaluate_condition_in_arena, with_arena,
11};
12use crate::engine::for_each::ForEach;
13use crate::engine::functions::BoxedFunctionHandler;
14use crate::engine::message::{AuditTrail, Change, Message};
15use crate::engine::observer::{
16    ExecutionObserver, MessageFinished, MessageStarted, TaskEvent, WorkflowFinished,
17    WorkflowStarted,
18};
19use crate::engine::task::{HaltOn, Task};
20use crate::engine::task_context::{CallStamp, TaskIdentity};
21use crate::engine::task_executor::TaskExecutor;
22use crate::engine::task_outcome::TaskOutcome;
23use crate::engine::trace::{
24    ExecutionStep, ExecutionTrace, StepStamp, StepTiming, duration_us_between,
25};
26use crate::engine::utils::{
27    compute_path_parts, describe_kind, get_nested_value_parts, remove_nested_value,
28    set_nested_value, set_nested_value_parts, strip_hash_prefix,
29};
30use crate::engine::workflow::{LoopConfig, Workflow};
31use chrono::{DateTime, Utc};
32use core::time::Duration;
33use datalogic_rs::{Engine, Logic};
34use datavalue::OwnedDataValue;
35use log::{debug, error, info, warn};
36use serde_json::Value;
37use std::collections::HashMap;
38use std::sync::Arc;
39
40/// Result of handling a task, including possible control flow signals
41enum TaskControlFlow {
42    /// Continue executing the next task
43    Continue,
44    /// Stop executing further tasks in this workflow (filter halt)
45    HaltWorkflow,
46}
47
48/// Constants shared by every task in one pass over a workflow's task list.
49///
50/// Tracks one workflow's observer span across however many sweeps it runs.
51///
52/// `started_at` doubles as "has `workflow_started` been emitted": a looping
53/// workflow opens the span on its first admitted sweep and closes it once, so
54/// the observer sees one pair for the whole loop rather than one per sweep.
55#[derive(Default)]
56struct WorkflowSpan {
57    started_at: Option<DateTime<Utc>>,
58    sweeps: u32,
59}
60
61/// Bundles the per-message timestamp with the loop counter so that threading
62/// the counter through the task loop did not push `run_tasks_slice_in_arena`
63/// and `handle_task_result` past clippy's argument-count threshold.
64#[derive(Clone, Copy)]
65struct PassCtx {
66    /// The single `Utc::now()` read for this `process_message` call, shared by
67    /// every `AuditTrail` it produces.
68    now: DateTime<Utc>,
69    /// Loop counter of the sweep this pass is, or `None` for a workflow
70    /// without a `loop`.
71    loop_counter: Option<i64>,
72    /// Fan-out element being recorded, or `None` outside a `for_each`. Set
73    /// per element on a copy of the pass by `run_for_each`.
74    element_index: Option<usize>,
75}
76
77impl PassCtx {
78    /// The single pass of a workflow without a `loop`.
79    #[inline]
80    fn once(now: DateTime<Utc>) -> Self {
81        Self {
82            now,
83            loop_counter: None,
84            element_index: None,
85        }
86    }
87
88    /// Record the executed-step trace entry for one task.
89    ///
90    /// Emitted identically by both task loops — only `mapping_contexts`
91    /// differs, since the sync stretch collects per-mapping snapshots for `map`
92    /// tasks and the async boundary never has any. Shared so a field added to
93    /// the step cannot be added to one loop and forgotten in the other.
94    fn note_executed(
95        self,
96        trace: Option<&mut ExecutionTrace>,
97        workflow_id: &str,
98        task_id: &str,
99        message: &Message,
100        clocks: TaskClocks,
101        mapping_contexts: Option<Vec<Value>>,
102    ) {
103        let Some(t) = trace else {
104            return;
105        };
106        let started_at = clocks.trace_start.unwrap_or(self.now);
107        t.add_executed_step(
108            workflow_id,
109            task_id,
110            message,
111            StepTiming {
112                started_at,
113                duration_us: duration_us_between(started_at, Utc::now()),
114            },
115            mapping_contexts,
116            StepStamp {
117                loop_counter: self.loop_counter,
118                element_index: self.element_index,
119            },
120        );
121    }
122}
123
124impl PassCtx {
125    /// As [`Self::note_executed`], with the step's timing measured by the
126    /// caller.
127    ///
128    /// A fan-out element is recorded when the calls are folded back, which
129    /// may be long after the call ended — and for concurrent calls, after
130    /// several others. Timing it at record time would report the fold, so
131    /// `run_element` times the call itself and hands the result here.
132    fn note_executed_timed(
133        self,
134        trace: Option<&mut ExecutionTrace>,
135        workflow_id: &str,
136        task_id: &str,
137        message: &Message,
138        timing: StepTiming,
139    ) {
140        if let Some(t) = trace {
141            t.add_executed_step(
142                workflow_id,
143                task_id,
144                message,
145                timing,
146                None,
147                StepStamp {
148                    loop_counter: self.loop_counter,
149                    element_index: self.element_index,
150                },
151            );
152        }
153    }
154}
155
156/// The two clock reads and the error watermark a task takes before its body
157/// runs.
158///
159/// Bundled and sampled in one place because *when* they are read is the whole
160/// contract: a `trace_start` taken after the body would mistime the step, and
161/// an `errors_before` taken after it would drop every error the task
162/// contributed. Both task loops spelled the three out identically, where a
163/// one-sided edit had nothing to catch it.
164#[derive(Clone, Copy)]
165struct TaskClocks {
166    /// `Utc::now()` at task start, but only when a trace is live.
167    trace_start: Option<DateTime<Utc>>,
168    /// `trace_start`, or the observer's own clock read when only an observer is
169    /// attached. `None` on the plain path, which is what keeps the documented
170    /// one-`Utc::now()`-per-message invariant.
171    obs_start: Option<DateTime<Utc>>,
172    /// `message.errors.len()` immediately before the body ran, so the errors
173    /// this task contributed are exactly the tail beyond this index.
174    errors_before: usize,
175}
176
177/// The task list one pass walks, plus the workflow it belongs to.
178///
179/// A pass runs over either the body (`workflow.tasks`) or a loop's `setup`
180/// list. Group jump targets index into whichever list is being walked, so the
181/// list travels with the workflow rather than being read off it.
182#[derive(Clone, Copy)]
183struct PassList<'a> {
184    workflow: &'a Workflow,
185    tasks: &'a [Task],
186}
187
188impl<'a> PassList<'a> {
189    /// The workflow's body — the whole of a non-looping workflow, and one sweep
190    /// of a looping one.
191    #[inline]
192    fn body(workflow: &'a Workflow) -> Self {
193        Self {
194            workflow,
195            tasks: &workflow.tasks,
196        }
197    }
198}
199
200/// What the group gate and the two conditions decided about one task.
201enum Admission {
202    /// A terminal group closed at this task — the workflow halts before it.
203    Halt,
204    /// A group's condition was false; resume at this absolute task index.
205    Jump(usize),
206    /// The task's own condition was false; move on to the next task.
207    Skip,
208    /// Run it.
209    Run,
210}
211
212/// Run the group gate and both conditions for one task, recording the skips
213/// they imply.
214///
215/// This is the identical opening of both task loops: close spans that ended
216/// before this task (a terminal one halts), open the spans that start here (a
217/// false condition jumps past the span without consulting the member tasks'
218/// own conditions), then the task's own condition. Only the *evaluation*
219/// differs between the loops — the owned context on the async path, the shared
220/// arena view on the sync one — so it arrives as `eval`. Both flavours already
221/// map a `None` condition to `true`, so `eval` takes the `Option` directly.
222fn admit_task(
223    list: PassList<'_>,
224    task: &Task,
225    abs: usize,
226    gate: &mut GroupGate,
227    mut trace: Option<&mut ExecutionTrace>,
228    pass: PassCtx,
229    mut eval: impl FnMut(Option<&Arc<Logic>>) -> Result<bool>,
230) -> Result<Admission> {
231    if gate.close_through(abs) {
232        return Ok(Admission::Halt);
233    }
234
235    if let Some(target) = gate.enter(task, &mut eval)? {
236        note_group_skip(trace.as_deref_mut(), list, abs, target, pass.loop_counter);
237        return Ok(Admission::Jump(target));
238    }
239
240    if !eval(task.compiled_condition.as_ref())? {
241        note_task_skip(trace, &list.workflow.id, &task.id, pass.loop_counter);
242        return Ok(Admission::Skip);
243    }
244
245    Ok(Admission::Run)
246}
247
248/// The two per-*task* values `handle_task_result` needs beyond the shared
249/// [`PassCtx`].
250///
251/// Bundled rather than passed separately because `handle_task_result` already
252/// sits at clippy's `too_many_arguments` threshold, and `PassCtx` cannot carry
253/// them — it is per-pass and shared by every task in a sweep.
254#[derive(Clone, Copy)]
255struct TaskPass {
256    /// The task-level `continue_on_error` flag.
257    continue_on_error: bool,
258    /// The task-level `terminal` flag — halt the workflow once this task has
259    /// run, whatever it returned.
260    terminal: bool,
261    /// The task-level `halt_on` rule — halt once this task has run *and* failed.
262    /// Folded together with `terminal` by [`Self::halts_at`].
263    halt_on: HaltOn,
264    /// `message.errors.len()` immediately before this task ran, so the errors it
265    /// contributed can be identified as the tail beyond this index.
266    errors_before: usize,
267}
268
269impl TaskPass {
270    /// Whether this task ends the workflow, given the status its run recorded.
271    ///
272    /// `terminal` is unconditional — it is about position. `halt_on` fires only
273    /// on a failure, which is a status of `400` or above: the same threshold the
274    /// classification in `handle_task_result` already splits on to warn (4xx) or
275    /// to record `TASK_STATUS_ERROR` (5xx). Defined once here so the two notions
276    /// of "failed" cannot drift apart.
277    #[inline]
278    fn halts_at(self, status: u16) -> bool {
279        self.terminal || (self.halt_on == HaltOn::Failure && status >= 400)
280    }
281}
282
283/// The per-task values every call of one fan-out shares.
284///
285/// Bundled so `run_element` stays under clippy's argument-count limit, and
286/// `Copy` so each call's future takes its own.
287#[derive(Clone, Copy)]
288struct ElementCall<'a> {
289    workflow: &'a Workflow,
290    task: &'a Task,
291    for_each: &'a ForEach,
292    /// The enclosing loop sweep, stamped on each call alongside its element.
293    loop_counter: Option<i64>,
294    /// Whether to read the clock around each call — only when a trace or an
295    /// observer will use it, keeping the one-`Utc::now()`-per-message
296    /// invariant on the plain path.
297    timed: bool,
298}
299
300/// What one fan-out call left behind once its copy of the message is gone.
301///
302/// A call's copy is reduced to this the moment the call ends, so at most
303/// `max_concurrency` copies are alive at once however long the array is.
304struct ElementOutcome {
305    index: usize,
306    /// The call's outcome. Its writes are in `changes`, not here, so an `Err`
307    /// simply has none: a call that failed hard contributes no writes.
308    result: Result<TaskOutcome>,
309    /// Every write the call made through `TaskContext::set`, in order.
310    changes: Vec<Change>,
311    /// Errors the call recorded itself, through `TaskContext::add_error`.
312    new_errors: Vec<ErrorInfo>,
313    /// The value at `collect` in the call's copy, or `Null`.
314    collected: OwnedDataValue,
315    /// Measured around the call when [`ElementCall::timed`] is set.
316    timing: Option<StepTiming>,
317}
318
319impl ElementOutcome {
320    /// Whether this element failed: a hard `Err`, or a recorded status of 400
321    /// or more — the threshold `halt_on` and the `null` result both use.
322    fn failed(&self) -> bool {
323        match &self.result {
324            Err(_) => true,
325            Ok(outcome) => outcome.audit_status().is_some_and(|s| s >= 400),
326        }
327    }
328
329    /// Whether no further call may start after this one.
330    ///
331    /// True when the call halts the workflow, or fails the task outright — an
332    /// `Err` or a `5xx` without `continue_on_error`. Calls already running
333    /// still finish; this only stops new ones.
334    fn stops_fan_out(&self, continue_on_error: bool) -> bool {
335        match &self.result {
336            Err(_) => !continue_on_error,
337            Ok(TaskOutcome::Halt) => true,
338            Ok(outcome) => !continue_on_error && outcome.audit_status().is_some_and(|s| s >= 500),
339        }
340    }
341}
342
343/// The per-element `TaskPass`. `terminal` and `halt_on` are neutralised here
344/// and applied once, to the whole fan-out, by `run_for_each` — a terminal
345/// task must halt after its last element, not after its first.
346fn fan_out_pass(task: &Task, errors_before: usize) -> TaskPass {
347    TaskPass {
348        continue_on_error: task.continue_on_error,
349        terminal: false,
350        halt_on: HaltOn::Never,
351        errors_before,
352    }
353}
354
355/// The control flow a whole fan-out ends in: a halt any element or the
356/// failure rule requested, or `terminal`.
357fn fan_out_flow(task: &Task, halt: bool) -> TaskControlFlow {
358    if halt || task.terminal {
359        TaskControlFlow::HaltWorkflow
360    } else {
361        TaskControlFlow::Continue
362    }
363}
364
365/// One slice of a workflow's task list, plus the group state that spans slices.
366///
367/// Bundled into a single parameter because `run_tasks_slice_in_arena` already
368/// sits at clippy's `too_many_arguments` threshold, and because the three
369/// travel together: an absolute task index is `offset + i`, and `gate` is the
370/// only thing that has to survive from one slice to the next.
371struct TaskSlice<'a, 'arena> {
372    /// The tasks to run — a sub-slice of `list.tasks`.
373    tasks: &'arena [Task],
374    /// Index of `tasks[0]` within `list.tasks`.
375    offset: usize,
376    /// Group state for the whole pass, shared across every slice in it.
377    gate: &'a mut GroupGate,
378    /// The whole list this slice was cut from, and its workflow.
379    list: PassList<'arena>,
380}
381
382/// Result of running one slice of a workflow's task list.
383enum SliceOutcome {
384    /// The slice ran to its end.
385    Completed,
386    /// A task halted the workflow — `TaskOutcome::Halt`, `Task::terminal`, or
387    /// the end of a terminal group.
388    Halted,
389    /// A group condition was false and its span ends beyond this slice, so the
390    /// caller must resume at this absolute task index.
391    JumpTo(usize),
392}
393
394/// Tracks which task groups are currently open during one pass over a
395/// workflow's task list.
396///
397/// Group spans are recorded at parse time on the task that opens them
398/// (`Task::group_starts`), so the executor keeps walking a flat `&[Task]`.
399/// This gate turns those spans back into control flow: evaluate a group's
400/// condition **once** on entry, jump past the span when it is false, and halt
401/// when a terminal group closes.
402///
403/// A workflow using no groups never pushes, so the gate costs one
404/// `Vec::is_empty` check per task and never allocates.
405#[derive(Default)]
406struct GroupGate {
407    /// `(end, terminal)` for each open group, outermost first.
408    open: Vec<(usize, bool)>,
409}
410
411impl GroupGate {
412    /// Close every open group whose span ends at or before `idx`, returning
413    /// `true` if any of them was `terminal`.
414    ///
415    /// Driven by `end` rather than by a per-task close count because a jump can
416    /// skip straight past the task that would have carried the count: with
417    /// `group A { group B { t1 } }` and `B`'s condition false, nothing in `A`
418    /// ever executes, yet `A` was entered and — if terminal — must still halt.
419    fn close_through(&mut self, idx: usize) -> bool {
420        let mut terminal = false;
421        while let Some(&(end, is_terminal)) = self.open.last() {
422            if end > idx {
423                break;
424            }
425            self.open.pop();
426            terminal |= is_terminal;
427        }
428        terminal
429    }
430
431    /// Evaluate the groups opening at `task`, outermost first. Returns
432    /// `Some(end)` when one's condition is false and the cursor must jump past
433    /// its span; the groups outside it stay open.
434    fn enter<F>(&mut self, task: &Task, mut eval: F) -> Result<Option<usize>>
435    where
436        F: FnMut(Option<&Arc<Logic>>) -> Result<bool>,
437    {
438        for group in &task.group_starts {
439            if !eval(group.compiled_condition.as_ref())? {
440                return Ok(Some(group.end));
441            }
442            self.open.push((group.end, group.terminal));
443        }
444        Ok(None)
445    }
446}
447
448/// Record the skip of every task in `list.tasks[from..to]` — the span of a
449/// group whose condition was false.
450///
451/// The trace stays task-granular rather than growing a group-level step, so
452/// `StepResult` and the npm wire type it mirrors are unchanged.
453fn note_group_skip(
454    mut trace: Option<&mut ExecutionTrace>,
455    list: PassList<'_>,
456    from: usize,
457    to: usize,
458    loop_counter: Option<i64>,
459) {
460    for task in &list.tasks[from..to.min(list.tasks.len())] {
461        note_task_skip(
462            trace.as_deref_mut(),
463            &list.workflow.id,
464            &task.id,
465            loop_counter,
466        );
467    }
468}
469
470/// Result of one pass over a workflow's task list.
471enum PassOutcome {
472    /// The workflow condition evaluated false — no task ran.
473    ConditionFalse,
474    /// Every task ran (or was individually skipped) to the end of the list.
475    Completed,
476    /// A task returned [`TaskOutcome::Halt`].
477    Halted,
478}
479
480/// Return the index of the first task at or after `start` that is *not* a
481/// synchronous built-in. Used to chunk `workflow.tasks` into sync-only
482/// stretches that can share a single `ArenaContext`.
483fn next_async_boundary(tasks: &[Task], start: usize) -> usize {
484    let mut i = start;
485    while i < tasks.len() && tasks[i].function.is_sync_builtin() {
486        i += 1;
487    }
488    i
489}
490
491/// Log and (if tracing) record a whole-workflow skip. `reason` is only for the
492/// debug log — `ExecutionStep::workflow_skipped` doesn't carry one, so a
493/// rollout-bucket exclusion and a false condition are indistinguishable in the
494/// trace, same as before this was factored out of its four call sites.
495fn note_workflow_skip(trace: Option<&mut ExecutionTrace>, workflow_id: &str, reason: &str) {
496    debug!("Skipping workflow {} - {}", workflow_id, reason);
497    if let Some(t) = trace {
498        t.add_step(ExecutionStep::workflow_skipped(workflow_id));
499    }
500}
501
502/// Log and (if tracing) record a single task's condition skip.
503///
504/// The async task loop and the shared-arena one both reach this point with the
505/// same state, and previously spelled the block out twice — every field added
506/// to the skipped step had to be added in both places, with nothing to catch a
507/// one-sided edit. Companion to [`note_workflow_skip`] above.
508fn note_task_skip(
509    trace: Option<&mut ExecutionTrace>,
510    workflow_id: &str,
511    task_id: &str,
512    loop_counter: Option<i64>,
513) {
514    debug!("Skipping task {} - condition not met", task_id);
515    if let Some(t) = trace {
516        t.add_step(
517            ExecutionStep::task_skipped(workflow_id, task_id).with_loop_counter(loop_counter),
518        );
519    }
520}
521
522/// Whether `workflow` serves this message's routing bucket.
523///
524/// A workflow with no `rollout`, or a message with no bucket, is admitted. The
525/// missing-bucket case admits deliberately: every message any existing caller
526/// builds has no bucket, and the wasm entry points have no way to set one, so
527/// rejecting would silently stop those workflows running.
528fn rollout_admits(workflow: &Workflow, message: &Message) -> bool {
529    match (workflow.rollout, message.routing_bucket()) {
530        (Some(r), Some(b)) => r.accepts(b),
531        _ => true,
532    }
533}
534
535/// Whether `workflow` may join a shared-arena run of consecutive fully-sync
536/// workflows.
537///
538/// A looping workflow is excluded even when every task is a sync built-in: its
539/// sweeps run through `execute_inner`, which opens a fresh arena scope per
540/// sweep. Bump arenas never free mid-scope, so sweeping inside one shared
541/// scope would grow memory with the iteration count.
542fn joins_sync_run(workflow: &Workflow) -> bool {
543    workflow.fully_sync && workflow.loop_config.is_none()
544}
545
546/// Resolve one loop slot's pre-split write path, once per looping workflow.
547///
548/// `LogicCompiler` pre-splits every `temp_data.{…}` slot of a `loop` at build
549/// time. A workflow constructed directly rather than through `Engine::builder`
550/// never got that pass, so the parts are computed here instead — once, ahead
551/// of the loop, rather than re-split on every iteration.
552///
553/// An unnamed slot resolves to an empty slice, which `set_nested_value_parts`
554/// treats as a no-op: an unnamed counter still bounds the loop (the audit trail
555/// carries it either way), an unnamed `as` still stops at the array's end, and
556/// an unnamed `scratch` resets nothing.
557fn resolve_parts(name: Option<&str>, precomputed: &Arc<[Arc<str>]>) -> Arc<[Arc<str>]> {
558    match name {
559        Some(name) if precomputed.is_empty() => compute_path_parts("temp_data", name),
560        _ => Arc::clone(precomputed),
561    }
562}
563
564/// Build a fresh `metadata.progress` object value.
565fn new_progress_object(workflow_id: &str, task_id: &str, status: u16) -> OwnedDataValue {
566    OwnedDataValue::Object(vec![
567        (
568            "workflow_id".to_string(),
569            OwnedDataValue::String(workflow_id.to_string()),
570        ),
571        (
572            "task_id".to_string(),
573            OwnedDataValue::String(task_id.to_string()),
574        ),
575        (
576            "status_code".to_string(),
577            OwnedDataValue::from(u64::from(status)),
578        ),
579    ])
580}
581
582/// Overwrite a string slot by reusing its existing buffer where possible.
583///
584/// The ids written per task are drawn from a small, repeating set — in a loop
585/// they are outright constant across every sweep — so the common case is
586/// writing the value that is already there. Comparing first turns that case
587/// into a no-op, and the mismatch case still reuses the allocation.
588fn overwrite_str_in_place(slot: &mut OwnedDataValue, value: &str) {
589    match slot {
590        OwnedDataValue::String(existing) => {
591            if existing != value {
592                existing.clear();
593                existing.push_str(value);
594            }
595        }
596        _ => *slot = OwnedDataValue::String(value.to_string()),
597    }
598}
599
600/// Overwrite the three fields of an existing 3-key `progress` object without
601/// reallocating it. Returns `false` when the object's shape diverges from
602/// `{workflow_id, task_id, status_code}`, in which case the caller replaces
603/// the slot wholesale (partial overwrites here are harmless — the whole slot
604/// gets replaced).
605fn overwrite_progress_in_place(
606    fields: &mut [(String, OwnedDataValue)],
607    workflow_id: &str,
608    task_id: &str,
609    status: u16,
610) -> bool {
611    if fields.len() != 3 {
612        return false;
613    }
614    let mut matched = 0;
615    for (k, v) in fields.iter_mut() {
616        match k.as_str() {
617            "workflow_id" => {
618                overwrite_str_in_place(v, workflow_id);
619                matched += 1;
620            }
621            "task_id" => {
622                overwrite_str_in_place(v, task_id);
623                matched += 1;
624            }
625            "status_code" => {
626                *v = OwnedDataValue::from(u64::from(status));
627                matched += 1;
628            }
629            _ => {}
630        }
631    }
632    matched == 3
633}
634
635/// Write `metadata.progress = {workflow_id, task_id, status_code}` with a
636/// single tree walk. From the second task of a message onward the slot
637/// already holds the expected 3-key object, so the three values are
638/// overwritten in place, reusing the id `String` buffers — no allocation at
639/// all once the shape settles. First write (or any shape divergence)
640/// replaces the slot wholesale; a context whose `metadata` is missing or
641/// non-Object falls back to the generic `set_nested_value` writer, which
642/// creates intermediate containers as needed.
643fn write_progress_metadata(
644    context: &mut OwnedDataValue,
645    workflow_id: &str,
646    task_id: &str,
647    status: u16,
648) {
649    if let OwnedDataValue::Object(top) = context
650        && let Some((_, OwnedDataValue::Object(meta))) =
651            top.iter_mut().find(|(k, _)| k == "metadata")
652    {
653        match meta.iter_mut().find(|(k, _)| k == "progress") {
654            Some((_, slot)) => {
655                if let OwnedDataValue::Object(fields) = slot
656                    && overwrite_progress_in_place(fields, workflow_id, task_id, status)
657                {
658                    return;
659                }
660                *slot = new_progress_object(workflow_id, task_id, status);
661            }
662            None => {
663                meta.push((
664                    "progress".to_string(),
665                    new_progress_object(workflow_id, task_id, status),
666                ));
667            }
668        }
669        return;
670    }
671    set_nested_value(
672        context,
673        "metadata.progress",
674        new_progress_object(workflow_id, task_id, status),
675    );
676}
677
678/// Build one context record for a failed task.
679///
680/// `workflow_id`, `task_id` and `status` come from the executor rather than from
681/// the `ErrorInfo`: `validation` builds its entries with `ErrorInfo::simple_ref`,
682/// which leaves both ids `None`, and `ErrorInfo` carries no status at all.
683///
684/// The error `message` and the operator-only `detail` are deliberately omitted —
685/// this value lands in `Message.context`, which is serialized back to callers.
686fn new_error_record(workflow_id: &str, task_id: &str, code: &str, status: u16) -> OwnedDataValue {
687    OwnedDataValue::Object(vec![
688        ("workflow_id".to_string(), OwnedDataValue::from(workflow_id)),
689        ("task_id".to_string(), OwnedDataValue::from(task_id)),
690        ("code".to_string(), OwnedDataValue::from(code)),
691        (
692            "status".to_string(),
693            OwnedDataValue::from(u64::from(status)),
694        ),
695    ])
696}
697
698/// Take `node` as an `Object`, replacing whatever non-`Object` sat there.
699///
700/// Normalise first, then destructure — the inverse order (match, then assign in
701/// the fallback arm and re-match the same binding) is NLL problem case #3 and
702/// does not compile.
703fn as_object_slot(node: &mut OwnedDataValue) -> &mut Vec<(String, OwnedDataValue)> {
704    if !matches!(node, OwnedDataValue::Object(_)) {
705        *node = OwnedDataValue::Object(Vec::new());
706    }
707    match node {
708        OwnedDataValue::Object(fields) => fields,
709        _ => unreachable!("just normalised to an Object"),
710    }
711}
712
713/// Append one record per entry in `new_errors` to the configured context path,
714/// keeping at most `cfg.limit` of them.
715///
716/// Hand-walks to the slot the way [`write_progress_metadata`] does. The generic
717/// [`set_nested_value`] cannot express an append — it indexes arrays by numeric
718/// segment and `Null`-pads the gap — and silently no-ops when a non-numeric
719/// segment meets an `Array`. A slot holding something other than an `Array` is
720/// replaced wholesale rather than skipped, so the shape a workflow author reads
721/// is predictable even if a `map` task wrote over the path first.
722///
723/// The array is created lazily, only when there is something to push, so a
724/// message whose tasks all succeed keeps the exact wire shape it had before the
725/// option existed — the key is absent, not `[]`.
726fn append_error_records(
727    context: &mut OwnedDataValue,
728    cfg: &ErrorContextConfig,
729    workflow_id: &str,
730    task_id: &str,
731    status: u16,
732    new_errors: &[ErrorInfo],
733) {
734    if new_errors.is_empty() {
735        return;
736    }
737    // Walk to the parent of the final segment, creating containers as needed,
738    // then take the slot itself.
739    let Some((last, parents)) = cfg.path_parts.split_last() else {
740        return;
741    };
742
743    let mut node = context;
744    for part in parents {
745        let key = strip_hash_prefix(part);
746        if !matches!(node, OwnedDataValue::Object(_)) {
747            // A scalar or array on the way down cannot hold a named child. The
748            // host declared the engine owns this path, so resolve the conflict
749            // in favour of the records rather than dropping them — but say so:
750            // whatever was written here is being discarded.
751            warn!(
752                "error context path `{}` runs through a non-object at `{}` — replacing it",
753                cfg.path, key
754            );
755        }
756        let fields = as_object_slot(node);
757        let idx = match fields.iter().position(|(k, _)| k == key) {
758            Some(i) => i,
759            None => {
760                fields.push((key.to_string(), OwnedDataValue::Object(Vec::new())));
761                fields.len() - 1
762            }
763        };
764        node = &mut fields[idx].1;
765    }
766
767    let key = strip_hash_prefix(last);
768    let fields = as_object_slot(node);
769    let idx = match fields.iter().position(|(k, _)| k == key) {
770        Some(i) => i,
771        None => {
772            fields.push((key.to_string(), OwnedDataValue::Array(Vec::new())));
773            fields.len() - 1
774        }
775    };
776    let slot = &mut fields[idx].1;
777    if !matches!(slot, OwnedDataValue::Array(_)) {
778        warn!(
779            "error context path `{}` held a non-array — replacing it",
780            cfg.path
781        );
782        *slot = OwnedDataValue::Array(Vec::new());
783    }
784    let OwnedDataValue::Array(items) = slot else {
785        unreachable!("just ensured an Array");
786    };
787
788    for error in new_errors {
789        items.push(new_error_record(workflow_id, task_id, &error.code, status));
790    }
791    // Keep-newest: a looping workflow with a failing body would otherwise grow
792    // this list once per sweep, and `Message.context` is deep-cloned into every
793    // trace snapshot.
794    if items.len() > cfg.limit {
795        items.drain(..items.len() - cfg.limit);
796    }
797}
798
799/// Handles the execution of workflows and their tasks
800///
801/// The `WorkflowExecutor` is responsible for:
802/// - Evaluating workflow conditions
803/// - Orchestrating task execution within workflows
804/// - Managing workflow-level error handling
805/// - Recording audit trails
806pub struct WorkflowExecutor {
807    /// Task executor for executing individual tasks
808    task_executor: Arc<TaskExecutor>,
809    /// Shared datalogic engine for condition evaluation
810    engine: Arc<Engine>,
811    /// Optional per-task observer. `None` keeps the instrumentation — and its
812    /// clock reads — entirely out of the dispatch path.
813    observer: Option<Arc<dyn ExecutionObserver>>,
814    /// Optional context path where per-task failure codes are mirrored. `None`
815    /// keeps the whole mechanism out of the dispatch path.
816    error_context: Option<Arc<ErrorContextConfig>>,
817}
818
819impl WorkflowExecutor {
820    /// Create a new WorkflowExecutor
821    pub fn new(task_executor: Arc<TaskExecutor>, engine: Arc<Engine>) -> Self {
822        Self {
823            task_executor,
824            engine,
825            observer: None,
826            error_context: None,
827        }
828    }
829
830    /// Attach an observer to an existing executor. Replaces any previous one.
831    pub fn with_observer(mut self, observer: Arc<dyn ExecutionObserver>) -> Self {
832        self.observer = Some(observer);
833        self
834    }
835
836    /// The registered observer, if any.
837    ///
838    /// Used by `Engine::with_new_workflows` to carry the observer across a hot
839    /// reload — without it, metrics would stop silently at the first reload.
840    pub fn observer(&self) -> Option<&Arc<dyn ExecutionObserver>> {
841        self.observer.as_ref()
842    }
843
844    /// Attach an error-context path to an existing executor. Replaces any
845    /// previous one.
846    pub(crate) fn with_error_context(mut self, cfg: Arc<ErrorContextConfig>) -> Self {
847        self.error_context = Some(cfg);
848        self
849    }
850
851    /// The configured error-context path, if any.
852    ///
853    /// Used by `Engine`'s executor rebuilds to carry the setting across a hot
854    /// reload or a `with_observer` call — without it, failure codes would stop
855    /// being recorded silently.
856    pub(crate) fn error_context(&self) -> Option<&Arc<ErrorContextConfig>> {
857        self.error_context.as_ref()
858    }
859
860    /// Emit a task event, deriving the status from the dispatch result.
861    ///
862    /// Called before `handle_task_result`, which takes `result` by value and
863    /// whose `?` propagates on a hard failure — emitting afterwards would
864    /// silently drop exactly the tasks a host most wants timed.
865    #[inline]
866    fn emit_task_event(
867        &self,
868        workflow: &Workflow,
869        task: &Task,
870        result: &Result<(TaskOutcome, Vec<Change>)>,
871        started_at: Option<DateTime<Utc>>,
872    ) {
873        if self.observer.is_some() {
874            let duration = started_at
875                .map(|s| Duration::from_micros(duration_us_between(s, Utc::now())))
876                .unwrap_or_default();
877            self.emit_task_finished(workflow, task, result, duration);
878        }
879    }
880
881    /// Emit a task event with a duration the caller measured. The fan-out
882    /// path uses this directly, since a folded element's call ended before it
883    /// is recorded.
884    fn emit_task_finished(
885        &self,
886        workflow: &Workflow,
887        task: &Task,
888        result: &Result<(TaskOutcome, Vec<Change>)>,
889        duration: Duration,
890    ) {
891        if let Some(observer) = self.observer.as_ref() {
892            let status = match result {
893                Ok((outcome, _)) => outcome.audit_status(),
894                Err(_) => Some(500),
895            };
896            observer.task_finished(&TaskEvent {
897                workflow_id: &workflow.id,
898                task_id: &task.id,
899                function: task.function.function_name(),
900                status,
901                duration,
902            });
903        }
904    }
905
906    /// Emit `workflow_started` the first time a workflow is admitted, and
907    /// remember when — so a looping workflow reports one pair for the whole
908    /// loop rather than one per sweep.
909    fn begin_workflow(&self, span: &mut WorkflowSpan, workflow: &Workflow) {
910        span.sweeps += 1;
911        // Unobserved: no clock read, and `started_at` stays `None` so
912        // `end_workflow` is a no-op too. The crate's documented
913        // one-`Utc::now()`-per-message invariant holds unchanged.
914        let Some(observer) = self.observer.as_ref() else {
915            return;
916        };
917        if span.started_at.is_some() {
918            return;
919        }
920        span.started_at = Some(Utc::now());
921        observer.workflow_started(&WorkflowStarted {
922            workflow_id: &workflow.id,
923        });
924    }
925
926    /// Close a span opened by [`Self::begin_workflow`]. A no-op when the
927    /// workflow was never admitted, so a skipped workflow emits nothing.
928    fn end_workflow(&self, span: &WorkflowSpan, workflow: &Workflow, halted: bool) {
929        let Some(observer) = self.observer.as_ref() else {
930            return;
931        };
932        let Some(started) = span.started_at else {
933            return;
934        };
935        observer.workflow_finished(&WorkflowFinished {
936            workflow_id: &workflow.id,
937            duration: Duration::from_micros(duration_us_between(started, Utc::now())),
938            sweeps: span.sweeps,
939            halted,
940        });
941    }
942
943    /// Clock read for the observer, only when one is attached.
944    ///
945    /// Gated so that `process_message`'s documented "one `Utc::now()` per
946    /// message" holds for every caller that has not opted in.
947    #[inline]
948    fn observer_clock(&self) -> Option<DateTime<Utc>> {
949        self.observer.as_ref().map(|_| Utc::now())
950    }
951
952    /// Sample the per-task clocks and error watermark. See [`TaskClocks`] for
953    /// why the three are taken together, and here rather than in each loop.
954    #[inline]
955    fn open_task_clocks(&self, tracing: bool, message: &Message) -> TaskClocks {
956        // Clock reads only when a trace is live or an observer is attached, so
957        // the plain path keeps its documented one-`Utc::now()`-per-message
958        // invariant.
959        let trace_start = if tracing { Some(Utc::now()) } else { None };
960        TaskClocks {
961            trace_start,
962            obs_start: trace_start.or_else(|| self.observer_clock()),
963            // Sampled before the body runs: `validation` and
964            // `TaskContext::add_error` both push during it, so the tail beyond
965            // this index is exactly what this task contributed.
966            errors_before: message.errors.len(),
967        }
968    }
969
970    /// Get a clone of the task_functions Arc for reuse in new engines
971    pub fn task_functions(&self) -> Arc<HashMap<String, BoxedFunctionHandler>> {
972        self.task_executor.task_functions()
973    }
974
975    /// Borrow the handler registry, for enumeration keyed to this executor's
976    /// lifetime. See `TaskExecutor::registry`.
977    pub fn registry(&self) -> &HashMap<String, BoxedFunctionHandler> {
978        self.task_executor.registry()
979    }
980
981    /// Execute a workflow if its condition is met
982    ///
983    /// This method:
984    /// 1. Evaluates the workflow condition
985    /// 2. Executes tasks sequentially if condition is met
986    /// 3. Handles error recovery based on workflow configuration
987    /// 4. Updates message metadata and audit trail
988    ///
989    /// # Arguments
990    /// * `workflow` - The workflow to execute
991    /// * `message` - The message being processed
992    ///
993    /// # Returns
994    /// * `Result<bool>` - Ok(true) if workflow was executed, Ok(false) if skipped, Err on failure
995    pub async fn execute(
996        &self,
997        workflow: &Workflow,
998        message: &mut Message,
999        now: DateTime<Utc>,
1000    ) -> Result<bool> {
1001        self.execute_inner(workflow, message, None, now).await
1002    }
1003
1004    /// Execute a workflow with step-by-step tracing
1005    ///
1006    /// Similar to `execute` but records execution steps for debugging.
1007    pub async fn execute_with_trace(
1008        &self,
1009        workflow: &Workflow,
1010        message: &mut Message,
1011        trace: &mut ExecutionTrace,
1012        now: DateTime<Utc>,
1013    ) -> Result<bool> {
1014        self.execute_inner(workflow, message, Some(trace), now)
1015            .await
1016    }
1017
1018    /// Run `workflow` against `message`: the rollout gate, then either a single
1019    /// pass over the task list or — for a workflow carrying a `loop` — a
1020    /// bounded sweep loop.
1021    ///
1022    /// `trace` is `None` for the production path and `Some(&mut trace)` for the
1023    /// debug path; stepping is the only behavioural difference between them.
1024    async fn execute_inner(
1025        &self,
1026        workflow: &Workflow,
1027        message: &mut Message,
1028        mut trace: Option<&mut ExecutionTrace>,
1029        now: DateTime<Utc>,
1030    ) -> Result<bool> {
1031        // Traffic-split gate, ahead of any arena work so an excluded workflow
1032        // costs no `ArenaContext::from_owned` walk. Reuses the existing skipped
1033        // path verbatim, so an excluded workflow is indistinguishable from a
1034        // false condition.
1035        if !rollout_admits(workflow, message) {
1036            note_workflow_skip(trace.as_deref_mut(), &workflow.id, "outside rollout bucket");
1037            return Ok(false);
1038        }
1039
1040        if let Some(loop_config) = workflow.loop_config.as_ref() {
1041            return self
1042                .execute_loop(workflow, loop_config, message, trace, now)
1043                .await;
1044        }
1045
1046        // Opened inside `execute_pass` the moment the condition admits, so a
1047        // skipped workflow leaves it closed and emits nothing.
1048        let mut span = WorkflowSpan::default();
1049        let outcome = self
1050            .execute_pass(
1051                PassList::body(workflow),
1052                message,
1053                trace.as_deref_mut(),
1054                PassCtx::once(now),
1055                &mut span,
1056            )
1057            .await;
1058        self.end_workflow(&span, workflow, matches!(outcome, Ok(PassOutcome::Halted)));
1059
1060        match outcome {
1061            Ok(PassOutcome::ConditionFalse) => {
1062                // Last use of `trace` on this path — no reborrow needed.
1063                note_workflow_skip(trace, &workflow.id, "condition not met");
1064                Ok(false)
1065            }
1066            Ok(_) => {
1067                info!("Successfully completed workflow: {}", workflow.id);
1068                Ok(true)
1069            }
1070            Err(e) => {
1071                // Single-channel contract: every error appears in
1072                // `message.errors`. The `Result::Err` return only signals to
1073                // the caller that we stopped before processing further
1074                // workflows. The workflow-level wrapper records workflow
1075                // context that the underlying task error doesn't carry.
1076                if self.record_workflow_error(workflow, message, &e) {
1077                    Err(e)
1078                } else {
1079                    Ok(true)
1080                }
1081            }
1082        }
1083    }
1084
1085    /// Drive a looping workflow.
1086    ///
1087    /// A loop with `setup` or `over` first runs the setup list once (the
1088    /// workflow condition gates it, folded in by `execute_pass`) and then
1089    /// evaluates `over`. A counter-only loop skips that entirely and enters its
1090    /// first sweep exactly as it always has — no extra condition evaluation.
1091    ///
1092    /// Per iteration — write counter, check `max`, check the element list,
1093    /// write the element, reset scratch, check the condition, run the body,
1094    /// advance — is the documented contract. The counter is in `temp_data`
1095    /// before the condition is evaluated, so a condition that indexes by it
1096    /// works on the first sweep. None of the engine-owned writes needs an arena
1097    /// refresh: `execute_pass` builds its `ArenaContext` from `message.context`
1098    /// after them.
1099    ///
1100    /// Returns `Ok(false)` only when the workflow was never admitted.
1101    async fn execute_loop(
1102        &self,
1103        workflow: &Workflow,
1104        config: &LoopConfig,
1105        message: &mut Message,
1106        mut trace: Option<&mut ExecutionTrace>,
1107        now: DateTime<Utc>,
1108    ) -> Result<bool> {
1109        let counter_parts = resolve_parts(config.counter.as_deref(), &config.counter_parts);
1110        let item_parts = resolve_parts(config.item.as_deref(), &config.item_parts);
1111        let scratch_parts = resolve_parts(config.scratch.as_deref(), &config.scratch_parts);
1112        let body = PassList::body(workflow);
1113        // One span for the whole loop: per-sweep events would explode
1114        // cardinality, so the sweep count goes on the single finished event.
1115        let mut span = WorkflowSpan::default();
1116        let mut halted = false;
1117
1118        // ---- Setup, then `over` ----
1119        // `admitted` records that the condition already admitted this workflow
1120        // before setup, so a later false condition is a loop exit rather than
1121        // a whole-workflow skip.
1122        let mut admitted = false;
1123        let mut items: Option<Vec<OwnedDataValue>> = None;
1124        if !config.setup.is_empty() || config.over.is_some() {
1125            let setup = PassList {
1126                workflow,
1127                tasks: &config.setup,
1128            };
1129            match self
1130                .execute_pass(
1131                    setup,
1132                    message,
1133                    trace.as_deref_mut(),
1134                    PassCtx::once(now),
1135                    &mut span,
1136                )
1137                .await
1138            {
1139                Ok(PassOutcome::ConditionFalse) => {
1140                    note_workflow_skip(trace, &workflow.id, "condition not met");
1141                    return Ok(false);
1142                }
1143                Ok(PassOutcome::Halted) => {
1144                    // `terminal`, `halt_on` or `TaskOutcome::Halt` in setup
1145                    // ends the workflow before its first iteration.
1146                    span.sweeps = 0;
1147                    self.end_workflow(&span, workflow, true);
1148                    info!(
1149                        "Successfully completed workflow: {} (halted in loop setup)",
1150                        workflow.id
1151                    );
1152                    return Ok(true);
1153                }
1154                Ok(PassOutcome::Completed) => {}
1155                Err(e) => {
1156                    // No first iteration without a completed setup. The error
1157                    // is recorded either way; `continue_on_error` decides only
1158                    // whether the *message* goes on to the next workflow.
1159                    span.sweeps = 0;
1160                    let stop = self.record_workflow_error(workflow, message, &e);
1161                    self.end_workflow(&span, workflow, false);
1162                    return if stop { Err(e) } else { Ok(true) };
1163                }
1164            }
1165            admitted = true;
1166            // Setup is not a sweep: `WorkflowFinished::sweeps` counts
1167            // iterations, and `begin_workflow` counted the setup pass.
1168            span.sweeps = 0;
1169
1170            if config.over.is_some() {
1171                match self.evaluate_over(workflow, config, message) {
1172                    Ok(list) => items = Some(list),
1173                    Err(e) => {
1174                        let stop = self.record_workflow_error(workflow, message, &e);
1175                        self.end_workflow(&span, workflow, false);
1176                        return if stop { Err(e) } else { Ok(true) };
1177                    }
1178                }
1179            }
1180        }
1181
1182        let mut counter = config.init;
1183        let mut sweeps_run: u32 = 0;
1184
1185        loop {
1186            // Written before the bound and condition checks so a condition
1187            // indexing by the counter — the per-item pattern — resolves on the
1188            // very first sweep.
1189            set_nested_value_parts(
1190                &mut message.context,
1191                &counter_parts,
1192                OwnedDataValue::from_i64(counter),
1193            );
1194
1195            // `>=`, not `>`, and that is load-bearing for termination rather
1196            // than a style choice. `increment >= 1` is validated at build time
1197            // and the advance below saturates, so the counter strictly
1198            // increases until it pins at `i64::MAX` — which satisfies
1199            // `>= config.max` for every representable `max`. With `>` a loop
1200            // whose counter saturates would spin forever.
1201            if counter >= config.max {
1202                // Normal completion: `max` is always author-supplied, so
1203                // reaching it is the stated bound rather than a runaway. A
1204                // condition that was still true wanted to keep going, which is
1205                // worth a log line but not an error.
1206                if workflow.compiled_condition.is_some() {
1207                    warn!(
1208                        "Workflow {} stopped at its loop bound (max {}) with the condition \
1209                         still true after {} sweep(s)",
1210                        workflow.id, config.max, sweeps_run
1211                    );
1212                }
1213                if let Some(items) = &items
1214                    && let Ok(next) = usize::try_from(counter)
1215                    && next < items.len()
1216                {
1217                    warn!(
1218                        "Workflow {} stopped at its loop bound (max {}) with {} of {} \
1219                         element(s) of loop.over unvisited",
1220                        workflow.id,
1221                        config.max,
1222                        items.len() - next,
1223                        items.len()
1224                    );
1225                }
1226                break;
1227            }
1228
1229            // The second bound of an `over` loop: the element list. Running
1230            // out of elements is normal completion, like reaching `max`.
1231            if let Some(items) = items.as_mut() {
1232                let Some(slot) = usize::try_from(counter).ok().and_then(|i| items.get_mut(i))
1233                else {
1234                    break;
1235                };
1236                // Moved, not cloned: the list is this loop's own evaluation
1237                // result, and `increment >= 1` means no index is visited twice.
1238                let element = std::mem::take(slot);
1239                set_nested_value_parts(&mut message.context, &item_parts, element);
1240            }
1241
1242            // A fresh scratch object for this iteration, so nothing written for
1243            // the previous element survives into this one. Before the
1244            // condition, so the whole iteration — condition included — sees the
1245            // reset. An unnamed scratch resolves to empty parts: a no-op.
1246            set_nested_value_parts(
1247                &mut message.context,
1248                &scratch_parts,
1249                OwnedDataValue::Object(Vec::new()),
1250            );
1251
1252            let pass = PassCtx {
1253                now,
1254                loop_counter: Some(counter),
1255                element_index: None,
1256            };
1257
1258            match self
1259                .execute_pass(body, message, trace.as_deref_mut(), pass, &mut span)
1260                .await
1261            {
1262                Ok(PassOutcome::ConditionFalse) => {
1263                    if sweeps_run == 0 && !admitted {
1264                        // Never entered: indistinguishable from a plain
1265                        // condition-skipped workflow, and reported as one.
1266                        note_workflow_skip(trace.as_deref_mut(), &workflow.id, "condition not met");
1267                    } else {
1268                        debug!(
1269                            "Workflow {} loop exited at counter {} - condition no longer met",
1270                            workflow.id, counter
1271                        );
1272                    }
1273                    break;
1274                }
1275                Ok(PassOutcome::Halted) => {
1276                    sweeps_run += 1;
1277                    debug!(
1278                        "Workflow {} loop halted at counter {}",
1279                        workflow.id, counter
1280                    );
1281                    halted = true;
1282                    break;
1283                }
1284                Ok(PassOutcome::Completed) => {
1285                    sweeps_run += 1;
1286                }
1287                Err(e) => {
1288                    sweeps_run += 1;
1289                    // Same single-channel contract as the non-looping path. On
1290                    // `continue_on_error` the loop advances past the failing
1291                    // sweep rather than abandoning the rest — the per-item case
1292                    // wants item 8 processed after item 7 failed.
1293                    if self.record_workflow_error(workflow, message, &e) {
1294                        // Closed on the error path too: an observer measuring
1295                        // engine overhead must not lose the span of the
1296                        // workflow that actually failed.
1297                        self.end_workflow(&span, workflow, halted);
1298                        return Err(e);
1299                    }
1300                }
1301            }
1302
1303            counter = counter.saturating_add(config.increment);
1304        }
1305
1306        self.end_workflow(&span, workflow, halted);
1307
1308        if sweeps_run > 0 {
1309            info!(
1310                "Successfully completed workflow: {} ({} loop sweep(s))",
1311                workflow.id, sweeps_run
1312            );
1313        }
1314        Ok(sweeps_run > 0 || admitted)
1315    }
1316
1317    /// Run one task's fan-out: evaluate `over`, run a call per element, then
1318    /// fold the calls back into `message` in element order.
1319    ///
1320    /// See [`ForEach`] for the contract. The shape here is what makes it hold:
1321    /// every call clones the message from the same untouched state (the calls
1322    /// phase only ever holds `&Message`), and the fold — the only mutation —
1323    /// happens after every call has finished, in element order. So the result
1324    /// cannot depend on `max_concurrency` or on the order calls complete in.
1325    async fn run_for_each(
1326        &self,
1327        workflow: &Workflow,
1328        task: &Task,
1329        for_each: &ForEach,
1330        message: &mut Message,
1331        mut trace: Option<&mut ExecutionTrace>,
1332        pass: PassCtx,
1333    ) -> Result<TaskControlFlow> {
1334        use futures_util::stream::{FuturesUnordered, StreamExt};
1335
1336        let tracing = trace.is_some();
1337
1338        // ---- `over`, once ----
1339        // An `over` that is not an array, and an empty one, each record one
1340        // entry with no element index — so `metadata.progress` is written
1341        // exactly as it is for any task, and `continue_on_error` applies.
1342        let items = match self.evaluate_for_each_over(task, for_each, message) {
1343            Ok(items) if !items.is_empty() => items,
1344            other => {
1345                let failed = other.is_err();
1346                if other.is_ok() && !for_each.into_parts.is_empty() {
1347                    self.write_empty_results(for_each, message);
1348                }
1349                let clocks = self.open_task_clocks(tracing, message);
1350                let result = other.map(|_| {
1351                    let changes = self.empty_results_change(for_each, message);
1352                    (TaskOutcome::Success, changes)
1353                });
1354                self.emit_task_event(workflow, task, &result, clocks.obs_start);
1355                let flow = self.handle_task_result(
1356                    result,
1357                    &workflow.id_arc,
1358                    &task.id_arc,
1359                    fan_out_pass(task, clocks.errors_before),
1360                    message,
1361                    pass,
1362                )?;
1363                pass.note_executed(trace, &workflow.id, &task.id, message, clocks, None);
1364                let halt = matches!(flow, TaskControlFlow::HaltWorkflow)
1365                    || (failed && task.halt_on == HaltOn::Failure);
1366                return Ok(fan_out_flow(task, halt));
1367            }
1368        };
1369        let n = items.len();
1370
1371        // ---- the calls ----
1372        let call = ElementCall {
1373            workflow,
1374            task,
1375            for_each,
1376            loop_counter: pass.loop_counter,
1377            timed: tracing || self.observer.is_some(),
1378        };
1379        let mut outcomes: Vec<Option<ElementOutcome>> = (0..n).map(|_| None).collect();
1380        {
1381            // Shared, on purpose: every call clones from this same state, and
1382            // nothing may change it until all of them are done.
1383            let base: &Message = message;
1384            let mut pending = items.into_iter().enumerate();
1385            let mut in_flight = FuturesUnordered::new();
1386            let mut stop = false;
1387            loop {
1388                while !stop && in_flight.len() < for_each.max_concurrency {
1389                    let Some((index, item)) = pending.next() else {
1390                        break;
1391                    };
1392                    in_flight.push(self.run_element(call, base, index, item));
1393                }
1394                let Some(outcome) = in_flight.next().await else {
1395                    break;
1396                };
1397                stop |= outcome.stops_fan_out(task.continue_on_error);
1398                let index = outcome.index;
1399                outcomes[index] = Some(outcome);
1400            }
1401        }
1402
1403        // ---- fold back, in element order ----
1404        // Stops at the first element that fails the task (the `?` below) or
1405        // halts (the `break`); the outcomes after it are dropped unfolded.
1406        if !for_each.into_parts.is_empty() {
1407            // One slot per element, so an element that never ran — or failed —
1408            // is a `null` at its own index rather than a gap.
1409            set_nested_value_parts(
1410                &mut message.context,
1411                &for_each.into_parts,
1412                OwnedDataValue::Array(vec![OwnedDataValue::Null; n]),
1413            );
1414        }
1415        let mut halt = false;
1416        let mut any_failed = false;
1417        for outcome in outcomes.into_iter().flatten() {
1418            let failed = outcome.failed();
1419            let ElementOutcome {
1420                index,
1421                result,
1422                changes,
1423                new_errors,
1424                collected,
1425                timing,
1426            } = outcome;
1427
1428            // Sampled before this element's own errors land, so the
1429            // error-context mirror in `handle_task_result` sees them as this
1430            // element's.
1431            let errors_before = message.errors.len();
1432            message
1433                .errors
1434                .extend(new_errors.into_iter().map(|mut error| {
1435                    error.element_index.get_or_insert(index);
1436                    error
1437                }));
1438
1439            for change in &changes {
1440                if change.removed {
1441                    remove_nested_value(&mut message.context, &change.path);
1442                } else {
1443                    set_nested_value(&mut message.context, &change.path, change.new_value.clone());
1444                }
1445            }
1446
1447            let mut changes = if message.capture_changes {
1448                changes
1449            } else {
1450                Vec::new()
1451            };
1452            if let Some(into) = for_each.into.as_deref() {
1453                let value = if failed {
1454                    OwnedDataValue::Null
1455                } else {
1456                    collected
1457                };
1458                if message.capture_changes {
1459                    changes.push(Change {
1460                        path: Arc::from(format!("{into}.{index}")),
1461                        old_value: OwnedDataValue::Null,
1462                        new_value: value.clone(),
1463                        removed: false,
1464                    });
1465                }
1466                let mut parts = for_each.into_parts.to_vec();
1467                parts.push(Arc::from(index.to_string()));
1468                set_nested_value_parts(&mut message.context, &parts, value);
1469            }
1470
1471            any_failed |= failed;
1472            let result = result.map(|outcome| (outcome, changes));
1473            let element = PassCtx {
1474                element_index: Some(index),
1475                ..pass
1476            };
1477            let duration = timing
1478                .map(|t| Duration::from_micros(t.duration_us))
1479                .unwrap_or_default();
1480            self.emit_task_finished(workflow, task, &result, duration);
1481            let flow = self.handle_task_result(
1482                result,
1483                &workflow.id_arc,
1484                &task.id_arc,
1485                fan_out_pass(task, errors_before),
1486                message,
1487                element,
1488            )?;
1489            if let Some(timing) = timing {
1490                element.note_executed_timed(
1491                    trace.as_deref_mut(),
1492                    &workflow.id,
1493                    &task.id,
1494                    message,
1495                    timing,
1496                );
1497            }
1498            // A halting element ends the fold here, after its own record has
1499            // landed — later elements contribute nothing, finished or not,
1500            // exactly as they never started at `max_concurrency: 1`. Under
1501            // `fan_out_pass` only `TaskOutcome::Halt` reaches this branch.
1502            if matches!(flow, TaskControlFlow::HaltWorkflow) {
1503                halt = true;
1504                break;
1505            }
1506        }
1507
1508        if halt {
1509            info!(
1510                "Task {} halted workflow {} during its fan-out",
1511                task.id, workflow.id
1512            );
1513        }
1514        Ok(fan_out_flow(
1515            task,
1516            halt || (task.halt_on == HaltOn::Failure && any_failed),
1517        ))
1518    }
1519
1520    /// One fan-out call, against its own copy of `base`.
1521    ///
1522    /// Takes `&Message`, not `&mut`: that is the isolation guarantee. The copy
1523    /// ([`Message::fork_for_element`]) carries the context but not the parent's
1524    /// audit trail or errors, so everything in its `errors` is the call's own.
1525    /// It holds the element at `temp_data.<as>` and its index at
1526    /// `temp_data.<as>_index`, records every write regardless of the real
1527    /// message's `capture_changes` (the fold replays them), and is reduced to
1528    /// an [`ElementOutcome`] as soon as the call ends.
1529    async fn run_element(
1530        &self,
1531        call: ElementCall<'_>,
1532        base: &Message,
1533        index: usize,
1534        item: OwnedDataValue,
1535    ) -> ElementOutcome {
1536        let fe = call.for_each;
1537        let mut copy = base.fork_for_element();
1538        set_nested_value_parts(&mut copy.context, &fe.item_parts, item);
1539        set_nested_value_parts(
1540            &mut copy.context,
1541            &fe.index_parts,
1542            OwnedDataValue::from(index as u64),
1543        );
1544
1545        let started_at = call.timed.then(Utc::now);
1546        let result = self
1547            .task_executor
1548            .execute_in_workflow(
1549                call.task,
1550                &mut copy,
1551                Some(TaskIdentity {
1552                    workflow_id: &call.workflow.id_arc,
1553                    task_id: &call.task.id_arc,
1554                }),
1555                CallStamp {
1556                    loop_counter: call.loop_counter,
1557                    element_index: Some(index),
1558                },
1559            )
1560            .await;
1561        let timing = started_at.map(|started_at| StepTiming {
1562            started_at,
1563            duration_us: duration_us_between(started_at, Utc::now()),
1564        });
1565
1566        let (result, changes) = match result {
1567            Ok((outcome, changes)) => (Ok(outcome), changes),
1568            Err(e) => (Err(e), Vec::new()),
1569        };
1570        let collected = if fe.collect_parts.is_empty() {
1571            OwnedDataValue::Null
1572        } else {
1573            get_nested_value_parts(&copy.context, &fe.collect_parts)
1574                .cloned()
1575                .unwrap_or(OwnedDataValue::Null)
1576        };
1577        ElementOutcome {
1578            index,
1579            result,
1580            changes,
1581            new_errors: copy.errors,
1582            collected,
1583            timing,
1584        }
1585    }
1586
1587    /// Evaluate `for_each.over` once, to the element list.
1588    ///
1589    /// Anything but an array — `null` included — fails the task. The error is
1590    /// task-classified so the task's `continue_on_error` applies to it; a
1591    /// budget ceiling keeps its own `BUDGET_EXCEEDED` code.
1592    fn evaluate_for_each_over(
1593        &self,
1594        task: &Task,
1595        for_each: &ForEach,
1596        message: &Message,
1597    ) -> Result<Vec<OwnedDataValue>> {
1598        let Some(compiled) = for_each.compiled_over.as_ref() else {
1599            return Err(DataflowError::Task(format!(
1600                "Task {}: for_each.over is not compiled — build the engine through \
1601                 Engine::builder or Engine::new rather than running the workflow directly",
1602                task.id
1603            )));
1604        };
1605        match eval_to_owned(&self.engine, compiled, &message.context) {
1606            Ok(OwnedDataValue::Array(items)) => Ok(items),
1607            Ok(other) => Err(DataflowError::Task(format!(
1608                "Task {}: for_each.over must evaluate to an array, got {}",
1609                task.id,
1610                describe_kind(&other)
1611            ))),
1612            Err(e) => match from_datalogic_eval(&e) {
1613                budget @ DataflowError::BudgetExceeded(_) => Err(budget),
1614                other => Err(DataflowError::Task(format!(
1615                    "Task {}: for_each.over failed to evaluate: {other}",
1616                    task.id
1617                ))),
1618            },
1619        }
1620    }
1621
1622    /// `into = []`, for a fan-out over an empty array.
1623    fn write_empty_results(&self, for_each: &ForEach, message: &mut Message) {
1624        set_nested_value_parts(
1625            &mut message.context,
1626            &for_each.into_parts,
1627            OwnedDataValue::Array(Vec::new()),
1628        );
1629    }
1630
1631    /// The audit `Change` for [`Self::write_empty_results`], when changes are
1632    /// captured and `into` is set. Called after the write, so `new_value` is
1633    /// what landed.
1634    fn empty_results_change(&self, for_each: &ForEach, message: &Message) -> Vec<Change> {
1635        match for_each.into.as_deref() {
1636            Some(into) if message.capture_changes => vec![Change {
1637                path: Arc::from(into),
1638                old_value: OwnedDataValue::Null,
1639                new_value: OwnedDataValue::Array(Vec::new()),
1640                removed: false,
1641            }],
1642            _ => Vec::new(),
1643        }
1644    }
1645
1646    /// Evaluate `loop.over` once, after setup, to the element list.
1647    ///
1648    /// Anything but an array is a workflow error naming the loop — `null`
1649    /// included. A missing batch is a bug the author wants to see, not a silent
1650    /// zero-iteration run; zero iterations is spelled `[]`.
1651    fn evaluate_over(
1652        &self,
1653        workflow: &Workflow,
1654        config: &LoopConfig,
1655        message: &Message,
1656    ) -> Result<Vec<OwnedDataValue>> {
1657        let Some(compiled) = config.compiled_over.as_ref() else {
1658            return Err(DataflowError::Workflow(format!(
1659                "Workflow {}: loop.over is not compiled — build the engine through \
1660                 Engine::builder or Engine::new rather than running the workflow directly",
1661                workflow.id
1662            )));
1663        };
1664        match eval_to_owned(&self.engine, compiled, &message.context) {
1665            Ok(OwnedDataValue::Array(items)) => Ok(items),
1666            Ok(other) => Err(DataflowError::Workflow(format!(
1667                "Workflow {}: loop.over must evaluate to an array, got {}",
1668                workflow.id,
1669                describe_kind(&other)
1670            ))),
1671            Err(e) => Err(DataflowError::Workflow(format!(
1672                "Workflow {}: loop.over failed to evaluate: {}",
1673                workflow.id,
1674                from_datalogic_eval(&e)
1675            ))),
1676        }
1677    }
1678
1679    /// One pass over a task list: evaluate the workflow condition, then run
1680    /// the list once. Over the body this is the whole of a non-looping
1681    /// workflow, and one sweep of a looping one; over a loop's `setup` it is
1682    /// the once-only phase before the first sweep.
1683    ///
1684    /// The workflow condition is folded into the *first* sync stretch's arena
1685    /// scope: one `ArenaContext::from_owned` walk serves both the condition
1686    /// eval and the leading run of sync built-in tasks. The owned path
1687    /// (`eval_to_owned`) walks the entire context's spine — every array and
1688    /// object of the `data.input` payload included — for the condition, and
1689    /// `execute_tasks` then walked the same context again to build the first
1690    /// stretch's arena form. Mixed sync+async workflows now pay one walk
1691    /// where they paid two.
1692    /// No `.await` occurs inside the scope, preserving the `!Send` arena
1693    /// invariant.
1694    async fn execute_pass(
1695        &self,
1696        list: PassList<'_>,
1697        message: &mut Message,
1698        mut trace: Option<&mut ExecutionTrace>,
1699        pass: PassCtx,
1700        span: &mut WorkflowSpan,
1701    ) -> Result<PassOutcome> {
1702        /// Outcome of the folded condition-plus-first-stretch arena scope.
1703        enum FirstStretch {
1704            /// Workflow condition evaluated false — skip the workflow.
1705            Skipped,
1706            /// A filter task halted the workflow inside the first stretch.
1707            Halted,
1708            /// Continue with the remaining tasks, resuming at this index —
1709            /// the first async boundary, or further on when a skipped group's
1710            /// span reached past it.
1711            Continue(usize),
1712        }
1713
1714        let PassList { workflow, tasks } = list;
1715        let first_boundary = next_async_boundary(tasks, 0);
1716        // One gate for the whole pass: a group can open in the folded first
1717        // stretch and close somewhere in the async tail.
1718        let mut gate = GroupGate::default();
1719
1720        let first: Result<FirstStretch> =
1721            if workflow.compiled_condition.is_none() && first_boundary == 0 {
1722                // No condition and the workflow leads with an async task —
1723                // nothing to fold; don't build an arena context for nothing.
1724                // Unconditional, so the workflow runs and the span opens here.
1725                self.begin_workflow(span, workflow);
1726                Ok(FirstStretch::Continue(0))
1727            } else {
1728                with_arena(|arena| -> Result<FirstStretch> {
1729                    let mut arena_ctx = ArenaContext::from_owned(&message.context, arena);
1730
1731                    let should_execute = match workflow.compiled_condition.as_ref() {
1732                        None => true,
1733                        Some(compiled) => evaluate_condition_in_arena(
1734                            &self.engine,
1735                            Some(compiled),
1736                            arena_ctx.as_data_value(),
1737                            arena,
1738                        )?,
1739                    };
1740                    if !should_execute {
1741                        return Ok(FirstStretch::Skipped);
1742                    }
1743                    // Admitted: this is the earliest point the workflow is
1744                    // known to run, so it is where the span opens. Emitting any
1745                    // earlier would report a workflow its condition rejected.
1746                    self.begin_workflow(span, workflow);
1747                    if first_boundary == 0 {
1748                        return Ok(FirstStretch::Continue(0));
1749                    }
1750                    let outcome = self.run_tasks_slice_in_arena(
1751                        TaskSlice {
1752                            tasks: &tasks[..first_boundary],
1753                            offset: 0,
1754                            gate: &mut gate,
1755                            list,
1756                        },
1757                        message,
1758                        &mut arena_ctx,
1759                        trace.as_deref_mut(),
1760                        pass,
1761                    )?;
1762                    Ok(match outcome {
1763                        SliceOutcome::Halted => FirstStretch::Halted,
1764                        SliceOutcome::JumpTo(target) => FirstStretch::Continue(target),
1765                        SliceOutcome::Completed => FirstStretch::Continue(first_boundary),
1766                    })
1767                })
1768            };
1769
1770        // Drive the remaining (async-containing) tail. The workflow-level error
1771        // contract lives in the caller, which is the one place that knows
1772        // whether this pass was a whole workflow or one sweep of a loop.
1773        match first? {
1774            FirstStretch::Skipped => Ok(PassOutcome::ConditionFalse),
1775            FirstStretch::Halted => Ok(PassOutcome::Halted),
1776            FirstStretch::Continue(resume_at) => {
1777                let halted = self
1778                    .execute_tasks(list, message, trace, pass, resume_at, &mut gate)
1779                    .await?;
1780                Ok(if halted {
1781                    PassOutcome::Halted
1782                } else {
1783                    PassOutcome::Completed
1784                })
1785            }
1786        }
1787    }
1788
1789    /// Record a `WORKFLOW_ERROR` to `message.errors` and log at the level
1790    /// `continue_on_error` implies. Returns `true` when the caller should stop
1791    /// processing further workflows (i.e. `continue_on_error` is `false`).
1792    ///
1793    /// Shared by `execute_inner` (returns from its own `Result<bool>`) and
1794    /// `execute_sync_workflow_run` (returns from its `with_arena` closure or
1795    /// continues the loop) — the recording and log-level decision are
1796    /// identical; only what happens next differs by call site.
1797    fn record_workflow_error(
1798        &self,
1799        workflow: &Workflow,
1800        message: &mut Message,
1801        e: &DataflowError,
1802    ) -> bool {
1803        message.errors.push(
1804            ErrorInfo::builder(
1805                "WORKFLOW_ERROR",
1806                format!("Workflow {} error: {}", workflow.id, e),
1807            )
1808            .workflow_id(&workflow.id)
1809            .build(),
1810        );
1811
1812        if workflow.continue_on_error {
1813            warn!(
1814                "Workflow {} encountered error but continuing: {:?}",
1815                workflow.id, e
1816            );
1817            false
1818        } else {
1819            error!("Workflow {} failed: {:?}", workflow.id, e);
1820            true
1821        }
1822    }
1823
1824    /// Execute the tasks of a workflow from index `start` onward.
1825    ///
1826    /// Groups consecutive synchronous built-in tasks into a single
1827    /// `with_arena` scope so the arena form of `message.context` is built
1828    /// once at the start of the stretch and reused across `parse_json`,
1829    /// `map`, `validation`, `log`, and `filter`. Async tasks (HTTP, Kafka,
1830    /// custom handlers) break the stretch — the arena flushes any pending
1831    /// state back to `OwnedDataValue` automatically (since each sync task
1832    /// already mutates `message.context` in place) and the next stretch
1833    /// rebuilds the arena form.
1834    ///
1835    /// `start` is non-zero when `execute_inner` already ran the leading sync
1836    /// stretch inside the folded condition scope.
1837    ///
1838    /// When `trace` is `Some`, the loop also records `ExecutionStep` entries
1839    /// after each task (skipped/executed) including per-mapping snapshots
1840    /// for `Map` tasks.
1841    ///
1842    /// Returns `Ok(true)` when a task halted the workflow.
1843    async fn execute_tasks(
1844        &self,
1845        list: PassList<'_>,
1846        message: &mut Message,
1847        mut trace: Option<&mut ExecutionTrace>,
1848        pass: PassCtx,
1849        start: usize,
1850        gate: &mut GroupGate,
1851    ) -> Result<bool> {
1852        let PassList { workflow, tasks } = list;
1853        let mut idx = start;
1854        while idx < tasks.len() {
1855            let stretch_end = next_async_boundary(tasks, idx);
1856
1857            if stretch_end > idx {
1858                // Run [idx, stretch_end) as a sync stretch inside one arena.
1859                match self.run_sync_stretch(
1860                    TaskSlice {
1861                        tasks: &tasks[idx..stretch_end],
1862                        offset: idx,
1863                        gate,
1864                        list,
1865                    },
1866                    message,
1867                    trace.as_deref_mut(),
1868                    pass,
1869                )? {
1870                    SliceOutcome::Halted => return Ok(true),
1871                    // A group opening inside the stretch was skipped and its
1872                    // span reaches past the stretch — resume where it ends.
1873                    SliceOutcome::JumpTo(target) => {
1874                        idx = target;
1875                        continue;
1876                    }
1877                    SliceOutcome::Completed => idx = stretch_end,
1878                }
1879            }
1880
1881            if idx < tasks.len() {
1882                // Single async task (or non-sync-builtin) at `idx`.
1883                let task = &tasks[idx];
1884
1885                match admit_task(
1886                    list,
1887                    task,
1888                    idx,
1889                    gate,
1890                    trace.as_deref_mut(),
1891                    pass,
1892                    |compiled| evaluate_condition(&self.engine, compiled, &message.context),
1893                )? {
1894                    Admission::Halt => return Ok(true),
1895                    Admission::Jump(target) => {
1896                        idx = target;
1897                        continue;
1898                    }
1899                    Admission::Skip => {
1900                        idx += 1;
1901                        continue;
1902                    }
1903                    Admission::Run => {}
1904                }
1905
1906                // A fan-out runs its own calls and records one entry per
1907                // element. `for_each` is only ever on a handler-backed
1908                // function, so it is always reached here, never in a sync
1909                // stretch.
1910                if let Some(for_each) = task.for_each.as_ref() {
1911                    let flow = self
1912                        .run_for_each(
1913                            workflow,
1914                            task,
1915                            for_each,
1916                            message,
1917                            trace.as_deref_mut(),
1918                            pass,
1919                        )
1920                        .await?;
1921                    if matches!(flow, TaskControlFlow::HaltWorkflow) {
1922                        return Ok(true);
1923                    }
1924                    idx += 1;
1925                    continue;
1926                }
1927
1928                let clocks = self.open_task_clocks(trace.is_some(), message);
1929
1930                let result = self
1931                    .task_executor
1932                    .execute_in_workflow(
1933                        task,
1934                        message,
1935                        Some(TaskIdentity {
1936                            workflow_id: &workflow.id_arc,
1937                            task_id: &task.id_arc,
1938                        }),
1939                        CallStamp {
1940                            loop_counter: pass.loop_counter,
1941                            element_index: None,
1942                        },
1943                    )
1944                    .await;
1945
1946                // Before `handle_task_result`, whose `?` would drop failed tasks.
1947                self.emit_task_event(workflow, task, &result, clocks.obs_start);
1948
1949                // No arena refresh here: no `ArenaContext` is live on this path,
1950                // and `run_sync_stretch` rebuilds one from `message.context` at
1951                // the start of the next stretch.
1952                let control_flow = self.handle_task_result(
1953                    result,
1954                    &workflow.id_arc,
1955                    &task.id_arc,
1956                    TaskPass {
1957                        continue_on_error: task.continue_on_error,
1958                        terminal: task.terminal,
1959                        halt_on: task.halt_on,
1960                        errors_before: clocks.errors_before,
1961                    },
1962                    message,
1963                    pass,
1964                )?;
1965
1966                // Async tasks at the boundary have no per-mapping snapshots —
1967                // they're either HTTP/Kafka/Enrich or a custom handler.
1968                pass.note_executed(
1969                    trace.as_deref_mut(),
1970                    &workflow.id,
1971                    &task.id,
1972                    message,
1973                    clocks,
1974                    None,
1975                );
1976
1977                if matches!(control_flow, TaskControlFlow::HaltWorkflow) {
1978                    return Ok(true);
1979                }
1980                idx += 1;
1981            }
1982        }
1983
1984        // A terminal group closing on the last task still has to halt: for a
1985        // workflow carrying a `loop`, halting breaks the loop where completing
1986        // would start another sweep.
1987        Ok(gate.close_through(tasks.len()))
1988    }
1989
1990    /// Execute a contiguous run of sync-builtin tasks inside one
1991    /// `with_arena` scope. The arena context is built once at the start and
1992    /// refreshed in place after each mutating task. Returns `Ok(true)` if a
1993    /// filter task halted the workflow.
1994    ///
1995    /// This is the single-workflow entry; the cross-workflow path
1996    /// (`execute_sync_workflow_run`) shares the same task loop via
1997    /// `run_tasks_slice_in_arena` but carries one `ArenaContext` across several
1998    /// workflows.
1999    fn run_sync_stretch(
2000        &self,
2001        slice: TaskSlice<'_, '_>,
2002        message: &mut Message,
2003        trace: Option<&mut ExecutionTrace>,
2004        pass: PassCtx,
2005    ) -> Result<SliceOutcome> {
2006        with_arena(|arena| -> Result<SliceOutcome> {
2007            let mut arena_ctx = ArenaContext::from_owned(&message.context, arena);
2008            self.run_tasks_slice_in_arena(slice, message, &mut arena_ctx, trace, pass)
2009        })
2010    }
2011
2012    /// Run `tasks` against an already-built `ArenaContext`, evaluating each
2013    /// task's condition in-arena and refreshing the cache after each mutating
2014    /// task. Returns `Ok(true)` if a filter task halted the workflow.
2015    ///
2016    /// Factored out of `run_sync_stretch` so both the single-workflow stretch
2017    /// and the cross-workflow shared-arena run (`execute_sync_workflow_run`)
2018    /// share one implementation. The caller owns the `ArenaContext` lifetime,
2019    /// so the cross-workflow path can reuse the same arena form of
2020    /// `message.context` across consecutive workflows instead of rebuilding it.
2021    fn run_tasks_slice_in_arena<'arena>(
2022        &self,
2023        slice: TaskSlice<'_, 'arena>,
2024        message: &mut Message,
2025        arena_ctx: &mut ArenaContext<'arena>,
2026        mut trace: Option<&mut ExecutionTrace>,
2027        pass: PassCtx,
2028    ) -> Result<SliceOutcome> {
2029        let arena = arena_ctx.arena();
2030        let TaskSlice {
2031            tasks,
2032            offset,
2033            gate,
2034            list,
2035        } = slice;
2036        let workflow = list.workflow;
2037        let slice_end = offset + tasks.len();
2038
2039        let mut i = 0;
2040        while i < tasks.len() {
2041            let task = &tasks[i];
2042            let abs = offset + i;
2043
2044            // Conditions evaluate against the arena form so we don't re-borrow
2045            // the thread-local `RefCell`. A `None` compiled condition (the
2046            // compiler folds the default literal `true` to `None`) skips both
2047            // the eval and the per-task arena context slice build.
2048            match admit_task(
2049                list,
2050                task,
2051                abs,
2052                gate,
2053                trace.as_deref_mut(),
2054                pass,
2055                |compiled| {
2056                    evaluate_condition_in_arena(
2057                        &self.engine,
2058                        compiled,
2059                        arena_ctx.as_data_value(),
2060                        arena,
2061                    )
2062                },
2063            )? {
2064                Admission::Halt => return Ok(SliceOutcome::Halted),
2065                Admission::Jump(target) => {
2066                    // A span reaching past this slice is the caller's to
2067                    // resume — it owns the tasks beyond `slice_end`.
2068                    if target >= slice_end {
2069                        return Ok(SliceOutcome::JumpTo(target));
2070                    }
2071                    i = target - offset;
2072                    continue;
2073                }
2074                Admission::Skip => {
2075                    i += 1;
2076                    continue;
2077                }
2078                Admission::Run => {}
2079            }
2080
2081            // Per-task snapshot buffer — only used for Map tasks in trace
2082            // mode, and only when the trace's policy wants them. Allocating an
2083            // empty Vec is cheap and the buffer stays empty for non-Map tasks.
2084            let mut mapping_snapshots: Vec<Value> = Vec::new();
2085            let want_mapping_contexts = trace
2086                .as_deref()
2087                .is_some_and(|t| t.options().mapping_contexts);
2088            let mapping_snapshots_buf = if want_mapping_contexts {
2089                Some(&mut mapping_snapshots)
2090            } else {
2091                None
2092            };
2093
2094            let clocks = self.open_task_clocks(trace.is_some(), message);
2095
2096            let result =
2097                self.execute_sync_task_in_arena(task, message, arena_ctx, mapping_snapshots_buf);
2098
2099            // Before `handle_task_result`, whose `?` would drop failed tasks.
2100            self.emit_task_event(workflow, task, &result, clocks.obs_start);
2101
2102            let flow = self.handle_task_result(
2103                result,
2104                &workflow.id_arc,
2105                &task.id_arc,
2106                TaskPass {
2107                    continue_on_error: task.continue_on_error,
2108                    terminal: task.terminal,
2109                    halt_on: task.halt_on,
2110                    errors_before: clocks.errors_before,
2111                },
2112                message,
2113                pass,
2114            );
2115
2116            // Refresh the slots `handle_task_result` wrote so the next task —
2117            // and, in the cross-workflow path, the next workflow's condition —
2118            // sees them, without re-arenaing unrelated metadata children
2119            // (mapped `metadata.routing.*`, chained workflow state, …) after
2120            // every task.
2121            //
2122            // Deliberately *before* the `?`. An `Err` here does not necessarily
2123            // end this arena scope: `execute_sync_workflow_run` continues into
2124            // the next workflow carrying this same `ArenaContext` whenever the
2125            // failing task had `continue_on_error: false` but its workflow had
2126            // `continue_on_error: true`, and that workflow's condition would
2127            // otherwise be evaluated against a stale `metadata.progress`.
2128            arena_ctx.refresh_for_path(&message.context, "metadata.progress");
2129            // Gated on a failure actually being recorded: this walk deep-copies
2130            // the target subtree into the arena, so running it after every
2131            // successful task would be a permanent cost on the hot path.
2132            if let Some(cfg) = self.error_context_refresh(message, clocks.errors_before) {
2133                arena_ctx.refresh_for_path_parts(&message.context, &cfg.path_parts);
2134            }
2135
2136            let control_flow = flow?;
2137
2138            pass.note_executed(
2139                trace.as_deref_mut(),
2140                &workflow.id,
2141                &task.id,
2142                message,
2143                clocks,
2144                // A `map` task in trace mode collects one snapshot per mapping;
2145                // every other sync built-in leaves the buffer empty.
2146                Some(mapping_snapshots).filter(|s| !s.is_empty()),
2147            );
2148
2149            if matches!(control_flow, TaskControlFlow::HaltWorkflow) {
2150                return Ok(SliceOutcome::Halted);
2151            }
2152            i += 1;
2153        }
2154        Ok(SliceOutcome::Completed)
2155    }
2156
2157    /// Drive a message through `workflows` in order, grouping maximal runs of
2158    /// consecutive `fully_sync` workflows into a single shared-arena scope
2159    /// (`execute_sync_workflow_run`) and falling back to the per-workflow
2160    /// `.await` path (`execute_inner`) for any workflow containing an async
2161    /// task.
2162    ///
2163    /// A thin `&[&Workflow]` wrapper over `Self::run_all_borrowed`, which is
2164    /// the actual shared entry all four `Engine::process_message*` variants
2165    /// call directly (against `&[Workflow]` from the engine's own registry,
2166    /// with no per-message `Vec<&Workflow>` collect). This method exists for
2167    /// a caller that already holds borrowed references.
2168    pub async fn run_all(
2169        &self,
2170        workflows: &[&Workflow],
2171        message: &mut Message,
2172        trace: Option<&mut ExecutionTrace>,
2173        now: DateTime<Utc>,
2174    ) -> Result<()> {
2175        self.run_all_borrowed(workflows, message, trace, now).await
2176    }
2177
2178    /// Generic driver behind [`Self::run_all`]: accepts any slice whose
2179    /// elements borrow as `Workflow` — `&[Workflow]` directly from the
2180    /// engine's registry (no per-message `Vec<&Workflow>` collect) or the
2181    /// `&[&Workflow]` shape the public entry keeps for compatibility.
2182    pub(crate) async fn run_all_borrowed<W: std::borrow::Borrow<Workflow>>(
2183        &self,
2184        workflows: &[W],
2185        message: &mut Message,
2186        trace: Option<&mut ExecutionTrace>,
2187        now: DateTime<Utc>,
2188    ) -> Result<()> {
2189        let started_at = self.observer_clock();
2190        if let Some(observer) = self.observer.as_ref() {
2191            observer.message_started(&MessageStarted {
2192                message_id: message.id(),
2193                workflows_considered: workflows.len(),
2194            });
2195        }
2196        let outcome = self.run_all_inner(workflows, message, trace, now).await;
2197        if let Some(observer) = self.observer.as_ref() {
2198            observer.message_finished(&MessageFinished {
2199                message_id: message.id(),
2200                duration: started_at
2201                    .map(|s| Duration::from_micros(duration_us_between(s, Utc::now())))
2202                    .unwrap_or_default(),
2203                errors: message.errors().len(),
2204                stopped_early: outcome.is_err(),
2205            });
2206        }
2207        outcome
2208    }
2209
2210    /// The driver proper. Split out so `message_finished` fires on the early
2211    /// `Err` path too — an observer measuring a run must see the runs that
2212    /// stopped, which are the interesting ones.
2213    async fn run_all_inner<W: std::borrow::Borrow<Workflow>>(
2214        &self,
2215        workflows: &[W],
2216        message: &mut Message,
2217        mut trace: Option<&mut ExecutionTrace>,
2218        now: DateTime<Utc>,
2219    ) -> Result<()> {
2220        let mut i = 0;
2221        while i < workflows.len() {
2222            if joins_sync_run(workflows[i].borrow()) {
2223                // Extend over the maximal run of consecutive fully-sync
2224                // workflows and execute them in one shared arena scope.
2225                let mut j = i + 1;
2226                while j < workflows.len() && joins_sync_run(workflows[j].borrow()) {
2227                    j += 1;
2228                }
2229                self.execute_sync_workflow_run(
2230                    &workflows[i..j],
2231                    message,
2232                    trace.as_deref_mut(),
2233                    now,
2234                )?;
2235                i = j;
2236            } else {
2237                // Mixed sync+async (or fully-async) workflow: the existing
2238                // driver interleaves per-stretch arenas with `.await`.
2239                self.execute_inner(workflows[i].borrow(), message, trace.as_deref_mut(), now)
2240                    .await?;
2241                i += 1;
2242            }
2243        }
2244        Ok(())
2245    }
2246
2247    /// Execute a maximal run of consecutive fully-sync workflows inside ONE
2248    /// shared `with_arena` scope. The message context is deep-walked into the
2249    /// arena once for the whole run, then carried — with the existing
2250    /// incremental `refresh_for_path` after each mutating task — across
2251    /// workflow boundaries, instead of being rebuilt per workflow.
2252    ///
2253    /// Per-workflow semantics are preserved exactly: each workflow's condition
2254    /// is evaluated (in-arena), a false condition skips only that workflow, a
2255    /// filter-halt stops only that workflow, and task errors are wrapped with
2256    /// the workflow id and honor `continue_on_error` (continue, or propagate
2257    /// `Err` out of the run to stop the whole message) — mirroring
2258    /// `execute_inner`.
2259    ///
2260    /// **Tokio safety:** this method is synchronous and the `fully_sync`
2261    /// precondition guarantees every task is a sync built-in, so no `.await`
2262    /// occurs while the `!Send` arena borrow is live. The borrow checker
2263    /// enforces this — the shared `ArenaContext` cannot escape the closure.
2264    fn execute_sync_workflow_run<W: std::borrow::Borrow<Workflow>>(
2265        &self,
2266        workflows: &[W],
2267        message: &mut Message,
2268        mut trace: Option<&mut ExecutionTrace>,
2269        now: DateTime<Utc>,
2270    ) -> Result<()> {
2271        // `joins_sync_run` keeps looping workflows out of this path, so every
2272        // workflow here runs exactly one pass and carries no loop counter.
2273        debug_assert!(
2274            workflows.iter().all(|w| joins_sync_run(w.borrow())),
2275            "only non-looping fully-sync workflows may join a shared-arena run"
2276        );
2277        let pass = PassCtx::once(now);
2278
2279        with_arena(|arena| -> Result<()> {
2280            let mut arena_ctx = ArenaContext::from_owned(&message.context, arena);
2281
2282            for workflow in workflows {
2283                let workflow: &Workflow = workflow.borrow();
2284
2285                // Same gate as `execute_inner`. This is the site a fully-sync
2286                // workflow actually reaches: `fully_sync` routes every
2287                // map/log/validation/filter-only workflow here and never through
2288                // `execute_inner`, so gating only there would silently not apply
2289                // to most workflows.
2290                if !rollout_admits(workflow, message) {
2291                    note_workflow_skip(
2292                        trace.as_deref_mut(),
2293                        &workflow.id,
2294                        "outside rollout bucket",
2295                    );
2296                    continue;
2297                }
2298
2299                // Workflow condition in-arena: a folded `None` skips the eval;
2300                // a real condition reuses the carried context instead of
2301                // repeating the owned-path `eval_to_owned` spine walk.
2302                let should_execute = match workflow.compiled_condition.as_ref() {
2303                    None => true,
2304                    Some(compiled) => evaluate_condition_in_arena(
2305                        &self.engine,
2306                        Some(compiled),
2307                        arena_ctx.as_data_value(),
2308                        arena,
2309                    )?,
2310                };
2311
2312                if !should_execute {
2313                    note_workflow_skip(trace.as_deref_mut(), &workflow.id, "condition not met");
2314                    continue;
2315                }
2316
2317                // Admitted, so the span opens here — the sync run's own gate
2318                // site, which `execute_inner` never sees for these workflows.
2319                let mut span = WorkflowSpan::default();
2320                self.begin_workflow(&mut span, workflow);
2321
2322                // Group state is per-workflow: this run carries one arena
2323                // across several workflows, but never a group.
2324                let mut gate = GroupGate::default();
2325                match self.run_tasks_slice_in_arena(
2326                    TaskSlice {
2327                        tasks: &workflow.tasks,
2328                        offset: 0,
2329                        gate: &mut gate,
2330                        list: PassList::body(workflow),
2331                    },
2332                    message,
2333                    &mut arena_ctx,
2334                    trace.as_deref_mut(),
2335                    pass,
2336                ) {
2337                    // A halt stops only this workflow; carry on with the next
2338                    // one (and keep the shared arena context). The slice spans
2339                    // the whole task list, so a jump can only land at its end.
2340                    Ok(outcome) => {
2341                        self.end_workflow(&span, workflow, matches!(outcome, SliceOutcome::Halted));
2342                        info!("Successfully completed workflow: {}", workflow.id);
2343                    }
2344                    Err(e) => {
2345                        // Closed before the early return, for the same reason
2346                        // as the loop path: the failing workflow's span is the
2347                        // one an observer most wants.
2348                        self.end_workflow(&span, workflow, false);
2349                        // Single-channel contract — mirror `execute_inner`.
2350                        if self.record_workflow_error(workflow, message, &e) {
2351                            return Err(e);
2352                        }
2353                    }
2354                }
2355            }
2356            Ok(())
2357        })
2358    }
2359
2360    /// Dispatch a single sync-builtin task via the consolidated
2361    /// `FunctionConfig::try_execute_in_arena`. `next_async_boundary` guarantees
2362    /// the stretch contents are sync built-ins, so the `None` arm is
2363    /// unreachable in practice.
2364    ///
2365    /// `mapping_snapshots` is only consulted by the `Map` variant; non-Map
2366    /// sync builtins ignore it. Pass `None` from the production path.
2367    fn execute_sync_task_in_arena<'arena>(
2368        &self,
2369        task: &'arena Task,
2370        message: &mut Message,
2371        arena_ctx: &mut ArenaContext<'arena>,
2372        mapping_snapshots: Option<&mut Vec<Value>>,
2373    ) -> Result<(TaskOutcome, Vec<Change>)> {
2374        debug!(
2375            "Executing sync task in arena: {} ({})",
2376            task.id,
2377            task.function.function_name()
2378        );
2379        debug_assert!(
2380            task.function.is_sync_builtin(),
2381            "execute_sync_task_in_arena called with non-sync-builtin task: {}",
2382            task.function.function_name()
2383        );
2384        // In debug builds the assert above catches mis-dispatch; in release
2385        // we still surface the invariant violation as a recoverable engine
2386        // error rather than panicking via `unreachable!`.
2387        task.function
2388            .try_execute_in_arena(message, arena_ctx, &self.engine, mapping_snapshots)
2389            .ok_or_else(|| {
2390                DataflowError::Task(format!(
2391                    "execute_sync_task_in_arena dispatched to non-sync-builtin task '{}' \
2392                     (engine bug — sync-stretch should only contain sync-builtin tasks)",
2393                    task.function.function_name()
2394                ))
2395            })?
2396    }
2397
2398    /// Mirror every error this task contributed to `message.errors` into the
2399    /// configured context path.
2400    ///
2401    /// Taking the delta beyond `task.errors_before` rather than recording at each
2402    /// push site is what makes coverage match `errors()` exactly. Two of the four
2403    /// per-task producers never reach a failure arm at all: the `validation`
2404    /// built-in appends its per-rule failures and then returns `Status(400)`,
2405    /// which lands in the *success* arm, and `TaskContext::add_error` can fire on
2406    /// a task that succeeds outright. Neither is visible to a host that wraps
2407    /// handlers either, since the sync built-ins never reach the registry.
2408    ///
2409    /// Must run *after* the two pushes `handle_task_result` performs itself
2410    /// (`TASK_STATUS_ERROR` and the task error), or they fall outside the delta —
2411    /// which would silently drop every `map` failure, since `map` returns
2412    /// `Status(500)` without touching `errors` itself.
2413    ///
2414    /// Returns nothing — the sync stretch reads [`Self::error_context_refresh`]
2415    /// instead, so the arena refresh stays off the no-failure path.
2416    #[inline]
2417    fn mirror_task_errors(
2418        &self,
2419        message: &mut Message,
2420        workflow_id: &str,
2421        task_id: &str,
2422        status: u16,
2423        task: TaskPass,
2424    ) {
2425        let Some(cfg) = self.error_context.as_ref() else {
2426            return;
2427        };
2428        // Disjoint borrows of two fields of `Message`, so the split is needed to
2429        // read the error tail while mutating the context.
2430        let Message {
2431            context, errors, ..
2432        } = message;
2433        let new_errors = errors.get(task.errors_before..).unwrap_or(&[]);
2434        append_error_records(context, cfg, workflow_id, task_id, status, new_errors);
2435    }
2436
2437    /// Whether the sync stretch must refresh the arena for the error-context
2438    /// path after this task — i.e. the option is on and the task contributed at
2439    /// least one error.
2440    #[inline]
2441    fn error_context_refresh<'a>(
2442        &'a self,
2443        message: &Message,
2444        errors_before: usize,
2445    ) -> Option<&'a Arc<ErrorContextConfig>> {
2446        let cfg = self.error_context.as_ref()?;
2447        if message.errors.len() > errors_before {
2448            Some(cfg)
2449        } else {
2450            None
2451        }
2452    }
2453
2454    /// Handle the result of a task execution.
2455    ///
2456    /// `workflow_id_arc` and `task_id_arc` are the compile-time cached
2457    /// `Arc<str>` mirrors of `workflow.id` / `task.id`; we Arc-clone them into
2458    /// each `AuditTrail` rather than reallocating from the `&str` form.
2459    fn handle_task_result(
2460        &self,
2461        result: Result<(TaskOutcome, Vec<Change>)>,
2462        workflow_id_arc: &Arc<str>,
2463        task_id_arc: &Arc<str>,
2464        task: TaskPass,
2465        message: &mut Message,
2466        pass: PassCtx,
2467    ) -> Result<TaskControlFlow> {
2468        let workflow_id: &str = workflow_id_arc;
2469        let task_id: &str = task_id_arc;
2470        let continue_on_error = task.continue_on_error;
2471        match result {
2472            Ok((TaskOutcome::Skip, _)) => {
2473                // No audit trail, no progress write, and no error-context record
2474                // — the task has explicitly opted out of the per-task record
2475                // (filter gate set to `Skip`). `audit_status()` is `None` here,
2476                // so a record would need a fabricated `status`, breaking the
2477                // fixed four-key shape that makes the path predictable to branch
2478                // on. Reaching this with errors recorded takes a handler that
2479                // calls `add_error` and *then* skips; the entry is still on
2480                // `message.errors()`.
2481                debug!("Task {} signaled skip", task_id);
2482                Ok(TaskControlFlow::Continue)
2483            }
2484            Ok((outcome, changes)) => {
2485                // `Skip` already returned above; the remaining variants all
2486                // record an audit entry. `audit_status()` is `Some` for
2487                // Success/Status/Halt — expect is for documentation only.
2488                let status = outcome
2489                    .audit_status()
2490                    .expect("Skip handled above; remaining variants emit audit status");
2491                // `Task::terminal` and `Task::halt_on` reach the same halt as
2492                // `TaskOutcome::Halt`, but they are applied *after* the status
2493                // classification below — see the `flow` fold. Deciding here would
2494                // make halting the first branch of the chain, so a terminal task
2495                // returning 500 would stop without recording `TASK_STATUS_ERROR`
2496                // and without propagating when `continue_on_error` is false.
2497                // `halts_at` is the single definition of "failed" for `halt_on`.
2498                let halt_requested = outcome.halts_workflow() || task.halts_at(status);
2499
2500                // Record audit trail. workflow_id_arc/task_id_arc are populated
2501                // by LogicCompiler at engine construction; cloning them is a
2502                // refcount bump, not a string copy. `now` is shared with all
2503                // other AuditTrails in this process_message call.
2504                message.record_audit(AuditTrail {
2505                    timestamp: pass.now,
2506                    workflow_id: Arc::clone(workflow_id_arc),
2507                    task_id: Arc::clone(task_id_arc),
2508                    status: status as usize,
2509                    changes,
2510                    loop_counter: pass.loop_counter,
2511                    element_index: pass.element_index,
2512                });
2513
2514                // Update progress metadata for workflow chaining. Always
2515                // emitted: when multiple workflows are registered in the same
2516                // engine, downstream workflows route on
2517                // `metadata.progress.{workflow_id,task_id,status_code}` to
2518                // advance through linear sequences. After the first task the
2519                // slot already holds the expected 3-key object, so the write
2520                // overwrites the three values in place — only the two id
2521                // `String` allocs remain. (This beat both three separate
2522                // `set_nested_value` calls and the batched slot replace on
2523                // the realistic workload.)
2524                write_progress_metadata(&mut message.context, workflow_id, task_id, status);
2525
2526                // Decide the control flow first rather than returning from
2527                // inside each branch, so the error-context mirror below runs on
2528                // exactly one path. The halt and `!continue_on_error` exits would
2529                // otherwise each need their own call, and a future exit added
2530                // without one would silently stop recording.
2531                let flow = if (400..500).contains(&status) {
2532                    warn!("Task {} returned client error status: {}", task_id, status);
2533                    Ok(TaskControlFlow::Continue)
2534                } else if status >= 500 {
2535                    error!("Task {} returned server error status: {}", task_id, status);
2536                    // Single-channel contract: surface 5xx outcomes through
2537                    // `message.errors` as well as the audit trail, so callers
2538                    // that scan `errors()` see a 5xx-status task even when
2539                    // the workflow continues past it.
2540                    let mut info = ErrorInfo::builder(
2541                        "TASK_STATUS_ERROR",
2542                        format!("Task {} returned status {}", task_id, status),
2543                    )
2544                    .workflow_id(workflow_id)
2545                    .task_id(task_id);
2546                    if let Some(index) = pass.element_index {
2547                        info = info.element_index(index);
2548                    }
2549                    message.errors.push(info.build());
2550                    if continue_on_error {
2551                        Ok(TaskControlFlow::Continue)
2552                    } else {
2553                        Err(DataflowError::Task(format!(
2554                            "Task {} failed with status {}",
2555                            task_id, status
2556                        )))
2557                    }
2558                } else {
2559                    Ok(TaskControlFlow::Continue)
2560                };
2561
2562                // Upgrade a `Continue` to a halt, leaving the 5xx `Err` and the
2563                // recording above untouched. `TaskOutcome::Halt`'s own status is
2564                // 299 — neither 4xx nor 5xx — so its behaviour is unchanged.
2565                let flow = match flow {
2566                    Ok(TaskControlFlow::Continue) if halt_requested => {
2567                        info!("Task {} halted workflow {}", task_id, workflow_id);
2568                        Ok(TaskControlFlow::HaltWorkflow)
2569                    }
2570                    other => other,
2571                };
2572
2573                // After the `TASK_STATUS_ERROR` push above, so it lands inside
2574                // this task's delta.
2575                self.mirror_task_errors(message, workflow_id, task_id, status, task);
2576                flow
2577            }
2578            Err(e) => {
2579                error!("Task {} failed: {:?}", task_id, e);
2580
2581                // Record error in audit trail (Arc clones are refcount bumps).
2582                message.record_audit(AuditTrail {
2583                    timestamp: pass.now,
2584                    workflow_id: Arc::clone(workflow_id_arc),
2585                    task_id: Arc::clone(task_id_arc),
2586                    status: 500,
2587                    changes: vec![],
2588                    loop_counter: pass.loop_counter,
2589                    element_index: pass.element_index,
2590                });
2591
2592                // Same invariant as the Ok arm: `metadata.progress` is written
2593                // after every task, unconditionally, so a downstream workflow
2594                // gating on it still sees this task ran even though it errored.
2595                write_progress_metadata(&mut message.context, workflow_id, task_id, 500);
2596
2597                // Add error to message. A service-classified error contributes
2598                // its own `kind` as the code and carries its operator-only
2599                // `detail`; everything else takes its variant's code.
2600                // Deliberately lifted at the task site only: the two
2601                // `WORKFLOW_ERROR` wrappers wrap the same propagated error, so
2602                // lifting there too would put two entries with the same
2603                // `code` on the message — making "count errors by code"
2604                // double-count — and would stop `WORKFLOW_ERROR` reliably
2605                // meaning "a workflow stopped".
2606                //
2607                // `format!("{}", e)` stays caller-safe because `Service`'s
2608                // `Display` is `{message}` — the detail is never interpolated.
2609                let mut info = ErrorInfo::builder(
2610                    service_error_code(&e),
2611                    format!("Task {} error: {}", task_id, e),
2612                )
2613                .workflow_id(workflow_id)
2614                .task_id(task_id);
2615                if let Some(detail) = e.detail() {
2616                    info = info.detail(detail);
2617                }
2618                if let Some(index) = pass.element_index {
2619                    info = info.element_index(index);
2620                }
2621                message.errors.push(info.build());
2622
2623                // `500` matches the audit entry and the progress write above: a
2624                // handler `Err` has no status of its own.
2625                self.mirror_task_errors(message, workflow_id, task_id, 500, task);
2626
2627                if !continue_on_error {
2628                    Err(e)
2629                } else if task.halts_at(500) {
2630                    // Either flag halts here: `terminal` because the author said
2631                    // "nothing after this runs" whatever happened, `halt_on`
2632                    // because this is a failure. `500` is the status already
2633                    // stamped on the audit entry, the progress write and the
2634                    // error mirror above — a handler `Err` has none of its own.
2635                    // The error stays on `message.errors()` either way.
2636                    info!(
2637                        "Task {} halted workflow {} after failing",
2638                        task_id, workflow_id
2639                    );
2640                    Ok(TaskControlFlow::HaltWorkflow)
2641                } else {
2642                    Ok(TaskControlFlow::Continue)
2643                }
2644            }
2645        }
2646    }
2647}
2648
2649#[cfg(test)]
2650mod tests {
2651    use super::*;
2652    use crate::engine::compiler::LogicCompiler;
2653    use serde_json::json;
2654    use std::collections::HashMap;
2655
2656    /// Test-only helper: build an `OwnedDataValue` from a `json!` literal.
2657    fn dv(v: serde_json::Value) -> OwnedDataValue {
2658        OwnedDataValue::from(&v)
2659    }
2660
2661    /// Compile `json` into a single runnable workflow plus its engine.
2662    fn compiled(json: &str) -> (Workflow, Arc<datalogic_rs::Engine>) {
2663        let compiler = LogicCompiler::new();
2664        let workflow = Workflow::from_json(json).expect("workflow should parse");
2665        let compiled = compiler
2666            .compile_workflows(vec![workflow])
2667            .expect("workflow should compile");
2668        (
2669            compiled.into_iter().next().expect("one workflow"),
2670            compiler.into_engine(),
2671        )
2672    }
2673
2674    /// A `WorkflowExecutor` over an empty handler registry.
2675    fn executor(engine: Arc<datalogic_rs::Engine>) -> WorkflowExecutor {
2676        let task_executor = Arc::new(TaskExecutor::new(
2677            Arc::new(HashMap::new()),
2678            Arc::clone(&engine),
2679        ));
2680        WorkflowExecutor::new(task_executor, engine)
2681    }
2682
2683    /// A `WorkflowExecutor` that mirrors failure codes to `metadata.errors`.
2684    fn executor_with_error_context(engine: Arc<datalogic_rs::Engine>) -> WorkflowExecutor {
2685        let cfg = ErrorContextConfig::new("metadata.errors".to_string(), 32)
2686            .expect("metadata.errors is a valid path");
2687        executor(engine).with_error_context(Arc::new(cfg))
2688    }
2689
2690    #[tokio::test]
2691    async fn appending_records_mid_stretch_keeps_the_arena_cache_consistent() {
2692        // A failing `validation` followed by a `map`, both sync built-ins, so
2693        // they share one `ArenaContext`. Two things are under test:
2694        //
2695        // 1. the `map` reads the record appended by the `validation`, which only
2696        //    works if the append refreshed the arena; and
2697        // 2. `apply_mutation_parts_write_through`'s `#[cfg(test)]`
2698        //    `assert_matches_owned` runs on the `map`'s write, giving free
2699        //    differential verification that the refresh left the arena cache
2700        //    identical to a from-scratch rebuild of the owned context. That
2701        //    assertion is compiled out for the `tests/` binaries, so it can only
2702        //    be exercised from here.
2703        let (workflow, engine) = compiled(
2704            r#"{ "id": "w", "name": "w", "tasks": [
2705                { "id": "check", "name": "check", "continue_on_error": true,
2706                  "function": {"name": "validation", "input": {"rules": [
2707                      {"logic": false, "message": "nope"}]}}},
2708                { "id": "react", "name": "react",
2709                  "function": {"name": "map", "input": {"mappings": [
2710                      {"path": "data.seen", "logic": {"var": "metadata.errors.0.code"}}]}}}
2711            ]}"#,
2712        );
2713        let mut message = Message::from_value(&json!({}));
2714
2715        executor_with_error_context(engine)
2716            .execute(&workflow, &mut message, Utc::now())
2717            .await
2718            .expect("continue_on_error keeps the workflow running");
2719
2720        assert_eq!(
2721            message.context["data"].get("seen"),
2722            Some(&dv(json!("VALIDATION_ERROR"))),
2723            "the map must read the record the validation appended in the same stretch"
2724        );
2725    }
2726
2727    #[tokio::test]
2728    async fn the_error_context_path_is_untouched_when_every_task_succeeds() {
2729        let (workflow, engine) = compiled(&format!(
2730            r#"{{ "id": "w", "name": "w", "tasks": [{COUNTER_BODY}] }}"#
2731        ));
2732        let mut message = Message::from_value(&json!({}));
2733
2734        executor_with_error_context(engine)
2735            .execute(&workflow, &mut message, Utc::now())
2736            .await
2737            .expect("workflow should complete");
2738
2739        assert_eq!(
2740            message.context["metadata"].get("errors"),
2741            None,
2742            "a clean run leaves the key absent, not an empty array"
2743        );
2744    }
2745
2746    /// Every `loop_counter` recorded on the audit trail, in order.
2747    fn counters(message: &Message) -> Vec<Option<i64>> {
2748        message
2749            .audit_trail
2750            .iter()
2751            .map(|entry| entry.loop_counter)
2752            .collect()
2753    }
2754
2755    /// A one-task `map` workflow body writing `data.n` from the counter.
2756    const COUNTER_BODY: &str = r#"{"id": "t", "name": "t", "function": {"name": "map",
2757        "input": {"mappings": [{"path": "data.n", "logic": {"var": "temp_data.i"}}]}}}"#;
2758
2759    #[tokio::test]
2760    async fn loop_without_a_condition_runs_exactly_max_sweeps() {
2761        let (workflow, engine) = compiled(&format!(
2762            r#"{{ "id": "w", "name": "w", "loop": {{"counter": "i", "max": 3}},
2763                  "tasks": [{COUNTER_BODY}] }}"#
2764        ));
2765        let mut message = Message::from_value(&json!({}));
2766
2767        let executed = executor(engine)
2768            .execute(&workflow, &mut message, Utc::now())
2769            .await
2770            .expect("loop should complete");
2771
2772        assert!(executed);
2773        // One audit entry per sweep, each stamped with its counter.
2774        assert_eq!(counters(&message), vec![Some(0), Some(1), Some(2)]);
2775        // The counter is left at the bound the loop stopped on.
2776        assert_eq!(message.context["temp_data"].get("i"), Some(&dv(json!(3))));
2777        // The body observed each value; the last one survives.
2778        assert_eq!(message.context["data"].get("n"), Some(&dv(json!(2))));
2779    }
2780
2781    #[tokio::test]
2782    async fn loop_exits_early_when_the_condition_goes_false() {
2783        // Bounded at 10 but the condition stops it at 4.
2784        let (workflow, engine) = compiled(&format!(
2785            r#"{{ "id": "w", "name": "w",
2786                  "condition": {{"<": [{{"var": "temp_data.i"}}, 4]}},
2787                  "loop": {{"counter": "i", "max": 10}},
2788                  "tasks": [{COUNTER_BODY}] }}"#
2789        ));
2790        let mut message = Message::from_value(&json!({}));
2791
2792        executor(engine)
2793            .execute(&workflow, &mut message, Utc::now())
2794            .await
2795            .expect("loop should complete");
2796
2797        assert_eq!(counters(&message), vec![Some(0), Some(1), Some(2), Some(3)]);
2798    }
2799
2800    #[tokio::test]
2801    async fn loop_whose_condition_is_false_on_the_first_sweep_is_a_plain_skip() {
2802        let (workflow, engine) = compiled(&format!(
2803            r#"{{ "id": "w", "name": "w", "condition": false,
2804                  "loop": {{"counter": "i", "max": 5}},
2805                  "tasks": [{COUNTER_BODY}] }}"#
2806        ));
2807        let mut message = Message::from_value(&json!({}));
2808
2809        let executed = executor(engine)
2810            .execute(&workflow, &mut message, Utc::now())
2811            .await
2812            .expect("a skip is not an error");
2813
2814        assert!(!executed, "a never-entered loop reports as skipped");
2815        assert!(message.audit_trail.is_empty());
2816    }
2817
2818    // -----------------------------------------------------------------
2819    // loop.setup / over / as / scratch (#60)
2820    // -----------------------------------------------------------------
2821
2822    /// The body records the element id and the index it was visited at.
2823    const OVER_BODY: &str = r#"{"id": "t", "name": "t", "function": {"name": "map",
2824        "input": {"mappings": [
2825            {"path": "data.last", "logic": {"var": "temp_data.item.id"}},
2826            {"path": "data.last_i", "logic": {"var": "temp_data.i"}}]}}}"#;
2827
2828    fn items_message() -> Message {
2829        Message::builder()
2830            .data(dv(
2831                json!({"items": [{"id": "a"}, {"id": "b"}, {"id": "c"}]}),
2832            ))
2833            .build()
2834    }
2835
2836    #[tokio::test]
2837    async fn an_over_loop_visits_each_element_with_its_index() {
2838        let (workflow, engine) = compiled(&format!(
2839            r#"{{ "id": "w", "name": "w",
2840                  "loop": {{"counter": "i", "max": 100, "over": {{"var": "data.items"}}, "as": "item"}},
2841                  "tasks": [{OVER_BODY}] }}"#
2842        ));
2843        let mut message = items_message();
2844
2845        let executed = executor(engine)
2846            .execute(&workflow, &mut message, Utc::now())
2847            .await
2848            .expect("loop should complete");
2849
2850        assert!(executed);
2851        assert_eq!(
2852            counters(&message),
2853            vec![Some(0), Some(1), Some(2)],
2854            "one sweep per element, not one per max"
2855        );
2856        assert_eq!(message.context["data"].get("last"), Some(&dv(json!("c"))));
2857        assert_eq!(message.context["data"].get("last_i"), Some(&dv(json!(2))));
2858        assert_eq!(
2859            message.context["temp_data"].get("item"),
2860            Some(&dv(json!({"id": "c"}))),
2861            "the element slot is left holding the last element, like the counter"
2862        );
2863        assert_eq!(message.context["temp_data"].get("i"), Some(&dv(json!(3))));
2864    }
2865
2866    #[tokio::test]
2867    async fn max_still_bounds_an_over_loop() {
2868        let (workflow, engine) = compiled(&format!(
2869            r#"{{ "id": "w", "name": "w",
2870                  "loop": {{"counter": "i", "max": 2, "over": {{"var": "data.items"}}, "as": "item"}},
2871                  "tasks": [{OVER_BODY}] }}"#
2872        ));
2873        let mut message = items_message();
2874        executor(engine)
2875            .execute(&workflow, &mut message, Utc::now())
2876            .await
2877            .unwrap();
2878        assert_eq!(counters(&message), vec![Some(0), Some(1)]);
2879    }
2880
2881    #[tokio::test]
2882    async fn init_offsets_and_increment_strides_the_element_index() {
2883        let (workflow, engine) = compiled(&format!(
2884            r#"{{ "id": "w", "name": "w",
2885                  "loop": {{"counter": "i", "init": 1, "increment": 2, "max": 100,
2886                            "over": {{"var": "data.items"}}, "as": "item"}},
2887                  "tasks": [{OVER_BODY}] }}"#
2888        ));
2889        let mut message = Message::builder()
2890            .data(dv(json!({
2891                "items": [{"id": "a"}, {"id": "b"}, {"id": "c"}, {"id": "d"}, {"id": "e"}, {"id": "f"}]
2892            })))
2893            .build();
2894        executor(engine)
2895            .execute(&workflow, &mut message, Utc::now())
2896            .await
2897            .unwrap();
2898        assert_eq!(counters(&message), vec![Some(1), Some(3), Some(5)]);
2899        assert_eq!(message.context["data"].get("last"), Some(&dv(json!("f"))));
2900    }
2901
2902    #[tokio::test]
2903    async fn an_empty_over_runs_no_iteration_but_the_workflow_still_executed() {
2904        let (workflow, engine) = compiled(&format!(
2905            r#"{{ "id": "w", "name": "w",
2906                  "loop": {{"counter": "i", "max": 100, "over": [], "as": "item"}},
2907                  "tasks": [{OVER_BODY}] }}"#
2908        ));
2909        let mut message = Message::from_value(&json!({}));
2910        let executed = executor(engine)
2911            .execute(&workflow, &mut message, Utc::now())
2912            .await
2913            .unwrap();
2914        assert!(
2915            executed,
2916            "admitted by its condition, zero elements to visit"
2917        );
2918        assert!(message.audit_trail.is_empty());
2919        assert_eq!(
2920            message.context["temp_data"].get("item"),
2921            None,
2922            "never written"
2923        );
2924    }
2925
2926    #[tokio::test]
2927    async fn a_non_array_over_is_a_workflow_error() {
2928        let make = |continue_on_error: bool| {
2929            compiled(&format!(
2930                r#"{{ "id": "w", "name": "w", "continue_on_error": {continue_on_error},
2931                      "loop": {{"counter": "i", "max": 100, "over": {{"var": "data.items"}}, "as": "item"}},
2932                      "tasks": [{OVER_BODY}] }}"#
2933            ))
2934        };
2935        for (items, kind) in [
2936            (json!("nope"), "a string"),
2937            (json!(null), "null"),
2938            (json!({"a": 1}), "an object"),
2939        ] {
2940            let (workflow, engine) = make(false);
2941            let mut message = Message::builder().data(dv(json!({"items": items}))).build();
2942            let err = executor(engine)
2943                .execute(&workflow, &mut message, Utc::now())
2944                .await
2945                .expect_err("not an array");
2946            assert!(
2947                err.to_string().contains("loop.over"),
2948                "names the loop: {err}"
2949            );
2950            assert!(err.to_string().contains(kind), "says what it got: {err}");
2951            assert_eq!(message.errors.len(), 1);
2952            assert_eq!(message.errors[0].code, "WORKFLOW_ERROR");
2953            assert!(message.audit_trail.is_empty(), "no sweep ran");
2954        }
2955
2956        // With continue_on_error the error is recorded and the next workflow
2957        // still runs — but this one runs no iteration.
2958        let (workflow, engine) = make(true);
2959        let mut message = Message::builder().data(dv(json!({"items": 5}))).build();
2960        let executed = executor(engine)
2961            .execute(&workflow, &mut message, Utc::now())
2962            .await
2963            .unwrap();
2964        assert!(executed);
2965        assert_eq!(message.errors[0].code, "WORKFLOW_ERROR");
2966        assert!(message.audit_trail.is_empty());
2967    }
2968
2969    /// A setup list that copies `data.items` into `temp_data.batch` and stamps
2970    /// `data.setup_ran` — the batch-read step of the issue.
2971    const SETUP: &str = r#"[{"id": "read", "name": "read", "function": {"name": "map",
2972        "input": {"mappings": [
2973            {"path": "temp_data.batch", "logic": {"var": "data.items"}},
2974            {"path": "data.setup_ran", "logic": true}]}}}]"#;
2975
2976    #[tokio::test]
2977    async fn setup_runs_once_before_the_first_iteration_without_a_loop_counter() {
2978        let (workflow, engine) = compiled(&format!(
2979            r#"{{ "id": "w", "name": "w",
2980                  "loop": {{"counter": "i", "max": 100, "setup": {SETUP},
2981                            "over": {{"var": "temp_data.batch"}}, "as": "item"}},
2982                  "tasks": [{OVER_BODY}] }}"#
2983        ));
2984        let mut message = items_message();
2985        executor(engine)
2986            .execute(&workflow, &mut message, Utc::now())
2987            .await
2988            .unwrap();
2989
2990        assert_eq!(
2991            counters(&message),
2992            vec![None, Some(0), Some(1), Some(2)],
2993            "setup is stamped like a non-looping task, then one entry per element"
2994        );
2995        assert_eq!(message.audit_trail[0].task_id.as_ref(), "read");
2996        assert_eq!(message.context["data"].get("last"), Some(&dv(json!("c"))));
2997    }
2998
2999    #[tokio::test]
3000    async fn a_false_condition_skips_setup_and_the_loop_alike() {
3001        let (workflow, engine) = compiled(&format!(
3002            r#"{{ "id": "w", "name": "w", "condition": false,
3003                  "loop": {{"counter": "i", "max": 100, "setup": {SETUP},
3004                            "over": {{"var": "temp_data.batch"}}, "as": "item"}},
3005                  "tasks": [{OVER_BODY}] }}"#
3006        ));
3007        let mut message = items_message();
3008        let executed = executor(engine)
3009            .execute(&workflow, &mut message, Utc::now())
3010            .await
3011            .unwrap();
3012        assert!(!executed);
3013        assert!(message.audit_trail.is_empty(), "setup did not run");
3014        assert_eq!(message.context["data"].get("setup_ran"), None);
3015    }
3016
3017    #[tokio::test]
3018    async fn the_condition_is_re_evaluated_per_iteration_after_setup() {
3019        let (workflow, engine) = compiled(&format!(
3020            r#"{{ "id": "w", "name": "w",
3021                  "condition": {{"!=": [{{"var": "temp_data.i"}}, 2]}},
3022                  "loop": {{"counter": "i", "max": 100, "setup": {SETUP},
3023                            "over": {{"var": "temp_data.batch"}}, "as": "item"}},
3024                  "tasks": [{OVER_BODY}] }}"#
3025        ));
3026        let mut message = items_message();
3027        // Before setup `temp_data.i` is absent, so `!= 2` admits; iterations
3028        // 0 and 1 run; the condition ends the loop at 2.
3029        let executed = executor(engine)
3030            .execute(&workflow, &mut message, Utc::now())
3031            .await
3032            .unwrap();
3033        assert!(executed);
3034        assert_eq!(counters(&message), vec![None, Some(0), Some(1)]);
3035    }
3036
3037    #[tokio::test]
3038    async fn a_condition_false_on_the_first_iteration_after_setup_still_reports_executed() {
3039        let (workflow, engine) = compiled(&format!(
3040            r#"{{ "id": "w", "name": "w",
3041                  "condition": {{"!": [{{"var": "temp_data.batch"}}]}},
3042                  "loop": {{"counter": "i", "max": 100, "setup": {SETUP},
3043                            "over": {{"var": "temp_data.batch"}}, "as": "item"}},
3044                  "tasks": [{OVER_BODY}] }}"#
3045        ));
3046        let mut message = items_message();
3047        // Admitted before setup (no batch yet); setup writes the batch, so the
3048        // condition is false on the first iteration. Setup ran, so this is an
3049        // executed workflow that visited nothing — not a skip.
3050        let executed = executor(engine)
3051            .execute(&workflow, &mut message, Utc::now())
3052            .await
3053            .unwrap();
3054        assert!(executed);
3055        assert_eq!(counters(&message), vec![None]);
3056    }
3057
3058    #[tokio::test]
3059    async fn a_terminal_setup_step_ends_the_workflow_before_any_iteration() {
3060        let (workflow, engine) = compiled(&format!(
3061            r#"{{ "id": "w", "name": "w",
3062                  "loop": {{"counter": "i", "max": 100,
3063                            "setup": [{{"id": "stop", "name": "stop", "terminal": true,
3064                                        "function": {{"name": "map", "input": {{"mappings": []}}}}}}],
3065                            "over": {{"var": "data.items"}}, "as": "item"}},
3066                  "tasks": [{OVER_BODY}] }}"#
3067        ));
3068        let mut message = items_message();
3069        let executed = executor(engine)
3070            .execute(&workflow, &mut message, Utc::now())
3071            .await
3072            .unwrap();
3073        assert!(executed);
3074        assert_eq!(counters(&message), vec![None], "only the setup step ran");
3075        assert!(message.errors.is_empty());
3076    }
3077
3078    #[tokio::test]
3079    async fn a_setup_error_stops_the_message_unless_the_workflow_continues_on_error() {
3080        let make = |continue_on_error: bool| {
3081            compiled(&format!(
3082                r#"{{ "id": "w", "name": "w", "continue_on_error": {continue_on_error},
3083                      "loop": {{"counter": "i", "max": 100,
3084                                "setup": [{{"id": "boom", "name": "boom",
3085                                            "function": {{"name": "map", "input": {{"mappings": [
3086                                              {{"path": {{"var": "data.root"}}, "unset": true}}]}}}}}}],
3087                                "over": {{"var": "data.items"}}, "as": "item"}},
3088                      "tasks": [{OVER_BODY}] }}"#
3089            ))
3090        };
3091        // A computed removal path resolving to a context root is refused at run
3092        // time with status 500, which without task-level continue_on_error
3093        // becomes an `Err` from the pass.
3094        let data = || dv(json!({"items": [1], "root": "temp_data"}));
3095
3096        let (workflow, engine) = make(false);
3097        let mut message = Message::builder().data(data()).build();
3098        assert!(
3099            executor(engine)
3100                .execute(&workflow, &mut message, Utc::now())
3101                .await
3102                .is_err()
3103        );
3104        assert_eq!(
3105            counters(&message),
3106            vec![None],
3107            "setup recorded, no iteration"
3108        );
3109
3110        let (workflow, engine) = make(true);
3111        let mut message = Message::builder().data(data()).build();
3112        let executed = executor(engine)
3113            .execute(&workflow, &mut message, Utc::now())
3114            .await
3115            .unwrap();
3116        assert!(executed);
3117        assert_eq!(
3118            counters(&message),
3119            vec![None],
3120            "recorded and ended — no first iteration without setup"
3121        );
3122        assert!(message.errors.iter().any(|e| e.code == "WORKFLOW_ERROR"));
3123    }
3124
3125    /// Task `flag` writes `temp_data.it.flag` on iteration 0 only; task `read`
3126    /// records whether the flag is set, at a computed path per index.
3127    const SCRATCH_BODY: &str = r#"
3128        {"id": "flag", "name": "flag", "condition": {"==": [{"var": "temp_data.i"}, 0]},
3129         "function": {"name": "map", "input": {"mappings": [
3130            {"path": "temp_data.it.flag", "logic": true}]}}},
3131        {"id": "read", "name": "read",
3132         "function": {"name": "map", "input": {"mappings": [
3133            {"path": {"cat": ["data.f", {"var": "temp_data.i"}]},
3134             "logic": {"!!": [{"var": "temp_data.it.flag"}]}}]}}}"#;
3135
3136    async fn run_scratch_body(scratch: &str) -> Message {
3137        let (workflow, engine) = compiled(&format!(
3138            r#"{{ "id": "w", "name": "w",
3139                  "loop": {{"counter": "i", "max": 3{scratch}}},
3140                  "tasks": [{SCRATCH_BODY}] }}"#
3141        ));
3142        let mut message = Message::builder()
3143            .temp_data(dv(json!({"keep": "me"})))
3144            .build();
3145        executor(engine)
3146            .execute(&workflow, &mut message, Utc::now())
3147            .await
3148            .unwrap();
3149        message
3150    }
3151
3152    #[tokio::test]
3153    async fn scratch_is_reset_at_the_start_of_every_iteration() {
3154        let leaky = run_scratch_body("").await;
3155        assert_eq!(
3156            leaky.context["data"].get("f1"),
3157            Some(&dv(json!(true))),
3158            "without scratch the slot leaks into the next iteration"
3159        );
3160
3161        let clean = run_scratch_body(r#", "scratch": "it""#).await;
3162        assert_eq!(clean.context["data"].get("f0"), Some(&dv(json!(true))));
3163        assert_eq!(clean.context["data"].get("f1"), Some(&dv(json!(false))));
3164        assert_eq!(clean.context["data"].get("f2"), Some(&dv(json!(false))));
3165        assert_eq!(
3166            clean.context["temp_data"].get("keep"),
3167            Some(&dv(json!("me"))),
3168            "the reset is narrow"
3169        );
3170        assert_eq!(
3171            clean.context["temp_data"].get("it"),
3172            Some(&dv(json!({}))),
3173            "left holding the last iteration's (empty) scratch"
3174        );
3175    }
3176
3177    #[tokio::test]
3178    async fn an_uncompiled_over_is_an_error_not_a_panic() {
3179        // A workflow that never went through `compile_workflows`.
3180        let workflow = Workflow::from_json(&format!(
3181            r#"{{ "id": "w", "name": "w",
3182                  "loop": {{"counter": "i", "max": 3, "over": {{"var": "data.items"}}}},
3183                  "tasks": [{COUNTER_BODY}] }}"#
3184        ))
3185        .unwrap();
3186        let engine = LogicCompiler::new().into_engine();
3187        let mut message = items_message();
3188        let err = executor(engine)
3189            .execute(&workflow, &mut message, Utc::now())
3190            .await
3191            .expect_err("not compiled");
3192        assert!(err.to_string().contains("loop.over"), "got: {err}");
3193    }
3194
3195    #[tokio::test]
3196    async fn filter_halt_breaks_the_whole_loop_not_just_one_sweep() {
3197        let (workflow, engine) = compiled(
3198            r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "max": 10},
3199                 "tasks": [
3200                   {"id": "gate", "name": "gate", "function": {"name": "filter",
3201                     "input": {"condition": {"<": [{"var": "temp_data.i"}, 2]},
3202                               "on_reject": "halt"}}},
3203                   {"id": "body", "name": "body", "function": {"name": "map",
3204                     "input": {"mappings": [
3205                        {"path": "data.n", "logic": {"var": "temp_data.i"}}]}}}] }"#,
3206        );
3207        let mut message = Message::from_value(&json!({}));
3208
3209        executor(engine)
3210            .execute(&workflow, &mut message, Utc::now())
3211            .await
3212            .expect("a halt is not an error");
3213
3214        // Sweeps 0 and 1 run both tasks; sweep 2's gate halts and ends the
3215        // loop rather than moving on to sweep 3.
3216        let ids: Vec<&str> = message
3217            .audit_trail
3218            .iter()
3219            .map(|entry| entry.task_id.as_ref())
3220            .collect();
3221        assert_eq!(ids, ["gate", "body", "gate", "body", "gate"]);
3222        assert_eq!(
3223            counters(&message),
3224            vec![Some(0), Some(0), Some(1), Some(1), Some(2)]
3225        );
3226    }
3227
3228    #[tokio::test]
3229    async fn init_and_increment_drive_the_counter() {
3230        let (workflow, engine) = compiled(&format!(
3231            r#"{{ "id": "w", "name": "w",
3232                  "loop": {{"counter": "i", "init": 10, "increment": 5, "max": 25}},
3233                  "tasks": [{COUNTER_BODY}] }}"#
3234        ));
3235        let mut message = Message::from_value(&json!({}));
3236
3237        executor(engine)
3238            .execute(&workflow, &mut message, Utc::now())
3239            .await
3240            .expect("loop should complete");
3241
3242        assert_eq!(counters(&message), vec![Some(10), Some(15), Some(20)]);
3243    }
3244
3245    #[tokio::test]
3246    async fn a_loop_without_a_named_counter_still_records_it_on_the_audit_trail() {
3247        let (workflow, engine) = compiled(
3248            r#"{ "id": "w", "name": "w", "loop": {"max": 2},
3249                 "tasks": [{"id": "t", "name": "t",
3250                            "function": {"name": "map", "input": {"mappings": []}}}] }"#,
3251        );
3252        let mut message = Message::from_value(&json!({}));
3253
3254        executor(engine)
3255            .execute(&workflow, &mut message, Utc::now())
3256            .await
3257            .expect("loop should complete");
3258
3259        assert_eq!(counters(&message), vec![Some(0), Some(1)]);
3260        // Nothing was written to temp_data — the counter was never named.
3261        assert_eq!(message.context["temp_data"], dv(json!({})));
3262    }
3263
3264    #[tokio::test]
3265    async fn a_non_looping_workflow_records_no_loop_counter() {
3266        let (workflow, engine) = compiled(
3267            r#"{ "id": "w", "name": "w",
3268                 "tasks": [{"id": "t", "name": "t",
3269                            "function": {"name": "map", "input": {"mappings": []}}}] }"#,
3270        );
3271        let mut message = Message::from_value(&json!({}));
3272
3273        executor(engine)
3274            .execute(&workflow, &mut message, Utc::now())
3275            .await
3276            .expect("should complete");
3277
3278        assert_eq!(counters(&message), vec![None]);
3279    }
3280
3281    #[tokio::test]
3282    async fn progress_metadata_is_written_on_every_sweep() {
3283        // `metadata.progress` is load-bearing for cross-workflow chaining; a
3284        // loop must not gate it.
3285        let (workflow, engine) = compiled(&format!(
3286            r#"{{ "id": "w", "name": "w", "loop": {{"counter": "i", "max": 3}},
3287                  "tasks": [{COUNTER_BODY}] }}"#
3288        ));
3289        let mut message = Message::from_value(&json!({}));
3290
3291        executor(engine)
3292            .execute(&workflow, &mut message, Utc::now())
3293            .await
3294            .expect("loop should complete");
3295
3296        let progress = message.context["metadata"]
3297            .get("progress")
3298            .expect("progress must be written");
3299        assert_eq!(progress.get("workflow_id"), Some(&dv(json!("w"))));
3300        assert_eq!(progress.get("task_id"), Some(&dv(json!("t"))));
3301        assert_eq!(progress.get("status_code"), Some(&dv(json!(200))));
3302    }
3303
3304    #[tokio::test]
3305    async fn the_engine_owns_the_counter_even_if_a_body_task_writes_it() {
3306        // A body task writing the counter path is overwritten at the next
3307        // increment, so termination reasoning stays local to LoopConfig.
3308        let (workflow, engine) = compiled(
3309            r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "max": 3},
3310                 "tasks": [{"id": "t", "name": "t", "function": {"name": "map",
3311                    "input": {"mappings": [{"path": "temp_data.i", "logic": 99}]}}}] }"#,
3312        );
3313        let mut message = Message::from_value(&json!({}));
3314
3315        executor(engine)
3316            .execute(&workflow, &mut message, Utc::now())
3317            .await
3318            .expect("loop should complete");
3319
3320        assert_eq!(
3321            counters(&message),
3322            vec![Some(0), Some(1), Some(2)],
3323            "the body's write must not stall or skew the loop"
3324        );
3325    }
3326
3327    /// Run a bare counting loop with the given bounds and return the counter
3328    /// values the sweeps actually recorded.
3329    async fn counter_sequence(init: i64, increment: i64, max: i64) -> Vec<Option<i64>> {
3330        let (workflow, engine) = compiled(&format!(
3331            r#"{{ "id": "w", "name": "w",
3332                  "loop": {{"counter": "i", "init": {init},
3333                            "increment": {increment}, "max": {max}}},
3334                  "tasks": [{{"id": "t", "name": "t",
3335                              "function": {{"name": "map", "input": {{"mappings": []}}}}}}] }}"#
3336        ));
3337        let mut message = Message::from_value(&json!({}));
3338        executor(engine)
3339            .execute(&workflow, &mut message, Utc::now())
3340            .await
3341            .expect("loop should complete");
3342        counters(&message)
3343    }
3344
3345    #[tokio::test]
3346    async fn counter_sequence_matrix_over_init_increment_and_max() {
3347        // The half-open `counter < max` bound, swept across signs and step
3348        // sizes. Each expected list is the exact sequence of sweeps.
3349        let cases: Vec<(i64, i64, i64, Vec<i64>)> = vec![
3350            // Defaults: 0-based, step 1 — the array-index case.
3351            (0, 1, 1, vec![0]),
3352            (0, 1, 2, vec![0, 1]),
3353            (0, 1, 5, vec![0, 1, 2, 3, 4]),
3354            // Non-unit steps, including a range the step does not divide.
3355            (0, 2, 6, vec![0, 2, 4]),
3356            (0, 3, 10, vec![0, 3, 6, 9]),
3357            (0, 5, 3, vec![0]),
3358            (0, 100, 1, vec![0]),
3359            // Non-zero starts.
3360            (10, 5, 25, vec![10, 15, 20]),
3361            (3, 1, 6, vec![3, 4, 5]),
3362            // Negative and mixed-sign ranges.
3363            (-3, 1, 2, vec![-3, -2, -1, 0, 1]),
3364            (-4, 2, 1, vec![-4, -2, 0]),
3365            (-10, 5, -5, vec![-10]),
3366        ];
3367
3368        for (init, increment, max, expected) in cases {
3369            let got = counter_sequence(init, increment, max).await;
3370            let expected: Vec<Option<i64>> = expected.into_iter().map(Some).collect();
3371            assert_eq!(got, expected, "init={init} increment={increment} max={max}");
3372        }
3373    }
3374
3375    #[tokio::test]
3376    async fn the_counter_advance_saturates_instead_of_overflowing() {
3377        // A huge increment must end the loop, not wrap into a negative counter
3378        // and spin. Both the giant-step and the near-i64::MAX start are
3379        // exercised, since either could overflow a plain `+`.
3380        assert_eq!(
3381            counter_sequence(0, i64::MAX, 5).await,
3382            vec![Some(0)],
3383            "one sweep, then the advance saturates past max"
3384        );
3385        assert_eq!(
3386            counter_sequence(i64::MAX - 1, 1, i64::MAX).await,
3387            vec![Some(i64::MAX - 1)],
3388            "the last representable sweep still terminates"
3389        );
3390        assert_eq!(
3391            counter_sequence(i64::MAX - 2, i64::MAX, i64::MAX).await,
3392            vec![Some(i64::MAX - 2)]
3393        );
3394    }
3395
3396    #[tokio::test]
3397    async fn a_task_condition_is_re_evaluated_against_the_counter_every_sweep() {
3398        // Per-sweep task conditions are the mechanism for "do this only on
3399        // some iterations"; a stale condition cache would break it.
3400        let (workflow, engine) = compiled(
3401            r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "max": 4},
3402                 "tasks": [
3403                   {"id": "evens", "name": "evens",
3404                    "condition": {"==": [{"%": [{"var": "temp_data.i"}, 2]}, 0]},
3405                    "function": {"name": "map", "input": {"mappings": []}}},
3406                   {"id": "always", "name": "always",
3407                    "function": {"name": "map", "input": {"mappings": []}}}] }"#,
3408        );
3409        let mut message = Message::from_value(&json!({}));
3410
3411        executor(engine)
3412            .execute(&workflow, &mut message, Utc::now())
3413            .await
3414            .expect("loop should complete");
3415
3416        let entries: Vec<(&str, Option<i64>)> = message
3417            .audit_trail
3418            .iter()
3419            .map(|e| (e.task_id.as_ref(), e.loop_counter))
3420            .collect();
3421        assert_eq!(
3422            entries,
3423            [
3424                ("evens", Some(0)),
3425                ("always", Some(0)),
3426                ("always", Some(1)),
3427                ("evens", Some(2)),
3428                ("always", Some(2)),
3429                ("always", Some(3)),
3430            ],
3431            "the gated task runs only on even counters"
3432        );
3433    }
3434
3435    #[tokio::test]
3436    async fn a_filter_skip_does_not_keep_the_loop_alive_or_record_entries() {
3437        // `TaskOutcome::Skip` records no audit entry and no progress write.
3438        // The loop is driven by its bound, not by whether tasks recorded
3439        // anything, so it still runs exactly `max` sweeps.
3440        let (workflow, engine) = compiled(
3441            r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "max": 3},
3442                 "tasks": [{"id": "gate", "name": "gate", "function": {"name": "filter",
3443                    "input": {"condition": false, "on_reject": "skip"}}}] }"#,
3444        );
3445        let mut message = Message::from_value(&json!({}));
3446
3447        let executed = executor(engine)
3448            .execute(&workflow, &mut message, Utc::now())
3449            .await
3450            .expect("skip is not an error");
3451
3452        assert!(executed, "sweeps ran even though every task skipped");
3453        assert!(message.audit_trail.is_empty(), "Skip records no entry");
3454        assert_eq!(
3455            message.context["temp_data"].get("i"),
3456            Some(&dv(json!(3))),
3457            "the loop still ran to its bound"
3458        );
3459    }
3460
3461    #[tokio::test]
3462    async fn a_4xx_task_status_is_recorded_per_sweep_without_stopping_the_loop() {
3463        // A failing `validation` yields 400: warned, recorded, loop continues.
3464        let (workflow, engine) = compiled(
3465            r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "max": 3},
3466                 "tasks": [{"id": "check", "name": "check", "function": {"name": "validation",
3467                    "input": {"rules": [{"logic": {"==": [1, 2]}, "message": "nope"}]}}}] }"#,
3468        );
3469        let mut message = Message::from_value(&json!({}));
3470
3471        executor(engine)
3472            .execute(&workflow, &mut message, Utc::now())
3473            .await
3474            .expect("a 4xx does not stop the workflow");
3475
3476        assert_eq!(counters(&message), vec![Some(0), Some(1), Some(2)]);
3477        assert!(
3478            message.audit_trail.iter().all(|e| e.status == 400),
3479            "every sweep recorded the 4xx"
3480        );
3481    }
3482
3483    #[tokio::test]
3484    async fn the_rollout_gate_excludes_a_looping_workflow_before_any_sweep() {
3485        // The gate runs ahead of the loop, so an excluded workflow writes no
3486        // counter at all — it must be indistinguishable from a plain skip.
3487        let (workflow, engine) = compiled(
3488            r#"{ "id": "w", "name": "w",
3489                 "rollout": {"bucket_start": 0, "bucket_end": 50},
3490                 "loop": {"counter": "i", "max": 5},
3491                 "tasks": [{"id": "t", "name": "t",
3492                            "function": {"name": "map", "input": {"mappings": []}}}] }"#,
3493        );
3494        let mut message = Message::builder().routing_bucket(75).build();
3495
3496        let executed = executor(engine)
3497            .execute(&workflow, &mut message, Utc::now())
3498            .await
3499            .expect("an excluded workflow is not an error");
3500
3501        assert!(!executed);
3502        assert!(message.audit_trail.is_empty());
3503        assert_eq!(
3504            message.context["temp_data"].get("i"),
3505            None,
3506            "no counter is written for an excluded workflow"
3507        );
3508    }
3509
3510    #[tokio::test]
3511    async fn a_nested_counter_path_is_created_and_advanced() {
3512        let (workflow, engine) = compiled(
3513            r#"{ "id": "w", "name": "w",
3514                 "loop": {"counter": "cursor.index", "max": 3},
3515                 "tasks": [{"id": "t", "name": "t", "function": {"name": "map",
3516                    "input": {"mappings": [
3517                       {"path": "data.seen", "logic": {"var": "temp_data.cursor.index"}}]}}}] }"#,
3518        );
3519        let mut message = Message::from_value(&json!({}));
3520
3521        executor(engine)
3522            .execute(&workflow, &mut message, Utc::now())
3523            .await
3524            .expect("loop should complete");
3525
3526        assert_eq!(
3527            message.context["temp_data"]["cursor"].get("index"),
3528            Some(&dv(json!(3)))
3529        );
3530        assert_eq!(
3531            message.context["data"].get("seen"),
3532            Some(&dv(json!(2))),
3533            "the body read the nested counter"
3534        );
3535    }
3536
3537    #[tokio::test]
3538    async fn writing_the_counter_preserves_unrelated_temp_data() {
3539        let (workflow, engine) = compiled(
3540            r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "max": 2},
3541                 "tasks": [{"id": "t", "name": "t",
3542                            "function": {"name": "map", "input": {"mappings": []}}}] }"#,
3543        );
3544        let mut message = Message::builder()
3545            .temp_data(dv(json!({"keep": "me", "nested": {"a": 1}})))
3546            .build();
3547
3548        executor(engine)
3549            .execute(&workflow, &mut message, Utc::now())
3550            .await
3551            .expect("loop should complete");
3552
3553        assert_eq!(
3554            message.context["temp_data"].get("keep"),
3555            Some(&dv(json!("me")))
3556        );
3557        assert_eq!(
3558            message.context["temp_data"]["nested"].get("a"),
3559            Some(&dv(json!(1)))
3560        );
3561        assert_eq!(message.context["temp_data"].get("i"), Some(&dv(json!(2))));
3562    }
3563
3564    #[tokio::test]
3565    async fn the_counter_overwrites_a_pre_existing_value_at_that_path() {
3566        // The engine owns the path: whatever was there before the loop is
3567        // replaced by `init` on the first sweep.
3568        let (workflow, engine) = compiled(
3569            r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "init": 5, "max": 7},
3570                 "tasks": [{"id": "t", "name": "t",
3571                            "function": {"name": "map", "input": {"mappings": []}}}] }"#,
3572        );
3573        let mut message = Message::builder()
3574            .temp_data(dv(json!({"i": "not a number"})))
3575            .build();
3576
3577        executor(engine)
3578            .execute(&workflow, &mut message, Utc::now())
3579            .await
3580            .expect("loop should complete");
3581
3582        assert_eq!(counters(&message), vec![Some(5), Some(6)]);
3583        assert_eq!(message.context["temp_data"].get("i"), Some(&dv(json!(7))));
3584    }
3585
3586    #[tokio::test]
3587    async fn a_loop_records_audit_entries_with_capture_changes_off() {
3588        // `capture_changes(false)` suppresses the per-change diff, not the
3589        // audit entries themselves — so the loop counter is still recorded.
3590        let (workflow, engine) = compiled(
3591            r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "max": 2},
3592                 "tasks": [{"id": "t", "name": "t", "function": {"name": "map",
3593                    "input": {"mappings": [
3594                       {"path": "data.n", "logic": {"var": "temp_data.i"}}]}}}] }"#,
3595        );
3596        let mut message = Message::builder().capture_changes(false).build();
3597
3598        executor(engine)
3599            .execute(&workflow, &mut message, Utc::now())
3600            .await
3601            .expect("loop should complete");
3602
3603        assert_eq!(counters(&message), vec![Some(0), Some(1)]);
3604        assert!(
3605            message.audit_trail.iter().all(|e| e.changes.is_empty()),
3606            "no diffs captured, but the entries are still there"
3607        );
3608    }
3609
3610    #[tokio::test]
3611    async fn two_loops_sharing_a_counter_name_do_not_interfere() {
3612        // Each loop re-initialises the path it owns, so the second starts from
3613        // its own `init` rather than inheriting where the first stopped.
3614        let first = r#"{ "id": "a", "name": "a", "priority": 0,
3615             "loop": {"counter": "i", "max": 2},
3616             "tasks": [{"id": "t", "name": "t",
3617                        "function": {"name": "map", "input": {"mappings": []}}}] }"#;
3618        let second = r#"{ "id": "b", "name": "b", "priority": 1,
3619             "loop": {"counter": "i", "init": 10, "max": 12},
3620             "tasks": [{"id": "t", "name": "t",
3621                        "function": {"name": "map", "input": {"mappings": []}}}] }"#;
3622
3623        let compiler = LogicCompiler::new();
3624        let workflows = compiler
3625            .compile_workflows(vec![
3626                Workflow::from_json(first).unwrap(),
3627                Workflow::from_json(second).unwrap(),
3628            ])
3629            .expect("should compile");
3630        let exec = executor(compiler.into_engine());
3631        let mut message = Message::from_value(&json!({}));
3632
3633        exec.run_all_borrowed(&workflows, &mut message, None, Utc::now())
3634            .await
3635            .expect("both loops should complete");
3636
3637        let per_workflow: Vec<(&str, Option<i64>)> = message
3638            .audit_trail
3639            .iter()
3640            .map(|e| (e.workflow_id.as_ref(), e.loop_counter))
3641            .collect();
3642        assert_eq!(
3643            per_workflow,
3644            [
3645                ("a", Some(0)),
3646                ("a", Some(1)),
3647                ("b", Some(10)),
3648                ("b", Some(11)),
3649            ]
3650        );
3651    }
3652
3653    #[tokio::test]
3654    async fn a_looping_workflow_between_sync_workflows_does_not_break_the_sync_run() {
3655        // Regression guard for the `joins_sync_run` change: a loop workflow is
3656        // excluded from the shared-arena run, which must split the run around
3657        // it rather than dropping its neighbours.
3658        let sync_wf = |id: &str, priority: u32| {
3659            format!(
3660                r#"{{ "id": "{id}", "name": "{id}", "priority": {priority},
3661                      "tasks": [{{"id": "t", "name": "t", "function": {{"name": "map",
3662                        "input": {{"mappings": [
3663                          {{"path": "data.{id}", "logic": true}}]}}}}}}] }}"#
3664            )
3665        };
3666        let loop_wf = r#"{ "id": "mid", "name": "mid", "priority": 1,
3667             "loop": {"counter": "i", "max": 2},
3668             "tasks": [{"id": "t", "name": "t", "function": {"name": "map",
3669                "input": {"mappings": [{"path": "data.mid", "logic": true}]}}}] }"#;
3670
3671        let compiler = LogicCompiler::new();
3672        let workflows = compiler
3673            .compile_workflows(vec![
3674                Workflow::from_json(&sync_wf("before", 0)).unwrap(),
3675                Workflow::from_json(loop_wf).unwrap(),
3676                Workflow::from_json(&sync_wf("after", 2)).unwrap(),
3677            ])
3678            .expect("should compile");
3679        // All three are sync-only, but the loop must not join a shared run.
3680        assert!(workflows.iter().all(|w| w.fully_sync));
3681        assert!(!joins_sync_run(&workflows[1]));
3682
3683        let exec = executor(compiler.into_engine());
3684        let mut message = Message::from_value(&json!({}));
3685
3686        exec.run_all_borrowed(&workflows, &mut message, None, Utc::now())
3687            .await
3688            .expect("all three should run");
3689
3690        for id in ["before", "mid", "after"] {
3691            assert_eq!(
3692                message.context["data"].get(id),
3693                Some(&dv(json!(true))),
3694                "workflow {id} must have run"
3695            );
3696        }
3697        let order: Vec<(&str, Option<i64>)> = message
3698            .audit_trail
3699            .iter()
3700            .map(|e| (e.workflow_id.as_ref(), e.loop_counter))
3701            .collect();
3702        assert_eq!(
3703            order,
3704            [
3705                ("before", None),
3706                ("mid", Some(0)),
3707                ("mid", Some(1)),
3708                ("after", None),
3709            ],
3710            "priority order is preserved across the split"
3711        );
3712    }
3713
3714    #[tokio::test]
3715    async fn consecutive_non_looping_sync_workflows_still_share_one_run() {
3716        // The other half of the same regression: without a loop in the way,
3717        // every fully-sync workflow still groups as it always did.
3718        let compiler = LogicCompiler::new();
3719        let workflows = compiler
3720            .compile_workflows(vec![
3721                Workflow::from_json(
3722                    r#"{ "id": "a", "name": "a", "priority": 0, "tasks": [{"id": "t", "name": "t",
3723                         "function": {"name": "map", "input": {"mappings": [
3724                           {"path": "data.a", "logic": 1}]}}}] }"#,
3725                )
3726                .unwrap(),
3727                Workflow::from_json(
3728                    r#"{ "id": "b", "name": "b", "priority": 1,
3729                         "condition": {"==": [{"var": "data.a"}, 1]},
3730                         "tasks": [{"id": "t", "name": "t",
3731                         "function": {"name": "map", "input": {"mappings": [
3732                           {"path": "data.b", "logic": 2}]}}}] }"#,
3733                )
3734                .unwrap(),
3735            ])
3736            .expect("should compile");
3737        assert!(workflows.iter().all(joins_sync_run));
3738
3739        let exec = executor(compiler.into_engine());
3740        let mut message = Message::from_value(&json!({}));
3741        exec.run_all_borrowed(&workflows, &mut message, None, Utc::now())
3742            .await
3743            .expect("both should run");
3744
3745        // `b`'s condition reads what `a` wrote, which only works if the shared
3746        // arena context was refreshed across the workflow boundary.
3747        assert_eq!(message.context["data"].get("b"), Some(&dv(json!(2))));
3748        assert_eq!(counters(&message), vec![None, None]);
3749    }
3750
3751    #[tokio::test]
3752    async fn a_loop_body_can_index_an_array_by_its_counter() {
3753        // The per-item pattern, using only core operators.
3754        let (workflow, engine) = compiled(
3755            r#"{ "id": "w", "name": "w", "loop": {"counter": "i", "max": 3},
3756                 "tasks": [{"id": "pick", "name": "pick", "function": {"name": "map",
3757                    "input": {"mappings": [
3758                       {"path": "data.picked",
3759                        "logic": {"merge": [{"var": "data.picked"},
3760                                            [{"val": [["data", "items",
3761                                                       {"var": "temp_data.i"}]]}]]}}]}}}] }"#,
3762        );
3763        let mut message = Message::builder()
3764            .data(dv(json!({"items": ["a", "b", "c"], "picked": []})))
3765            .build();
3766
3767        executor(engine)
3768            .execute(&workflow, &mut message, Utc::now())
3769            .await
3770            .expect("loop should complete");
3771
3772        assert_eq!(
3773            serde_json::Value::from(&message.context["data"]["picked"]),
3774            json!(["a", "b", "c"]),
3775            "each sweep appended the item at its own index"
3776        );
3777    }
3778
3779    #[tokio::test]
3780    async fn test_workflow_executor_skip_condition() {
3781        // Create a workflow with a false condition
3782        let workflow_json = r#"{
3783            "id": "test_workflow",
3784            "name": "Test Workflow",
3785            "condition": false,
3786            "tasks": [{
3787                "id": "dummy_task",
3788                "name": "Dummy Task",
3789                "function": {
3790                    "name": "map",
3791                    "input": {"mappings": []}
3792                }
3793            }]
3794        }"#;
3795
3796        let compiler = LogicCompiler::new();
3797        let mut workflow = Workflow::from_json(workflow_json).unwrap();
3798
3799        // Compile the workflow condition
3800        let workflows = compiler.compile_workflows(vec![workflow.clone()]).unwrap();
3801        if let Some(compiled_workflow) = workflows.iter().find(|w| w.id == "test_workflow") {
3802            workflow = compiled_workflow.clone();
3803        }
3804
3805        let engine = compiler.into_engine();
3806        let task_executor = Arc::new(TaskExecutor::new(
3807            Arc::new(HashMap::new()),
3808            Arc::clone(&engine),
3809        ));
3810        let workflow_executor = WorkflowExecutor::new(task_executor, engine);
3811
3812        let mut message = Message::from_value(&json!({}));
3813
3814        // Execute workflow - should be skipped due to false condition
3815        let executed = workflow_executor
3816            .execute(&workflow, &mut message, Utc::now())
3817            .await
3818            .unwrap();
3819        assert!(!executed);
3820        assert_eq!(message.audit_trail.len(), 0);
3821    }
3822
3823    #[tokio::test]
3824    async fn test_workflow_executor_execute_success() {
3825        // Create a workflow with a true condition
3826        let workflow_json = r#"{
3827            "id": "test_workflow",
3828            "name": "Test Workflow",
3829            "condition": true,
3830            "tasks": [{
3831                "id": "dummy_task",
3832                "name": "Dummy Task",
3833                "function": {
3834                    "name": "map",
3835                    "input": {"mappings": []}
3836                }
3837            }]
3838        }"#;
3839
3840        let compiler = LogicCompiler::new();
3841        let mut workflow = Workflow::from_json(workflow_json).unwrap();
3842
3843        // Compile the workflow
3844        let workflows = compiler.compile_workflows(vec![workflow.clone()]).unwrap();
3845        if let Some(compiled_workflow) = workflows.iter().find(|w| w.id == "test_workflow") {
3846            workflow = compiled_workflow.clone();
3847        }
3848
3849        let engine = compiler.into_engine();
3850        let task_executor = Arc::new(TaskExecutor::new(
3851            Arc::new(HashMap::new()),
3852            Arc::clone(&engine),
3853        ));
3854        let workflow_executor = WorkflowExecutor::new(task_executor, engine);
3855
3856        let mut message = Message::from_value(&json!({}));
3857
3858        // Execute workflow - should succeed with empty task list
3859        let executed = workflow_executor
3860            .execute(&workflow, &mut message, Utc::now())
3861            .await
3862            .unwrap();
3863        assert!(executed);
3864    }
3865}