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:
87
crates/manager-core/src/workflow_service.rs
Normal file
87
crates/manager-core/src/workflow_service.rs
Normal file
@@ -0,0 +1,87 @@
|
||||
//! `WorkflowServiceImpl` — backs the Rhai `workflow::start(name, input)` SDK
|
||||
//! call (v1.2 Workflows M5).
|
||||
//!
|
||||
//! Resolves a workflow **by name in the caller's app** (`cx.app_id` — never a
|
||||
//! script-passed arg, the isolation boundary), seeds a durable run, and returns
|
||||
//! its id. The dedicated orchestrator advances the run from there. Same-app
|
||||
//! only, mirroring `InvokeServiceImpl`.
|
||||
//!
|
||||
//! Like `invoke()`, an authenticated caller is gated on `AppInvoke`; an
|
||||
//! anonymous caller skips the check under the script-as-gate convention (the
|
||||
//! public script IS the authorization boundary).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{SdkCallCx, WorkflowError, WorkflowRunId, WorkflowService};
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::authz::{self, AuthzRepo, Capability};
|
||||
use crate::workflow_repo::{get_workflow_by_name, start_run, NewWorkflowRun};
|
||||
|
||||
pub struct WorkflowServiceImpl {
|
||||
pool: PgPool,
|
||||
/// Optional. When set, `start` requires `AppInvoke` for authenticated
|
||||
/// callers; anonymous callers skip the check (script-as-gate).
|
||||
authz: Option<Arc<dyn AuthzRepo>>,
|
||||
}
|
||||
|
||||
impl WorkflowServiceImpl {
|
||||
#[must_use]
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool, authz: None }
|
||||
}
|
||||
|
||||
/// Attach the authz repo so authenticated callers are gated on `AppInvoke`.
|
||||
#[must_use]
|
||||
pub fn with_authz(mut self, authz: Arc<dyn AuthzRepo>) -> Self {
|
||||
self.authz = Some(authz);
|
||||
self
|
||||
}
|
||||
|
||||
async fn check(&self, cx: &SdkCallCx) -> Result<(), WorkflowError> {
|
||||
let Some(authz_repo) = self.authz.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
authz::script_gate(
|
||||
authz_repo.as_ref(),
|
||||
cx,
|
||||
Capability::AppInvoke(cx.app_id),
|
||||
|| WorkflowError::Forbidden,
|
||||
WorkflowError::Backend,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WorkflowService for WorkflowServiceImpl {
|
||||
async fn start(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
name: &str,
|
||||
input: serde_json::Value,
|
||||
) -> Result<WorkflowRunId, WorkflowError> {
|
||||
self.check(cx).await?;
|
||||
let wf = get_workflow_by_name(&self.pool, cx.app_id, name)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Backend(e.to_string()))?
|
||||
.ok_or_else(|| WorkflowError::NotFound(name.to_string()))?;
|
||||
if !wf.enabled {
|
||||
return Err(WorkflowError::Disabled(name.to_string()));
|
||||
}
|
||||
let new = NewWorkflowRun {
|
||||
workflow_id: wf.id,
|
||||
app_id: cx.app_id,
|
||||
input,
|
||||
// Correlate the run's step logs under the caller's execution chain.
|
||||
root_execution_id: cx.root_execution_id.into_inner(),
|
||||
workflow_depth: 0,
|
||||
parent_run_id: None,
|
||||
parent_step_id: None,
|
||||
};
|
||||
start_run(&self.pool, new, &wf.definition)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Backend(e.to_string()))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user