From 6788ab43b8221f28c96e470a4970b52340709f5e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 9 Jun 2026 04:03:57 +0000 Subject: [PATCH 1/3] Initial plan From 1e09e1cc4015506b74fe1a947ceb9ddefeee7096 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 9 Jun 2026 04:13:15 +0000 Subject: [PATCH 2/3] fix(px): when+return now short-circuits procedure execution Two bugs caused `return` inside a `when:` block to not stop subsequent procedure steps: 1. executor.rs `execute_when`: iterated nested steps without checking for a `return` result. Now breaks early on `return` and propagates `kind: "return"` up so the procedure loop's `is_return` guard fires. 2. scenario_runner.rs: used a hand-rolled `execute_step` loop that had no `"return"` arm and no short-circuit check. Replaced with a direct call to `execute_with_vars` (which already handles `is_return`). Emitted events stored by the executor in `vars["emit"]` are replayed through `handler.call("emit", ...)` so `ScenarioActionHandler` continues to capture them for `event_emitted` expectation checks. Adds regression tests for: - `when` + `return` short-circuits in executor (3 new tests) - nested `when` + `return` propagation in executor (1 new test) - `when` + `return` short-circuit at scenario level (2 new tests) Fixes #974 regression; unblocks pares-radix#298. --- crates/pluresdb-px/src/px/executor.rs | 113 ++++++++++++++++++ crates/pluresdb-px/src/px/scenario_runner.rs | 114 +++++++++++++++++-- 2 files changed, 215 insertions(+), 12 deletions(-) diff --git a/crates/pluresdb-px/src/px/executor.rs b/crates/pluresdb-px/src/px/executor.rs index 8b6fffb..93f19cc 100644 --- a/crates/pluresdb-px/src/px/executor.rs +++ b/crates/pluresdb-px/src/px/executor.rs @@ -430,7 +430,23 @@ fn execute_when( let mut nested_results = Vec::new(); for (i, nested) in nested_steps.iter().enumerate() { let result = execute_step(nested, i, vars, handler)?; + let is_return = result.kind == "return"; nested_results.push(result); + if is_return { + break; + } + } + + // Propagate a return step so the procedure executor short-circuits. + if let Some(last) = nested_results.last() { + if last.kind == "return" { + return Ok(StepResult { + index, + kind: "return".into(), + output: last.output.clone(), + skipped: false, + }); + } } // Return the last nested result as the when step's output @@ -3563,6 +3579,103 @@ mod tests { assert!(result.step_results[0].skipped); } + #[test] + fn when_return_short_circuits_procedure() { + // A `return` inside a `when` block must stop all subsequent steps. + let handler = MockHandler::new().with_result("side_effect", json!("ran")); + + let procedure = json!({ + "type": "procedure", + "name": "guard_test", + "steps": [ + { + "kind": "when", + "condition": "input == empty", + "steps": [ + { "kind": "return", "value": "no_input" } + ] + }, + // This step must NOT execute when the when-return fires. + { "kind": "call", "name": "side_effect", "params": {} } + ] + }); + + let vars = HashMap::from([("input".to_string(), json!("empty"))]); + let result = execute_with_vars(&procedure, &handler, vars).unwrap(); + + // Only the when step should appear; the call after must be absent. + assert_eq!(result.step_results.len(), 1); + assert_eq!(result.step_results[0].kind, "return"); + assert_eq!(result.step_results[0].output, Some(json!("no_input"))); + } + + #[test] + fn when_condition_false_does_not_short_circuit() { + // When the when-condition is false the return is skipped and the rest continues. + let handler = MockHandler::new().with_result("next_step", json!("did_run")); + + let procedure = json!({ + "type": "procedure", + "name": "no_guard", + "steps": [ + { + "kind": "when", + "condition": "input == empty", + "steps": [ + { "kind": "return", "value": "no_input" } + ] + }, + { "kind": "call", "name": "next_step", "params": {} } + ] + }); + + let vars = HashMap::from([("input".to_string(), json!("hello"))]); + let result = execute_with_vars(&procedure, &handler, vars).unwrap(); + + // Both the (skipped) when and the call step should be present. + assert_eq!(result.step_results.len(), 2); + assert!(result.step_results[0].skipped); // when was skipped + assert_eq!(result.step_results[1].output, Some(json!("did_run"))); + } + + #[test] + fn when_return_nested_when_also_short_circuits() { + // A return inside a nested when should propagate all the way up. + let handler = MockHandler::new().with_result("outer_step", json!("outer_ran")); + + let procedure = json!({ + "type": "procedure", + "name": "nested_guard", + "steps": [ + { + "kind": "when", + "condition": "outer == true", + "steps": [ + { + "kind": "when", + "condition": "inner == true", + "steps": [ + { "kind": "return", "value": "inner_return" } + ] + } + ] + }, + { "kind": "call", "name": "outer_step", "params": {} } + ] + }); + + let vars = HashMap::from([ + ("outer".to_string(), json!(true)), + ("inner".to_string(), json!(true)), + ]); + let result = execute_with_vars(&procedure, &handler, vars).unwrap(); + + // The outer when propagates the return; outer_step never runs. + assert_eq!(result.step_results.len(), 1); + assert_eq!(result.step_results[0].kind, "return"); + assert_eq!(result.step_results[0].output, Some(json!("inner_return"))); + } + #[test] fn execute_match_selects_first_true_arm() { let handler = MockHandler::new(); diff --git a/crates/pluresdb-px/src/px/scenario_runner.rs b/crates/pluresdb-px/src/px/scenario_runner.rs index 6df937b..c8b772e 100644 --- a/crates/pluresdb-px/src/px/scenario_runner.rs +++ b/crates/pluresdb-px/src/px/scenario_runner.rs @@ -23,7 +23,7 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; use serde_json::Value; -use super::executor::{ActionHandler, ExecutionError}; +use super::executor::{ActionHandler, ExecutionError, execute_with_vars}; // ── Types ───────────────────────────────────────────────────────────────────── @@ -349,18 +349,29 @@ pub fn run_scenario( vars.extend(run_params); if let Some(proc_data) = procedures.get(&proc_name) { - if let Some(steps) = proc_data.get("steps").and_then(|v| v.as_array()) { - for step in steps { - if let Err(e) = execute_step(step, &handler, &mut vars) { - return ScenarioResult { - name, - given, - passed: false, - expectations: vec![], - error: Some(format!("procedure '{proc_name}' failed: {e}")), - duration_ms: start.elapsed().as_millis() as u64, - }; + match execute_with_vars(proc_data, &handler, vars) { + Ok(result) => { + // The executor's `emit` step stores events in `result.variables["emit"]` + // rather than calling `handler.call("emit", ...)`. Replay them through + // the handler so ScenarioActionHandler captures them in emitted_events. + if let Some(Value::Array(events)) = + result.variables.get("emit").cloned() + { + for event in &events { + let _ = handler.call("emit", event); + } } + vars = result.variables; + } + Err(e) => { + return ScenarioResult { + name, + given, + passed: false, + expectations: vec![], + error: Some(format!("procedure '{proc_name}' failed: {e}")), + duration_ms: start.elapsed().as_millis() as u64, + }; } } } else { @@ -957,4 +968,83 @@ mod tests { let result = run_scenario(&scenario, &procedures, &BuiltinChecker); assert!(result.passed, "expected pass, got: {:?}", result); } + + #[test] + fn when_return_short_circuits_procedure_in_scenario() { + // A `return` inside a `when` block must stop procedure execution. + // The entry must NOT be stored when the guard fires. + let mut procedures = HashMap::new(); + procedures.insert( + "guard_proc".to_string(), + json!({ + "name": "guard_proc", + "steps": [ + // Bind result to the return value via echo so var_equals can check it. + // The when fires (input == empty), returns "no_input". + { + "kind": "when", + "condition": "input == empty", + "steps": [ + {"kind": "call", "name": "echo", "params": "no_input", "output_var": "result"}, + {"kind": "return", "value": "no_input"} + ] + }, + // These steps must NOT run. + {"kind": "call", "name": "put_entry", "params": {"key": "should_not_exist", "value": 1}}, + {"kind": "call", "name": "echo", "params": "input_present", "output_var": "result"} + ] + }), + ); + + let scenario = json!({ + "name": "guard_short_circuit", + "setup": [ + {"kind": "call", "name": "echo", "params": "empty", "output_var": "input"} + ], + "run": "guard_proc", + "expectations": [ + {"check": "has_entry", "params": {"key": "should_not_exist"}, "negated": true}, + {"check": "var_equals", "params": {"var": "result", "value": "no_input"}, "negated": false} + ] + }); + + let result = run_scenario(&scenario, &procedures, &BuiltinChecker); + assert!(result.passed, "expected pass, got: {:?}", result); + } + + #[test] + fn when_condition_false_continues_execution() { + // When the when-condition is false the return is NOT triggered and execution continues. + let scenario = json!({ + "name": "no_guard_trigger", + "setup": [ + {"kind": "call", "name": "echo", "params": "hello", "output_var": "input"} + ], + "run": "guard_proc", + "expectations": [ + {"check": "has_entry", "params": {"key": "did_run"}, "negated": false} + ] + }); + + let mut procedures = HashMap::new(); + procedures.insert( + "guard_proc".to_string(), + json!({ + "name": "guard_proc", + "steps": [ + { + "kind": "when", + "condition": "input == empty", + "steps": [ + {"kind": "return", "value": "no_input"} + ] + }, + {"kind": "call", "name": "put_entry", "params": {"key": "did_run", "value": 1}} + ] + }), + ); + + let result = run_scenario(&scenario, &procedures, &BuiltinChecker); + assert!(result.passed, "expected pass, got: {:?}", result); + } } From 095b8ac3407fc090f3c946aad44cfa798844ac07 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 9 Jun 2026 04:16:06 +0000 Subject: [PATCH 3/3] fix(px): propagate emit replay errors in scenario runner --- crates/pluresdb-px/src/px/scenario_runner.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/pluresdb-px/src/px/scenario_runner.rs b/crates/pluresdb-px/src/px/scenario_runner.rs index c8b772e..6ec2ecd 100644 --- a/crates/pluresdb-px/src/px/scenario_runner.rs +++ b/crates/pluresdb-px/src/px/scenario_runner.rs @@ -358,7 +358,16 @@ pub fn run_scenario( result.variables.get("emit").cloned() { for event in &events { - let _ = handler.call("emit", event); + if let Err(e) = handler.call("emit", event) { + return ScenarioResult { + name, + given, + passed: false, + expectations: vec![], + error: Some(format!("emit failed: {e}")), + duration_ms: start.elapsed().as_millis() as u64, + }; + } } } vars = result.variables;