Compare commits

...

9 Commits

Author SHA1 Message Date
Alexander Petric
f9d194894d updating hub script: adding error logging to git sync 2024-12-12 18:05:34 +01:00
Alexander Petric
503930ff56 Merge branch 'main' into alp/slack_interactive_approvals 2024-12-12 17:06:50 +01:00
Alexander Petric
3529625774 date time picker and default values 2024-12-12 17:00:46 +01:00
Alexander Petric
045307ab41 polish messages 2024-12-12 01:25:49 +01:00
Alexander Petric
e437764f5e adding python client 2024-12-12 00:11:43 +01:00
Alexander Petric
6b3881bd00 Merge branch 'main' into alp/slack_interactive_approvals 2024-12-11 10:35:01 +01:00
Alexander Petric
410fb25309 move form creation logic to backend 2024-12-11 10:34:33 +01:00
Alexander Petric
804fd3468c Merge branch 'main' into alp/slack_interactive_approvals 2024-12-05 14:56:30 -05:00
Alexander Petric
e27a97dfbb feat: interactive slack approvals 2024-12-03 18:04:01 -05:00
15 changed files with 1011 additions and 34 deletions

View File

@@ -6937,6 +6937,43 @@ paths:
- resume
- cancel
/w/{workspace}/jobs/slack_approval/{id}/{resume_id}:
get:
summary: get interactive slack approval payload given the job_id, resume_id and a nonce to resume a flow
operationId: getSlackApprovalPayload
tags:
- job
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/JobId"
- name: resume_id
in: path
required: true
schema:
type: integer
- name: approver
in: query
schema:
type: string
- name: message
in: query
schema:
type: string
responses:
"200":
description: Blocks array for posting interactive slack approval
content:
application/json:
schema:
type: object
properties:
blocks:
type: array
items:
type: object
required:
- blocks
/w/{workspace}/jobs_u/resume/{id}/{resume_id}/{signature}:
get:
summary: resume a job for a suspended flow

View File

@@ -14,6 +14,7 @@ use serde_json::value::RawValue;
use sqlx::Pool;
use std::collections::HashMap;
use std::ops::{Deref, DerefMut};
use std::str::FromStr;
#[cfg(feature = "prometheus")]
use std::sync::atomic::Ordering;
use tokio::io::AsyncReadExt;
@@ -2316,7 +2317,7 @@ fn create_signature(
}
#[allow(non_snake_case)]
#[derive(Serialize)]
#[derive(Serialize, Debug)]
pub struct ResumeUrls {
approvalPage: String,
cancel: String,
@@ -5507,3 +5508,553 @@ async fn delete_completed_job<'a>(
let response = Json(cj).into_response();
Ok(response)
}
use axum::extract::Form;
use regex::Regex;
use reqwest::Client;
use serde_json::Value;
#[derive(Deserialize, Debug)]
pub struct SlackFormData {
payload: String,
}
#[derive(Deserialize, Debug)]
struct Payload {
actions: Vec<Action>,
state: State,
response_url: Option<String>,
message: Message,
}
#[derive(Deserialize, Debug)]
struct Message {
blocks: Option<Vec<Value>>,
}
#[derive(Deserialize, Debug)]
struct Action {
value: String,
}
#[derive(Deserialize, Debug)]
struct State {
values: HashMap<String, HashMap<String, ValueInput>>,
}
#[derive(Deserialize, Debug)]
#[serde(tag = "type", rename_all = "snake_case")]
enum ValueInput {
PlainTextInput { value: Option<Value> },
Datepicker { selected_date: Option<String> },
Timepicker { selected_time: Option<String> },
StaticSelect { selected_option: Option<SelectedOption> },
RadioButtons { selected_option: Option<SelectedOption> },
Checkboxes { selected_options: Option<Vec<SelectedOption>> },
}
#[derive(Deserialize, Debug)]
struct SelectedOption {
value: String,
}
pub async fn slack_app_callback_handler(
authed: Option<ApiAuthed>,
Extension(db): Extension<DB>,
Form(form_data): Form<SlackFormData>,
) -> error::Result<StatusCode> {
tracing::debug!("Form data: {:?}", form_data);
let payload: Payload = serde_json::from_str(&form_data.payload)?;
let action_value = payload.actions[0].value.clone();
let response_url = payload.response_url;
let re = Regex::new(r"/api/w/(?P<w_id>[^/]+)/jobs_u/(?P<action>resume|cancel)/(?P<job_id>[^/]+)/(?P<resume_id>[^/]+)/(?P<secret>[a-fA-F0-9]+)(?:\?approver=(?P<approver>[^&]+))?").unwrap();
if let Some(captures) = re.captures(&action_value) {
let w_id = captures.name("w_id").map_or("", |m| m.as_str());
let action = captures.name("action").map_or("", |m| m.as_str());
let job_id = captures.name("job_id").map_or("", |m| m.as_str());
let resume_id = captures.name("resume_id").map_or("", |m| m.as_str());
let secret = captures.name("secret").map_or("", |m| m.as_str());
let approver =
QueryApprover { approver: captures.name("approver").map(|m| m.as_str().to_string()) };
tracing::debug!("Request: {:?}", &form_data.payload.clone());
let state_values: HashMap<String, serde_json::Value> = payload
.state
.values
.iter()
.flat_map(|(_, inputs)| {
inputs.iter().filter_map(|(action_id, input)| {
if action_id.ends_with("_date") {
let base_key = action_id.strip_suffix("_date").unwrap();
let time_key = format!("{}_time", base_key);
// Check for Datepicker and Timepicker inputs specifically
if let ValueInput::Datepicker { selected_date: Some(date) } = input {
let matching_time = payload.state.values.values().flat_map(|inputs| {
inputs.get(&time_key).and_then(|time_input| {
if let ValueInput::Timepicker { selected_time: Some(time) } = time_input {
Some(time)
} else {
None
}
})
}).next();
if let Some(time) = matching_time {
return Some((
base_key.to_string(),
serde_json::json!(format!("{}T{}:00.000Z", date, time)),
));
}
}
}
// Process non-datetime inputs, including plain text or other types with `_date`
match input {
ValueInput::PlainTextInput { value } => {
value.as_ref().map(|v| (action_id.clone(), v.clone().into()))
}
ValueInput::StaticSelect { selected_option } => selected_option
.as_ref()
.map(|so| (action_id.clone(), serde_json::json!(so.value))),
ValueInput::RadioButtons { selected_option } => selected_option
.as_ref()
.map(|so| (action_id.clone(), serde_json::json!(so.value))),
ValueInput::Checkboxes { selected_options } => {
selected_options.as_ref().map(|so| {
(
action_id.clone(),
serde_json::json!(so
.iter()
.map(|option| option.value.clone())
.collect::<Vec<_>>()),
)
})
}
_ => None,
}
})
})
.collect();
let state_json = serde_json::to_value(state_values)
.unwrap_or_else(|_| serde_json::json!({}));
tracing::debug!("W ID: {}", w_id);
tracing::debug!("Action: {}", action);
tracing::debug!("Job ID: {}", job_id);
tracing::debug!("Resume ID: {}", resume_id);
tracing::debug!("Secret: {}", secret);
tracing::debug!("Approver: {:?}", approver.approver);
tracing::debug!("State JSON: {:?}", state_json);
let res = resume_suspended_job_internal(
Some(state_json),
db,
w_id.to_string(),
Uuid::from_str(job_id).unwrap_or_default(),
resume_id.parse::<u32>().unwrap_or_default(),
approver,
secret.to_string(),
authed,
action == "resume",
)
.await;
tracing::debug!("Res: {:?}", res);
if let Some(url) = response_url {
let message = if action == "resume" {
"\n\n*Workflow has been resumed!*"
} else {
"\n\n*Workflow has been canceled!*"
};
let _ = post_slack_response(&url, message).await;
}
} else {
tracing::error!("Resume URL does not match the pattern.");
}
Ok(StatusCode::OK)
}
#[derive(Deserialize)]
pub struct QueryMessage {
pub message: Option<String>,
}
pub async fn request_slack_approval(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path((w_id, job_id, resume_id)): Path<(String, Uuid, u32)>,
Query(approver): Query<QueryApprover>,
Query(message): Query<QueryMessage>,
) -> windmill_common::error::JsonResult<serde_json::Value> {
let res = get_resume_urls(
authed,
axum::Extension(db.clone()),
Path((w_id, job_id, resume_id)),
axum::extract::Query(approver),
)
.await;
let schema: Option<ResumeFormRow> = sqlx::query_as!(
ResumeFormRow,
"SELECT
module.value->'suspend'->'resume_form' AS resume_form,
(module.value->'suspend'->>'hide_cancel')::boolean AS hide_cancel
FROM
job
LEFT JOIN
queue ON job.id = queue.parent_job
LEFT JOIN
jsonb_array_elements(job.raw_flow->'modules') AS module
ON module->>'id' = queue.flow_step_id
WHERE
queue.id = $1",
job_id
)
.fetch_optional(&db)
.await?;
tracing::debug!("schema: {:?}", schema);
tracing::debug!("job_id: {:?}", job_id);
let message_str = message.message.as_deref().unwrap_or("*A workflow has been suspended and is waiting for approval:*\n");
if let Some(resume_schema) = schema {
let hide_cancel = resume_schema.hide_cancel.unwrap_or(false);
let schema_obj = match resume_schema.resume_form {
Some(schema) => schema,
None => {
tracing::debug!("No suspend form found!");
return transform_schemas(message_str, None, &res.unwrap().0, None, hide_cancel)
.await
.map(Json);
}
};
let inner_schema = schema_obj
.get("schema")
.ok_or_else(|| Error::BadRequest("Schema object is missing the 'schema' field!".to_string()))?;
let order_value = inner_schema
.get("order")
.ok_or_else(|| Error::BadRequest("Schema does not contain order field!".to_string()))?;
let order: Vec<String> = serde_json::from_value(order_value.clone())
.map_err(|e| {
tracing::error!("Failed to deserialize order: {:?}", e);
Error::BadRequest("Failed to deserialize order!".to_string())
})?;
let properties_value = inner_schema
.get("properties")
.ok_or_else(|| Error::BadRequest("Schema does not contain properties field!".to_string()))?;
let properties: HashMap<String, ResumeFormField> = serde_json::from_value(properties_value.clone())
.map_err(|e| {
tracing::error!("Deserialization failed: {:?}", e);
Error::BadRequest("Failed to deserialize properties!".to_string())
})?;
let blocks = transform_schemas(message_str, Some(&properties), &res.unwrap().0, Some(&order), hide_cancel)
.await?;
Ok(Json(blocks))
} else {
Err(Error::BadRequest(
"Could not generate interactive Slack message!".to_string(),
))
}
}
async fn post_slack_response(
response_url: &str,
message: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let mut final_blocks = vec![serde_json::json!({
"type": "section",
"text": {
"type": "mrkdwn",
"text": message
}
})];
let payload = serde_json::json!({
"replace_original": "true",
"text": message,
"blocks": final_blocks
});
let client = Client::new();
let response = client
.post(response_url)
.header("Content-Type", "application/json")
.json(&payload)
.send()
.await?;
if response.status().is_success() {
tracing::debug!("Slack response to approval sent successfully!");
} else {
tracing::error!(
"Slack response to approval failed. Status: {}",
response.status()
);
}
Ok(())
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ResumeSchema {
pub schema: Schema,
}
#[derive(Debug, Deserialize)]
pub struct ResumeFormRow {
pub resume_form: Option<serde_json::Value>,
pub hide_cancel: Option<bool>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct Schema {
pub order: Vec<String>,
pub required: Vec<String>,
pub properties: HashMap<String, ResumeFormField>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ResumeFormField {
#[serde(rename = "type")]
pub r#type: String,
pub format: Option<String>,
pub default: Option<String>,
pub description: Option<String>,
pub title: Option<String>,
#[serde(rename = "enum")]
pub r#enum: Option<Vec<String>>,
#[serde(rename = "enumLabels")]
pub enum_labels: Option<HashMap<String, String>>,
}
async fn transform_schemas(
text: &str,
properties: Option<&HashMap<String, ResumeFormField>>,
urls: &ResumeUrls,
order: Option<&Vec<String>>,
hide_cancel: bool,
) -> Result<serde_json::Value, Error> {
tracing::debug!("{:?}", 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 {
if let Some(order) = order {
for key in order {
if let Some(schema) = properties.get(key) {
let input_block = create_input_block(key, schema);
match input_block {
serde_json::Value::Array(arr) => blocks.extend(arr),
_ => blocks.push(input_block),
}
}
}
} else {
for (key, schema) in properties {
let input_block = create_input_block(key, schema);
match input_block {
serde_json::Value::Array(arr) => blocks.extend(arr),
_ => blocks.push(input_block),
}
}
}
}
blocks.push(create_action_buttons(urls, hide_cancel));
Ok(serde_json::Value::Array(blocks))
}
fn create_input_block(key: &str, schema: &ResumeFormField) -> serde_json::Value {
let placeholder = schema
.description
.as_deref()
.filter(|desc| !desc.is_empty())
.unwrap_or("Select an option");
// 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) {
(
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",
"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": schema.title.as_deref().unwrap_or(key),
"emoji": true
}
},
{
"type": "input",
"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::<Vec<_>>(),
"action_id": key
});
if let Some(option) = initial_option {
element["initial_option"] = option;
}
serde_json::json!({
"type": "input",
"element": element,
"label": {
"type": "plain_text",
"text": schema.title.as_deref().unwrap_or(key),
"emoji": true
}
})
} else {
// Handle other types
serde_json::json!({
"type": "input",
"element": {
"type": "plain_text_input",
"action_id": key,
"initial_value": schema.default.as_deref().unwrap_or("")
},
"label": {
"type": "plain_text",
"text": schema.title.as_deref().unwrap_or(key),
"emoji": true
}
})
}
}
fn create_action_buttons(urls: &ResumeUrls, hide_cancel: bool) -> serde_json::Value {
let mut elements = vec![
serde_json::json!({
"type": "button",
"text": {
"type": "plain_text",
"text": "Continue"
},
"style": "primary",
"action_id": "resume_action",
"value": urls.resume
})
];
if !hide_cancel {
elements.push(serde_json::json!({
"type": "button",
"text": {
"type": "plain_text",
"text": "Abort"
},
"style": "danger",
"action_id": "cancel_action",
"value": urls.cancel
}));
}
serde_json::json!({
"type": "actions",
"elements": elements
})
}

View File

@@ -23,7 +23,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;
@@ -367,6 +367,8 @@ pub async fn run_server(
"/w/:workspace_id/jobs_u",
jobs::workspace_unauthed_service().layer(cors.clone()),
)
.route("/slack", post(jobs::slack_app_callback_handler))
.route("/w/:workspace_id/jobs/slack_approval/:job_id/:resume_id", get(jobs::request_slack_approval))
.nest(
"/w/:workspace_id/resources_u",
resources::public_service().layer(cors.clone()),

View File

@@ -1,5 +1,6 @@
{
"gitSync": "hub/9087/sync-script-to-git-repo-windmill",
"gitSync_0": "hub/9087/sync-script-to-git-repo-windmill",
"gitSync": "hub/9987/sync-script-to-git-repo-windmill",
"gitSyncTest": "hub/9073/git-repo-test-read-write-windmill",
"slackErrorHandler": "hub/9206/workspace-or-schedule-error-handler-slack",
"slackErrorHandler_0": "hub/9079/workspace-or-schedule-error-handler-slack",

View File

@@ -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 <ruben@windmill.dev>"\]/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 <ruben@windmill.dev>"\]/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 <ruben@windmill.dev>"\]/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,7 @@ user friendly experience. We use \
echo "" >> windmill-api/README.md.tmp
head -n -13 windmill-api/README.md >> windmill-api/README.md.tmp
tail -r windmill-api/README.md | tail -n +14 | tail -r >> windmill-api/README.md.tmp
mv windmill-api/README.md.tmp windmill-api/README.md
cd windmill-api && poetry build

View File

@@ -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;

View File

@@ -17,6 +17,7 @@ include = ["wmill/py.typed"]
[tool.poetry.dependencies]
python = "^3.7"
httpx = ">=0.24"
slack-sdk = "^3.33.5"
[build-system]
requires = ["poetry>=1.0.2", "poetry-dynamic-versioning"]

View File

@@ -16,6 +16,7 @@ import httpx
from .s3_reader import S3BufferedReader, bytes_generator
from .s3_types import Boto3ConnectionSettings, DuckDbConnectionSettings, PolarsConnectionSettings, S3Object
from slack_sdk import WebClient
_client: "Windmill | None" = None
@@ -623,6 +624,48 @@ class Windmill:
params={"approver": approver},
).json()
def request_interactive_slack_approval(
self,
slack_token: str,
channel: str,
message: str = None,
approver: str = None,
) -> None:
"""
Request interactive Slack approval
:param slack_token: Slack token
:param channel: Slack channel
:param message: Message to send to Slack
:param approver: Approver name
"""
web = WebClient(slack_token)
nonce = random.randint(0, 4294967295)
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
blocks = self.get(
f"/w/{workspace}/jobs/slack_approval/{os.environ.get('WM_JOB_ID', 'NO_JOB_ID')}/{nonce}",
params=params,
).json()
web.chat_postMessage(
channel=channel,
text=message,
blocks=blocks,
)
def username_to_email(self, username: str) -> str:
"""
Get email from workspace username
@@ -972,6 +1015,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_token: str,
channel: str,
message: str = None,
approver: str = None,
) -> dict:
return _client.request_interactive_slack_approval(
slack_token,
channel,
message,
approver,
)
@init_global_client
def cancel_running() -> dict:

View File

@@ -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

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 } 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"

View File

@@ -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' "${script_dirpath}/src/core/OpenAPI.ts"
sed -i 's/TOKEN: undefined/TOKEN: getEnv("WM_TOKEN")/g' "${script_dirpath}/src/core/OpenAPI.ts"
sed -i "s/BASE: '\/api'/BASE: baseUrlApi/g" "${script_dirpath}/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"

View File

@@ -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");

View File

@@ -10,6 +10,7 @@ import {
import { OpenAPI } from "./index";
// import type { DenoS3LightClientSettings } from "./index";
import { DenoS3LightClientSettings, type S3Object } from "./s3Types";
import { WebClient, Block, KnownBlock } from "@slack/web-api";
export {
AdminService,
@@ -395,15 +396,15 @@ export async function setState(state: any): Promise<void> {
*/
export async function setProgress(percent: number, jobId?: any): Promise<void> {
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 +416,22 @@ export async function setProgress(percent: number, jobId?: any): Promise<void> {
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<number | null> {
// 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 +847,51 @@ export async function usernameToEmail(username: string): Promise<string> {
const workspace = getWorkspace();
return await UserService.usernameToEmail({ username, workspace });
}
interface SlackApprovalOptions {
slackToken: string;
channel: string;
message?: string;
approver?: string;
slackBlocks?: (Block | KnownBlock)[];
}
export async function requestInteractiveSlackApproval({
slackToken,
channel,
message,
approver,
}: SlackApprovalOptions): Promise<void> {
const web = new WebClient(slackToken);
const nonce = Math.floor(Math.random() * 4294967295);
const workspace = getWorkspace();
const flowJobId = getEnv("WM_FLOW_JOB_ID");
if (!flowJobId) {
throw new Error(
"You can't use this function in a standalon script or flow step preview. Please us it in a flow or a flow preview."
);
}
// Only include non-empty parameters
const params: { approver?: string; message?: string } = {};
if (message) {
params.message = message;
}
if (approver) {
params.approver = approver;
}
const blocks = await JobService.getSlackApprovalPayload({
workspace,
resumeId: nonce,
...params,
id: getEnv("WM_JOB_ID") ?? "NO_JOB_ID",
});
await web.chat.postMessage({
channel,
text: message,
blocks: blocks as unknown as (Block | KnownBlock)[],
});
}

View File

@@ -1,13 +1,31 @@
{
"name": "windmill-client",
"version": "1.335.0",
"name": "alex-windmill-client-alex",
"version": "1.435.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "windmill-client",
"version": "1.335.0",
"name": "alex-windmill-client-alex",
"version": "1.435.2",
"license": "Apache 2.0",
"dependencies": {
"@slack/web-api": "^7.8.0"
},
"devDependencies": {
"@types/node": "^20.4.10",
"dts-bundle-generator": "^9.5.1",
"esbuild": "^0.21.1",
"typescript": "^5.4.5"
}
},
"../typescript-client/": {
"name": "alex-windmill-client-alex",
"version": "1.435.2",
"extraneous": true,
"license": "Apache 2.0",
"dependencies": {
"@slack/web-api": "^7.8.0"
},
"devDependencies": {
"@types/node": "^20.4.10",
"dts-bundle-generator": "^9.5.1",
@@ -383,11 +401,63 @@
"node": ">=12"
}
},
"node_modules/@slack/logger": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@slack/logger/-/logger-4.0.0.tgz",
"integrity": "sha512-Wz7QYfPAlG/DR+DfABddUZeNgoeY7d1J39OCR2jR+v7VBsB8ezulDK5szTnDDPDwLH5IWhLvXIHlCFZV7MSKgA==",
"license": "MIT",
"dependencies": {
"@types/node": ">=18.0.0"
},
"engines": {
"node": ">= 18",
"npm": ">= 8.6.0"
}
},
"node_modules/@slack/types": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/@slack/types/-/types-2.14.0.tgz",
"integrity": "sha512-n0EGm7ENQRxlXbgKSrQZL69grzg1gHLAVd+GlRVQJ1NSORo0FrApR7wql/gaKdu2n4TO83Sq/AmeUOqD60aXUA==",
"license": "MIT",
"engines": {
"node": ">= 12.13.0",
"npm": ">= 6.12.0"
}
},
"node_modules/@slack/web-api": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@slack/web-api/-/web-api-7.8.0.tgz",
"integrity": "sha512-d4SdG+6UmGdzWw38a4sN3lF/nTEzsDxhzU13wm10ejOpPehtmRoqBKnPztQUfFiWbNvSb4czkWYJD4kt+5+Fuw==",
"license": "MIT",
"dependencies": {
"@slack/logger": "^4.0.0",
"@slack/types": "^2.9.0",
"@types/node": ">=18.0.0",
"@types/retry": "0.12.0",
"axios": "^1.7.8",
"eventemitter3": "^5.0.1",
"form-data": "^4.0.0",
"is-electron": "2.2.2",
"is-stream": "^2",
"p-queue": "^6",
"p-retry": "^4",
"retry": "^0.13.1"
},
"engines": {
"node": ">= 18",
"npm": ">= 8.6.0"
}
},
"node_modules/@types/node": {
"version": "20.4.10",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.4.10.tgz",
"integrity": "sha512-vwzFiiy8Rn6E0MtA13/Cxxgpan/N6UeNYR9oUu6kuJWxu6zCk98trcDp8CBhbtaeuq9SykCmXkFr2lWLoPcvLg==",
"dev": true
"integrity": "sha512-vwzFiiy8Rn6E0MtA13/Cxxgpan/N6UeNYR9oUu6kuJWxu6zCk98trcDp8CBhbtaeuq9SykCmXkFr2lWLoPcvLg=="
},
"node_modules/@types/retry": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz",
"integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==",
"license": "MIT"
},
"node_modules/ansi-regex": {
"version": "5.0.1",
@@ -413,6 +483,23 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.7.9",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz",
"integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
@@ -445,6 +532,27 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/dts-bundle-generator": {
"version": "9.5.1",
"resolved": "https://registry.npmjs.org/dts-bundle-generator/-/dts-bundle-generator-9.5.1.tgz",
@@ -514,6 +622,46 @@
"node": ">=6"
}
},
"node_modules/eventemitter3": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
"license": "MIT"
},
"node_modules/follow-redirects": {
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz",
"integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
@@ -523,6 +671,12 @@
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/is-electron": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/is-electron/-/is-electron-2.2.2.tgz",
"integrity": "sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==",
"license": "MIT"
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
@@ -532,6 +686,101 @@
"node": ">=8"
}
},
"node_modules/is-stream": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
"license": "MIT",
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/p-finally": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
"integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/p-queue": {
"version": "6.6.2",
"resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz",
"integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==",
"license": "MIT",
"dependencies": {
"eventemitter3": "^4.0.4",
"p-timeout": "^3.2.0"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-queue/node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
"node_modules/p-retry": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz",
"integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==",
"license": "MIT",
"dependencies": {
"@types/retry": "0.12.0",
"retry": "^0.13.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-timeout": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz",
"integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==",
"license": "MIT",
"dependencies": {
"p-finally": "^1.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -541,6 +790,15 @@
"node": ">=0.10.0"
}
},
"node_modules/retry": {
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
"integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==",
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",

View File

@@ -1,5 +1,5 @@
{
"name": "windmill-client",
"name": "alex-windmill-client-alex",
"description": "Windmill SDK client for browsers and Node.js",
"version": "1.437.1",
"author": "Ruben Fiszel",
@@ -18,5 +18,8 @@
"LICENSE",
"README.md",
"package.json"
]
],
"dependencies": {
"@slack/web-api": "^7.8.0"
}
}