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:
MechaCat02
2026-07-12 16:46:32 +02:00
parent c8c4f012ff
commit 44f992cbb0
18 changed files with 2059 additions and 3 deletions

View 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}");
}