//! 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}"); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn workflow_duplicate_name_is_rejected() { let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let slug = common::unique_slug("wfdup"); 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); // Two workflows whose names collide case-insensitively — the reconcile keys // by lower(name), so this must be rejected before it silently drops one. let dup = format!( "[app]\nslug = \"{slug}\"\nname = \"WF Dup\"\n\n{SCRIPTS_BLOCK}\ [[workflows]]\nname = \"Pipe\"\n\n\ [[workflows.steps]]\nname = \"a\"\nfunction = \"validate\"\n\n\ [[workflows]]\nname = \"pipe\"\n\n\ [[workflows.steps]]\nname = \"a\"\nfunction = \"charge\"\n" ); let path = dir.path().join("picloud.toml"); fs::write(&path, &dup).unwrap(); let out = common::pic_as(&env) .args(["apply", "--file"]) .arg(&path) .output() .expect("apply"); assert!(!out.status.success(), "duplicate names should be rejected"); let err = String::from_utf8_lossy(&out.stderr); assert!( err.contains("duplicate workflow"), "expected a duplicate-workflow error, got:\n{err}" ); } /// A workflow survives a `pull` → `plan` round-trip with no phantom drift. /// Regression for the bug where `pic pull` dropped `[[workflows]]`, so a /// subsequent `apply --prune` would silently delete them. #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn workflow_pull_round_trips_clean() { let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let slug = common::unique_slug("wfpull"); 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); // Exercise the full step shape the pull mapper must round-trip: input // template, when, depends_on, retry (with defaults omitted), on_error. let manifest = format!( "[app]\nslug = \"{slug}\"\nname = \"WF Pull\"\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\ on_error = \"continue\"\n[workflows.steps.retry]\nmax_attempts = 3\n" ); let path = dir.path().join("picloud.toml"); fs::write(&path, &manifest).unwrap(); common::pic_as(&env) .args(["apply", "--file"]) .arg(&path) .assert() .success(); // Pull the live state into a fresh dir, then plan it back — the workflow // must round-trip to a clean no-op (no create / update / delete). let out = TempDir::new().expect("tempdir"); common::pic_as(&env) .args(["pull", &slug, "--dir"]) .arg(out.path()) .assert() .success(); let pulled = out.path().join("picloud.toml"); let toml = fs::read_to_string(&pulled).unwrap(); assert!( toml.contains("[[workflows]]") && toml.contains("pipeline"), "pulled manifest is missing the workflow:\n{toml}" ); let plan = common::pic_as(&env) .args(["plan", "--file"]) .arg(&pulled) .output() .expect("plan"); let stdout = String::from_utf8_lossy(&plan.stdout); assert!( plan.status.success(), "plan failed: {}\n{stdout}", String::from_utf8_lossy(&plan.stderr) ); // The workflow row must be a no-op; nothing created/updated/deleted. assert!( !stdout.contains("create") && !stdout.contains("update") && !stdout.contains("delete"), "pull→plan should diff clean for workflows, got:\n{stdout}" ); } /// M5 end-to-end: `pic workflows run` starts a run, the durable orchestrator /// (running inside the fixture's picloud) executes both steps, and /// `run-status` shows it succeeded. #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn workflow_run_executes_end_to_end() { let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let slug = common::unique_slug("wfe2e"); 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); // validate → charge; charge only runs when validate's output.ok is true. let manifest = format!( "[app]\nslug = \"{slug}\"\nname = \"WF E2E\"\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, &manifest).unwrap(); common::pic_as(&env) .args(["apply", "--file"]) .arg(&path) .assert() .success(); // `pic workflows ls` shows the definition. let ls = String::from_utf8( common::pic_as(&env) .args(["workflows", "ls", "--app", &slug]) .output() .unwrap() .stdout, ) .unwrap(); assert!( ls.contains("pipeline"), "workflows ls missing pipeline:\n{ls}" ); // Start a run (JSON output so we can parse the run id). let run_out = common::pic_as(&env) .args([ "--output", "json", "workflows", "run", "--app", &slug, "pipeline", "--input", "{\"order\":\"o1\"}", ]) .output() .expect("run"); assert!( run_out.status.success(), "workflows run failed: {}", String::from_utf8_lossy(&run_out.stderr) ); let started: serde_json::Value = serde_json::from_slice(&run_out.stdout).expect("run output is JSON"); let run_id = started["run_id"] .as_str() .expect("run_id in output") .to_string(); // Poll run-status until the run reaches a terminal state. let mut final_status = String::new(); for _ in 0..40 { let status_out = String::from_utf8( common::pic_as(&env) .args(["workflows", "run-status", "--app", &slug, &run_id]) .output() .unwrap() .stdout, ) .unwrap(); final_status = status_out; if final_status.contains("succeeded") || final_status.contains("failed") { break; } std::thread::sleep(std::time::Duration::from_millis(300)); } assert!( final_status.contains("succeeded"), "run did not succeed in time:\n{final_status}" ); // Both steps ran (validate always; charge because when was true). assert!( final_status.contains('v') && final_status.contains('c'), "expected both steps in the status detail:\n{final_status}" ); }