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

@@ -624,6 +624,65 @@ impl Client {
decode(resp).await
}
/// `GET /api/v1/admin/apps/{id}/workflows`
pub async fn workflows_list(&self, app: &str) -> Result<Vec<WorkflowDto>> {
let app = seg(app);
let resp = self
.request(Method::GET, &format!("/api/v1/admin/apps/{app}/workflows"))
.send()
.await?;
decode(resp).await
}
/// `POST /api/v1/admin/apps/{id}/workflows/{name}/runs` — start a run.
pub async fn workflow_run_start(
&self,
app: &str,
name: &str,
input: serde_json::Value,
) -> Result<WorkflowRunDto> {
let (app, name) = (seg(app), seg(name));
let resp = self
.request(
Method::POST,
&format!("/api/v1/admin/apps/{app}/workflows/{name}/runs"),
)
.json(&serde_json::json!({ "input": input }))
.send()
.await?;
decode(resp).await
}
/// `GET /api/v1/admin/apps/{id}/workflows/{name}/runs` — recent runs.
pub async fn workflow_runs_list(&self, app: &str, name: &str) -> Result<Vec<WorkflowRunDto>> {
let (app, name) = (seg(app), seg(name));
let resp = self
.request(
Method::GET,
&format!("/api/v1/admin/apps/{app}/workflows/{name}/runs"),
)
.send()
.await?;
decode(resp).await
}
/// `GET /api/v1/admin/apps/{id}/workflow-runs/{run_id}` — one run + steps.
pub async fn workflow_run_status(
&self,
app: &str,
run_id: &str,
) -> Result<WorkflowRunDetailDto> {
let (app, run_id) = (seg(app), seg(run_id));
let resp = self
.request(
Method::GET,
&format!("/api/v1/admin/apps/{app}/workflow-runs/{run_id}"),
)
.send()
.await?;
decode(resp).await
}
/// `DELETE /api/v1/admin/apps/{id}/triggers/{trigger_id}`
pub async fn triggers_delete(&self, app: &str, trigger_id: &str) -> Result<()> {
let (app, trigger_id) = (seg(app), seg(trigger_id));
@@ -2202,6 +2261,41 @@ struct UpdateScriptBody<'a> {
// columns are a one-line add), but the TSV/KvBlock renderers don't print
// every field today.
#[derive(Debug, Deserialize)]
pub struct WorkflowDto {
pub name: String,
pub enabled: bool,
pub steps: usize,
}
#[derive(Debug, Deserialize)]
pub struct WorkflowRunDto {
pub id: String,
pub status: String,
#[serde(default)]
pub error: Option<String>,
pub workflow_depth: i32,
pub created_at: String,
}
#[derive(Debug, Deserialize)]
pub struct WorkflowRunStepDto {
pub name: String,
pub status: String,
pub attempt: i32,
pub max_attempts: i32,
#[serde(default)]
pub error: Option<String>,
#[serde(default)]
pub child_run_id: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct WorkflowRunDetailDto {
pub run: WorkflowRunDto,
pub steps: Vec<WorkflowRunStepDto>,
}
#[derive(Debug, Deserialize)]
pub struct AppMemberDto {
pub user_id: AdminUserId,

View File

@@ -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`].

View 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(())
}

View File

@@ -113,6 +113,14 @@ enum Cmd {
cmd: TriggersCmd,
},
/// Durable DAG workflows (v1.2). Definitions are authored declaratively
/// (`[[workflows]]` in `picloud.toml` → `pic apply`); this surface starts
/// runs and reads run history.
Workflows {
#[command(subcommand)]
cmd: WorkflowsCmd,
},
/// Pub/sub topic registry — control which topics external clients
/// can subscribe to over SSE (`/realtime/topics/{name}`). Publishing
/// from a script needs no registration; only outside subscribers do.
@@ -958,6 +966,40 @@ enum RoutesCmd {
},
}
#[derive(Subcommand)]
enum WorkflowsCmd {
/// List an app's workflow definitions.
Ls {
#[arg(long)]
app: String,
},
/// Start a run of a workflow.
Run {
#[arg(long)]
app: String,
/// Workflow name.
name: String,
/// Run input as a JSON object (e.g. '{"date":"2026-07-12"}').
#[arg(long)]
input: Option<String>,
},
/// List recent runs of a workflow.
Runs {
#[arg(long)]
app: String,
/// Workflow name.
name: String,
},
/// Show one run's status + its per-step detail.
#[command(name = "run-status")]
RunStatus {
#[arg(long)]
app: String,
/// Run id (from `pic workflows run` / `runs`).
run_id: String,
},
}
#[derive(Subcommand)]
enum TriggersCmd {
/// List an app's triggers, or a group's trigger TEMPLATES (`--group`).
@@ -1796,6 +1838,18 @@ async fn main() -> ExitCode {
Cmd::Admins {
cmd: AdminsCmd::Rm { id },
} => cmds::admins::rm(&id).await,
Cmd::Workflows {
cmd: WorkflowsCmd::Ls { app },
} => cmds::workflows::ls(&app, mode).await,
Cmd::Workflows {
cmd: WorkflowsCmd::Run { app, name, input },
} => cmds::workflows::run(&app, &name, input.as_deref(), mode).await,
Cmd::Workflows {
cmd: WorkflowsCmd::Runs { app, name },
} => cmds::workflows::runs(&app, &name, mode).await,
Cmd::Workflows {
cmd: WorkflowsCmd::RunStatus { app, run_id },
} => cmds::workflows::run_status(&app, &run_id, mode).await,
Cmd::Triggers {
cmd: TriggersCmd::Ls { app, group },
} => match cmds::require_one_owner(app.as_deref(), group.as_deref()) {