Skip to main content

dataflow_rs/engine/
task_context.rs

1//! # Task context
2//!
3//! Wraps the per-call state passed to every `AsyncFunctionHandler::execute`
4//! call: the message under processing, a handle to the shared datalogic
5//! engine, and an audit-trail accumulator. Exposes typed helpers so handlers
6//! don't have to reach into `crate::engine::utils::{get,set}_nested_value`
7//! or hand-build `Change` entries.
8//!
9//! Custom handlers should treat `TaskContext` as their *only* mutation surface
10//! for `message.context`: the `set` family records a `Change` automatically
11//! when `message.capture_changes` is true, keeping the audit trail in sync
12//! with the data without per-handler boilerplate.
13
14use crate::engine::error::{DataflowError, ErrorInfo, Result};
15use crate::engine::message::{Change, Message};
16use crate::engine::secrets::{self, Secrets};
17use crate::engine::utils::{get_nested_value, set_nested_value};
18use datalogic_rs::{Engine as DatalogicEngine, Logic};
19use datavalue::OwnedDataValue;
20use serde_json::Value as JsonValue;
21use std::sync::Arc;
22
23/// Per-call execution context handed to `AsyncFunctionHandler::execute`.
24///
25/// Borrows the message and datalogic engine for the duration of the handler
26/// call; collects `Change` entries that the workflow executor folds into the
27/// audit trail when the handler returns. Drop semantics are trivial — there
28/// is nothing to flush; the executor extracts the buffered changes via
29/// `into_changes()`.
30pub struct TaskContext<'a> {
31    message: &'a mut Message,
32    datalogic: &'a Arc<DatalogicEngine>,
33    /// Changes accumulated through the `set*` family. Only populated when
34    /// `message.capture_changes` is true; otherwise pushes are no-ops to
35    /// keep the bulk-pipeline fast path allocation-free.
36    changes: Vec<Change>,
37    /// Who is executing, when the engine built this context.
38    ///
39    /// Borrowed rather than `Arc`-cloned: the ids live on the `Workflow` and
40    /// `Task`, both of which outlive the dispatch call, and the accessors hand
41    /// back `&str` either way — so a refcount bump per task would buy nothing.
42    identity: Option<TaskIdentity<'a>>,
43    /// Sweep index of the enclosing looping workflow, if any.
44    loop_counter: Option<i64>,
45    /// The engine's secret store — empty for a context built with
46    /// [`Self::new`], for the same reason `identity` is `None` there.
47    secrets: &'a Secrets,
48}
49
50/// Which task, in which workflow, the engine is currently running.
51///
52/// All-or-nothing by construction: the engine executing a task inside a
53/// workflow knows both ids, and every other path knows neither. Two separate
54/// `Option<&str>` fields would additionally allow "workflow known, task
55/// unknown" — a state that never occurs — and would be silently swappable at
56/// the call site, being adjacent and identically typed.
57#[derive(Debug, Clone, Copy)]
58pub(crate) struct TaskIdentity<'a> {
59    pub workflow_id: &'a str,
60    pub task_id: &'a str,
61}
62
63impl<'a> TaskContext<'a> {
64    /// Construct a new context. Mostly engine-internal — handlers receive a
65    /// pre-built `&mut TaskContext` from the executor — but exposed `pub` so
66    /// tests and benchmarks can drive `AsyncFunctionHandler::execute`
67    /// directly without going through `Engine::process_message`.
68    /// A context built this way reports `None` from [`Self::workflow_id`],
69    /// [`Self::task_id`] and [`Self::loop_counter`] — there is no workflow run
70    /// to describe, and inventing ids would be worse than admitting their
71    /// absence.
72    pub fn new(message: &'a mut Message, datalogic: &'a Arc<DatalogicEngine>) -> Self {
73        Self {
74            message,
75            datalogic,
76            changes: Vec::new(),
77            identity: None,
78            loop_counter: None,
79            secrets: &secrets::EMPTY,
80        }
81    }
82
83    /// As [`Self::new`], with the identity of the executing task.
84    ///
85    /// Used by the task executor on the dispatch path. A separate constructor
86    /// rather than setters, so there is no window in which a context exists
87    /// with half its identity filled in.
88    pub(crate) fn with_identity(
89        message: &'a mut Message,
90        datalogic: &'a Arc<DatalogicEngine>,
91        identity: Option<TaskIdentity<'a>>,
92        loop_counter: Option<i64>,
93        secrets: &'a Secrets,
94    ) -> Self {
95        Self {
96            message,
97            datalogic,
98            changes: Vec::new(),
99            identity,
100            loop_counter,
101            secrets,
102        }
103    }
104
105    /// Id of the workflow being executed, when the engine built this context.
106    ///
107    /// `None` for a context built with [`Self::new`] — a test or benchmark
108    /// driving a handler directly is not inside a workflow run.
109    ///
110    /// ```
111    /// # use dataflow_rs::{TaskContext, engine::message::Message};
112    /// # use serde_json::json;
113    /// # let datalogic = std::sync::Arc::new(datalogic_rs::Engine::new());
114    /// # let mut message = Message::from_value(&json!({}));
115    /// let ctx = TaskContext::new(&mut message, &datalogic);
116    /// assert_eq!(ctx.workflow_id(), None);
117    /// ```
118    #[inline]
119    pub fn workflow_id(&self) -> Option<&str> {
120        self.identity.map(|i| i.workflow_id)
121    }
122
123    /// Id of the task being executed, when the engine built this context.
124    ///
125    /// Always a **leaf** task's id. Handlers run only on leaf tasks — a task
126    /// group is span bookkeeping recorded on the task that opens it, never a
127    /// dispatch target — so a group id can never appear here.
128    ///
129    /// `None` for a context built with [`Self::new`].
130    #[inline]
131    pub fn task_id(&self) -> Option<&str> {
132        self.identity.map(|i| i.task_id)
133    }
134
135    /// Sweep index of the enclosing looping workflow, or `None` when the
136    /// workflow does not carry a `loop`.
137    ///
138    /// This is a different fact from identity being unknown: a handler in a
139    /// non-looping workflow has both ids and no counter.
140    ///
141    /// Worth preferring over reading the counter out of `temp_data`, which
142    /// only works when the host gave `LoopConfig` a `counter` name and the
143    /// handler hardcodes that path. A loop with no named counter writes to no
144    /// path at all, and its sweep index is reachable no other way.
145    #[inline]
146    pub fn loop_counter(&self) -> Option<i64> {
147        self.loop_counter
148    }
149
150    /// A secret by dotted name, from the store the host configured through
151    /// [`crate::EngineBuilder::with_secrets`].
152    ///
153    /// For handlers whose config names a key (`"key_name": "partner_hmac"`)
154    /// rather than embedding a [`crate::Template`] — a `Template` field can
155    /// simply read `{"secret": "partner_hmac"}` and needs nothing here.
156    ///
157    /// `None` when the key is not declared, and always `None` for a context
158    /// built with [`Self::new`]. The contract for what you do with the value is
159    /// one line: **a handler must not write a secret-derived value into the
160    /// message.** Nothing in the engine records what this returns; whether it
161    /// stays unrecorded is the handler's business from here on.
162    #[inline]
163    pub fn secret(&self, name: &str) -> Option<&OwnedDataValue> {
164        self.secrets.get(name)
165    }
166
167    /// Borrow the message under processing. Use this when you need to inspect
168    /// the message id, payload, or audit trail; for reading and mutating the
169    /// `data` / `metadata` / `temp_data` context, prefer the typed helpers on
170    /// `TaskContext` itself.
171    #[inline]
172    pub fn message(&self) -> &Message {
173        self.message
174    }
175
176    /// Mutable access to the message. Prefer the typed helpers (`set`,
177    /// `add_error`) over poking at `message.context` directly — direct
178    /// mutations bypass the audit trail.
179    #[inline]
180    pub fn message_mut(&mut self) -> &mut Message {
181        self.message
182    }
183
184    /// Shared datalogic engine, in case the handler needs to evaluate ad-hoc
185    /// JSONLogic. Most handlers can ignore this argument.
186    #[inline]
187    pub fn datalogic(&self) -> &Arc<DatalogicEngine> {
188        self.datalogic
189    }
190
191    /// Read-only view of `data`. Returns `&OwnedDataValue::Null` if missing
192    /// (mirrors the `Index` fallback semantics of `serde_json::Value`).
193    #[inline]
194    pub fn data(&self) -> &OwnedDataValue {
195        self.message.data()
196    }
197
198    /// Read-only view of `metadata`.
199    #[inline]
200    pub fn metadata(&self) -> &OwnedDataValue {
201        self.message.metadata()
202    }
203
204    /// Read-only view of `temp_data`.
205    #[inline]
206    pub fn temp_data(&self) -> &OwnedDataValue {
207        self.message.temp_data()
208    }
209
210    /// The full `{data, metadata, temp_data}` tree — the root every workflow
211    /// JSONLogic expression is written against.
212    ///
213    /// [`Self::data`] / [`Self::metadata`] / [`Self::temp_data`] expose the three
214    /// slots individually; this is the whole-context accessor, so handlers do not
215    /// have to reach through `ctx.message().context`.
216    ///
217    /// Note `payload` is **not** part of this tree, and therefore not part of the
218    /// JSONLogic evaluation context — `{"var": "payload.foo"}` resolves to
219    /// nothing. Parse the payload into `data` first.
220    #[inline]
221    pub fn context(&self) -> &OwnedDataValue {
222        &self.message.context
223    }
224
225    /// Evaluate a pre-compiled expression against the message context, on the
226    /// worker thread's pooled arena.
227    ///
228    /// The same path [`crate::engine::executor::evaluate_condition`] takes, but
229    /// returning the value instead of collapsing it to a bool, and surfacing
230    /// evaluation failures as `Err` instead of `false`. That difference is
231    /// deliberate: a condition that fails to evaluate should not run its task,
232    /// whereas a handler reading a config value needs to know the read failed.
233    ///
234    /// # Errors
235    ///
236    /// [`DataflowError::LogicEvaluation`] if the expression fails to evaluate.
237    pub fn eval(&self, logic: &Logic) -> Result<OwnedDataValue> {
238        crate::engine::executor::eval_to_owned(self.datalogic, logic, &self.message.context)
239            .map_err(|e| DataflowError::LogicEvaluation(e.to_string()))
240    }
241
242    /// As [`Self::eval`], projected straight from the arena to
243    /// `serde_json::Value` in one walk — no `OwnedDataValue` intermediate and no
244    /// `serde_json::from_value` rebuild.
245    pub fn eval_json(&self, logic: &Logic) -> Result<JsonValue> {
246        crate::engine::executor::eval_to_json(self.datalogic, logic, &self.message.context)
247            .map_err(|e| DataflowError::LogicEvaluation(e.to_string()))
248    }
249
250    /// As [`Self::eval`], coerced to a *plain* string: a JSON string result
251    /// yields its contents, anything else its compact JSON form.
252    ///
253    /// # This disagrees with datalogic-rs on purpose
254    ///
255    /// datalogic-rs's `String: FromDataValue` — and therefore
256    /// `Session::eval_str` — keeps the JSON quoting, so a string result comes
257    /// back from it as `"\"abc\""`. This method returns `abc`.
258    ///
259    /// The name says `plain_string` rather than `to_string` precisely so the
260    /// difference is visible at the call site: two string semantics in one
261    /// ecosystem is a footgun, and these values end up in URL paths and message
262    /// keys. A test pins both sides, so it fails if either changes.
263    pub fn eval_to_plain_string(&self, logic: &Logic) -> Result<String> {
264        crate::engine::executor::eval_to_plain_string(self.datalogic, logic, &self.message.context)
265            .map_err(|e| DataflowError::LogicEvaluation(e.to_string()))
266    }
267
268    /// Look up a value by dot-path against the full context tree (rooted at
269    /// the unified `{data, metadata, temp_data}` object). Returns `None` if
270    /// the path doesn't resolve.
271    ///
272    /// Use the same path syntax as JSONLogic: `"data.user.name"`,
273    /// `"temp_data.items.0"`, `"metadata.progress.status_code"`.
274    #[inline]
275    pub fn get(&self, path: &str) -> Option<&OwnedDataValue> {
276        get_nested_value(&self.message.context, path)
277    }
278
279    /// Set a value at a dot-path on the context. Records a `Change` on the
280    /// audit trail when `message.capture_changes` is true; otherwise the
281    /// write happens but no audit entry is buffered.
282    ///
283    /// Intermediate objects/arrays are created on demand; see
284    /// [`crate::engine::utils::set_nested_value`] for the exact semantics
285    /// (numeric segments → arrays, `#` prefix → escaped object key, etc.).
286    pub fn set(&mut self, path: &str, value: OwnedDataValue) {
287        if self.message.capture_changes {
288            let old_value = get_nested_value(&self.message.context, path)
289                .cloned()
290                .unwrap_or(OwnedDataValue::Null);
291            let new_value = value.clone();
292            self.changes.push(Change {
293                path: Arc::from(path),
294                old_value,
295                new_value,
296            });
297        }
298        set_nested_value(&mut self.message.context, path, value);
299    }
300
301    /// Same as [`Self::set`] but accepts a `serde_json::Value` (bridges
302    /// through `OwnedDataValue::from`). Convenience for handlers that
303    /// already speak `serde_json::Value`.
304    #[inline]
305    pub fn set_json(&mut self, path: &str, value: &JsonValue) {
306        self.set(path, OwnedDataValue::from(value));
307    }
308
309    /// Append an error to `message.errors`. Convenience for
310    /// `ctx.message_mut().add_error(...)`.
311    #[inline]
312    pub fn add_error(&mut self, error: ErrorInfo) {
313        self.message.add_error(error);
314    }
315
316    /// Drain the accumulated changes. The workflow executor calls this after
317    /// the handler returns to fold them into the audit trail; tests and
318    /// benchmarks driving the trait directly can use it to inspect what the
319    /// handler buffered.
320    #[inline]
321    pub fn into_changes(self) -> Vec<Change> {
322        self.changes
323    }
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329    use crate::engine::executor::with_arena;
330    use crate::engine::utils::set_nested_value;
331    use serde_json::json;
332
333    fn dv(v: serde_json::Value) -> OwnedDataValue {
334        OwnedDataValue::from(&v)
335    }
336
337    fn engine() -> Arc<DatalogicEngine> {
338        Arc::new(DatalogicEngine::builder().with_templating(true).build())
339    }
340
341    /// A message with one key in each of the three context slots.
342    fn populated() -> Message {
343        let mut m = Message::from_value(&json!({"payload_key": "payload_value"}));
344        set_nested_value(&mut m.context, "data.x", dv(json!("dx")));
345        set_nested_value(&mut m.context, "metadata.x", dv(json!("mx")));
346        set_nested_value(&mut m.context, "temp_data.x", dv(json!("tx")));
347        m
348    }
349
350    #[test]
351    fn context_matches_the_three_slot_accessors() {
352        let mut m = populated();
353        let dl = engine();
354        let ctx = TaskContext::new(&mut m, &dl);
355
356        let whole = ctx.context();
357        assert_eq!(&whole["data"], ctx.data());
358        assert_eq!(&whole["metadata"], ctx.metadata());
359        assert_eq!(&whole["temp_data"], ctx.temp_data());
360        assert_eq!(whole, &ctx.message().context);
361    }
362
363    #[test]
364    fn eval_roots_at_the_unified_context_not_data_alone() {
365        let mut m = populated();
366        let dl = engine();
367        let ctx = TaskContext::new(&mut m, &dl);
368
369        for (path, expected) in [
370            ("data.x", "dx"),
371            ("metadata.x", "mx"),
372            ("temp_data.x", "tx"),
373        ] {
374            let logic = dl.compile_arc(&json!({"var": path})).unwrap();
375            assert_eq!(ctx.eval(&logic).unwrap(), dv(json!(expected)));
376            assert_eq!(ctx.eval_json(&logic).unwrap(), json!(expected));
377            assert_eq!(ctx.eval_to_plain_string(&logic).unwrap(), expected);
378        }
379    }
380
381    #[test]
382    fn payload_is_not_in_the_eval_context() {
383        // Stays true through the new surface: `payload` is a separate field on
384        // Message and never part of the JSONLogic root.
385        let mut m = populated();
386        let dl = engine();
387        let ctx = TaskContext::new(&mut m, &dl);
388
389        let logic = dl
390            .compile_arc(&json!({"var": "payload.payload_key"}))
391            .unwrap();
392        assert_eq!(ctx.eval(&logic).unwrap(), OwnedDataValue::Null);
393        assert_eq!(ctx.eval_json(&logic).unwrap(), serde_json::Value::Null);
394        assert_eq!(ctx.eval_to_plain_string(&logic).unwrap(), "null");
395    }
396
397    #[test]
398    fn eval_json_covers_every_result_kind() {
399        let mut m = Message::from_value(&json!({}));
400        let dl = engine();
401        let ctx = TaskContext::new(&mut m, &dl);
402
403        for expected in [
404            json!(null),
405            json!(true),
406            json!(42),
407            json!(1.5),
408            json!("abc"),
409            json!([1, 2]),
410            json!({"a": 1}),
411            json!({"a": [1, {"b": "c"}], "d": {"e": [true, null]}}),
412        ] {
413            let logic = dl.compile_arc(&expected).unwrap();
414            assert_eq!(
415                ctx.eval_json(&logic).unwrap(),
416                expected,
417                "round-trip for {expected}"
418            );
419            // The owned and JSON projections agree on the same result.
420            assert_eq!(
421                serde_json::Value::from(&ctx.eval(&logic).unwrap()),
422                expected
423            );
424        }
425    }
426
427    #[test]
428    fn eval_to_plain_string_unquotes_strings_and_compacts_the_rest() {
429        let mut m = Message::from_value(&json!({}));
430        let dl = engine();
431        let ctx = TaskContext::new(&mut m, &dl);
432
433        let cases = [
434            (json!("abc"), "abc"),
435            (json!(""), ""),
436            (json!(null), "null"),
437            (json!(true), "true"),
438            (json!(42), "42"),
439            (json!({"a": 1}), "{\"a\":1}"),
440            (json!([1, 2]), "[1,2]"),
441        ];
442        for (input, expected) in cases {
443            let logic = dl.compile_arc(&input).unwrap();
444            assert_eq!(
445                ctx.eval_to_plain_string(&logic).unwrap(),
446                expected,
447                "for {input}"
448            );
449        }
450    }
451
452    #[test]
453    fn eval_to_plain_string_diverges_from_datalogics_own_string_projection() {
454        // This test IS the documentation of the divergence — it must fail if
455        // either side changes. datalogic-rs's `String: FromDataValue` keeps the
456        // JSON quoting; ours does not.
457        let mut m = Message::from_value(&json!({}));
458        let dl = engine();
459        let ctx = TaskContext::new(&mut m, &dl);
460
461        // Non-ASCII plus an embedded quote, to cover escaping too.
462        let raw = "héllo \"world\" 世界";
463        let logic = dl.compile_arc(&json!(raw)).unwrap();
464
465        // Ours: contents, byte-identical.
466        assert_eq!(ctx.eval_to_plain_string(&logic).unwrap(), raw);
467
468        // datalogic's: JSON-quoted and escaped.
469        let via_session = dl.session().eval_str(&logic, &m.context).unwrap();
470        assert_ne!(
471            via_session, raw,
472            "if these agree, the divergence this method exists for is gone"
473        );
474        assert!(
475            via_session.starts_with('"') && via_session.contains("\\\""),
476            "datalogic keeps the quoting and escaping, got: {via_session}"
477        );
478    }
479
480    #[test]
481    fn eval_surfaces_an_error_where_evaluate_condition_returns_false() {
482        // Asserted together so the difference is intentional and visible: a
483        // condition that fails should not run its task; a handler reading a
484        // config value needs to know the read failed.
485        let mut m = Message::from_value(&json!({}));
486        let dl = engine();
487
488        // `+` over a non-numeric operand fails to evaluate.
489        let bad = dl.compile_arc(&json!({"+": ["abc", 1]})).unwrap();
490
491        let condition_result =
492            crate::engine::executor::evaluate_condition(&dl, Some(&bad), &m.context);
493
494        let ctx = TaskContext::new(&mut m, &dl);
495        let eval_result = ctx.eval(&bad);
496
497        match (&condition_result, &eval_result) {
498            (Ok(false), Err(DataflowError::LogicEvaluation(msg))) => {
499                assert!(!msg.is_empty(), "the error message must not be empty");
500            }
501            other => panic!(
502                "expected evaluate_condition Ok(false) alongside eval Err(LogicEvaluation), got {other:?}"
503            ),
504        }
505    }
506
507    #[test]
508    fn consecutive_evals_and_interleaved_sets_both_work() {
509        // The arena is rewound between calls, not corrupted; and an eval between
510        // two `set`s leaves the buffered Changes intact.
511        let mut m = populated();
512        let dl = engine();
513        let first = dl.compile_arc(&json!({"var": "data.x"})).unwrap();
514        let second = dl.compile_arc(&json!({"var": "metadata.x"})).unwrap();
515
516        let mut ctx = TaskContext::new(&mut m, &dl);
517
518        assert_eq!(ctx.eval_json(&first).unwrap(), json!("dx"));
519        assert_eq!(ctx.eval_json(&second).unwrap(), json!("mx"));
520        assert_eq!(ctx.eval_json(&first).unwrap(), json!("dx"));
521
522        ctx.set("data.written", dv(json!(1)));
523        assert_eq!(ctx.eval_json(&first).unwrap(), json!("dx"));
524        ctx.set("data.written2", dv(json!(2)));
525
526        let changes = ctx.into_changes();
527        let paths: Vec<&str> = changes.iter().map(|c| &*c.path).collect();
528        assert_eq!(paths, vec!["data.written", "data.written2"]);
529    }
530
531    #[test]
532    fn eval_inside_a_with_arena_scope_falls_back_instead_of_panicking() {
533        // `TaskContext::new` is pub so a test or bench can construct one inside a
534        // `with_arena` closure. The `try_borrow_mut` fallback makes that return
535        // `Ok` on a fresh Bump rather than panicking out of the arena scope.
536        let mut m = populated();
537        let dl = engine();
538        let logic = dl.compile_arc(&json!({"var": "data.x"})).unwrap();
539
540        let got = with_arena(|_| {
541            let ctx = TaskContext::new(&mut m, &dl);
542            ctx.eval_json(&logic)
543        });
544
545        assert_eq!(got.unwrap(), json!("dx"));
546    }
547}