Skip to content

Commit e0d77f5

Browse files
author
Bartok9
committed
feat(workflow): add optional precheck gate to skip empty runs
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 <danielrpike9@gmail.com>
1 parent 119a848 commit e0d77f5

2 files changed

Lines changed: 52 additions & 0 deletions

File tree

crates/buzz-workflow/src/executor.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -982,6 +982,36 @@ pub async fn execute_run(
982982
)
983983
})?;
984984

985+
// Precheck gate (#2297): evaluate once before any step runs. When false,
986+
// short-circuit with zero step actions — a scheduled poller can skip empty
987+
// runs at ~no cost (no messages, webhooks, or agent turns). Evaluation
988+
// errors are treated as "run" (fail open) so a bad gate never silently
989+
// disables a workflow; the error is surfaced in the trace.
990+
if let Some(expr) = def.precheck.as_deref() {
991+
match evaluate_condition(expr, trigger_ctx, &HashMap::new()).await {
992+
Ok(false) => {
993+
let trace = vec![serde_json::json!({
994+
"precheck": expr,
995+
"status": "skipped",
996+
"reason": "precheck-false",
997+
})];
998+
return Ok(ExecutionResult {
999+
approval_token: None,
1000+
step_index: 0,
1001+
step_outputs: HashMap::new(),
1002+
trace,
1003+
});
1004+
}
1005+
Ok(true) => {}
1006+
Err(e) => {
1007+
tracing::warn!(
1008+
workflow = %def.name,
1009+
"precheck evaluation failed; running steps (fail-open): {e}"
1010+
);
1011+
}
1012+
}
1013+
}
1014+
9851015
engine
9861016
.db
9871017
.update_workflow_run(

crates/buzz-workflow/src/schema.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@ pub struct WorkflowDef {
1919
pub description: Option<String>,
2020
/// The event trigger that starts this workflow.
2121
pub trigger: TriggerDef,
22+
/// Optional evalexpr gate evaluated once, before any steps run. When it
23+
/// evaluates to `false`, the run short-circuits with zero step actions
24+
/// (no messages, webhooks, or agent turns) and is recorded as completed.
25+
/// Uses the same variables as step `if:` expressions (trigger context).
26+
/// Lets a scheduled poller cheaply skip empty runs (#2297).
27+
#[serde(default)]
28+
pub precheck: Option<String>,
2229
/// Ordered list of steps to execute when triggered.
2330
pub steps: Vec<Step>,
2431
/// Whether this workflow is active. Defaults to `true`.
@@ -298,6 +305,21 @@ mod tests {
298305
assert_eq!(reparsed.name, def.name);
299306
}
300307

308+
#[test]
309+
fn parse_precheck_field_roundtrips_and_defaults_none() {
310+
// Absent precheck defaults to None (existing workflows unaffected).
311+
let yaml = "name: NoGate\ntrigger:\n on: schedule\n cron: '0 9 * * *'\nsteps:\n - id: s\n action: send_message\n text: hi\n";
312+
let (def, _) = parse_yaml(yaml).expect("parse failed");
313+
assert_eq!(def.precheck, None);
314+
315+
// Present precheck parses and round-trips through canonical JSON.
316+
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";
317+
let (def2, json2) = parse_yaml(yaml2).expect("parse failed");
318+
assert_eq!(def2.precheck.as_deref(), Some("trigger_open_pr_count > 0"));
319+
let reparsed: WorkflowDef = serde_json::from_str(&json2).expect("json round-trip");
320+
assert_eq!(reparsed.precheck, def2.precheck);
321+
}
322+
301323
#[test]
302324
fn parse_reaction_added_trigger() {
303325
let yaml = "name: Triage\ntrigger:\n on: reaction_added\n emoji: clipboard\nsteps:\n - id: ack\n action: add_reaction\n emoji: eyes\n";

0 commit comments

Comments
 (0)