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