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