Skip to content
Open
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
30 changes: 30 additions & 0 deletions crates/buzz-workflow/src/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
22 changes: 22 additions & 0 deletions crates/buzz-workflow/src/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ pub struct WorkflowDef {
pub description: Option<String>,
/// 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<String>,
/// Ordered list of steps to execute when triggered.
pub steps: Vec<Step>,
/// Whether this workflow is active. Defaults to `true`.
Expand Down Expand Up @@ -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";
Expand Down