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