Adversarial review of the v1.2 Workflows track surfaced two HIGH footguns and several correctness/hardening gaps. Fixes: HIGH - pull round-trip: `pic pull` dropped `[[workflows]]`, so a later `apply --prune` silently deleted them. The list endpoint now returns the full `definition`; pull rebuilds the manifest block (inverse of workflow_to_wire). - case-colliding names: two workflows differing only by case collided in the reconcile diff (keyed by lower(name)), silently dropping one. Rejected up front in validate_bundle_for. MEDIUM - reclaim retry budget: a crashed attempt (no outcome) consumed the retry budget. reclaim_stale_steps now decrements `attempt` (floored) and clears `next_attempt_at`, so a crash no longer counts as a failed try. - on_error/backoff: typed the manifest fields against the shared enums so a bad value fails at TOML parse with a clear message, not an opaque 500. - dedupe depends_on: a repeated dependency inflated the Kahn in-degree past the single decrement, reporting a spurious cycle for a valid DAG. Count distinct. - canceled child: a canceled sub-workflow resolved the parent step with a message naming the cause instead of a generic "failed"; documented that a run-level cancel op is not yet supported. LOW - list_run_steps is now app-scoped at the query (JOIN workflow_runs) rather than relying on caller pre-verification. - partial failure is surfaced: a run that succeeds with on_error=continue failures records them in the run's `error` field. - admin-started runs log the root_execution_id against the principal. - documented the deliberate when(missing→false) vs template(missing→fail) asymmetry; corrected the claim's atomicity comment. Tests: unit (dedupe-deps), DB-gated reclaim-budget assertion, and two new CLI journeys (duplicate-name rejection, pull→plan clean round-trip). fmt + clippy -D warnings clean; 450 lib + 14 orchestrator DB + 5 workflow journeys pass; schema snapshot unchanged (no migration). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
499 lines
16 KiB
Rust
499 lines
16 KiB
Rust
//! Workflow `when` condition evaluation (v1.2 Workflows) — pure, DB-free.
|
|
//!
|
|
//! A step's optional `when` is a small, safe JSON predicate over the run
|
|
//! context (`input` + prior step `output`s, resolved via [`workflow_template`]).
|
|
//! When it evaluates false the step is `skipped`. This keeps `manager-core`
|
|
//! free of a scripting engine (the boundary rule) — it is NOT Rhai and never
|
|
//! grows into one; a `when` that ever needs real scripting is a deliberate
|
|
//! future ExecutorClient round-trip, not an engine dependency here.
|
|
//!
|
|
//! Grammar (precedence low→high): `||`, `&&`, `!`, comparison
|
|
//! (`== != < > <= >=`), primary (`( … )`, `exists <ref>`, literal, `<ref>`).
|
|
//! Literals: numbers, `'…'`/`"…"` strings, `true`, `false`, `null`.
|
|
//! A bare reference is truthy per [`is_truthy`]; a reference that doesn't
|
|
//! resolve is `null` (falsy) at run time. This is a DELIBERATE asymmetry with
|
|
//! [`workflow_template`], which HARD-FAILS a missing ref: a `when` is a
|
|
//! predicate (an absent value is "condition not met"), whereas an input
|
|
//! template must not silently substitute null. Apply-time [`validate`] guards
|
|
//! the *shape* of a ref (the root `input`/`steps.<name>.output` prefix and that
|
|
//! `<name>` is a declared step), but it cannot check a deeper JSON path against
|
|
//! a runtime value — so a typo *past* the step name (e.g. `steps.a.output.typ`
|
|
//! for `…typo`) is not caught at apply and silently makes the step skip. Prefer
|
|
//! `exists <ref>` to make "the value may be absent" explicit.
|
|
//!
|
|
//! [`workflow_template`]: crate::workflow_template
|
|
|
|
use std::collections::BTreeSet;
|
|
|
|
use serde_json::Value;
|
|
use thiserror::Error;
|
|
|
|
use crate::workflow_template::{RunContext, TemplateError};
|
|
|
|
#[derive(Debug, Error, PartialEq, Eq)]
|
|
pub enum ExprError {
|
|
#[error("workflow `when` parse error: {0}")]
|
|
Parse(String),
|
|
#[error("workflow `when` reference error: {0}")]
|
|
Ref(#[from] TemplateError),
|
|
}
|
|
|
|
/// Parse a `when` expression (syntactic validation). Returns the AST.
|
|
///
|
|
/// # Errors
|
|
/// [`ExprError::Parse`] on malformed syntax.
|
|
pub fn parse(src: &str) -> Result<Expr, ExprError> {
|
|
let toks = lex(src)?;
|
|
let mut p = Parser {
|
|
toks: &toks,
|
|
pos: 0,
|
|
};
|
|
let e = p.parse_or()?;
|
|
if p.pos != p.toks.len() {
|
|
return Err(ExprError::Parse(format!(
|
|
"trailing tokens near {:?}",
|
|
p.toks.get(p.pos)
|
|
)));
|
|
}
|
|
Ok(e)
|
|
}
|
|
|
|
/// Parse + evaluate `src` against `ctx`.
|
|
///
|
|
/// # Errors
|
|
/// [`ExprError::Parse`] on malformed syntax.
|
|
pub fn eval(src: &str, ctx: &RunContext) -> Result<bool, ExprError> {
|
|
Ok(eval_bool(&parse(src)?, ctx))
|
|
}
|
|
|
|
/// Apply-time validation: parse, then check every referenced `steps.<name>`
|
|
/// names a declared step.
|
|
///
|
|
/// # Errors
|
|
/// [`ExprError`] on a parse error or a reference to an undeclared step.
|
|
pub fn validate(src: &str, step_names: &BTreeSet<String>) -> Result<(), ExprError> {
|
|
let ast = parse(src)?;
|
|
let mut refs = Vec::new();
|
|
ast.collect_refs(&mut refs);
|
|
for r in refs {
|
|
let parts: Vec<&str> = r.split('.').collect();
|
|
match parts.first().copied() {
|
|
Some("input") => {}
|
|
Some("steps") => {
|
|
let name = parts
|
|
.get(1)
|
|
.filter(|s| !s.is_empty())
|
|
.ok_or_else(|| ExprError::Ref(TemplateError::MalformedRef(r.clone())))?;
|
|
if parts.get(2).copied() != Some("output") {
|
|
return Err(ExprError::Ref(TemplateError::MalformedRef(r.clone())));
|
|
}
|
|
if !step_names.contains(*name) {
|
|
return Err(ExprError::Ref(TemplateError::UnknownStep(
|
|
r.clone(),
|
|
(*name).to_string(),
|
|
)));
|
|
}
|
|
}
|
|
_ => return Err(ExprError::Ref(TemplateError::MalformedRef(r.clone()))),
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
// ---- AST ------------------------------------------------------------------
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub enum Expr {
|
|
Or(Box<Expr>, Box<Expr>),
|
|
And(Box<Expr>, Box<Expr>),
|
|
Not(Box<Expr>),
|
|
Cmp(Operand, CmpOp, Operand),
|
|
Truthy(Operand),
|
|
Exists(String),
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub enum Operand {
|
|
Ref(String),
|
|
Lit(Value),
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum CmpOp {
|
|
Eq,
|
|
Ne,
|
|
Lt,
|
|
Gt,
|
|
Le,
|
|
Ge,
|
|
}
|
|
|
|
impl Expr {
|
|
fn collect_refs(&self, acc: &mut Vec<String>) {
|
|
match self {
|
|
Self::Or(a, b) | Self::And(a, b) => {
|
|
a.collect_refs(acc);
|
|
b.collect_refs(acc);
|
|
}
|
|
Self::Not(a) => a.collect_refs(acc),
|
|
Self::Cmp(l, _, r) => {
|
|
l.collect_ref(acc);
|
|
r.collect_ref(acc);
|
|
}
|
|
Self::Truthy(o) => o.collect_ref(acc),
|
|
Self::Exists(r) => acc.push(r.clone()),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Operand {
|
|
fn collect_ref(&self, acc: &mut Vec<String>) {
|
|
if let Self::Ref(r) = self {
|
|
acc.push(r.clone());
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- Evaluation -----------------------------------------------------------
|
|
|
|
fn eval_bool(e: &Expr, ctx: &RunContext) -> bool {
|
|
match e {
|
|
Expr::Or(a, b) => eval_bool(a, ctx) || eval_bool(b, ctx),
|
|
Expr::And(a, b) => eval_bool(a, ctx) && eval_bool(b, ctx),
|
|
Expr::Not(a) => !eval_bool(a, ctx),
|
|
Expr::Truthy(o) => is_truthy(&operand_value(o, ctx)),
|
|
Expr::Exists(r) => ctx.resolve_ref(r).is_some_and(|v| !v.is_null()),
|
|
Expr::Cmp(l, op, r) => {
|
|
let lv = operand_value(l, ctx);
|
|
let rv = operand_value(r, ctx);
|
|
eval_cmp(&lv, *op, &rv)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn operand_value(o: &Operand, ctx: &RunContext) -> Value {
|
|
match o {
|
|
Operand::Lit(v) => v.clone(),
|
|
Operand::Ref(r) => ctx.resolve_ref(r).cloned().unwrap_or(Value::Null),
|
|
}
|
|
}
|
|
|
|
/// JSON truthiness: null/false/0/""/[]/{}` are falsy; everything else truthy.
|
|
#[must_use]
|
|
pub fn is_truthy(v: &Value) -> bool {
|
|
match v {
|
|
Value::Null => false,
|
|
Value::Bool(b) => *b,
|
|
Value::Number(n) => n.as_f64().is_some_and(|f| f != 0.0),
|
|
Value::String(s) => !s.is_empty(),
|
|
Value::Array(a) => !a.is_empty(),
|
|
Value::Object(m) => !m.is_empty(),
|
|
}
|
|
}
|
|
|
|
fn eval_cmp(l: &Value, op: CmpOp, r: &Value) -> bool {
|
|
match op {
|
|
CmpOp::Eq => json_eq(l, r),
|
|
CmpOp::Ne => !json_eq(l, r),
|
|
CmpOp::Lt | CmpOp::Gt | CmpOp::Le | CmpOp::Ge => match order(l, r) {
|
|
Some(ord) => match op {
|
|
CmpOp::Lt => ord.is_lt(),
|
|
CmpOp::Gt => ord.is_gt(),
|
|
CmpOp::Le => ord.is_le(),
|
|
CmpOp::Ge => ord.is_ge(),
|
|
_ => unreachable!(),
|
|
},
|
|
None => false,
|
|
},
|
|
}
|
|
}
|
|
|
|
fn json_eq(l: &Value, r: &Value) -> bool {
|
|
match (l.as_f64(), r.as_f64()) {
|
|
(Some(a), Some(b)) => (a - b).abs() < f64::EPSILON,
|
|
_ => l == r,
|
|
}
|
|
}
|
|
|
|
fn order(l: &Value, r: &Value) -> Option<std::cmp::Ordering> {
|
|
if let (Some(a), Some(b)) = (l.as_f64(), r.as_f64()) {
|
|
return a.partial_cmp(&b);
|
|
}
|
|
if let (Value::String(a), Value::String(b)) = (l, r) {
|
|
return Some(a.cmp(b));
|
|
}
|
|
None
|
|
}
|
|
|
|
// ---- Lexer ----------------------------------------------------------------
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
enum Tok {
|
|
Word(String),
|
|
Num(f64),
|
|
Str(String),
|
|
Op(&'static str),
|
|
LParen,
|
|
RParen,
|
|
}
|
|
|
|
#[allow(clippy::too_many_lines)]
|
|
fn lex(src: &str) -> Result<Vec<Tok>, ExprError> {
|
|
let b = src.as_bytes();
|
|
let mut i = 0;
|
|
let mut out = Vec::new();
|
|
while i < b.len() {
|
|
let c = b[i];
|
|
match c {
|
|
_ if c.is_ascii_whitespace() => i += 1,
|
|
b'(' => {
|
|
out.push(Tok::LParen);
|
|
i += 1;
|
|
}
|
|
b')' => {
|
|
out.push(Tok::RParen);
|
|
i += 1;
|
|
}
|
|
b'\'' | b'"' => {
|
|
let quote = c;
|
|
let start = i + 1;
|
|
let mut j = start;
|
|
while j < b.len() && b[j] != quote {
|
|
j += 1;
|
|
}
|
|
if j >= b.len() {
|
|
return Err(ExprError::Parse("unterminated string".to_string()));
|
|
}
|
|
out.push(Tok::Str(src[start..j].to_string()));
|
|
i = j + 1;
|
|
}
|
|
b'0'..=b'9' | b'-' if c != b'-' || next_is_digit(b, i) => {
|
|
let start = i;
|
|
i += 1;
|
|
while i < b.len() && (b[i].is_ascii_digit() || b[i] == b'.') {
|
|
i += 1;
|
|
}
|
|
let f: f64 = src[start..i]
|
|
.parse()
|
|
.map_err(|_| ExprError::Parse(format!("bad number `{}`", &src[start..i])))?;
|
|
out.push(Tok::Num(f));
|
|
}
|
|
b'=' | b'!' | b'<' | b'>' | b'&' | b'|' => {
|
|
let two = if i + 1 < b.len() { &src[i..=i + 1] } else { "" };
|
|
match two {
|
|
"==" => two_op(&mut out, &mut i, "=="),
|
|
"!=" => two_op(&mut out, &mut i, "!="),
|
|
"<=" => two_op(&mut out, &mut i, "<="),
|
|
">=" => two_op(&mut out, &mut i, ">="),
|
|
"&&" => two_op(&mut out, &mut i, "&&"),
|
|
"||" => two_op(&mut out, &mut i, "||"),
|
|
_ => match c {
|
|
b'<' => one_op(&mut out, &mut i, "<"),
|
|
b'>' => one_op(&mut out, &mut i, ">"),
|
|
b'!' => one_op(&mut out, &mut i, "!"),
|
|
_ => return Err(ExprError::Parse(format!("unexpected `{}`", c as char))),
|
|
},
|
|
}
|
|
}
|
|
_ if c.is_ascii_alphabetic() || c == b'_' => {
|
|
let start = i;
|
|
while i < b.len() && (b[i].is_ascii_alphanumeric() || b[i] == b'_' || b[i] == b'.')
|
|
{
|
|
i += 1;
|
|
}
|
|
out.push(Tok::Word(src[start..i].to_string()));
|
|
}
|
|
_ => return Err(ExprError::Parse(format!("unexpected `{}`", c as char))),
|
|
}
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
fn next_is_digit(b: &[u8], i: usize) -> bool {
|
|
b.get(i + 1).is_some_and(u8::is_ascii_digit)
|
|
}
|
|
|
|
fn two_op(out: &mut Vec<Tok>, i: &mut usize, op: &'static str) {
|
|
out.push(Tok::Op(op));
|
|
*i += 2;
|
|
}
|
|
|
|
fn one_op(out: &mut Vec<Tok>, i: &mut usize, op: &'static str) {
|
|
out.push(Tok::Op(op));
|
|
*i += 1;
|
|
}
|
|
|
|
// ---- Parser ---------------------------------------------------------------
|
|
|
|
struct Parser<'a> {
|
|
toks: &'a [Tok],
|
|
pos: usize,
|
|
}
|
|
|
|
impl Parser<'_> {
|
|
fn peek(&self) -> Option<&Tok> {
|
|
self.toks.get(self.pos)
|
|
}
|
|
|
|
fn parse_or(&mut self) -> Result<Expr, ExprError> {
|
|
let mut left = self.parse_and()?;
|
|
while matches!(self.peek(), Some(Tok::Op("||"))) {
|
|
self.pos += 1;
|
|
let right = self.parse_and()?;
|
|
left = Expr::Or(Box::new(left), Box::new(right));
|
|
}
|
|
Ok(left)
|
|
}
|
|
|
|
fn parse_and(&mut self) -> Result<Expr, ExprError> {
|
|
let mut left = self.parse_not()?;
|
|
while matches!(self.peek(), Some(Tok::Op("&&"))) {
|
|
self.pos += 1;
|
|
let right = self.parse_not()?;
|
|
left = Expr::And(Box::new(left), Box::new(right));
|
|
}
|
|
Ok(left)
|
|
}
|
|
|
|
fn parse_not(&mut self) -> Result<Expr, ExprError> {
|
|
if matches!(self.peek(), Some(Tok::Op("!"))) {
|
|
self.pos += 1;
|
|
return Ok(Expr::Not(Box::new(self.parse_not()?)));
|
|
}
|
|
self.parse_cmp()
|
|
}
|
|
|
|
fn parse_cmp(&mut self) -> Result<Expr, ExprError> {
|
|
// `( … )` group is a full boolean sub-expression.
|
|
if matches!(self.peek(), Some(Tok::LParen)) {
|
|
self.pos += 1;
|
|
let inner = self.parse_or()?;
|
|
if !matches!(self.peek(), Some(Tok::RParen)) {
|
|
return Err(ExprError::Parse("expected `)`".to_string()));
|
|
}
|
|
self.pos += 1;
|
|
return Ok(inner);
|
|
}
|
|
// `exists <ref>`
|
|
if matches!(self.peek(), Some(Tok::Word(w)) if w == "exists") {
|
|
self.pos += 1;
|
|
let Some(Tok::Word(r)) = self.peek().cloned() else {
|
|
return Err(ExprError::Parse(
|
|
"expected reference after `exists`".to_string(),
|
|
));
|
|
};
|
|
self.pos += 1;
|
|
return Ok(Expr::Exists(r));
|
|
}
|
|
let left = self.parse_operand()?;
|
|
if let Some(op) = self.peek().and_then(cmp_op) {
|
|
self.pos += 1;
|
|
let right = self.parse_operand()?;
|
|
return Ok(Expr::Cmp(left, op, right));
|
|
}
|
|
Ok(Expr::Truthy(left))
|
|
}
|
|
|
|
fn parse_operand(&mut self) -> Result<Operand, ExprError> {
|
|
match self.peek().cloned() {
|
|
Some(Tok::Num(n)) => {
|
|
self.pos += 1;
|
|
Ok(Operand::Lit(serde_json::json!(n)))
|
|
}
|
|
Some(Tok::Str(s)) => {
|
|
self.pos += 1;
|
|
Ok(Operand::Lit(Value::String(s)))
|
|
}
|
|
Some(Tok::Word(w)) => {
|
|
self.pos += 1;
|
|
Ok(match w.as_str() {
|
|
"true" => Operand::Lit(Value::Bool(true)),
|
|
"false" => Operand::Lit(Value::Bool(false)),
|
|
"null" => Operand::Lit(Value::Null),
|
|
_ => Operand::Ref(w),
|
|
})
|
|
}
|
|
other => Err(ExprError::Parse(format!(
|
|
"expected operand, found {other:?}"
|
|
))),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cmp_op(t: &Tok) -> Option<CmpOp> {
|
|
match t {
|
|
Tok::Op("==") => Some(CmpOp::Eq),
|
|
Tok::Op("!=") => Some(CmpOp::Ne),
|
|
Tok::Op("<") => Some(CmpOp::Lt),
|
|
Tok::Op(">") => Some(CmpOp::Gt),
|
|
Tok::Op("<=") => Some(CmpOp::Le),
|
|
Tok::Op(">=") => Some(CmpOp::Ge),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use serde_json::json;
|
|
use std::collections::BTreeMap;
|
|
|
|
fn ctx() -> RunContext {
|
|
let mut steps = BTreeMap::new();
|
|
steps.insert("check".to_string(), json!({ "ok": true, "score": 7 }));
|
|
RunContext {
|
|
input: json!({ "amount": 100, "mode": "prod", "flag": false }),
|
|
steps,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn comparisons() {
|
|
let c = ctx();
|
|
assert!(eval("input.amount > 50", &c).unwrap());
|
|
assert!(!eval("input.amount > 500", &c).unwrap());
|
|
assert!(eval("input.amount == 100", &c).unwrap());
|
|
assert!(eval("input.mode == 'prod'", &c).unwrap());
|
|
assert!(eval("steps.check.output.score >= 7", &c).unwrap());
|
|
assert!(eval("steps.check.output.ok == true", &c).unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn boolean_ops_and_precedence() {
|
|
let c = ctx();
|
|
assert!(eval("input.amount > 50 && input.mode == 'prod'", &c).unwrap());
|
|
assert!(!eval("input.amount > 50 && input.mode == 'dev'", &c).unwrap());
|
|
assert!(eval("input.amount > 500 || input.mode == 'prod'", &c).unwrap());
|
|
assert!(eval("!(input.mode == 'dev')", &c).unwrap());
|
|
// && binds tighter than ||
|
|
assert!(eval("false && false || input.amount == 100", &c).unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn truthiness_and_exists() {
|
|
let c = ctx();
|
|
assert!(eval("steps.check.output.ok", &c).unwrap());
|
|
assert!(!eval("input.flag", &c).unwrap());
|
|
assert!(eval("exists input.amount", &c).unwrap());
|
|
assert!(!eval("exists input.nope", &c).unwrap());
|
|
// a missing ref is falsy, not an error, in a condition
|
|
assert!(!eval("input.nope == 1", &c).unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn parse_errors() {
|
|
assert!(parse("input.a >").is_err());
|
|
assert!(parse("&& input.a").is_err());
|
|
assert!(parse("(input.a == 1").is_err());
|
|
assert!(parse("'unterminated").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn validate_checks_declared_steps() {
|
|
let steps: BTreeSet<String> = ["check".to_string()].into_iter().collect();
|
|
assert!(validate("steps.check.output.ok && input.x > 1", &steps).is_ok());
|
|
assert!(validate("steps.ghost.output.ok", &steps).is_err());
|
|
assert!(validate("steps.check.result", &steps).is_err());
|
|
}
|
|
}
|