mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-11 16:09:39 +00:00
feat: add a minimal skin for the approval page and slack/teams (#11061)
* feat: add an approval skin to the approval page and slack/teams messages Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KK2Ye4PizykReZVuMEm3m9 * chore: point ee-repo-ref at the teams approval skin commit Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KK2Ye4PizykReZVuMEm3m9 * fix: resolve the approval skin from the step awaiting approval Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KK2Ye4PizykReZVuMEm3m9 * fix: shorten the slack approval message to fit the button value limit Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KK2Ye4PizykReZVuMEm3m9 * fix: rename skins to detailed/minimal and keep long slack messages Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KK2Ye4PizykReZVuMEm3m9 * feat: title the minimal approval page from the step and flow summaries Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KK2Ye4PizykReZVuMEm3m9 * feat: let wait_for_approval set the description approvers see Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KK2Ye4PizykReZVuMEm3m9 * fix: keep a finished workflow's approval description, still gated Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KK2Ye4PizykReZVuMEm3m9 * fix: keep a login-required approval locked after the run moves on Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KK2Ye4PizykReZVuMEm3m9 * feat: hide the windmill version on the approval page Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KK2Ye4PizykReZVuMEm3m9 * chore: update ee-repo-ref to e92abc9d1fba3ba898640df0cfeb0af8a50849b4 This commit updates the EE repository reference after PR #786 was merged in windmill-ee-private. Previous ee-repo-ref: bf1766ff49458f62d3f11746f1a06893ae3c2325 New ee-repo-ref: e92abc9d1fba3ba898640df0cfeb0af8a50849b4 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Claude Opus 5
windmill-internal-app[bot]
parent
e62bfdcd8c
commit
63cb46d7bb
@@ -1 +1 @@
|
||||
fe2418ff4e5630d6ad3fd85cd2c865bf51c87a2a
|
||||
e92abc9d1fba3ba898640df0cfeb0af8a50849b4
|
||||
|
||||
@@ -16851,6 +16851,7 @@ paths:
|
||||
- can_approve
|
||||
- user_auth_required
|
||||
- approvers
|
||||
- skin
|
||||
properties:
|
||||
flow_id:
|
||||
type: string
|
||||
@@ -16883,6 +16884,16 @@ paths:
|
||||
hide_cancel:
|
||||
type: boolean
|
||||
description: whether to hide the cancel button in the UI
|
||||
skin:
|
||||
type: string
|
||||
enum: [detailed, minimal]
|
||||
description: how the approval page presents the request
|
||||
step_summary:
|
||||
type: string
|
||||
description: summary of the approval step, for the page title
|
||||
flow_summary:
|
||||
type: string
|
||||
description: summary of the flow or workflow the approval belongs to
|
||||
approvers:
|
||||
type: array
|
||||
items:
|
||||
|
||||
@@ -17,6 +17,7 @@ use std::str::FromStr;
|
||||
use uuid::Uuid;
|
||||
use windmill_common::cache;
|
||||
use windmill_common::error::Error;
|
||||
use windmill_common::flows::{ApprovalSkin, Suspend};
|
||||
use windmill_common::jobs::JobKind;
|
||||
use windmill_common::scripts::ScriptHash;
|
||||
|
||||
@@ -94,6 +95,17 @@ pub struct ApprovalFormDetails {
|
||||
pub message_str: String,
|
||||
pub urls: ResumeUrls,
|
||||
pub schema: Option<ResumeFormRow>,
|
||||
pub skin: ApprovalSkin,
|
||||
}
|
||||
|
||||
/// The suspended step an approval message is about, and the flow run it belongs to.
|
||||
struct ApprovalStep {
|
||||
created_by: String,
|
||||
created_at: chrono::NaiveDateTime,
|
||||
script_path: Option<String>,
|
||||
parent_job_id: Option<Uuid>,
|
||||
args: Option<sqlx::types::Json<Box<RawValue>>>,
|
||||
suspend: Option<Suspend>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
@@ -205,6 +217,90 @@ pub async fn get_approval_form_details(
|
||||
|
||||
tracing::debug!("Job ID: {:?}", job_id);
|
||||
|
||||
let ApprovalStep { created_by, created_at, script_path, parent_job_id, args, suspend } =
|
||||
fetch_approval_step(&db, w_id, job_id, flow_step_id).await?;
|
||||
|
||||
let schema = suspend.as_ref().map(|suspend| ResumeFormRow {
|
||||
resume_form: suspend.resume_form.clone(),
|
||||
hide_cancel: suspend.hide_cancel,
|
||||
});
|
||||
let skin = suspend.and_then(|s| s.skin).unwrap_or_default();
|
||||
|
||||
let bold_format = match format {
|
||||
MessageFormat::Slack => "*{}*",
|
||||
MessageFormat::Teams => "**{}**",
|
||||
};
|
||||
|
||||
let message_str = match skin {
|
||||
ApprovalSkin::Detailed => {
|
||||
let args_str = args.map_or("None".to_string(), |a| {
|
||||
serde_json::from_str::<serde_json::Value>(a.get())
|
||||
.ok()
|
||||
.and_then(|v| serde_json::to_string_pretty(&v).ok())
|
||||
.unwrap_or_else(|| a.get().to_string())
|
||||
});
|
||||
let parent_job_id_str = parent_job_id.map_or("None".to_string(), |id| id.to_string());
|
||||
let script_path_str = script_path.as_deref().unwrap_or("None");
|
||||
|
||||
let created_at_formatted = created_at.format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
|
||||
let mut message_str = format!(
|
||||
"A workflow has been suspended and is waiting for approval:\n\n\
|
||||
{}: {created_by}\n\n\
|
||||
{}: {created_at_formatted}\n\n\
|
||||
{}: {script_path_str}\n\n\
|
||||
{}:\n```\n{args_str}\n```\n\n\
|
||||
{}: {parent_job_id_str}\n\n",
|
||||
bold_format.replace("{}", "Created by"),
|
||||
bold_format.replace("{}", "Created at"),
|
||||
bold_format.replace("{}", "Script path"),
|
||||
bold_format.replace("{}", "Args"),
|
||||
bold_format.replace("{}", "Flow ID")
|
||||
);
|
||||
|
||||
// Append custom message if provided
|
||||
if let Some(msg) = message {
|
||||
message_str.push_str(msg);
|
||||
}
|
||||
message_str
|
||||
}
|
||||
ApprovalSkin::Minimal => format!(
|
||||
"{}\n\n{}: {created_by}",
|
||||
message.unwrap_or("Your approval is requested."),
|
||||
bold_format.replace("{}", "Requested by"),
|
||||
),
|
||||
};
|
||||
|
||||
tracing::debug!("Schema: {:#?}", schema);
|
||||
|
||||
Ok(ApprovalFormDetails { message_str, urls, schema, skin })
|
||||
}
|
||||
|
||||
/// The skin of the approval step `flow_step_id` of the flow running `job_id`. Falls back to
|
||||
/// the detailed skin when the step cannot be resolved, so a message is still sent.
|
||||
/// Reads through the unrestricted pool without an authorization check of its own: only the
|
||||
/// skin, which is not sensitive, leaves this function.
|
||||
pub(crate) async fn get_approval_step_skin(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
job_id: Uuid,
|
||||
flow_step_id: &str,
|
||||
) -> ApprovalSkin {
|
||||
match fetch_approval_step(db, w_id, job_id, Some(flow_step_id)).await {
|
||||
Ok(step) => step.suspend.and_then(|s| s.skin).unwrap_or_default(),
|
||||
Err(e) => {
|
||||
tracing::warn!("Could not resolve approval step {flow_step_id} of job {job_id}: {e}");
|
||||
ApprovalSkin::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_approval_step(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
job_id: Uuid,
|
||||
flow_step_id: Option<&str>,
|
||||
) -> Result<ApprovalStep, Error> {
|
||||
// TODO: do we have a helper function for this?
|
||||
let (job_kind, script_hash, raw_flow, parent_job_id, created_at, created_by, script_path, args) = sqlx::query!(
|
||||
"WITH job_info AS (
|
||||
@@ -240,17 +336,17 @@ pub async fn get_approval_form_details(
|
||||
job_id,
|
||||
&w_id
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.fetch_optional(db)
|
||||
.await
|
||||
.map_err(|e| Error::BadRequest(e.to_string()))?
|
||||
.ok_or_else(|| Error::BadRequest("This workflow is no longer running and has either already timed out or been cancelled or completed.".to_string()))
|
||||
.map(|r| (r.job_kind, r.script_hash, r.raw_flow, r.parent_job, r.created_at, r.created_by, r.script_path, r.args))?;
|
||||
|
||||
let flow_data = match cache::job::fetch_flow(&db, &job_kind, script_hash).await {
|
||||
let flow_data = match cache::job::fetch_flow(db, &job_kind, script_hash).await {
|
||||
Ok(data) => data,
|
||||
Err(_) => {
|
||||
if let Some(parent_job_id) = parent_job_id.as_ref() {
|
||||
cache::job::fetch_preview_flow(&db, parent_job_id, raw_flow).await?
|
||||
cache::job::fetch_preview_flow(db, parent_job_id, raw_flow).await?
|
||||
} else {
|
||||
return Err(Error::BadRequest(
|
||||
"This workflow is no longer running and has either already timed out or been cancelled or completed.".to_string(),
|
||||
@@ -265,49 +361,12 @@ pub async fn get_approval_form_details(
|
||||
|
||||
tracing::debug!("Module: {:#?}", module);
|
||||
|
||||
let schema = module.and_then(|module| {
|
||||
module.suspend.as_ref().map(|suspend| ResumeFormRow {
|
||||
resume_form: suspend.resume_form.clone(),
|
||||
hide_cancel: suspend.hide_cancel,
|
||||
})
|
||||
});
|
||||
|
||||
let args_str = args.map_or("None".to_string(), |a| {
|
||||
serde_json::from_str::<serde_json::Value>(a.get())
|
||||
.ok()
|
||||
.and_then(|v| serde_json::to_string_pretty(&v).ok())
|
||||
.unwrap_or_else(|| a.get().to_string())
|
||||
});
|
||||
let parent_job_id_str = parent_job_id.map_or("None".to_string(), |id| id.to_string());
|
||||
let script_path_str = script_path.as_deref().unwrap_or("None");
|
||||
|
||||
let created_at_formatted = created_at.format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
|
||||
let bold_format = match format {
|
||||
MessageFormat::Slack => "*{}*",
|
||||
MessageFormat::Teams => "**{}**",
|
||||
};
|
||||
|
||||
let mut message_str = format!(
|
||||
"A workflow has been suspended and is waiting for approval:\n\n\
|
||||
{}: {created_by}\n\n\
|
||||
{}: {created_at_formatted}\n\n\
|
||||
{}: {script_path_str}\n\n\
|
||||
{}:\n```\n{args_str}\n```\n\n\
|
||||
{}: {parent_job_id_str}\n\n",
|
||||
bold_format.replace("{}", "Created by"),
|
||||
bold_format.replace("{}", "Created at"),
|
||||
bold_format.replace("{}", "Script path"),
|
||||
bold_format.replace("{}", "Args"),
|
||||
bold_format.replace("{}", "Flow ID")
|
||||
);
|
||||
|
||||
// Append custom message if provided
|
||||
if let Some(msg) = message {
|
||||
message_str.push_str(msg);
|
||||
}
|
||||
|
||||
tracing::debug!("Schema: {:#?}", schema);
|
||||
|
||||
Ok(ApprovalFormDetails { message_str, urls, schema })
|
||||
Ok(ApprovalStep {
|
||||
created_by,
|
||||
created_at,
|
||||
script_path,
|
||||
parent_job_id,
|
||||
args,
|
||||
suspend: module.and_then(|m| m.suspend.clone()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -107,7 +107,10 @@ use windmill_common::{
|
||||
db::UserDB,
|
||||
error::{self, to_anyhow, Error},
|
||||
flow_status::{Approval, ApprovalConditions, FlowStatus, FlowStatusModule},
|
||||
flows::{add_virtual_items_if_necessary, resolve_maybe_value, FlowValue},
|
||||
flows::{
|
||||
add_virtual_items_if_necessary, resolve_maybe_value, ApprovalSkin, FlowModule, FlowValue,
|
||||
Suspend,
|
||||
},
|
||||
jobs::{script_path_to_payload, CompletedJob, JobKind, JobPayload, QueuedJob, RawCode},
|
||||
oauth2::HmacSha256,
|
||||
query_builders,
|
||||
@@ -4858,6 +4861,11 @@ struct ApprovalInfo {
|
||||
user_auth_required: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
hide_cancel: Option<bool>,
|
||||
skin: ApprovalSkin,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
step_summary: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
flow_summary: Option<String>,
|
||||
approvers: Vec<Approval>,
|
||||
/// Share-read-link token for the flow, minted only for callers allowed to view this
|
||||
/// approval. Lets an authenticated workspace-member approver open the run details of
|
||||
@@ -4911,6 +4919,48 @@ fn can_approve_step(
|
||||
}
|
||||
}
|
||||
|
||||
/// The latest approval step the run has passed: a step before the current `step` that ran
|
||||
/// rather than being skipped. Steps from `step` on don't count, because while an approval is
|
||||
/// pending the step after it already holds the `WaitingForEvents` status.
|
||||
fn last_reached_approval_step<'a>(
|
||||
flow: &'a FlowValue,
|
||||
status: &FlowStatus,
|
||||
) -> Option<&'a FlowModule> {
|
||||
flow.modules
|
||||
.iter()
|
||||
.zip(status.modules.iter())
|
||||
.take(usize::try_from(status.step).unwrap_or(0))
|
||||
.rev()
|
||||
.filter(|(_, m)| matches!(m, FlowStatusModule::Success { skipped: false, .. }))
|
||||
.map(|(module, _)| module)
|
||||
.find(|module| module.suspend.is_some())
|
||||
}
|
||||
|
||||
/// The approval conditions a step's own settings give, as the worker records them when the step
|
||||
/// suspends. The worker drops them from the run once the step is approved, so a run that has
|
||||
/// moved on is gated by these. Groups computed by an expression can't be re-evaluated outside
|
||||
/// the run, so such a step falls back to any signed-in user.
|
||||
fn approval_conditions_from_settings(suspend: &Suspend) -> Option<ApprovalConditions> {
|
||||
let user_auth_required = suspend.user_auth_required.unwrap_or(false);
|
||||
let self_approval_disabled = suspend.self_approval_disabled.unwrap_or(false);
|
||||
if !user_auth_required && !self_approval_disabled {
|
||||
return None;
|
||||
}
|
||||
let user_groups_required = match &suspend.user_groups_required {
|
||||
Some(InputTransform::Static { value }) if user_auth_required => {
|
||||
serde_json::from_str(value.get()).unwrap_or_default()
|
||||
}
|
||||
_ => vec![],
|
||||
};
|
||||
Some(ApprovalConditions { user_auth_required, user_groups_required, self_approval_disabled })
|
||||
}
|
||||
|
||||
/// How the approval step presents itself on the approval page.
|
||||
struct ApprovalStepView {
|
||||
skin: ApprovalSkin,
|
||||
summary: Option<String>,
|
||||
}
|
||||
|
||||
async fn get_approval_info(
|
||||
OptAuthed(opt_authed): OptAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
@@ -4939,13 +4989,33 @@ async fn get_approval_info(
|
||||
script_path: Option<String>,
|
||||
email: String,
|
||||
flow_status: Option<serde_json::Value>,
|
||||
workflow_as_code_status: Option<serde_json::Value>,
|
||||
// `v2_job_status` only holds a run that hasn't finished, so the fields below also read
|
||||
// the completed run's status: a finished run's page keeps its skin and, for workflows as
|
||||
// code, its description, still gated by the approval conditions the run had.
|
||||
completed_flow_status: Option<serde_json::Value>,
|
||||
is_wac: bool,
|
||||
wac_approval: Option<serde_json::Value>,
|
||||
approval_conditions: Option<serde_json::Value>,
|
||||
flow_summary: Option<String>,
|
||||
}
|
||||
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
|
||||
s.flow_status,
|
||||
c.flow_status AS completed_flow_status,
|
||||
COALESCE(s.workflow_as_code_status, c.workflow_as_code_status) IS NOT NULL
|
||||
AS is_wac,
|
||||
COALESCE(s.workflow_as_code_status, c.workflow_as_code_status)->'_approval'
|
||||
AS wac_approval,
|
||||
COALESCE(s.flow_status, c.flow_status)->'approval_conditions'
|
||||
AS approval_conditions,
|
||||
NULLIF(COALESCE(f.summary, sc.summary), '') AS flow_summary
|
||||
FROM v2_job j
|
||||
LEFT JOIN v2_job_status s ON s.id = j.id
|
||||
LEFT JOIN v2_job_completed c ON c.id = j.id
|
||||
LEFT JOIN flow f
|
||||
ON j.kind = 'flow' AND f.workspace_id = j.workspace_id AND f.path = j.runnable_path
|
||||
LEFT JOIN script sc
|
||||
ON j.kind = 'script' AND sc.workspace_id = j.workspace_id AND sc.hash = j.runnable_id
|
||||
WHERE j.id = $1 AND j.workspace_id = $2",
|
||||
)
|
||||
.bind(&job_id)
|
||||
@@ -4954,31 +5024,31 @@ async fn get_approval_info(
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound(format!("Job {job_id} not found")))?;
|
||||
|
||||
let is_wac = row.workflow_as_code_status.is_some();
|
||||
let is_wac = row.is_wac;
|
||||
let run_ac = row
|
||||
.approval_conditions
|
||||
.as_ref()
|
||||
.and_then(|v| serde_json::from_value::<ApprovalConditions>(v.clone()).ok());
|
||||
|
||||
// Extract approval info based on WAC vs classic flow
|
||||
let (form_schema, description, default_args, enums, approval_conditions, hide_cancel) =
|
||||
let (form_schema, description, default_args, enums, approval_conditions, hide_cancel, step) =
|
||||
if is_wac {
|
||||
let approval_meta = row
|
||||
.workflow_as_code_status
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("_approval"));
|
||||
let approval_meta = row.wac_approval.as_ref();
|
||||
let form = approval_meta.and_then(|m| m.get("form").cloned());
|
||||
let default_args = approval_meta.and_then(|m| m.get("default_args").cloned());
|
||||
let enums = approval_meta.and_then(|m| m.get("enums").cloned());
|
||||
let description = approval_meta.and_then(|m| m.get("description").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, description, default_args, enums, ac, None)
|
||||
let skin = approval_meta
|
||||
.and_then(|m| m.get("skin"))
|
||||
.and_then(|v| serde_json::from_value::<ApprovalSkin>(v.clone()).ok())
|
||||
.unwrap_or_default();
|
||||
let step = Some(ApprovalStepView { skin, summary: None });
|
||||
(form, description, default_args, enums, run_ac, None, step)
|
||||
} 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));
|
||||
@@ -5038,6 +5108,28 @@ async fn get_approval_info(
|
||||
.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));
|
||||
let completed_fs = row
|
||||
.completed_flow_status
|
||||
.as_ref()
|
||||
.filter(|_| fs.is_none())
|
||||
.and_then(|v| serde_json::from_value::<FlowStatus>(v.clone()).ok());
|
||||
let approval_module = raw_flow
|
||||
.as_ref()
|
||||
.zip(fs.as_ref().or(completed_fs.as_ref()))
|
||||
.and_then(|(flow, status)| last_reached_approval_step(flow, status));
|
||||
let ac = run_ac.or_else(|| {
|
||||
approval_module
|
||||
.and_then(|module| module.suspend.as_ref())
|
||||
.and_then(approval_conditions_from_settings)
|
||||
});
|
||||
let step = approval_module.map(|module| ApprovalStepView {
|
||||
skin: module
|
||||
.suspend
|
||||
.as_ref()
|
||||
.and_then(|s| s.skin)
|
||||
.unwrap_or_default(),
|
||||
summary: module.summary.clone().filter(|s| !s.trim().is_empty()),
|
||||
});
|
||||
|
||||
// Fetch description, default_args, and enums from the step's completed job result
|
||||
let step_job_id = fs
|
||||
@@ -5061,9 +5153,12 @@ async fn get_approval_info(
|
||||
(None, None, None)
|
||||
};
|
||||
|
||||
(form, desc, default_args, enums, ac, hc)
|
||||
(form, desc, default_args, enums, ac, hc, step)
|
||||
};
|
||||
|
||||
let skin = step.as_ref().map(|s| s.skin).unwrap_or_default();
|
||||
let step_summary = step.and_then(|s| s.summary);
|
||||
|
||||
let user_auth_required = approval_conditions
|
||||
.as_ref()
|
||||
.map(|ac| ac.user_auth_required)
|
||||
@@ -5093,6 +5188,9 @@ async fn get_approval_info(
|
||||
can_approve: false,
|
||||
user_auth_required,
|
||||
hide_cancel: None,
|
||||
skin,
|
||||
step_summary: None,
|
||||
flow_summary: None,
|
||||
approvers: vec![],
|
||||
view_token: None,
|
||||
}));
|
||||
@@ -5128,6 +5226,9 @@ async fn get_approval_info(
|
||||
can_approve,
|
||||
user_auth_required,
|
||||
hide_cancel,
|
||||
skin,
|
||||
step_summary,
|
||||
flow_summary: row.flow_summary,
|
||||
approvers,
|
||||
view_token,
|
||||
}))
|
||||
@@ -11908,4 +12009,58 @@ mod approval_view_gate_tests {
|
||||
"trigger@example.com"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_step_is_the_last_one_passed() {
|
||||
let flow: FlowValue = serde_json::from_value(serde_json::json!({ "modules": [
|
||||
{ "id": "a", "value": { "type": "identity" }, "suspend": {} },
|
||||
{ "id": "b", "value": { "type": "identity" }, "suspend": {} },
|
||||
{ "id": "c", "value": { "type": "identity" } }
|
||||
]}))
|
||||
.unwrap();
|
||||
let step_at = |step: i32, types: [(&str, bool); 3]| {
|
||||
let mut status = FlowStatus::new(&flow);
|
||||
status.step = step;
|
||||
status.modules = ["a", "b", "c"]
|
||||
.into_iter()
|
||||
.zip(types)
|
||||
.map(|(id, (kind, skipped))| {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"type": kind, "id": id, "job": Uuid::nil(), "count": 1,
|
||||
"failed_retries": [], "skipped": skipped
|
||||
}))
|
||||
.unwrap()
|
||||
})
|
||||
.collect();
|
||||
last_reached_approval_step(&flow, &status).map(|module| module.id.clone())
|
||||
};
|
||||
let waiting = ("WaitingForEvents", false);
|
||||
let pending = ("WaitingForPriorSteps", false);
|
||||
let ran = ("Success", false);
|
||||
let skipped = ("Success", true);
|
||||
// Awaiting a's approval: b, itself an approval step, already holds `WaitingForEvents`.
|
||||
assert_eq!(step_at(1, [ran, waiting, pending]).as_deref(), Some("a"));
|
||||
assert_eq!(step_at(2, [ran, ran, waiting]).as_deref(), Some("b"));
|
||||
assert_eq!(step_at(3, [ran, skipped, ran]).as_deref(), Some("a"));
|
||||
assert_eq!(step_at(0, [pending, pending, pending]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approved_step_stays_gated_by_its_settings() {
|
||||
let from_settings = |suspend: serde_json::Value| {
|
||||
approval_conditions_from_settings(&serde_json::from_value(suspend).unwrap())
|
||||
};
|
||||
let login = from_settings(serde_json::json!({
|
||||
"user_auth_required": true,
|
||||
"user_groups_required": { "type": "static", "value": ["approvers"] }
|
||||
}));
|
||||
assert!(!can_view(
|
||||
&None,
|
||||
&login,
|
||||
Some("f/team/flow"),
|
||||
"trigger@example.com"
|
||||
));
|
||||
assert_eq!(login.unwrap().user_groups_required, ["approvers"]);
|
||||
assert!(from_settings(serde_json::json!({})).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,19 +13,25 @@ use sha2::Sha256;
|
||||
use sqlx::types::Uuid;
|
||||
use std::collections::HashMap;
|
||||
use windmill_common::error::{to_anyhow, Error};
|
||||
use windmill_common::flows::ApprovalSkin;
|
||||
use windmill_common::utils::truncate_with_ellipsis;
|
||||
use windmill_common::variables::{get_secret_value_as_admin, get_workspace_key};
|
||||
|
||||
use crate::db::{ApiAuthed, DB};
|
||||
use crate::jobs::{QueryApprover, ResumeUrls};
|
||||
use crate::{
|
||||
approvals::{
|
||||
extract_w_id_from_resume_url, handle_resume_action, ApprovalFormDetails, FieldType,
|
||||
MessageFormat, QueryButtonText, QueryDefaultArgsJson, QueryDynamicEnumJson,
|
||||
QueryFlowStepId, QueryMessage, ResumeFormField, ResumeSchema,
|
||||
extract_w_id_from_resume_url, get_approval_step_skin, handle_resume_action,
|
||||
ApprovalFormDetails, FieldType, MessageFormat, QueryButtonText, QueryDefaultArgsJson,
|
||||
QueryDynamicEnumJson, QueryFlowStepId, QueryMessage, ResumeFormField, ResumeSchema,
|
||||
},
|
||||
auth::OptTokened,
|
||||
};
|
||||
|
||||
// Slack rejects a button value over 2000 characters, and with it the whole post. The button value
|
||||
// carries the message on to the modal, so the message is shortened to fit.
|
||||
const SLACK_BUTTON_VALUE_MAX_CHARS: usize = 2000;
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct SlackFormData {
|
||||
payload: String,
|
||||
@@ -127,6 +133,9 @@ struct PrivateMetadata {
|
||||
// HMAC over (w_id, resource_path) keyed on the workspace key; minted when the modal is
|
||||
// built, required by `handle_submission` before the resource_path is decrypted.
|
||||
signature: Option<String>,
|
||||
// Only selects the wording of the updated channel message, so it is left unsigned.
|
||||
#[serde(default)]
|
||||
skin: ApprovalSkin,
|
||||
}
|
||||
|
||||
// Opportunistic transport-level check: when `SLACK_SIGNING_SECRET` is configured we verify
|
||||
@@ -432,6 +441,7 @@ async fn handle_submission(
|
||||
let container: Container = private_metadata.container;
|
||||
let hide_cancel = private_metadata.hide_cancel;
|
||||
let signature = private_metadata.signature;
|
||||
let skin = private_metadata.skin;
|
||||
|
||||
// If hide_cancel is true, we don't need to extract information from the private_metadata
|
||||
if hide_cancel.unwrap_or(false) && action == "cancel" {
|
||||
@@ -463,7 +473,7 @@ async fn handle_submission(
|
||||
tracing::warn!("Failed to resolve slack token for {w_id}/{resource_path}: {e:#}");
|
||||
Error::BadRequest("Invalid Slack callback request".to_string())
|
||||
})?;
|
||||
update_original_slack_message(action, slack_token, container).await?;
|
||||
update_original_slack_message(action, slack_token, container, skin).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -475,14 +485,19 @@ async fn transform_schemas(
|
||||
required: Option<Vec<String>>,
|
||||
default_args_json: Option<&serde_json::Value>,
|
||||
dynamic_enums_json: Option<&serde_json::Value>,
|
||||
skin: ApprovalSkin,
|
||||
) -> Result<serde_json::Value, Error> {
|
||||
tracing::debug!("Resume urls: {:#?}", urls);
|
||||
|
||||
let link_label = match skin {
|
||||
ApprovalSkin::Detailed => "Flow suspension details",
|
||||
ApprovalSkin::Minimal => "View in Windmill",
|
||||
};
|
||||
let mut blocks = vec![serde_json::json!({
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": format!("{}\n<{}|Flow suspension details>", text, urls.approvalPage),
|
||||
"text": format!("{}\n<{}|{link_label}>", text, urls.approvalPage),
|
||||
}
|
||||
})];
|
||||
|
||||
@@ -918,10 +933,6 @@ async fn send_slack_message(
|
||||
value["approver"] = serde_json::json!(approver);
|
||||
}
|
||||
|
||||
if let Some(message) = message {
|
||||
value["message"] = serde_json::json!(message);
|
||||
}
|
||||
|
||||
if let Some(default_args_json) = default_args_json {
|
||||
value["default_args_json"] = default_args_json.clone();
|
||||
}
|
||||
@@ -950,33 +961,8 @@ async fn send_slack_message(
|
||||
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
|
||||
value["signature"] = serde_json::json!(signature);
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"channel": channel_id,
|
||||
"text": "A flow has been suspended. Please approve or reject the flow.",
|
||||
"blocks": [
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": "A flow has been suspended. Please approve or reject the flow."
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "actions",
|
||||
"elements": [
|
||||
{
|
||||
"type": "button",
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": "View"
|
||||
},
|
||||
"action_id": "open_modal",
|
||||
"value": value.to_string()
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
let skin = get_approval_step_skin(db, w_id, job_id, flow_step_id).await;
|
||||
let payload = channel_message_payload(channel_id, skin, message, value);
|
||||
|
||||
tracing::debug!("Payload: {:?}", payload);
|
||||
|
||||
@@ -1000,6 +986,88 @@ async fn send_slack_message(
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
/// The channel post announcing the approval. Its button hands `button_value` to the modal, with
|
||||
/// `message` added, shortened to what Slack's button value limit leaves room for.
|
||||
fn channel_message_payload(
|
||||
channel_id: &str,
|
||||
skin: ApprovalSkin,
|
||||
message: Option<&str>,
|
||||
mut button_value: serde_json::Value,
|
||||
) -> serde_json::Value {
|
||||
let message = message.map(|m| message_fitting_button_value(&button_value, m));
|
||||
if let Some(message) = &message {
|
||||
button_value["message"] = serde_json::json!(message);
|
||||
}
|
||||
let (text, section, button_label) = match skin {
|
||||
ApprovalSkin::Detailed => {
|
||||
let text = "A flow has been suspended. Please approve or reject the flow.";
|
||||
(text, text.to_string(), "View")
|
||||
}
|
||||
ApprovalSkin::Minimal => {
|
||||
let mut section = "*Approval requested*".to_string();
|
||||
if let Some(message) = &message {
|
||||
section.push('\n');
|
||||
section.push_str(message);
|
||||
}
|
||||
("Approval requested", section, "Review")
|
||||
}
|
||||
};
|
||||
|
||||
serde_json::json!({
|
||||
"channel": channel_id,
|
||||
"text": text,
|
||||
"blocks": [
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": section
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "actions",
|
||||
"elements": [
|
||||
{
|
||||
"type": "button",
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": button_label
|
||||
},
|
||||
"action_id": "open_modal",
|
||||
"value": button_value.to_string()
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
/// The longest prefix of `message` that keeps `button_value` carrying it within Slack's limit.
|
||||
fn message_fitting_button_value(button_value: &serde_json::Value, message: &str) -> String {
|
||||
let mut with_message = button_value.clone();
|
||||
let mut fits = |max_chars: usize| {
|
||||
let fitted = truncate_with_ellipsis(message, max_chars);
|
||||
with_message["message"] = serde_json::json!(fitted);
|
||||
(with_message.to_string().chars().count() <= SLACK_BUTTON_VALUE_MAX_CHARS).then_some(fitted)
|
||||
};
|
||||
if let Some(whole) = fits(usize::MAX) {
|
||||
return whole;
|
||||
}
|
||||
// Searched on the serialized length, which escaping makes longer than the raw prefix, and
|
||||
// which grows with every character kept.
|
||||
let (mut shortest, mut longest) =
|
||||
(0, message.chars().count().min(SLACK_BUTTON_VALUE_MAX_CHARS));
|
||||
while shortest < longest {
|
||||
let mid = (shortest + longest + 1) / 2;
|
||||
if fits(mid).is_some() {
|
||||
shortest = mid;
|
||||
} else {
|
||||
longest = mid - 1;
|
||||
}
|
||||
}
|
||||
fits(shortest).unwrap_or_else(|| truncate_with_ellipsis(message, 0))
|
||||
}
|
||||
|
||||
async fn get_modal_blocks(
|
||||
db: DB,
|
||||
w_id: &str,
|
||||
@@ -1034,7 +1102,7 @@ async fn get_modal_blocks(
|
||||
)
|
||||
.await?;
|
||||
|
||||
let ApprovalFormDetails { message_str, urls, schema } = approval_details;
|
||||
let ApprovalFormDetails { message_str, urls, schema, skin } = approval_details;
|
||||
|
||||
// Get the card content
|
||||
let card_content = transform_schemas(
|
||||
@@ -1063,6 +1131,7 @@ async fn get_modal_blocks(
|
||||
}),
|
||||
default_args_json,
|
||||
dynamic_enums_json,
|
||||
skin,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -1077,6 +1146,7 @@ async fn get_modal_blocks(
|
||||
resume_button_text,
|
||||
cancel_button_text,
|
||||
&private_metadata_signature,
|
||||
skin,
|
||||
)))
|
||||
}
|
||||
|
||||
@@ -1090,27 +1160,32 @@ fn construct_payload(
|
||||
resume_button_text: Option<&str>,
|
||||
cancel_button_text: Option<&str>,
|
||||
signature: &str,
|
||||
skin: ApprovalSkin,
|
||||
) -> serde_json::Value {
|
||||
let (title, resume_label, cancel_label) = match skin {
|
||||
ApprovalSkin::Detailed => ("Workflow Suspended", "Resume Workflow", "Cancel Workflow"),
|
||||
ApprovalSkin::Minimal => ("Approval request", "Approve", "Reject"),
|
||||
};
|
||||
let mut view = serde_json::json!({
|
||||
"type": "modal",
|
||||
"callback_id": "submit_form",
|
||||
"notify_on_close": true,
|
||||
"title": {
|
||||
"type": "plain_text",
|
||||
"text": "Workflow Suspended"
|
||||
"text": title
|
||||
},
|
||||
"blocks": blocks,
|
||||
"submit": {
|
||||
"type": "plain_text",
|
||||
"text": resume_button_text.unwrap_or("Resume Workflow")
|
||||
"text": resume_button_text.unwrap_or(resume_label)
|
||||
},
|
||||
"private_metadata": serde_json::json!({ "resume_url": resume_url, "resource_path": resource_path, "container": container, "hide_cancel": hide_cancel, "signature": signature }).to_string(),
|
||||
"private_metadata": serde_json::json!({ "resume_url": resume_url, "resource_path": resource_path, "container": container, "hide_cancel": hide_cancel, "signature": signature, "skin": skin }).to_string(),
|
||||
});
|
||||
|
||||
if !hide_cancel {
|
||||
view["close"] = serde_json::json!({
|
||||
"type": "plain_text",
|
||||
"text": cancel_button_text.unwrap_or("Cancel Workflow")
|
||||
"text": cancel_button_text.unwrap_or(cancel_label)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1193,11 +1268,13 @@ async fn update_original_slack_message(
|
||||
action: &str,
|
||||
token: String,
|
||||
container: Container,
|
||||
skin: ApprovalSkin,
|
||||
) -> Result<(), Error> {
|
||||
let message = if action == "resume" {
|
||||
"\n\n*Workflow has been resumed!* :white_check_mark:"
|
||||
} else {
|
||||
"\n\n*Workflow has been canceled!* :x:"
|
||||
let message = match (skin, action == "resume") {
|
||||
(ApprovalSkin::Detailed, true) => "\n\n*Workflow has been resumed!* :white_check_mark:",
|
||||
(ApprovalSkin::Detailed, false) => "\n\n*Workflow has been canceled!* :x:",
|
||||
(ApprovalSkin::Minimal, true) => "*Approved* :white_check_mark:",
|
||||
(ApprovalSkin::Minimal, false) => "*Rejected* :x:",
|
||||
};
|
||||
|
||||
let final_blocks = vec![serde_json::json!({
|
||||
@@ -1242,3 +1319,66 @@ async fn update_original_slack_message(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn long_message_keeps_the_channel_post_within_slack_limits() {
|
||||
let button_value = serde_json::json!({
|
||||
"w_id": "demo",
|
||||
"job_id": Uuid::nil(),
|
||||
"path": "u/admin/slack",
|
||||
"channel": "C0123456789",
|
||||
"flow_step_id": "a",
|
||||
"signature": "f".repeat(64),
|
||||
});
|
||||
let carried = |skin, message: &str| {
|
||||
let payload = channel_message_payload("C1", skin, Some(message), button_value.clone());
|
||||
let button = payload["blocks"][1]["elements"][0]["value"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
assert!(button.chars().count() <= SLACK_BUTTON_VALUE_MAX_CHARS);
|
||||
let section = payload["blocks"][0]["text"]["text"].as_str().unwrap();
|
||||
assert!(section.chars().count() <= 3000);
|
||||
serde_json::from_str::<ModalActionValue>(&button)
|
||||
.unwrap()
|
||||
.message
|
||||
.unwrap()
|
||||
};
|
||||
// Quotes and newlines each cost two characters once escaped into the button value.
|
||||
let message = "Expense \"offsite\" line\n".repeat(1000);
|
||||
for skin in [ApprovalSkin::Detailed, ApprovalSkin::Minimal] {
|
||||
let kept = carried(skin, &message);
|
||||
let kept = kept.strip_suffix("...").unwrap();
|
||||
assert!(message.starts_with(kept));
|
||||
assert!(kept.chars().count() > 1_000);
|
||||
assert_eq!(carried(skin, "Short message"), "Short message");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimal_skin_survives_the_modal_round_trip() {
|
||||
let container = Container { message_ts: "1".to_string(), channel_id: "C1".to_string() };
|
||||
let payload = construct_payload(
|
||||
serde_json::json!([]),
|
||||
false,
|
||||
"trigger",
|
||||
"https://example.com/resume",
|
||||
"u/admin/slack",
|
||||
container,
|
||||
None,
|
||||
None,
|
||||
"signature",
|
||||
ApprovalSkin::Minimal,
|
||||
);
|
||||
let view = &payload["view"];
|
||||
assert_eq!(view["submit"]["text"], "Approve");
|
||||
assert_eq!(view["close"]["text"], "Reject");
|
||||
let metadata: PrivateMetadata =
|
||||
serde_json::from_str(view["private_metadata"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(metadata.skin, ApprovalSkin::Minimal);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -538,6 +538,20 @@ pub struct Suspend {
|
||||
pub hide_cancel: Option<bool>,
|
||||
#[serde(skip_serializing_if = "false_or_empty")]
|
||||
pub continue_on_disapprove_timeout: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub skin: Option<ApprovalSkin>,
|
||||
}
|
||||
|
||||
/// How an approval request is presented, on the approval page and in Slack/Teams messages.
|
||||
#[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ApprovalSkin {
|
||||
Minimal,
|
||||
/// A skin this server does not know renders as the detailed one rather than failing to
|
||||
/// deserialize the whole flow, so a flow authored against a newer version still runs.
|
||||
#[default]
|
||||
#[serde(other)]
|
||||
Detailed,
|
||||
}
|
||||
|
||||
fn false_or_empty(v: &Option<bool>) -> bool {
|
||||
@@ -1365,6 +1379,23 @@ mod tests {
|
||||
assert_eq!(val.modules.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suspend_skin_unknown_value_falls_back_to_detailed() {
|
||||
let skin_of = |skin: &str| {
|
||||
let val: FlowValue = serde_json::from_value(json!({
|
||||
"modules": [{
|
||||
"id": "a",
|
||||
"value": {"type": "identity"},
|
||||
"suspend": {"required_events": 1, "skin": skin}
|
||||
}]
|
||||
}))
|
||||
.unwrap();
|
||||
val.modules[0].suspend.as_ref().unwrap().skin
|
||||
};
|
||||
assert_eq!(skin_of("minimal"), Some(ApprovalSkin::Minimal));
|
||||
assert_eq!(skin_of("not_a_skin_yet"), Some(ApprovalSkin::Detailed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_tool_keeps_description_through_locking() {
|
||||
// #10244: the dependency job rebuilds each tool from its locked FlowModule; the
|
||||
|
||||
@@ -1975,7 +1975,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, self_approval_disabled: dispatch.self_approval_disabled }};
|
||||
return {{ type: "approval", key: dispatch.key, timeout: dispatch.timeout, form: dispatch.form, self_approval_disabled: dispatch.self_approval_disabled, skin: dispatch.skin, description: dispatch.description }};
|
||||
}}
|
||||
if (dispatch.mode === "sleep") {{
|
||||
return {{ type: "sleep", key: dispatch.key, seconds: dispatch.seconds }};
|
||||
@@ -3206,7 +3206,7 @@ pub async fn handle_wac_v2_output(
|
||||
job.id, num_steps
|
||||
)))
|
||||
}
|
||||
WacOutput::Approval { key, timeout, form, self_approval_disabled } => {
|
||||
WacOutput::Approval { key, timeout, form, self_approval_disabled, skin, description } => {
|
||||
let db = match conn {
|
||||
Connection::Sql(db) => db,
|
||||
_ => {
|
||||
@@ -3322,15 +3322,19 @@ pub async fn handle_wac_v2_output(
|
||||
};
|
||||
|
||||
// Store approval form metadata for the approval page endpoint
|
||||
let approval_meta = serde_json::json!({
|
||||
let mut approval_meta = serde_json::json!({
|
||||
"key": key,
|
||||
"form": form,
|
||||
"timeout": timeout_secs as u32,
|
||||
"self_approval_disabled": sad,
|
||||
"skin": skin.unwrap_or_default(),
|
||||
"resume": resume_url,
|
||||
"cancel": cancel_url,
|
||||
"approvalPage": approval_page_url,
|
||||
});
|
||||
if let Some(description) = description.filter(|d| !d.is_null()) {
|
||||
approval_meta["description"] = description;
|
||||
}
|
||||
sqlx::query(
|
||||
"UPDATE v2_job_status SET workflow_as_code_status = jsonb_set(
|
||||
COALESCE(workflow_as_code_status, '{}'::jsonb),
|
||||
|
||||
@@ -46,6 +46,10 @@ pub enum WacOutput {
|
||||
form: Option<Value>,
|
||||
#[serde(default)]
|
||||
self_approval_disabled: Option<bool>,
|
||||
#[serde(default)]
|
||||
skin: Option<windmill_common::flows::ApprovalSkin>,
|
||||
#[serde(default)]
|
||||
description: Option<Value>,
|
||||
},
|
||||
/// Server-side sleep — suspend the workflow for a duration without holding a worker.
|
||||
#[serde(rename = "sleep")]
|
||||
|
||||
Generated
+31
-7
File diff suppressed because one or more lines are too long
@@ -4,7 +4,7 @@
|
||||
anonymous usage-stats payload. It answers "does anyone use this, and which variant do they pick"
|
||||
without any identifying data leaving the instance.
|
||||
|
||||
It currently carries 48 registered actions across eighteen features (`ai_session`, `ai_chat`,
|
||||
It currently carries 49 registered actions across eighteen features (`ai_session`, `ai_chat`,
|
||||
`ai_fix`, `ai_agent`, `ai_agent_eval`, `app_sandbox`, `datatable`, `flow_editor`, `flow_run`,
|
||||
`flow_step`, `home`, `run_form`, `debugger`, `trigger`, `command_script`, `hub_script`,
|
||||
`usage_meter`, `sso_groups_claim`). Nearly all of the
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
centerVertically?: boolean
|
||||
loading?: boolean
|
||||
containOverflow?: boolean
|
||||
/** The Windmill version and update notice in the header. */
|
||||
showVersion?: boolean
|
||||
children?: import('svelte').Snippet
|
||||
}
|
||||
|
||||
@@ -25,6 +27,7 @@
|
||||
centerVertically = true,
|
||||
loading = false,
|
||||
containOverflow = false,
|
||||
showVersion = true,
|
||||
children
|
||||
}: Props = $props()
|
||||
|
||||
@@ -82,5 +85,5 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<LoginPageHeader />
|
||||
<LoginPageHeader {showVersion} />
|
||||
</div>
|
||||
|
||||
@@ -1083,10 +1083,11 @@
|
||||
loaded, whether SSO logins evaluate an IdP groups claim (SAML or OIDC) and change a
|
||||
membership, the plan tier and quota shown when the execution meter is opened, whether
|
||||
app sandbox isolation is turned on, whether a step's workspace script is edited from
|
||||
the flow editor, how data tables and their migrations are set up and used, how often
|
||||
an empty workspace home is seen, how often the home page’s create menu and hub-project
|
||||
picker are opened and from which entry point, and the name of any public hub project
|
||||
imported from the home page and how far that import got, last 30 days)</li
|
||||
the flow editor, which skin approval steps are given, how data tables and their
|
||||
migrations are set up and used, how often an empty workspace home is seen, how often
|
||||
the home page’s create menu and hub-project picker are opened and from which entry
|
||||
point, and the name of any public hub project imported from the home page and how far
|
||||
that import got, last 30 days)</li
|
||||
>
|
||||
<li
|
||||
>feature adoption (counts of which flow, script, trigger, worker and data table
|
||||
@@ -1146,10 +1147,11 @@
|
||||
loaded, whether SSO logins evaluate an IdP groups claim (SAML or OIDC) and change a
|
||||
membership, the plan tier and quota shown when the execution meter is opened, whether
|
||||
app sandbox isolation is turned on, whether a step's workspace script is edited from
|
||||
the flow editor, how data tables and their migrations are set up and used, how often
|
||||
an empty workspace home is seen, how often the home page’s create menu and hub-project
|
||||
picker are opened and from which entry point, and the name of any public hub project
|
||||
imported from the home page and how far that import got, last 30 days)</li
|
||||
the flow editor, which skin approval steps are given, how data tables and their
|
||||
migrations are set up and used, how often an empty workspace home is seen, how often
|
||||
the home page’s create menu and hub-project picker are opened and from which entry
|
||||
point, and the name of any public hub project imported from the home page and how far
|
||||
that import got, last 30 days)</li
|
||||
>
|
||||
<li
|
||||
>feature adoption (counts of which flow, script, trigger, worker and data table
|
||||
|
||||
@@ -9,9 +9,10 @@
|
||||
interface Props {
|
||||
/** Off for the login page, which puts the mark and the instance name in the middle. */
|
||||
showBrand?: boolean
|
||||
showVersion?: boolean
|
||||
}
|
||||
|
||||
let { showBrand = true }: Props = $props()
|
||||
let { showBrand = true, showVersion = true }: Props = $props()
|
||||
</script>
|
||||
|
||||
<div class="absolute top-0 inset-x-0 flex items-center justify-between gap-2 px-4 py-2">
|
||||
@@ -31,9 +32,11 @@
|
||||
<div class="flex flex-row gap-2 text-2xs text-gray-800 italic">
|
||||
<DarkModeToggle forcedDarkMode={false} />
|
||||
|
||||
<div class="font-mono flex-col flex p-2 justify-center">
|
||||
<Version />
|
||||
<Uptodate />
|
||||
</div>
|
||||
{#if showVersion}
|
||||
<div class="font-mono flex-col flex p-2 justify-center">
|
||||
<Version />
|
||||
<Uptodate />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
<script lang="ts">
|
||||
import type { GetApprovalInfoResponse, Job } from '$lib/gen'
|
||||
import { Alert, Badge, Button } from '$lib/components/common'
|
||||
import type { BadgeColor } from '$lib/components/common'
|
||||
import DisplayResult from '$lib/components/DisplayResult.svelte'
|
||||
import SchemaForm from '$lib/components/SchemaForm.svelte'
|
||||
import Login from '$lib/components/Login.svelte'
|
||||
import TimeAgo from '$lib/components/TimeAgo.svelte'
|
||||
import { enterpriseLicense, userStore } from '$lib/stores'
|
||||
import { emptyString } from '$lib/utils'
|
||||
import { mergeSchema } from '$lib/common'
|
||||
import { CheckCircle2, CircleSlash, ExternalLink, XCircle } from 'lucide-svelte'
|
||||
|
||||
interface Props {
|
||||
approvalInfo: GetApprovalInfoResponse & { enums?: Record<string, unknown> }
|
||||
job: Job | undefined
|
||||
completed: boolean
|
||||
actionTaken: 'approved' | 'denied' | undefined
|
||||
loading: boolean
|
||||
schema: any
|
||||
hasForm: boolean
|
||||
args: any
|
||||
valid: boolean
|
||||
isLocked: boolean
|
||||
isSelfApprovalBypass: boolean
|
||||
isWorkspaceMember: boolean
|
||||
runDetailsHref: string
|
||||
rd: string
|
||||
onApprove: () => void
|
||||
onReject: () => void
|
||||
}
|
||||
|
||||
let {
|
||||
approvalInfo,
|
||||
job,
|
||||
completed,
|
||||
actionTaken,
|
||||
loading,
|
||||
schema,
|
||||
hasForm,
|
||||
args = $bindable(),
|
||||
valid = $bindable(),
|
||||
isLocked,
|
||||
isSelfApprovalBypass,
|
||||
isWorkspaceMember,
|
||||
runDetailsHref,
|
||||
rd,
|
||||
onApprove,
|
||||
onReject
|
||||
}: Props = $props()
|
||||
|
||||
type Status = 'pending' | 'approved' | 'rejected' | 'closed'
|
||||
|
||||
const STATUS_BADGE: Record<Status, { label: string; color: BadgeColor }> = {
|
||||
pending: { label: 'Pending', color: 'yellow' },
|
||||
approved: { label: 'Approved', color: 'green' },
|
||||
rejected: { label: 'Rejected', color: 'red' },
|
||||
closed: { label: 'Closed', color: 'gray' }
|
||||
}
|
||||
|
||||
let status: Status = $derived(
|
||||
actionTaken === 'approved'
|
||||
? 'approved'
|
||||
: actionTaken === 'denied'
|
||||
? 'rejected'
|
||||
: completed
|
||||
? 'closed'
|
||||
: 'pending'
|
||||
)
|
||||
// The page title leads with the step's summary, falling back to the flow's: name the flow here
|
||||
// unless the title already does, and show the raw path only when the flow has no summary.
|
||||
let context = $derived(
|
||||
approvalInfo.flow_summary
|
||||
? approvalInfo.step_summary
|
||||
? approvalInfo.flow_summary
|
||||
: undefined
|
||||
: job?.script_path
|
||||
)
|
||||
let groupsRequired = $derived(approvalInfo.approval_conditions?.user_groups_required ?? [])
|
||||
let isSelfApprovalRefused = $derived(
|
||||
!!approvalInfo.approval_conditions?.self_approval_disabled &&
|
||||
!!$userStore &&
|
||||
$userStore.email === job?.email
|
||||
)
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex flex-row items-start justify-between gap-4">
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
{#if context}
|
||||
<span
|
||||
class={approvalInfo.flow_summary
|
||||
? 'text-xs font-semibold text-emphasis'
|
||||
: 'text-2xs font-mono font-normal text-emphasis break-all'}
|
||||
>
|
||||
{context}
|
||||
</span>
|
||||
{/if}
|
||||
{#if job}
|
||||
<p class="text-xs font-normal text-secondary">
|
||||
Requested by {job.created_by} · <TimeAgo date={job.created_at ?? ''} noSeconds />
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<Badge color={STATUS_BADGE[status].color}>{STATUS_BADGE[status].label}</Badge>
|
||||
</div>
|
||||
|
||||
{#if typeof approvalInfo.description === 'string'}
|
||||
<p class="text-xs font-normal text-primary whitespace-pre-wrap">{approvalInfo.description}</p>
|
||||
{:else if approvalInfo.description != undefined}
|
||||
<DisplayResult noControls result={approvalInfo.description} />
|
||||
{/if}
|
||||
|
||||
{#if status === 'pending'}
|
||||
{#if hasForm}
|
||||
{#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, approvalInfo.enums ?? {})}
|
||||
bind:args
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if approvalInfo.can_approve}
|
||||
<div class="flex flex-row flex-wrap justify-between gap-4">
|
||||
{#if approvalInfo.hide_cancel !== true}
|
||||
<Button
|
||||
unifiedSize="lg"
|
||||
variant="default"
|
||||
destructive
|
||||
onclick={onReject}
|
||||
disabled={loading}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
{:else}
|
||||
<div></div>
|
||||
{/if}
|
||||
<Button unifiedSize="lg" variant="accent" onclick={onApprove} disabled={!valid || loading}>
|
||||
Approve
|
||||
</Button>
|
||||
</div>
|
||||
{#if isSelfApprovalBypass}
|
||||
<Alert type="warning" title="Warning">
|
||||
As an administrator, by approving or rejecting this request, you bypass the self-approval
|
||||
interdiction.
|
||||
</Alert>
|
||||
{/if}
|
||||
{:else if approvalInfo.user_auth_required && !$userStore}
|
||||
<p class="text-xs font-normal text-primary">Sign in to review this request.</p>
|
||||
<Login {rd} />
|
||||
{:else}
|
||||
<div class="flex flex-col gap-1 text-xs font-normal text-primary">
|
||||
<p>You are not authorized to approve this request.</p>
|
||||
{#if isSelfApprovalRefused}
|
||||
<p>Self-approval is disabled for this step.</p>
|
||||
{/if}
|
||||
{#if groupsRequired.length > 0}
|
||||
<p>
|
||||
Only members of the following groups can approve:
|
||||
<span class="font-semibold text-emphasis">{groupsRequired.join(', ')}</span>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="flex flex-row items-start gap-3 rounded-md bg-surface-secondary p-4">
|
||||
{#if status === 'approved'}
|
||||
<CheckCircle2 size={20} class="shrink-0 text-green-500" />
|
||||
{:else if status === 'rejected'}
|
||||
<XCircle size={20} class="shrink-0 text-red-500" />
|
||||
{:else}
|
||||
<CircleSlash size={20} class="shrink-0 text-secondary" />
|
||||
{/if}
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-sm font-semibold text-emphasis">
|
||||
{status === 'closed' ? 'This request is closed' : STATUS_BADGE[status].label}
|
||||
</span>
|
||||
<span class="text-xs font-normal text-secondary">
|
||||
{#if status === 'approved'}
|
||||
Your approval was recorded. You can close this page.
|
||||
{:else if status === 'rejected'}
|
||||
Your rejection was recorded. You can close this page.
|
||||
{:else}
|
||||
The flow is no longer waiting for approval.
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !isLocked && ((status === 'pending' && approvalInfo.approvers.length > 0) || isWorkspaceMember)}
|
||||
<div
|
||||
class="flex flex-row flex-wrap items-center justify-between gap-2 border-t border-border-light pt-4"
|
||||
>
|
||||
<span class="text-2xs font-normal text-secondary">
|
||||
{#if status === 'pending' && approvalInfo.approvers.length > 0}
|
||||
Already approved by {approvalInfo.approvers.map((a) => a.approver).join(', ')}
|
||||
{/if}
|
||||
</span>
|
||||
{#if isWorkspaceMember}
|
||||
<Button
|
||||
unifiedSize="xs"
|
||||
variant="subtle"
|
||||
href={runDetailsHref}
|
||||
target="_blank"
|
||||
endIcon={{ icon: ExternalLink }}
|
||||
>
|
||||
Run details
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -16,8 +16,13 @@
|
||||
import SuspendDrawer from './SuspendDrawer.svelte'
|
||||
import EditableSchemaDrawer from '$lib/components/schema/EditableSchemaDrawer.svelte'
|
||||
import SchemaForm from '$lib/components/SchemaForm.svelte'
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
import { Pen, Plus } from 'lucide-svelte'
|
||||
import { slideDynamic } from '$lib/transitions'
|
||||
import { logFeatureUsage } from '$lib/utils/featureUsage'
|
||||
|
||||
type ApprovalSkin = NonNullable<NonNullable<FlowModule['suspend']>['skin']>
|
||||
|
||||
const { selectionManager, flowStateStore, opWorkspace } =
|
||||
getContext<FlowEditorContext>('FlowEditorContext')
|
||||
@@ -81,6 +86,12 @@
|
||||
}
|
||||
formEditor?.openDrawer()
|
||||
}
|
||||
|
||||
function setSkin(skin: ApprovalSkin) {
|
||||
if (!flowModule.suspend) return
|
||||
flowModule.suspend.skin = skin === 'detailed' ? undefined : skin
|
||||
logFeatureUsage('flow_step', 'approval_skin', { key: skin })
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
@@ -134,6 +145,34 @@
|
||||
<SecondsInput disabled />
|
||||
{/if}
|
||||
</Label>
|
||||
<Label label="Approval page skin">
|
||||
<ToggleButtonGroup
|
||||
noWFull
|
||||
selected={flowModule.suspend?.skin ?? 'detailed'}
|
||||
disabled={!flowModule.suspend}
|
||||
onSelected={setSkin}
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton
|
||||
value="detailed"
|
||||
label="Detailed"
|
||||
tooltip="The request plus the flow's details: arguments, graph and approvers"
|
||||
{item}
|
||||
small
|
||||
/>
|
||||
<ToggleButton
|
||||
value="minimal"
|
||||
label="Minimal"
|
||||
tooltip="Only the request: step description, form and approve/reject buttons"
|
||||
{item}
|
||||
small
|
||||
/>
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
<span class="text-2xs font-normal text-secondary">
|
||||
Slack and Teams approval messages use the same skin
|
||||
</span>
|
||||
</Label>
|
||||
|
||||
<Toggle
|
||||
size="xs"
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
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 MinimalApprovalSkin from '$lib/components/approvals/MinimalApprovalSkin.svelte'
|
||||
import { page } from '$app/state'
|
||||
|
||||
$workspaceStore = page.params.workspace
|
||||
@@ -163,6 +164,15 @@
|
||||
return url
|
||||
})
|
||||
let isWac = $derived(!!(job as any)?.workflow_as_code_status)
|
||||
let skin = $derived(approvalInfo?.skin ?? 'detailed')
|
||||
// Left blank until the approval info names the skin, so neither skin flashes the other's title.
|
||||
let title = $derived(
|
||||
!approvalInfo && !error
|
||||
? ''
|
||||
: skin === 'minimal'
|
||||
? approvalInfo?.step_summary || approvalInfo?.flow_summary || 'Approval request'
|
||||
: `Approval for resuming of ${isWac ? 'workflow' : 'flow'}`
|
||||
)
|
||||
let filteredArgs = $derived.by(() => {
|
||||
if (!job?.args) return job?.args
|
||||
const args = { ...(job.args as any) }
|
||||
@@ -211,8 +221,10 @@
|
||||
<ScheduleEditor bind:this={scheduleEditor} />
|
||||
|
||||
<CenteredModal
|
||||
title="Approval for resuming of {isWac ? 'workflow' : 'flow'}"
|
||||
{title}
|
||||
loading={!approvalInfo && !error}
|
||||
centerVertically={false}
|
||||
showVersion={false}
|
||||
>
|
||||
{#if error}
|
||||
<div class="space-y-6">
|
||||
@@ -237,6 +249,25 @@
|
||||
<p class="text-sm">{error}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if approvalInfo && skin === 'minimal'}
|
||||
<MinimalApprovalSkin
|
||||
{approvalInfo}
|
||||
{job}
|
||||
{completed}
|
||||
{actionTaken}
|
||||
{loading}
|
||||
{schema}
|
||||
{hasForm}
|
||||
bind:args={default_payload}
|
||||
bind:valid
|
||||
{isLocked}
|
||||
isSelfApprovalBypass={!!isSelfApprovalBypass}
|
||||
{isWorkspaceMember}
|
||||
{runDetailsHref}
|
||||
{rd}
|
||||
onApprove={resume}
|
||||
onReject={cancel}
|
||||
/>
|
||||
{:else if approvalInfo}
|
||||
{#if !isLocked}
|
||||
<div class="flex flex-row justify-between flex-wrap sm:flex-nowrap gap-x-4">
|
||||
|
||||
@@ -362,6 +362,14 @@ components:
|
||||
continue_on_disapprove_timeout:
|
||||
type: boolean
|
||||
description: If true, continue flow on timeout instead of canceling
|
||||
skin:
|
||||
type: string
|
||||
enum: [detailed, minimal]
|
||||
description: >-
|
||||
How the approval request is presented, on the approval page and in Slack/Teams
|
||||
approval messages. 'detailed' (used when unset) shows the flow details
|
||||
(arguments, graph, approvers); 'minimal' shows only the request: the step
|
||||
description, form and approve/reject actions
|
||||
priority:
|
||||
type: number
|
||||
description: Execution priority for this step (higher numbers run first)
|
||||
|
||||
@@ -3085,6 +3085,8 @@ class WorkflowCtx:
|
||||
form: dict | None = None,
|
||||
self_approval: bool = True,
|
||||
key: str | None = None,
|
||||
skin: str | None = None,
|
||||
description: str | dict | None = None,
|
||||
):
|
||||
if key is not None:
|
||||
_assert_usable_step_key(key, "wait_for_approval key")
|
||||
@@ -3113,6 +3115,8 @@ class WorkflowCtx:
|
||||
"timeout": timeout,
|
||||
"form": form,
|
||||
"self_approval_disabled": not self_approval,
|
||||
"skin": skin,
|
||||
"description": description,
|
||||
"steps": [],
|
||||
})
|
||||
|
||||
@@ -3559,6 +3563,8 @@ async def wait_for_approval(
|
||||
form: dict | None = None,
|
||||
self_approval: bool = True,
|
||||
key: str | None = None,
|
||||
skin: Literal["detailed", "minimal"] | None = None,
|
||||
description: str | dict | None = None,
|
||||
) -> dict:
|
||||
"""Suspend the workflow and wait for an external approval.
|
||||
|
||||
@@ -3573,6 +3579,10 @@ async def wait_for_approval(
|
||||
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.
|
||||
skin: ``"minimal"`` shows approvers only the request (form and approve/reject)
|
||||
instead of the detailed page with the workflow's details.
|
||||
description: Shown to approvers above the form: a string, or a rich value such as
|
||||
``{"markdown": "..."}``.
|
||||
|
||||
Example::
|
||||
|
||||
@@ -3583,7 +3593,12 @@ 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, self_approval=self_approval, key=key
|
||||
timeout=timeout,
|
||||
form=form,
|
||||
self_approval=self_approval,
|
||||
key=key,
|
||||
skin=skin,
|
||||
description=description,
|
||||
)
|
||||
raise RuntimeError("wait_for_approval can only be called inside a @workflow")
|
||||
|
||||
@@ -3653,6 +3668,8 @@ async def _run_workflow_async(func, checkpoint: dict, input_args: dict):
|
||||
"key": info["key"],
|
||||
"timeout": info.get("timeout"),
|
||||
"form": info.get("form"),
|
||||
"skin": info.get("skin"),
|
||||
"description": info.get("description"),
|
||||
}
|
||||
if mode == "sleep":
|
||||
return {
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1927,12 +1927,16 @@ workflow<T>(fn: (...args: any[]) => Promise<T>): void
|
||||
* resume exactly this approval — route them through your own channel. Without a
|
||||
* key the steps are named `approval`, `approval_2`, ...
|
||||
*
|
||||
* `skin: "minimal"` shows approvers only the request (form and approve/reject)
|
||||
* instead of the detailed page with the workflow's details. `description` is
|
||||
* shown above the form: a string, or a rich value such as `{ markdown: "..." }`.
|
||||
*
|
||||
* @example
|
||||
* 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; key?: string; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
|
||||
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; key?: string; skin?: "detailed" | "minimal"; description?: string | object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
|
||||
|
||||
/**
|
||||
* Resume/cancel/approval-page URLs bound to one `waitForApproval` step.
|
||||
@@ -2801,13 +2805,17 @@ async def sleep(seconds: int)
|
||||
# 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.
|
||||
# skin: ``"minimal"`` shows approvers only the request (form and approve/reject)
|
||||
# instead of the detailed page with the workflow's details.
|
||||
# description: Shown to approvers above the form: a string, or a rich value such as
|
||||
# ``{"markdown": "..."}``.
|
||||
#
|
||||
# Example::
|
||||
#
|
||||
# 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
|
||||
async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True, key: str | None = None, skin: Literal['detailed', 'minimal'] | None = None, description: str | dict | None = None) -> dict
|
||||
|
||||
# Process items in parallel with optional concurrency control.
|
||||
#
|
||||
|
||||
@@ -749,13 +749,17 @@ async def sleep(seconds: int)
|
||||
# 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.
|
||||
# skin: ``"minimal"`` shows approvers only the request (form and approve/reject)
|
||||
# instead of the detailed page with the workflow's details.
|
||||
# description: Shown to approvers above the form: a string, or a rich value such as
|
||||
# ``{"markdown": "..."}``.
|
||||
#
|
||||
# Example::
|
||||
#
|
||||
# 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
|
||||
async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True, key: str | None = None, skin: Literal['detailed', 'minimal'] | None = None, description: str | dict | None = None) -> dict
|
||||
|
||||
# Process items in parallel with optional concurrency control.
|
||||
#
|
||||
|
||||
@@ -492,12 +492,16 @@ workflow<T>(fn: (...args: any[]) => Promise<T>): void
|
||||
* resume exactly this approval — route them through your own channel. Without a
|
||||
* key the steps are named `approval`, `approval_2`, ...
|
||||
*
|
||||
* `skin: "minimal"` shows approvers only the request (form and approve/reject)
|
||||
* instead of the detailed page with the workflow's details. `description` is
|
||||
* shown above the form: a string, or a rich value such as `{ markdown: "..." }`.
|
||||
*
|
||||
* @example
|
||||
* 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; key?: string; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
|
||||
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; key?: string; skin?: "detailed" | "minimal"; description?: string | object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
|
||||
|
||||
/**
|
||||
* Resume/cancel/approval-page URLs bound to one `waitForApproval` step.
|
||||
|
||||
@@ -135,13 +135,17 @@ async def sleep(seconds: int)
|
||||
# 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.
|
||||
# skin: ``"minimal"`` shows approvers only the request (form and approve/reject)
|
||||
# instead of the detailed page with the workflow's details.
|
||||
# description: Shown to approvers above the form: a string, or a rich value such as
|
||||
# ``{"markdown": "..."}``.
|
||||
#
|
||||
# Example::
|
||||
#
|
||||
# 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
|
||||
async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True, key: str | None = None, skin: Literal['detailed', 'minimal'] | None = None, description: str | dict | None = None) -> dict
|
||||
|
||||
# Get the resume/cancel/approval-page URLs bound to one ``wait_for_approval`` step.
|
||||
#
|
||||
|
||||
@@ -117,12 +117,16 @@ export async function sleep(seconds: number): Promise<void>
|
||||
* resume exactly this approval — route them through your own channel. Without a
|
||||
* key the steps are named `approval`, `approval_2`, ...
|
||||
*
|
||||
* `skin: "minimal"` shows approvers only the request (form and approve/reject)
|
||||
* instead of the detailed page with the workflow's details. `description` is
|
||||
* shown above the form: a string, or a rich value such as `{ markdown: "..." }`.
|
||||
*
|
||||
* @example
|
||||
* 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 }>
|
||||
export function waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; key?: string; skin?: "detailed" | "minimal"; description?: string | object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
|
||||
|
||||
/**
|
||||
* Resume/cancel/approval-page URLs bound to one `waitForApproval` step.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -663,12 +663,16 @@ workflow<T>(fn: (...args: any[]) => Promise<T>): void
|
||||
* resume exactly this approval — route them through your own channel. Without a
|
||||
* key the steps are named `approval`, `approval_2`, ...
|
||||
*
|
||||
* `skin: "minimal"` shows approvers only the request (form and approve/reject)
|
||||
* instead of the detailed page with the workflow's details. `description` is
|
||||
* shown above the form: a string, or a rich value such as `{ markdown: "..." }`.
|
||||
*
|
||||
* @example
|
||||
* 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; key?: string; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
|
||||
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; key?: string; skin?: "detailed" | "minimal"; description?: string | object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
|
||||
|
||||
/**
|
||||
* Resume/cancel/approval-page URLs bound to one `waitForApproval` step.
|
||||
|
||||
@@ -663,12 +663,16 @@ workflow<T>(fn: (...args: any[]) => Promise<T>): void
|
||||
* resume exactly this approval — route them through your own channel. Without a
|
||||
* key the steps are named `approval`, `approval_2`, ...
|
||||
*
|
||||
* `skin: "minimal"` shows approvers only the request (form and approve/reject)
|
||||
* instead of the detailed page with the workflow's details. `description` is
|
||||
* shown above the form: a string, or a rich value such as `{ markdown: "..." }`.
|
||||
*
|
||||
* @example
|
||||
* 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; key?: string; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
|
||||
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; key?: string; skin?: "detailed" | "minimal"; description?: string | object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
|
||||
|
||||
/**
|
||||
* Resume/cancel/approval-page URLs bound to one `waitForApproval` step.
|
||||
|
||||
@@ -665,12 +665,16 @@ workflow<T>(fn: (...args: any[]) => Promise<T>): void
|
||||
* resume exactly this approval — route them through your own channel. Without a
|
||||
* key the steps are named `approval`, `approval_2`, ...
|
||||
*
|
||||
* `skin: "minimal"` shows approvers only the request (form and approve/reject)
|
||||
* instead of the detailed page with the workflow's details. `description` is
|
||||
* shown above the form: a string, or a rich value such as `{ markdown: "..." }`.
|
||||
*
|
||||
* @example
|
||||
* 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; key?: string; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
|
||||
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; key?: string; skin?: "detailed" | "minimal"; description?: string | object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
|
||||
|
||||
/**
|
||||
* Resume/cancel/approval-page URLs bound to one `waitForApproval` step.
|
||||
|
||||
@@ -934,13 +934,17 @@ async def sleep(seconds: int)
|
||||
# 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.
|
||||
# skin: ``"minimal"`` shows approvers only the request (form and approve/reject)
|
||||
# instead of the detailed page with the workflow's details.
|
||||
# description: Shown to approvers above the form: a string, or a rich value such as
|
||||
# ``{"markdown": "..."}``.
|
||||
#
|
||||
# Example::
|
||||
#
|
||||
# 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
|
||||
async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True, key: str | None = None, skin: Literal['detailed', 'minimal'] | None = None, description: str | dict | None = None) -> dict
|
||||
|
||||
# Process items in parallel with optional concurrency control.
|
||||
#
|
||||
|
||||
@@ -360,12 +360,16 @@ export async function sleep(seconds: number): Promise<void>
|
||||
* resume exactly this approval — route them through your own channel. Without a
|
||||
* key the steps are named `approval`, `approval_2`, ...
|
||||
*
|
||||
* `skin: "minimal"` shows approvers only the request (form and approve/reject)
|
||||
* instead of the detailed page with the workflow's details. `description` is
|
||||
* shown above the form: a string, or a rich value such as `{ markdown: "..." }`.
|
||||
*
|
||||
* @example
|
||||
* 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 }>
|
||||
export function waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; key?: string; skin?: "detailed" | "minimal"; description?: string | object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
|
||||
|
||||
/**
|
||||
* Resume/cancel/approval-page URLs bound to one `waitForApproval` step.
|
||||
@@ -541,13 +545,17 @@ async def sleep(seconds: int)
|
||||
# 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.
|
||||
# skin: ``"minimal"`` shows approvers only the request (form and approve/reject)
|
||||
# instead of the detailed page with the workflow's details.
|
||||
# description: Shown to approvers above the form: a string, or a rich value such as
|
||||
# ``{"markdown": "..."}``.
|
||||
#
|
||||
# Example::
|
||||
#
|
||||
# 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
|
||||
async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True, key: str | None = None, skin: Literal['detailed', 'minimal'] | None = None, description: str | dict | None = None) -> dict
|
||||
|
||||
# Get the resume/cancel/approval-page URLs bound to one ``wait_for_approval`` step.
|
||||
#
|
||||
|
||||
@@ -1962,6 +1962,8 @@ export class WorkflowCtx {
|
||||
form?: object;
|
||||
selfApproval?: boolean;
|
||||
key?: string;
|
||||
skin?: "detailed" | "minimal";
|
||||
description?: string | object;
|
||||
}): PromiseLike<{ value: any; approver: string; approved: boolean }> {
|
||||
this._rethrowSwallowed();
|
||||
if (options?.key !== undefined) assertUsableStepKey(options.key, "waitForApproval key");
|
||||
@@ -1996,6 +1998,8 @@ export class WorkflowCtx {
|
||||
timeout: options?.timeout ?? 1800,
|
||||
form: options?.form,
|
||||
self_approval_disabled: !(options?.selfApproval ?? true),
|
||||
skin: options?.skin,
|
||||
description: options?.description,
|
||||
steps: [],
|
||||
});
|
||||
}
|
||||
@@ -2459,6 +2463,10 @@ export function workflow<T>(fn: (...args: any[]) => Promise<T>) {
|
||||
* resume exactly this approval — route them through your own channel. Without a
|
||||
* key the steps are named `approval`, `approval_2`, ...
|
||||
*
|
||||
* `skin: "minimal"` shows approvers only the request (form and approve/reject)
|
||||
* instead of the detailed page with the workflow's details. `description` is
|
||||
* shown above the form: a string, or a rich value such as `{ markdown: "..." }`.
|
||||
*
|
||||
* @example
|
||||
* const urls = await step("urls", () => getApprovalUrls("manager"));
|
||||
* await step("notify", () => sendEmail(urls.resume, urls.cancel));
|
||||
@@ -2469,6 +2477,8 @@ export function waitForApproval(options?: {
|
||||
form?: object;
|
||||
selfApproval?: boolean;
|
||||
key?: string;
|
||||
skin?: "detailed" | "minimal";
|
||||
description?: string | object;
|
||||
}): PromiseLike<{ value: any; approver: string; approved: boolean }> {
|
||||
const ctx: WorkflowCtx | null = _workflowCtx ?? Reflect.get(globalThis, "__wmill_wf_ctx");
|
||||
if (!ctx) {
|
||||
|
||||
Reference in New Issue
Block a user