Skip to main content

dataflow_rs/engine/
mod.rs

1/*!
2# Engine Module
3
4This module implements the core async workflow engine for dataflow-rs. The engine provides
5high-performance, asynchronous message processing through workflows composed of tasks.
6
7## Architecture
8
9The engine features a clean async-first architecture built on datalogic v5:
10- **Compiler**: Pre-compiles JSONLogic expressions into `Arc<Logic>` via `Engine::compile_arc`
11- **Executor**: Handles internal function execution (map, validation) with async support
12- **Engine**: Orchestrates workflow processing with shared compiled logic
13- **Thread-Safe**: Single `datalogic_rs::Engine` shared via `Arc`, with `Arc<Logic>` entries for zero-copy sharing
14
15## Key Components
16
17- **Engine**: Async engine optimized for Tokio runtime with mixed I/O and CPU workloads
18- **LogicCompiler**: Compiles and caches JSONLogic expressions during initialization
19- **InternalExecutor**: Executes built-in map and validation functions with compiled logic
20- **Workflow**: Collection of tasks with JSONLogic conditions (can access data, metadata, temp_data)
21- **Task**: Individual processing unit that performs a specific function on a message
22- **AsyncFunctionHandler**: Trait for custom async processing logic
23- **Message**: Data structure flowing through the engine with audit trail
24
25## Performance Optimizations
26
27- **Pre-compilation**: All JSONLogic expressions compiled at startup
28- **Arc-wrapped Logic**: Zero-copy sharing of compiled logic across async tasks
29- **Bump-arena evaluation**: Per-worker thread-local `Bump` is rewound (not freed) between evals
30- **True Async**: I/O operations remain fully async
31
32## Usage
33
34```rust,no_run
35use dataflow_rs::{Engine, Workflow, engine::message::Message};
36use serde_json::json;
37
38#[tokio::main]
39async fn main() -> Result<(), Box<dyn std::error::Error>> {
40    // Define workflows
41    let workflows = vec![
42        Workflow::from_json(r#"{"id": "example", "name": "Example", "tasks": [{"id": "task1", "name": "Task 1", "function": {"name": "map", "input": {"mappings": []}}}]}"#)?
43    ];
44
45    // Create engine with defaults
46    let engine = Engine::builder().with_workflows(workflows).build()?;
47
48    // Process messages asynchronously
49    let mut message = Message::from_value(&json!({}));
50    engine.process_message(&mut message).await?;
51
52    Ok(())
53}
54```
55*/
56
57pub mod authoring;
58pub mod compiler;
59pub mod error;
60pub mod executor;
61pub mod functions;
62pub mod message;
63pub mod observer;
64/// Retrying a failed operation. Not available on `wasm32` — tokio's time
65/// driver, which the backoff needs, does not run there.
66#[cfg(not(target_arch = "wasm32"))]
67pub mod retry;
68pub mod rollout;
69pub mod secrets;
70pub mod steps;
71pub mod task;
72pub mod task_context;
73pub mod task_executor;
74pub mod task_outcome;
75pub mod trace;
76pub mod utils;
77pub mod workflow;
78pub mod workflow_executor;
79
80// Re-export key types for easier access
81pub use authoring::{IssueCode, Severity, WorkflowIssue};
82use error::{DEFAULT_ERROR_CONTEXT_LIMIT, ErrorContextConfig};
83pub use error::{DataflowError, ErrorInfo, Result, ServiceErrorBuilder};
84pub use functions::{
85    AsyncFunctionHandler, BoxedFunctionHandler, CompiledCustomInput, DynAsyncFunctionHandler,
86    FunctionConfig, Template, TemplateCompiler,
87};
88pub use message::Message;
89pub use observer::{
90    ExecutionObserver, MessageFinished, MessageStarted, TaskEvent, WorkflowFinished,
91    WorkflowStarted,
92};
93#[cfg(not(target_arch = "wasm32"))]
94pub use retry::{RetryPolicy, retry_with_attempts, retry_with_policy};
95pub use rollout::{Rollout, RolloutError};
96pub use secrets::Secrets;
97pub use steps::{
98    AuthoredStep, AuthoredSteps, MAX_GROUP_DEPTH, StepKind, is_group, walk_authored_steps,
99};
100pub use task::{HaltOn, Task, TaskGroup};
101pub use task_context::TaskContext;
102pub use task_outcome::{HALT_STATUS_CODE, TaskOutcome};
103pub use trace::{AuditTrailScope, ExecutionStep, ExecutionTrace, StepResult, TraceOptions};
104pub use workflow::{ConnectorRef, Workflow, WorkflowStatus};
105
106// `EngineBuilder` is defined further down in this file but exposed here so
107// downstream paths can import it via `dataflow_rs::engine::EngineBuilder`.
108
109use chrono::Utc;
110use datalogic_rs::Engine as DatalogicEngine;
111use datavalue::OwnedDataValue;
112use std::collections::HashMap;
113use std::sync::Arc;
114
115use crate::engine::functions::config::{
116    DispatchableFunction, can_dispatch_in, dispatchable_functions_in,
117};
118
119use compiler::LogicCompiler;
120use task_executor::TaskExecutor;
121use workflow_executor::WorkflowExecutor;
122
123/// High-performance async workflow engine for message processing.
124///
125/// ## Architecture
126///
127/// The engine is designed for async-first operation with Tokio:
128/// - **Separation of Concerns**: Distinct executors for workflows and tasks
129/// - **Shared datalogic engine**: Single `datalogic_rs::Engine` wrapped in `Arc` for thread-safe sharing
130/// - **`Arc<Logic>`**: Pre-compiled logic shared across all async tasks
131/// - **Async Functions**: Native async support for I/O-bound operations
132///
133/// ## Performance Characteristics
134///
135/// - **Zero Runtime Compilation**: All logic compiled during initialization
136/// - **Zero-Copy Sharing**: Arc-wrapped compiled logic shared without cloning
137/// - **Optimal for Mixed Workloads**: Async I/O with blocking CPU evaluation
138/// - **Thread-Safe by Design**: All components safe to share across Tokio tasks
139pub struct Engine {
140    /// Registry of available workflows, pre-sorted by priority (immutable after initialization).
141    /// Each workflow / task / function-config holds its own `Arc<Logic>` slots
142    /// — there is no central logic cache anymore.
143    workflows: Arc<Vec<Workflow>>,
144    /// Channel index: maps channel name -> indices into workflows vec (only Active workflows)
145    channel_index: Arc<HashMap<String, Vec<usize>>>,
146    /// Workflow executor for orchestrating workflow execution
147    workflow_executor: Arc<WorkflowExecutor>,
148    /// Shared datalogic v5 engine for JSONLogic evaluation (Send + Sync)
149    datalogic: Arc<DatalogicEngine>,
150    /// Custom JSONLogic operators registered via
151    /// [`EngineBuilder::with_datalogic_operator`]. Retained here — not just
152    /// applied once — because [`Engine::with_new_workflows`] builds a fresh
153    /// datalogic engine and must re-register them; holding only the built
154    /// engine would silently drop every custom operator at the first hot
155    /// reload.
156    datalogic_operators: DatalogicOperators,
157    /// Pre-built `Arc<OwnedDataValue::String>` of the engine version.
158    /// Built once at construction. Note the per-message stamp still clones
159    /// the inner `String` — the context owns its values, so the cached
160    /// form only saves re-formatting, not the (small) allocation.
161    engine_version: Arc<OwnedDataValue>,
162    /// The secret store behind the `secret` operator. Never part of a
163    /// `Message`; carried across [`Engine::with_new_workflows`] like the
164    /// custom operators, and for the same reason.
165    secrets: Arc<Secrets>,
166}
167
168/// The custom-operator registrations an engine carries across rebuilds.
169pub type DatalogicOperators = Arc<HashMap<String, Arc<dyn datalogic_rs::CustomOperator>>>;
170
171/// Build a channel index from pre-sorted workflows.
172/// Maps channel name -> indices into workflows vec, only for Active workflows.
173fn build_channel_index(workflows: &[Workflow]) -> HashMap<String, Vec<usize>> {
174    let mut index: HashMap<String, Vec<usize>> = HashMap::new();
175    for (i, workflow) in workflows.iter().enumerate() {
176        if workflow.status == WorkflowStatus::Active {
177            index.entry(workflow.channel.clone()).or_default().push(i);
178        }
179    }
180    index
181}
182
183impl Engine {
184    /// Creates a new Engine instance.
185    ///
186    /// Compiles every workflow / task / function-config JSONLogic expression
187    /// up-front. Returns `Err(DataflowError)` if any required expression
188    /// fails to compile — fail-loud at construction time instead of silently
189    /// dropping broken workflows at runtime.
190    ///
191    /// # Arguments
192    /// * `workflows` - The workflows to use for processing messages
193    /// * `task_functions` - Custom async function handlers (use
194    ///   `HashMap::new()` for none, or prefer [`Engine::builder`])
195    ///
196    /// # Example
197    ///
198    /// ```
199    /// use dataflow_rs::{Engine, Workflow};
200    ///
201    /// let workflows = vec![Workflow::from_json(r#"{"id": "test", "name": "Test", "priority": 0, "tasks": [{"id": "task1", "name": "Task 1", "function": {"name": "map", "input": {"mappings": []}}}]}"#).unwrap()];
202    ///
203    /// let engine = Engine::builder().with_workflows(workflows).build().unwrap();
204    /// ```
205    /// The recommended construction path is [`Engine::builder`]. `Engine::new`
206    /// is the lower-level escape hatch — accepts handlers as a plain
207    /// `HashMap` (use `HashMap::new()` for the no-handler case).
208    pub fn new(
209        workflows: Vec<Workflow>,
210        task_functions: HashMap<String, BoxedFunctionHandler>,
211    ) -> Result<Self> {
212        Self::new_with_operators(workflows, task_functions, Arc::new(HashMap::new()))
213    }
214
215    /// As [`Engine::new`], with custom JSONLogic operators registered on the
216    /// datalogic engine (and retained across [`Engine::with_new_workflows`]).
217    /// The builder path is [`EngineBuilder::with_datalogic_operator`]; this is
218    /// its escape-hatch twin, matching `new`.
219    pub fn new_with_operators(
220        workflows: Vec<Workflow>,
221        task_functions: HashMap<String, BoxedFunctionHandler>,
222        datalogic_operators: DatalogicOperators,
223    ) -> Result<Self> {
224        Self::new_inner(
225            workflows,
226            task_functions,
227            datalogic_operators,
228            Arc::new(Secrets::empty()),
229        )
230    }
231
232    /// The one constructor every public entry point funnels into. `secrets`
233    /// is builder-only — `Engine::new*` are escape hatches whose signatures
234    /// stay put.
235    fn new_inner(
236        workflows: Vec<Workflow>,
237        task_functions: HashMap<String, BoxedFunctionHandler>,
238        datalogic_operators: DatalogicOperators,
239        secrets: Arc<Secrets>,
240    ) -> Result<Self> {
241        // Checked here rather than in the builder so the `Engine::new*` escape
242        // hatches refuse too: a host operator under this name would be
243        // shadowed by the engine's own, silently, on every engine.
244        if datalogic_operators.contains_key(secrets::SECRET_OPERATOR) {
245            return Err(DataflowError::Validation(format!(
246                "'{}' is a reserved operator name — it reads the engine's secret store \
247                 (see EngineBuilder::with_secrets) and cannot be registered by a host",
248                secrets::SECRET_OPERATOR
249            )));
250        }
251        // Compile workflows (sorted by priority at compile time). Each
252        // workflow/task/config owns its own `Arc<Logic>` slots — no central
253        // cache to return. Any compile failure bubbles up immediately.
254        //
255        // The compiler is built first only to read the operator vocabulary —
256        // the key checks need it. Refusal still runs before compilation, so an
257        // authoring issue is reported ahead of any compile error.
258        let compiler = LogicCompiler::with_operators_and_secrets(&datalogic_operators, &secrets);
259        refuse_authoring_issues(&workflows, &secrets)?;
260        let mut sorted_workflows = compiler.compile_workflows(workflows)?;
261        let datalogic = compiler.into_engine();
262
263        // Pre-parse `FunctionConfig::Custom { input }` JSON into the
264        // registered handler's typed `Self::Input`, caching the boxed value
265        // on the task. Misshapen Custom configs fail here, not on first
266        // message — matches the "fail loud at startup" stance for compiled
267        // logic. Built-in async configs (HttpCall/Enrich/PublishKafka) are
268        // already typed by serde and need no second pass.
269        precompile_custom_inputs(&mut sorted_workflows, &task_functions, &datalogic)?;
270
271        let task_executor = Arc::new(TaskExecutor::with_secrets(
272            Arc::new(task_functions),
273            Arc::clone(&datalogic),
274            Arc::clone(&secrets),
275        ));
276
277        let workflow_executor =
278            Arc::new(WorkflowExecutor::new(task_executor, Arc::clone(&datalogic)));
279
280        // Build channel index for O(1) channel-based routing
281        let channel_index = build_channel_index(&sorted_workflows);
282
283        Ok(Self {
284            workflows: Arc::new(sorted_workflows),
285            channel_index: Arc::new(channel_index),
286            workflow_executor,
287            datalogic,
288            datalogic_operators,
289            engine_version: Arc::new(OwnedDataValue::String(
290                env!("CARGO_PKG_VERSION").to_string(),
291            )),
292            secrets,
293        })
294    }
295
296    /// Start building an engine. The recommended construction path —
297    /// chains `register("name", handler)` and `with_workflow(w)` calls,
298    /// then `build()` to produce a `Result<Engine>`.
299    ///
300    /// ```no_run
301    /// use dataflow_rs::{Engine, Workflow};
302    /// # let workflow: Workflow = unimplemented!();
303    /// let engine = Engine::builder()
304    ///     .with_workflow(workflow)
305    ///     // .register("my_handler", MyHandler)  // any AsyncFunctionHandler
306    ///     .build()
307    ///     .unwrap();
308    /// ```
309    pub fn builder() -> EngineBuilder {
310        EngineBuilder::new()
311    }
312
313    /// Cached `OwnedDataValue::String` of the engine version.
314    pub fn engine_version_value(&self) -> &OwnedDataValue {
315        &self.engine_version
316    }
317
318    /// The top-level names in the secret store — what `{"secret": "name"}` can
319    /// resolve. Names only, never values; for a host's admin surface or a
320    /// did-you-mean on [`IssueCode::UnknownSecret`].
321    ///
322    /// Empty when the host configured no secrets. **Ordering is not
323    /// meaningful**, matching [`Engine::operator_names`].
324    pub fn declared_secrets(&self) -> impl Iterator<Item = &str> {
325        self.secrets.names()
326    }
327
328    /// Creates a new Engine with different workflows but the same custom function handlers.
329    ///
330    /// This is the hot-reload path. The existing engine remains valid for any
331    /// in-flight `process_message` calls. The returned engine shares the same
332    /// function registry (zero-copy Arc bump) but has freshly compiled logic
333    /// for the new workflow set.
334    ///
335    /// # Arguments
336    /// * `workflows` - The new set of workflows to compile and use
337    pub fn with_new_workflows(&self, workflows: Vec<Workflow>) -> Result<Self> {
338        // Extract the shared function registry from the existing executor
339        let task_functions = self.workflow_executor.task_functions();
340
341        // Compile new workflows with a fresh datalogic engine instance —
342        // re-registering the retained custom operators, so a hot reload keeps
343        // the same operator vocabulary as the engine it replaces.
344        let compiler =
345            LogicCompiler::with_operators_and_secrets(&self.datalogic_operators, &self.secrets);
346        refuse_authoring_issues(&workflows, &self.secrets)?;
347        let mut sorted_workflows = compiler.compile_workflows(workflows)?;
348        let datalogic = compiler.into_engine();
349
350        // Pre-parse Custom inputs against the existing handler registry —
351        // hot-reload still validates the new workflow set against the
352        // already-registered handlers.
353        precompile_custom_inputs(&mut sorted_workflows, &task_functions, &datalogic)?;
354
355        // Rebuild the executor stack, reusing the existing function registry
356        let task_executor = Arc::new(TaskExecutor::with_secrets(
357            task_functions,
358            Arc::clone(&datalogic),
359            Arc::clone(&self.secrets),
360        ));
361
362        // Carry the observer across the reload. Dropping it here would stop
363        // metrics silently at the first hot reload.
364        let mut executor = WorkflowExecutor::new(task_executor, Arc::clone(&datalogic));
365        if let Some(observer) = self.workflow_executor.observer() {
366            executor = executor.with_observer(Arc::clone(observer));
367        }
368        // Same reasoning as the observer: dropping this would silently stop
369        // recording failure codes at the first hot reload.
370        if let Some(cfg) = self.workflow_executor.error_context() {
371            executor = executor.with_error_context(Arc::clone(cfg));
372        }
373        let workflow_executor = Arc::new(executor);
374
375        // Build channel index for O(1) channel-based routing
376        let channel_index = build_channel_index(&sorted_workflows);
377
378        Ok(Self {
379            workflows: Arc::new(sorted_workflows),
380            channel_index: Arc::new(channel_index),
381            workflow_executor,
382            datalogic,
383            datalogic_operators: Arc::clone(&self.datalogic_operators),
384            engine_version: Arc::clone(&self.engine_version),
385            secrets: Arc::clone(&self.secrets),
386        })
387    }
388
389    /// Attach a per-task [`ExecutionObserver`], returning the updated engine.
390    ///
391    /// The escape hatch matching [`Engine::new`] — [`EngineBuilder::with_observer`]
392    /// is the recommended path. Rebuilds the executor stack around the existing
393    /// handler registry and datalogic engine, so nothing is recompiled; the cost
394    /// is a few `Arc` bumps.
395    ///
396    /// Carried across [`Engine::with_new_workflows`], so a hot reload does not
397    /// silently stop reporting.
398    pub fn with_observer(self, observer: Arc<dyn ExecutionObserver>) -> Self {
399        self.rebuild_executor(|executor| executor.with_observer(observer))
400    }
401
402    /// Mirror per-task failure codes into the message context, returning the
403    /// updated engine.
404    ///
405    /// The escape hatch matching [`Engine::new`];
406    /// [`EngineBuilder::with_error_context_path`] is the recommended path and the
407    /// only one that validates the path. Carried across
408    /// [`Engine::with_new_workflows`] and [`Engine::with_observer`].
409    pub(crate) fn with_error_context(self, cfg: Arc<ErrorContextConfig>) -> Self {
410        self.rebuild_executor(|executor| executor.with_error_context(cfg))
411    }
412
413    /// Rebuild the executor stack around the existing handler registry and
414    /// datalogic engine, applying `configure` to the fresh executor.
415    ///
416    /// Nothing is recompiled; the cost is a few `Arc` bumps. Every knob the old
417    /// executor held is re-applied first, because the rebuild otherwise drops
418    /// them — that is what would make `.with_error_context(..)` followed by
419    /// `.with_observer(..)` silently lose the former.
420    fn rebuild_executor(
421        self,
422        configure: impl FnOnce(WorkflowExecutor) -> WorkflowExecutor,
423    ) -> Self {
424        let task_executor = Arc::new(TaskExecutor::with_secrets(
425            self.workflow_executor.task_functions(),
426            Arc::clone(&self.datalogic),
427            Arc::clone(&self.secrets),
428        ));
429        let mut executor = WorkflowExecutor::new(task_executor, Arc::clone(&self.datalogic));
430        if let Some(observer) = self.workflow_executor.observer() {
431            executor = executor.with_observer(Arc::clone(observer));
432        }
433        if let Some(cfg) = self.workflow_executor.error_context() {
434            executor = executor.with_error_context(Arc::clone(cfg));
435        }
436        Self {
437            workflows: self.workflows,
438            channel_index: self.channel_index,
439            workflow_executor: Arc::new(configure(executor)),
440            datalogic: self.datalogic,
441            datalogic_operators: self.datalogic_operators,
442            engine_version: self.engine_version,
443            secrets: self.secrets,
444        }
445    }
446
447    /// Processes a message through workflows that match their conditions.
448    ///
449    /// This async method:
450    /// 1. Iterates through workflows sequentially in priority order (pre-sorted at construction)
451    /// 2. Delegates workflow execution to the WorkflowExecutor
452    /// 3. Updates message metadata
453    ///
454    /// # Error contract
455    ///
456    /// Errors flow through two complementary channels:
457    /// - `message.errors()` — **always** contains every error encountered
458    ///   (validation failures, task panics, 5xx-status outcomes, workflow
459    ///   wrappers). Callers that want a uniform view inspect this list.
460    /// - `Result::Err` — signals **only** that the engine stopped before
461    ///   processing every workflow. Callers that want fail-fast match on
462    ///   this. The error pushed to `message.errors` for the same failure
463    ///   carries the workflow context (id) that the bare `Err` doesn't.
464    ///
465    /// In particular: a workflow with `continue_on_error: true` records its
466    /// errors to `message.errors` and returns `Ok(())` here. A workflow
467    /// with `continue_on_error: false` records to `message.errors` *and*
468    /// returns `Result::Err` (which short-circuits the rest of this call).
469    ///
470    /// # Arguments
471    /// * `message` - The message to process through workflows
472    ///
473    /// # Returns
474    /// * `Result<()>` — `Ok(())` if every workflow completed (each may have
475    ///   pushed errors to `message.errors`); `Err(e)` if the engine
476    ///   stopped early on a hard failure.
477    pub async fn process_message(&self, message: &mut Message) -> Result<()> {
478        // Capture a single timestamp for the entire process_message call. The
479        // workflow executor reads it back via Message metadata if it needs to
480        // emit AuditTrail entries; this caps the number of `Utc::now()` syscalls
481        // at 1 per message (down from 3+ — one stamp here, one per AuditTrail).
482        self.process_all(message, None, Utc::now()).await
483    }
484
485    /// Processes a message through workflows with step-by-step tracing,
486    /// recording into a caller-owned trace.
487    ///
488    /// Identical to [`Engine::process_message_with_trace`] except that the
489    /// trace is borrowed rather than returned, so the steps completed before a
490    /// hard failure survive the `Err`. That makes this the method to reach for
491    /// when the run you want to inspect is the run that failed — a returned
492    /// trace is dropped by the `?` at the call site, a borrowed one is not.
493    ///
494    /// Steps are **appended** to `trace`; any steps already present are
495    /// preserved, so a caller can accumulate across a chain of calls.
496    ///
497    /// The error contract is unchanged: `Ok(())` means every workflow was
498    /// processed (each may still have pushed to `message.errors`), and `Err(e)`
499    /// means the engine stopped early. See [`Engine::process_message`] for the
500    /// full contract.
501    ///
502    /// Note that the failing task's *own* step is not recorded — the engine
503    /// propagates the failure before appending it — so the retained trace ends
504    /// at the last known-good step rather than at the error. The error itself
505    /// is available from the returned `Err` and from `message.errors()`.
506    ///
507    /// # Arguments
508    /// * `message` - The message to process through workflows
509    /// * `trace` - Caller-owned trace to append steps to
510    ///
511    /// # Returns
512    /// * `Result<()>` — `Ok(())` if every workflow completed; `Err(e)` if the
513    ///   engine stopped early. In both cases `trace` holds the steps that ran.
514    pub async fn process_message_tracing(
515        &self,
516        message: &mut Message,
517        trace: &mut ExecutionTrace,
518    ) -> Result<()> {
519        // The trace carries its own capture policy, so nothing to pass here.
520        self.process_all(message, Some(trace), Utc::now()).await
521    }
522
523    /// Shared driver behind [`Self::process_message`] and
524    /// [`Self::process_message_tracing`] — stamps processing metadata and runs
525    /// every registered workflow in priority order. Mirrors [`Self::process_channel`]
526    /// for the whole-registry case.
527    ///
528    /// `run_all_borrowed` groups consecutive fully-sync workflows into a
529    /// single shared-arena scope so the context is deep-walked once per run
530    /// rather than once per workflow. Passing the registry slice directly
531    /// avoids a per-message `Vec<&Workflow>` collect.
532    async fn process_all(
533        &self,
534        message: &mut Message,
535        trace: Option<&mut ExecutionTrace>,
536        now: chrono::DateTime<Utc>,
537    ) -> Result<()> {
538        set_processing_metadata(&mut message.context, &self.engine_version, now, None);
539        self.workflow_executor
540            .run_all_borrowed(&self.workflows[..], message, trace, now)
541            .await
542    }
543
544    /// Processes a message through workflows with step-by-step tracing.
545    ///
546    /// This method is similar to `process_message` but captures an execution trace
547    /// that can be used for debugging and step-by-step visualization.
548    ///
549    /// Because the trace is returned by value, a `?` at the call site discards
550    /// it — on a hard failure this yields `Err` and no steps at all. Use
551    /// [`Engine::process_message_tracing`] to keep the steps that ran.
552    ///
553    /// # Arguments
554    /// * `message` - The message to process through workflows
555    ///
556    /// # Returns
557    /// * `Result<ExecutionTrace>` - The execution trace with message snapshots
558    pub async fn process_message_with_trace(
559        &self,
560        message: &mut Message,
561    ) -> Result<ExecutionTrace> {
562        self.process_message_with_trace_options(message, TraceOptions::default())
563            .await
564    }
565
566    /// Processes a message with tracing under an explicit capture policy.
567    ///
568    /// The default policy — what [`Engine::process_message_with_trace`] uses —
569    /// takes a full [`Message`] snapshot per executed step, which is unbounded
570    /// in message size and quadratic in task count. A host that *persists*
571    /// traces should bound them here rather than trimming the result
572    /// afterwards; by then the peak memory has already been paid.
573    ///
574    /// See [`TraceOptions`] for the knobs, and
575    /// [`Engine::process_message_tracing`] if you also need the steps to survive
576    /// a hard failure.
577    ///
578    /// # Arguments
579    /// * `message` - The message to process through workflows
580    /// * `options` - What to record for each step
581    pub async fn process_message_with_trace_options(
582        &self,
583        message: &mut Message,
584        options: TraceOptions,
585    ) -> Result<ExecutionTrace> {
586        let mut trace = ExecutionTrace::with_options(options);
587        self.process_message_tracing(message, &mut trace).await?;
588        Ok(trace)
589    }
590
591    /// Processes a message through only the Active workflows registered for a given channel.
592    ///
593    /// Workflows are processed in priority order (lowest first), same as process_message().
594    /// If the channel does not exist or has no Active workflows, this is a no-op.
595    ///
596    /// # Arguments
597    /// * `channel` - The channel name to route the message through
598    /// * `message` - The message to process
599    pub async fn process_message_for_channel(
600        &self,
601        channel: &str,
602        message: &mut Message,
603    ) -> Result<()> {
604        self.process_channel(channel, message, None, Utc::now())
605            .await
606    }
607
608    /// Channel-scoped variant of [`Engine::process_message_tracing`].
609    ///
610    /// As with [`Engine::process_message_for_channel`], an unknown channel — or
611    /// a channel with no Active workflows — is a no-op: this returns `Ok(())`
612    /// and leaves `trace` untouched. Steps are appended, matching
613    /// [`Engine::process_message_tracing`].
614    ///
615    /// # Arguments
616    /// * `channel` - The channel name to route the message through
617    /// * `message` - The message to process
618    /// * `trace` - Caller-owned trace to append steps to
619    pub async fn process_message_for_channel_tracing(
620        &self,
621        channel: &str,
622        message: &mut Message,
623        trace: &mut ExecutionTrace,
624    ) -> Result<()> {
625        self.process_channel(channel, message, Some(trace), Utc::now())
626            .await
627    }
628
629    /// Shared driver behind [`Self::process_message_for_channel`] and
630    /// [`Self::process_message_for_channel_tracing`] — stamps processing
631    /// metadata and runs only the channel's Active workflows. An unknown
632    /// channel, or one with no Active workflows, is a no-op.
633    async fn process_channel(
634        &self,
635        channel: &str,
636        message: &mut Message,
637        trace: Option<&mut ExecutionTrace>,
638        now: chrono::DateTime<Utc>,
639    ) -> Result<()> {
640        set_processing_metadata(
641            &mut message.context,
642            &self.engine_version,
643            now,
644            Some(channel),
645        );
646
647        if let Some(indices) = self.channel_index.get(channel) {
648            // Channel-selected workflows are non-contiguous in the registry,
649            // so the pointer collect stays on this path.
650            let workflows: Vec<&Workflow> =
651                indices.iter().map(|&idx| &self.workflows[idx]).collect();
652            self.workflow_executor
653                .run_all_borrowed(&workflows, message, trace, now)
654                .await?;
655        }
656
657        Ok(())
658    }
659
660    /// Processes a message through a channel with step-by-step tracing.
661    ///
662    /// Because the trace is returned by value, a `?` at the call site discards
663    /// it — on a hard failure this yields `Err` and no steps at all. Use
664    /// [`Engine::process_message_for_channel_tracing`] to keep the steps that
665    /// ran.
666    ///
667    /// # Arguments
668    /// * `channel` - The channel name to route the message through
669    /// * `message` - The message to process
670    pub async fn process_message_for_channel_with_trace(
671        &self,
672        channel: &str,
673        message: &mut Message,
674    ) -> Result<ExecutionTrace> {
675        self.process_message_for_channel_with_trace_options(
676            channel,
677            message,
678            TraceOptions::default(),
679        )
680        .await
681    }
682
683    /// Channel-scoped variant of
684    /// [`Engine::process_message_with_trace_options`].
685    ///
686    /// # Arguments
687    /// * `channel` - The channel name to route the message through
688    /// * `message` - The message to process
689    /// * `options` - What to record for each step
690    pub async fn process_message_for_channel_with_trace_options(
691        &self,
692        channel: &str,
693        message: &mut Message,
694        options: TraceOptions,
695    ) -> Result<ExecutionTrace> {
696        let mut trace = ExecutionTrace::with_options(options);
697        self.process_message_for_channel_tracing(channel, message, &mut trace)
698            .await?;
699        Ok(trace)
700    }
701
702    /// Get a reference to the workflows (pre-sorted by priority)
703    pub fn workflows(&self) -> &Arc<Vec<Workflow>> {
704        &self.workflows
705    }
706
707    /// Look up a workflow by its ID
708    pub fn workflow_by_id(&self, id: &str) -> Option<&Workflow> {
709        self.workflows.iter().find(|w| w.id == id)
710    }
711
712    /// Every function this engine will dispatch: self-contained built-ins,
713    /// plus [`crate::BuiltinKind::RequiresHandler`] built-ins and custom names with a
714    /// registered handler.
715    ///
716    /// This is the authoring-side vocabulary — what a host needs to screen a
717    /// workflow definition, build a completion catalogue, or offer a
718    /// did-you-mean on an unknown name, without keeping its own copy of the
719    /// list.
720    ///
721    /// Aliases are grouped: `validate` is yielded once carrying
722    /// `["validation"]`, not twice. [`Engine::can_dispatch`] does accept an
723    /// alias, so the two are deliberately different sets.
724    ///
725    /// **Ordering is not meaningful** and may change without notice; treat the
726    /// result as a set, and collect and sort if you need stable output.
727    ///
728    /// ```
729    /// use dataflow_rs::{BuiltinKind, Engine};
730    ///
731    /// let engine = Engine::builder().build().unwrap();
732    /// let mut names: Vec<&str> = engine.dispatchable_functions().map(|f| f.name).collect();
733    /// names.sort_unstable();
734    ///
735    /// // Self-contained built-ins need no registration…
736    /// assert!(names.contains(&"map"));
737    /// // …but `enrich` ships as a config schema only, so with no handler
738    /// // registered this engine cannot run it.
739    /// assert!(!names.contains(&"enrich"));
740    ///
741    /// let validate = engine
742    ///     .dispatchable_functions()
743    ///     .find(|f| f.name == "validate")
744    ///     .unwrap();
745    /// assert_eq!(validate.kind, Some(BuiltinKind::SelfContained));
746    /// assert_eq!(validate.aliases, &["validation"]);
747    /// ```
748    pub fn dispatchable_functions(&self) -> impl Iterator<Item = DispatchableFunction<'_>> {
749        dispatchable_functions_in(self.workflow_executor.registry())
750    }
751
752    /// Whether this engine can actually run a task named `name`.
753    ///
754    /// `true` for a [`crate::BuiltinKind::SelfContained`] built-in, which this crate
755    /// executes itself, and for any name with a registered handler — including
756    /// an alias such as `validation`.
757    ///
758    /// `false` means the opposite is guaranteed: a task naming it fails with
759    /// [`DataflowError::FunctionNotFound`] on the first message that reaches
760    /// it. That is the whole point of the method — `Engine::build` is
761    /// deliberately permissive about `http_call` / `enrich` / `publish_kafka`,
762    /// which deserialize into typed built-in variants and so pass construction
763    /// even with no handler behind them.
764    ///
765    /// ```
766    /// use dataflow_rs::Engine;
767    ///
768    /// let engine = Engine::builder().build().unwrap();
769    ///
770    /// assert!(engine.can_dispatch("map"));
771    /// assert!(engine.can_dispatch("validation")); // alias of `validate`
772    ///
773    /// // Builds fine, would fail every message — this is the check that catches it.
774    /// assert!(!engine.can_dispatch("enrich"));
775    /// assert!(!engine.can_dispatch("never_registered"));
776    /// ```
777    pub fn can_dispatch(&self, name: &str) -> bool {
778        can_dispatch_in(self.workflow_executor.registry(), name)
779    }
780
781    /// Check a workflow against this engine's registered handlers and secret
782    /// store, without building anything.
783    ///
784    /// Answers the half of the question [`Workflow::validate_authored`] cannot:
785    /// that method proves the definition *parses and validates*, but
786    /// [`EngineBuilder::build`] also resolves every task to a handler and parses
787    /// custom inputs. A definition can therefore be structurally perfect and
788    /// still abort a build — which, in a host that builds one engine over many
789    /// stored definitions, takes down every workflow in the process.
790    ///
791    /// Reports rather than aborts, so a host screens one definition at a time.
792    /// Issues are anchored on [`WorkflowIssue::task_id`] — step ids are unique
793    /// across tasks and groups — with a path relative to that task
794    /// (`function.input`). Join it with the coordinate
795    /// [`walk_authored_steps`] reports for that id
796    /// to point at the authored document.
797    ///
798    /// `Workflow::tasks` is already flattened, so tasks inside groups are
799    /// covered with no extra traversal.
800    ///
801    /// ```
802    /// use dataflow_rs::{Engine, IssueCode, Workflow};
803    ///
804    /// let workflow = Workflow::from_json(r#"{
805    ///     "id": "w", "name": "w", "priority": 0,
806    ///     "tasks": [{"id": "lookup", "name": "lookup",
807    ///                "function": {"name": "enrich",
808    ///                             "input": {"connector": "c", "merge_path": "data.out"}}}]
809    /// }"#).unwrap();
810    ///
811    /// // Builds cleanly — that permissiveness is deliberate.
812    /// let engine = Engine::builder().build().unwrap();
813    ///
814    /// let issues = engine.check_workflow(&workflow);
815    /// assert_eq!(issues[0].code, IssueCode::MissingHandler);
816    /// assert_eq!(issues[0].task_id.as_deref(), Some("lookup"));
817    /// ```
818    pub fn check_workflow(&self, workflow: &Workflow) -> Vec<WorkflowIssue> {
819        let compiler = TemplateCompiler::new(Arc::clone(&self.datalogic));
820        authoring::check_against_registry(
821            workflow,
822            self.workflow_executor.registry(),
823            &compiler,
824            &self.secrets,
825        )
826    }
827
828    /// Every operator name this build evaluates: datalogic's core vocabulary,
829    /// the extension families compiled in, and operators registered via
830    /// [`EngineBuilder::with_datalogic_operator`].
831    ///
832    /// Because the engine runs datalogic in templating mode, an unknown
833    /// operator is not an error — the object echoes back as literal data. That
834    /// makes this the only way to answer the authoring-side question a lint
835    /// needs: **is this single-key object a live operator call, or inert
836    /// data?**
837    ///
838    /// Turning a family on is therefore not a no-op. With `ext-string`
839    /// disabled, `{"length": …}` is a value; with it enabled, the same JSON is
840    /// a call. The enumeration moves with the feature.
841    ///
842    /// **Ordering is not meaningful** and may change without notice; treat the
843    /// result as a set, matching
844    /// [`BUILTIN_FUNCTION_NAMES`](crate::BUILTIN_FUNCTION_NAMES) and
845    /// [`Engine::dispatchable_functions`].
846    ///
847    /// ```
848    /// use dataflow_rs::Engine;
849    /// use std::collections::HashSet;
850    ///
851    /// let engine = Engine::builder().build().unwrap();
852    /// let vocabulary: HashSet<&str> = engine.operator_names().collect();
853    ///
854    /// // Core datalogic, always present.
855    /// assert!(vocabulary.contains("var"));
856    /// assert!(vocabulary.contains("if"));
857    ///
858    /// // A name outside the vocabulary is inert data, not a call — which is
859    /// // exactly what a lint wants to warn about.
860    /// assert!(!vocabulary.contains("lenght"));
861    /// ```
862    pub fn operator_names(&self) -> impl Iterator<Item = &str> + '_ {
863        // The built-in half comes from datalogic's own `OPCODE_NAMES` table
864        // (5.3.0's `builtin_operator_names`), the same table its compiler
865        // resolves keys against — so this cannot drift from dispatch the way a
866        // host-side copy of the list could. It moves with the compiled feature
867        // set on its side, including families this crate exposes no cargo
868        // feature for but that another crate in the graph turned on.
869        //
870        // The `map` is a lifetime coercion, not a transformation: datalogic
871        // yields `&'static str`, and `chain` needs both halves to agree on the
872        // item type with the `&'a str` borrowed from the custom registry.
873        let builtins = || {
874            self.datalogic
875                .builtin_operator_names()
876                .map(|name| -> &str { name })
877        };
878        // A custom registration under a built-in name is still that one name;
879        // filtering here is what dedups the two sources.
880        let customs = self
881            .datalogic_operators
882            .keys()
883            .map(String::as_str)
884            .filter(move |name| !builtins().any(|b| b == *name));
885        // The engine's own `secret` operator is live on every build; it cannot
886        // be in `datalogic_operators` (construction refuses the name).
887        builtins()
888            .chain(customs)
889            .chain(std::iter::once(secrets::SECRET_OPERATOR))
890    }
891
892    /// The prefix that escapes an object key in a JSONLogic template, so the
893    /// key is emitted as data instead of resolving as an operator.
894    ///
895    /// Exactly one leading prefix is stripped from every template key.
896    /// `{"$cat": …}` emits the key `cat`; `{"$$cat": …}` emits the literal
897    /// `$cat`; an unprefixed `{"cat": …}` is still the `cat` operator.
898    ///
899    /// Fixed for the life of the engine and identical on every build — this
900    /// accessor exists so an authoring tool can render or validate the spelling
901    /// without hardcoding it, not because it varies.
902    ///
903    /// This is the companion to [`Self::operator_names`]. That answers *which
904    /// names are live*; this answers *how to opt a key out of being one*.
905    ///
906    /// ```
907    /// use dataflow_rs::Engine;
908    ///
909    /// let engine = Engine::builder().build().unwrap();
910    /// assert_eq!(engine.template_key_escape(), '$');
911    /// ```
912    pub fn template_key_escape(&self) -> char {
913        compiler::TEMPLATE_KEY_ESCAPE
914    }
915
916    /// Get a reference to the underlying datalogic v5 engine.
917    ///
918    /// The same `Arc` every compiled [`Workflow`] in this engine evaluates
919    /// against, so a caller that wants to evaluate an expression under the
920    /// engine's exact operator vocabulary — the extension families compiled
921    /// in, the `secret` operator, and anything registered through
922    /// [`EngineBuilder::with_datalogic_operator`] — should use this rather
923    /// than building a second engine.
924    pub fn datalogic(&self) -> &Arc<DatalogicEngine> {
925        &self.datalogic
926    }
927}
928
929/// Builder for [`Engine`]. The recommended construction path — chain
930/// `register("name", handler)` and `with_workflow(workflow)` calls, then
931/// `build()` to produce a `Result<Engine>`. Empty registration is fine; an
932/// engine with no custom handlers still resolves the built-in functions.
933///
934/// `register` takes any [`AsyncFunctionHandler`] and boxes it internally; the
935/// `Box<dyn DynAsyncFunctionHandler + Send + Sync>` plumbing stays out of
936/// user code.
937///
938/// ```no_run
939/// use dataflow_rs::{Engine, Workflow};
940/// # let workflow: Workflow = unimplemented!();
941/// let engine = Engine::builder()
942///     .with_workflow(workflow)
943///     // .register("my_handler", MyHandler)
944///     .build()
945///     .unwrap();
946/// ```
947#[must_use = "EngineBuilder must be `.build()` to produce an Engine"]
948#[derive(Default)]
949pub struct EngineBuilder {
950    workflows: Vec<Workflow>,
951    handlers: HashMap<String, BoxedFunctionHandler>,
952    observer: Option<Arc<dyn ExecutionObserver>>,
953    datalogic_operators: HashMap<String, Arc<dyn datalogic_rs::CustomOperator>>,
954    error_context_path: Option<String>,
955    error_context_limit: Option<usize>,
956    /// Validated on the way in, so `build()` and `check_workflow` read one
957    /// store; the `Err` is what `build()` returns for a non-object value.
958    secrets: Option<Result<Secrets>>,
959}
960
961impl EngineBuilder {
962    /// Create an empty builder. Equivalent to [`EngineBuilder::default`].
963    pub fn new() -> Self {
964        Self::default()
965    }
966
967    /// Register a custom async handler under `name`. Accepts any
968    /// `AsyncFunctionHandler`; boxing happens internally via the engine's
969    /// blanket impl.
970    pub fn register<F>(mut self, name: impl Into<String>, handler: F) -> Self
971    where
972        F: AsyncFunctionHandler,
973    {
974        self.handlers.insert(name.into(), Box::new(handler));
975        self
976    }
977
978    /// Register a pre-boxed handler. Useful when handlers are constructed
979    /// dynamically (e.g. plugin registries) and the concrete type isn't
980    /// known at the call site.
981    pub fn register_boxed(
982        mut self,
983        name: impl Into<String>,
984        handler: BoxedFunctionHandler,
985    ) -> Self {
986        self.handlers.insert(name.into(), handler);
987        self
988    }
989
990    /// Every function this builder will dispatch once built.
991    ///
992    /// The pre-build twin of [`Engine::dispatchable_functions`], with identical
993    /// semantics — the two agree by construction, since `build()` moves this
994    /// registry into the engine unchanged. Takes `&self`, so screening a batch
995    /// of definitions does not consume the builder.
996    ///
997    /// ```
998    /// use dataflow_rs::Engine;
999    ///
1000    /// let builder = Engine::builder();
1001    /// let names: Vec<&str> = builder.dispatchable_functions().map(|f| f.name).collect();
1002    ///
1003    /// assert!(names.contains(&"parse_json"));
1004    /// assert!(!names.contains(&"publish_kafka")); // config schema, no handler
1005    /// ```
1006    pub fn dispatchable_functions(&self) -> impl Iterator<Item = DispatchableFunction<'_>> {
1007        dispatchable_functions_in(&self.handlers)
1008    }
1009
1010    /// Whether the engine this builder produces will run a task named `name`.
1011    ///
1012    /// The pre-build twin of [`Engine::can_dispatch`]. Screening a workflow is
1013    /// then a filter over its tasks — note that `Workflow::tasks` is already
1014    /// flattened, so this covers members of task groups too:
1015    ///
1016    /// ```
1017    /// use dataflow_rs::{Engine, Workflow};
1018    ///
1019    /// let workflow = Workflow::from_json(r#"{
1020    ///     "id": "w", "name": "w", "priority": 0,
1021    ///     "tasks": [
1022    ///         {"id": "a", "name": "a", "function": {"name": "map", "input": {"mappings": []}}},
1023    ///         {"id": "b", "name": "b",
1024    ///          "function": {"name": "enrich",
1025    ///                       "input": {"connector": "c", "merge_path": "data.out"}}}
1026    ///     ]
1027    /// }"#).unwrap();
1028    ///
1029    /// let builder = Engine::builder();
1030    /// let unrunnable: Vec<&str> = workflow
1031    ///     .tasks
1032    ///     .iter()
1033    ///     .map(|t| t.function.function_name())
1034    ///     .filter(|name| !builder.can_dispatch(name))
1035    ///     .collect();
1036    ///
1037    /// assert_eq!(unrunnable, vec!["enrich"]);
1038    /// ```
1039    pub fn can_dispatch(&self, name: &str) -> bool {
1040        can_dispatch_in(&self.handlers, name)
1041    }
1042
1043    /// Check a workflow against this builder's registered handlers, operators
1044    /// and secrets, without consuming the builder or building an engine.
1045    ///
1046    /// The pre-build twin of [`Engine::check_workflow`], with identical
1047    /// semantics. Takes `&self`, so a host can screen a batch of definitions
1048    /// against the registrations it is about to build with.
1049    ///
1050    /// Templates are compiled against a datalogic engine configured exactly as
1051    /// [`Self::build`] will configure it — same custom operators, same
1052    /// templating mode — so a template that passes here compiles there.
1053    ///
1054    /// ```
1055    /// use dataflow_rs::{Engine, IssueCode, Workflow};
1056    ///
1057    /// let workflow = Workflow::from_json(r#"{
1058    ///     "id": "w", "name": "w", "priority": 0,
1059    ///     "tasks": [{"id": "t", "name": "t",
1060    ///                "function": {"name": "typo_handler", "input": {}}}]
1061    /// }"#).unwrap();
1062    ///
1063    /// let issues = Engine::builder().check_workflow(&workflow);
1064    /// assert_eq!(issues[0].code, IssueCode::UnknownFunction);
1065    /// assert_eq!(issues[0].task_id.as_deref(), Some("t"));
1066    /// ```
1067    pub fn check_workflow(&self, workflow: &Workflow) -> Vec<WorkflowIssue> {
1068        // Build the datalogic engine the same way `build()` does, so template
1069        // compilation here is the same operation it will be there — rather than
1070        // an approximation a caller has to keep in step by hand.
1071        let compiler = LogicCompiler::with_operators(&self.datalogic_operators);
1072        let template_compiler = TemplateCompiler::new(compiler.into_engine());
1073        // With no store configured, nothing is declared and a literal name is
1074        // genuinely unknown — reporting it is the right answer. A store that is
1075        // *malformed* is a different problem: it fails `build()`, and checking
1076        // against the empty store would report every literal name as unknown
1077        // and bury the one thing actually wrong. So that case reports the store
1078        // instead, and drops the name verdicts — the only ones that depend on
1079        // it — below.
1080        let store = match &self.secrets {
1081            Some(Ok(secrets)) => secrets,
1082            None | Some(Err(_)) => &secrets::EMPTY,
1083        };
1084        let mut issues =
1085            authoring::check_against_registry(workflow, &self.handlers, &template_compiler, store);
1086
1087        if let Some(Err(err)) = &self.secrets {
1088            issues.retain(|issue| issue.code != IssueCode::UnknownSecret);
1089            issues.insert(
1090                0,
1091                WorkflowIssue {
1092                    code: IssueCode::InvalidSecretStore,
1093                    message: format!("the configured secret store is unusable: {err}"),
1094                    path: None,
1095                    task_id: None,
1096                },
1097            );
1098        }
1099        issues
1100    }
1101
1102    /// Add a single workflow. Subsequent calls append.
1103    pub fn with_workflow(mut self, workflow: Workflow) -> Self {
1104        self.workflows.push(workflow);
1105        self
1106    }
1107
1108    /// Append every workflow in `workflows`. Accepts anything iterable —
1109    /// `Vec<Workflow>`, an array, an iterator. Existing workflows on the
1110    /// builder are kept; subsequent registers/workflows still chain.
1111    pub fn with_workflows<I>(mut self, workflows: I) -> Self
1112    where
1113        I: IntoIterator<Item = Workflow>,
1114    {
1115        self.workflows.extend(workflows);
1116        self
1117    }
1118
1119    /// Insert every handler in `handlers`, keeping any already registered.
1120    ///
1121    /// Same extend-not-replace semantics as [`EngineBuilder::with_workflows`].
1122    /// Exists because `register` is per-name, which pushed an embedder that
1123    /// builds a whole `HashMap<String, BoxedFunctionHandler>` in one place onto
1124    /// [`Engine::new`] and off the builder entirely — and therefore out of reach
1125    /// of [`EngineBuilder::with_observer`].
1126    pub fn with_handlers(mut self, handlers: HashMap<String, BoxedFunctionHandler>) -> Self {
1127        self.handlers.extend(handlers);
1128        self
1129    }
1130
1131    /// Attach a per-task [`ExecutionObserver`]. Later calls replace the previous
1132    /// one.
1133    ///
1134    /// This is the only way to time the sync built-ins, which are dispatched
1135    /// inside the executor and never reach the function registry. With no
1136    /// observer attached the instrumentation — including its clock reads — stays
1137    /// out of the dispatch path entirely.
1138    pub fn with_observer(mut self, observer: Arc<dyn ExecutionObserver>) -> Self {
1139        self.observer = Some(observer);
1140        self
1141    }
1142
1143    /// Mirror per-task failure codes into the message context at `path`, so a
1144    /// downstream `condition` or `map` can branch on *why* a task failed.
1145    ///
1146    /// Off unless called: with no path configured nothing is written and the
1147    /// mechanism costs one `Option` check on a path that only runs after a task
1148    /// has already failed.
1149    ///
1150    /// One record is appended per error a task contributes to
1151    /// [`Message::errors`](crate::engine::message::Message::errors):
1152    ///
1153    /// ```json
1154    /// { "workflow_id": "place_order", "task_id": "charge_payment",
1155    ///   "code": "TIMEOUT_ERROR", "status": 500 }
1156    /// ```
1157    ///
1158    /// so a later task can gate on the reason:
1159    ///
1160    /// ```json
1161    /// { "in": [ { "var": "metadata.errors.0.code" },
1162    ///           ["TIMEOUT_ERROR", "IO_ERROR"] ] }
1163    /// ```
1164    ///
1165    /// Coverage matches `errors()` exactly — a handler returning `Err`, a task
1166    /// returning a 5xx outcome, the `validation` built-in's per-rule failures, and
1167    /// anything a handler adds through
1168    /// [`TaskContext::add_error`](crate::engine::task_context::TaskContext::add_error)
1169    /// all appear. The workflow-level `WORKFLOW_ERROR` wrapper does not: it
1170    /// re-reports the same underlying failure, so mirroring it would double-count.
1171    ///
1172    /// `status` is the task's own status — `500` when the handler returned `Err`,
1173    /// otherwise the status the outcome carried (`400` for `validation`). That is
1174    /// the distinction `metadata.progress` cannot make, since its failure arm
1175    /// hard-codes `500`.
1176    ///
1177    /// The error `message` and the operator-only `detail` are deliberately **not**
1178    /// recorded: the context is serialized back to callers, and `detail` is
1179    /// documented as unsafe to hand to an untrusted one. Read those from
1180    /// `message.errors()` host-side.
1181    ///
1182    /// `path` must start with `data`, `metadata` or `temp_data` — the JSONLogic
1183    /// evaluation context is exactly those three slots — and may not be
1184    /// `metadata.progress`. Violations fail [`EngineBuilder::build`].
1185    pub fn with_error_context_path(mut self, path: impl Into<String>) -> Self {
1186        self.error_context_path = Some(path.into());
1187        self
1188    }
1189
1190    /// Cap the number of records retained at the error-context path, keeping the
1191    /// most recent (default 32).
1192    ///
1193    /// The bound is what keeps the option's memory cost independent of a looping
1194    /// workflow's iteration count: `Message.context` is deep-cloned into every
1195    /// trace snapshot, so an uncapped list in a loop with a failing body grows the
1196    /// trace quadratically. Conditions overwhelmingly read the latest failure, so
1197    /// the oldest records are the ones dropped.
1198    ///
1199    /// Setting a limit without a path is inert, not an error. A limit of `0` fails
1200    /// [`EngineBuilder::build`].
1201    pub fn with_error_context_limit(mut self, limit: usize) -> Self {
1202        self.error_context_limit = Some(limit);
1203        self
1204    }
1205
1206    /// Values expressions may read through `{"secret": "name"}` but the engine
1207    /// never records.
1208    ///
1209    /// `secrets` must be a JSON object; [`Self::build`] rejects anything else.
1210    /// Nested objects are allowed and reached with a dotted path
1211    /// (`{"secret": "partner.hmac"}`). The host owns resolution — pass the
1212    /// values, not references to a vault. Later calls replace the earlier store.
1213    ///
1214    /// The store never enters a [`Message`]: not its `Serialize`, not an
1215    /// [`ExecutionTrace`] snapshot, not a `mapping_contexts` clone. That is the
1216    /// point of the store, and the reason the values are not simply seeded into
1217    /// `metadata`.
1218    pub fn with_secrets(mut self, secrets: OwnedDataValue) -> Self {
1219        self.secrets = Some(Secrets::new(secrets));
1220        self
1221    }
1222
1223    /// [`Self::with_secrets`] from a `serde_json::Value`.
1224    pub fn with_secrets_json(self, secrets: &serde_json::Value) -> Self {
1225        self.with_secrets(OwnedDataValue::from(secrets))
1226    }
1227
1228    /// Register a custom JSONLogic operator on the engine's internal datalogic
1229    /// instance, under `name`. Later calls with the same name replace the
1230    /// earlier registration.
1231    ///
1232    /// This is the host's door for domain operators: the engine builds (and on
1233    /// [`Engine::with_new_workflows`] *rebuilds*) its datalogic engine
1234    /// internally, where registration is builder-only — so operators must
1235    /// enter here to exist at all, and are retained on the engine so every
1236    /// hot reload re-registers them.
1237    ///
1238    /// Semantics follow `datalogic_rs`: arguments arrive pre-evaluated, and a
1239    /// built-in operator name always wins over a custom registration — pick
1240    /// names no built-in uses. Because the engine always runs in templating
1241    /// mode, a name that is *not* registered is not an error: the object
1242    /// echoes back as literal data, exactly like a disabled operator family.
1243    /// Registering a name therefore converts previously-inert values into
1244    /// live operator calls, the same caveat the cargo features carry.
1245    ///
1246    /// `secret` is reserved for the engine's own operator (see
1247    /// [`Self::with_secrets`]); registering it fails [`Self::build`].
1248    pub fn with_datalogic_operator<T>(mut self, name: impl Into<String>, operator: T) -> Self
1249    where
1250        T: datalogic_rs::CustomOperator + 'static,
1251    {
1252        self.datalogic_operators
1253            .insert(name.into(), Arc::new(operator));
1254        self
1255    }
1256
1257    /// Compile the workflows, pre-parse Custom inputs, and produce the
1258    /// engine. Compile errors and missing handler references surface here —
1259    /// the engine never deserializes Custom config on the hot path.
1260    pub fn build(self) -> Result<Engine> {
1261        // Validated here rather than at the setter so an invalid path fails at
1262        // engine construction alongside every other config-shape error, instead
1263        // of on the first message that happens to fail a task.
1264        let error_context = match self.error_context_path {
1265            Some(path) => Some(Arc::new(ErrorContextConfig::new(
1266                path,
1267                self.error_context_limit
1268                    .unwrap_or(DEFAULT_ERROR_CONTEXT_LIMIT),
1269            )?)),
1270            None => None,
1271        };
1272        let secrets = Arc::new(match self.secrets {
1273            Some(secrets) => secrets?,
1274            None => Secrets::empty(),
1275        });
1276        let engine = Engine::new_inner(
1277            self.workflows,
1278            self.handlers,
1279            Arc::new(self.datalogic_operators),
1280            secrets,
1281        )?;
1282        let engine = match error_context {
1283            Some(cfg) => engine.with_error_context(cfg),
1284            None => engine,
1285        };
1286        Ok(match self.observer {
1287            Some(observer) => engine.with_observer(observer),
1288            None => engine,
1289        })
1290    }
1291}
1292
1293/// Fail construction on the first workflow with a refusable authoring issue —
1294/// a secret an expression may not read, or a template object whose keys
1295/// collide. The same checks `check_workflow` reports, so what builds and what
1296/// checks clean are one set. Runs on the authored workflows before
1297/// compilation; nothing here needs compiled logic.
1298///
1299/// `check_workflow` additionally reports `ESCAPED_TEMPLATE_KEY`, the one
1300/// template-key finding that is [`Severity::Advisory`]; it is deliberately
1301/// *not* refused, because after a migration a `$`-escaped key is exactly what
1302/// the author meant.
1303fn refuse_authoring_issues(workflows: &[Workflow], secrets: &Secrets) -> Result<()> {
1304    for workflow in workflows {
1305        let mut issues = authoring::check_secrets(workflow, secrets);
1306        issues.extend(authoring::refusing_template_key_issues(workflow));
1307        if !issues.is_empty() {
1308            let listed: Vec<String> = issues.iter().map(ToString::to_string).collect();
1309            return Err(DataflowError::Validation(format!(
1310                "workflow '{}': {}",
1311                workflow.id,
1312                listed.join("; ")
1313            )));
1314        }
1315    }
1316    Ok(())
1317}
1318
1319/// Walk every task in every workflow; for each `FunctionConfig::Custom`,
1320/// look up the registered handler and ask it to parse the raw `input` JSON
1321/// into its typed `Self::Input` (boxed as `dyn Any`). The cached result is
1322/// stored on the task — dispatch then hands the handler a `&dyn Any` it
1323/// downcasts in O(1).
1324///
1325/// Built-in async configs (`HttpCall`, `Enrich`, `PublishKafka`) are already
1326/// parsed by serde's `untagged` representation on `FunctionConfig`; they
1327/// need no second pass.
1328///
1329/// Returns `FunctionNotFound` when a Custom task references an unregistered
1330/// handler — moves the failure from "first message" to engine construction.
1331fn precompile_custom_inputs(
1332    workflows: &mut [Workflow],
1333    handlers: &HashMap<String, BoxedFunctionHandler>,
1334    datalogic: &Arc<DatalogicEngine>,
1335) -> Result<()> {
1336    let template_compiler = TemplateCompiler::new(Arc::clone(datalogic));
1337    for workflow in workflows {
1338        for task in &mut workflow.tasks {
1339            if let FunctionConfig::Custom {
1340                name,
1341                input,
1342                compiled_input,
1343            } = &mut task.function
1344            {
1345                let handler = handlers
1346                    .get(name)
1347                    .ok_or_else(|| function_not_found_error(name, handlers))?;
1348                let mut parsed = handler.parse_input_box(input)?;
1349                handler.compile_input_box(&mut *parsed, &template_compiler)?;
1350                *compiled_input = Some(CompiledCustomInput(Arc::from(parsed)));
1351            }
1352        }
1353    }
1354    Ok(())
1355}
1356
1357/// Build a `FunctionNotFound` error that lists both the registered custom
1358/// handlers and the names of built-in functions, so a user with a typo
1359/// (e.g. `htttp_call`) can immediately spot the intended name.
1360///
1361/// **This message is free-form and deliberately unpinned.** It is a diagnostic
1362/// for humans; its wording and layout may change in any release. No test
1363/// asserts on it, and none should — a caller that needs the built-in vocabulary
1364/// programmatically should use [`crate::BUILTIN_FUNCTION_NAMES`] and
1365/// [`crate::builtin_function_kind`], which exist for exactly that purpose and
1366/// answer the sharper question of whether a name needs a registered handler.
1367fn function_not_found_error(
1368    name: &str,
1369    handlers: &HashMap<String, BoxedFunctionHandler>,
1370) -> DataflowError {
1371    use crate::engine::functions::config::BUILTIN_FUNCTION_NAMES;
1372    let mut registered: Vec<&str> = handlers.keys().map(String::as_str).collect();
1373    registered.sort_unstable();
1374    let registered_part = if registered.is_empty() {
1375        String::from("none")
1376    } else {
1377        registered.join(", ")
1378    };
1379    DataflowError::FunctionNotFound(format!(
1380        "{name} (registered handlers: {registered_part}; built-ins: {})",
1381        BUILTIN_FUNCTION_NAMES.join(", ")
1382    ))
1383}
1384
1385/// Stamp the standard processing metadata (`processed_at`, `engine_version`,
1386/// and optionally `channel`) into the message context.
1387///
1388/// `now` is captured once at the top of `process_message` and reused so the
1389/// timestamp on `metadata.processed_at` matches the one used for every
1390/// `AuditTrail` entry within the same call.
1391///
1392/// Walks to the `metadata` object once and sets every key in a single pass,
1393/// instead of one full `"metadata.*"` path split + tree walk per key.
1394/// Mirrors `set_nested_value` semantics for the degenerate shapes: a
1395/// non-object context or a non-object existing `metadata` slot no-ops; a
1396/// missing `metadata` slot is created.
1397///
1398/// `(**engine_version).clone()` deep-clones the inner `String` — the
1399/// context owns its values, so one small allocation per message is
1400/// inherent; the cached `Arc` only saves re-formatting the version.
1401fn set_processing_metadata(
1402    context: &mut OwnedDataValue,
1403    engine_version: &Arc<OwnedDataValue>,
1404    now: chrono::DateTime<Utc>,
1405    channel: Option<&str>,
1406) {
1407    let OwnedDataValue::Object(top) = context else {
1408        return;
1409    };
1410    let metadata = match top.iter().position(|(k, _)| k == "metadata") {
1411        Some(i) => &mut top[i].1,
1412        None => {
1413            top.push(("metadata".to_string(), OwnedDataValue::Object(Vec::new())));
1414            &mut top.last_mut().expect("just pushed").1
1415        }
1416    };
1417    let OwnedDataValue::Object(meta) = metadata else {
1418        return;
1419    };
1420
1421    let mut set_key = |key: &str, value: OwnedDataValue| {
1422        if let Some(slot) = meta.iter_mut().find(|(k, _)| k == key) {
1423            slot.1 = value;
1424        } else {
1425            meta.push((key.to_string(), value));
1426        }
1427    };
1428    set_key("processed_at", OwnedDataValue::String(now.to_rfc3339()));
1429    set_key("engine_version", (**engine_version).clone());
1430    if let Some(channel) = channel {
1431        set_key("channel", OwnedDataValue::String(channel.to_string()));
1432    }
1433}