Files
PiCloud/crates/picloud-cli/tests/workflows.rs
MechaCat02 5a630e1d9f feat(workflows): M5 — SDK workflow::start + admin run API + pic CLI
The user-facing surface for the durable DAG engine. A workflow run can now be
started three ways, all resolving the workflow by name in the caller's app
(`cx.app_id` / the resolved app — never a passed-in arg, the isolation
boundary), seeding a run the orchestrator advances.

SDK seam (shared):
- `WorkflowService` trait + `NoopWorkflowService` + `WorkflowError` in
  `shared::workflow` (mirrors `InvokeService`); added to `Services` as a
  noop-defaulted `with_workflow` builder (all `Services::new` call sites
  unchanged).
- `WorkflowServiceImpl` (manager-core): resolves + seeds a run; authenticated
  callers gated on `AppInvoke` (anonymous skips, script-as-gate), the same gate
  `invoke()` uses.
- `executor-core::sdk::workflow` — the Rhai bridge `workflow::start(name, input)`
  / `workflow::start(name)`, registered in `register_all`. Returns the run id.

Admin API (manager-core `workflows_api`, mounted in the binary):
- `GET  /apps/{app}/workflows`              — definitions (AppRead)
- `POST /apps/{app}/workflows/{name}/runs`  — start a run (AppInvoke)
- `GET  /apps/{app}/workflows/{name}/runs`  — run history (AppRead)
- `GET  /apps/{app}/workflow-runs/{run_id}` — one run + its steps (AppRead)
  `app_id` scopes every query; a foreign run 404s.

CLI (`pic workflows`): `ls` · `run <name> [--input JSON]` · `runs <name>` ·
`run-status <run_id>` — all `--app`-scoped; run definitions stay declarative
(`[[workflows]]` → `pic apply`).

Also: `list_runs_for_workflow` repo reader.

Tests: `workflow_service_start_seeds_a_run` (DB-gated — the SDK/API-shared
entry) + `workflow_run_executes_end_to_end` CLI journey (apply → `pic workflows
run` → poll `run-status` → succeeded, through the real binary + orchestrator).
fmt + clippy -D warnings clean, 449 manager-core lib tests, 14 orchestrator DB
tests, 18 executor-core lib tests, 3 workflow CLI journeys green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 17:48:38 +02:00

261 lines
8.5 KiB
Rust

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