feat(workflows): M1 — schema + definition validation + declarative reconcile
First milestone of the v1.2 Workflows track (blueprint §9.1/§9.2): the durable
DAG engine's foundation — the definition model, its validation, and the
declarative `apply` reconcile path. No execution yet (the orchestrator is M2).
- Migration 0071_workflows: `workflows` (owner-polymorphic like scripts, app-
owned in M1), `workflow_runs`, and `workflow_run_steps` (the per-step
competing-consumer lease table the M2 orchestrator will claim). Schema golden
reblessed.
- `shared::workflow`: the `WorkflowDefinition` / `WorkflowStepDef` DTOs (shared
by the CLI, apply, and the future orchestrator) + run/step status enums.
- Pure, DB-free `workflow_template` (input `{{ input.x }}` / `{{ steps.a.output.y }}`
resolution, type-preserving) and `workflow_expr` (a small safe JSON-predicate
evaluator for `when` — keeps manager-core free of a scripting engine).
- `workflow_repo`: read trait + reconcile tx free-fns (insert/update/delete).
- apply_service: `BundleWorkflow` + `Plan.workflows` + `CurrentState.workflows`
+ `ApplyReport.workflows_*`; server-side `validate_workflow_definition`
(unique/acyclic steps via topological sort, deps exist, function XOR workflow,
`when`/template parse) rejecting on a group node; `diff_workflows` by
lower(name) (Update on a definition change) + in-tx reconcile.
- CLI: `[[workflows]]` + `[[workflows.steps]]` manifest structs → wire bundle;
plan/apply render + report counts.
- Tests: 16 manager-core lib tests (template, expr, definition validation) +
the `workflows` CLI journey (apply → NoOp → prune; cyclic DAG rejected).
Deferred to later milestones: the orchestrator worker (M2), conditional/template
runtime wiring (M3), nested sub-workflows (M4), the SDK/API/CLI run surface
(M5), the dashboard (M6). The `group_id` column + run/step tables ship now so
those slot in without a migration churn.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
490
crates/manager-core/src/workflow_expr.rs
Normal file
490
crates/manager-core/src/workflow_expr.rs
Normal file
@@ -0,0 +1,490 @@
|
||||
//! 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 — apply-time [`validate`] guards typos.
|
||||
//!
|
||||
//! [`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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user