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::{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    /// [`crate::DataflowError::LogicEvaluation`] if the expression fails to
237    /// evaluate, or [`crate::DataflowError::BudgetExceeded`] if it was aborted
238    /// for crossing the ceiling set by
239    /// [`crate::EngineBuilder::with_ops_budget`].
240    pub fn eval(&self, logic: &Logic) -> Result<OwnedDataValue> {
241        crate::engine::executor::eval_to_owned(self.datalogic, logic, &self.message.context)
242            .map_err(|e| crate::engine::error::from_datalogic_eval(&e))
243    }
244
245    /// As [`Self::eval`], projected straight from the arena to
246    /// `serde_json::Value` in one walk — no `OwnedDataValue` intermediate and no
247    /// `serde_json::from_value` rebuild.
248    pub fn eval_json(&self, logic: &Logic) -> Result<JsonValue> {
249        crate::engine::executor::eval_to_json(self.datalogic, logic, &self.message.context)
250            .map_err(|e| crate::engine::error::from_datalogic_eval(&e))
251    }
252
253    /// As [`Self::eval`], coerced to a *plain* string: a JSON string result
254    /// yields its contents, anything else its compact JSON form.
255    ///
256    /// # This disagrees with datalogic-rs on purpose
257    ///
258    /// datalogic-rs's `String: FromDataValue` — and therefore
259    /// `Session::eval_str` — keeps the JSON quoting, so a string result comes
260    /// back from it as `"\"abc\""`. This method returns `abc`.
261    ///
262    /// The name says `plain_string` rather than `to_string` precisely so the
263    /// difference is visible at the call site: two string semantics in one
264    /// ecosystem is a footgun, and these values end up in URL paths and message
265    /// keys. A test pins both sides, so it fails if either changes.
266    pub fn eval_to_plain_string(&self, logic: &Logic) -> Result<String> {
267        crate::engine::executor::eval_to_plain_string(self.datalogic, logic, &self.message.context)
268            .map_err(|e| crate::engine::error::from_datalogic_eval(&e))
269    }
270
271    /// Look up a value by dot-path against the full context tree (rooted at
272    /// the unified `{data, metadata, temp_data}` object). Returns `None` if
273    /// the path doesn't resolve.
274    ///
275    /// Use the same path syntax as JSONLogic: `"data.user.name"`,
276    /// `"temp_data.items.0"`, `"metadata.progress.status_code"`.
277    #[inline]
278    pub fn get(&self, path: &str) -> Option<&OwnedDataValue> {
279        get_nested_value(&self.message.context, path)
280    }
281
282    /// Set a value at a dot-path on the context. Records a `Change` on the
283    /// audit trail when `message.capture_changes` is true; otherwise the
284    /// write happens but no audit entry is buffered.
285    ///
286    /// Intermediate objects/arrays are created on demand; see
287    /// [`crate::engine::utils::set_nested_value`] for the exact semantics
288    /// (numeric segments → arrays, `#` prefix → escaped object key, etc.).
289    pub fn set(&mut self, path: &str, value: OwnedDataValue) {
290        if self.message.capture_changes {
291            let old_value = get_nested_value(&self.message.context, path)
292                .cloned()
293                .unwrap_or(OwnedDataValue::Null);
294            let new_value = value.clone();
295            self.changes.push(Change {
296                path: Arc::from(path),
297                old_value,
298                new_value,
299            });
300        }
301        set_nested_value(&mut self.message.context, path, value);
302    }
303
304    /// Same as [`Self::set`] but accepts a `serde_json::Value` (bridges
305    /// through `OwnedDataValue::from`). Convenience for handlers that
306    /// already speak `serde_json::Value`.
307    #[inline]
308    pub fn set_json(&mut self, path: &str, value: &JsonValue) {
309        self.set(path, OwnedDataValue::from(value));
310    }
311
312    /// Append an error to `message.errors`. Convenience for
313    /// `ctx.message_mut().add_error(...)`.
314    #[inline]
315    pub fn add_error(&mut self, error: ErrorInfo) {
316        self.message.add_error(error);
317    }
318
319    /// Drain the accumulated changes. The workflow executor calls this after
320    /// the handler returns to fold them into the audit trail; tests and
321    /// benchmarks driving the trait directly can use it to inspect what the
322    /// handler buffered.
323    #[inline]
324    pub fn into_changes(self) -> Vec<Change> {
325        self.changes
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332    use crate::engine::error::DataflowError;
333    use crate::engine::executor::with_arena;
334    use crate::engine::utils::set_nested_value;
335    use serde_json::json;
336
337    fn dv(v: serde_json::Value) -> OwnedDataValue {
338        OwnedDataValue::from(&v)
339    }
340
341    fn engine() -> Arc<DatalogicEngine> {
342        Arc::new(crate::engine::compiler::datalogic_engine_builder().build())
343    }
344
345    /// A message with one key in each of the three context slots.
346    fn populated() -> Message {
347        let mut m = Message::from_value(&json!({"payload_key": "payload_value"}));
348        set_nested_value(&mut m.context, "data.x", dv(json!("dx")));
349        set_nested_value(&mut m.context, "metadata.x", dv(json!("mx")));
350        set_nested_value(&mut m.context, "temp_data.x", dv(json!("tx")));
351        m
352    }
353
354    #[test]
355    fn context_matches_the_three_slot_accessors() {
356        let mut m = populated();
357        let dl = engine();
358        let ctx = TaskContext::new(&mut m, &dl);
359
360        let whole = ctx.context();
361        assert_eq!(&whole["data"], ctx.data());
362        assert_eq!(&whole["metadata"], ctx.metadata());
363        assert_eq!(&whole["temp_data"], ctx.temp_data());
364        assert_eq!(whole, &ctx.message().context);
365    }
366
367    #[test]
368    fn eval_roots_at_the_unified_context_not_data_alone() {
369        let mut m = populated();
370        let dl = engine();
371        let ctx = TaskContext::new(&mut m, &dl);
372
373        for (path, expected) in [
374            ("data.x", "dx"),
375            ("metadata.x", "mx"),
376            ("temp_data.x", "tx"),
377        ] {
378            let logic = dl.compile_arc(&json!({"var": path})).unwrap();
379            assert_eq!(ctx.eval(&logic).unwrap(), dv(json!(expected)));
380            assert_eq!(ctx.eval_json(&logic).unwrap(), json!(expected));
381            assert_eq!(ctx.eval_to_plain_string(&logic).unwrap(), expected);
382        }
383    }
384
385    #[test]
386    fn payload_is_not_in_the_eval_context() {
387        // Stays true through the new surface: `payload` is a separate field on
388        // Message and never part of the JSONLogic root.
389        let mut m = populated();
390        let dl = engine();
391        let ctx = TaskContext::new(&mut m, &dl);
392
393        let logic = dl
394            .compile_arc(&json!({"var": "payload.payload_key"}))
395            .unwrap();
396        assert_eq!(ctx.eval(&logic).unwrap(), OwnedDataValue::Null);
397        assert_eq!(ctx.eval_json(&logic).unwrap(), serde_json::Value::Null);
398        assert_eq!(ctx.eval_to_plain_string(&logic).unwrap(), "null");
399    }
400
401    #[test]
402    fn eval_json_covers_every_result_kind() {
403        let mut m = Message::from_value(&json!({}));
404        let dl = engine();
405        let ctx = TaskContext::new(&mut m, &dl);
406
407        for expected in [
408            json!(null),
409            json!(true),
410            json!(42),
411            json!(1.5),
412            json!("abc"),
413            json!([1, 2]),
414            json!({"a": 1}),
415            json!({"a": [1, {"b": "c"}], "d": {"e": [true, null]}}),
416        ] {
417            let logic = dl.compile_arc(&expected).unwrap();
418            assert_eq!(
419                ctx.eval_json(&logic).unwrap(),
420                expected,
421                "round-trip for {expected}"
422            );
423            // The owned and JSON projections agree on the same result.
424            assert_eq!(
425                serde_json::Value::from(&ctx.eval(&logic).unwrap()),
426                expected
427            );
428        }
429    }
430
431    #[test]
432    fn eval_to_plain_string_unquotes_strings_and_compacts_the_rest() {
433        let mut m = Message::from_value(&json!({}));
434        let dl = engine();
435        let ctx = TaskContext::new(&mut m, &dl);
436
437        let cases = [
438            (json!("abc"), "abc"),
439            (json!(""), ""),
440            (json!(null), "null"),
441            (json!(true), "true"),
442            (json!(42), "42"),
443            (json!({"a": 1}), "{\"a\":1}"),
444            (json!([1, 2]), "[1,2]"),
445        ];
446        for (input, expected) in cases {
447            let logic = dl.compile_arc(&input).unwrap();
448            assert_eq!(
449                ctx.eval_to_plain_string(&logic).unwrap(),
450                expected,
451                "for {input}"
452            );
453        }
454    }
455
456    #[test]
457    fn eval_to_plain_string_diverges_from_datalogics_own_string_projection() {
458        // This test IS the documentation of the divergence — it must fail if
459        // either side changes. datalogic-rs's `String: FromDataValue` keeps the
460        // JSON quoting; ours does not.
461        let mut m = Message::from_value(&json!({}));
462        let dl = engine();
463        let ctx = TaskContext::new(&mut m, &dl);
464
465        // Non-ASCII plus an embedded quote, to cover escaping too.
466        let raw = "héllo \"world\" 世界";
467        let logic = dl.compile_arc(&json!(raw)).unwrap();
468
469        // Ours: contents, byte-identical.
470        assert_eq!(ctx.eval_to_plain_string(&logic).unwrap(), raw);
471
472        // datalogic's: JSON-quoted and escaped.
473        let via_session = dl.session().eval_str(&logic, &m.context).unwrap();
474        assert_ne!(
475            via_session, raw,
476            "if these agree, the divergence this method exists for is gone"
477        );
478        assert!(
479            via_session.starts_with('"') && via_session.contains("\\\""),
480            "datalogic keeps the quoting and escaping, got: {via_session}"
481        );
482    }
483
484    #[test]
485    fn eval_surfaces_an_error_where_evaluate_condition_returns_false() {
486        // Asserted together so the difference is intentional and visible: a
487        // condition that fails should not run its task; a handler reading a
488        // config value needs to know the read failed.
489        let mut m = Message::from_value(&json!({}));
490        let dl = engine();
491
492        // `+` over a non-numeric operand fails to evaluate.
493        let bad = dl.compile_arc(&json!({"+": ["abc", 1]})).unwrap();
494
495        let condition_result =
496            crate::engine::executor::evaluate_condition(&dl, Some(&bad), &m.context);
497
498        let ctx = TaskContext::new(&mut m, &dl);
499        let eval_result = ctx.eval(&bad);
500
501        match (&condition_result, &eval_result) {
502            (Ok(false), Err(DataflowError::LogicEvaluation(msg))) => {
503                assert!(!msg.is_empty(), "the error message must not be empty");
504            }
505            other => panic!(
506                "expected evaluate_condition Ok(false) alongside eval Err(LogicEvaluation), got {other:?}"
507            ),
508        }
509    }
510
511    #[test]
512    fn consecutive_evals_and_interleaved_sets_both_work() {
513        // The arena is rewound between calls, not corrupted; and an eval between
514        // two `set`s leaves the buffered Changes intact.
515        let mut m = populated();
516        let dl = engine();
517        let first = dl.compile_arc(&json!({"var": "data.x"})).unwrap();
518        let second = dl.compile_arc(&json!({"var": "metadata.x"})).unwrap();
519
520        let mut ctx = TaskContext::new(&mut m, &dl);
521
522        assert_eq!(ctx.eval_json(&first).unwrap(), json!("dx"));
523        assert_eq!(ctx.eval_json(&second).unwrap(), json!("mx"));
524        assert_eq!(ctx.eval_json(&first).unwrap(), json!("dx"));
525
526        ctx.set("data.written", dv(json!(1)));
527        assert_eq!(ctx.eval_json(&first).unwrap(), json!("dx"));
528        ctx.set("data.written2", dv(json!(2)));
529
530        let changes = ctx.into_changes();
531        let paths: Vec<&str> = changes.iter().map(|c| &*c.path).collect();
532        assert_eq!(paths, vec!["data.written", "data.written2"]);
533    }
534
535    #[test]
536    fn eval_inside_a_with_arena_scope_falls_back_instead_of_panicking() {
537        // `TaskContext::new` is pub so a test or bench can construct one inside a
538        // `with_arena` closure. The `try_borrow_mut` fallback makes that return
539        // `Ok` on a fresh Bump rather than panicking out of the arena scope.
540        let mut m = populated();
541        let dl = engine();
542        let logic = dl.compile_arc(&json!({"var": "data.x"})).unwrap();
543
544        let got = with_arena(|_| {
545            let ctx = TaskContext::new(&mut m, &dl);
546            ctx.eval_json(&logic)
547        });
548
549        assert_eq!(got.unwrap(), json!("dx"));
550    }
551}