mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-11 08:07:15 +00:00
Merge branch 'main' into dieri/triggers-queue-mode
This commit is contained in:
@@ -1,5 +1,39 @@
|
||||
# Changelog
|
||||
|
||||
## [1.575.0](https://github.com/windmill-labs/windmill/compare/v1.574.3...v1.575.0) (2025-11-12)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* SQL result_collection flag ([#7041](https://github.com/windmill-labs/windmill/issues/7041)) ([14e41ba](https://github.com/windmill-labs/windmill/commit/14e41babfe6321abb22081b1b3601e9009451257))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* allow --yes in fork command ([#7122](https://github.com/windmill-labs/windmill/issues/7122)) ([1280617](https://github.com/windmill-labs/windmill/commit/1280617a283ef7bcf53c17d833f1413ffe69dbf8))
|
||||
* **backend:** scim email filter ([#7120](https://github.com/windmill-labs/windmill/issues/7120)) ([7a8fd86](https://github.com/windmill-labs/windmill/commit/7a8fd865e289522fd2d5eaaa9af471f01193a03d))
|
||||
* **flow chat:** fix chat in edit mode + cleaner code ([#7118](https://github.com/windmill-labs/windmill/issues/7118)) ([9791eb4](https://github.com/windmill-labs/windmill/commit/9791eb4568aa9548174941f1bbfb6a338c3bef00))
|
||||
|
||||
## [1.574.3](https://github.com/windmill-labs/windmill/compare/v1.574.2...v1.574.3) (2025-11-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **backend:** add sslmode parameter to DuckLake PostgreSQL connections ([ea5b3e6](https://github.com/windmill-labs/windmill/commit/ea5b3e6b9269a0c631c7601313ab97c4bbdc2137))
|
||||
* use teams internal_id rather than id ([#7113](https://github.com/windmill-labs/windmill/issues/7113)) ([25c36c1](https://github.com/windmill-labs/windmill/commit/25c36c1e0a6cfc56e079ff5a18980ea67403d4f9))
|
||||
|
||||
## [1.574.2](https://github.com/windmill-labs/windmill/compare/v1.574.1...v1.574.2) (2025-11-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **backend:** improve pwsh param block parsing ([#7096](https://github.com/windmill-labs/windmill/issues/7096)) ([51cba95](https://github.com/windmill-labs/windmill/commit/51cba95d3961ec637fd5060556f0e7a8a8bdbb88))
|
||||
* **backend:** improve sse parsing ([#7100](https://github.com/windmill-labs/windmill/issues/7100)) ([2e245ac](https://github.com/windmill-labs/windmill/commit/2e245accc5f641f64a2cd28fc181e6a806c407e2))
|
||||
* fix multiselect in list for apps ([9e4882c](https://github.com/windmill-labs/windmill/commit/9e4882c0a919303480f5a431db3c05b1d5855d37))
|
||||
* make ai chat works with unicode messages ([2d54dfb](https://github.com/windmill-labs/windmill/commit/2d54dfbf05f21faa14655b7124daa291a9d8d66d))
|
||||
* preproccessor ui and expanding preprocessor support ([#6872](https://github.com/windmill-labs/windmill/issues/6872)) ([fc5034e](https://github.com/windmill-labs/windmill/commit/fc5034e94d513bab2feb0ebb2aff822bbb42e4aa))
|
||||
* proper error handling in pulled job preprocessor ([#7098](https://github.com/windmill-labs/windmill/issues/7098)) ([84992cd](https://github.com/windmill-labs/windmill/commit/84992cd8ffa94e009aeb6b25db224a3001581e15))
|
||||
|
||||
## [1.574.1](https://github.com/windmill-labs/windmill/compare/v1.574.0...v1.574.1) (2025-11-08)
|
||||
|
||||
|
||||
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO app_version\n (app_id, value, created_by, raw_app)\n SELECT app_id, value, created_by, raw_app\n FROM app_version WHERE id = $1\n RETURNING id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "3d38720e807b379645d8f3ab61c6a968143d42c3014152608f7d1b252cd8085c"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT COUNT(id) FROM v2_job WHERE permissioned_as_email = $1",
|
||||
"query": "SELECT COUNT(j.id) FROM v2_job_queue q JOIN v2_job j USING (id) WHERE j.permissioned_as_email = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -18,5 +18,5 @@
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "1368ccd2c15a75690041a6c87d4a2849fe6bc668654ffcbfbc22a02027280739"
|
||||
"hash": "7a4e79bcae7c7d69e4bb71c8540b2adb9ca8bd15098038e3ccf8b16164944951"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO app_version\n (app_id, value, created_by, raw_app)\n SELECT app_id, value, created_by, raw_app\n FROM app_version WHERE id = $1\n RETURNING id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "83232f2db5eb1b6fef744998e60420ef920d472286cf4c1f78452446a4bcb604"
|
||||
}
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO flow_version\n (workspace_id, path, value, schema, created_by)\n\n SELECT workspace_id, path, value, schema, created_by\n FROM flow_version WHERE path = $1 AND workspace_id = $2 AND id = $3\n\n RETURNING id\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "a6a973dcd92d2e40fd9a1c1be42052fcd350bd47ee4f63832448b6e6f0f472f0"
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO flow_version\n (workspace_id, path, value, schema, created_by)\n\n SELECT workspace_id, path, value, schema, created_by\n FROM flow_version WHERE path = $1 AND workspace_id = $2 AND id = $3\n\n RETURNING id\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "f0efa383f2025158de160577ad839ae72faf0c8fe097e6ad6d309aee9a8aede2"
|
||||
}
|
||||
Generated
+180
-167
File diff suppressed because it is too large
Load Diff
+3
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "windmill"
|
||||
version = "1.574.1"
|
||||
version = "1.575.0"
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
@@ -33,7 +33,7 @@ members = [
|
||||
exclude = ["./windmill-duckdb-ffi-internal"]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.574.1"
|
||||
version = "1.575.0"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -252,6 +252,7 @@ urlencoding = "^2"
|
||||
url = { version = "^2" , features = ["serde"]}
|
||||
async-oauth2 = "0.5.1"
|
||||
reqwest = { version = "^0.12", features = ["json", "stream", "gzip", "multipart"] }
|
||||
eventsource-stream = "0.2.3"
|
||||
time = "^0"
|
||||
serde_urlencoded = "^0"
|
||||
tokio-tar = "^0"
|
||||
|
||||
@@ -1 +1 @@
|
||||
5b7afe50da442441747e7a8f6ef461c96faa9dc2
|
||||
7cdb62d968feef22046b12a1217b711af457cced
|
||||
@@ -0,0 +1 @@
|
||||
-- Add down migration script here
|
||||
@@ -0,0 +1,99 @@
|
||||
CREATE OR REPLACE FUNCTION update_string(s text)
|
||||
RETURNS text
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
prefix TEXT := '-- https://www.windmill.dev/docs/getting_started/scripts_quickstart/sql#result-collection
|
||||
-- result_collection=legacy
|
||||
|
||||
';
|
||||
BEGIN
|
||||
RETURN prefix || s;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_all_modules(obj jsonb)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
result jsonb;
|
||||
k text;
|
||||
v jsonb;
|
||||
BEGIN
|
||||
IF jsonb_typeof(obj) = 'object' THEN
|
||||
result := '{}'::jsonb;
|
||||
|
||||
FOR k, v IN SELECT * FROM jsonb_each(obj)
|
||||
LOOP
|
||||
IF k = 'content' and jsonb_typeof(v) = 'string' AND obj->>'language' IN ('bigquery', 'postgresql', 'duckdb', 'mssql', 'oracledb', 'snowflake', 'mysql') THEN
|
||||
result := result || jsonb_build_object('content', update_string(obj->>'content'));
|
||||
ELSE
|
||||
result := result || jsonb_build_object(k, update_all_modules(v));
|
||||
END IF;
|
||||
END LOOP;
|
||||
|
||||
RETURN result;
|
||||
ELSIF jsonb_typeof(obj) = 'array' AND jsonb_array_length(obj) > 0 THEN
|
||||
SELECT jsonb_agg(update_all_modules(elem))
|
||||
INTO result
|
||||
FROM jsonb_array_elements(obj) elem;
|
||||
|
||||
RETURN result;
|
||||
ELSE
|
||||
RETURN obj;
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Run on a flow_version_lite jsonb value. Returns an array of flow_node ids whose languages are SQL.
|
||||
CREATE OR REPLACE FUNCTION find_sql_flow_nodes_ids(obj jsonb)
|
||||
RETURNS BIGINT[]
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
result BIGINT[] := '{}';
|
||||
k text;
|
||||
v jsonb;
|
||||
BEGIN
|
||||
IF jsonb_typeof(obj) = 'object' THEN
|
||||
IF obj->>'language' IN ('bigquery', 'postgresql', 'duckdb', 'mssql', 'oracledb', 'snowflake', 'mysql') AND jsonb_typeof(obj->'id') = 'number' THEN
|
||||
result := result || (obj->>'id')::BIGINT;
|
||||
END IF;
|
||||
|
||||
FOR k, v IN SELECT * FROM jsonb_each(obj)
|
||||
LOOP
|
||||
result := result || find_sql_flow_nodes_ids(v);
|
||||
END LOOP;
|
||||
ELSIF jsonb_typeof(obj) = 'array' AND jsonb_array_length(obj) > 0 THEN
|
||||
SELECT array_agg(result_ids)
|
||||
INTO result
|
||||
FROM jsonb_array_elements(obj) elem, unnest(find_sql_flow_nodes_ids(elem)) as result_ids;
|
||||
END IF;
|
||||
RETURN result;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
UPDATE app_version SET value = update_all_modules(value::jsonb)::json;
|
||||
UPDATE draft SET value = update_all_modules(value::jsonb)::json;
|
||||
UPDATE flow SET value = update_all_modules(value);
|
||||
UPDATE flow_version SET value = update_all_modules(value);
|
||||
UPDATE flow_node SET code = update_string(code) WHERE id IN (
|
||||
SELECT v FROM flow_version_lite, unnest(find_sql_flow_nodes_ids(value)) as v
|
||||
);
|
||||
UPDATE app_script SET code = update_string(code) WHERE id IN (
|
||||
SELECT v FROM app_version_lite, unnest(find_sql_flow_nodes_ids(value)) as v
|
||||
);
|
||||
UPDATE script SET content = update_string(content) WHERE language IN ('bigquery', 'postgresql', 'duckdb', 'mssql', 'oracledb', 'snowflake', 'mysql');
|
||||
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
-- ✅ LOG ERROR WITHOUT STOPPING THE MIGRATION
|
||||
RAISE WARNING 'Migration failed: %', SQLERRM;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP FUNCTION IF EXISTS update_all_modules(jsonb);
|
||||
DROP FUNCTION IF EXISTS update_string(text);
|
||||
DROP FUNCTION IF EXISTS find_sql_flow_nodes_ids(jsonb);
|
||||
@@ -47,7 +47,6 @@ pub fn parse_powershell_sig(code: &str) -> anyhow::Result<MainArgSignature> {
|
||||
lazy_static::lazy_static! {
|
||||
static ref RE_BASH: Regex = Regex::new(r#"(?m)^(\w+)="\$(?:(\d+)|\{(\d+)\}|\{(\d+):-(.*)\})"(?:[\t ]*)?(?:#.*)?\r?$"#).unwrap();
|
||||
|
||||
pub static ref RE_POWERSHELL_PARAM: Regex = Regex::new(r#"(?m)param[\t ]*\(([^)]*)\)"#).unwrap();
|
||||
static ref RE_POWERSHELL_ARGS: Regex = Regex::new(r#"(?:\[([\w\[\]]+)\])?\$(\w+)[\t ]*(?:=[\t ]*(?:(?:(?:"|')([^"\n\r\$]*)(?:"|'))|([\d.]+)))?\r?"#).unwrap();
|
||||
}
|
||||
|
||||
@@ -85,6 +84,136 @@ fn parse_bash_file(code: &str) -> anyhow::Result<Option<Vec<Arg>>> {
|
||||
Ok(Some(args))
|
||||
}
|
||||
|
||||
/// Extract a PowerShell param() block, handling nested parentheses.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `code` - The PowerShell code to extract from
|
||||
/// * `include_keyword` - If true, returns the full block including "param(...)"
|
||||
/// If false, returns only the contents between the parentheses
|
||||
///
|
||||
/// # Returns
|
||||
/// The extracted param block or contents, or None if not found.
|
||||
pub fn extract_powershell_param_block(code: &str, include_keyword: bool) -> Option<&str> {
|
||||
// Find "param" keyword (case-insensitive)
|
||||
let lower_code = code.to_lowercase();
|
||||
let param_start = lower_code.find("param")?;
|
||||
|
||||
// Verify that only comments and whitespace appear before "param"
|
||||
let before_param = &code[..param_start];
|
||||
let mut chars = before_param.chars().peekable();
|
||||
let mut in_block_comment = false;
|
||||
|
||||
while let Some(ch) = chars.next() {
|
||||
if in_block_comment {
|
||||
// Check for end of block comment: #>
|
||||
if ch == '#' && chars.peek() == Some(&'>') {
|
||||
chars.next(); // consume '>'
|
||||
in_block_comment = false;
|
||||
}
|
||||
} else {
|
||||
match ch {
|
||||
// Start of block comment: <#
|
||||
'<' if chars.peek() == Some(&'#') => {
|
||||
chars.next(); // consume '#'
|
||||
in_block_comment = true;
|
||||
}
|
||||
// Single-line comment: consume until end of line
|
||||
'#' => {
|
||||
while let Some(&next_ch) = chars.peek() {
|
||||
if next_ch == '\n' || next_ch == '\r' {
|
||||
break;
|
||||
}
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
// Whitespace is allowed
|
||||
c if c.is_whitespace() => {}
|
||||
// Any other character means there's code before param
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we're still in a block comment at the end, it's unclosed - invalid
|
||||
if in_block_comment {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Skip whitespace and tabs after "param"
|
||||
let mut chars = code[param_start + 5..].char_indices();
|
||||
let mut paren_offset = param_start + 5;
|
||||
|
||||
// Skip whitespace to find opening paren
|
||||
while let Some((idx, ch)) = chars.next() {
|
||||
if ch == '(' {
|
||||
paren_offset += idx;
|
||||
break;
|
||||
} else if !ch.is_whitespace() && ch != '\t' {
|
||||
// Found non-whitespace, non-paren character - not a valid param block
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
// Now parse from the opening parenthesis
|
||||
let remaining = &code[paren_offset..];
|
||||
let mut chars = remaining.char_indices();
|
||||
|
||||
// Skip the opening '('
|
||||
if let Some((_, ch)) = chars.next() {
|
||||
if ch != '(' {
|
||||
return None;
|
||||
}
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut depth = 1;
|
||||
let mut in_single_quote = false;
|
||||
let mut in_double_quote = false;
|
||||
let mut escape_next = false;
|
||||
let content_start = paren_offset + 1; // Start after the opening '('
|
||||
|
||||
for (idx, ch) in chars {
|
||||
if escape_next {
|
||||
escape_next = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
match ch {
|
||||
'`' if in_double_quote => {
|
||||
// PowerShell escape character
|
||||
escape_next = true;
|
||||
}
|
||||
'\'' if !in_double_quote => {
|
||||
in_single_quote = !in_single_quote;
|
||||
}
|
||||
'"' if !in_single_quote => {
|
||||
in_double_quote = !in_double_quote;
|
||||
}
|
||||
'(' if !in_single_quote && !in_double_quote => {
|
||||
depth += 1;
|
||||
}
|
||||
')' if !in_single_quote && !in_double_quote => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
// Found the matching closing parenthesis
|
||||
// idx is the position of ')' relative to paren_offset
|
||||
if include_keyword {
|
||||
// Return full block including "param" keyword and closing paren
|
||||
return Some(&code[param_start..paren_offset + idx + 1]);
|
||||
} else {
|
||||
// Return only contents between parentheses
|
||||
return Some(&code[content_start..paren_offset + idx]);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
enum ParserState {
|
||||
Normal,
|
||||
InSingleQuote,
|
||||
@@ -137,10 +266,9 @@ fn parse_powershell_single_typ(typ: &str) -> Typ {
|
||||
}
|
||||
|
||||
fn parse_powershell_file(code: &str) -> anyhow::Result<Option<Vec<Arg>>> {
|
||||
let param_wrapper = RE_POWERSHELL_PARAM.captures(code);
|
||||
let param_wrapper = extract_powershell_param_block(code, false);
|
||||
let mut args = vec![];
|
||||
if let Some(param_wrapper) = param_wrapper {
|
||||
let param_wrapper = param_wrapper.get(1).unwrap().as_str();
|
||||
let params = split_pwsh_args(param_wrapper);
|
||||
for param in params {
|
||||
if let Some(cap) = RE_POWERSHELL_ARGS.captures(param) {
|
||||
@@ -266,7 +394,7 @@ non_required="${5:-}"
|
||||
|
||||
#[test]
|
||||
fn test_parse_powershell_sig() -> anyhow::Result<()> {
|
||||
let code = r#"param($Msg, [string]$Msg2, $Dflt = "default value, with comma", [int]$Nb = 3 , $Nb2 = 5.0, $Nb3 = 5, $Wahoo = $env:WAHOO, [PSCustomObject]$Obj, [string[]]$Arr)"#;
|
||||
let code = r#"param($Msg, [string]$Msg2, $Dflt = "default value, with comma", [int]$Nb = 3 , $Nb2 = 5.0, $Nb3 = 5, $Wahoo = $env:WAHOO, [PSCustomObject]$Obj, [string[]]$Arr, [Parameter(Mandatory)][ValidateSet('Green', 'Blue', 'Red')][string]$Message)"#;
|
||||
assert_eq!(
|
||||
parse_powershell_sig(code)?,
|
||||
MainArgSignature {
|
||||
@@ -344,6 +472,14 @@ non_required="${5:-}"
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None
|
||||
},
|
||||
Arg {
|
||||
otyp: None,
|
||||
name: "Message".to_string(),
|
||||
typ: Typ::Str(None),
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None
|
||||
}
|
||||
],
|
||||
no_main_func: None,
|
||||
@@ -353,6 +489,121 @@ non_required="${5:-}"
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_powershell_param_block() {
|
||||
// Basic cases
|
||||
assert_eq!(
|
||||
extract_powershell_param_block("param($Name, $Age)", true),
|
||||
Some("param($Name, $Age)")
|
||||
);
|
||||
assert_eq!(
|
||||
extract_powershell_param_block("param($Name, $Age)", false),
|
||||
Some("$Name, $Age")
|
||||
);
|
||||
|
||||
// Case insensitive and whitespace
|
||||
assert_eq!(
|
||||
extract_powershell_param_block("PARAM ($Value)", false),
|
||||
Some("$Value")
|
||||
);
|
||||
|
||||
// Nested parentheses
|
||||
assert_eq!(
|
||||
extract_powershell_param_block("param([ValidateScript({$_ -gt 0})]$Count)", false),
|
||||
Some("[ValidateScript({$_ -gt 0})]$Count")
|
||||
);
|
||||
|
||||
// Strings with parentheses
|
||||
assert_eq!(
|
||||
extract_powershell_param_block("param([string]$Path = 'C:\\file(1).txt')", false),
|
||||
Some("[string]$Path = 'C:\\file(1).txt'")
|
||||
);
|
||||
assert_eq!(
|
||||
extract_powershell_param_block(r#"param($Msg = "Hello (world)")"#, false),
|
||||
Some(r#"$Msg = "Hello (world)""#)
|
||||
);
|
||||
assert_eq!(
|
||||
extract_powershell_param_block(
|
||||
r#"param([Parameter(Mandatory)][ValidateSet('Green', 'Blue', 'Red')][string]$Message)"#,
|
||||
false
|
||||
),
|
||||
Some(r#"[Parameter(Mandatory)][ValidateSet('Green', 'Blue', 'Red')][string]$Message"#)
|
||||
);
|
||||
|
||||
// Escaped quotes
|
||||
assert_eq!(
|
||||
extract_powershell_param_block("param($Text = 'don''t')", false),
|
||||
Some("$Text = 'don''t'")
|
||||
);
|
||||
assert_eq!(
|
||||
extract_powershell_param_block(r#"param($Text = "He said `"Hi`"")"#, false),
|
||||
Some(r#"$Text = "He said `"Hi`"""#)
|
||||
);
|
||||
|
||||
// Multiline
|
||||
let multiline = "param(\n [string]$Name,\n [int]$Age\n)";
|
||||
assert!(extract_powershell_param_block(multiline, false).is_some());
|
||||
|
||||
// Invalid cases
|
||||
assert_eq!(extract_powershell_param_block("$x = 5", false), None);
|
||||
assert_eq!(extract_powershell_param_block("param", false), None);
|
||||
assert_eq!(extract_powershell_param_block("param($x", false), None);
|
||||
|
||||
// Valid: param at beginning with single-line comments before
|
||||
assert_eq!(
|
||||
extract_powershell_param_block("# This is a comment\nparam($Name)", false),
|
||||
Some("$Name")
|
||||
);
|
||||
assert_eq!(
|
||||
extract_powershell_param_block("# Comment 1\n# Comment 2\n\nparam($Name)", false),
|
||||
Some("$Name")
|
||||
);
|
||||
|
||||
// Valid: param at beginning with block comment before
|
||||
assert_eq!(
|
||||
extract_powershell_param_block("<# Block comment #>\nparam($Name)", false),
|
||||
Some("$Name")
|
||||
);
|
||||
assert_eq!(
|
||||
extract_powershell_param_block(
|
||||
"<#\n Multi-line\n block comment\n#>\nparam($Name)",
|
||||
false
|
||||
),
|
||||
Some("$Name")
|
||||
);
|
||||
|
||||
// Valid: mixed comments and whitespace
|
||||
assert_eq!(
|
||||
extract_powershell_param_block(
|
||||
"# Line comment\n<# Block comment #>\n\nparam($Name)",
|
||||
false
|
||||
),
|
||||
Some("$Name")
|
||||
);
|
||||
|
||||
// Invalid: code before param
|
||||
assert_eq!(
|
||||
extract_powershell_param_block("$x = 5\nparam($Name)", false),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
extract_powershell_param_block("Write-Host 'test'\nparam($Name)", false),
|
||||
None
|
||||
);
|
||||
|
||||
// Invalid: unclosed block comment
|
||||
assert_eq!(
|
||||
extract_powershell_param_block("<# Unclosed comment\nparam($Name)", false),
|
||||
None
|
||||
);
|
||||
|
||||
// Invalid: unclosed block comment
|
||||
assert_eq!(
|
||||
extract_powershell_param_block("function test-x{ param($Name)\n}", false),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_bash_sig_with_crlf() -> anyhow::Result<()> {
|
||||
// Test with CRLF line endings (Windows-style)
|
||||
|
||||
@@ -44,23 +44,41 @@ fn parse_default_expr(e: Expression) -> Option<Value> {
|
||||
|
||||
pub fn parse_php_signature(
|
||||
code: &str,
|
||||
override_main: Option<String>,
|
||||
override_entrypoint: Option<String>,
|
||||
) -> anyhow::Result<MainArgSignature> {
|
||||
let main_name = override_main.unwrap_or("main".to_string());
|
||||
let entrypoint_fn_name = override_entrypoint.unwrap_or("main".to_string());
|
||||
|
||||
let ast = parser::parse(code)
|
||||
.map_err(|e| anyhow::anyhow!("Error parsing code: {}", e.to_string()))?;
|
||||
|
||||
let params = ast.into_iter().find_map(|x| match x {
|
||||
Statement::Function(FunctionStatement {
|
||||
name,
|
||||
parameters: FunctionParameterList { parameters, .. },
|
||||
..
|
||||
}) if name.to_string() == main_name => Some(parameters),
|
||||
_ => None,
|
||||
});
|
||||
let mut entrypoint_params = None;
|
||||
let mut has_preprocessor = None;
|
||||
for node in ast.into_iter() {
|
||||
match node {
|
||||
Statement::Function(FunctionStatement {
|
||||
name,
|
||||
parameters: FunctionParameterList { parameters, .. },
|
||||
..
|
||||
}) => {
|
||||
let fn_name = name.to_string();
|
||||
|
||||
if let Some(params) = params {
|
||||
if has_preprocessor.is_none() && fn_name == "preprocessor" {
|
||||
has_preprocessor = Some(true);
|
||||
}
|
||||
|
||||
if entrypoint_params.is_none() && fn_name == entrypoint_fn_name {
|
||||
entrypoint_params = Some(parameters);
|
||||
}
|
||||
|
||||
if has_preprocessor.is_some() && entrypoint_params.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
}
|
||||
|
||||
if let Some(params) = entrypoint_params {
|
||||
let args = params
|
||||
.into_iter()
|
||||
.map(|x| {
|
||||
@@ -82,7 +100,7 @@ pub fn parse_php_signature(
|
||||
star_kwargs: false,
|
||||
args,
|
||||
no_main_func: Some(false),
|
||||
has_preprocessor: None,
|
||||
has_preprocessor,
|
||||
})
|
||||
} else {
|
||||
Ok(MainArgSignature {
|
||||
@@ -90,7 +108,7 @@ pub fn parse_php_signature(
|
||||
star_kwargs: false,
|
||||
args: vec![],
|
||||
no_main_func: Some(true),
|
||||
has_preprocessor: None,
|
||||
has_preprocessor,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,8 +134,11 @@ pub fn parse_graphql(code: &str) -> String {
|
||||
|
||||
#[cfg(feature = "php-parser")]
|
||||
#[wasm_bindgen]
|
||||
pub fn parse_php(code: &str) -> String {
|
||||
wrap_sig(windmill_parser_php::parse_php_signature(code, None))
|
||||
pub fn parse_php(code: &str, main_override: Option<String>) -> String {
|
||||
wrap_sig(windmill_parser_php::parse_php_signature(
|
||||
code,
|
||||
main_override,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(feature = "rust-parser")]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
script_dirpath="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
root_dirpath="$(cd "${script_dirpath}/.." && pwd)"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: "3.0.3"
|
||||
|
||||
info:
|
||||
version: 1.574.1
|
||||
version: 1.575.0
|
||||
title: Windmill API
|
||||
|
||||
contact:
|
||||
@@ -7994,6 +7994,12 @@ paths:
|
||||
schema:
|
||||
type: boolean
|
||||
- $ref: "#/components/parameters/NewJobId"
|
||||
- name: memory_id
|
||||
description: memory ID for chat-enabled flows
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
|
||||
requestBody:
|
||||
description: preview
|
||||
@@ -8020,6 +8026,12 @@ paths:
|
||||
- job
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: memory_id
|
||||
description: memory ID for chat-enabled flows
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
|
||||
requestBody:
|
||||
description: preview
|
||||
|
||||
@@ -6533,7 +6533,15 @@ async fn run_preview_flow_job(
|
||||
check_tag_available_for_workspace(&db, &w_id, &tag, &authed).await?;
|
||||
let tx = PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into());
|
||||
|
||||
let (uuid, tx) = push(
|
||||
let chat_input_enabled = raw_flow.value.chat_input_enabled.unwrap_or(false);
|
||||
let flow_path = raw_flow.path.clone().unwrap_or_default();
|
||||
let user_message = raw_flow
|
||||
.args
|
||||
.as_ref()
|
||||
.and_then(|args| args.get("user_message"))
|
||||
.cloned();
|
||||
|
||||
let (uuid, mut tx) = push(
|
||||
&db,
|
||||
tx,
|
||||
&w_id,
|
||||
@@ -6568,6 +6576,25 @@ async fn run_preview_flow_job(
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Set memory_id if provided (for agent memory)
|
||||
if let Some(memory_id) = run_query.memory_id {
|
||||
set_flow_memory_id(&mut tx, uuid, memory_id).await?;
|
||||
}
|
||||
|
||||
// Handle conversation messages for chat-enabled flows
|
||||
if chat_input_enabled {
|
||||
handle_chat_conversation_messages(
|
||||
&mut tx,
|
||||
&authed,
|
||||
&w_id,
|
||||
&flow_path,
|
||||
&run_query,
|
||||
user_message.as_ref(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok((StatusCode::CREATED, uuid.to_string()))
|
||||
|
||||
@@ -62,7 +62,10 @@ use tower_http::{
|
||||
};
|
||||
use windmill_common::db::UserDB;
|
||||
use windmill_common::worker::CLOUD_HOSTED;
|
||||
use windmill_common::{utils::{configure_client, GIT_VERSION}, BASE_URL, INSTANCE_NAME};
|
||||
use windmill_common::{
|
||||
utils::{configure_client, GIT_VERSION},
|
||||
BASE_URL, INSTANCE_NAME,
|
||||
};
|
||||
|
||||
use crate::scim_oss::has_scim_token;
|
||||
use windmill_common::error::AppError;
|
||||
@@ -180,6 +183,7 @@ pub mod workspaces_ee;
|
||||
mod workspaces_export;
|
||||
mod workspaces_extra;
|
||||
mod workspaces_oss;
|
||||
mod public_app_layer;
|
||||
|
||||
#[cfg(feature = "mcp")]
|
||||
mod mcp;
|
||||
@@ -699,6 +703,15 @@ pub async fn run_server(
|
||||
)
|
||||
};
|
||||
|
||||
let app = if let Some(domain) = public_app_layer::PUBLIC_APP_DOMAIN.as_ref() {
|
||||
tracing::info!("Public app domain filter enabled for domain: {}", domain);
|
||||
app.layer(axum::middleware::from_fn(
|
||||
public_app_layer::public_app_domain_filter,
|
||||
))
|
||||
} else {
|
||||
app
|
||||
};
|
||||
|
||||
let app = app.layer(CatchPanicLayer::custom(|err| {
|
||||
tracing::error!("panic in handler, returning 500: {:?}", err);
|
||||
Response::builder()
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
/*
|
||||
* Author: Ruben Fiszel
|
||||
* Copyright: Windmill Labs, Inc 2022
|
||||
* This file and its contents are licensed under the AGPLv3 License.
|
||||
* Please see the included NOTICE for copyright information and
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::response::Response;
|
||||
use http::StatusCode;
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
pub static ref PUBLIC_APP_DOMAIN: Option<String> = std::env::var("PUBLIC_APP_DOMAIN").ok();
|
||||
}
|
||||
|
||||
/// Middleware to restrict public app domain to whitelisted routes
|
||||
pub async fn public_app_domain_filter(
|
||||
req: axum::extract::Request,
|
||||
next: axum::middleware::Next,
|
||||
) -> Response {
|
||||
if let Some(public_domain) = PUBLIC_APP_DOMAIN.as_ref() {
|
||||
if let Some(host) = req.headers().get(http::header::HOST) {
|
||||
if let Ok(host_str) = host.to_str() {
|
||||
// Extract hostname without port
|
||||
let hostname = host_str.split(':').next().unwrap_or(host_str);
|
||||
|
||||
if hostname == public_domain {
|
||||
let path = req.uri().path();
|
||||
|
||||
// Check if route is whitelisted
|
||||
let is_whitelisted = is_public_route_whitelisted(path);
|
||||
|
||||
if !is_whitelisted {
|
||||
tracing::warn!(
|
||||
"Rejected request to {} on public app domain {}",
|
||||
path,
|
||||
public_domain
|
||||
);
|
||||
return Response::builder()
|
||||
.status(StatusCode::FORBIDDEN)
|
||||
.body(Body::from(
|
||||
"Access forbidden: route not allowed on public app domain",
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
next.run(req).await
|
||||
}
|
||||
|
||||
fn is_public_route_whitelisted(path: &str) -> bool {
|
||||
// Whitelisted route patterns for public app domain
|
||||
let whitelist = [
|
||||
// Public app routes
|
||||
"/api/ee_license",
|
||||
"/api/w/*/users/whoami",
|
||||
"/api/w/*/apps_u/*",
|
||||
"/api/w/*/resources/list",
|
||||
"/api/w/*/jobs_u/getupdate_sse/*",
|
||||
"/api/auth/login",
|
||||
"/api/w/*/folders/listnames",
|
||||
"/api/w/*/resources/exists/*",
|
||||
"/api/w/*/resources/type/get/*",
|
||||
"/api/w/*/resources/type/listnames",
|
||||
"/api/oauth/login/*",
|
||||
"/api/oauth/connect/*",
|
||||
"/oauth/callback/*",
|
||||
"/user/login_callback/*",
|
||||
"/api/workspaces/users",
|
||||
"/api/users/whoami",
|
||||
"/api/oauth/list_connects",
|
||||
"/api/oauth/list_logins",
|
||||
"/public/*",
|
||||
"/a/*",
|
||||
"/Inter-Variable.woff2",
|
||||
];
|
||||
|
||||
for pattern in &whitelist {
|
||||
if matches_pattern(path, pattern) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn matches_pattern(path: &str, pattern: &str) -> bool {
|
||||
// Handle exact match
|
||||
if path == pattern {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle wildcard patterns
|
||||
if !pattern.contains('*') {
|
||||
return false;
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = pattern.split('*').collect();
|
||||
|
||||
// Start from the beginning - path must start with first part
|
||||
if !path.starts_with(parts[0]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut remaining = &path[parts[0].len()..];
|
||||
|
||||
// Check each part in between wildcards
|
||||
for i in 1..parts.len() - 1 {
|
||||
let part = parts[i];
|
||||
if let Some(pos) = remaining.find(part) {
|
||||
remaining = &remaining[pos + part.len()..];
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check the last part
|
||||
let last = parts[parts.len() - 1];
|
||||
if last.is_empty() {
|
||||
// Wildcard at the end, already matched
|
||||
return true;
|
||||
} else {
|
||||
// Must end with the last part
|
||||
return remaining.ends_with(last);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_matches_pattern_exact() {
|
||||
assert!(matches_pattern("/api/version", "/api/version"));
|
||||
assert!(!matches_pattern("/api/versions", "/api/version"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_matches_pattern_wildcard_middle() {
|
||||
assert!(matches_pattern(
|
||||
"/api/w/my-workspace/apps_u/my-app",
|
||||
"/api/w/*/apps_u/*"
|
||||
));
|
||||
assert!(matches_pattern(
|
||||
"/api/w/workspace123/jobs_u/job456",
|
||||
"/api/w/*/jobs_u/*"
|
||||
));
|
||||
assert!(!matches_pattern(
|
||||
"/api/w/workspace/apps/my-app",
|
||||
"/api/w/*/apps_u/*"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_matches_pattern_wildcard_end() {
|
||||
assert!(matches_pattern("/api/r/some/path", "/api/r/*"));
|
||||
assert!(matches_pattern("/api/auth/login", "/api/auth/*"));
|
||||
assert!(matches_pattern("/public/asset.css", "/public/*"));
|
||||
assert!(!matches_pattern("/api/admin/users", "/api/r/*"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_public_route_whitelisted() {
|
||||
// Whitelisted routes based on current whitelist
|
||||
assert!(is_public_route_whitelisted("/api/ee_license"));
|
||||
assert!(is_public_route_whitelisted(
|
||||
"/api/w/my-workspace/users/whoami"
|
||||
));
|
||||
assert!(is_public_route_whitelisted(
|
||||
"/api/w/my-workspace/apps_u/my-app"
|
||||
));
|
||||
assert!(is_public_route_whitelisted(
|
||||
"/api/w/workspace/resources/list"
|
||||
));
|
||||
|
||||
// Non-whitelisted routes
|
||||
assert!(!is_public_route_whitelisted("/api/version"));
|
||||
assert!(!is_public_route_whitelisted("/api/uptodate"));
|
||||
assert!(!is_public_route_whitelisted("/api/w/workspace/scripts"));
|
||||
assert!(!is_public_route_whitelisted("/api/w/workspace/flows"));
|
||||
assert!(!is_public_route_whitelisted(
|
||||
"/api/w/workspace/apps/private"
|
||||
));
|
||||
assert!(!is_public_route_whitelisted("/api/admin/settings"));
|
||||
assert!(!is_public_route_whitelisted("/api/users"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_matches_pattern_complex_multiple_wildcards() {
|
||||
// Test pattern with two wildcards like "/api/w/*/foo/*"
|
||||
assert!(matches_pattern(
|
||||
"/api/w/workspace1/s3_proxy/file.txt",
|
||||
"/api/w/*/s3_proxy/*"
|
||||
));
|
||||
assert!(matches_pattern(
|
||||
"/api/w/my-workspace/s3_proxy/path/to/file.jpg",
|
||||
"/api/w/*/s3_proxy/*"
|
||||
));
|
||||
assert!(matches_pattern(
|
||||
"/api/w/test/apps_u/myapp",
|
||||
"/api/w/*/apps_u/*"
|
||||
));
|
||||
|
||||
// Should not match if middle part is different
|
||||
assert!(!matches_pattern(
|
||||
"/api/w/workspace1/other/file.txt",
|
||||
"/api/w/*/s3_proxy/*"
|
||||
));
|
||||
assert!(!matches_pattern(
|
||||
"/api/w/workspace/apps/myapp",
|
||||
"/api/w/*/apps_u/*"
|
||||
));
|
||||
|
||||
// Edge cases
|
||||
assert!(matches_pattern(
|
||||
"/api/w/ws/s3_proxy/a",
|
||||
"/api/w/*/s3_proxy/*"
|
||||
));
|
||||
|
||||
// Path must have content after the last wildcard segment
|
||||
assert!(matches_pattern(
|
||||
"/api/w/ws/s3_proxy/",
|
||||
"/api/w/*/s3_proxy/*"
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -433,9 +433,9 @@ pub mod flow {
|
||||
make_static! {
|
||||
/// Flow node cache.
|
||||
/// FIXME: Use `Arc<Node>` for cheap cloning.
|
||||
static ref NODES: { FlowNodeId => RawData } in "flow" <= 1000;
|
||||
static ref NODES: { FlowNodeId => RawData } in "flow_2" <= 1000;
|
||||
/// Flow version value cache (version id => value).
|
||||
static ref FLOWS: { i64 => Entry<FlowData> } in "flows" <= 1000;
|
||||
static ref FLOWS: { i64 => Entry<FlowData> } in "flows_2" <= 1000;
|
||||
/// Flow version lite value cache (version id => value).
|
||||
static ref FLOWS_LITE: { i64 => Entry<FlowData> } in "flowslite" <= 1000;
|
||||
}
|
||||
@@ -567,7 +567,7 @@ pub mod script {
|
||||
make_static! {
|
||||
/// Scripts cache.
|
||||
/// FIXME: Use `Arc<Val>` for cheap cloning.
|
||||
static ref CACHE: { ScriptHash => ScriptFull } in "script" <= 1000;
|
||||
static ref CACHE: { ScriptHash => ScriptFull } in "script_2" <= 1000;
|
||||
}
|
||||
|
||||
/// Clear the script cache.
|
||||
@@ -670,7 +670,7 @@ pub mod app {
|
||||
|
||||
make_static! {
|
||||
/// App scripts cache.
|
||||
static ref CACHE: { AppScriptId => Entry<ScriptData> } in "app" <= 1000;
|
||||
static ref CACHE: { AppScriptId => Entry<ScriptData> } in "app_2" <= 1000;
|
||||
}
|
||||
|
||||
/// Clear the app scripts cache.
|
||||
|
||||
@@ -7,7 +7,7 @@ use regex::Regex;
|
||||
use reqwest_middleware::ClientWithMiddleware;
|
||||
use semver::Version;
|
||||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||
use serde_json::value::RawValue;
|
||||
use serde_json::{json, value::RawValue};
|
||||
use sqlx::{types::Json, Pool, Postgres};
|
||||
use std::{
|
||||
cmp::Reverse,
|
||||
@@ -663,13 +663,11 @@ fn parse_file<T: FromStr>(path: &str) -> Option<T> {
|
||||
.flatten()
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
#[annotations("#")]
|
||||
pub struct RubyAnnotations {
|
||||
pub verbose: bool,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
#[annotations("#")]
|
||||
pub struct PythonAnnotations {
|
||||
pub no_cache: bool,
|
||||
@@ -682,7 +680,6 @@ pub struct PythonAnnotations {
|
||||
pub py313: bool,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
#[annotations("//")]
|
||||
pub struct GoAnnotations {
|
||||
pub go1_22_compat: bool,
|
||||
@@ -698,13 +695,174 @@ pub struct TypeScriptAnnotations {
|
||||
|
||||
#[annotations("--")]
|
||||
pub struct SqlAnnotations {
|
||||
pub return_last_result: bool,
|
||||
pub return_last_result: bool, // deprecated, use result_collection instead
|
||||
pub result_collection: SqlResultCollectionStrategy,
|
||||
}
|
||||
|
||||
#[annotations("#")]
|
||||
pub struct BashAnnotations {
|
||||
pub docker: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum SqlResultCollectionStrategy {
|
||||
LastStatementAllRows,
|
||||
LastStatementFirstRow,
|
||||
LastStatementAllRowsScalar,
|
||||
LastStatementFirstRowScalar,
|
||||
AllStatementsAllRows,
|
||||
AllStatementsFirstRow,
|
||||
AllStatementsAllRowsScalar,
|
||||
AllStatementsFirstRowScalar,
|
||||
Legacy,
|
||||
}
|
||||
|
||||
impl SqlResultCollectionStrategy {
|
||||
pub fn parse(s: &str) -> Self {
|
||||
use SqlResultCollectionStrategy::*;
|
||||
match s {
|
||||
"last_statement_all_rows" => LastStatementAllRows,
|
||||
"last_statement_first_row" => LastStatementFirstRow,
|
||||
"last_statement_all_rows_scalar" => LastStatementAllRowsScalar,
|
||||
"last_statement_first_row_scalar" => LastStatementFirstRowScalar,
|
||||
"all_statements_all_rows" => AllStatementsAllRows,
|
||||
"all_statements_first_row" => AllStatementsFirstRow,
|
||||
"all_statements_all_rows_scalar" => AllStatementsAllRowsScalar,
|
||||
"all_statements_first_row_scalar" => AllStatementsFirstRowScalar,
|
||||
"legacy" => Legacy,
|
||||
_ => SqlResultCollectionStrategy::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn collect_last_statement_only(&self, query_count: usize) -> bool {
|
||||
use SqlResultCollectionStrategy::*;
|
||||
match self {
|
||||
LastStatementAllRows
|
||||
| LastStatementFirstRow
|
||||
| LastStatementFirstRowScalar
|
||||
| LastStatementAllRowsScalar => true,
|
||||
Legacy => query_count == 1,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
pub fn collect_first_row_only(&self) -> bool {
|
||||
use SqlResultCollectionStrategy::*;
|
||||
match self {
|
||||
LastStatementFirstRow
|
||||
| LastStatementFirstRowScalar
|
||||
| AllStatementsFirstRow
|
||||
| AllStatementsFirstRowScalar => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
pub fn collect_scalar(&self) -> bool {
|
||||
use SqlResultCollectionStrategy::*;
|
||||
match self {
|
||||
LastStatementFirstRowScalar
|
||||
| AllStatementsFirstRowScalar
|
||||
| LastStatementAllRowsScalar
|
||||
| AllStatementsAllRowsScalar => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
// This function transforms the shape (e.g Row[][] -> Row)
|
||||
// It is the responsibility of the executor to avoid fetching unnecessary statements/rows
|
||||
pub fn collect(
|
||||
&self,
|
||||
values: Vec<Vec<Box<serde_json::value::RawValue>>>,
|
||||
) -> error::Result<Box<serde_json::value::RawValue>> {
|
||||
let null = || serde_json::value::RawValue::from_string("null".to_string()).unwrap();
|
||||
|
||||
let values = if self.collect_last_statement_only(values.len()) {
|
||||
values.into_iter().rev().take(1).collect()
|
||||
} else {
|
||||
values
|
||||
};
|
||||
|
||||
let values = if self.collect_first_row_only() {
|
||||
values
|
||||
.into_iter()
|
||||
.map(|rows| rows.into_iter().take(1).collect())
|
||||
.collect()
|
||||
} else {
|
||||
values
|
||||
};
|
||||
|
||||
let values = if self.collect_scalar() {
|
||||
values
|
||||
.into_iter()
|
||||
.map(|rows| {
|
||||
rows.into_iter()
|
||||
.map(|row| {
|
||||
// Take the first value in the object
|
||||
let record =
|
||||
match serde_json::from_str(row.get()) {
|
||||
Ok(serde_json::Value::Object(record)) => record,
|
||||
Ok(_) => return Err(error::Error::ExecutionErr(
|
||||
"Could not collect sql scalar value from non-object row"
|
||||
.to_string(),
|
||||
)),
|
||||
Err(e) => {
|
||||
return Err(error::Error::ExecutionErr(format!(
|
||||
"Could not collect sql scalar value (failed to parse row): {}",
|
||||
e
|
||||
)))
|
||||
}
|
||||
};
|
||||
let Some((_, value)) = record.iter().next() else {
|
||||
return Err(error::Error::ExecutionErr(
|
||||
"Could not collect sql scalar value from empty row".to_string(),
|
||||
));
|
||||
};
|
||||
Ok(serde_json::value::RawValue::from_string(
|
||||
serde_json::to_string(value).map_err(to_anyhow)?,
|
||||
)
|
||||
.map_err(to_anyhow)?)
|
||||
})
|
||||
.collect::<error::Result<Vec<_>>>()
|
||||
})
|
||||
.collect::<error::Result<Vec<_>>>()?
|
||||
} else {
|
||||
values
|
||||
};
|
||||
|
||||
match (
|
||||
self.collect_last_statement_only(values.len()),
|
||||
self.collect_first_row_only(),
|
||||
) {
|
||||
(true, true) => {
|
||||
match values
|
||||
.into_iter()
|
||||
.last()
|
||||
.map(|rows| rows.into_iter().next())
|
||||
{
|
||||
Some(Some(row)) => Ok(row.clone()),
|
||||
_ => Ok(null()),
|
||||
}
|
||||
}
|
||||
(true, false) => match values.into_iter().last() {
|
||||
Some(rows) => Ok(to_raw_value(&rows)),
|
||||
None => Ok(null()),
|
||||
},
|
||||
(false, true) => {
|
||||
let values = values
|
||||
.into_iter()
|
||||
.map(|rows| rows.into_iter().next().unwrap_or_else(null))
|
||||
.collect::<Vec<_>>();
|
||||
Ok(to_raw_value(&values))
|
||||
}
|
||||
(false, false) => Ok(to_raw_value(&values)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SqlResultCollectionStrategy {
|
||||
fn default() -> Self {
|
||||
SqlResultCollectionStrategy::LastStatementAllRows
|
||||
}
|
||||
}
|
||||
|
||||
/// length = 5
|
||||
/// value = "foo"
|
||||
/// output = "foo "
|
||||
@@ -1701,6 +1859,13 @@ pub fn load_env_vars(
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn error_to_value(err: &error::Error) -> serde_json::Value {
|
||||
match err {
|
||||
error::Error::JsonErr(err) => err.clone(),
|
||||
_ => json!({"message": err.to_string(), "name": err.name()}),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub struct WorkspacedPath {
|
||||
pub workspace_id: String,
|
||||
|
||||
@@ -12,4 +12,4 @@ INSERT INTO t VALUES (NULL);
|
||||
|
||||
causes `Constraint Error: NOT NULL constraint failed: t.x` normally, but here we see `Unknown exception in ExecutorTask::Execute`. This opaque errors comes directly from the C++ DuckDB library : https://github.com/duckdb/duckdb/blob/f99fed1e0b16a842573f9dad529f6c170a004f6e/src/parallel/executor_task.cpp#L58
|
||||
|
||||
To solve this, we compile duckdb separately from the main backend crate and call it with FFI
|
||||
To solve this, we compile duckdb separately from the main backend crate and call it with FFI. It has to be loaded dynamically, if it is loaded statically it will still share lib c++ with deno_core and cause issues.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
ffi::{CStr, CString, c_char},
|
||||
ffi::{CStr, CString, c_char, c_uint},
|
||||
ptr::null_mut,
|
||||
};
|
||||
|
||||
@@ -27,6 +27,14 @@ pub extern "C" fn free_cstr(string: *mut c_char) -> () {
|
||||
}
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn get_version() -> c_uint {
|
||||
// Increment when making breaking changes to the FFI interface.
|
||||
// The windmill worker will check that the version matches or else refuse to call
|
||||
// the FFI functions to avoid undefined behavior.
|
||||
return 1;
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn run_duckdb_ffi(
|
||||
query_block_list: *const *const c_char,
|
||||
@@ -36,6 +44,8 @@ pub extern "C" fn run_duckdb_ffi(
|
||||
base_internal_url: *const c_char,
|
||||
w_id: *const c_char,
|
||||
column_order_ptr: *mut *mut c_char,
|
||||
collect_last_only: bool,
|
||||
collect_first_row_only: bool,
|
||||
) -> *mut c_char {
|
||||
let (r, column_order) = match convert_args(
|
||||
query_block_list,
|
||||
@@ -54,6 +64,8 @@ pub extern "C" fn run_duckdb_ffi(
|
||||
token,
|
||||
base_internal_url,
|
||||
w_id,
|
||||
collect_last_only,
|
||||
collect_first_row_only,
|
||||
)
|
||||
},
|
||||
) {
|
||||
@@ -153,6 +165,8 @@ fn run_duckdb_internal<'a>(
|
||||
token: &str,
|
||||
base_internal_url: &str,
|
||||
w_id: &str,
|
||||
collect_last_only: bool,
|
||||
collect_first_row_only: bool,
|
||||
) -> Result<(String, Option<Vec<String>>), String> {
|
||||
let conn = duckdb::Connection::open_in_memory().map_err(|e| e.to_string())?;
|
||||
|
||||
@@ -189,23 +203,23 @@ fn run_duckdb_internal<'a>(
|
||||
))
|
||||
.map_err(|e| format!("Error setting up S3 secret: {}", e.to_string()))?;
|
||||
|
||||
let mut result: Option<Box<RawValue>> = None;
|
||||
let mut results: Vec<Vec<Box<RawValue>>> = vec![];
|
||||
let mut column_order = None;
|
||||
|
||||
for (query_block_index, query_block) in query_block_list.enumerate() {
|
||||
result = Some(
|
||||
do_duckdb_inner(
|
||||
&conn,
|
||||
query_block,
|
||||
&job_args,
|
||||
query_block_index != query_block_list_count - 1,
|
||||
&mut column_order,
|
||||
)
|
||||
.map_err(|e| e.to_string())?,
|
||||
);
|
||||
let result = do_duckdb_inner(
|
||||
&conn,
|
||||
query_block,
|
||||
&job_args,
|
||||
collect_last_only && query_block_index != query_block_list_count - 1,
|
||||
collect_first_row_only,
|
||||
&mut column_order,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
results.push(result);
|
||||
}
|
||||
let result = result.unwrap_or_else(|| RawValue::from_string("[]".to_string()).unwrap());
|
||||
Ok((result.get().to_string(), column_order))
|
||||
let results = serde_json::value::to_raw_value(&results).map_err(|e| e.to_string())?;
|
||||
Ok((results.get().to_string(), column_order))
|
||||
}
|
||||
|
||||
fn do_duckdb_inner(
|
||||
@@ -213,8 +227,9 @@ fn do_duckdb_inner(
|
||||
query: &str,
|
||||
job_args: &HashMap<String, duckdb::types::Value>,
|
||||
skip_collect: bool,
|
||||
collect_first_row_only: bool,
|
||||
column_order: &mut Option<Vec<String>>,
|
||||
) -> Result<Box<RawValue>, String> {
|
||||
) -> Result<Vec<Box<RawValue>>, String> {
|
||||
let mut rows_vec = vec![];
|
||||
|
||||
let (query, job_args) = interpolate_named_args(query, &job_args);
|
||||
@@ -226,7 +241,7 @@ fn do_duckdb_inner(
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if skip_collect {
|
||||
return Ok(RawValue::from_string("[]".to_string()).unwrap());
|
||||
return Ok(vec![]);
|
||||
}
|
||||
// Statement needs to be stepped at least once or stmt.column_names() will panic
|
||||
let mut column_names = None;
|
||||
@@ -266,11 +281,14 @@ fn do_duckdb_inner(
|
||||
return Err(e.to_string());
|
||||
}
|
||||
}
|
||||
if collect_first_row_only {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
*column_order = column_names;
|
||||
|
||||
serde_json::value::to_raw_value(&rows_vec).map_err(|e| e.to_string())
|
||||
Ok(rows_vec)
|
||||
}
|
||||
|
||||
// duckdb-rs does not support named parameters,
|
||||
|
||||
@@ -1,93 +1,83 @@
|
||||
use proc_macro::TokenStream;
|
||||
use quote::quote;
|
||||
use syn::{parse_macro_input, Ident, ItemStruct, Lit};
|
||||
use syn::{parse_macro_input, ItemStruct, Lit, Type};
|
||||
|
||||
fn is_bool_type(ty: &Type) -> bool {
|
||||
if let Type::Path(type_path) = ty {
|
||||
if let Some(segment) = type_path.path.segments.last() {
|
||||
return segment.ident == "bool";
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[proc_macro_attribute]
|
||||
pub fn annotations(attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||
let input = parse_macro_input!(item as ItemStruct);
|
||||
let name = input.ident.clone();
|
||||
let fields = input
|
||||
.fields
|
||||
.iter()
|
||||
.map(|f| f.ident.clone().unwrap())
|
||||
.collect::<Vec<Ident>>();
|
||||
|
||||
// Match on the literal to extract the string value
|
||||
// Separate fields by type
|
||||
let mut bool_fields = Vec::new();
|
||||
let mut custom_fields = Vec::new();
|
||||
|
||||
for field in input.fields.iter() {
|
||||
let field_name = field.ident.clone().unwrap();
|
||||
let field_type = &field.ty;
|
||||
|
||||
if is_bool_type(field_type) {
|
||||
bool_fields.push(field_name);
|
||||
} else {
|
||||
custom_fields.push((field_name, field_type));
|
||||
}
|
||||
}
|
||||
let (custom_field_names, custom_field_types): (Vec<_>, Vec<_>) =
|
||||
custom_fields.into_iter().unzip();
|
||||
|
||||
// Parse comment literal
|
||||
let comm_lit = match parse_macro_input!(attr as Lit) {
|
||||
Lit::Str(lit_str) => lit_str.value(), // This will give "#" without quotes
|
||||
Lit::Str(lit_str) => lit_str.value(),
|
||||
_ => panic!("Expected a string literal"),
|
||||
};
|
||||
|
||||
// Generate regex
|
||||
let mut reg = format!("^{}|", &comm_lit);
|
||||
{
|
||||
for field in fields.iter() {
|
||||
reg.push_str(&(field.to_string()));
|
||||
reg.push_str("\\b");
|
||||
}
|
||||
|
||||
reg.push_str(r#"|\w+"#);
|
||||
}
|
||||
// Example of generated regex:
|
||||
// ^#
|
||||
// |ann1\b|ann2\b|ann3\b|ann4\b
|
||||
// |\w+
|
||||
|
||||
TokenStream::from(quote! {
|
||||
#[derive(Default, Debug)]
|
||||
#[derive(Default, Debug, Copy, Clone)]
|
||||
#input
|
||||
|
||||
impl std::ops::BitOrAssign for #name{
|
||||
fn bitor_assign(&mut self, rhs: Self) {
|
||||
// Unfold fields
|
||||
// Read more: https://docs.rs/quote/latest/quote/macro.quote.html#interpolation
|
||||
#( self.#fields |= rhs.#fields; )*
|
||||
}
|
||||
}
|
||||
|
||||
impl #name {
|
||||
/// Autogenerated by windmill-macros
|
||||
pub fn parse(inner_content: &str) -> Self{
|
||||
pub fn parse(code: &str) -> Self {
|
||||
let mut res = Self::default();
|
||||
lazy_static::lazy_static! {
|
||||
static ref RE: regex::Regex = regex::Regex::new(#reg).unwrap();
|
||||
}
|
||||
// Create lines stream
|
||||
let mut lines = inner_content.lines();
|
||||
'outer: while let Some(line) = lines.next() {
|
||||
// If comment sign(s) on the right place
|
||||
let mut comms = false;
|
||||
// New instance
|
||||
// We will apply it if in line only annotations
|
||||
let mut new = Self::default();
|
||||
|
||||
'inner: for (i, mat) in RE.find_iter(line).enumerate() {
|
||||
let mut lines = code.lines();
|
||||
|
||||
match mat.as_str(){
|
||||
#comm_lit if i == 0 => {
|
||||
comms = true;
|
||||
continue 'inner;
|
||||
},
|
||||
|
||||
// Will expand into something like:
|
||||
// "ann1" => new.ann1 = true,
|
||||
// "ann2" => new.ann2 = true,
|
||||
// "ann3" => new.ann3 = true,
|
||||
#( stringify!(#fields) => new.#fields = true, )*
|
||||
// Non annotations
|
||||
_ => continue 'outer,
|
||||
};
|
||||
while let Some(line) = lines.next() {
|
||||
if !line.starts_with(#comm_lit) {
|
||||
break;
|
||||
}
|
||||
let line = line[#comm_lit.len()..].trim();
|
||||
let (key, value) = line.split_once('=').unwrap_or((line, ""));
|
||||
|
||||
if !comms {
|
||||
// We dont want to continue if line does not start with #
|
||||
return res;
|
||||
|
||||
match key {
|
||||
#(
|
||||
stringify!(#custom_field_names) => {
|
||||
if value.is_empty() {
|
||||
continue;
|
||||
}
|
||||
res.#custom_field_names = #custom_field_types::parse(value);
|
||||
}
|
||||
)*
|
||||
#(
|
||||
stringify!(#bool_fields) => {
|
||||
res.#bool_fields = true;
|
||||
}
|
||||
)*
|
||||
_ => {
|
||||
// Unknown key=value annotation
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply changes
|
||||
res |= new;
|
||||
}
|
||||
|
||||
res
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ mod annotations_tests {
|
||||
}
|
||||
|
||||
#[annotations("#")]
|
||||
#[derive(Eq, PartialEq, Copy, Clone)]
|
||||
#[derive(Eq, PartialEq)]
|
||||
pub struct Annotations {
|
||||
pub ann1: bool,
|
||||
pub ann2: bool,
|
||||
@@ -34,7 +34,7 @@ mod annotations_tests {
|
||||
}
|
||||
|
||||
#[annotations("//")]
|
||||
#[derive(Eq, PartialEq, Copy, Clone)]
|
||||
#[derive(Eq, PartialEq)]
|
||||
pub struct SlashedAnnotations {
|
||||
pub ann1: bool,
|
||||
pub ann2: bool,
|
||||
@@ -43,7 +43,7 @@ mod annotations_tests {
|
||||
}
|
||||
|
||||
#[annotations("--")]
|
||||
#[derive(Eq, PartialEq, Copy, Clone)]
|
||||
#[derive(Eq, PartialEq)]
|
||||
pub struct MinusedAnnotations {
|
||||
pub ann1: bool,
|
||||
pub ann2: bool,
|
||||
|
||||
@@ -46,3 +46,4 @@ serde_urlencoded.workspace = true
|
||||
regex.workspace = true
|
||||
backon.workspace = true
|
||||
quick_cache.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
||||
+286
-250
@@ -2460,12 +2460,18 @@ pub struct PulledJobResult {
|
||||
pub job: Option<PulledJob>,
|
||||
pub suspended: bool,
|
||||
pub missing_concurrency_key: bool,
|
||||
pub error_while_preprocessing: Option<String>
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum PulledJobResultToJobErr {
|
||||
#[error("missing concurrency key")]
|
||||
MissingConcurrencyKey(JobCompleted),
|
||||
#[error("pulled job preprocessor error: {}", .0.result)]
|
||||
ErrorWhilePreprocessing(JobCompleted),
|
||||
}
|
||||
|
||||
|
||||
impl PulledJobResult {
|
||||
pub fn to_pulled_job(self) -> Result<Option<PulledJob>, PulledJobResultToJobErr> {
|
||||
match self {
|
||||
@@ -2488,9 +2494,278 @@ impl PulledJobResult {
|
||||
from_cache: None,
|
||||
}),
|
||||
),
|
||||
PulledJobResult { job: Some(job), error_while_preprocessing: Some(e), .. } => Err(
|
||||
PulledJobResultToJobErr::ErrorWhilePreprocessing(JobCompleted {
|
||||
preprocessed_args: None,
|
||||
job: MiniCompletedJob::from(job.job),
|
||||
success: false,
|
||||
result: Arc::new(windmill_common::worker::to_raw_value(&json!({
|
||||
"name": "Pulled job preprocessing error",
|
||||
"message": e
|
||||
}))),
|
||||
result_columns: None,
|
||||
mem_peak: 0,
|
||||
cached_res_path: None,
|
||||
token: "".to_string(),
|
||||
canceled_by: None,
|
||||
duration: None,
|
||||
has_stream: Some(false),
|
||||
from_cache: None,
|
||||
}),
|
||||
),
|
||||
PulledJobResult { job, .. } => Ok(job),
|
||||
}
|
||||
}
|
||||
|
||||
/// Generic preprocess function
|
||||
/// Can be used for any kind of preprocessing
|
||||
pub async fn preprocess(&mut self, db: &DB) -> error::Result<()> {
|
||||
let PulledJobResult { job: Some(ref mut pulled_job), .. } = self else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let kind = pulled_job.kind;
|
||||
// Handle dependency job debouncing cleanup when a job is pulled for execution
|
||||
if kind.is_dependency()
|
||||
&& pulled_job
|
||||
.args
|
||||
.as_ref()
|
||||
.map(|x| x.get("triggered_by_relative_import").is_some())
|
||||
.unwrap_or_default()
|
||||
&& !*WMDEBUG_NO_DJOB_DEBOUNCING
|
||||
{
|
||||
return Box::pin(async move {
|
||||
// Only used for testing in tests/relative_imports.rs
|
||||
// Give us some space to work with.
|
||||
#[cfg(debug_assertions)]
|
||||
if let Some(duration) = pulled_job
|
||||
.args
|
||||
.as_ref()
|
||||
.map(|x| {
|
||||
x.get("dbg_sleep_between_pull_and_debounce_key_removal")
|
||||
.map(|v| serde_json::from_str::<u32>(v.get()).ok())
|
||||
.flatten()
|
||||
})
|
||||
.flatten()
|
||||
{
|
||||
tracing::debug!("going to sleep",);
|
||||
sleep(std::time::Duration::from_secs(duration as u64)).await;
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
"Processing debounce cleanup for dependency job {} at path {:?}",
|
||||
&pulled_job.id,
|
||||
&pulled_job.runnable_path
|
||||
);
|
||||
|
||||
let key = format!("{}:{}:dependency", &pulled_job.workspace_id, pulled_job.runnable_path());
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
// === DEBOUNCE CLEANUP ===
|
||||
//
|
||||
// Clean up the debounce_key entry for this job (if it exists).
|
||||
//
|
||||
// IMPORTANT: We delete by key (not job_id) to avoid race conditions:
|
||||
// If pusher has locked this row then this call will be blocked until all txs are commited.
|
||||
//
|
||||
// The idea is that the worker_lockfiles::trigger_dependents_to_recompute_locks will fetch the latest version of the obj.
|
||||
// This object needs to be created before the djob is executed and it happens right here.
|
||||
//
|
||||
// This way the next pusher can fetch the latest version of object and base their djob payload on newest version.
|
||||
// The concurrency limit on djobs will make sure that by the time next djob is started executing the base version it is referencing
|
||||
// has already calculated all locks. This way even next djob will always use the fully finalized version of object.
|
||||
//
|
||||
//
|
||||
//
|
||||
// Note: We don't use a transaction here for performance (it's called during job pull).
|
||||
// This means there's a tiny window where the job is running but key isn't deleted yet,
|
||||
// which is acceptable because new requests will just accumulate data to this job.
|
||||
tracing::debug!(
|
||||
job_id = %pulled_job.id,
|
||||
"Cleaning up debounce_key entry for completed/pulled job"
|
||||
);
|
||||
|
||||
// This will either:
|
||||
// 1. Block until pusher pushed. Which gives us:
|
||||
// - If there was any stale data in pusher, then we will read it here (couple of lines below)
|
||||
// 2. Block pusher until we are done here. This gives us:
|
||||
// - We will clone objects and retrieve the latest version. So when we are done the pusher can read latest version.
|
||||
sqlx::query!("DELETE FROM debounce_key WHERE key = $1", &key)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(
|
||||
error = %e,
|
||||
job_id = %pulled_job.id,
|
||||
"Failed to delete debounce_key"
|
||||
);
|
||||
e
|
||||
})?;
|
||||
|
||||
let Some(base_hash) = pulled_job.runnable_id else {
|
||||
return Err(Error::InternalErr(
|
||||
"Missing runnable_id for dependency job triggered by relative import"
|
||||
.to_string(),
|
||||
));
|
||||
};
|
||||
|
||||
tracing::debug!(
|
||||
job_id = %pulled_job.id,
|
||||
base_hash = %base_hash,
|
||||
job_kind = ?kind,
|
||||
"Creating new version for dependency job triggered by relative import"
|
||||
);
|
||||
|
||||
let new_id = match kind {
|
||||
JobKind::Dependencies => {
|
||||
let deployment_message = pulled_job
|
||||
.args
|
||||
.clone()
|
||||
.map(|hashmap| {
|
||||
hashmap
|
||||
.get("deployment_message")
|
||||
.map(|map_value| {
|
||||
serde_json::from_str::<String>(map_value.get()).ok()
|
||||
})
|
||||
.flatten()
|
||||
})
|
||||
.flatten();
|
||||
|
||||
// This way we tell downstream which script we should archive when the resolution is finished.
|
||||
// (not used at the moment)
|
||||
pulled_job.args.as_mut().map(|args| {
|
||||
args.insert("base_hash".to_owned(), to_raw_value(&*base_hash))
|
||||
});
|
||||
|
||||
let cloned_script = windmill_common::scripts::clone_script(
|
||||
base_hash,
|
||||
&pulled_job.workspace_id,
|
||||
deployment_message,
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if is_generated_from_raw_requirements(&Some(cloned_script.old_script.language), &cloned_script.old_script.lock.map(|v| v.to_string())) {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Script at path {} is generated from raw requirements, not overriding",
|
||||
pulled_job.runnable_path()
|
||||
)));
|
||||
}
|
||||
|
||||
cloned_script.new_hash
|
||||
}
|
||||
JobKind::FlowDependencies => {
|
||||
sqlx::query_scalar!(
|
||||
"INSERT INTO flow_version
|
||||
(workspace_id, path, value, schema, created_by)
|
||||
|
||||
SELECT workspace_id, path, value, schema, created_by
|
||||
FROM flow_version WHERE path = $1 AND workspace_id = $2 AND id = $3
|
||||
|
||||
RETURNING id
|
||||
",
|
||||
pulled_job.runnable_path(),
|
||||
pulled_job.workspace_id,
|
||||
*base_hash,
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?
|
||||
}
|
||||
JobKind::AppDependencies => {
|
||||
sqlx::query_scalar!(
|
||||
"INSERT INTO app_version
|
||||
(app_id, value, created_by, raw_app)
|
||||
SELECT app_id, value, created_by, raw_app
|
||||
FROM app_version WHERE id = $1
|
||||
RETURNING id",
|
||||
*base_hash
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?
|
||||
}
|
||||
_ => {
|
||||
return Err(Error::InternalErr(format!(
|
||||
"Matched unexpected JobKind ({:?}). This is a bug!",
|
||||
kind
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
pulled_job.runnable_id.replace(new_id.into());
|
||||
|
||||
if *windmill_common::worker::MIN_VERSION_SUPPORTS_DEBOUNCING.read().await {
|
||||
// === RETRIEVE ACCUMULATED DEBOUNCE DATA ===
|
||||
//
|
||||
// For flows and apps, retrieve all nodes/components that were accumulated
|
||||
// during the debounce window. This data comes from requests that were merged
|
||||
// into this job instead of creating their own jobs.
|
||||
//
|
||||
// Scripts don't need this because they don't have nodes/components to relock.
|
||||
if let Some(to_relock_field) = match &pulled_job.kind {
|
||||
JobKind::FlowDependencies => Some("nodes_to_relock"),
|
||||
JobKind::AppDependencies => Some("components_to_relock"),
|
||||
_ => None, // Scripts don't use accumulated stale data
|
||||
} {
|
||||
tracing::debug!(
|
||||
job_id = %pulled_job.id,
|
||||
job_kind = ?pulled_job.kind,
|
||||
field = %to_relock_field,
|
||||
"Retrieving accumulated stale data from debounced requests"
|
||||
);
|
||||
|
||||
if let Some(stale_data) = sqlx::query_scalar!(
|
||||
"DELETE FROM debounce_stale_data WHERE job_id = $1 RETURNING to_relock",
|
||||
&pulled_job.id
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(
|
||||
error = %e,
|
||||
job_id = %pulled_job.id,
|
||||
"Failed to retrieve debounce_stale_data"
|
||||
);
|
||||
e
|
||||
})?
|
||||
.flatten()
|
||||
{
|
||||
tracing::debug!(
|
||||
job_id = %pulled_job.id,
|
||||
node_count = stale_data.len(),
|
||||
nodes = ?stale_data,
|
||||
"Retrieved accumulated nodes/components from {} debounced requests",
|
||||
stale_data.len()
|
||||
);
|
||||
|
||||
// Replace the job's relock list with the accumulated data
|
||||
// This ensures all nodes from all debounced requests are processed
|
||||
if let Some(args) = pulled_job.args.as_mut() {
|
||||
args.insert(to_relock_field.to_owned(), to_raw_value(&stale_data));
|
||||
tracing::debug!(
|
||||
field = %to_relock_field,
|
||||
"Updated job args with accumulated debounce data"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
tracing::trace!(
|
||||
job_id = %pulled_job.id,
|
||||
"No accumulated stale data found (no debounced requests or already cleaned up)"
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracing::warn!("Debouncing is not supported on this version of Windmill. Minimum version required for debouncing support.");
|
||||
|
||||
}
|
||||
// This will unblock pusher.
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(())
|
||||
}).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull the job from queue
|
||||
@@ -2516,6 +2791,7 @@ pub async fn pull(
|
||||
job: None,
|
||||
suspended: false,
|
||||
missing_concurrency_key: false,
|
||||
error_while_preprocessing: None,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2571,20 +2847,21 @@ pub async fn pull(
|
||||
// Concurrency limit is available for either enterprise job or dependency job
|
||||
&& (cfg!(feature = "enterprise") || (job.is_dependency() && !*WMDEBUG_NO_DJOB_DEBOUNCING)) =>
|
||||
{
|
||||
let job = crate::jobs_ee::apply_concurrency_limit(
|
||||
crate::jobs_ee::apply_concurrency_limit(
|
||||
db,
|
||||
pull_loop_count,
|
||||
suspended,
|
||||
job,
|
||||
)
|
||||
.await?;
|
||||
job.unwrap_or(PulledJobResult {
|
||||
.await?
|
||||
.unwrap_or(PulledJobResult {
|
||||
job: None,
|
||||
suspended,
|
||||
missing_concurrency_key: false,
|
||||
error_while_preprocessing: None,
|
||||
})
|
||||
}
|
||||
_ => PulledJobResult { job, suspended, missing_concurrency_key: false },
|
||||
_ => PulledJobResult { job, suspended, missing_concurrency_key: false, error_while_preprocessing: None },
|
||||
};
|
||||
|
||||
Ok::<_, Error>(pulled_job_result)
|
||||
@@ -2602,7 +2879,7 @@ pub async fn pull(
|
||||
)
|
||||
.await?;
|
||||
let Some(job) = job else {
|
||||
return Ok(PulledJobResult { job: None, suspended, missing_concurrency_key: false });
|
||||
return Ok(PulledJobResult { job: None, suspended, missing_concurrency_key: false, error_while_preprocessing: None });
|
||||
};
|
||||
|
||||
let has_concurent_limit = job.concurrent_limit.is_some();
|
||||
@@ -2630,6 +2907,7 @@ pub async fn pull(
|
||||
job: Some(pulled_job),
|
||||
suspended,
|
||||
missing_concurrency_key: false,
|
||||
error_while_preprocessing: None,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2637,11 +2915,11 @@ pub async fn pull(
|
||||
if cfg!(feature = "enterprise")
|
||||
|| (pulled_job.is_dependency() && !*WMDEBUG_NO_DJOB_DEBOUNCING)
|
||||
{
|
||||
if let Some(pulled_job) =
|
||||
if let Some(pulled_job_res) =
|
||||
crate::jobs_ee::apply_concurrency_limit(db, pull_loop_count, suspended, pulled_job)
|
||||
.await?
|
||||
{
|
||||
return Ok(pulled_job);
|
||||
return Ok(pulled_job_res);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3693,7 +3971,7 @@ pub async fn push<'c, 'd>(
|
||||
}
|
||||
|
||||
let in_queue = sqlx::query_scalar!(
|
||||
"SELECT COUNT(id) FROM v2_job WHERE permissioned_as_email = $1",
|
||||
"SELECT COUNT(j.id) FROM v2_job_queue q JOIN v2_job j USING (id) WHERE j.permissioned_as_email = $1",
|
||||
email
|
||||
)
|
||||
.fetch_one(_db)
|
||||
@@ -5553,245 +5831,3 @@ pub async fn get_same_worker_job(
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn preprocess_dependency_job(job: &mut PulledJob, db: &DB) -> error::Result<()> {
|
||||
let kind = job.kind;
|
||||
// Handle dependency job debouncing cleanup when a job is pulled for execution
|
||||
if kind.is_dependency()
|
||||
&& job
|
||||
.args
|
||||
.as_ref()
|
||||
.map(|x| x.get("triggered_by_relative_import").is_some())
|
||||
.unwrap_or_default()
|
||||
&& !*WMDEBUG_NO_DJOB_DEBOUNCING
|
||||
{
|
||||
return Box::pin(async move {
|
||||
// Only used for testing in tests/relative_imports.rs
|
||||
// Give us some space to work with.
|
||||
#[cfg(debug_assertions)]
|
||||
if let Some(duration) = job
|
||||
.args
|
||||
.as_ref()
|
||||
.map(|x| {
|
||||
x.get("dbg_sleep_between_pull_and_debounce_key_removal")
|
||||
.map(|v| serde_json::from_str::<u32>(v.get()).ok())
|
||||
.flatten()
|
||||
})
|
||||
.flatten()
|
||||
{
|
||||
tracing::debug!("going to sleep",);
|
||||
sleep(std::time::Duration::from_secs(duration as u64)).await;
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
"Processing debounce cleanup for dependency job {} at path {:?}",
|
||||
&job.id,
|
||||
&job.runnable_path
|
||||
);
|
||||
|
||||
let key = format!("{}:{}:dependency", &job.workspace_id, job.runnable_path());
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
// === DEBOUNCE CLEANUP ===
|
||||
//
|
||||
// Clean up the debounce_key entry for this job (if it exists).
|
||||
//
|
||||
// IMPORTANT: We delete by key (not job_id) to avoid race conditions:
|
||||
// If pusher has locked this row then this call will be blocked until all txs are commited.
|
||||
//
|
||||
// The idea is that the worker_lockfiles::trigger_dependents_to_recompute_locks will fetch the latest version of the obj.
|
||||
// This object needs to be created before the djob is executed and it happens right here.
|
||||
//
|
||||
// This way the next pusher can fetch the latest version of object and base their djob payload on newest version.
|
||||
// The concurrency limit on djobs will make sure that by the time next djob is started executing the base version it is referencing
|
||||
// has already calculated all locks. This way even next djob will always use the fully finalized version of object.
|
||||
//
|
||||
//
|
||||
//
|
||||
// Note: We don't use a transaction here for performance (it's called during job pull).
|
||||
// This means there's a tiny window where the job is running but key isn't deleted yet,
|
||||
// which is acceptable because new requests will just accumulate data to this job.
|
||||
tracing::debug!(
|
||||
job_id = %job.id,
|
||||
"Cleaning up debounce_key entry for completed/pulled job"
|
||||
);
|
||||
|
||||
// This will either:
|
||||
// 1. Block until pusher pushed. Which gives us:
|
||||
// - If there was any stale data in pusher, then we will read it here (couple of lines below)
|
||||
// 2. Block pusher until we are done here. This gives us:
|
||||
// - We will clone objects and retrieve the latest version. So when we are done the pusher can read latest version.
|
||||
sqlx::query!("DELETE FROM debounce_key WHERE key = $1", &key)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(
|
||||
error = %e,
|
||||
job_id = %job.id,
|
||||
"Failed to delete debounce_key"
|
||||
);
|
||||
e
|
||||
})?;
|
||||
|
||||
let Some(base_hash) = job.runnable_id else {
|
||||
return Err(Error::InternalErr(
|
||||
"Missing runnable_id for dependency job triggered by relative import"
|
||||
.to_string(),
|
||||
));
|
||||
};
|
||||
|
||||
tracing::debug!(
|
||||
job_id = %job.id,
|
||||
base_hash = %base_hash,
|
||||
job_kind = ?kind,
|
||||
"Creating new version for dependency job triggered by relative import"
|
||||
);
|
||||
|
||||
let new_id = match kind {
|
||||
JobKind::Dependencies => {
|
||||
let deployment_message = job
|
||||
.args
|
||||
.clone()
|
||||
.map(|hashmap| {
|
||||
hashmap
|
||||
.get("deployment_message")
|
||||
.map(|map_value| {
|
||||
serde_json::from_str::<String>(map_value.get()).ok()
|
||||
})
|
||||
.flatten()
|
||||
})
|
||||
.flatten();
|
||||
|
||||
// This way we tell downstream which script we should archive when the resolution is finished.
|
||||
// (not used at the moment)
|
||||
job.args.as_mut().map(|args| {
|
||||
args.insert("base_hash".to_owned(), to_raw_value(&*base_hash))
|
||||
});
|
||||
|
||||
let cloned_script = windmill_common::scripts::clone_script(
|
||||
base_hash,
|
||||
&job.workspace_id,
|
||||
deployment_message,
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
if is_generated_from_raw_requirements(&Some(cloned_script.old_script.language), &cloned_script.old_script.lock.map(|v| v.to_string())) {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Script at path {} is generated from raw requirements, not overriding",
|
||||
job.runnable_path()
|
||||
)));
|
||||
}
|
||||
cloned_script.new_hash
|
||||
}
|
||||
JobKind::FlowDependencies => {
|
||||
sqlx::query_scalar!(
|
||||
"INSERT INTO flow_version
|
||||
(workspace_id, path, value, schema, created_by)
|
||||
|
||||
SELECT workspace_id, path, value, schema, created_by
|
||||
FROM flow_version WHERE path = $1 AND workspace_id = $2 AND id = $3
|
||||
|
||||
RETURNING id
|
||||
",
|
||||
job.runnable_path(),
|
||||
job.workspace_id,
|
||||
*base_hash,
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?
|
||||
}
|
||||
JobKind::AppDependencies => {
|
||||
sqlx::query_scalar!(
|
||||
"INSERT INTO app_version
|
||||
(app_id, value, created_by, raw_app)
|
||||
SELECT app_id, value, created_by, raw_app
|
||||
FROM app_version WHERE id = $1
|
||||
RETURNING id",
|
||||
*base_hash
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?
|
||||
}
|
||||
_ => {
|
||||
return Err(Error::InternalErr(format!(
|
||||
"Matched unexpected JobKind ({:?}). This is a bug!",
|
||||
kind
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
job.runnable_id.replace(new_id.into());
|
||||
|
||||
if !*windmill_common::worker::MIN_VERSION_SUPPORTS_DEBOUNCING.read().await {
|
||||
tx.commit().await?;
|
||||
tracing::warn!("Debouncing is not supported on this version of Windmill. Minimum version required for debouncing support.");
|
||||
return Ok(());
|
||||
}
|
||||
// === RETRIEVE ACCUMULATED DEBOUNCE DATA ===
|
||||
//
|
||||
// For flows and apps, retrieve all nodes/components that were accumulated
|
||||
// during the debounce window. This data comes from requests that were merged
|
||||
// into this job instead of creating their own jobs.
|
||||
//
|
||||
// Scripts don't need this because they don't have nodes/components to relock.
|
||||
if let Some(to_relock_field) = match &job.kind {
|
||||
JobKind::FlowDependencies => Some("nodes_to_relock"),
|
||||
JobKind::AppDependencies => Some("components_to_relock"),
|
||||
_ => None, // Scripts don't use accumulated stale data
|
||||
} {
|
||||
tracing::debug!(
|
||||
job_id = %job.id,
|
||||
job_kind = ?job.kind,
|
||||
field = %to_relock_field,
|
||||
"Retrieving accumulated stale data from debounced requests"
|
||||
);
|
||||
|
||||
if let Some(stale_data) = sqlx::query_scalar!(
|
||||
"DELETE FROM debounce_stale_data WHERE job_id = $1 RETURNING to_relock",
|
||||
&job.id
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(
|
||||
error = %e,
|
||||
job_id = %job.id,
|
||||
"Failed to retrieve debounce_stale_data"
|
||||
);
|
||||
e
|
||||
})?
|
||||
.flatten()
|
||||
{
|
||||
tracing::debug!(
|
||||
job_id = %job.id,
|
||||
node_count = stale_data.len(),
|
||||
nodes = ?stale_data,
|
||||
"Retrieved accumulated nodes/components from {} debounced requests",
|
||||
stale_data.len()
|
||||
);
|
||||
|
||||
// Replace the job's relock list with the accumulated data
|
||||
// This ensures all nodes from all debounced requests are processed
|
||||
if let Some(args) = job.args.as_mut() {
|
||||
args.insert(to_relock_field.to_owned(), to_raw_value(&stale_data));
|
||||
tracing::debug!(
|
||||
field = %to_relock_field,
|
||||
"Updated job args with accumulated debounce data"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
tracing::trace!(
|
||||
job_id = %job.id,
|
||||
"No accumulated stale data found (no debounced requests or already cleaned up)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// This will unblock pusher.
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -115,6 +115,7 @@ nix.workspace = true
|
||||
bytes.workspace = true
|
||||
reqwest.workspace = true
|
||||
reqwest-middleware.workspace = true
|
||||
eventsource-stream.workspace = true
|
||||
mime_guess.workspace = true
|
||||
hex.workspace = true
|
||||
tiberius = { workspace = true, optional = true }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use eventsource_stream::Eventsource;
|
||||
use reqwest::Response;
|
||||
use serde::Deserialize;
|
||||
use serde_json;
|
||||
@@ -41,39 +42,29 @@ pub struct OpenAISSEEvent {
|
||||
pub choices: Option<Vec<OpenAIChoice>>,
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref DEBUG_SSE_STREAM: bool = std::env::var("DEBUG_SSE_STREAM")
|
||||
.unwrap_or("false".to_string())
|
||||
.parse::<bool>()
|
||||
.unwrap_or(false);
|
||||
}
|
||||
pub trait SSEParser {
|
||||
async fn parse_event_data(&mut self, data: &str) -> Result<(), Error>;
|
||||
|
||||
async fn parse_events(&mut self, response: Response) -> Result<(), Error> {
|
||||
let mut stream = response.bytes_stream();
|
||||
let mut buffer = String::new();
|
||||
let mut stream = response.bytes_stream().eventsource();
|
||||
|
||||
while let Some(chunk_result) = stream.next().await {
|
||||
let chunk = chunk_result
|
||||
.map_err(|e| Error::internal_err(format!("Failed to read chunk: {}", e)))?;
|
||||
|
||||
// Convert chunk to string and add to buffer
|
||||
let chunk_str = String::from_utf8_lossy(&chunk);
|
||||
buffer.push_str(&chunk_str);
|
||||
|
||||
// Process complete lines from buffer
|
||||
while let Some(newline_pos) = buffer.find("\n\n") {
|
||||
let line = buffer.drain(..newline_pos + 2).collect::<String>();
|
||||
let line = line.trim_end_matches('\n');
|
||||
|
||||
// Skip empty lines and comments
|
||||
if line.is_empty() || line.starts_with(':') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse SSE data field
|
||||
if let Some(data) = line.strip_prefix("data: ") {
|
||||
if data == "[DONE]" {
|
||||
// OpenAI sends [DONE] to indicate end of stream
|
||||
return Ok(());
|
||||
while let Some(event) = stream.next().await {
|
||||
match event {
|
||||
Ok(event) => {
|
||||
if *DEBUG_SSE_STREAM {
|
||||
tracing::info!("SSE event: {:?}", event);
|
||||
}
|
||||
|
||||
self.parse_event_data(data).await?;
|
||||
self.parse_event_data(&event.data).await?;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to parse SSE event: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -102,50 +93,58 @@ impl OpenAISSEParser {
|
||||
|
||||
impl SSEParser for OpenAISSEParser {
|
||||
async fn parse_event_data(&mut self, data: &str) -> Result<(), Error> {
|
||||
let event: OpenAISSEEvent = serde_json::from_str(data).map_err(|e| {
|
||||
Error::internal_err(format!("Failed to parse SSE chunk {}: {}", data, e))
|
||||
})?;
|
||||
if data == "[DONE]" {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(mut choices) = event.choices.filter(|s| !s.is_empty()) {
|
||||
if let Some(delta) = choices.remove(0).delta {
|
||||
if let Some(content) = delta.content.filter(|s| !s.is_empty()) {
|
||||
self.accumulated_content.push_str(&content);
|
||||
let event = StreamingEvent::TokenDelta { content };
|
||||
self.stream_event_processor
|
||||
.send(event, &mut self.events_str)
|
||||
.await?;
|
||||
}
|
||||
let event: Option<OpenAISSEEvent> = serde_json::from_str(data)
|
||||
.inspect_err(|e| {
|
||||
tracing::error!("Failed to parse SSE as an OpenAI event {}: {}", data, e);
|
||||
})
|
||||
.ok();
|
||||
|
||||
if let Some(tool_calls) = delta.tool_calls {
|
||||
for (idx, tool_call) in tool_calls.into_iter().enumerate() {
|
||||
let idx = tool_call.index.unwrap_or_else(|| idx as i64);
|
||||
if let Some(event) = event {
|
||||
if let Some(mut choices) = event.choices.filter(|s| !s.is_empty()) {
|
||||
if let Some(delta) = choices.remove(0).delta {
|
||||
if let Some(content) = delta.content.filter(|s| !s.is_empty()) {
|
||||
self.accumulated_content.push_str(&content);
|
||||
let event = StreamingEvent::TokenDelta { content };
|
||||
self.stream_event_processor
|
||||
.send(event, &mut self.events_str)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if let Some(function) = tool_call.function {
|
||||
if let Some(tool_call) = self.accumulated_tool_calls.get_mut(&idx) {
|
||||
if let Some(arguments) = function.arguments {
|
||||
tool_call.function.arguments += &arguments;
|
||||
}
|
||||
} else {
|
||||
let fun_name = function.name.unwrap_or_default();
|
||||
let call_id = tool_call.id.unwrap_or_else(|| rd_string(24));
|
||||
let event = StreamingEvent::ToolCall {
|
||||
call_id: call_id.clone(),
|
||||
function_name: fun_name.clone(),
|
||||
};
|
||||
self.stream_event_processor
|
||||
.send(event, &mut self.events_str)
|
||||
.await?;
|
||||
self.accumulated_tool_calls.insert(
|
||||
idx,
|
||||
OpenAIToolCall {
|
||||
id: call_id,
|
||||
function: OpenAIFunction {
|
||||
name: fun_name,
|
||||
arguments: function.arguments.unwrap_or_default(),
|
||||
if let Some(tool_calls) = delta.tool_calls {
|
||||
for (idx, tool_call) in tool_calls.into_iter().enumerate() {
|
||||
let idx = tool_call.index.unwrap_or_else(|| idx as i64);
|
||||
|
||||
if let Some(function) = tool_call.function {
|
||||
if let Some(tool_call) = self.accumulated_tool_calls.get_mut(&idx) {
|
||||
if let Some(arguments) = function.arguments {
|
||||
tool_call.function.arguments += &arguments;
|
||||
}
|
||||
} else {
|
||||
let fun_name = function.name.unwrap_or_default();
|
||||
let call_id = tool_call.id.unwrap_or_else(|| rd_string(24));
|
||||
let event = StreamingEvent::ToolCall {
|
||||
call_id: call_id.clone(),
|
||||
function_name: fun_name.clone(),
|
||||
};
|
||||
self.stream_event_processor
|
||||
.send(event, &mut self.events_str)
|
||||
.await?;
|
||||
self.accumulated_tool_calls.insert(
|
||||
idx,
|
||||
OpenAIToolCall {
|
||||
id: call_id,
|
||||
function: OpenAIFunction {
|
||||
name: fun_name,
|
||||
arguments: function.arguments.unwrap_or_default(),
|
||||
},
|
||||
r#type: "function".to_string(),
|
||||
},
|
||||
r#type: "function".to_string(),
|
||||
},
|
||||
);
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::ai::utils::{
|
||||
update_flow_status_module_with_actions, update_flow_status_module_with_actions_success,
|
||||
FlowContext,
|
||||
};
|
||||
use crate::common::{error_to_value, OccupancyMetrics};
|
||||
use crate::common::OccupancyMetrics;
|
||||
use crate::result_processor::handle_non_flow_job_error;
|
||||
use crate::worker_flow::{
|
||||
evaluate_input_transform, raw_script_to_payload, script_to_payload, JobPayloadWithTag,
|
||||
@@ -592,7 +592,7 @@ async fn handle_tool_execution_error(
|
||||
final_events_str: &mut String,
|
||||
) -> Result<(), Error> {
|
||||
let err_string = format!("{}: {}", err.name(), err.to_string());
|
||||
let err_json = error_to_value(&err);
|
||||
let err_json = windmill_common::worker::error_to_value(&err);
|
||||
let _ = handle_non_flow_job_error(
|
||||
ctx.db,
|
||||
tool_job,
|
||||
|
||||
@@ -7,7 +7,7 @@ use serde_json::{json, value::RawValue, Value};
|
||||
use windmill_common::client::AuthedClient;
|
||||
use windmill_common::error::to_anyhow;
|
||||
use windmill_common::s3_helpers::convert_json_line_stream;
|
||||
use windmill_common::worker::Connection;
|
||||
use windmill_common::worker::{Connection, SqlResultCollectionStrategy};
|
||||
use windmill_common::{error::Error, worker::to_raw_value};
|
||||
use windmill_parser_sql::{
|
||||
parse_bigquery_sig, parse_db_resource, parse_s3_mode, parse_sql_blocks,
|
||||
@@ -86,9 +86,11 @@ fn do_bigquery_inner<'a>(
|
||||
timeout_ms: u64,
|
||||
column_order: Option<&'a mut Option<Vec<String>>>,
|
||||
skip_collect: bool,
|
||||
first_row_only: bool,
|
||||
http_client: &'a Client,
|
||||
s3: Option<S3ModeWorkerData>,
|
||||
) -> windmill_common::error::Result<BoxFuture<'a, windmill_common::error::Result<Box<RawValue>>>> {
|
||||
) -> windmill_common::error::Result<BoxFuture<'a, windmill_common::error::Result<Vec<Box<RawValue>>>>>
|
||||
{
|
||||
let param_names = parse_sql_statement_named_params(query, '@');
|
||||
|
||||
let statement_values = all_statement_values
|
||||
@@ -113,7 +115,7 @@ fn do_bigquery_inner<'a>(
|
||||
.json(&json!({
|
||||
"query": query,
|
||||
"useLegacySql": false,
|
||||
"maxResults": 10000,
|
||||
"maxResults": if first_row_only { 1 } else { 10000 },
|
||||
"timeoutMs": timeout_ms,
|
||||
"queryParameters": statement_values,
|
||||
}))
|
||||
@@ -126,7 +128,7 @@ fn do_bigquery_inner<'a>(
|
||||
match response.error_for_status_ref() {
|
||||
Ok(_) => {
|
||||
if skip_collect {
|
||||
return Ok(to_raw_value(&Value::Array(vec![])));
|
||||
return Ok(vec![]);
|
||||
} else {
|
||||
let result = response.json::<BigqueryResponse>().await.map_err(|e| {
|
||||
Error::ExecutionErr(format!(
|
||||
@@ -205,10 +207,10 @@ fn do_bigquery_inner<'a>(
|
||||
convert_json_line_stream(rows_stream.boxed(), s3.format).await?;
|
||||
s3.upload(stream.boxed()).await?;
|
||||
|
||||
return Ok(to_raw_value(&s3.to_return_s3_obj()));
|
||||
return Ok(vec![to_raw_value(&s3.to_return_s3_obj())]);
|
||||
}
|
||||
|
||||
Ok(to_raw_value(&rows))
|
||||
Ok(rows.iter().map(to_raw_value).collect::<Vec<_>>())
|
||||
}
|
||||
}
|
||||
Err(e) => match response.json::<BigqueryErrorResponse>().await {
|
||||
@@ -341,6 +343,11 @@ pub async fn do_bigquery(
|
||||
};
|
||||
|
||||
let annotations = windmill_common::worker::SqlAnnotations::parse(query);
|
||||
let collection_strategy = if annotations.return_last_result {
|
||||
SqlResultCollectionStrategy::LastStatementAllRows
|
||||
} else {
|
||||
annotations.result_collection
|
||||
};
|
||||
|
||||
let service_account = CustomServiceAccount::from_json(&database)
|
||||
.map_err(|e| Error::ExecutionErr(e.to_string()))?;
|
||||
@@ -425,52 +432,34 @@ pub async fn do_bigquery(
|
||||
statement_values.insert(arg_n, bigquery_v);
|
||||
}
|
||||
|
||||
let result_f = if queries.len() > 1 {
|
||||
let futures = queries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, x)| {
|
||||
do_bigquery_inner(
|
||||
x,
|
||||
&statement_values,
|
||||
&project_id,
|
||||
token.as_str(),
|
||||
timeout_ms,
|
||||
None,
|
||||
annotations.return_last_result && i < queries.len() - 1,
|
||||
&http_client,
|
||||
s3.clone(),
|
||||
)
|
||||
})
|
||||
.collect::<windmill_common::error::Result<Vec<_>>>()?;
|
||||
let result_f = async move {
|
||||
let mut results = vec![];
|
||||
for (i, q) in queries.iter().enumerate() {
|
||||
let result = do_bigquery_inner(
|
||||
q,
|
||||
&statement_values,
|
||||
&project_id,
|
||||
token.as_str(),
|
||||
timeout_ms,
|
||||
if i == queries.len() - 1
|
||||
&& collection_strategy.collect_last_statement_only(queries.len())
|
||||
&& !collection_strategy.collect_scalar()
|
||||
{
|
||||
Some(column_order)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
collection_strategy.collect_last_statement_only(queries.len())
|
||||
&& i < queries.len() - 1,
|
||||
collection_strategy.collect_first_row_only(),
|
||||
&http_client,
|
||||
s3.clone(),
|
||||
)?
|
||||
.await?;
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
let f = async {
|
||||
let mut res: Vec<Box<RawValue>> = vec![];
|
||||
|
||||
for fut in futures {
|
||||
let r = fut.await?;
|
||||
res.push(r);
|
||||
}
|
||||
if annotations.return_last_result && res.len() > 0 {
|
||||
Ok(res.pop().unwrap())
|
||||
} else {
|
||||
Ok(to_raw_value(&res))
|
||||
}
|
||||
};
|
||||
|
||||
f.boxed()
|
||||
} else {
|
||||
do_bigquery_inner(
|
||||
query,
|
||||
&statement_values,
|
||||
&project_id,
|
||||
token.as_str(),
|
||||
timeout_ms,
|
||||
Some(column_order),
|
||||
false,
|
||||
&http_client,
|
||||
s3,
|
||||
)?
|
||||
collection_strategy.collect(results)
|
||||
};
|
||||
|
||||
let r = run_future_with_polling_update_job_poller(
|
||||
|
||||
@@ -539,13 +539,6 @@ pub async fn update_worker_ping_for_failed_init_script(
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error_to_value(err: &Error) -> serde_json::Value {
|
||||
match err {
|
||||
Error::JsonErr(err) => err.clone(),
|
||||
_ => json!({"message": err.to_string(), "name": err.name()}),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct OccupancyMetrics {
|
||||
pub running_job_started_at: Option<Instant>,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::cell::RefCell;
|
||||
use std::env;
|
||||
use std::ffi::{c_char, CStr, CString};
|
||||
use std::ffi::{c_char, c_uint, CStr, CString};
|
||||
use std::ptr::NonNull;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
@@ -12,7 +12,7 @@ use uuid::Uuid;
|
||||
use windmill_common::error::{to_anyhow, Error, Result};
|
||||
use windmill_common::s3_helpers::{S3Object, S3_PROXY_LAST_ERRORS_CACHE};
|
||||
use windmill_common::utils::sanitize_string_from_password;
|
||||
use windmill_common::worker::Connection;
|
||||
use windmill_common::worker::{Connection, SqlResultCollectionStrategy};
|
||||
use windmill_common::workspaces::{get_ducklake_from_db_unchecked, DucklakeCatalogResourceType};
|
||||
use windmill_parser_sql::{parse_duckdb_sig, parse_sql_blocks};
|
||||
use windmill_queue::{CanceledBy, MiniPulledJob};
|
||||
@@ -39,6 +39,20 @@ pub async fn do_duckdb(
|
||||
occupancy_metrics: &mut OccupancyMetrics,
|
||||
parent_runnable_path: Option<String>,
|
||||
) -> Result<Box<RawValue>> {
|
||||
let annotations = windmill_common::worker::SqlAnnotations::parse(query);
|
||||
let collection_strategy =
|
||||
if annotations.result_collection == SqlResultCollectionStrategy::Legacy {
|
||||
// Before result_collection was introduced, duckdb ignored all statements results except the last one
|
||||
SqlResultCollectionStrategy::LastStatementAllRows
|
||||
} else {
|
||||
annotations.result_collection
|
||||
};
|
||||
if annotations.return_last_result {
|
||||
return Err(Error::ExecutionErr(
|
||||
"return_last_result annotation is deprecated, use result_collection=last_statement_all_rows instead".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let token = client.token.clone();
|
||||
let hidden_passwords = Arc::new(Mutex::new(Vec::<String>::new()));
|
||||
|
||||
@@ -141,11 +155,13 @@ pub async fn do_duckdb(
|
||||
&token,
|
||||
&base_internal_url,
|
||||
&w_id,
|
||||
collection_strategy,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| Error::from(to_anyhow(e)))
|
||||
.and_then(|r| r);
|
||||
|
||||
let (result, column_order) = match result {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
@@ -210,6 +226,8 @@ struct DuckDbFfiLib {
|
||||
base_internal_url: *const c_char,
|
||||
w_id: *const c_char,
|
||||
column_order_ptr: *mut *mut c_char,
|
||||
collect_last_only: bool,
|
||||
collect_first_row_only: bool,
|
||||
) -> *mut c_char,
|
||||
>,
|
||||
free_cstr: Symbol<'static, unsafe extern "C" fn(string: *mut c_char) -> ()>,
|
||||
@@ -249,7 +267,28 @@ impl DuckDbFfiLib {
|
||||
))
|
||||
})?
|
||||
};
|
||||
|
||||
let lib = Box::leak(Box::new(lib));
|
||||
|
||||
// Version mismatch should only be possible on Windows agent workers
|
||||
// We check for it because FFI interface mismatch will cause undefined behavior / crashes
|
||||
unsafe {
|
||||
let expected_version: c_uint = 1;
|
||||
let get_version: Symbol<'static, unsafe extern "C" fn() -> c_uint> =
|
||||
lib.get(b"get_version")
|
||||
.map_err(|e| return Error::ExecutionErr(format!("Could not find get_version in the duckdb ffi library. If you are not using docker, consider manually upgrading windmill_duckdb_ffi_lib. {}", e.to_string())))?;
|
||||
let actual_version = get_version();
|
||||
if actual_version < expected_version {
|
||||
return Err(Error::InternalErr(
|
||||
format!("Incompatible duckdb ffi library version. Expected: {expected_version}, actual: {actual_version}. Please update to the latest windmill_duckdb_ffi_lib."),
|
||||
));
|
||||
} else if actual_version > expected_version {
|
||||
return Err(Error::InternalErr(
|
||||
format!("Incompatible duckdb ffi library version. Expected: {expected_version}, actual: {actual_version}. Please upgrade your worker to the latest windmill version."),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(DuckDbFfiLib {
|
||||
run_duckdb_ffi: unsafe { lib.get(b"run_duckdb_ffi").map_err(to_anyhow)? },
|
||||
free_cstr: unsafe { lib.get(b"free_cstr").map_err(to_anyhow)? },
|
||||
@@ -265,6 +304,7 @@ fn run_duckdb_ffi_safe<'a>(
|
||||
token: &str,
|
||||
base_internal_url: &str,
|
||||
w_id: &str,
|
||||
collection_strategy: SqlResultCollectionStrategy,
|
||||
) -> Result<(Box<RawValue>, Option<Vec<String>>)> {
|
||||
let query_block_list = query_block_list
|
||||
.map(|s| {
|
||||
@@ -296,13 +336,18 @@ fn run_duckdb_ffi_safe<'a>(
|
||||
base_internal_url.as_ptr(),
|
||||
w_id.as_ptr(),
|
||||
&mut column_order,
|
||||
collection_strategy.collect_last_statement_only(query_block_list_count),
|
||||
collection_strategy.collect_first_row_only(),
|
||||
);
|
||||
let str = CStr::from_ptr(ptr).to_string_lossy().to_string();
|
||||
free_cstr(ptr);
|
||||
str
|
||||
};
|
||||
|
||||
let column_order = if column_order.is_null() {
|
||||
let column_order = if column_order.is_null()
|
||||
|| !collection_strategy.collect_last_statement_only(query_block_list_count)
|
||||
|| collection_strategy.collect_scalar()
|
||||
{
|
||||
None
|
||||
} else {
|
||||
let str = unsafe { CStr::from_ptr(column_order).to_string_lossy().to_string() };
|
||||
@@ -313,7 +358,14 @@ fn run_duckdb_ffi_safe<'a>(
|
||||
if result_str.starts_with("ERROR") {
|
||||
Err(Error::ExecutionErr(result_str[6..].to_string()))
|
||||
} else {
|
||||
let result = serde_json::value::RawValue::from_string(result_str).map_err(to_anyhow)?;
|
||||
let result = if collection_strategy == SqlResultCollectionStrategy::AllStatementsAllRows {
|
||||
// Avoid parsing JSON
|
||||
serde_json::value::RawValue::from_string(result_str).map_err(to_anyhow)?
|
||||
} else {
|
||||
let result =
|
||||
serde_json::from_str::<Vec<Vec<Box<RawValue>>>>(&result_str).map_err(to_anyhow)?;
|
||||
collection_strategy.collect(result)?
|
||||
};
|
||||
Ok((result, column_order))
|
||||
}
|
||||
}
|
||||
@@ -350,7 +402,7 @@ fn format_attach_db_conn_str(db_resource: Value, db_type: &str) -> Result<String
|
||||
"postgres" | "postgresql" => {
|
||||
let res: PgDatabase = serde_json::from_value(db_resource)?;
|
||||
format!(
|
||||
"dbname={} {} host={} {} {}",
|
||||
"dbname={} {} host={} {} {} {}",
|
||||
res.dbname,
|
||||
res.user.map(|u| format!("user={}", u)).unwrap_or_default(),
|
||||
res.host,
|
||||
@@ -358,6 +410,9 @@ fn format_attach_db_conn_str(db_resource: Value, db_type: &str) -> Result<String
|
||||
.map(|p| format!("password={}", p))
|
||||
.unwrap_or_default(),
|
||||
res.port.map(|p| format!("port={}", p)).unwrap_or_default(),
|
||||
res.sslmode
|
||||
.map(|s| format!("sslmode={}", s))
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
}
|
||||
#[cfg(feature = "mysql")]
|
||||
|
||||
@@ -12,6 +12,7 @@ use tokio::net::TcpStream;
|
||||
use tokio_util::compat::TokioAsyncWriteCompatExt;
|
||||
use uuid::Uuid;
|
||||
use windmill_common::s3_helpers::convert_json_line_stream;
|
||||
use windmill_common::worker::SqlResultCollectionStrategy;
|
||||
use windmill_common::{
|
||||
error::{self, to_anyhow, Error},
|
||||
utils::empty_as_none,
|
||||
@@ -95,6 +96,13 @@ pub async fn do_mssql(
|
||||
};
|
||||
|
||||
let annotations = windmill_common::worker::SqlAnnotations::parse(query);
|
||||
let collection_strategy = if annotations.return_last_result {
|
||||
SqlResultCollectionStrategy::LastStatementAllRows
|
||||
} else if annotations.result_collection == SqlResultCollectionStrategy::Legacy {
|
||||
SqlResultCollectionStrategy::AllStatementsAllRows
|
||||
} else {
|
||||
annotations.result_collection
|
||||
};
|
||||
|
||||
let mut config = Config::new();
|
||||
|
||||
@@ -237,21 +245,20 @@ pub async fn do_mssql(
|
||||
let len = results.len();
|
||||
let mut json_results = vec![];
|
||||
for (i, statement_result) in results.into_iter().enumerate() {
|
||||
if annotations.return_last_result && i < len - 1 {
|
||||
if collection_strategy.collect_last_statement_only(len) && i < len - 1 {
|
||||
continue;
|
||||
}
|
||||
let mut json_rows = vec![];
|
||||
for row in statement_result {
|
||||
let row = row_to_json(row)?;
|
||||
let row = to_raw_value(&row_to_json(row)?);
|
||||
json_rows.push(row);
|
||||
if collection_strategy.collect_first_row_only() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
json_results.push(json_rows);
|
||||
}
|
||||
if annotations.return_last_result && json_results.len() > 0 {
|
||||
Ok(to_raw_value(&json_results.pop().unwrap()))
|
||||
} else {
|
||||
Ok(to_raw_value(&json_results))
|
||||
}
|
||||
collection_strategy.collect(json_results)
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ use windmill_common::{
|
||||
client::AuthedClient,
|
||||
error::{to_anyhow, Error},
|
||||
s3_helpers::convert_json_line_stream,
|
||||
worker::{to_raw_value, Connection},
|
||||
worker::{to_raw_value, Connection, SqlResultCollectionStrategy},
|
||||
};
|
||||
use windmill_parser_sql::{
|
||||
parse_db_resource, parse_mysql_sig, parse_s3_mode, parse_sql_blocks,
|
||||
@@ -50,8 +50,10 @@ fn do_mysql_inner<'a>(
|
||||
conn: Arc<Mutex<mysql_async::Conn>>,
|
||||
column_order: Option<&'a mut Option<Vec<String>>>,
|
||||
skip_collect: bool,
|
||||
first_row_only: bool,
|
||||
s3: Option<S3ModeWorkerData>,
|
||||
) -> windmill_common::error::Result<BoxFuture<'a, windmill_common::error::Result<Box<RawValue>>>> {
|
||||
) -> windmill_common::error::Result<BoxFuture<'a, windmill_common::error::Result<Vec<Box<RawValue>>>>>
|
||||
{
|
||||
let param_names = parse_sql_statement_named_params(query, ':')
|
||||
.into_iter()
|
||||
.map(|x| x.into_bytes())
|
||||
@@ -76,7 +78,7 @@ fn do_mysql_inner<'a>(
|
||||
.await
|
||||
.map_err(to_anyhow)?;
|
||||
|
||||
Ok(to_raw_value(&Value::Array(vec![])))
|
||||
Ok(vec![])
|
||||
} else if let Some(ref s3) = s3 {
|
||||
let query = query.to_string();
|
||||
let rows_stream = async_stream::stream! {
|
||||
@@ -108,14 +110,23 @@ fn do_mysql_inner<'a>(
|
||||
let stream = convert_json_line_stream(rows_stream.boxed(), s3.format).await?;
|
||||
s3.upload(stream.boxed()).await?;
|
||||
|
||||
Ok(to_raw_value(&s3.to_return_s3_obj()))
|
||||
Ok(vec![to_raw_value(&s3.to_return_s3_obj())])
|
||||
} else {
|
||||
let rows: Vec<Row> = conn
|
||||
.lock()
|
||||
.await
|
||||
.exec(query, statement_values)
|
||||
.await
|
||||
.map_err(to_anyhow)?;
|
||||
let rows: Vec<Row> = if first_row_only {
|
||||
conn.lock()
|
||||
.await
|
||||
.exec_first(query, statement_values)
|
||||
.await
|
||||
.map_err(to_anyhow)?
|
||||
.into_iter()
|
||||
.collect()
|
||||
} else {
|
||||
conn.lock()
|
||||
.await
|
||||
.exec(query, statement_values)
|
||||
.await
|
||||
.map_err(to_anyhow)?
|
||||
};
|
||||
|
||||
if let Some(column_order) = column_order {
|
||||
*column_order = Some(
|
||||
@@ -130,12 +141,10 @@ fn do_mysql_inner<'a>(
|
||||
);
|
||||
}
|
||||
|
||||
Ok(to_raw_value(
|
||||
&rows
|
||||
.into_iter()
|
||||
.map(|x| convert_row_to_value(x))
|
||||
.collect::<Vec<serde_json::Value>>(),
|
||||
))
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|x| to_raw_value(&convert_row_to_value(x)))
|
||||
.collect::<Vec<_>>())
|
||||
}
|
||||
};
|
||||
|
||||
@@ -180,6 +189,11 @@ pub async fn do_mysql(
|
||||
};
|
||||
|
||||
let annotations = windmill_common::worker::SqlAnnotations::parse(query);
|
||||
let collection_strategy = if annotations.return_last_result {
|
||||
SqlResultCollectionStrategy::LastStatementAllRows
|
||||
} else {
|
||||
annotations.result_collection
|
||||
};
|
||||
|
||||
let opts = OptsBuilder::default()
|
||||
.db_name(Some(database.database))
|
||||
@@ -285,45 +299,32 @@ pub async fn do_mysql(
|
||||
|
||||
let queries = parse_sql_blocks(query);
|
||||
|
||||
let result_f = if queries.len() > 1 {
|
||||
let futures = queries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, x)| {
|
||||
do_mysql_inner(
|
||||
x,
|
||||
&statement_values,
|
||||
conn_a.clone(),
|
||||
None,
|
||||
annotations.return_last_result && i < queries.len() - 1,
|
||||
s3.clone(),
|
||||
)
|
||||
})
|
||||
.collect::<windmill_common::error::Result<Vec<_>>>()?;
|
||||
let conn_a_ref = &conn_a;
|
||||
let result_f = async move {
|
||||
let mut results = vec![];
|
||||
for (i, query) in queries.iter().enumerate() {
|
||||
let result = do_mysql_inner(
|
||||
query,
|
||||
&statement_values,
|
||||
conn_a_ref.clone(),
|
||||
if i == queries.len() - 1
|
||||
&& collection_strategy.collect_last_statement_only(queries.len())
|
||||
&& !collection_strategy.collect_scalar()
|
||||
{
|
||||
Some(column_order)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
collection_strategy.collect_last_statement_only(queries.len())
|
||||
&& i < queries.len() - 1,
|
||||
collection_strategy.collect_first_row_only(),
|
||||
s3.clone(),
|
||||
)?
|
||||
.await?;
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
let f = async {
|
||||
let mut res: Vec<Box<RawValue>> = vec![];
|
||||
for fut in futures {
|
||||
let r = fut.await?;
|
||||
res.push(r);
|
||||
}
|
||||
if annotations.return_last_result && res.len() > 0 {
|
||||
Ok(res.pop().unwrap())
|
||||
} else {
|
||||
Ok(to_raw_value(&res))
|
||||
}
|
||||
};
|
||||
|
||||
f.boxed()
|
||||
} else {
|
||||
do_mysql_inner(
|
||||
query,
|
||||
&statement_values,
|
||||
conn_a.clone(),
|
||||
Some(column_order),
|
||||
false,
|
||||
s3,
|
||||
)?
|
||||
collection_strategy.collect(results)
|
||||
};
|
||||
|
||||
let result = run_future_with_polling_update_job_poller(
|
||||
@@ -344,11 +345,10 @@ pub async fn do_mysql(
|
||||
|
||||
pool.disconnect().await.map_err(to_anyhow)?;
|
||||
|
||||
let raw_result = windmill_common::worker::to_raw_value(&json!(result));
|
||||
*mem_peak = (raw_result.get().len() / 1000) as i32;
|
||||
*mem_peak = (result.get().len() / 1000) as i32;
|
||||
|
||||
// And then check that we got back the same string we sent over.
|
||||
return Ok(raw_result);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// 2023-12-01T16:18:00.000Z
|
||||
|
||||
@@ -11,7 +11,7 @@ use serde_json::{json, value::RawValue, Value};
|
||||
use windmill_common::{
|
||||
error::{to_anyhow, Error},
|
||||
s3_helpers::convert_json_line_stream,
|
||||
worker::{to_raw_value, Connection},
|
||||
worker::{to_raw_value, Connection, SqlResultCollectionStrategy},
|
||||
};
|
||||
use windmill_queue::MiniPulledJob;
|
||||
|
||||
@@ -48,8 +48,10 @@ pub fn do_oracledb_inner<'a>(
|
||||
conn: Arc<std::sync::Mutex<oracle::Connection>>,
|
||||
column_order: Option<&'a mut Option<Vec<String>>>,
|
||||
skip_collect: bool,
|
||||
first_row_only: bool,
|
||||
s3: Option<S3ModeWorkerData>,
|
||||
) -> windmill_common::error::Result<BoxFuture<'a, windmill_common::error::Result<Box<RawValue>>>> {
|
||||
) -> windmill_common::error::Result<BoxFuture<'a, windmill_common::error::Result<Vec<Box<RawValue>>>>>
|
||||
{
|
||||
let qw = query.trim_end_matches(';').to_string();
|
||||
|
||||
let result_f = async move {
|
||||
@@ -85,7 +87,7 @@ pub fn do_oracledb_inner<'a>(
|
||||
.map_err(to_anyhow)?
|
||||
.map_err(to_anyhow)?;
|
||||
|
||||
Ok(to_raw_value(&Value::Array(vec![])))
|
||||
Ok(vec![])
|
||||
} else {
|
||||
// We use an mpsc because we need an async stream for s3 mode. However since everything is sync
|
||||
// in rust-oracle, I assumed that calling ResultSet::next() is blocking when it has to refetch.
|
||||
@@ -133,6 +135,9 @@ pub fn do_oracledb_inner<'a>(
|
||||
break;
|
||||
}
|
||||
}
|
||||
if first_row_only {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
@@ -159,17 +164,16 @@ pub fn do_oracledb_inner<'a>(
|
||||
if let Some(s3) = s3 {
|
||||
let stream = convert_json_line_stream(rows_stream.boxed(), s3.format).await?;
|
||||
s3.upload(stream.boxed()).await?;
|
||||
return Ok(to_raw_value(&s3.to_return_s3_obj()));
|
||||
return Ok(vec![to_raw_value(&s3.to_return_s3_obj())]);
|
||||
} else {
|
||||
let rows: Vec<_> = rows_stream.collect().await;
|
||||
Ok(to_raw_value(
|
||||
&rows
|
||||
.into_iter()
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(to_anyhow)?
|
||||
.into_iter()
|
||||
.collect::<Vec<serde_json::Value>>(),
|
||||
))
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(to_anyhow)?
|
||||
.iter()
|
||||
.map(to_raw_value)
|
||||
.collect::<Vec<_>>())
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -377,6 +381,11 @@ pub async fn do_oracledb(
|
||||
};
|
||||
|
||||
let annotations = windmill_common::worker::SqlAnnotations::parse(query);
|
||||
let collection_strategy = if annotations.return_last_result {
|
||||
SqlResultCollectionStrategy::LastStatementAllRows
|
||||
} else {
|
||||
annotations.result_collection
|
||||
};
|
||||
|
||||
let sig = parse_oracledb_sig(query)
|
||||
.map_err(|x| Error::ExecutionErr(x.to_string()))?
|
||||
@@ -388,7 +397,7 @@ pub async fn do_oracledb(
|
||||
let (query, args_to_skip) =
|
||||
sanitize_and_interpolate_unsafe_sql_args(query, &sig, &job_args, &reserved_variables)?;
|
||||
|
||||
let (statement_values, errors) = get_statement_values(sig.clone(), &job_args, &args_to_skip);
|
||||
let (_, errors) = get_statement_values(sig.clone(), &job_args, &args_to_skip);
|
||||
|
||||
if !errors.is_empty() {
|
||||
return Err(Error::ExecutionErr(errors.join("\n")));
|
||||
@@ -412,40 +421,33 @@ pub async fn do_oracledb(
|
||||
|
||||
let queries = parse_sql_blocks(&query);
|
||||
|
||||
let result_f = if queries.len() > 1 {
|
||||
let f = async {
|
||||
let mut res: Vec<Box<RawValue>> = vec![];
|
||||
for (i, q) in queries.iter().enumerate() {
|
||||
let (vals, _) = get_statement_values(sig.clone(), &job_args, &args_to_skip);
|
||||
let r = do_oracledb_inner(
|
||||
q,
|
||||
vals,
|
||||
conn_a.clone(),
|
||||
None,
|
||||
annotations.return_last_result && i < queries.len() - 1,
|
||||
s3.clone(),
|
||||
)?
|
||||
.await?;
|
||||
res.push(r);
|
||||
}
|
||||
let result_f = async move {
|
||||
let mut results = vec![];
|
||||
for (i, q) in queries.iter().enumerate() {
|
||||
let (vals, _) = get_statement_values(sig.clone(), &job_args, &args_to_skip);
|
||||
|
||||
if annotations.return_last_result && res.len() > 0 {
|
||||
Ok(res.pop().unwrap())
|
||||
} else {
|
||||
Ok(to_raw_value(&res))
|
||||
}
|
||||
};
|
||||
let result = do_oracledb_inner(
|
||||
q,
|
||||
vals,
|
||||
conn_a.clone(),
|
||||
if i == queries.len() - 1
|
||||
&& collection_strategy.collect_last_statement_only(queries.len())
|
||||
&& !collection_strategy.collect_scalar()
|
||||
{
|
||||
Some(column_order)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
collection_strategy.collect_last_statement_only(queries.len())
|
||||
&& i < queries.len() - 1,
|
||||
collection_strategy.collect_first_row_only(),
|
||||
s3.clone(),
|
||||
)?
|
||||
.await?;
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
f.boxed()
|
||||
} else {
|
||||
do_oracledb_inner(
|
||||
&query,
|
||||
statement_values,
|
||||
conn_a,
|
||||
Some(column_order),
|
||||
false,
|
||||
s3,
|
||||
)?
|
||||
collection_strategy.collect(results)
|
||||
};
|
||||
|
||||
let result = run_future_with_polling_update_job_poller(
|
||||
|
||||
@@ -28,7 +28,9 @@ use uuid::Uuid;
|
||||
use windmill_common::error::to_anyhow;
|
||||
use windmill_common::error::{self, Error};
|
||||
use windmill_common::s3_helpers::convert_json_line_stream;
|
||||
use windmill_common::worker::{to_raw_value, Connection, CLOUD_HOSTED};
|
||||
use windmill_common::worker::{
|
||||
to_raw_value, Connection, SqlResultCollectionStrategy, CLOUD_HOSTED,
|
||||
};
|
||||
use windmill_parser::{Arg, Typ};
|
||||
use windmill_parser_sql::{
|
||||
parse_db_resource, parse_pg_statement_arg_indices, parse_pgsql_sig, parse_s3_mode,
|
||||
@@ -73,8 +75,9 @@ fn do_postgresql_inner<'a>(
|
||||
column_order: Option<&'a mut Option<Vec<String>>>,
|
||||
siz: &'a AtomicUsize,
|
||||
skip_collect: bool,
|
||||
first_row_only: bool,
|
||||
s3: Option<S3ModeWorkerData>,
|
||||
) -> error::Result<BoxFuture<'a, error::Result<Box<RawValue>>>> {
|
||||
) -> error::Result<BoxFuture<'a, error::Result<Vec<Box<RawValue>>>>> {
|
||||
let mut query_params = vec![];
|
||||
|
||||
let arg_indices = parse_pg_statement_arg_indices(&query);
|
||||
@@ -100,7 +103,7 @@ fn do_postgresql_inner<'a>(
|
||||
let result_f = async move {
|
||||
// Now we can execute a simple statement that just returns its parameter.
|
||||
|
||||
let mut res: Vec<serde_json::Value> = vec![];
|
||||
let mut res: Vec<Box<serde_json::value::RawValue>> = vec![];
|
||||
|
||||
let query_params = query_params
|
||||
.iter()
|
||||
@@ -125,13 +128,19 @@ fn do_postgresql_inner<'a>(
|
||||
let stream = convert_json_line_stream(rows_stream.boxed(), s3.format).await?;
|
||||
s3.upload(stream.boxed()).await?;
|
||||
|
||||
return Ok(to_raw_value(&s3.to_return_s3_obj()));
|
||||
return Ok(vec![to_raw_value(&s3.to_return_s3_obj())]);
|
||||
} else {
|
||||
let rows = client
|
||||
.query_raw(&query, query_params)
|
||||
.await
|
||||
.map_err(to_anyhow)?;
|
||||
|
||||
let rows = if first_row_only {
|
||||
rows.take(1).boxed()
|
||||
} else {
|
||||
rows.boxed()
|
||||
};
|
||||
|
||||
let rows = rows.try_collect::<Vec<Row>>().await.map_err(to_anyhow)?;
|
||||
|
||||
if let Some(column_order) = column_order {
|
||||
@@ -164,14 +173,14 @@ fn do_postgresql_inner<'a>(
|
||||
}
|
||||
}
|
||||
if let Ok(v) = r {
|
||||
res.push(v);
|
||||
res.push(to_raw_value(&v));
|
||||
} else {
|
||||
return Err(to_anyhow(r.err().unwrap()).into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(to_raw_value(&res))
|
||||
Ok(res)
|
||||
};
|
||||
|
||||
Ok(result_f.boxed())
|
||||
@@ -216,6 +225,11 @@ pub async fn do_postgresql(
|
||||
};
|
||||
|
||||
let annotations = windmill_common::worker::SqlAnnotations::parse(query);
|
||||
let collection_strategy = if annotations.return_last_result {
|
||||
SqlResultCollectionStrategy::LastStatementAllRows
|
||||
} else {
|
||||
annotations.result_collection
|
||||
};
|
||||
|
||||
let sslmode = match database.sslmode.as_deref() {
|
||||
Some("allow") => "prefer".to_string(),
|
||||
@@ -336,47 +350,33 @@ pub async fn do_postgresql(
|
||||
.collect::<HashMap<_, _>>();
|
||||
|
||||
let size = AtomicUsize::new(0);
|
||||
let result_f = if queries.len() > 1 {
|
||||
let futures = queries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, x)| {
|
||||
do_postgresql_inner(
|
||||
x.to_string(),
|
||||
¶m_idx_to_arg_and_value,
|
||||
client,
|
||||
None,
|
||||
&size,
|
||||
annotations.return_last_result && i < queries.len() - 1,
|
||||
s3.clone(),
|
||||
)
|
||||
})
|
||||
.collect::<error::Result<Vec<_>>>()?;
|
||||
let size_ref = &size;
|
||||
let result_f = async move {
|
||||
let mut results = vec![];
|
||||
for (i, query) in queries.iter().enumerate() {
|
||||
let result = do_postgresql_inner(
|
||||
query.to_string(),
|
||||
¶m_idx_to_arg_and_value,
|
||||
client,
|
||||
if i == queries.len() - 1
|
||||
&& collection_strategy.collect_last_statement_only(queries.len())
|
||||
&& !collection_strategy.collect_scalar()
|
||||
{
|
||||
Some(column_order)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
size_ref,
|
||||
collection_strategy.collect_last_statement_only(queries.len())
|
||||
&& i < queries.len() - 1,
|
||||
collection_strategy.collect_first_row_only(),
|
||||
s3.clone(),
|
||||
)?
|
||||
.await?;
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
let f = async {
|
||||
let mut res: Vec<Box<RawValue>> = vec![];
|
||||
for fut in futures {
|
||||
let r = fut.await?;
|
||||
res.push(r);
|
||||
}
|
||||
if annotations.return_last_result && res.len() > 0 {
|
||||
Ok(res.pop().unwrap())
|
||||
} else {
|
||||
Ok(to_raw_value(&res))
|
||||
}
|
||||
};
|
||||
|
||||
f.boxed()
|
||||
} else {
|
||||
do_postgresql_inner(
|
||||
query.to_string(),
|
||||
¶m_idx_to_arg_and_value,
|
||||
client,
|
||||
Some(column_order),
|
||||
&size,
|
||||
false,
|
||||
s3,
|
||||
)?
|
||||
collection_strategy.collect(results)
|
||||
};
|
||||
|
||||
let result = run_future_with_polling_update_job_poller(
|
||||
|
||||
@@ -426,9 +426,8 @@ $env:PSModulePath = \"{};$PSModulePathBackup\"",
|
||||
}\n";
|
||||
|
||||
// make sure param() is first
|
||||
let param_match = windmill_parser_bash::RE_POWERSHELL_PARAM.find(&content);
|
||||
let param_match = windmill_parser_bash::extract_powershell_param_block(&content, true);
|
||||
let content: String = if let Some(param_match) = param_match {
|
||||
let param_match = param_match.as_str();
|
||||
format!(
|
||||
"{}\n{}\n{}\n{}\n{}",
|
||||
param_match,
|
||||
|
||||
@@ -22,7 +22,7 @@ use windmill_common::{
|
||||
flow_status::FlowJobDuration,
|
||||
jobs::JobKind,
|
||||
utils::WarnAfterExt,
|
||||
worker::{to_raw_value, Connection, WORKER_GROUP},
|
||||
worker::{error_to_value, to_raw_value, Connection, WORKER_GROUP},
|
||||
worker_group_job_stats::{accumulate_job_stats, flush_stats_to_db, JobStatsMap},
|
||||
KillpillSender, DB,
|
||||
};
|
||||
@@ -31,7 +31,8 @@ use windmill_common::{
|
||||
use windmill_common::bench::{BenchmarkInfo, BenchmarkIter};
|
||||
|
||||
use windmill_queue::{
|
||||
CanceledBy, INIT_SCRIPT_TAG, JobCompleted, MiniCompletedJob, MiniPulledJob, ValidableJson, WrappedError, append_logs, get_mini_completed_job
|
||||
append_logs, get_mini_completed_job, CanceledBy, JobCompleted, MiniCompletedJob, MiniPulledJob,
|
||||
ValidableJson, WrappedError, INIT_SCRIPT_TAG,
|
||||
};
|
||||
|
||||
use serde_json::{json, value::RawValue, Value};
|
||||
@@ -42,7 +43,7 @@ use windmill_queue::{add_completed_job, add_completed_job_error};
|
||||
|
||||
use crate::{
|
||||
bash_executor::ANSI_ESCAPE_RE,
|
||||
common::{error_to_value, read_result, save_in_cache},
|
||||
common::{read_result, save_in_cache},
|
||||
otel_oss::add_root_flow_job_to_otlp,
|
||||
worker_flow::update_flow_status_after_job_completion,
|
||||
JobCompletedReceiver, JobCompletedSender, SameWorkerSender, SendResult, SendResultPayload,
|
||||
|
||||
@@ -10,7 +10,7 @@ use sha2::{Digest, Sha256};
|
||||
use std::collections::HashMap;
|
||||
use windmill_common::error::to_anyhow;
|
||||
use windmill_common::s3_helpers::convert_json_line_stream;
|
||||
use windmill_common::worker::Connection;
|
||||
use windmill_common::worker::{Connection, SqlResultCollectionStrategy};
|
||||
|
||||
use windmill_common::{error::Error, worker::to_raw_value};
|
||||
use windmill_parser_sql::{
|
||||
@@ -130,10 +130,12 @@ fn do_snowflake_inner<'a>(
|
||||
token_is_keypair: bool,
|
||||
column_order: Option<&'a mut Option<Vec<String>>>,
|
||||
skip_collect: bool,
|
||||
first_row_only: bool,
|
||||
http_client: &'a Client,
|
||||
s3: Option<S3ModeWorkerData>,
|
||||
reserved_variables: &HashMap<String, String>,
|
||||
) -> windmill_common::error::Result<BoxFuture<'a, windmill_common::error::Result<Box<RawValue>>>> {
|
||||
) -> windmill_common::error::Result<BoxFuture<'a, windmill_common::error::Result<Vec<Box<RawValue>>>>>
|
||||
{
|
||||
let sig = parse_snowflake_sig(&query)
|
||||
.map_err(|x| Error::ExecutionErr(x.to_string()))?
|
||||
.args;
|
||||
@@ -179,7 +181,7 @@ fn do_snowflake_inner<'a>(
|
||||
|
||||
if skip_collect {
|
||||
handle_snowflake_result(result).await?;
|
||||
Ok(to_raw_value(&Value::Array(vec![])))
|
||||
Ok(vec![])
|
||||
} else {
|
||||
let response = result
|
||||
.parse_snowflake_response::<SnowflakeResponse>()
|
||||
@@ -254,19 +256,22 @@ fn do_snowflake_inner<'a>(
|
||||
row_map
|
||||
});
|
||||
|
||||
let rows_stream = rows_stream.take(if first_row_only { 1 } else { usize::MAX });
|
||||
|
||||
if let Some(s3) = s3 {
|
||||
let rows_stream =
|
||||
rows_stream.map(|r| serde_json::value::to_value(&r?).map_err(to_anyhow));
|
||||
let stream = convert_json_line_stream(rows_stream.boxed(), s3.format).await?;
|
||||
s3.upload(stream.boxed()).await?;
|
||||
Ok(to_raw_value(&s3.to_return_s3_obj()))
|
||||
Ok(vec![to_raw_value(&s3.to_return_s3_obj())])
|
||||
} else {
|
||||
let rows = rows_stream
|
||||
.collect::<Vec<_>>()
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|x| x.map(|v| to_raw_value(&v)))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(to_raw_value(&rows))
|
||||
Ok(rows)
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -312,6 +317,11 @@ pub async fn do_snowflake(
|
||||
};
|
||||
|
||||
let annotations = windmill_common::worker::SqlAnnotations::parse(query);
|
||||
let collection_strategy = if annotations.return_last_result {
|
||||
SqlResultCollectionStrategy::LastStatementAllRows
|
||||
} else {
|
||||
annotations.result_collection
|
||||
};
|
||||
|
||||
// Check if the token is present in db_arg and use it if available
|
||||
let (token, token_is_keypair) = if let Some(token) = db_arg
|
||||
@@ -409,56 +419,38 @@ pub async fn do_snowflake(
|
||||
let reserved_variables =
|
||||
get_reserved_variables(job, &client.token, conn, parent_runnable_path).await?;
|
||||
|
||||
let result_f = if queries.len() > 1 {
|
||||
let futures = queries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, x)| {
|
||||
do_snowflake_inner(
|
||||
x,
|
||||
&snowflake_args,
|
||||
body.clone(),
|
||||
&database.account_identifier,
|
||||
&token,
|
||||
token_is_keypair,
|
||||
None,
|
||||
annotations.return_last_result && i < queries.len() - 1,
|
||||
&http_client,
|
||||
s3.clone(),
|
||||
&reserved_variables,
|
||||
)
|
||||
})
|
||||
.collect::<windmill_common::error::Result<Vec<_>>>()?;
|
||||
let result_f = async move {
|
||||
let mut results = vec![];
|
||||
for (i, q) in queries.iter().enumerate() {
|
||||
let result = do_snowflake_inner(
|
||||
q,
|
||||
&snowflake_args,
|
||||
body.clone(),
|
||||
&database.account_identifier,
|
||||
&token,
|
||||
token_is_keypair,
|
||||
if i == queries.len() - 1
|
||||
&& collection_strategy.collect_last_statement_only(queries.len())
|
||||
&& !collection_strategy.collect_scalar()
|
||||
{
|
||||
Some(column_order)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
collection_strategy.collect_last_statement_only(queries.len())
|
||||
&& i < queries.len() - 1,
|
||||
collection_strategy.collect_first_row_only(),
|
||||
&http_client,
|
||||
s3.clone(),
|
||||
&reserved_variables,
|
||||
)?
|
||||
.await?;
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
let f = async {
|
||||
let mut res: Vec<Box<RawValue>> = vec![];
|
||||
for fut in futures {
|
||||
let r = fut.await?;
|
||||
res.push(r);
|
||||
}
|
||||
if annotations.return_last_result && res.len() > 0 {
|
||||
Ok(res.pop().unwrap())
|
||||
} else {
|
||||
Ok(to_raw_value(&res))
|
||||
}
|
||||
};
|
||||
|
||||
f.boxed()
|
||||
} else {
|
||||
do_snowflake_inner(
|
||||
query,
|
||||
&snowflake_args,
|
||||
body.clone(),
|
||||
&database.account_identifier,
|
||||
&token,
|
||||
token_is_keypair,
|
||||
Some(column_order),
|
||||
false,
|
||||
&http_client,
|
||||
s3.clone(),
|
||||
&reserved_variables,
|
||||
)?
|
||||
collection_strategy.collect(results)
|
||||
};
|
||||
|
||||
let r = run_future_with_polling_update_job_poller(
|
||||
job.id,
|
||||
job.timeout,
|
||||
|
||||
@@ -18,6 +18,7 @@ use windmill_common::scripts::hash_to_codebase_id;
|
||||
use windmill_common::scripts::is_special_codebase_hash;
|
||||
use windmill_common::utils::report_critical_error;
|
||||
use windmill_common::utils::retrieve_common_worker_prefix;
|
||||
use windmill_common::worker::error_to_value;
|
||||
use windmill_common::{
|
||||
agent_workers::DECODED_AGENT_TOKEN,
|
||||
apps::AppScriptId,
|
||||
@@ -58,7 +59,6 @@ use std::{
|
||||
time::Duration,
|
||||
};
|
||||
use windmill_parser::MainArgSignature;
|
||||
use windmill_queue::preprocess_dependency_job;
|
||||
use windmill_queue::MiniCompletedJob;
|
||||
use windmill_queue::PulledJobResultToJobErr;
|
||||
|
||||
@@ -112,7 +112,7 @@ use crate::{
|
||||
bash_executor::handle_bash_job,
|
||||
bun_executor::handle_bun_job,
|
||||
common::{
|
||||
build_args_map, cached_result_path, error_to_value, get_cached_resource_value_if_valid,
|
||||
build_args_map, cached_result_path, get_cached_resource_value_if_valid,
|
||||
get_reserved_variables, update_worker_ping_for_failed_init_script, OccupancyMetrics,
|
||||
},
|
||||
csharp_executor::handle_csharp_job,
|
||||
@@ -840,10 +840,22 @@ pub fn start_interactive_worker_shell(
|
||||
|
||||
match job {
|
||||
Ok(j) => match j.to_pulled_job() {
|
||||
Ok(j) => Ok(j.map(NextJob::Sql)),
|
||||
Err(PulledJobResultToJobErr::MissingConcurrencyKey(jc)) => {
|
||||
if let Err(err) = job_completed_tx.send_job(jc, true).await {
|
||||
tracing::error!("An error occurred while sending job completed (missing concurrency key): {:#?}", err)
|
||||
Ok(j) => Ok(j.clone().map(NextJob::Sql)),
|
||||
ref e @ (Err(PulledJobResultToJobErr::MissingConcurrencyKey(
|
||||
ref jc,
|
||||
))
|
||||
| Err(PulledJobResultToJobErr::ErrorWhilePreprocessing(
|
||||
ref jc,
|
||||
))) => {
|
||||
if let Err(err) =
|
||||
job_completed_tx.send_job(jc.clone(), true).await
|
||||
{
|
||||
let e_fmt = match e {
|
||||
Ok(_) => "unknown error".to_owned(),
|
||||
Err(e) => e.to_string(),
|
||||
};
|
||||
|
||||
tracing::error!("An error occurred while sending job completed ({e_fmt}): {:#?}", err)
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
@@ -878,7 +890,7 @@ pub fn start_interactive_worker_shell(
|
||||
token,
|
||||
precomputed_agent_info: precomputed_bundle,
|
||||
} = extract_job_and_perms(job, &conn).await;
|
||||
|
||||
|
||||
let authed_client = AuthedClient::new(
|
||||
base_internal_url.to_owned(),
|
||||
job.workspace_id.clone(),
|
||||
@@ -887,7 +899,7 @@ pub fn start_interactive_worker_shell(
|
||||
);
|
||||
|
||||
let arc_job = Arc::new(job);
|
||||
|
||||
|
||||
let _ = handle_queued_job(
|
||||
arc_job.clone(),
|
||||
raw_code,
|
||||
@@ -1606,30 +1618,23 @@ pub async fn run_worker(
|
||||
}
|
||||
};
|
||||
|
||||
// Essential debouncing job preprocessing.
|
||||
if let Ok(windmill_queue::PulledJobResult {
|
||||
job: Some(ref mut pulled_job),
|
||||
..
|
||||
}) = &mut job
|
||||
{
|
||||
match timeout(
|
||||
// Preprocess pulled job result
|
||||
if let Ok(ref mut pulled_job_res) = job {
|
||||
if let Err(e) = timeout(
|
||||
// Will fail if longer than 10 seconds
|
||||
core::time::Duration::from_secs(10),
|
||||
preprocess_dependency_job(pulled_job, &db),
|
||||
pulled_job_res.preprocess(db),
|
||||
)
|
||||
.warn_after_seconds(2)
|
||||
.await
|
||||
// Flatten result
|
||||
.map_err(error::Error::from)
|
||||
.and_then(|r| r)
|
||||
{
|
||||
Ok(Err(e)) => {
|
||||
tracing::error!(worker = %worker_name, hostname = %hostname, "critical: debouncing job preprocessor failed: {e:?}");
|
||||
job = Err(e.into());
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(worker = %worker_name, hostname = %hostname, "critical: debouncing job preprocessor has timed out: {e:?}");
|
||||
job = Err(e.into());
|
||||
}
|
||||
_ => {}
|
||||
pulled_job_res.error_while_preprocessing = Some(e.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
add_time!(bench, "job pulled from DB");
|
||||
let duration_pull_s = pull_time.elapsed().as_secs_f64();
|
||||
let err_pull = job.is_ok();
|
||||
@@ -1689,9 +1694,21 @@ pub async fn run_worker(
|
||||
match job {
|
||||
Ok(pulled_job_result) => match pulled_job_result.to_pulled_job() {
|
||||
Ok(j) => Ok(j.map(NextJob::Sql)),
|
||||
Err(PulledJobResultToJobErr::MissingConcurrencyKey(jc)) => {
|
||||
if let Err(err) = job_completed_tx.send_job(jc, true).await {
|
||||
tracing::error!("An error occurred while sending job completed (missing concurrency key): {:#?}", err)
|
||||
ref e @ (Err(PulledJobResultToJobErr::MissingConcurrencyKey(
|
||||
ref jc,
|
||||
))
|
||||
| Err(PulledJobResultToJobErr::ErrorWhilePreprocessing(
|
||||
ref jc,
|
||||
))) => {
|
||||
if let Err(err) =
|
||||
job_completed_tx.send_job(jc.clone(), true).await
|
||||
{
|
||||
let e_fmt = match e {
|
||||
Ok(_) => "unknown error".to_owned(),
|
||||
Err(e) => e.to_string(),
|
||||
};
|
||||
|
||||
tracing::error!("An error occurred while sending job completed ({e_fmt}): {:#?}", err)
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
@@ -2232,7 +2232,7 @@ async fn push_next_flow_job(
|
||||
}
|
||||
});
|
||||
|
||||
// if this is an empty module without preprocessor of if the module has already been completed, successfully, update the parent flow
|
||||
// if this is an empty module without preprocessor or if the module has already been completed, successfully, update the parent flow
|
||||
if (flow.modules.is_empty() && !step.is_preprocessor_step())
|
||||
|| matches!(status_module, FlowStatusModule::Success { .. })
|
||||
{
|
||||
@@ -3718,11 +3718,13 @@ pub struct JobPayloadWithTag {
|
||||
pub timeout: Option<i32>,
|
||||
pub on_behalf_of: Option<OnBehalfOf>,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
enum ContinuePayload {
|
||||
SingleJob(JobPayloadWithTag),
|
||||
ParallelJobs(Vec<JobPayloadWithTag>),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum NextFlowTransform {
|
||||
EmptyInnerFlows { branch_chosen: Option<BranchChosen> },
|
||||
Continue(ContinuePayload, NextStatus),
|
||||
|
||||
+1
-1
@@ -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.574.1";
|
||||
export const VERSION = "v1.575.0";
|
||||
|
||||
export async function login(email: string, password: string): Promise<string> {
|
||||
return await windmill.UserService.login({
|
||||
|
||||
@@ -477,6 +477,7 @@ const command = new Command()
|
||||
.command("delete-fork")
|
||||
.description("Delete a forked workspace and git branch")
|
||||
.arguments("<fork_name:string>")
|
||||
.option("-y --yes", "Skip confirmation prompt")
|
||||
.action(deleteWorkspaceFork as any);
|
||||
|
||||
export default command;
|
||||
|
||||
+1
-1
@@ -68,7 +68,7 @@ export {
|
||||
// }
|
||||
// });
|
||||
|
||||
export const VERSION = "1.574.1";
|
||||
export const VERSION = "1.575.0";
|
||||
|
||||
export const WM_FORK_PREFIX = "wm-fork";
|
||||
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export function parse_php(code: string): string;
|
||||
export function parse_php(code: string, main_override?: string | null): string;
|
||||
|
||||
@@ -56,30 +56,41 @@ function passStringToWasm0(arg, malloc, realloc) {
|
||||
return ptr;
|
||||
}
|
||||
|
||||
const cachedTextDecoder = (typeof TextDecoder !== 'undefined' ? new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }) : { decode: () => { throw Error('TextDecoder not available') } } );
|
||||
function isLikeNone(x) {
|
||||
return x === undefined || x === null;
|
||||
}
|
||||
|
||||
let cachedTextDecoder = (typeof TextDecoder !== 'undefined' ? new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }) : { decode: () => { throw Error('TextDecoder not available') } } );
|
||||
|
||||
if (typeof TextDecoder !== 'undefined') { cachedTextDecoder.decode(); };
|
||||
|
||||
function decodeText(ptr, len) {
|
||||
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
|
||||
}
|
||||
|
||||
function getStringFromWasm0(ptr, len) {
|
||||
ptr = ptr >>> 0;
|
||||
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
|
||||
return decodeText(ptr, len);
|
||||
}
|
||||
/**
|
||||
* @param {string} code
|
||||
* @param {string | null} [main_override]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function parse_php(code) {
|
||||
let deferred2_0;
|
||||
let deferred2_1;
|
||||
export function parse_php(code, main_override) {
|
||||
let deferred3_0;
|
||||
let deferred3_1;
|
||||
try {
|
||||
const ptr0 = passStringToWasm0(code, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.parse_php(ptr0, len0);
|
||||
deferred2_0 = ret[0];
|
||||
deferred2_1 = ret[1];
|
||||
var ptr1 = isLikeNone(main_override) ? 0 : passStringToWasm0(main_override, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
var len1 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.parse_php(ptr0, len0, ptr1, len1);
|
||||
deferred3_0 = ret[0];
|
||||
deferred3_1 = ret[1];
|
||||
return getStringFromWasm0(ret[0], ret[1]);
|
||||
} finally {
|
||||
wasm.__wbindgen_free(deferred2_0, deferred2_1, 1);
|
||||
wasm.__wbindgen_free(deferred3_0, deferred3_1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
+1
-1
@@ -1,7 +1,7 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export const memory: WebAssembly.Memory;
|
||||
export const parse_php: (a: number, b: number) => [number, number];
|
||||
export const parse_php: (a: number, b: number, c: number, d: number) => [number, number];
|
||||
export const __wbindgen_export_0: WebAssembly.Table;
|
||||
export const __wbindgen_malloc: (a: number, b: number) => number;
|
||||
export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
|
||||
|
||||
+8
-8
@@ -1,14 +1,14 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export function parse_bash(code: string): string;
|
||||
export function parse_snowflake(code: string): string;
|
||||
export function parse_assets_sql(code: string): string;
|
||||
export function parse_duckdb(code: string): string;
|
||||
export function parse_graphql(code: string): string;
|
||||
export function parse_db_resource(code: string): string | undefined;
|
||||
export function parse_mysql(code: string): string;
|
||||
export function parse_sql(code: string): string;
|
||||
export function parse_oracledb(code: string): string;
|
||||
export function parse_bigquery(code: string): string;
|
||||
export function parse_bash(code: string): string;
|
||||
export function parse_mssql(code: string): string;
|
||||
export function parse_oracledb(code: string): string;
|
||||
export function parse_powershell(code: string): string;
|
||||
export function parse_bigquery(code: string): string;
|
||||
export function parse_graphql(code: string): string;
|
||||
export function parse_snowflake(code: string): string;
|
||||
export function parse_duckdb(code: string): string;
|
||||
export function parse_sql(code: string): string;
|
||||
export function parse_mysql(code: string): string;
|
||||
|
||||
@@ -68,44 +68,6 @@ function getStringFromWasm0(ptr, len) {
|
||||
ptr = ptr >>> 0;
|
||||
return decodeText(ptr, len);
|
||||
}
|
||||
/**
|
||||
* @param {string} code
|
||||
* @returns {string}
|
||||
*/
|
||||
export function parse_bash(code) {
|
||||
let deferred2_0;
|
||||
let deferred2_1;
|
||||
try {
|
||||
const ptr0 = passStringToWasm0(code, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.parse_bash(ptr0, len0);
|
||||
deferred2_0 = ret[0];
|
||||
deferred2_1 = ret[1];
|
||||
return getStringFromWasm0(ret[0], ret[1]);
|
||||
} finally {
|
||||
wasm.__wbindgen_free(deferred2_0, deferred2_1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} code
|
||||
* @returns {string}
|
||||
*/
|
||||
export function parse_snowflake(code) {
|
||||
let deferred2_0;
|
||||
let deferred2_1;
|
||||
try {
|
||||
const ptr0 = passStringToWasm0(code, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.parse_snowflake(ptr0, len0);
|
||||
deferred2_0 = ret[0];
|
||||
deferred2_1 = ret[1];
|
||||
return getStringFromWasm0(ret[0], ret[1]);
|
||||
} finally {
|
||||
wasm.__wbindgen_free(deferred2_0, deferred2_1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} code
|
||||
* @returns {string}
|
||||
@@ -125,44 +87,6 @@ export function parse_assets_sql(code) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} code
|
||||
* @returns {string}
|
||||
*/
|
||||
export function parse_duckdb(code) {
|
||||
let deferred2_0;
|
||||
let deferred2_1;
|
||||
try {
|
||||
const ptr0 = passStringToWasm0(code, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.parse_duckdb(ptr0, len0);
|
||||
deferred2_0 = ret[0];
|
||||
deferred2_1 = ret[1];
|
||||
return getStringFromWasm0(ret[0], ret[1]);
|
||||
} finally {
|
||||
wasm.__wbindgen_free(deferred2_0, deferred2_1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} code
|
||||
* @returns {string}
|
||||
*/
|
||||
export function parse_graphql(code) {
|
||||
let deferred2_0;
|
||||
let deferred2_1;
|
||||
try {
|
||||
const ptr0 = passStringToWasm0(code, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.parse_graphql(ptr0, len0);
|
||||
deferred2_0 = ret[0];
|
||||
deferred2_1 = ret[1];
|
||||
return getStringFromWasm0(ret[0], ret[1]);
|
||||
} finally {
|
||||
wasm.__wbindgen_free(deferred2_0, deferred2_1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} code
|
||||
* @returns {string | undefined}
|
||||
@@ -183,70 +107,13 @@ export function parse_db_resource(code) {
|
||||
* @param {string} code
|
||||
* @returns {string}
|
||||
*/
|
||||
export function parse_mysql(code) {
|
||||
export function parse_bash(code) {
|
||||
let deferred2_0;
|
||||
let deferred2_1;
|
||||
try {
|
||||
const ptr0 = passStringToWasm0(code, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.parse_mysql(ptr0, len0);
|
||||
deferred2_0 = ret[0];
|
||||
deferred2_1 = ret[1];
|
||||
return getStringFromWasm0(ret[0], ret[1]);
|
||||
} finally {
|
||||
wasm.__wbindgen_free(deferred2_0, deferred2_1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} code
|
||||
* @returns {string}
|
||||
*/
|
||||
export function parse_sql(code) {
|
||||
let deferred2_0;
|
||||
let deferred2_1;
|
||||
try {
|
||||
const ptr0 = passStringToWasm0(code, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.parse_sql(ptr0, len0);
|
||||
deferred2_0 = ret[0];
|
||||
deferred2_1 = ret[1];
|
||||
return getStringFromWasm0(ret[0], ret[1]);
|
||||
} finally {
|
||||
wasm.__wbindgen_free(deferred2_0, deferred2_1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} code
|
||||
* @returns {string}
|
||||
*/
|
||||
export function parse_oracledb(code) {
|
||||
let deferred2_0;
|
||||
let deferred2_1;
|
||||
try {
|
||||
const ptr0 = passStringToWasm0(code, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.parse_oracledb(ptr0, len0);
|
||||
deferred2_0 = ret[0];
|
||||
deferred2_1 = ret[1];
|
||||
return getStringFromWasm0(ret[0], ret[1]);
|
||||
} finally {
|
||||
wasm.__wbindgen_free(deferred2_0, deferred2_1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} code
|
||||
* @returns {string}
|
||||
*/
|
||||
export function parse_bigquery(code) {
|
||||
let deferred2_0;
|
||||
let deferred2_1;
|
||||
try {
|
||||
const ptr0 = passStringToWasm0(code, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.parse_bigquery(ptr0, len0);
|
||||
const ret = wasm.parse_bash(ptr0, len0);
|
||||
deferred2_0 = ret[0];
|
||||
deferred2_1 = ret[1];
|
||||
return getStringFromWasm0(ret[0], ret[1]);
|
||||
@@ -274,6 +141,25 @@ export function parse_mssql(code) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} code
|
||||
* @returns {string}
|
||||
*/
|
||||
export function parse_oracledb(code) {
|
||||
let deferred2_0;
|
||||
let deferred2_1;
|
||||
try {
|
||||
const ptr0 = passStringToWasm0(code, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.parse_oracledb(ptr0, len0);
|
||||
deferred2_0 = ret[0];
|
||||
deferred2_1 = ret[1];
|
||||
return getStringFromWasm0(ret[0], ret[1]);
|
||||
} finally {
|
||||
wasm.__wbindgen_free(deferred2_0, deferred2_1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} code
|
||||
* @returns {string}
|
||||
@@ -293,6 +179,120 @@ export function parse_powershell(code) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} code
|
||||
* @returns {string}
|
||||
*/
|
||||
export function parse_bigquery(code) {
|
||||
let deferred2_0;
|
||||
let deferred2_1;
|
||||
try {
|
||||
const ptr0 = passStringToWasm0(code, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.parse_bigquery(ptr0, len0);
|
||||
deferred2_0 = ret[0];
|
||||
deferred2_1 = ret[1];
|
||||
return getStringFromWasm0(ret[0], ret[1]);
|
||||
} finally {
|
||||
wasm.__wbindgen_free(deferred2_0, deferred2_1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} code
|
||||
* @returns {string}
|
||||
*/
|
||||
export function parse_graphql(code) {
|
||||
let deferred2_0;
|
||||
let deferred2_1;
|
||||
try {
|
||||
const ptr0 = passStringToWasm0(code, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.parse_graphql(ptr0, len0);
|
||||
deferred2_0 = ret[0];
|
||||
deferred2_1 = ret[1];
|
||||
return getStringFromWasm0(ret[0], ret[1]);
|
||||
} finally {
|
||||
wasm.__wbindgen_free(deferred2_0, deferred2_1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} code
|
||||
* @returns {string}
|
||||
*/
|
||||
export function parse_snowflake(code) {
|
||||
let deferred2_0;
|
||||
let deferred2_1;
|
||||
try {
|
||||
const ptr0 = passStringToWasm0(code, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.parse_snowflake(ptr0, len0);
|
||||
deferred2_0 = ret[0];
|
||||
deferred2_1 = ret[1];
|
||||
return getStringFromWasm0(ret[0], ret[1]);
|
||||
} finally {
|
||||
wasm.__wbindgen_free(deferred2_0, deferred2_1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} code
|
||||
* @returns {string}
|
||||
*/
|
||||
export function parse_duckdb(code) {
|
||||
let deferred2_0;
|
||||
let deferred2_1;
|
||||
try {
|
||||
const ptr0 = passStringToWasm0(code, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.parse_duckdb(ptr0, len0);
|
||||
deferred2_0 = ret[0];
|
||||
deferred2_1 = ret[1];
|
||||
return getStringFromWasm0(ret[0], ret[1]);
|
||||
} finally {
|
||||
wasm.__wbindgen_free(deferred2_0, deferred2_1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} code
|
||||
* @returns {string}
|
||||
*/
|
||||
export function parse_sql(code) {
|
||||
let deferred2_0;
|
||||
let deferred2_1;
|
||||
try {
|
||||
const ptr0 = passStringToWasm0(code, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.parse_sql(ptr0, len0);
|
||||
deferred2_0 = ret[0];
|
||||
deferred2_1 = ret[1];
|
||||
return getStringFromWasm0(ret[0], ret[1]);
|
||||
} finally {
|
||||
wasm.__wbindgen_free(deferred2_0, deferred2_1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} code
|
||||
* @returns {string}
|
||||
*/
|
||||
export function parse_mysql(code) {
|
||||
let deferred2_0;
|
||||
let deferred2_1;
|
||||
try {
|
||||
const ptr0 = passStringToWasm0(code, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.parse_mysql(ptr0, len0);
|
||||
deferred2_0 = ret[0];
|
||||
deferred2_1 = ret[1];
|
||||
return getStringFromWasm0(ret[0], ret[1]);
|
||||
} finally {
|
||||
wasm.__wbindgen_free(deferred2_0, deferred2_1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
const imports = {
|
||||
__wbindgen_placeholder__: {
|
||||
__wbindgen_init_externref_table: function() {
|
||||
|
||||
Binary file not shown.
+1
-1
@@ -1,5 +1,5 @@
|
||||
# Windmill frontend
|
||||
|
||||
The Windmill frontend written in Svelte + Tailwind CSS
|
||||
The Windmill frontend written in Svelte 5 + Tailwind CSS
|
||||
|
||||
The frontend is under AGPL, see the LICENSE file at the root of this repo
|
||||
|
||||
Generated
+14
-14
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "windmill-components",
|
||||
"version": "1.574.1",
|
||||
"version": "1.575.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "windmill-components",
|
||||
"version": "1.574.1",
|
||||
"version": "1.575.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
@@ -24,7 +24,7 @@
|
||||
"@redocly/json-to-json-schema": "^0.0.1",
|
||||
"@scalar/openapi-parser": "^0.15.0",
|
||||
"@tanstack/svelte-table": "npm:tanstack-table-8-svelte-5@^0.1",
|
||||
"@tutorlatin/svelte-tiny-virtual-list": "^3.0.2",
|
||||
"@tutorlatin/svelte-tiny-virtual-list": "^3.0.16",
|
||||
"@windmill-labs/svelte-dnd-action": "^0.9.44",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xyflow/svelte": "^1.0.0",
|
||||
@@ -77,9 +77,9 @@
|
||||
"windmill-parser-wasm-go": "1.510.1",
|
||||
"windmill-parser-wasm-java": "1.510.1",
|
||||
"windmill-parser-wasm-nu": "1.510.1",
|
||||
"windmill-parser-wasm-php": "1.510.1",
|
||||
"windmill-parser-wasm-php": "1.574.1",
|
||||
"windmill-parser-wasm-py": "1.538.0",
|
||||
"windmill-parser-wasm-regex": "1.565.0",
|
||||
"windmill-parser-wasm-regex": "1.574.1",
|
||||
"windmill-parser-wasm-ruby": "1.526.1",
|
||||
"windmill-parser-wasm-rust": "1.558.1",
|
||||
"windmill-parser-wasm-ts": "1.565.0",
|
||||
@@ -3021,9 +3021,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tutorlatin/svelte-tiny-virtual-list": {
|
||||
"version": "3.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@tutorlatin/svelte-tiny-virtual-list/-/svelte-tiny-virtual-list-3.0.15.tgz",
|
||||
"integrity": "sha512-ew61aZNXGf0b5X+UjbOAhiNwzI21vijhB/mtBs8bpNOVYQ50TG6Qx00t+fR5C72eGnmdzguewZ2WP6QPNOTQJg==",
|
||||
"version": "3.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@tutorlatin/svelte-tiny-virtual-list/-/svelte-tiny-virtual-list-3.0.16.tgz",
|
||||
"integrity": "sha512-JQSmhRDAFZbq2rTlzn+kFXJayi5VPLxeGjD01ZVyV2ti7PlQE/ov6rQFR1c8s7Y3B1OiTcv3oEWGi3ib69V8eQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.17.0"
|
||||
@@ -13689,9 +13689,9 @@
|
||||
"integrity": "sha512-AJLFiUy6af+LpUe7CddDo4+JOmw3c0K/1iOWh8NdTwXcLDj90lL6089mdsVo1apyloLgrTbcuFDzZMXVGBgtCg=="
|
||||
},
|
||||
"node_modules/windmill-parser-wasm-php": {
|
||||
"version": "1.510.1",
|
||||
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-php/-/windmill-parser-wasm-php-1.510.1.tgz",
|
||||
"integrity": "sha512-qM+yeaqPdMuAaPpqND31ZabpeHlPxxtmRWLs11cGHOCHU32FIaZ92/icNUoAxgBhdJjRhR4GXaeZG32nWit2cg=="
|
||||
"version": "1.574.1",
|
||||
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-php/-/windmill-parser-wasm-php-1.574.1.tgz",
|
||||
"integrity": "sha512-COyid6B1RYs+bpzUCInsA4HY/WZkpDLfkQ90+AqU/TVTpzYSbAC2JCbIwy0cRElBvlhI4bQ+9Wg6hSQKMpEkpA=="
|
||||
},
|
||||
"node_modules/windmill-parser-wasm-py": {
|
||||
"version": "1.538.0",
|
||||
@@ -13699,9 +13699,9 @@
|
||||
"integrity": "sha512-s+bdIgT/fA5em3zYUwF8D14uA/dZh7iu0krZYZQqZUO7txN37hwSCVfovbMkIwm4zPbsJ50mU8DRLt7UpAPZIw=="
|
||||
},
|
||||
"node_modules/windmill-parser-wasm-regex": {
|
||||
"version": "1.565.0",
|
||||
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-regex/-/windmill-parser-wasm-regex-1.565.0.tgz",
|
||||
"integrity": "sha512-fcAy7SkyrCML6YgnsQ5H3utpJFxLqWssGmY+hJAibdQtAYNRASsSQZl87A46nIFO8TpKKiWioTSxZDl/9Ovtrw=="
|
||||
"version": "1.574.1",
|
||||
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-regex/-/windmill-parser-wasm-regex-1.574.1.tgz",
|
||||
"integrity": "sha512-KnNBnpTGcnBPSzTQHdsjTPJbRQ81iem98eUnMGO8Zt1qgBFJ/eFZPwmUg6TZMbMITzTi0QqRCJj08eQID1FcPg=="
|
||||
},
|
||||
"node_modules/windmill-parser-wasm-ruby": {
|
||||
"version": "1.526.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "windmill-components",
|
||||
"version": "1.574.1",
|
||||
"version": "1.575.0",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
@@ -89,7 +89,7 @@
|
||||
"@redocly/json-to-json-schema": "^0.0.1",
|
||||
"@scalar/openapi-parser": "^0.15.0",
|
||||
"@tanstack/svelte-table": "npm:tanstack-table-8-svelte-5@^0.1",
|
||||
"@tutorlatin/svelte-tiny-virtual-list": "^3.0.2",
|
||||
"@tutorlatin/svelte-tiny-virtual-list": "^3.0.16",
|
||||
"@windmill-labs/svelte-dnd-action": "^0.9.44",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xyflow/svelte": "^1.0.0",
|
||||
@@ -142,9 +142,9 @@
|
||||
"windmill-parser-wasm-go": "1.510.1",
|
||||
"windmill-parser-wasm-java": "1.510.1",
|
||||
"windmill-parser-wasm-nu": "1.510.1",
|
||||
"windmill-parser-wasm-php": "1.510.1",
|
||||
"windmill-parser-wasm-php": "1.574.1",
|
||||
"windmill-parser-wasm-py": "1.538.0",
|
||||
"windmill-parser-wasm-regex": "1.565.0",
|
||||
"windmill-parser-wasm-regex": "1.574.1",
|
||||
"windmill-parser-wasm-ruby": "1.526.1",
|
||||
"windmill-parser-wasm-rust": "1.558.1",
|
||||
"windmill-parser-wasm-ts": "1.565.0",
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { CancelablePromise } from './gen'
|
||||
|
||||
export namespace CancelablePromiseUtils {
|
||||
export function then<T, U>(
|
||||
promise: CancelablePromise<T>,
|
||||
f: (value: T) => CancelablePromise<U>
|
||||
): CancelablePromise<U> {
|
||||
let promiseToBeCanceled: CancelablePromise<any> = promise
|
||||
let p = new CancelablePromise<U>((resolve, reject) => {
|
||||
promise
|
||||
.then((value1) => {
|
||||
let promise2 = f(value1)
|
||||
promiseToBeCanceled = promise2
|
||||
promise2.then((value2) => resolve(value2)).catch((err) => reject(err))
|
||||
})
|
||||
.catch((err) => reject(err))
|
||||
})
|
||||
p.cancel = () => promiseToBeCanceled.cancel()
|
||||
return p
|
||||
}
|
||||
|
||||
export function pure<T>(value: T): CancelablePromise<T> {
|
||||
return new CancelablePromise((resolve) => resolve(value))
|
||||
}
|
||||
|
||||
export function err<T>(error: any): CancelablePromise<T> {
|
||||
return new CancelablePromise((_, reject) => reject(error))
|
||||
}
|
||||
|
||||
export function map<T, U>(
|
||||
promise: CancelablePromise<T>,
|
||||
f: (value: T) => U
|
||||
): CancelablePromise<U> {
|
||||
return then(promise, (value) => pure(f(value)))
|
||||
}
|
||||
|
||||
export function pipe<T>(
|
||||
promise: CancelablePromise<T>,
|
||||
f: (value: T) => void
|
||||
): CancelablePromise<T> {
|
||||
promise.then((value) => {
|
||||
f(value)
|
||||
})
|
||||
return promise
|
||||
}
|
||||
|
||||
export function catchErr<T, U>(
|
||||
promise: CancelablePromise<T>,
|
||||
f: (error: any) => CancelablePromise<U>
|
||||
): CancelablePromise<T | U> {
|
||||
let promiseToBeCanceled: CancelablePromise<any> = promise
|
||||
let p = new CancelablePromise<T | U>((resolve, reject) => {
|
||||
promise
|
||||
.then((value) => resolve(value))
|
||||
.catch((err) => {
|
||||
let promise2 = f(err)
|
||||
promiseToBeCanceled = promise2
|
||||
return promise2.then((value2) => resolve(value2)).catch((err2) => reject(err2))
|
||||
})
|
||||
.catch((err) => reject(err))
|
||||
})
|
||||
p.cancel = () => promiseToBeCanceled.cancel()
|
||||
return p
|
||||
}
|
||||
|
||||
export function finallyDo<T>(promise: CancelablePromise<T>, f: () => void): CancelablePromise<T> {
|
||||
promise = map(promise, (value) => (f(), value))
|
||||
promise = catchErr(promise, (e) => (f(), err(e)))
|
||||
return promise
|
||||
}
|
||||
|
||||
// Calls onTimeout if the promise does not settle within timeoutMs milliseconds
|
||||
export function onTimeout<T>(
|
||||
promise: CancelablePromise<T>,
|
||||
timeoutMs: number,
|
||||
onTimeout: () => void
|
||||
): CancelablePromise<T> {
|
||||
let timeoutId: number | undefined = setTimeout(onTimeout, timeoutMs)
|
||||
promise = finallyDo(promise, () => {
|
||||
if (timeoutId !== undefined) clearTimeout(timeoutId)
|
||||
})
|
||||
return promise
|
||||
}
|
||||
}
|
||||
@@ -400,6 +400,7 @@
|
||||
const UUID_PATTERN = '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
|
||||
const IPV6_PATTERN =
|
||||
'^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$'
|
||||
|
||||
function validateInput(pattern: string | undefined, v: any, required: boolean): void {
|
||||
if (nullable && emptyString(v)) {
|
||||
error = ''
|
||||
@@ -407,6 +408,18 @@
|
||||
} else if (required && (v == undefined || v == null || v === '') && inputCat != 'object') {
|
||||
error = 'Required'
|
||||
valid && (valid = false)
|
||||
} else if (
|
||||
required &&
|
||||
inputCat == 'list' &&
|
||||
extra?.['nonEmpty'] == true &&
|
||||
Array.isArray(v) &&
|
||||
v.length === 0
|
||||
) {
|
||||
error = 'Required'
|
||||
valid && (valid = false)
|
||||
} else if (inputCat == 'list' && !Array.isArray(v)) {
|
||||
error = 'Expected an array, got ' + typeof v + ' instead'
|
||||
valid && (valid = false)
|
||||
} else {
|
||||
if (inputCat == 'number' && typeof v === 'number') {
|
||||
let min = extra['min']
|
||||
@@ -523,6 +536,7 @@
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
extra?.['nonEmpty']
|
||||
let args = [pattern, value, required] as const
|
||||
untrack(() => validateInput(...args))
|
||||
})
|
||||
|
||||
@@ -24,12 +24,14 @@
|
||||
properties?: { [name: string]: SchemaProperty }
|
||||
}
|
||||
| undefined
|
||||
nonEmpty?: boolean | undefined
|
||||
}
|
||||
|
||||
let {
|
||||
canEditResourceType = false,
|
||||
originalType = undefined,
|
||||
itemsType = $bindable()
|
||||
itemsType = $bindable(),
|
||||
nonEmpty = $bindable()
|
||||
}: Props = $props()
|
||||
|
||||
let selected:
|
||||
@@ -143,6 +145,12 @@
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<Toggle
|
||||
size="sm"
|
||||
class="mt-8"
|
||||
bind:checked={nonEmpty}
|
||||
options={{ right: 'Require non-empty array' }}
|
||||
/>
|
||||
{#if canEditResourceType || originalType == 'string[]' || originalType == 'object[]'}
|
||||
<div class="flex flex-row mb-1 mt-2">
|
||||
<Button
|
||||
|
||||
@@ -141,7 +141,10 @@
|
||||
bind:selectedTeam
|
||||
minWidth="180px"
|
||||
disabled={!$enterpriseLicense}
|
||||
onError={(e) => sendUserToast('Failed to load teams: ' + e.message, true)}
|
||||
onError={(e) => {
|
||||
const errorMsg = typeof (e as any)?.body === 'string' ? (e as any).body : (e?.message || 'Unknown error')
|
||||
sendUserToast('Failed to load teams: ' + errorMsg, true)
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
{:else}
|
||||
|
||||
@@ -668,6 +668,7 @@
|
||||
bind:customErrorMessage={
|
||||
schema.properties[argName].customErrorMessage
|
||||
}
|
||||
bind:nonEmpty={schema.properties[argName].nonEmpty}
|
||||
bind:itemsType={schema.properties[argName].items}
|
||||
bind:extra={schema.properties[argName]}
|
||||
bind:title={schema.properties[argName].title}
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
import { aiChatManager } from './copilot/chat/AIChatManager.svelte'
|
||||
import type { Selection } from 'monaco-editor'
|
||||
import { getDbSchemas } from './apps/components/display/dbtable/utils'
|
||||
import { PYTHON_PREPROCESSOR_MODULE_CODE, TS_PREPROCESSOR_MODULE_CODE } from '$lib/script_helpers'
|
||||
import { canHavePreprocessor, getPreprocessorModuleCode } from '$lib/script_helpers'
|
||||
import { setMonacoTypescriptOptions } from './monacoLanguagesOptions'
|
||||
import { copilotInfo } from '$lib/aiStore'
|
||||
// import EditorTheme from './EditorTheme.svelte'
|
||||
@@ -432,11 +432,46 @@
|
||||
let command: IDisposable | undefined = undefined
|
||||
|
||||
let sqlTypeCompletor: IDisposable | undefined = $state(undefined)
|
||||
let resultCollectionCompletor: IDisposable | undefined = $state(undefined)
|
||||
|
||||
function addSqlTypeCompletions() {
|
||||
if (sqlTypeCompletor) {
|
||||
sqlTypeCompletor.dispose()
|
||||
}
|
||||
sqlTypeCompletor?.dispose()
|
||||
resultCollectionCompletor?.dispose()
|
||||
|
||||
resultCollectionCompletor = languages.registerCompletionItemProvider('sql', {
|
||||
triggerCharacters: ['='],
|
||||
provideCompletionItems: function (model, position) {
|
||||
const lineContent = model.getLineContent(position.lineNumber)
|
||||
const match = lineContent.match(/^--\s*result_collection=/)
|
||||
if (!match) {
|
||||
return { suggestions: [] }
|
||||
}
|
||||
const word = model.getWordUntilPosition(position)
|
||||
const range = {
|
||||
startLineNumber: position.lineNumber,
|
||||
endLineNumber: position.lineNumber,
|
||||
startColumn: word.startColumn,
|
||||
endColumn: word.endColumn
|
||||
}
|
||||
const suggestions = [
|
||||
'last_statement_all_rows',
|
||||
'last_statement_first_row',
|
||||
'last_statement_all_rows_scalar',
|
||||
'last_statement_first_row_scalar',
|
||||
'all_statements_all_rows',
|
||||
'all_statements_first_row',
|
||||
'all_statements_all_rows_scalar',
|
||||
'all_statements_first_row_scalar'
|
||||
].map((label) => ({
|
||||
label: label,
|
||||
kind: languages.CompletionItemKind.Function,
|
||||
insertText: label,
|
||||
range,
|
||||
sortText: 'a'
|
||||
}))
|
||||
return { suggestions }
|
||||
}
|
||||
})
|
||||
sqlTypeCompletor = languages.registerCompletionItemProvider('sql', {
|
||||
triggerCharacters: scriptLang === 'postgresql' ? [':'] : ['('],
|
||||
provideCompletionItems: function (model, position) {
|
||||
@@ -620,12 +655,18 @@
|
||||
}
|
||||
|
||||
let preprocessorCompletor: IDisposable | undefined = undefined
|
||||
function addPreprocessorCompletions(lang: 'typescript' | 'python') {
|
||||
function addPreprocessorCompletions(lang: string) {
|
||||
if (preprocessorCompletor) {
|
||||
preprocessorCompletor.dispose()
|
||||
}
|
||||
const preprocessorCode =
|
||||
lang === 'typescript' ? TS_PREPROCESSOR_MODULE_CODE : PYTHON_PREPROCESSOR_MODULE_CODE
|
||||
|
||||
const windmillLang = lang === 'typescript' ? 'deno' : lang === 'python' ? 'python3' : lang
|
||||
const preprocessorCode = getPreprocessorModuleCode(windmillLang as ScriptLang)
|
||||
|
||||
if (!preprocessorCode) {
|
||||
return
|
||||
}
|
||||
|
||||
preprocessorCompletor = languages.registerCompletionItemProvider(lang, {
|
||||
provideCompletionItems: function (model, position) {
|
||||
const word = model.getWordUntilPosition(position)
|
||||
@@ -1581,6 +1622,7 @@
|
||||
sqlSchemaCompletor && sqlSchemaCompletor.dispose()
|
||||
autocompletor && autocompletor.dispose()
|
||||
sqlTypeCompletor && sqlTypeCompletor.dispose()
|
||||
resultCollectionCompletor && resultCollectionCompletor.dispose()
|
||||
preprocessorCompletor && preprocessorCompletor.dispose()
|
||||
timeoutModel && clearTimeout(timeoutModel)
|
||||
loadTimeout && clearTimeout(loadTimeout)
|
||||
@@ -1649,12 +1691,12 @@
|
||||
$effect(() => {
|
||||
initialized && lang === 'sql' && scriptLang
|
||||
? untrack(() => addSqlTypeCompletions())
|
||||
: sqlTypeCompletor?.dispose()
|
||||
: (sqlTypeCompletor?.dispose(), resultCollectionCompletor?.dispose())
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
initialized && (lang === 'typescript' || lang === 'python') && enablePreprocessorSnippet
|
||||
? untrack(() => addPreprocessorCompletions(lang as 'typescript' | 'python'))
|
||||
initialized && canHavePreprocessor(lang) && enablePreprocessorSnippet
|
||||
? untrack(() => addPreprocessorCompletions(lang))
|
||||
: preprocessorCompletor?.dispose()
|
||||
})
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
variant="default"
|
||||
btnClasses={twMerge(
|
||||
connecting ? 'text-accent' : '',
|
||||
'bg-surface group/plug-btn overflow-clip flex p-0'
|
||||
'bg-surface hover:bg-surface-hover group/plug-btn overflow-clip flex p-0'
|
||||
)}
|
||||
on:click
|
||||
{...id ? { id } : {}}
|
||||
|
||||
@@ -38,8 +38,7 @@
|
||||
import { getStepHistoryLoaderContext } from './stepHistoryLoader.svelte'
|
||||
import { aiChatManager } from './copilot/chat/AIChatManager.svelte'
|
||||
import { stateSnapshot } from '$lib/svelte5Utils.svelte'
|
||||
import FlowChatInterface from './flows/conversations/FlowChatInterface.svelte'
|
||||
import { randomUUID } from './flows/conversations/FlowChatManager.svelte'
|
||||
import FlowChat from './flows/conversations/FlowChat.svelte'
|
||||
|
||||
interface Props {
|
||||
previewMode: 'upTo' | 'whole'
|
||||
@@ -95,9 +94,9 @@
|
||||
let suspendStatus: StateStore<Record<string, { job: Job; nb: number }>> = $state({ val: {} })
|
||||
let isOwner: boolean = $state(false)
|
||||
|
||||
export async function test(): Promise<string | undefined> {
|
||||
export async function test(conversationId?: string): Promise<string | undefined> {
|
||||
renderCount++
|
||||
return await runPreview(previewArgs.val, undefined)
|
||||
return await runPreview(previewArgs.val, undefined, conversationId)
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -120,6 +119,16 @@
|
||||
let flowProgressBar: FlowProgressBar | undefined = $state(undefined)
|
||||
let loadingHistory = $state(false)
|
||||
|
||||
let shouldUseStreaming = $derived.by(() => {
|
||||
const modules = flowStore.val.value?.modules
|
||||
const lastModule = modules && modules.length > 0 ? modules[modules.length - 1] : undefined
|
||||
return (
|
||||
lastModule?.value?.type === 'aiagent' &&
|
||||
lastModule?.value?.input_transforms?.streaming?.type === 'static' &&
|
||||
lastModule?.value?.input_transforms?.streaming?.value === true
|
||||
)
|
||||
})
|
||||
|
||||
function extractFlow(previewMode: 'upTo' | 'whole'): OpenFlow {
|
||||
const previewFlow = aiChatManager.flowAiChatHelpers?.getPreviewFlow()
|
||||
if (previewMode === 'whole') {
|
||||
@@ -139,7 +148,8 @@
|
||||
let lastPreviewFlow: undefined | string = $state(undefined)
|
||||
export async function runPreview(
|
||||
args: Record<string, any>,
|
||||
restartedFrom: RestartedFrom | undefined
|
||||
restartedFrom: RestartedFrom | undefined,
|
||||
conversationId?: string | undefined
|
||||
) {
|
||||
let newJobId: string | undefined = undefined
|
||||
if (stepHistoryLoader?.flowJobInitial !== false) {
|
||||
@@ -149,7 +159,7 @@
|
||||
lastPreviewFlow = JSON.stringify(flowStore.val)
|
||||
flowProgressBar?.reset()
|
||||
const newFlow = extractFlow(previewMode)
|
||||
newJobId = await runFlowPreview(args, newFlow, $pathStore, restartedFrom)
|
||||
newJobId = await runFlowPreview(args, newFlow, $pathStore, restartedFrom, conversationId)
|
||||
jobId = newJobId
|
||||
isRunning = true
|
||||
if (inputSelected) {
|
||||
@@ -464,15 +474,14 @@
|
||||
{#if render}
|
||||
{#if flowStore.val.value?.chat_input_enabled}
|
||||
<div class="flex flex-row justify-center w-full">
|
||||
<FlowChatInterface
|
||||
onRunFlow={async (userMessage, _conversationId) => {
|
||||
await runPreview({ user_message: userMessage }, undefined)
|
||||
<FlowChat
|
||||
useStreaming={shouldUseStreaming}
|
||||
onRunFlow={async (userMessage, conversationId) => {
|
||||
await runPreview({ user_message: userMessage }, undefined, conversationId)
|
||||
return jobId ?? ''
|
||||
}}
|
||||
createConversation={async () => {
|
||||
const newConversationId = randomUUID()
|
||||
return newConversationId
|
||||
}}
|
||||
hideSidebar={true}
|
||||
path={$pathStore}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
@@ -528,7 +528,7 @@
|
||||
{pickableProperties}
|
||||
{argName}
|
||||
btnClass={twMerge(
|
||||
'h-6 min-w-8 px-2',
|
||||
'h-7 min-w-8 px-2',
|
||||
'group-hover:opacity-100 transition-opacity',
|
||||
!connecting ? 'opacity-0' : ''
|
||||
)}
|
||||
@@ -538,7 +538,7 @@
|
||||
{#if propPickerWrapperContext}
|
||||
<FlowPlugConnect
|
||||
wrapperClasses={twMerge(
|
||||
connecting ? 'h-5 w-7' : 'h-6 w-8',
|
||||
connecting ? 'h-6 w-7' : 'h-7 w-8',
|
||||
'group-hover:opacity-100 transition-opacity p-0',
|
||||
!connecting ? 'opacity-0' : ''
|
||||
)}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import { refreshSuperadmin } from '$lib/refreshUser'
|
||||
import { createEventDispatcher, onDestroy, onMount } from 'svelte'
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
import Skeleton from './common/skeleton/Skeleton.svelte'
|
||||
import Button from './common/button/Button.svelte'
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
error?: string | undefined
|
||||
popup?: boolean
|
||||
firstTime?: boolean
|
||||
onLoginSuccess?: () => void
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -35,7 +36,8 @@
|
||||
password = $bindable(undefined),
|
||||
error = undefined,
|
||||
popup = false,
|
||||
firstTime = false
|
||||
firstTime = false,
|
||||
onLoginSuccess = undefined
|
||||
}: Props = $props()
|
||||
|
||||
const providers = [
|
||||
@@ -200,8 +202,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
onMount(() => {
|
||||
try {
|
||||
localStorage.removeItem('closeUponLogin')
|
||||
@@ -212,19 +212,23 @@
|
||||
|
||||
function popupListener(event) {
|
||||
let data = event.data
|
||||
console.log('popupListener', data, event.origin, window.location.origin)
|
||||
if (event.origin !== window.location.origin) {
|
||||
return
|
||||
}
|
||||
|
||||
processPopupData(data)
|
||||
window.removeEventListener('message', popupListener)
|
||||
if (data.type === 'success' || data.type === 'error') {
|
||||
console.log('Removing popup listener')
|
||||
window.removeEventListener('message', popupListener)
|
||||
}
|
||||
}
|
||||
|
||||
function processPopupData(data) {
|
||||
if (data.type === 'error') {
|
||||
sendUserToast(data.error, true)
|
||||
} else if (data.type === 'success') {
|
||||
dispatch('login')
|
||||
onLoginSuccess?.()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
selectedIds?: string[]
|
||||
canSelect?: boolean
|
||||
lastFetchWentToEnd?: boolean
|
||||
totalRowsFetched: number
|
||||
onPointClicked: (ids: string[]) => void
|
||||
onLoadExtra: () => void
|
||||
onZoom: (zoom: { min: Date; max: Date }) => void
|
||||
@@ -41,6 +42,7 @@
|
||||
selectedIds = $bindable([]),
|
||||
canSelect = true,
|
||||
lastFetchWentToEnd = false,
|
||||
totalRowsFetched,
|
||||
onPointClicked,
|
||||
onLoadExtra,
|
||||
onZoom
|
||||
@@ -301,13 +303,14 @@
|
||||
<!-- {JSON.stringify(jobs?.map((x) => x.started_at))} -->
|
||||
<div class="relative max-h-40">
|
||||
{#if !lastFetchWentToEnd}
|
||||
<div class="absolute top-[-28px] left-[220px]"
|
||||
><Button size="xs" color="transparent" variant="contained" on:click={() => onLoadExtra()}
|
||||
>Load more <Tooltip2
|
||||
>There are more jobs to load but only the first 1000 were fetched</Tooltip2
|
||||
></Button
|
||||
></div
|
||||
>
|
||||
<div class="absolute top-[-28px] left-[220px]">
|
||||
<Button size="xs" color="transparent" variant="contained" on:click={() => onLoadExtra()}>
|
||||
Load more
|
||||
<Tooltip2>
|
||||
There are more jobs to load but only the first {totalRowsFetched} were fetched
|
||||
</Tooltip2>
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
<Scatter {data} options={scatterOptions} />
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,12 @@
|
||||
WorkerService
|
||||
} from '$lib/gen'
|
||||
import { inferArgs } from '$lib/infer'
|
||||
import { initialCode } from '$lib/script_helpers'
|
||||
import {
|
||||
initialCode,
|
||||
canHavePreprocessor,
|
||||
getPreprocessorFullCode,
|
||||
getMainFunctionPattern
|
||||
} from '$lib/script_helpers'
|
||||
import AIFormSettings from './copilot/AIFormSettings.svelte'
|
||||
import {
|
||||
defaultScripts,
|
||||
@@ -80,12 +85,6 @@
|
||||
import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte'
|
||||
import TriggersEditor from './triggers/TriggersEditor.svelte'
|
||||
import type { ScheduleTrigger, TriggerContext } from './triggers'
|
||||
import {
|
||||
TS_PREPROCESSOR_MODULE_CODE,
|
||||
TS_PREPROCESSOR_SCRIPT_INTRO,
|
||||
PYTHON_PREPROCESSOR_MODULE_CODE,
|
||||
PYTHON_PREPROCESSOR_SCRIPT_INTRO
|
||||
} from '$lib/script_helpers'
|
||||
import CaptureTable from './triggers/CaptureTable.svelte'
|
||||
import type { SavedAndModifiedValue } from './common/confirmationModal/unsavedTypes'
|
||||
import DeployButton from './DeployButton.svelte'
|
||||
@@ -313,7 +312,7 @@
|
||||
{
|
||||
value: 'preprocessor',
|
||||
title: 'Preprocessor',
|
||||
desc: 'Transform incoming requests before they are passed to the flow.',
|
||||
desc: 'Transform incoming requests before they are passed to the main entrypoint.',
|
||||
documentationLink: 'https://www.windmill.dev/docs/core_concepts/preprocessors',
|
||||
Icon: Shuffle
|
||||
}
|
||||
@@ -853,13 +852,10 @@
|
||||
function addPreprocessor() {
|
||||
const code = editor?.getCode()
|
||||
if (code) {
|
||||
const preprocessorCode =
|
||||
script.language === 'python3'
|
||||
? PYTHON_PREPROCESSOR_SCRIPT_INTRO + PYTHON_PREPROCESSOR_MODULE_CODE
|
||||
: TS_PREPROCESSOR_SCRIPT_INTRO + TS_PREPROCESSOR_MODULE_CODE
|
||||
const mainIndex = code.indexOf(
|
||||
script.language === 'python3' ? 'def main' : 'export async function main'
|
||||
)
|
||||
const preprocessorCode = getPreprocessorFullCode(script.language, false)
|
||||
const mainPattern = getMainFunctionPattern(script.language)
|
||||
const mainIndex = code.indexOf(mainPattern)
|
||||
|
||||
if (mainIndex === -1) {
|
||||
editor?.setCode(code + preprocessorCode)
|
||||
} else {
|
||||
@@ -1139,7 +1135,8 @@
|
||||
btnClasses={isPicked ? '' : 'm-[1px]'}
|
||||
on:click={() => onScriptLanguageTrigger(lang)}
|
||||
disabled={lockedLanguage ||
|
||||
(enterpriseLangs.includes(lang) && !$enterpriseLicense)}
|
||||
(enterpriseLangs.includes(lang) && !$enterpriseLicense) ||
|
||||
(script.kind == 'preprocessor' && !canHavePreprocessor(lang))}
|
||||
startIcon={{
|
||||
icon: LanguageIcon,
|
||||
props: { lang }
|
||||
@@ -1667,9 +1664,7 @@
|
||||
newItem={initialPath == ''}
|
||||
isFlow={false}
|
||||
{hasPreprocessor}
|
||||
canHavePreprocessor={script.language === 'bun' ||
|
||||
script.language === 'deno' ||
|
||||
script.language === 'python3'}
|
||||
canHavePreprocessor={canHavePreprocessor(script.language)}
|
||||
args={hasPreprocessor && selectedInputTab !== 'preprocessor' ? {} : args}
|
||||
isDeployed={savedScript && !savedScript?.draft_only}
|
||||
schema={script.schema}
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
import { aiChatManager, AIMode } from './copilot/chat/AIChatManager.svelte'
|
||||
import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte'
|
||||
import AssetsDropdownButton from './assets/AssetsDropdownButton.svelte'
|
||||
import { canHavePreprocessor } from '$lib/script_helpers'
|
||||
import { assetEq, type AssetWithAltAccessType } from './assets/lib'
|
||||
import { editor as meditor } from 'monaco-editor'
|
||||
import type { ReviewChangesOpts } from './copilot/chat/monaco-adapter'
|
||||
@@ -741,7 +742,7 @@
|
||||
<CaptureTable
|
||||
bind:this={captureTable}
|
||||
{hasPreprocessor}
|
||||
canHavePreprocessor={lang === 'bun' || lang === 'deno' || lang === 'python3'}
|
||||
canHavePreprocessor={canHavePreprocessor(lang)}
|
||||
isFlow={false}
|
||||
path={stablePathForCaptures}
|
||||
canEdit={true}
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
lang: 'snowflake',
|
||||
argName: 'database'
|
||||
},
|
||||
ms_sql_server: {
|
||||
mssql: {
|
||||
code: `SELECT 1`,
|
||||
lang: 'mssql',
|
||||
argName: 'database'
|
||||
|
||||
@@ -502,7 +502,7 @@ return schema
|
||||
},
|
||||
argName: 'database'
|
||||
},
|
||||
ms_sql_server: {
|
||||
mssql: {
|
||||
argName: 'database',
|
||||
code: `select TABLE_SCHEMA, TABLE_NAME, DATA_TYPE, COLUMN_NAME, COLUMN_DEFAULT from information_schema.columns where table_schema != 'sys'`,
|
||||
lang: 'mssql',
|
||||
@@ -548,6 +548,8 @@ export async function getDbSchemas(
|
||||
dbSchemas: DBSchemas,
|
||||
errorCallback: (message: string) => void
|
||||
): Promise<void> {
|
||||
if (!scripts[resourceType]) return
|
||||
|
||||
return new Promise(async (resolve, reject) => {
|
||||
if (!resourceType || !resourcePath || !workspace) {
|
||||
resolve()
|
||||
|
||||
@@ -197,7 +197,6 @@
|
||||
id="schema-plug-{key}"
|
||||
/>
|
||||
<ToggleButtonGroup
|
||||
class="h-6"
|
||||
bind:selected={componentInput.type}
|
||||
on:selected={(e) => {
|
||||
if (
|
||||
|
||||
@@ -26,22 +26,23 @@
|
||||
UserService,
|
||||
ScriptService,
|
||||
FlowService,
|
||||
AppService
|
||||
AppService,
|
||||
CancelError
|
||||
} from '$lib/gen'
|
||||
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import { ChevronDown, Loader2, RefreshCcw } from 'lucide-svelte'
|
||||
import { onDestroy, tick, untrack } from 'svelte'
|
||||
import { onDestroy, untrack } from 'svelte'
|
||||
import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte'
|
||||
import Select from '../select/Select.svelte'
|
||||
import { usePromise } from '$lib/svelte5Utils.svelte'
|
||||
import { safeSelectItems } from '../select/utils.svelte'
|
||||
import { CancelablePromiseUtils } from '$lib/cancelable-promise-utils'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
|
||||
let usernames: string[] | undefined = $state()
|
||||
let resources = usePromise(() => loadResources($workspaceStore!), { loadInit: false })
|
||||
let loading: boolean = $state(false)
|
||||
let page: number | undefined = undefined
|
||||
|
||||
interface Props {
|
||||
logs?: AuditLog[]
|
||||
@@ -55,6 +56,7 @@
|
||||
resource?: string | undefined
|
||||
actionKind?: ActionKind | 'all'
|
||||
scope?: undefined | 'all_workspaces' | 'instance'
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -68,7 +70,8 @@
|
||||
operation = $bindable(),
|
||||
resource = $bindable() as string | undefined,
|
||||
actionKind = $bindable(undefined),
|
||||
scope = $bindable(undefined)
|
||||
scope = $bindable(undefined),
|
||||
loading = $bindable(false)
|
||||
}: Props = $props()
|
||||
|
||||
$effect.pre(() => {
|
||||
@@ -86,50 +89,45 @@
|
||||
}
|
||||
})
|
||||
|
||||
async function loadLogs(
|
||||
username: string | undefined,
|
||||
page: number | undefined,
|
||||
perPage: number | undefined,
|
||||
before: string | undefined,
|
||||
after: string | undefined,
|
||||
operation: string | undefined,
|
||||
resource: string | undefined,
|
||||
actionKind: ActionKind | undefined | 'all',
|
||||
scope: undefined | 'all_workspaces' | 'instance'
|
||||
): Promise<void> {
|
||||
function loadLogs() {
|
||||
loading = true
|
||||
|
||||
if (username == 'all') {
|
||||
username = undefined
|
||||
}
|
||||
if (operation == 'all' || operation == '') {
|
||||
operation = undefined
|
||||
}
|
||||
let username_ = username == 'all' ? undefined : username
|
||||
let operation_ = operation == 'all' || operation == '' ? undefined : operation
|
||||
let actionKind_ = actionKind == 'all' ? undefined : actionKind
|
||||
let resource_ = resource == 'all' || resource == '' ? undefined : resource
|
||||
|
||||
// @ts-ignore
|
||||
if (actionKind == 'all' || actionKind == '') {
|
||||
actionKind = undefined
|
||||
}
|
||||
|
||||
if (resource == 'all' || resource == '') {
|
||||
resource = undefined
|
||||
}
|
||||
|
||||
logs = await AuditService.listAuditLogs({
|
||||
let _promise = AuditService.listAuditLogs({
|
||||
workspace: scope === 'instance' ? 'global' : $workspaceStore!,
|
||||
page,
|
||||
page: pageIndex,
|
||||
perPage,
|
||||
before,
|
||||
after,
|
||||
username,
|
||||
operation,
|
||||
resource,
|
||||
actionKind,
|
||||
username: username_,
|
||||
operation: operation_,
|
||||
resource: resource_,
|
||||
actionKind: actionKind_,
|
||||
allWorkspaces: scope === 'all_workspaces'
|
||||
})
|
||||
hasMore = logs.length > 0 && logs.length === perPage
|
||||
|
||||
loading = false
|
||||
let promise = CancelablePromiseUtils.map(_promise, (value) => {
|
||||
logs = value
|
||||
hasMore = !logs || (logs.length > 0 && logs.length === perPage)
|
||||
loading = false
|
||||
})
|
||||
promise = CancelablePromiseUtils.onTimeout(promise, 4000, () => {
|
||||
sendUserToast(
|
||||
'Loading audit logs is taking longer than expected...',
|
||||
true,
|
||||
perPage > 25
|
||||
? [{ label: 'Reduce to 25 items per page', callback: () => (perPage = 25) }]
|
||||
: []
|
||||
)
|
||||
})
|
||||
promise = CancelablePromiseUtils.catchErr(promise, (e) => {
|
||||
if (e instanceof CancelError) return CancelablePromiseUtils.pure<void>(undefined)
|
||||
return CancelablePromiseUtils.err<void>(e)
|
||||
})
|
||||
return promise
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
@@ -139,17 +137,7 @@
|
||||
: [$userStore?.username ?? '']
|
||||
}
|
||||
|
||||
let initialLoad = true
|
||||
function refreshLogs() {
|
||||
loadUsers()
|
||||
resources.refresh()
|
||||
loadLogs(username, page, perPage, before, after, operation, resource, actionKind, scope)
|
||||
tick().then(() => {
|
||||
initialLoad = false
|
||||
})
|
||||
}
|
||||
|
||||
function updateLogs() {
|
||||
function updateQueryParams() {
|
||||
const queryParams: string[] = []
|
||||
|
||||
function addQueryParam(key: string, value: string | number | undefined | null) {
|
||||
@@ -159,7 +147,7 @@
|
||||
}
|
||||
|
||||
addQueryParam('username', username)
|
||||
addQueryParam('page', page)
|
||||
addQueryParam('page', pageIndex)
|
||||
addQueryParam('perPage', perPage)
|
||||
addQueryParam('before', before)
|
||||
addQueryParam('after', after)
|
||||
@@ -172,25 +160,6 @@
|
||||
}
|
||||
const query = '?' + queryParams.join('&')
|
||||
goto(query, { replaceState: true, keepFocus: true })
|
||||
|
||||
loadLogs(username, page, perPage, before, after, operation, resource, actionKind, scope)
|
||||
}
|
||||
|
||||
function updateQueryParams() {
|
||||
if (initialLoad) {
|
||||
return
|
||||
}
|
||||
page = 1
|
||||
pageIndex = 1
|
||||
updateLogs()
|
||||
}
|
||||
|
||||
function updatePageQueryParams(pageIndex?: number | undefined) {
|
||||
if (initialLoad) {
|
||||
return
|
||||
}
|
||||
page = pageIndex
|
||||
updateLogs()
|
||||
}
|
||||
|
||||
window.addEventListener('popstate', handlePopState)
|
||||
@@ -307,18 +276,22 @@
|
||||
WORKSPACES_DELETE: 'workspaces.delete'
|
||||
}
|
||||
|
||||
let refresh = $state(1)
|
||||
$effect(() => {
|
||||
$workspaceStore && refresh && untrack(() => refreshLogs())
|
||||
})
|
||||
let refresh = $state(0)
|
||||
let lastRefresh = $state(-1)
|
||||
|
||||
// observe all the variables that should trigger an update
|
||||
$effect(() => {
|
||||
;[username, perPage, before, after, operation, resource, actionKind, scope]
|
||||
updateQueryParams()
|
||||
})
|
||||
// observe the pageIndex variable that should trigger an update
|
||||
$effect(() => {
|
||||
updatePageQueryParams(pageIndex)
|
||||
;[refresh, username, perPage, before, after, operation, resource, actionKind, scope, pageIndex]
|
||||
return untrack(() => {
|
||||
if (refresh !== lastRefresh) {
|
||||
loadUsers()
|
||||
resources.refresh()
|
||||
lastRefresh = refresh
|
||||
}
|
||||
updateQueryParams()
|
||||
let promise = loadLogs()
|
||||
return () => promise?.cancel()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -390,25 +363,30 @@
|
||||
|
||||
<div class="flex gap-1 relative w-full">
|
||||
<span class="text-xs absolute font-semibold text-emphasis -top-4">Username</span>
|
||||
<select bind:value={username}>
|
||||
{#if usernames}
|
||||
{#if $userStore?.is_admin || $userStore?.is_super_admin}
|
||||
<option selected>all</option>
|
||||
{/if}
|
||||
{#each usernames as e}
|
||||
{#if e == username || $userStore?.is_admin || $userStore?.is_super_admin}
|
||||
<option>{e}</option>
|
||||
{:else}
|
||||
<option disabled>{e}</option>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</select>
|
||||
<Select
|
||||
bind:value={username}
|
||||
class="w-full"
|
||||
disablePortal
|
||||
RightIcon={ChevronDown}
|
||||
items={usernames
|
||||
? [
|
||||
...($userStore?.is_admin || $userStore?.is_super_admin
|
||||
? [{ value: 'all', label: 'all' }]
|
||||
: []),
|
||||
...usernames.map((e) => ({
|
||||
value: e,
|
||||
label: e,
|
||||
disabled: e !== username && !$userStore?.is_admin && !$userStore?.is_super_admin
|
||||
}))
|
||||
]
|
||||
: []}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-1 relative w-full">
|
||||
<span class="text-xs absolute font-semibold text-emphasis -top-4">Resource</span>
|
||||
|
||||
<Select
|
||||
disablePortal
|
||||
onCreateItem={(r) => (resources.value?.push(r), (resource = r))}
|
||||
createText="Press enter to use this value"
|
||||
bind:value={resource}
|
||||
@@ -423,6 +401,7 @@
|
||||
<span class="text-xs absolute font-semibold text-emphasis -top-4">Operation</span>
|
||||
|
||||
<Select
|
||||
disablePortal
|
||||
bind:value={operation}
|
||||
items={['all', ...Object.values(operations)].map((r) => ({ value: r, label: r }))}
|
||||
inputClass="dark:!bg-gray-700"
|
||||
@@ -434,12 +413,19 @@
|
||||
<div class="flex gap-1 relative w-full">
|
||||
<span class="text-xs absolute font-semibold text-emphasis -top-4">Action</span>
|
||||
|
||||
<select class="!truncate" bind:value={actionKind}>
|
||||
<option selected value="all">all</option>
|
||||
{#each ['Create', 'Update', 'Delete', 'Execute'] as e}
|
||||
<option value={e.toLocaleLowerCase()}>{e}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<Select
|
||||
class="w-full"
|
||||
bind:value={actionKind}
|
||||
disablePortal
|
||||
RightIcon={ChevronDown}
|
||||
items={[
|
||||
{ value: 'all', label: 'all' },
|
||||
{ value: 'create', label: 'Create' },
|
||||
{ value: 'update', label: 'Update' },
|
||||
{ value: 'delete', label: 'Delete' },
|
||||
{ value: 'execute', label: 'Execute' }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row gap-1">
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { displayDate } from '$lib/utils'
|
||||
import { onMount, tick } from 'svelte'
|
||||
import Button from '../common/button/Button.svelte'
|
||||
import { ChevronLeft, ChevronRight, ListFilterPlus } from 'lucide-svelte'
|
||||
import { ChevronLeft, ChevronRight, ListFilterPlus, Loader2 } from 'lucide-svelte'
|
||||
import VirtualList from '@tutorlatin/svelte-tiny-virtual-list'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
usernameFilter?: string | undefined
|
||||
resourceFilter?: string | undefined
|
||||
showWorkspace?: boolean
|
||||
loading?: boolean
|
||||
onselect?: (id: number) => void
|
||||
}
|
||||
|
||||
@@ -33,7 +34,8 @@
|
||||
usernameFilter = $bindable(),
|
||||
resourceFilter = $bindable(),
|
||||
showWorkspace = false,
|
||||
onselect
|
||||
onselect,
|
||||
loading
|
||||
}: Props = $props()
|
||||
|
||||
function groupLogsByDay(logs: AuditLog[]): Record<string, AuditLog[]> {
|
||||
@@ -120,6 +122,7 @@
|
||||
}
|
||||
return 'gray'
|
||||
}
|
||||
let height = $derived(tableHeight - headerHeight - footerHeight)
|
||||
</script>
|
||||
|
||||
<svelte:window onresize={() => computeHeight()} />
|
||||
@@ -139,12 +142,22 @@
|
||||
<div class="w-2/12">Resource</div>
|
||||
</div>
|
||||
</div>
|
||||
{#if logs?.length == 0}
|
||||
<div class="text-xs text-secondary p-8"> No logs found for the selected filters. </div>
|
||||
|
||||
{#if loading}
|
||||
<div style="height: {height}px;" class="flex justify-center items-center">
|
||||
<Loader2 class="animate-spin" />
|
||||
</div>
|
||||
{:else if !logs?.length}
|
||||
<div
|
||||
class="text-xs text-secondary p-8 flex justify-center items-center"
|
||||
style="height: {height}px;"
|
||||
>
|
||||
No logs found for the selected filters.
|
||||
</div>
|
||||
{:else}
|
||||
<VirtualList
|
||||
width="100%"
|
||||
height={tableHeight - headerHeight - footerHeight}
|
||||
{height}
|
||||
itemCount={flatLogs?.length ?? 0}
|
||||
itemSize={(index) => {
|
||||
if (flatLogs?.[index]?.type === 'date') {
|
||||
|
||||
@@ -550,7 +550,7 @@
|
||||
)
|
||||
</script>
|
||||
|
||||
<div class="p-4 bg-surface mb-4 h-full">
|
||||
<div class="p-4 bg-surface mb-4 h-full text-xs">
|
||||
{#if logs.length === 0}
|
||||
<div class="text-center py-8 text-secondary"> No audit logs to display </div>
|
||||
{:else if !groupedData || groupedData.status === 'loading'}
|
||||
|
||||
@@ -24,8 +24,9 @@
|
||||
iconOnly
|
||||
unifiedSize="sm"
|
||||
color="light"
|
||||
wrapperClasses="shrink-0"
|
||||
btnClasses={twMerge(
|
||||
'hover:bg-surface-hover rounded-full p-0',
|
||||
'hover:bg-surface-hover rounded-full p-0 !min-h-0',
|
||||
noBg ? '' : 'bg-surface-secondary',
|
||||
small ? 'w-6 h-6' : 'w-8 h-8',
|
||||
className ?? ''
|
||||
|
||||
@@ -99,7 +99,10 @@
|
||||
on:click={() => handleConnect(true)}
|
||||
{id}
|
||||
wrapperClasses={twMerge(btnWrapperClasses, selected ? 'opacity-100' : '')}
|
||||
btnClasses={small ? 'p-0' : ''}
|
||||
btnClasses={twMerge(
|
||||
small ? 'p-0' : '',
|
||||
isOpen && selected ? 'bg-surface hover:bg-surface' : ''
|
||||
)}
|
||||
>
|
||||
<Plug size={14} />
|
||||
</Button>
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
localModuleStates?: Record<string, GraphModuleState>
|
||||
testModuleStates?: ModulesTestStates
|
||||
isOwner?: boolean
|
||||
onTestFlow?: () => Promise<string | undefined>
|
||||
onTestFlow?: (conversationId?: string) => Promise<string | undefined>
|
||||
isRunning?: boolean
|
||||
onCancelTestFlow?: () => void
|
||||
onOpenPreview?: () => void
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
onDeployTrigger?: (trigger: Trigger) => void
|
||||
forceTestTab?: Record<string, boolean>
|
||||
highlightArg?: Record<string, string | undefined>
|
||||
onTestFlow?: () => Promise<string | undefined>
|
||||
onTestFlow?: (conversationId?: string) => Promise<string | undefined>
|
||||
job?: Job
|
||||
isOwner?: boolean
|
||||
suspendStatus?: StateStore<Record<string, { job: Job; nb: number }>>
|
||||
|
||||
@@ -44,17 +44,16 @@
|
||||
import { refreshStateStore } from '$lib/svelte5Utils.svelte'
|
||||
import type { ScriptLang } from '$lib/gen'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import FlowChatInterface from '$lib/components/flows/conversations/FlowChatInterface.svelte'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import { AI_AGENT_SCHEMA } from '../flowInfers'
|
||||
import { nextId } from '../flowModuleNextId'
|
||||
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
|
||||
import { randomUUID } from '../conversations/FlowChatManager.svelte'
|
||||
import FlowChat from '../conversations/FlowChat.svelte'
|
||||
|
||||
interface Props {
|
||||
noEditor: boolean
|
||||
disabled: boolean
|
||||
onTestFlow?: () => Promise<string | undefined>
|
||||
onTestFlow?: (conversationId?: string) => Promise<string | undefined>
|
||||
previewOpen: boolean
|
||||
}
|
||||
|
||||
@@ -70,6 +69,15 @@
|
||||
} = getContext<FlowEditorContext>('FlowEditorContext')
|
||||
|
||||
let chatInputEnabled = $derived(Boolean(flowStore.val.value?.chat_input_enabled))
|
||||
let shouldUseStreaming = $derived.by(() => {
|
||||
const modules = flowStore.val.value?.modules
|
||||
const lastModule = modules && modules.length > 0 ? modules[modules.length - 1] : undefined
|
||||
return (
|
||||
lastModule?.value?.type === 'aiagent' &&
|
||||
lastModule?.value?.input_transforms?.streaming?.type === 'static' &&
|
||||
lastModule?.value?.input_transforms?.streaming?.value === true
|
||||
)
|
||||
})
|
||||
let showChatModeWarning = $state(false)
|
||||
|
||||
let addPropertyV2: AddPropertyV2 | undefined = $state(undefined)
|
||||
@@ -218,7 +226,7 @@
|
||||
}
|
||||
|
||||
async function runPreview() {
|
||||
await onTestFlow?.()
|
||||
await onTestFlow?.(undefined)
|
||||
}
|
||||
|
||||
function updatePreviewSchemaAndArgs(payload: any) {
|
||||
@@ -371,9 +379,12 @@
|
||||
firstStepInputs?.resetSelected(true)
|
||||
}
|
||||
|
||||
async function runFlowWithMessage(message: string): Promise<string | undefined> {
|
||||
async function runFlowWithMessage(
|
||||
message: string,
|
||||
conversationId: string
|
||||
): Promise<string | undefined> {
|
||||
previewArgs.val = { user_message: message }
|
||||
const jobId = await onTestFlow?.()
|
||||
const jobId = await onTestFlow?.(conversationId)
|
||||
return jobId
|
||||
}
|
||||
|
||||
@@ -488,13 +499,12 @@
|
||||
{#if !disabled}
|
||||
<div class="flex flex-col h-full">
|
||||
{#if flowStore.val.value?.chat_input_enabled}
|
||||
<div class="flex-1 min-h-0">
|
||||
<FlowChatInterface
|
||||
<div class="flex flex-col h-full">
|
||||
<FlowChat
|
||||
onRunFlow={runFlowWithMessage}
|
||||
createConversation={async () => {
|
||||
const newConversationId = randomUUID()
|
||||
return newConversationId
|
||||
}}
|
||||
path={$pathStore}
|
||||
hideSidebar={true}
|
||||
useStreaming={shouldUseStreaming}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
import type { SupportedLanguage } from '$lib/common'
|
||||
import DefaultScripts from '$lib/components/DefaultScripts.svelte'
|
||||
import type { FlowBuilderWhitelabelCustomUi } from '$lib/components/custom_ui'
|
||||
import { canHavePreprocessor } from '$lib/script_helpers'
|
||||
|
||||
interface Props {
|
||||
failureModule: boolean
|
||||
@@ -57,18 +58,51 @@
|
||||
)
|
||||
|
||||
function displayLang(lang: SupportedLanguage | 'docker', kind: string) {
|
||||
if (lang == 'bun' || lang == 'python3' || lang == 'deno') {
|
||||
if (preprocessorModule) {
|
||||
return canHavePreprocessor(lang as SupportedLanguage)
|
||||
}
|
||||
|
||||
if (failureModule || kind === 'trigger') {
|
||||
if (
|
||||
[
|
||||
'postgresql',
|
||||
'mysql',
|
||||
'bigquery',
|
||||
'snowflake',
|
||||
'mssql',
|
||||
'graphql',
|
||||
'duckdb',
|
||||
'oracledb'
|
||||
].includes(lang)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if (lang == 'go') {
|
||||
return (kind == 'script' || kind == 'trigger' || failureModule) && !preprocessorModule
|
||||
if (kind === 'script') {
|
||||
return lang !== 'docker'
|
||||
}
|
||||
|
||||
if (lang == 'bash' || lang == 'nativets') {
|
||||
return kind == 'script' && !preprocessorModule
|
||||
if (kind === 'approval') {
|
||||
if (
|
||||
[
|
||||
'postgresql',
|
||||
'mysql',
|
||||
'bigquery',
|
||||
'snowflake',
|
||||
'mssql',
|
||||
'graphql',
|
||||
'duckdb',
|
||||
'oracledb'
|
||||
].includes(lang)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
return kind == 'script' && !failureModule && !preprocessorModule
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
let customUi: undefined | FlowBuilderWhitelabelCustomUi = getContext('customUi')
|
||||
|
||||
@@ -30,6 +30,12 @@
|
||||
import GenAiQuick from './GenAiQuick.svelte'
|
||||
import FlowToplevelNode from '../pickers/FlowToplevelNode.svelte'
|
||||
import { copilotInfo } from '$lib/aiStore'
|
||||
import {
|
||||
canHavePreprocessor,
|
||||
canHaveTrigger,
|
||||
canHaveApproval,
|
||||
canHaveFailure
|
||||
} from '$lib/script_helpers'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
@@ -94,17 +100,17 @@
|
||||
kind: 'script' | 'flow' | 'approval' | 'trigger' | 'preprocessor' | 'failure'
|
||||
) {
|
||||
if (kind == 'trigger') {
|
||||
return ['python3', 'bun', 'deno', 'go'].includes(lang)
|
||||
return canHaveTrigger(lang as SupportedLanguage)
|
||||
} else if (kind == 'script') {
|
||||
return true
|
||||
} else if (kind == 'approval') {
|
||||
return ['python3', 'bun', 'deno'].includes(lang)
|
||||
return canHaveApproval(lang as SupportedLanguage)
|
||||
} else if (kind == 'flow') {
|
||||
return false
|
||||
} else if (kind == 'preprocessor') {
|
||||
return ['python3', 'bun', 'deno'].includes(lang)
|
||||
return canHavePreprocessor(lang as SupportedLanguage)
|
||||
} else if (kind == 'failure') {
|
||||
return ['python3', 'bun', 'deno', 'go'].includes(lang)
|
||||
return canHaveFailure(lang as SupportedLanguage)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { createFlowChatManager } from './FlowChatManager.svelte'
|
||||
import FlowConversationsSidebar from './FlowConversationsSidebar.svelte'
|
||||
import FlowChatInterface from './FlowChatInterface.svelte'
|
||||
import { untrack } from 'svelte'
|
||||
|
||||
interface Props {
|
||||
onRunFlow: (userMessage: string, conversationId: string) => Promise<string | undefined>
|
||||
useStreaming?: boolean
|
||||
deploymentInProgress?: boolean
|
||||
path: string
|
||||
hideSidebar?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
onRunFlow,
|
||||
deploymentInProgress = false,
|
||||
useStreaming = false,
|
||||
path,
|
||||
hideSidebar = false
|
||||
}: Props = $props()
|
||||
|
||||
const manager = createFlowChatManager()
|
||||
|
||||
// Initialize manager when component mounts
|
||||
$effect(() => {
|
||||
if ($workspaceStore) {
|
||||
manager.initialize(onRunFlow, path, useStreaming)
|
||||
}
|
||||
|
||||
return () => {
|
||||
manager.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
// Initialize InfiniteList when component mounts or flowPath changes
|
||||
$effect(() => {
|
||||
if ($workspaceStore && path && manager.conversationListComponent) {
|
||||
untrack(() => {
|
||||
manager.setupInfiniteList()
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="flex border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden flex-1">
|
||||
{#if !hideSidebar}
|
||||
<FlowConversationsSidebar {manager} />
|
||||
{/if}
|
||||
<FlowChatInterface {manager} {deploymentInProgress} />
|
||||
</div>
|
||||
@@ -1,75 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { Button, Alert } from '$lib/components/common'
|
||||
import { MessageCircle, Loader2, ArrowUp, Square } from 'lucide-svelte'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import autosize from '$lib/autosize'
|
||||
import FlowChatMessage from './FlowChatMessage.svelte'
|
||||
import { createFlowChatManager } from './FlowChatManager.svelte'
|
||||
import { FlowChatManager } from './FlowChatManager.svelte'
|
||||
|
||||
interface Props {
|
||||
onRunFlow: (userMessage: string, conversationId: string) => Promise<string | undefined>
|
||||
useStreaming?: boolean
|
||||
refreshConversations?: () => Promise<void>
|
||||
conversationId?: string
|
||||
manager: FlowChatManager
|
||||
deploymentInProgress?: boolean
|
||||
createConversation: (options: { clearMessages?: boolean }) => Promise<string>
|
||||
path?: string
|
||||
}
|
||||
|
||||
let {
|
||||
onRunFlow,
|
||||
conversationId,
|
||||
refreshConversations,
|
||||
deploymentInProgress = false,
|
||||
createConversation,
|
||||
useStreaming = false,
|
||||
path
|
||||
}: Props = $props()
|
||||
|
||||
const manager = createFlowChatManager()
|
||||
|
||||
// Initialize manager when component mounts
|
||||
$effect(() => {
|
||||
if ($workspaceStore) {
|
||||
manager.initialize(
|
||||
{
|
||||
onRunFlow,
|
||||
createConversation,
|
||||
refreshConversations,
|
||||
conversationId,
|
||||
useStreaming,
|
||||
path
|
||||
},
|
||||
$workspaceStore
|
||||
)
|
||||
}
|
||||
|
||||
return () => {
|
||||
manager.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
// Update conversation ID when it changes
|
||||
$effect(() => {
|
||||
manager.updateConversationId(conversationId)
|
||||
})
|
||||
|
||||
// Public API for parent components
|
||||
export function fillInputMessage(message: string) {
|
||||
manager.fillInputMessage(message)
|
||||
}
|
||||
|
||||
export function focusInput() {
|
||||
manager.focusInput()
|
||||
}
|
||||
|
||||
export function clearMessages() {
|
||||
manager.clearMessages()
|
||||
}
|
||||
|
||||
export async function loadConversationMessages(conversationId?: string) {
|
||||
await manager.loadConversationMessages(conversationId)
|
||||
}
|
||||
let { manager, deploymentInProgress = false }: Props = $props()
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full flex-1 min-w-0">
|
||||
@@ -93,7 +34,7 @@
|
||||
<p class="text-sm">Send a message to run the flow and see the results</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="w-full xl:max-w-7xl mx-auto space-y-4">
|
||||
<div class="w-full space-y-4 xl:max-w-7xl mx-auto">
|
||||
{#each manager.messages as message (message.id)}
|
||||
<FlowChatMessage {message} />
|
||||
{/each}
|
||||
@@ -108,9 +49,9 @@
|
||||
</div>
|
||||
|
||||
<!-- Chat Input -->
|
||||
<div class="p-2 bg-surface">
|
||||
<div class="flex flex-row justify-center py-2 xl:max-w-7xl mx-auto w-full">
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-lg border border-gray-200 dark:border-gray-600 bg-surface"
|
||||
class="flex items-center gap-2 rounded-lg border border-gray-200 dark:border-gray-600 bg-surface-input w-full"
|
||||
class:opacity-50={deploymentInProgress}
|
||||
>
|
||||
<textarea
|
||||
@@ -119,10 +60,10 @@
|
||||
use:autosize
|
||||
onkeydown={manager.handleKeyDown}
|
||||
placeholder="Type your message here..."
|
||||
class="flex-1 min-h-[24px] max-h-32 resize-none !border-0 !bg-transparent text-sm placeholder-gray-400 !outline-none !ring-0 p-0 !shadow-none focus:!border-0 focus:!outline-none focus:!ring-0 focus:!shadow-none"
|
||||
class="flex-1 min-h-[24px] max-h-32 resize-none !border-0 text-sm placeholder-gray-400 !outline-none !ring-0 p-0 !shadow-none focus:!border-0 focus:!outline-none focus:!ring-0 focus:!shadow-none"
|
||||
rows={3}
|
||||
></textarea>
|
||||
<div class="flex-shrink-0 pr-2">
|
||||
<div class="flex-shrink-0 pr-2 bg-surface-input">
|
||||
{#if manager.isWaitingForResponse || manager.isLoading}
|
||||
<Button
|
||||
color="red"
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
import type { FlowConversationMessage } from '$lib/gen/types.gen'
|
||||
import type { FlowConversation, FlowConversationMessage } from '$lib/gen/types.gen'
|
||||
import { FlowConversationService, JobService } from '$lib/gen'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { waitJob } from '$lib/components/waitJob'
|
||||
import { tick } from 'svelte'
|
||||
import InfiniteList from '$lib/components/InfiniteList.svelte'
|
||||
import { workspaceStore, userStore } from '$lib/stores'
|
||||
import { get } from 'svelte/store'
|
||||
|
||||
export interface ChatMessage extends FlowConversationMessage {
|
||||
loading?: boolean
|
||||
streaming?: boolean
|
||||
}
|
||||
|
||||
export interface FlowChatManagerOptions {
|
||||
onRunFlow: (userMessage: string, conversationId: string) => Promise<string | undefined>
|
||||
createConversation: (options: { clearMessages?: boolean }) => Promise<string>
|
||||
refreshConversations?: () => Promise<void>
|
||||
conversationId?: string
|
||||
useStreaming?: boolean
|
||||
path?: string
|
||||
export interface ConversationWithDraft extends FlowConversation {
|
||||
isDraft?: boolean
|
||||
}
|
||||
|
||||
export function randomUUID() {
|
||||
@@ -27,7 +25,7 @@ export function randomUUID() {
|
||||
})
|
||||
}
|
||||
|
||||
class FlowChatManager {
|
||||
export class FlowChatManager {
|
||||
// State
|
||||
messages = $state<ChatMessage[]>([])
|
||||
inputMessage = $state('')
|
||||
@@ -42,33 +40,34 @@ class FlowChatManager {
|
||||
currentEventSource = $state<EventSource | undefined>(undefined)
|
||||
pollingInterval = $state<ReturnType<typeof setInterval> | undefined>(undefined)
|
||||
currentJobId = $state<string | undefined>(undefined)
|
||||
conversations = $state<ConversationWithDraft[]>([])
|
||||
deletingConversationId = $state<string | undefined>(undefined)
|
||||
isSidebarExpanded = $state(false)
|
||||
selectedConversationId = $state<string | undefined>(undefined)
|
||||
conversationListComponent = $state<InfiniteList | undefined>(undefined)
|
||||
|
||||
// Private state
|
||||
#conversationsCache = $state<Record<string, ChatMessage[]>>({})
|
||||
#scrollTimeout: ReturnType<typeof setTimeout> | undefined = undefined
|
||||
#perPage = 50
|
||||
#workspace = $state<string | undefined>(undefined)
|
||||
|
||||
// Options
|
||||
#onRunFlow?: FlowChatManagerOptions['onRunFlow']
|
||||
#createConversation?: FlowChatManagerOptions['createConversation']
|
||||
#refreshConversations?: FlowChatManagerOptions['refreshConversations']
|
||||
#conversationId = $state<string | undefined>(undefined)
|
||||
#onRunFlow?: (userMessage: string, conversationId: string) => Promise<string | undefined>
|
||||
#useStreaming = $state(false)
|
||||
#path = $state<string | undefined>(undefined)
|
||||
|
||||
initialize(options: FlowChatManagerOptions, workspace: string) {
|
||||
this.#onRunFlow = options.onRunFlow
|
||||
this.#createConversation = options.createConversation
|
||||
this.#refreshConversations = options.refreshConversations
|
||||
this.#conversationId = options.conversationId
|
||||
this.#useStreaming = options.useStreaming ?? false
|
||||
this.#path = options.path
|
||||
this.#workspace = workspace
|
||||
initialize(
|
||||
onRunFlow: (userMessage: string, conversationId: string) => Promise<string | undefined>,
|
||||
path: string,
|
||||
useStreaming: boolean = false
|
||||
) {
|
||||
this.#onRunFlow = onRunFlow
|
||||
this.#path = path
|
||||
this.#useStreaming = useStreaming
|
||||
}
|
||||
|
||||
updateConversationId(conversationId: string | undefined) {
|
||||
this.#conversationId = conversationId
|
||||
this.selectedConversationId = conversationId
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
@@ -97,15 +96,95 @@ class FlowChatManager {
|
||||
this.page = 1
|
||||
}
|
||||
|
||||
async createConversation({ clearMessages = true }: { clearMessages?: boolean }) {
|
||||
// Check if there's already a draft conversation
|
||||
const existingDraft = this.conversations.find((c) => c.isDraft)
|
||||
if (existingDraft) {
|
||||
// Select the existing draft instead of creating a new one
|
||||
this.selectedConversationId = existingDraft.id
|
||||
this.clearMessages()
|
||||
return existingDraft.id
|
||||
}
|
||||
const newConversationId = randomUUID()
|
||||
this.selectedConversationId = newConversationId
|
||||
|
||||
// Create a new conversation object and add it to the top of the list
|
||||
const newConversation: ConversationWithDraft = {
|
||||
id: newConversationId,
|
||||
workspace_id: get(workspaceStore)!,
|
||||
flow_path: this.#path!,
|
||||
title: 'New chat',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
created_by: get(userStore)!.username!,
|
||||
isDraft: true
|
||||
}
|
||||
|
||||
// Prepend to conversations list
|
||||
this.conversations = [newConversation, ...this.conversations]
|
||||
// Clear messages in the chat interface
|
||||
if (clearMessages) {
|
||||
this.clearMessages()
|
||||
}
|
||||
this.focusInput()
|
||||
|
||||
return newConversationId
|
||||
}
|
||||
|
||||
setupInfiniteList() {
|
||||
this.conversationListComponent?.setLoader((page, perPage) =>
|
||||
this.loadConversations(page, perPage)
|
||||
)
|
||||
this.conversationListComponent?.setDeleteItemFn((id) => this.deleteConversation(id))
|
||||
}
|
||||
|
||||
async selectConversation(conversationId: string, isDraft?: boolean) {
|
||||
this.selectedConversationId = conversationId
|
||||
// Load conversation messages into chat interface
|
||||
if (isDraft) {
|
||||
// For draft conversations, just clear messages (don't try to load from backend)
|
||||
this.clearMessages()
|
||||
} else {
|
||||
// For persisted conversations, load messages from backend
|
||||
await this.loadConversationMessages(conversationId)
|
||||
}
|
||||
}
|
||||
|
||||
async refreshConversations() {
|
||||
await this.conversationListComponent?.loadData('forceRefresh')
|
||||
}
|
||||
|
||||
// Only used by InfiniteList
|
||||
private async deleteConversation(conversationId: string) {
|
||||
try {
|
||||
this.deletingConversationId = conversationId
|
||||
await FlowConversationService.deleteFlowConversation({
|
||||
workspace: get(workspaceStore)!,
|
||||
conversationId
|
||||
})
|
||||
if (this.selectedConversationId === conversationId) {
|
||||
this.selectedConversationId = undefined
|
||||
this.clearMessages()
|
||||
}
|
||||
sendUserToast('Conversation deleted successfully')
|
||||
} catch (error) {
|
||||
console.error('Failed to delete conversation:', error)
|
||||
sendUserToast('Failed to delete conversation', true)
|
||||
throw error
|
||||
} finally {
|
||||
this.deletingConversationId = undefined
|
||||
}
|
||||
}
|
||||
|
||||
async cancelCurrentJob() {
|
||||
if (!this.#workspace) {
|
||||
if (!get(workspaceStore)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (this.currentJobId) {
|
||||
await JobService.cancelQueuedJob({
|
||||
workspace: this.#workspace,
|
||||
workspace: get(workspaceStore)!,
|
||||
id: this.currentJobId,
|
||||
requestBody: {}
|
||||
})
|
||||
@@ -124,10 +203,30 @@ class FlowChatManager {
|
||||
await this.loadMessages(true, conversationId)
|
||||
}
|
||||
|
||||
// Only used by InfiniteList
|
||||
private async loadConversations(page: number, perPage: number) {
|
||||
if (!get(workspaceStore) || !this.#path) return []
|
||||
|
||||
try {
|
||||
const response = await FlowConversationService.listFlowConversations({
|
||||
workspace: get(workspaceStore)!,
|
||||
flowPath: this.#path,
|
||||
page: page,
|
||||
perPage: perPage
|
||||
})
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('Failed to load conversations:', error)
|
||||
sendUserToast('Failed to load conversations', true)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// Message loading
|
||||
private async loadMessages(reset: boolean, conversationId?: string) {
|
||||
let conversationIdToUse = conversationId ?? this.#conversationId
|
||||
if (!this.#workspace || !conversationIdToUse) return
|
||||
let conversationIdToUse = conversationId ?? this.selectedConversationId
|
||||
if (!get(workspaceStore) || !conversationIdToUse) return
|
||||
|
||||
if (reset) {
|
||||
if (this.#conversationsCache[conversationIdToUse]) {
|
||||
@@ -145,7 +244,7 @@ class FlowChatManager {
|
||||
const previousScrollHeight = this.messagesContainer?.scrollHeight || 0
|
||||
|
||||
const response = await FlowConversationService.listConversationMessages({
|
||||
workspace: this.#workspace,
|
||||
workspace: get(workspaceStore)!,
|
||||
conversationId: conversationIdToUse,
|
||||
page: pageToFetch,
|
||||
perPage: this.#perPage
|
||||
@@ -213,8 +312,8 @@ class FlowChatManager {
|
||||
} finally {
|
||||
// Do a final poll to get all messages from database
|
||||
try {
|
||||
if (this.#conversationId) {
|
||||
await this.pollConversationMessages(this.#conversationId)
|
||||
if (this.selectedConversationId) {
|
||||
await this.pollConversationMessages(this.selectedConversationId)
|
||||
}
|
||||
} catch {}
|
||||
this.cleanup()
|
||||
@@ -252,12 +351,12 @@ class FlowChatManager {
|
||||
}
|
||||
|
||||
private async pollConversationMessages(conversationId: string, isNewConversation?: boolean) {
|
||||
if (!this.#workspace) return
|
||||
if (!get(workspaceStore)) return
|
||||
|
||||
try {
|
||||
const lastId = this.messages[this.messages.length - 1].id
|
||||
const response = await FlowConversationService.listConversationMessages({
|
||||
workspace: this.#workspace,
|
||||
workspace: get(workspaceStore)!,
|
||||
conversationId: conversationId,
|
||||
page: 1,
|
||||
perPage: 50,
|
||||
@@ -265,7 +364,7 @@ class FlowChatManager {
|
||||
})
|
||||
|
||||
if (isNewConversation) {
|
||||
await this.#refreshConversations?.()
|
||||
await this.refreshConversations()
|
||||
}
|
||||
|
||||
const filteredResponse = response.filter((msg) => msg.message_type !== 'user')
|
||||
@@ -314,9 +413,9 @@ class FlowChatManager {
|
||||
this.stopPolling()
|
||||
|
||||
// Generate a new conversation ID if we don't have one
|
||||
let currentConversationId = this.#conversationId
|
||||
if (!this.#conversationId && this.#createConversation) {
|
||||
const newConversationId = await this.#createConversation({ clearMessages: false })
|
||||
let currentConversationId = this.selectedConversationId
|
||||
if (!this.selectedConversationId) {
|
||||
const newConversationId = await this.createConversation({ clearMessages: false })
|
||||
currentConversationId = newConversationId
|
||||
}
|
||||
|
||||
@@ -380,16 +479,14 @@ class FlowChatManager {
|
||||
let isCompleted = false
|
||||
|
||||
try {
|
||||
const jobId = await JobService.runFlowByPath({
|
||||
workspace: this.#workspace!,
|
||||
path: this.#path!,
|
||||
requestBody: { user_message: messageContent },
|
||||
memoryId: currentConversationId
|
||||
})
|
||||
// Encode the payload as base64
|
||||
const jobId = await this.#onRunFlow?.(messageContent, currentConversationId)
|
||||
if (!jobId) {
|
||||
console.error('No jobId returned from onRunFlow')
|
||||
return
|
||||
}
|
||||
|
||||
// Build the EventSource URL
|
||||
const streamUrl = `/api/w/${this.#workspace}/jobs_u/getupdate_sse/${jobId}`
|
||||
const streamUrl = `/api/w/${get(workspaceStore)}/jobs_u/getupdate_sse/${jobId}`
|
||||
const url = new URL(streamUrl, window.location.origin)
|
||||
url.searchParams.set('poll_delay_ms', '50')
|
||||
url.searchParams.set('fast', 'true')
|
||||
@@ -479,8 +576,8 @@ class FlowChatManager {
|
||||
if (data.completed) {
|
||||
isCompleted = true
|
||||
// Do a final poll to get all messages from database
|
||||
if (this.#conversationId) {
|
||||
await this.pollConversationMessages(this.#conversationId)
|
||||
if (this.selectedConversationId) {
|
||||
await this.pollConversationMessages(this.selectedConversationId)
|
||||
}
|
||||
this.cleanup()
|
||||
}
|
||||
@@ -518,7 +615,7 @@ class FlowChatManager {
|
||||
this.currentJobId = jobId
|
||||
|
||||
if (isNewConversation) {
|
||||
await this.#refreshConversations?.()
|
||||
await this.refreshConversations()
|
||||
}
|
||||
|
||||
// Start polling for intermediate messages in non-streaming mode too
|
||||
|
||||
@@ -8,126 +8,25 @@
|
||||
PanelLeftOpen,
|
||||
Loader2
|
||||
} from 'lucide-svelte'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { FlowConversationService, type FlowConversation } from '$lib/gen'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { type FlowConversation } from '$lib/gen'
|
||||
import CountBadge from '$lib/components/common/badge/CountBadge.svelte'
|
||||
import InfiniteList from '$lib/components/InfiniteList.svelte'
|
||||
import { untrack } from 'svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { FlowChatManager } from './FlowChatManager.svelte'
|
||||
|
||||
interface Props {
|
||||
flowPath: string
|
||||
selectedConversationId?: string
|
||||
onNewConversation: (options: { clearMessages?: boolean }) => void
|
||||
onSelectConversation: (conversationId: string, isDraft?: boolean) => void
|
||||
onDeleteConversation: (conversationId: string) => void
|
||||
manager: FlowChatManager
|
||||
}
|
||||
|
||||
interface ConversationWithDraft extends FlowConversation {
|
||||
isDraft?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
flowPath,
|
||||
selectedConversationId,
|
||||
onNewConversation,
|
||||
onSelectConversation,
|
||||
onDeleteConversation
|
||||
}: Props = $props()
|
||||
|
||||
let isExpanded = $state(false)
|
||||
let infiniteList: InfiniteList | undefined = $state()
|
||||
let conversations = $state<ConversationWithDraft[]>([])
|
||||
let deletingConversationId = $state<string | undefined>(undefined)
|
||||
|
||||
export async function refreshConversations() {
|
||||
return await infiniteList?.loadData('forceRefresh')
|
||||
}
|
||||
|
||||
export async function addNewConversation(conversationId: string, username: string) {
|
||||
// Check if there's already a draft conversation
|
||||
const existingDraft = conversations.find((c) => c.isDraft)
|
||||
if (existingDraft) {
|
||||
// Select the existing draft instead of creating a new one
|
||||
onSelectConversation(existingDraft.id, true)
|
||||
return existingDraft.id
|
||||
}
|
||||
|
||||
// Create a new conversation object and add it to the top of the list
|
||||
const newConversation: ConversationWithDraft = {
|
||||
id: conversationId,
|
||||
workspace_id: $workspaceStore!,
|
||||
flow_path: flowPath,
|
||||
title: 'New chat',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
created_by: username,
|
||||
isDraft: true
|
||||
}
|
||||
|
||||
// Prepend to conversations list
|
||||
conversations = [newConversation, ...conversations]
|
||||
return conversationId
|
||||
}
|
||||
|
||||
async function loadConversations(page: number, perPage: number) {
|
||||
if (!$workspaceStore || !flowPath) return []
|
||||
|
||||
try {
|
||||
const response = await FlowConversationService.listFlowConversations({
|
||||
workspace: $workspaceStore,
|
||||
flowPath: flowPath,
|
||||
page: page,
|
||||
perPage: perPage
|
||||
})
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('Failed to load conversations:', error)
|
||||
sendUserToast('Failed to load conversations', true)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteConversation(conversationId: string) {
|
||||
try {
|
||||
deletingConversationId = conversationId
|
||||
await FlowConversationService.deleteFlowConversation({
|
||||
workspace: $workspaceStore!,
|
||||
conversationId
|
||||
})
|
||||
|
||||
onDeleteConversation(conversationId)
|
||||
sendUserToast('Conversation deleted successfully')
|
||||
} catch (error) {
|
||||
console.error('Failed to delete conversation:', error)
|
||||
sendUserToast('Failed to delete conversation', true)
|
||||
throw error
|
||||
} finally {
|
||||
deletingConversationId = undefined
|
||||
}
|
||||
}
|
||||
let { manager }: Props = $props()
|
||||
|
||||
function getConversationTitle(conversation: FlowConversation): string {
|
||||
return conversation.title || `Conversation ${conversation.created_at.slice(0, 10)}`
|
||||
}
|
||||
|
||||
// Initialize InfiniteList when component mounts or flowPath changes
|
||||
$effect(() => {
|
||||
if ($workspaceStore && flowPath) {
|
||||
if (infiniteList) {
|
||||
untrack(() => {
|
||||
infiniteList?.setLoader(loadConversations)
|
||||
infiniteList?.setDeleteItemFn(deleteConversation)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex flex-col h-full bg-surface border-r border-gray-200 dark:border-gray-700 transition-all duration-300 {isExpanded
|
||||
class="flex flex-col h-full bg-surface border-r border-gray-200 dark:border-gray-700 transition-all duration-300 {manager.isSidebarExpanded
|
||||
? 'w-60'
|
||||
: 'w-16'}"
|
||||
>
|
||||
@@ -137,10 +36,10 @@
|
||||
<Button
|
||||
size="sm"
|
||||
color="light"
|
||||
startIcon={{ icon: isExpanded ? PanelLeftClose : PanelLeftOpen }}
|
||||
onclick={() => (isExpanded = !isExpanded)}
|
||||
iconOnly={!isExpanded}
|
||||
btnClasses="!justify-start"
|
||||
startIcon={{ icon: manager.isSidebarExpanded ? PanelLeftClose : PanelLeftOpen }}
|
||||
onclick={() => (manager.isSidebarExpanded = !manager.isSidebarExpanded)}
|
||||
iconOnly={!manager.isSidebarExpanded}
|
||||
btnClasses={manager.isSidebarExpanded ? '!justify-start' : ''}
|
||||
label="Conversations"
|
||||
>
|
||||
Conversations
|
||||
@@ -149,10 +48,10 @@
|
||||
size="sm"
|
||||
color="light"
|
||||
startIcon={{ icon: Plus }}
|
||||
onclick={() => onNewConversation({ clearMessages: true })}
|
||||
onclick={() => manager.createConversation({ clearMessages: true })}
|
||||
title="Start new conversation"
|
||||
iconOnly={!isExpanded}
|
||||
btnClasses="!justify-start"
|
||||
iconOnly={!manager.isSidebarExpanded}
|
||||
btnClasses={manager.isSidebarExpanded ? '!justify-start' : ''}
|
||||
label="New chat"
|
||||
>
|
||||
New chat
|
||||
@@ -161,26 +60,28 @@
|
||||
</div>
|
||||
|
||||
<!-- Conversations List -->
|
||||
{#if !isExpanded}
|
||||
{#if !manager.isSidebarExpanded}
|
||||
<!-- Collapsed state - show single chat icon with badge -->
|
||||
<div class="p-2 flex flex-col items-center mt-2">
|
||||
<button
|
||||
class="relative w-[23px] h-[23px] rounded-md center-center hover:bg-surface-hover transition-all duration-100 text-secondary hover:text-primary group"
|
||||
onclick={() => (isExpanded = true)}
|
||||
title="{conversations.length} conversation{conversations.length !== 1 ? 's' : ''}"
|
||||
onclick={() => (manager.isSidebarExpanded = true)}
|
||||
title="{manager.conversations.length} conversation{manager.conversations.length !== 1
|
||||
? 's'
|
||||
: ''}"
|
||||
>
|
||||
<MessageCircle size={16} />
|
||||
<CountBadge count={conversations.length} small={true} alwaysVisible={true} />
|
||||
<CountBadge count={manager.conversations.length} small={true} alwaysVisible={true} />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Always mount InfiniteList, but hide it when collapsed -->
|
||||
<div class="flex-1 overflow-hidden" class:hidden={!isExpanded}>
|
||||
<div class="flex-1 overflow-hidden" class:hidden={!manager.isSidebarExpanded}>
|
||||
<InfiniteList
|
||||
bind:this={infiniteList}
|
||||
bind:items={conversations}
|
||||
selectedItemId={selectedConversationId}
|
||||
bind:this={manager.conversationListComponent}
|
||||
bind:items={manager.conversations}
|
||||
selectedItemId={manager.selectedConversationId}
|
||||
noBorder={true}
|
||||
rounded={false}
|
||||
>
|
||||
@@ -188,7 +89,7 @@
|
||||
<div
|
||||
class={twMerge(
|
||||
'w-full p-1',
|
||||
selectedConversationId === conversation.id
|
||||
manager.selectedConversationId === conversation.id
|
||||
? 'bg-blue-200/30 text-blue-500 dark:bg-blue-600/30 text-blue-400'
|
||||
: ''
|
||||
)}
|
||||
@@ -196,30 +97,29 @@
|
||||
<Button
|
||||
color="transparent"
|
||||
size="xs"
|
||||
onclick={() => onSelectConversation(conversation.id, conversation.isDraft)}
|
||||
onclick={() => manager.selectConversation(conversation.id, conversation.isDraft)}
|
||||
>
|
||||
<span class="flex-1 text-left text-sm font-medium text-primary truncate">
|
||||
{getConversationTitle(conversation)}
|
||||
</span>
|
||||
<button
|
||||
class="ml-2 p-1 rounded hover:bg-red-100 dark:hover:bg-red-900/30 text-red-500 transition-all {hover ||
|
||||
deletingConversationId === conversation.id
|
||||
manager.deletingConversationId === conversation.id
|
||||
? 'opacity-100'
|
||||
: 'opacity-0'}"
|
||||
disabled={deletingConversationId === conversation.id}
|
||||
disabled={manager.deletingConversationId === conversation.id}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (conversation.isDraft) {
|
||||
// just remove first conversation as it is the draft
|
||||
conversations = [...conversations.slice(1)]
|
||||
onDeleteConversation(conversation.id)
|
||||
manager.conversations = [...manager.conversations.slice(1)]
|
||||
} else {
|
||||
infiniteList?.deleteItem(conversation.id)
|
||||
manager.conversationListComponent?.deleteItem(conversation.id)
|
||||
}
|
||||
}}
|
||||
title="Delete conversation"
|
||||
>
|
||||
{#if deletingConversationId === conversation.id}
|
||||
{#if manager.deletingConversationId === conversation.id}
|
||||
<Loader2 size={14} class="animate-spin" />
|
||||
{:else}
|
||||
<Trash2 size={14} />
|
||||
@@ -237,11 +137,11 @@
|
||||
</InfiniteList>
|
||||
</div>
|
||||
|
||||
{#if isExpanded}
|
||||
{#if manager.isSidebarExpanded}
|
||||
<!-- Footer -->
|
||||
<div class="flex-shrink-0 p-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<p class="text-xs text-primary">
|
||||
{conversations.length} conversation{conversations.length !== 1 ? 's' : ''}
|
||||
{manager.conversations.length} conversation{manager.conversations.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -50,14 +50,14 @@
|
||||
flowPreviewContent?.test()
|
||||
}
|
||||
|
||||
export async function runPreview(): Promise<string | undefined> {
|
||||
export async function runPreview(conversationId?: string): Promise<string | undefined> {
|
||||
if (!previewOpen) {
|
||||
deferContent = true
|
||||
await tick()
|
||||
}
|
||||
previewMode = 'whole'
|
||||
flowPreviewContent?.refresh()
|
||||
return await flowPreviewContent?.test()
|
||||
return await flowPreviewContent?.test(conversationId)
|
||||
}
|
||||
|
||||
export function cancelTest() {
|
||||
|
||||
@@ -494,7 +494,12 @@
|
||||
{/if}
|
||||
|
||||
{#if id !== 'preprocessor'}
|
||||
<div class={twMerge('absolute -translate-y-[100%] top-2 right-4 h-7 p-1 min-w-7')}>
|
||||
<!-- The `style="will-change: transform;"` fixes a bug in Safari where the close and move
|
||||
and delete buttons would get clipped (unless an animation is running) -->
|
||||
<div
|
||||
class={twMerge('absolute -translate-y-[100%] top-2 right-4 h-7 p-1 min-w-7')}
|
||||
style="will-change: transform;"
|
||||
>
|
||||
<button
|
||||
class={twMerge(
|
||||
'trash center-center p-1 text-secondary shadow-sm bg-surface duration-0 hover:bg-surface-tertiary',
|
||||
@@ -510,7 +515,10 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="absolute -translate-y-[100%] top-2 -right-2 h-7 p-1 min-w-7">
|
||||
<div
|
||||
class="absolute -translate-y-[100%] top-2 -right-2 h-7 p-1 min-w-7"
|
||||
style="will-change: transform;"
|
||||
>
|
||||
<button
|
||||
class={twMerge(
|
||||
'trash center-center text-secondary shadow-sm bg-surface duration-0 hover:bg-red-400 hover:text-white p-1',
|
||||
@@ -530,6 +538,7 @@
|
||||
|
||||
{#if (id && Object.values($flowInputsStore?.[id]?.flowStepWarnings || {}).length > 0) || Boolean(warningMessage)}
|
||||
<Popover
|
||||
style="will-change: transform;"
|
||||
class={twMerge(
|
||||
'absolute -translate-y-[100%] top-1 -left-1',
|
||||
'flex items-center justify-center rounded-b-none rounded-md p-1 shadow-md duration-0 ',
|
||||
|
||||
@@ -154,7 +154,8 @@ export async function runFlowPreview(
|
||||
args: Record<string, any>,
|
||||
flow: OpenFlow & { tag?: string },
|
||||
path: string,
|
||||
restartedFrom: RestartedFrom | undefined
|
||||
restartedFrom: RestartedFrom | undefined,
|
||||
conversationId?: string | undefined
|
||||
) {
|
||||
const newFlow = flow
|
||||
return await JobService.runFlowPreview({
|
||||
@@ -165,7 +166,8 @@ export async function runFlowPreview(
|
||||
path: path,
|
||||
tag: newFlow.tag,
|
||||
restarted_from: restartedFrom
|
||||
}
|
||||
},
|
||||
memoryId: conversationId
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -268,6 +268,7 @@
|
||||
variant="border"
|
||||
wrapperClasses="p-0 whitespace-nowrap w-fit"
|
||||
btnClasses={twMerge(
|
||||
'hover:bg-surface',
|
||||
'font-mono h-4 py-1 text-2xs',
|
||||
'font-thin px-1 rounded-[0.275rem]',
|
||||
metaData ? 'rounded-r-none border-r-0.5' : ''
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
type CompletedJob,
|
||||
type ExtendedJobs,
|
||||
ConcurrencyGroupsService,
|
||||
type ObscuredJob
|
||||
type ObscuredJob,
|
||||
CancelablePromise,
|
||||
CancelError
|
||||
} from '$lib/gen'
|
||||
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
@@ -14,6 +16,7 @@
|
||||
|
||||
import { tweened, type Tweened } from 'svelte/motion'
|
||||
import { subtractDaysFromDateString } from '$lib/utils'
|
||||
import { CancelablePromiseUtils } from '$lib/cancelable-promise-utils'
|
||||
|
||||
interface Props {
|
||||
jobs: Job[] | undefined
|
||||
@@ -49,7 +52,7 @@
|
||||
allWorkspaces?: boolean
|
||||
computeMinAndMax: (() => { minTs: string; maxTs: string | undefined } | undefined) | undefined
|
||||
lookback?: number
|
||||
perPage?: number | undefined
|
||||
perPage?: number
|
||||
allowWildcards?: boolean
|
||||
}
|
||||
|
||||
@@ -87,7 +90,7 @@
|
||||
allWorkspaces = false,
|
||||
computeMinAndMax,
|
||||
lookback = 0,
|
||||
perPage = undefined,
|
||||
perPage = $bindable(1000),
|
||||
allowWildcards = false
|
||||
}: Props = $props()
|
||||
let intervalId: number | undefined = $state()
|
||||
@@ -95,7 +98,23 @@
|
||||
|
||||
function onParamChanges() {
|
||||
resetJobs()
|
||||
loadJobsIntern(true)
|
||||
let promise = loadJobsIntern(true)
|
||||
promise = CancelablePromiseUtils.onTimeout(promise, 4000, () => {
|
||||
sendUserToast(
|
||||
'Loading jobs is taking longer than expected...',
|
||||
true,
|
||||
perPage > 25
|
||||
? [{ label: 'Reduce to 25 items per page', callback: () => (perPage = 25) }]
|
||||
: []
|
||||
)
|
||||
})
|
||||
promise = CancelablePromiseUtils.catchErr(promise, (e) => {
|
||||
if (e instanceof CancelError) {
|
||||
return CancelablePromiseUtils.pure<void>(undefined)
|
||||
}
|
||||
return CancelablePromiseUtils.err(e)
|
||||
})
|
||||
return promise
|
||||
}
|
||||
|
||||
function computeJobKinds(jobKindsCat: string | undefined): string {
|
||||
@@ -162,123 +181,119 @@
|
||||
const minCreated = new Date(new Date(ts).getTime() - 1).toISOString()
|
||||
|
||||
let olderJobs = await fetchJobs(minCreated, minTs, undefined)
|
||||
jobs = jobs.concat(olderJobs)
|
||||
jobs = jobs?.concat(olderJobs)
|
||||
computeCompletedJobs()
|
||||
return olderJobs?.length < 1000
|
||||
return olderJobs?.length < perPage
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function fetchJobs(
|
||||
function fetchJobs(
|
||||
completedBefore: string | undefined,
|
||||
completedAfter: string | undefined,
|
||||
createdAfterQueue: string | undefined
|
||||
): Promise<Job[]> {
|
||||
): CancelablePromise<Job[]> {
|
||||
loadingFetch = true
|
||||
try {
|
||||
let scriptPathStart = folder === null || folder === '' ? undefined : `f/${folder}/`
|
||||
let scriptPathExact = path === null || path === '' ? undefined : path
|
||||
return JobService.listJobs({
|
||||
workspace: $workspaceStore!,
|
||||
completedBefore,
|
||||
completedAfter,
|
||||
createdAfterQueue,
|
||||
schedulePath,
|
||||
scriptPathExact,
|
||||
createdBy: user === null || user === '' ? undefined : user,
|
||||
scriptPathStart: scriptPathStart,
|
||||
jobKinds: jobKindsCat == 'all' || jobKinds == '' ? undefined : jobKinds,
|
||||
success: success == 'success' ? true : success == 'failure' ? false : undefined,
|
||||
running:
|
||||
success == 'running' || success == 'suspended'
|
||||
? true
|
||||
: success == 'waiting'
|
||||
? false
|
||||
: undefined,
|
||||
isSkipped: showSkipped ? undefined : false,
|
||||
// isFlowStep: jobKindsCat != 'all' ? false : undefined,
|
||||
hasNullParent: jobKindsCat != 'all' ? true : undefined,
|
||||
label: label === null || label === '' ? undefined : label,
|
||||
tag: tag === null || tag === '' ? undefined : tag,
|
||||
worker: worker === null || worker === '' ? undefined : worker,
|
||||
isNotSchedule: showSchedules == false ? true : undefined,
|
||||
suspended: success == 'waiting' ? false : success == 'suspended' ? true : undefined,
|
||||
scheduledForBeforeNow:
|
||||
showFutureJobs == false || success == 'waiting' || success == 'suspended'
|
||||
? true
|
||||
let scriptPathStart = folder === null || folder === '' ? undefined : `f/${folder}/`
|
||||
let scriptPathExact = path === null || path === '' ? undefined : path
|
||||
let promise = JobService.listJobs({
|
||||
workspace: $workspaceStore!,
|
||||
completedBefore,
|
||||
completedAfter,
|
||||
createdAfterQueue,
|
||||
schedulePath,
|
||||
scriptPathExact,
|
||||
createdBy: user === null || user === '' ? undefined : user,
|
||||
scriptPathStart: scriptPathStart,
|
||||
jobKinds: jobKindsCat == 'all' || jobKinds == '' ? undefined : jobKinds,
|
||||
success: success == 'success' ? true : success == 'failure' ? false : undefined,
|
||||
running:
|
||||
success == 'running' || success == 'suspended'
|
||||
? true
|
||||
: success == 'waiting'
|
||||
? false
|
||||
: undefined,
|
||||
args:
|
||||
argFilter && argFilter != '{}' && argFilter != '' && argError == ''
|
||||
? argFilter
|
||||
: undefined,
|
||||
result:
|
||||
resultFilter && resultFilter != '{}' && resultFilter != '' && resultError == ''
|
||||
? resultFilter
|
||||
: undefined,
|
||||
allWorkspaces: allWorkspaces ? true : undefined,
|
||||
perPage,
|
||||
allowWildcards: allowWildcards ? true : undefined
|
||||
})
|
||||
} catch (e) {
|
||||
isSkipped: showSkipped ? undefined : false,
|
||||
// isFlowStep: jobKindsCat != 'all' ? false : undefined,
|
||||
hasNullParent: jobKindsCat != 'all' ? true : undefined,
|
||||
label: label === null || label === '' ? undefined : label,
|
||||
tag: tag === null || tag === '' ? undefined : tag,
|
||||
worker: worker === null || worker === '' ? undefined : worker,
|
||||
isNotSchedule: showSchedules == false ? true : undefined,
|
||||
suspended: success == 'waiting' ? false : success == 'suspended' ? true : undefined,
|
||||
scheduledForBeforeNow:
|
||||
showFutureJobs == false || success == 'waiting' || success == 'suspended'
|
||||
? true
|
||||
: undefined,
|
||||
args:
|
||||
argFilter && argFilter != '{}' && argFilter != '' && argError == '' ? argFilter : undefined,
|
||||
result:
|
||||
resultFilter && resultFilter != '{}' && resultFilter != '' && resultError == ''
|
||||
? resultFilter
|
||||
: undefined,
|
||||
allWorkspaces: allWorkspaces ? true : undefined,
|
||||
perPage,
|
||||
allowWildcards: allowWildcards ? true : undefined
|
||||
})
|
||||
promise = CancelablePromiseUtils.catchErr(promise, (e) => {
|
||||
if (e instanceof CancelError) return CancelablePromiseUtils.err(e)
|
||||
sendUserToast('There was an issue loading jobs, see browser console for more details', true)
|
||||
console.error(e)
|
||||
return []
|
||||
} finally {
|
||||
return CancelablePromiseUtils.pure([] as Job[])
|
||||
})
|
||||
CancelablePromiseUtils.pipe(promise, () => {
|
||||
loadingFetch = false
|
||||
}
|
||||
})
|
||||
return promise
|
||||
}
|
||||
|
||||
async function fetchExtendedJobs(
|
||||
function fetchExtendedJobs(
|
||||
concurrencyKey: string | null,
|
||||
createdBeforeQueue: string | undefined,
|
||||
completedAfter: string | undefined
|
||||
): Promise<ExtendedJobs> {
|
||||
): CancelablePromise<ExtendedJobs> {
|
||||
loadingFetch = true
|
||||
try {
|
||||
return ConcurrencyGroupsService.listExtendedJobs({
|
||||
rowLimit: 1000,
|
||||
concurrencyKey: concurrencyKey == null || concurrencyKey == '' ? undefined : concurrencyKey,
|
||||
workspace: $workspaceStore!,
|
||||
completedAfter,
|
||||
createdBeforeQueue: createdBeforeQueue,
|
||||
// createdOrStartedBefore: startedBefore,
|
||||
// createdOrStartedAfter: startedAfter,
|
||||
// createdOrStartedAfterCompletedJobs: startedAfterCompletedJobs,
|
||||
schedulePath,
|
||||
scriptPathExact: path === null || path === '' ? undefined : path,
|
||||
createdBy: user === null || user === '' ? undefined : user,
|
||||
scriptPathStart: folder === null || folder === '' ? undefined : `f/${folder}/`,
|
||||
jobKinds: jobKindsCat == 'all' || jobKinds == '' ? undefined : jobKinds,
|
||||
success: success == 'success' ? true : success == 'failure' ? false : undefined,
|
||||
running: success == 'running' ? true : undefined,
|
||||
isSkipped: showSkipped ? undefined : false,
|
||||
isFlowStep: jobKindsCat != 'all' ? false : undefined,
|
||||
label: label === null || label === '' ? undefined : label,
|
||||
tag: tag === null || tag === '' ? undefined : tag,
|
||||
isNotSchedule: showSchedules == false ? true : undefined,
|
||||
scheduledForBeforeNow: showFutureJobs == false ? true : undefined,
|
||||
args:
|
||||
argFilter && argFilter != '{}' && argFilter != '' && argError == ''
|
||||
? argFilter
|
||||
: undefined,
|
||||
result:
|
||||
resultFilter && resultFilter != '{}' && resultFilter != '' && resultError == ''
|
||||
? resultFilter
|
||||
: undefined,
|
||||
allWorkspaces: allWorkspaces ? true : undefined,
|
||||
perPage,
|
||||
allowWildcards
|
||||
})
|
||||
} catch (e) {
|
||||
let promise = ConcurrencyGroupsService.listExtendedJobs({
|
||||
rowLimit: perPage,
|
||||
concurrencyKey: concurrencyKey == null || concurrencyKey == '' ? undefined : concurrencyKey,
|
||||
workspace: $workspaceStore!,
|
||||
completedAfter,
|
||||
createdBeforeQueue: createdBeforeQueue,
|
||||
// createdOrStartedBefore: startedBefore,
|
||||
// createdOrStartedAfter: startedAfter,
|
||||
// createdOrStartedAfterCompletedJobs: startedAfterCompletedJobs,
|
||||
schedulePath,
|
||||
scriptPathExact: path === null || path === '' ? undefined : path,
|
||||
createdBy: user === null || user === '' ? undefined : user,
|
||||
scriptPathStart: folder === null || folder === '' ? undefined : `f/${folder}/`,
|
||||
jobKinds: jobKindsCat == 'all' || jobKinds == '' ? undefined : jobKinds,
|
||||
success: success == 'success' ? true : success == 'failure' ? false : undefined,
|
||||
running: success == 'running' ? true : undefined,
|
||||
isSkipped: showSkipped ? undefined : false,
|
||||
isFlowStep: jobKindsCat != 'all' ? false : undefined,
|
||||
label: label === null || label === '' ? undefined : label,
|
||||
tag: tag === null || tag === '' ? undefined : tag,
|
||||
isNotSchedule: showSchedules == false ? true : undefined,
|
||||
scheduledForBeforeNow: showFutureJobs == false ? true : undefined,
|
||||
args:
|
||||
argFilter && argFilter != '{}' && argFilter != '' && argError == '' ? argFilter : undefined,
|
||||
result:
|
||||
resultFilter && resultFilter != '{}' && resultFilter != '' && resultError == ''
|
||||
? resultFilter
|
||||
: undefined,
|
||||
allWorkspaces: allWorkspaces ? true : undefined,
|
||||
perPage,
|
||||
allowWildcards
|
||||
})
|
||||
promise = CancelablePromiseUtils.catchErr(promise, (e) => {
|
||||
sendUserToast('There was an issue loading jobs, see browser console for more details', true)
|
||||
console.error(e)
|
||||
return {
|
||||
jobs: [],
|
||||
obscured_jobs: []
|
||||
}
|
||||
} finally {
|
||||
return CancelablePromiseUtils.pure({ jobs: [], obscured_jobs: [] } as ExtendedJobs)
|
||||
})
|
||||
promise = CancelablePromiseUtils.pipe(promise, () => {
|
||||
loadingFetch = false
|
||||
}
|
||||
})
|
||||
return promise
|
||||
}
|
||||
|
||||
export async function loadJobs(
|
||||
@@ -303,54 +318,58 @@
|
||||
intervalId && clearInterval(intervalId)
|
||||
intervalId = setInterval(syncer, refreshRate)
|
||||
}
|
||||
async function loadJobsIntern(shouldGetCount?: boolean): Promise<void> {
|
||||
function loadJobsIntern(shouldGetCount?: boolean): CancelablePromise<void> {
|
||||
if (shouldGetCount) {
|
||||
getCount()
|
||||
}
|
||||
loading = true
|
||||
try {
|
||||
// Extend MinTs to fetch jobs mefore minTs and show a correct concurrency graph
|
||||
// TODO: when an ended_at column is created on the completed_job table,
|
||||
// lookback won't be needed anymore (just filter ended_at > minTs instead
|
||||
const extendedMinTs = subtractDaysFromDateString(minTs, lookback)
|
||||
if (concurrencyKey == null || concurrencyKey === '') {
|
||||
let newJobs = await fetchJobs(maxTs, undefined, extendedMinTs)
|
||||
// Extend MinTs to fetch jobs mefore minTs and show a correct concurrency graph
|
||||
// TODO: when an ended_at column is created on the completed_job table,
|
||||
// lookback won't be needed anymore (just filter ended_at > minTs instead
|
||||
const extendedMinTs = subtractDaysFromDateString(minTs, lookback)
|
||||
|
||||
if (concurrencyKey == null || concurrencyKey === '') {
|
||||
return CancelablePromiseUtils.map(fetchJobs(maxTs, undefined, extendedMinTs), (newJobs) => {
|
||||
extendedJobs = { jobs: newJobs, obscured_jobs: [] } as ExtendedJobs
|
||||
|
||||
// Filter on minTs here and not in the backend
|
||||
// to get enough data for the concurrency graph
|
||||
jobs = sortMinDate(minTs, newJobs)
|
||||
externalJobs = []
|
||||
} else {
|
||||
extendedJobs = await fetchExtendedJobs(concurrencyKey, maxTs, extendedMinTs)
|
||||
const newJobs = extendedJobs.jobs
|
||||
const newExternalJobs = extendedJobs.obscured_jobs
|
||||
computeCompletedJobs()
|
||||
loading = false
|
||||
})
|
||||
} else {
|
||||
return CancelablePromiseUtils.map(
|
||||
fetchExtendedJobs(concurrencyKey, maxTs, extendedMinTs),
|
||||
(newExtendedJobs) => {
|
||||
extendedJobs = newExtendedJobs
|
||||
const newJobs = newExtendedJobs.jobs
|
||||
const newExternalJobs = newExtendedJobs.obscured_jobs
|
||||
|
||||
// Filter on minTs here and not in the backend
|
||||
// to get enough data for the concurrency graph
|
||||
if (minTs != undefined) {
|
||||
const minDate = new Date(minTs)
|
||||
jobs = newJobs.filter((x) =>
|
||||
x.started_at
|
||||
? new Date(x.started_at) > minDate
|
||||
: x.created_at
|
||||
? new Date(x.created_at) > minDate
|
||||
: false
|
||||
)
|
||||
externalJobs = computeExternalJobs(
|
||||
newExternalJobs.filter((x) => x.started_at && new Date(x.started_at) > minDate)
|
||||
)
|
||||
} else {
|
||||
jobs = newJobs
|
||||
externalJobs = computeExternalJobs(newExternalJobs)
|
||||
// Filter on minTs here and not in the backend
|
||||
// to get enough data for the concurrency graph
|
||||
if (minTs != undefined) {
|
||||
const minDate = new Date(minTs)
|
||||
jobs = newJobs.filter((x) =>
|
||||
x.started_at
|
||||
? new Date(x.started_at) > minDate
|
||||
: x.created_at
|
||||
? new Date(x.created_at) > minDate
|
||||
: false
|
||||
)
|
||||
externalJobs = computeExternalJobs(
|
||||
newExternalJobs.filter((x) => x.started_at && new Date(x.started_at) > minDate)
|
||||
)
|
||||
} else {
|
||||
jobs = newJobs
|
||||
externalJobs = computeExternalJobs(newExternalJobs)
|
||||
}
|
||||
computeCompletedJobs()
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
computeCompletedJobs()
|
||||
} catch (err) {
|
||||
sendUserToast(`There was a problem fetching jobs: ${err}`, true)
|
||||
console.error(JSON.stringify(err))
|
||||
)
|
||||
}
|
||||
loading = false
|
||||
}
|
||||
|
||||
async function getCount() {
|
||||
@@ -557,10 +576,11 @@
|
||||
showSchedules,
|
||||
allWorkspaces,
|
||||
argFilter,
|
||||
resultFilter
|
||||
resultFilter,
|
||||
perPage
|
||||
]
|
||||
|
||||
untrack(() => onParamChanges())
|
||||
let p = untrack(() => onParamChanges())
|
||||
return () => p.cancel()
|
||||
})
|
||||
$effect(() => {
|
||||
;[autoRefresh, refreshRate]
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
|
||||
interface Props {
|
||||
minTs: string | undefined;
|
||||
maxTs: string | undefined;
|
||||
loading?: boolean;
|
||||
selectedManualDate?: number;
|
||||
loadText?: string | undefined;
|
||||
serviceLogsChoices?: boolean;
|
||||
minTs: string | undefined
|
||||
maxTs: string | undefined
|
||||
loading?: boolean
|
||||
selectedManualDate?: number
|
||||
loadText?: string | undefined
|
||||
serviceLogsChoices?: boolean
|
||||
numberOfLastJobsToFetch?: number
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -18,8 +19,9 @@
|
||||
loading = false,
|
||||
selectedManualDate = $bindable(0),
|
||||
loadText = undefined,
|
||||
serviceLogsChoices = false
|
||||
}: Props = $props();
|
||||
serviceLogsChoices = false,
|
||||
numberOfLastJobsToFetch = 1000
|
||||
}: Props = $props()
|
||||
|
||||
export function computeMinMax(): { minTs: string; maxTs: string | undefined } | undefined {
|
||||
return manualDates[selectedManualDate].computeMinMax()
|
||||
@@ -75,7 +77,7 @@
|
||||
|
||||
let manualDates = $derived([
|
||||
{
|
||||
label: loadText ?? 'Last 1000 runs',
|
||||
label: loadText ?? `Last ${numberOfLastJobsToFetch} runs`,
|
||||
computeMinMax: () => {
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
activeLabel?: string | null
|
||||
// const loadMoreQuantity: number = 100
|
||||
lastFetchWentToEnd?: boolean
|
||||
perPage?: number
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -33,7 +34,8 @@
|
||||
selectedIds = $bindable([]),
|
||||
selectedWorkspace = $bindable(undefined),
|
||||
activeLabel = null,
|
||||
lastFetchWentToEnd = $bindable(false)
|
||||
lastFetchWentToEnd = $bindable(false),
|
||||
perPage = 1000
|
||||
}: Props = $props()
|
||||
|
||||
function getTime(job: Job): string | undefined {
|
||||
@@ -116,7 +118,7 @@
|
||||
let tableHeight: number = $state(0)
|
||||
let headerHeight: number = $state(0)
|
||||
let containerWidth: number = $state(0)
|
||||
// const MAX_ITEMS = 1000
|
||||
// const MAX_ITEMS = perPage
|
||||
|
||||
/*
|
||||
function infiniteHandler({ detail: { loaded, error, complete } }) {
|
||||
@@ -139,7 +141,7 @@
|
||||
return ''
|
||||
}
|
||||
const jc = jobCount
|
||||
const isTruncated = jc >= 1000 && !lastFetchWentToEnd
|
||||
const isTruncated = jc >= perPage && !lastFetchWentToEnd
|
||||
|
||||
return `${jc}${isTruncated ? '+' : ''} job${jc != 1 ? 's' : ''}`
|
||||
}
|
||||
@@ -338,13 +340,15 @@
|
||||
{/snippet}
|
||||
{#snippet footer()}
|
||||
<div
|
||||
>{#if !lastFetchWentToEnd && jobs && jobs.length >= 1000}
|
||||
>{#if !lastFetchWentToEnd && jobs && jobs.length >= perPage}
|
||||
<button
|
||||
class="text-xs text-blue-600 text-center w-full pb-2"
|
||||
class="text-xs text-accent text-center w-full pb-2"
|
||||
onclick={() => {
|
||||
dispatch('loadExtra')
|
||||
}}>Load next 1000 jobs</button
|
||||
}}
|
||||
>
|
||||
Load next {perPage} jobs
|
||||
</button>
|
||||
{/if}</div
|
||||
>
|
||||
{/snippet}
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
title?: string | undefined
|
||||
placeholder?: string | undefined
|
||||
properties?: Record<string, any> | undefined
|
||||
nonEmpty?: boolean | undefined
|
||||
isFlowInput?: boolean
|
||||
isAppInput?: boolean
|
||||
order?: string[] | undefined
|
||||
@@ -57,6 +58,7 @@
|
||||
pattern = $bindable(undefined),
|
||||
enum_ = $bindable(undefined),
|
||||
extra = $bindable({}),
|
||||
nonEmpty = $bindable(undefined),
|
||||
minW = true,
|
||||
customErrorMessage = $bindable(undefined),
|
||||
title = $bindable(undefined),
|
||||
@@ -219,6 +221,7 @@
|
||||
originalType={extra['originalType']}
|
||||
bind:itemsType
|
||||
canEditResourceType={isFlowInput || isAppInput}
|
||||
bind:nonEmpty
|
||||
/>
|
||||
{:else if type == 'string' || ['number', 'integer'].includes(type ?? '')}
|
||||
<div>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<script lang="ts" generics="Item extends { label?: string; value: any; subtitle?: string }">
|
||||
<script
|
||||
lang="ts"
|
||||
generics="Item extends { label?: string; value: any; subtitle?: string; disabled?: boolean }"
|
||||
>
|
||||
import { clickOutside } from '$lib/utils'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import CloseButton from '../common/CloseButton.svelte'
|
||||
|
||||
@@ -195,11 +195,12 @@
|
||||
: item.value === value
|
||||
? 'bg-surface-hover/60'
|
||||
: 'hover:bg-surface-hover',
|
||||
itemButtonWrapperClasses
|
||||
itemButtonWrapperClasses,
|
||||
item.disabled ? 'cursor-not-allowed text-disabled' : ''
|
||||
)}
|
||||
onclick={(e) => {
|
||||
e.stopImmediatePropagation()
|
||||
onSelectValue(item)
|
||||
if (!item.disabled) onSelectValue(item)
|
||||
}}
|
||||
>
|
||||
{@render startSnippet?.({ item, close: () => (open = false) })}
|
||||
|
||||
@@ -44,6 +44,7 @@ export type ProcessedItem<T> = {
|
||||
label: string
|
||||
value: T
|
||||
subtitle?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function getLabel<T>(item: { label?: string; value: T } | undefined): string | undefined {
|
||||
@@ -51,7 +52,7 @@ export function getLabel<T>(item: { label?: string; value: T } | undefined): str
|
||||
if (item.label) return item.label
|
||||
if (typeof item.value === 'string') return item.value
|
||||
if (typeof item.value == 'number' || typeof item.value == 'boolean') return item.value.toString()
|
||||
if (item.value == null) { return undefined }
|
||||
if (item.value == null) return undefined
|
||||
return JSON.stringify(item.value)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,12 +4,14 @@
|
||||
import { goto } from '$lib/navigation'
|
||||
import { base } from '$lib/base'
|
||||
import {
|
||||
JobService,
|
||||
ResourceService,
|
||||
SettingService,
|
||||
UserService,
|
||||
VariableService,
|
||||
WorkspaceService,
|
||||
type AIProvider
|
||||
type AIProvider,
|
||||
type CompletedJob
|
||||
} from '$lib/gen'
|
||||
import { validateUsername } from '$lib/utils'
|
||||
import { logoutWithRedirect } from '$lib/logout'
|
||||
@@ -79,8 +81,41 @@
|
||||
const WM_FORK_PREFIX = 'wm-fork-'
|
||||
|
||||
let forkCreationLoading = $state(false)
|
||||
let forkCreationError = $state("")
|
||||
let forkCreationError = $state('')
|
||||
let errorMsgs: string[] = $state([])
|
||||
let failedSyncJobs: string[] = $state([])
|
||||
|
||||
async function fetchFailedSyncJobs(jobs: string[]): Promise<CompletedJob[]> {
|
||||
let ret: CompletedJob[] = []
|
||||
for (const job of jobs) {
|
||||
let j = await JobService.getCompletedJob({
|
||||
id: job,
|
||||
workspace: $workspaceStore!
|
||||
})
|
||||
ret.push(j)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
function isPathVersionLessThan(path: string | undefined, version: number): boolean {
|
||||
if (!path || !path.startsWith('hub/')) {
|
||||
return false
|
||||
}
|
||||
|
||||
const parts = path.split('/')
|
||||
|
||||
if (parts.length < 2) {
|
||||
return false
|
||||
}
|
||||
|
||||
const embeddedVersion = parseInt(parts[1], 10)
|
||||
|
||||
if (isNaN(embeddedVersion)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return embeddedVersion < version
|
||||
}
|
||||
|
||||
async function createOrForkWorkspace() {
|
||||
const prefixed_id = `${WM_FORK_PREFIX}${id}`
|
||||
@@ -88,7 +123,8 @@
|
||||
if ($workspaceStore) {
|
||||
forkCreationLoading = true
|
||||
errorMsgs = []
|
||||
forkCreationError = ""
|
||||
failedSyncJobs = []
|
||||
forkCreationError = ''
|
||||
|
||||
let gitSyncJobIds = await WorkspaceService.createWorkspaceForkGitBranch({
|
||||
workspace: $workspaceStore!,
|
||||
@@ -109,6 +145,7 @@
|
||||
onProgress: (status) => {
|
||||
if (status.status === 'failure') {
|
||||
errorMsgs.push(status.error ?? 'Deploy fork job failed')
|
||||
failedSyncJobs.push(jobId)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -123,7 +160,7 @@
|
||||
return
|
||||
}
|
||||
if (errorMsgs.length != 0) {
|
||||
forkCreationError = "Failed to create a branch for this fork on the git sync repo(s)"
|
||||
forkCreationError = 'Failed to create a branch for this fork on the git sync repo(s)'
|
||||
forkCreationLoading = false
|
||||
sendUserToast(
|
||||
`Could not fork workspace ${$workspaceStore} because branch creation failed: ${errorMsgs}`,
|
||||
@@ -143,7 +180,7 @@
|
||||
})
|
||||
} catch (e) {
|
||||
forkCreationError = `Failed to create fork '${prefixed_id}'`
|
||||
errorMsgs.push(e?.body ?? e ?? "Unknown error")
|
||||
errorMsgs.push(e?.body ?? e ?? 'Unknown error')
|
||||
forkCreationLoading = false
|
||||
sendUserToast(`Could not create fork '${prefixed_id}' ${e}`, true)
|
||||
return
|
||||
@@ -319,11 +356,57 @@
|
||||
{/if}
|
||||
{#if errorMsgs.length != 0}
|
||||
<Alert class="p-2" title={forkCreationError} type="error">
|
||||
<ul class="pl-2 pr-4 break-words">
|
||||
<ul class="pl-2 pr-4 break-words pb-5">
|
||||
{#each errorMsgs as errorMsg}
|
||||
<li><pre class="whitespace-pre-wrap">- {errorMsg}</pre></li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if failedSyncJobs.length != 0}
|
||||
More details on the jobs that failed:
|
||||
{#await fetchFailedSyncJobs(failedSyncJobs)}
|
||||
<LoaderCircle class="animate-spin" />
|
||||
{:then failedJobs}
|
||||
<ul class="pl-2 pr-4 break-words">
|
||||
{#each failedJobs as job}
|
||||
<li>
|
||||
-
|
||||
<a
|
||||
target="_blank"
|
||||
class="underline"
|
||||
href={`/run/${job.id}?workspace=${$workspaceStore}`}
|
||||
>
|
||||
{job.id}
|
||||
</a>
|
||||
</li>
|
||||
<!-- This 28073 is the version where git sync on fork was introduced -->
|
||||
{#if isPathVersionLessThan(job.script_path, 28073)}
|
||||
<div class="font-bold">
|
||||
This job was not running the latest version of the git sync script available on
|
||||
the hub. You might be able to solve this issue by going to `Workspace Settings`
|
||||
-> `Git Sync` and updating the script.
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</ul>
|
||||
{:catch error}
|
||||
Tried to fetch jobs to get more information, but failed: {error}. Here are the failed
|
||||
job ids:
|
||||
<ul class="pl-2 pr-4 break-words">
|
||||
{#each failedSyncJobs as jobId}
|
||||
<li>
|
||||
-
|
||||
<a
|
||||
target="_blank"
|
||||
class="underline"
|
||||
href={`/run/${jobId}?workspace=${$workspaceStore}`}
|
||||
>
|
||||
{jobId}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/await}
|
||||
{/if}
|
||||
</Alert>
|
||||
{/if}
|
||||
<label class="flex flex-col gap-1">
|
||||
|
||||
@@ -23,7 +23,11 @@ import initPythonParser, { parse_assets_py, parse_python } from 'windmill-parser
|
||||
import initGoParser, { parse_go } from 'windmill-parser-wasm-go'
|
||||
import initPhpParser, { parse_php } from 'windmill-parser-wasm-php'
|
||||
import initRustParser, { parse_rust } from 'windmill-parser-wasm-rust'
|
||||
import initYamlParser, { parse_assets_ansible, parse_ansible, parse_ansible_delegate } from 'windmill-parser-wasm-yaml'
|
||||
import initYamlParser, {
|
||||
parse_assets_ansible,
|
||||
parse_ansible,
|
||||
parse_ansible_delegate
|
||||
} from 'windmill-parser-wasm-yaml'
|
||||
import initCSharpParser, { parse_csharp } from 'windmill-parser-wasm-csharp'
|
||||
import initNuParser, { parse_nu } from 'windmill-parser-wasm-nu'
|
||||
import initJavaParser, { parse_java } from 'windmill-parser-wasm-java'
|
||||
@@ -244,7 +248,7 @@ export async function inferArgs(
|
||||
inferedSchema = JSON.parse(parse_powershell(code))
|
||||
} else if (language == 'php') {
|
||||
await initWasmPhp()
|
||||
inferedSchema = JSON.parse(parse_php(code))
|
||||
inferedSchema = JSON.parse(parse_php(code, mainOverride))
|
||||
} else if (language == 'rust') {
|
||||
await initWasmRust()
|
||||
inferedSchema = JSON.parse(parse_rust(code))
|
||||
|
||||
@@ -248,9 +248,9 @@ export async function main(message: string, name: string, step_id: string) {
|
||||
}
|
||||
`
|
||||
|
||||
const POSTGRES_INIT_CODE = `-- to pin the database use '-- database f/your/path'
|
||||
const POSTGRES_INIT_CODE = `-- result_collection=last_statement_all_rows
|
||||
-- to pin the database use '-- database f/your/path'
|
||||
-- to stream a large query result to your workspace storage use '-- s3'
|
||||
-- to only return the result of the last query use '--return_last_result'
|
||||
-- $1 name1 = default arg
|
||||
-- $2 name2
|
||||
-- $3 name3
|
||||
@@ -259,7 +259,8 @@ INSERT INTO demo VALUES (\$1::TEXT, \$2::INT, \$3::TEXT[]) RETURNING *;
|
||||
UPDATE demo SET col2 = \$4::INT WHERE col2 = \$2::INT;
|
||||
`
|
||||
|
||||
const MYSQL_INIT_CODE = `-- to pin the database use '-- database f/your/path'
|
||||
const MYSQL_INIT_CODE = `-- result_collection=last_statement_all_rows
|
||||
-- to pin the database use '-- database f/your/path'
|
||||
-- to stream a large query result to your workspace storage use '-- s3'
|
||||
-- :name1 (text) = default arg
|
||||
-- :name2 (int)
|
||||
@@ -268,7 +269,8 @@ INSERT INTO demo VALUES (:name1, :name2);
|
||||
UPDATE demo SET col2 = :name3 WHERE col2 = :name2;
|
||||
`
|
||||
|
||||
const BIGQUERY_INIT_CODE = `-- to pin the database use '-- database f/your/path'
|
||||
const BIGQUERY_INIT_CODE = `-- result_collection=last_statement_all_rows
|
||||
-- to pin the database use '-- database f/your/path'
|
||||
-- to stream a large query result to your workspace storage use '-- s3'
|
||||
-- @name1 (string) = default arg
|
||||
-- @name2 (integer)
|
||||
@@ -278,7 +280,8 @@ INSERT INTO \`demodb.demo\` VALUES (@name1, @name2, @name3);
|
||||
UPDATE \`demodb.demo\` SET col2 = @name4 WHERE col2 = @name2;
|
||||
`
|
||||
|
||||
const ORACLEDB_INIT_CODE = `-- to pin the database use '-- database f/your/path'
|
||||
const ORACLEDB_INIT_CODE = `-- result_collection=last_statement_all_rows
|
||||
-- to pin the database use '-- database f/your/path'
|
||||
-- to stream a large query result to your workspace storage use '-- s3'
|
||||
-- :name1 (text) = default arg
|
||||
-- :name2 (int)
|
||||
@@ -287,7 +290,8 @@ INSERT INTO demo VALUES (:name1, :name2);
|
||||
UPDATE demo SET col2 = :name3 WHERE col2 = :name2;
|
||||
`
|
||||
|
||||
const SNOWFLAKE_INIT_CODE = `-- to pin the database use '-- database f/your/path'
|
||||
const SNOWFLAKE_INIT_CODE = `-- result_collection=last_statement_all_rows
|
||||
-- to pin the database use '-- database f/your/path'
|
||||
-- to stream a large query result to your workspace storage use '-- s3'
|
||||
-- ? name1 (varchar) = default arg
|
||||
-- ? name2 (int)
|
||||
@@ -297,7 +301,7 @@ INSERT INTO demo VALUES (?, ?);
|
||||
UPDATE demo SET col2 = ? WHERE col2 = ?;
|
||||
`
|
||||
|
||||
const MSSQL_INIT_CODE = `-- return_last_result
|
||||
const MSSQL_INIT_CODE = `-- result_collection=last_statement_all_rows
|
||||
-- to pin the database use '-- database f/your/path'
|
||||
-- to stream a large query result to your workspace storage use '-- s3'
|
||||
-- @P1 name1 (varchar) = default arg
|
||||
@@ -307,7 +311,8 @@ INSERT INTO demo VALUES (@P1, @P2);
|
||||
UPDATE demo SET col2 = @P3 WHERE col2 = @P2;
|
||||
`
|
||||
|
||||
const DUCKDB_INIT_CODE = `-- $name (text) = Ben
|
||||
const DUCKDB_INIT_CODE = `-- result_collection=last_statement_all_rows
|
||||
-- $name (text) = Ben
|
||||
-- $age (text) = 20
|
||||
-- -- $friends_csv (s3object)
|
||||
|
||||
@@ -969,6 +974,86 @@ def preprocessor(event: Event):
|
||||
}
|
||||
`
|
||||
|
||||
export const PHP_PREPROCESSOR_SCRIPT_INTRO = `<?php
|
||||
/**
|
||||
* Trigger preprocessor
|
||||
*
|
||||
* ⚠️ This function runs BEFORE the main function.
|
||||
*
|
||||
* It processes raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, Postgres, or email)
|
||||
* before passing it to \`main\`. This separates the trigger logic from the main logic and keeps the auto-generated runnable UI clean.
|
||||
*
|
||||
* The returned object defines the parameter values passed to \`main()\`.
|
||||
* e.g., ['b' => 1, 'a' => 2] → Calls \`main(2, 1)\`, assuming \`main\` is defined as \`main($a, $b)\`.
|
||||
* Ensure that the parameter names in \`main\` match the keys in the returned array.
|
||||
*
|
||||
* Learn more: https://www.windmill.dev/docs/core_concepts/preprocessors
|
||||
*/
|
||||
|
||||
`
|
||||
|
||||
export const PHP_PREPROCESSOR_FLOW_INTRO = `<?php
|
||||
/**
|
||||
* Trigger preprocessor
|
||||
*
|
||||
* It processes raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, Postgres, or email)
|
||||
* before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean.
|
||||
*
|
||||
* The returned object determines the parameter values passed to the flow.
|
||||
* e.g., ['b' => 1, 'a' => 2] → Calls the flow with \`a = 2\` and \`b = 1\`, assuming the flow has two inputs called \`a\` and \`b\`.
|
||||
* Ensure that the input names of the flow match the keys in the returned array.
|
||||
*
|
||||
* Learn more: https://www.windmill.dev/docs/core_concepts/preprocessors
|
||||
*/
|
||||
|
||||
`
|
||||
|
||||
export const PHP_PREPROCESSOR_MODULE_CODE = `function preprocessor(object $event) {
|
||||
// $event can be one of the following types:
|
||||
//
|
||||
// Webhook event:
|
||||
// ['kind' => 'webhook', 'body' => [...], 'raw_string' => '...', 'query' => [...], 'headers' => [...]]
|
||||
//
|
||||
// HTTP event:
|
||||
// ['kind' => 'http', 'body' => [...], 'raw_string' => '...', 'route' => '...', 'path' => '...',
|
||||
// 'method' => '...', 'params' => [...], 'query' => [...], 'headers' => [...]]
|
||||
//
|
||||
// Email event:
|
||||
// ['kind' => 'email', 'parsed_email' => [...], 'raw_email' => '...', 'email_extra_args' => [...]]
|
||||
//
|
||||
// WebSocket event:
|
||||
// ['kind' => 'websocket', 'msg' => '...', 'url' => '...']
|
||||
//
|
||||
// Kafka event:
|
||||
// ['kind' => 'kafka', 'payload' => '...', 'brokers' => [...], 'topic' => '...', 'group_id' => '...']
|
||||
//
|
||||
// NATS event:
|
||||
// ['kind' => 'nats', 'payload' => '...', 'servers' => [...], 'subject' => '...',
|
||||
// 'headers' => [...], 'status' => 200, 'description' => '...', 'length' => 100]
|
||||
//
|
||||
// SQS event:
|
||||
// ['kind' => 'sqs', 'msg' => '...', 'queue_url' => '...', 'message_id' => '...',
|
||||
// 'receipt_handle' => '...', 'attributes' => [...], 'message_attributes' => [...]]
|
||||
//
|
||||
// MQTT event:
|
||||
// ['kind' => 'mqtt', 'payload' => '...', 'topic' => '...', 'retain' => true, 'pkid' => 1,
|
||||
// 'qos' => 1, 'v5' => [...]]
|
||||
//
|
||||
// GCP event:
|
||||
// ['kind' => 'gcp', 'payload' => '...', 'message_id' => '...', 'subscription' => '...',
|
||||
// 'ordering_key' => '...', 'attributes' => [...], 'delivery_type' => 'push',
|
||||
// 'headers' => [...], 'publish_time' => '...', 'ack_id' => '...']
|
||||
//
|
||||
// Postgres event:
|
||||
// ['kind' => 'postgres', 'transaction_type' => 'insert', 'schema_name' => '...',
|
||||
// 'table_name' => '...', 'old_row' => [...], 'row' => [...]]
|
||||
|
||||
return [
|
||||
// return the args to be passed to the runnable
|
||||
];
|
||||
}
|
||||
`
|
||||
|
||||
const DOCKER_INIT_CODE = `# shellcheck shell=bash
|
||||
# docker
|
||||
# The annotation "docker" above is important, it tells windmill that after
|
||||
@@ -1226,7 +1311,8 @@ export const INITIAL_CODE = {
|
||||
script: ORACLEDB_INIT_CODE
|
||||
},
|
||||
php: {
|
||||
script: PHP_INIT_CODE
|
||||
script: PHP_INIT_CODE,
|
||||
preprocessor: PHP_PREPROCESSOR_FLOW_INTRO + PHP_PREPROCESSOR_MODULE_CODE
|
||||
},
|
||||
rust: {
|
||||
script: RUST_INIT_CODE
|
||||
@@ -1349,6 +1435,9 @@ export function initialCode(
|
||||
} else if (language == 'duckdb') {
|
||||
return INITIAL_CODE.duckdb.script
|
||||
} else if (language == 'php') {
|
||||
if (kind == 'preprocessor') {
|
||||
return INITIAL_CODE.php.preprocessor
|
||||
}
|
||||
return INITIAL_CODE.php.script
|
||||
} else if (language == 'rust') {
|
||||
return INITIAL_CODE.rust.script
|
||||
@@ -1420,3 +1509,115 @@ export function getResetCode(
|
||||
return initialCode(language, kind, subkind)
|
||||
}
|
||||
}
|
||||
|
||||
export const PREPROCESSOR_SUPPORTED_LANGUAGES = [
|
||||
'typescript',
|
||||
'python',
|
||||
'python3',
|
||||
'deno',
|
||||
'bun',
|
||||
'php'
|
||||
] as const
|
||||
|
||||
export function canHavePreprocessor(language: string | undefined): boolean {
|
||||
if (!language) {
|
||||
return false
|
||||
}
|
||||
|
||||
return PREPROCESSOR_SUPPORTED_LANGUAGES.includes(language as any)
|
||||
}
|
||||
|
||||
export function canHaveTrigger(language: SupportedLanguage | undefined): boolean {
|
||||
if (!language) {
|
||||
return false
|
||||
}
|
||||
|
||||
return ['python3', 'bun', 'deno', 'go'].includes(language)
|
||||
}
|
||||
|
||||
export function canHaveApproval(language: SupportedLanguage | undefined): boolean {
|
||||
if (!language) {
|
||||
return false
|
||||
}
|
||||
|
||||
return ['python3', 'bun', 'deno'].includes(language)
|
||||
}
|
||||
|
||||
export function canHaveFailure(language: SupportedLanguage | undefined): boolean {
|
||||
if (!language) {
|
||||
return false
|
||||
}
|
||||
|
||||
return ['python3', 'bun', 'deno', 'go'].includes(language)
|
||||
}
|
||||
|
||||
export function getPreprocessorIntro(
|
||||
language: SupportedLanguage | 'docker' | 'bunnative' | undefined,
|
||||
isFlow: boolean = false
|
||||
): string {
|
||||
if (!language || !PREPROCESSOR_SUPPORTED_LANGUAGES.includes(language as any)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
switch (language) {
|
||||
case 'python3':
|
||||
return isFlow ? PYTHON_PREPROCESSOR_FLOW_INTRO : PYTHON_PREPROCESSOR_SCRIPT_INTRO
|
||||
case 'deno':
|
||||
case 'bun':
|
||||
return isFlow ? TS_PREPROCESSOR_FLOW_INTRO : TS_PREPROCESSOR_SCRIPT_INTRO
|
||||
case 'php':
|
||||
return isFlow ? PHP_PREPROCESSOR_FLOW_INTRO : PHP_PREPROCESSOR_SCRIPT_INTRO
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export function getPreprocessorModuleCode(
|
||||
language: SupportedLanguage | 'docker' | 'bunnative' | undefined
|
||||
): string {
|
||||
if (!language || !PREPROCESSOR_SUPPORTED_LANGUAGES.includes(language as any)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
switch (language) {
|
||||
case 'python3':
|
||||
return PYTHON_PREPROCESSOR_MODULE_CODE
|
||||
case 'deno':
|
||||
case 'bun':
|
||||
return TS_PREPROCESSOR_MODULE_CODE
|
||||
case 'php':
|
||||
return PHP_PREPROCESSOR_MODULE_CODE
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export function getPreprocessorFullCode(
|
||||
language: SupportedLanguage | 'docker' | 'bunnative' | undefined,
|
||||
isFlow: boolean = false
|
||||
): string {
|
||||
const intro = getPreprocessorIntro(language, isFlow)
|
||||
const moduleCode = getPreprocessorModuleCode(language)
|
||||
return intro + moduleCode
|
||||
}
|
||||
|
||||
export function getMainFunctionPattern(
|
||||
language: SupportedLanguage | 'docker' | 'bunnative' | undefined
|
||||
): string {
|
||||
if (!language) {
|
||||
return ''
|
||||
}
|
||||
|
||||
switch (language) {
|
||||
case 'python3':
|
||||
return 'def main'
|
||||
case 'deno':
|
||||
case 'bun':
|
||||
case 'nativets':
|
||||
return 'export async function main'
|
||||
case 'php':
|
||||
return 'function main'
|
||||
default:
|
||||
return 'main'
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user