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>
316 lines
10 KiB
Rust
316 lines
10 KiB
Rust
//! 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"));
|
|
}
|
|
}
|