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