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:
MechaCat02
2026-07-12 16:46:32 +02:00
parent c8c4f012ff
commit 44f992cbb0
18 changed files with 2059 additions and 3 deletions

View File

@@ -103,6 +103,20 @@ pub struct Bundle {
/// declines (the binding 404s instead of serving). App-only.
#[serde(default)]
pub suppress_routes: Vec<String>,
/// v1.2 Workflows: declared DAG workflow definitions. App-owned (M1); a
/// `[group]` carrying workflows is rejected in `validate_bundle_for`.
#[serde(default)]
pub workflows: Vec<BundleWorkflow>,
}
/// One declared workflow on the wire: a name + its (already-parsed) DAG.
#[derive(Debug, Clone, Deserialize)]
pub struct BundleWorkflow {
pub name: String,
pub definition: picloud_shared::workflow::WorkflowDefinition,
/// Three-state lifecycle (§4.3); omitted ⇒ active.
#[serde(default = "picloud_shared::default_true")]
pub enabled: bool,
}
/// One declared shared-collection marker on the wire: a name + its store kind.
@@ -450,6 +464,9 @@ pub struct Plan {
/// §11 tail per-app suppression markers, keyed `"{target_kind}:{reference}"`.
#[serde(default)]
pub suppressions: Vec<ResourceChange>,
/// v1.2 Workflows: workflow definitions, keyed by name.
#[serde(default)]
pub workflows: Vec<ResourceChange>,
}
impl Plan {
@@ -464,6 +481,8 @@ impl Plan {
.chain(&self.vars)
.chain(&self.extension_points)
.chain(&self.collections)
.chain(&self.suppressions)
.chain(&self.workflows)
.all(|c| c.op == Op::NoOp)
}
}
@@ -744,6 +763,8 @@ pub struct CurrentState {
/// §11 tail per-app suppression markers at this node (app-only), as
/// `(target_kind, reference)` pairs.
pub suppressions: Vec<(String, String)>,
/// v1.2 Workflows owned directly by this node (app-owned; empty for a group).
pub workflows: Vec<crate::workflow_repo::Workflow>,
}
/// One row of the read-only extension-point report (§5.5).
@@ -1125,6 +1146,20 @@ impl ApplyService {
// subtree. Both are inheritance-only (the dispatch filters gate to
// group-owned templates on the suppressing owner's chain), so no
// owner-kind guard here.
//
// v1.2 Workflows: app-owned only (M1). Reject on a group node (the
// `group_id` column ships unused). Then structurally validate each
// definition (unique/acyclic steps, deps exist, when/template parse).
if is_group && !bundle.workflows.is_empty() {
return Err(ApplyError::Invalid(
"a group cannot declare workflows — they are app-owned (group-owned \
workflow templates are a future addition)"
.into(),
));
}
for w in &bundle.workflows {
validate_workflow_definition(&w.name, &w.definition).map_err(ApplyError::Invalid)?;
}
self.validate_bundle(bundle, inherited_endpoints)
}
@@ -1568,6 +1603,62 @@ impl ApplyService {
}
}
}
// v1.2 Workflows (app-owned in M1). Create/Update always; Delete only
// under --prune (mirrors scripts). Function / sub-workflow references
// are resolved at RUN time, so there is no in-tx ordering dependency on
// the scripts applied above.
if let ApplyOwner::App(app_id) = owner {
let cur_by_name: HashMap<String, &crate::workflow_repo::Workflow> = current
.workflows
.iter()
.map(|w| (w.name.to_lowercase(), w))
.collect();
let desired_by_name: HashMap<String, &BundleWorkflow> = bundle
.workflows
.iter()
.map(|w| (w.name.to_lowercase(), w))
.collect();
for ch in &plan.workflows {
let key = ch.key.to_lowercase();
match ch.op {
Op::Create => {
let w = desired_by_name[&key];
crate::workflow_repo::insert_workflow_tx(
&mut *tx,
app_id,
&w.name,
&w.definition,
w.enabled,
)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
report.workflows_created += 1;
}
Op::Update => {
let w = desired_by_name[&key];
let cur = cur_by_name[&key];
crate::workflow_repo::update_workflow_tx(
&mut *tx,
cur.id,
&w.definition,
w.enabled,
)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
report.workflows_updated += 1;
}
Op::Delete if prune => {
let cur = cur_by_name[&key];
crate::workflow_repo::delete_workflow_tx(&mut *tx, cur.id)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
report.workflows_deleted += 1;
}
Op::NoOp | Op::Delete => {}
}
}
}
Ok(name_to_id)
}
@@ -3934,6 +4025,15 @@ impl ApplyService {
crate::suppression_repo::list_for_owner(&self.pool, owner.as_script_owner())
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
// v1.2 Workflows are app-owned (M1); a group node owns none.
let workflows = match owner {
ApplyOwner::App(app_id) => {
crate::workflow_repo::list_workflows_for_app(&self.pool, app_id)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
}
ApplyOwner::Group(_) => Vec::new(),
};
Ok(CurrentState {
scripts,
routes,
@@ -3943,6 +4043,7 @@ impl ApplyService {
extension_point_names,
collections,
suppressions,
workflows,
})
}
@@ -4008,9 +4109,160 @@ fn compute_diff_with_names(
extension_points: diff_extension_points(current, bundle),
collections: diff_collections(current, bundle),
suppressions: diff_suppressions(current, bundle),
workflows: diff_workflows(current, bundle),
}
}
/// Diff workflows by `lower(name)`. Like scripts, a workflow has a stable name
/// identity with a mutable body, so a changed definition (or `enabled`) is an
/// `Update`, not a delete+create. Live workflows absent from the manifest are
/// `Delete` (applied only under `--prune`).
fn diff_workflows(current: &CurrentState, bundle: &Bundle) -> Vec<ResourceChange> {
let by_name: HashMap<String, &crate::workflow_repo::Workflow> = current
.workflows
.iter()
.map(|w| (w.name.to_lowercase(), w))
.collect();
let desired: HashSet<String> = bundle
.workflows
.iter()
.map(|w| w.name.to_lowercase())
.collect();
let mut out = Vec::new();
for w in &bundle.workflows {
match by_name.get(&w.name.to_lowercase()) {
None => out.push(ResourceChange {
op: Op::Create,
key: w.name.clone(),
detail: None,
}),
Some(cur) => {
let reason = if cur.definition != w.definition {
Some("definition changed".to_string())
} else if cur.enabled != w.enabled {
Some(if w.enabled { "enabled" } else { "disabled" }.to_string())
} else {
None
};
out.push(ResourceChange {
op: if reason.is_some() {
Op::Update
} else {
Op::NoOp
},
key: w.name.clone(),
detail: reason,
});
}
}
}
for w in &current.workflows {
if !desired.contains(&w.name.to_lowercase()) {
out.push(ResourceChange {
op: Op::Delete,
key: w.name.clone(),
detail: None,
});
}
}
out
}
/// Pure structural validation of a workflow definition (mirrors
/// `validate_trigger_shape`): non-empty; unique step names; each step targets
/// exactly one of function/workflow; `depends_on` references exist and aren't
/// self-loops; the DAG is acyclic; every `when` + input template parses and
/// only references declared steps. Returns a human error string on failure.
fn validate_workflow_definition(
wf_name: &str,
def: &picloud_shared::workflow::WorkflowDefinition,
) -> Result<(), String> {
use std::collections::{BTreeSet, HashMap as Map};
if def.steps.is_empty() {
return Err(format!("workflow `{wf_name}` has no steps"));
}
let mut names: BTreeSet<String> = BTreeSet::new();
for s in &def.steps {
if s.name.trim().is_empty() {
return Err(format!(
"workflow `{wf_name}` has a step with an empty name"
));
}
if !names.insert(s.name.clone()) {
return Err(format!(
"workflow `{wf_name}` has a duplicate step name `{}`",
s.name
));
}
}
for s in &def.steps {
if s.target().is_none() {
return Err(format!(
"workflow `{wf_name}` step `{}` must set exactly one of `function` or `workflow`",
s.name
));
}
for dep in &s.depends_on {
if dep == &s.name {
return Err(format!(
"workflow `{wf_name}` step `{}` depends on itself",
s.name
));
}
if !names.contains(dep) {
return Err(format!(
"workflow `{wf_name}` step `{}` depends on undeclared step `{dep}`",
s.name
));
}
}
if let Some(w) = &s.when {
crate::workflow_expr::validate(w, &names).map_err(|e| {
format!(
"workflow `{wf_name}` step `{}` has a bad `when`: {e}",
s.name
)
})?;
}
crate::workflow_template::validate(&s.input, &names).map_err(|e| {
format!(
"workflow `{wf_name}` step `{}` has a bad input template: {e}",
s.name
)
})?;
}
// DAG acyclicity via Kahn topological sort.
let mut indeg: Map<&str, usize> = def.steps.iter().map(|s| (s.name.as_str(), 0)).collect();
for s in &def.steps {
for _dep in &s.depends_on {
*indeg.get_mut(s.name.as_str()).unwrap() += 1;
}
}
let mut queue: Vec<&str> = indeg
.iter()
.filter(|(_, d)| **d == 0)
.map(|(n, _)| *n)
.collect();
let mut seen = 0usize;
while let Some(n) = queue.pop() {
seen += 1;
for s in &def.steps {
if s.depends_on.iter().any(|d| d == n) {
let e = indeg.get_mut(s.name.as_str()).unwrap();
*e -= 1;
if *e == 0 {
queue.push(s.name.as_str());
}
}
}
}
if seen != def.steps.len() {
return Err(format!("workflow `{wf_name}` has a dependency cycle"));
}
Ok(())
}
/// Diff app-owned vars by key. Unlike secrets (whose values live out-of-band),
/// a var's value IS in the manifest, so equality is value-sensitive: a changed
/// value is an `Update`. Live vars absent from the manifest are `Delete`
@@ -5140,6 +5392,12 @@ pub struct ApplyReport {
pub suppressions_created: u32,
#[serde(default)]
pub suppressions_deleted: u32,
#[serde(default)]
pub workflows_created: u32,
#[serde(default)]
pub workflows_updated: u32,
#[serde(default)]
pub workflows_deleted: u32,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<String>,
}
@@ -5483,6 +5741,7 @@ mod tests {
collections: Vec::new(),
suppress_triggers: Vec::new(),
suppress_routes: Vec::new(),
workflows: Vec::new(),
};
let plan = compute_diff(&current, &bundle);
assert_eq!(plan.scripts.len(), 1);
@@ -5509,6 +5768,7 @@ mod tests {
collections: Vec::new(),
suppress_triggers: Vec::new(),
suppress_routes: Vec::new(),
workflows: Vec::new(),
};
let plan = compute_diff(&current, &bundle);
assert!(plan.is_noop(), "expected all no-op, got {plan:?}");
@@ -5530,6 +5790,7 @@ mod tests {
collections: Vec::new(),
suppress_triggers: Vec::new(),
suppress_routes: Vec::new(),
workflows: Vec::new(),
};
let plan = compute_diff(&current, &bundle);
assert_eq!(plan.scripts[0].op, Op::Update);
@@ -5552,6 +5813,7 @@ mod tests {
collections: Vec::new(),
suppress_triggers: Vec::new(),
suppress_routes: Vec::new(),
workflows: Vec::new(),
};
let plan = compute_diff(&current, &bundle);
assert_eq!(plan.scripts[0].op, Op::Delete);
@@ -5605,6 +5867,7 @@ mod tests {
collections: Vec::new(),
suppress_triggers: Vec::new(),
suppress_routes: Vec::new(),
workflows: Vec::new(),
};
let plan = compute_diff(&current, &bundle);
assert_eq!(plan.routes[0].op, Op::Update);
@@ -5621,6 +5884,7 @@ mod tests {
collections: Vec::new(),
suppress_triggers: Vec::new(),
suppress_routes: Vec::new(),
workflows: Vec::new(),
}
}
@@ -6350,4 +6614,85 @@ mod tests {
assert!(!bare.confirm_required("production"));
assert!(bare.gated_envs().is_empty());
}
// ---- v1.2 Workflows: definition validation ----------------------------
fn wf_def(steps: serde_json::Value) -> picloud_shared::workflow::WorkflowDefinition {
serde_json::from_value(serde_json::json!({ "steps": steps })).expect("valid def json")
}
#[test]
fn workflow_validation_accepts_a_valid_dag() {
let def = wf_def(serde_json::json!([
{ "name": "a", "function": "fa" },
{ "name": "b", "function": "fb", "depends_on": ["a"],
"when": "steps.a.output.ok == true",
"input": { "x": "{{ steps.a.output.value }}" } },
{ "name": "c", "workflow": "sub", "depends_on": ["a", "b"] },
]));
assert!(validate_workflow_definition("w", &def).is_ok());
}
#[test]
fn workflow_validation_rejects_empty_and_dupes() {
assert!(validate_workflow_definition("w", &wf_def(serde_json::json!([]))).is_err());
let dupe = wf_def(serde_json::json!([
{ "name": "a", "function": "fa" },
{ "name": "a", "function": "fb" },
]));
assert!(validate_workflow_definition("w", &dupe)
.unwrap_err()
.contains("duplicate step name"));
}
#[test]
fn workflow_validation_rejects_bad_target_and_deps() {
// neither function nor workflow
let no_target = wf_def(serde_json::json!([{ "name": "a" }]));
assert!(validate_workflow_definition("w", &no_target)
.unwrap_err()
.contains("exactly one"));
// both function and workflow
let both = wf_def(serde_json::json!([{ "name": "a", "function": "f", "workflow": "w" }]));
assert!(validate_workflow_definition("w", &both).is_err());
// dependency on an undeclared step
let bad_dep = wf_def(serde_json::json!([
{ "name": "a", "function": "fa", "depends_on": ["ghost"] },
]));
assert!(validate_workflow_definition("w", &bad_dep)
.unwrap_err()
.contains("undeclared step"));
// self-dependency
let self_dep = wf_def(serde_json::json!([
{ "name": "a", "function": "fa", "depends_on": ["a"] },
]));
assert!(validate_workflow_definition("w", &self_dep)
.unwrap_err()
.contains("depends on itself"));
}
#[test]
fn workflow_validation_detects_cycles() {
let cyclic = wf_def(serde_json::json!([
{ "name": "a", "function": "fa", "depends_on": ["c"] },
{ "name": "b", "function": "fb", "depends_on": ["a"] },
{ "name": "c", "function": "fc", "depends_on": ["b"] },
]));
assert!(validate_workflow_definition("w", &cyclic)
.unwrap_err()
.contains("cycle"));
}
#[test]
fn workflow_validation_rejects_bad_when_and_template() {
let bad_when = wf_def(serde_json::json!([
{ "name": "a", "function": "fa" },
{ "name": "b", "function": "fb", "depends_on": ["a"], "when": "steps.ghost.output.x" },
]));
assert!(validate_workflow_definition("w", &bad_when).is_err());
let bad_tmpl = wf_def(serde_json::json!([
{ "name": "a", "function": "fa", "input": { "x": "{{ steps.ghost.output }}" } },
]));
assert!(validate_workflow_definition("w", &bad_tmpl).is_err());
}
}

View File

@@ -108,6 +108,9 @@ pub mod users_service;
pub mod vars_api;
pub mod vars_repo;
pub mod vars_service;
pub mod workflow_expr;
pub mod workflow_repo;
pub mod workflow_template;
pub use abandoned_repo::{
AbandonedRepo, AbandonedRepoError, NewAbandonedExecution, PostgresAbandonedRepo,

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

View File

@@ -0,0 +1,229 @@
//! Persistence for workflow definitions (v1.2 Workflows).
//!
//! The `workflows` table (0071) is owner-polymorphic like `scripts`; M1 authors
//! **app-owned** workflows only (the `group_id` column ships unused). The read
//! trait backs the admin/CLI surface; the `*_tx` free-fns are used by the
//! declarative `apply` reconcile engine (Create/Update/Delete by `lower(name)`,
//! all in one transaction) — mirroring `trigger_repo::insert_trigger_tx`.
//!
//! Run/step state (`workflow_runs`, `workflow_run_steps`) is added in M2 with
//! the orchestrator.
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use sqlx::PgPool;
use thiserror::Error;
use uuid::Uuid;
use picloud_shared::workflow::WorkflowDefinition;
use picloud_shared::{AppId, WorkflowId};
#[derive(Debug, Error)]
pub enum WorkflowRepoError {
#[error(transparent)]
Db(#[from] sqlx::Error),
#[error("workflow definition (de)serialization failed: {0}")]
Serde(String),
}
/// A stored workflow definition (app-owned in M1).
#[derive(Debug, Clone)]
pub struct Workflow {
pub id: WorkflowId,
pub app_id: AppId,
pub name: String,
pub definition: WorkflowDefinition,
pub enabled: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// Raw row shape (definition decoded separately).
#[derive(sqlx::FromRow)]
struct WorkflowRow {
id: Uuid,
app_id: Uuid,
name: String,
definition: serde_json::Value,
enabled: bool,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
impl TryFrom<WorkflowRow> for Workflow {
type Error = WorkflowRepoError;
fn try_from(r: WorkflowRow) -> Result<Self, Self::Error> {
Ok(Self {
id: r.id.into(),
app_id: r.app_id.into(),
name: r.name,
definition: serde_json::from_value(r.definition)
.map_err(|e| WorkflowRepoError::Serde(e.to_string()))?,
enabled: r.enabled,
created_at: r.created_at,
updated_at: r.updated_at,
})
}
}
const SELECT_COLS: &str = "id, app_id, name, definition, enabled, created_at, updated_at";
#[async_trait]
pub trait WorkflowRepo: Send + Sync {
/// All workflows owned by `app_id`, ordered by name.
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Workflow>, WorkflowRepoError>;
/// A single app-owned workflow by case-insensitive name.
async fn get_by_name(
&self,
app_id: AppId,
name: &str,
) -> Result<Option<Workflow>, WorkflowRepoError>;
/// A single workflow by id (app-scoped for isolation).
async fn get_by_id(
&self,
app_id: AppId,
id: WorkflowId,
) -> Result<Option<Workflow>, WorkflowRepoError>;
}
pub struct PostgresWorkflowRepo {
pool: PgPool,
}
impl PostgresWorkflowRepo {
#[must_use]
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl WorkflowRepo for PostgresWorkflowRepo {
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Workflow>, WorkflowRepoError> {
let rows: Vec<WorkflowRow> = sqlx::query_as(&format!(
"SELECT {SELECT_COLS} FROM workflows WHERE app_id = $1 ORDER BY LOWER(name)"
))
.bind(app_id.into_inner())
.fetch_all(&self.pool)
.await?;
rows.into_iter().map(TryInto::try_into).collect()
}
async fn get_by_name(
&self,
app_id: AppId,
name: &str,
) -> Result<Option<Workflow>, WorkflowRepoError> {
let row: Option<WorkflowRow> = sqlx::query_as(&format!(
"SELECT {SELECT_COLS} FROM workflows WHERE app_id = $1 AND LOWER(name) = LOWER($2)"
))
.bind(app_id.into_inner())
.bind(name)
.fetch_optional(&self.pool)
.await?;
row.map(TryInto::try_into).transpose()
}
async fn get_by_id(
&self,
app_id: AppId,
id: WorkflowId,
) -> Result<Option<Workflow>, WorkflowRepoError> {
let row: Option<WorkflowRow> = sqlx::query_as(&format!(
"SELECT {SELECT_COLS} FROM workflows WHERE app_id = $1 AND id = $2"
))
.bind(app_id.into_inner())
.bind(id.into_inner())
.fetch_optional(&self.pool)
.await?;
row.map(TryInto::try_into).transpose()
}
}
/// Pool-based read of an app's workflows (used by `apply`'s `load_current`,
/// which reads several markers directly off the pool).
pub async fn list_workflows_for_app(
pool: &PgPool,
app_id: AppId,
) -> Result<Vec<Workflow>, WorkflowRepoError> {
let rows: Vec<WorkflowRow> = sqlx::query_as(&format!(
"SELECT {SELECT_COLS} FROM workflows WHERE app_id = $1 ORDER BY LOWER(name)"
))
.bind(app_id.into_inner())
.fetch_all(pool)
.await?;
rows.into_iter().map(TryInto::try_into).collect()
}
// ---- reconcile tx free-fns (used by apply_service) ------------------------
/// Load an app's workflows inside the apply transaction (so the diff sees a
/// snapshot consistent with the writes).
pub async fn list_workflows_for_app_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
app_id: AppId,
) -> Result<Vec<Workflow>, WorkflowRepoError> {
let rows: Vec<WorkflowRow> = sqlx::query_as(&format!(
"SELECT {SELECT_COLS} FROM workflows WHERE app_id = $1 ORDER BY LOWER(name)"
))
.bind(app_id.into_inner())
.fetch_all(&mut **tx)
.await?;
rows.into_iter().map(TryInto::try_into).collect()
}
/// Insert an app-owned workflow.
pub async fn insert_workflow_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
app_id: AppId,
name: &str,
definition: &WorkflowDefinition,
enabled: bool,
) -> Result<WorkflowId, WorkflowRepoError> {
let def =
serde_json::to_value(definition).map_err(|e| WorkflowRepoError::Serde(e.to_string()))?;
let (id,): (Uuid,) = sqlx::query_as(
"INSERT INTO workflows (app_id, name, definition, enabled) \
VALUES ($1, $2, $3, $4) RETURNING id",
)
.bind(app_id.into_inner())
.bind(name)
.bind(def)
.bind(enabled)
.fetch_one(&mut **tx)
.await?;
Ok(id.into())
}
/// Update a workflow's definition + enabled flag (name/identity unchanged).
pub async fn update_workflow_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
id: WorkflowId,
definition: &WorkflowDefinition,
enabled: bool,
) -> Result<(), WorkflowRepoError> {
let def =
serde_json::to_value(definition).map_err(|e| WorkflowRepoError::Serde(e.to_string()))?;
sqlx::query(
"UPDATE workflows SET definition = $2, enabled = $3, updated_at = NOW() WHERE id = $1",
)
.bind(id.into_inner())
.bind(def)
.bind(enabled)
.execute(&mut **tx)
.await?;
Ok(())
}
/// Delete a workflow (prune). RESTRICTs if runs reference it — a workflow with
/// history is kept; this is surfaced as an error to the reconcile caller.
pub async fn delete_workflow_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
id: WorkflowId,
) -> Result<(), WorkflowRepoError> {
sqlx::query("DELETE FROM workflows WHERE id = $1")
.bind(id.into_inner())
.execute(&mut **tx)
.await?;
Ok(())
}

View File

@@ -0,0 +1,315 @@
//! Workflow input reference resolution (v1.2 Workflows) — pure, DB-free.
//!
//! A step's declared `input` is a JSON value whose string leaves may carry
//! `{{ … }}` references, resolved at run time against the run's accumulated
//! context: the run `input` and each prior step's `output`.
//!
//! Reference grammar:
//! `{{ input }}` · `{{ input.a.b }}`
//! `{{ steps.<name>.output }}` · `{{ steps.<name>.output.a.b }}`
//!
//! A string that is *exactly* a single `{{ … }}` reference is replaced by the
//! referenced JSON value (type-preserving). A reference embedded in a larger
//! string interpolates as text. A missing reference is a hard error (a workflow
//! referencing a non-existent output is a definition bug we surface, not hide).
//!
//! The same reference walker backs the `when` condition evaluator
//! (`workflow_expr`).
use std::collections::{BTreeMap, BTreeSet};
use serde_json::{Map, Value};
use thiserror::Error;
/// The run context a template/condition resolves against.
#[derive(Debug, Default, Clone)]
pub struct RunContext {
/// The run's top-level input.
pub input: Value,
/// Prior step outputs, keyed by step name.
pub steps: BTreeMap<String, Value>,
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum TemplateError {
#[error("unresolved reference `{0}` (no such input/step output)")]
MissingRef(String),
#[error("malformed reference `{0}`")]
MalformedRef(String),
#[error("reference `{0}` targets undeclared step `{1}`")]
UnknownStep(String, String),
}
impl RunContext {
/// Resolve a dotted reference path (`input.a.b` / `steps.name.output.a.b`)
/// to the JSON value it points at, or `None` if absent.
#[must_use]
pub fn resolve_ref(&self, path: &str) -> Option<&Value> {
let parts: Vec<&str> = path.split('.').collect();
let (root_val, rest): (&Value, &[&str]) = match parts.first()?.trim() {
"input" => (&self.input, &parts[1..]),
"steps" => {
// steps.<name>.output[.deep...]
let name = parts.get(1)?;
if parts.get(2).map(|s| s.trim()) != Some("output") {
return None;
}
(self.steps.get(*name)?, &parts[3..])
}
_ => return None,
};
let mut cur = root_val;
for seg in rest {
let seg = seg.trim();
cur = match cur {
Value::Object(m) => m.get(seg)?,
Value::Array(a) => a.get(seg.parse::<usize>().ok()?)?,
_ => return None,
};
}
Some(cur)
}
}
/// Resolve every `{{ … }}` reference in `value` against `ctx`.
///
/// # Errors
/// [`TemplateError::MissingRef`] if a reference does not resolve;
/// [`TemplateError::MalformedRef`] on a syntactically-bad reference.
pub fn resolve(value: &Value, ctx: &RunContext) -> Result<Value, TemplateError> {
match value {
Value::String(s) => resolve_string(s, ctx),
Value::Array(a) => Ok(Value::Array(
a.iter()
.map(|v| resolve(v, ctx))
.collect::<Result<_, _>>()?,
)),
Value::Object(m) => {
let mut out = Map::with_capacity(m.len());
for (k, v) in m {
out.insert(k.clone(), resolve(v, ctx)?);
}
Ok(Value::Object(out))
}
other => Ok(other.clone()),
}
}
fn resolve_string(s: &str, ctx: &RunContext) -> Result<Value, TemplateError> {
// Exact-single-reference: preserve the referenced value's JSON type.
if let Some(inner) = exact_single_ref(s) {
return ctx
.resolve_ref(inner)
.cloned()
.ok_or_else(|| TemplateError::MissingRef(inner.to_string()));
}
// Otherwise interpolate references into the surrounding text.
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(open) = rest.find("{{") {
out.push_str(&rest[..open]);
let after = &rest[open + 2..];
let close = after
.find("}}")
.ok_or_else(|| TemplateError::MalformedRef(s.to_string()))?;
let refpath = after[..close].trim();
let val = ctx
.resolve_ref(refpath)
.ok_or_else(|| TemplateError::MissingRef(refpath.to_string()))?;
out.push_str(&render_scalar(val));
rest = &after[close + 2..];
}
out.push_str(rest);
Ok(Value::String(out))
}
/// If `s` is exactly one `{{ ref }}` (ignoring surrounding whitespace and with
/// no other braces), return the trimmed inner reference.
fn exact_single_ref(s: &str) -> Option<&str> {
let t = s.trim();
let inner = t.strip_prefix("{{")?.strip_suffix("}}")?;
if inner.contains("{{") || inner.contains("}}") {
return None;
}
Some(inner.trim())
}
/// Render a JSON scalar for string interpolation (strings raw, others via JSON).
fn render_scalar(v: &Value) -> String {
match v {
Value::String(s) => s.clone(),
Value::Null => String::new(),
other => other.to_string(),
}
}
/// Apply-time validation: every reference in `value` must be syntactically valid
/// and target either `input` or a declared step's `output`. Step outputs don't
/// exist yet at apply time, so this checks *shape*, not resolvability.
///
/// # Errors
/// [`TemplateError`] on a malformed reference or one naming an undeclared step.
pub fn validate(value: &Value, step_names: &BTreeSet<String>) -> Result<(), TemplateError> {
for r in collect_refs(value)? {
let parts: Vec<&str> = r.split('.').map(str::trim).collect();
match parts.first().copied() {
Some("input") => {}
Some("steps") => {
let name = parts
.get(1)
.filter(|s| !s.is_empty())
.ok_or_else(|| TemplateError::MalformedRef(r.clone()))?;
if parts.get(2).copied() != Some("output") {
return Err(TemplateError::MalformedRef(r.clone()));
}
if !step_names.contains(*name) {
return Err(TemplateError::UnknownStep(r.clone(), (*name).to_string()));
}
}
_ => return Err(TemplateError::MalformedRef(r.clone())),
}
}
Ok(())
}
/// Collect every `{{ … }}` reference path found anywhere in `value`.
fn collect_refs(value: &Value) -> Result<Vec<String>, TemplateError> {
let mut acc = Vec::new();
collect_into(value, &mut acc)?;
Ok(acc)
}
fn collect_into(value: &Value, acc: &mut Vec<String>) -> Result<(), TemplateError> {
match value {
Value::String(s) => {
let mut rest = s.as_str();
while let Some(open) = rest.find("{{") {
let after = &rest[open + 2..];
let close = after
.find("}}")
.ok_or_else(|| TemplateError::MalformedRef(s.clone()))?;
acc.push(after[..close].trim().to_string());
rest = &after[close + 2..];
}
}
Value::Array(a) => {
for v in a {
collect_into(v, acc)?;
}
}
Value::Object(m) => {
for v in m.values() {
collect_into(v, acc)?;
}
}
_ => {}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn ctx() -> RunContext {
let mut steps = BTreeMap::new();
steps.insert("validate".to_string(), json!({ "ok": true, "score": 7 }));
steps.insert("fetch".to_string(), json!([10, 20, 30]));
RunContext {
input: json!({ "order": { "id": "abc", "total": 42 } }),
steps,
}
}
#[test]
fn exact_single_ref_preserves_type() {
let c = ctx();
assert_eq!(
resolve(&json!("{{ input.order.total }}"), &c).unwrap(),
json!(42)
);
assert_eq!(
resolve(&json!("{{ steps.validate.output.ok }}"), &c).unwrap(),
json!(true)
);
// whole-object ref
assert_eq!(
resolve(&json!("{{ steps.validate.output }}"), &c).unwrap(),
json!({ "ok": true, "score": 7 })
);
}
#[test]
fn array_index_and_nested_object() {
let c = ctx();
assert_eq!(
resolve(&json!("{{ steps.fetch.output.1 }}"), &c).unwrap(),
json!(20)
);
assert_eq!(
resolve(&json!({ "id": "{{ input.order.id }}" }), &c).unwrap(),
json!({ "id": "abc" })
);
}
#[test]
fn interpolation_renders_text() {
let c = ctx();
assert_eq!(
resolve(
&json!("order {{ input.order.id }} scored {{ steps.validate.output.score }}"),
&c
)
.unwrap(),
json!("order abc scored 7")
);
}
#[test]
fn missing_ref_is_error() {
let c = ctx();
assert_eq!(
resolve(&json!("{{ steps.nope.output }}"), &c).unwrap_err(),
TemplateError::MissingRef("steps.nope.output".to_string())
);
assert!(matches!(
resolve(&json!("{{ input.order.missing }}"), &c).unwrap_err(),
TemplateError::MissingRef(_)
));
}
#[test]
fn validate_shape_against_declared_steps() {
let steps: BTreeSet<String> = ["validate".to_string()].into_iter().collect();
// ok: input + declared step
assert!(validate(
&json!({ "a": "{{ input.x }}", "b": "{{ steps.validate.output.ok }}" }),
&steps
)
.is_ok());
// unknown step
assert!(matches!(
validate(&json!("{{ steps.ghost.output }}"), &steps).unwrap_err(),
TemplateError::UnknownStep(_, _)
));
// malformed root
assert!(matches!(
validate(&json!("{{ bogus.x }}"), &steps).unwrap_err(),
TemplateError::MalformedRef(_)
));
// steps without .output
assert!(matches!(
validate(&json!("{{ steps.validate.result }}"), &steps).unwrap_err(),
TemplateError::MalformedRef(_)
));
}
#[test]
fn non_template_values_pass_through() {
let c = ctx();
assert_eq!(resolve(&json!(123), &c).unwrap(), json!(123));
assert_eq!(resolve(&json!(null), &c).unwrap(), json!(null));
assert_eq!(resolve(&json!("plain"), &c).unwrap(), json!("plain"));
}
}