Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions crates/tinyflows/src/loop_tests/loop_tests_part_01_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -239,3 +239,41 @@ fn a_merge_outside_the_cycle_is_accepted() {
};
assert_eq!(validate_all(&graph), Vec::new());
}

/// A `loop` node that hangs off the cycle on a side path does not bound it: the
/// direct `b -> c -> b` cycle never passes through the loop, so it spins with no
/// pass count. Refuse it like any other unbounded cycle.
#[test]
fn loop_on_side_path_does_not_bound_a_bypass_cycle() {
let graph = WorkflowGraph {
nodes: vec![
node("t", NodeKind::Trigger),
node("b", NodeKind::OutputParser),
node("c", NodeKind::OutputParser),
node_cfg(
"l",
NodeKind::Loop,
serde_json::json!({ "max_iterations": 2 }),
),
node("work", NodeKind::OutputParser),
],
edges: vec![
edge_on("t", "main", "b"),
edge_on("b", "main", "c"),
edge_on("c", "main", "b"),
edge_on("b", "main", "l"),
edge_on("l", "body", "work"),
edge_on("work", "main", "l"),
edge_on("l", "done", "c"),
],
..Default::default()
};

let errors = validate_all(&graph);
assert!(
errors
.iter()
.any(|error| matches!(error, ValidationError::IllegalCycle(_))),
"the b <-> c cycle bypasses the loop and must be refused: {errors:?}"
);
}
44 changes: 38 additions & 6 deletions crates/tinyflows/src/validate/loops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -181,16 +181,48 @@ pub(super) fn validate_loops(graph: &WorkflowGraph, errors: &mut Vec<ValidationE
})
});
if !has_run_level_bound {
let is_loop = |id: &str| {
graph
.nodes
.iter()
.any(|n| n.id == id && n.kind == NodeKind::Loop)
};
for (from, to) in &loop_edges {
let bounded = nodes_on_cycle(graph, to, from).into_iter().any(|id| {
graph
.nodes
.iter()
.any(|n| n.id == id && n.kind == NodeKind::Loop)
});
// A `loop` merely *on* the cycle is not enough: a side path through
// it leaves a direct `to -> from` path that never counts a pass.
// The cycle is bounded only when the loop sits on every way round.
let bounded = is_loop(to) || is_loop(from) || !path_avoiding(graph, to, from, is_loop);
if !bounded {
errors.push(ValidationError::IllegalCycle(to.clone()));
}
}
}
}

/// Whether `target` is reachable from `start` without passing through a node
/// `skip` matches. `start` and `target` themselves are never skipped.
fn path_avoiding(
graph: &WorkflowGraph,
start: &str,
target: &str,
skip: impl Fn(&str) -> bool,
) -> bool {
let mut seen = HashSet::new();
let mut stack = vec![start];
while let Some(node) = stack.pop() {
if node == target {
return true;
}
if !seen.insert(node) || (node != start && skip(node)) {
continue;
}
stack.extend(
graph
.edges
.iter()
.filter(|edge| edge.from_node == node)
.map(|edge| edge.to_node.as_str()),
);
}
false
}
Loading