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:
@@ -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()?)
|
||||
|
||||
Reference in New Issue
Block a user