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:
315
crates/manager-core/src/workflow_template.rs
Normal file
315
crates/manager-core/src/workflow_template.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user