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:
@@ -1619,6 +1619,8 @@ pub struct PlanDto {
|
||||
pub collections: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub suppressions: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub workflows: Vec<ChangeDto>,
|
||||
/// Fingerprint of the live state this plan was computed against; carried
|
||||
/// in `.picloud/` and replayed to `apply` for the bound-plan check.
|
||||
#[serde(default)]
|
||||
@@ -1694,6 +1696,8 @@ pub struct NodePlanDto {
|
||||
pub collections: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub suppressions: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub workflows: Vec<ChangeDto>,
|
||||
/// §7 M3: this node's ownership outcome under the tree's `[project]`.
|
||||
#[serde(default)]
|
||||
pub ownership: Option<OwnershipPreviewDto>,
|
||||
@@ -1753,6 +1757,12 @@ pub struct ApplyReportDto {
|
||||
#[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(default)]
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
|
||||
@@ -148,6 +148,13 @@ pub async fn run(
|
||||
"+{} -{}",
|
||||
report.suppressions_created, report.suppressions_deleted
|
||||
),
|
||||
)
|
||||
.field(
|
||||
"workflows",
|
||||
format!(
|
||||
"+{} ~{} -{}",
|
||||
report.workflows_created, report.workflows_updated, report.workflows_deleted
|
||||
),
|
||||
);
|
||||
for w in &report.warnings {
|
||||
block.field("warning", w.clone());
|
||||
@@ -269,6 +276,13 @@ pub async fn run_tree(
|
||||
"+{} -{}",
|
||||
report.suppressions_created, report.suppressions_deleted
|
||||
),
|
||||
)
|
||||
.field(
|
||||
"workflows",
|
||||
format!(
|
||||
"+{} ~{} -{}",
|
||||
report.workflows_created, report.workflows_updated, report.workflows_deleted
|
||||
),
|
||||
);
|
||||
for w in &report.warnings {
|
||||
block.field("warning", w.clone());
|
||||
|
||||
@@ -143,6 +143,7 @@ fn scaffold_manifest(slug: &str, name: &str) -> Manifest {
|
||||
secrets: crate::manifest::ManifestSecrets::default(),
|
||||
vars: std::collections::BTreeMap::new(),
|
||||
suppress: crate::manifest::ManifestSuppress::default(),
|
||||
workflows: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ fn render_tree(plan: &TreePlanDto, mode: OutputMode) {
|
||||
]);
|
||||
}
|
||||
}
|
||||
let groups: [(&str, &Vec<ChangeDto>); 8] = [
|
||||
let groups: [(&str, &Vec<ChangeDto>); 9] = [
|
||||
("script", &n.scripts),
|
||||
("route", &n.routes),
|
||||
("trigger", &n.triggers),
|
||||
@@ -115,6 +115,7 @@ fn render_tree(plan: &TreePlanDto, mode: OutputMode) {
|
||||
("extension_point", &n.extension_points),
|
||||
("collection", &n.collections),
|
||||
("suppression", &n.suppressions),
|
||||
("workflow", &n.workflows),
|
||||
];
|
||||
for (rk, changes) in groups {
|
||||
for c in changes {
|
||||
@@ -246,6 +247,24 @@ pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result<Value> {
|
||||
.collect::<Vec<_>>(),
|
||||
"suppress_triggers": manifest.suppress.triggers,
|
||||
"suppress_routes": manifest.suppress.routes,
|
||||
"workflows": manifest
|
||||
.workflows
|
||||
.iter()
|
||||
.map(workflow_to_wire)
|
||||
.collect::<Result<Vec<_>>>()?,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Convert a `[[workflows]]` manifest block to the server `BundleWorkflow`
|
||||
/// shape `{ name, enabled, definition: { steps } }`. The manifest authors steps
|
||||
/// at the workflow's top level; the wire nests them under `definition`.
|
||||
fn workflow_to_wire(w: &crate::manifest::ManifestWorkflow) -> Result<Value> {
|
||||
let steps = serde_json::to_value(&w.steps)
|
||||
.with_context(|| format!("encoding workflow `{}`", w.name))?;
|
||||
Ok(json!({
|
||||
"name": w.name,
|
||||
"enabled": w.enabled,
|
||||
"definition": { "steps": steps },
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -276,7 +295,7 @@ fn render(plan: &PlanDto, mode: OutputMode) {
|
||||
]);
|
||||
}
|
||||
}
|
||||
let groups: [(&str, &Vec<ChangeDto>); 8] = [
|
||||
let groups: [(&str, &Vec<ChangeDto>); 9] = [
|
||||
("script", &plan.scripts),
|
||||
("route", &plan.routes),
|
||||
("trigger", &plan.triggers),
|
||||
@@ -285,6 +304,7 @@ fn render(plan: &PlanDto, mode: OutputMode) {
|
||||
("extension_point", &plan.extension_points),
|
||||
("collection", &plan.collections),
|
||||
("suppression", &plan.suppressions),
|
||||
("workflow", &plan.workflows),
|
||||
];
|
||||
for (kind, changes) in groups {
|
||||
for c in changes {
|
||||
|
||||
@@ -251,6 +251,7 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
|
||||
},
|
||||
vars: manifest_vars,
|
||||
suppress: crate::manifest::ManifestSuppress::default(),
|
||||
workflows: Vec::new(),
|
||||
};
|
||||
|
||||
std::fs::write(&manifest_path, manifest.to_toml()?)
|
||||
|
||||
@@ -60,6 +60,10 @@ pub struct Manifest {
|
||||
/// App-only; a `[group]` carrying it is rejected in [`Manifest::parse`].
|
||||
#[serde(default, skip_serializing_if = "ManifestSuppress::is_empty")]
|
||||
pub suppress: ManifestSuppress,
|
||||
/// `[[workflows]]` (v1.2 Workflows) — declarative DAG orchestrations.
|
||||
/// App-owned; a `[group]` carrying them is rejected server-side.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub workflows: Vec<ManifestWorkflow>,
|
||||
}
|
||||
|
||||
impl Manifest {
|
||||
@@ -606,6 +610,64 @@ impl ManifestSuppress {
|
||||
}
|
||||
}
|
||||
|
||||
/// `[[workflows]]` (v1.2 Workflows) — a named DAG. Steps are authored as a
|
||||
/// `[[workflows.steps]]` array of tables. The server nests `steps` under the
|
||||
/// stored `definition` JSONB and validates the graph (acyclic, deps exist).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ManifestWorkflow {
|
||||
pub name: String,
|
||||
/// Three-state lifecycle (§4.3); omitted ⇒ active.
|
||||
#[serde(
|
||||
default = "picloud_shared::default_true",
|
||||
skip_serializing_if = "is_true"
|
||||
)]
|
||||
pub enabled: bool,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub steps: Vec<ManifestWorkflowStep>,
|
||||
}
|
||||
|
||||
/// One step of a `[[workflows]]` DAG. Serializes to the server's
|
||||
/// `WorkflowStepDef` shape (the CLI nests these under `definition.steps`).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ManifestWorkflowStep {
|
||||
pub name: String,
|
||||
/// A function step invokes a script by name; exactly one of
|
||||
/// `function`/`workflow` is set (validated server-side).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub function: Option<String>,
|
||||
/// A workflow step starts a nested sub-workflow by name.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub workflow: Option<String>,
|
||||
/// Input mapping — an arbitrary TOML table whose string leaves may carry
|
||||
/// `{{ … }}` references, round-tripped to JSON for the wire.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub input: Option<toml::Value>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub depends_on: Vec<String>,
|
||||
/// A `when` JSON-predicate condition (false ⇒ the step is skipped).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub when: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub retry: Option<ManifestWorkflowRetry>,
|
||||
/// `fail` (default) or `continue`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub on_error: Option<String>,
|
||||
}
|
||||
|
||||
/// Per-step retry policy in the manifest (mirrors the server `WorkflowRetry`).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ManifestWorkflowRetry {
|
||||
pub max_attempts: u32,
|
||||
/// `constant` | `linear` | `exponential` (default).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub backoff: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub base_ms: Option<u32>,
|
||||
}
|
||||
|
||||
// ---- serde skip/default helpers ----
|
||||
|
||||
fn is_endpoint(kind: &ScriptKind) -> bool {
|
||||
@@ -708,6 +770,7 @@ mod tests {
|
||||
("max-retries".to_string(), toml::Value::Integer(3)),
|
||||
]),
|
||||
suppress: ManifestSuppress::default(),
|
||||
workflows: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -814,6 +877,7 @@ mod tests {
|
||||
secrets: ManifestSecrets::default(),
|
||||
vars: BTreeMap::new(),
|
||||
suppress: ManifestSuppress::default(),
|
||||
workflows: Vec::new(),
|
||||
};
|
||||
let text = m.to_toml().unwrap();
|
||||
assert!(!text.contains("[[scripts]]"), "got:\n{text}");
|
||||
|
||||
@@ -56,3 +56,4 @@ mod suppress;
|
||||
mod tree;
|
||||
mod triggers;
|
||||
mod vars;
|
||||
mod workflows;
|
||||
|
||||
155
crates/picloud-cli/tests/workflows.rs
Normal file
155
crates/picloud-cli/tests/workflows.rs
Normal file
@@ -0,0 +1,155 @@
|
||||
//! v1.2 Workflows — M1 declarative authoring journey via `pic`.
|
||||
//!
|
||||
//! An app applies a `[[workflows]]` manifest block (a DAG referencing its
|
||||
//! scripts, with `depends_on`, a `when`, and an input template) → the apply
|
||||
//! report shows the workflow created; re-applying is a NoOp; a manifest with a
|
||||
//! cyclic DAG is rejected atomically; pruning the block deletes the workflow.
|
||||
//!
|
||||
//! The durable execution of a run (the orchestrator) lands in M2; this journey
|
||||
//! pins the authoring + reconcile path the operator uses.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::common;
|
||||
use crate::common::cleanup::AppGuard;
|
||||
|
||||
fn manifest_dir() -> TempDir {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
fs::create_dir_all(dir.path().join("scripts")).expect("scripts dir");
|
||||
dir
|
||||
}
|
||||
|
||||
fn seed_scripts(dir: &TempDir) {
|
||||
fs::write(
|
||||
dir.path().join("scripts/validate.rhai"),
|
||||
"let body = #{ ok: true, value: 7 }; body",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
dir.path().join("scripts/charge.rhai"),
|
||||
"let body = #{ charged: true }; body",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
const SCRIPTS_BLOCK: &str = "\
|
||||
[[scripts]]\nname = \"validate\"\nfile = \"scripts/validate.rhai\"\n\n\
|
||||
[[scripts]]\nname = \"charge\"\nfile = \"scripts/charge.rhai\"\n\n";
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn workflow_apply_creates_then_noop_then_prunes() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let slug = common::unique_slug("wf");
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &slug])
|
||||
.assert()
|
||||
.success();
|
||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||
|
||||
let dir = manifest_dir();
|
||||
seed_scripts(&dir);
|
||||
let with_wf = format!(
|
||||
"[app]\nslug = \"{slug}\"\nname = \"WF Test\"\n\n{SCRIPTS_BLOCK}\
|
||||
[[workflows]]\nname = \"pipeline\"\n\n\
|
||||
[[workflows.steps]]\nname = \"v\"\nfunction = \"validate\"\n\
|
||||
input = {{ order = \"{{{{ input.order }}}}\" }}\n\n\
|
||||
[[workflows.steps]]\nname = \"c\"\nfunction = \"charge\"\n\
|
||||
depends_on = [\"v\"]\nwhen = \"steps.v.output.ok == true\"\n"
|
||||
);
|
||||
let path = dir.path().join("picloud.toml");
|
||||
fs::write(&path, &with_wf).unwrap();
|
||||
|
||||
// First apply: creates the two scripts + the workflow.
|
||||
let out = common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&path)
|
||||
.output()
|
||||
.expect("apply");
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"apply failed: {}\n{stdout}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
assert!(
|
||||
stdout.contains("workflows") && stdout.contains("+1"),
|
||||
"expected a workflow creation in the report:\n{stdout}"
|
||||
);
|
||||
|
||||
// Re-plan is clean (desired state reached — the workflow is a NoOp).
|
||||
let p = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["plan", "--file"])
|
||||
.arg(&path)
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
!p.contains("create") && !p.contains("update"),
|
||||
"expected a clean plan after apply:\n{p}"
|
||||
);
|
||||
|
||||
// Drop the workflow from the manifest and prune → it is deleted.
|
||||
let without_wf = format!("[app]\nslug = \"{slug}\"\nname = \"WF Test\"\n\n{SCRIPTS_BLOCK}");
|
||||
fs::write(&path, &without_wf).unwrap();
|
||||
let pruned = common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&path)
|
||||
.args(["--prune", "--yes"])
|
||||
.output()
|
||||
.expect("prune apply");
|
||||
let ptext = String::from_utf8_lossy(&pruned.stdout);
|
||||
assert!(
|
||||
pruned.status.success(),
|
||||
"prune apply failed: {}\n{ptext}",
|
||||
String::from_utf8_lossy(&pruned.stderr)
|
||||
);
|
||||
assert!(
|
||||
ptext.contains("workflows") && ptext.contains("-1"),
|
||||
"expected a workflow deletion under --prune:\n{ptext}"
|
||||
);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn workflow_with_a_cycle_is_rejected() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let slug = common::unique_slug("wfcycle");
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &slug])
|
||||
.assert()
|
||||
.success();
|
||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||
|
||||
let dir = manifest_dir();
|
||||
seed_scripts(&dir);
|
||||
// a → b → a is a cycle; the server's topological sort must reject it.
|
||||
let cyclic = format!(
|
||||
"[app]\nslug = \"{slug}\"\nname = \"WF Cycle\"\n\n{SCRIPTS_BLOCK}\
|
||||
[[workflows]]\nname = \"loop\"\n\n\
|
||||
[[workflows.steps]]\nname = \"a\"\nfunction = \"validate\"\ndepends_on = [\"b\"]\n\n\
|
||||
[[workflows.steps]]\nname = \"b\"\nfunction = \"charge\"\ndepends_on = [\"a\"]\n"
|
||||
);
|
||||
let path = dir.path().join("picloud.toml");
|
||||
fs::write(&path, &cyclic).unwrap();
|
||||
|
||||
let out = common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&path)
|
||||
.output()
|
||||
.expect("apply");
|
||||
assert!(!out.status.success(), "cyclic workflow should be rejected");
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(err.contains("cycle"), "expected a cycle error, got:\n{err}");
|
||||
}
|
||||
Reference in New Issue
Block a user