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:
@@ -27,6 +27,7 @@ pub mod secrets;
|
||||
pub mod stdlib;
|
||||
pub mod users;
|
||||
pub mod vars;
|
||||
pub mod workflow;
|
||||
|
||||
pub use bridge::{dynamic_to_json, json_to_dynamic};
|
||||
pub use cx::SdkCallCx;
|
||||
@@ -66,5 +67,6 @@ pub fn register_all(
|
||||
vars::register(engine, services, cx.clone());
|
||||
email::register(engine, services, cx.clone());
|
||||
users::register(engine, services, cx.clone());
|
||||
workflow::register(engine, services, cx.clone());
|
||||
invoke::register(engine, services, cx, limits, self_engine);
|
||||
}
|
||||
|
||||
83
crates/executor-core/src/sdk/workflow.rs
Normal file
83
crates/executor-core/src/sdk/workflow.rs
Normal file
@@ -0,0 +1,83 @@
|
||||
//! `workflow::` Rhai bridge — start a durable workflow run (v1.2 M5).
|
||||
//!
|
||||
//! ```rhai
|
||||
//! let run_id = workflow::start("nightly_report", #{ date: "2026-07-12" });
|
||||
//! let run_id = workflow::start("cleanup"); // no input
|
||||
//! ```
|
||||
//!
|
||||
//! Returns the run id as a string. The owning workflow resolves from
|
||||
//! `cx.app_id` inside the service — never a script arg (the isolation
|
||||
//! boundary). The durable orchestrator advances the run asynchronously; the
|
||||
//! script does not block on completion.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use picloud_shared::{SdkCallCx, Services, WorkflowService};
|
||||
use rhai::{Dynamic, Engine as RhaiEngine, EvalAltResult, Module};
|
||||
use tokio::runtime::Handle as TokioHandle;
|
||||
|
||||
use crate::sdk::bridge::dynamic_to_json;
|
||||
|
||||
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
||||
let svc = services.workflow.clone();
|
||||
let mut module = Module::new();
|
||||
|
||||
// `workflow::start(name, input)`
|
||||
{
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"start",
|
||||
move |name: &str, input: Dynamic| -> Result<String, Box<EvalAltResult>> {
|
||||
start_blocking(&svc, &cx, name, &input)
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// `workflow::start(name)` — no input (passes JSON null).
|
||||
{
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"start",
|
||||
move |name: &str| -> Result<String, Box<EvalAltResult>> {
|
||||
start_blocking(&svc, &cx, name, &Dynamic::UNIT)
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
engine.register_static_module("workflow", module.into());
|
||||
}
|
||||
|
||||
fn start_blocking(
|
||||
svc: &Arc<dyn WorkflowService>,
|
||||
cx: &Arc<SdkCallCx>,
|
||||
name: &str,
|
||||
input: &Dynamic,
|
||||
) -> Result<String, Box<EvalAltResult>> {
|
||||
let input_json = if input.is_unit() {
|
||||
serde_json::Value::Null
|
||||
} else {
|
||||
dynamic_to_json(input)
|
||||
};
|
||||
let handle = TokioHandle::try_current().map_err(|e| -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(
|
||||
format!("workflow::start: no tokio runtime available: {e}").into(),
|
||||
rhai::Position::NONE,
|
||||
)
|
||||
.into()
|
||||
})?;
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
let name = name.to_string();
|
||||
let run_id = handle
|
||||
.block_on(async move { svc.start(&cx, &name, input_json).await })
|
||||
.map_err(|e| -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(
|
||||
format!("workflow::start: {e}").into(),
|
||||
rhai::Position::NONE,
|
||||
)
|
||||
.into()
|
||||
})?;
|
||||
Ok(run_id.to_string())
|
||||
}
|
||||
Reference in New Issue
Block a user