//! Persistence for workflow definitions (v1.2 Workflows). //! //! The `workflows` table (0071) is owner-polymorphic like `scripts`; M1 authors //! **app-owned** workflows only (the `group_id` column ships unused). The read //! trait backs the admin/CLI surface; the `*_tx` free-fns are used by the //! declarative `apply` reconcile engine (Create/Update/Delete by `lower(name)`, //! all in one transaction) — mirroring `trigger_repo::insert_trigger_tx`. //! //! Run/step state (`workflow_runs`, `workflow_run_steps`) lands in M2: the //! durable orchestrator seeds a run (`start_run`), claims a `ready` step with //! the same `FOR UPDATE SKIP LOCKED` lease `queue_repo` uses, and — in one //! token-gated transaction — writes the step outcome and advances the DAG //! (`complete_step_and_advance`). The graph-advance decision is a pure, //! DB-free function ([`compute_advance`]) so it is unit-testable. use std::collections::BTreeMap; use async_trait::async_trait; use chrono::{DateTime, Utc}; use sqlx::PgPool; use thiserror::Error; use uuid::Uuid; use picloud_shared::workflow::{ OnError, RunStatus, StepStatus, WorkflowDefinition, WorkflowStepDef, }; use picloud_shared::{AppId, WorkflowId, WorkflowRunId, WorkflowRunStepId}; #[derive(Debug, Error)] pub enum WorkflowRepoError { #[error(transparent)] Db(#[from] sqlx::Error), #[error("workflow definition (de)serialization failed: {0}")] Serde(String), } /// A stored workflow definition (app-owned in M1). #[derive(Debug, Clone)] pub struct Workflow { pub id: WorkflowId, pub app_id: AppId, pub name: String, pub definition: WorkflowDefinition, pub enabled: bool, pub created_at: DateTime, pub updated_at: DateTime, } /// Raw row shape (definition decoded separately). #[derive(sqlx::FromRow)] struct WorkflowRow { id: Uuid, app_id: Uuid, name: String, definition: serde_json::Value, enabled: bool, created_at: DateTime, updated_at: DateTime, } impl TryFrom for Workflow { type Error = WorkflowRepoError; fn try_from(r: WorkflowRow) -> Result { Ok(Self { id: r.id.into(), app_id: r.app_id.into(), name: r.name, definition: serde_json::from_value(r.definition) .map_err(|e| WorkflowRepoError::Serde(e.to_string()))?, enabled: r.enabled, created_at: r.created_at, updated_at: r.updated_at, }) } } const SELECT_COLS: &str = "id, app_id, name, definition, enabled, created_at, updated_at"; #[async_trait] pub trait WorkflowRepo: Send + Sync { /// All workflows owned by `app_id`, ordered by name. async fn list_for_app(&self, app_id: AppId) -> Result, WorkflowRepoError>; /// A single app-owned workflow by case-insensitive name. async fn get_by_name( &self, app_id: AppId, name: &str, ) -> Result, WorkflowRepoError>; /// A single workflow by id (app-scoped for isolation). async fn get_by_id( &self, app_id: AppId, id: WorkflowId, ) -> Result, WorkflowRepoError>; } pub struct PostgresWorkflowRepo { pool: PgPool, } impl PostgresWorkflowRepo { #[must_use] pub fn new(pool: PgPool) -> Self { Self { pool } } } #[async_trait] impl WorkflowRepo for PostgresWorkflowRepo { async fn list_for_app(&self, app_id: AppId) -> Result, WorkflowRepoError> { let rows: Vec = sqlx::query_as(&format!( "SELECT {SELECT_COLS} FROM workflows WHERE app_id = $1 ORDER BY LOWER(name)" )) .bind(app_id.into_inner()) .fetch_all(&self.pool) .await?; rows.into_iter().map(TryInto::try_into).collect() } async fn get_by_name( &self, app_id: AppId, name: &str, ) -> Result, WorkflowRepoError> { let row: Option = sqlx::query_as(&format!( "SELECT {SELECT_COLS} FROM workflows WHERE app_id = $1 AND LOWER(name) = LOWER($2)" )) .bind(app_id.into_inner()) .bind(name) .fetch_optional(&self.pool) .await?; row.map(TryInto::try_into).transpose() } async fn get_by_id( &self, app_id: AppId, id: WorkflowId, ) -> Result, WorkflowRepoError> { let row: Option = sqlx::query_as(&format!( "SELECT {SELECT_COLS} FROM workflows WHERE app_id = $1 AND id = $2" )) .bind(app_id.into_inner()) .bind(id.into_inner()) .fetch_optional(&self.pool) .await?; row.map(TryInto::try_into).transpose() } } /// Pool-based read of an app's workflows (used by `apply`'s `load_current`, /// which reads several markers directly off the pool). pub async fn list_workflows_for_app( pool: &PgPool, app_id: AppId, ) -> Result, WorkflowRepoError> { let rows: Vec = sqlx::query_as(&format!( "SELECT {SELECT_COLS} FROM workflows WHERE app_id = $1 ORDER BY LOWER(name)" )) .bind(app_id.into_inner()) .fetch_all(pool) .await?; rows.into_iter().map(TryInto::try_into).collect() } // ---- reconcile tx free-fns (used by apply_service) ------------------------ /// Load an app's workflows inside the apply transaction (so the diff sees a /// snapshot consistent with the writes). pub async fn list_workflows_for_app_tx( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, app_id: AppId, ) -> Result, WorkflowRepoError> { let rows: Vec = sqlx::query_as(&format!( "SELECT {SELECT_COLS} FROM workflows WHERE app_id = $1 ORDER BY LOWER(name)" )) .bind(app_id.into_inner()) .fetch_all(&mut **tx) .await?; rows.into_iter().map(TryInto::try_into).collect() } /// Insert an app-owned workflow. pub async fn insert_workflow_tx( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, app_id: AppId, name: &str, definition: &WorkflowDefinition, enabled: bool, ) -> Result { let def = serde_json::to_value(definition).map_err(|e| WorkflowRepoError::Serde(e.to_string()))?; let (id,): (Uuid,) = sqlx::query_as( "INSERT INTO workflows (app_id, name, definition, enabled) \ VALUES ($1, $2, $3, $4) RETURNING id", ) .bind(app_id.into_inner()) .bind(name) .bind(def) .bind(enabled) .fetch_one(&mut **tx) .await?; Ok(id.into()) } /// Update a workflow's definition + enabled flag (name/identity unchanged). pub async fn update_workflow_tx( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, id: WorkflowId, definition: &WorkflowDefinition, enabled: bool, ) -> Result<(), WorkflowRepoError> { let def = serde_json::to_value(definition).map_err(|e| WorkflowRepoError::Serde(e.to_string()))?; sqlx::query( "UPDATE workflows SET definition = $2, enabled = $3, updated_at = NOW() WHERE id = $1", ) .bind(id.into_inner()) .bind(def) .bind(enabled) .execute(&mut **tx) .await?; Ok(()) } /// Delete a workflow (prune). RESTRICTs if runs reference it — a workflow with /// history is kept; this is surfaced as an error to the reconcile caller. pub async fn delete_workflow_tx( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, id: WorkflowId, ) -> Result<(), WorkflowRepoError> { sqlx::query("DELETE FROM workflows WHERE id = $1") .bind(id.into_inner()) .execute(&mut **tx) .await?; Ok(()) } // =========================================================================== // M2 — run/step state + the durable orchestrator's persistence surface. // =========================================================================== /// Everything needed to seed a fresh run (mirrors `NewQueueMessage`). The /// orchestrator's M5 `workflow::start` and the M2 tests both build this. #[derive(Debug, Clone)] pub struct NewWorkflowRun { pub workflow_id: WorkflowId, pub app_id: AppId, pub input: serde_json::Value, /// Correlates every step execution in `execution_logs` under one id. pub root_execution_id: Uuid, /// 0 for a top-level run; a nested sub-workflow (M4) is parent + 1. pub workflow_depth: i32, pub parent_run_id: Option, pub parent_step_id: Option, } /// A run row, read for the admin/CLI surface + test assertions. #[derive(Debug, Clone)] pub struct WorkflowRun { pub id: WorkflowRunId, pub workflow_id: WorkflowId, pub app_id: AppId, pub status: RunStatus, pub input: serde_json::Value, pub output: Option, pub error: Option, pub root_execution_id: Uuid, pub workflow_depth: i32, pub parent_run_id: Option, pub parent_step_id: Option, pub started_at: Option>, pub finished_at: Option>, pub created_at: DateTime, } /// A step row within a run (read for the admin/CLI surface + assertions). #[derive(Debug, Clone)] pub struct WorkflowRunStep { pub id: WorkflowRunStepId, pub run_id: WorkflowRunId, pub step_name: String, pub status: StepStatus, pub attempt: i32, pub max_attempts: i32, pub output: Option, pub error: Option, pub child_run_id: Option, } /// A claimed `ready` step, joined with its run + workflow definition so the /// orchestrator has everything it needs to resolve, execute, and advance /// without a second round trip. #[derive(Debug, Clone)] pub struct ClaimedStep { pub step_id: WorkflowRunStepId, pub run_id: WorkflowRunId, pub step_name: String, /// 1-indexed current attempt (the claim increments it). pub attempt: i32, pub max_attempts: i32, pub claim_token: Uuid, // ---- run context ---- pub app_id: AppId, pub workflow_id: WorkflowId, pub root_execution_id: Uuid, pub workflow_depth: i32, pub run_input: serde_json::Value, pub definition: WorkflowDefinition, } impl ClaimedStep { /// The definition of the step being executed. #[must_use] pub fn step_def(&self) -> Option<&WorkflowStepDef> { self.definition .steps .iter() .find(|s| s.name == self.step_name) } } /// The outcome the orchestrator hands back after executing a step. On /// `Failed`, the repo decides retry-vs-terminal from `attempt < max_attempts`; /// the orchestrator supplies the delay to use if a retry remains. #[derive(Debug, Clone)] pub enum StepOutcome { Succeeded(serde_json::Value), Failed { error: String, retry_delay: chrono::Duration, }, /// The step's `when` condition evaluated false — it is `skipped` (never /// executed) and counts as satisfied-but-empty for its dependents (M3). Skipped, } /// What `complete_step_and_advance` did — surfaced mostly for tests/logging. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AdvanceResult { /// The claim token no longer matched (a stale worker); nothing written. Stale, /// The step failed but had attempts left; it was re-armed as `ready`. Retried, /// The step reached a terminal status and the run graph was advanced. Advanced, } // ---- the pure graph-advance decision (DB-free, unit-tested) -------------- /// The write plan `complete_step_and_advance` derives after a step reaches a /// terminal status: which `pending` steps become `ready`, and the run's new /// status/output/error. #[derive(Debug, Clone, PartialEq)] pub struct AdvancePlan { pub promote: Vec, pub run_status: RunStatus, pub run_error: Option, pub run_output: Option, } /// Is `dep` satisfied for the purpose of unblocking a dependent? Succeeded and /// skipped always satisfy; a `failed` step satisfies **only** when its /// definition says `on_error = continue` (the run limps on). fn dep_satisfied(status: StepStatus, on_error: OnError) -> bool { match status { StepStatus::Succeeded | StepStatus::Skipped => true, StepStatus::Failed => matches!(on_error, OnError::Continue), _ => false, } } /// Pure DAG advance. Given the definition and the current per-step /// status/output snapshot, decide the promotions + terminal run state. /// /// - A `pending` step whose every `depends_on` is satisfied becomes `ready`. /// - If any step `failed` with `on_error = fail`, the run fails immediately. /// - Else if every step is terminal, the run succeeds; its output is the /// object of `{ step_name: output }` over the succeeded steps. /// - Otherwise the run is still `running`. #[must_use] pub fn compute_advance( def: &WorkflowDefinition, statuses: &BTreeMap, outputs: &BTreeMap, ) -> AdvancePlan { let by_name: BTreeMap<&str, &WorkflowStepDef> = def.steps.iter().map(|s| (s.name.as_str(), s)).collect(); let status_of = |name: &str| statuses.get(name).copied().unwrap_or(StepStatus::Pending); // Promote every pending step whose deps are all satisfied. let mut promote = Vec::new(); for step in &def.steps { if status_of(&step.name) != StepStatus::Pending { continue; } let ready = step.depends_on.iter().all(|dep| { let d_on_err = by_name .get(dep.as_str()) .map_or(OnError::Fail, |s| s.on_error); dep_satisfied(status_of(dep), d_on_err) }); if ready { promote.push(step.name.clone()); } } // A hard failure (on_error = fail that exhausted) fails the whole run. if let Some(failed) = def .steps .iter() .find(|s| status_of(&s.name) == StepStatus::Failed && matches!(s.on_error, OnError::Fail)) { return AdvancePlan { promote, run_status: RunStatus::Failed, run_error: Some(format!("step {:?} failed", failed.name)), run_output: None, }; } // All steps terminal (accounting for the promotions we just queued — a // promoted step is pending → ready, i.e. not terminal, so the run stays // running whenever `promote` is non-empty). let all_terminal = promote.is_empty() && def.steps.iter().all(|s| status_of(&s.name).is_terminal()); if all_terminal { let mut out = serde_json::Map::new(); for step in &def.steps { if status_of(&step.name) == StepStatus::Succeeded { if let Some(v) = outputs.get(&step.name) { out.insert(step.name.clone(), v.clone()); } } } // A run reaches here as Succeeded even if some steps failed under // `on_error = continue` (a hard `fail` short-circuits above). Those // steps contribute no output, so surface them in the run's `error` // field — otherwise a partial failure is invisible at the run level. let failed: Vec<&str> = def .steps .iter() .filter(|s| status_of(&s.name) == StepStatus::Failed) .map(|s| s.name.as_str()) .collect(); let run_error = (!failed.is_empty()).then(|| { format!( "run succeeded with {} continued failure(s): {}", failed.len(), failed.join(", ") ) }); return AdvancePlan { promote, run_status: RunStatus::Succeeded, run_error, run_output: Some(serde_json::Value::Object(out)), }; } AdvancePlan { promote, run_status: RunStatus::Running, run_error: None, run_output: None, } } // ---- run seeding ---------------------------------------------------------- /// Seed a run: insert the `workflow_runs` row + one `workflow_run_steps` row /// per definition step (root steps — no `depends_on` — start `ready`, the rest /// `pending`). All in one transaction so a partially-seeded run is never /// visible to the orchestrator's claim scan. pub async fn start_run( pool: &PgPool, new: NewWorkflowRun, definition: &WorkflowDefinition, ) -> Result { let mut tx = pool.begin().await?; let run_id = seed_run_tx(&mut tx, &new, definition).await?; tx.commit().await?; Ok(run_id) } /// Insert the `workflow_runs` row + its `workflow_run_steps` (roots `ready`, /// the rest `pending`) inside an open transaction. Shared by `start_run` and /// the nested-child start (`start_child_and_park`). pub async fn seed_run_tx( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, new: &NewWorkflowRun, definition: &WorkflowDefinition, ) -> Result { let (run_id,): (Uuid,) = sqlx::query_as( "INSERT INTO workflow_runs \ (workflow_id, app_id, status, input, root_execution_id, \ workflow_depth, parent_run_id, parent_step_id) \ VALUES ($1, $2, 'pending', $3, $4, $5, $6, $7) RETURNING id", ) .bind(new.workflow_id.into_inner()) .bind(new.app_id.into_inner()) .bind(&new.input) .bind(new.root_execution_id) .bind(new.workflow_depth) .bind(new.parent_run_id.map(WorkflowRunId::into_inner)) .bind(new.parent_step_id.map(WorkflowRunStepId::into_inner)) .fetch_one(&mut **tx) .await?; for step in &definition.steps { let status = if step.depends_on.is_empty() { "ready" } else { "pending" }; let max_attempts = i32::try_from(step.max_attempts()).unwrap_or(1); sqlx::query( "INSERT INTO workflow_run_steps \ (run_id, step_name, status, max_attempts) \ VALUES ($1, $2, $3, $4)", ) .bind(run_id) .bind(&step.name) .bind(status) .bind(max_attempts) .execute(&mut **tx) .await?; } Ok(run_id.into()) } // ---- claim / complete / reclaim ------------------------------------------ #[derive(sqlx::FromRow)] struct ClaimedRow { id: Uuid, run_id: Uuid, step_name: String, attempt: i32, max_attempts: i32, app_id: Uuid, workflow_id: Uuid, root_execution_id: Uuid, workflow_depth: i32, input: serde_json::Value, definition: serde_json::Value, } /// Atomically claim one `ready`, due step across all active runs — the queue /// competing-consumer lease (`FOR UPDATE SKIP LOCKED`), so parallel workers /// (and, cluster mode later, parallel nodes) never grab the same step. The /// claimed step flips to `running` with a fresh `claim_token` in one atomic /// statement. A *separate*, non-transactional follow-up statement then reflects /// the run as `pending → running` — this is a promptness convenience, not part /// of the claim's atomicity: a crash in between is harmless (the token-gated /// advance, and `reclaim`, both re-derive run status). Returns `None` when /// nothing is claimable. pub async fn claim_ready_step(pool: &PgPool) -> Result, WorkflowRepoError> { let token = Uuid::new_v4(); let row: Option = sqlx::query_as( "WITH claimed AS ( \ UPDATE workflow_run_steps \ SET status = 'running', claim_token = $1, claimed_at = NOW(), \ attempt = attempt + 1, updated_at = NOW() \ WHERE id = ( \ SELECT s.id FROM workflow_run_steps s \ JOIN workflow_runs r ON r.id = s.run_id \ WHERE s.status = 'ready' \ AND (s.next_attempt_at IS NULL OR s.next_attempt_at <= NOW()) \ AND r.status IN ('pending', 'running') \ ORDER BY s.created_at \ FOR UPDATE OF s SKIP LOCKED \ LIMIT 1 \ ) \ RETURNING id, run_id, step_name, attempt, max_attempts \ ) \ SELECT c.id, c.run_id, c.step_name, c.attempt, c.max_attempts, \ r.app_id, r.workflow_id, r.root_execution_id, r.workflow_depth, \ r.input, w.definition \ FROM claimed c \ JOIN workflow_runs r ON r.id = c.run_id \ JOIN workflows w ON w.id = r.workflow_id", ) .bind(token) .fetch_optional(pool) .await?; let Some(r) = row else { return Ok(None) }; // Reflect the run as running promptly (advance also does this, but a // long-running first step would otherwise leave the run 'pending'). sqlx::query( "UPDATE workflow_runs SET status = 'running', started_at = COALESCE(started_at, NOW()) \ WHERE id = $1 AND status = 'pending'", ) .bind(r.run_id) .execute(pool) .await?; let definition = serde_json::from_value(r.definition) .map_err(|e| WorkflowRepoError::Serde(e.to_string()))?; Ok(Some(ClaimedStep { step_id: r.id.into(), run_id: r.run_id.into(), step_name: r.step_name, attempt: r.attempt, max_attempts: r.max_attempts, claim_token: token, app_id: r.app_id.into(), workflow_id: r.workflow_id.into(), root_execution_id: r.root_execution_id, workflow_depth: r.workflow_depth, run_input: r.input, definition, })) } #[derive(sqlx::FromRow)] struct StepStateRow { step_name: String, status: String, output: Option, } /// Write a claimed step's outcome and advance the run graph — all in one /// token-gated transaction. A stale worker (claim reclaimed out from under it) /// matches zero rows and writes nothing (`AdvanceResult::Stale`). /// /// - `Succeeded` → the step is marked `succeeded` with its output, then the /// graph advances (promotions + terminal run status). /// - `Failed` with attempts left → the step is re-armed `ready` with a backoff /// gate; the run keeps running (`Retried`, no advance). /// - `Failed` exhausted → the step is marked `failed`, then the graph advances /// (its `on_error` decides whether the run fails or limps on). #[allow(clippy::too_many_lines)] pub async fn complete_step_and_advance( pool: &PgPool, claimed: &ClaimedStep, outcome: StepOutcome, ) -> Result { let mut tx = pool.begin().await?; // 1. Token-gated step write. match &outcome { StepOutcome::Succeeded(out) => { let res = sqlx::query( "UPDATE workflow_run_steps \ SET status = 'succeeded', output = $3, error = NULL, \ claim_token = NULL, claimed_at = NULL, updated_at = NOW() \ WHERE id = $1 AND claim_token = $2", ) .bind(claimed.step_id.into_inner()) .bind(claimed.claim_token) .bind(out) .execute(&mut *tx) .await?; if res.rows_affected() == 0 { tx.rollback().await?; return Ok(AdvanceResult::Stale); } } StepOutcome::Failed { error, retry_delay } => { if claimed.attempt < claimed.max_attempts { let next = Utc::now() + *retry_delay; let res = sqlx::query( "UPDATE workflow_run_steps \ SET status = 'ready', error = $3, claim_token = NULL, \ claimed_at = NULL, next_attempt_at = $4, updated_at = NOW() \ WHERE id = $1 AND claim_token = $2", ) .bind(claimed.step_id.into_inner()) .bind(claimed.claim_token) .bind(error) .bind(next) .execute(&mut *tx) .await?; if res.rows_affected() == 0 { tx.rollback().await?; return Ok(AdvanceResult::Stale); } tx.commit().await?; return Ok(AdvanceResult::Retried); } let res = sqlx::query( "UPDATE workflow_run_steps \ SET status = 'failed', error = $3, claim_token = NULL, \ claimed_at = NULL, updated_at = NOW() \ WHERE id = $1 AND claim_token = $2", ) .bind(claimed.step_id.into_inner()) .bind(claimed.claim_token) .bind(error) .execute(&mut *tx) .await?; if res.rows_affected() == 0 { tx.rollback().await?; return Ok(AdvanceResult::Stale); } } StepOutcome::Skipped => { let res = sqlx::query( "UPDATE workflow_run_steps \ SET status = 'skipped', output = NULL, error = NULL, \ claim_token = NULL, claimed_at = NULL, updated_at = NOW() \ WHERE id = $1 AND claim_token = $2", ) .bind(claimed.step_id.into_inner()) .bind(claimed.claim_token) .execute(&mut *tx) .await?; if res.rows_affected() == 0 { tx.rollback().await?; return Ok(AdvanceResult::Stale); } } } // 2. Advance the run graph in the same tx. advance_run_tx(&mut tx, claimed.run_id, &claimed.definition).await?; tx.commit().await?; Ok(AdvanceResult::Advanced) } /// Advance one run's graph inside an open transaction: lock the run row (so /// concurrent advances serialize — each does a full recompute, so this is /// idempotent), promote pending steps whose deps are now satisfied, and write /// the run's terminal status. A no-op if the run is already terminal. Does NOT /// commit — the caller owns the transaction. /// /// Shared by `complete_step_and_advance` (a function/skip step finished) and /// `resume_finished_children` (a parked sub-workflow step finished). pub async fn advance_run_tx( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, run_id: WorkflowRunId, definition: &WorkflowDefinition, ) -> Result<(), WorkflowRepoError> { let run: Option<(String,)> = sqlx::query_as("SELECT status FROM workflow_runs WHERE id = $1 FOR UPDATE") .bind(run_id.into_inner()) .fetch_optional(&mut **tx) .await?; let Some((run_status,)) = run else { return Ok(()); }; if RunStatus::from_str(&run_status).is_some_and(RunStatus::is_terminal) { // Another worker already finished the run; nothing to do. return Ok(()); } let step_rows: Vec = sqlx::query_as( "SELECT step_name, status, output FROM workflow_run_steps WHERE run_id = $1", ) .bind(run_id.into_inner()) .fetch_all(&mut **tx) .await?; let mut statuses = BTreeMap::new(); let mut outputs = BTreeMap::new(); for r in step_rows { if let Some(st) = StepStatus::from_str(&r.status) { statuses.insert(r.step_name.clone(), st); } if let Some(o) = r.output { outputs.insert(r.step_name, o); } } let plan = compute_advance(definition, &statuses, &outputs); for name in &plan.promote { sqlx::query( "UPDATE workflow_run_steps \ SET status = 'ready', next_attempt_at = NULL, updated_at = NOW() \ WHERE run_id = $1 AND step_name = $2 AND status = 'pending'", ) .bind(run_id.into_inner()) .bind(name) .execute(&mut **tx) .await?; } match plan.run_status { RunStatus::Succeeded => { sqlx::query( "UPDATE workflow_runs \ SET status = 'succeeded', output = $2, error = $3, finished_at = NOW() \ WHERE id = $1", ) .bind(run_id.into_inner()) .bind(plan.run_output) .bind(plan.run_error) .execute(&mut **tx) .await?; } RunStatus::Failed => { sqlx::query( "UPDATE workflow_runs \ SET status = 'failed', error = $2, finished_at = NOW() WHERE id = $1", ) .bind(run_id.into_inner()) .bind(plan.run_error) .execute(&mut **tx) .await?; } _ => { sqlx::query( "UPDATE workflow_runs \ SET status = 'running', started_at = COALESCE(started_at, NOW()) WHERE id = $1", ) .bind(run_id.into_inner()) .execute(&mut **tx) .await?; } } Ok(()) } /// Periodic safety net: a step leased by a crashed worker (still `running` /// with a `claim_token` past the visibility timeout) is re-armed `ready` so /// another worker retries it. Only touches steps of still-active runs. /// /// The crashed attempt produced no outcome, so it must NOT consume the retry /// budget: `claim_ready_step` incremented `attempt` up front, so reclaim /// *decrements* it (floored at 0) — the re-claim will re-increment, leaving the /// effective attempt count unchanged. `next_attempt_at` is cleared so the /// reclaimed step is immediately eligible. /// /// Returns the number of steps reclaimed. pub async fn reclaim_stale_steps( pool: &PgPool, visibility_timeout_secs: u32, ) -> Result { let res = sqlx::query( "UPDATE workflow_run_steps s \ SET status = 'ready', claim_token = NULL, claimed_at = NULL, \ attempt = GREATEST(s.attempt - 1, 0), next_attempt_at = NULL, updated_at = NOW() \ FROM workflow_runs r \ WHERE s.run_id = r.id \ AND s.claim_token IS NOT NULL \ AND s.status = 'running' \ AND s.claimed_at < NOW() - ($1 || ' seconds')::INTERVAL \ AND r.status IN ('pending', 'running')", ) .bind(i64::from(visibility_timeout_secs)) .execute(pool) .await?; Ok(res.rows_affected()) } // ---- run/step reads (admin surface + test assertions) -------------------- #[derive(sqlx::FromRow)] struct RunRow { id: Uuid, workflow_id: Uuid, app_id: Uuid, status: String, input: serde_json::Value, output: Option, error: Option, root_execution_id: Uuid, workflow_depth: i32, parent_run_id: Option, parent_step_id: Option, started_at: Option>, finished_at: Option>, created_at: DateTime, } impl TryFrom for WorkflowRun { type Error = WorkflowRepoError; fn try_from(r: RunRow) -> Result { Ok(Self { id: r.id.into(), workflow_id: r.workflow_id.into(), app_id: r.app_id.into(), status: RunStatus::from_str(&r.status).ok_or_else(|| { WorkflowRepoError::Serde(format!("bad run status {:?}", r.status)) })?, input: r.input, output: r.output, error: r.error, root_execution_id: r.root_execution_id, workflow_depth: r.workflow_depth, parent_run_id: r.parent_run_id.map(Into::into), parent_step_id: r.parent_step_id.map(Into::into), started_at: r.started_at, finished_at: r.finished_at, created_at: r.created_at, }) } } const RUN_COLS: &str = "id, workflow_id, app_id, status, input, output, error, \ root_execution_id, workflow_depth, parent_run_id, parent_step_id, \ started_at, finished_at, created_at"; /// Most-recent runs of a workflow (newest first), capped at `limit`. Backs the /// admin/CLI run-history list. pub async fn list_runs_for_workflow( pool: &PgPool, app_id: AppId, workflow_id: WorkflowId, limit: i64, ) -> Result, WorkflowRepoError> { let rows: Vec = sqlx::query_as(&format!( "SELECT {RUN_COLS} FROM workflow_runs \ WHERE app_id = $1 AND workflow_id = $2 ORDER BY created_at DESC LIMIT $3" )) .bind(app_id.into_inner()) .bind(workflow_id.into_inner()) .bind(limit) .fetch_all(pool) .await?; rows.into_iter().map(TryInto::try_into).collect() } /// Read a single run by id (app-scoped for isolation). pub async fn get_run( pool: &PgPool, app_id: AppId, run_id: WorkflowRunId, ) -> Result, WorkflowRepoError> { let row: Option = sqlx::query_as(&format!( "SELECT {RUN_COLS} FROM workflow_runs WHERE id = $1 AND app_id = $2" )) .bind(run_id.into_inner()) .bind(app_id.into_inner()) .fetch_optional(pool) .await?; row.map(TryInto::try_into).transpose() } #[derive(sqlx::FromRow)] struct StepRow { id: Uuid, run_id: Uuid, step_name: String, status: String, attempt: i32, max_attempts: i32, output: Option, error: Option, child_run_id: Option, } impl TryFrom for WorkflowRunStep { type Error = WorkflowRepoError; fn try_from(r: StepRow) -> Result { Ok(Self { id: r.id.into(), run_id: r.run_id.into(), step_name: r.step_name, status: StepStatus::from_str(&r.status).ok_or_else(|| { WorkflowRepoError::Serde(format!("bad step status {:?}", r.status)) })?, attempt: r.attempt, max_attempts: r.max_attempts, output: r.output, error: r.error, child_run_id: r.child_run_id.map(Into::into), }) } } /// All steps of a run, ordered by name (for the admin/CLI surface + tests). /// App-scoped at the query (JOIN `workflow_runs`) so isolation is enforced here /// rather than relying on the caller having pre-verified run ownership — a /// run under another app returns no rows. pub async fn list_run_steps( pool: &PgPool, app_id: AppId, run_id: WorkflowRunId, ) -> Result, WorkflowRepoError> { let rows: Vec = sqlx::query_as( "SELECT s.id, s.run_id, s.step_name, s.status, s.attempt, s.max_attempts, s.output, \ s.error, s.child_run_id \ FROM workflow_run_steps s \ JOIN workflow_runs r ON r.id = s.run_id \ WHERE s.run_id = $1 AND r.app_id = $2 ORDER BY s.step_name", ) .bind(run_id.into_inner()) .bind(app_id.into_inner()) .fetch_all(pool) .await?; rows.into_iter().map(TryInto::try_into).collect() } /// The `{ step_name: output }` map of a run's **succeeded** steps — the /// accumulated context M3 resolves a step's `when` + `input` templates against /// (see `workflow_template::RunContext`). Read fresh at execution time so a /// step sees every prior step's output. pub async fn load_run_step_outputs( pool: &PgPool, run_id: WorkflowRunId, ) -> Result, WorkflowRepoError> { let rows: Vec<(String, Option)> = sqlx::query_as( "SELECT step_name, output FROM workflow_run_steps \ WHERE run_id = $1 AND status = 'succeeded'", ) .bind(run_id.into_inner()) .fetch_all(pool) .await?; Ok(rows .into_iter() .filter_map(|(name, out)| out.map(|v| (name, v))) .collect()) } // ---- nested sub-workflows (M4) -------------------------------------------- /// Resolve an app-owned workflow by case-insensitive name (a `workflow`-kind /// step's target). Pool free-fn mirror of `WorkflowRepo::get_by_name`. pub async fn get_workflow_by_name( pool: &PgPool, app_id: AppId, name: &str, ) -> Result, WorkflowRepoError> { let row: Option = sqlx::query_as(&format!( "SELECT {SELECT_COLS} FROM workflows WHERE app_id = $1 AND LOWER(name) = LOWER($2)" )) .bind(app_id.into_inner()) .bind(name) .fetch_optional(pool) .await?; row.map(TryInto::try_into).transpose() } /// Resolve an app-owned workflow by id (app-scoped). Backs the run-detail /// API's DAG-edge lookup (`depends_on` lives on the definition, not the run). pub async fn get_workflow_by_id( pool: &PgPool, app_id: AppId, id: WorkflowId, ) -> Result, WorkflowRepoError> { let row: Option = sqlx::query_as(&format!( "SELECT {SELECT_COLS} FROM workflows WHERE app_id = $1 AND id = $2" )) .bind(app_id.into_inner()) .bind(id.into_inner()) .fetch_optional(pool) .await?; row.map(TryInto::try_into).transpose() } /// Start a nested sub-workflow run and **park** the parent step on it. In one /// token-gated transaction: the parent step (still claimed by the caller) is /// set `running` with `claim_token` cleared and `child_run_id` pointing at a /// freshly-seeded child run (depth + 1, parent linkage). The parked step is /// never re-claimed (only `ready` steps are) nor reclaimed (only leased /// `running` steps — `claim_token` is NULL here); `resume_finished_children` /// resolves it once the child terminates. A stale worker matches zero rows and /// nothing (including the child) is written. pub async fn start_child_and_park( pool: &PgPool, claimed: &ClaimedStep, child_workflow_id: WorkflowId, child_definition: &WorkflowDefinition, child_input: serde_json::Value, ) -> Result { let mut tx = pool.begin().await?; // Token-gated park FIRST — so a stale claim short-circuits before we ever // insert an orphan child run (child_run_id is linked below). let res = sqlx::query( "UPDATE workflow_run_steps \ SET status = 'running', claim_token = NULL, claimed_at = NULL, updated_at = NOW() \ WHERE id = $1 AND claim_token = $2", ) .bind(claimed.step_id.into_inner()) .bind(claimed.claim_token) .execute(&mut *tx) .await?; if res.rows_affected() == 0 { tx.rollback().await?; return Ok(AdvanceResult::Stale); } let new = NewWorkflowRun { workflow_id: child_workflow_id, app_id: claimed.app_id, input: child_input, // Correlate the whole nested tree under the top-level run's root id. root_execution_id: claimed.root_execution_id, workflow_depth: claimed.workflow_depth + 1, parent_run_id: Some(claimed.run_id), parent_step_id: Some(claimed.step_id), }; let child_run = seed_run_tx(&mut tx, &new, child_definition).await?; sqlx::query( "UPDATE workflow_run_steps SET child_run_id = $2, updated_at = NOW() WHERE id = $1", ) .bind(claimed.step_id.into_inner()) .bind(child_run.into_inner()) .execute(&mut *tx) .await?; sqlx::query( "UPDATE workflow_runs SET status = 'running', started_at = COALESCE(started_at, NOW()) \ WHERE id = $1 AND status = 'pending'", ) .bind(claimed.run_id.into_inner()) .execute(&mut *tx) .await?; tx.commit().await?; Ok(AdvanceResult::Advanced) } #[derive(sqlx::FromRow)] struct ParkedRow { parent_step_id: Uuid, parent_run_id: Uuid, child_status: String, child_output: Option, child_error: Option, parent_definition: serde_json::Value, } /// Resolve parent steps parked on a now-terminal child sub-workflow: write the /// child's output (or error) onto the parent step and advance the parent run. /// Idempotent — the conditional `status = 'running'` gate means a second pass /// (or a second worker) matches zero rows. Returns the number resolved. pub async fn resume_finished_children(pool: &PgPool) -> Result { let rows: Vec = sqlx::query_as( "SELECT ps.id AS parent_step_id, ps.run_id AS parent_run_id, \ child.status AS child_status, child.output AS child_output, \ child.error AS child_error, w.definition AS parent_definition \ FROM workflow_run_steps ps \ JOIN workflow_runs child ON child.id = ps.child_run_id \ JOIN workflow_runs parent ON parent.id = ps.run_id \ JOIN workflows w ON w.id = parent.workflow_id \ WHERE ps.status = 'running' AND ps.child_run_id IS NOT NULL \ AND ps.claim_token IS NULL \ AND child.status IN ('succeeded', 'failed', 'canceled') \ LIMIT 32", ) .fetch_all(pool) .await?; let mut resolved = 0u64; for r in rows { let mut tx = pool.begin().await?; // A child terminates as succeeded / failed / canceled. There is no // distinct `canceled` step status (nor a run-cancel operation yet — the // `canceled` run status is reserved for a future admin cancel), so a // canceled child resolves the parent step to `failed` with a message // that names the cause rather than masquerading as a generic failure. let (new_status, out, err) = match r.child_status.as_str() { "succeeded" => ("succeeded", r.child_output.clone(), None), "canceled" => ( "failed", None, Some("sub-workflow was canceled".to_string()), ), _ => ( "failed", None, Some( r.child_error .clone() .unwrap_or_else(|| "sub-workflow failed".to_string()), ), ), }; let res = sqlx::query( "UPDATE workflow_run_steps \ SET status = $2, output = $3, error = $4, updated_at = NOW() \ WHERE id = $1 AND status = 'running' AND child_run_id IS NOT NULL", ) .bind(r.parent_step_id) .bind(new_status) .bind(&out) .bind(&err) .execute(&mut *tx) .await?; if res.rows_affected() == 0 { tx.rollback().await?; continue; // already resolved by another pass } let parent_def: WorkflowDefinition = serde_json::from_value(r.parent_definition) .map_err(|e| WorkflowRepoError::Serde(e.to_string()))?; advance_run_tx(&mut tx, r.parent_run_id.into(), &parent_def).await?; tx.commit().await?; resolved += 1; } Ok(resolved) } #[cfg(test)] mod tests { use super::*; use picloud_shared::workflow::WorkflowStepDef; use serde_json::json; fn step(name: &str, deps: &[&str]) -> WorkflowStepDef { WorkflowStepDef { name: name.into(), function: Some(format!("fn_{name}")), workflow: None, input: serde_json::Value::Null, depends_on: deps.iter().map(|s| (*s).to_string()).collect(), when: None, retry: None, on_error: OnError::Fail, } } fn def(steps: Vec) -> WorkflowDefinition { WorkflowDefinition { steps } } fn statuses(pairs: &[(&str, StepStatus)]) -> BTreeMap { pairs.iter().map(|(n, s)| ((*n).to_string(), *s)).collect() } #[test] fn promotes_dependent_when_dep_succeeds() { // a -> b. a succeeded, b still pending → b becomes ready. let d = def(vec![step("a", &[]), step("b", &["a"])]); let st = statuses(&[("a", StepStatus::Succeeded), ("b", StepStatus::Pending)]); let plan = compute_advance(&d, &st, &BTreeMap::new()); assert_eq!(plan.promote, vec!["b".to_string()]); assert_eq!(plan.run_status, RunStatus::Running); } #[test] fn fan_in_waits_for_all_deps() { // c depends on [a, b]. Only a done → c must NOT be promoted. let d = def(vec![step("a", &[]), step("b", &[]), step("c", &["a", "b"])]); let st = statuses(&[ ("a", StepStatus::Succeeded), ("b", StepStatus::Running), ("c", StepStatus::Pending), ]); let plan = compute_advance(&d, &st, &BTreeMap::new()); assert!(plan.promote.is_empty()); assert_eq!(plan.run_status, RunStatus::Running); // Now both a and b done → c promoted. let st = statuses(&[ ("a", StepStatus::Succeeded), ("b", StepStatus::Succeeded), ("c", StepStatus::Pending), ]); let plan = compute_advance(&d, &st, &BTreeMap::new()); assert_eq!(plan.promote, vec!["c".to_string()]); } #[test] fn all_terminal_succeeds_with_output_object() { let d = def(vec![step("a", &[]), step("b", &["a"])]); let st = statuses(&[("a", StepStatus::Succeeded), ("b", StepStatus::Succeeded)]); let mut out = BTreeMap::new(); out.insert("a".to_string(), json!({ "x": 1 })); out.insert("b".to_string(), json!("done")); let plan = compute_advance(&d, &st, &out); assert!(plan.promote.is_empty()); assert_eq!(plan.run_status, RunStatus::Succeeded); assert_eq!( plan.run_output, Some(json!({ "a": { "x": 1 }, "b": "done" })) ); } #[test] fn on_error_fail_exhausted_fails_the_run() { let d = def(vec![step("a", &[]), step("b", &["a"])]); // a failed (on_error = fail) → run fails, b never promoted. let st = statuses(&[("a", StepStatus::Failed), ("b", StepStatus::Pending)]); let plan = compute_advance(&d, &st, &BTreeMap::new()); assert!(plan.promote.is_empty()); assert_eq!(plan.run_status, RunStatus::Failed); assert!(plan.run_error.is_some()); } #[test] fn on_error_continue_lets_dependents_proceed_and_run_succeed() { let mut a = step("a", &[]); a.on_error = OnError::Continue; let d = def(vec![a, step("b", &["a"])]); // a failed but continue → b promoted. let st = statuses(&[("a", StepStatus::Failed), ("b", StepStatus::Pending)]); let plan = compute_advance(&d, &st, &BTreeMap::new()); assert_eq!(plan.promote, vec!["b".to_string()]); assert_eq!(plan.run_status, RunStatus::Running); // a failed-continue + b succeeded → run succeeds (a contributes no output). let st = statuses(&[("a", StepStatus::Failed), ("b", StepStatus::Succeeded)]); let mut out = BTreeMap::new(); out.insert("b".to_string(), json!(42)); let plan = compute_advance(&d, &st, &out); assert_eq!(plan.run_status, RunStatus::Succeeded); assert_eq!(plan.run_output, Some(json!({ "b": 42 }))); } #[test] fn skipped_dep_satisfies_dependents() { let d = def(vec![step("a", &[]), step("b", &["a"])]); let st = statuses(&[("a", StepStatus::Skipped), ("b", StepStatus::Pending)]); let plan = compute_advance(&d, &st, &BTreeMap::new()); assert_eq!(plan.promote, vec!["b".to_string()]); } #[test] fn parallel_fan_out_promotes_all_ready_children() { // root -> b, root -> c (diamond top). After root succeeds, both promote. let d = def(vec![ step("root", &[]), step("b", &["root"]), step("c", &["root"]), ]); let st = statuses(&[ ("root", StepStatus::Succeeded), ("b", StepStatus::Pending), ("c", StepStatus::Pending), ]); let plan = compute_advance(&d, &st, &BTreeMap::new()); assert_eq!(plan.promote, vec!["b".to_string(), "c".to_string()]); assert_eq!(plan.run_status, RunStatus::Running); } }