dataflow_rs/engine/steps.rs
1//! The authored step grammar: how a workflow's `tasks` array is read.
2//!
3//! An element of `tasks` is either a [`Task`] or a [`TaskGroup`], and the
4//! parser flattens that tree into `Workflow::tasks` at deserialization time so
5//! the executor keeps walking a flat slice. This module owns both halves of
6//! that grammar:
7//!
8//! - [`flatten`] — the parser, which builds `Vec<Task>` and fails on the first
9//! malformed element.
10//! - [`walk_authored_steps`] — a public walker over the *authored* JSON, which
11//! never fails and yields every node with the coordinate the author typed.
12//!
13//! They live together deliberately. The group test and the depth cap are the
14//! two facts a downstream host would otherwise have to mirror, and keeping the
15//! parser and the walker in one file puts both users of those facts on screen
16//! for anyone who changes them.
17//!
18//! # Why a host needs the authored shape
19//!
20//! By the time a host holds a [`Workflow`](crate::Workflow), the tree is gone:
21//! `tasks` is flat and `Task::group_starts` is not part of the stable API. But
22//! a validation error, a lint finding or a dependency extraction has to point
23//! at `tasks[1].tasks[0].id` — the coordinate in the document the author
24//! actually wrote. That is what this walker provides.
25
26use super::task::{Task, TaskGroup};
27use serde::Deserialize;
28use serde::de::{Deserializer, Error as DeError};
29use serde_json::Value;
30
31/// Maximum group nesting the parser accepts.
32///
33/// Deeper than this is a generated-JSON accident rather than an authored
34/// control-flow shape, and the bound keeps the per-task `group_starts` vector
35/// trivially small.
36///
37/// Public so a host validating authored JSON reads the engine's real limit
38/// instead of copying the number. Depth counts *enclosing groups*: a top-level
39/// group is at depth 0, so groups are accepted at depths `0..MAX_GROUP_DEPTH`
40/// and a group at `MAX_GROUP_DEPTH` is rejected by the parser and reported as
41/// [`StepKind::TooDeep`] by the walker.
42pub const MAX_GROUP_DEPTH: usize = 8;
43
44/// Whether this authored step element parses as a task group.
45///
46/// The test is **presence of a `tasks` key, nothing else** — the same test the
47/// parser makes. In particular a `tasks` key holding a non-array is still a
48/// group, and a malformed one: the parser will reject it as a bad group rather
49/// than silently reading it as a task.
50///
51/// An element carrying neither `tasks` nor `function` is *not* a group, so a
52/// caller reports a broken task — which is what the parser's own diagnostic
53/// says (`missing field 'function'`).
54///
55/// ```
56/// use dataflow_rs::engine::steps::is_group;
57/// use serde_json::json;
58///
59/// assert!(is_group(&json!({"id": "g", "tasks": []})));
60/// assert!(
61/// is_group(&json!({"id": "g", "tasks": "oops"})),
62/// "presence of the key, not its type — this is a malformed group"
63/// );
64/// assert!(!is_group(&json!({"id": "t", "function": {"name": "map"}})));
65/// assert!(!is_group(&json!({"id": "t"})), "neither key: a broken task");
66/// assert!(!is_group(&json!("not even an object")));
67/// ```
68#[inline]
69pub fn is_group(step: &Value) -> bool {
70 step.get("tasks").is_some()
71}
72
73/// What an authored step element is.
74///
75/// Deliberately not `#[non_exhaustive]`: a caller matching on this is deciding
76/// how to report a node, and a fourth kind would need that decision revisited
77/// at every site.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum StepKind {
80 /// A task — or an element malformed enough that it is not a group either.
81 Leaf,
82 /// A task group. Its members follow it in the walk.
83 Group,
84 /// A group nested at or beyond [`MAX_GROUP_DEPTH`]. The parser rejects the
85 /// whole workflow here; the walker reports it and does **not** descend, so
86 /// nothing is silently truncated without a node to point at.
87 TooDeep,
88}
89
90/// One node of an authored `tasks` tree.
91#[derive(Debug, Clone)]
92pub struct AuthoredStep<'a> {
93 /// The coordinate the author typed, rooted at the workflow: `tasks[1]`,
94 /// `tasks[1].tasks[0]`. Append your own field segment to point at a
95 /// property — `format!("{}.id", step.path)`.
96 pub path: String,
97 /// The element itself, borrowed from the input.
98 pub node: &'a Value,
99 /// Whether this is a task, a group, or a group too deeply nested.
100 pub kind: StepKind,
101 /// Enclosing groups. `0` for a top-level element.
102 pub depth: usize,
103}
104
105/// Walk an authored `tasks` array, yielding every node with its path.
106///
107/// Traversal is document order, pre-order: a group is yielded before its
108/// members, so filtering to [`StepKind::Leaf`] reproduces the engine's
109/// flattened `Workflow::tasks` exactly, in order.
110///
111/// **This walker never fails.** Where [`flatten`] returns `Err` on the first
112/// malformed element, an empty group or an over-deep group, the walker yields
113/// those nodes so a validator can collect every violation in one pass. A
114/// `tasks` value that is not an array yields nothing at all — whether `tasks`
115/// is a non-empty array is a rule for the caller to report, not for this walk
116/// to fail on.
117///
118/// ```
119/// use dataflow_rs::engine::steps::{StepKind, walk_authored_steps};
120/// use serde_json::json;
121///
122/// let tasks = json!([
123/// {"id": "first", "function": {"name": "map", "input": {"mappings": []}}},
124/// {"id": "guard", "condition": true, "tasks": [
125/// {"id": "inner", "function": {"name": "map", "input": {"mappings": []}}}
126/// ]}
127/// ]);
128///
129/// let steps: Vec<_> = walk_authored_steps(&tasks).collect();
130/// let seen: Vec<(&str, StepKind)> =
131/// steps.iter().map(|s| (s.path.as_str(), s.kind)).collect();
132///
133/// assert_eq!(seen, vec![
134/// ("tasks[0]", StepKind::Leaf),
135/// ("tasks[1]", StepKind::Group),
136/// ("tasks[1].tasks[0]", StepKind::Leaf),
137/// ]);
138/// ```
139pub fn walk_authored_steps(tasks: &Value) -> AuthoredSteps<'_> {
140 AuthoredSteps {
141 stack: match tasks.as_array() {
142 Some(items) => vec![Frame {
143 items,
144 idx: 0,
145 prefix: "tasks".to_string(),
146 depth: 0,
147 }],
148 // Not an array: nothing to walk. The caller reports the shape.
149 None => Vec::new(),
150 },
151 }
152}
153
154/// One level of the walk: the array being iterated, how far through it we are,
155/// and the path prefix its elements hang off.
156struct Frame<'a> {
157 items: &'a [Value],
158 idx: usize,
159 prefix: String,
160 depth: usize,
161}
162
163/// Iterator returned by [`walk_authored_steps`].
164///
165/// Lazy, over an explicit stack rather than recursion, so nothing is allocated
166/// beyond each node's `path` and the stack itself — which is bounded by
167/// [`MAX_GROUP_DEPTH`].
168pub struct AuthoredSteps<'a> {
169 stack: Vec<Frame<'a>>,
170}
171
172impl<'a> Iterator for AuthoredSteps<'a> {
173 type Item = AuthoredStep<'a>;
174
175 fn next(&mut self) -> Option<Self::Item> {
176 loop {
177 let frame = self.stack.last_mut()?;
178 let Some(node) = frame.items.get(frame.idx) else {
179 // This level is exhausted; resume the one that opened it.
180 self.stack.pop();
181 continue;
182 };
183
184 let path = format!("{}[{}]", frame.prefix, frame.idx);
185 let depth = frame.depth;
186 frame.idx += 1;
187
188 if !is_group(node) {
189 return Some(AuthoredStep {
190 path,
191 node,
192 kind: StepKind::Leaf,
193 depth,
194 });
195 }
196
197 // A group at the cap is what the parser rejects. Report it and do
198 // not descend — the members are unreachable either way, and this
199 // gives the caller a node to point at instead of a silent gap.
200 if depth >= MAX_GROUP_DEPTH {
201 return Some(AuthoredStep {
202 path,
203 node,
204 kind: StepKind::TooDeep,
205 depth,
206 });
207 }
208
209 // Descend only into a well-formed `tasks` array. A `tasks` key
210 // holding anything else is still a group — a malformed one — and is
211 // reported as such with no members.
212 if let Some(children) = node.get("tasks").and_then(Value::as_array) {
213 self.stack.push(Frame {
214 items: children,
215 idx: 0,
216 prefix: format!("{path}.tasks"),
217 depth: depth + 1,
218 });
219 }
220
221 return Some(AuthoredStep {
222 path,
223 node,
224 kind: StepKind::Group,
225 depth,
226 });
227 }
228 }
229}
230
231/// The non-`tasks` half of a group element. `tasks` is carried too so the
232/// whole element deserializes in one pass; unknown keys are ignored, as
233/// everywhere else in the workflow schema.
234#[derive(Deserialize)]
235struct GroupHeader {
236 id: String,
237 #[serde(default)]
238 name: Option<String>,
239 #[serde(default)]
240 description: Option<String>,
241 #[serde(default = "crate::engine::utils::default_condition")]
242 condition: Value,
243 #[serde(default)]
244 terminal: bool,
245 tasks: Vec<Value>,
246}
247
248/// `deserialize_with` target for `Workflow::tasks`.
249///
250/// Fails fast, unlike [`walk_authored_steps`]: the engine will not run a
251/// workflow it cannot fully parse, so the first malformed element ends the
252/// attempt. A host that wants every problem at once walks the authored JSON
253/// instead.
254pub(crate) fn flatten<'de, D>(deserializer: D) -> Result<Vec<Task>, D::Error>
255where
256 D: Deserializer<'de>,
257{
258 let steps = Vec::<Value>::deserialize(deserializer)?;
259 let mut tasks = Vec::with_capacity(steps.len());
260 walk(&steps, 0, &mut tasks).map_err(D::Error::custom)?;
261 Ok(tasks)
262}
263
264/// Append `steps` to `out` in document order, recording group spans.
265fn walk(steps: &[Value], depth: usize, out: &mut Vec<Task>) -> Result<(), String> {
266 for step in steps {
267 if !is_group(step) {
268 let task: Task = serde_json::from_value(step.clone())
269 .map_err(|e| format!("invalid task in workflow tasks: {e}"))?;
270 out.push(task);
271 continue;
272 }
273
274 if depth >= MAX_GROUP_DEPTH {
275 return Err(format!(
276 "task groups nested deeper than {MAX_GROUP_DEPTH} levels"
277 ));
278 }
279
280 let header: GroupHeader = serde_json::from_value(step.clone())
281 .map_err(|e| format!("invalid task group in workflow tasks: {e}"))?;
282
283 let start = out.len();
284 walk(&header.tasks, depth + 1, out)?;
285 let end = out.len();
286 if end == start {
287 return Err(format!(
288 "task group '{}' contains no tasks — an empty group can only be a mistake",
289 header.id
290 ));
291 }
292
293 // Outermost first: an inner group nested at the same start index
294 // has already pushed its own entry, so this one goes in front of
295 // it. Bounded by `MAX_GROUP_DEPTH`, so the shift is trivial.
296 out[start].group_starts.insert(
297 0,
298 TaskGroup {
299 id: header.id,
300 name: header.name,
301 description: header.description,
302 condition: header.condition,
303 compiled_condition: None,
304 terminal: header.terminal,
305 end,
306 },
307 );
308 }
309 Ok(())
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315 use crate::engine::workflow::Workflow;
316 use serde_json::json;
317
318 fn leaf(id: &str) -> Value {
319 json!({"id": id, "name": id, "function": {"name": "map", "input": {"mappings": []}}})
320 }
321
322 /// `n` groups nested one inside the next, innermost holding one task.
323 /// `n == 1` is a single top-level group, which sits at depth 0.
324 fn nested_groups(n: usize) -> Value {
325 let mut node = leaf("innermost");
326 for level in (0..n).rev() {
327 node = json!({"id": format!("g{level}"), "condition": true, "tasks": [node]});
328 }
329 json!([node])
330 }
331
332 fn workflow_with(tasks: &Value) -> Result<Workflow, String> {
333 Workflow::from_json(
334 &json!({"id": "w", "name": "w", "priority": 0, "tasks": tasks}).to_string(),
335 )
336 .map_err(|e| e.to_string())
337 }
338
339 fn kinds(tasks: &Value) -> Vec<(String, StepKind, usize)> {
340 walk_authored_steps(tasks)
341 .map(|s| (s.path, s.kind, s.depth))
342 .collect()
343 }
344
345 /// Acceptance criterion: the walker's leaf set is the parser's flattened
346 /// `Workflow::tasks`, by id and by order. This is what pins the two
347 /// recursions to each other — sharing `is_group` alone would not catch a
348 /// divergence in the walk itself.
349 #[test]
350 fn walker_leaves_match_the_parsers_flattened_tasks() {
351 let fixtures = vec![
352 json!([leaf("a"), leaf("b")]),
353 json!([{"id": "g", "condition": true, "tasks": [leaf("a"), leaf("b")]}]),
354 json!([
355 leaf("before"),
356 {"id": "g1", "condition": true, "tasks": [
357 leaf("in1"),
358 {"id": "g2", "condition": true, "tasks": [leaf("deep")]},
359 leaf("in2"),
360 ]},
361 leaf("after"),
362 ]),
363 nested_groups(MAX_GROUP_DEPTH),
364 ];
365
366 for tasks in fixtures {
367 let parsed = workflow_with(&tasks).expect("fixture parses");
368 let from_parser: Vec<&str> = parsed.tasks.iter().map(|t| t.id.as_str()).collect();
369
370 let from_walker: Vec<&str> = walk_authored_steps(&tasks)
371 .filter(|s| s.kind == StepKind::Leaf)
372 .map(|s| s.node["id"].as_str().unwrap())
373 .collect();
374
375 assert_eq!(
376 from_walker, from_parser,
377 "walker leaves must equal the flattened tasks, in order, for {tasks}"
378 );
379 }
380 }
381
382 #[test]
383 fn paths_are_the_coordinates_the_author_typed() {
384 let tasks = json!([
385 leaf("first"),
386 {"id": "g", "condition": true, "tasks": [leaf("inner"), leaf("second")]},
387 ]);
388
389 let paths: Vec<String> = walk_authored_steps(&tasks).map(|s| s.path).collect();
390 assert_eq!(
391 paths,
392 vec![
393 "tasks[0]",
394 "tasks[1]",
395 "tasks[1].tasks[0]",
396 "tasks[1].tasks[1]"
397 ]
398 );
399 }
400
401 #[test]
402 fn groups_are_yielded_before_their_members() {
403 let tasks = json!([{"id": "g", "condition": true, "tasks": [leaf("inner")]}]);
404 assert_eq!(
405 kinds(&tasks),
406 vec![
407 ("tasks[0]".to_string(), StepKind::Group, 0),
408 ("tasks[0].tasks[0]".to_string(), StepKind::Leaf, 1),
409 ],
410 "pre-order, so filtering to Leaf reproduces parse order"
411 );
412 }
413
414 #[test]
415 fn max_group_depth_is_the_value_the_parser_enforces() {
416 // Exactly at the cap parses: MAX_GROUP_DEPTH groups occupy depths
417 // 0..MAX_GROUP_DEPTH.
418 let ok = nested_groups(MAX_GROUP_DEPTH);
419 assert!(
420 workflow_with(&ok).is_ok(),
421 "{MAX_GROUP_DEPTH} levels of nesting is accepted"
422 );
423 assert!(
424 walk_authored_steps(&ok).all(|s| s.kind != StepKind::TooDeep),
425 "and the walker agrees nothing is too deep"
426 );
427
428 // One more is rejected by the parser…
429 let too_deep = nested_groups(MAX_GROUP_DEPTH + 1);
430 let err = workflow_with(&too_deep).expect_err("one level past the cap is rejected");
431 assert!(
432 err.contains("nested deeper than"),
433 "parser reports the depth cap, got: {err}"
434 );
435
436 // …and reported — not silently dropped — by the walker, at the same node.
437 let flagged: Vec<_> = walk_authored_steps(&too_deep)
438 .filter(|s| s.kind == StepKind::TooDeep)
439 .collect();
440 assert_eq!(flagged.len(), 1, "exactly the one offending group");
441 assert_eq!(flagged[0].depth, MAX_GROUP_DEPTH);
442 assert_eq!(flagged[0].node["id"], json!(format!("g{MAX_GROUP_DEPTH}")));
443 }
444
445 #[test]
446 fn a_too_deep_group_is_not_descended_into() {
447 let tasks = nested_groups(MAX_GROUP_DEPTH + 1);
448 let deepest = walk_authored_steps(&tasks).map(|s| s.depth).max().unwrap();
449 assert_eq!(
450 deepest, MAX_GROUP_DEPTH,
451 "the walk stops at the offending group; its members are never yielded"
452 );
453 assert!(
454 !walk_authored_steps(&tasks).any(|s| s.node["id"] == json!("innermost")),
455 "the leaf below the cap is unreachable, and reported as such by its absent parent"
456 );
457 }
458
459 #[test]
460 fn a_leaf_is_never_too_deep() {
461 // A task sitting inside the maximum legal nesting is fine — the parser
462 // checks depth only when it opens a group.
463 let tasks = nested_groups(MAX_GROUP_DEPTH);
464 let innermost = walk_authored_steps(&tasks)
465 .find(|s| s.node["id"] == json!("innermost"))
466 .expect("the deepest leaf is yielded");
467 assert_eq!(innermost.kind, StepKind::Leaf);
468 assert_eq!(innermost.depth, MAX_GROUP_DEPTH);
469 }
470
471 #[test]
472 fn an_element_with_neither_tasks_nor_function_is_a_leaf() {
473 // Reported as a broken *task*, matching the parser's own diagnostic —
474 // not as a broken group.
475 let tasks = json!([{"id": "orphan"}]);
476 assert_eq!(
477 kinds(&tasks),
478 vec![("tasks[0]".to_string(), StepKind::Leaf, 0)]
479 );
480
481 let err = workflow_with(&tasks).expect_err("the parser rejects it");
482 assert!(
483 err.contains("invalid task in workflow tasks"),
484 "and calls it a task, got: {err}"
485 );
486 }
487
488 #[test]
489 fn a_tasks_key_that_is_not_an_array_is_still_a_group() {
490 // Presence of the key decides, not its type. This is exactly where the
491 // TypeScript `isTaskGroup` used to disagree with the engine.
492 let tasks = json!([{"id": "g", "tasks": "oops"}]);
493 assert!(is_group(&tasks[0]));
494 assert_eq!(
495 kinds(&tasks),
496 vec![("tasks[0]".to_string(), StepKind::Group, 0)],
497 "a malformed group with no members, not a task"
498 );
499
500 let err = workflow_with(&tasks).expect_err("the parser rejects it");
501 assert!(
502 err.contains("invalid task group"),
503 "and calls it a group, got: {err}"
504 );
505 }
506
507 #[test]
508 fn an_empty_group_is_yielded_not_an_error() {
509 // The walker is total where the parser fails fast, so a validator can
510 // collect this alongside every other violation in one pass.
511 let tasks = json!([{"id": "empty", "condition": true, "tasks": []}]);
512 assert_eq!(
513 kinds(&tasks),
514 vec![("tasks[0]".to_string(), StepKind::Group, 0)]
515 );
516
517 let err = workflow_with(&tasks).expect_err("the parser rejects an empty group");
518 assert!(err.contains("contains no tasks"), "got: {err}");
519 }
520
521 #[test]
522 fn a_non_array_input_yields_nothing() {
523 for input in [
524 Value::Null,
525 json!({}),
526 json!("tasks"),
527 json!(7),
528 json!({"tasks": []}),
529 ] {
530 assert_eq!(
531 walk_authored_steps(&input).count(),
532 0,
533 "not an array, so nothing to walk: {input}"
534 );
535 }
536 }
537
538 #[test]
539 fn an_empty_array_yields_nothing_and_leaves_no_frame_behind() {
540 assert_eq!(walk_authored_steps(&json!([])).count(), 0);
541 // Nested empties must not hang or double-yield the parent.
542 let tasks = json!([{"id": "g", "tasks": [{"id": "inner", "tasks": []}]}]);
543 assert_eq!(
544 kinds(&tasks),
545 vec![
546 ("tasks[0]".to_string(), StepKind::Group, 0),
547 ("tasks[0].tasks[0]".to_string(), StepKind::Group, 1),
548 ]
549 );
550 }
551}