diff --git a/CHANGELOG.md b/CHANGELOG.md index 30366fdb06..3bda342059 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.441.0](https://github.com/windmill-labs/windmill/compare/v1.440.3...v1.441.0) (2024-12-20) + + +### Features + +* interactive slack approvals ([#4942](https://github.com/windmill-labs/windmill/issues/4942)) ([6308bf0](https://github.com/windmill-labs/windmill/commit/6308bf0dcb1d6670e839a1a1e0b794bf3ce6520c)) + ## [1.440.3](https://github.com/windmill-labs/windmill/compare/v1.440.2...v1.440.3) (2024-12-19) diff --git a/backend/.sqlx/query-bb6141ad0e93986b38ccdf4d027c486137d4fe79906e22842b711f4a9379b8c8.json b/backend/.sqlx/query-bb6141ad0e93986b38ccdf4d027c486137d4fe79906e22842b711f4a9379b8c8.json new file mode 100644 index 0000000000..da89afe0f7 --- /dev/null +++ b/backend/.sqlx/query-bb6141ad0e93986b38ccdf4d027c486137d4fe79906e22842b711f4a9379b8c8.json @@ -0,0 +1,67 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n queue.job_kind AS \"job_kind: JobKind\",\n queue.script_hash AS \"script_hash: ScriptHash\",\n queue.raw_flow AS \"raw_flow: sqlx::types::Json>\",\n completed_job.parent_job AS \"parent_job: Uuid\"\n FROM queue\n JOIN completed_job ON completed_job.parent_job = queue.id\n WHERE completed_job.id = $1 AND completed_job.workspace_id = $2\n LIMIT 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "job_kind: JobKind", + "type_info": { + "Custom": { + "name": "job_kind", + "kind": { + "Enum": [ + "script", + "preview", + "flow", + "dependencies", + "flowpreview", + "script_hub", + "identity", + "flowdependencies", + "http", + "graphql", + "postgresql", + "noop", + "appdependencies", + "deploymentcallback", + "singlescriptflow", + "flowscript", + "flownode", + "appscript" + ] + } + } + } + }, + { + "ordinal": 1, + "name": "script_hash: ScriptHash", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "raw_flow: sqlx::types::Json>", + "type_info": "Jsonb" + }, + { + "ordinal": 3, + "name": "parent_job: Uuid", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + false, + true, + true, + true + ] + }, + "hash": "bb6141ad0e93986b38ccdf4d027c486137d4fe79906e22842b711f4a9379b8c8" +} diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 27553ac633..a4e86fac60 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1400,18 +1400,18 @@ dependencies = [ [[package]] name = "bytemuck" -version = "1.20.0" +version = "1.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b37c88a63ffd85d15b406896cc343916d7cf57838a847b3a6f2ca5d39a5695a" +checksum = "ef657dfab802224e671f5818e9a4935f9b1957ed18e58292690cc39e7a4092a3" dependencies = [ "bytemuck_derive", ] [[package]] name = "bytemuck_derive" -version = "1.8.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcfcc3cd946cb52f0bbfdbbcfa2f4e24f75ebb6c0e1002f7c25904fada18b9ec" +checksum = "3fa76293b4f7bb636ab88fd78228235b5248b4d05cc589aed610f954af5d7c7a" dependencies = [ "proc-macro2", "quote", @@ -1530,9 +1530,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.4" +version = "1.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9157bbaa6b165880c27a4293a474c91cdcf265cc68cc829bf10be0964a391caf" +checksum = "c31a0499c1dc64f458ad13872de75c0eb7e3fdb0e67964610c914b034fc5956e" dependencies = [ "jobserver", "libc", @@ -2585,7 +2585,7 @@ dependencies = [ "http 1.2.0", "http-body-util", "hyper 1.5.2", - "hyper-rustls 0.27.4", + "hyper-rustls 0.27.5", "hyper-util", "ipnet", "percent-encoding", @@ -3310,9 +3310,9 @@ checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "foldhash" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f81ec6369c545a7d40e4589b5597581fa1c441fe1cce96dd1de43159910a36a2" +checksum = "a0d2fde1f7b3d48b8395d5f2de76c18a528bd6a9cdde438df747bfcba3e05d6f" [[package]] name = "foreign-types" @@ -4270,9 +4270,9 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.4" +version = "0.27.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6884a48c6826ec44f524c7456b163cebe9e55a18d7b5e307cb4f100371cc767" +checksum = "2d191583f3da1305256f22463b9bb0471acad48a4e534a5218b9963e9c1f59b2" dependencies = [ "futures-util", "http 1.2.0", @@ -4824,9 +4824,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.168" +version = "0.2.169" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5aaeb2981e0606ca11d79718f8bb01164f1d6ed75080182d3abf017e6d244b6d" +checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" [[package]] name = "libgit2-sys" @@ -6976,7 +6976,7 @@ dependencies = [ "http-body 1.0.1", "http-body-util", "hyper 1.5.2", - "hyper-rustls 0.27.4", + "hyper-rustls 0.27.5", "hyper-tls 0.6.0", "hyper-util", "ipnet", @@ -9359,9 +9359,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.8.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "445e881f4f6d382d5f27c034e25eb92edd7c784ceab92a0937db7f2e9471b938" +checksum = "022db8904dfa342efe721985167e9fcd16c29b226db4397ed752a761cfce81e8" dependencies = [ "tinyvec_macros", ] @@ -10657,7 +10657,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill" -version = "1.440.3" +version = "1.441.0" dependencies = [ "anyhow", "axum", @@ -10698,7 +10698,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.440.3" +version = "1.441.0" dependencies = [ "anyhow", "argon2", @@ -10783,7 +10783,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.440.3" +version = "1.441.0" dependencies = [ "base64 0.22.1", "chrono", @@ -10801,7 +10801,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.440.3" +version = "1.441.0" dependencies = [ "chrono", "serde", @@ -10814,7 +10814,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.440.3" +version = "1.441.0" dependencies = [ "anyhow", "serde", @@ -10828,7 +10828,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.440.3" +version = "1.441.0" dependencies = [ "anyhow", "async-stream", @@ -10887,7 +10887,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.440.3" +version = "1.441.0" dependencies = [ "regex", "serde", @@ -10901,7 +10901,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.440.3" +version = "1.441.0" dependencies = [ "anyhow", "bytes", @@ -10924,7 +10924,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.440.3" +version = "1.441.0" dependencies = [ "itertools 0.13.0", "lazy_static", @@ -10936,7 +10936,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.440.3" +version = "1.441.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -10945,7 +10945,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.440.3" +version = "1.441.0" dependencies = [ "anyhow", "lazy_static", @@ -10957,7 +10957,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.440.3" +version = "1.441.0" dependencies = [ "anyhow", "serde_json", @@ -10969,7 +10969,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.440.3" +version = "1.441.0" dependencies = [ "anyhow", "gosyn", @@ -10981,7 +10981,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.440.3" +version = "1.441.0" dependencies = [ "anyhow", "lazy_static", @@ -10993,7 +10993,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.440.3" +version = "1.441.0" dependencies = [ "anyhow", "itertools 0.13.0", @@ -11004,7 +11004,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.440.3" +version = "1.441.0" dependencies = [ "anyhow", "itertools 0.13.0", @@ -11015,7 +11015,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.440.3" +version = "1.441.0" dependencies = [ "anyhow", "async-recursion", @@ -11033,7 +11033,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.440.3" +version = "1.441.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -11050,7 +11050,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.440.3" +version = "1.441.0" dependencies = [ "anyhow", "lazy_static", @@ -11062,7 +11062,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.440.3" +version = "1.441.0" dependencies = [ "anyhow", "lazy_static", @@ -11080,7 +11080,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.440.3" +version = "1.441.0" dependencies = [ "anyhow", "getrandom 0.2.15", @@ -11102,7 +11102,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.440.3" +version = "1.441.0" dependencies = [ "anyhow", "serde_json", @@ -11112,7 +11112,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.440.3" +version = "1.441.0" dependencies = [ "anyhow", "async-recursion", @@ -11146,7 +11146,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.440.3" +version = "1.441.0" dependencies = [ "wasm-bindgen", "wasm-bindgen-test", @@ -11156,7 +11156,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.440.3" +version = "1.441.0" dependencies = [ "anyhow", "async-recursion", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 1706c19d86..5b408a1a5b 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.440.3" +version = "1.441.0" authors.workspace = true edition.workspace = true @@ -30,7 +30,7 @@ members = [ ] [workspace.package] -version = "1.440.3" +version = "1.441.0" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 64c79ff154..44615c7721 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.440.3 + version: 1.441.0 title: Windmill API contact: @@ -6943,6 +6943,42 @@ paths: - resume - cancel + /w/{workspace}/jobs/slack_approval/{id}: + get: + summary: generate interactive slack approval for suspended job + operationId: getSlackApprovalPayload + 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: slack_resource_path + in: query + required: true + schema: + type: string + - name: channel_id + in: query + required: true + schema: + type: string + - name: flow_step_id + in: query + required: true + 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 diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index 4db486dcc5..d73c8d59fb 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -2160,7 +2160,7 @@ pub struct SuspendedJobFlow { pub approvers: Vec, } -#[derive(Deserialize)] +#[derive(Deserialize, Debug)] pub struct QueryApprover { pub approver: Option, } @@ -2373,11 +2373,11 @@ fn create_signature( } #[allow(non_snake_case)] -#[derive(Serialize)] +#[derive(Serialize, Debug)] pub struct ResumeUrls { - approvalPage: String, - cancel: String, - resume: String, + pub approvalPage: String, + pub cancel: String, + pub resume: String, } fn build_resume_url( @@ -2397,6 +2397,14 @@ pub async fn get_resume_urls( Extension(db): Extension, Path((w_id, job_id, resume_id)): Path<(String, Uuid, u32)>, Query(approver): Query, +) -> error::JsonResult { + get_resume_urls_internal(Extension(db), Path((w_id, job_id, resume_id)), Query(approver)).await +} + +pub async fn get_resume_urls_internal( + Extension(db): Extension, + Path((w_id, job_id, resume_id)): Path<(String, Uuid, u32)>, + Query(approver): Query, ) -> error::JsonResult { let key = get_workspace_key(&w_id, &db).await?; let signature = create_signature(key, job_id, resume_id, approver.approver.clone())?; @@ -5561,3 +5569,4 @@ async fn delete_completed_job<'a>( let response = Json(cj).into_response(); Ok(response) } + diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index 96a0f7e3f6..3cb499af79 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -28,7 +28,7 @@ use crate::{ use anyhow::Context; use argon2::Argon2; use axum::extract::DefaultBodyLimit; -use axum::{middleware::from_extractor, routing::get, Extension, Router}; +use axum::{middleware::from_extractor, routing::get, routing::post, Extension, Router}; use db::DB; use http::HeaderValue; use reqwest::Client; @@ -109,6 +109,7 @@ mod websocket_triggers; mod workers; mod workspaces; mod workspaces_ee; +mod slack_approvals; mod workspaces_export; mod workspaces_extra; @@ -415,6 +416,8 @@ pub async fn run_server( "/w/:workspace_id/jobs_u", jobs::workspace_unauthed_service().layer(cors.clone()), ) + .route("/slack", post(slack_approvals::slack_app_callback_handler)) + .route("/w/:workspace_id/jobs/slack_approval/:job_id", get(slack_approvals::request_slack_approval)) .nest( "/w/:workspace_id/resources_u", resources::public_service().layer(cors.clone()), diff --git a/backend/windmill-api/src/slack_approvals.rs b/backend/windmill-api/src/slack_approvals.rs new file mode 100644 index 0000000000..7bd19ce848 --- /dev/null +++ b/backend/windmill-api/src/slack_approvals.rs @@ -0,0 +1,1133 @@ +use axum::{ + extract::{Form, Path, Query}, + Extension, +}; +use hyper::StatusCode; +use serde::{Deserialize, Serialize}; +use serde_json::value::{RawValue, Value}; + +use sqlx::types::Uuid; +use std::{collections::HashMap, str::FromStr}; +use windmill_common::error::{self, Error}; + +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 windmill_common::{ + jobs::JobKind, + scripts::ScriptHash, + variables::{build_crypt, decrypt_value_with_mc}, +}; + +#[derive(Deserialize, Debug)] +pub struct SlackFormData { + payload: String, +} + +#[derive(Deserialize, Debug, Serialize)] +struct Container { + message_ts: String, + channel_id: String, +} + +#[derive(Deserialize, Debug)] +struct Payload { + actions: Option>, + view: Option, + trigger_id: Option, + #[serde(rename = "type")] + r#type: String, + container: Option, +} + +#[derive(Deserialize, Debug)] +struct View { + state: Option, + private_metadata: Option, +} + +#[derive(Deserialize, Debug)] +struct Action { + value: Option, + action_id: String, +} + +#[derive(Deserialize, Debug)] +struct State { + values: HashMap>, +} + +#[derive(Deserialize, Debug)] +#[serde(tag = "type", rename_all = "snake_case")] +enum ValueInput { + PlainTextInput { value: Option }, + Datepicker { selected_date: Option }, + Timepicker { selected_time: Option }, + StaticSelect { selected_option: Option }, + RadioButtons { selected_option: Option }, + Checkboxes { selected_options: Option> }, +} + +#[derive(Deserialize, Debug)] +struct SelectedOption { + value: String, +} + +#[derive(Debug, Deserialize, Serialize)] +struct ResumeSchema { + schema: Schema, +} + +#[derive(Debug, Deserialize)] +struct ResumeFormRow { + resume_form: Option, + hide_cancel: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +struct Schema { + order: Vec, + required: Vec, + properties: HashMap, +} + +#[derive(Debug, Deserialize, Serialize)] +struct ResumeFormField { + #[serde(rename = "type")] + r#type: String, + format: Option, + default: Option, + description: Option, + title: Option, + #[serde(rename = "enum")] + r#enum: Option>, + #[serde(rename = "enumLabels")] + enum_labels: Option>, + nullable: Option, +} + +#[derive(Deserialize)] +pub struct QueryMessage { + message: Option, +} + +#[derive(Deserialize)] +pub struct QueryResourcePath { + slack_resource_path: Option, +} + +#[derive(Deserialize)] +pub struct QueryChannelId { + channel_id: Option, +} + +#[derive(Deserialize)] +pub struct QueryFlowStepId { + flow_step_id: Option, +} + +#[derive(Deserialize, Debug)] +struct ModalActionValue { + w_id: String, + job_id: String, + path: String, + approver: Option, + message: Option, + flow_step_id: Option, +} + +#[derive(Deserialize, Debug)] +struct PrivateMetadata { + resume_url: String, + resource_path: String, + container: Container, +} + +pub async fn slack_app_callback_handler( + authed: Option, + Extension(db): Extension, + Form(form_data): Form, +) -> Result { + tracing::debug!("Form data: {:#?}", form_data); + let payload: Payload = serde_json::from_str(&form_data.payload)?; + tracing::debug!("Payload: {:#?}", payload); + + match payload.r#type.as_str() { + "view_submission" => { + //print the container + handle_submission(authed, db, &payload, "resume").await? + } + "view_closed" => handle_submission(authed, db, &payload, "cancel").await?, + _ => { + if let Some(actions) = payload.actions.as_ref() { + if let Some(action) = actions.first() { + match action.action_id.as_str() { + "open_modal" => { + let trigger_id = payload.trigger_id.as_deref().ok_or_else(|| { + Error::BadRequest("No trigger_id found in payload.".to_string()) + })?; + + let value_str = action.value.as_ref().ok_or_else(|| { + Error::BadRequest("No action value found".to_string()) + })?; + + let parsed_value: ModalActionValue = serde_json::from_str(value_str) + .map_err(|_| { + Error::BadRequest("Invalid JSON in action value".to_string()) + })?; + + let w_id = &parsed_value.w_id; + let path = &parsed_value.path; + let approver = parsed_value.approver.as_deref(); + let message = parsed_value.message.as_deref(); + let job_id = Uuid::parse_str(&parsed_value.job_id)?; + let flow_step_id = parsed_value.flow_step_id.as_deref(); + + let slack_token = get_slack_token(&db, path, w_id).await?; + let client = Client::new(); + let container = payload.container.ok_or_else(|| { + Error::BadRequest("No container found.".to_string()) + })?; + + open_modal_with_blocks( + &client, + slack_token.as_str(), + trigger_id, + db, + w_id, + path, + job_id, + approver, + message, + flow_step_id, + container, + ) + .await + .map_err(|e| { + windmill_common::error::Error::BadRequest(e.to_string()) + })?; + } + _ => println!("Unknown action_id: {}", action.action_id), + } + } + } else { + tracing::debug!("Unkown Slack Action!"); + } + } + } + + Ok(StatusCode::OK) +} + +pub async fn request_slack_approval( + _authed: ApiAuthed, + Extension(db): Extension, + Path((w_id, job_id)): Path<(String, Uuid)>, + Query(approver): Query, + Query(message): Query, + Query(slack_resource_path): Query, + Query(channel_id): Query, + Query(flow_step_id): Query, +) -> Result { + let slack_resource_path = match slack_resource_path.slack_resource_path { + Some(path) => path, + None => { + return Err(windmill_common::error::Error::BadRequest( + "slack_resource_path is required".to_string(), + )) + } + }; + + let channel_id = match channel_id.channel_id { + Some(id) => id, + None => { + return Err(windmill_common::error::Error::BadRequest( + "Slack channel_id is required".to_string(), + )) + } + }; + + let flow_step_id = match flow_step_id.flow_step_id { + Some(id) => id, + None => { + return Err(windmill_common::error::Error::BadRequest( + "Slack flow_step_id is required".to_string(), + )) + } + }; + + let slack_token = get_slack_token(&db, slack_resource_path.as_str(), &w_id).await?; + let client = Client::new(); + + // Optional fields + let approver_str = approver.approver.as_deref(); + let message_str = message.message.as_deref(); + + tracing::debug!("Approver: {:?}", approver_str); + tracing::debug!("Message: {:?}", message_str); + tracing::debug!("W ID: {:?}", w_id); + tracing::debug!("Slack Resource Path: {:?}", slack_resource_path); + tracing::debug!("Channel ID: {:?}", channel_id); + + // Use approver_str and message_str in the function call + send_slack_message( + &client, + slack_token.as_str(), + channel_id.as_str(), + &w_id, + job_id, + &slack_resource_path, + approver_str, + message_str, + flow_step_id.as_str(), + ) + .await + .map_err(|e| windmill_common::error::Error::BadRequest(e.to_string()))?; + + Ok(StatusCode::OK) +} + +async fn handle_submission( + authed: Option, + db: DB, + payload: &Payload, + action: &str, +) -> Result<(), Error> { + let view = payload + .view + .as_ref() + .ok_or_else(|| Error::BadRequest("No view found in payload.".to_string()))?; + + let state = view + .state + .as_ref() + .ok_or_else(|| Error::BadRequest("No state found in view.".to_string()))?; + + let values = &state.values; + tracing::debug!("Values: {:#?}", values); + + let state_values = parse_state_values(state); + let state_json = serde_json::to_value(state_values).unwrap_or_else(|_| serde_json::json!({})); + + let private_metadata = view + .private_metadata + .as_ref() + .ok_or_else(|| Error::BadRequest("No private metadata found.".to_string()))?; + + tracing::debug!("Private Metadata: {}", private_metadata); + let private_metadata: PrivateMetadata = serde_json::from_str(private_metadata)?; + let resume_url = private_metadata.resume_url; + let resource_path = private_metadata.resource_path; + let container: Container = private_metadata.container; + + // Use regex to extract information from private_metadata + let re = Regex::new(r"/api/w/(?P[^/]+)/jobs_u/(?Presume|cancel)/(?P[^/]+)/(?P[^/]+)/(?P[a-fA-F0-9]+)(?:\?approver=(?P[^&]+))?").unwrap(); + let captures = re.captures(resume_url.as_str()).ok_or_else(|| { + tracing::error!("Resume URL does not match the pattern."); + Error::BadRequest("Invalid URL format.".to_string()) + })?; + + let (w_id, job_id, resume_id, secret, approver) = ( + captures.name("w_id").map_or("", |m| m.as_str()), + captures.name("job_id").map_or("", |m| m.as_str()), + captures.name("resume_id").map_or("", |m| m.as_str()), + captures.name("secret").map_or("", |m| m.as_str()), + captures.name("approver").map(|m| m.as_str().to_string()), + ); + + let approver = QueryApprover { approver: approver }; + + // Convert job_id and resume_id to appropriate types + let job_uuid = Uuid::from_str(job_id) + .map_err(|_| Error::BadRequest("Invalid job ID format.".to_string()))?; + + let resume_id_parsed = resume_id + .parse::() + .map_err(|_| Error::BadRequest("Invalid resume ID format.".to_string()))?; + + // Call the appropriate function based on the action + let res = if action == "resume" { + resume_suspended_job( + authed, + Extension(db.clone()), + Path(( + w_id.to_string(), + job_uuid, + resume_id_parsed, + secret.to_string(), + )), + Query(approver), + QueryOrBody(Some(state_json)), + ) + .await + } else { + cancel_suspended_job( + authed, + Extension(db.clone()), + Path(( + w_id.to_string(), + job_uuid, + resume_id_parsed, + secret.to_string(), + )), + Query(approver), + QueryOrBody(Some(state_json)), + ) + .await + }; + tracing::debug!("Resume job action result: {:#?}", res); + let slack_token = get_slack_token(&db, &resource_path, &w_id).await?; + update_original_slack_message(action, slack_token, container).await?; + Ok(()) +} + +async fn transform_schemas( + text: &str, + properties: Option<&HashMap>, + urls: &ResumeUrls, + order: Option<&Vec>, + required: Option<&Vec>, +) -> Result { + tracing::debug!("Resume urls: {:#?}", urls); + + let mut blocks = vec![serde_json::json!({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": format!("{}\n<{}|Flow suspension details>", text, urls.approvalPage), + } + })]; + + if let Some(properties) = properties { + for key in order.unwrap() { + if let Some(schema) = properties.get(key) { + let is_required = required.unwrap().contains(key); + let input_block = create_input_block(key, schema, is_required); + match input_block { + serde_json::Value::Array(arr) => blocks.extend(arr), + _ => blocks.push(input_block), + } + } + } + } + + Ok(serde_json::Value::Array(blocks)) +} + +fn create_input_block(key: &str, schema: &ResumeFormField, required: bool) -> serde_json::Value { + let placeholder = schema + .description + .as_deref() + .filter(|desc| !desc.is_empty()) + .unwrap_or("Select an option"); + + let title = schema.title.as_deref().unwrap_or(key); + let title_with_required = if required { + format!("{}*", title) + } else { + title.to_string() + }; + + // Handle boolean type + if schema.r#type == "boolean" { + let initial_value = schema + .default + .as_ref() + .and_then(|default| default.as_bool()) + .unwrap_or(false); + + let mut element = serde_json::json!({ + "type": "checkboxes", + "optional": !required, + "options": [{ + "text": { + "type": "plain_text", + "text": title_with_required, + "emoji": true + }, + "value": "true" + }], + "action_id": key + }); + + if initial_value { + element["initial_options"] = serde_json::json!([ + { + "text": { + "type": "plain_text", + "text": title_with_required, + "emoji": true + }, + "value": "true" + } + ]); + } + + return serde_json::json!({ + "type": "input", + "element": element, + "label": { + "type": "plain_text", + "text": title_with_required, + "emoji": true + } + }); + } + + // Handle date-time format + if schema.r#type == "string" && schema.format.as_deref() == Some("date-time") { + let now = chrono::Local::now(); + let current_date = now.format("%Y-%m-%d").to_string(); + let current_time = now.format("%H:%M").to_string(); + + let (default_date, default_time) = if let Some(default) = &schema.default { + if let Ok(parsed_date) = chrono::DateTime::parse_from_rfc3339(default.as_str().unwrap()) + { + ( + parsed_date.format("%Y-%m-%d").to_string(), + parsed_date.format("%H:%M").to_string(), + ) + } else { + (current_date.clone(), current_time.clone()) + } + } else { + (current_date.clone(), current_time.clone()) + }; + + return serde_json::json!([ + { + "type": "input", + "optional": !required, + "element": { + "type": "datepicker", + "initial_date": &default_date, + "placeholder": { + "type": "plain_text", + "text": "Select a date", + "emoji": true + }, + "action_id": format!("{}_date", key) + }, + "label": { + "type": "plain_text", + "text": title_with_required, + "emoji": true + } + }, + { + "type": "input", + "optional": !required, + "element": { + "type": "timepicker", + "initial_time": &default_time, + "placeholder": { + "type": "plain_text", + "text": "Select time", + "emoji": true + }, + "action_id": format!("{}_time", key) + }, + "label": { + "type": "plain_text", + "text": " ", + "emoji": true + } + } + ]); + } + + // Handle enum type + if let Some(enums) = &schema.r#enum { + let initial_option = schema.default.as_ref().and_then(|default_value| { + enums + .iter() + .find(|enum_value| enum_value == &default_value) + .map(|enum_value| { + serde_json::json!({ + "text": { + "type": "plain_text", + "text": schema.enum_labels.as_ref() + .and_then(|labels| labels.get(enum_value)) + .unwrap_or(enum_value), + "emoji": true + }, + "value": enum_value + }) + }) + }); + + let mut element = serde_json::json!({ + "type": "static_select", + "placeholder": { + "type": "plain_text", + "text": placeholder, + "emoji": true, + }, + "options": enums.iter().map(|enum_value| { + serde_json::json!({ + "text": { + "type": "plain_text", + "text": schema.enum_labels.as_ref() + .and_then(|labels| labels.get(enum_value)) + .unwrap_or(enum_value), + "emoji": true + }, + "value": enum_value + }) + }).collect::>(), + "action_id": key + }); + + if let Some(option) = initial_option { + element["initial_option"] = option; + } + + serde_json::json!({ + "type": "input", + "optional": !required, + "element": element, + "label": { + "type": "plain_text", + "text": title_with_required, + "emoji": true + } + }) + } else if schema.r#type == "number" || schema.r#type == "integer" { + // Handle number and integer types + let initial_value = schema + .default + .as_ref() + .and_then(|default| default.as_f64()) + .unwrap_or(0.0); + + let action_id_suffix = if schema.r#type == "number" { + "_type_number" + } else { + "_type_integer" + }; + + serde_json::json!({ + "type": "input", + "optional": !required, + "element": { + "type": "plain_text_input", + "action_id": format!("{}{}", key, action_id_suffix), + "initial_value": initial_value.to_string() + }, + "label": { + "type": "plain_text", + "text": title_with_required, + "emoji": true + } + }) + } else { + // Handle other types as string + let initial_value = schema + .default + .as_ref() + .and_then(|default| default.as_str()) + .unwrap_or(""); + + serde_json::json!({ + "type": "input", + "optional": !required, + "element": { + "type": "plain_text_input", + "action_id": key, + "initial_value": initial_value + }, + "label": { + "type": "plain_text", + "text": title_with_required, + "emoji": true + } + }) + } +} + +fn parse_state_values(state: &State) -> HashMap { + state + .values + .iter() + .flat_map(|(_, inputs)| { + inputs.iter().filter_map(|(action_id, input)| { + if action_id.ends_with("_date") { + process_datetime_inputs(action_id, input, &state) + } else { + process_non_datetime_inputs(action_id, input) + } + }) + }) + .collect() +} + +fn process_datetime_inputs( + action_id: &str, + input: &ValueInput, + state: &State, +) -> Option<(String, serde_json::Value)> { + let base_key = action_id.strip_suffix("_date").unwrap(); + let time_key = format!("{}_time", base_key); + + if let ValueInput::Datepicker { selected_date: Some(date) } = input { + let matching_time = state + .values + .values() + .flat_map(|inputs| inputs.get(&time_key)) + .find_map(|time_input| match time_input { + ValueInput::Timepicker { selected_time: Some(time) } => Some(time), + _ => None, + }); + + return matching_time.map(|time| { + ( + base_key.to_string(), + serde_json::json!(format!("{}T{}:00.000Z", date, time)), + ) + }); + } + None +} + +fn process_non_datetime_inputs( + action_id: &str, + input: &ValueInput, +) -> Option<(String, serde_json::Value)> { + match input { + // Handle number and integer types first + ValueInput::PlainTextInput { value } + if action_id.ends_with("_type_number") || action_id.ends_with("_type_integer") => + { + let base_action_id = action_id + .trim_end_matches("_type_number") + .trim_end_matches("_type_integer"); + value + .as_ref() + .and_then(|v| { + v.as_str().and_then(|s| s.parse::().ok()).map(|num| { + if action_id.ends_with("_type_integer") { + ( + base_action_id.to_string(), + serde_json::json!(num.floor() as i64), + ) + } else { + (base_action_id.to_string(), serde_json::json!(num)) + } + }) + }) + .or_else(|| { + tracing::error!("Failed to parse value to number: {:?}", value); + None + }) + } + + // Plain text input: Extracts the text value + ValueInput::PlainTextInput { value } => value + .as_ref() + .map(|v| (action_id.to_string(), v.clone().into())), + + // Static select: Extracts the selected option's value + ValueInput::StaticSelect { selected_option } => selected_option + .as_ref() + .map(|so| (action_id.to_string(), serde_json::json!(so.value))), + + // Radio buttons: Extracts the selected option's value + ValueInput::RadioButtons { selected_option } => selected_option + .as_ref() + .map(|so| (action_id.to_string(), serde_json::json!(so.value))), + + // Checkboxes: Convert single "true/false" string to boolean true/false + ValueInput::Checkboxes { selected_options } => selected_options.as_ref().map(|options| { + let is_true = options.iter().any(|opt| opt.value == "true"); + (action_id.to_string(), serde_json::json!(is_true)) + }), + + // Default case: Unsupported types return `None` + _ => None, + } +} + +async fn get_slack_token(db: &DB, slack_resource_path: &str, w_id: &str) -> anyhow::Result { + let slack_token = match sqlx::query!( + "SELECT value, is_secret FROM variable WHERE path = $1", + slack_resource_path + ) + .fetch_optional(db) + .await? + { + Some(row) => row, + None => { + return Err(anyhow::anyhow!("No slack token found")); + } + }; + + if slack_token.is_secret { + let mc = build_crypt(&db, w_id).await?; + let bot_token = decrypt_value_with_mc(slack_token.value, mc).await?; + Ok(bot_token) + } else { + Ok(slack_token.value) + } +} + +// Sends a Slack message with a button that opens a modal +async fn send_slack_message( + client: &Client, + bot_token: &str, + channel_id: &str, + w_id: &str, + job_id: Uuid, + resource_path: &str, + approver: Option<&str>, + message: Option<&str>, + flow_step_id: &str, +) -> Result> { + let url = "https://slack.com/api/chat.postMessage"; + + let mut value = serde_json::json!({ + "w_id": w_id, + "job_id": job_id, + "path": resource_path, + "channel": channel_id, + "flow_step_id": flow_step_id, + }); + + if let Some(approver) = approver { + value["approver"] = serde_json::json!(approver); + } + + if let Some(message) = message { + value["message"] = serde_json::json!(message); + } + + 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() + } + ] + } + ] + }); + + tracing::debug!("Payload: {:?}", payload); + + let response = client + .post(url) + .bearer_auth(bot_token) + .json(&payload) + .send() + .await?; + + if response.status().is_success() { + tracing::info!("Interactive Slack approval message sent successfully!"); + tracing::debug!("Response: {:#?}", response.text().await?); + } else { + tracing::error!( + "Failed to send interactive Slack approval message: {}", + response.text().await? + ); + } + + Ok(StatusCode::OK) +} + +async fn get_modal_blocks( + db: DB, + w_id: &str, + job_id: Uuid, + resume_id: u32, + approver: Option<&str>, + message: Option<&str>, + trigger_id: &str, + flow_step_id: Option<&str>, + resource_path: &str, + container: Container, +) -> Result, windmill_common::error::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) = sqlx::query!( + "SELECT + queue.job_kind AS \"job_kind: JobKind\", + queue.script_hash AS \"script_hash: ScriptHash\", + queue.raw_flow AS \"raw_flow: sqlx::types::Json>\", + completed_job.parent_job AS \"parent_job: Uuid\" + FROM queue + JOIN completed_job ON completed_job.parent_job = queue.id + WHERE completed_job.id = $1 AND 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))?; + + let flow_data = match windmill_common::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() { + windmill_common::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 message_str = + message.unwrap_or("*A workflow has been suspended and is waiting for approval:*\n"); + + tracing::debug!("Schema: {:#?}", schema); + + if let Some(resume_schema) = schema { + let hide_cancel = resume_schema.hide_cancel.unwrap_or(false); + + if let Some(schema_obj) = resume_schema.resume_form { + let inner_schema: ResumeSchema = + serde_json::from_value(schema_obj.clone()).map_err(|e| { + tracing::error!("Failed to deserialize form schema: {:?}", e); + Error::BadRequest( + "Failed to deserialize resume form schema! Unsupported form field used." + .to_string(), + ) + })?; + + let blocks = transform_schemas( + message_str, + Some(&inner_schema.schema.properties), + &urls, + Some(&inner_schema.schema.order), + Some(&inner_schema.schema.required), + ) + .await?; + + tracing::debug!("Slack Blocks: {:#?}", blocks); + return Ok(axum::Json(construct_payload( + blocks, + hide_cancel, + trigger_id, + &urls.resume, + resource_path, + container, + ))); + } else { + tracing::debug!("No suspend form found!"); + let blocks = transform_schemas(message_str, None, &urls, None, None).await?; + return Ok(axum::Json(construct_payload( + blocks, + hide_cancel, + trigger_id, + &urls.resume, + resource_path, + container, + ))); + } + } else { + Err(Error::BadRequest( + "No approval form schema found.".to_string(), + )) + } +} + +fn construct_payload( + blocks: serde_json::Value, + hide_cancel: bool, + trigger_id: &str, + resume_url: &str, + resource_path: &str, + container: Container, +) -> serde_json::Value { + let mut view = serde_json::json!({ + "type": "modal", + "callback_id": "submit_form", + "notify_on_close": true, + "title": { + "type": "plain_text", + "text": "Worfklow Suspended" + }, + "blocks": blocks, + "submit": { + "type": "plain_text", + "text": "Resume Workflow" + }, + "private_metadata": serde_json::json!({ "resume_url": resume_url, "resource_path": resource_path, "container": container }).to_string(), + }); + + if !hide_cancel { + view["close"] = serde_json::json!({ + "type": "plain_text", + "text": "Cancel Workflow" + }); + } + + serde_json::json!({ + "trigger_id": trigger_id, + "view": view + }) +} + +async fn open_modal_with_blocks( + client: &Client, + bot_token: &str, + trigger_id: &str, + db: DB, + w_id: &str, + resource_path: &str, + job_id: Uuid, + approver: Option<&str>, + message: Option<&str>, + flow_step_id: Option<&str>, + container: Container, +) -> Result<(), Box> { + let resume_id = rand::random::(); + let blocks_json = match get_modal_blocks( + db, + w_id, + job_id, + resume_id, + approver, + message, + trigger_id, + flow_step_id, + resource_path, + container, + ) + .await + { + Ok(blocks) => blocks, + Err(e) => axum::Json(serde_json::json!({ + "trigger_id": trigger_id, + "view": { + "type": "modal", + "title": { "type": "plain_text", "text": "Error" }, + "blocks": [ { "type": "section", "text": { "type": "mrkdwn", "text": e.to_string() } } ] + } + })), + }; + + let blocks = &*blocks_json; + + tracing::debug!("Blocks: {:#?}", blocks); + + let url = "https://slack.com/api/views.open"; + + let response = client + .post(url) + .bearer_auth(bot_token) + .json(&blocks) + .send() + .await?; + + if response.status().is_success() { + tracing::info!("Slack modal opened successfully!"); + } else { + tracing::error!("Failed to open Slack modal: {}", response.text().await?); + } + + Ok(()) +} + +async fn update_original_slack_message( + action: &str, + token: String, + container: Container, +) -> 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 final_blocks = vec![serde_json::json!({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": message + } + })]; + + let payload = serde_json::json!({ + "channel": container.channel_id, + "ts": container.message_ts, + "text": message, + "blocks": final_blocks, + "mrkdwn": true // Enable markdown to support emojis + }); + + let client = Client::new(); + + let response = client + .post("https://slack.com/api/chat.update") + .bearer_auth(token) // Use the token for authentication + .header("Content-Type", "application/json") + .json(&payload) + .send() + .await + .map_err(|e| Error::from(anyhow::Error::new(e)))?; + + if response.status().is_success() { + tracing::debug!("Slack message updated successfully!"); + } else { + tracing::error!( + "Failed to update Slack message. Status: {}, Response: {:?}", + response.status(), + response + .text() + .await + .map_err(|e| Error::from(anyhow::Error::new(e)))? + ); + } + + Ok(()) +} diff --git a/backend/windmill-worker/src/bigquery_executor.rs b/backend/windmill-worker/src/bigquery_executor.rs index 30c0f1d575..e4674ed6ee 100644 --- a/backend/windmill-worker/src/bigquery_executor.rs +++ b/backend/windmill-worker/src/bigquery_executor.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use futures::future::BoxFuture; use futures::{FutureExt, TryFutureExt}; +use reqwest::Client; use serde_json::{json, value::RawValue, Value}; use windmill_common::error::to_anyhow; use windmill_common::jobs::QueuedJob; @@ -9,11 +10,11 @@ use windmill_common::{error::Error, worker::to_raw_value}; use windmill_parser_sql::{ parse_bigquery_sig, parse_db_resource, parse_sql_blocks, parse_sql_statement_named_params, }; -use windmill_queue::{CanceledBy, HTTP_CLIENT}; +use windmill_queue::CanceledBy; use serde::Deserialize; -use crate::common::OccupancyMetrics; +use crate::common::{build_http_client, OccupancyMetrics}; use crate::handle_child::run_future_with_polling_update_job_poller; use crate::{ common::{build_args_values, resolve_job_timeout}, @@ -68,9 +69,10 @@ fn do_bigquery_inner<'a>( all_statement_values: &'a HashMap, project_id: &'a str, token: &'a str, - timeout_ms: i32, + timeout_ms: u64, column_order: Option<&'a mut Option>>, skip_collect: bool, + http_client: &'a Client, ) -> windmill_common::error::Result>>> { let param_names = parse_sql_statement_named_params(query, '@'); @@ -86,7 +88,7 @@ fn do_bigquery_inner<'a>( .collect::>(); let result_f = async move { - let response = HTTP_CLIENT + let response = http_client .post( "https://bigquery.googleapis.com/bigquery/v2/projects/".to_string() + project_id @@ -249,13 +251,10 @@ pub async fn do_bigquery( .await .map_err(|e| Error::ExecutionErr(e.to_string()))?; - let timeout_ms = i32::try_from( - resolve_job_timeout(&db, &job.workspace_id, job.id, job.timeout) - .await - .0 - .as_millis(), - ) - .unwrap_or(200000); + let (timeout_duration, _, _) = + resolve_job_timeout(&db, &job.workspace_id, job.id, job.timeout).await; + let timeout_ms = timeout_duration.as_millis() as u64; + let http_client = build_http_client(timeout_duration)?; let project_id = authentication_manager .project_id() @@ -325,6 +324,7 @@ pub async fn do_bigquery( timeout_ms, None, annotations.return_last_result && i < queries.len() - 1, + &http_client, ) }) .collect::>>()?; @@ -353,6 +353,7 @@ pub async fn do_bigquery( timeout_ms, Some(column_order), false, + &http_client, )? }; diff --git a/backend/windmill-worker/src/common.rs b/backend/windmill-worker/src/common.rs index 54d7307fd7..05444dd975 100644 --- a/backend/windmill-worker/src/common.rs +++ b/backend/windmill-worker/src/common.rs @@ -3,6 +3,7 @@ use async_recursion::async_recursion; use itertools::Itertools; use lazy_static::lazy_static; use regex::Regex; +use reqwest::Client; use serde::{Deserialize, Serialize}; use serde_json::value::RawValue; use serde_json::{json, Value}; @@ -32,11 +33,7 @@ use windmill_common::{ use anyhow::{anyhow, Result}; use std::path::Path; -use std::{ - collections::HashMap, - sync::Arc, - time::Duration, -}; +use std::{collections::HashMap, sync::Arc, time::Duration}; use uuid::Uuid; use windmill_common::{variables, DB}; @@ -965,3 +962,12 @@ pub fn use_flow_root_path(flow_path: &str) -> String { return flow_path.to_string(); } } + +pub fn build_http_client(timeout_duration: std::time::Duration) -> error::Result { + reqwest::ClientBuilder::new() + .user_agent("windmill/beta") + .timeout(timeout_duration) + .connect_timeout(std::time::Duration::from_secs(10)) + .build() + .map_err(|e| Error::InternalErr(format!("Error building http client: {e:#}"))) +} diff --git a/backend/windmill-worker/src/graphql_executor.rs b/backend/windmill-worker/src/graphql_executor.rs index 67bb6a2c7b..929235cccf 100644 --- a/backend/windmill-worker/src/graphql_executor.rs +++ b/backend/windmill-worker/src/graphql_executor.rs @@ -8,11 +8,11 @@ use windmill_common::jobs::QueuedJob; use windmill_common::worker::to_raw_value; use windmill_common::{error::Error, worker::CLOUD_HOSTED}; use windmill_parser_graphql::parse_graphql_sig; -use windmill_queue::{CanceledBy, HTTP_CLIENT}; +use windmill_queue::CanceledBy; use serde::Deserialize; -use crate::common::OccupancyMetrics; +use crate::common::{build_http_client, resolve_job_timeout, OccupancyMetrics}; use crate::handle_child::run_future_with_polling_update_job_poller; use crate::{common::build_args_map, AuthedClientBackgroundTask}; @@ -81,8 +81,12 @@ pub async fn do_graphql( } } } + let (timeout_duration, _, _) = + resolve_job_timeout(&db, &job.workspace_id, job.id, job.timeout).await; - let mut request = HTTP_CLIENT.post(api.base_url).json(&json!({ + let http_client = build_http_client(timeout_duration)?; + + let mut request = http_client.post(api.base_url).json(&json!({ "query": query, "variables": variables })); diff --git a/backend/windmill-worker/src/python_executor.rs b/backend/windmill-worker/src/python_executor.rs index 578040374a..7f588d143f 100644 --- a/backend/windmill-worker/src/python_executor.rs +++ b/backend/windmill-worker/src/python_executor.rs @@ -332,6 +332,7 @@ pub async fn uv_pip_compile( .env_clear() .env("HOME", HOME_ENV.to_string()) .env("PATH", PATH_ENV.to_string()) + .envs(PROXY_ENVS.clone()) .args(&args) .stdout(Stdio::piped()) .stderr(Stdio::piped()); diff --git a/backend/windmill-worker/src/snowflake_executor.rs b/backend/windmill-worker/src/snowflake_executor.rs index 4e817a3927..3315df6bc1 100644 --- a/backend/windmill-worker/src/snowflake_executor.rs +++ b/backend/windmill-worker/src/snowflake_executor.rs @@ -4,7 +4,7 @@ use core::fmt::Write; use futures::future::BoxFuture; use futures::{FutureExt, TryFutureExt}; use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; -use reqwest::Response; +use reqwest::{Client, Response}; use serde_json::{json, value::RawValue, Value}; use sha2::{Digest, Sha256}; use std::collections::HashMap; @@ -17,7 +17,7 @@ use windmill_queue::{CanceledBy, HTTP_CLIENT}; use serde::{Deserialize, Serialize}; -use crate::common::{resolve_job_timeout, OccupancyMetrics}; +use crate::common::{build_http_client, resolve_job_timeout, OccupancyMetrics}; use crate::handle_child::run_future_with_polling_update_job_poller; use crate::{common::build_args_values, AuthedClientBackgroundTask}; @@ -122,6 +122,7 @@ fn do_snowflake_inner<'a>( token_is_keypair: bool, column_order: Option<&'a mut Option>>, skip_collect: bool, + http_client: &'a Client, ) -> windmill_common::error::Result>>> { body.insert("statement".to_string(), json!(query)); @@ -145,7 +146,7 @@ fn do_snowflake_inner<'a>( } let result_f = async move { - let mut request = HTTP_CLIENT + let mut request = http_client .post(format!( "https://{}.snowflakecomputing.com/api/v2/statements/", account_identifier.to_uppercase() @@ -365,6 +366,11 @@ pub async fn do_snowflake( let queries = parse_sql_blocks(query); + let (timeout_duration, _, _) = + resolve_job_timeout(&db, &job.workspace_id, job.id, job.timeout).await; + + let http_client = build_http_client(timeout_duration)?; + let result_f = if queries.len() > 1 { let futures = queries .iter() @@ -379,6 +385,7 @@ pub async fn do_snowflake( token_is_keypair, None, annotations.return_last_result && i < queries.len() - 1, + &http_client, ) }) .collect::>>()?; @@ -407,6 +414,7 @@ pub async fn do_snowflake( token_is_keypair, Some(column_order), false, + &http_client, )? }; let r = run_future_with_polling_update_job_poller( diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 21f1efed86..cc6493b997 100644 --- a/benchmarks/lib.ts +++ b/benchmarks/lib.ts @@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts"; import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts"; import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts"; -export const VERSION = "v1.440.3"; +export const VERSION = "v1.441.0"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/main.ts b/cli/main.ts index 329fe03fb2..06bc530849 100644 --- a/cli/main.ts +++ b/cli/main.ts @@ -60,7 +60,7 @@ export { // } // }); -export const VERSION = "1.440.3"; +export const VERSION = "1.441.0"; const command = new Command() .name("wmill") diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 583be44233..c611408c3c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-components", - "version": "1.440.3", + "version": "1.441.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-components", - "version": "1.440.3", + "version": "1.441.0", "license": "AGPL-3.0", "dependencies": { "@anthropic-ai/sdk": "^0.32.1", diff --git a/frontend/package.json b/frontend/package.json index 550d7b6829..a33ee17338 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "windmill-components", - "version": "1.440.3", + "version": "1.441.0", "scripts": { "dev": "vite dev", "build": "vite build", diff --git a/frontend/src/lib/components/ExecutionDuration.svelte b/frontend/src/lib/components/ExecutionDuration.svelte index aa42d428ce..3be235583b 100644 --- a/frontend/src/lib/components/ExecutionDuration.svelte +++ b/frontend/src/lib/components/ExecutionDuration.svelte @@ -1,5 +1,6 @@ - - - - - - - {#if !$selectedComponentInEditor} -
- Select a script on the left panel -
- {:else if gridItem} - {#key gridItem?.id} - + + + + + {#if !$selectedComponentInEditor} +
+ Select a script on the left panel +
+ {:else if gridItem} + {#key gridItem?.id} + { + createScriptFromInlineScript(gridItem?.id ?? 'unknown', e.detail) + }} + bind:gridItem + /> + {/key} + {:else if unusedInlineScript > -1 && $app.unusedInlineScripts?.[unusedInlineScript]} + {#key unusedInlineScript} + + sendUserToast('Cannot save to workspace unused scripts', true)} + id={`unused-${unusedInlineScript}`} + bind:name={$app.unusedInlineScripts[unusedInlineScript].name} + bind:inlineScript={$app.unusedInlineScripts[unusedInlineScript].inlineScript} + on:delete={() => { + // remove the script from the array at the index + $app.unusedInlineScripts.splice(unusedInlineScript, 1) + $app.unusedInlineScripts = [...$app.unusedInlineScripts] + }} + /> + {/key} + {:else if hiddenInlineScript > -1} + {#key hiddenInlineScript} + {#if $app.hiddenInlineScripts?.[hiddenInlineScript]} + { - createScriptFromInlineScript(gridItem?.id ?? 'unknown', e.detail) + createScriptFromInlineScript(BG_PREFIX + hiddenInlineScript, e.detail) }} - bind:gridItem - /> - {/key} - {:else if unusedInlineScript > -1 && $app.unusedInlineScripts?.[unusedInlineScript]} - {#key unusedInlineScript} - - sendUserToast('Cannot save to workspace unused scripts', true)} - id={`unused-${unusedInlineScript}`} - bind:name={$app.unusedInlineScripts[unusedInlineScript].name} - bind:inlineScript={$app.unusedInlineScripts[unusedInlineScript].inlineScript} - on:delete={() => { - // remove the script from the array at the index - $app.unusedInlineScripts.splice(unusedInlineScript, 1) - $app.unusedInlineScripts = [...$app.unusedInlineScripts] - }} - /> - {/key} - {:else if hiddenInlineScript > -1} - {#key hiddenInlineScript} - {#if $app.hiddenInlineScripts?.[hiddenInlineScript]} - { - createScriptFromInlineScript(BG_PREFIX + hiddenInlineScript, e.detail) - }} - transformer={$selectedComponentInEditor?.endsWith('_transformer')} - on:delete={() => deleteBackgroundScript(hiddenInlineScript)} - id={BG_PREFIX + hiddenInlineScript} - bind:runnable={$app.hiddenInlineScripts[hiddenInlineScript]} - />{/if}{/key} - {:else} -
- No script found at id {$selectedComponentInEditor} -
- {/if} -
-
-
+ transformer={$selectedComponentInEditor?.endsWith('_transformer')} + on:delete={() => deleteBackgroundScript(hiddenInlineScript)} + id={BG_PREFIX + hiddenInlineScript} + bind:runnable={$app.hiddenInlineScripts[hiddenInlineScript]} + />{/if}{/key} + {:else} +
+ No script found at id {$selectedComponentInEditor} +
+ {/if} + + diff --git a/frontend/src/lib/components/apps/editor/settingsPanel/mainInput/RunnableSelector.svelte b/frontend/src/lib/components/apps/editor/settingsPanel/mainInput/RunnableSelector.svelte index 6870811eeb..cec3bb6df4 100644 --- a/frontend/src/lib/components/apps/editor/settingsPanel/mainInput/RunnableSelector.svelte +++ b/frontend/src/lib/components/apps/editor/settingsPanel/mainInput/RunnableSelector.svelte @@ -18,13 +18,18 @@ export let defaultUserInput = false export let hideCreateScript = false export let onlyFlow = false - - let tab: Tab = onlyFlow ? 'workspaceflows' : 'inlinescripts' - let filter: string = '' - let picker: Drawer + export let rawApps = false const { app, workspace } = getContext('AppViewerContext') + let tab: Tab = onlyFlow + ? 'workspaceflows' + : $app?.unusedInlineScripts?.length > 0 + ? 'inlinescripts' + : 'workspacescripts' + let filter: string = '' + let picker: Drawer + const dispatch = createEventDispatcher<{ pick: { runnable: Runnable @@ -138,15 +143,17 @@
{#if !onlyFlow} - -
- - Detached Inline Scripts -
-
+ {#if !rawApps} + +
+ + Detached Inline Scripts +
+
+ {/if}
- + Workspace Scripts
diff --git a/frontend/src/lib/components/runs/JobPreview.svelte b/frontend/src/lib/components/runs/JobPreview.svelte index e4baf7012c..2dcf08946f 100644 --- a/frontend/src/lib/components/runs/JobPreview.svelte +++ b/frontend/src/lib/components/runs/JobPreview.svelte @@ -14,7 +14,7 @@ import { workspaceStore } from '$lib/stores' import WorkflowTimeline from '../WorkflowTimeline.svelte' import Popover from '../Popover.svelte' - import { truncateRev } from '$lib/utils' + import { isFlowPreview, isScriptPreview, truncateRev } from '$lib/utils' import { createEventDispatcher } from 'svelte' import { ListFilter } from 'lucide-svelte' @@ -154,7 +154,7 @@ {/if}
- {#if job?.is_flow_step == false && job?.flow_status && (job?.job_kind == 'preview' || job?.job_kind == 'script') && !(typeof job.flow_status == 'object' && '_metadata' in job.flow_status)} + {#if job?.is_flow_step == false && job?.flow_status && (isScriptPreview(job?.job_kind) || job?.job_kind == 'script') && !(typeof job.flow_status == 'object' && '_metadata' in job.flow_status)} Result Logs - {#if job?.job_kind == 'preview'} + {#if isScriptPreview(job?.job_kind)} Code {/if} {#if job} - {#if viewTab == 'result' && (job?.job_kind == 'flow' || job?.job_kind == 'flowpreview')} + {#if viewTab == 'result' && (job?.job_kind == 'flow' || isFlowPreview(job?.job_kind))}
@@ -215,7 +215,7 @@ {/if} {/if} {:else if job && `running` in job ? job.running : false} - {#if job?.job_kind == 'flow' || job?.job_kind == 'flowpreview'} + {#if job?.job_kind == 'flow' || isFlowPreview(job?.job_kind)}
diff --git a/frontend/src/lib/components/runs/RunRow.svelte b/frontend/src/lib/components/runs/RunRow.svelte index f79b1b7307..7fd0e87ef5 100644 --- a/frontend/src/lib/components/runs/RunRow.svelte +++ b/frontend/src/lib/components/runs/RunRow.svelte @@ -2,7 +2,15 @@ import { base } from '$lib/base' import { goto } from '$lib/navigation' import type { Job } from '$lib/gen' - import { displayDate, msToReadableTime, truncateHash, truncateRev, isJobCancelable } from '$lib/utils' + import { + displayDate, + msToReadableTime, + truncateHash, + truncateRev, + isJobCancelable, + isFlowPreview, + isScriptPreview + } from '$lib/utils' import { Badge, Button } from '../common' import ScheduleEditor from '../ScheduleEditor.svelte' import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte' @@ -38,7 +46,6 @@ let scheduleEditor: ScheduleEditor $: isExternal = job && job.id === '-' - @@ -110,7 +117,7 @@ {#if job && 'duration_ms' in job && job.duration_ms != undefined} (Ran in {msToReadableTime( job.duration_ms - )}{#if job.job_kind == 'flow' || job.job_kind == 'flowpreview'} total{/if}) + )}{#if job.job_kind == 'flow' || isFlowPreview(job.job_kind)} total{/if}) {/if} {#if job && (job.self_wait_time_ms || job.aggregate_wait_time_ms)} ) - {:else} Waiting for executor (created ) {/if} @@ -176,7 +182,7 @@ {/if}
- {:else if 'job_kind' in job && job.job_kind == 'preview'} + {:else if 'job_kind' in job && isScriptPreview(job.job_kind)} Preview without path {:else if 'job_kind' in job && job.job_kind == 'dependencies'} diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 7dde9aeb2d..22f8518ac8 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -1089,3 +1089,13 @@ export function validateFileExtension(ext: string) { const validExtensionRegex = /^[a-zA-Z0-9]+([._][a-zA-Z0-9]+)*$/ return validExtensionRegex.test(ext) } + +export function isFlowPreview(job_kind: Job['job_kind'] | undefined) { + return !!job_kind && (job_kind === 'flowpreview' || job_kind === 'flownode') +} + +export function isScriptPreview(job_kind: Job['job_kind'] | undefined) { + return ( + !!job_kind && (job_kind === 'preview' || job_kind === 'flowscript' || job_kind === 'appscript') + ) +} diff --git a/frontend/src/routes/(root)/(logged)/resources/+page.svelte b/frontend/src/routes/(root)/(logged)/resources/+page.svelte index 5cbd1cb659..e0312ddb4e 100644 --- a/frontend/src/routes/(root)/(logged)/resources/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/resources/+page.svelte @@ -59,6 +59,7 @@ import autosize from '$lib/autosize' import EditableSchemaWrapper from '$lib/components/schema/EditableSchemaWrapper.svelte' import ResourceEditorDrawer from '$lib/components/ResourceEditorDrawer.svelte' + import GfmMarkdown from '$lib/components/GfmMarkdown.svelte' type ResourceW = ListableResource & { canWrite: boolean; marked?: string } type ResourceTypeW = ResourceType & { canWrite: boolean } @@ -437,7 +438,7 @@

- {resourceTypeViewerObj.description ?? ''} +
{#if resourceTypeViewerObj.formatExtension} diff --git a/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte b/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte index 02bc163746..7a350b7733 100644 --- a/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte @@ -19,6 +19,8 @@ displayDate, emptyString, encodeState, + isFlowPreview, + isScriptPreview, truncateHash, truncateRev } from '$lib/utils' @@ -288,7 +290,7 @@ } function forkPreview() { - if (job?.job_kind == 'flowpreview') { + if (isFlowPreview(job?.job_kind)) { $initialArgsStore = job?.args const state = { flow: { value: job?.raw_flow }, @@ -355,7 +357,7 @@ -{#if (job?.job_kind == 'flow' || job?.job_kind == 'flowpreview') && job?.['running'] && job?.parent_job == undefined} +{#if (job?.job_kind == 'flow' || isFlowPreview(job?.job_kind)) && job?.['running'] && job?.parent_job == undefined} @@ -472,7 +474,7 @@ {@const stem = `/${job?.job_kind}s`} {@const isScript = job?.job_kind === 'script'} {@const viewHref = `${stem}/get/${isScript ? job?.script_hash : job?.script_path}`} - {#if (job?.job_kind == 'flow' || job?.job_kind == 'flowpreview') && job?.['running'] && job?.parent_job == undefined} + {#if (job?.job_kind == 'flow' || isFlowPreview(job?.job_kind)) && job?.['running'] && job?.parent_job == undefined}
@@ -488,7 +490,7 @@
{/if} - {#if job?.job_kind === 'flowpreview' || job?.job_kind === 'preview'} + {#if isFlowPreview(job?.job_kind) || isScriptPreview(job?.job_kind)} {/if} {#if persistentScriptDefinition !== undefined} @@ -833,8 +835,8 @@

Scheduled to be executed later: {displayDate(job?.['scheduled_for'])}

{/if} - {#if job?.job_kind !== 'flow' && job?.job_kind !== 'flowpreview' && job?.job_kind !== 'singlescriptflow' && job?.job_kind !== 'flownode'} - {#if ['python3', 'bun', 'deno'].includes(job?.language ?? '') && (job?.job_kind == 'script' || job?.job_kind == 'preview')} + {#if job?.job_kind !== 'flow' && job?.job_kind !== 'singlescriptflow' && !isFlowPreview(job?.job_kind)} + {#if ['python3', 'bun', 'deno'].includes(job?.language ?? '') && (job?.job_kind == 'script' || isScriptPreview(job?.job_kind))} {/if}
@@ -854,7 +856,7 @@ Result Logs Metrics - {#if job?.job_kind == 'preview'} + {#if isScriptPreview(job?.job_kind)} Code {/if} diff --git a/lsp/Pipfile b/lsp/Pipfile index c55c762bc9..eafd9f245b 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,8 +4,8 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.440.3" -wmill_pg = ">=1.440.3" +wmill = ">=1.441.0" +wmill_pg = ">=1.441.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index d0e40a6dce..178a8f5047 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.440.3 + version: 1.441.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index 2adf4f4277..a661b06208 100644 --- a/powershell-client/WindmillClient/WindmillClient.psd1 +++ b/powershell-client/WindmillClient/WindmillClient.psd1 @@ -12,7 +12,7 @@ RootModule = 'WindmillClient.psm1' # Version number of this module. - ModuleVersion = '1.440.3' + ModuleVersion = '1.441.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/build.sh b/python-client/build.sh index 90195376e2..76bfa4e736 100755 --- a/python-client/build.sh +++ b/python-client/build.sh @@ -6,7 +6,13 @@ cp ../backend/windmill-api/openapi.yaml openapi/openapi.yaml npx @redocly/openapi-cli@latest bundle openapi/openapi.yaml > openapi-bundled.yaml -sed -z 's/FlowModuleValue:/FlowModuleValue2:/' openapi-bundled.yaml > openapi-decycled.yaml +if [[ "$OSTYPE" == "darwin"* ]]; then + # sed -z is not supported on macOS, use perl instead + perl -0777 -pe 's/FlowModuleValue:/FlowModuleValue2:/g' openapi-bundled.yaml > openapi-decycled.yaml +else + sed -z 's/FlowModuleValue:/FlowModuleValue2:/' openapi-bundled.yaml > openapi-decycled.yaml +fi + echo " FlowModuleValue: {}" >> openapi-decycled.yaml npx @redocly/openapi-cli@latest bundle openapi-decycled.yaml --ext json -d > openapi-deref.json @@ -20,9 +26,19 @@ rm -rf openapi/ rm openapi* cp LICENSE windmill-api/ -sed -i '5 i license = "Apache-2.0"' windmill-api/pyproject.toml -sed -i 's/authors = \[\]/authors = \["Ruben Fiszel "\]/g' windmill-api/pyproject.toml +# Check if running on macOS +if [[ "$OSTYPE" == "darwin"* ]]; then + # macOS version + sed -i '' '5 i\ +license = "Apache-2.0"' windmill-api/pyproject.toml + sed -i '' 's/authors = \[\]/\nauthors = \["Ruben Fiszel "\]/g' windmill-api/pyproject.toml +else + # Linux version + sed -i '5 i license = "Apache-2.0"' windmill-api/pyproject.toml + + sed -i 's/authors = \[\]/authors = \["Ruben Fiszel "\]/g' windmill-api/pyproject.toml +fi echo "# Autogenerated Windmill OpenApi Client" >> windmill-api/README.md.tmp echo "This is the raw autogenerated api client. You are most likely more interested \ @@ -33,8 +49,12 @@ user friendly experience. We use \ echo "" >> windmill-api/README.md.tmp +if [[ "$OSTYPE" == "darwin"* ]]; then + tail -r windmill-api/README.md | tail -n +14 | tail -r >> windmill-api/README.md.tmp +else + head -n -13 windmill-api/README.md >> windmill-api/README.md.tmp +fi -head -n -13 windmill-api/README.md >> windmill-api/README.md.tmp mv windmill-api/README.md.tmp windmill-api/README.md cd windmill-api && poetry build diff --git a/python-client/dev.nu b/python-client/dev.nu index 52d68a7bec..11b92503de 100755 --- a/python-client/dev.nu +++ b/python-client/dev.nu @@ -1,6 +1,6 @@ #! /usr/bin/env nu -let cache = "/tmp/windmill/cache/pip/" +let cache = "/tmp/windmill/cache/python_311/" # Clean cache def "main clean" [] { @@ -42,7 +42,7 @@ def main [ rm -rf ($cache ++ wmill*/wmill/*) # Copy files from local ./dist to every wm-client version in cache - ls /tmp/windmill/cache/pip/wmill* | each { + ls /tmp/windmill/cache/python_311/wmill* | each { |i| let path = $i | get name; diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index 9dd8b69e03..3dc143bdda 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.440.3" +version = "1.441.0" description = "A client library for accessing Windmill server wrapping the Windmill client API" license = "Apache-2.0" homepage = "https://windmill.dev" diff --git a/python-client/wmill/wmill/client.py b/python-client/wmill/wmill/client.py index c57e181a0f..a251635502 100644 --- a/python-client/wmill/wmill/client.py +++ b/python-client/wmill/wmill/client.py @@ -623,6 +623,46 @@ class Windmill: params={"approver": approver}, ).json() + def request_interactive_slack_approval( + self, + slack_resource_path: str, + channel_id: str, + message: str = None, + approver: str = None, + ) -> None: + """ + Request interactive Slack approval + :param slack_resource_path: Slack resource path + :param channel_id: Slack channel + :param message: Message to send to Slack + :param approver: Approver name + """ + workspace = self.workspace + flow_job_id = os.environ.get("WM_FLOW_JOB_ID") + + if not flow_job_id: + raise Exception( + "You can't use 'request_interactive_slack_approval' function in a standalone script or flow step preview. Please use it in a flow or a flow preview." + ) + + # Only include non-empty parameters + params = {} + if message: + params["message"] = message + if approver: + params["approver"] = approver + if slack_resource_path: + params["slack_resource_path"] = slack_resource_path + if channel_id: + params["channel_id"] = channel_id + if os.environ.get("WM_FLOW_STEP_ID"): + params["flow_step_id"] = os.environ.get("WM_FLOW_STEP_ID") + + self.get( + f"/w/{workspace}/jobs/slack_approval/{os.environ.get('WM_JOB_ID', 'NO_JOB_ID')}", + params=params, + ) + def username_to_email(self, username: str) -> str: """ Get email from workspace username @@ -972,6 +1012,19 @@ def get_state_path() -> str: def get_resume_urls(approver: str = None) -> dict: return _client.get_resume_urls(approver) +@init_global_client +def request_interactive_slack_approval( + slack_resource_path: str, + channel_id: str, + message: str = None, + approver: str = None, +) -> None: + return _client.request_interactive_slack_approval( + slack_resource_path=slack_resource_path, + channel_id=channel_id, + message=message, + approver=approver, + ) @init_global_client def cancel_running() -> dict: diff --git a/python-client/wmill_pg/pyproject.toml b/python-client/wmill_pg/pyproject.toml index 617075e494..73ebd794cb 100644 --- a/python-client/wmill_pg/pyproject.toml +++ b/python-client/wmill_pg/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill-pg" -version = "1.440.3" +version = "1.441.0" description = "An extension client for the wmill client library focused on pg" license = "Apache-2.0" homepage = "https://windmill.dev" diff --git a/typescript-client/README_DEV.md b/typescript-client/README_DEV.md index 5e2481d978..671f7c915b 100644 --- a/typescript-client/README_DEV.md +++ b/typescript-client/README_DEV.md @@ -1,8 +1,7 @@ # Generate windmill-client bundle ```bash -./node_modules/.bin/esbuild src/index.ts --b -undle --outfile=windmill.js --format=esm +./node_modules/.bin/esbuild src/index.ts --bundle --outfile=windmill.js --format=esm --platform=node ``` # Generate d.ts bundle diff --git a/typescript-client/build.jsr.sh b/typescript-client/build.jsr.sh index b347febb3c..cdcd68f975 100755 --- a/typescript-client/build.jsr.sh +++ b/typescript-client/build.jsr.sh @@ -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 } 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} from "./client";' >> "${script_dirpath}/src/index.ts" diff --git a/typescript-client/build.sh b/typescript-client/build.sh index 436f2a526b..6c80fb5677 100755 --- a/typescript-client/build.sh +++ b/typescript-client/build.sh @@ -22,10 +22,15 @@ const baseUrl = getEnv("BASE_INTERNAL_URL") ?? getEnv("BASE_URL") ?? "http://loc const baseUrlApi = (baseUrl ?? '') + "/api"; EOF -sed -i 's/WITH_CREDENTIALS: false/WITH_CREDENTIALS: true/g' src/core/OpenAPI.ts -sed -i 's/TOKEN: undefined/TOKEN: getEnv("WM_TOKEN")/g' src/core/OpenAPI.ts -sed -i "s/BASE: '\/api'/BASE: baseUrlApi/g" src/core/OpenAPI.ts - +if [[ "$OSTYPE" == "darwin"* ]]; then + sed -i '' 's/WITH_CREDENTIALS: false/WITH_CREDENTIALS: true/g' src/core/OpenAPI.ts + sed -i '' 's/TOKEN: undefined/TOKEN: getEnv("WM_TOKEN")/g' src/core/OpenAPI.ts + sed -i '' "s/BASE: '\/api'/BASE: baseUrlApi/g" src/core/OpenAPI.ts +else + sed -i 's/WITH_CREDENTIALS: false/WITH_CREDENTIALS: true/g' src/core/OpenAPI.ts + sed -i 's/TOKEN: undefined/TOKEN: getEnv("WM_TOKEN")/g' src/core/OpenAPI.ts + sed -i "s/BASE: '\/api'/BASE: baseUrlApi/g" src/core/OpenAPI.ts +fi @@ -34,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 } 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 } from "./client";' >> "${script_dirpath}/src/index.ts" diff --git a/typescript-client/client.js b/typescript-client/client.js index 5a586411da..01dddd9614 100644 --- a/typescript-client/client.js +++ b/typescript-client/client.js @@ -9,7 +9,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge }); }; Object.defineProperty(exports, "__esModule", { value: true }); -exports.usernameToEmail = exports.uint8ArrayToBase64 = exports.base64ToUint8Array = exports.getIdToken = exports.getResumeEndpoints = exports.getResumeUrls = exports.writeS3File = exports.loadS3FileStream = exports.loadS3File = exports.denoS3LightClientSettings = exports.databaseUrlFromResource = exports.setVariable = exports.getVariable = exports.getState = exports.getInternalState = exports.getFlowUserState = exports.setFlowUserState = exports.setState = exports.setInternalState = exports.setResource = exports.getStatePath = exports.resolveDefaultResource = exports.runScriptAsync = exports.task = exports.getResultMaybe = exports.getResult = exports.waitJob = exports.runScript = exports.getRootJobId = exports.getResource = exports.getWorkspace = exports.setClient = exports.SHARED_FOLDER = exports.WorkspaceService = exports.UserService = exports.SettingsService = exports.ScheduleService = exports.ScriptService = exports.VariableService = exports.ResourceService = exports.JobService = exports.GroupService = exports.GranularAclService = exports.FlowService = exports.AuditService = exports.AdminService = void 0; +exports.requestInteractiveSlackApproval = exports.usernameToEmail = exports.uint8ArrayToBase64 = exports.base64ToUint8Array = exports.getIdToken = exports.getResumeEndpoints = exports.getResumeUrls = exports.writeS3File = exports.loadS3FileStream = exports.loadS3File = exports.denoS3LightClientSettings = exports.databaseUrlFromResource = exports.setVariable = exports.getVariable = exports.getState = exports.getInternalState = exports.getFlowUserState = exports.setFlowUserState = exports.setState = exports.setInternalState = exports.setResource = exports.getStatePath = exports.resolveDefaultResource = exports.runScriptAsync = exports.task = exports.getResultMaybe = exports.getResult = exports.waitJob = exports.runScript = exports.getRootJobId = exports.getResource = exports.getWorkspace = exports.setClient = exports.SHARED_FOLDER = exports.WorkspaceService = exports.UserService = exports.SettingsService = exports.ScheduleService = exports.ScriptService = exports.VariableService = exports.ResourceService = exports.JobService = exports.GroupService = exports.GranularAclService = exports.FlowService = exports.AuditService = exports.AdminService = void 0; const index_1 = require("./index"); const index_2 = require("./index"); var index_3 = require("./index"); diff --git a/typescript-client/client.ts b/typescript-client/client.ts index 1fa88fca1b..8f00e4f157 100644 --- a/typescript-client/client.ts +++ b/typescript-client/client.ts @@ -395,15 +395,15 @@ export async function setState(state: any): Promise { */ export async function setProgress(percent: number, jobId?: any): Promise { const workspace = getWorkspace(); - let flowId = getEnv("WM_FLOW_JOB_ID"); + let flowId = getEnv("WM_FLOW_JOB_ID"); // If jobId specified we need to find if there is a parent/flow if (jobId) { const job = await JobService.getJob({ id: jobId ?? "NO_JOB_ID", workspace, - noLogs: true - }); + noLogs: true, + }); // Could be actual flowId or undefined flowId = job.parent_job; @@ -415,22 +415,22 @@ export async function setProgress(percent: number, jobId?: any): Promise { requestBody: { // In case user inputs float, it should be converted to int percent: Math.floor(percent), - flow_job_id: (flowId == "") ? undefined : flowId, - } + flow_job_id: flowId == "" ? undefined : flowId, + }, }); } /** * Get the progress * @param jobId? Job to get progress from - * @returns Optional clamped between 0 and 100 progress value + * @returns Optional clamped between 0 and 100 progress value */ export async function getProgress(jobId?: any): Promise { // TODO: Delete or set to 100 completed job metrics return await MetricsService.getJobProgress({ id: jobId ?? getEnv("WM_JOB_ID") ?? "NO_JOB_ID", workspace: getWorkspace(), - }); + }); } /** @@ -846,3 +846,57 @@ export async function usernameToEmail(username: string): Promise { const workspace = getWorkspace(); return await UserService.usernameToEmail({ username, workspace }); } + +interface SlackApprovalOptions { + slackResourcePath: string; + channelId: string; + message?: string; + approver?: string; +} + +export async function requestInteractiveSlackApproval({ + slackResourcePath, + channelId, + message, + approver, +}: SlackApprovalOptions): Promise { + 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; + slackResourcePath: string; + channelId: string; + flowStepId: string; + } = { + slackResourcePath, + channelId, + flowStepId, + }; + + if (message) { + params.message = message; + } + if (approver) { + params.approver = approver; + } + + await JobService.getSlackApprovalPayload({ + workspace, + ...params, + id: getEnv("WM_JOB_ID") ?? "NO_JOB_ID", + }); +} diff --git a/typescript-client/jsr.json b/typescript-client/jsr.json index 701836c1f4..28d76009d5 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.440.3", + "version": "1.441.0", "exports": "./src/index.ts", "publish": { "exclude": ["!src", "./s3Types.ts", "./client.ts"] diff --git a/typescript-client/package.json b/typescript-client/package.json index e65cc8cd7c..23f0ea2176 100644 --- a/typescript-client/package.json +++ b/typescript-client/package.json @@ -1,7 +1,7 @@ { "name": "windmill-client", "description": "Windmill SDK client for browsers and Node.js", - "version": "1.440.3", + "version": "1.441.0", "author": "Ruben Fiszel", "license": "Apache 2.0", "devDependencies": { diff --git a/version.txt b/version.txt index 68cc1a4f86..7a0dad5f73 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.440.3 +1.441.0