diff --git a/crates/buzz-workflow/src/executor.rs b/crates/buzz-workflow/src/executor.rs index e30541377e..f4dbaded15 100644 --- a/crates/buzz-workflow/src/executor.rs +++ b/crates/buzz-workflow/src/executor.rs @@ -982,6 +982,36 @@ pub async fn execute_run( ) })?; + // Precheck gate (#2297): evaluate once before any step runs. When false, + // short-circuit with zero step actions — a scheduled poller can skip empty + // runs at ~no cost (no messages, webhooks, or agent turns). Evaluation + // errors are treated as "run" (fail open) so a bad gate never silently + // disables a workflow; the error is surfaced in the trace. + if let Some(expr) = def.precheck.as_deref() { + match evaluate_condition(expr, trigger_ctx, &HashMap::new()).await { + Ok(false) => { + let trace = vec![serde_json::json!({ + "precheck": expr, + "status": "skipped", + "reason": "precheck-false", + })]; + return Ok(ExecutionResult { + approval_token: None, + step_index: 0, + step_outputs: HashMap::new(), + trace, + }); + } + Ok(true) => {} + Err(e) => { + tracing::warn!( + workflow = %def.name, + "precheck evaluation failed; running steps (fail-open): {e}" + ); + } + } + } + engine .db .update_workflow_run( diff --git a/crates/buzz-workflow/src/schema.rs b/crates/buzz-workflow/src/schema.rs index 9bc79aa48b..e8ecb53098 100644 --- a/crates/buzz-workflow/src/schema.rs +++ b/crates/buzz-workflow/src/schema.rs @@ -19,6 +19,13 @@ pub struct WorkflowDef { pub description: Option, /// The event trigger that starts this workflow. pub trigger: TriggerDef, + /// Optional evalexpr gate evaluated once, before any steps run. When it + /// evaluates to `false`, the run short-circuits with zero step actions + /// (no messages, webhooks, or agent turns) and is recorded as completed. + /// Uses the same variables as step `if:` expressions (trigger context). + /// Lets a scheduled poller cheaply skip empty runs (#2297). + #[serde(default)] + pub precheck: Option, /// Ordered list of steps to execute when triggered. pub steps: Vec, /// Whether this workflow is active. Defaults to `true`. @@ -298,6 +305,21 @@ mod tests { assert_eq!(reparsed.name, def.name); } + #[test] + fn parse_precheck_field_roundtrips_and_defaults_none() { + // Absent precheck defaults to None (existing workflows unaffected). + let yaml = "name: NoGate\ntrigger:\n on: schedule\n cron: '0 9 * * *'\nsteps:\n - id: s\n action: send_message\n text: hi\n"; + let (def, _) = parse_yaml(yaml).expect("parse failed"); + assert_eq!(def.precheck, None); + + // Present precheck parses and round-trips through canonical JSON. + let yaml2 = "name: Gated\ntrigger:\n on: schedule\n cron: '*/15 * * * *'\nprecheck: 'trigger_open_pr_count > 0'\nsteps:\n - id: s\n action: send_message\n text: hi\n"; + let (def2, json2) = parse_yaml(yaml2).expect("parse failed"); + assert_eq!(def2.precheck.as_deref(), Some("trigger_open_pr_count > 0")); + let reparsed: WorkflowDef = serde_json::from_str(&json2).expect("json round-trip"); + assert_eq!(reparsed.precheck, def2.precheck); + } + #[test] fn parse_reaction_added_trigger() { let yaml = "name: Triage\ntrigger:\n on: reaction_added\n emoji: clipboard\nsteps:\n - id: ack\n action: add_reaction\n emoji: eyes\n";