fix: match WAC waitForApproval resume to its own step (WIN-2240)

Sequential waitForApproval() calls in a workflow-as-code job all share the
same parent job_id. prepare_checkpoint_for_resume read the resume_job with
`WHERE job = $1 ORDER BY created_at ASC LIMIT 1`, so every approval after the
first re-injected the first approval's {value, approver, approved}. A canceled
or timed-out step also inherited the first approval's result.

Each approval already has a unique resume_id derived from its step key (used in
its resume/cancel URL and stored in resume_job.resume_id). Match on that so
each step consumes only its own resume event; a canceled step keeps
approved=false on its own row and a timed-out step matches no row and falls
back to the approved=false default. The key->resume_id hash is extracted into a
shared approval_resume_id() so URL generation and resume matching stay in sync.

Fixes WIN-2240

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-07-24 15:32:24 +00:00
co-authored by Claude Opus 4.8
parent 992ed01244
commit 402d8bf148
2 changed files with 23 additions and 8 deletions
+3 -7
View File
@@ -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)
+20 -1
View File
@@ -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<Box<serde_json::value::RawValue>>, Option<String>, 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?;