Skip to main content

dataflow_rs/engine/
workflow_executor.rs

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