//! Admin API for v1.2 Workflows (M5): list definitions, start runs, and read //! run history. //! //! - `GET /apps/{app}/workflows` — list the app's workflows //! - `POST /apps/{app}/workflows/{name}/runs` — start a run (body: input JSON) //! - `GET /apps/{app}/workflows/{name}/runs` — recent runs of a workflow //! - `GET /apps/{app}/workflow-runs/{run_id}` — one run + its steps //! //! Reads are gated on `AppRead`; starting a run is gated on `AppInvoke` — the //! same capability the SDK `workflow::start` uses. `app_id` scopes every query //! (the isolation boundary); a run of another app 404s. use std::sync::Arc; use axum::extract::{Path, State}; use axum::http::StatusCode; use axum::response::{IntoResponse, Json, Response}; use axum::routing::get; use axum::{Extension, Router}; use chrono::{DateTime, Utc}; use picloud_shared::{AppId, Principal, WorkflowDefinition, WorkflowRunId}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use uuid::Uuid; use crate::app_repo::AppRepository; use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability}; use crate::workflow_repo::{ get_run, get_workflow_by_id, get_workflow_by_name, list_run_steps, list_runs_for_workflow, list_workflows_for_app, start_run, NewWorkflowRun, WorkflowRun, WorkflowRunStep, }; use sqlx::PgPool; /// Cap on the run-history list length. const RUN_LIST_LIMIT: i64 = 100; #[derive(Clone)] pub struct WorkflowsState { pub pool: PgPool, pub apps: Arc, pub authz: Arc, } pub fn workflows_router(state: WorkflowsState) -> Router { Router::new() .route("/apps/{app_id}/workflows", get(list_workflows)) .route( "/apps/{app_id}/workflows/{name}/runs", get(list_runs).post(start_run_handler), ) .route("/apps/{app_id}/workflow-runs/{run_id}", get(get_run_detail)) .with_state(state) } // ---- response DTOs -------------------------------------------------------- #[derive(Serialize)] struct WorkflowDto { name: String, enabled: bool, /// Step count (kept for the dashboard's summary column). steps: usize, /// The full stored definition, so `pic pull` can round-trip the /// `[[workflows]]` manifest block (without it, a pull-then-apply silently /// deletes every workflow). definition: WorkflowDefinition, } #[derive(Serialize)] struct RunDto { id: Uuid, workflow_id: Uuid, status: String, input: Value, output: Option, error: Option, workflow_depth: i32, root_execution_id: Uuid, created_at: DateTime, started_at: Option>, finished_at: Option>, } impl From for RunDto { fn from(r: WorkflowRun) -> Self { Self { id: r.id.into_inner(), workflow_id: r.workflow_id.into_inner(), status: r.status.as_str().to_string(), input: r.input, output: r.output, error: r.error, workflow_depth: r.workflow_depth, root_execution_id: r.root_execution_id, created_at: r.created_at, started_at: r.started_at, finished_at: r.finished_at, } } } #[derive(Serialize)] struct StepDto { name: String, status: String, attempt: i32, max_attempts: i32, output: Option, error: Option, child_run_id: Option, /// DAG edges (from the definition) so the dashboard can draw the graph. depends_on: Vec, } impl StepDto { fn from_step(s: WorkflowRunStep, depends_on: Vec) -> Self { Self { name: s.step_name, status: s.status.as_str().to_string(), attempt: s.attempt, max_attempts: s.max_attempts, output: s.output, error: s.error, child_run_id: s.child_run_id.map(WorkflowRunId::into_inner), depends_on, } } } #[derive(Deserialize, Default)] struct StartRunReq { #[serde(default)] input: Value, } // ---- handlers ------------------------------------------------------------- async fn list_workflows( State(s): State, Extension(principal): Extension, Path(id_or_slug): Path, ) -> Result>, WorkflowsApiError> { let app_id = resolve_app(&*s.apps, &id_or_slug).await?; require(s.authz.as_ref(), &principal, Capability::AppRead(app_id)).await?; let workflows = list_workflows_for_app(&s.pool, app_id) .await .map_err(|e| WorkflowsApiError::Backend(e.to_string()))?; Ok(Json( workflows .into_iter() .map(|w| WorkflowDto { name: w.name, enabled: w.enabled, steps: w.definition.steps.len(), definition: w.definition, }) .collect(), )) } async fn start_run_handler( State(s): State, Extension(principal): Extension, Path((id_or_slug, name)): Path<(String, String)>, body: Option>, ) -> Result<(StatusCode, Json), WorkflowsApiError> { let app_id = resolve_app(&*s.apps, &id_or_slug).await?; require(s.authz.as_ref(), &principal, Capability::AppInvoke(app_id)).await?; let wf = get_workflow_by_name(&s.pool, app_id, &name) .await .map_err(|e| WorkflowsApiError::Backend(e.to_string()))? .ok_or_else(|| WorkflowsApiError::NotFound(format!("workflow {name:?}")))?; if !wf.enabled { return Err(WorkflowsApiError::Invalid(format!( "workflow {name:?} is disabled" ))); } let input = body.map_or(Value::Null, |Json(b)| b.input); // An operator-initiated run has no parent execution to correlate to, so it // roots a fresh execution tree. Log the id against the principal so the // run's step logs remain traceable to who launched it (the SDK path threads // `cx.root_execution_id` instead). let root_execution_id = Uuid::new_v4(); let new = NewWorkflowRun { workflow_id: wf.id, app_id, input, root_execution_id, workflow_depth: 0, parent_run_id: None, parent_step_id: None, }; let run_id = start_run(&s.pool, new, &wf.definition) .await .map_err(|e| WorkflowsApiError::Backend(e.to_string()))?; tracing::info!( %app_id, workflow = %name, run_id = %run_id.into_inner(), %root_execution_id, user_id = %principal.user_id.into_inner(), "admin started a workflow run" ); // Read it back for the response (status = pending until the orchestrator ticks). let run = get_run(&s.pool, app_id, run_id) .await .map_err(|e| WorkflowsApiError::Backend(e.to_string()))? .ok_or_else(|| WorkflowsApiError::Backend("run vanished after start".into()))?; Ok((StatusCode::CREATED, Json(run.into()))) } async fn list_runs( State(s): State, Extension(principal): Extension, Path((id_or_slug, name)): Path<(String, String)>, ) -> Result>, WorkflowsApiError> { let app_id = resolve_app(&*s.apps, &id_or_slug).await?; require(s.authz.as_ref(), &principal, Capability::AppRead(app_id)).await?; let wf = get_workflow_by_name(&s.pool, app_id, &name) .await .map_err(|e| WorkflowsApiError::Backend(e.to_string()))? .ok_or_else(|| WorkflowsApiError::NotFound(format!("workflow {name:?}")))?; let runs = list_runs_for_workflow(&s.pool, app_id, wf.id, RUN_LIST_LIMIT) .await .map_err(|e| WorkflowsApiError::Backend(e.to_string()))?; Ok(Json(runs.into_iter().map(Into::into).collect())) } async fn get_run_detail( State(s): State, Extension(principal): Extension, Path((id_or_slug, run_id)): Path<(String, Uuid)>, ) -> Result, WorkflowsApiError> { let app_id = resolve_app(&*s.apps, &id_or_slug).await?; require(s.authz.as_ref(), &principal, Capability::AppRead(app_id)).await?; let run = get_run(&s.pool, app_id, WorkflowRunId::from(run_id)) .await .map_err(|e| WorkflowsApiError::Backend(e.to_string()))? .ok_or_else(|| WorkflowsApiError::NotFound(format!("run {run_id}")))?; let steps = list_run_steps(&s.pool, app_id, run.id) .await .map_err(|e| WorkflowsApiError::Backend(e.to_string()))?; // Load the definition for the per-step `depends_on` DAG edges (they live on // the definition, not the run's step rows). let deps: std::collections::HashMap> = match get_workflow_by_id(&s.pool, app_id, run.workflow_id) .await .map_err(|e| WorkflowsApiError::Backend(e.to_string()))? { Some(w) => w .definition .steps .into_iter() .map(|st| (st.name, st.depends_on)) .collect(), None => std::collections::HashMap::new(), }; let run_dto: RunDto = run.into(); let step_dtos: Vec = steps .into_iter() .map(|st| { let d = deps.get(&st.step_name).cloned().unwrap_or_default(); StepDto::from_step(st, d) }) .collect(); Ok(Json(json!({ "run": run_dto, "steps": step_dtos }))) } async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result { crate::app_repo::resolve_app(apps, ident) .await .map_err(|e| WorkflowsApiError::Backend(e.to_string()))? .map(|l| l.app.id) .ok_or_else(|| WorkflowsApiError::NotFound(format!("app {ident:?}"))) } // ---- errors --------------------------------------------------------------- #[derive(Debug, thiserror::Error)] pub enum WorkflowsApiError { #[error("{0}")] NotFound(String), #[error("{0}")] Invalid(String), #[error("forbidden")] Forbidden, #[error("authorization repo error: {0}")] AuthzRepo(String), #[error("workflow backend: {0}")] Backend(String), } impl From for WorkflowsApiError { fn from(d: AuthzDenied) -> Self { match d { AuthzDenied::Denied => Self::Forbidden, AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()), } } } impl From for WorkflowsApiError { fn from(e: AuthzError) -> Self { Self::AuthzRepo(e.to_string()) } } impl IntoResponse for WorkflowsApiError { fn into_response(self) -> Response { let (status, body) = match &self { Self::NotFound(_) => (StatusCode::NOT_FOUND, json!({ "error": self.to_string() })), Self::Invalid(_) => ( StatusCode::UNPROCESSABLE_ENTITY, json!({ "error": self.to_string() }), ), Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })), Self::AuthzRepo(e) => { tracing::error!(error = %e, "workflows authz repo error"); ( StatusCode::INTERNAL_SERVER_ERROR, json!({ "error": "internal error" }), ) } Self::Backend(e) => { tracing::error!(error = %e, "workflows api backend error"); ( StatusCode::INTERNAL_SERVER_ERROR, json!({ "error": "internal error" }), ) } }; (status, Json(body)).into_response() } }