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