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(crate::engine::compiler::datalogic_engine_builder().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}