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:
@@ -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 ¤t.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(¤t, &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(¤t, &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(¤t, &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(¤t, &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(¤t, &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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
229
crates/manager-core/src/workflow_repo.rs
Normal file
229
crates/manager-core/src/workflow_repo.rs
Normal 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(())
|
||||
}
|
||||
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