diff --git a/backend/windmill-worker/src/bun_executor.rs b/backend/windmill-worker/src/bun_executor.rs index f6b093f766..2a0ad5f618 100644 --- a/backend/windmill-worker/src/bun_executor.rs +++ b/backend/windmill-worker/src/bun_executor.rs @@ -3127,13 +3127,9 @@ pub async fn handle_wac_v2_output( // Generate resume URLs for the inline approval buttons. // Use a hash of the step key as resume_id so each waitForApproval() - // in the same workflow gets a unique resume_job record. - let resume_id: u32 = { - use std::hash::{Hash, Hasher}; - let mut hasher = std::collections::hash_map::DefaultHasher::new(); - key.hash(&mut hasher); - (hasher.finish() & 0xFFFF_FFFF) as u32 - }; + // in the same workflow gets a unique resume_job record. The resume-side + // matching in prepare_checkpoint_for_resume derives the same id from the key. + let resume_id: u32 = crate::wac_executor::approval_resume_id(&key); // Generate stateless approval token using shared utility let approval_token = windmill_common::variables::generate_approval_token(&job.workspace_id, job.id, db) diff --git a/backend/windmill-worker/src/wac_executor.rs b/backend/windmill-worker/src/wac_executor.rs index 2fcfc8e2c5..afb1553d8d 100644 --- a/backend/windmill-worker/src/wac_executor.rs +++ b/backend/windmill-worker/src/wac_executor.rs @@ -49,6 +49,17 @@ pub enum WacOutput { Sleep { key: String, seconds: u32 }, } +/// Derive the per-approval `resume_id` from the step key. This MUST stay in sync with the +/// resume/cancel URL generation in `handle_wac_v2_output` so that the resume endpoint and the +/// checkpoint-resume matching agree on which `resume_job` row belongs to which +/// `waitForApproval()` call within a workflow. +pub fn approval_resume_id(key: &str) -> u32 { + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + key.hash(&mut hasher); + (hasher.finish() & 0xFFFF_FFFF) as u32 +} + /// A step dispatched by the WAC SDK. /// /// `dispatch_type` determines how the child job is created: @@ -143,10 +154,18 @@ pub async fn prepare_checkpoint_for_resume( .and_then(|p| p.keys.first().cloned()) .unwrap_or_default(); + // Match the resume event to *this* approval step by its resume_id. Every + // waitForApproval() in the same workflow shares the parent job_id, so keying + // only on `job` (and taking the oldest row) made every step after the first + // re-inject the first approval's value. A canceled step then carries approved=false + // on its own row, and a timed-out step has no row at all -> the None branch below + // yields the approved=false default. + let resume_id = approval_resume_id(&approval_key) as i32; let resume_row = sqlx::query_as::<_, (sqlx::types::Json>, Option, bool)>( - "SELECT value, approver, approved FROM resume_job WHERE job = $1 ORDER BY created_at ASC LIMIT 1", + "SELECT value, approver, approved FROM resume_job WHERE job = $1 AND resume_id = $2 ORDER BY created_at DESC LIMIT 1", ) .bind(job_id) + .bind(resume_id) .fetch_optional(db) .await?;