quicksave

This commit is contained in:
Alex Petric
2025-02-12 14:17:20 -05:00
parent 5c10fcda73
commit da084aacd5
12 changed files with 7600 additions and 1523 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+46
View File
@@ -7315,6 +7315,52 @@ paths:
"200":
description: Interactive slack approval message sent successfully
/w/{workspace}/jobs/teams_approval/{id}:
get:
summary: generate interactive teams approval for suspended job
operationId: getTeamsApprovalPayload
tags:
- job
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/JobId"
- name: approver
in: query
schema:
type: string
- name: message
in: query
schema:
type: string
- name: team_name
in: query
required: true
schema:
type: string
- name: channel_name
in: query
required: true
schema:
type: string
- name: flow_step_id
in: query
required: true
schema:
type: string
- name: default_args_json
in: query
required: false
schema:
type: string
- name: dynamic_enums_json
in: query
required: false
schema:
type: string
responses:
"200":
description: Interactive slack approval message sent successfully
/w/{workspace}/jobs_u/resume/{id}/{resume_id}/{signature}:
get:
summary: resume a job for a suspended flow
+157
View File
@@ -0,0 +1,157 @@
use serde::Deserialize;
use serde_json::value::RawValue;
use sqlx::types::Uuid;
use windmill_common::cache;
use windmill_common::db::DB;
use windmill_common::error::Error;
use axum::extract::{Path, Query};
use crate::jobs::{get_resume_urls_internal, ResumeUrls};
use windmill_common::{
error::{self},
jobs::JobKind,
scripts::ScriptHash,
};
#[derive(Debug, Deserialize)]
pub struct ResumeFormRow {
pub resume_form: Option<serde_json::Value>,
pub hide_cancel: Option<bool>,
}
#[derive(Deserialize)]
pub struct QueryApprover {
pub approver: Option<String>,
}
#[derive(Deserialize)]
pub struct QueryMessage {
pub message: Option<String>,
}
#[derive(Deserialize)]
pub struct QueryResourcePath {
pub slack_resource_path: String,
}
#[derive(Deserialize)]
pub struct QueryChannelId {
pub channel_id: String,
}
#[derive(Deserialize)]
pub struct QueryFlowStepId {
pub flow_step_id: String,
}
#[derive(Deserialize, Debug)]
pub struct QueryDefaultArgsJson {
pub default_args_json: Option<serde_json::Value>,
}
#[derive(Deserialize, Debug)]
pub struct QueryDynamicEnumJson {
pub dynamic_enums_json: Option<serde_json::Value>,
}
#[derive(Debug)]
pub struct ApprovalFormDetails {
pub message_str: String,
pub urls: ResumeUrls,
pub schema: Option<ResumeFormRow>,
}
pub async fn get_approval_form(
db: DB,
w_id: &str,
job_id: Uuid,
flow_step_id: Option<&str>,
resume_id: u32,
approver: Option<&str>,
message: Option<&str>,
) -> Result<ApprovalFormDetails, Error> {
let res = get_resume_urls_internal(
axum::Extension(db.clone()),
Path((w_id.to_string(), job_id, resume_id)),
Query(QueryApprover { approver: approver.map(|a| a.to_string()) }),
)
.await?;
let urls = res.0;
tracing::debug!("Job ID: {:?}", job_id);
let (job_kind, script_hash, raw_flow, parent_job_id, created_at, created_by, script_path, args) = sqlx::query!(
"SELECT
v2_as_queue.job_kind AS \"job_kind!: JobKind\",
v2_as_queue.script_hash AS \"script_hash: ScriptHash\",
v2_as_queue.raw_flow AS \"raw_flow: sqlx::types::Json<Box<RawValue>>\",
v2_as_completed_job.parent_job AS \"parent_job: Uuid\",
v2_as_completed_job.created_at AS \"created_at!: chrono::NaiveDateTime\",
v2_as_completed_job.created_by AS \"created_by!\",
v2_as_queue.script_path,
v2_as_queue.args AS \"args: sqlx::types::Json<Box<RawValue>>\"
FROM v2_as_queue
JOIN v2_as_completed_job ON v2_as_completed_job.parent_job = v2_as_queue.id
WHERE v2_as_completed_job.id = $1 AND v2_as_completed_job.workspace_id = $2
LIMIT 1",
job_id,
&w_id
)
.fetch_optional(&db)
.await
.map_err(|e| error::Error::BadRequest(e.to_string()))?
.ok_or_else(|| error::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 {
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?
} else {
return Err(error::Error::BadRequest(
"This workflow is no longer running and has either already timed out or been cancelled or completed.".to_string(),
));
}
}
};
let flow_value = &flow_data.flow;
let flow_step_id = flow_step_id.unwrap_or("");
let module = flow_value.modules.iter().find(|m| m.id == flow_step_id);
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| 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*: {created_by}\n\
*Created at*: {created_at_formatted}\n\
*Script path*: {script_path_str}\n\
*Args*: {args_str}\n\
*Flow ID*: {parent_job_id_str}\n\n"
);
// 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 })
}
+2 -5
View File
@@ -80,6 +80,8 @@ use windmill_common::{
},
};
use crate::approvals::QueryApprover;
#[cfg(all(feature = "enterprise", feature = "parquet"))]
use windmill_common::s3_helpers::OBJECT_STORE_CACHE_SETTINGS;
#[cfg(feature = "prometheus")]
@@ -2125,11 +2127,6 @@ pub struct SuspendedJobFlow {
pub approvers: Vec<Approval>,
}
#[derive(Deserialize, Debug)]
pub struct QueryApprover {
pub approver: Option<String>,
}
pub async fn get_suspended_job_flow(
authed: Option<ApiAuthed>,
Extension(db): Extension<DB>,
+8
View File
@@ -55,6 +55,8 @@ use windmill_common::{utils::GIT_VERSION, BASE_URL, INSTANCE_NAME};
use crate::scim_ee::has_scim_token;
use windmill_common::error::AppError;
use crate::teams_approvals::request_teams_approval;
mod ai;
mod apps;
mod args;
@@ -101,7 +103,9 @@ mod scim_ee;
mod scripts;
mod service_logs;
mod settings;
mod approvals;
mod slack_approvals;
mod teams_approvals;
#[cfg(feature = "smtp")]
mod smtp_server_ee;
mod static_assets;
@@ -494,6 +498,10 @@ pub async fn run_server(
"/w/:workspace_id/jobs/slack_approval/:job_id",
get(slack_approvals::request_slack_approval),
)
.route(
"/w/:workspace_id/jobs/teams_approval/:job_id",
get(request_teams_approval),
)
.nest(
"/w/:workspace_id/resources_u",
resources::public_service().layer(cors.clone()),
+13 -125
View File
@@ -4,7 +4,7 @@ use axum::{
};
use hyper::StatusCode;
use serde::{Deserialize, Serialize};
use serde_json::value::{RawValue, Value};
use serde_json::value::Value;
use sqlx::types::Uuid;
use std::{collections::HashMap, str::FromStr};
@@ -13,19 +13,18 @@ use regex::Regex;
use reqwest::Client;
use crate::db::{ApiAuthed, DB};
use crate::jobs::{
cancel_suspended_job, get_resume_urls_internal, resume_suspended_job, QueryApprover,
QueryOrBody, ResumeUrls,
};
use crate::jobs::{cancel_suspended_job, resume_suspended_job, QueryOrBody, ResumeUrls};
use windmill_common::{
cache,
error::{self, Error},
jobs::JobKind,
scripts::ScriptHash,
error::Error,
variables::{build_crypt, decrypt},
};
use crate::approvals::{
get_approval_form, ApprovalFormDetails, QueryApprover, QueryChannelId, QueryDefaultArgsJson,
QueryDynamicEnumJson, QueryFlowStepId, QueryMessage, QueryResourcePath,
};
#[derive(Deserialize, Debug)]
pub struct SlackFormData {
payload: String,
@@ -96,12 +95,6 @@ struct ResumeSchema {
schema: Schema,
}
#[derive(Debug, Deserialize)]
struct ResumeFormRow {
resume_form: Option<serde_json::Value>,
hide_cancel: Option<bool>,
}
#[derive(Debug, Deserialize, Serialize)]
struct Schema {
order: Vec<String>,
@@ -133,36 +126,6 @@ struct ResumeFormField {
nullable: Option<bool>,
}
#[derive(Deserialize)]
pub struct QueryMessage {
message: Option<String>,
}
#[derive(Deserialize)]
pub struct QueryResourcePath {
slack_resource_path: String,
}
#[derive(Deserialize)]
pub struct QueryChannelId {
channel_id: String,
}
#[derive(Deserialize)]
pub struct QueryFlowStepId {
flow_step_id: String,
}
#[derive(Deserialize, Debug)]
pub struct QueryDefaultArgsJson {
default_args_json: Option<serde_json::Value>,
}
#[derive(Deserialize, Debug)]
pub struct QueryDynamicEnumJson {
dynamic_enums_json: Option<serde_json::Value>,
}
#[derive(Deserialize, Debug)]
struct ModalActionValue {
w_id: String,
@@ -964,92 +927,17 @@ async fn get_modal_blocks(
default_args_json: Option<&serde_json::Value>,
dynamic_enums_json: Option<&serde_json::Value>,
) -> Result<axum::Json<serde_json::Value>, Error> {
let res = get_resume_urls_internal(
axum::Extension(db.clone()),
Path((w_id.to_string(), job_id, resume_id)),
Query(QueryApprover { approver: approver.map(|a| a.to_string()) }),
)
.await?;
let approval_details =
get_approval_form(db, w_id, job_id, flow_step_id, resume_id, approver, message).await?;
let urls = res.0;
let ApprovalFormDetails { mut message_str, urls, schema } = approval_details;
tracing::debug!("Job ID: {:?}", job_id);
let (job_kind, script_hash, raw_flow, parent_job_id, created_at, created_by, script_path, args) = sqlx::query!(
"SELECT
v2_as_queue.job_kind AS \"job_kind!: JobKind\",
v2_as_queue.script_hash AS \"script_hash: ScriptHash\",
v2_as_queue.raw_flow AS \"raw_flow: sqlx::types::Json<Box<RawValue>>\",
v2_as_completed_job.parent_job AS \"parent_job: Uuid\",
v2_as_completed_job.created_at AS \"created_at!: chrono::NaiveDateTime\",
v2_as_completed_job.created_by AS \"created_by!\",
v2_as_queue.script_path,
v2_as_queue.args AS \"args: sqlx::types::Json<Box<RawValue>>\"
FROM v2_as_queue
JOIN v2_as_completed_job ON v2_as_completed_job.parent_job = v2_as_queue.id
WHERE v2_as_completed_job.id = $1 AND v2_as_completed_job.workspace_id = $2
LIMIT 1",
job_id,
&w_id
)
.fetch_optional(&db)
.await
.map_err(|e| error::Error::BadRequest(e.to_string()))?
.ok_or_else(|| error::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 {
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?
} else {
return Err(error::Error::BadRequest(
"This workflow is no longer running and has either already timed out or been cancelled or completed.".to_string(),
));
}
}
};
let flow_value = &flow_data.flow;
let flow_step_id = flow_step_id.unwrap_or("");
let module = flow_value.modules.iter().find(|m| m.id == flow_step_id);
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| 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*: {created_by}\n\
*Created at*: {created_at_formatted}\n\
*Script path*: {script_path_str}\n\
*Args*: {args_str}\n\
*Flow ID*: {parent_job_id_str}\n\n"
);
// Append custom message if provided
if let Some(msg) = message {
message_str.push_str(msg);
}
tracing::debug!("Schema: {:#?}", schema);
// tracing::debug!("Approval Details: {:?}", approval_details);
if let Some(resume_schema) = schema {
let hide_cancel = resume_schema.hide_cancel.unwrap_or(false);
// if hide cancel is false add note to message
// If hide_cancel is false, add a note to the message
if !hide_cancel {
message_str.push_str("\n\n*NOTE*: closing this modal will cancel the workflow.\n\n");
}
@@ -0,0 +1,73 @@
use crate::approvals::{
get_approval_form, ApprovalFormDetails, QueryApprover, QueryDefaultArgsJson, QueryDynamicEnumJson, QueryFlowStepId, QueryMessage, ResumeFormRow,
};
use crate::db::{ApiAuthed, DB};
use axum::{
extract::{Path, Query},
Extension,
};
use http::StatusCode;
use serde::Deserialize;
use uuid::Uuid;
use windmill_common::error::Error;
use windmill_common::teams_ee::get_global_teams_bot_token;
#[derive(Deserialize)]
pub struct RequestTeamsApprovalPayload {
pub team_name: String,
pub channel_name: String,
pub message: String,
pub approver: String,
pub default_args_json: Option<serde_json::Value>,
pub dynamic_enums_json: Option<serde_json::Value>,
}
#[derive(Deserialize)]
pub struct QueryTeamName {
pub team_name: String,
}
#[derive(Deserialize)]
pub struct QueryChannelName {
pub channel_name: String,
}
pub async fn request_teams_approval(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path((w_id, job_id)): Path<(String, Uuid)>,
Query(team_name): Query<QueryTeamName>,
Query(channel_name): Query<QueryChannelName>,
Query(approver): Query<QueryApprover>,
Query(message): Query<QueryMessage>,
Query(flow_step_id): Query<QueryFlowStepId>,
Query(default_args_json): Query<QueryDefaultArgsJson>,
Query(dynamic_enums_json): Query<QueryDynamicEnumJson>,
) -> Result<StatusCode, Error> {
let teams_bot_token = get_global_teams_bot_token(&db).await?;
let resume_id = rand::random::<u32>();
let approval_details = get_approval_form(
db,
w_id.as_str(),
job_id,
Some(flow_step_id.flow_step_id.as_str()),
resume_id,
approver.approver.as_deref(),
message.message.as_deref(),
).await?;
let ApprovalFormDetails { mut message_str, urls, schema } = approval_details;
let blocks = transform_schemas(
message_str,
schema,
);
Ok(StatusCode::OK)
}
fn transform_schemas(message_str: String, schema: Option<ResumeFormRow>) -> Vec<String> {
vec![]
}
+1 -1
View File
@@ -248,7 +248,7 @@ declare function setValue(id: string, value: any): void;
*/
declare function setSelectedIndex(id: string, index: number): void;
/** close a drawer or modal
/** open a drawer or modal
* @param id component's id
*/
declare function open(id: string): void;
+1 -1
View File
@@ -14,5 +14,5 @@ cp "${script_dirpath}/s3Types.ts" "${script_dirpath}/src/"
echo "" >> "${script_dirpath}/src/index.ts"
echo 'export type { S3Object, DenoS3LightClientSettings } from "./s3Types";' >> "${script_dirpath}/src/index.ts"
echo "" >> "${script_dirpath}/src/index.ts"
echo 'export { type Base64, setClient, getVariable, setVariable, getResource, setResource, getResumeUrls, setState, getState, getIdToken, denoS3LightClientSettings, loadS3FileStream, loadS3File, writeS3File, task, runScript, runScriptAsync, runFlow, runFlowAsync, waitJob, getRootJobId, setFlowUserState, getFlowUserState, usernameToEmail, requestInteractiveSlackApproval} from "./client";' >> "${script_dirpath}/src/index.ts"
echo 'export { type Base64, setClient, getVariable, setVariable, getResource, setResource, getResumeUrls, setState, getState, getIdToken, denoS3LightClientSettings, loadS3FileStream, loadS3File, writeS3File, task, runScript, runScriptAsync, runFlow, runFlowAsync, waitJob, getRootJobId, setFlowUserState, getFlowUserState, usernameToEmail, requestInteractiveSlackApproval, requestInteractiveTeamsApproval} from "./client";' >> "${script_dirpath}/src/index.ts"
+1 -1
View File
@@ -39,4 +39,4 @@ cp "${script_dirpath}/s3Types.ts" "${script_dirpath}/src/"
echo "" >> "${script_dirpath}/src/index.ts"
echo 'export type { S3Object, DenoS3LightClientSettings } from "./s3Types";' >> "${script_dirpath}/src/index.ts"
echo "" >> "${script_dirpath}/src/index.ts"
echo 'export { type Base64, setClient, getVariable, setVariable, getResource, setResource, getResumeUrls, setState, setProgress, getProgress, getState, getIdToken, denoS3LightClientSettings, loadS3FileStream, loadS3File, writeS3File, task, runScript, runScriptAsync, runFlow, runFlowAsync, waitJob, getRootJobId, setFlowUserState, getFlowUserState, usernameToEmail, requestInteractiveSlackApproval } from "./client";' >> "${script_dirpath}/src/index.ts"
echo 'export { type Base64, setClient, getVariable, setVariable, getResource, setResource, getResumeUrls, setState, setProgress, getProgress, getState, getIdToken, denoS3LightClientSettings, loadS3FileStream, loadS3File, writeS3File, task, runScript, runScriptAsync, runFlow, runFlowAsync, waitJob, getRootJobId, setFlowUserState, getFlowUserState, usernameToEmail, requestInteractiveSlackApproval, requestInteractiveTeamsApproval } from "./client";' >> "${script_dirpath}/src/index.ts"
+101
View File
@@ -892,6 +892,15 @@ interface SlackApprovalOptions {
dynamicEnumsJson?: Record<string, any>;
}
interface TeamsApprovalOptions {
teamName: string;
channelName: string;
message?: string;
approver?: string;
defaultArgsJson?: Record<string, any>;
dynamicEnumsJson?: Record<string, any>;
}
/**
* Sends an interactive approval request via Slack, allowing optional customization of the message, approver, and form fields.
*
@@ -984,6 +993,98 @@ export async function requestInteractiveSlackApproval({
});
}
/**
* Sends an interactive approval request via Teams, allowing optional customization of the message, approver, and form fields.
*
* **[Enterprise Edition Only]** To include form fields in the Teams approval request, go to **Advanced -> Suspend -> Form**
* and define a form. Learn more at [Windmill Documentation](https://www.windmill.dev/docs/flows/flow_approval#form).
*
* @param {Object} options - The configuration options for the Teams approval request.
* @param {string} options.teamName - The Teams team name where the approval request will be sent.
* @param {string} options.channelName - The Teams channel name where the approval request will be sent.
* @param {string} [options.message] - Optional custom message to include in the Teams approval request.
* @param {string} [options.approver] - Optional user ID or name of the approver for the request.
* @param {DefaultArgs} [options.defaultArgsJson] - Optional object defining or overriding the default arguments to a form field.
* @param {Enums} [options.dynamicEnumsJson] - Optional object overriding the enum default values of an enum form field.
*
* @returns {Promise<void>} Resolves when the Teams approval request is successfully sent.
*
* @throws {Error} If the function is not called within a flow or flow preview.
* @throws {Error} If the `JobService.getTeamsApprovalPayload` call fails.
*
* **Usage Example:**
* ```typescript
* await requestInteractiveTeamsApproval({
* teamName: "admins-teams",
* channelName: "admins-teams-channel",
* message: "Please approve this request",
* approver: "approver123",
* defaultArgsJson: { key1: "value1", key2: 42 },
* dynamicEnumsJson: { foo: ["choice1", "choice2"], bar: ["optionA", "optionB"] },
* });
* ```
*
* **Note:** This function requires execution within a Windmill flow or flow preview.
*/
export async function requestInteractiveTeamsApproval({
teamName,
channelName,
message,
approver,
defaultArgsJson,
dynamicEnumsJson,
}: TeamsApprovalOptions): Promise<void> {
const workspace = getWorkspace();
const flowJobId = getEnv("WM_FLOW_JOB_ID");
if (!flowJobId) {
throw new Error(
"You can't use this function in a standalone script or flow step preview. Please use it in a flow or a flow preview."
);
}
const flowStepId = getEnv("WM_FLOW_STEP_ID");
if (!flowStepId) {
throw new Error("This function can only be called as a flow step");
}
// Only include non-empty parameters
const params: {
approver?: string;
message?: string;
teamName: string;
channelName: string;
flowStepId: string;
defaultArgsJson?: string;
dynamicEnumsJson?: string;
} = {
teamName,
channelName,
flowStepId,
};
if (message) {
params.message = message;
}
if (approver) {
params.approver = approver;
}
if (defaultArgsJson) {
params.defaultArgsJson = JSON.stringify(defaultArgsJson);
}
if (dynamicEnumsJson) {
params.dynamicEnumsJson = JSON.stringify(dynamicEnumsJson);
}
await JobService.getTeamsApprovalPayload({
workspace,
...params,
id: getEnv("WM_JOB_ID") ?? "NO_JOB_ID",
});
}
async function getMockedApi(): Promise<MockedApi | undefined> {
if (mockedApi) {
return mockedApi;