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