Files
PiCloud/crates/manager-core/src/workflow_template.rs
MechaCat02 44f992cbb0 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>
2026-07-12 16:46:32 +02:00

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