Skip to content

Commit 78e73c2

Browse files
Bartok9Bartok9
authored andcommitted
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> (cherry picked from commit d495c95) Signed-off-by: Bartok9 <259807879+Bartok9@users.noreply.github.com> (cherry picked from commit a53cee2) Signed-off-by: Bartok9 <259807879+Bartok9@users.noreply.github.com> (cherry picked from commit 91e2d1a) Signed-off-by: Bartok9 <259807879+Bartok9@users.noreply.github.com> (cherry picked from commit 14f93a7) Signed-off-by: Bartok9 <259807879+Bartok9@users.noreply.github.com>
1 parent 4cd82f5 commit 78e73c2

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
@@ -1075,6 +1075,36 @@ pub async fn execute_run(
10751075
)
10761076
})?;
10771077

1078+
// Precheck gate (#2297): evaluate once before any step runs. When false,
1079+
// short-circuit with zero step actions — a scheduled poller can skip empty
1080+
// runs at ~no cost (no messages, webhooks, or agent turns). Evaluation
1081+
// errors are treated as "run" (fail open) so a bad gate never silently
1082+
// disables a workflow; the error is surfaced in the trace.
1083+
if let Some(expr) = def.precheck.as_deref() {
1084+
match evaluate_condition(expr, trigger_ctx, &HashMap::new()).await {
1085+
Ok(false) => {
1086+
let trace = vec![serde_json::json!({
1087+
"precheck": expr,
1088+
"status": "skipped",
1089+
"reason": "precheck-false",
1090+
})];
1091+
return Ok(ExecutionResult {
1092+
approval_token: None,
1093+
step_index: 0,
1094+
step_outputs: HashMap::new(),
1095+
trace,
1096+
});
1097+
}
1098+
Ok(true) => {}
1099+
Err(e) => {
1100+
tracing::warn!(
1101+
workflow = %def.name,
1102+
"precheck evaluation failed; running steps (fail-open): {e}"
1103+
);
1104+
}
1105+
}
1106+
}
1107+
10781108
engine
10791109
.db
10801110
.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`.
@@ -334,6 +341,21 @@ mod tests {
334341
assert_eq!(reparsed.name, def.name);
335342
}
336343

344+
#[test]
345+
fn parse_precheck_field_roundtrips_and_defaults_none() {
346+
// Absent precheck defaults to None (existing workflows unaffected).
347+
let yaml = "name: NoGate\ntrigger:\n on: schedule\n cron: '0 9 * * *'\nsteps:\n - id: s\n action: send_message\n text: hi\n";
348+
let (def, _) = parse_yaml(yaml).expect("parse failed");
349+
assert_eq!(def.precheck, None);
350+
351+
// Present precheck parses and round-trips through canonical JSON.
352+
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";
353+
let (def2, json2) = parse_yaml(yaml2).expect("parse failed");
354+
assert_eq!(def2.precheck.as_deref(), Some("trigger_open_pr_count > 0"));
355+
let reparsed: WorkflowDef = serde_json::from_str(&json2).expect("json round-trip");
356+
assert_eq!(reparsed.precheck, def2.precheck);
357+
}
358+
337359
#[test]
338360
fn parse_reaction_added_trigger() {
339361
let yaml = "name: Triage\ntrigger:\n on: reaction_added\n emoji: clipboard\n filter: 'trigger_message_id == \"abc123\"'\nsteps:\n - id: ack\n action: add_reaction\n emoji: eyes\n";

0 commit comments

Comments
 (0)