Skip to main content

dataflow_rs/engine/
task.rs

1//! # Task Module
2//!
3//! This module defines the `Task` structure, which represents a single
4//! processing unit within a workflow. Tasks are the fundamental building
5//! blocks of data processing pipelines.
6
7use crate::engine::functions::FunctionConfig;
8use datalogic_rs::Logic;
9use serde::Deserialize;
10use serde_json::Value;
11use std::sync::Arc;
12
13/// A contiguous run of tasks sharing one condition, and optionally ending the
14/// workflow once the run completes.
15///
16/// Groups are authored as nested objects inside a workflow's `tasks` list — an
17/// element carrying a `tasks` key is a group, one carrying a `function` key is
18/// a [`Task`]. The parser flattens the tree into `Workflow::tasks` and records
19/// each group's span on the task that opens it ([`Task::group_starts`]), so the
20/// executor keeps walking a flat slice.
21///
22/// The condition is evaluated **once, on entry**. A false result skips every
23/// task in the span; the individual tasks' own conditions are not evaluated.
24///
25/// # Example JSON Definition
26///
27/// ```json
28/// {
29///     "id": "have_videos",
30///     "condition": {">": [{"length": [{"var": "temp_data.videos"}]}, 0]},
31///     "terminal": false,
32///     "tasks": [
33///         {"id": "rank", "name": "Rank", "function": {"name": "map", "input": {}}},
34///         {"id": "trim", "name": "Trim", "function": {"name": "map", "input": {}}}
35///     ]
36/// }
37/// ```
38///
39/// `#[non_exhaustive]`: groups are produced by the workflow parser, never built
40/// by hand — `end` is an index into the *flattened* task list and means nothing
41/// on its own. Read the fields freely.
42#[derive(Clone, Debug)]
43#[non_exhaustive]
44pub struct TaskGroup {
45    /// Unique identifier for the group within the workflow. Shares the task
46    /// id namespace, so a group cannot reuse a task's id.
47    pub id: String,
48
49    /// Human-readable name.
50    pub name: Option<String>,
51
52    /// Optional description explaining what the group covers.
53    pub description: Option<String>,
54
55    /// JSONLogic condition gating the whole span. Defaults to `true`.
56    pub condition: Value,
57
58    /// Engine-internal: pre-compiled JSONLogic for `condition`, populated by
59    /// `LogicCompiler`. `None` is treated as "always enter" by the executor.
60    /// Not part of the stable API.
61    #[doc(hidden)]
62    pub compiled_condition: Option<Arc<Logic>>,
63
64    /// Whether reaching the end of this group ends the workflow.
65    pub terminal: bool,
66
67    /// Whether the author wrote `"continue_on_error": true` on this group.
68    ///
69    /// **The engine does not honour it.** Error handling is per task
70    /// ([`Task::continue_on_error`]) and per workflow; a group only gates a
71    /// span. The key is recorded here so
72    /// [`check_workflow`](crate::EngineBuilder::check_workflow) can report
73    /// [`IssueCode::GroupContinueOnError`](crate::IssueCode::GroupContinueOnError)
74    /// rather than let it vanish — being real at the other two levels is
75    /// exactly what makes a group the one place it looks like it should work.
76    ///
77    /// `false` for a group that omits the key, and for one that spells it with
78    /// anything other than a literal `true`.
79    pub continue_on_error: bool,
80
81    /// Engine-internal: exclusive end of the span, as an index into
82    /// `Workflow::tasks`. The start is the index of the task carrying this
83    /// entry in its [`Task::group_starts`]. Not part of the stable API.
84    #[doc(hidden)]
85    pub end: usize,
86}
87
88/// When a task's own outcome ends the workflow — the outcome complement of
89/// [`Task::terminal`].
90///
91/// Authored as a string on a task: `"halt_on": "failure"`. Absent means
92/// [`Self::Never`], so every workflow written before this existed is unchanged.
93///
94/// `#[non_exhaustive]`: further modes (a status range, say) would otherwise
95/// break every downstream `match`. Match with a `_` arm.
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
97#[serde(rename_all = "snake_case")]
98#[non_exhaustive]
99pub enum HaltOn {
100    /// Never halt on outcome. The default, and the behaviour of every task that
101    /// does not mention `halt_on`.
102    #[default]
103    Never,
104
105    /// Halt once this task has run and failed — a recorded status of `400` or
106    /// above, which covers the `validation` built-in's `400`, any handler
107    /// returning [`TaskOutcome::Status`](crate::engine::task_outcome::TaskOutcome::Status)
108    /// in that range, and a handler returning `Err` (recorded as `500`).
109    ///
110    /// [`TaskOutcome::Skip`](crate::engine::task_outcome::TaskOutcome::Skip)
111    /// records nothing and never halts, and
112    /// [`TaskOutcome::Halt`](crate::engine::task_outcome::TaskOutcome::Halt)
113    /// carries `HALT_STATUS_CODE` (`299`), below the threshold — it halts on its
114    /// own account, not through this flag.
115    ///
116    /// There is no `"always"`: [`Task::terminal`] already spells that.
117    Failure,
118}
119
120/// A single processing unit within a workflow (also known as an Action in rules-engine terminology).
121///
122/// Tasks execute functions with optional conditions and error handling.
123/// They are processed sequentially within a workflow, allowing later tasks
124/// to depend on results from earlier ones.
125///
126/// # Example JSON Definition
127///
128/// ```json
129/// {
130///     "id": "validate_user",
131///     "name": "Validate User Data",
132///     "description": "Ensures user data meets requirements",
133///     "condition": {">=": [{"var": "data.order.total"}, 1000]},
134///     "function": {
135///         "name": "validation",
136///         "input": { "rules": [...] }
137///     },
138///     "continue_on_error": false,
139///     "terminal": false,
140///     "halt_on": "failure"
141/// }
142/// ```
143/// A single unit of work inside a workflow.
144///
145/// `#[non_exhaustive]`: construct through [`Task::action`] and assign the
146/// public fields you need, or parse a workflow from JSON. Field reads and
147/// writes are unaffected, and `..` patterns keep working.
148///
149/// The attribute exists because three of this struct's fields — `id_arc`,
150/// `compiled_condition`, `group_starts` — are engine internals documented as
151/// *not part of the stable API*, yet struct-literal construction forced every
152/// caller to name them. Field additions had already broken those callers twice
153/// (3.3.0, 3.6.0); this is the change that stops it.
154#[derive(Clone, Debug, Deserialize)]
155#[non_exhaustive]
156pub struct Task {
157    /// Unique identifier for the task within the workflow.
158    pub id: String,
159
160    /// Engine-internal: `Arc<str>` mirror of `id`, populated by
161    /// `LogicCompiler::compile_workflows`. Audit-trail emission clones this
162    /// instead of allocating a fresh `Arc<str>`. Public for crate-internal
163    /// access from the compiler and tests; not part of the stable API.
164    #[doc(hidden)]
165    #[serde(skip)]
166    pub id_arc: Arc<str>,
167
168    /// Human-readable name for the task.
169    pub name: String,
170
171    /// Optional description explaining what the task does.
172    pub description: Option<String>,
173
174    /// JSONLogic condition that determines if the task should execute.
175    /// Conditions can access any context field (`data`, `metadata`, `temp_data`).
176    /// Defaults to `true` (always execute).
177    #[serde(default = "crate::engine::utils::default_condition")]
178    pub condition: Value,
179
180    /// Engine-internal: pre-compiled JSONLogic for `condition`, populated by
181    /// `LogicCompiler`. `None` is treated as "always run" by the executor.
182    /// Not part of the stable API.
183    #[doc(hidden)]
184    #[serde(skip)]
185    pub compiled_condition: Option<Arc<Logic>>,
186
187    /// The function configuration specifying what operation to perform.
188    /// Can be a built-in function (map, validation) or a custom function.
189    pub function: FunctionConfig,
190
191    /// Whether to continue workflow execution if this task fails.
192    /// When `true`, errors are recorded but don't stop the workflow.
193    /// Defaults to `false`.
194    ///
195    /// **"Fails" means a `5xx` outcome or a returned `Err` — not every
196    /// unsuccessful task.** A `4xx` is logged as a warning and the workflow
197    /// carries on regardless of this flag, so `continue_on_error: false` after a
198    /// `validation` task does *not* stop the tasks that follow it: a failing rule
199    /// returns `400`. To gate on an outcome, use [`Task::halt_on`]; to reject the
200    /// whole message, return an `Err` from a handler.
201    #[serde(default)]
202    pub continue_on_error: bool,
203
204    /// Whether running this task ends the workflow. Defaults to `false`.
205    ///
206    /// `terminal` is a statement about *position* — "nothing after this runs" —
207    /// not about outcome:
208    ///
209    /// - a false `condition` means the task never ran, so nothing halts;
210    /// - [`TaskOutcome::Skip`](crate::engine::task_outcome::TaskOutcome::Skip)
211    ///   does not halt, for the same reason;
212    /// - a task that *failed* under `continue_on_error: true` still halts, and
213    ///   its error is still recorded on `message.errors()`.
214    ///
215    /// Halting stops this workflow only; later workflows registered on the same
216    /// engine still process the message. Inside a workflow carrying a
217    /// [`LoopConfig`](crate::engine::workflow::LoopConfig) it breaks the whole
218    /// loop, not one sweep — the same scope as
219    /// [`TaskOutcome::Halt`](crate::engine::task_outcome::TaskOutcome::Halt).
220    ///
221    /// The audit-trail entry keeps the task's *own* status (`200`, `404`, …)
222    /// rather than `HALT_STATUS_CODE`: the task did its job, and a `map` that
223    /// wrote a 404 response body should not report "a filter halted here".
224    ///
225    /// For the *outcome* axis — "halt only if this task failed" — see
226    /// [`Task::halt_on`].
227    #[serde(default)]
228    pub terminal: bool,
229
230    /// Halt the workflow based on this task's **outcome**. Defaults to
231    /// [`HaltOn::Never`].
232    ///
233    /// The complement of [`Task::terminal`]: `terminal` is about position and
234    /// halts whatever happened, `halt_on` is about what happened and halts only
235    /// then. The two combine as `terminal || (halt_on matched)` — `terminal` is
236    /// strictly stronger, so setting both is redundant rather than contradictory.
237    ///
238    /// This is what lets an assertion reject. A `validation` task returns `400`
239    /// when a rule fails, which is *not* covered by
240    /// [`continue_on_error`](Task::continue_on_error), so without `halt_on` the
241    /// tasks after it still run:
242    ///
243    /// ```json
244    /// { "id": "check_state", "halt_on": "failure",
245    ///   "function": { "name": "validation", "input": { "rules": [ … ] } } }
246    /// ```
247    ///
248    /// **Failure means a recorded status of `400` or above** — the same
249    /// threshold the executor already splits on to warn (4xx) and to record
250    /// `TASK_STATUS_ERROR` (5xx) — or a handler returning `Err`, recorded as
251    /// `500`. It is deliberately *not* "the task appended to
252    /// `message.errors()`": a handler may call
253    /// [`TaskContext::add_error`](crate::TaskContext::add_error) and still
254    /// return `Success`, and that does not halt.
255    ///
256    /// | The task … | `terminal: true` | `halt_on: "failure"` |
257    /// |---|---|---|
258    /// | never ran (its `condition`, or its group's, was false) | no | no |
259    /// | returned [`TaskOutcome::Skip`](crate::TaskOutcome::Skip) | no | no |
260    /// | returned `Success`, or a 2xx–3xx status | **halts** | no |
261    /// | returned [`TaskOutcome::Halt`](crate::TaskOutcome::Halt) | halts already | halts already |
262    /// | returned a 4xx status | **halts** | **halts** |
263    /// | returned 5xx, `continue_on_error: true` | **halts** | **halts** |
264    /// | returned 5xx, `continue_on_error: false` | error propagates | error propagates |
265    /// | handler returned `Err`, `continue_on_error: true` | **halts** | **halts** |
266    /// | handler returned `Err`, `continue_on_error: false` | error propagates | error propagates |
267    /// | called `add_error` but returned `Success` | **halts** | no |
268    ///
269    /// The two "error propagates" rows are not an omission: the executor returns
270    /// `Err` before either flag is consulted, which abandons the rest of this
271    /// workflow — everything halting would have done — and additionally reaches
272    /// the caller. They differ in exactly one shape: a workflow carrying a
273    /// [`loop`](crate::engine::workflow::LoopConfig) whose *own*
274    /// `continue_on_error` is `true`, where the error advances to the next sweep
275    /// while a halt would break the loop. Set `continue_on_error: false` on the
276    /// workflow if the loop must stop.
277    ///
278    /// Everything `terminal` documents about *scope* applies unchanged: halting
279    /// stops this workflow only, later workflows still process the message, it
280    /// breaks a whole loop rather than one sweep, and the audit entry keeps the
281    /// task's own status — `400`, not `HALT_STATUS_CODE`. **Halting is therefore
282    /// not a security control**: to stop a message outright return an `Err`, or
283    /// gate the following workflow on
284    /// [`EngineBuilder::with_error_context_path`](crate::EngineBuilder::with_error_context_path).
285    #[serde(default)]
286    pub halt_on: HaltOn,
287
288    /// Engine-internal: groups opening at this task, outermost first. Populated
289    /// by the workflow parser; empty for a task in no group. Not part of the
290    /// stable API.
291    #[doc(hidden)]
292    #[serde(skip)]
293    pub group_starts: Vec<TaskGroup>,
294}
295
296impl Task {
297    /// Create a task (action) with default settings.
298    ///
299    /// This is a convenience constructor for the IFTTT-style rules engine pattern,
300    /// creating an action that always executes (condition defaults to `true`).
301    ///
302    /// # Arguments
303    /// * `id` - Unique identifier for the action
304    /// * `name` - Human-readable name
305    /// * `function` - The function configuration to execute
306    pub fn action(id: &str, name: &str, function: FunctionConfig) -> Self {
307        Self {
308            id: id.to_string(),
309            id_arc: Arc::from(id),
310            name: name.to_string(),
311            description: None,
312            condition: Value::Bool(true),
313            compiled_condition: None,
314            function,
315            continue_on_error: false,
316            terminal: false,
317            halt_on: HaltOn::Never,
318            group_starts: Vec::new(),
319        }
320    }
321}