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:
@@ -30,6 +30,7 @@ pub mod triggers;
|
||||
pub mod users;
|
||||
pub mod vars;
|
||||
pub mod whoami;
|
||||
pub mod workflows;
|
||||
|
||||
/// A resolved owner reference for a command that targets EITHER an app or a
|
||||
/// group (the `--app` / `--group` XOR). See [`require_one_owner`].
|
||||
|
||||
79
crates/picloud-cli/src/cmds/workflows.rs
Normal file
79
crates/picloud-cli/src/cmds/workflows.rs
Normal file
@@ -0,0 +1,79 @@
|
||||
//! `pic workflows` subcommands (v1.2 Workflows M5).
|
||||
//!
|
||||
//! Read-only listing + starting/inspecting runs. Workflow *definitions* are
|
||||
//! authored declaratively (`[[workflows]]` in `picloud.toml` → `pic apply`);
|
||||
//! this surface starts runs and reads run history.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::config;
|
||||
use crate::output::{KvBlock, OutputMode, Table};
|
||||
|
||||
/// `pic workflows ls --app <a>` — the app's workflow definitions.
|
||||
pub async fn ls(app: &str, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let workflows = client.workflows_list(app).await?;
|
||||
let mut table = Table::new(["name", "enabled", "steps"]);
|
||||
for w in workflows {
|
||||
table.row([w.name, w.enabled.to_string(), w.steps.to_string()]);
|
||||
}
|
||||
table.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `pic workflows run <name> --app <a> [--input JSON]` — start a run.
|
||||
pub async fn run(app: &str, name: &str, input: Option<&str>, mode: OutputMode) -> Result<()> {
|
||||
let input_json: Value = match input {
|
||||
Some(s) => serde_json::from_str(s)
|
||||
.context("--input must be valid JSON (e.g. '{\"date\":\"2026-07-12\"}')")?,
|
||||
None => Value::Null,
|
||||
};
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let run = client.workflow_run_start(app, name, input_json).await?;
|
||||
KvBlock::new()
|
||||
.field("run_id", run.id)
|
||||
.field("status", run.status)
|
||||
.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `pic workflows runs <name> --app <a>` — recent runs of a workflow.
|
||||
pub async fn runs(app: &str, name: &str, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let runs = client.workflow_runs_list(app, name).await?;
|
||||
let mut table = Table::new(["run_id", "status", "depth", "created_at"]);
|
||||
for r in runs {
|
||||
table.row([r.id, r.status, r.workflow_depth.to_string(), r.created_at]);
|
||||
}
|
||||
table.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `pic workflows run-status <run_id> --app <a>` — one run + its steps.
|
||||
pub async fn run_status(app: &str, run_id: &str, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let detail = client.workflow_run_status(app, run_id).await?;
|
||||
KvBlock::new()
|
||||
.field("run_id", detail.run.id)
|
||||
.field("status", detail.run.status)
|
||||
.field("error", detail.run.error.unwrap_or_default())
|
||||
.print(mode);
|
||||
let mut table = Table::new(["step", "status", "attempt", "error", "child_run"]);
|
||||
for s in detail.steps {
|
||||
table.row([
|
||||
s.name,
|
||||
s.status,
|
||||
format!("{}/{}", s.attempt, s.max_attempts),
|
||||
s.error.unwrap_or_default(),
|
||||
s.child_run_id.unwrap_or_default(),
|
||||
]);
|
||||
}
|
||||
table.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user