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