Files
PiCloud/crates/manager-core/src/workflow_expr.rs
MechaCat02 eaf5ace30f fix(workflows): close review gaps — pull round-trip, dup-names, reclaim budget
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>
2026-07-12 18:56:44 +02:00

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());
}
}