feat: bind WAC approval urls to a named wait_for_approval step (#10317)

* feat: bind WAC approval urls to a named wait_for_approval step

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: reject duplicate WAC approval step keys instead of renaming them

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: reject WAC approval links minted for a step that is not awaiting approval

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: bind WAC approval links to the awaiting step and stop step key aliasing

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: reject empty approval keys and scope minted-key writes to the workspace

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: enforce WAC approval binding at consumption and reject colliding keys

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: make WAC approval binding and collision checks atomic, harden TS step keys

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: decrement WAC suspend atomically instead of from a pre-lock snapshot

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore: add sqlx cache entry for the atomic WAC suspend decrement

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: omit empty approver param from python get_approval_urls

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test: pin the suspend-snapshot decrement and the colliding-mint race

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test: drop the suspend-snapshot interleave test, it cannot both be stable and discriminate

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: reject step keys that cannot be minted as a URL path segment

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-07-25 11:41:48 +02:00
committed by GitHub
parent 15a6382c89
commit 9cef724ff2
29 changed files with 1676 additions and 228 deletions
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE v2_job_queue SET suspend = GREATEST(suspend - 1, 0) WHERE id = $1 AND suspend > 0",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "d1b882ab87de6d6cd16e8bc4364e6f11e55231d2e62b0f8a7404f0e6093d7d68"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT (kind::text NOT IN ('flow', 'flowpreview', 'flownode', 'singlestepflow')\n AND parent_job IS NULL) AS \"is_wac!\"\n FROM v2_job WHERE id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "is_wac!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
null
]
},
"hash": "ed42846826aa3f82f17097e52fcbf55f4e0a37d7c9280064a5636d9d3bf1f6c2"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS (SELECT 1 FROM v2_job WHERE id = $1 AND workspace_id = $2)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": [
null
]
},
"hash": "f3bae157b92cbc1b3741593a620c9203012a7d3754f56af665e2f34a969eabd7"
}
+23
View File
@@ -0,0 +1,23 @@
-- A Workflow-as-Code job in the queue, suspended on a wait_for_approval step
-- (see tests/wac_approval_urls.rs). WAC parents are plain script jobs with no
-- parent_job, which is what makes get_flow_info_for_resume treat them as WAC.
INSERT INTO public.v2_job (
id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email,
kind, script_lang, runnable_path, tag, visible_to_owner
) VALUES (
'a1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1', 'test-workspace', 'test-user',
'2023-01-01 00:00:00', 'u/test-user', 'test@windmill.dev',
'script', 'bun', 'u/test-user/wac_workflow', 'bun', true
);
INSERT INTO public.v2_job_queue (id, workspace_id, scheduled_for, running, suspend, tag) VALUES
('a1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1', 'test-workspace', '2023-01-01 00:00:00', true, 1, 'bun');
-- A second workspace the caller also administers, so a cross-workspace mint is
-- rejected by the job's workspace check rather than by workspace authorization.
INSERT INTO workspace (id, name, owner) VALUES
('test-workspace-2', 'test-workspace-2', 'test-user');
INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES
('test-workspace-2', 'test@windmill.dev', 'test-user', true, 'Admin');
INSERT INTO workspace_key(workspace_id, kind, key) VALUES
('test-workspace-2', 'cloud', 'test-key-2');
INSERT INTO workspace_settings (workspace_id) VALUES ('test-workspace-2');
+46
View File
@@ -81,3 +81,49 @@ async fn wac_sequential_approvals_read_own_row(db: Pool<Postgres>) -> anyhow::Re
Ok(())
}
/// A row carrying another step's bound resume_id belongs to that step. The API
/// refuses such resumes but cannot do so atomically with the insert, so
/// consumption must skip them however they got in — while leaving the random ids
/// the interactive channels sign fully eligible.
#[sqlx::test]
async fn wac_approval_skips_another_steps_bound_row(db: Pool<Postgres>) -> anyhow::Result<()> {
let job_id = Uuid::new_v4();
sqlx::query(
"INSERT INTO v2_job_queue (id, workspace_id, scheduled_for) VALUES ($1, 'test-workspace', now())",
)
.bind(job_id)
.execute(&db)
.await?;
sqlx::query(
"INSERT INTO v2_job_status (id, workflow_as_code_status) VALUES ($1, $2)",
)
.bind(job_id)
.bind(sqlx::types::Json(json!({
"_minted_approval_keys": { "legal": true, "finance": true }
})))
.execute(&db)
.await?;
// Oldest row is bound to `finance`; the later one is a plain Slack-style resume.
insert_resume_row(
&db,
job_id,
windmill_common::wac::approval_resume_id("finance") as i32,
"bob",
true,
json!({"n": "finance"}),
)
.await?;
insert_resume_row(&db, job_id, 4242, "alice", true, json!({"n": "slack"})).await?;
let ckpt = pending_approval(WacCheckpoint::default(), "legal");
let ckpt = prepare_checkpoint_for_resume(&db, &job_id, ckpt).await?;
assert_eq!(
ckpt.completed_steps["legal"]["approver"],
json!("alice"),
"`legal` must skip finance's bound row and take the unbound one"
);
Ok(())
}
+267
View File
@@ -0,0 +1,267 @@
//! `jobs/wac_approval_urls/{job}/{step_key}` mints the resume URLs a WAC
//! workflow routes through its own channel. They must address the same
//! `resume_job` record the step's built-in buttons use — i.e. carry
//! `approval_resume_id(step_key)` — and be signed so the unauthenticated resume
//! route accepts them, without that route becoming any easier to forge.
use sqlx::{Pool, Postgres};
use windmill_common::wac::approval_resume_id;
use windmill_test_utils::*;
const WAC_JOB: &str = "a1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1";
// Distinct keys whose SHA-256 prefixes collide in the u32 the resume routes take.
const APPROVAL_COLLISION_A: &str = "approval-12509";
const APPROVAL_COLLISION_B: &str = "approval-81661";
/// The minted URLs point at `BASE_URL`, not the ephemeral test server.
fn to_test_url(base: &str, url: &str) -> String {
let (_, path) = url.split_once("/api/").expect("url has an /api/ segment");
format!("{base}/{path}")
}
/// Park the job on `step_key`, as the worker does when that approval suspends.
async fn awaiting_approval(db: &Pool<Postgres>, step_key: &str) -> anyhow::Result<()> {
sqlx::query(
"INSERT INTO v2_job_status (id, workflow_as_code_status) VALUES ($1::uuid, $2)
ON CONFLICT (id) DO UPDATE SET workflow_as_code_status =
v2_job_status.workflow_as_code_status || EXCLUDED.workflow_as_code_status",
)
.bind(WAC_JOB)
.bind(sqlx::types::Json(serde_json::json!({
"_checkpoint": { "pending_steps": { "mode": "approval", "keys": [step_key], "job_ids": {} } }
})))
.execute(db)
.await?;
Ok(())
}
#[sqlx::test(fixtures("base", "wac_approval_urls"))]
async fn wac_approval_urls_bind_to_step_key(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let base = format!("http://localhost:{}/api", server.addr.port());
let client = reqwest::Client::new();
let urls: serde_json::Value = client
.get(format!(
"{base}/w/test-workspace/jobs/wac_approval_urls/{WAC_JOB}/manager"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.send()
.await?
.error_for_status()?
.json()
.await?;
let resume = urls["resume"].as_str().expect("resume url").to_string();
let manager_id = approval_resume_id("manager");
assert!(
resume.contains(&format!("/jobs_u/resume/{WAC_JOB}/{manager_id}/")),
"resume url must carry the step key's resume_id: {resume}"
);
assert!(
urls["cancel"]
.as_str()
.is_some_and(|c| c.contains(&format!("/jobs_u/cancel/{WAC_JOB}/{manager_id}/"))),
"cancel url must carry the same resume_id: {urls}"
);
assert_ne!(
manager_id,
approval_resume_id("finance"),
"distinct approval steps must not share a resume_job record"
);
// A signature is only valid for the resume_id it was minted for, so the URL
// can't be retargeted at another step of the same workflow.
let retargeted = resume.replace(
&format!("/{manager_id}/"),
&format!("/{}/", approval_resume_id("finance")),
);
let status = client
.post(to_test_url(&base, &retargeted))
.json(&serde_json::json!({}))
.send()
.await?
.status();
assert!(
!status.is_success(),
"signature minted for `manager` must not resume another step (got {status})"
);
// The genuine URL resumes without any credential — possession of the
// signature is the authority, as for the built-in approval buttons.
awaiting_approval(&db, "manager").await?;
let resp = client
.post(to_test_url(&base, &resume))
.json(&serde_json::json!({ "ok": true }))
.send()
.await?;
assert!(
resp.status().is_success(),
"minted resume url must be accepted: {} {}",
resp.status(),
resp.text().await.unwrap_or_default()
);
let (approved, value): (bool, sqlx::types::Json<serde_json::Value>) = sqlx::query_as(
"SELECT approved, value FROM resume_job WHERE job = $1::uuid AND resume_id = $2",
)
.bind(WAC_JOB)
.bind(manager_id as i32)
.fetch_one(&db)
.await?;
assert!(approved);
assert_eq!(value.0, serde_json::json!({ "ok": true }));
Ok(())
}
/// Approval rows are consumed oldest-first regardless of resume_id (WIN-2241), so
/// a URL minted for a later step and clicked while an earlier one is pending would
/// otherwise resolve the earlier step with this approver's answer.
#[sqlx::test(fixtures("base", "wac_approval_urls"))]
async fn wac_approval_url_for_another_step_is_rejected(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let base = format!("http://localhost:{}/api", server.addr.port());
let client = reqwest::Client::new();
// The workflow mints both approvals' URLs up front, then suspends on `legal`.
let mut minted = Vec::new();
for step_key in ["legal", "finance"] {
let urls: serde_json::Value = client
.get(format!(
"{base}/w/test-workspace/jobs/wac_approval_urls/{WAC_JOB}/{step_key}"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.send()
.await?
.error_for_status()?
.json()
.await?;
minted.push(urls["resume"].as_str().expect("resume url").to_string());
}
// Nothing pending yet: the link must not bank a row that the next approval
// to be reached would consume, whichever step that turns out to be.
let resp = client
.post(to_test_url(&base, &minted[1]))
.json(&serde_json::json!({}))
.send()
.await?;
assert_eq!(
resp.status(),
reqwest::StatusCode::BAD_REQUEST,
"a bound link must not be bankable before its step awaits approval"
);
awaiting_approval(&db, "legal").await?;
let resp = client
.post(to_test_url(&base, &minted[1]))
.json(&serde_json::json!({}))
.send()
.await?;
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
assert_eq!(
status,
reqwest::StatusCode::BAD_REQUEST,
"finance's url must not resume the pending `legal` step: {body}"
);
assert!(
body.contains("finance"),
"error must name the bound step: {body}"
);
// The pending step's own url still works.
let resp = client
.post(to_test_url(&base, &minted[0]))
.json(&serde_json::json!({}))
.send()
.await?;
assert!(
resp.status().is_success(),
"the pending step's url must still resume: {}",
resp.text().await.unwrap_or_default()
);
Ok(())
}
/// The guards around minting: a step key must be non-empty, the job must be in the
/// caller's workspace and be WAC-shaped, and two keys may not share a resume id.
#[sqlx::test(fixtures("base", "wac_approval_urls"))]
async fn wac_approval_urls_mint_guards(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let base = format!("http://localhost:{}/api", server.addr.port());
let client = reqwest::Client::new();
let mint = |ws: &str, job: &str, key: &str| {
let url = format!("{base}/w/{ws}/jobs/wac_approval_urls/{job}/{key}");
client.get(url).header("Authorization", "Bearer SECRET_TOKEN").send()
};
assert_eq!(
mint("test-workspace", WAC_JOB, "%20").await?.status(),
reqwest::StatusCode::BAD_REQUEST,
"a blank step key must be refused rather than silently meaning `approval`"
);
// `v2_job_status` is keyed by job id alone, so the mint must not accept a job
// from another workspace and stamp its status row.
assert_eq!(
mint("test-workspace-2", WAC_JOB, "manager").await?.status(),
reqwest::StatusCode::NOT_FOUND,
"a job outside the caller's workspace must not be mintable"
);
// APPROVAL_COLLISION_A and _B hash to the same 32-bit resume id, so they would
// share one resume_job row and one capability.
assert!(mint("test-workspace", WAC_JOB, APPROVAL_COLLISION_A)
.await?
.status()
.is_success());
let resp = mint("test-workspace", WAC_JOB, APPROVAL_COLLISION_B).await?;
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
assert_eq!(status, reqwest::StatusCode::BAD_REQUEST, "colliding key: {body}");
assert!(body.contains(APPROVAL_COLLISION_A), "error must name the other key: {body}");
Ok(())
}
/// Colliding keys share one resume_job row and one capability, so the mint that
/// records them must let exactly one through however the requests interleave.
#[sqlx::test(fixtures("base", "wac_approval_urls"))]
async fn wac_concurrent_colliding_mints_admit_exactly_one(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let base = format!("http://localhost:{}/api", server.addr.port());
let mut set = tokio::task::JoinSet::new();
for key in [APPROVAL_COLLISION_A, APPROVAL_COLLISION_B] {
let url = format!("{base}/w/test-workspace/jobs/wac_approval_urls/{WAC_JOB}/{key}");
set.spawn(async move {
reqwest::Client::new()
.get(url)
.header("Authorization", "Bearer SECRET_TOKEN")
.send()
.await
.map(|r| r.status())
});
}
let mut ok = 0;
let mut rejected = 0;
while let Some(res) = set.join_next().await {
match res?? {
s if s.is_success() => ok += 1,
reqwest::StatusCode::BAD_REQUEST => rejected += 1,
other => anyhow::bail!("unexpected status {other}"),
}
}
assert_eq!((ok, rejected), (1, 1), "exactly one colliding key may be minted");
Ok(())
}
+38
View File
@@ -14771,6 +14771,44 @@ paths:
- resume
- cancel
/w/{workspace}/jobs/wac_approval_urls/{id}/{step_key}:
get:
summary: get the resume urls bound to a specific wait_for_approval step of a workflow-as-code job
operationId: getWacApprovalUrls
tags:
- job
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/JobId"
- name: step_key
in: path
required: true
description: checkpoint key of the wait_for_approval step, as passed to `wait_for_approval(key=...)`
schema:
type: string
- name: approver
in: query
schema:
type: string
responses:
"200":
description: url endpoints
content:
application/json:
schema:
type: object
properties:
approvalPage:
type: string
resume:
type: string
cancel:
type: string
required:
- approvalPage
- resume
- cancel
/w/{workspace}/jobs/slack_approval/{id}:
get:
summary: generate interactive slack approval for suspended job
+196 -9
View File
@@ -342,6 +342,10 @@ pub fn workspaced_service() -> Router {
"/resume_urls/{job_id}/{resume_id}",
get(get_resume_urls).layer(cors.clone()),
)
.route(
"/wac_approval_urls/{job_id}/{step_key}",
get(get_wac_approval_urls).layer(cors.clone()),
)
.route(
"/result_by_id/{job_id}/{node_id}",
get(get_result_by_id).layer(cors.clone()),
@@ -3684,6 +3688,13 @@ async fn resume_suspended_job_internal(
};
let mut tx: Transaction<'_, Postgres> = db.begin().await?;
// Inside the transaction that inserts the row and moves the suspend counter:
// validating earlier would let the workflow resolve this step and suspend on the
// next one in between, so a stale request would wake that later step instead.
if is_wac {
reject_mismatched_wac_approval(&mut tx, flow_info.id, resume_id).await?;
}
insert_resume_job(
resume_id,
job_id,
@@ -3703,15 +3714,17 @@ async fn resume_suspended_job_internal(
.execute(&mut *tx)
.await?;
} else if is_wac {
// WAC approval: decrement suspend counter directly on the WAC parent job
if flow_info.suspend > 0 {
sqlx::query!(
"UPDATE v2_job_queue SET suspend = GREATEST(suspend - 1, 0) WHERE id = $1",
flow_info.id,
)
.execute(&mut *tx)
.await?;
}
// WAC approval: decrement suspend counter directly on the WAC parent job.
// `flow_info.suspend` was read before this transaction took the queue-row
// lock, so gating on it would skip the decrement for a workflow that
// suspended in between and leave the approval parked until timeout.
sqlx::query!(
"UPDATE v2_job_queue SET suspend = GREATEST(suspend - 1, 0) \
WHERE id = $1 AND suspend > 0",
flow_info.id,
)
.execute(&mut *tx)
.await?;
} else if is_flow_level {
// For flow-level resumes, decrement the suspend counter if the flow is currently suspended
// The approval will be matched when the worker checks for resumes (both step-level and flow-level)
@@ -4308,6 +4321,180 @@ pub async fn get_resume_urls(
.await
}
/// Resume URLs bound to one `wait_for_approval(key=...)` step of a running
/// Workflow-as-Code job, so the workflow can route the request through its own
/// channel instead of the built-in ones. Same authority as `get_resume_urls`:
/// only the `resume_id` derivation differs, and it is the one the worker will
/// use when that step suspends.
pub async fn get_wac_approval_urls(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path((w_id, job_id, step_key)): Path<(String, Uuid, String)>,
Query(approver): Query<QueryApprover>,
) -> error::JsonResult<ResumeUrls> {
if step_key.trim().is_empty() {
return Err(Error::BadRequest(
"step_key must be the key of a wait_for_approval step".to_string(),
));
}
let flow_path = resume_target_flow_path(&db, &w_id, job_id).await?;
check_scopes(&authed, || format!("jobs:run:flows:{}", flow_path))?;
// This handler writes to the job's status row, so the job must actually be in
// the caller's workspace — `v2_job_status` is keyed by job id alone and would
// otherwise take a write aimed at another workspace's job.
let in_workspace = sqlx::query_scalar!(
"SELECT EXISTS (SELECT 1 FROM v2_job WHERE id = $1 AND workspace_id = $2)",
job_id,
w_id
)
.fetch_one(&db)
.await?
.unwrap_or(false);
if !in_workspace {
return Err(Error::NotFound(format!("job {job_id} not found")));
}
// The approval belongs to the WAC parent, but WM_JOB_ID is the child job when
// this is called from inside a task() rather than a step(). Resolve up so the
// URL still targets the workflow that will suspend.
let job_id = get_flow_id_for_job(&db, job_id).await.unwrap_or(job_id);
// The write below is what the run page keys its WAC timeline off, so it must not
// land on a job that has no WAC status. Rules out flows and step/child jobs; a
// WAC parent is itself a script job, so a plain script is indistinguishable here
// and still passes — it simply never mints, since only the SDK calls this.
let is_wac = sqlx::query_scalar!(
r#"SELECT (kind::text NOT IN ('flow', 'flowpreview', 'flownode', 'singlestepflow')
AND parent_job IS NULL) AS "is_wac!"
FROM v2_job WHERE id = $1"#,
job_id
)
.fetch_optional(&db)
.await?
.unwrap_or(false);
if !is_wac {
return Err(Error::BadRequest(format!(
"job {job_id} is not a workflow-as-code job"
)));
}
let resume_id = windmill_common::wac::approval_resume_id(&step_key);
// Remember which steps have a minted URL in circulation. A workflow may mint
// several up front, and the resume path uses this to tell "URL for the step
// awaiting approval" apart from "URL for some other step of this workflow",
// which it otherwise cannot: the interactive channels sign random resume_ids
// and must keep resuming whatever step is pending.
//
// Record first, then look for a collision, both in one transaction: the upsert
// takes the row lock, so a concurrent mint of a colliding key is serialized
// behind it and sees this key rather than racing past an earlier read. Upsert
// because a workflow can mint before any step has checkpointed, and a bare
// UPDATE would silently match nothing and leave the link unbound.
let mut tx = db.begin().await?;
sqlx::query(
"INSERT INTO v2_job_status (id, workflow_as_code_status)
VALUES ($1, jsonb_build_object('_minted_approval_keys',
jsonb_build_object($2::text, true)))
ON CONFLICT (id) DO UPDATE SET workflow_as_code_status = jsonb_set(
COALESCE(v2_job_status.workflow_as_code_status, '{}'::jsonb),
ARRAY['_minted_approval_keys'],
COALESCE(v2_job_status.workflow_as_code_status->'_minted_approval_keys', '{}'::jsonb)
|| jsonb_build_object($2::text, true)
)",
)
.bind(job_id)
.bind(&step_key)
.execute(&mut *tx)
.await?;
// Two keys sharing a resume_id share one resume_job row and one capability, and
// the binding check could not tell which of them a link was minted for.
if let Some(other) = sqlx::query_scalar::<_, String>(
"SELECT jsonb_object_keys(
COALESCE(workflow_as_code_status->'_minted_approval_keys', '{}'::jsonb))
FROM v2_job_status WHERE id = $1",
)
.bind(job_id)
.fetch_all(&mut *tx)
.await?
.into_iter()
.find(|k| *k != step_key && windmill_common::wac::approval_resume_id(k) == resume_id)
{
tx.rollback().await?;
return Err(Error::BadRequest(format!(
"step key `{step_key}` collides with `{other}` on the same resume id; rename one"
)));
}
tx.commit().await?;
get_resume_urls_internal(
Extension(db),
Path((w_id, job_id, resume_id)),
Query(approver),
)
.await
}
/// A WAC resume URL minted for a named `wait_for_approval` step is accepted only
/// while that step is the one awaiting approval. Approval rows are consumed
/// oldest-first regardless of resume_id (WIN-2241 — required so Slack/Teams/the
/// approval page, which sign random ids, keep working), so a row banked at any
/// other moment is picked up by whichever approval is reached first, silently
/// answering it with this approver's response. Unbound resume_ids are untouched.
async fn reject_mismatched_wac_approval(
tx: &mut Transaction<'_, Postgres>,
job_id: Uuid,
resume_id: u32,
) -> Result<(), Error> {
// Lock the queue row the worker also writes when it suspends on the next step,
// so the pending step read below cannot change before this transaction commits.
sqlx::query("SELECT 1 FROM v2_job_queue WHERE id = $1 FOR UPDATE")
.bind(job_id)
.fetch_optional(&mut **tx)
.await?;
let status: Option<sqlx::types::Json<WacApprovalBinding>> = sqlx::query_scalar(
"SELECT jsonb_build_object(
'minted', COALESCE(workflow_as_code_status->'_minted_approval_keys', '{}'::jsonb),
'pending', workflow_as_code_status->'_checkpoint'->'pending_steps'
) FROM v2_job_status WHERE id = $1",
)
.bind(job_id)
.fetch_optional(&mut **tx)
.await?;
let Some(sqlx::types::Json(binding)) = status else {
return Ok(());
};
let awaiting = binding.pending.as_ref().filter(|p| p.mode == "approval");
let bound_to = binding
.minted
.keys()
.find(|k| windmill_common::wac::approval_resume_id(k) == resume_id);
// A bound link is only ever valid while its own step is the one awaiting
// approval. Accepting it at any other time — including while the workflow is
// still running toward that step — leaves a row that the next approval to be
// reached consumes, whichever step that is.
match (bound_to, awaiting) {
(Some(step), pending) if !pending.is_some_and(|p| p.keys.iter().any(|k| k == step)) => {
Err(Error::BadRequest(format!(
"this approval link is bound to step `{step}`, which is not currently awaiting \
approval"
)))
}
_ => Ok(()),
}
}
#[derive(Deserialize)]
struct WacApprovalBinding {
minted: std::collections::HashMap<String, serde_json::Value>,
pending: Option<windmill_common::wac::WacPendingSteps>,
}
pub async fn get_resume_urls_internal(
Extension(db): Extension<DB>,
Path((w_id, job_id, resume_id)): Path<(String, Uuid, u32)>,
+32
View File
@@ -50,6 +50,38 @@ pub struct WacPendingSteps {
pub job_ids: serde_json::Map<String, Value>,
}
/// `resume_id` bound to a WAC `wait_for_approval` step key.
///
/// Two callers must agree on it — the worker minting the inline resume/cancel
/// buttons at suspend time, and the API signing URLs the workflow asked for
/// ahead of time — so the derivation must be stable across processes and
/// releases. `DefaultHasher` is explicitly not (std makes no cross-release
/// guarantee), hence SHA-256 truncated to the `u32` the resume routes take.
/// Distinctness per key is what matters: `resume_job`'s primary key is
/// `job_id ^ resume_id`, so two steps sharing a resume_id would collide on
/// one row.
pub fn approval_resume_id(step_key: &str) -> u32 {
use sha2::{Digest, Sha256};
let digest = Sha256::digest(step_key.as_bytes());
u32::from_be_bytes([digest[0], digest[1], digest[2], digest[3]])
}
#[cfg(test)]
mod tests {
use super::approval_resume_id;
/// Golden values: worker and API must agree on this mapping, and they can run
/// different builds during a rolling deploy. Changing it strands every resume
/// URL already in the hands of an approver, so a diff here is a deliberate
/// break, not a refactor.
#[test]
fn approval_resume_id_is_a_stable_cross_process_contract() {
assert_eq!(approval_resume_id("approval"), 0x9deb_65b8);
assert_eq!(approval_resume_id("approval_2"), 0x50d1_eeca);
assert_eq!(approval_resume_id("manager"), 0x6ee4_a469);
}
}
/// Load the WAC checkpoint from `v2_job_status.workflow_as_code_status._checkpoint`.
pub async fn load_checkpoint(db: &DB, job_id: &Uuid) -> error::Result<WacCheckpoint> {
let row: Option<Option<Value>> = sqlx::query_scalar(
+6 -9
View File
@@ -3125,15 +3125,12 @@ 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
};
// Generate resume URLs for the inline approval buttons. The resume_id
// is derived from the step key so each waitForApproval() in the same
// workflow gets a unique resume_job record, and so URLs the workflow
// minted for this step ahead of time (getApprovalUrls) address the
// same one.
let resume_id: u32 = windmill_common::wac::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)
+23 -1
View File
@@ -149,6 +149,26 @@ pub async fn prepare_checkpoint_for_resume(
// page, in-run button, Slack, Teams and resume-as-owner store a
// random id. A timed-out step matches no row -> else branch below.
let consumed = checkpoint.consumed_resume_row_ids.clone();
// A row carrying another step's bound resume_id answers that step, not
// this one, so it must never be picked up here however it got in — the
// API rejects such resumes but cannot do so atomically with the insert.
// Only keys this workflow minted a URL for are known to be bound; every
// other resume_id stays eligible, preserving WIN-2241 for the channels
// that sign random ids.
let foreign_bound_ids: Vec<i32> = sqlx::query_scalar::<_, String>(
"SELECT jsonb_object_keys(
COALESCE(workflow_as_code_status->'_minted_approval_keys', '{}'::jsonb))
FROM v2_job_status WHERE id = $1",
)
.bind(job_id)
.fetch_all(db)
.await?
.into_iter()
.filter(|k| *k != approval_key)
.map(|k| windmill_common::wac::approval_resume_id(&k) as i32)
.collect();
let resume_row = sqlx::query_as::<
_,
(
@@ -159,10 +179,12 @@ pub async fn prepare_checkpoint_for_resume(
),
>(
"SELECT id, value, approver, approved FROM resume_job \
WHERE job = $1 AND id <> ALL($2) ORDER BY created_at ASC LIMIT 1",
WHERE job = $1 AND id <> ALL($2) AND resume_id <> ALL($3) \
ORDER BY created_at ASC LIMIT 1",
)
.bind(job_id)
.bind(&consumed)
.bind(&foreign_bound_ids)
.fetch_optional(db)
.await?;
+204 -49
View File
@@ -1038,15 +1038,43 @@ workflow<T>(fn: (...args: any[]) => Promise<T>): void
/**
* Suspend the workflow and wait for an external approval.
*
* Use \`getResumeUrls()\` (wrapped in \`step()\`) to obtain resume/cancel/approvalPage
* URLs before calling this function.
* Pass \`key\` to name the step, then \`getApprovalUrls(key)\` yields the URLs that
* resume exactly this approval route them through your own channel. Without a
* key the steps are named \`approval\`, \`approval_2\`, ...
*
* @example
* const urls = await step("urls", () => getResumeUrls());
* await step("notify", () => sendEmail(urls.approvalPage));
* const { value, approver } = await waitForApproval({ timeout: 3600 });
* const urls = await step("urls", () => getApprovalUrls("manager"));
* await step("notify", () => sendEmail(urls.resume, urls.cancel));
* const { value, approver } = await waitForApproval({ key: "manager", timeout: 3600 });
*/
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; key?: string; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
/**
* Resume/cancel/approval-page URLs bound to one \`waitForApproval\` step.
*
* Unlike \`getResumeUrls()\`, which signs a random nonce, these address the very
* \`resume_job\` record the step's built-in approval buttons use, so they are
* stable across replays and safe to embed in a custom notification.
*
* \`stepKey\` must match the \`key\` given to \`waitForApproval\`. Keys must be unique
* within a workflow; reusing one throws rather than silently renaming it. The URL
* only resumes while that step is awaiting approval; used at any other moment it is
* rejected rather than banking a row a different approval would consume. Send it
* ahead of time approvers just cannot act before the workflow reaches the step.
*
* \`resume\` and \`cancel\` are step-bound; \`approvalPage\` is not — it opens the job's
* approval page, which acts on whichever approval is pending when it is used.
*
* @example
* const urls = await step("urls", () => getApprovalUrls("manager"));
* await step("notify", () => sendEmail(urls.resume, urls.cancel));
* await waitForApproval({ key: "manager" });
*/
async getApprovalUrls(stepKey: string = "approval", approver?: string): Promise<{
approvalPage: string;
resume: string;
cancel: string;
}>
/**
* Process items in parallel with optional concurrency control.
@@ -1802,15 +1830,43 @@ workflow<T>(fn: (...args: any[]) => Promise<T>): void
/**
* Suspend the workflow and wait for an external approval.
*
* Use \`getResumeUrls()\` (wrapped in \`step()\`) to obtain resume/cancel/approvalPage
* URLs before calling this function.
* Pass \`key\` to name the step, then \`getApprovalUrls(key)\` yields the URLs that
* resume exactly this approval route them through your own channel. Without a
* key the steps are named \`approval\`, \`approval_2\`, ...
*
* @example
* const urls = await step("urls", () => getResumeUrls());
* await step("notify", () => sendEmail(urls.approvalPage));
* const { value, approver } = await waitForApproval({ timeout: 3600 });
* const urls = await step("urls", () => getApprovalUrls("manager"));
* await step("notify", () => sendEmail(urls.resume, urls.cancel));
* const { value, approver } = await waitForApproval({ key: "manager", timeout: 3600 });
*/
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; key?: string; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
/**
* Resume/cancel/approval-page URLs bound to one \`waitForApproval\` step.
*
* Unlike \`getResumeUrls()\`, which signs a random nonce, these address the very
* \`resume_job\` record the step's built-in approval buttons use, so they are
* stable across replays and safe to embed in a custom notification.
*
* \`stepKey\` must match the \`key\` given to \`waitForApproval\`. Keys must be unique
* within a workflow; reusing one throws rather than silently renaming it. The URL
* only resumes while that step is awaiting approval; used at any other moment it is
* rejected rather than banking a row a different approval would consume. Send it
* ahead of time approvers just cannot act before the workflow reaches the step.
*
* \`resume\` and \`cancel\` are step-bound; \`approvalPage\` is not — it opens the job's
* approval page, which acts on whichever approval is pending when it is used.
*
* @example
* const urls = await step("urls", () => getApprovalUrls("manager"));
* await step("notify", () => sendEmail(urls.resume, urls.cancel));
* await waitForApproval({ key: "manager" });
*/
async getApprovalUrls(stepKey: string = "approval", approver?: string): Promise<{
approvalPage: string;
resume: string;
cancel: string;
}>
/**
* Process items in parallel with optional concurrency control.
@@ -2660,15 +2716,43 @@ workflow<T>(fn: (...args: any[]) => Promise<T>): void
/**
* Suspend the workflow and wait for an external approval.
*
* Use \`getResumeUrls()\` (wrapped in \`step()\`) to obtain resume/cancel/approvalPage
* URLs before calling this function.
* Pass \`key\` to name the step, then \`getApprovalUrls(key)\` yields the URLs that
* resume exactly this approval route them through your own channel. Without a
* key the steps are named \`approval\`, \`approval_2\`, ...
*
* @example
* const urls = await step("urls", () => getResumeUrls());
* await step("notify", () => sendEmail(urls.approvalPage));
* const { value, approver } = await waitForApproval({ timeout: 3600 });
* const urls = await step("urls", () => getApprovalUrls("manager"));
* await step("notify", () => sendEmail(urls.resume, urls.cancel));
* const { value, approver } = await waitForApproval({ key: "manager", timeout: 3600 });
*/
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; key?: string; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
/**
* Resume/cancel/approval-page URLs bound to one \`waitForApproval\` step.
*
* Unlike \`getResumeUrls()\`, which signs a random nonce, these address the very
* \`resume_job\` record the step's built-in approval buttons use, so they are
* stable across replays and safe to embed in a custom notification.
*
* \`stepKey\` must match the \`key\` given to \`waitForApproval\`. Keys must be unique
* within a workflow; reusing one throws rather than silently renaming it. The URL
* only resumes while that step is awaiting approval; used at any other moment it is
* rejected rather than banking a row a different approval would consume. Send it
* ahead of time approvers just cannot act before the workflow reaches the step.
*
* \`resume\` and \`cancel\` are step-bound; \`approvalPage\` is not — it opens the job's
* approval page, which acts on whichever approval is pending when it is used.
*
* @example
* const urls = await step("urls", () => getApprovalUrls("manager"));
* await step("notify", () => sendEmail(urls.resume, urls.cancel));
* await waitForApproval({ key: "manager" });
*/
async getApprovalUrls(stepKey: string = "approval", approver?: string): Promise<{
approvalPage: string;
resume: string;
cancel: string;
}>
/**
* Process items in parallel with optional concurrency control.
@@ -4254,6 +4338,17 @@ def get_shared_state(path: str = 'state.json') -> None
# Dictionary with approvalPage, resume, and cancel URLs
def get_resume_urls(approver: str = None, flow_level: bool = None) -> dict
# Get the resume URLs bound to one \`\`wait_for_approval\`\` step of this workflow.
#
# Args:
# step_key: Checkpoint key of the approval step, as passed to
# \`\`wait_for_approval(key=...)\`\`
# approver: Optional approver name
#
# Returns:
# Dictionary with approvalPage, resume, and cancel URLs
def get_approval_urls(step_key: str = 'approval', approver: str = None) -> dict
# Sends an interactive approval request via Slack, allowing optional customization of the message, approver, and form fields.
#
# **[Enterprise Edition Only]** To include form fields in the Slack approval request, use the "Advanced -> Suspend -> Form" functionality.
@@ -4530,8 +4625,9 @@ async def sleep(seconds: int)
# Suspend the workflow and wait for an external approval.
#
# Use \`\`get_resume_urls()\`\` (wrapped in \`\`step()\`\`) to obtain
# resume/cancel/approval URLs before calling this function.
# Pass \`\`key\`\` to name the step, then \`\`get_approval_urls(key)\`\` yields the URLs
# that resume exactly this approval route them through your own channel.
# Without a key the steps are named \`\`approval\`\`, \`\`approval_2\`\`, ...
#
# Returns a dict with \`\`value\`\` (form data), \`\`approver\`\`, and \`\`approved\`\`.
#
@@ -4539,13 +4635,14 @@ async def sleep(seconds: int)
# timeout: Approval timeout in seconds (default 1800).
# form: Optional form schema for the approval page.
# self_approval: Whether the user who triggered the flow can approve it (default True).
# key: Optional checkpoint key naming this approval step.
#
# Example::
#
# urls = await step("urls", lambda: get_resume_urls())
# await step("notify", lambda: send_email(urls["approvalPage"]))
# result = await wait_for_approval(timeout=3600)
async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True) -> dict
# urls = await step("urls", lambda: get_approval_urls("manager"))
# await step("notify", lambda: send_email(urls["resume"], urls["cancel"]))
# result = await wait_for_approval(key="manager", timeout=3600)
async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True, key: str | None = None) -> dict
# Process items in parallel with optional concurrency control.
#
@@ -6177,7 +6274,7 @@ import {
step,
sleep,
waitForApproval,
getResumeUrls,
getApprovalUrls,
parallel,
workflow,
} from "windmill-client";
@@ -6195,7 +6292,7 @@ export const main = workflow(async (x: string) => {
Python:
\`\`\`python
from wmill import task, task_script, task_flow, step, sleep, wait_for_approval, get_resume_urls, parallel, workflow
from wmill import task, task_script, task_flow, step, sleep, wait_for_approval, get_approval_urls, parallel, workflow
@task()
async def process(x: str) -> str:
@@ -6279,12 +6376,13 @@ output = await pipeline(input=data)
Use \`step()\` for lightweight inline values that must not change during replay:
\`\`\`typescript
const urls = await step("get_urls", () => getResumeUrls());
const startedAt = await step("started_at", () => new Date().toISOString());
\`\`\`
\`\`\`python
urls = await step("get_urls", lambda: get_resume_urls())
from datetime import datetime
started_at = await step("started_at", lambda: datetime.now().isoformat())
\`\`\`
Use stable, descriptive step names. Do not generate step names dynamically.
@@ -6309,20 +6407,28 @@ Only parallelize independent steps. Do not read the result of a task before it i
## Approvals
Generate resume URLs inside \`step()\` before sending them:
Name the approval step and generate its URLs inside \`step()\` before sending them.
\`getApprovalUrls\` / \`get_approval_urls\` returns the URLs bound to that step, the same
ones its built-in approve/reject buttons use:
\`\`\`typescript
const urls = await step("get_urls", () => getResumeUrls());
await step("notify", () => sendApprovalEmail(urls.approvalPage));
const approval = await waitForApproval({ timeout: 3600 });
const urls = await step("urls", () => getApprovalUrls("manager"));
await step("notify", () => sendApprovalEmail(urls.resume, urls.cancel));
const approval = await waitForApproval({ key: "manager", timeout: 3600 });
\`\`\`
\`\`\`python
urls = await step("get_urls", lambda: get_resume_urls())
await step("notify", lambda: send_approval_email(urls["approvalPage"]))
approval = await wait_for_approval(timeout=3600)
urls = await step("urls", lambda: get_approval_urls("manager"))
await step("notify", lambda: send_approval_email(urls["resume"], urls["cancel"]))
approval = await wait_for_approval(key="manager", timeout=3600)
\`\`\`
With several approvals in one workflow, give each its own key so each notification
resumes its own step. Keys must be unique reusing one raises an error rather than
silently renaming the step. A minted URL only resumes while its own step is awaiting
approval; used at any other moment it is rejected rather than resuming the wrong one. \`getResumeUrls()\` / \`get_resume_urls()\` still works but signs a
random nonce, so its URLs are not tied to any particular approval step.
\`selfApproval: false\` and \`self_approval=False\` are Enterprise-only approval behavior. Do not use them unless the user asks for that behavior.
## Error Handling
@@ -6336,7 +6442,7 @@ TypeScript: avoid broad \`try/catch\` around WAC SDK calls. The SDK uses an inte
## TypeScript Workflow-as-Code API (windmill-client)
Import: \`import { workflow, task, taskScript, taskFlow, step, sleep, waitForApproval, getResumeUrls, parallel } from "windmill-client"\`
Import: \`import { workflow, task, taskScript, taskFlow, step, sleep, waitForApproval, getApprovalUrls, getResumeUrls, parallel } from "windmill-client"\`
\`\`\`typescript
export interface TaskOptions {
@@ -6406,15 +6512,39 @@ export async function sleep(seconds: number): Promise<void>
/**
* Suspend the workflow and wait for an external approval.
*
* Use \`getResumeUrls()\` (wrapped in \`step()\`) to obtain resume/cancel/approvalPage
* URLs before calling this function.
* Pass \`key\` to name the step, then \`getApprovalUrls(key)\` yields the URLs that
* resume exactly this approval route them through your own channel. Without a
* key the steps are named \`approval\`, \`approval_2\`, ...
*
* @example
* const urls = await step("urls", () => getResumeUrls());
* await step("notify", () => sendEmail(urls.approvalPage));
* const { value, approver } = await waitForApproval({ timeout: 3600 });
* const urls = await step("urls", () => getApprovalUrls("manager"));
* await step("notify", () => sendEmail(urls.resume, urls.cancel));
* const { value, approver } = await waitForApproval({ key: "manager", timeout: 3600 });
*/
export function waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
export function waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; key?: string; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
/**
* Resume/cancel/approval-page URLs bound to one \`waitForApproval\` step.
*
* Unlike \`getResumeUrls()\`, which signs a random nonce, these address the very
* \`resume_job\` record the step's built-in approval buttons use, so they are
* stable across replays and safe to embed in a custom notification.
*
* \`stepKey\` must match the \`key\` given to \`waitForApproval\`. Keys must be unique
* within a workflow; reusing one throws rather than silently renaming it. The URL
* only resumes while that step is awaiting approval; used at any other moment it is
* rejected rather than banking a row a different approval would consume. Send it
* ahead of time approvers just cannot act before the workflow reaches the step.
*
* \`resume\` and \`cancel\` are step-bound; \`approvalPage\` is not — it opens the job's
* approval page, which acts on whichever approval is pending when it is used.
*
* @example
* const urls = await step("urls", () => getApprovalUrls("manager"));
* await step("notify", () => sendEmail(urls.resume, urls.cancel));
* await waitForApproval({ key: "manager" });
*/
export async function getApprovalUrls(stepKey: string = "approval", approver?: string): Promise<{ approvalPage: string; resume: string; cancel: string; }>
/**
* Process items in parallel with optional concurrency control.
@@ -6432,7 +6562,7 @@ export async function parallel<T, R>(items: T[], fn: (item: T) => PromiseLike<R>
## Python Workflow-as-Code API (wmill)
Import: \`from wmill import workflow, task, task_script, task_flow, step, sleep, wait_for_approval, get_resume_urls, parallel, TaskError\`
Import: \`from wmill import workflow, task, task_script, task_flow, step, sleep, wait_for_approval, get_approval_urls, get_resume_urls, parallel, TaskError\`
\`\`\`python
# Raised when a WAC task step failed.
@@ -6520,8 +6650,9 @@ async def sleep(seconds: int)
# Suspend the workflow and wait for an external approval.
#
# Use \`\`get_resume_urls()\`\` (wrapped in \`\`step()\`\`) to obtain
# resume/cancel/approval URLs before calling this function.
# Pass \`\`key\`\` to name the step, then \`\`get_approval_urls(key)\`\` yields the URLs
# that resume exactly this approval route them through your own channel.
# Without a key the steps are named \`\`approval\`\`, \`\`approval_2\`\`, ...
#
# Returns a dict with \`\`value\`\` (form data), \`\`approver\`\`, and \`\`approved\`\`.
#
@@ -6529,13 +6660,37 @@ async def sleep(seconds: int)
# timeout: Approval timeout in seconds (default 1800).
# form: Optional form schema for the approval page.
# self_approval: Whether the user who triggered the flow can approve it (default True).
# key: Optional checkpoint key naming this approval step.
#
# Example::
#
# urls = await step("urls", lambda: get_resume_urls())
# await step("notify", lambda: send_email(urls["approvalPage"]))
# result = await wait_for_approval(timeout=3600)
async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True) -> dict
# urls = await step("urls", lambda: get_approval_urls("manager"))
# await step("notify", lambda: send_email(urls["resume"], urls["cancel"]))
# result = await wait_for_approval(key="manager", timeout=3600)
async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True, key: str | None = None) -> dict
# Get the resume/cancel/approval-page URLs bound to one \`\`wait_for_approval\`\` step.
#
# Unlike :func:\`get_resume_urls\`, which signs a random nonce, these address the
# very \`\`resume_job\`\` record the step's built-in approval buttons use, so they
# are stable across replays and safe to embed in a custom notification.
#
# Args:
# step_key: Checkpoint key of the approval step, as passed to
# \`\`wait_for_approval(key=...)\`\`. Keys must be unique within a workflow;
# reusing one raises rather than silently renaming it. The URL only
# resumes while that step is awaiting approval; used at any other moment
# it is rejected rather than banking a row a different approval would
# consume. Send it ahead of time approvers just cannot act before the
# workflow reaches the step.
# \`\`resume\`\` and \`\`cancel\`\` are step-bound; \`\`approvalPage\`\` is not — it
# opens the job's approval page, which acts on whichever approval is
# pending when it is used.
# approver: Optional approver name
#
# Returns:
# Dictionary with approvalPage, resume, and cancel URLs
def get_approval_urls(step_key: str = 'approval', approver: str = None) -> dict
# Process items in parallel with optional concurrency control.
#
+56 -1
View File
@@ -3,7 +3,7 @@
import asyncio
import pytest
from wmill.client import WorkflowCtx, _StepSuspend, TaskError, workflow, task, step, sleep, parallel, _run_workflow
from wmill.client import WorkflowCtx, _StepSuspend, TaskError, workflow, task, step, sleep, parallel, wait_for_approval, _run_workflow
@task
@@ -1112,3 +1112,58 @@ class TestParallel:
r = _run_workflow(wf, {}, {})
assert r["type"] == "complete"
assert r["result"] == []
class TestApprovalKeys:
"""`key` names the step that get_approval_urls() mints URLs against, so a
duplicate must fail rather than silently become `<key>_2` and leave the
caller holding a URL for the earlier step."""
def test_explicit_key_is_used_verbatim(self):
@workflow
async def wf():
return await wait_for_approval(key="manager")
assert _run_workflow(wf, {}, {})["key"] == "manager"
def test_duplicate_explicit_key_raises(self):
@workflow
async def wf():
await wait_for_approval(key="manager")
await wait_for_approval(key="manager")
with pytest.raises(RuntimeError, match="already used"):
_run_workflow(wf, {"completed_steps": {"manager": {"approved": True}}}, {})
def test_explicit_key_colliding_with_a_suffixed_step_key_raises(self):
"""`step("dup")` twice yields `dup`/`dup_2`, so an approval explicitly named
`dup_2` would alias the second step's key."""
@workflow
async def wf():
await step("dup", lambda: 1)
await step("dup", lambda: 2)
await wait_for_approval(key="dup_2")
with pytest.raises(RuntimeError, match="already used"):
_run_workflow(wf, {"completed_steps": {"dup": 1, "dup_2": 2}}, {})
@pytest.mark.parametrize("bad", ["", " ", ".", "..", "a/b"])
def test_unusable_key_raises(self, bad):
"""The key travels as one path segment when its URLs are minted, so anything
`get_approval_urls` could not address must be refused here too."""
@workflow
async def wf():
await wait_for_approval(key=bad)
with pytest.raises(RuntimeError, match="non-empty step name"):
_run_workflow(wf, {}, {})
def test_unnamed_approvals_still_auto_number(self):
@workflow
async def wf():
await wait_for_approval()
await wait_for_approval()
assert _run_workflow(wf, {"completed_steps": {"approval": {}}}, {})["key"] == "approval_2"
+102 -10
View File
@@ -1274,6 +1274,29 @@ class Windmill:
params=params,
).json()
def get_approval_urls(self, step_key: str = "approval", approver: str = None) -> dict:
"""Get the resume URLs bound to one ``wait_for_approval`` step of this workflow.
Args:
step_key: Checkpoint key of the approval step, as passed to
``wait_for_approval(key=...)``
approver: Optional approver name
Returns:
Dictionary with approvalPage, resume, and cancel URLs
"""
from urllib.parse import quote
_assert_usable_step_key(step_key, "get_approval_urls step_key")
job_id = os.environ.get("WM_JOB_ID") or "NO_ID"
# Omit rather than send `approver=`: an empty value is echoed into the
# returned URLs and recorded as the approver instead of "anonymous".
params = {"approver": approver} if approver is not None else {}
return self.get(
f"/w/{self.workspace}/jobs/wac_approval_urls/{job_id}/{quote(step_key, safe='')}",
params=params,
).json()
def request_interactive_slack_approval(
self,
slack_resource_path: str,
@@ -2027,6 +2050,33 @@ def get_resume_urls(approver: str = None, flow_level: bool = None) -> dict:
return _client.get_resume_urls(approver, flow_level)
@init_global_client
def get_approval_urls(step_key: str = "approval", approver: str = None) -> dict:
"""Get the resume/cancel/approval-page URLs bound to one ``wait_for_approval`` step.
Unlike :func:`get_resume_urls`, which signs a random nonce, these address the
very ``resume_job`` record the step's built-in approval buttons use, so they
are stable across replays and safe to embed in a custom notification.
Args:
step_key: Checkpoint key of the approval step, as passed to
``wait_for_approval(key=...)``. Keys must be unique within a workflow;
reusing one raises rather than silently renaming it. The URL only
resumes while that step is awaiting approval; used at any other moment
it is rejected rather than banking a row a different approval would
consume. Send it ahead of time — approvers just cannot act before the
workflow reaches the step.
``resume`` and ``cancel`` are step-bound; ``approvalPage`` is not — it
opens the job's approval page, which acts on whichever approval is
pending when it is used.
approver: Optional approver name
Returns:
Dictionary with approvalPage, resume, and cancel URLs
"""
return _client.get_approval_urls(step_key, approver)
@init_global_client
def request_interactive_slack_approval(
slack_resource_path: str,
@@ -2606,6 +2656,15 @@ import asyncio as _asyncio
import contextvars as _contextvars
def _assert_usable_step_key(key: str, what: str) -> None:
"""A step key travels as one path segment when its URLs are minted, so it must be
non-empty and free of ``/`` and dot segments — otherwise ``wait_for_approval``
would accept a key ``get_approval_urls`` can never address."""
k = key.strip()
if not k or k in (".", "..") or "/" in key or "\\" in key:
raise RuntimeError(f"{what} must be a non-empty step name without `/` or dot segments")
class _StepSuspend(BaseException):
"""Raised to suspend workflow execution. Inherits from BaseException
so it is not caught by bare `except Exception:` blocks."""
@@ -2645,6 +2704,8 @@ class WorkflowCtx:
checkpoint = checkpoint or {}
self._completed: dict = checkpoint.get("completed_steps", {})
self._counters: dict[str, int] = {}
# Every key handed out by _alloc_key, so distinct names can't alias one key.
self._used_keys: set[str] = set()
self._pending: list = []
self._executing_key: str | None = checkpoint.get("_executing_key")
# Reuse a single httpx.AsyncClient across all fast-path step() calls
@@ -2666,10 +2727,20 @@ class WorkflowCtx:
self._inline_lock: "_asyncio.Lock | None" = None
def _alloc_key(self, name: str = "step") -> str:
"""Name-based key: ``double`` for first call, ``double_2``, ``double_3`` for subsequent."""
"""Name-based key: ``double`` for first call, ``double_2``, ``double_3`` for subsequent.
Suffixing alone can alias — a second ``step("x")`` and a first ``step("x_2")``
both want ``x_2`` — so keep bumping past keys already handed out. Allocation
order is fixed by the workflow body, so replays reproduce the same keys.
"""
n = self._counters.get(name, 0) + 1
key = name if n == 1 else f"{name}_{n}"
while key in self._used_keys:
n += 1
key = f"{name}_{n}"
self._counters[name] = n
return name if n == 1 else f"{name}_{n}"
self._used_keys.add(key)
return key
def _next_step(self, name: str, script: str, func=None, dispatch_type: str = "inline", _task_options: Optional[dict] = None, **kwargs):
"""Return an awaitable that either resolves from cache or suspends."""
@@ -2724,9 +2795,25 @@ class WorkflowCtx:
)
async def _wait_for_approval(
self, timeout: int = 1800, form: dict | None = None, self_approval: bool = True
self,
timeout: int = 1800,
form: dict | None = None,
self_approval: bool = True,
key: str | None = None,
):
key = self._alloc_key("approval")
if key is not None:
_assert_usable_step_key(key, "wait_for_approval key")
requested_key, key = key, self._alloc_key(key or "approval")
# An explicit key is an identifier callers mint URLs against, so silently
# renaming a duplicate to ``<key>_2`` would hand them a URL for the *first*
# step — which then fails with "resume request already sent" and parks the
# workflow until timeout. Unnamed approvals keep auto-numbering.
if requested_key and key != requested_key:
raise RuntimeError(
f'WAC step key "{requested_key}" is already used in this workflow. '
"Give each wait_for_approval() its own key so get_approval_urls() can address it."
)
if key in self._completed:
return self._completed[key]
@@ -3073,11 +3160,13 @@ async def wait_for_approval(
timeout: int = 1800,
form: dict | None = None,
self_approval: bool = True,
key: str | None = None,
) -> dict:
"""Suspend the workflow and wait for an external approval.
Use ``get_resume_urls()`` (wrapped in ``step()``) to obtain
resume/cancel/approval URLs before calling this function.
Pass ``key`` to name the step, then ``get_approval_urls(key)`` yields the URLs
that resume exactly this approval — route them through your own channel.
Without a key the steps are named ``approval``, ``approval_2``, ...
Returns a dict with ``value`` (form data), ``approver``, and ``approved``.
@@ -3085,16 +3174,19 @@ async def wait_for_approval(
timeout: Approval timeout in seconds (default 1800).
form: Optional form schema for the approval page.
self_approval: Whether the user who triggered the flow can approve it (default True).
key: Optional checkpoint key naming this approval step.
Example::
urls = await step("urls", lambda: get_resume_urls())
await step("notify", lambda: send_email(urls["approvalPage"]))
result = await wait_for_approval(timeout=3600)
urls = await step("urls", lambda: get_approval_urls("manager"))
await step("notify", lambda: send_email(urls["resume"], urls["cancel"]))
result = await wait_for_approval(key="manager", timeout=3600)
"""
ctx: WorkflowCtx | None = _workflow_ctx.get(None)
if ctx is not None:
return await ctx._wait_for_approval(timeout=timeout, form=form, self_approval=self_approval)
return await ctx._wait_for_approval(
timeout=timeout, form=form, self_approval=self_approval, key=key
)
raise RuntimeError("wait_for_approval can only be called inside a @workflow")
+136 -37
View File
@@ -874,7 +874,7 @@ import {
step,
sleep,
waitForApproval,
getResumeUrls,
getApprovalUrls,
parallel,
workflow,
} from "windmill-client";
@@ -892,7 +892,7 @@ export const main = workflow(async (x: string) => {
Python:
\`\`\`python
from wmill import task, task_script, task_flow, step, sleep, wait_for_approval, get_resume_urls, parallel, workflow
from wmill import task, task_script, task_flow, step, sleep, wait_for_approval, get_approval_urls, parallel, workflow
@task()
async def process(x: str) -> str:
@@ -976,12 +976,13 @@ output = await pipeline(input=data)
Use \`step()\` for lightweight inline values that must not change during replay:
\`\`\`typescript
const urls = await step("get_urls", () => getResumeUrls());
const startedAt = await step("started_at", () => new Date().toISOString());
\`\`\`
\`\`\`python
urls = await step("get_urls", lambda: get_resume_urls())
from datetime import datetime
started_at = await step("started_at", lambda: datetime.now().isoformat())
\`\`\`
Use stable, descriptive step names. Do not generate step names dynamically.
@@ -1006,20 +1007,28 @@ Only parallelize independent steps. Do not read the result of a task before it i
## Approvals
Generate resume URLs inside \`step()\` before sending them:
Name the approval step and generate its URLs inside \`step()\` before sending them.
\`getApprovalUrls\` / \`get_approval_urls\` returns the URLs bound to that step, the same
ones its built-in approve/reject buttons use:
\`\`\`typescript
const urls = await step("get_urls", () => getResumeUrls());
await step("notify", () => sendApprovalEmail(urls.approvalPage));
const approval = await waitForApproval({ timeout: 3600 });
const urls = await step("urls", () => getApprovalUrls("manager"));
await step("notify", () => sendApprovalEmail(urls.resume, urls.cancel));
const approval = await waitForApproval({ key: "manager", timeout: 3600 });
\`\`\`
\`\`\`python
urls = await step("get_urls", lambda: get_resume_urls())
await step("notify", lambda: send_approval_email(urls["approvalPage"]))
approval = await wait_for_approval(timeout=3600)
urls = await step("urls", lambda: get_approval_urls("manager"))
await step("notify", lambda: send_approval_email(urls["resume"], urls["cancel"]))
approval = await wait_for_approval(key="manager", timeout=3600)
\`\`\`
With several approvals in one workflow, give each its own key so each notification
resumes its own step. Keys must be unique — reusing one raises an error rather than
silently renaming the step. A minted URL only resumes while its own step is awaiting
approval; used at any other moment it is rejected rather than resuming the wrong one. \`getResumeUrls()\` / \`get_resume_urls()\` still works but signs a
random nonce, so its URLs are not tied to any particular approval step.
\`selfApproval: false\` and \`self_approval=False\` are Enterprise-only approval behavior. Do not use them unless the user asks for that behavior.
## Error Handling
@@ -1566,15 +1575,43 @@ workflow<T>(fn: (...args: any[]) => Promise<T>): void
/**
* Suspend the workflow and wait for an external approval.
*
* Use \`getResumeUrls()\` (wrapped in \`step()\`) to obtain resume/cancel/approvalPage
* URLs before calling this function.
* Pass \`key\` to name the step, then \`getApprovalUrls(key)\` yields the URLs that
* resume exactly this approval — route them through your own channel. Without a
* key the steps are named \`approval\`, \`approval_2\`, ...
*
* @example
* const urls = await step("urls", () => getResumeUrls());
* await step("notify", () => sendEmail(urls.approvalPage));
* const { value, approver } = await waitForApproval({ timeout: 3600 });
* const urls = await step("urls", () => getApprovalUrls("manager"));
* await step("notify", () => sendEmail(urls.resume, urls.cancel));
* const { value, approver } = await waitForApproval({ key: "manager", timeout: 3600 });
*/
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; key?: string; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
/**
* Resume/cancel/approval-page URLs bound to one \`waitForApproval\` step.
*
* Unlike \`getResumeUrls()\`, which signs a random nonce, these address the very
* \`resume_job\` record the step's built-in approval buttons use, so they are
* stable across replays and safe to embed in a custom notification.
*
* \`stepKey\` must match the \`key\` given to \`waitForApproval\`. Keys must be unique
* within a workflow; reusing one throws rather than silently renaming it. The URL
* only resumes while that step is awaiting approval; used at any other moment it is
* rejected rather than banking a row a different approval would consume. Send it
* ahead of time — approvers just cannot act before the workflow reaches the step.
*
* \`resume\` and \`cancel\` are step-bound; \`approvalPage\` is not — it opens the job's
* approval page, which acts on whichever approval is pending when it is used.
*
* @example
* const urls = await step("urls", () => getApprovalUrls("manager"));
* await step("notify", () => sendEmail(urls.resume, urls.cancel));
* await waitForApproval({ key: "manager" });
*/
async getApprovalUrls(stepKey: string = "approval", approver?: string): Promise<{
approvalPage: string;
resume: string;
cancel: string;
}>
/**
* Process items in parallel with optional concurrency control.
@@ -2066,6 +2103,17 @@ def get_shared_state(path: str = 'state.json') -> None
# Dictionary with approvalPage, resume, and cancel URLs
def get_resume_urls(approver: str = None, flow_level: bool = None) -> dict
# Get the resume URLs bound to one \`\`wait_for_approval\`\` step of this workflow.
#
# Args:
# step_key: Checkpoint key of the approval step, as passed to
# \`\`wait_for_approval(key=...)\`\`
# approver: Optional approver name
#
# Returns:
# Dictionary with approvalPage, resume, and cancel URLs
def get_approval_urls(step_key: str = 'approval', approver: str = None) -> dict
# Sends an interactive approval request via Slack, allowing optional customization of the message, approver, and form fields.
#
# **[Enterprise Edition Only]** To include form fields in the Slack approval request, use the "Advanced -> Suspend -> Form" functionality.
@@ -2342,8 +2390,9 @@ async def sleep(seconds: int)
# Suspend the workflow and wait for an external approval.
#
# Use \`\`get_resume_urls()\`\` (wrapped in \`\`step()\`\`) to obtain
# resume/cancel/approval URLs before calling this function.
# Pass \`\`key\`\` to name the step, then \`\`get_approval_urls(key)\`\` yields the URLs
# that resume exactly this approval — route them through your own channel.
# Without a key the steps are named \`\`approval\`\`, \`\`approval_2\`\`, ...
#
# Returns a dict with \`\`value\`\` (form data), \`\`approver\`\`, and \`\`approved\`\`.
#
@@ -2351,13 +2400,14 @@ async def sleep(seconds: int)
# timeout: Approval timeout in seconds (default 1800).
# form: Optional form schema for the approval page.
# self_approval: Whether the user who triggered the flow can approve it (default True).
# key: Optional checkpoint key naming this approval step.
#
# Example::
#
# urls = await step("urls", lambda: get_resume_urls())
# await step("notify", lambda: send_email(urls["approvalPage"]))
# result = await wait_for_approval(timeout=3600)
async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True) -> dict
# urls = await step("urls", lambda: get_approval_urls("manager"))
# await step("notify", lambda: send_email(urls["resume"], urls["cancel"]))
# result = await wait_for_approval(key="manager", timeout=3600)
async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True, key: str | None = None) -> dict
# Process items in parallel with optional concurrency control.
#
@@ -2386,7 +2436,7 @@ def commit_kafka_offsets(trigger_path: str, topic: str, partition: int, offset:
export const WAC_SDK_TYPESCRIPT = `## TypeScript Workflow-as-Code API (windmill-client)
Import: \`import { workflow, task, taskScript, taskFlow, step, sleep, waitForApproval, getResumeUrls, parallel } from "windmill-client"\`
Import: \`import { workflow, task, taskScript, taskFlow, step, sleep, waitForApproval, getApprovalUrls, getResumeUrls, parallel } from "windmill-client"\`
\`\`\`typescript
export interface TaskOptions {
@@ -2456,15 +2506,39 @@ export async function sleep(seconds: number): Promise<void>
/**
* Suspend the workflow and wait for an external approval.
*
* Use \`getResumeUrls()\` (wrapped in \`step()\`) to obtain resume/cancel/approvalPage
* URLs before calling this function.
* Pass \`key\` to name the step, then \`getApprovalUrls(key)\` yields the URLs that
* resume exactly this approval — route them through your own channel. Without a
* key the steps are named \`approval\`, \`approval_2\`, ...
*
* @example
* const urls = await step("urls", () => getResumeUrls());
* await step("notify", () => sendEmail(urls.approvalPage));
* const { value, approver } = await waitForApproval({ timeout: 3600 });
* const urls = await step("urls", () => getApprovalUrls("manager"));
* await step("notify", () => sendEmail(urls.resume, urls.cancel));
* const { value, approver } = await waitForApproval({ key: "manager", timeout: 3600 });
*/
export function waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
export function waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; key?: string; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
/**
* Resume/cancel/approval-page URLs bound to one \`waitForApproval\` step.
*
* Unlike \`getResumeUrls()\`, which signs a random nonce, these address the very
* \`resume_job\` record the step's built-in approval buttons use, so they are
* stable across replays and safe to embed in a custom notification.
*
* \`stepKey\` must match the \`key\` given to \`waitForApproval\`. Keys must be unique
* within a workflow; reusing one throws rather than silently renaming it. The URL
* only resumes while that step is awaiting approval; used at any other moment it is
* rejected rather than banking a row a different approval would consume. Send it
* ahead of time — approvers just cannot act before the workflow reaches the step.
*
* \`resume\` and \`cancel\` are step-bound; \`approvalPage\` is not — it opens the job's
* approval page, which acts on whichever approval is pending when it is used.
*
* @example
* const urls = await step("urls", () => getApprovalUrls("manager"));
* await step("notify", () => sendEmail(urls.resume, urls.cancel));
* await waitForApproval({ key: "manager" });
*/
export async function getApprovalUrls(stepKey: string = "approval", approver?: string): Promise<{ approvalPage: string; resume: string; cancel: string; }>
/**
* Process items in parallel with optional concurrency control.
@@ -2482,7 +2556,7 @@ export async function parallel<T, R>(items: T[], fn: (item: T) => PromiseLike<R>
export const WAC_SDK_PYTHON = `## Python Workflow-as-Code API (wmill)
Import: \`from wmill import workflow, task, task_script, task_flow, step, sleep, wait_for_approval, get_resume_urls, parallel, TaskError\`
Import: \`from wmill import workflow, task, task_script, task_flow, step, sleep, wait_for_approval, get_approval_urls, get_resume_urls, parallel, TaskError\`
\`\`\`python
# Raised when a WAC task step failed.
@@ -2570,8 +2644,9 @@ async def sleep(seconds: int)
# Suspend the workflow and wait for an external approval.
#
# Use \`\`get_resume_urls()\`\` (wrapped in \`\`step()\`\`) to obtain
# resume/cancel/approval URLs before calling this function.
# Pass \`\`key\`\` to name the step, then \`\`get_approval_urls(key)\`\` yields the URLs
# that resume exactly this approval — route them through your own channel.
# Without a key the steps are named \`\`approval\`\`, \`\`approval_2\`\`, ...
#
# Returns a dict with \`\`value\`\` (form data), \`\`approver\`\`, and \`\`approved\`\`.
#
@@ -2579,13 +2654,37 @@ async def sleep(seconds: int)
# timeout: Approval timeout in seconds (default 1800).
# form: Optional form schema for the approval page.
# self_approval: Whether the user who triggered the flow can approve it (default True).
# key: Optional checkpoint key naming this approval step.
#
# Example::
#
# urls = await step("urls", lambda: get_resume_urls())
# await step("notify", lambda: send_email(urls["approvalPage"]))
# result = await wait_for_approval(timeout=3600)
async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True) -> dict
# urls = await step("urls", lambda: get_approval_urls("manager"))
# await step("notify", lambda: send_email(urls["resume"], urls["cancel"]))
# result = await wait_for_approval(key="manager", timeout=3600)
async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True, key: str | None = None) -> dict
# Get the resume/cancel/approval-page URLs bound to one \`\`wait_for_approval\`\` step.
#
# Unlike :func:\`get_resume_urls\`, which signs a random nonce, these address the
# very \`\`resume_job\`\` record the step's built-in approval buttons use, so they
# are stable across replays and safe to embed in a custom notification.
#
# Args:
# step_key: Checkpoint key of the approval step, as passed to
# \`\`wait_for_approval(key=...)\`\`. Keys must be unique within a workflow;
# reusing one raises rather than silently renaming it. The URL only
# resumes while that step is awaiting approval; used at any other moment
# it is rejected rather than banking a row a different approval would
# consume. Send it ahead of time — approvers just cannot act before the
# workflow reaches the step.
# \`\`resume\`\` and \`\`cancel\`\` are step-bound; \`\`approvalPage\`\` is not — it
# opens the job's approval page, which acts on whichever approval is
# pending when it is used.
# approver: Optional approver name
#
# Returns:
# Dictionary with approvalPage, resume, and cancel URLs
def get_approval_urls(step_key: str = 'approval', approver: str = None) -> dict
# Process items in parallel with optional concurrency control.
#
+53 -12
View File
@@ -1928,15 +1928,43 @@ workflow<T>(fn: (...args: any[]) => Promise<T>): void
/**
* Suspend the workflow and wait for an external approval.
*
* Use `getResumeUrls()` (wrapped in `step()`) to obtain resume/cancel/approvalPage
* URLs before calling this function.
* Pass `key` to name the step, then `getApprovalUrls(key)` yields the URLs that
* resume exactly this approval — route them through your own channel. Without a
* key the steps are named `approval`, `approval_2`, ...
*
* @example
* const urls = await step("urls", () => getResumeUrls());
* await step("notify", () => sendEmail(urls.approvalPage));
* const { value, approver } = await waitForApproval({ timeout: 3600 });
* const urls = await step("urls", () => getApprovalUrls("manager"));
* await step("notify", () => sendEmail(urls.resume, urls.cancel));
* const { value, approver } = await waitForApproval({ key: "manager", timeout: 3600 });
*/
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; key?: string; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
/**
* Resume/cancel/approval-page URLs bound to one `waitForApproval` step.
*
* Unlike `getResumeUrls()`, which signs a random nonce, these address the very
* `resume_job` record the step's built-in approval buttons use, so they are
* stable across replays and safe to embed in a custom notification.
*
* `stepKey` must match the `key` given to `waitForApproval`. Keys must be unique
* within a workflow; reusing one throws rather than silently renaming it. The URL
* only resumes while that step is awaiting approval; used at any other moment it is
* rejected rather than banking a row a different approval would consume. Send it
* ahead of time — approvers just cannot act before the workflow reaches the step.
*
* `resume` and `cancel` are step-bound; `approvalPage` is not — it opens the job's
* approval page, which acts on whichever approval is pending when it is used.
*
* @example
* const urls = await step("urls", () => getApprovalUrls("manager"));
* await step("notify", () => sendEmail(urls.resume, urls.cancel));
* await waitForApproval({ key: "manager" });
*/
async getApprovalUrls(stepKey: string = "approval", approver?: string): Promise<{
approvalPage: string;
resume: string;
cancel: string;
}>
/**
* Process items in parallel with optional concurrency control.
@@ -2428,6 +2456,17 @@ def get_shared_state(path: str = 'state.json') -> None
# Dictionary with approvalPage, resume, and cancel URLs
def get_resume_urls(approver: str = None, flow_level: bool = None) -> dict
# Get the resume URLs bound to one ``wait_for_approval`` step of this workflow.
#
# Args:
# step_key: Checkpoint key of the approval step, as passed to
# ``wait_for_approval(key=...)``
# approver: Optional approver name
#
# Returns:
# Dictionary with approvalPage, resume, and cancel URLs
def get_approval_urls(step_key: str = 'approval', approver: str = None) -> dict
# Sends an interactive approval request via Slack, allowing optional customization of the message, approver, and form fields.
#
# **[Enterprise Edition Only]** To include form fields in the Slack approval request, use the "Advanced -> Suspend -> Form" functionality.
@@ -2704,8 +2743,9 @@ async def sleep(seconds: int)
# Suspend the workflow and wait for an external approval.
#
# Use ``get_resume_urls()`` (wrapped in ``step()``) to obtain
# resume/cancel/approval URLs before calling this function.
# Pass ``key`` to name the step, then ``get_approval_urls(key)`` yields the URLs
# that resume exactly this approval — route them through your own channel.
# Without a key the steps are named ``approval``, ``approval_2``, ...
#
# Returns a dict with ``value`` (form data), ``approver``, and ``approved``.
#
@@ -2713,13 +2753,14 @@ async def sleep(seconds: int)
# timeout: Approval timeout in seconds (default 1800).
# form: Optional form schema for the approval page.
# self_approval: Whether the user who triggered the flow can approve it (default True).
# key: Optional checkpoint key naming this approval step.
#
# Example::
#
# urls = await step("urls", lambda: get_resume_urls())
# await step("notify", lambda: send_email(urls["approvalPage"]))
# result = await wait_for_approval(timeout=3600)
async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True) -> dict
# urls = await step("urls", lambda: get_approval_urls("manager"))
# await step("notify", lambda: send_email(urls["resume"], urls["cancel"]))
# result = await wait_for_approval(key="manager", timeout=3600)
async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True, key: str | None = None) -> dict
# Process items in parallel with optional concurrency control.
#
+19 -6
View File
@@ -399,6 +399,17 @@ def get_shared_state(path: str = 'state.json') -> None
# Dictionary with approvalPage, resume, and cancel URLs
def get_resume_urls(approver: str = None, flow_level: bool = None) -> dict
# Get the resume URLs bound to one ``wait_for_approval`` step of this workflow.
#
# Args:
# step_key: Checkpoint key of the approval step, as passed to
# ``wait_for_approval(key=...)``
# approver: Optional approver name
#
# Returns:
# Dictionary with approvalPage, resume, and cancel URLs
def get_approval_urls(step_key: str = 'approval', approver: str = None) -> dict
# Sends an interactive approval request via Slack, allowing optional customization of the message, approver, and form fields.
#
# **[Enterprise Edition Only]** To include form fields in the Slack approval request, use the "Advanced -> Suspend -> Form" functionality.
@@ -675,8 +686,9 @@ async def sleep(seconds: int)
# Suspend the workflow and wait for an external approval.
#
# Use ``get_resume_urls()`` (wrapped in ``step()``) to obtain
# resume/cancel/approval URLs before calling this function.
# Pass ``key`` to name the step, then ``get_approval_urls(key)`` yields the URLs
# that resume exactly this approval — route them through your own channel.
# Without a key the steps are named ``approval``, ``approval_2``, ...
#
# Returns a dict with ``value`` (form data), ``approver``, and ``approved``.
#
@@ -684,13 +696,14 @@ async def sleep(seconds: int)
# timeout: Approval timeout in seconds (default 1800).
# form: Optional form schema for the approval page.
# self_approval: Whether the user who triggered the flow can approve it (default True).
# key: Optional checkpoint key naming this approval step.
#
# Example::
#
# urls = await step("urls", lambda: get_resume_urls())
# await step("notify", lambda: send_email(urls["approvalPage"]))
# result = await wait_for_approval(timeout=3600)
async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True) -> dict
# urls = await step("urls", lambda: get_approval_urls("manager"))
# await step("notify", lambda: send_email(urls["resume"], urls["cancel"]))
# result = await wait_for_approval(key="manager", timeout=3600)
async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True, key: str | None = None) -> dict
# Process items in parallel with optional concurrency control.
#
@@ -493,15 +493,43 @@ workflow<T>(fn: (...args: any[]) => Promise<T>): void
/**
* Suspend the workflow and wait for an external approval.
*
* Use `getResumeUrls()` (wrapped in `step()`) to obtain resume/cancel/approvalPage
* URLs before calling this function.
* Pass `key` to name the step, then `getApprovalUrls(key)` yields the URLs that
* resume exactly this approval — route them through your own channel. Without a
* key the steps are named `approval`, `approval_2`, ...
*
* @example
* const urls = await step("urls", () => getResumeUrls());
* await step("notify", () => sendEmail(urls.approvalPage));
* const { value, approver } = await waitForApproval({ timeout: 3600 });
* const urls = await step("urls", () => getApprovalUrls("manager"));
* await step("notify", () => sendEmail(urls.resume, urls.cancel));
* const { value, approver } = await waitForApproval({ key: "manager", timeout: 3600 });
*/
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; key?: string; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
/**
* Resume/cancel/approval-page URLs bound to one `waitForApproval` step.
*
* Unlike `getResumeUrls()`, which signs a random nonce, these address the very
* `resume_job` record the step's built-in approval buttons use, so they are
* stable across replays and safe to embed in a custom notification.
*
* `stepKey` must match the `key` given to `waitForApproval`. Keys must be unique
* within a workflow; reusing one throws rather than silently renaming it. The URL
* only resumes while that step is awaiting approval; used at any other moment it is
* rejected rather than banking a row a different approval would consume. Send it
* ahead of time — approvers just cannot act before the workflow reaches the step.
*
* `resume` and `cancel` are step-bound; `approvalPage` is not — it opens the job's
* approval page, which acts on whichever approval is pending when it is used.
*
* @example
* const urls = await step("urls", () => getApprovalUrls("manager"));
* await step("notify", () => sendEmail(urls.resume, urls.cancel));
* await waitForApproval({ key: "manager" });
*/
async getApprovalUrls(stepKey: string = "approval", approver?: string): Promise<{
approvalPage: string;
resume: string;
cancel: string;
}>
/**
* Process items in parallel with optional concurrency control.
@@ -1,6 +1,6 @@
## Python Workflow-as-Code API (wmill)
Import: `from wmill import workflow, task, task_script, task_flow, step, sleep, wait_for_approval, get_resume_urls, parallel, TaskError`
Import: `from wmill import workflow, task, task_script, task_flow, step, sleep, wait_for_approval, get_approval_urls, get_resume_urls, parallel, TaskError`
```python
# Raised when a WAC task step failed.
@@ -88,8 +88,9 @@ async def sleep(seconds: int)
# Suspend the workflow and wait for an external approval.
#
# Use ``get_resume_urls()`` (wrapped in ``step()``) to obtain
# resume/cancel/approval URLs before calling this function.
# Pass ``key`` to name the step, then ``get_approval_urls(key)`` yields the URLs
# that resume exactly this approval — route them through your own channel.
# Without a key the steps are named ``approval``, ``approval_2``, ...
#
# Returns a dict with ``value`` (form data), ``approver``, and ``approved``.
#
@@ -97,13 +98,37 @@ async def sleep(seconds: int)
# timeout: Approval timeout in seconds (default 1800).
# form: Optional form schema for the approval page.
# self_approval: Whether the user who triggered the flow can approve it (default True).
# key: Optional checkpoint key naming this approval step.
#
# Example::
#
# urls = await step("urls", lambda: get_resume_urls())
# await step("notify", lambda: send_email(urls["approvalPage"]))
# result = await wait_for_approval(timeout=3600)
async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True) -> dict
# urls = await step("urls", lambda: get_approval_urls("manager"))
# await step("notify", lambda: send_email(urls["resume"], urls["cancel"]))
# result = await wait_for_approval(key="manager", timeout=3600)
async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True, key: str | None = None) -> dict
# Get the resume/cancel/approval-page URLs bound to one ``wait_for_approval`` step.
#
# Unlike :func:`get_resume_urls`, which signs a random nonce, these address the
# very ``resume_job`` record the step's built-in approval buttons use, so they
# are stable across replays and safe to embed in a custom notification.
#
# Args:
# step_key: Checkpoint key of the approval step, as passed to
# ``wait_for_approval(key=...)``. Keys must be unique within a workflow;
# reusing one raises rather than silently renaming it. The URL only
# resumes while that step is awaiting approval; used at any other moment
# it is rejected rather than banking a row a different approval would
# consume. Send it ahead of time — approvers just cannot act before the
# workflow reaches the step.
# ``resume`` and ``cancel`` are step-bound; ``approvalPage`` is not — it
# opens the job's approval page, which acts on whichever approval is
# pending when it is used.
# approver: Optional approver name
#
# Returns:
# Dictionary with approvalPage, resume, and cancel URLs
def get_approval_urls(step_key: str = 'approval', approver: str = None) -> dict
# Process items in parallel with optional concurrency control.
#
@@ -1,6 +1,6 @@
## TypeScript Workflow-as-Code API (windmill-client)
Import: `import { workflow, task, taskScript, taskFlow, step, sleep, waitForApproval, getResumeUrls, parallel } from "windmill-client"`
Import: `import { workflow, task, taskScript, taskFlow, step, sleep, waitForApproval, getApprovalUrls, getResumeUrls, parallel } from "windmill-client"`
```typescript
export interface TaskOptions {
@@ -70,15 +70,39 @@ export async function sleep(seconds: number): Promise<void>
/**
* Suspend the workflow and wait for an external approval.
*
* Use `getResumeUrls()` (wrapped in `step()`) to obtain resume/cancel/approvalPage
* URLs before calling this function.
* Pass `key` to name the step, then `getApprovalUrls(key)` yields the URLs that
* resume exactly this approval — route them through your own channel. Without a
* key the steps are named `approval`, `approval_2`, ...
*
* @example
* const urls = await step("urls", () => getResumeUrls());
* await step("notify", () => sendEmail(urls.approvalPage));
* const { value, approver } = await waitForApproval({ timeout: 3600 });
* const urls = await step("urls", () => getApprovalUrls("manager"));
* await step("notify", () => sendEmail(urls.resume, urls.cancel));
* const { value, approver } = await waitForApproval({ key: "manager", timeout: 3600 });
*/
export function waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
export function waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; key?: string; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
/**
* Resume/cancel/approval-page URLs bound to one `waitForApproval` step.
*
* Unlike `getResumeUrls()`, which signs a random nonce, these address the very
* `resume_job` record the step's built-in approval buttons use, so they are
* stable across replays and safe to embed in a custom notification.
*
* `stepKey` must match the `key` given to `waitForApproval`. Keys must be unique
* within a workflow; reusing one throws rather than silently renaming it. The URL
* only resumes while that step is awaiting approval; used at any other moment it is
* rejected rather than banking a row a different approval would consume. Send it
* ahead of time — approvers just cannot act before the workflow reaches the step.
*
* `resume` and `cancel` are step-bound; `approvalPage` is not — it opens the job's
* approval page, which acts on whichever approval is pending when it is used.
*
* @example
* const urls = await step("urls", () => getApprovalUrls("manager"));
* await step("notify", () => sendEmail(urls.resume, urls.cancel));
* await waitForApproval({ key: "manager" });
*/
export async function getApprovalUrls(stepKey: string = "approval", approver?: string): Promise<{ approvalPage: string; resume: string; cancel: string; }>
/**
* Process items in parallel with optional concurrency control.
@@ -664,15 +664,43 @@ workflow<T>(fn: (...args: any[]) => Promise<T>): void
/**
* Suspend the workflow and wait for an external approval.
*
* Use `getResumeUrls()` (wrapped in `step()`) to obtain resume/cancel/approvalPage
* URLs before calling this function.
* Pass `key` to name the step, then `getApprovalUrls(key)` yields the URLs that
* resume exactly this approval — route them through your own channel. Without a
* key the steps are named `approval`, `approval_2`, ...
*
* @example
* const urls = await step("urls", () => getResumeUrls());
* await step("notify", () => sendEmail(urls.approvalPage));
* const { value, approver } = await waitForApproval({ timeout: 3600 });
* const urls = await step("urls", () => getApprovalUrls("manager"));
* await step("notify", () => sendEmail(urls.resume, urls.cancel));
* const { value, approver } = await waitForApproval({ key: "manager", timeout: 3600 });
*/
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; key?: string; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
/**
* Resume/cancel/approval-page URLs bound to one `waitForApproval` step.
*
* Unlike `getResumeUrls()`, which signs a random nonce, these address the very
* `resume_job` record the step's built-in approval buttons use, so they are
* stable across replays and safe to embed in a custom notification.
*
* `stepKey` must match the `key` given to `waitForApproval`. Keys must be unique
* within a workflow; reusing one throws rather than silently renaming it. The URL
* only resumes while that step is awaiting approval; used at any other moment it is
* rejected rather than banking a row a different approval would consume. Send it
* ahead of time — approvers just cannot act before the workflow reaches the step.
*
* `resume` and `cancel` are step-bound; `approvalPage` is not — it opens the job's
* approval page, which acts on whichever approval is pending when it is used.
*
* @example
* const urls = await step("urls", () => getApprovalUrls("manager"));
* await step("notify", () => sendEmail(urls.resume, urls.cancel));
* await waitForApproval({ key: "manager" });
*/
async getApprovalUrls(stepKey: string = "approval", approver?: string): Promise<{
approvalPage: string;
resume: string;
cancel: string;
}>
/**
* Process items in parallel with optional concurrency control.
@@ -664,15 +664,43 @@ workflow<T>(fn: (...args: any[]) => Promise<T>): void
/**
* Suspend the workflow and wait for an external approval.
*
* Use `getResumeUrls()` (wrapped in `step()`) to obtain resume/cancel/approvalPage
* URLs before calling this function.
* Pass `key` to name the step, then `getApprovalUrls(key)` yields the URLs that
* resume exactly this approval — route them through your own channel. Without a
* key the steps are named `approval`, `approval_2`, ...
*
* @example
* const urls = await step("urls", () => getResumeUrls());
* await step("notify", () => sendEmail(urls.approvalPage));
* const { value, approver } = await waitForApproval({ timeout: 3600 });
* const urls = await step("urls", () => getApprovalUrls("manager"));
* await step("notify", () => sendEmail(urls.resume, urls.cancel));
* const { value, approver } = await waitForApproval({ key: "manager", timeout: 3600 });
*/
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; key?: string; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
/**
* Resume/cancel/approval-page URLs bound to one `waitForApproval` step.
*
* Unlike `getResumeUrls()`, which signs a random nonce, these address the very
* `resume_job` record the step's built-in approval buttons use, so they are
* stable across replays and safe to embed in a custom notification.
*
* `stepKey` must match the `key` given to `waitForApproval`. Keys must be unique
* within a workflow; reusing one throws rather than silently renaming it. The URL
* only resumes while that step is awaiting approval; used at any other moment it is
* rejected rather than banking a row a different approval would consume. Send it
* ahead of time — approvers just cannot act before the workflow reaches the step.
*
* `resume` and `cancel` are step-bound; `approvalPage` is not — it opens the job's
* approval page, which acts on whichever approval is pending when it is used.
*
* @example
* const urls = await step("urls", () => getApprovalUrls("manager"));
* await step("notify", () => sendEmail(urls.resume, urls.cancel));
* await waitForApproval({ key: "manager" });
*/
async getApprovalUrls(stepKey: string = "approval", approver?: string): Promise<{
approvalPage: string;
resume: string;
cancel: string;
}>
/**
* Process items in parallel with optional concurrency control.
@@ -666,15 +666,43 @@ workflow<T>(fn: (...args: any[]) => Promise<T>): void
/**
* Suspend the workflow and wait for an external approval.
*
* Use `getResumeUrls()` (wrapped in `step()`) to obtain resume/cancel/approvalPage
* URLs before calling this function.
* Pass `key` to name the step, then `getApprovalUrls(key)` yields the URLs that
* resume exactly this approval — route them through your own channel. Without a
* key the steps are named `approval`, `approval_2`, ...
*
* @example
* const urls = await step("urls", () => getResumeUrls());
* await step("notify", () => sendEmail(urls.approvalPage));
* const { value, approver } = await waitForApproval({ timeout: 3600 });
* const urls = await step("urls", () => getApprovalUrls("manager"));
* await step("notify", () => sendEmail(urls.resume, urls.cancel));
* const { value, approver } = await waitForApproval({ key: "manager", timeout: 3600 });
*/
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; key?: string; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
/**
* Resume/cancel/approval-page URLs bound to one `waitForApproval` step.
*
* Unlike `getResumeUrls()`, which signs a random nonce, these address the very
* `resume_job` record the step's built-in approval buttons use, so they are
* stable across replays and safe to embed in a custom notification.
*
* `stepKey` must match the `key` given to `waitForApproval`. Keys must be unique
* within a workflow; reusing one throws rather than silently renaming it. The URL
* only resumes while that step is awaiting approval; used at any other moment it is
* rejected rather than banking a row a different approval would consume. Send it
* ahead of time — approvers just cannot act before the workflow reaches the step.
*
* `resume` and `cancel` are step-bound; `approvalPage` is not — it opens the job's
* approval page, which acts on whichever approval is pending when it is used.
*
* @example
* const urls = await step("urls", () => getApprovalUrls("manager"));
* await step("notify", () => sendEmail(urls.resume, urls.cancel));
* await waitForApproval({ key: "manager" });
*/
async getApprovalUrls(stepKey: string = "approval", approver?: string): Promise<{
approvalPage: string;
resume: string;
cancel: string;
}>
/**
* Process items in parallel with optional concurrency control.
@@ -584,6 +584,17 @@ def get_shared_state(path: str = 'state.json') -> None
# Dictionary with approvalPage, resume, and cancel URLs
def get_resume_urls(approver: str = None, flow_level: bool = None) -> dict
# Get the resume URLs bound to one ``wait_for_approval`` step of this workflow.
#
# Args:
# step_key: Checkpoint key of the approval step, as passed to
# ``wait_for_approval(key=...)``
# approver: Optional approver name
#
# Returns:
# Dictionary with approvalPage, resume, and cancel URLs
def get_approval_urls(step_key: str = 'approval', approver: str = None) -> dict
# Sends an interactive approval request via Slack, allowing optional customization of the message, approver, and form fields.
#
# **[Enterprise Edition Only]** To include form fields in the Slack approval request, use the "Advanced -> Suspend -> Form" functionality.
@@ -860,8 +871,9 @@ async def sleep(seconds: int)
# Suspend the workflow and wait for an external approval.
#
# Use ``get_resume_urls()`` (wrapped in ``step()``) to obtain
# resume/cancel/approval URLs before calling this function.
# Pass ``key`` to name the step, then ``get_approval_urls(key)`` yields the URLs
# that resume exactly this approval — route them through your own channel.
# Without a key the steps are named ``approval``, ``approval_2``, ...
#
# Returns a dict with ``value`` (form data), ``approver``, and ``approved``.
#
@@ -869,13 +881,14 @@ async def sleep(seconds: int)
# timeout: Approval timeout in seconds (default 1800).
# form: Optional form schema for the approval page.
# self_approval: Whether the user who triggered the flow can approve it (default True).
# key: Optional checkpoint key naming this approval step.
#
# Example::
#
# urls = await step("urls", lambda: get_resume_urls())
# await step("notify", lambda: send_email(urls["approvalPage"]))
# result = await wait_for_approval(timeout=3600)
async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True) -> dict
# urls = await step("urls", lambda: get_approval_urls("manager"))
# await step("notify", lambda: send_email(urls["resume"], urls["cancel"]))
# result = await wait_for_approval(key="manager", timeout=3600)
async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True, key: str | None = None) -> dict
# Process items in parallel with optional concurrency control.
#
@@ -73,7 +73,7 @@ import {
step,
sleep,
waitForApproval,
getResumeUrls,
getApprovalUrls,
parallel,
workflow,
} from "windmill-client";
@@ -91,7 +91,7 @@ export const main = workflow(async (x: string) => {
Python:
```python
from wmill import task, task_script, task_flow, step, sleep, wait_for_approval, get_resume_urls, parallel, workflow
from wmill import task, task_script, task_flow, step, sleep, wait_for_approval, get_approval_urls, parallel, workflow
@task()
async def process(x: str) -> str:
@@ -175,12 +175,13 @@ output = await pipeline(input=data)
Use `step()` for lightweight inline values that must not change during replay:
```typescript
const urls = await step("get_urls", () => getResumeUrls());
const startedAt = await step("started_at", () => new Date().toISOString());
```
```python
urls = await step("get_urls", lambda: get_resume_urls())
from datetime import datetime
started_at = await step("started_at", lambda: datetime.now().isoformat())
```
Use stable, descriptive step names. Do not generate step names dynamically.
@@ -205,20 +206,28 @@ Only parallelize independent steps. Do not read the result of a task before it i
## Approvals
Generate resume URLs inside `step()` before sending them:
Name the approval step and generate its URLs inside `step()` before sending them.
`getApprovalUrls` / `get_approval_urls` returns the URLs bound to that step, the same
ones its built-in approve/reject buttons use:
```typescript
const urls = await step("get_urls", () => getResumeUrls());
await step("notify", () => sendApprovalEmail(urls.approvalPage));
const approval = await waitForApproval({ timeout: 3600 });
const urls = await step("urls", () => getApprovalUrls("manager"));
await step("notify", () => sendApprovalEmail(urls.resume, urls.cancel));
const approval = await waitForApproval({ key: "manager", timeout: 3600 });
```
```python
urls = await step("get_urls", lambda: get_resume_urls())
await step("notify", lambda: send_approval_email(urls["approvalPage"]))
approval = await wait_for_approval(timeout=3600)
urls = await step("urls", lambda: get_approval_urls("manager"))
await step("notify", lambda: send_approval_email(urls["resume"], urls["cancel"]))
approval = await wait_for_approval(key="manager", timeout=3600)
```
With several approvals in one workflow, give each its own key so each notification
resumes its own step. Keys must be unique — reusing one raises an error rather than
silently renaming the step. A minted URL only resumes while its own step is awaiting
approval; used at any other moment it is rejected rather than resuming the wrong one. `getResumeUrls()` / `get_resume_urls()` still works but signs a
random nonce, so its URLs are not tied to any particular approval step.
`selfApproval: false` and `self_approval=False` are Enterprise-only approval behavior. Do not use them unless the user asks for that behavior.
## Error Handling
@@ -232,7 +241,7 @@ TypeScript: avoid broad `try/catch` around WAC SDK calls. The SDK uses an intern
## TypeScript Workflow-as-Code API (windmill-client)
Import: `import { workflow, task, taskScript, taskFlow, step, sleep, waitForApproval, getResumeUrls, parallel } from "windmill-client"`
Import: `import { workflow, task, taskScript, taskFlow, step, sleep, waitForApproval, getApprovalUrls, getResumeUrls, parallel } from "windmill-client"`
```typescript
export interface TaskOptions {
@@ -302,15 +311,39 @@ export async function sleep(seconds: number): Promise<void>
/**
* Suspend the workflow and wait for an external approval.
*
* Use `getResumeUrls()` (wrapped in `step()`) to obtain resume/cancel/approvalPage
* URLs before calling this function.
* Pass `key` to name the step, then `getApprovalUrls(key)` yields the URLs that
* resume exactly this approval — route them through your own channel. Without a
* key the steps are named `approval`, `approval_2`, ...
*
* @example
* const urls = await step("urls", () => getResumeUrls());
* await step("notify", () => sendEmail(urls.approvalPage));
* const { value, approver } = await waitForApproval({ timeout: 3600 });
* const urls = await step("urls", () => getApprovalUrls("manager"));
* await step("notify", () => sendEmail(urls.resume, urls.cancel));
* const { value, approver } = await waitForApproval({ key: "manager", timeout: 3600 });
*/
export function waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
export function waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; key?: string; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
/**
* Resume/cancel/approval-page URLs bound to one `waitForApproval` step.
*
* Unlike `getResumeUrls()`, which signs a random nonce, these address the very
* `resume_job` record the step's built-in approval buttons use, so they are
* stable across replays and safe to embed in a custom notification.
*
* `stepKey` must match the `key` given to `waitForApproval`. Keys must be unique
* within a workflow; reusing one throws rather than silently renaming it. The URL
* only resumes while that step is awaiting approval; used at any other moment it is
* rejected rather than banking a row a different approval would consume. Send it
* ahead of time — approvers just cannot act before the workflow reaches the step.
*
* `resume` and `cancel` are step-bound; `approvalPage` is not — it opens the job's
* approval page, which acts on whichever approval is pending when it is used.
*
* @example
* const urls = await step("urls", () => getApprovalUrls("manager"));
* await step("notify", () => sendEmail(urls.resume, urls.cancel));
* await waitForApproval({ key: "manager" });
*/
export async function getApprovalUrls(stepKey: string = "approval", approver?: string): Promise<{ approvalPage: string; resume: string; cancel: string; }>
/**
* Process items in parallel with optional concurrency control.
@@ -328,7 +361,7 @@ export async function parallel<T, R>(items: T[], fn: (item: T) => PromiseLike<R>
## Python Workflow-as-Code API (wmill)
Import: `from wmill import workflow, task, task_script, task_flow, step, sleep, wait_for_approval, get_resume_urls, parallel, TaskError`
Import: `from wmill import workflow, task, task_script, task_flow, step, sleep, wait_for_approval, get_approval_urls, get_resume_urls, parallel, TaskError`
```python
# Raised when a WAC task step failed.
@@ -416,8 +449,9 @@ async def sleep(seconds: int)
# Suspend the workflow and wait for an external approval.
#
# Use ``get_resume_urls()`` (wrapped in ``step()``) to obtain
# resume/cancel/approval URLs before calling this function.
# Pass ``key`` to name the step, then ``get_approval_urls(key)`` yields the URLs
# that resume exactly this approval — route them through your own channel.
# Without a key the steps are named ``approval``, ``approval_2``, ...
#
# Returns a dict with ``value`` (form data), ``approver``, and ``approved``.
#
@@ -425,13 +459,37 @@ async def sleep(seconds: int)
# timeout: Approval timeout in seconds (default 1800).
# form: Optional form schema for the approval page.
# self_approval: Whether the user who triggered the flow can approve it (default True).
# key: Optional checkpoint key naming this approval step.
#
# Example::
#
# urls = await step("urls", lambda: get_resume_urls())
# await step("notify", lambda: send_email(urls["approvalPage"]))
# result = await wait_for_approval(timeout=3600)
async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True) -> dict
# urls = await step("urls", lambda: get_approval_urls("manager"))
# await step("notify", lambda: send_email(urls["resume"], urls["cancel"]))
# result = await wait_for_approval(key="manager", timeout=3600)
async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True, key: str | None = None) -> dict
# Get the resume/cancel/approval-page URLs bound to one ``wait_for_approval`` step.
#
# Unlike :func:`get_resume_urls`, which signs a random nonce, these address the
# very ``resume_job`` record the step's built-in approval buttons use, so they
# are stable across replays and safe to embed in a custom notification.
#
# Args:
# step_key: Checkpoint key of the approval step, as passed to
# ``wait_for_approval(key=...)``. Keys must be unique within a workflow;
# reusing one raises rather than silently renaming it. The URL only
# resumes while that step is awaiting approval; used at any other moment
# it is rejected rather than banking a row a different approval would
# consume. Send it ahead of time — approvers just cannot act before the
# workflow reaches the step.
# ``resume`` and ``cancel`` are step-bound; ``approvalPage`` is not — it
# opens the job's approval page, which acts on whichever approval is
# pending when it is used.
# approver: Optional approver name
#
# Returns:
# Dictionary with approvalPage, resume, and cancel URLs
def get_approval_urls(step_key: str = 'approval', approver: str = None) -> dict
# Process items in parallel with optional concurrency control.
#
+20 -11
View File
@@ -21,7 +21,7 @@ import {
step,
sleep,
waitForApproval,
getResumeUrls,
getApprovalUrls,
parallel,
workflow,
} from "windmill-client";
@@ -39,7 +39,7 @@ export const main = workflow(async (x: string) => {
Python:
```python
from wmill import task, task_script, task_flow, step, sleep, wait_for_approval, get_resume_urls, parallel, workflow
from wmill import task, task_script, task_flow, step, sleep, wait_for_approval, get_approval_urls, parallel, workflow
@task()
async def process(x: str) -> str:
@@ -123,12 +123,13 @@ output = await pipeline(input=data)
Use `step()` for lightweight inline values that must not change during replay:
```typescript
const urls = await step("get_urls", () => getResumeUrls());
const startedAt = await step("started_at", () => new Date().toISOString());
```
```python
urls = await step("get_urls", lambda: get_resume_urls())
from datetime import datetime
started_at = await step("started_at", lambda: datetime.now().isoformat())
```
Use stable, descriptive step names. Do not generate step names dynamically.
@@ -153,20 +154,28 @@ Only parallelize independent steps. Do not read the result of a task before it i
## Approvals
Generate resume URLs inside `step()` before sending them:
Name the approval step and generate its URLs inside `step()` before sending them.
`getApprovalUrls` / `get_approval_urls` returns the URLs bound to that step, the same
ones its built-in approve/reject buttons use:
```typescript
const urls = await step("get_urls", () => getResumeUrls());
await step("notify", () => sendApprovalEmail(urls.approvalPage));
const approval = await waitForApproval({ timeout: 3600 });
const urls = await step("urls", () => getApprovalUrls("manager"));
await step("notify", () => sendApprovalEmail(urls.resume, urls.cancel));
const approval = await waitForApproval({ key: "manager", timeout: 3600 });
```
```python
urls = await step("get_urls", lambda: get_resume_urls())
await step("notify", lambda: send_approval_email(urls["approvalPage"]))
approval = await wait_for_approval(timeout=3600)
urls = await step("urls", lambda: get_approval_urls("manager"))
await step("notify", lambda: send_approval_email(urls["resume"], urls["cancel"]))
approval = await wait_for_approval(key="manager", timeout=3600)
```
With several approvals in one workflow, give each its own key so each notification
resumes its own step. Keys must be unique — reusing one raises an error rather than
silently renaming the step. A minted URL only resumes while its own step is awaiting
approval; used at any other moment it is rejected rather than resuming the wrong one. `getResumeUrls()` / `get_resume_urls()` still works but signs a
random nonce, so its URLs are not tied to any particular approval step.
`selfApproval: false` and `self_approval=False` are Enterprise-only approval behavior. Do not use them unless the user asks for that behavior.
## Error Handling
+4 -2
View File
@@ -1249,6 +1249,7 @@ WAC_TS_FUNCTIONS = [
'step',
'sleep',
'waitForApproval',
'getApprovalUrls',
'parallel',
]
@@ -1261,6 +1262,7 @@ WAC_PY_FUNCTIONS = [
'step',
'sleep',
'wait_for_approval',
'get_approval_urls',
'parallel',
]
@@ -1406,7 +1408,7 @@ def extract_wac_ts_sdk(ts_content: str) -> str:
return ''
md = "## TypeScript Workflow-as-Code API (windmill-client)\n\n"
md += 'Import: `import { workflow, task, taskScript, taskFlow, step, sleep, waitForApproval, getResumeUrls, parallel } from "windmill-client"`\n\n'
md += 'Import: `import { workflow, task, taskScript, taskFlow, step, sleep, waitForApproval, getApprovalUrls, getResumeUrls, parallel } from "windmill-client"`\n\n'
md += "```typescript\n"
md += "\n\n".join(declarations)
md += "\n```\n"
@@ -1528,7 +1530,7 @@ def extract_wac_py_sdk(py_content: str) -> str:
return ''
md = "## Python Workflow-as-Code API (wmill)\n\n"
md += "Import: `from wmill import workflow, task, task_script, task_flow, step, sleep, wait_for_approval, get_resume_urls, parallel, TaskError`\n\n"
md += "Import: `from wmill import workflow, task, task_script, task_flow, step, sleep, wait_for_approval, get_approval_urls, get_resume_urls, parallel, TaskError`\n\n"
md += "```python\n"
md += "\n\n".join(declarations)
md += "\n```\n"
+3 -1
View File
@@ -40,7 +40,7 @@ cp "${script_dirpath}/sqlUtils.ts" "${script_dirpath}/src/"
echo "" >> "${script_dirpath}/src/index.ts"
echo 'export type { DenoS3LightClientSettings } from "./s3Types";' >> "${script_dirpath}/src/index.ts"
echo "" >> "${script_dirpath}/src/index.ts"
echo 'export { type Base64, setClient, getVariable, setVariable, getResource, setResource, getResumeUrls, setState, setProgress, getProgress, getState, getIdToken, denoS3LightClientSettings, loadS3FileStream, loadS3File, writeS3File, deleteS3File, signS3Objects, signS3Object, getPresignedS3PublicUrls, getPresignedS3PublicUrl, task, taskScript, taskFlow, workflow, step, sleep, parallel, waitForApproval, type TaskOptions, WorkflowCtx, _workflowCtx, setWorkflowCtx, StepSuspend, runScript, runScriptAsync, runScriptByPath, runScriptByHash, runScriptByPathAsync, runScriptByHashAsync, runFlow, runFlowAsync, waitJob, getRootJobId, setFlowUserState, getFlowUserState, usernameToEmail, requestInteractiveSlackApproval, type Sql, requestInteractiveTeamsApproval, appendToResultStream, streamResult, datatable, ducklake, upsertPartition, appendPartition, type DucklakeMaterializeOptions, type SqlStatement, type DatatableSqlTemplateFunction, type SqlTemplateFunction, type S3Object, type S3ObjectRecord, type S3ObjectURI, commitKafkaOffsets } from "./client";' >> "${script_dirpath}/src/index.ts"
echo 'export { type Base64, setClient, getVariable, setVariable, getResource, setResource, getResumeUrls, setState, setProgress, getProgress, getState, getIdToken, denoS3LightClientSettings, loadS3FileStream, loadS3File, writeS3File, deleteS3File, signS3Objects, signS3Object, getPresignedS3PublicUrls, getPresignedS3PublicUrl, task, taskScript, taskFlow, workflow, step, sleep, parallel, waitForApproval, getApprovalUrls, type TaskOptions, WorkflowCtx, _workflowCtx, setWorkflowCtx, StepSuspend, runScript, runScriptAsync, runScriptByPath, runScriptByHash, runScriptByPathAsync, runScriptByHashAsync, runFlow, runFlowAsync, waitJob, getRootJobId, setFlowUserState, getFlowUserState, usernameToEmail, requestInteractiveSlackApproval, type Sql, requestInteractiveTeamsApproval, appendToResultStream, streamResult, datatable, ducklake, upsertPartition, appendPartition, type DucklakeMaterializeOptions, type SqlStatement, type DatatableSqlTemplateFunction, type SqlTemplateFunction, type S3Object, type S3ObjectRecord, type S3ObjectURI, commitKafkaOffsets } from "./client";' >> "${script_dirpath}/src/index.ts"
# Build default export by combining client utilities + services
# This preserves backward compatibility for `import wmill from "windmill-client"`
@@ -76,6 +76,7 @@ import {
sleep,
parallel,
waitForApproval,
getApprovalUrls,
WorkflowCtx,
_workflowCtx,
setWorkflowCtx,
@@ -164,6 +165,7 @@ const wmill = {
sleep,
parallel,
waitForApproval,
getApprovalUrls,
WorkflowCtx,
_workflowCtx,
setWorkflowCtx,
+88 -11
View File
@@ -1530,6 +1530,16 @@ export interface TaskOptions {
concurrency_time_window_s?: number;
}
/** A step key travels as one path segment when its URLs are minted, so it must be
* non-empty and free of `/` and dot segments otherwise `waitForApproval` would
* accept a key `getApprovalUrls` can never address. */
function assertUsableStepKey(key: string, what: string): void {
const k = key.trim();
if (k === "" || k === "." || k === ".." || key.includes("/") || key.includes("\\")) {
throw new Error(`${what} must be a non-empty step name without \`/\` or dot segments`);
}
}
export let _workflowCtx: WorkflowCtx | null = null;
export function setWorkflowCtx(ctx: WorkflowCtx | null) {
_workflowCtx = ctx;
@@ -1539,7 +1549,11 @@ export function setWorkflowCtx(ctx: WorkflowCtx | null) {
export class WorkflowCtx {
private completed: Record<string, any>;
private counters: Record<string, number> = {};
/** Null-prototype: step keys are caller-supplied, and a plain object would
* resolve `toString`/`constructor`/`__proto__` off `Object.prototype`. */
private counters: Record<string, number> = Object.create(null);
/** Every key handed out by `_allocKey`, so distinct names can't alias one key. */
private _usedKeys = new Set<string>();
private pending: Array<{
name: string;
script: string;
@@ -1563,15 +1577,24 @@ export class WorkflowCtx {
private _inlineChain: Promise<void> = Promise.resolve();
constructor(checkpoint: Record<string, any> = {}) {
this.completed = checkpoint?.completed_steps ?? {};
this.completed = Object.assign(Object.create(null), checkpoint?.completed_steps ?? {});
this._executingKey = checkpoint?._executing_key ?? null;
}
/** Name-based key: `double` for first call, `double_2`, `double_3` for subsequent. */
/** Name-based key: `double` for first call, `double_2`, `double_3` for subsequent.
* Suffixing alone can alias a second `step("x")` and a first `step("x_2")` both
* want `x_2` so keep bumping past keys already handed out. Allocation order is
* fixed by the workflow body, so replays reproduce the same keys. */
_allocKey(name: string): string {
const n = (this.counters[name] ?? 0) + 1;
let n = (this.counters[name] ?? 0) + 1;
let key = n === 1 ? name : `${name}_${n}`;
while (this._usedKeys.has(key)) {
n++;
key = `${name}_${n}`;
}
this.counters[name] = n;
return n === 1 ? name : `${name}_${n}`;
this._usedKeys.add(key);
return key;
}
_nextStep(
@@ -1649,8 +1672,21 @@ export class WorkflowCtx {
timeout?: number;
form?: object;
selfApproval?: boolean;
key?: string;
}): PromiseLike<{ value: any; approver: string; approved: boolean }> {
const key = this._allocKey("approval");
if (options?.key !== undefined) assertUsableStepKey(options.key, "waitForApproval key");
const key = this._allocKey(options?.key || "approval");
// An explicit key is an identifier callers mint URLs against, so silently
// renaming a duplicate to `<key>_2` would hand them a URL for the *first*
// step — which then fails with "resume request already sent" and parks the
// workflow until timeout. Unnamed approvals keep auto-numbering.
if (options?.key && key !== options.key) {
throw new Error(
`WAC step key "${options.key}" is already used in this workflow. ` +
`Give each waitForApproval() its own key so getApprovalUrls() can address it.`,
);
}
if (key in this.completed) {
const value = this.completed[key];
@@ -1970,18 +2006,20 @@ export function workflow<T>(fn: (...args: any[]) => Promise<T>) {
/**
* Suspend the workflow and wait for an external approval.
*
* Use `getResumeUrls()` (wrapped in `step()`) to obtain resume/cancel/approvalPage
* URLs before calling this function.
* Pass `key` to name the step, then `getApprovalUrls(key)` yields the URLs that
* resume exactly this approval route them through your own channel. Without a
* key the steps are named `approval`, `approval_2`, ...
*
* @example
* const urls = await step("urls", () => getResumeUrls());
* await step("notify", () => sendEmail(urls.approvalPage));
* const { value, approver } = await waitForApproval({ timeout: 3600 });
* const urls = await step("urls", () => getApprovalUrls("manager"));
* await step("notify", () => sendEmail(urls.resume, urls.cancel));
* const { value, approver } = await waitForApproval({ key: "manager", timeout: 3600 });
*/
export function waitForApproval(options?: {
timeout?: number;
form?: object;
selfApproval?: boolean;
key?: string;
}): PromiseLike<{ value: any; approver: string; approved: boolean }> {
const ctx: WorkflowCtx | null = _workflowCtx ?? Reflect.get(globalThis, "__wmill_wf_ctx");
if (!ctx) {
@@ -1990,6 +2028,45 @@ export function waitForApproval(options?: {
return ctx._waitForApproval(options);
}
/**
* Resume/cancel/approval-page URLs bound to one `waitForApproval` step.
*
* Unlike `getResumeUrls()`, which signs a random nonce, these address the very
* `resume_job` record the step's built-in approval buttons use, so they are
* stable across replays and safe to embed in a custom notification.
*
* `stepKey` must match the `key` given to `waitForApproval`. Keys must be unique
* within a workflow; reusing one throws rather than silently renaming it. The URL
* only resumes while that step is awaiting approval; used at any other moment it is
* rejected rather than banking a row a different approval would consume. Send it
* ahead of time approvers just cannot act before the workflow reaches the step.
*
* `resume` and `cancel` are step-bound; `approvalPage` is not it opens the job's
* approval page, which acts on whichever approval is pending when it is used.
*
* @example
* const urls = await step("urls", () => getApprovalUrls("manager"));
* await step("notify", () => sendEmail(urls.resume, urls.cancel));
* await waitForApproval({ key: "manager" });
*/
export async function getApprovalUrls(
stepKey: string = "approval",
approver?: string
): Promise<{
approvalPage: string;
resume: string;
cancel: string;
}> {
assertUsableStepKey(stepKey, "getApprovalUrls stepKey");
const workspace = getWorkspace();
return await JobService.getWacApprovalUrls({
workspace,
stepKey,
approver,
id: getEnv("WM_JOB_ID") ?? "NO_JOB_ID",
});
}
/**
* Process items in parallel with optional concurrency control.
*