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};
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::task::Task;
13use crate::engine::task_executor::TaskExecutor;
14use crate::engine::task_outcome::TaskOutcome;
15use crate::engine::trace::{ExecutionStep, ExecutionTrace};
16use crate::engine::utils::set_nested_value;
17use crate::engine::workflow::Workflow;
18use chrono::{DateTime, Utc};
19use datalogic_rs::Engine;
20use datavalue::OwnedDataValue;
21use log::{debug, error, info, warn};
22use serde_json::Value;
23use std::collections::HashMap;
24use std::sync::Arc;
25
26/// Result of handling a task, including possible control flow signals
27enum TaskControlFlow {
28    /// Continue executing the next task
29    Continue,
30    /// Stop executing further tasks in this workflow (filter halt)
31    HaltWorkflow,
32}
33
34/// Return the index of the first task at or after `start` that is *not* a
35/// synchronous built-in. Used to chunk `workflow.tasks` into sync-only
36/// stretches that can share a single `ArenaContext`.
37fn next_async_boundary(tasks: &[Task], start: usize) -> usize {
38    let mut i = start;
39    while i < tasks.len() && tasks[i].function.is_sync_builtin() {
40        i += 1;
41    }
42    i
43}
44
45/// Build a fresh `metadata.progress` object value.
46fn new_progress_object(workflow_id: &str, task_id: &str, status: u16) -> OwnedDataValue {
47    OwnedDataValue::Object(vec![
48        (
49            "workflow_id".to_string(),
50            OwnedDataValue::String(workflow_id.to_string()),
51        ),
52        (
53            "task_id".to_string(),
54            OwnedDataValue::String(task_id.to_string()),
55        ),
56        (
57            "status_code".to_string(),
58            OwnedDataValue::from(u64::from(status)),
59        ),
60    ])
61}
62
63/// Overwrite the three fields of an existing 3-key `progress` object without
64/// reallocating it. Returns `false` when the object's shape diverges from
65/// `{workflow_id, task_id, status_code}`, in which case the caller replaces
66/// the slot wholesale (partial overwrites here are harmless — the whole slot
67/// gets replaced).
68fn overwrite_progress_in_place(
69    fields: &mut [(String, OwnedDataValue)],
70    workflow_id: &str,
71    task_id: &str,
72    status: u16,
73) -> bool {
74    if fields.len() != 3 {
75        return false;
76    }
77    let mut matched = 0;
78    for (k, v) in fields.iter_mut() {
79        match k.as_str() {
80            "workflow_id" => {
81                *v = OwnedDataValue::String(workflow_id.to_string());
82                matched += 1;
83            }
84            "task_id" => {
85                *v = OwnedDataValue::String(task_id.to_string());
86                matched += 1;
87            }
88            "status_code" => {
89                *v = OwnedDataValue::from(u64::from(status));
90                matched += 1;
91            }
92            _ => {}
93        }
94    }
95    matched == 3
96}
97
98/// Write `metadata.progress = {workflow_id, task_id, status_code}` with a
99/// single tree walk. From the second task of a message onward the slot
100/// already holds the expected 3-key object, so the three values are
101/// overwritten in place — no Vec/Object/key-`String` allocations, just the
102/// two unavoidable id `String`s. First write (or any shape divergence)
103/// replaces the slot wholesale; a context whose `metadata` is missing or
104/// non-Object falls back to the generic `set_nested_value` writer, which
105/// creates intermediate containers as needed.
106fn write_progress_metadata(
107    context: &mut OwnedDataValue,
108    workflow_id: &str,
109    task_id: &str,
110    status: u16,
111) {
112    // Nested `if let` rather than a let-chain: let-chains are stable only from
113    // Rust 1.88 and this crate's MSRV is 1.85. Keep it that way.
114    if let OwnedDataValue::Object(top) = context {
115        if let Some((_, OwnedDataValue::Object(meta))) =
116            top.iter_mut().find(|(k, _)| k == "metadata")
117        {
118            match meta.iter_mut().find(|(k, _)| k == "progress") {
119                Some((_, slot)) => {
120                    if let OwnedDataValue::Object(fields) = slot {
121                        if overwrite_progress_in_place(fields, workflow_id, task_id, status) {
122                            return;
123                        }
124                    }
125                    *slot = new_progress_object(workflow_id, task_id, status);
126                }
127                None => {
128                    meta.push((
129                        "progress".to_string(),
130                        new_progress_object(workflow_id, task_id, status),
131                    ));
132                }
133            }
134            return;
135        }
136    }
137    set_nested_value(
138        context,
139        "metadata.progress",
140        new_progress_object(workflow_id, task_id, status),
141    );
142}
143
144/// Handles the execution of workflows and their tasks
145///
146/// The `WorkflowExecutor` is responsible for:
147/// - Evaluating workflow conditions
148/// - Orchestrating task execution within workflows
149/// - Managing workflow-level error handling
150/// - Recording audit trails
151pub struct WorkflowExecutor {
152    /// Task executor for executing individual tasks
153    task_executor: Arc<TaskExecutor>,
154    /// Shared datalogic engine for condition evaluation
155    engine: Arc<Engine>,
156}
157
158impl WorkflowExecutor {
159    /// Create a new WorkflowExecutor
160    pub fn new(task_executor: Arc<TaskExecutor>, engine: Arc<Engine>) -> Self {
161        Self {
162            task_executor,
163            engine,
164        }
165    }
166
167    /// Get a clone of the task_functions Arc for reuse in new engines
168    pub fn task_functions(&self) -> Arc<HashMap<String, BoxedFunctionHandler>> {
169        self.task_executor.task_functions()
170    }
171
172    /// Execute a workflow if its condition is met
173    ///
174    /// This method:
175    /// 1. Evaluates the workflow condition
176    /// 2. Executes tasks sequentially if condition is met
177    /// 3. Handles error recovery based on workflow configuration
178    /// 4. Updates message metadata and audit trail
179    ///
180    /// # Arguments
181    /// * `workflow` - The workflow to execute
182    /// * `message` - The message being processed
183    ///
184    /// # Returns
185    /// * `Result<bool>` - Ok(true) if workflow was executed, Ok(false) if skipped, Err on failure
186    pub async fn execute(
187        &self,
188        workflow: &Workflow,
189        message: &mut Message,
190        now: DateTime<Utc>,
191    ) -> Result<bool> {
192        self.execute_inner(workflow, message, None, now).await
193    }
194
195    /// Execute a workflow with step-by-step tracing
196    ///
197    /// Similar to `execute` but records execution steps for debugging.
198    pub async fn execute_with_trace(
199        &self,
200        workflow: &Workflow,
201        message: &mut Message,
202        trace: &mut ExecutionTrace,
203        now: DateTime<Utc>,
204    ) -> Result<bool> {
205        self.execute_inner(workflow, message, Some(trace), now)
206            .await
207    }
208
209    /// Unified workflow-condition + task-loop driver. `trace` is `None` for
210    /// the production path and `Some(&mut trace)` for the debug path —
211    /// stepping is the only behavioural difference between them.
212    ///
213    /// The workflow condition is folded into the *first* sync stretch's arena
214    /// scope: one `ArenaContext::from_owned` walk serves both the condition
215    /// eval and the leading run of sync built-in tasks. The owned path
216    /// (`eval_to_owned`) deep-borrowed the entire context — including the
217    /// heavy `data.input` payload — for the condition, and `execute_tasks`
218    /// then walked the same context again to build the first stretch's arena
219    /// form. Mixed sync+async workflows now pay one walk where they paid two.
220    /// No `.await` occurs inside the scope, preserving the `!Send` arena
221    /// invariant.
222    async fn execute_inner(
223        &self,
224        workflow: &Workflow,
225        message: &mut Message,
226        mut trace: Option<&mut ExecutionTrace>,
227        now: DateTime<Utc>,
228    ) -> Result<bool> {
229        /// Outcome of the folded condition-plus-first-stretch arena scope.
230        enum FirstStretch {
231            /// Workflow condition evaluated false — skip the workflow.
232            Skipped,
233            /// A filter task halted the workflow inside the first stretch.
234            Halted,
235            /// Continue with the remaining tasks (from the first async
236            /// boundary onward).
237            Continue,
238        }
239
240        let tasks = &workflow.tasks;
241        let first_boundary = next_async_boundary(tasks, 0);
242
243        let first: Result<FirstStretch> =
244            if workflow.compiled_condition.is_none() && first_boundary == 0 {
245                // No condition and the workflow leads with an async task —
246                // nothing to fold; don't build an arena context for nothing.
247                Ok(FirstStretch::Continue)
248            } else {
249                with_arena(|arena| -> Result<FirstStretch> {
250                    let mut arena_ctx = ArenaContext::from_owned(&message.context, arena);
251
252                    let should_execute = match workflow.compiled_condition.as_ref() {
253                        None => true,
254                        Some(compiled) => evaluate_condition_in_arena(
255                            &self.engine,
256                            Some(compiled),
257                            arena_ctx.as_data_value(),
258                            arena,
259                        )?,
260                    };
261                    if !should_execute {
262                        return Ok(FirstStretch::Skipped);
263                    }
264                    if first_boundary == 0 {
265                        return Ok(FirstStretch::Continue);
266                    }
267                    let halted = self.run_tasks_slice_in_arena(
268                        &tasks[..first_boundary],
269                        workflow,
270                        message,
271                        &mut arena_ctx,
272                        trace.as_deref_mut(),
273                        now,
274                    )?;
275                    Ok(if halted {
276                        FirstStretch::Halted
277                    } else {
278                        FirstStretch::Continue
279                    })
280                })
281            };
282
283        // Drive the remaining (async-containing) tail, then apply the single
284        // workflow-level error contract to whichever half failed.
285        let run_result: Result<()> = match first {
286            Ok(FirstStretch::Skipped) => {
287                debug!("Skipping workflow {} - condition not met", workflow.id);
288                if let Some(t) = trace.as_deref_mut() {
289                    t.add_step(ExecutionStep::workflow_skipped(&workflow.id));
290                }
291                return Ok(false);
292            }
293            Ok(FirstStretch::Halted) => Ok(()),
294            Ok(FirstStretch::Continue) => {
295                self.execute_tasks(workflow, message, trace, now, first_boundary)
296                    .await
297            }
298            Err(e) => Err(e),
299        };
300
301        match run_result {
302            Ok(_) => {
303                info!("Successfully completed workflow: {}", workflow.id);
304                Ok(true)
305            }
306            Err(e) => {
307                // Single-channel contract: every error appears in
308                // `message.errors`. The `Result::Err` return only signals to
309                // the caller that we stopped before processing further
310                // workflows. The workflow-level wrapper records workflow
311                // context that the underlying task error doesn't carry.
312                message.errors.push(
313                    ErrorInfo::builder(
314                        "WORKFLOW_ERROR",
315                        format!("Workflow {} error: {}", workflow.id, e),
316                    )
317                    .workflow_id(&workflow.id)
318                    .build(),
319                );
320
321                if workflow.continue_on_error {
322                    warn!(
323                        "Workflow {} encountered error but continuing: {:?}",
324                        workflow.id, e
325                    );
326                    Ok(true)
327                } else {
328                    error!("Workflow {} failed: {:?}", workflow.id, e);
329                    Err(e)
330                }
331            }
332        }
333    }
334
335    /// Execute the tasks of a workflow from index `start` onward.
336    ///
337    /// Groups consecutive synchronous built-in tasks into a single
338    /// `with_arena` scope so the arena form of `message.context` is built
339    /// once at the start of the stretch and reused across `parse_json`,
340    /// `map`, `validation`, `log`, and `filter`. Async tasks (HTTP, Kafka,
341    /// custom handlers) break the stretch — the arena flushes any pending
342    /// state back to `OwnedDataValue` automatically (since each sync task
343    /// already mutates `message.context` in place) and the next stretch
344    /// rebuilds the arena form.
345    ///
346    /// `start` is non-zero when `execute_inner` already ran the leading sync
347    /// stretch inside the folded condition scope.
348    ///
349    /// When `trace` is `Some`, the loop also records `ExecutionStep` entries
350    /// after each task (skipped/executed) including per-mapping snapshots
351    /// for `Map` tasks.
352    async fn execute_tasks(
353        &self,
354        workflow: &Workflow,
355        message: &mut Message,
356        mut trace: Option<&mut ExecutionTrace>,
357        now: DateTime<Utc>,
358        start: usize,
359    ) -> Result<()> {
360        let tasks = &workflow.tasks;
361        let mut idx = start;
362        while idx < tasks.len() {
363            let stretch_end = next_async_boundary(tasks, idx);
364
365            if stretch_end > idx {
366                // Run [idx, stretch_end) as a sync stretch inside one arena.
367                let halt = self.run_sync_stretch(
368                    &tasks[idx..stretch_end],
369                    workflow,
370                    message,
371                    trace.as_deref_mut(),
372                    now,
373                )?;
374                if halt {
375                    return Ok(());
376                }
377                idx = stretch_end;
378            }
379
380            if idx < tasks.len() {
381                // Single async task (or non-sync-builtin) at `idx`.
382                let task = &tasks[idx];
383                let should_execute = evaluate_condition(
384                    &self.engine,
385                    task.compiled_condition.as_ref(),
386                    &message.context,
387                )?;
388
389                if !should_execute {
390                    debug!("Skipping task {} - condition not met", task.id);
391                    if let Some(t) = trace.as_deref_mut() {
392                        t.add_step(ExecutionStep::task_skipped(&workflow.id, &task.id));
393                    }
394                    idx += 1;
395                    continue;
396                }
397
398                let result = self.task_executor.execute(task, message).await;
399                let control_flow = self.handle_task_result(
400                    result,
401                    &workflow.id_arc,
402                    &task.id_arc,
403                    task.continue_on_error,
404                    message,
405                    now,
406                )?;
407
408                // Async tasks at the boundary have no per-mapping snapshots —
409                // they're either HTTP/Kafka/Enrich or a custom handler.
410                if let Some(t) = trace.as_deref_mut() {
411                    t.add_step(ExecutionStep::executed(&workflow.id, &task.id, message));
412                }
413
414                if matches!(control_flow, TaskControlFlow::HaltWorkflow) {
415                    return Ok(());
416                }
417                idx += 1;
418            }
419        }
420
421        Ok(())
422    }
423
424    /// Execute a contiguous run of sync-builtin tasks inside one
425    /// `with_arena` scope. The arena context is built once at the start and
426    /// refreshed in place after each mutating task. Returns `Ok(true)` if a
427    /// filter task halted the workflow.
428    ///
429    /// This is the single-workflow entry; the cross-workflow path
430    /// (`execute_sync_workflow_run`) shares the same task loop via
431    /// `run_tasks_slice_in_arena` but carries one `ArenaContext` across several
432    /// workflows.
433    fn run_sync_stretch(
434        &self,
435        tasks: &[Task],
436        workflow: &Workflow,
437        message: &mut Message,
438        trace: Option<&mut ExecutionTrace>,
439        now: DateTime<Utc>,
440    ) -> Result<bool> {
441        with_arena(|arena| -> Result<bool> {
442            let mut arena_ctx = ArenaContext::from_owned(&message.context, arena);
443            self.run_tasks_slice_in_arena(tasks, workflow, message, &mut arena_ctx, trace, now)
444        })
445    }
446
447    /// Run `tasks` against an already-built `ArenaContext`, evaluating each
448    /// task's condition in-arena and refreshing the cache after each mutating
449    /// task. Returns `Ok(true)` if a filter task halted the workflow.
450    ///
451    /// Factored out of `run_sync_stretch` so both the single-workflow stretch
452    /// and the cross-workflow shared-arena run (`execute_sync_workflow_run`)
453    /// share one implementation. The caller owns the `ArenaContext` lifetime,
454    /// so the cross-workflow path can reuse the same arena form of
455    /// `message.context` across consecutive workflows instead of rebuilding it.
456    fn run_tasks_slice_in_arena<'arena>(
457        &self,
458        tasks: &'arena [Task],
459        workflow: &Workflow,
460        message: &mut Message,
461        arena_ctx: &mut ArenaContext<'arena>,
462        mut trace: Option<&mut ExecutionTrace>,
463        now: DateTime<Utc>,
464    ) -> Result<bool> {
465        let arena = arena_ctx.arena();
466
467        for task in tasks {
468            // Task condition — evaluate against the arena form so we don't
469            // re-borrow the thread-local `RefCell`. A `None` compiled
470            // condition (compiler folds the default literal `true` to
471            // `None`) skips both the eval and the per-task arena context
472            // slice build.
473            let should_execute = match task.compiled_condition.as_ref() {
474                None => true,
475                Some(compiled) => evaluate_condition_in_arena(
476                    &self.engine,
477                    Some(compiled),
478                    arena_ctx.as_data_value(),
479                    arena,
480                )?,
481            };
482
483            if !should_execute {
484                debug!("Skipping task {} - condition not met", task.id);
485                if let Some(t) = trace.as_deref_mut() {
486                    t.add_step(ExecutionStep::task_skipped(&workflow.id, &task.id));
487                }
488                continue;
489            }
490
491            // Per-task snapshot buffer — only used for Map tasks in trace
492            // mode. Allocating an empty Vec is cheap and the buffer stays
493            // empty for non-Map tasks.
494            let mut mapping_snapshots: Vec<Value> = Vec::new();
495            let snapshot_buf = if trace.is_some() {
496                Some(&mut mapping_snapshots)
497            } else {
498                None
499            };
500            let result = self.execute_sync_task_in_arena(task, message, arena_ctx, snapshot_buf);
501
502            let control_flow = self.handle_task_result(
503                result,
504                &workflow.id_arc,
505                &task.id_arc,
506                task.continue_on_error,
507                message,
508                now,
509            )?;
510
511            // The only context write `handle_task_result` performs is
512            // `metadata.progress`. Refresh exactly that depth-2 slot so the
513            // next task — and, in the cross-workflow path, the next
514            // workflow's condition — sees it, without re-arenaing unrelated
515            // metadata children (mapped `metadata.routing.*`, chained
516            // workflow state, …) after every task.
517            arena_ctx.refresh_for_path(&message.context, "metadata.progress");
518
519            if let Some(t) = trace.as_deref_mut() {
520                let mut step = ExecutionStep::executed(&workflow.id, &task.id, message);
521                if !mapping_snapshots.is_empty() {
522                    step = step.with_mapping_contexts(mapping_snapshots);
523                }
524                t.add_step(step);
525            }
526
527            if matches!(control_flow, TaskControlFlow::HaltWorkflow) {
528                return Ok(true);
529            }
530        }
531        Ok(false)
532    }
533
534    /// Drive a message through `workflows` in order, grouping maximal runs of
535    /// consecutive `fully_sync` workflows into a single shared-arena scope
536    /// (`execute_sync_workflow_run`) and falling back to the per-workflow
537    /// `.await` path (`execute_inner`) for any workflow containing an async
538    /// task. This is the single orchestration entry for all four
539    /// `Engine::process_message*` variants.
540    pub async fn run_all(
541        &self,
542        workflows: &[&Workflow],
543        message: &mut Message,
544        trace: Option<&mut ExecutionTrace>,
545        now: DateTime<Utc>,
546    ) -> Result<()> {
547        self.run_all_borrowed(workflows, message, trace, now).await
548    }
549
550    /// Generic driver behind [`Self::run_all`]: accepts any slice whose
551    /// elements borrow as `Workflow` — `&[Workflow]` directly from the
552    /// engine's registry (no per-message `Vec<&Workflow>` collect) or the
553    /// `&[&Workflow]` shape the public entry keeps for compatibility.
554    pub(crate) async fn run_all_borrowed<W: std::borrow::Borrow<Workflow>>(
555        &self,
556        workflows: &[W],
557        message: &mut Message,
558        mut trace: Option<&mut ExecutionTrace>,
559        now: DateTime<Utc>,
560    ) -> Result<()> {
561        let mut i = 0;
562        while i < workflows.len() {
563            if workflows[i].borrow().fully_sync {
564                // Extend over the maximal run of consecutive fully-sync
565                // workflows and execute them in one shared arena scope.
566                let mut j = i + 1;
567                while j < workflows.len() && workflows[j].borrow().fully_sync {
568                    j += 1;
569                }
570                self.execute_sync_workflow_run(
571                    &workflows[i..j],
572                    message,
573                    trace.as_deref_mut(),
574                    now,
575                )?;
576                i = j;
577            } else {
578                // Mixed sync+async (or fully-async) workflow: the existing
579                // driver interleaves per-stretch arenas with `.await`.
580                self.execute_inner(workflows[i].borrow(), message, trace.as_deref_mut(), now)
581                    .await?;
582                i += 1;
583            }
584        }
585        Ok(())
586    }
587
588    /// Execute a maximal run of consecutive fully-sync workflows inside ONE
589    /// shared `with_arena` scope. The message context is deep-walked into the
590    /// arena once for the whole run, then carried — with the existing
591    /// incremental `refresh_for_path` after each mutating task — across
592    /// workflow boundaries, instead of being rebuilt per workflow.
593    ///
594    /// Per-workflow semantics are preserved exactly: each workflow's condition
595    /// is evaluated (in-arena), a false condition skips only that workflow, a
596    /// filter-halt stops only that workflow, and task errors are wrapped with
597    /// the workflow id and honor `continue_on_error` (continue, or propagate
598    /// `Err` out of the run to stop the whole message) — mirroring
599    /// `execute_inner`.
600    ///
601    /// **Tokio safety:** this method is synchronous and the `fully_sync`
602    /// precondition guarantees every task is a sync built-in, so no `.await`
603    /// occurs while the `!Send` arena borrow is live. The borrow checker
604    /// enforces this — the shared `ArenaContext` cannot escape the closure.
605    fn execute_sync_workflow_run<W: std::borrow::Borrow<Workflow>>(
606        &self,
607        workflows: &[W],
608        message: &mut Message,
609        mut trace: Option<&mut ExecutionTrace>,
610        now: DateTime<Utc>,
611    ) -> Result<()> {
612        with_arena(|arena| -> Result<()> {
613            let mut arena_ctx = ArenaContext::from_owned(&message.context, arena);
614
615            for workflow in workflows {
616                let workflow: &Workflow = workflow.borrow();
617                // Workflow condition in-arena: a folded `None` skips the eval;
618                // a real condition reuses the carried context instead of the
619                // owned-path `eval_to_owned` deep-walk.
620                let should_execute = match workflow.compiled_condition.as_ref() {
621                    None => true,
622                    Some(compiled) => evaluate_condition_in_arena(
623                        &self.engine,
624                        Some(compiled),
625                        arena_ctx.as_data_value(),
626                        arena,
627                    )?,
628                };
629
630                if !should_execute {
631                    debug!("Skipping workflow {} - condition not met", workflow.id);
632                    if let Some(t) = trace.as_deref_mut() {
633                        t.add_step(ExecutionStep::workflow_skipped(&workflow.id));
634                    }
635                    continue;
636                }
637
638                match self.run_tasks_slice_in_arena(
639                    &workflow.tasks,
640                    workflow,
641                    message,
642                    &mut arena_ctx,
643                    trace.as_deref_mut(),
644                    now,
645                ) {
646                    // Filter-halt stops only this workflow; carry on with the
647                    // next one (and keep the shared arena context).
648                    Ok(_halted) => {
649                        info!("Successfully completed workflow: {}", workflow.id);
650                    }
651                    Err(e) => {
652                        // Single-channel contract — mirror `execute_inner`:
653                        // record the workflow-context error to `message.errors`
654                        // and honor `continue_on_error`.
655                        message.errors.push(
656                            ErrorInfo::builder(
657                                "WORKFLOW_ERROR",
658                                format!("Workflow {} error: {}", workflow.id, e),
659                            )
660                            .workflow_id(&workflow.id)
661                            .build(),
662                        );
663
664                        if workflow.continue_on_error {
665                            warn!(
666                                "Workflow {} encountered error but continuing: {:?}",
667                                workflow.id, e
668                            );
669                        } else {
670                            error!("Workflow {} failed: {:?}", workflow.id, e);
671                            return Err(e);
672                        }
673                    }
674                }
675            }
676            Ok(())
677        })
678    }
679
680    /// Dispatch a single sync-builtin task via the consolidated
681    /// `FunctionConfig::try_execute_in_arena`. `next_async_boundary` guarantees
682    /// the stretch contents are sync built-ins, so the `None` arm is
683    /// unreachable in practice.
684    ///
685    /// `map_snapshot_buf` is only consulted by the `Map` variant; non-Map
686    /// sync builtins ignore it. Pass `None` from the production path.
687    fn execute_sync_task_in_arena<'arena>(
688        &self,
689        task: &'arena Task,
690        message: &mut Message,
691        arena_ctx: &mut ArenaContext<'arena>,
692        map_snapshot_buf: Option<&mut Vec<Value>>,
693    ) -> Result<(TaskOutcome, Vec<Change>)> {
694        debug!(
695            "Executing sync task in arena: {} ({})",
696            task.id,
697            task.function.function_name()
698        );
699        debug_assert!(
700            task.function.is_sync_builtin(),
701            "execute_sync_task_in_arena called with non-sync-builtin task: {}",
702            task.function.function_name()
703        );
704        // In debug builds the assert above catches mis-dispatch; in release
705        // we still surface the invariant violation as a recoverable engine
706        // error rather than panicking via `unreachable!`.
707        task.function
708            .try_execute_in_arena(message, arena_ctx, &self.engine, map_snapshot_buf)
709            .ok_or_else(|| {
710                DataflowError::Task(format!(
711                    "execute_sync_task_in_arena dispatched to non-sync-builtin task '{}' \
712                     (engine bug — sync-stretch should only contain sync-builtin tasks)",
713                    task.function.function_name()
714                ))
715            })?
716    }
717
718    /// Handle the result of a task execution.
719    ///
720    /// `workflow_id_arc` and `task_id_arc` are the compile-time cached
721    /// `Arc<str>` mirrors of `workflow.id` / `task.id`; we Arc-clone them into
722    /// each `AuditTrail` rather than reallocating from the `&str` form.
723    fn handle_task_result(
724        &self,
725        result: Result<(TaskOutcome, Vec<Change>)>,
726        workflow_id_arc: &Arc<str>,
727        task_id_arc: &Arc<str>,
728        continue_on_error: bool,
729        message: &mut Message,
730        now: DateTime<Utc>,
731    ) -> Result<TaskControlFlow> {
732        let workflow_id: &str = workflow_id_arc;
733        let task_id: &str = task_id_arc;
734        match result {
735            Ok((TaskOutcome::Skip, _)) => {
736                // No audit trail, no progress write — task has explicitly opted
737                // out (filter gate set to `Skip`).
738                debug!("Task {} signaled skip", task_id);
739                Ok(TaskControlFlow::Continue)
740            }
741            Ok((outcome, changes)) => {
742                // `Skip` already returned above; the remaining variants all
743                // record an audit entry. `audit_status()` is `Some` for
744                // Success/Status/Halt — expect is for documentation only.
745                let status = outcome
746                    .audit_status()
747                    .expect("Skip handled above; remaining variants emit audit status");
748                let halt = outcome.halts_workflow();
749
750                // Record audit trail. workflow_id_arc/task_id_arc are populated
751                // by LogicCompiler at engine construction; cloning them is a
752                // refcount bump, not a string copy. `now` is shared with all
753                // other AuditTrails in this process_message call.
754                message.audit_trail.push(AuditTrail {
755                    timestamp: now,
756                    workflow_id: Arc::clone(workflow_id_arc),
757                    task_id: Arc::clone(task_id_arc),
758                    status: status as usize,
759                    changes,
760                });
761
762                // Update progress metadata for workflow chaining. Always
763                // emitted: when multiple workflows are registered in the same
764                // engine, downstream workflows route on
765                // `metadata.progress.{workflow_id,task_id,status_code}` to
766                // advance through linear sequences. After the first task the
767                // slot already holds the expected 3-key object, so the write
768                // overwrites the three values in place — only the two id
769                // `String` allocs remain. (This beat both three separate
770                // `set_nested_value` calls and the batched slot replace on
771                // the realistic workload.)
772                write_progress_metadata(&mut message.context, workflow_id, task_id, status);
773
774                if halt {
775                    info!("Task {} halted workflow {}", task_id, workflow_id);
776                    return Ok(TaskControlFlow::HaltWorkflow);
777                }
778
779                // Check status code
780                if (400..500).contains(&status) {
781                    warn!("Task {} returned client error status: {}", task_id, status);
782                } else if status >= 500 {
783                    error!("Task {} returned server error status: {}", task_id, status);
784                    // Single-channel contract: surface 5xx outcomes through
785                    // `message.errors` as well as the audit trail, so callers
786                    // that scan `errors()` see a 5xx-status task even when
787                    // the workflow continues past it.
788                    message.errors.push(
789                        ErrorInfo::builder(
790                            "TASK_STATUS_ERROR",
791                            format!("Task {} returned status {}", task_id, status),
792                        )
793                        .workflow_id(workflow_id)
794                        .task_id(task_id)
795                        .build(),
796                    );
797                    if !continue_on_error {
798                        return Err(DataflowError::Task(format!(
799                            "Task {} failed with status {}",
800                            task_id, status
801                        )));
802                    }
803                }
804                Ok(TaskControlFlow::Continue)
805            }
806            Err(e) => {
807                error!("Task {} failed: {:?}", task_id, e);
808
809                // Record error in audit trail (Arc clones are refcount bumps).
810                message.audit_trail.push(AuditTrail {
811                    timestamp: now,
812                    workflow_id: Arc::clone(workflow_id_arc),
813                    task_id: Arc::clone(task_id_arc),
814                    status: 500,
815                    changes: vec![],
816                });
817
818                // Add error to message
819                message.errors.push(
820                    ErrorInfo::builder("TASK_ERROR", format!("Task {} error: {}", task_id, e))
821                        .workflow_id(workflow_id)
822                        .task_id(task_id)
823                        .build(),
824                );
825
826                if !continue_on_error {
827                    Err(e)
828                } else {
829                    Ok(TaskControlFlow::Continue)
830                }
831            }
832        }
833    }
834}
835
836#[cfg(test)]
837mod tests {
838    use super::*;
839    use crate::engine::compiler::LogicCompiler;
840    use serde_json::json;
841    use std::collections::HashMap;
842
843    #[tokio::test]
844    async fn test_workflow_executor_skip_condition() {
845        // Create a workflow with a false condition
846        let workflow_json = r#"{
847            "id": "test_workflow",
848            "name": "Test Workflow",
849            "condition": false,
850            "tasks": [{
851                "id": "dummy_task",
852                "name": "Dummy Task",
853                "function": {
854                    "name": "map",
855                    "input": {"mappings": []}
856                }
857            }]
858        }"#;
859
860        let compiler = LogicCompiler::new();
861        let mut workflow = Workflow::from_json(workflow_json).unwrap();
862
863        // Compile the workflow condition
864        let workflows = compiler.compile_workflows(vec![workflow.clone()]).unwrap();
865        if let Some(compiled_workflow) = workflows.iter().find(|w| w.id == "test_workflow") {
866            workflow = compiled_workflow.clone();
867        }
868
869        let engine = compiler.into_engine();
870        let task_executor = Arc::new(TaskExecutor::new(
871            Arc::new(HashMap::new()),
872            Arc::clone(&engine),
873        ));
874        let workflow_executor = WorkflowExecutor::new(task_executor, engine);
875
876        let mut message = Message::from_value(&json!({}));
877
878        // Execute workflow - should be skipped due to false condition
879        let executed = workflow_executor
880            .execute(&workflow, &mut message, Utc::now())
881            .await
882            .unwrap();
883        assert!(!executed);
884        assert_eq!(message.audit_trail.len(), 0);
885    }
886
887    #[tokio::test]
888    async fn test_workflow_executor_execute_success() {
889        // Create a workflow with a true condition
890        let workflow_json = r#"{
891            "id": "test_workflow",
892            "name": "Test Workflow",
893            "condition": true,
894            "tasks": [{
895                "id": "dummy_task",
896                "name": "Dummy Task",
897                "function": {
898                    "name": "map",
899                    "input": {"mappings": []}
900                }
901            }]
902        }"#;
903
904        let compiler = LogicCompiler::new();
905        let mut workflow = Workflow::from_json(workflow_json).unwrap();
906
907        // Compile the workflow
908        let workflows = compiler.compile_workflows(vec![workflow.clone()]).unwrap();
909        if let Some(compiled_workflow) = workflows.iter().find(|w| w.id == "test_workflow") {
910            workflow = compiled_workflow.clone();
911        }
912
913        let engine = compiler.into_engine();
914        let task_executor = Arc::new(TaskExecutor::new(
915            Arc::new(HashMap::new()),
916            Arc::clone(&engine),
917        ));
918        let workflow_executor = WorkflowExecutor::new(task_executor, engine);
919
920        let mut message = Message::from_value(&json!({}));
921
922        // Execute workflow - should succeed with empty task list
923        let executed = workflow_executor
924            .execute(&workflow, &mut message, Utc::now())
925            .await
926            .unwrap();
927        assert!(executed);
928    }
929}