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:
MechaCat02
2026-07-12 17:48:38 +02:00
parent 74d5874384
commit 5a630e1d9f
16 changed files with 965 additions and 7 deletions

View File

@@ -120,3 +120,4 @@ pub use users::{
pub use validator::{ScriptValidator, ValidatedScript, ValidationError};
pub use vars::{NoopVarsService, VarsError, VarsService};
pub use version::{API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION};
pub use workflow::{NoopWorkflowService, WorkflowError, WorkflowService};

View File

@@ -26,8 +26,9 @@ use crate::{
NoopEmailService, NoopEventEmitter, NoopFilesService, NoopGroupDocsService,
NoopGroupFilesService, NoopGroupKvService, NoopGroupPubsubService, NoopGroupQueueService,
NoopHttpService, NoopInvokeService, NoopKvService, NoopModuleSource, NoopPubsubService,
NoopQueueService, NoopSecretsService, NoopUsersService, NoopVarsService, PubsubService,
QueueService, SecretsService, ServiceEventEmitter, UsersService, VarsService,
NoopQueueService, NoopSecretsService, NoopUsersService, NoopVarsService, NoopWorkflowService,
PubsubService, QueueService, SecretsService, ServiceEventEmitter, UsersService, VarsService,
WorkflowService,
};
/// SDK service bundle. See module docs for the lifecycle and the v1.1.x
@@ -146,6 +147,11 @@ pub struct Services {
/// drained by competing per-descendant consumers. Wired via
/// [`Services::with_group_queue`]; defaults to `NoopGroupQueueService`.
pub group_queue: Arc<dyn GroupQueueService>,
/// v1.2 Workflows (M5). Scripts get `workflow::start(name, input)` — start a
/// run of a named workflow in the caller's app. Wired via
/// [`Services::with_workflow`]; defaults to `NoopWorkflowService`.
pub workflow: Arc<dyn WorkflowService>,
}
impl Services {
@@ -193,9 +199,18 @@ impl Services {
group_files: Arc::new(NoopGroupFilesService),
group_pubsub: Arc::new(NoopGroupPubsubService),
group_queue: Arc::new(NoopGroupQueueService),
workflow: Arc::new(NoopWorkflowService),
}
}
/// Set the v1.2 Workflows service (picloud binary wires the Postgres-backed
/// impl; tests leave the noop default).
#[must_use]
pub fn with_workflow(mut self, workflow: Arc<dyn WorkflowService>) -> Self {
self.workflow = workflow;
self
}
/// Set the §11.6 shared group-KV service (the picloud binary wires the
/// Postgres-backed impl; tests leave the noop default).
#[must_use]

View File

@@ -7,10 +7,60 @@
//! resolver (`workflow_template`), and the durable orchestrator all live
//! server-side in `manager-core`, the only crate that touches Postgres.
//!
//! The `WorkflowService` trait (the injected SDK seam for `workflow::start`)
//! is added here in M5.
//! The `WorkflowService` trait is the injected SDK seam for `workflow::start`
//! (M5): a script (or the admin API) starts a run of a named workflow in the
//! caller's app. Same-app only — the owning workflow resolves from `cx.app_id`,
//! never a script-passed arg (the isolation boundary), mirroring `InvokeService`.
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::{SdkCallCx, WorkflowRunId};
/// Backs the Rhai `workflow::start(name, input)` call and the admin run API.
#[async_trait]
pub trait WorkflowService: Send + Sync {
/// Start a run of the workflow named `name` in `cx.app_id`, seeded with
/// `input`. Returns the new run id. The durable orchestrator advances it.
async fn start(
&self,
cx: &SdkCallCx,
name: &str,
input: serde_json::Value,
) -> Result<WorkflowRunId, WorkflowError>;
}
#[derive(Debug, Error)]
pub enum WorkflowError {
#[error("workflow {0:?} not found")]
NotFound(String),
#[error("workflow {0:?} is disabled")]
Disabled(String),
/// The authenticated caller lacks `AppInvoke` on the app.
#[error("forbidden")]
Forbidden,
#[error("workflow backend error: {0}")]
Backend(String),
}
/// Default no-op: every call errors so a misconfigured bundle can't silently
/// no-op a `workflow::start`.
pub struct NoopWorkflowService;
#[async_trait]
impl WorkflowService for NoopWorkflowService {
async fn start(
&self,
_cx: &SdkCallCx,
_name: &str,
_input: serde_json::Value,
) -> Result<WorkflowRunId, WorkflowError> {
Err(WorkflowError::Backend(
"workflow service is not configured".into(),
))
}
}
/// A workflow's DAG: an ordered list of steps connected by `depends_on` edges.
/// Serialized to the `workflows.definition` JSONB column.