feat: add selfApproval option to WAC + inline approval buttons (#8440)

* feat: add selfApproval option to WAC waitForApproval + inline approval buttons

Add self-approval configuration to WAC workflows and inline
approve/reject buttons in WorkflowTimeline.

- TS SDK: add selfApproval option to waitForApproval()
- Python SDK: add self_approval param to wait_for_approval()
- Backend: store approval_conditions in flow_status for WAC,
  enforce self-approval checks on resume endpoints
- Frontend: show Approve/Reject buttons in timeline with form
  support (EE), gated by user permissions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: revert sqlx query change + regenerate system prompts

- Revert get_suspended_flow_info to use original sqlx::query_as!
  with COALESCE to avoid sqlx offline cache mismatch in CI
- Detect WAC by checking if FlowStatus parsing fails + suspend > 0
- Re-fetch flow_status column separately for WAC approval conditions
- Regenerate auto-generated system prompt files for SDK changes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: use resume URLs for WAC inline approval buttons

- Backend generates HMAC-signed resume/cancel URLs when creating
  WAC approval, stores them in timeline entry and approval meta
- Frontend uses anonymous resume endpoint (like classic flows)
  with fallback to resumeSuspendedFlowAsOwner for admins
- Buttons show for everyone when URLs are present; server-side
  self_approval_disabled check enforces restrictions
- Show warning for admins/owners when self-approval is disabled
- selfApproval: false requires EE (errors at dispatch on CE)
- self_approval_disabled check moved outside user_auth_required
  gate so it works independently
- WAC detection no longer requires task import

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add resume_suspended and approval_info endpoints

- New approval_token DB table for token-based approval access
- New POST /jobs_u/flow/resume_suspended/{job_id} endpoint:
  - OptAuthed: works with login or approval_token
  - Checks approval_conditions (self_approval, groups, auth)
  - Admins/owners bypass rules
- New GET /jobs_u/flow/approval_info/{job_id} endpoint:
  - Returns form, rules, can_approve status
- HMAC anonymous endpoint now bypasses all approval_conditions
  (secret = full capability)
- getResumeUrls approvalPage URL now uses token format
- WAC approval dispatch generates and stores approval tokens
- Mark resumeSuspendedFlowAsOwner as legacy

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: simplify frontend to use resume_suspended endpoint

- OpenAPI spec updated with resume_suspended and approval_info endpoints
- WorkflowTimeline: removed URL parsing, now calls single
  resumeSuspended endpoint for both approve and reject
- Buttons show for any logged-in user viewing the job (backend
  enforces authorization rules)
- Kept self-approval warning for admins

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: stateless approval tokens, new approval page, FlowStatusWaitingForEvents update

- Replace DB-stored approval tokens with stateless HMAC derivation:
  token = HMAC(workspace_key, job_id + "approval_token")
  Verifiable without DB lookup, not reversible to resume secret
- Drop approval_token migration (no DB table needed)
- FlowStatusWaitingForEvents: use resumeSuspended endpoint instead
  of URL parsing + resumeSuspendedFlowAsOwner
- New approval page route /approve/{ws}/{job}?token= that uses
  approval_info and resume_suspended endpoints
- Old approval page route kept for back-compat

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: match old approval page content in new approval page

- Add FlowMetadata, JobArgs, FlowGraphV2, DisplayResult
- Add approvers with tooltips, flow arguments section
- Add admin self-approval bypass warning
- Add "Open run details" link
- Fetch full job alongside approval_info for all UI data

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: filter _MODULES from args, show 'workflow' for WAC approvals

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: remove deno template from approval/prompt SuspendDrawer

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: approval page form display + hide deno from approval script picker

- Fix form schema rendering on new approval page by wrapping flat
  WAC form schemas in { properties, order } for SchemaForm
- Hide deno from the approval step language picker in flow editor

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove deno from canHaveApproval in script_helpers.ts

The insert menu uses canHaveApproval() from script_helpers.ts via
FlowInputsQuick, not the displayLang function in FlowInputs.svelte.
Revert the unnecessary FlowInputs.svelte change.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: return form schema and description in approval_info for classic flows

The approval_info endpoint was returning None for form_schema on
classic flows. Now fetches raw_flow to get suspend.resume_form
schema, hide_cancel, and the step's completed result for description.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: inline Login component on approval page instead of redirect

Show the Login component directly on the approval page when
authentication is required. On successful login, reloads user
and approval info without navigating away.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: show resume buttons for all users, not just owners

The resume_suspended endpoint handles authorization server-side,
so the frontend should always show the buttons. Remove isOwner
gate and the "cannot resume" message.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: prevent layout shift on resume by removing spinner from cancel button

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: prevent resume button expansion by using disabled instead of loading

The loading prop adds a Loader2 spinner that expands the button width.
Use disabled={loading} instead to prevent layout shift.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: approval page login redirects back with full page reload

Set rd to the full URL (starts with http) so Login.redirectUser()
uses window.location.href instead of goto(), triggering a full page
reload after login. This ensures the approval page re-fetches data
as an authenticated user.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: fetch flow definition from flow_version when raw_flow is null

Deployed flows don't store raw_flow on the job. Fall back to
flow_version table using runnable_id to get suspend settings
(form schema, hide_cancel) for the approval_info endpoint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: show specific reasons when user cannot approve

Display whether denial is due to self-approval being disabled,
required group membership, or both.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: support both nested and flat form schema in waitForApproval

Users can now pass either:
  waitForApproval({ form: { schema: { name: { type: "string" } } } })
or:
  waitForApproval({ form: { name: { type: "string" } } })

Both WorkflowTimeline and approval page handle both formats.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: convert sqlx query macros to non-macro for CI offline cache

Replace sqlx::query! and sqlx::query_scalar! with sqlx::query and
sqlx::query_as to avoid SQLX_OFFLINE cache misses in CI.
Also remove unused LogIn import from approval page.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: suppress dead code warning + unused isOwner variable

- Add #[allow(dead_code)] to without_flow method (CI -D warnings)
- Rename isOwner to _isOwner in FlowStatusWaitingForEvents (unused)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: security and robustness fixes from PR review

- Add workspace_id verification in resume_suspended to prevent
  cross-workspace approval (#3)
- Fix token leakage: use relative path for login redirect instead
  of full URL with token (#4)
- Handle getJob failure independently from approval_info so the
  page works for unauthenticated users (#7)
- Clear error state on successful data load (#13)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address review feedback — shared token gen, rand resume_id, UX

- Move generate_approval_token to windmill-common::variables (shared
  between windmill-api and windmill-worker, eliminates duplicate HMAC)
- Use rand::random::<u32>() for resume_id instead of DefaultHasher
- Stop polling after approve/reject on approval page
- Add cancelLoading state to WorkflowTimeline Reject button

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-03-24 21:22:35 +00:00
committed by GitHub
parent db5e03610d
commit d578e40101
28 changed files with 1313 additions and 222 deletions
+1
View File
@@ -17500,6 +17500,7 @@ dependencies = [
"gcp_auth",
"git-version",
"hex",
"hmac",
"hudsucker",
"hyper-http-proxy",
"hyper-tls",
+123
View File
@@ -11088,6 +11088,129 @@ paths:
"200":
description: Interactive slack approval message sent successfully
/w/{workspace}/jobs_u/flow/resume_suspended/{job_id}:
post:
summary: resume or cancel a suspended flow/WAC job
description: >
Resume or cancel a suspended flow/WAC job. Uses approval rules to
determine authorization. Either a valid approval_token or an
authenticated session is required.
operationId: resumeSuspended
tags:
- job
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: job_id
in: path
required: true
schema:
type: string
format: uuid
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
payload:
description: payload to send to the resumed job
approval_token:
type: string
description: approval token for unauthenticated access
approved:
type: boolean
description: whether to approve (true) or cancel (false) the job
default: true
responses:
"201":
description: job resumed
content:
text/plain:
schema:
type: string
/w/{workspace}/jobs_u/flow/approval_info/{job_id}:
get:
summary: get approval info for a suspended flow/WAC job
description: >
Get approval info for a suspended flow/WAC job. Returns form schema,
approval rules, and whether the current user can approve. Either a
valid token query parameter or an authenticated session is required.
operationId: getApprovalInfo
tags:
- job
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: job_id
in: path
required: true
schema:
type: string
format: uuid
- name: token
in: query
required: false
schema:
type: string
description: approval token for unauthenticated access
responses:
"200":
description: approval info
content:
application/json:
schema:
type: object
required:
- flow_id
- can_approve
- user_auth_required
- approvers
properties:
flow_id:
type: string
format: uuid
form_schema:
description: form schema for the approval step
description:
description: description of the approval step
approval_conditions:
type: object
properties:
user_auth_required:
type: boolean
user_groups_required:
type: array
items:
type: string
self_approval_disabled:
type: boolean
required:
- user_auth_required
- user_groups_required
- self_approval_disabled
can_approve:
type: boolean
description: whether the current user/token holder can approve
user_auth_required:
type: boolean
description: whether user authentication is required to approve
hide_cancel:
type: boolean
description: whether to hide the cancel button in the UI
approvers:
type: array
items:
type: object
required:
- resume_id
- approver
properties:
resume_id:
type: integer
approver:
type: string
/w/{workspace}/jobs_u/resume/{id}/{resume_id}/{signature}:
get:
summary: resume a job for a suspended flow
+487 -43
View File
@@ -103,7 +103,7 @@ use windmill_common::{
cache,
db::UserDB,
error::{self, to_anyhow, Error},
flow_status::{Approval, FlowStatus, FlowStatusModule},
flow_status::{Approval, ApprovalConditions, FlowStatus, FlowStatusModule},
flows::{add_virtual_items_if_necessary, resolve_maybe_value, FlowValue},
jobs::{script_path_to_payload, CompletedJob, JobKind, JobPayload, QueuedJob, RawCode},
oauth2::HmacSha256,
@@ -401,6 +401,8 @@ pub fn workspace_unauthed_service() -> Router {
post(cancel_persistent_script_api),
)
.route("/queue/force_cancel/:id", post(force_cancel))
.route("/flow/resume_suspended/:job_id", post(resume_suspended))
.route("/flow/approval_info/:job_id", get(get_approval_info))
}
pub fn global_root_service() -> Router {
@@ -1058,6 +1060,7 @@ impl<'a> GetQuery<'a> {
Self { with_code: false, ..self }
}
#[allow(dead_code)]
fn without_flow(self) -> Self {
Self { with_flow: false, ..self }
}
@@ -2181,7 +2184,7 @@ pub async fn resume_suspended_flow_as_owner(
) -> error::Result<StatusCode> {
let mut tx = db.begin().await?;
let (flow, job_id) = get_suspended_flow_info(flow_id, &mut tx).await?;
let (flow, job_id, is_wac) = get_suspended_flow_info(flow_id, &mut tx).await?;
let flow_path = flow.script_path.as_deref().unwrap_or_else(|| "");
require_owner_of_path(&authed, flow_path)?;
@@ -2189,10 +2192,17 @@ pub async fn resume_suspended_flow_as_owner(
// Check approval conditions (self-approval, required groups, etc.)
if let Some(ref flow_status_value) = flow.flow_status {
if let Ok(flow_status) = serde_json::from_value::<FlowStatus>(flow_status_value.clone()) {
let trigger_email = flow.email.as_deref().unwrap_or("");
conditionally_require_authed_user(Some(authed.clone()), flow_status, trigger_email)?;
}
let trigger_email = flow.email.as_deref().unwrap_or("");
let ac = serde_json::from_value::<FlowStatus>(flow_status_value.clone())
.ok()
.and_then(|fs| fs.approval_conditions)
.or_else(|| {
// WAC flows store approval_conditions directly in flow_status JSONB
flow_status_value
.get("approval_conditions")
.and_then(|v| serde_json::from_value::<ApprovalConditions>(v.clone()).ok())
});
conditionally_require_authed_user(Some(authed.clone()), ac, trigger_email)?;
}
let value = value.unwrap_or(serde_json::Value::Null);
@@ -2208,12 +2218,426 @@ pub async fn resume_suspended_flow_as_owner(
)
.await?;
resume_immediately_if_relevant(flow, job_id, &mut tx).await?;
if is_wac {
// WAC: directly decrement suspend counter
if flow.suspend > 0 {
sqlx::query!(
"UPDATE v2_job_queue SET suspend = GREATEST(suspend - 1, 0) WHERE id = $1",
flow.id,
)
.execute(&mut *tx)
.await?;
}
} else {
resume_immediately_if_relevant(flow, job_id, &mut tx).await?;
}
tx.commit().await?;
Ok(StatusCode::CREATED)
}
// --- New approval system endpoints ---
use windmill_common::variables::generate_approval_token;
/// Verify an approval token against the workspace key + job_id.
async fn validate_approval_token(
db: &DB,
token: &str,
job_id: Uuid,
workspace_id: &str,
) -> error::Result<()> {
let expected = generate_approval_token(workspace_id, job_id, db).await?;
if token != expected {
return Err(Error::NotAuthorized("Invalid approval token".to_string()));
}
Ok(())
}
#[derive(Deserialize)]
struct ResumeSuspendedBody {
payload: Option<serde_json::Value>,
approval_token: Option<String>,
approved: Option<bool>,
}
async fn resume_suspended(
OptAuthed(opt_authed): OptAuthed,
Extension(db): Extension<DB>,
Path((w_id, job_id)): Path<(String, Uuid)>,
Json(body): Json<ResumeSuspendedBody>,
) -> error::Result<StatusCode> {
let approved = body.approved.unwrap_or(true);
let value = body.payload.unwrap_or(serde_json::Value::Null);
// Determine if we have a valid authed user or token
let has_token = if let Some(ref token) = body.approval_token {
validate_approval_token(&db, token, job_id, &w_id)
.await
.is_ok()
} else {
false
};
if opt_authed.is_none() && !has_token {
return Err(Error::NotAuthorized(
"Must be logged in or provide a valid approval token".to_string(),
));
}
let mut tx = db.begin().await?;
// Resolve the suspended flow (works for both WAC and classic flows)
let (flow, resume_job_id, is_wac) = get_suspended_flow_info(job_id, &mut tx).await?;
// Verify the job belongs to this workspace
let job_workspace: Option<String> =
sqlx::query_scalar("SELECT workspace_id FROM v2_job WHERE id = $1")
.bind(&flow.id)
.fetch_optional(&mut *tx)
.await?;
if job_workspace.as_deref() != Some(w_id.as_str()) {
return Err(Error::NotFound(
"Job not found in this workspace".to_string(),
));
}
// Check approval conditions
let approval_conditions = if is_wac {
flow.flow_status
.as_ref()
.and_then(|v| v.get("approval_conditions"))
.and_then(|v| serde_json::from_value::<ApprovalConditions>(v.clone()).ok())
} else {
flow.flow_status
.as_ref()
.and_then(|v| serde_json::from_value::<FlowStatus>(v.clone()).ok())
.and_then(|fs| fs.approval_conditions)
};
if let Some(ref ac) = approval_conditions {
if ac.user_auth_required && opt_authed.is_none() {
return Err(Error::NotAuthorized(
"This approval requires a logged-in user. Please sign in.".to_string(),
));
}
}
// If logged in, check authorization rules
if let Some(ref authed) = opt_authed {
let is_admin = authed.is_admin;
let is_owner = flow
.script_path
.as_deref()
.map(|p| require_owner_of_path(authed, p).is_ok())
.unwrap_or(false);
if !is_admin && !is_owner {
let trigger_email = flow.email.as_deref().unwrap_or("");
conditionally_require_authed_user(
Some(authed.clone()),
approval_conditions.clone(),
trigger_email,
)?;
}
} else if !has_token {
return Err(Error::NotAuthorized(
"Must be logged in or provide a valid approval token".to_string(),
));
}
// Generate a unique resume_id
let resume_id: u32 = rand::random();
// Check for duplicate
let exists: bool = sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM resume_job WHERE id = $1)")
.bind(Uuid::from_u128(resume_job_id.as_u128() ^ resume_id as u128))
.fetch_one(&mut *tx)
.await?;
if exists {
return Err(Error::BadRequest("Resume request already sent".to_string()));
}
let approver_value = opt_authed.as_ref().map(|a| a.username.clone());
insert_resume_job(
resume_id,
resume_job_id,
&flow,
value,
approver_value.clone(),
approved,
&mut tx,
)
.await?;
if !approved {
sqlx::query("UPDATE v2_job_queue SET suspend = 0 WHERE id = $1")
.bind(&flow.id)
.execute(&mut *tx)
.await?;
} else if is_wac {
if flow.suspend > 0 {
sqlx::query("UPDATE v2_job_queue SET suspend = GREATEST(suspend - 1, 0) WHERE id = $1")
.bind(&flow.id)
.execute(&mut *tx)
.await?;
}
} else {
resume_immediately_if_relevant(flow, resume_job_id, &mut tx).await?;
}
let approver = approver_value.unwrap_or_else(|| "anonymous".to_string());
let audit_author = if let Some(ref authed) = opt_authed {
AuditAuthor::from(authed)
} else {
AuditAuthor {
email: approver.clone(),
username: approver.clone(),
username_override: None,
token_prefix: None,
}
};
audit_log(
&mut *tx,
&audit_author,
"jobs.suspend_resume",
ActionKind::Update,
&w_id,
Some(
&serde_json::json!({
"approved": approved,
"job_id": job_id,
"details": if approved {
format!("Approved by {}", &approver)
} else {
format!("Cancelled by {}", &approver)
}
})
.to_string(),
),
None,
)
.await?;
tx.commit().await?;
Ok(StatusCode::CREATED)
}
#[derive(Deserialize)]
struct ApprovalInfoQuery {
token: Option<String>,
}
#[derive(Serialize)]
struct ApprovalInfo {
flow_id: Uuid,
#[serde(skip_serializing_if = "Option::is_none")]
form_schema: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
approval_conditions: Option<ApprovalConditions>,
can_approve: bool,
user_auth_required: bool,
#[serde(skip_serializing_if = "Option::is_none")]
hide_cancel: Option<bool>,
approvers: Vec<Approval>,
}
async fn get_approval_info(
OptAuthed(opt_authed): OptAuthed,
Extension(db): Extension<DB>,
Path((w_id, job_id)): Path<(String, Uuid)>,
Query(query): Query<ApprovalInfoQuery>,
) -> error::Result<Json<ApprovalInfo>> {
// Validate access: either logged in or valid token
let has_token = if let Some(ref token) = query.token {
validate_approval_token(&db, token, job_id, &w_id)
.await
.is_ok()
} else {
false
};
if opt_authed.is_none() && !has_token {
return Err(Error::NotAuthorized(
"Must be logged in or provide a valid approval token".to_string(),
));
}
// Fetch job info
#[derive(sqlx::FromRow)]
struct ApprovalJobRow {
id: Uuid,
script_path: Option<String>,
email: String,
flow_status: Option<serde_json::Value>,
workflow_as_code_status: Option<serde_json::Value>,
}
let row = sqlx::query_as::<_, ApprovalJobRow>(
"SELECT j.id, j.runnable_path as script_path, j.permissioned_as_email as email,
s.flow_status, s.workflow_as_code_status
FROM v2_job j
LEFT JOIN v2_job_status s ON s.id = j.id
WHERE j.id = $1 AND j.workspace_id = $2",
)
.bind(&job_id)
.bind(&w_id)
.fetch_optional(&db)
.await?
.ok_or_else(|| Error::NotFound(format!("Job {job_id} not found")))?;
let is_wac = row.workflow_as_code_status.is_some();
// Extract approval info based on WAC vs classic flow
let (form_schema, description, approval_conditions, hide_cancel) = if is_wac {
let approval_meta = row
.workflow_as_code_status
.as_ref()
.and_then(|v| v.get("_approval"));
let form = approval_meta.and_then(|m| m.get("form").cloned());
let ac = row
.flow_status
.as_ref()
.and_then(|v| v.get("approval_conditions"))
.and_then(|v| serde_json::from_value::<ApprovalConditions>(v.clone()).ok());
(form, None, ac, None)
} else {
let fs = row
.flow_status
.as_ref()
.and_then(|v| serde_json::from_value::<FlowStatus>(v.clone()).ok());
let ac = fs.as_ref().and_then(|s| s.approval_conditions.clone());
// For classic flows, form/description come from the flow definition and step result
let approval_step = fs.as_ref().map(|s| (s.step as usize).saturating_sub(1));
// Fetch flow definition to get suspend settings (form schema, hide_cancel).
// Try raw_flow on the job first, fall back to flow_version for deployed flows.
let raw_flow: Option<FlowValue> = {
let from_job: Option<serde_json::Value> = sqlx::query_scalar(
"SELECT raw_flow FROM v2_job WHERE id = $1 AND workspace_id = $2",
)
.bind(&job_id)
.bind(&w_id)
.fetch_optional(&db)
.await?
.flatten();
if let Some(v) = from_job {
serde_json::from_value(v).ok()
} else {
// Deployed flow: fetch from flow_version using runnable_id
let from_version: Option<serde_json::Value> = sqlx::query_scalar(
"SELECT fv.value FROM v2_job j JOIN flow_version fv ON fv.id = j.runnable_id \
WHERE j.id = $1 AND j.workspace_id = $2",
)
.bind(&job_id)
.bind(&w_id)
.fetch_optional(&db)
.await?
.flatten();
from_version.and_then(|v| serde_json::from_value(v).ok())
}
};
let suspend_module = raw_flow
.as_ref()
.and_then(|rf| approval_step.and_then(|s| rf.modules.get(s)));
let suspend_settings = suspend_module.and_then(|m| m.suspend.as_ref());
let form = suspend_settings
.and_then(|s| s.resume_form.as_ref())
.map(|rf| serde_json::json!(rf));
let hc = suspend_settings.map(|s| s.hide_cancel.unwrap_or(false));
// Fetch description and default_args from the step's completed job result
let step_job_id = fs
.as_ref()
.and_then(|s| approval_step.and_then(|step| s.modules.get(step)))
.and_then(|m| m.job());
let (desc, _default_args) = if let Some(sjid) = step_job_id {
let result: Option<serde_json::Value> = sqlx::query_scalar(
"SELECT result FROM v2_job_completed WHERE id = $1 AND workspace_id = $2",
)
.bind(sjid)
.bind(&w_id)
.fetch_optional(&db)
.await?
.flatten();
let desc = result.as_ref().and_then(|r| r.get("description").cloned());
let da = result.as_ref().and_then(|r| r.get("default_args").cloned());
(desc, da)
} else {
(None, None)
};
(form, desc, ac, hc)
};
let user_auth_required = approval_conditions
.as_ref()
.map(|ac| ac.user_auth_required)
.unwrap_or(false);
// Determine if current user can approve
let can_approve = if let Some(ref authed) = opt_authed {
if authed.is_admin {
true
} else {
let is_owner = row
.script_path
.as_deref()
.map(|p| require_owner_of_path(authed, p).is_ok())
.unwrap_or(false);
if is_owner {
true
} else {
let trigger_email = row.email.as_str();
conditionally_require_authed_user(
Some(authed.clone()),
approval_conditions.clone(),
trigger_email,
)
.is_ok()
}
}
} else {
// Not logged in — can approve only if no auth required
!user_auth_required
};
// Get existing approvers
let approvers: Vec<Approval> = sqlx::query_as::<_, (i32, Option<String>)>(
"SELECT resume_id, approver FROM resume_job WHERE flow = $1",
)
.bind(&job_id)
.fetch_all(&db)
.await?
.into_iter()
.map(|(rid, approver)| Approval {
resume_id: rid as u16,
approver: approver.unwrap_or_else(|| "anonymous".to_string()),
})
.collect();
Ok(Json(ApprovalInfo {
flow_id: row.id,
form_schema,
description,
approval_conditions,
can_approve,
user_auth_required,
hide_cancel,
approvers,
}))
}
// --- End new approval system endpoints ---
pub async fn resume_suspended_job(
authed: Option<ApiAuthed>,
opt_tokened: OptTokened,
@@ -2255,26 +2679,8 @@ async fn resume_suspended_job_internal(
// Get flow info - works for step-level, flow-level, and WAC approval
let (flow_info, is_flow_level, is_wac) = get_flow_info_for_resume(job_id, &db).await?;
// For step-level resumes, verify user auth and flow status
// For flow-level resumes (pre-approvals), the flow might not be at a suspended step yet
// For WAC approvals, skip flow status checks (there is no flow)
if !is_flow_level && !is_wac {
let parent_flow = GetQuery::new()
.without_logs()
.without_code()
.without_flow()
.fetch(&db, &flow_info.id, &w_id)
.await?;
let flow_status = parent_flow
.flow_status()
.ok_or_else(|| anyhow::anyhow!("unable to find the flow status in the flow job"))?;
let trigger_email = match &parent_flow {
Job::CompletedJob(job) => &job.email,
Job::QueuedJob(job) => &job.email,
};
conditionally_require_authed_user(authed.clone(), flow_status, trigger_email)?;
}
// HMAC secret = full capability. Skip approval_conditions checks.
// Authorization rules are enforced by the new resume_suspended endpoint instead.
let exists = sqlx::query_scalar!(
r#"
@@ -2540,7 +2946,7 @@ async fn get_flow_info_for_resume(job_id: Uuid, db: &DB) -> error::Result<(FlowI
async fn get_suspended_flow_info<'c>(
job_id: Uuid,
tx: &mut Transaction<'c, Postgres>,
) -> error::Result<(FlowInfo, Uuid)> {
) -> error::Result<(FlowInfo, Uuid, bool)> {
let flow = sqlx::query_as!(
FlowInfo,
r#"
@@ -2553,7 +2959,9 @@ async fn get_suspended_flow_info<'c>(
.fetch_optional(&mut **tx)
.await?
.ok_or_else(|| anyhow::anyhow!("parent flow job not found"))?;
let job_id = flow
// Try to extract step job_id from FlowStatus modules (classic flow path)
let step_job_id = flow
.flow_status
.as_ref()
.and_then(|v| serde_json::from_value::<FlowStatus>(v.clone()).ok())
@@ -2562,8 +2970,31 @@ async fn get_suspended_flow_info<'c>(
_ => None,
});
if let Some(job_id) = job_id {
Ok((flow, job_id))
if let Some(step_job_id) = step_job_id {
// Classic flow
Ok((flow, step_job_id, false))
} else if flow.suspend > 0 {
// WAC approval: no FlowStatus modules, but the job is suspended
// The flow_status here comes from COALESCE(flow_status, workflow_as_code_status),
// so for WAC it may contain approval_conditions from flow_status column
// or the WAC checkpoint from workflow_as_code_status column.
// We need the approval_conditions which are in flow_status column.
// Re-fetch just flow_status (without COALESCE fallback) for the auth check.
let flow_status_only: Option<serde_json::Value> =
sqlx::query_scalar("SELECT flow_status FROM v2_job_status WHERE id = $1")
.bind(&job_id)
.fetch_optional(&mut **tx)
.await?
.flatten();
let flow = FlowInfo {
id: flow.id,
flow_status: flow_status_only,
suspend: flow.suspend,
script_path: flow.script_path,
email: flow.email,
};
Ok((flow, job_id, true))
} else {
Err(anyhow::anyhow!("the flow is not in a suspended state anymore").into())
}
@@ -2640,7 +3071,11 @@ pub async fn get_suspended_job_flow(
Job::CompletedJob(job) => &job.email,
Job::QueuedJob(job) => &job.email,
};
conditionally_require_authed_user(authed.clone(), flow_status.clone(), trigger_email)?;
conditionally_require_authed_user(
authed.clone(),
flow_status.approval_conditions.clone(),
trigger_email,
)?;
let approvers_from_status = match flow_module_status {
FlowStatusModule::Success { approvers, .. } => approvers.to_owned(),
@@ -2681,16 +3116,25 @@ pub async fn get_suspended_job_flow(
fn conditionally_require_authed_user(
_authed: Option<ApiAuthed>,
flow_status: FlowStatus,
approval_conditions_opt: Option<ApprovalConditions>,
_trigger_email: &str,
) -> error::Result<()> {
let approval_conditions_opt = flow_status.approval_conditions;
if approval_conditions_opt.is_none() {
return Ok(());
}
let approval_conditions = approval_conditions_opt.unwrap();
// Check self-approval independently of user_auth_required
if approval_conditions.self_approval_disabled {
if let Some(ref authed) = _authed {
if !authed.is_admin && authed.email.eq(_trigger_email) {
return Err(Error::PermissionDenied(
"Self-approval is disabled for this flow step".to_string(),
));
}
}
}
if approval_conditions.user_auth_required {
{
#[cfg(not(feature = "enterprise"))]
@@ -2708,13 +3152,6 @@ fn conditionally_require_authed_user(
let authed = _authed.unwrap();
if !authed.is_admin {
if approval_conditions.self_approval_disabled && authed.email.eq(_trigger_email)
{
return Err(Error::PermissionDenied(
"Self-approval is disabled for this flow step".to_string(),
));
}
if !approval_conditions.user_groups_required.is_empty() {
#[cfg(feature = "enterprise")]
{
@@ -2860,11 +3297,18 @@ pub async fn get_resume_urls_internal(
.map(|x| format!("?approver={}", encode(x)))
.unwrap_or_else(String::new);
// Generate approval token for the new approval page URL.
// The token targets the parent flow/WAC job for proper resolution.
let approval_target_id = get_flow_id_for_job(&db, job_id)
.await
.unwrap_or(target_job_id);
let approval_token = generate_approval_token(&w_id, approval_target_id, &db).await?;
let base_url_str = BASE_URL.read().await.clone();
let base_url = base_url_str.as_str();
let res = ResumeUrls {
approvalPage: format!(
"{base_url}/approve/{w_id}/{target_job_id}/{resume_id}/{signature}{approver_query}"
"{base_url}/approve/{w_id}/{approval_target_id}?token={approval_token}"
),
cancel: build_resume_url(
"cancel",
+18
View File
@@ -140,6 +140,24 @@ pub async fn get_workspace_key(w_id: &str, db: &DB) -> crate::error::Result<Stri
Ok(key)
}
/// Generate a stateless approval token from workspace key + job_id.
/// This token grants access to view approval info and attempt to resume,
/// but cannot be reversed to obtain the HMAC resume secret.
pub async fn generate_approval_token(
w_id: &str,
job_id: uuid::Uuid,
db: &DB,
) -> crate::error::Result<String> {
use hmac::{Hmac, Mac};
use sha2::Sha256;
let key = get_workspace_key(w_id, db).await?;
let mut mac = Hmac::<Sha256>::new_from_slice(key.as_bytes())
.map_err(|e| crate::Error::internal_err(format!("HMAC key error: {e}")))?;
mac.update(job_id.as_bytes());
mac.update(b"approval_token");
Ok(hex::encode(mac.finalize().into_bytes()))
}
pub async fn get_secret_value_as_admin(
db: &DB,
w_id: &str,
+1
View File
@@ -110,6 +110,7 @@ gcp_auth = { workspace = true, optional = true }
rust_decimal.workspace = true
jsonwebtoken.workspace = true
sha2.workspace = true
hmac.workspace = true
pem = { workspace = true, optional = true }
urlencoding.workspace = true
nix.workspace = true
+87 -2
View File
@@ -1504,7 +1504,7 @@ async function run() {{
return {{ type: "inline_checkpoint", key: dispatch.key, result: dispatch.result ?? null, started_at: dispatch.started_at, duration_ms: dispatch.duration_ms }};
}}
if (dispatch.mode === "approval") {{
return {{ type: "approval", key: dispatch.key, timeout: dispatch.timeout, form: dispatch.form }};
return {{ type: "approval", key: dispatch.key, timeout: dispatch.timeout, form: dispatch.form, self_approval_disabled: dispatch.self_approval_disabled }};
}}
if (dispatch.mode === "sleep") {{
return {{ type: "sleep", key: dispatch.key, seconds: dispatch.seconds }};
@@ -2634,7 +2634,7 @@ pub async fn handle_wac_v2_output(
job.id, num_steps
)))
}
WacOutput::Approval { key, timeout, form } => {
WacOutput::Approval { key, timeout, form, self_approval_disabled } => {
let db = match conn {
Connection::Sql(db) => db,
_ => {
@@ -2676,11 +2676,91 @@ pub async fn handle_wac_v2_output(
.await
.map_err(|e| error::Error::internal_err(format!("Failed to save checkpoint: {e}")))?;
// Store approval_conditions in flow_status for resume endpoint auth checks
let sad = self_approval_disabled.unwrap_or(false);
if sad {
#[cfg(not(feature = "enterprise"))]
return Err(error::Error::ExecutionErr(
"Disabling self-approval is an enterprise only feature".to_string(),
));
#[cfg(feature = "enterprise")]
{
use windmill_common::flow_status::ApprovalConditions;
let approval_conditions = ApprovalConditions {
user_auth_required: true,
user_groups_required: vec![],
self_approval_disabled: true,
};
sqlx::query(
"UPDATE v2_job_status SET flow_status = JSONB_SET(
COALESCE(flow_status, '{}'::jsonb),
'{approval_conditions}',
$2::jsonb
) WHERE id = $1",
)
.bind(&job.id)
.bind(&serde_json::json!(approval_conditions))
.execute(&mut *tx)
.await
.map_err(|e| {
error::Error::internal_err(format!(
"Failed to save approval conditions: {e}"
))
})?;
}
}
// 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 stateless approval token using shared utility
let approval_token =
windmill_common::variables::generate_approval_token(&job.workspace_id, job.id, db)
.await?;
let (resume_url, cancel_url, approval_page_url) = {
use hmac::{Hmac, Mac};
use sha2::Sha256;
use windmill_common::variables::get_workspace_key;
let wkey = get_workspace_key(&job.workspace_id, db).await?;
let mut mac = Hmac::<Sha256>::new_from_slice(wkey.as_bytes())
.map_err(|e| error::Error::internal_err(format!("HMAC key error: {e}")))?;
mac.update(job.id.as_bytes());
mac.update(resume_id.to_be_bytes().as_ref());
let signature = hex::encode(mac.finalize().into_bytes());
let base_url = windmill_common::BASE_URL.read().await.clone();
let w_id = &job.workspace_id;
let job_id = &job.id;
let resume = format!(
"{base_url}/api/w/{w_id}/jobs_u/resume/{job_id}/{resume_id}/{signature}"
);
let cancel = format!(
"{base_url}/api/w/{w_id}/jobs_u/cancel/{job_id}/{resume_id}/{signature}"
);
let approval_page =
format!("{base_url}/approve/{w_id}/{job_id}?token={approval_token}");
(resume, cancel, approval_page)
};
// Store approval form metadata for the approval page endpoint
let approval_meta = serde_json::json!({
"key": key,
"form": form,
"timeout": timeout_secs as u32,
"self_approval_disabled": sad,
"resume": resume_url,
"cancel": cancel_url,
"approvalPage": approval_page_url,
});
sqlx::query(
"UPDATE v2_job_status SET workflow_as_code_status = jsonb_set(
@@ -2705,6 +2785,11 @@ pub async fn handle_wac_v2_output(
"started_at": &now_str,
"name": key,
"approval": true,
"self_approval_disabled": sad,
"form": form,
"resume": &resume_url,
"cancel": &cancel_url,
"approvalPage": &approval_page_url,
});
let step_timeline_key = format!("_step/{}", key);
sqlx::query(
+9 -15
View File
@@ -59,7 +59,13 @@ pub enum WacOutput {
/// No child job is dispatched — the parent suspends directly and resumes
/// when a user hits the resume/cancel endpoint.
#[serde(rename = "approval")]
Approval { key: String, timeout: Option<u32>, form: Option<Value> },
Approval {
key: String,
timeout: Option<u32>,
form: Option<Value>,
#[serde(default)]
self_approval_disabled: Option<bool>,
},
/// Server-side sleep — suspend the workflow for a duration without holding a worker.
#[serde(rename = "sleep")]
Sleep { key: String, seconds: u32 },
@@ -306,15 +312,13 @@ pub async fn prepare_checkpoint_for_resume(
}
/// Detect WAC v2 patterns in TypeScript/Bun code.
/// Checks for `import ... from "windmill-client"` containing workflow/task,
/// Checks for `import ... from "windmill-client"` containing workflow,
/// skipping comment lines. Handles both single-line and multi-line imports.
pub fn is_wac_v2_ts(code: &str) -> bool {
let mut has_wac_import = false;
let mut has_workflow = false;
let mut has_task = false;
let mut in_import_block = false;
let mut import_block_has_workflow = false;
let mut import_block_has_task = false;
for line in code.lines() {
let trimmed = line.trim();
if trimmed.starts_with("//") {
@@ -328,34 +332,24 @@ pub fn is_wac_v2_ts(code: &str) -> bool {
if trimmed.contains("workflow") {
has_workflow = true;
}
if trimmed.contains("task") {
has_task = true;
}
in_import_block = false;
}
// Start of multi-line import: import {
else if trimmed.starts_with("import") && trimmed.contains("{") && !trimmed.contains("}") {
in_import_block = true;
import_block_has_workflow = trimmed.contains("workflow");
import_block_has_task = trimmed.contains("task");
}
// Inside multi-line import block
else if in_import_block {
if trimmed.contains("workflow") {
import_block_has_workflow = true;
}
if trimmed.contains("task") {
import_block_has_task = true;
}
// End of multi-line import: } from "windmill-client"
if trimmed.contains("windmill-client") {
has_wac_import = true;
if import_block_has_workflow {
has_workflow = true;
}
if import_block_has_task {
has_task = true;
}
in_import_block = false;
}
// End of import block but not windmill-client
@@ -367,7 +361,7 @@ pub fn is_wac_v2_ts(code: &str) -> bool {
has_workflow = true;
}
}
has_wac_import && has_workflow && has_task
has_wac_import && has_workflow
}
/// Detect WAC v2 patterns in Python code.
+10 -5
View File
@@ -740,7 +740,7 @@ workflow<T>(fn: (...args: any[]) => Promise<T>): void
* await step("notify", () => sendEmail(urls.approvalPage));
* const { value, approver } = await waitForApproval({ timeout: 3600 });
*/
waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
/**
* Process items in parallel with optional concurrency control.
@@ -1403,7 +1403,7 @@ workflow<T>(fn: (...args: any[]) => Promise<T>): void
* await step("notify", () => sendEmail(urls.approvalPage));
* const { value, approver } = await waitForApproval({ timeout: 3600 });
*/
waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
/**
* Process items in parallel with optional concurrency control.
@@ -2129,7 +2129,7 @@ workflow<T>(fn: (...args: any[]) => Promise<T>): void
* await step("notify", () => sendEmail(urls.approvalPage));
* const { value, approver } = await waitForApproval({ timeout: 3600 });
*/
waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
/**
* Process items in parallel with optional concurrency control.
@@ -3069,7 +3069,7 @@ workflow<T>(fn: (...args: any[]) => Promise<T>): void
* await step("notify", () => sendEmail(urls.approvalPage));
* const { value, approver } = await waitForApproval({ timeout: 3600 });
*/
waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
/**
* Process items in parallel with optional concurrency control.
@@ -4078,12 +4078,17 @@ async def sleep(seconds: int)
#
# Returns a dict with \`\`value\`\` (form data), \`\`approver\`\`, and \`\`approved\`\`.
#
# Args:
# 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).
#
# 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) -> dict
async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True) -> dict
# Process items in parallel with optional concurrency control.
#
@@ -2084,6 +2084,7 @@
stepResults={getStepResults(node.workflow_as_code_status)}
result={node.result}
success={node.type === 'Success'}
jobId={node.job_id}
/>
</div>
{/if}
@@ -18,11 +18,9 @@
light?: boolean
}
let { isOwner, workspaceId, job, light = false }: Props = $props()
let { isOwner: _isOwner, workspaceId, job, light = false }: Props = $props()
let default_payload: object = $state({})
let resumeUrl: string | undefined = $state(undefined)
let cancelUrl: string | undefined = $state(undefined)
let description: any = $state(undefined)
let hide_cancel = $state(false)
@@ -49,8 +47,6 @@
defaultValues = JSON.parse(JSON.stringify(args))
default_payload = args
resumeUrl = job_result?.['resume']
cancelUrl = job_result?.['cancel']
hide_cancel = job?.raw_flow?.modules?.[approvalStep]?.suspend?.hide_cancel ?? false
schema = mergeSchema(
job?.raw_flow?.modules?.[approvalStep]?.suspend?.resume_form?.schema ?? {},
@@ -61,61 +57,19 @@
let loading = $state(false)
async function continu(approve: boolean) {
loading = true
if ((resumeUrl && approve) || (cancelUrl && !approve)) {
let split = (approve ? resumeUrl : cancelUrl)!.split('/')
let signatureUrl = split.pop() ?? ''
const regex = /([^?]+)(?:\?[^=]+=(\w+))?/
const matches = signatureUrl.match(regex)
const signature = matches?.[1]
if (!signature) {
sendUserToast(`Could not parse signature: ${signatureUrl}`, true)
return
}
const approver = matches?.[2] || undefined
let resumeId = -1
let parsedResumeId = split.pop() ?? ''
try {
resumeId = new Number(parsedResumeId).valueOf()
} catch (e) {
console.error(`Could not parse resume id: ${parsedResumeId}`)
}
let jobId = split.pop() ?? ''
if (approve) {
await JobService.resumeSuspendedJobPost({
workspace: workspaceId ?? $workspaceStore ?? '',
id: jobId,
requestBody: default_payload as any,
resumeId,
signature,
approver
})
} else {
await JobService.cancelSuspendedJobPost({
workspace: workspaceId ?? $workspaceStore ?? '',
id: jobId,
resumeId,
signature,
approver,
requestBody: {}
})
}
} else {
if (approve) {
await JobService.resumeSuspendedFlowAsOwner({
workspace: workspaceId ?? $workspaceStore ?? '',
id: job?.id ?? '',
requestBody: default_payload as any
})
} else {
await JobService.cancelQueuedJob({
workspace: workspaceId ?? $workspaceStore ?? '',
id: job?.id ?? '',
requestBody: {}
})
}
try {
await JobService.resumeSuspended({
workspace: workspaceId ?? $workspaceStore ?? '',
jobId: job?.id ?? '',
requestBody: {
payload: approve ? (default_payload as any) : undefined,
approved: approve
}
})
} catch (e: any) {
sendUserToast(e?.body ?? e?.message ?? 'Failed', true)
} finally {
loading = false
}
}
let approvalStep = $derived((job?.flow_status?.step ?? 1) - 1)
@@ -130,51 +84,41 @@
<div class="mt-2"></div>
{/if}
<div>
{#if isOwner || resumeUrl}
<div class={twMerge('flex gap-2', light ? 'flex-col' : 'flex-row ')}>
{#if !hide_cancel}
<div>
<Button
title="Cancel the step"
{loading}
iconOnly
startIcon={{ icon: X }}
variant="default"
disabled={!cancelUrl}
destructive
unifiedSize="md"
on:click={() => continu(false)}
/>
</div>
{/if}
<div class={twMerge('flex gap-2', light ? 'flex-col' : 'flex-row ')}>
{#if !hide_cancel}
<div>
<Button variant="accent" onClick={() => continu(true)} {loading} unifiedSize="md">
Resume
<Tooltip class="text-white">
Since you are an owner of this flow, you can send resume events without necessarily
knowing the resume id sent by the approval step
</Tooltip>
</Button>
<Button
title="Cancel the step"
iconOnly
startIcon={{ icon: X }}
variant="default"
disabled={loading}
destructive
unifiedSize="md"
on:click={() => continu(false)}
/>
</div>
{#if job?.raw_flow?.modules?.[approvalStep]?.suspend?.resume_form?.schema}
<div
class={twMerge(
'w-full border rounded-lg p-2',
light ? 'min-w-96 max-h-svh overflow-y-auto' : ''
)}
>
<SchemaForm onlyMaskPassword bind:args={default_payload} {defaultValues} {schema} />
</div>
<Tooltip>
The payload is optional, it is passed to the following step through the `resume`
variable
</Tooltip>
{/if}
{/if}
<div>
<Button variant="accent" onClick={() => continu(true)} disabled={loading} unifiedSize="md">
Resume
<Tooltip class="text-white">Resume or approve this suspended step</Tooltip>
</Button>
</div>
{:else}
You cannot resume the flow yourself without receiving the resume secret since you are not an
owner of {job.script_path} and the approval step did not contain the resume url at key `resume`
{/if}
{#if job?.raw_flow?.modules?.[approvalStep]?.suspend?.resume_form?.schema}
<div
class={twMerge(
'w-full border rounded-lg p-2',
light ? 'min-w-96 max-h-svh overflow-y-auto' : ''
)}
>
<SchemaForm onlyMaskPassword bind:args={default_payload} {defaultValues} {schema} />
</div>
<Tooltip>
The payload is optional, it is passed to the following step through the `resume` variable
</Tooltip>
{/if}
</div>
</div>
</div>
@@ -1,6 +1,6 @@
<script lang="ts">
import { base } from '$lib/base'
import { displayDate, msToSec } from '$lib/utils'
import { displayDate, msToSec, emptyString } from '$lib/utils'
import { onDestroy } from 'svelte'
import { getDbClockNow } from '$lib/forLater'
import { ChevronDown, ChevronRight, Loader2, Moon, ShieldCheck } from 'lucide-svelte'
@@ -9,7 +9,11 @@
import ObjectViewer from './propertyPicker/ObjectViewer.svelte'
import { CheckCircle2, XCircle } from 'lucide-svelte'
import { JobService, type Job, type WorkflowStatus } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
import { Button } from '$lib/components/common'
import { Alert } from '$lib/components/common'
import SchemaForm from '$lib/components/SchemaForm.svelte'
import { sendUserToast } from '$lib/toast'
interface Props {
flow_status: Record<string, WorkflowStatus>
@@ -18,6 +22,7 @@
result?: any
success?: boolean
autoExpandResult?: boolean
jobId?: string
}
let {
@@ -26,7 +31,8 @@
stepResults = {},
result = undefined,
success = true,
autoExpandResult = false
autoExpandResult = false,
jobId = undefined
}: Props = $props()
let resultExpanded = $state(false)
@@ -114,6 +120,49 @@
}
}
}, 2000)
// Approval action state
let approvalLoading: Record<string, boolean> = $state({})
let approvalFormArgs: Record<string, Record<string, any>> = $state({})
async function handleApprove(key: string, formSchema: any) {
const ws = $workspaceStore
if (!ws || !jobId) return
approvalLoading[key] = true
try {
const payload =
formSchema && Object.keys(formSchema).length > 0 ? (approvalFormArgs[key] ?? {}) : undefined
await JobService.resumeSuspended({
workspace: ws,
jobId: jobId,
requestBody: { payload, approved: true }
})
sendUserToast('Approval submitted')
} catch (e: any) {
sendUserToast(e?.body ?? e?.message ?? 'Failed to approve', true)
} finally {
approvalLoading[key] = false
}
}
let cancelLoading = $state(false)
async function handleCancel() {
const ws = $workspaceStore
if (!ws || !jobId) return
cancelLoading = true
try {
await JobService.resumeSuspended({
workspace: ws,
jobId: jobId,
requestBody: { approved: false }
})
sendUserToast('Job cancelled')
} catch (e: any) {
sendUserToast(e?.body ?? e?.message ?? 'Failed to cancel', true)
} finally {
cancelLoading = false
}
}
</script>
{#if flow_status}
@@ -167,22 +216,74 @@
<span class="italic">sleep ({(v as any).sleep_duration_s}s)</span>
</div>
{:else if isApproval}
<div class="w-full px-2 py-1.5 text-xs flex items-center gap-2">
<div class="w-3 flex-shrink-0"></div>
<ShieldCheck
size={12}
class="flex-shrink-0 {isDone ? 'text-green-600' : 'text-yellow-500'}"
/>
<span class="italic text-secondary">
{v.name ?? stepKey(k)}
</span>
{#if !isDone}
<span class="text-tertiary flex items-center gap-1">
<Loader2 size={12} class="animate-spin" />
waiting
{@const selfApprovalDisabled = (v as any).self_approval_disabled === true}
{@const formSchema = (v as any).form?.schema ?? (v as any).form}
{@const hasForm =
formSchema && typeof formSchema === 'object' && Object.keys(formSchema).length > 0}
{@const canApprove = !isDone && jobId}
<div class="w-full px-2 py-1.5 text-xs">
<div class="flex items-center gap-2">
<div class="w-3 flex-shrink-0"></div>
<ShieldCheck
size={12}
class="flex-shrink-0 {isDone ? 'text-green-600' : 'text-yellow-500'}"
/>
<span class="italic text-secondary">
{v.name ?? stepKey(k)}
</span>
{:else}
<span class="text-tertiary">{msToSec(v.duration_ms ?? 0)}s</span>
{#if !isDone}
<span class="text-tertiary flex items-center gap-1">
<Loader2 size={12} class="animate-spin" />
waiting
</span>
{#if canApprove}
<div class="ml-auto flex gap-1">
<Button
variant="default"
unifiedSize="sm"
disabled={approvalLoading[k]}
onclick={() => handleApprove(k, formSchema)}
>
Approve
</Button>
<Button
variant="default"
unifiedSize="sm"
disabled={approvalLoading[k] || cancelLoading}
onclick={() => handleCancel()}
>
Reject
</Button>
</div>
{/if}
{:else}
<span class="text-tertiary">{msToSec(v.duration_ms ?? 0)}s</span>
{/if}
</div>
{#if canApprove && selfApprovalDisabled && $userStore?.is_admin}
<div class="mt-1 ml-5 text-yellow-600 text-2xs">
Self-approval is disabled but allowed because you are an admin/owner
</div>
{/if}
{#if canApprove && hasForm}
<div class="mt-2 ml-5 max-w-md">
{#if emptyString($enterpriseLicense)}
<Alert
type="warning"
title="Adding a form to the approval page is an EE feature"
/>
{:else}
<SchemaForm
onlyMaskPassword
noVariablePicker
schema={{
properties: formSchema,
order: Object.keys(formSchema)
}}
bind:args={approvalFormArgs[k]}
/>
{/if}
</div>
{/if}
</div>
{:else}
@@ -275,13 +376,13 @@
{@const result = stepResults[stepKey(k)]}
{#if isDone && result !== undefined}
<div>
<div class="text-2xs text-secondary font-semibold mb-1">Result</div>
<div class="text-2xs text-secondary font-semibold mb-1"> Result </div>
<div class="max-h-40 overflow-auto">
<ObjectViewer json={result} pureViewer />
</div>
</div>
{:else}
<div class="text-xs text-secondary py-1">Step completed (no result)</div>
<div class="text-xs text-secondary py-1"> Step completed (no result) </div>
{/if}
{:else if loadingJobs[k] && !childJobs[k]}
<div class="flex items-center gap-2 text-xs text-secondary py-1">
@@ -293,7 +394,7 @@
<!-- Logs -->
{#if job.logs || isRunning}
<div class="mb-2">
<div class="text-2xs text-secondary font-semibold mb-1">Logs</div>
<div class="text-2xs text-secondary font-semibold mb-1"> Logs </div>
<LogViewer
content={job.logs ?? ''}
jobId={k}
@@ -309,7 +410,7 @@
<!-- Result -->
{#if isDone && job.result !== undefined}
<div>
<div class="text-2xs text-secondary font-semibold mb-1">Result</div>
<div class="text-2xs text-secondary font-semibold mb-1"> Result </div>
<div class="max-h-40 overflow-auto">
<ObjectViewer json={job.result} pureViewer />
</div>
@@ -39,27 +39,9 @@
render a cancel button, providing the operator with an option to cancel the step. e.g:
<Tabs selected="bun" class="pt-4">
<Tab value="bun" label="TypeScript (Bun)" />
<Tab value="deno" label="TypeScript (Deno)" />
<Tab value="python" label="Python" />
{#snippet content()}
<TabContent value="deno" class="p-2">
<HighlightCode
language={'deno'}
code={`import * as wmill from "npm:windmill-client@^1.158.2"
export async function main() {
const urls = await wmill.getResumeUrls("approver1")
return {
resume: urls['resume'],
cancel: urls['cancel'],
default_args: {}, // optional, see below
enums: {} // optional, see below
}
}`}
/>
</TabContent>
<TabContent value="bun" class="p-2">
<HighlightCode
language={'deno'}
@@ -163,6 +163,7 @@
stepResults={getStepResults(job.workflow_as_code_status)}
result={(job as any).result}
success={(job as any).success !== false}
jobId={job.id}
/>
</div>
{/if}
@@ -158,6 +158,7 @@
result={previewJob?.result}
success={previewJob?.success !== false}
autoExpandResult
jobId={previewJob?.id}
/>
</div>
{:else}
+1 -1
View File
@@ -1601,7 +1601,7 @@ export function canHaveApproval(language: SupportedLanguage | undefined): boolea
return false
}
return ['python3', 'bun', 'deno'].includes(language)
return ['python3', 'bun'].includes(language)
}
export function canHaveFailure(language: SupportedLanguage | undefined): boolean {
@@ -797,6 +797,7 @@
stepResults={getStepResults(job.workflow_as_code_status)}
result={job.result}
success={(job as any).success !== false}
jobId={job.id}
/>
</div>
</div>
@@ -0,0 +1,359 @@
<script lang="ts">
import { type Job, JobService } from '$lib/gen'
import { base } from '$lib/base'
import Button from '$lib/components/common/button/Button.svelte'
import CenteredModal from '$lib/components/CenteredModal.svelte'
import { sendUserToast } from '$lib/toast'
import FlowMetadata from '$lib/components/FlowMetadata.svelte'
import JobArgs from '$lib/components/JobArgs.svelte'
import { onDestroy, onMount, untrack } from 'svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import SchemaForm from '$lib/components/SchemaForm.svelte'
import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
import Login from '$lib/components/Login.svelte'
import { AlertTriangle, ExternalLink } from 'lucide-svelte'
import { mergeSchema } from '$lib/common'
import { emptyString } from '$lib/utils'
import { Alert } from '$lib/components/common'
import { getUserExt } from '$lib/user'
import { setLicense } from '$lib/enterpriseUtils'
import DisplayResult from '$lib/components/DisplayResult.svelte'
import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte'
import FlowGraphV2 from '$lib/components/graph/FlowGraphV2.svelte'
import { page } from '$app/state'
$workspaceStore = page.params.workspace
let rd = page.url.href.replace(page.url.origin, '')
let token = page.url.searchParams.get('token') ?? undefined
let job: Job | undefined = $state(undefined)
let approvalInfo: any = $state(undefined)
let completed = $state(false)
let error: string | undefined = $state(undefined)
let default_payload: any = $state({})
let loading = $state(false)
let valid = $state(true)
let pollInterval: number | undefined = undefined
let scheduleEditor: ScheduleEditor | undefined = $state(undefined)
setLicense()
onMount(() => {
window.onunhandledrejection = (event: PromiseRejectionEvent) => {
event.preventDefault()
pollInterval && clearInterval(pollInterval)
if (event.reason?.message) {
const { message, body } = event.reason
if (body) {
sendUserToast(`${body}`, true)
error = body.toString()
} else {
sendUserToast(`${message}`, true)
error = message.toString()
}
}
}
loadData()
pollInterval = setInterval(loadData, 2000)
})
onDestroy(() => {
pollInterval && clearInterval(pollInterval)
})
async function loadData() {
try {
error = undefined
approvalInfo = await JobService.getApprovalInfo({
workspace: page.params.workspace ?? '',
jobId: page.params.job ?? '',
token
})
} catch (e: any) {
error = e?.body ?? e?.message ?? 'Failed to load approval info'
pollInterval && clearInterval(pollInterval)
return
}
try {
job = (await JobService.getJob({
workspace: page.params.workspace ?? '',
id: page.params.job ?? ''
})) as Job
completed = job?.type === 'CompletedJob'
} catch {
// Job details are optional — page works with just approvalInfo
}
}
async function loadUser() {
userStore.set(await getUserExt(page.params.workspace ?? ''))
}
async function resume() {
loading = true
try {
await JobService.resumeSuspended({
workspace: page.params.workspace ?? '',
jobId: page.params.job ?? '',
requestBody: {
payload: default_payload,
approval_token: token,
approved: true
}
})
sendUserToast('Flow approved')
pollInterval && clearInterval(pollInterval)
loadData()
} catch (e: any) {
sendUserToast(e?.body ?? e?.message ?? 'Failed to approve', true)
error = e?.body ?? e?.message
} finally {
loading = false
}
}
async function cancel() {
loading = true
try {
await JobService.resumeSuspended({
workspace: page.params.workspace ?? '',
jobId: page.params.job ?? '',
requestBody: {
approval_token: token,
approved: false
}
})
sendUserToast('Flow denied!')
pollInterval && clearInterval(pollInterval)
loadData()
} catch (e: any) {
sendUserToast(e?.body ?? e?.message ?? 'Failed to cancel', true)
error = e?.body ?? e?.message
} finally {
loading = false
}
}
let isWac = $derived(!!(job as any)?.workflow_as_code_status)
let filteredArgs = $derived.by(() => {
if (!job?.args) return job?.args
const args = { ...(job.args as any) }
delete args['_MODULES']
return args
})
let rawFormSchema = $derived(approvalInfo?.form_schema?.schema ?? approvalInfo?.form_schema ?? {})
let schema = $derived.by(() => {
if (
!rawFormSchema ||
typeof rawFormSchema !== 'object' ||
Object.keys(rawFormSchema).length === 0
)
return {}
// If the schema already has 'properties', use it as-is (classic flow format)
if ('properties' in rawFormSchema) return rawFormSchema
// Otherwise wrap as properties (WAC format: form.schema is a flat map of field definitions)
return { properties: rawFormSchema, order: Object.keys(rawFormSchema) }
})
let hasForm = $derived(schema && typeof schema === 'object' && Object.keys(schema).length > 0)
let selfApprovalDisabled = $derived(
approvalInfo?.approval_conditions?.self_approval_disabled ?? false
)
let isSelfApprovalBypass = $derived(
selfApprovalDisabled &&
$userStore &&
$userStore.email === (job as any)?.email &&
($userStore.is_admin || $userStore.is_super_admin)
)
$effect(() => {
if (approvalInfo?.user_auth_required && !$userStore) {
untrack(() => loadUser())
}
})
</script>
<ScheduleEditor bind:this={scheduleEditor} />
<CenteredModal
title="Approval for resuming of {isWac ? 'workflow' : 'flow'}"
disableLogo
centerVertically={false}
>
{#if error}
<div class="space-y-6">
{#if error.includes('logged in') || error.includes('sign in') || error.includes('Not authorized')}
<div class="flex flex-row gap-4 justify-center">
<AlertTriangle />
<p class="text-lg">Not Authorized</p>
</div>
<p class="text-sm">{error}</p>
<Login {rd} />
{:else if error.includes('Permission denied') || error.includes('Self-approval')}
<div class="flex flex-row gap-4 justify-center">
<AlertTriangle />
<p class="text-lg">Permission denied</p>
</div>
<p class="text-sm">{error}</p>
{:else}
<div class="flex flex-row gap-4 justify-center">
<AlertTriangle />
<p class="text-lg">Error</p>
</div>
<p class="text-sm">{error}</p>
{/if}
</div>
{:else if approvalInfo}
<div class="flex flex-row justify-between flex-wrap sm:flex-nowrap gap-x-4">
<div class="w-full">
<h2 class="text-sm font-semibold text-emphasis">Approvers</h2>
<div class="mt-2 text-xs font-normal text-primary">
{#if approvalInfo.approvers?.length > 0}
<ul>
{#each approvalInfo.approvers as a}
<li>
<p>
{a.approver}
<Tooltip>Unique id of approval: {a.resume_id}</Tooltip>
</p>
</li>
{/each}
</ul>
{:else}
<p class="text-xs text-secondary">
No current approvers for this step (approval steps can require more than one approval)
</p>
{/if}
</div>
</div>
<div class="w-full">
{#if job && job.raw_flow}
<FlowMetadata {job} {scheduleEditor} />
{/if}
</div>
</div>
{#if !completed}
<h2 class="mt-4 mb-2 text-sm font-semibold text-emphasis">
{isWac ? 'Workflow' : 'Flow'} arguments
</h2>
<JobArgs
id={job?.id}
workspace={job?.workspace_id ?? $workspaceStore ?? 'no_w'}
args={filteredArgs}
/>
{/if}
<div class="mt-8"></div>
<div class="p-4 rounded-md bg-surface-tertiary shadow-md">
{#if completed}
<Alert type="info" title="Flow completed">
The flow is not running anymore. You cannot cancel or resume it.
</Alert>
{/if}
{#if approvalInfo.description != undefined}
<DisplayResult noControls result={approvalInfo.description} />
{/if}
{#if hasForm && !completed}
{#if emptyString($enterpriseLicense)}
<Alert type="warning" title="Adding a form to the approval page is an EE feature" />
{:else}
<SchemaForm
onlyMaskPassword
noVariablePicker
bind:isValid={valid}
schema={mergeSchema(schema, {})}
bind:args={default_payload}
/>
{/if}
{/if}
{#if !completed && approvalInfo.can_approve}
<div class="w-max-md flex flex-row gap-x-4 gap-y-4 justify-between w-full flex-wrap">
{#if approvalInfo.hide_cancel !== true}
<Button
variant="accent"
destructive
onclick={cancel}
size="lg"
disabled={completed || loading}
>
Deny
</Button>
{:else}
<div></div>
{/if}
<Button
variant="accent"
onclick={resume}
size="lg"
disabled={completed || !valid || loading}
>
Approve
</Button>
</div>
{:else if !completed && !approvalInfo.can_approve}
{#if approvalInfo.user_auth_required && !$userStore}
<Login {rd} />
{:else}
<div class="text-sm text-secondary space-y-1">
<p>You are not authorized to approve this flow.</p>
{#if approvalInfo.approval_conditions?.self_approval_disabled && $userStore && $userStore.email === (job as any)?.email}
<p class="text-yellow-600">Self-approval is disabled for this step.</p>
{/if}
{#if approvalInfo.approval_conditions?.user_groups_required?.length > 0}
<p
>Only members of the following groups can approve: <b
>{approvalInfo.approval_conditions.user_groups_required.join(', ')}</b
></p
>
{/if}
</div>
{/if}
{:else if completed}
<!-- already shown above -->
{/if}
{#if !completed && isSelfApprovalBypass}
<div class="mt-2">
<Alert type="warning" title="Warning">
As an administrator, by resuming or cancelling this stage of the flow, you bypass the
self-approval interdiction.
</Alert>
</div>
{/if}
</div>
<div class="mt-4 flex flex-row flex-wrap justify-between">
<a
class="text-accent text-xs"
target="_blank"
rel="noreferrer"
href="{base}/run/{job?.id}?workspace={job?.workspace_id}"
>
Open run details (require auth) <ExternalLink size={12} class="inline" />
</a>
</div>
{#if job && job.raw_flow && !completed}
<h2 class="mt-10 text-sm font-semibold text-emphasis mb-2">Flow details</h2>
<div class="rounded-md overflow-hidden">
<FlowGraphV2
workspace={job.workspace_id}
triggerNode={false}
earlyStop={job.raw_flow?.skip_expr !== undefined}
cache={job.raw_flow?.cache_ttl !== undefined}
modules={job.raw_flow?.modules}
failureModule={job.raw_flow?.failure_module}
preprocessorModule={job.raw_flow?.preprocessor_module}
notSelectable
/>
</div>
{/if}
{:else}
<p class="text-sm text-secondary">Loading...</p>
{/if}
</CenteredModal>
+9 -2
View File
@@ -2463,7 +2463,7 @@ class WorkflowCtx:
)
async def _wait_for_approval(
self, timeout: int = 1800, form: dict | None = None
self, timeout: int = 1800, form: dict | None = None, self_approval: bool = True
):
key = self._alloc_key("approval")
@@ -2479,6 +2479,7 @@ class WorkflowCtx:
"key": key,
"timeout": timeout,
"form": form,
"self_approval_disabled": not self_approval,
"steps": [],
})
@@ -2762,6 +2763,7 @@ async def sleep(seconds: int):
async def wait_for_approval(
timeout: int = 1800,
form: dict | None = None,
self_approval: bool = True,
) -> dict:
"""Suspend the workflow and wait for an external approval.
@@ -2770,6 +2772,11 @@ async def wait_for_approval(
Returns a dict with ``value`` (form data), ``approver``, and ``approved``.
Args:
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).
Example::
urls = await step("urls", lambda: get_resume_urls())
@@ -2778,7 +2785,7 @@ async def wait_for_approval(
"""
ctx: WorkflowCtx | None = _workflow_ctx.get(None)
if ctx is not None:
return await ctx._wait_for_approval(timeout=timeout, form=form)
return await ctx._wait_for_approval(timeout=timeout, form=form, self_approval=self_approval)
raise RuntimeError("wait_for_approval can only be called inside a @workflow")
+7 -2
View File
@@ -632,7 +632,7 @@ workflow<T>(fn: (...args: any[]) => Promise<T>): void
* await step("notify", () => sendEmail(urls.approvalPage));
* const { value, approver } = await waitForApproval({ timeout: 3600 });
*/
waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
/**
* Process items in parallel with optional concurrency control.
@@ -1336,12 +1336,17 @@ async def sleep(seconds: int)
#
# Returns a dict with \`\`value\`\` (form data), \`\`approver\`\`, and \`\`approved\`\`.
#
# Args:
# 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).
#
# 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) -> dict
async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True) -> dict
# Process items in parallel with optional concurrency control.
#
+7 -2
View File
@@ -1605,7 +1605,7 @@ workflow<T>(fn: (...args: any[]) => Promise<T>): void
* await step("notify", () => sendEmail(urls.approvalPage));
* const { value, approver } = await waitForApproval({ timeout: 3600 });
*/
waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
/**
* Process items in parallel with optional concurrency control.
@@ -2309,12 +2309,17 @@ async def sleep(seconds: int)
#
# Returns a dict with ``value`` (form data), ``approver``, and ``approved``.
#
# Args:
# 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).
#
# 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) -> dict
async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True) -> dict
# Process items in parallel with optional concurrency control.
#
+6 -1
View File
@@ -648,12 +648,17 @@ async def sleep(seconds: int)
#
# Returns a dict with ``value`` (form data), ``approver``, and ``approved``.
#
# Args:
# 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).
#
# 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) -> dict
async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True) -> dict
# Process items in parallel with optional concurrency control.
#
@@ -481,7 +481,7 @@ workflow<T>(fn: (...args: any[]) => Promise<T>): void
* await step("notify", () => sendEmail(urls.approvalPage));
* const { value, approver } = await waitForApproval({ timeout: 3600 });
*/
waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
/**
* Process items in parallel with optional concurrency control.
@@ -610,7 +610,7 @@ workflow<T>(fn: (...args: any[]) => Promise<T>): void
* await step("notify", () => sendEmail(urls.approvalPage));
* const { value, approver } = await waitForApproval({ timeout: 3600 });
*/
waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
/**
* Process items in parallel with optional concurrency control.
@@ -608,7 +608,7 @@ workflow<T>(fn: (...args: any[]) => Promise<T>): void
* await step("notify", () => sendEmail(urls.approvalPage));
* const { value, approver } = await waitForApproval({ timeout: 3600 });
*/
waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
/**
* Process items in parallel with optional concurrency control.
@@ -614,7 +614,7 @@ workflow<T>(fn: (...args: any[]) => Promise<T>): void
* await step("notify", () => sendEmail(urls.approvalPage));
* const { value, approver } = await waitForApproval({ timeout: 3600 });
*/
waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
/**
* Process items in parallel with optional concurrency control.
@@ -575,7 +575,7 @@ workflow<T>(fn: (...args: any[]) => Promise<T>): void
* await step("notify", () => sendEmail(urls.approvalPage));
* const { value, approver } = await waitForApproval({ timeout: 3600 });
*/
waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
/**
* Process items in parallel with optional concurrency control.
@@ -783,12 +783,17 @@ async def sleep(seconds: int)
#
# Returns a dict with ``value`` (form data), ``approver``, and ``approved``.
#
# Args:
# 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).
#
# 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) -> dict
async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True) -> dict
# Process items in parallel with optional concurrency control.
#
+3
View File
@@ -1577,6 +1577,7 @@ export class WorkflowCtx {
_waitForApproval(options?: {
timeout?: number;
form?: object;
selfApproval?: boolean;
}): PromiseLike<{ value: any; approver: string; approved: boolean }> {
const key = this._allocKey("approval");
@@ -1597,6 +1598,7 @@ export class WorkflowCtx {
key,
timeout: options?.timeout ?? 1800,
form: options?.form,
self_approval_disabled: !(options?.selfApproval ?? true),
steps: [],
});
}
@@ -1842,6 +1844,7 @@ export function workflow<T>(fn: (...args: any[]) => Promise<T>) {
export function waitForApproval(options?: {
timeout?: number;
form?: object;
selfApproval?: boolean;
}): PromiseLike<{ value: any; approver: string; approved: boolean }> {
const ctx: WorkflowCtx | null = _workflowCtx ?? Reflect.get(globalThis, "__wmill_wf_ctx");
if (!ctx) {