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>
This commit is contained in:
@@ -153,3 +153,108 @@ fn workflow_with_a_cycle_is_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}"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user