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