From 188c59382f9edf3ed640b1f1912f5ca2b68e9a0b Mon Sep 17 00:00:00 2001 From: Bartok9 Date: Wed, 22 Jul 2026 10:33:34 -0400 Subject: [PATCH] feat(workflow): add optional precheck gate to skip empty runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a workflow-level `precheck` evalexpr gate evaluated once at run start, before any step executes. When it evaluates to false the run short-circuits with zero step actions (no messages, webhooks, or agent turns) and returns a completed result with a skipped-precheck trace entry. This lets a scheduled poller workflow ("every 15m, if there's new work, have an agent summarize it") cheaply skip empty ticks instead of spinning up the step chain each time — the same zero-cost-gate pattern we contributed to the OpenClaw and Hermes cron systems. - Additive: workflows without `precheck` are unchanged (serde default None). - Reuses the existing `evaluate_condition` machinery (4 KB expr cap + 100 ms timeout), so no new execution surface and no shell — same safety envelope as step `if:` conditions. - Fail-open: a precheck that errors runs the steps (a bad gate never silently disables a workflow) and logs a warning. - Tests: precheck field parse + JSON round-trip + default-None. Refs #2297 Signed-off-by: Bartok9 --- crates/buzz-workflow/src/executor.rs | 30 ++++++++++++++++++++++++++++ crates/buzz-workflow/src/schema.rs | 22 ++++++++++++++++++++ 2 files changed, 52 insertions(+) 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";