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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user