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:
@@ -22,15 +22,17 @@ use picloud_manager_core::workflow_orchestrator::{
|
||||
WorkflowOrchestrator, WorkflowOrchestratorConfig,
|
||||
};
|
||||
use picloud_manager_core::workflow_repo::{
|
||||
claim_ready_step, complete_step_and_advance, get_run, list_run_steps, reclaim_stale_steps,
|
||||
start_run, AdvanceResult, ClaimedStep, NewWorkflowRun, StepOutcome,
|
||||
claim_ready_step, complete_step_and_advance, get_run, list_run_steps, list_runs_for_workflow,
|
||||
reclaim_stale_steps, start_run, AdvanceResult, ClaimedStep, NewWorkflowRun, StepOutcome,
|
||||
};
|
||||
use picloud_manager_core::workflow_service::WorkflowServiceImpl;
|
||||
use picloud_orchestrator_core::{ExecutionGate, ExecutorClient, ScriptIdentity};
|
||||
use picloud_shared::workflow::{
|
||||
OnError, RunStatus, StepStatus, WorkflowBackoff, WorkflowDefinition, WorkflowRetry,
|
||||
WorkflowStepDef,
|
||||
};
|
||||
use picloud_shared::{AppId, ExecutionLogSink, LogSinkError, WorkflowId};
|
||||
use picloud_shared::{RequestId, ScriptId, WorkflowService};
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use sqlx::PgPool;
|
||||
@@ -927,3 +929,59 @@ async fn nesting_depth_ceiling_fails_the_run() {
|
||||
"self-nesting run fails at the depth ceiling, not infinitely"
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// M5 — the SDK/API-shared service entry (`WorkflowServiceImpl::start`).
|
||||
// ===========================================================================
|
||||
|
||||
fn anon_cx(app: AppId) -> picloud_shared::SdkCallCx {
|
||||
let execution_id = picloud_shared::ExecutionId::new();
|
||||
picloud_shared::SdkCallCx {
|
||||
app_id: app,
|
||||
script_id: ScriptId::new(),
|
||||
principal: None,
|
||||
execution_id,
|
||||
request_id: RequestId::new(),
|
||||
trigger_depth: 0,
|
||||
root_execution_id: execution_id,
|
||||
is_dead_letter_handler: false,
|
||||
event: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn workflow_service_start_seeds_a_run() {
|
||||
let Some(pool) = pool_or_skip().await else {
|
||||
return;
|
||||
};
|
||||
let _claim_guard = lock_and_reset(&pool).await;
|
||||
let app = seed_app(&pool).await;
|
||||
let def = WorkflowDefinition {
|
||||
steps: vec![step("only", "fn_only", &[])],
|
||||
};
|
||||
seed_named_workflow(&pool, app, "svc_wf", &def).await;
|
||||
|
||||
let svc = WorkflowServiceImpl::new(pool.clone());
|
||||
let cx = anon_cx(app);
|
||||
|
||||
// start seeds a durable run with the given input + its steps.
|
||||
let run_id = svc
|
||||
.start(&cx, "svc_wf", json!({ "k": "v" }))
|
||||
.await
|
||||
.expect("start");
|
||||
let r = get_run(&pool, app, run_id).await.unwrap().unwrap();
|
||||
assert_eq!(r.input, json!({ "k": "v" }));
|
||||
let steps = list_run_steps(&pool, run_id).await.unwrap();
|
||||
assert_eq!(steps.len(), 1);
|
||||
assert_eq!(steps[0].step_name, "only");
|
||||
assert_eq!(steps[0].status, StepStatus::Ready); // a root
|
||||
|
||||
// it shows up in the run-history list.
|
||||
let runs = list_runs_for_workflow(&pool, app, r.workflow_id, 10)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(runs.len(), 1);
|
||||
|
||||
// an unknown workflow name is a not-found error.
|
||||
assert!(svc.start(&cx, "ghost", Value::Null).await.is_err());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user