From 289549a048e96019e16d84f40ebbb5f0f58ff5eb Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Wed, 20 May 2026 17:24:07 +0200 Subject: [PATCH 01/71] refactor: move google ai proxy handling to windmill-ai (#9260) * refactor: add ai proxy execution mode * refactor: move google ai proxy handling * refactor: share google ai request building --- backend/Cargo.lock | 1 + backend/windmill-ai/Cargo.toml | 1 + .../windmill-ai/src/providers/google_ai.rs | 540 +++++++++++++++++- backend/windmill-ai/src/proxy.rs | 66 ++- backend/windmill-api/src/ai.rs | 189 +++--- backend/windmill-api/src/google.rs | 354 ------------ backend/windmill-api/src/lib.rs | 1 - docs/windmill-ai-refactor-plan.md | 37 +- 8 files changed, 678 insertions(+), 511 deletions(-) delete mode 100644 backend/windmill-api/src/google.rs diff --git a/backend/Cargo.lock b/backend/Cargo.lock index b8b0ab8625..8e590776cd 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -13871,6 +13871,7 @@ dependencies = [ name = "windmill-ai" version = "1.704.1" dependencies = [ + "async-stream", "async-trait", "aws-config", "aws-credential-types", diff --git a/backend/windmill-ai/Cargo.toml b/backend/windmill-ai/Cargo.toml index b419101f25..8f2f679c7e 100644 --- a/backend/windmill-ai/Cargo.toml +++ b/backend/windmill-ai/Cargo.toml @@ -20,6 +20,7 @@ windmill-parser.workspace = true windmill-mcp = { workspace = true, optional = true } async-trait.workspace = true +async-stream.workspace = true base64.workspace = true bytes.workspace = true eventsource-stream.workspace = true diff --git a/backend/windmill-ai/src/providers/google_ai.rs b/backend/windmill-ai/src/providers/google_ai.rs index 81f34e7acb..b6a6295d88 100644 --- a/backend/windmill-ai/src/providers/google_ai.rs +++ b/backend/windmill-ai/src/providers/google_ai.rs @@ -1,15 +1,24 @@ use crate::{ ai_google::{ - openai_messages_to_gemini, openai_tools_to_gemini, GeminiGenerationConfig, + gemini_event_to_openai_sse_chunks, gemini_response_to_openai, openai_messages_to_gemini, + openai_tools_to_gemini, parse_gemini_response, parse_gemini_sse_event, + sanitize_schema_for_google, GeminiFunctionDeclaration, GeminiGenerationConfig, GeminiImageContent, GeminiImageRequest, GeminiImageResponse, GeminiInlineData, GeminiPart, GeminiPredictContent, GeminiTextRequest, GeminiTool, }, image_handler::{download_and_encode_s3_image, prepare_messages_for_api}, + proxy::{ProxyBuildArgs, ProxyRequest}, query_builder::{BuildRequestArgs, ParsedResponse, QueryBuilder, StreamEventSink}, sse::{GeminiSSEParser, SSEParser}, types::*, }; use async_trait::async_trait; +use bytes::Bytes; +use eventsource_stream::Eventsource; +use futures::{stream::BoxStream, StreamExt}; +use http::{header, HeaderMap, HeaderValue, Method, StatusCode}; +use serde::Deserialize; +use serde_json::json; use windmill_common::{client::AuthedClient, error::Error}; // ============================================================================ @@ -37,22 +46,11 @@ impl GoogleAIQueryBuilder { ) -> Result { let prepared_messages = prepare_messages_for_api(args.messages, client, workspace_id).await?; - let (contents, system_instruction) = openai_messages_to_gemini(&prepared_messages); - - let tools = self.convert_tools_to_gemini(args.tools, args.has_websearch); - - let generation_config = self.build_generation_config(args); - - let request = GeminiTextRequest { - contents, - tools, - tool_config: None, - system_instruction, - generation_config, - }; - - serde_json::to_string(&request) - .map_err(|e| Error::internal_err(format!("Failed to serialize request: {}", e))) + build_gemini_text_request_body( + &prepared_messages, + self.convert_tools_to_gemini(args.tools, args.has_websearch), + self.build_generation_config(args), + ) } async fn build_image_request( @@ -155,19 +153,378 @@ impl GoogleAIQueryBuilder { (None, None) }; - if args.temperature.is_some() || args.max_tokens.is_some() || response_mime_type.is_some() { - Some(GeminiGenerationConfig { - temperature: args.temperature, - max_output_tokens: args.max_tokens, - response_mime_type, - response_schema, - }) - } else { - None - } + build_gemini_generation_config( + args.temperature, + args.max_tokens, + response_mime_type, + response_schema, + ) } } +fn build_gemini_text_request_body( + messages: &[OpenAIMessage], + tools: Option>, + generation_config: Option, +) -> Result { + let request = build_gemini_text_request(messages, tools, generation_config); + serde_json::to_string(&request) + .map_err(|e| Error::internal_err(format!("Failed to serialize Gemini request: {}", e))) +} + +fn build_gemini_text_request( + messages: &[OpenAIMessage], + tools: Option>, + generation_config: Option, +) -> GeminiTextRequest { + let (contents, system_instruction) = openai_messages_to_gemini(messages); + + GeminiTextRequest { contents, tools, tool_config: None, system_instruction, generation_config } +} + +fn build_gemini_generation_config( + temperature: Option, + max_tokens: Option, + response_mime_type: Option, + response_schema: Option, +) -> Option { + if temperature.is_some() + || max_tokens.is_some() + || response_mime_type.is_some() + || response_schema.is_some() + { + Some(GeminiGenerationConfig { + temperature, + max_output_tokens: max_tokens, + response_mime_type, + response_schema, + }) + } else { + None + } +} + +#[derive(Deserialize, Debug)] +struct GoogleAIProxyChatRequest { + model: String, + messages: Vec, + #[serde(default)] + stream: bool, + #[serde(default)] + temperature: Option, + #[serde(default)] + max_tokens: Option, + #[serde(default)] + tools: Option>, +} + +#[derive(Deserialize, Debug)] +struct GoogleAIProxyChatTool { + function: GoogleAIProxyChatToolFunction, +} + +#[derive(Deserialize, Debug)] +struct GoogleAIProxyChatToolFunction { + name: String, + #[serde(default)] + description: Option, + #[serde(default)] + parameters: Option, +} + +#[derive(Deserialize)] +struct GeminiModel { + name: String, + #[serde(rename = "displayName", default)] + display_name: String, +} + +#[derive(Deserialize)] +struct GeminiModelsResponse { + #[serde(default)] + models: Vec, +} + +struct GoogleAIProxyRequest { + request: ProxyRequest, + model: String, + stream: bool, +} + +pub enum GoogleAIProxyResponseBody { + Fixed(Bytes), + Stream(BoxStream<'static, std::result::Result>), +} + +pub struct GoogleAIProxyResponse { + pub status_code: StatusCode, + pub headers: HeaderMap, + pub body: GoogleAIProxyResponseBody, +} + +/// Handle a workspace Google AI chat proxy request. +/// +/// The API still owns credential resolution, auditing, and keepalive injection. +/// Callers must verify the user can use the supplied credentials before calling. +/// This helper owns the provider-specific OpenAI <-> Gemini transformations. +pub async fn handle_google_ai_chat_proxy( + client: &reqwest::Client, + args: &ProxyBuildArgs<'_>, +) -> Result { + let GoogleAIProxyRequest { request, model, stream } = build_google_ai_chat_proxy_request(args)?; + + let response = + send_google_ai_proxy_request(client, request, "Failed to send request to Gemini API") + .await?; + + if stream { + Ok(convert_streaming_response(response, &model)) + } else { + convert_non_streaming_response(response, &model).await + } +} + +/// Handle a workspace Google AI model-list proxy request. +/// +/// The API still owns credential resolution and auditing. Callers must verify +/// the user can use the supplied credentials before calling. +pub async fn handle_google_ai_models_proxy( + client: &reqwest::Client, + args: &ProxyBuildArgs<'_>, +) -> Result { + let request = build_google_ai_models_proxy_request(args); + let response = + send_google_ai_proxy_request(client, request, "Failed to fetch Gemini models").await?; + + let gemini_resp: GeminiModelsResponse = response.json().await.map_err(|e| { + Error::internal_err(format!("Failed to parse Gemini models response: {}", e)) + })?; + + let data: Vec = gemini_resp + .models + .into_iter() + .map(|m| { + json!({ + "id": m.name, + "object": "model", + "display_name": m.display_name, + }) + }) + .collect(); + + let body = serde_json::to_vec(&json!({ "data": data })) + .map_err(|e| Error::internal_err(format!("Failed to serialize models: {}", e)))?; + + Ok(GoogleAIProxyResponse { + status_code: StatusCode::OK, + headers: json_response_headers(), + body: GoogleAIProxyResponseBody::Fixed(Bytes::from(body)), + }) +} + +fn build_google_ai_chat_proxy_request( + args: &ProxyBuildArgs<'_>, +) -> Result { + let request: GoogleAIProxyChatRequest = serde_json::from_slice(args.body) + .map_err(|e| Error::BadRequest(format!("Failed to parse request body: {}", e)))?; + + let gemini_tools = request.tools.as_ref().map(|tools| { + let declarations: Vec = tools + .iter() + .map(|t| { + let mut params = t.function.parameters.clone().unwrap_or(json!({})); + sanitize_schema_for_google(&mut params); + GeminiFunctionDeclaration { + name: t.function.name.clone(), + description: t.function.description.clone(), + parameters: params, + } + }) + .collect(); + vec![GeminiTool { function_declarations: Some(declarations), google_search: None }] + }); + + let body = build_gemini_text_request_body( + &request.messages, + gemini_tools, + build_gemini_generation_config(request.temperature, request.max_tokens, None, None), + )? + .into_bytes(); + + let credentials = args.credentials; + let base_url = credentials.base_url.trim_end_matches('/'); + let is_vertex = credentials.platform == AIPlatform::GoogleVertexAi; + let endpoint = if request.stream { + format!( + "{}?alt=sse", + build_google_ai_model_endpoint( + base_url, + &request.model, + "streamGenerateContent", + is_vertex, + ) + ) + } else { + build_google_ai_model_endpoint(base_url, &request.model, "generateContent", is_vertex) + }; + + let mut headers = vec![("content-type".to_string(), "application/json".to_string())]; + add_google_ai_auth_header( + &mut headers, + credentials.api_key.as_deref().unwrap_or(""), + is_vertex, + ); + + Ok(GoogleAIProxyRequest { + request: ProxyRequest { method: Method::POST, url: endpoint, headers, body }, + model: request.model, + stream: request.stream, + }) +} + +fn build_google_ai_models_proxy_request(args: &ProxyBuildArgs<'_>) -> ProxyRequest { + let credentials = args.credentials; + let base_url = credentials.base_url.trim_end_matches('/'); + let is_vertex = credentials.platform == AIPlatform::GoogleVertexAi; + let url = if is_vertex { + base_url.to_string() + } else { + format!("{}/models", base_url) + }; + + let mut headers = Vec::new(); + add_google_ai_auth_header( + &mut headers, + credentials.api_key.as_deref().unwrap_or(""), + is_vertex, + ); + + ProxyRequest { method: Method::GET, url, headers, body: Vec::new() } +} + +fn build_google_ai_model_endpoint( + base_url: &str, + model: &str, + action: &str, + is_vertex: bool, +) -> String { + if is_vertex { + format!("{}/{}:{}", base_url, model, action) + } else { + format!("{}/models/{}:{}", base_url, model, action) + } +} + +fn add_google_ai_auth_header(headers: &mut Vec<(String, String)>, api_key: &str, is_vertex: bool) { + if is_vertex { + headers.push(("Authorization".to_string(), format!("Bearer {}", api_key))); + } else { + headers.push(("x-goog-api-key".to_string(), api_key.to_string())); + } +} + +async fn send_google_ai_proxy_request( + client: &reqwest::Client, + proxy_request: ProxyRequest, + send_error_message: &str, +) -> Result { + let mut request = client.request(proxy_request.method.clone(), &proxy_request.url); + for (header_name, header_value) in &proxy_request.headers { + request = request.header(header_name.as_str(), header_value.as_str()); + } + + let response = request + .body(proxy_request.body) + .send() + .await + .map_err(|e| Error::internal_err(format!("{}: {}", send_error_message, e)))?; + + if let Err(e) = response.error_for_status_ref() { + let status = e.status().map(|s| s.to_string()).unwrap_or_default(); + let body = response.text().await.unwrap_or_default(); + return Err(Error::AIError(format!("{}: {}", status, body))); + } + + Ok(response) +} + +fn convert_streaming_response(response: reqwest::Response, model: &str) -> GoogleAIProxyResponse { + let id = format!("chatcmpl-{}", uuid::Uuid::new_v4().simple()); + let model = model.to_string(); + + let gemini_sse_stream = response.bytes_stream().eventsource(); + let openai_sse_stream = async_stream::stream! { + tokio::pin!(gemini_sse_stream); + let mut tool_call_index: usize = 0; + while let Some(event) = gemini_sse_stream.next().await { + match event { + Ok(event) => match parse_gemini_sse_event(&event.data) { + Ok(Some(parsed)) => { + for chunk in gemini_event_to_openai_sse_chunks( + &parsed, &id, &model, &mut tool_call_index, + ) { + yield Ok::(Bytes::from(chunk)); + } + } + Ok(None) => {} + Err(e) => tracing::error!("Error parsing Gemini SSE event: {}", e), + }, + Err(e) => tracing::error!("Error reading Gemini SSE stream: {}", e), + } + } + yield Ok::(Bytes::from("data: [DONE]\n\n")); + } + .boxed(); + + GoogleAIProxyResponse { + status_code: StatusCode::OK, + headers: event_stream_response_headers(), + body: GoogleAIProxyResponseBody::Stream(openai_sse_stream), + } +} + +async fn convert_non_streaming_response( + response: reqwest::Response, + model: &str, +) -> Result { + let body = response + .bytes() + .await + .map_err(|e| Error::internal_err(format!("Failed to read Gemini response body: {}", e)))?; + + let parsed = parse_gemini_response(&body)?; + let openai_response = gemini_response_to_openai(&parsed, model); + + let body = serde_json::to_vec(&openai_response) + .map_err(|e| Error::internal_err(format!("Failed to serialize response: {}", e)))?; + + Ok(GoogleAIProxyResponse { + status_code: StatusCode::OK, + headers: json_response_headers(), + body: GoogleAIProxyResponseBody::Fixed(Bytes::from(body)), + }) +} + +fn json_response_headers() -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ); + headers +} + +fn event_stream_response_headers() -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/event-stream"), + ); + headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache")); + headers.insert(header::CONNECTION, HeaderValue::from_static("keep-alive")); + headers +} + #[async_trait] impl QueryBuilder for GoogleAIQueryBuilder { fn supports_tools_with_output_type(&self, output_type: &OutputType) -> bool { @@ -324,3 +681,132 @@ impl QueryBuilder for GoogleAIQueryBuilder { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ai_providers::AIProvider, proxy::ProviderCredentials}; + use std::collections::HashMap; + + fn credentials(base_url: &str, platform: AIPlatform) -> ProviderCredentials { + ProviderCredentials { + provider: AIProvider::GoogleAI, + base_url: base_url.to_string(), + api_key: Some("api-key".to_string()), + access_token: None, + organization_id: None, + user: None, + region: None, + aws_access_key_id: None, + aws_secret_access_key: None, + aws_session_token: None, + platform, + enable_1m_context: false, + custom_headers: HashMap::new(), + } + } + + #[test] + fn builds_standard_google_ai_chat_proxy_request() { + let credentials = credentials( + "https://generativelanguage.googleapis.com/v1beta/", + AIPlatform::Standard, + ); + let method = Method::POST; + let headers = HeaderMap::new(); + let body = br#"{ + "model": "gemini-2.0-flash", + "messages": [{"role": "user", "content": "hello"}], + "temperature": 0.2, + "max_tokens": 123, + "stream": false + }"#; + + let request = build_google_ai_chat_proxy_request(&ProxyBuildArgs { + method: &method, + path: "chat/completions", + headers: &headers, + body, + credentials: &credentials, + }) + .unwrap(); + + assert_eq!(request.request.method, Method::POST); + assert_eq!( + request.request.url, + "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent" + ); + assert!(!request.stream); + assert_eq!(request.model, "gemini-2.0-flash"); + assert!(request + .request + .headers + .contains(&("x-goog-api-key".to_string(), "api-key".to_string()))); + + let body: serde_json::Value = serde_json::from_slice(&request.request.body).unwrap(); + assert_eq!(body["generationConfig"]["maxOutputTokens"], 123); + assert_eq!(body["generationConfig"]["temperature"], 0.2); + assert!(body["contents"].is_array()); + } + + #[test] + fn builds_vertex_google_ai_streaming_proxy_request() { + let credentials = credentials( + "https://us-central1-aiplatform.googleapis.com/v1/projects/p/locations/us-central1/publishers/google/models/", + AIPlatform::GoogleVertexAi, + ); + let method = Method::POST; + let headers = HeaderMap::new(); + let body = br#"{ + "model": "gemini-2.0-flash", + "messages": [{"role": "user", "content": "hello"}], + "stream": true + }"#; + + let request = build_google_ai_chat_proxy_request(&ProxyBuildArgs { + method: &method, + path: "chat/completions", + headers: &headers, + body, + credentials: &credentials, + }) + .unwrap(); + + assert_eq!( + request.request.url, + "https://us-central1-aiplatform.googleapis.com/v1/projects/p/locations/us-central1/publishers/google/models/gemini-2.0-flash:streamGenerateContent?alt=sse" + ); + assert!(request.stream); + assert!(request + .request + .headers + .contains(&("Authorization".to_string(), "Bearer api-key".to_string()))); + } + + #[test] + fn builds_google_ai_models_proxy_request() { + let credentials = credentials( + "https://generativelanguage.googleapis.com/v1beta/", + AIPlatform::Standard, + ); + let method = Method::GET; + let headers = HeaderMap::new(); + + let request = build_google_ai_models_proxy_request(&ProxyBuildArgs { + method: &method, + path: "models", + headers: &headers, + body: &[], + credentials: &credentials, + }); + + assert_eq!(request.method, Method::GET); + assert_eq!( + request.url, + "https://generativelanguage.googleapis.com/v1beta/models" + ); + assert!(request + .headers + .contains(&("x-goog-api-key".to_string(), "api-key".to_string()))); + } +} diff --git a/backend/windmill-ai/src/proxy.rs b/backend/windmill-ai/src/proxy.rs index 21b92705db..bbc570a18a 100644 --- a/backend/windmill-ai/src/proxy.rs +++ b/backend/windmill-ai/src/proxy.rs @@ -46,6 +46,24 @@ pub struct ProxyRequest { pub body: Vec, } +/// How the API proxy should execute a request for a provider. +/// +/// Most providers can be represented as a transformed HTTP request. Google AI +/// and Bedrock need native execution because their proxy paths also transform +/// responses or call an SDK rather than forwarding an HTTP request directly. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ProxyExecutionMode { + HttpForward, + NativeGoogleAi, + NativeAwsBedrock, +} + +impl ProxyExecutionMode { + pub fn uses_query_builder_proxy(self) -> bool { + matches!(self, Self::HttpForward) + } +} + pub fn supports_openai_compatible_proxy(provider: &AIProvider) -> bool { matches!( provider, @@ -60,8 +78,24 @@ pub fn supports_openai_compatible_proxy(provider: &AIProvider) -> bool { ) } +pub fn proxy_execution_mode(provider: &AIProvider) -> ProxyExecutionMode { + match provider { + AIProvider::OpenAI + | AIProvider::AzureOpenAI + | AIProvider::Anthropic + | AIProvider::Mistral + | AIProvider::DeepSeek + | AIProvider::Groq + | AIProvider::OpenRouter + | AIProvider::TogetherAI + | AIProvider::CustomAI => ProxyExecutionMode::HttpForward, + AIProvider::GoogleAI => ProxyExecutionMode::NativeGoogleAi, + AIProvider::AWSBedrock => ProxyExecutionMode::NativeAwsBedrock, + } +} + pub fn supports_query_builder_proxy(provider: &AIProvider) -> bool { - supports_openai_compatible_proxy(provider) || matches!(provider, AIProvider::Anthropic) + proxy_execution_mode(provider).uses_query_builder_proxy() } pub fn build_openai_compatible_proxy_request(args: &ProxyBuildArgs<'_>) -> Result { @@ -180,10 +214,32 @@ mod tests { #[test] fn query_builder_proxy_support_includes_anthropic() { - assert!(supports_query_builder_proxy(&AIProvider::OpenAI)); - assert!(supports_query_builder_proxy(&AIProvider::Anthropic)); - assert!(!supports_query_builder_proxy(&AIProvider::GoogleAI)); - assert!(!supports_query_builder_proxy(&AIProvider::AWSBedrock)); + let cases = [ + (AIProvider::OpenAI, ProxyExecutionMode::HttpForward), + (AIProvider::AzureOpenAI, ProxyExecutionMode::HttpForward), + (AIProvider::Anthropic, ProxyExecutionMode::HttpForward), + (AIProvider::Mistral, ProxyExecutionMode::HttpForward), + (AIProvider::DeepSeek, ProxyExecutionMode::HttpForward), + (AIProvider::Groq, ProxyExecutionMode::HttpForward), + (AIProvider::OpenRouter, ProxyExecutionMode::HttpForward), + (AIProvider::TogetherAI, ProxyExecutionMode::HttpForward), + (AIProvider::CustomAI, ProxyExecutionMode::HttpForward), + (AIProvider::GoogleAI, ProxyExecutionMode::NativeGoogleAi), + (AIProvider::AWSBedrock, ProxyExecutionMode::NativeAwsBedrock), + ]; + + for (provider, expected_mode) in cases { + let mode = proxy_execution_mode(&provider); + assert_eq!( + mode, expected_mode, + "unexpected proxy mode for {provider:?}" + ); + assert_eq!( + supports_query_builder_proxy(&provider), + mode.uses_query_builder_proxy(), + "query-builder support drifted for {provider:?}" + ); + } } #[test] diff --git a/backend/windmill-api/src/ai.rs b/backend/windmill-api/src/ai.rs index eb337e8125..05bf995700 100644 --- a/backend/windmill-api/src/ai.rs +++ b/backend/windmill-api/src/ai.rs @@ -19,9 +19,16 @@ use windmill_ai::ai_cache::current_instance_ai_config_revision; use windmill_ai::ai_providers::{ empty_string_as_none, AIPlatform, AIProvider, ProviderConfig, ProviderModel, }; -use windmill_ai::providers::create_proxy_query_builder; +use windmill_ai::providers::{ + create_proxy_query_builder, + google_ai::{ + handle_google_ai_chat_proxy, handle_google_ai_models_proxy, GoogleAIProxyResponse, + GoogleAIProxyResponseBody, + }, +}; use windmill_ai::proxy::{ - supports_query_builder_proxy, ProviderCredentials, ProxyBuildArgs, ProxyRequest, + proxy_execution_mode, supports_query_builder_proxy, ProviderCredentials, ProxyBuildArgs, + ProxyExecutionMode, ProxyRequest, }; use windmill_ai::utils::AI_HTTP_HEADERS; use windmill_audit::{audit_oss::audit_log, ActionKind}; @@ -368,82 +375,6 @@ impl AIRequestConfig { Ok(response.access_token) } - pub fn prepare_request( - self, - provider: &AIProvider, - path: &str, - method: Method, - _headers: HeaderMap, - body: Bytes, - ) -> Result { - let credentials = self.into_provider_credentials(provider.clone()); - - let body = if let Some(user) = credentials.user.as_ref() { - Self::add_user_to_body(body, user.clone())? - } else { - body - }; - - let base_url = credentials.base_url.trim_end_matches('/'); - - let is_azure = credentials.provider.is_azure_openai(base_url); - let is_google_ai = credentials.provider == AIProvider::GoogleAI; - - let base_url = base_url.to_string(); - let base_url = base_url.as_str(); - - // Build URL based on provider - let url = if is_azure { - let azure_url = AIProvider::build_azure_openai_url(base_url, path); - azure_url - } else { - let default_url = format!("{}/{}", base_url, path); - default_url - }; - - tracing::debug!("AI request URL: {}", url); - - let mut request = HTTP_CLIENT - .request(method.clone(), &url) - .header("content-type", "application/json"); - - // Add authentication headers - if let Some(api_key) = credentials.api_key { - if is_azure { - request = request.header("api-key", api_key.clone()) - } else if is_google_ai { - // Note: GoogleAI requests are intercepted earlier (see the GoogleAI - // handler block above) and never reach this code path. This branch - // is kept as a safety net for the standard Gemini API auth format. - request = request.header("x-goog-api-key", api_key.clone()) - } else { - request = request.header("authorization", format!("Bearer {}", api_key.clone())) - } - } - - if let Some(access_token) = credentials.access_token { - request = request.header("authorization", format!("Bearer {}", access_token)) - } - - request = request.body(body); - - if let Some(org_id) = credentials.organization_id { - request = request.header("OpenAI-Organization", org_id); - } - - // Apply custom headers from AI_HTTP_HEADERS environment variable - for (header_name, header_value) in AI_HTTP_HEADERS.iter() { - request = request.header(header_name.as_str(), header_value.as_str()); - } - - // Apply custom headers from the resource - for (header_name, header_value) in &credentials.custom_headers { - request = request.header(header_name.as_str(), header_value.as_str()); - } - - Ok(request) - } - fn into_provider_credentials(self, provider: AIProvider) -> ProviderCredentials { ProviderCredentials { provider, @@ -461,24 +392,6 @@ impl AIRequestConfig { custom_headers: self.custom_headers, } } - - fn add_user_to_body(body: Bytes, user: String) -> Result { - tracing::debug!("Adding user to request body"); - let mut json_body: HashMap> = serde_json::from_slice(&body) - .map_err(|e| Error::internal_err(format!("Failed to parse request body: {}", e)))?; - - let user_json_string = serde_json::Value::String(user).to_string(); // makes sure to escape characters - - json_body.insert( - "user".to_string(), - RawValue::from_string(user_json_string) - .map_err(|e| Error::internal_err(format!("Failed to parse user: {}", e)))?, - ); - - Ok(serde_json::to_vec(&json_body) - .map_err(|e| Error::internal_err(format!("Failed to reserialize request body: {}", e)))? - .into()) - } } #[derive(Clone, Debug)] @@ -613,6 +526,19 @@ fn proxy_request_to_request_builder(proxy_request: ProxyRequest) -> RequestBuild request.body(proxy_request.body) } +fn google_ai_proxy_response_to_body( + response: GoogleAIProxyResponse, +) -> (http::StatusCode, HeaderMap, axum::body::Body) { + let body = match response.body { + GoogleAIProxyResponseBody::Fixed(body) => axum::body::Body::from(body), + GoogleAIProxyResponseBody::Stream(stream) => axum::body::Body::from_stream( + inject_keepalives(stream, Duration::from_secs(KEEPALIVE_INTERVAL_SECS)), + ), + }; + + (response.status_code, response.headers, body) +} + pub(crate) fn inject_keepalives( upstream: S, interval: Duration, @@ -888,12 +814,10 @@ async fn proxy( ai_path = chat_path; } - // Handle GoogleAI (Gemini) using the native Gemini API - if matches!(provider, AIProvider::GoogleAI) { - let api_key = request_config.api_key.as_deref().unwrap_or(""); - let base_url = request_config.base_url.trim_end_matches('/'); - let is_vertex = request_config.platform == AIPlatform::GoogleVertexAi; + let proxy_mode = proxy_execution_mode(&provider); + // Handle GoogleAI (Gemini) using the native Gemini API + if matches!(proxy_mode, ProxyExecutionMode::NativeGoogleAi) { let mut tx = db.begin().await?; audit_log( &mut *tx, @@ -907,23 +831,32 @@ async fn proxy( .await?; tx.commit().await?; - return match ai_path.as_str() { - "chat/completions" => { - crate::google::handle_google_ai_chat(&body, api_key, base_url, is_vertex).await - } - "models" => crate::google::handle_google_ai_models(api_key, base_url, is_vertex).await, + let credentials = request_config.into_provider_credentials(provider.clone()); + let proxy_args = ProxyBuildArgs { + method: &method, + path: &ai_path, + headers: &headers, + body: &body, + credentials: &credentials, + }; + + let response = match ai_path.as_str() { + "chat/completions" => handle_google_ai_chat_proxy(&HTTP_CLIENT, &proxy_args).await, + "models" => handle_google_ai_models_proxy(&HTTP_CLIENT, &proxy_args).await, _ => Err(Error::BadRequest(format!( "Unsupported Google AI path: {}", ai_path ))), - }; + }?; + + return Ok(google_ai_proxy_response_to_body(response)); } // Handle Bedrock-specific logic when the feature is enabled #[cfg(feature = "bedrock")] { // Extract model and streaming flag for Bedrock transformation (only for POST requests) - let (model, is_streaming) = if matches!(provider, AIProvider::AWSBedrock) + let (model, is_streaming) = if matches!(proxy_mode, ProxyExecutionMode::NativeAwsBedrock) && method == Method::POST { #[derive(Deserialize, Debug)] @@ -940,7 +873,7 @@ async fn proxy( }; // For Bedrock requests, use the SDK-based approach - if matches!(provider, AIProvider::AWSBedrock) { + if matches!(proxy_mode, ProxyExecutionMode::NativeAwsBedrock) { let region = request_config .region .as_deref() @@ -1014,25 +947,35 @@ async fn proxy( // When bedrock feature is disabled, return error for Bedrock provider #[cfg(not(feature = "bedrock"))] - if matches!(provider, AIProvider::AWSBedrock) { + if matches!(proxy_mode, ProxyExecutionMode::NativeAwsBedrock) { return Err(Error::BadRequest( "AWS Bedrock support is not enabled. Build with 'bedrock' feature.".to_string(), )); } - let request = if supports_query_builder_proxy(&provider) { - let credentials = request_config.into_provider_credentials(provider.clone()); - let query_builder = create_proxy_query_builder(&credentials); - let proxy_request = query_builder.build_proxy_request(&ProxyBuildArgs { - method: &method, - path: &ai_path, - headers: &headers, - body: &body, - credentials: &credentials, - })?; - proxy_request_to_request_builder(proxy_request) - } else { - request_config.prepare_request(&provider, &ai_path, method, headers, body)? + let request = match proxy_mode { + ProxyExecutionMode::HttpForward => { + let credentials = request_config.into_provider_credentials(provider.clone()); + let query_builder = create_proxy_query_builder(&credentials); + let proxy_request = query_builder.build_proxy_request(&ProxyBuildArgs { + method: &method, + path: &ai_path, + headers: &headers, + body: &body, + credentials: &credentials, + })?; + proxy_request_to_request_builder(proxy_request) + } + ProxyExecutionMode::NativeGoogleAi => { + return Err(Error::internal_err( + "Google AI proxy route was not handled".to_string(), + )) + } + ProxyExecutionMode::NativeAwsBedrock => { + return Err(Error::BadRequest( + "Unsupported AWS Bedrock proxy request".to_string(), + )) + } }; let response = request.send().await.map_err(to_anyhow)?; diff --git a/backend/windmill-api/src/google.rs b/backend/windmill-api/src/google.rs deleted file mode 100644 index 95987ab789..0000000000 --- a/backend/windmill-api/src/google.rs +++ /dev/null @@ -1,354 +0,0 @@ -//! Google AI (Gemini API) handler for the AI chat proxy. -//! -//! Handles POST `chat/completions` requests using the native Gemini API, -//! converting from/to OpenAI format so the existing frontend parsers continue to work. -//! -//! Supports both standard Google AI (generativelanguage.googleapis.com) and -//! Google Vertex AI ({region}-aiplatform.googleapis.com) endpoints. -//! -//! Used by `windmill-api/src/ai.rs` when the provider is `GoogleAI`. -//! Shared conversion logic lives in `windmill_common::ai_google`. - -use axum::body::Body; -use bytes::Bytes; -use eventsource_stream::Eventsource; -use futures::StreamExt; -use serde::Deserialize; -use serde_json::json; -use windmill_ai::{ - ai_google::{ - gemini_event_to_openai_sse_chunks, gemini_response_to_openai, openai_messages_to_gemini, - parse_gemini_response, parse_gemini_sse_event, sanitize_schema_for_google, - GeminiFunctionDeclaration, GeminiGenerationConfig, GeminiTextRequest, GeminiTool, - }, - ai_types::OpenAIMessage, -}; -use windmill_common::error::{Error, Result}; - -use crate::ai::{inject_keepalives, HTTP_CLIENT, KEEPALIVE_INTERVAL_SECS}; - -// ============================================================================ -// Request type (OpenAI format received from the frontend) -// ============================================================================ - -#[derive(Deserialize, Debug)] -struct ChatRequest { - model: String, - messages: Vec, - #[serde(default)] - stream: bool, - #[serde(default)] - temperature: Option, - #[serde(default)] - max_tokens: Option, - #[serde(default)] - tools: Option>, -} - -#[derive(Deserialize, Debug)] -struct ChatRequestTool { - function: ChatRequestToolFunction, -} - -#[derive(Deserialize, Debug)] -struct ChatRequestToolFunction { - name: String, - #[serde(default)] - description: Option, - #[serde(default)] - parameters: Option, -} - -// ============================================================================ -// Helpers for Vertex AI vs standard Google AI URL/auth -// ============================================================================ - -/// Build the endpoint URL for a model action (streamGenerateContent, generateContent, predict). -/// -/// - Standard: `{base_url}/models/{model}:{action}` -/// - Vertex AI: `{base_url}/{model}:{action}` (base_url already contains .../publishers/google/models) -fn build_model_endpoint(base_url: &str, model: &str, action: &str, is_vertex: bool) -> String { - if is_vertex { - format!("{}/{}:{}", base_url, model, action) - } else { - format!("{}/models/{}:{}", base_url, model, action) - } -} - -/// Set the appropriate auth header on a request builder. -/// -/// - Standard: `x-goog-api-key` header -/// - Vertex AI: `Authorization: Bearer` header -fn set_auth( - request: reqwest::RequestBuilder, - api_key: &str, - is_vertex: bool, -) -> reqwest::RequestBuilder { - if is_vertex { - request.header("Authorization", format!("Bearer {}", api_key)) - } else { - request.header("x-goog-api-key", api_key) - } -} - -// ============================================================================ -// Public handler -// ============================================================================ - -/// Handle a `chat/completions` POST request using the native Gemini API. -/// -/// Converts the incoming OpenAI-format body to a `GeminiTextRequest`, sends it -/// to the appropriate Gemini endpoint, and converts the response back to the -/// OpenAI SSE or JSON format that the frontend expects. -pub async fn handle_google_ai_chat( - body: &Bytes, - api_key: &str, - base_url: &str, - is_vertex: bool, -) -> Result<(http::StatusCode, http::HeaderMap, Body)> { - let request: ChatRequest = serde_json::from_slice(body) - .map_err(|e| Error::BadRequest(format!("Failed to parse request body: {}", e)))?; - - let (contents, system_instruction) = openai_messages_to_gemini(&request.messages); - - let generation_config = if request.temperature.is_some() || request.max_tokens.is_some() { - Some(GeminiGenerationConfig { - temperature: request.temperature, - max_output_tokens: request.max_tokens, - response_mime_type: None, - response_schema: None, - }) - } else { - None - }; - - let gemini_tools = request.tools.as_ref().map(|tools| { - let declarations: Vec = tools - .iter() - .map(|t| { - let mut params = t.function.parameters.clone().unwrap_or(json!({})); - sanitize_schema_for_google(&mut params); - GeminiFunctionDeclaration { - name: t.function.name.clone(), - description: t.function.description.clone(), - parameters: params, - } - }) - .collect(); - vec![GeminiTool { function_declarations: Some(declarations), google_search: None }] - }); - - let gemini_request = GeminiTextRequest { - contents, - tools: gemini_tools, - tool_config: None, - system_instruction, - generation_config, - }; - - let request_body = serde_json::to_string(&gemini_request) - .map_err(|e| Error::internal_err(format!("Failed to serialize Gemini request: {}", e)))?; - - let base_url = base_url.trim_end_matches('/'); - - if request.stream { - handle_streaming(&request.model, request_body, api_key, base_url, is_vertex).await - } else { - handle_non_streaming(&request.model, request_body, api_key, base_url, is_vertex).await - } -} - -// ============================================================================ -// Streaming path -// ============================================================================ - -async fn handle_streaming( - model: &str, - request_body: String, - api_key: &str, - base_url: &str, - is_vertex: bool, -) -> Result<(http::StatusCode, http::HeaderMap, Body)> { - let endpoint = format!( - "{}?alt=sse", - build_model_endpoint(base_url, model, "streamGenerateContent", is_vertex) - ); - - let request = HTTP_CLIENT - .post(&endpoint) - .header("content-type", "application/json") - .body(request_body); - let request = set_auth(request, api_key, is_vertex); - - let response = request - .send() - .await - .map_err(|e| Error::internal_err(format!("Failed to send request to Gemini API: {}", e)))?; - - if let Err(e) = response.error_for_status_ref() { - let status = e.status().map(|s| s.to_string()).unwrap_or_default(); - let body = response.text().await.unwrap_or_default(); - return Err(Error::AIError(format!("{}: {}", status, body))); - } - - let id = format!("chatcmpl-{}", uuid::Uuid::new_v4().simple()); - let model_str = model.to_string(); - - let gemini_sse_stream = response.bytes_stream().eventsource(); - let openai_sse_stream = async_stream::stream! { - tokio::pin!(gemini_sse_stream); - let mut tool_call_index: usize = 0; - while let Some(event) = gemini_sse_stream.next().await { - match event { - Ok(event) => match parse_gemini_sse_event(&event.data) { - Ok(Some(parsed)) => { - for chunk in gemini_event_to_openai_sse_chunks( - &parsed, &id, &model_str, &mut tool_call_index, - ) { - yield Ok::(Bytes::from(chunk)); - } - } - Ok(None) => {} - Err(e) => tracing::error!("Error parsing Gemini SSE event: {}", e), - }, - Err(e) => tracing::error!("Error reading Gemini SSE stream: {}", e), - } - } - yield Ok::(Bytes::from("data: [DONE]\n\n")); - }; - - let mut headers = http::HeaderMap::new(); - headers.insert("content-type", "text/event-stream".parse().unwrap()); - headers.insert("cache-control", "no-cache".parse().unwrap()); - headers.insert("connection", "keep-alive".parse().unwrap()); - - Ok(( - http::StatusCode::OK, - headers, - Body::from_stream(inject_keepalives( - Box::pin(openai_sse_stream), - std::time::Duration::from_secs(KEEPALIVE_INTERVAL_SECS), - )), - )) -} - -// ============================================================================ -// Model listing -// ============================================================================ - -/// List available Gemini models and convert to OpenAI format. -/// -/// - Standard: `GET {base_url}/models` — returns `{ models: [...] }` -/// - Vertex AI: `GET {base_url}` — returns `{ models: [...] }` (base_url already ends with .../models) -pub async fn handle_google_ai_models( - api_key: &str, - base_url: &str, - is_vertex: bool, -) -> Result<(http::StatusCode, http::HeaderMap, Body)> { - #[derive(Deserialize)] - struct GeminiModel { - name: String, - #[serde(rename = "displayName", default)] - display_name: String, - } - - #[derive(Deserialize)] - struct GeminiModelsResponse { - #[serde(default)] - models: Vec, - } - - let base_url = base_url.trim_end_matches('/'); - let endpoint = if is_vertex { - // Vertex AI: base_url is .../publishers/google/models - base_url.to_string() - } else { - // Standard: append /models - format!("{}/models", base_url) - }; - - let request = HTTP_CLIENT.get(&endpoint); - let request = set_auth(request, api_key, is_vertex); - - let response = request - .send() - .await - .map_err(|e| Error::internal_err(format!("Failed to fetch Gemini models: {}", e)))?; - - if let Err(e) = response.error_for_status_ref() { - let status = e.status().map(|s| s.to_string()).unwrap_or_default(); - let body = response.text().await.unwrap_or_default(); - return Err(Error::AIError(format!("{}: {}", status, body))); - } - - let gemini_resp: GeminiModelsResponse = response.json().await.map_err(|e| { - Error::internal_err(format!("Failed to parse Gemini models response: {}", e)) - })?; - - let data: Vec = gemini_resp - .models - .into_iter() - .map(|m| { - json!({ - "id": m.name, - "object": "model", - "display_name": m.display_name, - }) - }) - .collect(); - - let body_bytes = serde_json::to_vec(&json!({ "data": data })) - .map_err(|e| Error::internal_err(format!("Failed to serialize models: {}", e)))?; - - let mut headers = http::HeaderMap::new(); - headers.insert("content-type", "application/json".parse().unwrap()); - - Ok((http::StatusCode::OK, headers, Body::from(body_bytes))) -} - -// ============================================================================ -// Non-streaming path -// ============================================================================ - -async fn handle_non_streaming( - model: &str, - request_body: String, - api_key: &str, - base_url: &str, - is_vertex: bool, -) -> Result<(http::StatusCode, http::HeaderMap, Body)> { - let endpoint = build_model_endpoint(base_url, model, "generateContent", is_vertex); - - let request = HTTP_CLIENT - .post(&endpoint) - .header("content-type", "application/json") - .body(request_body); - let request = set_auth(request, api_key, is_vertex); - - let response = request - .send() - .await - .map_err(|e| Error::internal_err(format!("Failed to send request to Gemini API: {}", e)))?; - - if let Err(e) = response.error_for_status_ref() { - let status = e.status().map(|s| s.to_string()).unwrap_or_default(); - let body = response.text().await.unwrap_or_default(); - return Err(Error::AIError(format!("{}: {}", status, body))); - } - - let body = response - .bytes() - .await - .map_err(|e| Error::internal_err(format!("Failed to read Gemini response body: {}", e)))?; - - let parsed = parse_gemini_response(&body)?; - let openai_response = gemini_response_to_openai(&parsed, model); - - let body_bytes = serde_json::to_vec(&openai_response) - .map_err(|e| Error::internal_err(format!("Failed to serialize response: {}", e)))?; - - let mut headers = http::HeaderMap::new(); - headers.insert("content-type", "application/json".parse().unwrap()); - - Ok((http::StatusCode::OK, headers, Body::from(body_bytes))) -} diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index 75b3469c41..e7e4508971 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -79,7 +79,6 @@ mod capture; mod concurrency_groups; mod db; mod db_health; -mod google; mod drafts; #[cfg(feature = "private")] diff --git a/docs/windmill-ai-refactor-plan.md b/docs/windmill-ai-refactor-plan.md index 4c0e109735..ea2b9f451e 100644 --- a/docs/windmill-ai-refactor-plan.md +++ b/docs/windmill-ai-refactor-plan.md @@ -37,7 +37,7 @@ Avoid adding modules whose only purpose is to re-export moved code. Direct impor Also do not make `build_proxy_request(raw_body, path)` too narrow. The proxy path needs method, incoming headers, resolved credentials, base URL/platform, organization/user fields, custom headers, and Bedrock/Azure/Vertex-specific context. Introduce a structured `ProxyBuildArgs`/`ProviderCredentials` shape before deleting `AIRequestConfig::prepare_request`, `google.rs`, or `bedrock.rs`. -## Current Phase PR: Proxy Contract + OpenAI-Compatible Proxy +## Completed Phase: Proxy Contract + OpenAI-Compatible Proxy ✅ Goal: introduce the shared API proxy contract in `windmill-ai` and move the OpenAI-compatible proxy request builder there without changing provider behavior. @@ -66,6 +66,41 @@ Validation: - `cargo check -p windmill-ai -p windmill-api` - `cargo check -p windmill-ai -p windmill-api --features bedrock` +Follow-up status: Anthropic/Vertex proxy handling has since moved into +`windmill-ai`, and the dead `AIRequestConfig::prepare_request` fallback has +been removed. + +## Current Phase PR: Proxy Execution Mode + Google AI Proxy Migration + +Goal: introduce a shared provider execution classifier before moving Google AI +and Bedrock. `ProxyRequest` is a good contract for HTTP-forwarding providers +such as OpenAI-compatible providers and Anthropic, but Google AI also converts +responses back to OpenAI shape and Bedrock uses SDK execution. Model that split +explicitly before moving those providers, then move the Google AI proxy +transformation into `windmill-ai` as the first native-provider migration. + +Suggested PR title: `refactor(ai): add provider proxy execution mode`. + +Scope: +- Add `ProxyExecutionMode` in `windmill-ai::proxy`. +- Classify providers as HTTP-forwarding, native Google AI, or native Bedrock. +- Make `supports_query_builder_proxy` derive from the shared execution mode. +- Use the shared execution mode in `windmill-api/src/ai.rs` for workspace proxy routing. +- Move Google AI workspace proxy request conversion, streaming/non-streaming response conversion, and model-list normalization into `windmill-ai::providers::google_ai`. +- Share Google AI `GeminiTextRequest` and generation-config construction between worker agent requests and API proxy requests. +- Delete the API-local `windmill-api/src/google.rs` module. +- Keep global proxy behavior, Bedrock native handling, credential resolution, audit logging, caching, and SSE keepalive behavior unchanged. + +Out of scope: +- Do not move `windmill-api/src/bedrock.rs`. +- Do not unify `AIRequestConfig` and `ProviderWithResource`. + +Validation: +- `cargo test -p windmill-ai google_ai` +- `cargo test -p windmill-ai proxy` +- `cargo test -p windmill-api maps_request_config_to_provider_credentials` +- `cargo test -p windmill-ai anthropic` + ## Step-by-Step Plan Each step produces a compiling, working backend. From 2db1c0a1fcfdcad94cae97dcffa090ffb91494f7 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Wed, 20 May 2026 17:58:05 +0200 Subject: [PATCH 02/71] fix: early return should consider failure_module result (#9241) --- ...451452efc25e57769fb94b771d4879150835.json} | 20 +++-- backend/tests/worker.rs | 89 +++++++++++++++++++ backend/windmill-api-jobs/src/execution.rs | 62 +++++++++++-- backend/windmill-api/src/jobs.rs | 68 +++++++++----- .../windmill-api/src/triggers/http/handler.rs | 3 +- backend/windmill-common/src/lib.rs | 2 + .../windmill-trigger/src/global_handler.rs | 43 ++++----- .../windmill-trigger/src/trigger_helpers.rs | 58 ++++++++---- 8 files changed, 266 insertions(+), 79 deletions(-) rename backend/.sqlx/{query-6d992a933bb878733b7afd7a4295b9ad6f5276b60ce20e0378d6148976e02777.json => query-04409657066c624308954958d9dd451452efc25e57769fb94b771d4879150835.json} (54%) diff --git a/backend/.sqlx/query-6d992a933bb878733b7afd7a4295b9ad6f5276b60ce20e0378d6148976e02777.json b/backend/.sqlx/query-04409657066c624308954958d9dd451452efc25e57769fb94b771d4879150835.json similarity index 54% rename from backend/.sqlx/query-6d992a933bb878733b7afd7a4295b9ad6f5276b60ce20e0378d6148976e02777.json rename to backend/.sqlx/query-04409657066c624308954958d9dd451452efc25e57769fb94b771d4879150835.json index e32c1e06ed..9c88e54c21 100644 --- a/backend/.sqlx/query-6d992a933bb878733b7afd7a4295b9ad6f5276b60ce20e0378d6148976e02777.json +++ b/backend/.sqlx/query-04409657066c624308954958d9dd451452efc25e57769fb94b771d4879150835.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n flow_version.id AS version,\n flow_version.value->>'early_return' as early_return,\n flow_version.value->>'preprocessor_module' IS NOT NULL as has_preprocessor,\n (flow_version.value->>'chat_input_enabled')::boolean as chat_input_enabled,\n flow.tag,\n flow.dedicated_worker,\n flow.on_behalf_of_email,\n flow.edited_by,\n flow.labels\n FROM\n flow_version\n INNER JOIN flow\n ON flow.path = flow_version.path AND\n flow.workspace_id = flow_version.workspace_id\n WHERE\n flow_version.workspace_id = $1 AND\n flow_version.path = $2 AND\n flow_version.id = $3\n ", + "query": "\n SELECT\n flow_version.id AS version,\n flow_version.value->>'early_return' as early_return,\n flow_version.value->>'preprocessor_module' IS NOT NULL as has_preprocessor,\n flow_version.value->>'failure_module' IS NOT NULL as has_failure_module,\n (flow_version.value->>'chat_input_enabled')::boolean as chat_input_enabled,\n flow.tag,\n flow.dedicated_worker,\n flow.on_behalf_of_email,\n flow.edited_by,\n flow.labels\n FROM\n flow_version\n INNER JOIN flow\n ON flow.path = flow_version.path AND\n flow.workspace_id = flow_version.workspace_id\n WHERE\n flow_version.workspace_id = $1 AND\n flow_version.path = $2 AND\n flow_version.id = $3\n ", "describe": { "columns": [ { @@ -20,31 +20,36 @@ }, { "ordinal": 3, - "name": "chat_input_enabled", + "name": "has_failure_module", "type_info": "Bool" }, { "ordinal": 4, + "name": "chat_input_enabled", + "type_info": "Bool" + }, + { + "ordinal": 5, "name": "tag", "type_info": "Varchar" }, { - "ordinal": 5, + "ordinal": 6, "name": "dedicated_worker", "type_info": "Bool" }, { - "ordinal": 6, + "ordinal": 7, "name": "on_behalf_of_email", "type_info": "Text" }, { - "ordinal": 7, + "ordinal": 8, "name": "edited_by", "type_info": "Varchar" }, { - "ordinal": 8, + "ordinal": 9, "name": "labels", "type_info": "TextArray" } @@ -61,6 +66,7 @@ null, null, null, + null, true, true, true, @@ -68,5 +74,5 @@ true ] }, - "hash": "6d992a933bb878733b7afd7a4295b9ad6f5276b60ce20e0378d6148976e02777" + "hash": "04409657066c624308954958d9dd451452efc25e57769fb94b771d4879150835" } diff --git a/backend/tests/worker.rs b/backend/tests/worker.rs index 0d436701cc..f21862fd38 100644 --- a/backend/tests/worker.rs +++ b/backend/tests/worker.rs @@ -3990,6 +3990,94 @@ async fn test_failure_module(db: Pool) -> anyhow::Result<()> { Ok(()) } +#[cfg(feature = "deno_core")] +#[sqlx::test(fixtures("base"))] +async fn test_run_wait_result_early_return_with_failure_module( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let flow: FlowValue = serde_json::from_value(serde_json::json!({ + "modules": [{ + "id": "a", + "value": { + "type": "rawscript", + "language": "deno", + "input_transforms": {}, + "content": "export function main() { throw new Error('boom'); }", + }, + }], + "failure_module": { + "value": { + "type": "rawscript", + "language": "deno", + "input_transforms": {}, + "content": "export function main() { return { recovered: true } }", + }, + }, + })) + .unwrap(); + + let completed = + RunJob::from(JobPayload::RawFlow { value: flow, path: None, restarted_from: None }) + .run_until_complete(&db, false, port) + .await; + + // Sanity: flow result is the failure_module's output. + assert_eq!( + json!({ "recovered": true }), + completed.json_result().unwrap() + ); + + let read_body = |response: axum::response::Response| async move { + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + serde_json::from_slice::(&bytes).unwrap() + }; + + // has_failure_module=false: legacy behavior — return the early_return node's failure. + let resp_a_only = windmill_api::jobs::run_wait_result( + &db, + completed.id, + "test-workspace", + Some("a".to_string()), + false, + "test-user", + ) + .await + .unwrap(); + let body_a_only = read_body(resp_a_only).await; + let body_a_only_str = body_a_only.to_string(); + assert!( + body_a_only_str.contains("boom"), + "expected node 'a' failure, got {body_a_only_str}", + ); + assert!( + !body_a_only_str.contains("recovered"), + "expected node 'a' failure, not failure_module output; got {body_a_only_str}", + ); + + // has_failure_module=true: skip the early_return node's failure and return the + // failure_module's recovered result instead. + let resp_with_fm = windmill_api::jobs::run_wait_result( + &db, + completed.id, + "test-workspace", + Some("a".to_string()), + true, + "test-user", + ) + .await + .unwrap(); + let body_with_fm = read_body(resp_with_fm).await; + assert_eq!(json!({ "recovered": true }), body_with_fm); + + Ok(()) +} + #[cfg(feature = "python")] #[sqlx::test(fixtures("base"))] async fn test_flow_lock_all(db: Pool) -> anyhow::Result<()> { @@ -4783,6 +4871,7 @@ async fn test_result_format(db: Pool) -> anyhow::Result<()> { Uuid::parse_str(ordered_result_job_id).unwrap(), "test-workspace", None, + false, "test-user", ) .await diff --git a/backend/windmill-api-jobs/src/execution.rs b/backend/windmill-api-jobs/src/execution.rs index e4359e19da..0581b7acd9 100644 --- a/backend/windmill-api-jobs/src/execution.rs +++ b/backend/windmill-api-jobs/src/execution.rs @@ -262,6 +262,7 @@ pub async fn run_wait_result_internal( uuid: Uuid, w_id: &str, node_id_for_empty_return: Option, + has_failure_module: bool, username: &str, ) -> error::Result<(Box, bool)> { let mut result = None; @@ -283,9 +284,15 @@ pub async fn run_wait_result_internal( let fast_poll_duration = *WAIT_RESULT_FAST_POLL_DURATION_SECS as u64 * 1000; let mut accumulated_delay = 0 as u64; + // Once we observe the early_return node failed with a failure_module configured, + // its result is final — no need to re-query it on every poll. + let mut early_return_failed_and_suppressed = false; loop { - if let Some(node_id_for_empty_return) = node_id_for_empty_return.as_ref() { + if let Some(node_id_for_empty_return) = node_id_for_empty_return + .as_ref() + .filter(|_| !early_return_failed_and_suppressed) + { let result_and_success = get_result_and_success_by_id_from_flow( &db, w_id, @@ -296,8 +303,16 @@ pub async fn run_wait_result_internal( .await .ok(); if let Some((r, s)) = result_and_success { - result = Some(r); - success = s; + // When the early_return node failed but the flow has a failure_module, + // the error handler will run and may recover. Skip this result and let + // the loop fall through to the completed flow result below, which is + // the failure_module's output. + if has_failure_module && !s { + early_return_failed_and_suppressed = true; + } else { + result = Some(r); + success = s; + } } } @@ -445,10 +460,18 @@ pub async fn run_wait_result( uuid: Uuid, w_id: &str, node_id_for_empty_return: Option, + has_failure_module: bool, username: &str, ) -> error::Result { - let (result, success) = - run_wait_result_internal(db, uuid, w_id, node_id_for_empty_return, username).await?; + let (result, success) = run_wait_result_internal( + db, + uuid, + w_id, + node_id_for_empty_return, + has_failure_module, + username, + ) + .await?; result_to_response(result, success) } @@ -624,6 +647,7 @@ pub async fn run_flow<'c>( ) -> error::Result<( Uuid, Option, + bool, Option>, )> { let FlowVersionInfo { @@ -631,6 +655,7 @@ pub async fn run_flow<'c>( tag, dedicated_worker, has_preprocessor, + has_failure_module, chat_input_enabled, on_behalf_of_email, edited_by, @@ -728,10 +753,20 @@ pub async fn run_flow<'c>( // If we were given a transaction, return it; otherwise commit it if return_tx { - Ok((uuid, early_return, Some(tx))) + Ok(( + uuid, + early_return, + has_failure_module.unwrap_or(false), + Some(tx), + )) } else { tx.commit().await?; - Ok((uuid, early_return, None)) + Ok(( + uuid, + early_return, + has_failure_module.unwrap_or(false), + None, + )) } } @@ -746,7 +781,7 @@ pub async fn run_flow_and_wait_result( args: PushArgsOwned, trigger: Option, ) -> error::Result { - let (uuid, early_return, _) = run_flow( + let (uuid, early_return, has_failure_module, _) = run_flow( authed, db, None, @@ -760,7 +795,15 @@ pub async fn run_flow_and_wait_result( ) .await?; - run_wait_result(&db, uuid, w_id, early_return, &authed.username).await + run_wait_result( + &db, + uuid, + w_id, + early_return, + has_failure_module, + &authed.username, + ) + .await } // --------------------------------------------------------------------------- @@ -780,6 +823,7 @@ pub async fn push_flow_job_by_path_into_queue<'c>( ) -> error::Result<( Uuid, Option, + bool, Option>, )> { #[cfg(feature = "enterprise")] diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index cb8d71022f..85f0aedfc3 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -3941,7 +3941,7 @@ async fn batch_rerun_handle_job( None, ) .await; - if let Ok((uuid, _, _)) = result { + if let Ok((uuid, _, _, _)) = result { return Ok(uuid.to_string()); } } @@ -4003,7 +4003,7 @@ pub async fn run_flow_by_path( ) .await?; - let (uuid, _, _) = push_flow_job_by_path_into_queue( + let (uuid, _, _, _) = push_flow_job_by_path_into_queue( authed, db, None, @@ -4037,7 +4037,7 @@ pub async fn run_flow_by_version( ) .await?; - let (uuid, _) = + let (uuid, _, _) = run_flow_by_version_inner(authed, db, user_db, w_id, version, run_query, args, None) .await?; @@ -4053,7 +4053,7 @@ pub async fn run_flow_by_version_inner( run_query: RunJobQuery, args: PushArgsOwned, trigger: Option, -) -> error::Result<(Uuid, Option)> { +) -> error::Result<(Uuid, Option, bool)> { #[cfg(feature = "enterprise")] check_license_key_valid().await?; @@ -4065,7 +4065,7 @@ pub async fn run_flow_by_version_inner( let flow_version_info = get_flow_version_info_from_version(&db, version, &w_id, &flow_path).await?; - let (uuid, early_return, _) = run_flow( + let (uuid, early_return, has_failure_module, _) = run_flow( &authed, &db, None, @@ -4079,7 +4079,7 @@ pub async fn run_flow_by_version_inner( ) .await?; - Ok((uuid, early_return)) + Ok((uuid, early_return, has_failure_module)) } #[cfg(not(feature = "enterprise"))] @@ -4983,7 +4983,7 @@ pub async fn run_wait_result_job_by_path_get( .await?; tx.commit().await?; - let wait_result = run_wait_result(&db, uuid, &w_id, None, &authed.username).await; + let wait_result = run_wait_result(&db, uuid, &w_id, None, false, &authed.username).await; handle_delete_after_completion(&db, uuid, &w_id, delete_after_use, delete_after_secs).await?; return wait_result; } @@ -5127,7 +5127,7 @@ pub async fn run_wait_result_script_by_path_internal( .await?; tx.commit().await?; - let wait_result = run_wait_result(&db, uuid, &w_id, None, &authed.username).await; + let wait_result = run_wait_result(&db, uuid, &w_id, None, false, &authed.username).await; handle_delete_after_completion(&db, uuid, &w_id, delete_after_use, delete_after_secs).await?; return wait_result; } @@ -5252,7 +5252,7 @@ pub async fn run_wait_result_script_by_hash( .await?; tx.commit().await?; - let wait_result = run_wait_result(&db, uuid, &w_id, None, &authed.username).await; + let wait_result = run_wait_result(&db, uuid, &w_id, None, false, &authed.username).await; handle_delete_after_completion(&db, uuid, &w_id, delete_after_use, delete_after_secs).await?; return wait_result; } @@ -5405,7 +5405,7 @@ pub async fn stream_job( }; let poll_delay_ms = run_query.poll_delay_ms; - let (uuid, early_return) = match runnable_id { + let (uuid, early_return, has_failure_module) = match runnable_id { RunnableId::ScriptId(ScriptId::ScriptPath(script_path)) | RunnableId::HubScript(script_path) => { let (uuid, _, _) = push_script_job_by_path_into_queue( @@ -5420,7 +5420,7 @@ pub async fn stream_job( None, ) .await?; - (uuid, None) + (uuid, None, false) } RunnableId::ScriptId(ScriptId::ScriptHash(script_hash)) => { let (uuid, _, _) = run_job_by_hash_inner( @@ -5434,10 +5434,10 @@ pub async fn stream_job( None, ) .await?; - (uuid, None) + (uuid, None, false) } RunnableId::FlowId(FlowId::FlowPath(flow_path)) => { - let (uuid, early_return, _) = push_flow_job_by_path_into_queue( + let (uuid, early_return, has_failure_module, _) = push_flow_job_by_path_into_queue( authed.clone(), db.clone(), None, @@ -5449,10 +5449,10 @@ pub async fn stream_job( None, ) .await?; - (uuid, early_return) + (uuid, early_return, has_failure_module) } RunnableId::FlowId(FlowId::FlowVersion(version)) => { - let (uuid, early_return) = run_flow_by_version_inner( + let (uuid, early_return, has_failure_module) = run_flow_by_version_inner( authed.clone(), db.clone(), user_db, @@ -5463,7 +5463,7 @@ pub async fn stream_job( None, ) .await?; - (uuid, early_return) + (uuid, early_return, has_failure_module) } }; @@ -5495,6 +5495,7 @@ pub async fn stream_job( tx, poll_delay_ms, early_return, + has_failure_module, ); let body = axum::body::Body::from_stream(stream.map(Result::<_, std::convert::Infallible>::Ok)); @@ -5978,7 +5979,7 @@ async fn run_wait_result_preview_script( let uuid = uuid .parse::() .map_err(|_| Error::BadRequest("Invalid UUID".to_string()))?; - let result = run_wait_result(&db, uuid, &w_id, None, &authed.username).await; + let result = run_wait_result(&db, uuid, &w_id, None, false, &authed.username).await; return result; } @@ -6252,7 +6253,7 @@ async fn run_dependencies_job( Json(req): Json, ) -> error::Result { let uuid = push_dependencies_job(&authed, &db, &w_id, req).await?; - run_wait_result(&db, uuid, &w_id, None, &authed.username).await + run_wait_result(&db, uuid, &w_id, None, false, &authed.username).await } async fn run_dependencies_job_async( @@ -6361,7 +6362,7 @@ async fn run_flow_dependencies_job( Json(req): Json, ) -> error::Result { let uuid = push_flow_dependencies_job(&authed, &db, &w_id, req).await?; - run_wait_result(&db, uuid, &w_id, None, &authed.username).await + run_wait_result(&db, uuid, &w_id, None, false, &authed.username).await } async fn run_flow_dependencies_job_async( @@ -6780,7 +6781,7 @@ async fn run_wait_result_preview_flow( let uuid = uuid .parse::() .map_err(|_| Error::BadRequest("Invalid UUID".to_string()))?; - let result = run_wait_result(&db, uuid, &w_id, None, &authed.username).await; + let result = run_wait_result(&db, uuid, &w_id, None, false, &authed.username).await; return result; } @@ -7214,6 +7215,8 @@ async fn get_job_update( is_flow, None, None, + false, + &mut false, ) .await?, )) @@ -7255,6 +7258,7 @@ async fn get_job_update_sse( tx, poll_delay_ms, None, + false, ); let stream = tokio_stream::wrappers::ReceiverStream::new(rx).map(|x| { @@ -7293,12 +7297,16 @@ pub fn start_job_update_sse_stream( tx: tokio::sync::mpsc::Sender, poll_delay_ms: Option, early_return: Option, + has_failure_module: bool, ) -> () { tokio::spawn(async move { let mut log_offset = initial_log_offset; let mut stream_offset = initial_stream_offset; let mut last_update_hash: Option = None; let mut flow_stream_job_id = None; + // Latched once the early_return node's failure is observed alongside a + // failure_module — subsequent polls then skip the redundant per-node lookup. + let mut early_return_suppressed = false; // Send initial update immediately let mut running = running; @@ -7321,6 +7329,8 @@ pub fn start_job_update_sse_stream( is_flow, flow_stream_job_id, early_return.as_deref(), + has_failure_module, + &mut early_return_suppressed, ) .await { @@ -7439,6 +7449,8 @@ pub fn start_job_update_sse_stream( is_flow, flow_stream_job_id, early_return.as_deref(), + has_failure_module, + &mut early_return_suppressed, ) .await { @@ -7570,6 +7582,8 @@ async fn get_job_update_data( is_flow: Option, flow_stream_job_id: Option, early_return: Option<&str>, + has_failure_module: bool, + early_return_suppressed: &mut bool, ) -> error::Result { let tags = if log_view { log_job_view( @@ -7733,11 +7747,21 @@ async fn get_job_update_data( let flow_stream_job_id = flow_stream_job_id.or(new_flow_stream_job_id); - let result = if let Some(early_return) = early_return { + let result = if let Some(early_return) = early_return.filter(|_| !*early_return_suppressed) + { match get_result_and_success_by_id_from_flow(db, w_id, job_id, early_return, None).await { + // When the early_return node failed but the flow has a failure_module, + // the error handler will run and may recover. Keep the completed flow + // result instead (it reflects the failure_module's output). Latch the + // observation so subsequent polls skip this query — the early-return + // node's failure is final once observed. + Ok((_, early_success)) if has_failure_module && !early_success => { + *early_return_suppressed = true; + result + } Ok((early_result, _)) => Some(early_result), - Err(_) => result, + _ => result, } } else { result diff --git a/backend/windmill-api/src/triggers/http/handler.rs b/backend/windmill-api/src/triggers/http/handler.rs index ffb61e31fa..0fd7717582 100644 --- a/backend/windmill-api/src/triggers/http/handler.rs +++ b/backend/windmill-api/src/triggers/http/handler.rs @@ -529,7 +529,7 @@ async fn route_job( match trigger.request_type { RequestType::SyncSse => { // Trigger the job (always async when streaming) - let (uuid, _, early_return, _) = trigger_runnable_inner( + let (uuid, _, early_return, has_failure_module, _) = trigger_runnable_inner( &db, None, Some(user_db.clone()), @@ -578,6 +578,7 @@ async fn route_job( tx, None, early_return, + has_failure_module, ); let body = axum::body::Body::from_stream( diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index 973a289c48..3c4244398e 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -1384,6 +1384,7 @@ pub struct FlowVersionInfo { pub tag: Option, pub early_return: Option, pub has_preprocessor: Option, + pub has_failure_module: Option, pub chat_input_enabled: Option, pub on_behalf_of_email: Option, pub edited_by: String, @@ -1512,6 +1513,7 @@ pub fn get_flow_version_info_from_version< flow_version.id AS version, flow_version.value->>'early_return' as early_return, flow_version.value->>'preprocessor_module' IS NOT NULL as has_preprocessor, + flow_version.value->>'failure_module' IS NOT NULL as has_failure_module, (flow_version.value->>'chat_input_enabled')::boolean as chat_input_enabled, flow.tag, flow.dedicated_worker, diff --git a/backend/windmill-trigger/src/global_handler.rs b/backend/windmill-trigger/src/global_handler.rs index def5a5457d..3fbbcddd0e 100644 --- a/backend/windmill-trigger/src/global_handler.rs +++ b/backend/windmill-trigger/src/global_handler.rs @@ -205,27 +205,28 @@ pub async fn resume_suspended_trigger_jobs( } else { // Job was created after trigger edit - delete and repush with new configuration // Pass the transaction to trigger_runnable_inner so everything is in the same transaction - let (_uuid, _delete_after_use, _early_return, tx_o) = trigger_runnable_inner( - &db, - Some(tx), - Some(user_db.clone()), - authed.clone(), - &w_id, - &trigger.script_path, - trigger.is_flow, - windmill_queue::PushArgsOwned { - extra: None, - args: job.args.map(|a| a.0).unwrap_or_default(), - }, - trigger.retry.as_ref(), - trigger.error_handler_path.as_deref(), - trigger.error_handler_args.as_ref(), - trigger_path.clone(), - None, - trigger_metadata.clone(), - None, - ) - .await?; + let (_uuid, _delete_after_use, _early_return, _has_failure_module, tx_o) = + trigger_runnable_inner( + &db, + Some(tx), + Some(user_db.clone()), + authed.clone(), + &w_id, + &trigger.script_path, + trigger.is_flow, + windmill_queue::PushArgsOwned { + extra: None, + args: job.args.map(|a| a.0).unwrap_or_default(), + }, + trigger.retry.as_ref(), + trigger.error_handler_path.as_deref(), + trigger.error_handler_args.as_ref(), + trigger_path.clone(), + None, + trigger_metadata.clone(), + None, + ) + .await?; tx = match tx_o { Some(tx) => tx, diff --git a/backend/windmill-trigger/src/trigger_helpers.rs b/backend/windmill-trigger/src/trigger_helpers.rs index b89699a169..44e5884420 100644 --- a/backend/windmill-trigger/src/trigger_helpers.rs +++ b/backend/windmill-trigger/src/trigger_helpers.rs @@ -524,6 +524,7 @@ pub async fn trigger_runnable_inner<'c>( Uuid, Option, Option, + bool, Option>, )> { let error_handler_args = error_handler_args.map(|args| { @@ -536,10 +537,10 @@ pub async fn trigger_runnable_inner<'c>( }); let user_db = user_db.unwrap_or_else(|| UserDB::new(db.clone())); - let (uuid, resolved_delete_secs, early_return, tx_out) = if is_flow { + let (uuid, resolved_delete_secs, early_return, has_failure_module, tx_out) = if is_flow { let run_query = RunJobQuery { job_id, suspended_mode, ..Default::default() }; let path = StripPath(runnable_path.to_string()); - let (uuid, early_return, tx_out) = push_flow_job_by_path_into_queue( + let (uuid, early_return, has_failure_module, tx_out) = push_flow_job_by_path_into_queue( authed, db.clone(), tx_o, @@ -551,7 +552,7 @@ pub async fn trigger_runnable_inner<'c>( Some(trigger), ) .await?; - (uuid, None, early_return, tx_out) + (uuid, None, early_return, has_failure_module, tx_out) } else { let (uuid, resolved_delete_secs, tx_out) = trigger_script_internal( db, @@ -570,10 +571,16 @@ pub async fn trigger_runnable_inner<'c>( suspended_mode, ) .await?; - (uuid, resolved_delete_secs, None, tx_out) + (uuid, resolved_delete_secs, None, false, tx_out) }; - Ok((uuid, resolved_delete_secs, early_return, tx_out)) + Ok(( + uuid, + resolved_delete_secs, + early_return, + has_failure_module, + tx_out, + )) } #[allow(dead_code)] @@ -631,7 +638,7 @@ pub async fn trigger_runnable_and_wait_for_result( trigger: TriggerMetadata, ) -> Result { let username = authed.username.clone(); - let (uuid, resolved_delete_secs, early_return, _) = trigger_runnable_inner( + let (uuid, resolved_delete_secs, early_return, has_failure_module, _) = trigger_runnable_inner( db, None, user_db, @@ -649,8 +656,15 @@ pub async fn trigger_runnable_and_wait_for_result( None, ) .await?; - let (result, success) = - run_wait_result_internal(db, uuid, &workspace_id, early_return, &username).await?; + let (result, success) = run_wait_result_internal( + db, + uuid, + &workspace_id, + early_return, + has_failure_module, + &username, + ) + .await?; match resolved_delete_secs { Some(0) => delete_job_metadata_after_use(&db, uuid).await?, @@ -677,7 +691,7 @@ pub async fn trigger_runnable_and_wait_for_raw_result( trigger: TriggerMetadata, ) -> Result<(Box, bool)> { let username = authed.username.clone(); - let (uuid, resolved_delete_secs, early_return, _) = trigger_runnable_inner( + let (uuid, resolved_delete_secs, early_return, has_failure_module, _) = trigger_runnable_inner( db, None, user_db, @@ -696,16 +710,22 @@ pub async fn trigger_runnable_and_wait_for_raw_result( ) .await?; - let (result, success) = - run_wait_result_internal(db, uuid, &workspace_id, early_return, &username) - .await - .with_context(|| { - format!( - "Error fetching job result for {} {}", - if is_flow { "flow" } else { "script" }, - runnable_path - ) - })?; + let (result, success) = run_wait_result_internal( + db, + uuid, + &workspace_id, + early_return, + has_failure_module, + &username, + ) + .await + .with_context(|| { + format!( + "Error fetching job result for {} {}", + if is_flow { "flow" } else { "script" }, + runnable_path + ) + })?; match resolved_delete_secs { Some(0) => delete_job_metadata_after_use(&db, uuid).await?, From 740a35bf7b20f0bd8cb94c3d703dd353f0711b0a Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Wed, 20 May 2026 18:15:11 +0200 Subject: [PATCH 03/71] fix(flows): flag noLogs jobs and lazily resolve them in log panel (#9099) * fix(flows): flag noLogs jobs and lazily resolve them in log panel * fix appending to flag * fix: preserve WM_LOGS_SKIPPED sentinel on SSE/replay completion pickMoreCompleteLogs resolved both sentinel and undefined to '', so the SSE completion event (whose job field is fetched .without_logs()) would clobber the sentinel placed by flagSkippedLogs. The module log panel then saw '' instead of the sentinel, defeating the lazy-resolve path. Also wire onLogsResolved on the OutputPickerInner inline LogViewer so a lazy resolve writes back to flowStateStore.previewLogs, matching ModulePreviewResultViewer and avoiding repeated fetches on remount. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- frontend/src/lib/components/JobLoader.svelte | 112 +++++++++++++----- frontend/src/lib/components/LogViewer.svelte | 63 ++++++++-- .../ModulePreviewResultViewer.svelte | 14 ++- .../flows/propPicker/OutputPickerInner.svelte | 21 +++- frontend/src/lib/consts.ts | 6 + 5 files changed, 172 insertions(+), 44 deletions(-) diff --git a/frontend/src/lib/components/JobLoader.svelte b/frontend/src/lib/components/JobLoader.svelte index 78429177a7..7913fd4be7 100644 --- a/frontend/src/lib/components/JobLoader.svelte +++ b/frontend/src/lib/components/JobLoader.svelte @@ -15,6 +15,7 @@ type OpenFlow } from '$lib/gen' import { workspaceStore } from '$lib/stores' + import { WM_LOGS_SKIPPED } from '$lib/consts' import { getContext, onDestroy, tick, untrack } from 'svelte' import type { SupportedLanguage } from '$lib/common' import { sendUserToast } from '$lib/toast' @@ -129,6 +130,37 @@ } }) + function isSkippedLogsValue(logs: string | undefined): boolean { + return logs === WM_LOGS_SKIPPED + } + + function getResolvedLogs(logs: string | undefined): string { + return isSkippedLogsValue(logs) ? '' : (logs ?? '') + } + + function mergeLogs(existingLogs: string | undefined, newLogs: string | undefined): string { + const existing = getResolvedLogs(existingLogs) + const incoming = newLogs ?? '' + return existing.length === 0 ? incoming : existing.concat(incoming) + } + + function pickMoreCompleteLogs( + primaryLogs: string | undefined, + fallbackLogs: string | undefined + ): string { + const primary = getResolvedLogs(primaryLogs) + const fallback = getResolvedLogs(fallbackLogs) + // When neither side has real logs but one was the skipped sentinel, keep + // the sentinel so downstream consumers can still lazily resolve logs + // instead of treating the job as having genuinely produced none. + if (primary.length === 0 && fallback.length === 0) { + return isSkippedLogsValue(primaryLogs) || isSkippedLogsValue(fallbackLogs) + ? WM_LOGS_SKIPPED + : '' + } + return primary.length >= fallback.length ? primary : fallback + } + function clearCurrentId() { if (currentId) { if (allowConcurentRequests) { @@ -251,7 +283,8 @@ function refreshLogOffset() { if (logOffset == 0) { - logOffset = job?.logs?.length ? job.logs?.length + 1 : 0 + const currentLogs = getResolvedLogs(job?.logs) + logOffset = currentLogs.length ? currentLogs.length + 1 : 0 } } export async function getLogs() { @@ -264,7 +297,7 @@ logOffset: logOffset }) - if ((job?.logs ?? '').length == 0) { + if (getResolvedLogs(job?.logs).length == 0) { job.logs = getUpdate.new_logs ?? '' logOffset = getUpdate.log_offset ?? 0 } @@ -385,11 +418,9 @@ if (event.data.completed) { const njob = (event.data as any).job as Job & { result_stream?: string } if (njob) { - // Use whichever logs are more complete (longer) - const streamedLogs = job?.logs ?? '' - const completedLogs = njob.logs ?? '' - njob.logs = - streamedLogs.length >= completedLogs.length ? streamedLogs : completedLogs + // Use whichever logs are more complete (longer), but never + // let the WM_LOGS_SKIPPED sentinel win over real logs. + njob.logs = pickMoreCompleteLogs(job?.logs, njob.logs) const streamedResult = job?.result_stream ?? '' const completedResult = njob.result_stream ?? '' njob.result_stream = @@ -451,11 +482,10 @@ } if (previewJobUpdates.new_logs) { - if (logOffset == 0) { - job.logs = previewJobUpdates.new_logs ?? '' - } else { - job.logs = (job?.logs ?? '').concat(previewJobUpdates.new_logs) - } + job.logs = + logOffset == 0 + ? (previewJobUpdates.new_logs ?? '') + : mergeLogs(job?.logs, previewJobUpdates.new_logs) } if (previewJobUpdates.new_result_stream) { @@ -500,6 +530,17 @@ callbacks?.change?.(job) } } + // When a job is fetched with no_logs=true the server omits logs entirely. + // Flag it with a sentinel so consumers (the log panel) can tell "logs were + // intentionally skipped" apart from "job genuinely produced no logs", and + // lazily resolve the real logs on demand. + function flagSkippedLogs(j: T, effectiveNoLogs: boolean): T { + if (effectiveNoLogs && !(j as Job & { logs?: string }).logs) { + ;(j as Job & { logs?: string }).logs = WM_LOGS_SKIPPED + } + return j + } + async function loadTestJob(id: string, callbacks?: Callbacks): Promise { let isCompleted = false if (isCurrentJob(id)) { @@ -519,23 +560,29 @@ }) if ((previewJobUpdates.running ?? false) || (previewJobUpdates.completed ?? false)) { - job = await JobService.getJob({ - workspace: workspace!, - id, - noCode, - noLogs: onlyResult || noLogs - }) + job = flagSkippedLogs( + await JobService.getJob({ + workspace: workspace!, + id, + noCode, + noLogs: onlyResult || noLogs + }), + onlyResult || noLogs + ) callbacks?.change?.(job) } updateJobFromProgress(previewJobUpdates, job, callbacks) } else { - job = await JobService.getJob({ - workspace: workspace!, - id, - noLogs: onlyResult || noLogs, - noCode - }) + job = flagSkippedLogs( + await JobService.getJob({ + workspace: workspace!, + id, + noLogs: onlyResult || noLogs, + noCode + }), + onlyResult || noLogs + ) } jobUpdateLastFetch = new Date() @@ -628,12 +675,15 @@ try { // First load the job to get initial state if ((!job || job.id == '') && !onlyResult) { - job = await JobService.getJob({ - workspace: workspace!, - id, - noLogs: noLogs, - noCode - }) + job = flagSkippedLogs( + await JobService.getJob({ + workspace: workspace!, + id, + noLogs: noLogs, + noCode + }), + noLogs + ) callbacks?.change?.(job) getActiveRecording()?.recordInitialJob(id, job) @@ -775,7 +825,7 @@ clearCurrentId() } else { const njob = previewJobUpdates.job as Job & { result_stream?: string } - njob.logs = job?.logs ?? '' + njob.logs = pickMoreCompleteLogs(job?.logs, njob.logs) njob.result_stream = job?.result_stream ?? '' job = njob onJobCompleted(id, job, callbacks) diff --git a/frontend/src/lib/components/LogViewer.svelte b/frontend/src/lib/components/LogViewer.svelte index 39089dec8b..ca90e3968f 100644 --- a/frontend/src/lib/components/LogViewer.svelte +++ b/frontend/src/lib/components/LogViewer.svelte @@ -21,6 +21,7 @@ import { AnsiUp } from 'ansi_up' import NoWorkerWithTagWarning from './runs/NoWorkerWithTagWarning.svelte' import { JobService } from '$lib/gen' + import { WM_LOGS_SKIPPED } from '$lib/consts' import Tooltip from './Tooltip.svelte' import { twMerge } from 'tailwind-merge' import QueuePosition from './QueuePosition.svelte' @@ -42,6 +43,10 @@ tagLabel?: string noPadding?: boolean navigationId?: string + /** Called once after we resolve a WM_LOGS_SKIPPED sentinel by fetching the + * full job. Use this to write the real logs back into the source of truth + * (e.g. flowStateStore) so subsequent mounts don't refetch. */ + onLogsResolved?: (logs: string) => void } let { @@ -60,7 +65,8 @@ customEmptyMessage = 'No logs are available yet', tagLabel = undefined, noPadding = false, - navigationId = undefined + navigationId = undefined, + onLogsResolved }: Props = $props() // @ts-ignore @@ -80,6 +86,34 @@ let loadedFromObjectStore = $state('') + // `content` is the WM_LOGS_SKIPPED sentinel when the job was fetched with + // no_logs=true. If an older in-memory value accidentally has real bytes + // concatenated after the sentinel, treat that as skipped too and refetch. + let isLogsSkipped = $derived((content ?? '').startsWith(WM_LOGS_SKIPPED)) + let resolvedSkippedLogs: string | undefined = $state(undefined) + let fetchedSkippedJobId: string | undefined = $state(undefined) + let effectiveContent = $derived(isLogsSkipped ? resolvedSkippedLogs : content) + let resolvingSkippedLogs = $derived(isLogsSkipped && !!jobId && resolvedSkippedLogs === undefined) + + $effect(() => { + if (!isLogsSkipped || !jobId || fetchedSkippedJobId === jobId) { + return + } + const id = jobId + fetchedSkippedJobId = id + untrack(() => { + JobService.getJob({ workspace: $workspaceStore ?? '', id }) + .then((j) => { + if (fetchedSkippedJobId === id) { + const logs = (j as { logs?: string })['logs'] ?? '' + resolvedSkippedLogs = logs + onLogsResolved?.(logs) + } + }) + .catch((e) => console.error('Failed to resolve skipped logs', e)) + }) + }) + function findPrefixInfo( truncateContent: string ): { prefixIndex: number; position: number } | undefined { @@ -181,7 +215,7 @@ })) as string LOG_LIMIT += Math.min(LOG_INC, res.length) loadedFromObjectStore = res + loadedFromObjectStore - let newC = truncateContent(content, loadedFromObjectStore, LOG_LIMIT) + let newC = truncateContent(effectiveContent, loadedFromObjectStore, LOG_LIMIT) LOG_LIMIT -= newC.indexOf('\n') + 1 } else { console.error('No file detected to download from') @@ -191,7 +225,7 @@ function showMoreTruncate(len: number) { scroll = false LOG_LIMIT += LOG_INC - let newC = truncateContent(content, loadedFromObjectStore, LOG_LIMIT) + let newC = truncateContent(effectiveContent, loadedFromObjectStore, LOG_LIMIT) let newlineIndex = newC.indexOf('\n') + 1 if (newlineIndex < LOG_INC / 2) { LOG_LIMIT -= newlineIndex @@ -203,12 +237,16 @@ loadedFromObjectStore = '' LOG_LIMIT = LOG_INC scroll = true + resolvedSkippedLogs = undefined + fetchedSkippedJobId = undefined } }) let logsApiPath = $derived(`/w/${$workspaceStore}/jobs_u/get_logs/${jobId}`) let downloadHref = $derived(withExternalDomain(`${base}/api${logsApiPath}`)) let downloadName = $derived(`windmill_logs_${jobId}.txt`) - let truncatedContent = $derived(truncateContent(content, loadedFromObjectStore, LOG_LIMIT)) + let truncatedContent = $derived( + truncateContent(effectiveContent, loadedFromObjectStore, LOG_LIMIT) + ) let prefixInfo = $derived(findPrefixInfo(truncatedContent)) let downloadStartUrl = $derived(findStartUrl(truncatedContent, prefixInfo)) $effect.pre(() => { @@ -274,7 +312,7 @@ {/if} {#if content}{@const len = - (content?.length ?? 0) + (loadedFromObjectStore?.length ?? 0)}{#if splitHtml}{@html splitHtml.before}{#if resolvingSkippedLogs}{:else if effectiveContent}{@const len = + (effectiveContent?.length ?? 0) + + (loadedFromObjectStore?.length ?? 0)}{#if splitHtml}{@html splitHtml.before}{@html splitHtml.after}{:else if downloadStartUrl} + {/each} + + {/snippet} + + + {/if} + {#if effectiveAutonomyMode === AIAutonomyMode.YOLO && aiChatManager.autoAcceptToolConfirmationsAvailable} + + + {#snippet text()} +
+

+ {aiChatManager.autoAcceptEditsAvailable + ? 'Yolo auto-accepts edits and tool usage.' + : 'Yolo auto-accepts tool usage.'} +

+

+ {aiChatManager.autoAcceptEditsAvailable + ? 'This can result in edits being applied or tools being called without user confirmation.' + : 'This can result in tools being called without user confirmation.'} +

+ {#if yoloBypassedTools.length > 0} +

Bypassed in current mode:

+
    + {#each visibleYoloBypassedTools as tool (tool.name)} +
  • {tool.label}
  • + {/each} +
+ {#if hiddenYoloBypassedToolCount > 0} +

+ {hiddenYoloBypassedToolCount} more

+ {/if} + {:else} +

No tools in the current mode require confirmation.

+ {/if} +
+ {/snippet} +
+ {/if} + {#if aiChatManager.mode === AIMode.SCRIPT && hasDiff} + + {/if} + + {/if} {#if disabled}
diff --git a/frontend/src/lib/components/copilot/chat/AIChatInlineWidget.svelte b/frontend/src/lib/components/copilot/chat/AIChatInlineWidget.svelte index 8e57dde1fe..da1a1e26af 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatInlineWidget.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatInlineWidget.svelte @@ -213,7 +213,7 @@ try { const reply = await aiChatManager.sendInlineRequest(instructions, selectedCode, selection) if (reply) { - aiChatManager.scriptEditorApplyCode?.(reply) + await aiChatManager.applyScriptEditorCode(reply) } } catch (error) { console.error('Inline AI request failed:', error) diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 8b56edcc43..93145e3b1b 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -43,6 +43,7 @@ import type { FlowModuleState, FlowState } from '$lib/components/flows/flowState import type { CurrentEditor, ExtendedOpenFlow } from '$lib/components/flows/types' import { untrack } from 'svelte' import { get } from 'svelte/store' +import { BROWSER } from 'esm-env' import { workspaceStore, type DBSchemas } from '$lib/stores' import { askTools, prepareAskSystemMessage, prepareAskUserMessage } from './ask/core' import { chatState, DEFAULT_SIZE, triggerablesByAi } from './sharedChatState.svelte' @@ -66,6 +67,8 @@ import { isGlobalAiEnabled } from './global/gate' // If the estimated token usage is greater than the model context window - the threshold, we delete the oldest message const MAX_TOKENS_THRESHOLD_PERCENTAGE = 0.05 const MAX_TOKENS_HARD_LIMIT = 5000 +const AI_AUTONOMY_MODE_STORAGE_KEY = 'ai-chat-autonomy-mode' +const LEGACY_AUTO_ACCEPT_TOOL_CONFIRMATIONS_STORAGE_KEY = 'ai-chat-yolo-mode' export enum AIMode { SCRIPT = 'script', @@ -77,12 +80,38 @@ export enum AIMode { ASK = 'ask' } +export enum AIAutonomyMode { + DEFAULT = 'default', + ACCEPT_EDIT = 'acceptedit', + YOLO = 'yolo' +} + const ALL_AI_MODES = Object.values(AIMode) +const ALL_AI_AUTONOMY_MODES = Object.values(AIAutonomyMode) +const AUTO_ACCEPT_EDIT_MODES = new Set([AIMode.SCRIPT, AIMode.FLOW]) +const AUTO_ACCEPT_TOOL_CONFIRMATION_MODES = new Set([ + AIMode.SCRIPT, + AIMode.FLOW, + AIMode.APP, + AIMode.GLOBAL +]) export function isAIMode(mode: unknown): mode is AIMode { return ALL_AI_MODES.includes(mode as AIMode) } +export function isAIAutonomyMode(mode: unknown): mode is AIAutonomyMode { + return ALL_AI_AUTONOMY_MODES.includes(mode as AIAutonomyMode) +} + +export function supportsAutoAcceptEdits(mode: AIMode): boolean { + return AUTO_ACCEPT_EDIT_MODES.has(mode) +} + +export function supportsAutoAcceptToolConfirmations(mode: AIMode): boolean { + return AUTO_ACCEPT_TOOL_CONFIRMATION_MODES.has(mode) +} + export function isAIModeVisible(mode: AIMode): boolean { return mode !== AIMode.GLOBAL || isGlobalAiEnabled() } @@ -95,6 +124,26 @@ function isWorkspacePath(path: string | undefined): path is string { return path?.startsWith('f/') === true || path?.startsWith('u/') === true } +function getPersistedAutonomyMode(): AIAutonomyMode { + if (!BROWSER || typeof localStorage === 'undefined') { + return AIAutonomyMode.DEFAULT + } + const persistedMode = localStorage.getItem(AI_AUTONOMY_MODE_STORAGE_KEY) + if (isAIAutonomyMode(persistedMode)) { + return persistedMode + } + return localStorage.getItem(LEGACY_AUTO_ACCEPT_TOOL_CONFIRMATIONS_STORAGE_KEY) === 'true' + ? AIAutonomyMode.YOLO + : AIAutonomyMode.DEFAULT +} + +function persistAutonomyMode(mode: AIAutonomyMode) { + if (!BROWSER || typeof localStorage === 'undefined') { + return + } + localStorage.setItem(AI_AUTONOMY_MODE_STORAGE_KEY, mode) +} + export class AIChatManager { contextManager = new ContextManager() historyManager = new HistoryManager() @@ -112,6 +161,17 @@ export class AIChatManager { currentReply = $state('') displayMessages = $state([]) messages = $state([]) + autonomyMode = $state(getPersistedAutonomyMode()) + autoAcceptEditsAvailable = $derived(supportsAutoAcceptEdits(this.mode)) + autoAcceptEditsActive = $derived( + this.autoAcceptEditsAvailable && + (this.autonomyMode === AIAutonomyMode.ACCEPT_EDIT || + this.autonomyMode === AIAutonomyMode.YOLO) + ) + autoAcceptToolConfirmationsAvailable = $derived(supportsAutoAcceptToolConfirmations(this.mode)) + autoAcceptToolConfirmationsActive = $derived( + this.autonomyMode === AIAutonomyMode.YOLO && this.autoAcceptToolConfirmationsAvailable + ) #automaticScroll = $state(true) systemMessage = $state({ role: 'system', @@ -122,9 +182,9 @@ export class AIChatManager { scriptEditorOptions = $state(undefined) flowOptions = $state(undefined) - scriptEditorApplyCode = $state<((code: string, opts?: ReviewChangesOpts) => void) | undefined>( - undefined - ) + scriptEditorApplyCode = $state< + ((code: string, opts?: ReviewChangesOpts) => void | Promise) | undefined + >(undefined) scriptEditorShowDiffMode = $state<(() => void) | undefined>(undefined) scriptEditorGetLintErrors = $state<(() => ScriptLintResult) | undefined>(undefined) flowAiChatHelpers = $state(undefined) @@ -141,7 +201,7 @@ export class AIChatManager { /** Cached datatables for app context (fetched asynchronously) */ cachedDatatables = $state([]) - private confirmationCallback = $state<((value: boolean) => void) | undefined>(undefined) + private confirmationCallbacks = new Map void>() private userQuestionCallbacks = new Map void>() private appDatatablesRefreshTimeout: ReturnType | undefined = undefined @@ -215,20 +275,65 @@ export class AIChatManager { // Request confirmation from user for a tool call requestConfirmation = (toolId: string): Promise => { + if (this.autoAcceptToolConfirmationsActive) { + return Promise.resolve(true) + } + return new Promise((resolve) => { - // Store the callback for this specific tool - this.confirmationCallback = resolve + this.confirmationCallbacks.set(toolId, resolve) }) } // Handle confirmation response for a specific tool handleToolConfirmation = (toolId: string, confirmed: boolean) => { - if (this.confirmationCallback) { - this.confirmationCallback(confirmed) - this.confirmationCallback = undefined + const confirmationCallback = this.confirmationCallbacks.get(toolId) + if (confirmationCallback) { + confirmationCallback(confirmed) + this.confirmationCallbacks.delete(toolId) } } + private acceptPendingToolConfirmations = () => { + for (const confirmationCallback of this.confirmationCallbacks.values()) { + confirmationCallback(true) + } + this.confirmationCallbacks.clear() + } + + private acceptPendingFlowEdits = (flowHelpers = this.flowAiChatHelpers) => { + if (flowHelpers?.hasPendingChanges()) { + flowHelpers.acceptAllModuleActions() + } + } + + setAutonomyMode = (mode: AIAutonomyMode) => { + this.autonomyMode = mode + persistAutonomyMode(mode) + + if (this.autoAcceptToolConfirmationsActive) { + this.acceptPendingToolConfirmations() + } + if (this.autoAcceptEditsActive) { + this.acceptPendingFlowEdits() + } + } + + setAutoAcceptToolConfirmations = (enabled: boolean) => { + this.setAutonomyMode(enabled ? AIAutonomyMode.YOLO : AIAutonomyMode.DEFAULT) + } + + applyScriptEditorCode = async (code: string, opts?: ReviewChangesOpts) => { + if (this.autoAcceptEditsActive && opts?.mode === 'revert') { + return + } + + const effectiveOpts = + this.autoAcceptEditsActive && (opts?.mode ?? 'apply') === 'apply' + ? ({ ...opts, mode: 'apply', applyAll: true } satisfies ReviewChangesOpts) + : opts + await this.scriptEditorApplyCode?.(code, effectiveOpts) + } + requestUserQuestion = ( toolId: string, _question: { question: string; choices: string[] } @@ -346,7 +451,7 @@ export class AIChatManager { }, getWorkspaceMutationTarget: this.getScriptWorkspaceMutationTarget, applyCode: (code: string, opts?: ReviewChangesOpts) => { - this.scriptEditorApplyCode?.(code, opts) + return this.applyScriptEditorCode(code, opts) }, getLintErrors: () => { if (this.scriptEditorGetLintErrors) { @@ -874,6 +979,7 @@ export class AIChatManager { } }, requestConfirmation: this.requestConfirmation, + shouldAutoAcceptToolConfirmations: () => this.autoAcceptToolConfirmationsActive, requestUserQuestion: this.requestUserQuestion } } @@ -886,6 +992,9 @@ export class AIChatManager { ...params }) this.messages = [...this.messages, ...(addedMessages ?? [])] + if (this.autoAcceptEditsActive) { + this.acceptPendingFlowEdits() + } await this.historyManager.saveChat(this.displayMessages, this.messages) } catch (err) { console.error(err) @@ -901,10 +1010,10 @@ export class AIChatManager { } cancel = (reason?: string) => { - if (this.confirmationCallback) { - this.confirmationCallback(false) - this.confirmationCallback = undefined + for (const confirmationCallback of this.confirmationCallbacks.values()) { + confirmationCallback(false) } + this.confirmationCallbacks.clear() for (const resolveQuestion of this.userQuestionCallbacks.values()) { resolveQuestion(undefined) } @@ -1060,10 +1169,10 @@ export class AIChatManager { listenForCurrentEditorChanges = (currentEditor: CurrentEditor) => { if (currentEditor && currentEditor.type === 'script') { - this.scriptEditorApplyCode = (code) => { + this.scriptEditorApplyCode = async (code, opts) => { if (currentEditor && currentEditor.type === 'script') { currentEditor.hideDiffMode() - currentEditor.editor.reviewAndApplyCode(code) + await currentEditor.editor.reviewAndApplyCode(code, opts) } } this.scriptEditorShowDiffMode = () => { @@ -1164,6 +1273,11 @@ export class AIChatManager { setFlowHelpers = (flowHelpers: FlowAIChatHelpers) => { this.flowAiChatHelpers = flowHelpers + untrack(() => { + if (this.autoAcceptEditsActive) { + this.acceptPendingFlowEdits(flowHelpers) + } + }) return () => { this.flowAiChatHelpers = undefined diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts new file mode 100644 index 0000000000..df2cba5575 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts @@ -0,0 +1,155 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { FlowAIChatHelpers } from './flow/core' +import type { CurrentEditor } from '$lib/components/flows/types' +import type { ReviewChangesOpts } from './monaco-adapter' +import { AIChatManager, AIMode, AIAutonomyMode } from './AIChatManager.svelte' + +vi.mock('monaco-editor', () => ({ + Selection: class Selection {} +})) + +vi.mock('$lib/gen', () => ({ + WorkspaceService: {}, + ScriptService: {}, + FlowService: {}, + JobService: {} +})) + +vi.mock('$lib/stores', () => ({ + workspaceStore: { subscribe: () => () => undefined } +})) + +vi.mock('$lib/toast', () => ({ + sendUserToast: vi.fn() +})) + +vi.mock('$lib/aiStore', () => ({ + getCurrentModel: () => undefined, + tryGetCurrentModel: () => undefined, + getCombinedCustomPrompt: () => '' +})) + +vi.mock('../lib', () => ({ + getModelContextWindow: () => 128000, + workspaceAIClients: { subscribe: () => () => undefined } +})) + +vi.mock('./api/apiTools', () => ({ + loadApiTools: vi.fn() +})) + +vi.mock('./chatLoop', () => ({ + runChatLoop: vi.fn() +})) + +vi.mock('./global/gate', () => ({ + isGlobalAiEnabled: () => true +})) + +function createFlowHelpers({ + hasPendingChanges, + acceptAllModuleActions +}: { + hasPendingChanges: () => boolean + acceptAllModuleActions: () => void +}): FlowAIChatHelpers { + return { + getFlowAndSelectedId: vi.fn(), + getRootModules: vi.fn(), + inlineScriptSession: { get: vi.fn(), set: vi.fn(), clear: vi.fn() }, + setSnapshot: vi.fn(), + revertToSnapshot: vi.fn(), + setCode: vi.fn(), + setFlowJson: vi.fn(), + getFlowInputsSchema: vi.fn(), + updateExprsToSet: vi.fn(), + acceptAllModuleActions, + rejectAllModuleActions: vi.fn(), + hasPendingChanges, + selectStep: vi.fn(), + testFlow: vi.fn(), + getLintErrors: vi.fn() + } as unknown as FlowAIChatHelpers +} + +describe('AIChatManager autonomy mode', () => { + beforeEach(() => { + localStorage.clear() + vi.clearAllMocks() + }) + + it('accepts pending flow edits when auto-accept is enabled from script mode', async () => { + const manager = new AIChatManager() + const acceptAllModuleActions = vi.fn() + + manager.mode = AIMode.SCRIPT + manager.setFlowHelpers( + createFlowHelpers({ + hasPendingChanges: () => true, + acceptAllModuleActions + }) + ) + + manager.setAutonomyMode(AIAutonomyMode.ACCEPT_EDIT) + + expect(acceptAllModuleActions).toHaveBeenCalledTimes(1) + }) + + it('accepts pending flow edits when helpers register while auto-accept is already enabled', async () => { + const manager = new AIChatManager() + const acceptAllModuleActions = vi.fn() + + manager.mode = AIMode.SCRIPT + manager.setAutonomyMode(AIAutonomyMode.ACCEPT_EDIT) + manager.setFlowHelpers( + createFlowHelpers({ + hasPendingChanges: () => true, + acceptAllModuleActions + }) + ) + + expect(acceptAllModuleActions).toHaveBeenCalledTimes(1) + }) + + it('waits for flow step editor review before resolving applyScriptEditorCode', async () => { + const manager = new AIChatManager() + let finishReview: (() => void) | undefined + const reviewPromise = new Promise((resolve) => { + finishReview = resolve + }) + const hideDiffMode = vi.fn() + const reviewAndApplyCode = vi.fn(() => reviewPromise) + const opts = { mode: 'apply' } satisfies ReviewChangesOpts + + manager.listenForCurrentEditorChanges({ + type: 'script', + stepId: 'step-a', + editor: { + reviewAndApplyCode, + getLintErrors: vi.fn() + }, + showDiffMode: vi.fn(), + hideDiffMode, + diffMode: false, + lastDeployedCode: undefined + } as unknown as CurrentEditor) + + let applied = false + const applyPromise = manager + .applyScriptEditorCode('export async function main() {}', opts) + .then(() => { + applied = true + }) + + await Promise.resolve() + + expect(hideDiffMode).toHaveBeenCalledTimes(1) + expect(reviewAndApplyCode).toHaveBeenCalledWith('export async function main() {}', opts) + expect(applied).toBe(false) + + finishReview?.() + await applyPromise + + expect(applied).toBe(true) + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/flow/FlowAIChat.svelte b/frontend/src/lib/components/copilot/chat/flow/FlowAIChat.svelte index d8ca5858ea..9a12a04548 100644 --- a/frontend/src/lib/components/copilot/chat/flow/FlowAIChat.svelte +++ b/frontend/src/lib/components/copilot/chat/flow/FlowAIChat.svelte @@ -1,6 +1,6 @@ diff --git a/frontend/src/lib/components/copilot/chat/shared.test.ts b/frontend/src/lib/components/copilot/chat/shared.test.ts index d26f5cbb54..69b868ee50 100644 --- a/frontend/src/lib/components/copilot/chat/shared.test.ts +++ b/frontend/src/lib/components/copilot/chat/shared.test.ts @@ -247,6 +247,49 @@ describe('processToolCall', () => { expect(result.content).toBe('ok') }) + it('auto-accepts required confirmations when yolo mode is active', async () => { + const { createToolDef, processToolCall } = await import('./shared') + const fn = vi.fn().mockResolvedValue('ok') + const requestConfirmation = vi.fn() + const setToolStatus = vi.fn() + + const result = await processToolCall({ + tools: [ + { + def: createToolDef(z.object({}), 'create_schedule', 'Create schedule'), + requiresConfirmation: true, + confirmationMessage: 'Create schedule', + fn + } + ], + toolCall: { + id: 'call_yolo', + type: 'function', + function: { name: 'create_schedule', arguments: '{}' } + }, + helpers: {}, + workspace: 'test-workspace', + toolCallbacks: { + setToolStatus, + removeToolStatus: vi.fn(), + requestConfirmation, + shouldAutoAcceptToolConfirmations: () => true + } + }) + + expect(requestConfirmation).not.toHaveBeenCalled() + expect(fn).toHaveBeenCalled() + expect(setToolStatus).toHaveBeenCalledWith( + 'call_yolo', + expect.objectContaining({ + content: 'Create schedule', + isLoading: true, + needsConfirmation: false + }) + ) + expect(result.content).toBe('ok') + }) + it('blocks workspace mutation tools for undeployed scripts and flows', async () => { const { processToolCall } = await import('./shared') const { createWorkspaceMutationTools } = await import('./workspaceTools') diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts index 0723cc3eb3..88ed20d664 100644 --- a/frontend/src/lib/components/copilot/chat/shared.ts +++ b/frontend/src/lib/components/copilot/chat/shared.ts @@ -582,10 +582,13 @@ export async function processToolCall({ } // Check if tool requires confirmation - const needsConfirmation = tool?.requiresConfirmation + const requiresConfirmation = tool?.requiresConfirmation === true + const autoAcceptConfirmation = + requiresConfirmation && toolCallbacks.shouldAutoAcceptToolConfirmations?.() === true + const needsConfirmation = requiresConfirmation && !autoAcceptConfirmation toolCallbacks.setToolStatus(toolCall.id, { - ...(tool?.requiresConfirmation + ...(requiresConfirmation ? { content: tool.confirmationMessage ?? 'Waiting for confirmation...' } : {}), parameters: args, @@ -695,6 +698,7 @@ export interface ToolCallbacks { setToolStatus: (id: string, metadata?: Partial) => void removeToolStatus: (id: string) => void requestConfirmation?: (toolId: string) => Promise + shouldAutoAcceptToolConfirmations?: () => boolean requestUserQuestion?: ( toolId: string, question: UserQuestionDisplay From 0bdb6a9d5d5fb28a27af1b6eda9fde7172308faf Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 21 May 2026 15:14:29 +0000 Subject: [PATCH 12/71] fix(debugger): add non-root user support to Dockerfile (#9277) Mirrors the main Windmill Dockerfile pattern: creates a windmill user (UID/GID 1000) and makes cache/work directories world-writable so the image runs cleanly under Kubernetes securityContext.runAsNonRoot or runAsUser: 1000 without permission errors on Bun, pip, or windmill cache writes. Fixes WIN-1969 Co-authored-by: Claude Opus 4.7 (1M context) --- debugger/Dockerfile | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/debugger/Dockerfile b/debugger/Dockerfile index fe2b769e7c..51e993746a 100644 --- a/debugger/Dockerfile +++ b/debugger/Dockerfile @@ -51,6 +51,14 @@ COPY dap_websocket_server.py . # Expose the default port EXPOSE 5679 +# Create a non-root user 'windmill' with UID and GID 1000 (mirrors main Windmill image) +RUN addgroup --gid 1000 windmill && \ + adduser --disabled-password --gecos "" --uid 1000 --gid 1000 windmill + +# Ensure cache and work directories are writable by any UID +RUN mkdir -p /tmp/windmill/cache /tmp/windmill/cache_nomount /tmp/.cache && \ + chmod -R 777 /tmp/windmill /tmp/.cache /app + # Health check HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ CMD curl -f http://localhost:5679/health || exit 1 From 0692b97c8a3818549d7050ea3e057e9cbf1ddb44 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 21 May 2026 15:21:27 +0000 Subject: [PATCH 13/71] fix(ai): enforce RLS and scope check on user-supplied X-Resource-Path (#9276) * fix(ai): enforce RLS and scope check on user-supplied X-Resource-Path The AI proxy handler accepts an X-Resource-Path header to override the configured workspace AI provider. When supplied, the handler loaded the resource value from the resource table using the root DB pool with no resources:read scope check, so any authenticated workspace user could point X-Resource-Path at a restricted AI resource (e.g. one in a folder they cannot read) and the proxy would use that resource's provider credentials for the outbound AI request. For user-supplied resource paths, now require resources:read:{path} scope and fetch the resource through user_db.begin(&authed) so RLS enforces the same folder/group boundary as the resource API. The RLS- scoped $var: resolution stays in place as defense in depth. The admin-configured workspace/instance ai_config path is unchanged. Fixes WIN-1971 Co-Authored-By: Claude Opus 4.7 (1M context) * test(ai): regression test for X-Resource-Path RLS enforcement Cover all four cases: - non-admin pointing X-Resource-Path at a restricted resource is rejected - non-admin pointing it at a resource they own still works - admin can point it at any resource - workspace-configured proxy flow (no X-Resource-Path) is unchanged Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../tests/ai_routes.rs | 126 ++++++++++++++++++ backend/windmill-api/src/ai.rs | 45 ++++++- 2 files changed, 164 insertions(+), 7 deletions(-) diff --git a/backend/windmill-api-integration-tests/tests/ai_routes.rs b/backend/windmill-api-integration-tests/tests/ai_routes.rs index 72b284fcd1..cfd93caef5 100644 --- a/backend/windmill-api-integration-tests/tests/ai_routes.rs +++ b/backend/windmill-api-integration-tests/tests/ai_routes.rs @@ -10,6 +10,10 @@ fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { builder.header("Authorization", "Bearer SECRET_TOKEN") } +fn authed_with(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder { + builder.header("Authorization", format!("Bearer {token}")) +} + fn assert_2xx(status: u16, body: &str, endpoint: &str) { assert!( (200..300).contains(&status), @@ -106,3 +110,125 @@ async fn test_ai_proxy_endpoints(db: Pool) -> anyhow::Result<()> { Ok(()) } + +/// Regression test for WIN-1971: the AI proxy's X-Resource-Path header must +/// honour resource RLS so that a low-privilege user cannot point the proxy +/// at a resource they are not allowed to read. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_ai_proxy_x_resource_path_enforces_rls(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + std::env::set_var("ALLOW_PRIVATE_AI_BASE_URLS", "true"); + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let mock_port = start_mock_ai_api().await; + let mock_url = format!("http://127.0.0.1:{mock_port}/v1"); + + // Resource owned by test-user (admin). With default extra_perms {} the + // RLS `see_own` policy restricts SELECT to user `test-user`. + sqlx::query( + "INSERT INTO resource (workspace_id, path, value, resource_type, extra_perms, created_by) \ + VALUES ('test-workspace', 'u/test-user/restricted_openai', $1::jsonb, 'openai', '{}', 'test-user')", + ) + .bind(json!({ + "api_key": "sk-secret-restricted", + "base_url": mock_url, + })) + .execute(&db) + .await?; + + // Sanity-check: normal resource API rejects test-user-3 (non-admin) for the restricted path. + let resp = authed_with( + client().get(format!( + "http://localhost:{port}/api/w/test-workspace/resources/get/u/test-user/restricted_openai" + )), + "SECRET_TOKEN_3", + ) + .send() + .await?; + assert!( + resp.status().as_u16() >= 400, + "normal resource API should deny test-user-3 reading restricted resource, got {}", + resp.status() + ); + + // The vulnerability: as a non-admin user, point X-Resource-Path at the + // restricted resource. Must be rejected before the proxy fetches/uses it. + let resp = authed_with( + client() + .post(format!( + "http://localhost:{port}/api/w/test-workspace/ai/proxy/chat/completions" + )) + .header("X-Provider", "openai") + .header("X-Resource-Path", "u/test-user/restricted_openai") + .json(&json!({ + "model": "gpt-4", + "messages": [{"role": "user", "content": "hi"}] + })), + "SECRET_TOKEN_3", + ) + .send() + .await?; + let status = resp.status().as_u16(); + let body = resp.text().await?; + assert!( + status >= 400, + "non-admin user should be rejected when X-Resource-Path points at a resource they cannot read, got {status}: {body}", + ); + + // A resource the non-admin owns must still work through X-Resource-Path. + sqlx::query( + "INSERT INTO resource (workspace_id, path, value, resource_type, extra_perms, created_by) \ + VALUES ('test-workspace', 'u/test-user-3/own_openai', $1::jsonb, 'openai', '{}', 'test-user-3')", + ) + .bind(json!({ + "api_key": "sk-self", + "base_url": mock_url, + })) + .execute(&db) + .await?; + + let resp = authed_with( + client() + .post(format!( + "http://localhost:{port}/api/w/test-workspace/ai/proxy/chat/completions" + )) + .header("X-Provider", "openai") + .header("X-Resource-Path", "u/test-user-3/own_openai") + .json(&json!({ + "model": "gpt-4", + "messages": [{"role": "user", "content": "hi"}] + })), + "SECRET_TOKEN_3", + ) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "non-admin with X-Resource-Path on owned resource", + ); + + // Admin must still be able to use X-Resource-Path on any resource. + let resp = authed( + client() + .post(format!( + "http://localhost:{port}/api/w/test-workspace/ai/proxy/chat/completions" + )) + .header("X-Provider", "openai") + .header("X-Resource-Path", "u/test-user/restricted_openai") + .json(&json!({ + "model": "gpt-4", + "messages": [{"role": "user", "content": "hi"}] + })), + ) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "admin with X-Resource-Path on restricted resource", + ); + + Ok(()) +} diff --git a/backend/windmill-api/src/ai.rs b/backend/windmill-api/src/ai.rs index 05bf995700..e7bc089d7c 100644 --- a/backend/windmill-api/src/ai.rs +++ b/backend/windmill-api/src/ai.rs @@ -1,6 +1,7 @@ #[cfg(feature = "bedrock")] use crate::bedrock; use crate::db::{ApiAuthed, DB}; +use crate::utils::check_scopes; #[cfg(feature = "bedrock")] use axum::routing::get; @@ -669,6 +670,7 @@ async fn global_proxy( async fn proxy( authed: ApiAuthed, Extension(db): Extension, + Extension(user_db): Extension, Path((w_id, mut ai_path)): Path<(String, String)>, method: Method, headers: HeaderMap, @@ -689,6 +691,16 @@ async fn proxy( .get("X-Resource-Path") .map(|v| v.to_str().unwrap_or("").to_string()); let is_user_specified_resource = forced_resource_path.is_some(); + + // When the caller supplies X-Resource-Path, the resource is treated as if it + // were being read through the normal resource API: scope and RLS checks must + // apply so that a low-privilege user cannot point the proxy at a restricted + // AI resource (e.g. one in a folder they cannot read) to exfiltrate the + // resource's provider credentials or use them via the proxy. + if let Some(resource_path) = forced_resource_path.as_ref() { + check_scopes(&authed, || format!("resources:read:{}", resource_path))?; + } + let request_config = match workspace_cache { Some(request_cache) if !request_cache.is_expired() && forced_resource_path.is_none() => { request_cache.config @@ -759,13 +771,32 @@ async fn proxy( ) }; - let resource = sqlx::query_scalar::<_, Option>>>( - "SELECT value FROM resource WHERE path = $1 AND workspace_id = $2", - ) - .bind(&resource_path) - .bind(&resource_workspace) - .fetch_optional(&db) - .await? + // For user-specified resources, fetch through an RLS-scoped + // connection so PostgreSQL row-level security enforces the same + // folder/group boundaries as the regular resource API. For the + // workspace/instance ai_config path, the resource_path was already + // validated by an admin/devops user when configuring the workspace, + // so the raw pool is used. + let resource = if is_user_specified_resource { + let mut tx = user_db.clone().begin(&authed).await?; + let res = sqlx::query_scalar::<_, Option>>>( + "SELECT value FROM resource WHERE path = $1 AND workspace_id = $2", + ) + .bind(&resource_path) + .bind(&resource_workspace) + .fetch_optional(&mut *tx) + .await?; + tx.commit().await?; + res + } else { + sqlx::query_scalar::<_, Option>>>( + "SELECT value FROM resource WHERE path = $1 AND workspace_id = $2", + ) + .bind(&resource_path) + .bind(&resource_workspace) + .fetch_optional(&db) + .await? + } .ok_or_else(|| Error::NotFound(format!("Could not find the resource {}, update the resource path in the workspace settings", resource_path)))? .ok_or_else(|| Error::BadRequest(format!("Empty resource value for {}", resource_path)))?; From d0ee697e8b8de58085ea0b2ecde1af2b2441428d Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Thu, 21 May 2026 17:30:17 +0200 Subject: [PATCH 14/71] feat: add userdraft listing primitives (#9268) * feat: add userdraft listing primitives * fix: cancel stale userdraft discard writes * docs: remove global ai userdraft plan --- frontend/src/lib/svelte5Utils.svelte.ts | 26 ++- frontend/src/lib/userDraft.svelte.ts | 190 ++++++++++++++++--- frontend/src/lib/userDraft.test.ts | 240 ++++++++++++++++++++++++ 3 files changed, 420 insertions(+), 36 deletions(-) diff --git a/frontend/src/lib/svelte5Utils.svelte.ts b/frontend/src/lib/svelte5Utils.svelte.ts index e3a927281a..aad16431cb 100644 --- a/frontend/src/lib/svelte5Utils.svelte.ts +++ b/frontend/src/lib/svelte5Utils.svelte.ts @@ -601,7 +601,7 @@ export function useLocalStorageValue( */ transformBeforePersist?: (val: T) => T } -): { val: T; skipNextWriteOnce(): void } { +): { val: T; skipNextWriteOnce(): void; setWithoutPersist(newVal: T): void } { const saveInitialValue = options?.saveInitialValue ?? true const debounceMs = options?.debounce ?? 0 const transformBeforePersist = options?.transformBeforePersist @@ -626,7 +626,9 @@ export function useLocalStorageValue( } } - if (typeof window === 'undefined') return { val: defaultValue, skipNextWriteOnce: () => {} } + if (typeof window === 'undefined') { + return { val: defaultValue, skipNextWriteOnce: () => {}, setWithoutPersist: () => {} } + } const savedValue = localStorage.getItem(key) let s = $state( savedValue != null && savedValue !== 'undefined' ? (deserialize(savedValue) as T) : defaultValue @@ -662,6 +664,13 @@ export function useLocalStorageValue( pendingValue = undefined }, debounceMs) } + const cancelPendingWrite = () => { + if (debounceTimer != null) { + clearTimeout(debounceTimer) + debounceTimer = undefined + } + pendingValue = undefined + } $effect(() => { readFieldsRecursively(s) @@ -698,12 +707,19 @@ export function useLocalStorageValue( /** * Arm the persist skip so the next `set val` (or deep-mutation flush) * updates only the in-memory cell and leaves localStorage untouched. - * Used by `UserDraft.discard` to reset the in-memory state to a - * fallback without re-persisting it — pairs with an explicit LS - * delete to leave the slot empty. */ skipNextWriteOnce(): void { skipNextWrite = true + }, + /** + * Reset the in-memory state while canceling any queued debounced write. + * Used when a caller performs its own synchronous persistence action. + */ + setWithoutPersist(newVal: T): void { + cancelPendingWrite() + lastSerialized = newVal === undefined ? undefined : serialize(newVal) + skipNextWrite = false + s = newVal } } } diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index 923b87fec5..3d83952513 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -4,31 +4,34 @@ import { deepEqual } from 'fast-equals' import { workspaceStore } from './stores' import { useLocalStorageValue } from './svelte5Utils.svelte' -export type UserDraftItemKind = - | 'script' - | 'flow' - | 'app' - | 'raw_app' - | 'resource' - | 'variable' - | 'trigger_schedule' - | 'trigger_webhook' - | 'trigger_default_email' - | 'trigger_email' - | 'trigger_http' - | 'trigger_websocket' - | 'trigger_postgres' - | 'trigger_kafka' - | 'trigger_nats' - | 'trigger_mqtt' - | 'trigger_sqs' - | 'trigger_gcp' - | 'trigger_azure' - | 'trigger_poll' - | 'trigger_cli' - | 'trigger_nextcloud' - | 'trigger_google' - | 'trigger_github' +export const USER_DRAFT_ITEM_KINDS = [ + 'script', + 'flow', + 'app', + 'raw_app', + 'resource', + 'variable', + 'trigger_schedule', + 'trigger_webhook', + 'trigger_default_email', + 'trigger_email', + 'trigger_http', + 'trigger_websocket', + 'trigger_postgres', + 'trigger_kafka', + 'trigger_nats', + 'trigger_mqtt', + 'trigger_sqs', + 'trigger_gcp', + 'trigger_azure', + 'trigger_poll', + 'trigger_cli', + 'trigger_nextcloud', + 'trigger_google', + 'trigger_github' +] as const + +export type UserDraftItemKind = (typeof USER_DRAFT_ITEM_KINDS)[number] export type UserDraftOptions = { workspace?: string @@ -43,6 +46,10 @@ export type UserDraftUseOptions = UserDraftOptions & { defaultValue?: V } +export type UserDraftListOptions = UserDraftOptions & { + itemKinds?: readonly UserDraftItemKind[] +} + /** * A single (kind, path, workspace) tuple that `useMany` should hold a handle * for. The shape mirrors `use()`'s arguments, just bundled into one object @@ -93,10 +100,14 @@ function stamp(stored: StoredDraft | undefined): StoredDraft | undefine type DraftState = { val: StoredDraft | undefined skipNextWriteOnce(): void + setWithoutPersist(newVal: StoredDraft | undefined): void } type DraftEntry = { count: number + workspace: string + itemKind: UserDraftItemKind + path: string state: DraftState /** * Tears down the `$effect.root` scope that owns the entry's @@ -109,6 +120,16 @@ type DraftEntry = { destroyRoot?: () => void } +export type UserDraftEntry = { + workspace: string + itemKind: UserDraftItemKind + path: string + value: V | undefined + meta: UserDraftMeta + persisted: boolean + live: boolean +} + const entries = new Map() function resolveWorkspace(opts?: UserDraftOptions): string { @@ -209,6 +230,36 @@ function localStorageKey(workspace: string, itemKind: UserDraftItemKind, path: s return `userdraft/w/${workspace}/${itemKind}/${path}` } +function parseLocalStorageKey( + key: string, + workspace: string, + itemKinds: readonly UserDraftItemKind[] +): { itemKind: UserDraftItemKind; path: string } | undefined { + const prefix = `userdraft/w/${workspace}/` + if (!key.startsWith(prefix)) return undefined + const rest = key.slice(prefix.length) + for (const itemKind of itemKinds) { + const kindPrefix = `${itemKind}/` + if (rest.startsWith(kindPrefix)) { + return { itemKind, path: rest.slice(kindPrefix.length) } + } + } + return undefined +} + +function snapshotDraftValue(value: V | undefined): V | undefined { + if (value === undefined) return undefined + try { + return structuredClone($state.snapshot(value)) as V + } catch { + try { + return JSON.parse(JSON.stringify(value)) as V + } catch { + return undefined + } + } +} + export type UserDraftHandle = { get draft(): V | undefined set draft(value: V | undefined) @@ -299,6 +350,27 @@ export const UserDraft = { } }, + setDraftAndMeta( + itemKind: UserDraftItemKind, + path: string, + value: V | undefined, + meta: UserDraftMeta, + opts?: UserDraftOptions + ): void { + const ws = resolveWorkspace(opts) + const mk = mapKey(ws, itemKind, path) + const entry = entries.get(mk) + if (entry) { + entry.state.val = wrap(value, meta) + // Static writes represent explicit external draft mutations. A + // freshly acquired live entry may still have the initial-write skip + // armed, so force the storage slot to match the live value. + persistDirect(localStorageKey(ws, itemKind, path), value, meta) + return + } + persistDirect(localStorageKey(ws, itemKind, path), value, meta) + }, + /** * Autosave gate: persist `value` only when it differs (after * `normalizeForCompare`) from the `deployed` baseline; otherwise remove @@ -396,6 +468,61 @@ export const UserDraft = { } }, + clear(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): void { + UserDraft.discard(itemKind, path, undefined, opts) + }, + + list(opts?: UserDraftListOptions): UserDraftEntry[] { + const ws = resolveWorkspace(opts) + const itemKinds = opts?.itemKinds ?? USER_DRAFT_ITEM_KINDS + const out = new Map>() + + if (typeof localStorage !== 'undefined') { + const keys: string[] = [] + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i) + if (key != null && key.startsWith(`userdraft/w/${ws}/`)) keys.push(key) + } + for (const key of keys) { + const parsed = parseLocalStorageKey(key, ws, itemKinds) + if (!parsed) continue + const stored = readPersisted(key) + if (stored === undefined) continue + out.set(mapKey(ws, parsed.itemKind, parsed.path), { + workspace: ws, + itemKind: parsed.itemKind, + path: parsed.path, + value: snapshotDraftValue(unwrap(stored)), + meta: extractMeta(stored), + persisted: true, + live: false + }) + } + } + + for (const entry of entries.values()) { + if (entry.workspace !== ws || !itemKinds.includes(entry.itemKind)) continue + const stored = untrack(() => entry.state.val as StoredDraft | undefined) + const mk = mapKey(entry.workspace, entry.itemKind, entry.path) + if (stored === undefined) { + out.delete(mk) + continue + } + const existing = out.get(mk) + out.set(mk, { + workspace: entry.workspace, + itemKind: entry.itemKind, + path: entry.path, + value: snapshotDraftValue(unwrap(stored)), + meta: extractMeta(stored), + persisted: existing?.persisted ?? false, + live: true + }) + } + + return Array.from(out.values()) + }, + /** * Like `remove`, but also resets any live handle's `draft` to * `fallback` in-memory (so reactive readers see it immediately) and @@ -412,10 +539,11 @@ export const UserDraft = { const mk = mapKey(ws, itemKind, path) const entry = entries.get(mk) if (entry) { - // Arm the skip before the cell write so the setter suppresses - // the persist; the removeItem below actually clears the slot. - entry.state.skipNextWriteOnce() - entry.state.val = wrap(fallback) as StoredDraft | undefined + // Drop any queued debounced write owned by this live entry before + // resetting the in-memory value. Otherwise a timer from the old + // entry can outlive unmount and later delete a freshly written + // draft for the same key. + entry.state.setWithoutPersist(wrap(fallback) as StoredDraft | undefined) } try { localStorage.removeItem(localStorageKey(ws, itemKind, path)) @@ -549,7 +677,7 @@ function acquireEntry( ) }) if (stateRef) { - entries.set(mk, { count: 1, state: stateRef, destroyRoot }) + entries.set(mk, { count: 1, workspace, itemKind, path, state: stateRef, destroyRoot }) return } // Fallback for the vitest runtime where `$effect.root`'s callback isn't @@ -560,7 +688,7 @@ function acquireEntry( undefined, useLocalStorageOptions ) - entries.set(mk, { count: 1, state }) + entries.set(mk, { count: 1, workspace, itemKind, path, state }) } function releaseEntry(mk: string): void { diff --git a/frontend/src/lib/userDraft.test.ts b/frontend/src/lib/userDraft.test.ts index 90d25000e9..f703953f11 100644 --- a/frontend/src/lib/userDraft.test.ts +++ b/frontend/src/lib/userDraft.test.ts @@ -719,3 +719,243 @@ describe('UserDraft.saveIfChanged', () => { expect(storedShape(KEY)).toBe(wrapped(value)) }) }) + +describe('UserDraft.list / clear / setDraftAndMeta', () => { + it('enumerates persisted-only drafts for the requested workspace and kinds', () => { + UserDraft.setDraftAndMeta('script', 'f/a', { path: 'f/a', content: 'a' }, { remoteRev: 'h1' }) + UserDraft.setDraftAndMeta( + 'flow', + 'f/b', + { path: 'f/b', value: { modules: [] } }, + { remoteRev: 2 }, + { workspace: 'other_ws' } + ) + UserDraft.setDraftAndMeta('resource', 'f/c', { path: 'f/c' }, {}) + + expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([ + { + workspace: 'test_ws', + itemKind: 'script', + path: 'f/a', + value: { path: 'f/a', content: 'a' }, + meta: { remoteRev: 'h1' }, + persisted: true, + live: false + } + ]) + expect(UserDraft.list({ workspace: 'other_ws' })).toEqual([ + expect.objectContaining({ + workspace: 'other_ws', + itemKind: 'flow', + path: 'f/b', + persisted: true, + live: false + }) + ]) + }) + + it('keeps multiple path-addressed drafts and the empty-path scratch draft distinct', () => { + UserDraft.setDraftAndMeta('script', '', { path: '', content: 'scratch' }, {}) + UserDraft.setDraftAndMeta('script', 'f/new-a', { path: 'f/new-a', content: 'a' }, {}) + UserDraft.setDraftAndMeta('script', 'f/new-b', { path: 'f/new-b', content: 'b' }, {}) + + const entries = UserDraft.list<{ path: string; content: string }>({ itemKinds: ['script'] }) + + expect(entries).toHaveLength(3) + expect(entries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + itemKind: 'script', + path: '', + value: { path: '', content: 'scratch' } + }), + expect.objectContaining({ + itemKind: 'script', + path: 'f/new-a', + value: { path: 'f/new-a', content: 'a' } + }), + expect.objectContaining({ + itemKind: 'script', + path: 'f/new-b', + value: { path: 'f/new-b', content: 'b' } + }) + ]) + ) + }) + + it('enumerates live-only drafts before the debounce persists them', () => { + const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/live') + handle.setDraftAndMeta({ path: 'f/live', content: 'live' }, { remoteRev: 'h1' }) + + expect(localStorage.getItem('userdraft/w/test_ws/script/f/live')).toBeNull() + expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([ + { + workspace: 'test_ws', + itemKind: 'script', + path: 'f/live', + value: { path: 'f/live', content: 'live' }, + meta: { remoteRev: 'h1' }, + persisted: false, + live: true + } + ]) + }) + + it('dedupes entries that are both persisted and live', () => { + UserDraft.setDraftAndMeta( + 'script', + 'f/both', + { path: 'f/both', content: 'persisted' }, + { + remoteRev: 'h1' + } + ) + const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/both') + handle.draft = { path: 'f/both', content: 'live' } + + expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([ + { + workspace: 'test_ws', + itemKind: 'script', + path: 'f/both', + value: { path: 'f/both', content: 'live' }, + meta: { remoteRev: 'h1' }, + persisted: true, + live: true + } + ]) + }) + + it('clear removes persisted storage and live state without re-persisting', () => { + UserDraft.setDraftAndMeta( + 'script', + 'f/clear', + { path: 'f/clear', content: 'x' }, + { + remoteRev: 'h1' + } + ) + const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/clear') + expect(handle.draft).toEqual({ path: 'f/clear', content: 'x' }) + + UserDraft.clear('script', 'f/clear') + flushPersist() + + expect(handle.draft).toBeUndefined() + expect(localStorage.getItem('userdraft/w/test_ws/script/f/clear')).toBeNull() + expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([]) + }) + + it('clear cancels pending debounced live writes', () => { + const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/pending-clear') + handle.draft = { path: 'f/pending-clear', content: 'initial' } + handle.draft = { path: 'f/pending-clear', content: 'pending' } + + UserDraft.clear('script', 'f/pending-clear') + expect(handle.draft).toBeUndefined() + expect(localStorage.getItem('userdraft/w/test_ws/script/f/pending-clear')).toBeNull() + + flushPersist() + expect(localStorage.getItem('userdraft/w/test_ws/script/f/pending-clear')).toBeNull() + }) + + it('clear does not let an old debounced remove delete a later direct write', () => { + const key = 'userdraft/w/test_ws/script/f/rewrite-after-clear' + const handle = UserDraft.use<{ path: string; content: string }>( + 'script', + 'f/rewrite-after-clear' + ) + handle.draft = { path: 'f/rewrite-after-clear', content: 'initial' } + handle.draft = { path: 'f/rewrite-after-clear', content: 'pending' } + + UserDraft.clear('script', 'f/rewrite-after-clear') + flushDestroyCallbacks() + UserDraft.setDraftAndMeta( + 'script', + 'f/rewrite-after-clear', + { path: 'f/rewrite-after-clear', content: 'new' }, + {} + ) + + flushPersist() + expect(storedShape(key)).toBe( + wrapped({ path: 'f/rewrite-after-clear', content: 'new' }) + ) + }) + + it('list hides persisted drafts when a live handle has cleared the value', () => { + UserDraft.setDraftAndMeta( + 'script', + 'f/live-clear', + { path: 'f/live-clear', content: 'persisted' }, + {} + ) + const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/live-clear') + handle.draft = { path: 'f/live-clear', content: 'edited' } + handle.draft = undefined + + expect(localStorage.getItem('userdraft/w/test_ws/script/f/live-clear')).not.toBeNull() + expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([]) + }) + + it('setDraftAndMeta updates live handles atomically and preserves metadata on later draft writes', () => { + const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/meta') + + UserDraft.setDraftAndMeta( + 'script', + 'f/meta', + { path: 'f/meta', content: 'first' }, + { + remoteRev: 'h1', + remoteDraftRev: 'd1' + } + ) + handle.draft = { path: 'f/meta', content: 'second' } + + expect(handle.draft).toEqual({ path: 'f/meta', content: 'second' }) + expect(handle.meta).toEqual({ remoteRev: 'h1', remoteDraftRev: 'd1' }) + expect(UserDraft.list({ itemKinds: ['script'] })[0]).toEqual( + expect.objectContaining({ + value: { path: 'f/meta', content: 'second' }, + meta: { remoteRev: 'h1', remoteDraftRev: 'd1' } + }) + ) + }) + + it('static setDraftAndMeta persists first writes even when a live handle exists', () => { + const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/static-live') + + UserDraft.setDraftAndMeta( + 'script', + 'f/static-live', + { path: 'f/static-live', content: 'first' }, + { remoteRev: 'h1' } + ) + + expect(handle.draft).toEqual({ path: 'f/static-live', content: 'first' }) + expect(storedShape('userdraft/w/test_ws/script/f/static-live')).toBe( + JSON.stringify({ + value: { path: 'f/static-live', content: 'first' }, + remoteRev: 'h1' + }) + ) + }) + + it('lists live drafts with runtime-only values without throwing', () => { + const handle = UserDraft.use>('script', 'f/runtime') + handle.draft = { + path: 'f/runtime', + content: 'x', + callback: () => 'not serializable' + } + + expect(() => UserDraft.list({ itemKinds: ['script'] })).not.toThrow() + expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([ + expect.objectContaining({ + itemKind: 'script', + path: 'f/runtime', + value: { path: 'f/runtime', content: 'x' } + }) + ]) + }) +}) From b656dc6cdc8c50ef9740240447f119cceed18547 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 21 May 2026 15:34:49 +0000 Subject: [PATCH 15/71] feat(nsjail): optional disk-backed /tmp via instance setting (#9272) * feat(nsjail): optional disk-backed /tmp via instance setting * test(nsjail): unit-test tmp mount resolver and narrow visibility * refactor(nsjail): switch tmp backing to select + conditional UI * ui(nsjail): make tmpfs the visible default in /tmp backing select * fix(nsjail): refuse preexisting jail_tmp to block symlink escape * fix(nsjail): allow jail_tmp reuse on sequential nsjail calls Codex flagged that python/ruby/rust executors invoke nsjail twice per job_dir (install then run). The previous resolver treated any preexisting jail_tmp as hostile and silently fell back to tmpfs on the second call, so disk-backed mode never reached the main script run for those langs. Use symlink_metadata().is_dir() to distinguish a real directory left by an earlier call in the same job_dir (safe to reuse) from a symlink or other entity (still refused, as the codebase-tar escape requires). Also loosen the frontend visibility predicate: only hide nsjail settings when job_isolation is explicitly 'none' or 'unshare', so deployments that enable nsjail via DISABLE_NSJAIL=false with no DB setting can still see the controls. --- backend/src/main.rs | 26 +- backend/src/monitor.rs | 31 +- .../windmill-common/src/global_settings.rs | 3 + .../windmill-common/src/instance_config.rs | 2 + .../nsjail/download.py.config.proto | 7 +- .../nsjail/download.ruby.config.proto | 7 +- .../nsjail/download.rust.config.proto | 7 +- .../nsjail/run.ansible.config.proto | 7 +- .../nsjail/run.bash.config.proto | 7 +- .../nsjail/run.bun.config.proto | 7 +- .../nsjail/run.csharp.config.proto | 7 +- .../nsjail/run.go.config.proto | 7 +- .../nsjail/run.java.config.proto | 7 +- .../nsjail/run.nu.config.proto | 7 +- .../nsjail/run.php.config.proto | 7 +- .../nsjail/run.powershell.config.proto | 7 +- .../nsjail/run.python3.config.proto | 7 +- .../windmill-worker/nsjail/run.r.config.proto | 7 +- .../nsjail/run.ruby.config.proto | 7 +- .../nsjail/run.rust.config.proto | 7 +- .../windmill-worker/src/ansible_executor.rs | 6 +- backend/windmill-worker/src/bash_executor.rs | 6 +- backend/windmill-worker/src/bun_executor.rs | 6 +- backend/windmill-worker/src/common.rs | 276 +++++++++++++++++- .../windmill-worker/src/csharp_executor.rs | 6 +- backend/windmill-worker/src/go_executor.rs | 6 +- backend/windmill-worker/src/java_executor.rs | 6 +- backend/windmill-worker/src/nu_executor.rs | 6 +- backend/windmill-worker/src/php_executor.rs | 6 +- backend/windmill-worker/src/pwsh_executor.rs | 6 +- .../windmill-worker/src/python_executor.rs | 10 +- backend/windmill-worker/src/r_executor.rs | 6 +- backend/windmill-worker/src/ruby_executor.rs | 11 +- backend/windmill-worker/src/rust_executor.rs | 10 +- backend/windmill-worker/src/worker.rs | 6 + .../src/lib/components/InstanceSetting.svelte | 14 + .../src/lib/components/instanceSettings.ts | 16 +- 37 files changed, 413 insertions(+), 164 deletions(-) diff --git a/backend/src/main.rs b/backend/src/main.rs index a322b716b1..ecc49c22f6 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -51,14 +51,15 @@ use windmill_common::{ JOB_DEFAULT_TIMEOUT_SECS_SETTING, JOB_ISOLATION_SETTING, JWT_SECRET_SETTING, KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, MAVEN_REPOS_SETTING, MAVEN_SETTINGS_XML_SETTING, MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NO_DEFAULT_MAVEN_SETTING, - NPM_CONFIG_REGISTRY_SETTING, NSJAIL_TMPFS_SIZE_MB_SETTING, NUGET_CONFIG_SETTING, - OAUTH_SETTING, OTEL_SETTING, OTEL_TRACING_PROXY_SETTING, PIP_INDEX_URL_SETTING, - POWERSHELL_REPO_PAT_SETTING, POWERSHELL_REPO_URL_SETTING, PREVIEW_TAGS_OVERRIDE_SETTING, - REQUEST_SIZE_LIMIT_SETTING, REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING, - RESTART_COORDINATION_SETTING, RETENTION_PERIOD_SECS_SETTING, RUBY_REPOS_SETTING, - SAML_METADATA_SETTING, SCIM_TOKEN_SETTING, SMTP_SETTING, STORE_AUDIT_LOGS_S3_SETTING, - TEAMS_SETTING, TIMEOUT_WAIT_RESULT_SETTING, UV_EXCLUDE_NEWER_SETTING, - UV_INDEX_STRATEGY_SETTING, UV_PYTHON_INSTALL_MIRROR_SETTING, WORKSPACE_REGISTRIES_SETTING, + NPM_CONFIG_REGISTRY_SETTING, NSJAIL_TMPFS_SIZE_MB_SETTING, NSJAIL_TMP_BACKING_SETTING, + NUGET_CONFIG_SETTING, OAUTH_SETTING, OTEL_SETTING, OTEL_TRACING_PROXY_SETTING, + PIP_INDEX_URL_SETTING, POWERSHELL_REPO_PAT_SETTING, POWERSHELL_REPO_URL_SETTING, + PREVIEW_TAGS_OVERRIDE_SETTING, REQUEST_SIZE_LIMIT_SETTING, + REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING, RESTART_COORDINATION_SETTING, + RETENTION_PERIOD_SECS_SETTING, RUBY_REPOS_SETTING, SAML_METADATA_SETTING, + SCIM_TOKEN_SETTING, SMTP_SETTING, STORE_AUDIT_LOGS_S3_SETTING, TEAMS_SETTING, + TIMEOUT_WAIT_RESULT_SETTING, UV_EXCLUDE_NEWER_SETTING, UV_INDEX_STRATEGY_SETTING, + UV_PYTHON_INSTALL_MIRROR_SETTING, WORKSPACE_REGISTRIES_SETTING, }, scripts::ScriptLang, stats_oss::schedule_stats, @@ -127,10 +128,10 @@ use crate::monitor::{ reload_http_route_workspaced_route_setting, reload_hub_api_secret_setting, reload_hub_base_url_setting, reload_instance_events_webhook_setting, reload_job_default_timeout_setting, reload_job_isolation_setting, reload_jwt_secret_setting, - reload_license_key, reload_npm_config_registry_setting, reload_nsjail_tmpfs_size_setting, - reload_otel_tracing_proxy_setting, reload_pip_index_url_setting, - reload_retention_period_setting, reload_scim_token_setting, reload_smtp_config, - reload_store_audit_logs_s3_setting, reload_uv_exclude_newer_setting, + reload_license_key, reload_npm_config_registry_setting, reload_nsjail_tmp_backing_setting, + reload_nsjail_tmpfs_size_setting, reload_otel_tracing_proxy_setting, + reload_pip_index_url_setting, reload_retention_period_setting, reload_scim_token_setting, + reload_smtp_config, reload_store_audit_logs_s3_setting, reload_uv_exclude_newer_setting, reload_uv_index_strategy_setting, reload_uv_python_install_mirror_setting, reload_worker_config, MonitorIteration, }; @@ -1785,6 +1786,7 @@ async fn process_notify_event( JOB_DEFAULT_TIMEOUT_SECS_SETTING => reload_job_default_timeout_setting(conn).await, JOB_ISOLATION_SETTING => reload_job_isolation_setting(conn).await, NSJAIL_TMPFS_SIZE_MB_SETTING => reload_nsjail_tmpfs_size_setting(conn).await, + NSJAIL_TMP_BACKING_SETTING => reload_nsjail_tmp_backing_setting(conn).await, #[cfg(feature = "parquet")] OBJECT_STORE_CONFIG_SETTING => { if !disable_s3_store { diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index 70575aeb21..6f33e6255f 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -62,13 +62,13 @@ use windmill_common::{ HUB_BASE_URL_SETTING, INSTANCE_PYTHON_VERSION_SETTING, JOB_DEFAULT_TIMEOUT_SECS_SETTING, JOB_ISOLATION_SETTING, JWT_SECRET_SETTING, KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NPMRC_SETTING, NPM_CONFIG_REGISTRY_SETTING, - NSJAIL_TMPFS_SIZE_MB_SETTING, NUGET_CONFIG_SETTING, OTEL_SETTING, - OTEL_TRACING_PROXY_SETTING, PIP_INDEX_URL_SETTING, POWERSHELL_REPO_PAT_SETTING, - POWERSHELL_REPO_URL_SETTING, PREVIEW_TAGS_OVERRIDE_SETTING, REQUEST_SIZE_LIMIT_SETTING, - REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING, RETENTION_PERIOD_SECS_SETTING, - SAML_METADATA_SETTING, SCIM_TOKEN_SETTING, STORE_AUDIT_LOGS_S3_SETTING, - TIMEOUT_WAIT_RESULT_SETTING, UV_EXCLUDE_NEWER_SETTING, UV_INDEX_STRATEGY_SETTING, - UV_PYTHON_INSTALL_MIRROR_SETTING, + NSJAIL_TMPFS_SIZE_MB_SETTING, NSJAIL_TMP_BACKING_SETTING, NUGET_CONFIG_SETTING, + OTEL_SETTING, OTEL_TRACING_PROXY_SETTING, PIP_INDEX_URL_SETTING, + POWERSHELL_REPO_PAT_SETTING, POWERSHELL_REPO_URL_SETTING, PREVIEW_TAGS_OVERRIDE_SETTING, + REQUEST_SIZE_LIMIT_SETTING, REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING, + RETENTION_PERIOD_SECS_SETTING, SAML_METADATA_SETTING, SCIM_TOKEN_SETTING, + STORE_AUDIT_LOGS_S3_SETTING, TIMEOUT_WAIT_RESULT_SETTING, UV_EXCLUDE_NEWER_SETTING, + UV_INDEX_STRATEGY_SETTING, UV_PYTHON_INSTALL_MIRROR_SETTING, }, indexer::load_indexer_config, jwt::JWT_SECRET, @@ -108,9 +108,9 @@ use windmill_worker::{ BUN_INSTALL_MIN_RELEASE_AGE, CARGO_REGISTRIES, INSTANCE_PYTHON_VERSION, JAVA_HOME_DIR, JOB_DEFAULT_TIMEOUT, JOB_ISOLATION, KEEP_JOB_DIR, MAVEN_REPOS, MAVEN_SETTINGS_XML, NO_DEFAULT_MAVEN, NPMRC, NPM_CONFIG_REGISTRY, NSJAIL_AVAILABLE, NSJAIL_TMPFS_SIZE_MB, - NUGET_CONFIG, OTEL_TRACING_PROXY_SETTINGS, PIP_EXTRA_INDEX_URL, PIP_INDEX_URL, - POWERSHELL_REPO_PAT, POWERSHELL_REPO_URL, UNSHARE_PATH, UV_EXCLUDE_NEWER, UV_INDEX_STRATEGY, - UV_PYTHON_INSTALL_MIRROR, WORKSPACE_REGISTRIES, + NSJAIL_TMP_BACKING, NUGET_CONFIG, OTEL_TRACING_PROXY_SETTINGS, PIP_EXTRA_INDEX_URL, + PIP_INDEX_URL, POWERSHELL_REPO_PAT, POWERSHELL_REPO_URL, UNSHARE_PATH, UV_EXCLUDE_NEWER, + UV_INDEX_STRATEGY, UV_PYTHON_INSTALL_MIRROR, WORKSPACE_REGISTRIES, }; #[cfg(feature = "parquet")] @@ -387,6 +387,7 @@ pub async fn initial_load( reload_job_default_timeout_setting(&conn).await; reload_job_isolation_setting(&conn).await; reload_nsjail_tmpfs_size_setting(&conn).await; + reload_nsjail_tmp_backing_setting(&conn).await; reload_extra_pip_index_url_setting(&conn).await; reload_pip_index_url_setting(&conn).await; reload_uv_index_strategy_setting(&conn).await; @@ -1909,6 +1910,16 @@ pub async fn reload_nsjail_tmpfs_size_setting(conn: &Connection) { .await; } +pub async fn reload_nsjail_tmp_backing_setting(conn: &Connection) { + reload_option_setting_with_tracing( + conn, + NSJAIL_TMP_BACKING_SETTING, + "NSJAIL_TMP_BACKING", + NSJAIL_TMP_BACKING.clone(), + ) + .await; +} + pub async fn reload_job_isolation_setting(conn: &Connection) { let value = match load_value_from_global_settings_with_conn(conn, JOB_ISOLATION_SETTING, true).await { diff --git a/backend/windmill-common/src/global_settings.rs b/backend/windmill-common/src/global_settings.rs index 063f7d9a43..5b14d809bd 100644 --- a/backend/windmill-common/src/global_settings.rs +++ b/backend/windmill-common/src/global_settings.rs @@ -55,6 +55,9 @@ pub const KEEP_JOB_DIR_SETTING: &str = "keep_job_dir"; pub const REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING: &str = "require_preexisting_user_for_oauth"; pub const JOB_ISOLATION_SETTING: &str = "job_isolation"; pub const NSJAIL_TMPFS_SIZE_MB_SETTING: &str = "nsjail_tmpfs_size_mb"; +pub const NSJAIL_TMP_BACKING_SETTING: &str = "nsjail_tmp_backing"; +pub const NSJAIL_TMP_BACKING_DISK: &str = "disk"; +pub const NSJAIL_TMP_BACKING_TMPFS: &str = "tmpfs"; pub const OBJECT_STORE_CONFIG_SETTING: &str = "object_store_cache_config"; pub const HUB_API_SECRET_SETTING: &str = "hub_api_secret"; diff --git a/backend/windmill-common/src/instance_config.rs b/backend/windmill-common/src/instance_config.rs index eca3acd456..1872b52140 100644 --- a/backend/windmill-common/src/instance_config.rs +++ b/backend/windmill-common/src/instance_config.rs @@ -223,6 +223,8 @@ pub struct GlobalSettings { #[serde(skip_serializing_if = "Option::is_none")] pub nsjail_tmpfs_size_mb: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub nsjail_tmp_backing: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub bun_install_min_release_age: Option, #[serde(skip_serializing_if = "Option::is_none")] pub uv_exclude_newer: Option, diff --git a/backend/windmill-worker/nsjail/download.py.config.proto b/backend/windmill-worker/nsjail/download.py.config.proto index 8880a565c7..e56ef66de0 100644 --- a/backend/windmill-worker/nsjail/download.py.config.proto +++ b/backend/windmill-worker/nsjail/download.py.config.proto @@ -86,12 +86,7 @@ mount { rw: true } -mount { - dst: "/tmp" - fstype: "tmpfs" - rw: true - options: "size={NSJAIL_TMPFS_SIZE}" -} +{TMP_MOUNT_BLOCK} mount { diff --git a/backend/windmill-worker/nsjail/download.ruby.config.proto b/backend/windmill-worker/nsjail/download.ruby.config.proto index 98422abb87..4d6b398fda 100644 --- a/backend/windmill-worker/nsjail/download.ruby.config.proto +++ b/backend/windmill-worker/nsjail/download.ruby.config.proto @@ -86,12 +86,7 @@ mount { rw: true } -mount { - dst: "/tmp" - fstype: "tmpfs" - rw: true - options: "size={NSJAIL_TMPFS_SIZE}" -} +{TMP_MOUNT_BLOCK} mount { src: "{TARGET}" diff --git a/backend/windmill-worker/nsjail/download.rust.config.proto b/backend/windmill-worker/nsjail/download.rust.config.proto index 0884034076..57e3c2d7e8 100644 --- a/backend/windmill-worker/nsjail/download.rust.config.proto +++ b/backend/windmill-worker/nsjail/download.rust.config.proto @@ -49,12 +49,7 @@ mount { rw: true } -mount { - dst: "/tmp" - fstype: "tmpfs" - rw: true - options: "size={NSJAIL_TMPFS_SIZE}" -} +{TMP_MOUNT_BLOCK} mount { src: "/etc" diff --git a/backend/windmill-worker/nsjail/run.ansible.config.proto b/backend/windmill-worker/nsjail/run.ansible.config.proto index 7df486c53a..11660a5372 100644 --- a/backend/windmill-worker/nsjail/run.ansible.config.proto +++ b/backend/windmill-worker/nsjail/run.ansible.config.proto @@ -66,12 +66,7 @@ mount { is_bind: false } -mount { - dst: "/tmp" - fstype: "tmpfs" - rw: true - options: "size={NSJAIL_TMPFS_SIZE}" -} +{TMP_MOUNT_BLOCK} mount { src: "{JOB_DIR}/main.yml" diff --git a/backend/windmill-worker/nsjail/run.bash.config.proto b/backend/windmill-worker/nsjail/run.bash.config.proto index 1136c8d298..899f7caa1e 100644 --- a/backend/windmill-worker/nsjail/run.bash.config.proto +++ b/backend/windmill-worker/nsjail/run.bash.config.proto @@ -68,12 +68,7 @@ mount { -mount { - dst: "/tmp" - fstype: "tmpfs" - rw: true - options: "size={NSJAIL_TMPFS_SIZE}" -} +{TMP_MOUNT_BLOCK} mount { src: "{JOB_DIR}/main.sh" diff --git a/backend/windmill-worker/nsjail/run.bun.config.proto b/backend/windmill-worker/nsjail/run.bun.config.proto index 280ef8a7f9..43e4464beb 100644 --- a/backend/windmill-worker/nsjail/run.bun.config.proto +++ b/backend/windmill-worker/nsjail/run.bun.config.proto @@ -60,12 +60,7 @@ mount { rw: true } -mount { - dst: "/tmp" - fstype: "tmpfs" - rw: true - options: "size={NSJAIL_TMPFS_SIZE}" -} +{TMP_MOUNT_BLOCK} mount { src: "{JOB_DIR}/package.json" diff --git a/backend/windmill-worker/nsjail/run.csharp.config.proto b/backend/windmill-worker/nsjail/run.csharp.config.proto index b58d6c330f..c624e55c43 100644 --- a/backend/windmill-worker/nsjail/run.csharp.config.proto +++ b/backend/windmill-worker/nsjail/run.csharp.config.proto @@ -57,12 +57,7 @@ mount { rw: true } -mount { - dst: "/tmp" - fstype: "tmpfs" - rw: true - options: "size={NSJAIL_TMPFS_SIZE}" -} +{TMP_MOUNT_BLOCK} mount { diff --git a/backend/windmill-worker/nsjail/run.go.config.proto b/backend/windmill-worker/nsjail/run.go.config.proto index 4fec8f3b2d..1e6de06c9c 100644 --- a/backend/windmill-worker/nsjail/run.go.config.proto +++ b/backend/windmill-worker/nsjail/run.go.config.proto @@ -50,12 +50,7 @@ mount { rw: true } -mount { - dst: "/tmp" - fstype: "tmpfs" - rw: true - options: "size={NSJAIL_TMPFS_SIZE}" -} +{TMP_MOUNT_BLOCK} mount { diff --git a/backend/windmill-worker/nsjail/run.java.config.proto b/backend/windmill-worker/nsjail/run.java.config.proto index 3b0a635b0c..9ae43f5b52 100644 --- a/backend/windmill-worker/nsjail/run.java.config.proto +++ b/backend/windmill-worker/nsjail/run.java.config.proto @@ -51,12 +51,7 @@ mount { rw: true } -mount { - dst: "/tmp" - fstype: "tmpfs" - rw: true - options: "size={NSJAIL_TMPFS_SIZE}" -} +{TMP_MOUNT_BLOCK} mount { diff --git a/backend/windmill-worker/nsjail/run.nu.config.proto b/backend/windmill-worker/nsjail/run.nu.config.proto index 40a5de69a1..6c517a5b39 100644 --- a/backend/windmill-worker/nsjail/run.nu.config.proto +++ b/backend/windmill-worker/nsjail/run.nu.config.proto @@ -51,12 +51,7 @@ mount { rw: true } -mount { - dst: "/tmp" - fstype: "tmpfs" - rw: true - options: "size={NSJAIL_TMPFS_SIZE}" -} +{TMP_MOUNT_BLOCK} mount { src: "{NU_PATH}" diff --git a/backend/windmill-worker/nsjail/run.php.config.proto b/backend/windmill-worker/nsjail/run.php.config.proto index 965ac7cd36..6910cbe1a0 100644 --- a/backend/windmill-worker/nsjail/run.php.config.proto +++ b/backend/windmill-worker/nsjail/run.php.config.proto @@ -51,12 +51,7 @@ mount { rw: true } -mount { - dst: "/tmp" - fstype: "tmpfs" - rw: true - options: "size={NSJAIL_TMPFS_SIZE}" -} +{TMP_MOUNT_BLOCK} mount { src: "{JOB_DIR}/main.php" diff --git a/backend/windmill-worker/nsjail/run.powershell.config.proto b/backend/windmill-worker/nsjail/run.powershell.config.proto index bab869c9c8..5fd758a813 100644 --- a/backend/windmill-worker/nsjail/run.powershell.config.proto +++ b/backend/windmill-worker/nsjail/run.powershell.config.proto @@ -64,12 +64,7 @@ mount { rw: true } -mount { - dst: "/tmp" - fstype: "tmpfs" - rw: true - options: "size={NSJAIL_TMPFS_SIZE}" -} +{TMP_MOUNT_BLOCK} mount { src: "{JOB_DIR}/main.ps1" diff --git a/backend/windmill-worker/nsjail/run.python3.config.proto b/backend/windmill-worker/nsjail/run.python3.config.proto index dec9d6bae7..53d5a6c64d 100644 --- a/backend/windmill-worker/nsjail/run.python3.config.proto +++ b/backend/windmill-worker/nsjail/run.python3.config.proto @@ -54,12 +54,7 @@ mount { rw: true } -mount { - dst: "/tmp" - fstype: "tmpfs" - rw: true - options: "size={NSJAIL_TMPFS_SIZE}" -} +{TMP_MOUNT_BLOCK} mount { src: "{JOB_DIR}/{MAIN}.py" diff --git a/backend/windmill-worker/nsjail/run.r.config.proto b/backend/windmill-worker/nsjail/run.r.config.proto index 0f5d71bf5a..bc4d74bcba 100644 --- a/backend/windmill-worker/nsjail/run.r.config.proto +++ b/backend/windmill-worker/nsjail/run.r.config.proto @@ -51,12 +51,7 @@ mount { rw: true } -mount { - dst: "/tmp" - fstype: "tmpfs" - rw: true - options: "size={NSJAIL_TMPFS_SIZE}" -} +{TMP_MOUNT_BLOCK} mount { diff --git a/backend/windmill-worker/nsjail/run.ruby.config.proto b/backend/windmill-worker/nsjail/run.ruby.config.proto index 3b9509cea6..2527e785b6 100644 --- a/backend/windmill-worker/nsjail/run.ruby.config.proto +++ b/backend/windmill-worker/nsjail/run.ruby.config.proto @@ -51,12 +51,7 @@ mount { rw: true } -mount { - dst: "/tmp" - fstype: "tmpfs" - rw: true - options: "size={NSJAIL_TMPFS_SIZE}" -} +{TMP_MOUNT_BLOCK} mount { diff --git a/backend/windmill-worker/nsjail/run.rust.config.proto b/backend/windmill-worker/nsjail/run.rust.config.proto index 06da7be662..070a76e997 100644 --- a/backend/windmill-worker/nsjail/run.rust.config.proto +++ b/backend/windmill-worker/nsjail/run.rust.config.proto @@ -50,12 +50,7 @@ mount { rw: true } -mount { - dst: "/tmp" - fstype: "tmpfs" - rw: true - options: "size={NSJAIL_TMPFS_SIZE}" -} +{TMP_MOUNT_BLOCK} mount { diff --git a/backend/windmill-worker/src/ansible_executor.rs b/backend/windmill-worker/src/ansible_executor.rs index f745bc1661..7b0ae3b3ea 100644 --- a/backend/windmill-worker/src/ansible_executor.rs +++ b/backend/windmill-worker/src/ansible_executor.rs @@ -30,7 +30,7 @@ use crate::{ bash_executor::BIN_BASH, common::{ build_command_with_isolation, check_executor_binary_exists, get_reserved_variables, - read_and_check_result, resolve_nsjail_timeout, resolve_nsjail_tmpfs_size_bytes, + read_and_check_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process, transform_json, OccupancyMetrics, }, handle_child::handle_child, @@ -1457,8 +1457,8 @@ mount {{ additional_python_paths_folders.as_str(), ) .replace( - "{NSJAIL_TMPFS_SIZE}", - &resolve_nsjail_tmpfs_size_bytes().await, + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, ) .replace("{TIMEOUT}", &nsjail_timeout), )?; diff --git a/backend/windmill-worker/src/bash_executor.rs b/backend/windmill-worker/src/bash_executor.rs index 47f100bd2d..516c75fdba 100644 --- a/backend/windmill-worker/src/bash_executor.rs +++ b/backend/windmill-worker/src/bash_executor.rs @@ -41,7 +41,7 @@ use crate::handle_child::run_future_with_polling_update_job_poller; use crate::{ common::{ build_args_map, build_command_with_isolation, get_reserved_variables, read_file, - read_file_content, resolve_nsjail_timeout, resolve_nsjail_tmpfs_size_bytes, start_child_process, + read_file_content, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process, OccupancyMetrics, DEV_CONF_NSJAIL, }, get_proxy_envs_for_lang, @@ -216,8 +216,8 @@ exit $exit_status .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) .replace("#{DEV}", DEV_CONF_NSJAIL) .replace( - "{NSJAIL_TMPFS_SIZE}", - &resolve_nsjail_tmpfs_size_bytes().await, + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, ) .replace("{TIMEOUT}", &nsjail_timeout), )?; diff --git a/backend/windmill-worker/src/bun_executor.rs b/backend/windmill-worker/src/bun_executor.rs index 2346cb413f..1e4f4aafd6 100644 --- a/backend/windmill-worker/src/bun_executor.rs +++ b/backend/windmill-worker/src/bun_executor.rs @@ -16,7 +16,7 @@ use crate::{ common::{ build_command_with_isolation, create_args_and_out_file, get_reserved_variables, parse_npm_config, read_file, read_file_content, read_result, resolve_nsjail_timeout, - resolve_nsjail_tmpfs_size_bytes, start_child_process, write_file_binary, MaybeLock, + resolve_nsjail_tmp_mount_block, start_child_process, write_file_binary, MaybeLock, OccupancyMetrics, StreamNotifier, DEV_CONF_NSJAIL, }, get_proxy_envs_for_lang, @@ -2186,8 +2186,8 @@ try {{ .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) .replace("#{DEV}", DEV_CONF_NSJAIL) .replace( - "{NSJAIL_TMPFS_SIZE}", - &resolve_nsjail_tmpfs_size_bytes().await, + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, ) .replace("{TIMEOUT}", &nsjail_timeout), )?; diff --git a/backend/windmill-worker/src/common.rs b/backend/windmill-worker/src/common.rs index 95c7063328..aa3c6dbe2a 100644 --- a/backend/windmill-worker/src/common.rs +++ b/backend/windmill-worker/src/common.rs @@ -15,6 +15,7 @@ use tokio::process::Command; use tokio::{fs::File, io::AsyncReadExt}; use windmill_common::flows::Step; +use windmill_common::global_settings::NSJAIL_TMP_BACKING_DISK; use windmill_common::variables::{build_crypt_with_key_suffix, decrypt}; use windmill_common::worker::{ to_raw_value, update_ping_for_failed_init_script_query, write_file, Connection, Ping, PingType, @@ -48,7 +49,8 @@ use tokio::{io::AsyncWriteExt, time::Instant}; use crate::agent_workers::UPDATE_PING_URL; use crate::{ - JOB_DEFAULT_TIMEOUT, MAX_RESULT_SIZE, MAX_TIMEOUT_DURATION, NSJAIL_TMPFS_SIZE_MB, PATH_ENV, + JOB_DEFAULT_TIMEOUT, MAX_RESULT_SIZE, MAX_TIMEOUT_DURATION, NSJAIL_TMPFS_SIZE_MB, + NSJAIL_TMP_BACKING, PATH_ENV, }; use windmill_common::client::AuthedClient; @@ -1023,6 +1025,278 @@ pub async fn resolve_nsjail_tmpfs_size_bytes() -> String { } } +/// Sub-directory inside each job dir used as the disk-backed `/tmp` when +/// `nsjail_tmp_disk_backed` is enabled. Kept under `{JOB_DIR}` so existing +/// job-dir cleanup removes it for free. +const NSJAIL_TMP_BIND_SUBDIR: &str = "jail_tmp"; + +fn tmpfs_mount_block(size_bytes: &str) -> String { + format!( + "mount {{\n dst: \"/tmp\"\n fstype: \"tmpfs\"\n rw: true\n options: \"size={size_bytes}\"\n}}" + ) +} + +fn bind_mount_block(jail_tmp: &str) -> String { + format!( + "mount {{\n src: \"{jail_tmp}\"\n dst: \"/tmp\"\n is_bind: true\n rw: true\n}}" + ) +} + +/// Build the nsjail `mount { ... }` block that backs `/tmp` inside the +/// sandbox. +/// +/// **Caller contract**: `job_dir` must be a trusted, worker-allocated job +/// directory (typically `{worker_dir}/{job_id}`). In disk-backed mode this +/// function creates `{job_dir}/jail_tmp` and bind-mounts it as `/tmp` with +/// `rw: true`. Callers must not pass user-controlled paths. +/// +/// Some executors (e.g. the bun codebase path) extract user-supplied archives +/// into `job_dir` before this resolver runs, so the resolver actively refuses +/// any pre-existing entry at `{job_dir}/jail_tmp` (including symlinks) to +/// avoid bind-mounting an attacker-controlled host directory as `/tmp`. +/// +/// When the `nsjail_tmp_backing` instance setting is `"disk"`, returns a +/// disk-backed bind mount of `{job_dir}/jail_tmp` after creating the +/// directory. If creation or the pre-existence check fails, logs an error and +/// falls back to the historical tmpfs block so the job can still start. For +/// any other value (including unset, `"tmpfs"`, or unrecognized), returns the +/// historical RAM-backed tmpfs mount sized via `nsjail_tmpfs_size_mb`. +pub(crate) async fn resolve_nsjail_tmp_mount_block(job_dir: &str) -> String { + let disk_backed = NSJAIL_TMP_BACKING + .read() + .await + .as_deref() + .map(|v| v.eq_ignore_ascii_case(NSJAIL_TMP_BACKING_DISK)) + .unwrap_or(false); + let size_bytes = resolve_nsjail_tmpfs_size_bytes().await; + if !disk_backed { + return tmpfs_mount_block(&size_bytes); + } + let jail_tmp = format!("{job_dir}/{NSJAIL_TMP_BIND_SUBDIR}"); + + // SECURITY: never bind-mount a symlinked (or otherwise non-directory) + // entry at jail_tmp. `symlink_metadata` returns the link's own metadata + // without following it, so `is_dir()` is true only for a real directory. + // User-controlled archives extracted into job_dir could otherwise plant + // `jail_tmp` as a symlink to an arbitrary host directory, which nsjail + // would then expose as a writable /tmp. + // + // A pre-existing real directory at this path is legitimate: several + // executors (python_executor, ruby_executor, rust_executor) invoke nsjail + // more than once per job_dir (e.g. dep install, then run), and the first + // invocation will have created it via the `create_dir` below. + match tokio::fs::symlink_metadata(&jail_tmp).await { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + if let Err(e) = tokio::fs::create_dir(&jail_tmp).await { + tracing::error!( + "Failed to create nsjail disk-backed /tmp at {jail_tmp}: {e:?}; \ + falling back to tmpfs for this job." + ); + return tmpfs_mount_block(&size_bytes); + } + } + Ok(meta) if meta.is_dir() => { + // Real directory left over from an earlier nsjail invocation in + // this same job_dir — safe to reuse. + } + Ok(_) => { + tracing::error!( + "Refusing to bind-mount nsjail disk-backed /tmp: {jail_tmp} \ + exists but is not a regular directory (possibly a symlink \ + planted by a user-controlled archive). Falling back to \ + RAM-backed tmpfs for this job." + ); + return tmpfs_mount_block(&size_bytes); + } + Err(e) => { + tracing::error!( + "Failed to stat nsjail disk-backed /tmp at {jail_tmp}: {e:?}; \ + falling back to tmpfs for this job." + ); + return tmpfs_mount_block(&size_bytes); + } + } + bind_mount_block(&jail_tmp) +} + +#[cfg(test)] +mod nsjail_tmp_mount_tests { + use super::*; + + #[test] + fn tmpfs_block_renders_size() { + let block = tmpfs_mount_block("800000000"); + assert!(block.contains("dst: \"/tmp\"")); + assert!(block.contains("fstype: \"tmpfs\"")); + assert!(block.contains("options: \"size=800000000\"")); + assert!(!block.contains("is_bind")); + } + + #[test] + fn bind_block_renders_source_path() { + let block = bind_mount_block("/var/lib/windmill/jobs/abc/jail_tmp"); + assert!(block.contains("src: \"/var/lib/windmill/jobs/abc/jail_tmp\"")); + assert!(block.contains("dst: \"/tmp\"")); + assert!(block.contains("is_bind: true")); + assert!(block.contains("rw: true")); + assert!(!block.contains("fstype")); + } + + /// Serializes tests that mutate the process-global `NSJAIL_TMP_BACKING` + /// so they don't race when cargo runs them in parallel. + static SETTING_GUARD: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + + async fn with_tmp_backing(value: Option, f: F) -> T + where + F: FnOnce() -> Fut, + Fut: std::future::Future, + { + let _serial = SETTING_GUARD.lock().await; + let prev = NSJAIL_TMP_BACKING.read().await.clone(); + *NSJAIL_TMP_BACKING.write().await = value; + let res = f().await; + *NSJAIL_TMP_BACKING.write().await = prev; + res + } + + #[tokio::test] + async fn tmpfs_mode_returns_tmpfs_block_for_any_job_dir() { + let block = with_tmp_backing(Some("tmpfs".to_string()), || async { + resolve_nsjail_tmp_mount_block("/anything").await + }) + .await; + assert!(block.contains("fstype: \"tmpfs\"")); + assert!(block.contains("options: \"size=")); + assert!(!block.contains("is_bind")); + } + + #[tokio::test] + async fn unset_defaults_to_tmpfs() { + let block = with_tmp_backing(None, || async { + resolve_nsjail_tmp_mount_block("/anything").await + }) + .await; + assert!(block.contains("fstype: \"tmpfs\"")); + assert!(!block.contains("is_bind")); + } + + /// Disk-backed branch: the resolver must create `{job_dir}/jail_tmp` and + /// emit a bind block pointing at it. + #[tokio::test] + async fn disk_backed_creates_jail_tmp_and_returns_bind_block() { + let tmp = tempfile::tempdir().expect("tempdir"); + let job_dir = tmp.path().to_str().expect("utf8 path").to_string(); + + let block = with_tmp_backing(Some("disk".to_string()), || async { + resolve_nsjail_tmp_mount_block(&job_dir).await + }) + .await; + + let expected_dir = format!("{job_dir}/{NSJAIL_TMP_BIND_SUBDIR}"); + assert!( + std::path::Path::new(&expected_dir).is_dir(), + "jail_tmp dir should have been created at {expected_dir}" + ); + assert!(block.contains("is_bind: true")); + assert!(block.contains(&format!("src: \"{expected_dir}\""))); + } + + /// Disk-backed branch fallback: if `create_dir_all` fails, we must emit + /// the tmpfs block instead of returning an invalid bind config. + #[tokio::test] + async fn disk_backed_falls_back_to_tmpfs_on_mkdir_error() { + // /proc is a kernel filesystem that disallows directory creation, + // so create_dir_all on a subpath returns EPERM/EACCES. + let job_dir = "/proc/win1967_should_not_exist"; + + let block = with_tmp_backing(Some("disk".to_string()), || async { + resolve_nsjail_tmp_mount_block(job_dir).await + }) + .await; + + assert!(block.contains("fstype: \"tmpfs\"")); + assert!(!block.contains("is_bind")); + } + + /// Unknown values fall through to the tmpfs branch instead of crashing. + #[tokio::test] + async fn unknown_value_defaults_to_tmpfs() { + let block = with_tmp_backing(Some("bogus".to_string()), || async { + resolve_nsjail_tmp_mount_block("/anything").await + }) + .await; + assert!(block.contains("fstype: \"tmpfs\"")); + assert!(!block.contains("is_bind")); + } + + /// Security regression: if a pre-existing symlink sits at the jail_tmp + /// path (e.g. planted by a user-controlled tarball extracted into + /// `job_dir` before the resolver runs), the resolver must refuse the + /// bind mount and fall back to tmpfs — never bind-mount the symlink + /// target into the sandbox as /tmp. + #[tokio::test] + async fn disk_backed_refuses_preexisting_symlink_at_jail_tmp() { + let tmp = tempfile::tempdir().expect("tempdir"); + let job_dir = tmp.path().to_str().expect("utf8 path").to_string(); + + // Plant a symlink at {job_dir}/jail_tmp pointing at an arbitrary host + // path. Target doesn't have to exist — what matters is that the + // resolver doesn't follow it. + let jail_tmp_path = format!("{job_dir}/{NSJAIL_TMP_BIND_SUBDIR}"); + std::os::unix::fs::symlink("/etc", &jail_tmp_path).expect("plant symlink"); + assert!(std::path::Path::new(&jail_tmp_path).is_symlink()); + + let block = with_tmp_backing(Some("disk".to_string()), || async { + resolve_nsjail_tmp_mount_block(&job_dir).await + }) + .await; + + // Fell back to tmpfs — no bind-mount of the attacker-controlled path. + assert!( + block.contains("fstype: \"tmpfs\""), + "expected tmpfs fallback, got: {block}" + ); + assert!( + !block.contains("is_bind"), + "must not emit bind block, got: {block}" + ); + assert!( + !block.contains("/etc"), + "must not leak the symlink target into the proto, got: {block}" + ); + } + + /// Sequential resolver calls in the same `job_dir` (e.g. Python uv install + /// → Python run, Ruby install → run, Rust build → run) must keep using + /// the bind mount instead of silently falling back to tmpfs on the + /// second call. The first call creates `jail_tmp`; subsequent calls see + /// it as a pre-existing real directory and must accept it. + #[tokio::test] + async fn disk_backed_reuses_jail_tmp_across_sequential_calls() { + let tmp = tempfile::tempdir().expect("tempdir"); + let job_dir = tmp.path().to_str().expect("utf8 path").to_string(); + let expected_dir = format!("{job_dir}/{NSJAIL_TMP_BIND_SUBDIR}"); + + let (first, second) = with_tmp_backing(Some("disk".to_string()), || async { + let first = resolve_nsjail_tmp_mount_block(&job_dir).await; + // Simulate an executor that completes its first nsjail invocation + // (e.g. uv install) leaving jail_tmp on disk, then invokes nsjail + // again for the main run. + assert!(std::path::Path::new(&expected_dir).is_dir()); + let second = resolve_nsjail_tmp_mount_block(&job_dir).await; + (first, second) + }) + .await; + + assert!(first.contains("is_bind: true"), "first call: {first}"); + assert!( + second.contains("is_bind: true"), + "second call regressed to tmpfs: {second}" + ); + assert!(second.contains(&format!("src: \"{expected_dir}\""))); + } +} + async fn hash_args( #[allow(unused)] db: &DB, #[allow(unused)] client: &AuthedClient, diff --git a/backend/windmill-worker/src/csharp_executor.rs b/backend/windmill-worker/src/csharp_executor.rs index cfd9a544a1..70df9fb4e0 100644 --- a/backend/windmill-worker/src/csharp_executor.rs +++ b/backend/windmill-worker/src/csharp_executor.rs @@ -27,7 +27,7 @@ use windmill_queue::CanceledBy; use crate::{ common::{ build_command_with_isolation, check_executor_binary_exists, create_args_and_out_file, - get_reserved_variables, read_result, resolve_nsjail_timeout, resolve_nsjail_tmpfs_size_bytes, + get_reserved_variables, read_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process, DEV_CONF_NSJAIL, }, get_proxy_envs_for_lang, @@ -604,8 +604,8 @@ pub async fn handle_csharp_job( .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) .replace("#{DEV}", DEV_CONF_NSJAIL) .replace( - "{NSJAIL_TMPFS_SIZE}", - &resolve_nsjail_tmpfs_size_bytes().await, + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, ) .replace("{TIMEOUT}", &nsjail_timeout), )?; diff --git a/backend/windmill-worker/src/go_executor.rs b/backend/windmill-worker/src/go_executor.rs index ee08aa1b30..c10e41752c 100644 --- a/backend/windmill-worker/src/go_executor.rs +++ b/backend/windmill-worker/src/go_executor.rs @@ -22,7 +22,7 @@ use windmill_queue::{append_logs, CanceledBy, MiniPulledJob}; use crate::{ common::{ build_command_with_isolation, capitalize, create_args_and_out_file, get_reserved_variables, - read_result, resolve_nsjail_timeout, resolve_nsjail_tmpfs_size_bytes, start_child_process, + read_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process, OccupancyMetrics, DEV_CONF_NSJAIL, }, handle_child::handle_child, @@ -352,8 +352,8 @@ func Run(req Req) (interface{{}}, error){{ .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) .replace("#{DEV}", DEV_CONF_NSJAIL) .replace( - "{NSJAIL_TMPFS_SIZE}", - &resolve_nsjail_tmpfs_size_bytes().await, + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, ) .replace("{TIMEOUT}", &nsjail_timeout), )?; diff --git a/backend/windmill-worker/src/java_executor.rs b/backend/windmill-worker/src/java_executor.rs index f927f921c8..35541c3ee3 100644 --- a/backend/windmill-worker/src/java_executor.rs +++ b/backend/windmill-worker/src/java_executor.rs @@ -23,7 +23,7 @@ use windmill_queue::{append_logs, CanceledBy, MiniPulledJob}; use crate::{ common::{ build_command_with_isolation, create_args_and_out_file, get_reserved_variables, - read_result, resolve_nsjail_timeout, resolve_nsjail_tmpfs_size_bytes, start_child_process, + read_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process, OccupancyMetrics, }, handle_child, is_sandboxing_enabled, read_ee_registry_bool_with_workspace_override, @@ -671,8 +671,8 @@ async fn run<'a>( // .replace("{CACHED_TARGET}", &shared_mount) .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) .replace( - "{NSJAIL_TMPFS_SIZE}", - &resolve_nsjail_tmpfs_size_bytes().await, + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, ) .replace("{TIMEOUT}", &nsjail_timeout), )?; diff --git a/backend/windmill-worker/src/nu_executor.rs b/backend/windmill-worker/src/nu_executor.rs index d9a55c40c9..8aa0946666 100644 --- a/backend/windmill-worker/src/nu_executor.rs +++ b/backend/windmill-worker/src/nu_executor.rs @@ -14,7 +14,7 @@ use windmill_queue::{append_logs, CanceledBy, MiniPulledJob}; use crate::{ common::{ build_command_with_isolation, create_args_and_out_file, get_reserved_variables, - read_result, resolve_nsjail_timeout, resolve_nsjail_tmpfs_size_bytes, start_child_process, + read_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process, OccupancyMetrics, DEV_CONF_NSJAIL, }, get_proxy_envs_for_lang, handle_child, is_sandboxing_enabled, DISABLE_NUSER, NSJAIL_PATH, @@ -259,8 +259,8 @@ async fn run<'a>( .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) .replace("#{DEV}", DEV_CONF_NSJAIL) .replace( - "{NSJAIL_TMPFS_SIZE}", - &resolve_nsjail_tmpfs_size_bytes().await, + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, ) .replace("{TIMEOUT}", &nsjail_timeout), )?; diff --git a/backend/windmill-worker/src/php_executor.rs b/backend/windmill-worker/src/php_executor.rs index e0ba015951..08a92b3641 100644 --- a/backend/windmill-worker/src/php_executor.rs +++ b/backend/windmill-worker/src/php_executor.rs @@ -20,7 +20,7 @@ use windmill_queue::{append_logs, CanceledBy}; use crate::{ common::{ build_command_with_isolation, check_executor_binary_exists, create_args_and_out_file, - get_reserved_variables, read_result, resolve_nsjail_timeout, resolve_nsjail_tmpfs_size_bytes, + get_reserved_variables, read_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process, MaybeLock, OccupancyMetrics, }, handle_child::handle_child, @@ -426,8 +426,8 @@ try {{ .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) .replace("{SHARED_MOUNT}", shared_mount) .replace( - "{NSJAIL_TMPFS_SIZE}", - &resolve_nsjail_tmpfs_size_bytes().await, + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, ) .replace("{TIMEOUT}", &nsjail_timeout), )?; diff --git a/backend/windmill-worker/src/pwsh_executor.rs b/backend/windmill-worker/src/pwsh_executor.rs index 5ec6ff6bcd..1ce2eb09b3 100644 --- a/backend/windmill-worker/src/pwsh_executor.rs +++ b/backend/windmill-worker/src/pwsh_executor.rs @@ -26,7 +26,7 @@ lazy_static::lazy_static! { use crate::{ common::{ build_args_map, build_command_with_isolation, get_reserved_variables, read_file, - read_file_content, resolve_nsjail_timeout, resolve_nsjail_tmpfs_size_bytes, start_child_process, + read_file_content, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process, MaybeLock, OccupancyMetrics, }, handle_child::handle_child, @@ -683,8 +683,8 @@ $env:PSModulePath = \"{};$PSModulePathBackup\"", .replace("{SHARED_MOUNT}", shared_mount) .replace("{CACHE_DIR}", &*POWERSHELL_CACHE_DIR) .replace( - "{NSJAIL_TMPFS_SIZE}", - &resolve_nsjail_tmpfs_size_bytes().await, + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, ) .replace("{TIMEOUT}", &nsjail_timeout), )?; diff --git a/backend/windmill-worker/src/python_executor.rs b/backend/windmill-worker/src/python_executor.rs index 2930c9d339..4bec8d44bf 100644 --- a/backend/windmill-worker/src/python_executor.rs +++ b/backend/windmill-worker/src/python_executor.rs @@ -146,7 +146,7 @@ use windmill_object_store::OBJECT_STORE_SETTINGS; use crate::{ common::{ build_command_with_isolation, create_args_and_out_file, get_reserved_variables, read_file, - read_result, resolve_nsjail_timeout, resolve_nsjail_tmpfs_size_bytes, start_child_process, + read_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process, OccupancyMetrics, StreamNotifier, DEV_CONF_NSJAIL, }, get_proxy_envs_for_lang, @@ -1028,8 +1028,8 @@ mount {{ .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) .replace("#{DEV}", DEV_CONF_NSJAIL) .replace( - "{NSJAIL_TMPFS_SIZE}", - &resolve_nsjail_tmpfs_size_bytes().await, + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, ) .replace("{TIMEOUT}", &nsjail_timeout), )?; @@ -2056,8 +2056,8 @@ async fn spawn_uv_install( .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) .replace("#{DEV}", DEV_CONF_NSJAIL) .replace( - "{NSJAIL_TMPFS_SIZE}", - &resolve_nsjail_tmpfs_size_bytes().await, + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, ) .as_str(), )?; diff --git a/backend/windmill-worker/src/r_executor.rs b/backend/windmill-worker/src/r_executor.rs index 19959ee22c..9b2bbc6874 100644 --- a/backend/windmill-worker/src/r_executor.rs +++ b/backend/windmill-worker/src/r_executor.rs @@ -20,7 +20,7 @@ use windmill_queue::{append_logs, CanceledBy, MiniPulledJob}; use crate::{ common::{ build_command_with_isolation, create_args_and_out_file, get_reserved_variables, - read_result, resolve_nsjail_tmpfs_size_bytes, start_child_process, OccupancyMetrics, + read_result, resolve_nsjail_tmp_mount_block, start_child_process, OccupancyMetrics, DEV_CONF_NSJAIL, }, get_proxy_envs_for_lang, @@ -582,8 +582,8 @@ async fn run<'a>( .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) .replace("#{DEV}", DEV_CONF_NSJAIL) .replace( - "{NSJAIL_TMPFS_SIZE}", - &resolve_nsjail_tmpfs_size_bytes().await, + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, ) .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()), )?; diff --git a/backend/windmill-worker/src/ruby_executor.rs b/backend/windmill-worker/src/ruby_executor.rs index c82d5ad7df..7871c050c6 100644 --- a/backend/windmill-worker/src/ruby_executor.rs +++ b/backend/windmill-worker/src/ruby_executor.rs @@ -23,7 +23,7 @@ use windmill_queue::{append_logs, CanceledBy, MiniPulledJob}; use crate::{ common::{ build_command_with_isolation, create_args_and_out_file, get_reserved_variables, - read_result, resolve_nsjail_timeout, resolve_nsjail_tmpfs_size_bytes, start_child_process, + read_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process, OccupancyMetrics, DEV_CONF_NSJAIL, }, get_proxy_envs_for_lang, @@ -619,7 +619,7 @@ async fn install<'a>( envs.clone(), get_reserved_variables(job, &client.token, conn, parent_runnable_path.clone()).await?, ); - let nsjail_tmpfs_size = resolve_nsjail_tmpfs_size_bytes().await; + let nsjail_tmp_mount_block = resolve_nsjail_tmp_mount_block(&job_dir).await; par_install_language_dependencies_seq( InstallDeps::Flat(deps.clone()), "ruby", @@ -639,7 +639,7 @@ async fn install<'a>( .replace("{TARGET}", &dependency.path) .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) - .replace("{NSJAIL_TMPFS_SIZE}", &nsjail_tmpfs_size) + .replace("{TMP_MOUNT_BLOCK}", &nsjail_tmp_mount_block) .replace("#{DEV}", DEV_CONF_NSJAIL), // .replace("{BUILD}", &build_dir), )?; let mut cmd = Command::new(NSJAIL_PATH.as_str()); @@ -812,7 +812,10 @@ mount {{ .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) .replace("#{DEV}", DEV_CONF_NSJAIL) .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) - .replace("{NSJAIL_TMPFS_SIZE}", &resolve_nsjail_tmpfs_size_bytes().await) + .replace( + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, + ) .replace("{TIMEOUT}", &nsjail_timeout), )?; let mut cmd = Command::new(NSJAIL_PATH.as_str()); diff --git a/backend/windmill-worker/src/rust_executor.rs b/backend/windmill-worker/src/rust_executor.rs index 0f404569da..5941e9db1c 100644 --- a/backend/windmill-worker/src/rust_executor.rs +++ b/backend/windmill-worker/src/rust_executor.rs @@ -23,7 +23,7 @@ use windmill_queue::{append_logs, CanceledBy}; use crate::{ common::{ build_command_with_isolation, check_executor_binary_exists, create_args_and_out_file, - get_reserved_variables, read_result, resolve_nsjail_timeout, resolve_nsjail_tmpfs_size_bytes, + get_reserved_variables, read_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process, OccupancyMetrics, DEV_CONF_NSJAIL, }, get_proxy_envs_for_lang, @@ -481,8 +481,8 @@ pub async fn build_rust_crate( .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) .replace("#{DEV}", DEV_CONF_NSJAIL) .replace( - "{NSJAIL_TMPFS_SIZE}", - &resolve_nsjail_tmpfs_size_bytes().await, + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, ) .replace("{BUILD}", &build_dir), )?; @@ -706,8 +706,8 @@ pub async fn handle_rust_job( .replace("#{DEV}", DEV_CONF_NSJAIL) .replace("{SHARED_MOUNT}", shared_mount) .replace( - "{NSJAIL_TMPFS_SIZE}", - &resolve_nsjail_tmpfs_size_bytes().await, + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, ) .replace("{TIMEOUT}", &nsjail_timeout), )?; diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index b85c35d65b..6b1216ad9d 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -687,6 +687,12 @@ lazy_static::lazy_static! { /// `DEFAULT_NSJAIL_TMPFS_SIZE_BYTES` (800MB). pub static ref NSJAIL_TMPFS_SIZE_MB: Arc>> = Arc::new(RwLock::new(None)); + /// Selects how `/tmp` is backed inside nsjail sandboxes. `Some("disk")` + /// switches to a bind mount on `{JOB_DIR}/jail_tmp` (disk-backed); any + /// other value (including `None` or `Some("tmpfs")`) keeps the historical + /// RAM-backed tmpfs sized by `nsjail_tmpfs_size_mb`. + pub static ref NSJAIL_TMP_BACKING: Arc>> = Arc::new(RwLock::new(None)); + /// Optional mirror URL for `uv python install`. Wires to the `UV_PYTHON_INSTALL_MIRROR` /// env var when forwarded to uv. Can be set via the `UV_PYTHON_INSTALL_MIRROR` env var /// or the `uv_python_install_mirror` instance setting. diff --git a/frontend/src/lib/components/InstanceSetting.svelte b/frontend/src/lib/components/InstanceSetting.svelte index dff8591322..834ef0b96c 100644 --- a/frontend/src/lib/components/InstanceSetting.svelte +++ b/frontend/src/lib/components/InstanceSetting.svelte @@ -72,6 +72,20 @@ return false } } + // Hide the nsjail-only settings only when isolation is *explicitly* a + // non-nsjail mode. When `job_isolation` is unset, nsjail may still be + // enabled via the legacy env-driven path (`DISABLE_NSJAIL=false`), so + // keep the controls reachable. + if (setting == 'nsjail_tmp_backing' || setting == 'nsjail_tmpfs_size_mb') { + const isolation = values['job_isolation'] + if (isolation === 'none' || isolation === 'unshare') { + return false + } + } + // The tmpfs size knob is meaningless when /tmp is disk-backed. + if (setting == 'nsjail_tmpfs_size_mb' && values['nsjail_tmp_backing'] === 'disk') { + return false + } return true } diff --git a/frontend/src/lib/components/instanceSettings.ts b/frontend/src/lib/components/instanceSettings.ts index d8cf5901ec..a127027dac 100644 --- a/frontend/src/lib/components/instanceSettings.ts +++ b/frontend/src/lib/components/instanceSettings.ts @@ -245,11 +245,25 @@ export const settings: Record = { } ] }, + { + label: 'Nsjail /tmp backing', + key: 'nsjail_tmp_backing', + fieldType: 'select', + description: + 'How /tmp is backed inside the nsjail sandbox. RAM (tmpfs) is the default — fast, with a hard size cap from Nsjail tmpfs size, but consumes worker memory. Disk (bind mount) uses a per-job directory on the worker disk — no RAM cost, but the only remaining per-file ceiling is rlimit_fsize (~1GB for python/ansible, unbounded for most other languages because they set disable_rl: true); pair with host disk monitoring or quotas.', + storage: 'setting', + placeholder: 'tmpfs', + defaultValue: () => 'tmpfs', + select_items: [ + { label: 'RAM (tmpfs) — default', value: 'tmpfs' }, + { label: 'Disk (bind mount)', value: 'disk' } + ] + }, { label: 'Nsjail tmpfs size (MB)', key: 'nsjail_tmpfs_size_mb', description: - 'Override the size of the /tmp tmpfs mount inside the nsjail sandbox (in MB). When left empty, defaults to 800MB. Only applies when the job isolation mode is set to Nsjail.', + 'Override the size of the /tmp tmpfs mount inside the nsjail sandbox (in MB). When left empty, defaults to 800MB. Only applies when Nsjail /tmp backing is RAM (tmpfs).', fieldType: 'number', placeholder: '800', storage: 'setting' From e6f80dad1c247d0e8289defd91e259ee31e6bc37 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 21 May 2026 16:33:02 +0000 Subject: [PATCH 16/71] chore(main): release 1.706.0 (#9270) * chore(main): release 1.706.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- CHANGELOG.md | 19 +++ backend/Cargo.lock | 160 +++++++++--------- backend/Cargo.toml | 4 +- .../parsers/windmill-parser-wasm/Cargo.lock | 48 +++--- .../parsers/windmill-parser-wasm/Cargo.toml | 2 +- backend/windmill-api/openapi.yaml | 2 +- benchmarks/lib.ts | 2 +- cli/src/main.ts | 2 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- lsp/Pipfile | 2 +- openflow.openapi.yaml | 2 +- .../WindmillClient/WindmillClient.psd1 | 2 +- python-client/wmill/pyproject.toml | 2 +- typescript-client/jsr.json | 2 +- typescript-client/package.json | 2 +- version.txt | 2 +- 17 files changed, 139 insertions(+), 120 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac41734dda..5272d8ec38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## [1.706.0](https://github.com/windmill-labs/windmill/compare/v1.705.0...v1.706.0) (2026-05-21) + + +### Features + +* add userdraft listing primitives ([#9268](https://github.com/windmill-labs/windmill/issues/9268)) ([d0ee697](https://github.com/windmill-labs/windmill/commit/d0ee697e8b8de58085ea0b2ecde1af2b2441428d)) +* add UV_PYTHON_INSTALL_MIRROR env and instance setting ([#9271](https://github.com/windmill-labs/windmill/issues/9271)) ([1169371](https://github.com/windmill-labs/windmill/commit/1169371d4885bdc18c76d03c6caae71f0e440235)) +* add yolo mode for ai chat tools ([#9258](https://github.com/windmill-labs/windmill/issues/9258)) ([ac26aa4](https://github.com/windmill-labs/windmill/commit/ac26aa4e4c7cc2d493f136b59738c0708803cc6d)) +* CLI datatable serve / psql ([#9267](https://github.com/windmill-labs/windmill/issues/9267)) ([28c8b5c](https://github.com/windmill-labs/windmill/commit/28c8b5c60fd46f961ae11b363b9be834fad6ee68)) +* **cli:** add `wmill init prompts` and custom override slot ([#9266](https://github.com/windmill-labs/windmill/issues/9266)) ([1ba8ed8](https://github.com/windmill-labs/windmill/commit/1ba8ed8abd827313ce0f7728d9f84357417206ee)) +* **nsjail:** optional disk-backed /tmp via instance setting ([#9272](https://github.com/windmill-labs/windmill/issues/9272)) ([b656dc6](https://github.com/windmill-labs/windmill/commit/b656dc6cdc8c50ef9740240447f119cceed18547)) + + +### Bug Fixes + +* **ai:** enforce RLS and scope check on user-supplied X-Resource-Path ([#9276](https://github.com/windmill-labs/windmill/issues/9276)) ([0692b97](https://github.com/windmill-labs/windmill/commit/0692b97c8a3818549d7050ea3e057e9cbf1ddb44)) +* **debugger:** add non-root user support to Dockerfile ([#9277](https://github.com/windmill-labs/windmill/issues/9277)) ([0bdb6a9](https://github.com/windmill-labs/windmill/commit/0bdb6a9d5d5fb28a27af1b6eda9fde7172308faf)) +* **indexer:** tell admins when ingress routes search to wrong pod ([#9274](https://github.com/windmill-labs/windmill/issues/9274)) ([d29a561](https://github.com/windmill-labs/windmill/commit/d29a5612fcd17eb4197468289e955a1209127cc1)) + ## [1.705.0](https://github.com/windmill-labs/windmill/compare/v1.704.1...v1.705.0) (2026-05-20) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 1f871d5afb..b1a804bed3 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -4315,9 +4315,9 @@ dependencies = [ [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" dependencies = [ "serde", ] @@ -13788,7 +13788,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-nats", @@ -13869,7 +13869,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.705.0" +version = "1.706.0" dependencies = [ "async-stream", "async-trait", @@ -13901,7 +13901,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.705.0" +version = "1.706.0" dependencies = [ "axum 0.8.9", "chrono", @@ -13914,7 +13914,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "argon2", @@ -14057,7 +14057,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.705.0" +version = "1.706.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14080,7 +14080,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.705.0" +version = "1.706.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14093,7 +14093,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14119,7 +14119,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.705.0" +version = "1.706.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -14129,7 +14129,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.705.0" +version = "1.706.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14146,7 +14146,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.705.0" +version = "1.706.0" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -14168,7 +14168,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14191,7 +14191,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.705.0" +version = "1.706.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14207,7 +14207,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.705.0" +version = "1.706.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14228,7 +14228,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.705.0" +version = "1.706.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14249,7 +14249,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.705.0" +version = "1.706.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14263,7 +14263,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-nats", @@ -14295,7 +14295,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14320,7 +14320,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.705.0" +version = "1.706.0" dependencies = [ "axum 0.8.9", "flate2", @@ -14338,7 +14338,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14360,7 +14360,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.705.0" +version = "1.706.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14380,7 +14380,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.705.0" +version = "1.706.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14410,7 +14410,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14438,7 +14438,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.705.0" +version = "1.706.0" dependencies = [ "lazy_static", "serde", @@ -14450,7 +14450,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.705.0" +version = "1.706.0" dependencies = [ "argon2", "axum 0.8.9", @@ -14475,7 +14475,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.705.0" +version = "1.706.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14489,7 +14489,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.705.0" +version = "1.706.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14522,7 +14522,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.705.0" +version = "1.706.0" dependencies = [ "chrono", "lazy_static", @@ -14536,7 +14536,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14555,7 +14555,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.705.0" +version = "1.706.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -14656,7 +14656,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.705.0" +version = "1.706.0" dependencies = [ "chrono", "itertools 0.14.0", @@ -14675,7 +14675,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.705.0" +version = "1.706.0" dependencies = [ "regex", "serde", @@ -14690,7 +14690,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -14714,7 +14714,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "futures", @@ -14731,7 +14731,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.705.0" +version = "1.706.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -14747,7 +14747,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-trait", @@ -14768,7 +14768,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-trait", @@ -14799,7 +14799,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "arc-swap", @@ -14824,7 +14824,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-stream", @@ -14858,7 +14858,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "futures", @@ -14876,7 +14876,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.705.0" +version = "1.706.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -14885,7 +14885,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "lazy_static", @@ -14897,7 +14897,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "serde_json", @@ -14909,7 +14909,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "gosyn", @@ -14921,7 +14921,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "lazy_static", @@ -14933,7 +14933,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "serde_json", @@ -14945,7 +14945,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "nu-parser", @@ -14956,7 +14956,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14967,7 +14967,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14979,7 +14979,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "rustpython-ast", @@ -14990,7 +14990,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-recursion", @@ -15012,7 +15012,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "serde_json", @@ -15024,7 +15024,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "lazy_static", @@ -15038,7 +15038,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -15055,7 +15055,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "lazy_static", @@ -15068,7 +15068,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "serde", @@ -15080,7 +15080,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "lazy_static", @@ -15098,7 +15098,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -15114,7 +15114,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "rustpython-ast", @@ -15130,7 +15130,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "serde", @@ -15141,7 +15141,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-recursion", @@ -15178,7 +15178,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "const_format", @@ -15216,7 +15216,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.705.0" +version = "1.706.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -15227,7 +15227,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-recursion", @@ -15257,7 +15257,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-trait", @@ -15281,7 +15281,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-trait", @@ -15314,7 +15314,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-trait", @@ -15347,7 +15347,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-trait", @@ -15367,7 +15367,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-trait", @@ -15401,7 +15401,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-trait", @@ -15437,7 +15437,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-trait", @@ -15460,7 +15460,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-trait", @@ -15484,7 +15484,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-nats", @@ -15508,7 +15508,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-trait", @@ -15543,7 +15543,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-trait", @@ -15571,7 +15571,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-trait", @@ -15594,7 +15594,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "bitflags 2.11.1", @@ -15613,7 +15613,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-once-cell", @@ -15723,7 +15723,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.705.0" +version = "1.706.0" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 50c9b7501a..41115db17e 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.705.0" +version = "1.706.0" authors.workspace = true edition.workspace = true @@ -87,7 +87,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"] [workspace.package] -version = "1.705.0" +version = "1.706.0" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index 89f0fd74c8..e3beab7f1c 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6183,7 +6183,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.705.0" +version = "1.706.0" dependencies = [ "aho-corasick", "anyhow", @@ -6263,7 +6263,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.705.0" +version = "1.706.0" dependencies = [ "proc-macro2", "quote", @@ -6275,7 +6275,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.705.0" +version = "1.706.0" dependencies = [ "convert_case", "serde", @@ -6284,7 +6284,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "lazy_static", @@ -6296,7 +6296,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "serde_json", @@ -6308,7 +6308,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "gosyn", @@ -6320,7 +6320,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "lazy_static", @@ -6332,7 +6332,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "serde_json", @@ -6344,7 +6344,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "nu-parser", @@ -6355,7 +6355,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6366,7 +6366,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6389,7 +6389,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "async-recursion", @@ -6411,7 +6411,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "serde_json", @@ -6423,7 +6423,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "lazy_static", @@ -6437,7 +6437,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "convert_case", @@ -6454,7 +6454,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "lazy_static", @@ -6467,7 +6467,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "serde", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "lazy_static", @@ -6497,7 +6497,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6513,7 +6513,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6529,7 +6529,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6561,7 +6561,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "serde", @@ -6572,7 +6572,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.705.0" +version = "1.706.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index 901abe9fd7..566f9b1e58 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.toml +++ b/backend/parsers/windmill-parser-wasm/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" members = ["."] [workspace.package] -version = "1.705.0" +version = "1.706.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 6f8755da3a..46c2639ea4 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.705.0 + version: 1.706.0 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 9e041bc6ac..5d0acca54f 100644 --- a/benchmarks/lib.ts +++ b/benchmarks/lib.ts @@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts"; import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts"; import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts"; -export const VERSION = "v1.705.0"; +export const VERSION = "v1.706.0"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/src/main.ts b/cli/src/main.ts index a03db55d0d..e807779ed9 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -87,7 +87,7 @@ export { token, }; -export const VERSION = "1.705.0"; +export const VERSION = "1.706.0"; // Re-exported from constants.ts to maintain backwards compatibility export { WM_FORK_PREFIX } from "./core/constants.ts"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4952b96d14..442771f584 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-components", - "version": "1.705.0", + "version": "1.706.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-components", - "version": "1.705.0", + "version": "1.706.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 7bfde58794..75d3a2d30f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "windmill-components", - "version": "1.705.0", + "version": "1.706.0", "scripts": { "dev": "vite dev", "build": "vite build", diff --git a/lsp/Pipfile b/lsp/Pipfile index 238918d44c..2018887843 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.705.0" +wmill = ">=1.706.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 9b406df385..c8594a6aed 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.705.0 + version: 1.706.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index b0683e959e..c23a7fbf3f 100644 --- a/powershell-client/WindmillClient/WindmillClient.psd1 +++ b/powershell-client/WindmillClient/WindmillClient.psd1 @@ -12,7 +12,7 @@ RootModule = 'WindmillClient.psm1' # Version number of this module. - ModuleVersion = '1.705.0' + ModuleVersion = '1.706.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index 561c313aae..4f1ed0e32b 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.705.0" +version = "1.706.0" description = "A client library for accessing Windmill server wrapping the Windmill client API" license = "Apache-2.0" homepage = "https://windmill.dev" diff --git a/typescript-client/jsr.json b/typescript-client/jsr.json index 738580f772..ca7b1a859c 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.705.0", + "version": "1.706.0", "exports": "./src/index.ts", "publish": { "exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts"] diff --git a/typescript-client/package.json b/typescript-client/package.json index 4a85819712..51507ebdc7 100644 --- a/typescript-client/package.json +++ b/typescript-client/package.json @@ -1,7 +1,7 @@ { "name": "windmill-client", "description": "Windmill SDK client for browsers and Node.js", - "version": "1.705.0", + "version": "1.706.0", "author": "Ruben Fiszel", "license": "Apache 2.0", "sideEffects": false, diff --git a/version.txt b/version.txt index 2ce80397aa..0fdf01b5f5 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.705.0 +1.706.0 From 72e2c3a6b3e0cb0f5bddf8291ae18bb8cf55ec28 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 21 May 2026 20:47:26 +0000 Subject: [PATCH 17/71] fix(nsjail): gate unix-symlink test behind cfg(unix) for Windows build (#9280) The disk_backed_refuses_preexisting_symlink_at_jail_tmp test calls std::os::unix::fs::symlink directly, which doesn't exist on Windows targets. Without a cfg gate, `cargo check --tests` fails on Windows with E0433. Other symlink call sites in this crate (php_executor, bun_executor, rust_executor, etc.) already follow this pattern. Fixes WIN-1972 Co-authored-by: Claude Opus 4.7 (1M context) --- backend/windmill-worker/src/common.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/windmill-worker/src/common.rs b/backend/windmill-worker/src/common.rs index aa3c6dbe2a..9864093cd0 100644 --- a/backend/windmill-worker/src/common.rs +++ b/backend/windmill-worker/src/common.rs @@ -1234,6 +1234,7 @@ mod nsjail_tmp_mount_tests { /// `job_dir` before the resolver runs), the resolver must refuse the /// bind mount and fall back to tmpfs — never bind-mount the symlink /// target into the sandbox as /tmp. + #[cfg(unix)] #[tokio::test] async fn disk_backed_refuses_preexisting_symlink_at_jail_tmp() { let tmp = tempfile::tempdir().expect("tempdir"); From 88294182c0d0f47ca21ef532cdec7c47104f2a60 Mon Sep 17 00:00:00 2001 From: Aldrin Jenson Date: Fri, 22 May 2026 02:39:36 -0400 Subject: [PATCH 18/71] Reduce slim image vulnerability surface (#9279) * Reduce slim image vulnerability surface * chore(docker): drop apt-get upgrade -y from slim images apt-get upgrade hurts build reproducibility (same Dockerfile + same commit at different times produces divergent images) and trips hadolint DL3005. The freshness it buys is dominated by simply rebuilding against the periodically-refreshed debian:bookworm-slim base image. The --no-install-recommends and apt-list cleanup wins are kept. --------- Co-authored-by: Ruben Fiszel --- docker/DockerfileSlim | 13 +++++++++---- docker/DockerfileSlimEe | 13 +++++++++---- frontend/src/lib/hubPaths.json | 30 ------------------------------ 3 files changed, 18 insertions(+), 38 deletions(-) diff --git a/docker/DockerfileSlim b/docker/DockerfileSlim index 181d87b380..2bfa883466 100644 --- a/docker/DockerfileSlim +++ b/docker/DockerfileSlim @@ -5,8 +5,9 @@ FROM debian:bookworm-slim AS nsjail WORKDIR /nsjail RUN apt-get -y update \ - && apt-get install -y \ + && apt-get install -y --no-install-recommends \ bison=2:3.8.* \ + ca-certificates \ flex=2.6.* \ g++=4:12.2.* \ gcc=4:12.2.* \ @@ -15,7 +16,9 @@ RUN apt-get -y update \ libnl-route-3-dev=3.7.* \ make=4.3-4.1 \ pkg-config=1.8.* \ - protobuf-compiler=3.21.* + protobuf-compiler=3.21.* \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* RUN git clone -b master --single-branch https://github.com/google/nsjail.git . && git checkout dccf911fd2659e7b08ce9507c25b2b38ec2c5800 RUN make @@ -36,7 +39,8 @@ ENV PATH=/usr/local/bin:/root/.local/bin:/tmp/.local/bin:$PATH # Install system dependencies RUN apt-get update \ - && apt-get install -y ca-certificates wget curl git jq unzip unixodbc xmlsec1 gnupg lsb-release \ + && apt-get install -y --no-install-recommends ca-certificates wget curl git jq unzip unixodbc xmlsec1 gnupg lsb-release \ + && apt-get clean \ && rm -rf /var/lib/apt/lists/* # Install latest PostgreSQL client (pg_dump) from official PostgreSQL apt repository @@ -78,7 +82,8 @@ RUN curl -fsSL https://claude.ai/install.sh | bash \ COPY --from=docker:29-dind /usr/local/bin/docker /usr/local/bin/ # nsjail runtime deps and binary -RUN apt-get update && apt-get install -y libprotobuf-dev libnl-route-3-dev \ +RUN apt-get update \ + && apt-get install -y --no-install-recommends libprotobuf-dev libnl-route-3-dev \ && apt-get clean && rm -rf /var/lib/apt/lists/* COPY --from=nsjail /nsjail/nsjail /bin/nsjail diff --git a/docker/DockerfileSlimEe b/docker/DockerfileSlimEe index cc74d14996..d6616b5b97 100644 --- a/docker/DockerfileSlimEe +++ b/docker/DockerfileSlimEe @@ -5,8 +5,9 @@ FROM debian:bookworm-slim AS nsjail WORKDIR /nsjail RUN apt-get -y update \ - && apt-get install -y \ + && apt-get install -y --no-install-recommends \ bison=2:3.8.* \ + ca-certificates \ flex=2.6.* \ g++=4:12.2.* \ gcc=4:12.2.* \ @@ -15,7 +16,9 @@ RUN apt-get -y update \ libnl-route-3-dev=3.7.* \ make=4.3-4.1 \ pkg-config=1.8.* \ - protobuf-compiler=3.21.* + protobuf-compiler=3.21.* \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* RUN git clone -b master --single-branch https://github.com/google/nsjail.git . && git checkout dccf911fd2659e7b08ce9507c25b2b38ec2c5800 RUN make @@ -36,7 +39,8 @@ ENV PATH=/usr/local/bin:/root/.local/bin:/tmp/.local/bin:$PATH # Install system dependencies RUN apt-get update \ - && apt-get install -y ca-certificates wget curl git jq unzip unixodbc xmlsec1 gnupg lsb-release \ + && apt-get install -y --no-install-recommends ca-certificates wget curl git jq unzip unixodbc xmlsec1 gnupg lsb-release \ + && apt-get clean \ && rm -rf /var/lib/apt/lists/* # Install latest PostgreSQL client (pg_dump) from official PostgreSQL apt repository @@ -78,7 +82,8 @@ RUN curl -fsSL https://claude.ai/install.sh | bash \ COPY --from=docker:29-dind /usr/local/bin/docker /usr/local/bin/ # nsjail runtime deps and binary -RUN apt-get update && apt-get install -y libprotobuf-dev libnl-route-3-dev \ +RUN apt-get update \ + && apt-get install -y --no-install-recommends libprotobuf-dev libnl-route-3-dev \ && apt-get clean && rm -rf /var/lib/apt/lists/* COPY --from=nsjail /nsjail/nsjail /bin/nsjail diff --git a/frontend/src/lib/hubPaths.json b/frontend/src/lib/hubPaths.json index 0be40c0bca..902a90c9d1 100644 --- a/frontend/src/lib/hubPaths.json +++ b/frontend/src/lib/hubPaths.json @@ -1,41 +1,11 @@ { - "deprecated_gitSync_0": "hub/9087/sync-script-to-git-repo-windmill", - "deprecated_gitSync_1": "hub/9987/sync-script-to-git-repo-windmill", - "deprecated_gitSync_2": "hub/11498/sync-script-to-git-repo-windmill", - "deprecated_gitSync_3": "hub/11533/sync-script-to-git-repo-windmill", - "deprecated_gitSync_4": "hub/11580/sync-script-to-git-repo-windmill", - "deprecated_gitSync_5": "hub/11641/sync-script-to-git-repo-windmill", - "deprecated_gitSync_6": "hub/11666/sync-script-to-git-repo-windmill", - "deprecated_gitSync_7": "hub/11668/sync-script-to-git-repo-windmill", - "deprecated_gitSync_8": "hub/19673/sync-script-to-git-repo-windmill", - "deprecated_gitSync_9": "hub/19738/sync-script-to-git-repo-windmill", - "deprecated_gitSync_10": "hub/19785/sync-script-to-git-repo-windmill", - "deprecated_gitSync_11": "hub/19789/sync-script-to-git-repo-windmill", - "deprecated_gitSync_12": "hub/19798/sync-script-to-git-repo-windmill", - "deprecated_gitSync_13": "hub/19801/sync-script-to-git-repo-windmill", - "deprecated_gitSync_14": "hub/19803/sync-script-to-git-repo-windmill", - "deprecated_gitSync_15": "hub/19816/sync-script-to-git-repo-windmill", - "deprecated_gitSync_16": "hub/19818/sync-script-to-git-repo-windmill", - "deprecated_gitSync_17": "hub/28073/sync-script-to-git-repo-windmill", - "deprecated_gitSync_18": "hub/28078/sync-script-to-git-repo-windmill", - "deprecated_gitSync_19": "hub/28081/sync-script-to-git-repo-windmill", - "deprecated_gitSync_20": "hub/28102/sync-script-to-git-repo-windmill", - "deprecated_gitSync_21": "hub/28131/sync-script-to-git-repo-windmill", - "deprecated_gitSync_22": "hub/28159/sync-script-to-git-repo-windmill", - "deprecated_gitSync_23": "hub/28160/sync-script-to-git-repo-windmill", - "deprecated_gitSync_24": "hub/28176/sync-script-to-git-repo-windmill", - "deprecated_gitSync_latest": "hub/28180/sync-script-to-git-repo-windmill", - "deprecated_gitSync_25": "hub/28183/sync-script-to-git-repo-windmill", "gitSyncTest": "hub/28184/git-repo-test-read-write-windmill", "gitInitRepo": "hub/28219/git-sync%3A-init-repository-windmill", "slackErrorHandler": "hub/19741/workspace-or-schedule-error-handler-slack", - "slackErrorHandler_0": "hub/9079/workspace-or-schedule-error-handler-slack", - "slackErrorHandler_1": "hub/9206/workspace-or-schedule-error-handler-slack", "emailErrorHandler": "hub/19795/workspace-or-error-handler-email", "slackRecoveryHandler": "hub/9080/slack/schedule-recovery-handler-slack", "slackSuccessHandler": "hub/28220/slack/schedule-success-handler-slack", "teamsErrorHandler": "hub/19742/workspace-or-schedule-error-handler-teams", - "teamsErrorHandler_0": "hub/11598/workspace-or-schedule-error-handler-teams", "teamsRecoveryHandler": "hub/11593/schedule-recovery-handler-teams", "teamsSuccessHandler": "hub/11596/schedule-success-handler-teams", "slackReport": "hub/9084/slack", From 89a2f07218818b95238b4a4484deab3138099672 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 22 May 2026 07:09:02 +0000 Subject: [PATCH 19/71] fix(git-sync): bump to hub/28234 with stateless gpg.program wrapper (WIN-1974) (#9282) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(git-sync): revert LATEST_GIT_SYNC_SCRIPT_PATH to hub/28230 to restore GPG-signed deploys (WIN-1974) hub/28231 (PR #9230) is the "thin" script that hands the actual `git commit` to the CLI's hidden `sync git-deploy`. The hub script still does the GPG setup (import key into a fresh GNUPGHOME, dummy `gpg -bsau` to warm the agent passphrase cache, then `git config user.signingkey` + `commit.gpgsign` locally), but the commit no longer runs in the same `git_push` flow — it runs minutes later inside the CLI after workspace API resolution, zip pull, file extraction, and lockfile autofill. By the time the spawned `git commit` asks gpg-agent for the cached passphrase, the cache state is no longer reliable (or the spawned `gpg` ends up talking to a fresh agent), so signing fails non-interactively with `gpg failed to sign the data`. hub/28230 is hub/28217's in-script logic rebuilt with windmill-cli@1.703.3: the GPG setup and the in-script `sh_run("git commit ...")` happen back-to-back in `git_push`, so the cache is always fresh. It preserves wm_deploy / fork branch behavior, the EE deployment-callback `main()` signature is unchanged, and the only min-version check in EE (`is_script_meets_min_version(28103)`) is comfortably below 28230 — so this revert is safe. Forward fix (separate PR): publish a new thin script that, alongside the existing GPG setup, writes a `gpg.program` wrapper using `--pinentry-mode loopback --passphrase-file` so signing is independent of the agent's cache state. Re-bump past 28231 then. Fixes WIN-1974 Co-Authored-By: Claude Opus 4.7 (1M context) * chore(git-sync): check in source-of-truth for the next hub script (gpg.program wrapper) This is the script that will be published to hub.windmill.dev once verified on a customer GPG-signed deploy. It replaces hub/28231's agent-cache pre-warm (`gpg -bsau` with --passphrase) with a stateless gpg.program wrapper + chmod-600 passphrase file. Every git-invoked gpg call goes through the wrapper, which always uses --pinentry-mode loopback (and --passphrase-file when a passphrase exists). Signing no longer depends on gpg-agent having a cached passphrase by the time the CLI's `git commit` runs — which closes WIN-1974. Not wired in yet: LATEST_GIT_SYNC_SCRIPT_PATH stays on hub/28230 until this script is uploaded and the new hub id is known. This file is checked in so the diff is reviewable, future bumps have a source of truth, and a CLI regression test can `cat` it for fixture parity. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(frontend): skip format/pattern validation for $var/$res/$jsonvar references in ArgInput A resource field with a `pattern` constraint (e.g. the gpg_key.private_key field, whose pattern enforces a `-----BEGIN PGP PRIVATE KEY BLOCK-----` prefix) rejects values like `$var:u/me/gpg-private-key` with an "invalid format" error in the resource editor — even though `$var:`/`$res:`/`$jsonvar:` are placeholders the backend resolves at runtime, not the actual string that needs to match the regex. Bail out of all format/pattern checks (email, ipv4, ipv6, uuid, custom pattern) when the value is one of these references. Required/numeric bounds/array checks still apply since they're shape-level, not regex. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(git-sync): bump LATEST_GIT_SYNC_SCRIPT_PATH to hub/28234 (gpg.program-wrapper fix) hub/28234 is the forward fix for WIN-1974: replaces hub/28231's agent-cache pre-warm (which became stale by the time the CLI's `git commit` ran) with a stateless `gpg.program` wrapper that uses `--pinentry-mode loopback` (and `--passphrase-file` when a passphrase exists) on every gpg invocation. Bundled CLI is windmill-cli@1.705.0. Verified via reproducer at /tmp/git-sync-diff/test-gpg-fix.sh: deliberately killing gpg-agent between GPG setup and `git commit` reproduces the customer's `gpg failed to sign the data` error verbatim under the old flow, and the wrapper signs through it. Holds for passphrase-protected keys, split-subkey [C]+[S] layouts, and unprotected keys. Drops the local source-of-truth copy (`hub-scripts/`) — hub is canonical now that 28234 is published. Co-Authored-By: Claude Opus 4.7 (1M context) * chore(git-sync): drop verbose comment above LATEST_GIT_SYNC_SCRIPT_PATH The git history (this PR) carries the why; the constant name + value carry the what. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- backend/windmill-common/src/workspaces.rs | 2 +- frontend/src/lib/components/ArgInput.svelte | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index dd42d5632a..04461e821e 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -157,7 +157,7 @@ pub enum ObjectType { WorkspaceDependencies, } -pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28231/sync-script-to-git-repo-windmill"; +pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28234/sync-script-to-git-repo-windmill"; /// Prefix used to identify fork workspaces. A workspace whose id starts with this string is a /// fork of another workspace. diff --git a/frontend/src/lib/components/ArgInput.svelte b/frontend/src/lib/components/ArgInput.svelte index 839153ad67..ed9d11eaa7 100644 --- a/frontend/src/lib/components/ArgInput.svelte +++ b/frontend/src/lib/components/ArgInput.svelte @@ -406,6 +406,15 @@ if (nullable && emptyString(v)) { error = '' valid && (valid = true) + } else if ( + typeof v === 'string' && + (v.startsWith('$var:') || v.startsWith('$res:') || v.startsWith('$jsonvar:')) + ) { + // $var/$res/$jsonvar are placeholders resolved at runtime; the literal + // string won't match format constraints (email/ipv4/uuid/custom pattern), + // so format-checking it produces a false-positive "invalid format" error. + error = '' + !valid && (valid = true) } else if (required && (v == undefined || v == null || v === '') && inputCat != 'object') { error = 'Required' valid && (valid = false) From 3c3e99d1a5e00056c24bc0a499ee1156cc588c05 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 22 May 2026 07:54:12 +0000 Subject: [PATCH 20/71] refactor(cli): wmill sync git-deploy stops committing; caller owns commit+push (#9284) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single contract for the deployment-callback path: the CLI does branch checkout + pull, the caller (hub script in production, test in test) does git add + commit + push. This restores the WIN-1974 invariant — GPG setup and `git commit` run back-to-back in the same process, so the agent's pre-warmed passphrase cache is still warm at sign time — without needing a `--skip-commit` flag for the hub case and a default "also-commit" for everything else. Same behavior in every call site. Changes: - sync.ts: drop the gitSyncDeployPush call from pull()'s deploy path (both the onlyCreateBranch fast-return and the post-pull commit). `gitSyncDeployPush` stays exported for any caller that wants the same commit/push semantics — just not invoked by the CLI subcommand. - gitsync_promotion.test.ts: e2e test now does its own git add + commit + push after `wmill sync git-deploy`, mirroring what the hub script does in production. Same regression coverage (wm_deploy branch created in Case A, main untouched; main updated in Case B, no new wm_deploy). CLI typecheck unchanged (two pre-existing TarAsZip errors at lines 2578/3307, present before this PR). All 743 unit tests still pass. The accompanying hub script (option-C — CLI for branch+pull, script for commit+push) lives at /tmp/git-sync-diff/sync-script-to-git-repo-windmill.option-C.ts. Once published, a follow-up bumps LATEST_GIT_SYNC_SCRIPT_PATH to its id. Co-authored-by: Claude Opus 4.7 (1M context) --- cli/src/commands/sync/sync.ts | 28 +++++++--------------------- cli/test/gitsync_promotion.test.ts | 29 +++++++++++++++++++++++++---- 2 files changed, 32 insertions(+), 25 deletions(-) diff --git a/cli/src/commands/sync/sync.ts b/cli/src/commands/sync/sync.ts index f9c648eef0..d4aa3d818c 100644 --- a/cli/src/commands/sync/sync.ts +++ b/cli/src/commands/sync/sync.ts @@ -2503,14 +2503,8 @@ export async function pull( } if (opts.onlyCreateBranch) { - gitSyncDeployPush({ - items: deployItems, - authorName: process.env["WM_USERNAME"] || "windmill", - authorEmail: process.env["WM_EMAIL"] || "windmill@windmill.dev", - committerName: opts.gitCommitterName, - committerEmail: opts.gitCommitterEmail, - onlyCreateBranch: true, - }); + // Branch is checked out locally; the caller pushes it. Symmetric with + // the non-onlyCreateBranch path: CLI does branch + pull, never push. return; } } @@ -2982,19 +2976,11 @@ export async function pull( log.warn(`Failed to pull shared UI folder: ${e}`); } - // Git-sync deployment-callback mode: commit the pulled files and push the - // current branch (the wm_deploy/fork branch checked out above, or the base - // branch in workspace-wide mode). - if (opts.gitDeployItems !== undefined && !opts.onlyCreateBranch) { - const deployItems: GitSyncDeployItem[] = JSON.parse(opts.gitDeployItems); - gitSyncDeployPush({ - items: deployItems, - authorName: process.env["WM_USERNAME"] || "windmill", - authorEmail: process.env["WM_EMAIL"] || "windmill@windmill.dev", - committerName: opts.gitCommitterName, - committerEmail: opts.gitCommitterEmail, - }); - } + // Git-sync deployment-callback mode stops here: branch checkout + pull have + // happened, but commit + push are the caller's job. The hub script does + // them in-process with `set_gpg_signing_secret` so the agent's pre-warmed + // passphrase cache is still warm at sign time (WIN-1974). `gitSyncDeployPush` + // stays exported for callers that want the same commit/push behavior. } // Internal git-sync deployment-callback entrypoint. Invoked only by the diff --git a/cli/test/gitsync_promotion.test.ts b/cli/test/gitsync_promotion.test.ts index f809d2032b..5b73e8f8e1 100644 --- a/cli/test/gitsync_promotion.test.ts +++ b/cli/test/gitsync_promotion.test.ts @@ -6,10 +6,11 @@ * `use_individual_branch` is set — NOT straight to the cloned base branch * (e.g. a protected `main`, which fails with GH006). * - * The CLI's `wmill sync pull --git-deploy-items ...` now owns that branch - * checkout + commit + push (previously hub-script-only, hence untestable). - * This drives it against a real local bare repo so the regression is caught - * deterministically, with no network and no GitHub. + * Contract: `wmill sync git-deploy` does branch checkout + pull only. Commit + * + push are the caller's job — the hub script does them in the same process + * as `set_gpg_signing_secret` so the GPG agent's passphrase cache is still + * warm at sign time (WIN-1974). This test replicates the caller half (git + * add + commit + push) inline so the full promotion regression stays caught. */ import { expect, test } from "bun:test"; @@ -121,6 +122,23 @@ test.skipIf(shouldSkipOnCI())( { path_type: "script", path: "f/promo/foo", commit_msg: "deploy foo" }, ]); + // Caller-half: stage anything the CLI's pull dropped, commit on the + // current branch (which the CLI just checked out), and push. Mirrors + // what the hub script does in production after `wmill sync git-deploy`. + const commitAndPush = (work: string) => { + git(work, "config", "user.email", "test@windmill.dev"); + git(work, "config", "user.name", "test"); + git(work, "add", "-A"); + try { + git(work, "diff", "--cached", "--quiet"); + // Exit 0 = nothing staged; nothing to commit. Still push the + // (possibly new) branch ref so the assertions see it. + } catch { + git(work, "commit", "-m", "deploy foo"); + } + git(work, "push", "--porcelain", "-u", "origin", "HEAD"); + }; + // --- Case A: use_individual_branch=true -> wm_deploy branch, main untouched --- const workA = await mkdtemp(join(tmpdir(), "wmill_promo_a_")); git(workA, "clone", `file://${bareDir}`, "."); @@ -141,6 +159,7 @@ test.skipIf(shouldSkipOnCI())( workA, ); expect(resA.code).toBe(0); + commitAndPush(workA); const branchesA = remoteBranches(bareDir); const expectedBranch = `refs/heads/wm_deploy/${ws}/script/f__promo__foo`; @@ -167,6 +186,8 @@ test.skipIf(shouldSkipOnCI())( workB, ); expect(resB.code).toBe(0); + commitAndPush(workB); + expect(remoteHead(bareDir, "main")).not.toBe(seedMain); expect( remoteBranches(bareDir).filter((b) => b.includes("wm_deploy")).length, From bd153434f4f443cac554b39f5df90325da2c9b73 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 22 May 2026 07:57:52 +0000 Subject: [PATCH 21/71] bump git sync to 28236 --- backend/windmill-common/src/workspaces.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index 04461e821e..e81a24817f 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -157,7 +157,7 @@ pub enum ObjectType { WorkspaceDependencies, } -pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28234/sync-script-to-git-repo-windmill"; +pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28236/sync-script-to-git-repo-windmill"; /// Prefix used to identify fork workspaces. A workspace whose id starts with this string is a /// fork of another workspace. From 82722449e79da0b4b0ad4142aec7e7965e9ff236 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 22 May 2026 08:03:59 +0000 Subject: [PATCH 22/71] fix: fork compare visibility for non-admins and stale-token superadmins (#9283) * fix: use fork-scoped authed for fork visibility in compare_workspaces * test: add EE end-to-end repro for fork rename visibility * chore: restore concurrency_locks sqlx cache lost in cleanup * test: add regression for stale-superadmin-token fork visibility bug * chore: update sqlx cache for new test queries --- ...364503371a5272d88e970b95760555ab33ce2.json | 14 + ...ef6a9ed6d29a02bb7fd08f2ceb0551ecac640.json | 14 + ...9d081edf45fef34f630372815ec323544acee.json | 14 + ...e60a7a4f53c3b7ed6592f70ef4fed97b2bc6d.json | 20 + ...821e9f568b1976ea462a09e779e9a4c490197.json | 12 + ...2e570bdfaf885b4f39ef3ab973256e0448ec7.json | 35 ++ ...7c731317199b28694a9c3e04028ef1f1d7500.json | 12 + ...b21a3566667ec1a9463c7464c5cd788d89270.json | 12 + ...78991315969faf7b845c93678c7ef63b8b8cb.json | 12 + ...f2d3fbbe0a140cddad3762e0b4147eb84c0b4.json | 14 + ...ec86cd9f3b485a1bacccbd2e5ac5266ac6f01.json | 12 + ...6fb7fe3573e3aa71441d04b7cd7bba685b371.json | 14 + ...c15271cbba1601c589a6e0f3399875438aae9.json | 12 + ...a472a4f9ae49e9c2e601c41cd7229ecde765a.json | 12 + .../tests/workspace_comparison.rs | 476 ++++++++++++++++++ .../windmill-api-workspaces/src/workspaces.rs | 115 ++++- 16 files changed, 795 insertions(+), 5 deletions(-) create mode 100644 backend/.sqlx/query-2e35598cb9695b726ee1d2cd5c8364503371a5272d88e970b95760555ab33ce2.json create mode 100644 backend/.sqlx/query-31445efb75a7b706f4404c411a4ef6a9ed6d29a02bb7fd08f2ceb0551ecac640.json create mode 100644 backend/.sqlx/query-31e486e3377e79bfab4e391d6789d081edf45fef34f630372815ec323544acee.json create mode 100644 backend/.sqlx/query-3fac8694f59803a42b635ce7dd1e60a7a4f53c3b7ed6592f70ef4fed97b2bc6d.json create mode 100644 backend/.sqlx/query-652637b534f7d7b4c429a201247821e9f568b1976ea462a09e779e9a4c490197.json create mode 100644 backend/.sqlx/query-8d64e61fad7bdf0cc4d4cad1a032e570bdfaf885b4f39ef3ab973256e0448ec7.json create mode 100644 backend/.sqlx/query-8eb5866b6279cb386bbeb7c387a7c731317199b28694a9c3e04028ef1f1d7500.json create mode 100644 backend/.sqlx/query-903c01dbda5996417a81f7fd76fb21a3566667ec1a9463c7464c5cd788d89270.json create mode 100644 backend/.sqlx/query-90c765e384170c2e9f9bc244c7578991315969faf7b845c93678c7ef63b8b8cb.json create mode 100644 backend/.sqlx/query-a420ea939b0bfe58b89f29c9eacf2d3fbbe0a140cddad3762e0b4147eb84c0b4.json create mode 100644 backend/.sqlx/query-b8cf0655ecb679c8437ea897a28ec86cd9f3b485a1bacccbd2e5ac5266ac6f01.json create mode 100644 backend/.sqlx/query-d94636ff736f9cfefb3c001acab6fb7fe3573e3aa71441d04b7cd7bba685b371.json create mode 100644 backend/.sqlx/query-e48bf61e59268f95ec389ef30eac15271cbba1601c589a6e0f3399875438aae9.json create mode 100644 backend/.sqlx/query-f83cf3c87a1e80d4a0a7f236c4fa472a4f9ae49e9c2e601c41cd7229ecde765a.json diff --git a/backend/.sqlx/query-2e35598cb9695b726ee1d2cd5c8364503371a5272d88e970b95760555ab33ce2.json b/backend/.sqlx/query-2e35598cb9695b726ee1d2cd5c8364503371a5272d88e970b95760555ab33ce2.json new file mode 100644 index 0000000000..2ca417b902 --- /dev/null +++ b/backend/.sqlx/query-2e35598cb9695b726ee1d2cd5c8364503371a5272d88e970b95760555ab33ce2.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO script (workspace_id, path, hash, content, summary, description, language, created_by, created_at, archived, schema_validation, ws_error_handler_muted, deleted, extra_perms)\n VALUES ('wm-fork-stale-super', 'f/folder2/myscript', 333333, 'echo 1', '', '', 'bash', 'test-user-2', NOW(), false, false, false, false, $1)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "2e35598cb9695b726ee1d2cd5c8364503371a5272d88e970b95760555ab33ce2" +} diff --git a/backend/.sqlx/query-31445efb75a7b706f4404c411a4ef6a9ed6d29a02bb7fd08f2ceb0551ecac640.json b/backend/.sqlx/query-31445efb75a7b706f4404c411a4ef6a9ed6d29a02bb7fd08f2ceb0551ecac640.json new file mode 100644 index 0000000000..b7a32466b0 --- /dev/null +++ b/backend/.sqlx/query-31445efb75a7b706f4404c411a4ef6a9ed6d29a02bb7fd08f2ceb0551ecac640.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, summary, created_by)\n VALUES ('wm-fork-visibility-test', 'folder2', 'folder2', ARRAY['u/test-user-2']::varchar[], $1, '', 'test-user-2')", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "31445efb75a7b706f4404c411a4ef6a9ed6d29a02bb7fd08f2ceb0551ecac640" +} diff --git a/backend/.sqlx/query-31e486e3377e79bfab4e391d6789d081edf45fef34f630372815ec323544acee.json b/backend/.sqlx/query-31e486e3377e79bfab4e391d6789d081edf45fef34f630372815ec323544acee.json new file mode 100644 index 0000000000..9c71de3e01 --- /dev/null +++ b/backend/.sqlx/query-31e486e3377e79bfab4e391d6789d081edf45fef34f630372815ec323544acee.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, summary, created_by)\n VALUES ('test-workspace', 'folder1', 'folder1', ARRAY['u/test-user-2']::varchar[], $1, '', 'test-user-2')", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "31e486e3377e79bfab4e391d6789d081edf45fef34f630372815ec323544acee" +} diff --git a/backend/.sqlx/query-3fac8694f59803a42b635ce7dd1e60a7a4f53c3b7ed6592f70ef4fed97b2bc6d.json b/backend/.sqlx/query-3fac8694f59803a42b635ce7dd1e60a7a4f53c3b7ed6592f70ef4fed97b2bc6d.json new file mode 100644 index 0000000000..be9274cf37 --- /dev/null +++ b/backend/.sqlx/query-3fac8694f59803a42b635ce7dd1e60a7a4f53c3b7ed6592f70ef4fed97b2bc6d.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) AS \"count!\" FROM workspace_diff\n WHERE source_workspace_id = 'test-workspace'\n AND fork_workspace_id = 'wm-fork-rename-test'\n AND kind = 'script'\n AND path = 'f/folder2/myscript'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null + ] + }, + "hash": "3fac8694f59803a42b635ce7dd1e60a7a4f53c3b7ed6592f70ef4fed97b2bc6d" +} diff --git a/backend/.sqlx/query-652637b534f7d7b4c429a201247821e9f568b1976ea462a09e779e9a4c490197.json b/backend/.sqlx/query-652637b534f7d7b4c429a201247821e9f568b1976ea462a09e779e9a4c490197.json new file mode 100644 index 0000000000..7420f30c6e --- /dev/null +++ b/backend/.sqlx/query-652637b534f7d7b4c429a201247821e9f568b1976ea462a09e779e9a4c490197.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO usr (workspace_id, email, username, is_admin, role) VALUES\n ('wm-fork-visibility-test', 'test2@windmill.dev', 'test-user-2', false, 'User')", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "652637b534f7d7b4c429a201247821e9f568b1976ea462a09e779e9a4c490197" +} diff --git a/backend/.sqlx/query-8d64e61fad7bdf0cc4d4cad1a032e570bdfaf885b4f39ef3ab973256e0448ec7.json b/backend/.sqlx/query-8d64e61fad7bdf0cc4d4cad1a032e570bdfaf885b4f39ef3ab973256e0448ec7.json new file mode 100644 index 0000000000..1a23b0f877 --- /dev/null +++ b/backend/.sqlx/query-8d64e61fad7bdf0cc4d4cad1a032e570bdfaf885b4f39ef3ab973256e0448ec7.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT username, is_admin, operator FROM usr\n WHERE workspace_id = $1 AND email = $2 AND disabled = false", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "username", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "is_admin", + "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "operator", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "8d64e61fad7bdf0cc4d4cad1a032e570bdfaf885b4f39ef3ab973256e0448ec7" +} diff --git a/backend/.sqlx/query-8eb5866b6279cb386bbeb7c387a7c731317199b28694a9c3e04028ef1f1d7500.json b/backend/.sqlx/query-8eb5866b6279cb386bbeb7c387a7c731317199b28694a9c3e04028ef1f1d7500.json new file mode 100644 index 0000000000..0961786e35 --- /dev/null +++ b/backend/.sqlx/query-8eb5866b6279cb386bbeb7c387a7c731317199b28694a9c3e04028ef1f1d7500.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM skip_workspace_diff_tally", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "8eb5866b6279cb386bbeb7c387a7c731317199b28694a9c3e04028ef1f1d7500" +} diff --git a/backend/.sqlx/query-903c01dbda5996417a81f7fd76fb21a3566667ec1a9463c7464c5cd788d89270.json b/backend/.sqlx/query-903c01dbda5996417a81f7fd76fb21a3566667ec1a9463c7464c5cd788d89270.json new file mode 100644 index 0000000000..be765afaae --- /dev/null +++ b/backend/.sqlx/query-903c01dbda5996417a81f7fd76fb21a3566667ec1a9463c7464c5cd788d89270.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO workspace_diff\n (source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes)\n VALUES ('test-workspace', 'wm-fork-visibility-test', 'f/folder2/myscript', 'script', 1, 0, NULL)", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "903c01dbda5996417a81f7fd76fb21a3566667ec1a9463c7464c5cd788d89270" +} diff --git a/backend/.sqlx/query-90c765e384170c2e9f9bc244c7578991315969faf7b845c93678c7ef63b8b8cb.json b/backend/.sqlx/query-90c765e384170c2e9f9bc244c7578991315969faf7b845c93678c7ef63b8b8cb.json new file mode 100644 index 0000000000..1c560ac187 --- /dev/null +++ b/backend/.sqlx/query-90c765e384170c2e9f9bc244c7578991315969faf7b845c93678c7ef63b8b8cb.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM skip_workspace_diff_tally WHERE workspace_id IN ('test-workspace', 'wm-fork-visibility-test')", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "90c765e384170c2e9f9bc244c7578991315969faf7b845c93678c7ef63b8b8cb" +} diff --git a/backend/.sqlx/query-a420ea939b0bfe58b89f29c9eacf2d3fbbe0a140cddad3762e0b4147eb84c0b4.json b/backend/.sqlx/query-a420ea939b0bfe58b89f29c9eacf2d3fbbe0a140cddad3762e0b4147eb84c0b4.json new file mode 100644 index 0000000000..58022cbc43 --- /dev/null +++ b/backend/.sqlx/query-a420ea939b0bfe58b89f29c9eacf2d3fbbe0a140cddad3762e0b4147eb84c0b4.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, summary, created_by)\n VALUES ('wm-fork-stale-super', 'folder2', 'folder2', ARRAY['u/test-user-2']::varchar[], $1, '', 'test-user-2')", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "a420ea939b0bfe58b89f29c9eacf2d3fbbe0a140cddad3762e0b4147eb84c0b4" +} diff --git a/backend/.sqlx/query-b8cf0655ecb679c8437ea897a28ec86cd9f3b485a1bacccbd2e5ac5266ac6f01.json b/backend/.sqlx/query-b8cf0655ecb679c8437ea897a28ec86cd9f3b485a1bacccbd2e5ac5266ac6f01.json new file mode 100644 index 0000000000..7d84982484 --- /dev/null +++ b/backend/.sqlx/query-b8cf0655ecb679c8437ea897a28ec86cd9f3b485a1bacccbd2e5ac5266ac6f01.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO usr (workspace_id, email, username, is_admin, role)\n VALUES ('wm-fork-rename-test', 'test2@windmill.dev', 'test-user-2', false, 'User')", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "b8cf0655ecb679c8437ea897a28ec86cd9f3b485a1bacccbd2e5ac5266ac6f01" +} diff --git a/backend/.sqlx/query-d94636ff736f9cfefb3c001acab6fb7fe3573e3aa71441d04b7cd7bba685b371.json b/backend/.sqlx/query-d94636ff736f9cfefb3c001acab6fb7fe3573e3aa71441d04b7cd7bba685b371.json new file mode 100644 index 0000000000..9e15a5445d --- /dev/null +++ b/backend/.sqlx/query-d94636ff736f9cfefb3c001acab6fb7fe3573e3aa71441d04b7cd7bba685b371.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO script (workspace_id, path, hash, content, summary, description, language, created_by, created_at, archived, schema_validation, ws_error_handler_muted, deleted, extra_perms)\n VALUES ('wm-fork-visibility-test', 'f/folder2/myscript', 222222, 'def main():\n return 1', '', '', 'python3', 'test-user-2', NOW(), false, false, false, false, $1)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "d94636ff736f9cfefb3c001acab6fb7fe3573e3aa71441d04b7cd7bba685b371" +} diff --git a/backend/.sqlx/query-e48bf61e59268f95ec389ef30eac15271cbba1601c589a6e0f3399875438aae9.json b/backend/.sqlx/query-e48bf61e59268f95ec389ef30eac15271cbba1601c589a6e0f3399875438aae9.json new file mode 100644 index 0000000000..1cb5e8166c --- /dev/null +++ b/backend/.sqlx/query-e48bf61e59268f95ec389ef30eac15271cbba1601c589a6e0f3399875438aae9.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE password SET super_admin = true WHERE email = 'test2@windmill.dev'", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "e48bf61e59268f95ec389ef30eac15271cbba1601c589a6e0f3399875438aae9" +} diff --git a/backend/.sqlx/query-f83cf3c87a1e80d4a0a7f236c4fa472a4f9ae49e9c2e601c41cd7229ecde765a.json b/backend/.sqlx/query-f83cf3c87a1e80d4a0a7f236c4fa472a4f9ae49e9c2e601c41cd7229ecde765a.json new file mode 100644 index 0000000000..f2aff2c94d --- /dev/null +++ b/backend/.sqlx/query-f83cf3c87a1e80d4a0a7f236c4fa472a4f9ae49e9c2e601c41cd7229ecde765a.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO workspace_diff\n (source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes)\n VALUES ('test-workspace', 'wm-fork-stale-super', 'f/folder2/myscript', 'script', 1, 0, NULL)", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "f83cf3c87a1e80d4a0a7f236c4fa472a4f9ae49e9c2e601c41cd7229ecde765a" +} diff --git a/backend/windmill-api-integration-tests/tests/workspace_comparison.rs b/backend/windmill-api-integration-tests/tests/workspace_comparison.rs index dc4341cc26..dccc4048e4 100644 --- a/backend/windmill-api-integration-tests/tests/workspace_comparison.rs +++ b/backend/windmill-api-integration-tests/tests/workspace_comparison.rs @@ -797,3 +797,479 @@ async fn test_compare_workspaces_trigger_and_schedule(db: Pool) -> any Ok(()) } + +/// Regression for the "superadmin-still-sees-the-warning" case in WIN-1975. +/// +/// `compare_workspaces` historically trusted `authed.is_admin` for RLS — but +/// that flag is derived from the *token's* cached `super_admin` column at +/// auth time (windmill-api-auth/src/auth.rs), not from a live +/// `password.super_admin` read. A user who is *currently* an instance +/// superadmin can have a token from before the promotion (or via a session +/// refresh race) where `token.super_admin = false`. If they're also not a +/// workspace admin in the source workspace (only in the fork), +/// `authed.is_admin` lands as `false` and source-scoped RLS gets applied to +/// fork-side visibility queries — same bug as the regular non-admin case. +/// +/// With the fix, `load_workspace_authed` re-checks `is_super_admin_email` +/// against `password.super_admin` at request time, so the fork-scoped authed +/// gets `is_admin = true` and RLS bypass kicks back in for the fork queries. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_compare_workspaces_stale_superadmin_token(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base_url = format!("http://localhost:{port}/api"); + + // Promote test-user-2 to instance superadmin AFTER their token was issued + // (base.sql inserts SECRET_TOKEN_2 with super_admin=false). The token row + // keeps super_admin=false; password.super_admin flips to true. + sqlx::query!("UPDATE password SET super_admin = true WHERE email = 'test2@windmill.dev'") + .execute(&db) + .await?; + + let stale_super = windmill_api_client::create_client( + &format!("http://localhost:{port}"), + "SECRET_TOKEN_2".to_string(), + ); + + // Fork test-workspace. + let resp = stale_super + .client() + .post(&format!( + "{base_url}/w/test-workspace/workspaces/create_fork" + )) + .json(&json!({ + "id": "wm-fork-stale-super", + "name": "Stale Super Fork", + "color": "#0000ff" + })) + .send() + .await?; + assert!( + resp.status().is_success(), + "fork creation failed: {} — {}", + resp.status(), + resp.text().await? + ); + + // Fork-only folder + script, with empty extra_perms so the only way to + // see them is via fork's folder-based RLS or admin bypass. + sqlx::query!( + "INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, summary, created_by) + VALUES ('wm-fork-stale-super', 'folder2', 'folder2', ARRAY['u/test-user-2']::varchar[], $1, '', 'test-user-2')", + json!({"u/test-user-2": true}) + ) + .execute(&db) + .await?; + sqlx::query!( + "INSERT INTO script (workspace_id, path, hash, content, summary, description, language, created_by, created_at, archived, schema_validation, ws_error_handler_muted, deleted, extra_perms) + VALUES ('wm-fork-stale-super', 'f/folder2/myscript', 333333, 'echo 1', '', '', 'bash', 'test-user-2', NOW(), false, false, false, false, $1)", + json!({}) + ) + .execute(&db) + .await?; + sqlx::query!( + "INSERT INTO workspace_diff + (source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes) + VALUES ('test-workspace', 'wm-fork-stale-super', 'f/folder2/myscript', 'script', 1, 0, NULL)" + ) + .execute(&db) + .await?; + sqlx::query!("DELETE FROM skip_workspace_diff_tally") + .execute(&db) + .await?; + + let comparison: serde_json::Value = stale_super + .client() + .get(&format!( + "{base_url}/w/test-workspace/workspaces/compare/wm-fork-stale-super" + )) + .send() + .await? + .json() + .await?; + + assert_eq!( + comparison["all_ahead_items_visible"].as_bool(), + Some(true), + "current superadmin with stale token should still see ahead items: {comparison}" + ); + let diffs = comparison["diffs"].as_array().unwrap(); + assert!( + diffs + .iter() + .any(|d| d["path"] == "f/folder2/myscript" && d["kind"] == "script"), + "fork-only script should appear in diffs; got {diffs:?}" + ); + + Ok(()) +} + +/// End-to-end regression for WIN-1975 against the real EE tally path. +/// Reproduces the reporter's exact steps with the API: fork → create script +/// in folder1 → rename to folder2 → compare. Folder2 only exists in the +/// fork, so before the fix the source-scoped authed in `filter_visible_diffs` +/// hid the script and the response set `all_ahead_items_visible = false`. +/// +/// Gated on `private` because the OSS build of `handle_deployment_metadata` +/// is a no-op (`windmill-git-sync/src/git_sync_oss.rs`) — without it the +/// `workspace_diff` rows never get written and the test would assert against +/// an empty diff set. +#[cfg(feature = "private")] +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_compare_workspaces_rename_visibility_ee_e2e( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base_url = format!("http://localhost:{port}/api"); + let admin = windmill_api_client::create_client( + &format!("http://localhost:{port}"), + "SECRET_TOKEN".to_string(), + ); + let non_admin = windmill_api_client::create_client( + &format!("http://localhost:{port}"), + "SECRET_TOKEN_2".to_string(), + ); + + // The base fixture pre-populates `skip_workspace_diff_tally` for every + // workspace existing at migration time — that bypasses the diff + // accounting. Clear it so tally + compare run normally for this test. + sqlx::query!("DELETE FROM skip_workspace_diff_tally") + .execute(&db) + .await?; + + // ------ Fork the existing test-workspace. + let resp = admin + .client() + .post(&format!( + "{base_url}/w/test-workspace/workspaces/create_fork" + )) + .json(&json!({ + "id": "wm-fork-rename-test", + "name": "Rename Fork", + "color": "#0000ff" + })) + .send() + .await?; + assert!( + resp.status().is_success(), + "fork creation failed: {}", + resp.status() + ); + + // Non-admin user must be a member of both workspaces. They already are in + // test-workspace (base fixture); add them to the fork. Same username as + // the source so RLS extra_perms keys still resolve. + sqlx::query!( + "INSERT INTO usr (workspace_id, email, username, is_admin, role) + VALUES ('wm-fork-rename-test', 'test2@windmill.dev', 'test-user-2', false, 'User')" + ) + .execute(&db) + .await?; + + // ------ Non-admin creates folder1 in the fork (owner = self). + let resp = non_admin + .client() + .post(&format!("{base_url}/w/wm-fork-rename-test/folders/create")) + .json(&json!({"name": "folder1", "owners": [], "summary": ""})) + .send() + .await?; + assert!( + resp.status().is_success(), + "folder1 create failed: {} — {}", + resp.status(), + resp.text().await? + ); + + // ------ Deploy a script in folder1 (initial deploy, no parent_hash). + let resp = non_admin + .client() + .post(&format!("{base_url}/w/wm-fork-rename-test/scripts/create")) + .json(&json!({ + "path": "f/folder1/myscript", + "summary": "renamed test", + "description": "", + // Use bash so we don't trigger the dependency-job code path — + // create_script defers `handle_deployment_metadata` (and the + // tally) to the dep job for languages that need lock generation + // (Deno/Bun/Python/etc), which never runs in this test. + "content": "echo 1", + "language": "bash", + "schema": {"type": "object", "properties": {}, "required": []}, + "deployment_message": "initial", + })) + .send() + .await?; + let status = resp.status(); + let initial_hash = resp.text().await?; + assert!( + status.is_success(), + "initial script create failed: {} — {}", + status, + initial_hash + ); + + // ------ Create folder2 in fork. + let resp = non_admin + .client() + .post(&format!("{base_url}/w/wm-fork-rename-test/folders/create")) + .json(&json!({"name": "folder2", "owners": [], "summary": ""})) + .send() + .await?; + assert!( + resp.status().is_success(), + "folder2 create failed: {}", + resp.status() + ); + + // ------ Rename: re-deploy the same script at the new path with the old + // hash as parent_hash. This is exactly what the script editor sends when + // the user changes the path field and clicks Deploy. The EE tally upserts + // a workspace_diff row for both the new path AND the renamed_from path. + let resp = non_admin + .client() + .post(&format!("{base_url}/w/wm-fork-rename-test/scripts/create")) + .json(&json!({ + "path": "f/folder2/myscript", + "summary": "renamed test", + "description": "", + // Use bash so we don't trigger the dependency-job code path — + // create_script defers `handle_deployment_metadata` (and the + // tally) to the dep job for languages that need lock generation + // (Deno/Bun/Python/etc), which never runs in this test. + "content": "echo 1", + "language": "bash", + "schema": {"type": "object", "properties": {}, "required": []}, + // The API returns hash as hex (ScriptHash Serialize impl); pass it + // through verbatim — the backend deserializer parses hex back. + "parent_hash": initial_hash.trim().trim_matches('"'), + "deployment_message": "rename to folder2", + })) + .send() + .await?; + assert!( + resp.status().is_success(), + "rename failed: {} — {}", + resp.status(), + resp.text().await? + ); + + // The tally is fired via `tokio::spawn` in `handle_deployment_metadata` + // (windmill-git-sync/src/git_sync_ee.rs) — wait specifically for the + // renamed script row to appear so we don't race the actual case under + // test. + let mut script_diff_written = false; + for _ in 0..40 { + let row_count: i64 = sqlx::query_scalar!( + "SELECT COUNT(*) AS \"count!\" FROM workspace_diff + WHERE source_workspace_id = 'test-workspace' + AND fork_workspace_id = 'wm-fork-rename-test' + AND kind = 'script' + AND path = 'f/folder2/myscript'" + ) + .fetch_one(&db) + .await?; + if row_count >= 1 { + script_diff_written = true; + break; + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + assert!( + script_diff_written, + "tally never wrote the renamed-script row to workspace_diff" + ); + + // ------ Compare as the non-admin who owns folder2 in the fork. With the + // bug, the source-scoped authed has no folder2 entry → fork visibility + // query hides f/folder2/myscript → all_ahead_items_visible flips to + // false. With the fix, the fork-scoped authed sees folder2 and the + // visibility check passes. + let comparison: serde_json::Value = non_admin + .client() + .get(&format!( + "{base_url}/w/test-workspace/workspaces/compare/wm-fork-rename-test" + )) + .send() + .await? + .json() + .await?; + + assert_eq!( + comparison["all_ahead_items_visible"].as_bool(), + Some(true), + "non-admin owner of fork-only folder should see ahead items as visible; got {comparison}" + ); + + let diffs = comparison["diffs"].as_array().unwrap(); + assert!( + diffs + .iter() + .any(|d| d["path"] == "f/folder2/myscript" && d["kind"] == "script"), + "renamed script at f/folder2/myscript should appear in diffs; got {diffs:?}" + ); + // The renamed_from row (f/folder1/myscript) must NOT appear: both sides' + // archived=false views show it missing, so compare_two_scripts returns + // has_changes=false and the row is deleted. Keep an explicit assertion + // so a future regression that leaks the old path is caught here. + assert!( + !diffs + .iter() + .any(|d| d["path"] == "f/folder1/myscript" && d["kind"] == "script"), + "renamed-from path f/folder1/myscript should be cleaned up; got {diffs:?}" + ); + + // ------ Also confirm the superadmin path still works (this used to be + // the only path that worked because RLS bypass masked the bug). + let comparison: serde_json::Value = admin + .client() + .get(&format!( + "{base_url}/w/test-workspace/workspaces/compare/wm-fork-rename-test" + )) + .send() + .await? + .json() + .await?; + assert_eq!( + comparison["all_ahead_items_visible"].as_bool(), + Some(true), + "superadmin must always see all ahead items: {comparison}" + ); + + Ok(()) +} + +/// Regression test for WIN-1975. A non-admin user creating a script in a fork- +/// only folder used to get the spurious +/// "this fork has changes not visible to your user" warning because +/// `filter_visible_diffs` ran every RLS query with the source-workspace +/// authed, so any item only reachable via fork-specific folders/groups was +/// hidden from the visibility check. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_compare_workspaces_fork_only_folder_visibility( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let client_user_2 = windmill_api_client::create_client( + &format!("http://localhost:{port}"), + "SECRET_TOKEN_2".to_string(), + ); + let base_url = format!("http://localhost:{port}/api"); + + // ----- Set up parent workspace folder1 owned by test-user-2, then fork it. + sqlx::query!( + "INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, summary, created_by) + VALUES ('test-workspace', 'folder1', 'folder1', ARRAY['u/test-user-2']::varchar[], $1, '', 'test-user-2')", + json!({"u/test-user-2": true}) + ) + .execute(&db) + .await?; + + // Create fork via the API so cloning + workspace_settings.deploy_to wiring + // matches what production sees. + let client_admin = windmill_api_client::create_client( + &format!("http://localhost:{port}"), + "SECRET_TOKEN".to_string(), + ); + let fork_response = client_admin + .client() + .post(&format!( + "{base_url}/w/test-workspace/workspaces/create_fork" + )) + .json(&json!({ + "id": "wm-fork-visibility-test", + "name": "Test Fork", + "color": "#0000ff" + })) + .send() + .await?; + assert!( + fork_response.status().is_success(), + "Fork creation failed: {}", + fork_response.status() + ); + + // test-user-2 must be a member of the fork. The fork's clone copies the + // creator's usr row only — add test-user-2 manually so they can hit the + // compare endpoint and own a fork-only folder. + sqlx::query!( + "INSERT INTO usr (workspace_id, email, username, is_admin, role) VALUES + ('wm-fork-visibility-test', 'test2@windmill.dev', 'test-user-2', false, 'User')" + ) + .execute(&db) + .await?; + + // ----- Fork-only folder2 (does not exist in source) owned by test-user-2. + sqlx::query!( + "INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, summary, created_by) + VALUES ('wm-fork-visibility-test', 'folder2', 'folder2', ARRAY['u/test-user-2']::varchar[], $1, '', 'test-user-2')", + json!({"u/test-user-2": true}) + ) + .execute(&db) + .await?; + + // Script in the fork-only folder with empty extra_perms (typical: scripts + // inherit access through their containing folder, not direct perms). + sqlx::query!( + "INSERT INTO script (workspace_id, path, hash, content, summary, description, language, created_by, created_at, archived, schema_validation, ws_error_handler_muted, deleted, extra_perms) + VALUES ('wm-fork-visibility-test', 'f/folder2/myscript', 222222, 'def main():\n return 1', '', '', 'python3', 'test-user-2', NOW(), false, false, false, false, $1)", + json!({}) + ) + .execute(&db) + .await?; + + // Seed workspace_diff to mirror what the tally would write. + sqlx::query!( + "INSERT INTO workspace_diff + (source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes) + VALUES ('test-workspace', 'wm-fork-visibility-test', 'f/folder2/myscript', 'script', 1, 0, NULL)" + ) + .execute(&db) + .await?; + + // Clear the skip flag added by the bootstrap migration so compare actually + // runs against this fork (it short-circuits otherwise). + sqlx::query!( + "DELETE FROM skip_workspace_diff_tally WHERE workspace_id IN ('test-workspace', 'wm-fork-visibility-test')" + ) + .execute(&db) + .await?; + + let comparison: serde_json::Value = client_user_2 + .client() + .get(&format!( + "{base_url}/w/test-workspace/workspaces/compare/wm-fork-visibility-test" + )) + .send() + .await? + .json() + .await?; + + assert_eq!( + comparison["all_ahead_items_visible"].as_bool(), + Some(true), + "ahead items should be visible to the fork-only folder owner; full response: {comparison}" + ); + assert_eq!( + comparison["all_behind_items_visible"].as_bool(), + Some(true), + "behind items should be visible (no behind items here)" + ); + + let diffs = comparison["diffs"].as_array().unwrap(); + assert!( + diffs + .iter() + .any(|d| d["path"] == "f/folder2/myscript" && d["kind"] == "script"), + "fork-only script should appear in diffs; got {diffs:?}" + ); + + Ok(()) +} diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 95c7157d61..3c99548628 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -6372,11 +6372,23 @@ async fn compare_workspaces( } } + // The authed in `authed` is loaded for the source workspace (the one in the + // URL path). Its `folders`/`groups`/`is_admin` reflect membership in the + // source workspace only. Using it as the RLS context when querying the + // fork's tables would hide items the user can only see via fork-specific + // permissions (e.g. a folder the user owns in the fork but that does not + // exist in the source), causing the spurious + // "this fork has changes not visible to your user" warning. Build a + // matching authed for the fork so each side's visibility check uses the + // right RLS context. + let fork_authed = load_workspace_authed(&db, &authed, &fork_workspace_id).await?; let visible_diffs = filter_visible_diffs( &confirmed_diffs, &source_workspace_id, &fork_workspace_id, - user_db.begin(&authed).await?, + &authed, + &fork_authed, + &user_db, ) .await?; @@ -6443,11 +6455,90 @@ async fn compare_workspaces( })); } +/// Build an `ApiAuthed` for the same user but scoped to a different workspace. +/// +/// Reloads `is_admin`, `groups`, and `folders` from the target workspace's +/// `usr` / `group_` / `folder` tables (keyed by the caller's email) so the +/// returned authed can be used as the RLS context for queries against that +/// workspace. `is_admin` is OR'd with the user's superadmin status so cross- +/// workspace superadmins keep their RLS bypass. +/// +/// If the user is not a member of `workspace_id`, returns an authed with no +/// folders/groups/operator/admin (except for superadmins, who stay admin) — +/// i.e. they will only see what RLS explicitly allows for unknown users. +async fn load_workspace_authed( + db: &DB, + base_authed: &ApiAuthed, + workspace_id: &str, +) -> Result { + let mut conn = db + .acquire() + .await + .map_err(|e| Error::internal_err(e.to_string()))?; + + let is_super_admin = + windmill_common::auth::is_super_admin_email(db, &base_authed.email).await?; + + let user_row = sqlx::query!( + "SELECT username, is_admin, operator FROM usr + WHERE workspace_id = $1 AND email = $2 AND disabled = false", + workspace_id, + &base_authed.email + ) + .fetch_optional(&mut *conn) + .await?; + + let Some(user_row) = user_row else { + return Ok(ApiAuthed { + email: base_authed.email.clone(), + username: base_authed.username.clone(), + is_admin: is_super_admin, + is_operator: false, + groups: vec![], + folders: vec![], + scopes: base_authed.scopes.clone(), + username_override: base_authed.username_override.clone(), + token_prefix: base_authed.token_prefix.clone(), + read_only: base_authed.read_only, + }); + }; + + let groups = windmill_common::auth::get_groups_for_user( + workspace_id, + &user_row.username, + &base_authed.email, + &mut *conn, + ) + .await?; + let folders = windmill_common::auth::get_folders_for_user( + workspace_id, + &user_row.username, + &groups, + &mut *conn, + ) + .await?; + + Ok(ApiAuthed { + email: base_authed.email.clone(), + username: user_row.username, + is_admin: is_super_admin || user_row.is_admin, + is_operator: user_row.operator, + groups, + folders, + scopes: base_authed.scopes.clone(), + username_override: base_authed.username_override.clone(), + token_prefix: base_authed.token_prefix.clone(), + read_only: base_authed.read_only, + }) +} + async fn filter_visible_diffs( confirmed_diffs: &[WorkspaceDiffRow], source_workspace_id: &str, fork_workspace_id: &str, - mut tx: Transaction<'static, Postgres>, + source_authed: &ApiAuthed, + fork_authed: &ApiAuthed, + user_db: &UserDB, ) -> Result> { // Step 1: Group paths by (workspace, kind) let mut source_items: HashMap<&str, Vec<&str>> = HashMap::new(); @@ -6462,9 +6553,23 @@ async fn filter_visible_diffs( } } - // Step 2: Batch query for each (workspace, kind) combination - let source_visible = query_visible_items(&mut tx, source_workspace_id, &source_items).await?; - let fork_visible = query_visible_items(&mut tx, fork_workspace_id, &fork_items).await?; + // Step 2: Batch query for each (workspace, kind) combination, each in its + // own transaction so RLS uses the right authed for each side. The fork's + // authed picks up fork-only folders/groups; without this split the fork + // queries would run with the source workspace's permissions and miss any + // item the user can only reach through fork-specific permissions. + let source_visible = { + let mut tx = user_db.clone().begin(source_authed).await?; + let visible = query_visible_items(&mut tx, source_workspace_id, &source_items).await?; + tx.commit().await?; + visible + }; + let fork_visible = { + let mut tx = user_db.clone().begin(fork_authed).await?; + let visible = query_visible_items(&mut tx, fork_workspace_id, &fork_items).await?; + tx.commit().await?; + visible + }; // Step 3: Filter diffs based on visibility let visible_diffs: Vec = confirmed_diffs From 9b218dc4058a45dbb98d6eaff59a76ba70a41c6f Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 22 May 2026 08:26:25 +0000 Subject: [PATCH 23/71] chore(main): release 1.706.1 (#9281) * chore(main): release 1.706.1 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- CHANGELOG.md | 9 + backend/Cargo.lock | 160 +++++++++--------- backend/Cargo.toml | 4 +- .../parsers/windmill-parser-wasm/Cargo.lock | 48 +++--- .../parsers/windmill-parser-wasm/Cargo.toml | 2 +- backend/windmill-api/openapi.yaml | 2 +- benchmarks/lib.ts | 2 +- cli/src/main.ts | 2 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- lsp/Pipfile | 2 +- openflow.openapi.yaml | 2 +- .../WindmillClient/WindmillClient.psd1 | 2 +- python-client/wmill/pyproject.toml | 2 +- typescript-client/jsr.json | 2 +- typescript-client/package.json | 2 +- version.txt | 2 +- 17 files changed, 129 insertions(+), 120 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5272d8ec38..5766e7cd05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## [1.706.1](https://github.com/windmill-labs/windmill/compare/v1.706.0...v1.706.1) (2026-05-22) + + +### Bug Fixes + +* fork compare visibility for non-admins and stale-token superadmins ([#9283](https://github.com/windmill-labs/windmill/issues/9283)) ([8272244](https://github.com/windmill-labs/windmill/commit/82722449e79da0b4b0ad4142aec7e7965e9ff236)) +* **git-sync:** bump to hub/28234 with stateless gpg.program wrapper (WIN-1974) ([#9282](https://github.com/windmill-labs/windmill/issues/9282)) ([89a2f07](https://github.com/windmill-labs/windmill/commit/89a2f07218818b95238b4a4484deab3138099672)) +* **nsjail:** gate unix-symlink test behind cfg(unix) for Windows build ([#9280](https://github.com/windmill-labs/windmill/issues/9280)) ([72e2c3a](https://github.com/windmill-labs/windmill/commit/72e2c3a6b3e0cb0f5bddf8291ae18bb8cf55ec28)) + ## [1.706.0](https://github.com/windmill-labs/windmill/compare/v1.705.0...v1.706.0) (2026-05-21) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index b1a804bed3..bcd0c4b860 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -10473,9 +10473,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "indexmap 2.14.0", "itoa", @@ -13788,7 +13788,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "async-nats", @@ -13869,7 +13869,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.706.0" +version = "1.706.1" dependencies = [ "async-stream", "async-trait", @@ -13901,7 +13901,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.706.0" +version = "1.706.1" dependencies = [ "axum 0.8.9", "chrono", @@ -13914,7 +13914,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "argon2", @@ -14057,7 +14057,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.706.0" +version = "1.706.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14080,7 +14080,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.706.0" +version = "1.706.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14093,7 +14093,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -14119,7 +14119,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.706.0" +version = "1.706.1" dependencies = [ "reqwest 0.12.28", "serde", @@ -14129,7 +14129,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.706.0" +version = "1.706.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14146,7 +14146,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.706.0" +version = "1.706.1" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -14168,7 +14168,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -14191,7 +14191,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.706.0" +version = "1.706.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14207,7 +14207,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.706.0" +version = "1.706.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14228,7 +14228,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.706.0" +version = "1.706.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14249,7 +14249,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.706.0" +version = "1.706.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14263,7 +14263,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "async-nats", @@ -14295,7 +14295,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -14320,7 +14320,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.706.0" +version = "1.706.1" dependencies = [ "axum 0.8.9", "flate2", @@ -14338,7 +14338,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -14360,7 +14360,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.706.0" +version = "1.706.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14380,7 +14380,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.706.0" +version = "1.706.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14410,7 +14410,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -14438,7 +14438,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.706.0" +version = "1.706.1" dependencies = [ "lazy_static", "serde", @@ -14450,7 +14450,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.706.0" +version = "1.706.1" dependencies = [ "argon2", "axum 0.8.9", @@ -14475,7 +14475,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.706.0" +version = "1.706.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14489,7 +14489,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.706.0" +version = "1.706.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14522,7 +14522,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.706.0" +version = "1.706.1" dependencies = [ "chrono", "lazy_static", @@ -14536,7 +14536,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -14555,7 +14555,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.706.0" +version = "1.706.1" dependencies = [ "aes-gcm", "aho-corasick", @@ -14656,7 +14656,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.706.0" +version = "1.706.1" dependencies = [ "chrono", "itertools 0.14.0", @@ -14675,7 +14675,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.706.0" +version = "1.706.1" dependencies = [ "regex", "serde", @@ -14690,7 +14690,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "astral-tokio-tar", @@ -14714,7 +14714,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "futures", @@ -14731,7 +14731,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.706.0" +version = "1.706.1" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -14747,7 +14747,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "async-trait", @@ -14768,7 +14768,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "async-trait", @@ -14799,7 +14799,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "arc-swap", @@ -14824,7 +14824,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "async-stream", @@ -14858,7 +14858,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "futures", @@ -14876,7 +14876,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.706.0" +version = "1.706.1" dependencies = [ "convert_case 0.6.0", "serde", @@ -14885,7 +14885,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "lazy_static", @@ -14897,7 +14897,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "serde_json", @@ -14909,7 +14909,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "gosyn", @@ -14921,7 +14921,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "lazy_static", @@ -14933,7 +14933,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "serde_json", @@ -14945,7 +14945,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "nu-parser", @@ -14956,7 +14956,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14967,7 +14967,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14979,7 +14979,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "rustpython-ast", @@ -14990,7 +14990,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "async-recursion", @@ -15012,7 +15012,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "serde_json", @@ -15024,7 +15024,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "lazy_static", @@ -15038,7 +15038,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -15055,7 +15055,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "lazy_static", @@ -15068,7 +15068,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "serde", @@ -15080,7 +15080,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "lazy_static", @@ -15098,7 +15098,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -15114,7 +15114,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "rustpython-ast", @@ -15130,7 +15130,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "serde", @@ -15141,7 +15141,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "async-recursion", @@ -15178,7 +15178,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "const_format", @@ -15216,7 +15216,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.706.0" +version = "1.706.1" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -15227,7 +15227,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "async-recursion", @@ -15257,7 +15257,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "async-trait", @@ -15281,7 +15281,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "async-trait", @@ -15314,7 +15314,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "async-trait", @@ -15347,7 +15347,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "async-trait", @@ -15367,7 +15367,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "async-trait", @@ -15401,7 +15401,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "async-trait", @@ -15437,7 +15437,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "async-trait", @@ -15460,7 +15460,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "async-trait", @@ -15484,7 +15484,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "async-nats", @@ -15508,7 +15508,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "async-trait", @@ -15543,7 +15543,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "async-trait", @@ -15571,7 +15571,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "async-trait", @@ -15594,7 +15594,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "bitflags 2.11.1", @@ -15613,7 +15613,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "async-once-cell", @@ -15723,7 +15723,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.706.0" +version = "1.706.1" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 41115db17e..2e691b6a60 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.706.0" +version = "1.706.1" authors.workspace = true edition.workspace = true @@ -87,7 +87,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"] [workspace.package] -version = "1.706.0" +version = "1.706.1" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index e3beab7f1c..af04b8e170 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6183,7 +6183,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.706.0" +version = "1.706.1" dependencies = [ "aho-corasick", "anyhow", @@ -6263,7 +6263,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.706.0" +version = "1.706.1" dependencies = [ "proc-macro2", "quote", @@ -6275,7 +6275,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.706.0" +version = "1.706.1" dependencies = [ "convert_case", "serde", @@ -6284,7 +6284,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "lazy_static", @@ -6296,7 +6296,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "serde_json", @@ -6308,7 +6308,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "gosyn", @@ -6320,7 +6320,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "lazy_static", @@ -6332,7 +6332,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "serde_json", @@ -6344,7 +6344,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "nu-parser", @@ -6355,7 +6355,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6366,7 +6366,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "rustpython-ast", @@ -6389,7 +6389,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "async-recursion", @@ -6411,7 +6411,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "serde_json", @@ -6423,7 +6423,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "lazy_static", @@ -6437,7 +6437,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "convert_case", @@ -6454,7 +6454,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "lazy_static", @@ -6467,7 +6467,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "serde", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "lazy_static", @@ -6497,7 +6497,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6513,7 +6513,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "rustpython-ast", @@ -6529,7 +6529,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6561,7 +6561,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "serde", @@ -6572,7 +6572,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.706.0" +version = "1.706.1" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index 566f9b1e58..ca8ecfb04d 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.toml +++ b/backend/parsers/windmill-parser-wasm/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" members = ["."] [workspace.package] -version = "1.706.0" +version = "1.706.1" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 46c2639ea4..e8b1459e20 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.706.0 + version: 1.706.1 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 5d0acca54f..57355c67cc 100644 --- a/benchmarks/lib.ts +++ b/benchmarks/lib.ts @@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts"; import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts"; import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts"; -export const VERSION = "v1.706.0"; +export const VERSION = "v1.706.1"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/src/main.ts b/cli/src/main.ts index e807779ed9..170d9e318f 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -87,7 +87,7 @@ export { token, }; -export const VERSION = "1.706.0"; +export const VERSION = "1.706.1"; // Re-exported from constants.ts to maintain backwards compatibility export { WM_FORK_PREFIX } from "./core/constants.ts"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 442771f584..111c653471 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-components", - "version": "1.706.0", + "version": "1.706.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-components", - "version": "1.706.0", + "version": "1.706.1", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 75d3a2d30f..4e7e2fea1a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "windmill-components", - "version": "1.706.0", + "version": "1.706.1", "scripts": { "dev": "vite dev", "build": "vite build", diff --git a/lsp/Pipfile b/lsp/Pipfile index 2018887843..9379176973 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.706.0" +wmill = ">=1.706.1" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index c8594a6aed..5d5e9eca9c 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.706.0 + version: 1.706.1 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index c23a7fbf3f..2c9cda4590 100644 --- a/powershell-client/WindmillClient/WindmillClient.psd1 +++ b/powershell-client/WindmillClient/WindmillClient.psd1 @@ -12,7 +12,7 @@ RootModule = 'WindmillClient.psm1' # Version number of this module. - ModuleVersion = '1.706.0' + ModuleVersion = '1.706.1' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index 4f1ed0e32b..931a5f9a53 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.706.0" +version = "1.706.1" description = "A client library for accessing Windmill server wrapping the Windmill client API" license = "Apache-2.0" homepage = "https://windmill.dev" diff --git a/typescript-client/jsr.json b/typescript-client/jsr.json index ca7b1a859c..66ecb75f68 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.706.0", + "version": "1.706.1", "exports": "./src/index.ts", "publish": { "exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts"] diff --git a/typescript-client/package.json b/typescript-client/package.json index 51507ebdc7..e022ff7056 100644 --- a/typescript-client/package.json +++ b/typescript-client/package.json @@ -1,7 +1,7 @@ { "name": "windmill-client", "description": "Windmill SDK client for browsers and Node.js", - "version": "1.706.0", + "version": "1.706.1", "author": "Ruben Fiszel", "license": "Apache 2.0", "sideEffects": false, diff --git a/version.txt b/version.txt index 0fdf01b5f5..b5570ce111 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.706.0 +1.706.1 From e0ffea2deb5acf30815edd3669f4fc4c818b6e19 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Fri, 22 May 2026 10:31:21 +0200 Subject: [PATCH 24/71] feat: add wmill job rerun subcommand (#9275) * feat: add wmill job rerun subcommand * feat: add wmill job restart subcommand for flow restart-at-step --- cli/src/commands/job/job.ts | 73 ++++++++++++++++++- cli/src/guidance/core.ts | 2 + cli/src/guidance/skills.gen.ts | 4 + .../auto-generated/cli/cli-commands.md | 4 + system_prompts/auto-generated/prompts.ts | 4 + .../skills/cli-commands/SKILL.md | 4 + 6 files changed, 90 insertions(+), 1 deletion(-) diff --git a/cli/src/commands/job/job.ts b/cli/src/commands/job/job.ts index 454a600a19..50f0915075 100644 --- a/cli/src/commands/job/job.ts +++ b/cli/src/commands/job/job.ts @@ -376,6 +376,63 @@ async function cancel( log.info(colors.green(`Job ${id} canceled.`)); } +async function rerun( + opts: GlobalOptions, + id: string +) { + log.setSilent(true); + opts = await mergeConfigWithConfigFile(opts); + const workspace = await resolveWorkspace(opts); + await requireLogin(opts); + + const response = await wmill.batchReRunJobs({ + workspace: workspace.workspaceId, + requestBody: { + job_ids: [id], + script_options_by_path: {}, + flow_options_by_path: {}, + }, + }); + + const newIds: string[] = []; + const errorLines: string[] = []; + for (const line of String(response).split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + if (trimmed.startsWith("Error:")) errorLines.push(trimmed); + else newIds.push(trimmed); + } + + for (const err of errorLines) log.error(err); + + if (newIds.length === 0) { + throw new Error(`Failed to re-run job ${id}.`); + } + + console.log(newIds[0]); +} + +async function restart( + opts: GlobalOptions & { step: string; iteration?: number }, + id: string +) { + log.setSilent(true); + opts = await mergeConfigWithConfigFile(opts); + const workspace = await resolveWorkspace(opts); + await requireLogin(opts); + + const newId = await wmill.restartFlowAtStep({ + workspace: workspace.workspaceId, + id, + requestBody: { + step_id: opts.step, + branch_or_iteration_n: opts.iteration, + }, + }); + + console.log(newId); +} + // Shared list options to avoid repetition between default action and list subcommand const listOptions = (cmd: Command) => cmd @@ -410,6 +467,20 @@ const command = listOptions(new Command() .command("cancel", "Cancel a running or queued job") .arguments("") .option("--reason ", "Reason for cancellation") - .action(cancel as any); + .action(cancel as any) + .command( + "rerun", + "Re-run a completed job with the same args. Prints the new job UUID on stdout." + ) + .arguments("") + .action(rerun as any) + .command( + "restart", + "Restart a completed flow at a given top-level step. Prints the new flow job UUID on stdout." + ) + .arguments("") + .option("--step ", "Top-level step id to restart the flow from", { required: true }) + .option("--iteration ", "For a top-level branchall or for-loop step, the iteration to restart at") + .action(restart as any); export default command; diff --git a/cli/src/guidance/core.ts b/cli/src/guidance/core.ts index de65b33343..e87f3c72ca 100644 --- a/cli/src/guidance/core.ts +++ b/cli/src/guidance/core.ts @@ -147,6 +147,8 @@ When the user reports a script or flow failure, is investigating unexpected outp - \`wmill job logs \` — stdout/stderr; for flows, aggregates every step's logs - \`wmill job result \` — JSON result of a completed job - \`wmill job cancel \` — stop a running or queued job +- \`wmill job rerun \` — re-run a completed job with the same args (single-job equivalent of the frontend "rerun" button) +- \`wmill job restart --step [--iteration ]\` — restart a completed flow at a top-level step (for nested-container restart, use the UI) For flow failures, start with \`wmill job get \` to identify the failing step and its sub-job ID, then \`wmill job logs \` to drill in. diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index de1b8bcdc6..7cf2ae0adc 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -6948,6 +6948,10 @@ Manage jobs (list, inspect, cancel) - \`job logs \` - Get job logs. For flows: aggregates all step logs - \`job cancel \` - Cancel a running or queued job - \`--reason \` - Reason for cancellation +- \`job rerun \` - Re-run a completed job with the same args. Prints the new job UUID on stdout. +- \`job restart \` - Restart a completed flow at a given top-level step. Prints the new flow job UUID on stdout. + - \`--step \` - Top-level step id to restart the flow from + - \`--iteration \` - For a top-level branchall or for-loop step, the iteration to restart at ### jobs diff --git a/system_prompts/auto-generated/cli/cli-commands.md b/system_prompts/auto-generated/cli/cli-commands.md index 342f4cade4..2e431b25b7 100644 --- a/system_prompts/auto-generated/cli/cli-commands.md +++ b/system_prompts/auto-generated/cli/cli-commands.md @@ -334,6 +334,10 @@ Manage jobs (list, inspect, cancel) - `job logs ` - Get job logs. For flows: aggregates all step logs - `job cancel ` - Cancel a running or queued job - `--reason ` - Reason for cancellation +- `job rerun ` - Re-run a completed job with the same args. Prints the new job UUID on stdout. +- `job restart ` - Restart a completed flow at a given top-level step. Prints the new flow job UUID on stdout. + - `--step ` - Top-level step id to restart the flow from + - `--iteration ` - For a top-level branchall or for-loop step, the iteration to restart at ### jobs diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index 3a0ee24ac8..fc8d0b4f28 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -2865,6 +2865,10 @@ Manage jobs (list, inspect, cancel) - \`job logs \` - Get job logs. For flows: aggregates all step logs - \`job cancel \` - Cancel a running or queued job - \`--reason \` - Reason for cancellation +- \`job rerun \` - Re-run a completed job with the same args. Prints the new job UUID on stdout. +- \`job restart \` - Restart a completed flow at a given top-level step. Prints the new flow job UUID on stdout. + - \`--step \` - Top-level step id to restart the flow from + - \`--iteration \` - For a top-level branchall or for-loop step, the iteration to restart at ### jobs diff --git a/system_prompts/auto-generated/skills/cli-commands/SKILL.md b/system_prompts/auto-generated/skills/cli-commands/SKILL.md index 0ea6cf848f..4f91714b81 100644 --- a/system_prompts/auto-generated/skills/cli-commands/SKILL.md +++ b/system_prompts/auto-generated/skills/cli-commands/SKILL.md @@ -339,6 +339,10 @@ Manage jobs (list, inspect, cancel) - `job logs ` - Get job logs. For flows: aggregates all step logs - `job cancel ` - Cancel a running or queued job - `--reason ` - Reason for cancellation +- `job rerun ` - Re-run a completed job with the same args. Prints the new job UUID on stdout. +- `job restart ` - Restart a completed flow at a given top-level step. Prints the new flow job UUID on stdout. + - `--step ` - Top-level step id to restart the flow from + - `--iteration ` - For a top-level branchall or for-loop step, the iteration to restart at ### jobs From 4cca21ca6c0fceac443117ec68bd155365556390 Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Fri, 22 May 2026 11:43:34 +0200 Subject: [PATCH 25/71] chore(system_prompts): point plugin skills sync at plugins/windmill/ (#9287) * chore(system_prompts): point plugin skills sync at plugins/windmill/ The plugin checkout's plugin folder is being renamed from `plugins/windmill-code-plugin/` to `plugins/windmill/` to shorten the slash-command namespace and align with the matching Cursor plugin layout. Paired with windmill-labs/windmill-claude-plugin#8. That PR must merge first so the next sync run finds the new folder. Co-Authored-By: Claude Opus 4.7 (1M context) * docs(system_prompts): update plugin-dir example to plugins/windmill Co-authored-by: centdix --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: centdix --- system_prompts/README.md | 2 +- system_prompts/generate.py | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/system_prompts/README.md b/system_prompts/README.md index cc9449f93e..6dc8835a50 100644 --- a/system_prompts/README.md +++ b/system_prompts/README.md @@ -35,7 +35,7 @@ python system_prompts/generate.py --plugin-dir ~/windmill-claude-plugin `--plugin-dir` accepts: - the `windmill-claude-plugin` repo root -- a plugin root such as `plugins/windmill-code-plugin` +- a plugin root such as `plugins/windmill` - a direct `skills/` directory To regenerate the public docs repo (consumed by context7): diff --git a/system_prompts/generate.py b/system_prompts/generate.py index e5a6d609ae..eb4dd96462 100644 --- a/system_prompts/generate.py +++ b/system_prompts/generate.py @@ -1761,10 +1761,9 @@ def resolve_plugin_skills_dir(plugin_dir: Path) -> Path: """Resolve the plugin skills directory from a repo root, plugin root, or skills dir.""" plugin_dir = plugin_dir.expanduser().resolve() - repo_skills_dir = plugin_dir / "plugins" / "windmill-code-plugin" / "skills" - repo_plugin_json = plugin_dir / "plugins" / "windmill-code-plugin" / ".claude-plugin" / "plugin.json" - if repo_plugin_json.exists(): - return repo_skills_dir + plugin_root = plugin_dir / "plugins" / "windmill" + if (plugin_root / ".claude-plugin" / "plugin.json").exists(): + return plugin_root / "skills" plugin_skills_dir = plugin_dir / "skills" plugin_json = plugin_dir / ".claude-plugin" / "plugin.json" From 486e5f947b1649c17d32e3b214c50d4be701a4e8 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Fri, 22 May 2026 13:56:05 +0200 Subject: [PATCH 26/71] fix(cli): wmill sync pull updates wmill-lock.yaml for raw apps (#9289) --- cli/src/commands/sync/sync.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/src/commands/sync/sync.ts b/cli/src/commands/sync/sync.ts index d4aa3d818c..625fe69780 100644 --- a/cli/src/commands/sync/sync.ts +++ b/cli/src/commands/sync/sync.ts @@ -2858,7 +2858,7 @@ export async function pull( await generateAppLocksInternal( change, true, - true, + false, workspace, opts, true, From 13a2fae745ba4862006db5ee0811475c1d27fd1d Mon Sep 17 00:00:00 2001 From: hugocasa Date: Fri, 22 May 2026 15:27:50 +0200 Subject: [PATCH 27/71] fix: flow recording teardown crash + rename package to @windmill-labs/components (#9288) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: guard against null recording during FlowRecordingReplay teardown Navigating away from a flow recording inside a workspace file-tree view threw `TypeError: Cannot read properties of null (reading 'flow')` from FlowGraphViewer once during the teardown tick. Svelte 5 compiles child component props as live getters that close over `$$props.recording.flow`. When `recording` flips to null on the parent's navigation, an outer `{#if !recording?.flow}` doesn't stop those getters from firing one more time as derived effects re-evaluate before the unmount lands — so the getter dereferences null and throws. Fix at the two layers where the deref actually happens: - FlowRecordingReplay: use `recording?.flow` at the binding sites (FlowViewer + graph-snippet FlowGraphViewer) so the compiler emits an optional-chained getter, and guard the snippet branch with `{:else if recording?.flow}` so it doesn't mount when there's nothing to show. - FlowGraphViewer: finish the optional chaining the rest of the file already used everywhere else (`flow?.value?.skip_expr`, `flow?.value?.cache_ttl`, `flow?.schema`). When the upstream binding returns undefined during teardown, the graph degrades to an empty frame instead of crashing. Co-Authored-By: Claude Opus 4.7 (1M context) * chore: rename package to @windmill-labs/components - frontend/package.json: rename `windmill-components` → `@windmill-labs/components` - frontend/publish.sh: drop the in-place sed rename dance; the checked-in name now matches what's published, so `npm run package && npm publish` is enough - frontend/package-lock.json, system_prompts/auto-generated/prompts.d.ts: regenerated by `npm run package` under the new name Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- frontend/package-lock.json | 54 ++----------------- frontend/package.json | 2 +- frontend/publish.sh | 2 - .../src/lib/components/FlowGraphViewer.svelte | 6 +-- .../recording/FlowRecordingReplay.svelte | 6 +-- system_prompts/auto-generated/prompts.d.ts | 6 ++- 6 files changed, 15 insertions(+), 61 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 111c653471..991c61a1a0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,11 +1,11 @@ { - "name": "windmill-components", + "name": "@windmill-labs/components", "version": "1.706.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "windmill-components", + "name": "@windmill-labs/components", "version": "1.706.1", "hasInstallScript": true, "license": "AGPL-3.0", @@ -846,7 +846,6 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -858,7 +857,6 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -869,7 +867,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1359,7 +1356,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1508,7 +1504,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1525,7 +1520,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1542,7 +1536,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1559,7 +1552,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1576,7 +1568,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1593,7 +1584,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1610,7 +1600,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1627,7 +1616,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1644,7 +1632,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1661,7 +1648,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1678,7 +1664,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1695,7 +1680,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1712,7 +1696,6 @@ "cpu": [ "wasm32" ], - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1731,7 +1714,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1748,7 +1730,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2054,7 +2035,6 @@ "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -6830,7 +6810,7 @@ "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "jiti": "bin/jiti.js" @@ -7329,7 +7309,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7350,7 +7329,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7371,7 +7349,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7392,7 +7369,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7413,7 +7389,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7434,7 +7409,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7455,7 +7429,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7476,7 +7449,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7497,7 +7469,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7518,7 +7489,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7539,7 +7509,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -12108,21 +12077,6 @@ } } }, - "node_modules/svelte-check/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/svelte-eslint-parser": { "version": "0.43.0", "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.43.0.tgz", @@ -12853,7 +12807,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/frontend/package.json b/frontend/package.json index 4e7e2fea1a..7fe739d254 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,5 +1,5 @@ { - "name": "windmill-components", + "name": "@windmill-labs/components", "version": "1.706.1", "scripts": { "dev": "vite dev", diff --git a/frontend/publish.sh b/frontend/publish.sh index b97332f60f..9c374f0191 100755 --- a/frontend/publish.sh +++ b/frontend/publish.sh @@ -1,4 +1,2 @@ npm run package -sed -i -e 's/windmill/windmill-components/g' package.json npm publish -sed -i -e 's/windmill-components/windmill/g' package.json diff --git a/frontend/src/lib/components/FlowGraphViewer.svelte b/frontend/src/lib/components/FlowGraphViewer.svelte index 7e4fceeda7..56f91d25e9 100644 --- a/frontend/src/lib/components/FlowGraphViewer.svelte +++ b/frontend/src/lib/components/FlowGraphViewer.svelte @@ -76,8 +76,8 @@ > - +
{/if} diff --git a/frontend/src/lib/components/recording/FlowRecordingReplay.svelte b/frontend/src/lib/components/recording/FlowRecordingReplay.svelte index afd2cee94c..fce96e7b64 100644 --- a/frontend/src/lib/components/recording/FlowRecordingReplay.svelte +++ b/frontend/src/lib/components/recording/FlowRecordingReplay.svelte @@ -230,7 +230,7 @@ {/if} - {:else} + {:else if recording?.flow}

Click on a step to see its details

- +
{/if} {/snippet} diff --git a/system_prompts/auto-generated/prompts.d.ts b/system_prompts/auto-generated/prompts.d.ts index fe4156afd4..d187631f5f 100644 --- a/system_prompts/auto-generated/prompts.d.ts +++ b/system_prompts/auto-generated/prompts.d.ts @@ -1,5 +1,7 @@ export declare const SCRIPT_BASE = "# Windmill Script Writing Guide\n\n## General Principles\n\n- Scripts must export a main function (do not call it)\n- Libraries are installed automatically - do not show installation instructions\n- Credentials and configuration are stored in resources and passed as parameters\n- The windmill client (`wmill`) provides APIs for interacting with the platform\n\n## Function Naming\n\n- Main function: `main` (or `preprocessor` for preprocessor scripts)\n- Must be async for TypeScript variants\n\n## Return Values\n\n- Scripts can return any JSON-serializable value\n- Return values become available to subsequent flow steps via `results.step_id`\n\n## Preprocessor Scripts\n\nPreprocessor scripts process 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.\n\nThe returned object determines the parameter values passed to the flow.\ne.g., `{ b: 1, a: 2 }` calls the flow with `a = 2` and `b = 1`, assuming the flow has two inputs called `a` and `b`.\n\nThe preprocessor receives a single parameter called `event`.\n"; export declare const FLOW_BASE = "# Windmill Flow Building Guide\n\n## OpenFlow Schema\n\nThe OpenFlow schema (openflow.openapi.yaml) is the source of truth for flow structure. Refer to OPENFLOW_SCHEMA for the complete type definitions.\n\n## Reserved Module IDs\n\n- `failure` - Reserved for failure handler module\n- `preprocessor` - Reserved for preprocessor module\n- `Input` - Reserved for flow input reference\n\n## Hard Structural Rules\n\nThese are strict Windmill schema rules. Follow them exactly.\n\n- `value.modules` is only for normal sequential steps\n- `value.preprocessor_module` and `value.failure_module` are special top-level fields inside `value`, not entries in `value.modules`\n- If a flow needs a preprocessor, create `value.preprocessor_module` with `id: preprocessor`\n- If a flow needs a failure handler, create `value.failure_module` with `id: failure`\n- Do NOT create regular modules inside `value.modules` named `preprocessor` or `failure`\n- `preprocessor_module` and `failure_module` only support `script` or `rawscript`\n- `preprocessor_module` runs before normal modules and cannot reference `results.*`\n- `failure_module` can use the `error` object with `error.message`, `error.step_id`, `error.name`, and `error.stack`\n\nCorrect shape:\n\n```yaml\nvalue:\n preprocessor_module:\n id: preprocessor\n value:\n type: rawscript\n ...\n failure_module:\n id: failure\n value:\n type: rawscript\n ...\n modules:\n - id: process_event\n value:\n type: rawscript\n ...\n```\n\nIncorrect shape:\n\n```yaml\nvalue:\n modules:\n - id: preprocessor\n ...\n - id: process_event\n ...\n - id: failure\n ...\n```\n\n## Module ID Rules\n\n- Must be unique across the entire flow\n- Use underscores, not spaces (e.g., `fetch_data` not `fetch data`)\n- Use descriptive names that reflect the step's purpose\n\n## Common Mistakes to Avoid\n\n- Missing `input_transforms` - Rawscript parameters won't receive values without them\n- Referencing future steps - `results.step_id` only works for steps that execute before the current one\n- Duplicate module IDs - Each module ID must be unique in the flow\n\n## Data Flow Between Steps\n\n- `flow_input.property` - Access flow input parameters\n- `results.step_id` - Access output from a previous step only when that step result is in scope\n- `results.step_id.property` - Access specific property from a previous step output only when that step result is in scope\n- `flow_input.iter.value` - Current iteration value when inside a loop (`forloopflow` or `whileloopflow`)\n- `flow_input.iter.index` - Current loop index when inside a loop (`forloopflow` or `whileloopflow`)\n\n## Loop Structure Rules\n\n- For `whileloopflow`, use module-level `stop_after_if` on the loop module itself when the loop should stop after an iteration result\n- Do NOT put `stop_after_if` inside `value` of a `whileloopflow`\n- `stop_after_all_iters_if` is for checks after the whole loop finishes, not the normal per-iteration break condition\n- When a `whileloopflow` carries state forward between iterations, use `flow_input.iter.value` as the current loop value and provide an explicit first-iteration fallback when needed\n- Use `flow_input.iter.index` only when the loop logic is truly based on the iteration index, not as a replacement for the current loop value\n- If the user asks for a final scalar/object after a loop, add a normal step after the loop that extracts the final value from the loop result instead of returning the whole loop result array\n\nCorrect `whileloopflow` shape:\n\n```yaml\n- id: loop_until_done\n stop_after_if:\n expr: result.done === true\n skip_if_stopped: false\n value:\n type: whileloopflow\n skip_failures: false\n modules:\n - id: advance_state\n value:\n type: rawscript\n input_transforms:\n state:\n type: javascript\n expr: flow_input.iter && flow_input.iter.value !== undefined ? flow_input.iter.value : flow_input.initial_state\n- id: return_final_state\n value:\n type: rawscript\n input_transforms:\n final_state:\n type: javascript\n expr: results.loop_until_done[results.loop_until_done.length - 1]\n```\n\nIncorrect `whileloopflow` patterns:\n\n```yaml\n- id: loop_until_done\n value:\n type: whileloopflow\n stop_after_if:\n expr: result.done === true\n```\n\n```yaml\ninput_transforms:\n state:\n type: javascript\n expr: flow_input.iter.index\n```\n\n```yaml\ninput_transforms:\n final_state:\n type: javascript\n expr: results.loop_until_done\n```\n\n## Approval / Suspend Structure\n\n- `suspend` belongs on the flow module object itself, as a sibling of `id` and `value`\n- Never put `suspend` inside `value`\n\nCorrect shape:\n\n```yaml\n- id: request_approval\n suspend:\n required_events: 1\n resume_form:\n schema:\n type: object\n properties:\n comment:\n type: string\n required: [comment]\n value:\n type: identity\n```\n\nIncorrect shape:\n\n```yaml\n- id: request_approval\n value:\n type: rawscript\n suspend:\n required_events: 1\n```\n\n## Branch Result Scope Rules\n\n- Inside a branch, you may reference earlier outer steps and earlier steps in the same branch\n- Outside a `branchone`, do NOT reference ids of steps that only exist inside its branches or default branch. Use `results.` instead\n- Outside a `branchall`, do NOT reference ids of steps inside its branches. Use `results.` instead\n- If downstream steps need a stable shape after a branch, make each branch return the same fields\n- When needed, add a normalization step immediately after the branch and consume `results.` there\n\nCorrect after `branchone`:\n\n```yaml\n- id: route_order\n value:\n type: branchone\n ...\n- id: send_confirmation\n value:\n input_transforms:\n routed:\n type: javascript\n expr: results.route_order\n```\n\nIncorrect after `branchone`:\n\n```yaml\nexpr: results.create_shipment\nexpr: results.create_backorder\n```\n\nCorrect after `branchall`:\n\n```yaml\n- id: enrich_parallel\n value:\n type: branchall\n parallel: true\n ...\n- id: combine_data\n value:\n input_transforms:\n enrichments:\n type: javascript\n expr: results.enrich_parallel\n```\n\n## Input Transforms\n\nEvery rawscript module needs `input_transforms` to map function parameters to values:\n\nStatic transform (fixed value):\n{\"param_name\": {\"type\": \"static\", \"value\": \"fixed_string\"}}\n\nJavaScript transform (dynamic expression):\n{\"param_name\": {\"type\": \"javascript\", \"expr\": \"results.previous_step.data\"}}\n\n## Resource References\n\n- For flow inputs: Use type `\"object\"` with format `\"resource-{type}\"` (e.g., `\"resource-postgresql\"`)\n- For step inputs: Use static value `\"$res:path/to/resource\"`\n\n## Final Structural Self-Check\n\nBefore finalizing a flow, verify:\n\n- any preprocessor is in `value.preprocessor_module`\n- any failure handler is in `value.failure_module`\n- any approval step has module-level `suspend`\n- no downstream step references inner branch step ids from outside the branch\n\n## S3 Object Operations\n\nWindmill provides built-in support for S3-compatible storage operations.\n\nTo accept an S3 object as flow input:\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"file\": {\n \"type\": \"object\",\n \"format\": \"resource-s3_object\",\n \"description\": \"File to process\"\n }\n }\n}\n```\n\n## Using Resources in Flows\n\nOn Windmill, credentials and configuration are stored in resources. Resource types define the format of the resource.\n\n### As Flow Input\n\nIn the flow schema, set the property type to `\"object\"` with format `\"resource-{type}\"`:\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"database\": {\n \"type\": \"object\",\n \"format\": \"resource-postgresql\",\n \"description\": \"Database connection\"\n }\n }\n}\n```\n\n### As Step Input (Static Reference)\n\nReference a specific resource using `$res:` prefix:\n\n```json\n{\n \"database\": {\n \"type\": \"static\",\n \"value\": \"$res:f/folder/my_database\"\n }\n}\n```\n"; +export declare const RESOURCES_BASE = "# Windmill Resources\n\nResources store credentials and configuration for external services.\n\n## File Format\n\nResource files use the pattern: `{path}.resource.json`\n\nExample: `f/databases/postgres_prod.resource.json`\n\n## Resource Structure\n\n```json\n{\n \"value\": {\n \"host\": \"db.example.com\",\n \"port\": 5432,\n \"user\": \"admin\",\n \"password\": \"$var:g/all/db_password\",\n \"dbname\": \"production\"\n },\n \"description\": \"Production PostgreSQL database\",\n \"resource_type\": \"postgresql\"\n}\n```\n\n## Required Fields\n\n- `value` - Object containing the resource configuration\n- `resource_type` - Name of the resource type (e.g., \"postgresql\", \"slack\")\n\n## Variable References\n\nReference variables in resource values:\n\n```json\n{\n \"value\": {\n \"api_key\": \"$var:g/all/api_key\",\n \"secret\": \"$var:u/admin/secret\"\n }\n}\n```\n\n**Reference formats:**\n- `$var:g/all/name` - Global variable\n- `$var:u/username/name` - User variable\n- `$var:f/folder/name` - Folder variable\n\n## Resource References\n\nReference other resources:\n\n```json\n{\n \"value\": {\n \"database\": \"$res:f/databases/postgres\"\n }\n}\n```\n\n## Common Resource Types\n\n### PostgreSQL\n```json\n{\n \"resource_type\": \"postgresql\",\n \"value\": {\n \"host\": \"localhost\",\n \"port\": 5432,\n \"user\": \"postgres\",\n \"password\": \"$var:g/all/pg_password\",\n \"dbname\": \"windmill\",\n \"sslmode\": \"prefer\"\n }\n}\n```\n\n### MySQL\n```json\n{\n \"resource_type\": \"mysql\",\n \"value\": {\n \"host\": \"localhost\",\n \"port\": 3306,\n \"user\": \"root\",\n \"password\": \"$var:g/all/mysql_password\",\n \"database\": \"myapp\"\n }\n}\n```\n\n### Slack\n```json\n{\n \"resource_type\": \"slack\",\n \"value\": {\n \"token\": \"$var:g/all/slack_token\"\n }\n}\n```\n\n### AWS S3\n```json\n{\n \"resource_type\": \"s3\",\n \"value\": {\n \"bucket\": \"my-bucket\",\n \"region\": \"us-east-1\",\n \"accessKeyId\": \"$var:g/all/aws_access_key\",\n \"secretAccessKey\": \"$var:g/all/aws_secret_key\"\n }\n}\n```\n\n### HTTP/API\n```json\n{\n \"resource_type\": \"http\",\n \"value\": {\n \"baseUrl\": \"https://api.example.com\",\n \"headers\": {\n \"Authorization\": \"Bearer $var:g/all/api_token\"\n }\n }\n}\n```\n\n### Kafka\n```json\n{\n \"resource_type\": \"kafka\",\n \"value\": {\n \"brokers\": \"broker1:9092,broker2:9092\",\n \"sasl_mechanism\": \"PLAIN\",\n \"security_protocol\": \"SASL_SSL\",\n \"username\": \"$var:g/all/kafka_user\",\n \"password\": \"$var:g/all/kafka_password\"\n }\n}\n```\n\n### NATS\n```json\n{\n \"resource_type\": \"nats\",\n \"value\": {\n \"servers\": [\"nats://localhost:4222\"],\n \"user\": \"$var:g/all/nats_user\",\n \"password\": \"$var:g/all/nats_password\"\n }\n}\n```\n\n### MQTT\n```json\n{\n \"resource_type\": \"mqtt\",\n \"value\": {\n \"host\": \"mqtt.example.com\",\n \"port\": 8883,\n \"username\": \"$var:g/all/mqtt_user\",\n \"password\": \"$var:g/all/mqtt_password\",\n \"tls\": true\n }\n}\n```\n\n## Custom Resource Types\n\nCreate custom resource types with JSON Schema:\n\n```json\n{\n \"name\": \"custom_api\",\n \"schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"base_url\": {\"type\": \"string\", \"format\": \"uri\"},\n \"api_key\": {\"type\": \"string\"},\n \"timeout\": {\"type\": \"integer\", \"default\": 30}\n },\n \"required\": [\"base_url\", \"api_key\"]\n },\n \"description\": \"Custom API connection\"\n}\n```\n\nSave as: `custom_api.resource-type.json`\n\n## OAuth Resources\n\nOAuth resources are managed through the Windmill UI and marked:\n\n```json\n{\n \"is_oauth\": true,\n \"account\": 123\n}\n```\n\nOAuth tokens are automatically refreshed by Windmill.\n\n## Using Resources in Scripts\n\n### TypeScript (Bun/Deno)\n```typescript\nexport async function main(db: RT.Postgresql) {\n // db contains the resource values\n const { host, port, user, password, dbname } = db;\n}\n```\n\n### Python\n```python\nclass postgresql(TypedDict):\n host: str\n port: int\n user: str\n password: str\n dbname: str\n\ndef main(db: postgresql):\n # db contains the resource values\n pass\n```\n\n## CLI Commands\n\n```bash\n# List resources\nwmill resource list\n\n# List resource types with schemas\nwmill resource-type list --schema\n\n# Get specific resource type schema\nwmill resource-type get postgresql\n\n# Push resources (tell the user to run this, do NOT run it yourself)\nwmill sync push\n```\n"; +export declare const RAW_APP_BASE = "# Windmill Raw Apps\n\nRaw apps let you build custom frontends with React, Svelte, or Vue that connect to Windmill backend runnables and datatables.\n\n## App shape\n\nA raw app has three logical parts:\n\n- **Frontend** \u2014 bundled with esbuild from `index.tsx` as the entrypoint. Files include the entrypoint, components (`App.tsx`), styles, etc.\n- **Backend runnables** \u2014 server-side scripts the frontend calls, each addressed by a unique key.\n- **Data** \u2014 optional whitelisted datatables (managed PostgreSQL) that the backend runnables can query. The frontend never queries the database directly; backend runnables are the only bridge.\n\n## Frontend\n\n### Entrypoint\n\n`index.tsx` is the bundling entrypoint. It typically renders a top-level `App` component. The bundler is esbuild.\n\n### Generated bindings (`wmill.d.ts` / `wmill.ts`)\n\nThe frontend imports a generated module that mirrors the backend runnables. **Never write to it directly** \u2014 it gets regenerated whenever backend runnables change. Modifying it by hand will be overwritten.\n\n### Calling backend runnables\n\nImport the generated bindings and call the runnable like a function:\n\n```typescript\nimport { backend } from './wmill';\n\n// Call a backend runnable\nconst user = await backend.get_user({ user_id: '123' });\n```\n\nThe frontend cannot reach datatables, workspace items, or external services on its own \u2014 it goes through `backend.(args)` for everything server-side.\n\n## Backend runnables\n\nEach runnable has a unique key (used to call it from the frontend) and one of four types:\n\n| Type | What it is |\n|---|---|\n| `inline` | Custom code stored on the app itself. Most common for app-specific logic. |\n| `script` | Reference to an existing workspace script by path. |\n| `flow` | Reference to an existing workspace flow by path. |\n| `hubscript` | Reference to a hub script by path. |\n\n### Inline runnables\n\nInline runnables carry their own source code. For file-based raw apps, the runnable language is determined by the backend file extension. The script must expose a `main` function as its entrypoint.\n\n**TypeScript example** (`backend/get_user.ts`):\n\n```typescript\nimport * as wmill from 'windmill-client';\n\nexport async function main(user_id: string) {\n const sql = wmill.datatable();\n const user = await sql`SELECT * FROM users WHERE id = ${user_id}`.fetchOne();\n return user;\n}\n```\n\n**Python example** (`backend/get_user.py`):\n\n```python\nimport wmill\n\ndef main(user_id: str):\n db = wmill.datatable()\n user = db.query('SELECT * FROM users WHERE id = $1', user_id).fetch_one()\n return user\n```\n\n### Path runnables (script / flow / hubscript)\n\nWhen `type` is `script`, `flow`, or `hubscript`, the runnable just stores a `path` to an existing workspace or hub item \u2014 no inline code. The referenced item's input/output schema becomes the runnable's surface.\n\n### Static inputs\n\n`staticInputs` is an optional `Record` for arguments not overridable from the frontend. Useful with path runnables to pre-fill some args while leaving the rest to the frontend caller.\n\n## Data Tables\n\nData tables are PostgreSQL databases managed by Windmill. Backend runnables query them via the `wmill` client; the frontend never queries them directly.\n\n### Critical rules\n\n1. **Whitelisted tables only**: a runnable can only query tables listed in the app's `data.tables` config. Tables not in this list are not accessible.\n2. **Add tables before using**: queries against unlisted tables fail at runtime. When you introduce a new table, register it in `data.tables` first.\n3. **Use the configured datatable/schema**: the app's `data` config sets the default datatable and schema; reference them consistently across runnables.\n\n### Querying in TypeScript (Bun/Deno)\n\n```typescript\nimport * as wmill from 'windmill-client';\n\nexport async function main(user_id: string) {\n const sql = wmill.datatable(); // Or: wmill.datatable('other_datatable')\n\n // Parameterized queries (safe from SQL injection)\n const user = await sql`SELECT * FROM users WHERE id = ${user_id}`.fetchOne();\n const users = await sql`SELECT * FROM users WHERE active = ${true}`.fetch();\n\n // Insert/Update\n await sql`INSERT INTO users (name, email) VALUES (${name}, ${email})`;\n await sql`UPDATE users SET name = ${newName} WHERE id = ${user_id}`;\n\n return user;\n}\n```\n\n### Querying in Python\n\n```python\nimport wmill\n\ndef main(user_id: str):\n db = wmill.datatable() # Or: wmill.datatable('other_datatable')\n\n # Use $1, $2, etc. for parameters\n user = db.query('SELECT * FROM users WHERE id = $1', user_id).fetch_one()\n users = db.query('SELECT * FROM users WHERE active = $1', True).fetch()\n\n # Insert/Update\n db.query('INSERT INTO users (name, email) VALUES ($1, $2)', name, email)\n db.query('UPDATE users SET name = $1 WHERE id = $2', new_name, user_id)\n\n return user\n```\n\n## Best Practices\n\n1. **Check existing tables** before creating new ones \u2014 reuse beats schema growth.\n2. **Use parameterized queries** \u2014 never concatenate user input into SQL.\n3. **Keep runnables focused** \u2014 one function per runnable; small surface area.\n4. **Use descriptive keys** \u2014 `get_user`, not `a`.\n5. **Always whitelist tables** \u2014 adding a runnable that queries a new table requires the table to be in `data.tables` first.\n"; export declare const WORKFLOW_AS_CODE_BASE = "# Windmill Workflow-as-Code Writing Guide\n\n## Scope\n\nUse this guide when writing or modifying Windmill Workflow-as-Code (WAC) scripts.\nWAC is authored as a Windmill script and deployed with the normal script workflow. It is not an OpenFlow YAML flow.\n\nSupported WAC authoring targets:\n- Bun TypeScript scripts that import from `windmill-client`\n- Python 3 scripts that import from `wmill`\n\n## File Shape\n\nBun TypeScript:\n\n```typescript\nimport {\n task,\n taskScript,\n taskFlow,\n step,\n sleep,\n waitForApproval,\n getResumeUrls,\n parallel,\n workflow,\n} from \"windmill-client\";\n\nconst process = task(async (x: string): Promise => {\n return `processed: ${x}`;\n});\n\nexport const main = workflow(async (x: string) => {\n const result = await process(x);\n return { result };\n});\n```\n\nPython:\n\n```python\nfrom wmill import task, task_script, task_flow, step, sleep, wait_for_approval, get_resume_urls, parallel, workflow\n\n@task()\nasync def process(x: str) -> str:\n return f\"processed: {x}\"\n\n@workflow\nasync def main(x: str):\n result = await process(x)\n return {\"result\": result}\n```\n\nRules:\n- Do not call `main`.\n- Bun TypeScript should export the workflow entrypoint, preferably `export const main = workflow(async (...) => { ... })`.\n- Python must use `@workflow` on an async top-level function, usually `main`.\n- Define task functions and `taskScript`/`task_script` or `taskFlow`/`task_flow` assignments at module top level with stable names.\n- Use the exact SDK names. Do not alias `workflow`, `task`, `taskScript`, `taskFlow`, `step`, `sleep`, `waitForApproval`, `task_script`, `task_flow`, or `wait_for_approval`; the WAC parser recognizes these names directly.\n\n## Checkpoint And Replay Model\n\nThe parent workflow may rerun from the top after any suspension, retry, approval, or child task completion. Completed durable steps are replayed from the checkpoint.\n\nPut every side effect or non-deterministic value behind a durable WAC boundary:\n- Use `task()` / `@task()` for substantial work that should run as its own child job.\n- Use `taskScript()` / `task_script()` for an existing script or a relative module file.\n- Use `taskFlow()` / `task_flow()` for an existing Windmill flow.\n- Use `step(name, fn)` for lightweight inline work whose result must be checkpointed.\n- Use `sleep(seconds)` for server-side sleeps that do not hold a worker.\n- Use `waitForApproval()` / `wait_for_approval()` for external approval suspension.\n\nNever put API calls, database writes, notifications, random values, timestamps, or irreversible changes directly in the top-level workflow body. The workflow body can be rerun. Put those operations in a task or in `step()`.\n\nBranching on task or step results is safe because those results are checkpointed. Branching on current time, random data, environment reads, or external state is unsafe unless the value is first captured with `step()`.\n\n## Tasks\n\nUse `task()` / `@task()` for inline functions that become workflow steps:\n\n```typescript\nconst enrich = task(async (customerId: string) => {\n return await fetchCustomer(customerId);\n});\n```\n\n```python\n@task(timeout=600, tag=\"etl\")\nasync def enrich(customer_id: str):\n return await fetch_customer(customer_id)\n```\n\nIn TypeScript, prefer assigning each task to a named top-level const. In Python, prefer top-level async functions decorated with `@task()` or `@task`.\n\nFor existing scripts:\n\n```typescript\nconst helper = taskScript(\"./helper.ts\");\nconst existing = taskScript(\"f/data/extract\", { timeout: 600 });\nconst value = await helper({ input: x });\n```\n\n```python\nhelper = task_script(\"./helper.py\")\nexisting = task_script(\"f/data/extract\", timeout=600)\nvalue = await helper(input=x)\n```\n\nFor existing flows:\n\n```typescript\nconst pipeline = taskFlow(\"f/etl/pipeline\");\nconst output = await pipeline({ input: data });\n```\n\n```python\npipeline = task_flow(\"f/etl/pipeline\")\noutput = await pipeline(input=data)\n```\n\n## Inline Steps\n\nUse `step()` for lightweight inline values that must not change during replay:\n\n```typescript\nconst urls = await step(\"get_urls\", () => getResumeUrls());\nconst startedAt = await step(\"started_at\", () => new Date().toISOString());\n```\n\n```python\nurls = await step(\"get_urls\", lambda: get_resume_urls())\n```\n\nUse stable, descriptive step names. Do not generate step names dynamically.\n\n## Parallelism\n\nTo run independent work in parallel, start task promises/coroutines before awaiting them together:\n\n```typescript\nconst [a, b] = await Promise.all([process(\"a\"), process(\"b\")]);\nconst many = await parallel(items, process, { concurrency: 5 });\n```\n\n```python\nimport asyncio\n\na, b = await asyncio.gather(process(\"a\"), process(\"b\"))\nmany = await parallel(items, process, concurrency=5)\n```\n\nOnly parallelize independent steps. Do not read the result of a task before it is awaited.\n\n## Approvals\n\nGenerate resume URLs inside `step()` before sending them:\n\n```typescript\nconst urls = await step(\"get_urls\", () => getResumeUrls());\nawait step(\"notify\", () => sendApprovalEmail(urls.approvalPage));\nconst approval = await waitForApproval({ timeout: 3600 });\n```\n\n```python\nurls = await step(\"get_urls\", lambda: get_resume_urls())\nawait step(\"notify\", lambda: send_approval_email(urls[\"approvalPage\"]))\napproval = await wait_for_approval(timeout=3600)\n```\n\n`selfApproval: false` and `self_approval=False` are Enterprise-only approval behavior. Do not use them unless the user asks for that behavior.\n\n## Error Handling\n\nLet task errors fail the workflow unless the user asks for recovery logic.\n\nPython: `except Exception` is safe around WAC calls because internal suspension inherits from `BaseException`. Avoid bare `except:` in workflow code. If the user asks for recovery logic around failed child work, catch `TaskError` from `wmill` for task failures.\n\nTypeScript: avoid broad `try/catch` around WAC SDK calls. The SDK uses an internal suspension error during initial dispatch; catching it can break workflow suspension. If a broad catch is unavoidable, rethrow internal suspension errors before handling business errors.\n"; export declare const FLOW_CHAT_SPECIAL_MODULES = "## Special Modules\n\n- Use `set_preprocessor_module` to add, replace, or remove the top-level `value.preprocessor_module`\n- Use `set_failure_module` to add, replace, or remove the top-level `value.failure_module`\n- Use `set_flow_json` only when you are replacing the whole flow, including normal modules and optional special modules\n\n**Example - Update only the special modules:**\n```javascript\nset_preprocessor_module({\n module: JSON.stringify({\n id: \"preprocessor\",\n value: {\n type: \"rawscript\",\n language: \"bun\",\n content: \"export async function preprocessor(payload: string) { const trimmed = payload.trim(); if (!trimmed) { throw new Error('payload must not be empty'); } return { payload: trimmed }; }\",\n input_transforms: {\n payload: { type: \"javascript\", expr: \"flow_input.payload\" }\n }\n }\n })\n})\n\nset_failure_module({\n module: JSON.stringify({\n id: \"failure\",\n value: {\n type: \"rawscript\",\n language: \"bun\",\n content: \"export async function main(message: string, name: string, step_id: string) { return { message, name, step_id }; }\",\n input_transforms: {\n message: { type: \"javascript\", expr: \"error.message\" },\n name: { type: \"javascript\", expr: \"error.name\" },\n step_id: { type: \"javascript\", expr: \"error.step_id\" }\n }\n }\n })\n})\n```\n"; export declare const SDK_TYPESCRIPT = "# TypeScript SDK (windmill-client)\n\nImport: import * as wmill from 'windmill-client'\n\nworkerHasInternalServer(): boolean\n\n/**\n * Initialize the Windmill client with authentication token and base URL\n * @param token - Authentication token (defaults to WM_TOKEN env variable)\n * @param baseUrl - API base URL (defaults to BASE_INTERNAL_URL or BASE_URL env variable)\n */\nsetClient(token?: string, baseUrl?: string): void\n\n/**\n * Create a client configuration from env variables\n * @returns client configuration\n */\ngetWorkspace(): string\n\n/**\n * Get a resource value by path\n * @param path path of the resource, default to internal state path\n * @param undefinedIfEmpty if the resource does not exist, return undefined instead of throwing an error\n * @returns resource value\n */\nasync getResource(path?: string, undefinedIfEmpty?: boolean): Promise\n\n/**\n * Get the true root job id\n * @param jobId job id to get the root job id from (default to current job)\n * @returns root job id\n */\nasync getRootJobId(jobId?: string): Promise\n\n/**\n * @deprecated Use runScriptByPath or runScriptByHash instead\n */\nasync runScript(path: string | null = null, hash_: string | null = null, args: Record | null = null, verbose: boolean = false): Promise\n\n/**\n * Run a script synchronously by its path and wait for the result\n * @param path - Script path in Windmill\n * @param args - Arguments to pass to the script\n * @param verbose - Enable verbose logging\n * @returns Script execution result\n */\nasync runScriptByPath(path: string, args: Record | null = null, verbose: boolean = false): Promise\n\n/**\n * Run a script synchronously by its hash and wait for the result\n * @param hash_ - Script hash in Windmill\n * @param args - Arguments to pass to the script\n * @param verbose - Enable verbose logging\n * @returns Script execution result\n */\nasync runScriptByHash(hash_: string, args: Record | null = null, verbose: boolean = false): Promise\n\n/**\n * Append a text to the result stream\n * @param text text to append to the result stream\n */\nappendToResultStream(text: string): void\n\n/**\n * Stream to the result stream\n * @param stream stream to stream to the result stream\n */\nasync streamResult(stream: AsyncIterable): Promise\n\n/**\n * Run a flow synchronously by its path and wait for the result\n * @param path - Flow path in Windmill\n * @param args - Arguments to pass to the flow\n * @param verbose - Enable verbose logging\n * @returns Flow execution result\n */\nasync runFlow(path: string | null = null, args: Record | null = null, verbose: boolean = false): Promise\n\n/**\n * Wait for a job to complete and return its result\n * @param jobId - ID of the job to wait for\n * @param verbose - Enable verbose logging\n * @returns Job result when completed\n */\nasync waitJob(jobId: string, verbose: boolean = false): Promise\n\n/**\n * Get the result of a completed job\n * @param jobId - ID of the completed job\n * @returns Job result\n */\nasync getResult(jobId: string): Promise\n\n/**\n * Get the result of a job if completed, or its current status\n * @param jobId - ID of the job\n * @returns Object with started, completed, success, and result properties\n */\nasync getResultMaybe(jobId: string): Promise\n\n/**\n * @deprecated Use runScriptByPathAsync or runScriptByHashAsync instead\n */\nasync runScriptAsync(path: string | null, hash_: string | null, args: Record | null, scheduledInSeconds: number | null = null): Promise\n\n/**\n * Run a script asynchronously by its path\n * @param path - Script path in Windmill\n * @param args - Arguments to pass to the script\n * @param scheduledInSeconds - Schedule execution for a future time (in seconds)\n * @returns Job ID of the created job\n */\nasync runScriptByPathAsync(path: string, args: Record | null = null, scheduledInSeconds: number | null = null): Promise\n\n/**\n * Run a script asynchronously by its hash\n * @param hash_ - Script hash in Windmill\n * @param args - Arguments to pass to the script\n * @param scheduledInSeconds - Schedule execution for a future time (in seconds)\n * @returns Job ID of the created job\n */\nasync runScriptByHashAsync(hash_: string, args: Record | null = null, scheduledInSeconds: number | null = null): Promise\n\n/**\n * Run a flow asynchronously by its path\n * @param path - Flow path in Windmill\n * @param args - Arguments to pass to the flow\n * @param scheduledInSeconds - Schedule execution for a future time (in seconds)\n * @param doNotTrackInParent - If false, tracks state in parent job (only use when fully awaiting the job)\n * @returns Job ID of the created job\n */\nasync runFlowAsync(path: string | null, args: Record | null, scheduledInSeconds: number | null = null, // can only be set to false if this the job will be fully await and not concurrent with any other job // as otherwise the child flow and its own child will store their state in the parent job which will // lead to incorrectness and failures doNotTrackInParent: boolean = true): Promise\n\n/**\n * Resolve a resource value in case the default value was picked because the input payload was undefined\n * @param obj resource value or path of the resource under the format `$res:path`\n * @returns resource value\n */\nasync resolveDefaultResource(obj: any): Promise\n\n/**\n * Get the state file path from environment variables\n * @returns State path string\n */\ngetStatePath(): string\n\n/**\n * Set a resource value by path\n * @param path path of the resource to set, default to state path\n * @param value new value of the resource to set\n * @param initializeToTypeIfNotExist if the resource does not exist, initialize it with this type\n */\nasync setResource(value: any, path?: string, initializeToTypeIfNotExist?: string): Promise\n\n/**\n * Set the state\n * @param state state to set\n * @deprecated use setState instead\n */\nasync setInternalState(state: any): Promise\n\n/**\n * Set the state\n * @param state state to set\n * @param path Optional state resource path override. Defaults to `getStatePath()`.\n */\nasync setState(state: any, path?: string): Promise\n\n/**\n * Set the progress\n * Progress cannot go back and limited to 0% to 99% range\n * @param percent Progress to set in %\n * @param jobId? Job to set progress for\n */\nasync setProgress(percent: number, jobId?: any): Promise\n\n/**\n * Get the progress\n * @param jobId? Job to get progress from\n * @returns Optional clamped between 0 and 100 progress value\n */\nasync getProgress(jobId?: any): Promise\n\n/**\n * Set a flow user state\n * @param key key of the state\n * @param value value of the state\n */\nasync setFlowUserState(key: string, value: any, errorIfNotPossible?: boolean): Promise\n\n/**\n * Get a flow user state\n * @param path path of the variable\n */\nasync getFlowUserState(key: string, errorIfNotPossible?: boolean): Promise\n\n/**\n * Get the internal state\n * @deprecated use getState instead\n */\nasync getInternalState(): Promise\n\n/**\n * Get the state shared across executions\n * @param path Optional state resource path override. Defaults to `getStatePath()`.\n */\nasync getState(path?: string): Promise\n\n/**\n * Get a variable by path\n * @param path path of the variable\n * @returns variable value\n */\nasync getVariable(path: string): Promise\n\n/**\n * Set a variable by path, create if not exist\n * @param path path of the variable\n * @param value value of the variable\n * @param isSecretIfNotExist if the variable does not exist, create it as secret or not (default: false)\n * @param descriptionIfNotExist if the variable does not exist, create it with this description (default: \"\")\n */\nasync setVariable(path: string, value: string, isSecretIfNotExist?: boolean, descriptionIfNotExist?: string): Promise\n\n/**\n * Build a PostgreSQL connection URL from a database resource\n * @param path - Path to the database resource\n * @returns PostgreSQL connection URL string\n */\nasync databaseUrlFromResource(path: string): Promise\n\nasync polarsConnectionSettings(s3_resource_path: string | undefined): Promise\n\nasync duckdbConnectionSettings(s3_resource_path: string | undefined): Promise\n\n/**\n * Get S3 client settings from a resource or workspace default\n * @param s3_resource_path - Path to S3 resource (uses workspace default if undefined)\n * @returns S3 client configuration settings\n */\nasync denoS3LightClientSettings(s3_resource_path: string | undefined): Promise\n\n/**\n * Load the content of a file stored in S3. If the s3ResourcePath is undefined, it will default to the workspace S3 resource.\n * \n * ```typescript\n * let fileContent = await wmill.loadS3FileContent(inputFile)\n * // if the file is a raw text file, it can be decoded and printed directly:\n * const text = new TextDecoder().decode(fileContentStream)\n * console.log(text);\n * ```\n */\nasync loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefined): Promise\n\n/**\n * Load the content of a file stored in S3 as a stream. If the s3ResourcePath is undefined, it will default to the workspace S3 resource.\n * \n * ```typescript\n * let fileContentBlob = await wmill.loadS3FileStream(inputFile)\n * // if the content is plain text, the blob can be read directly:\n * console.log(await fileContentBlob.text());\n * ```\n */\nasync loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined): Promise\n\n/**\n * Persist a file to the S3 bucket. If the s3ResourcePath is undefined, it will default to the workspace S3 resource.\n * \n * ```typescript\n * const s3object = await writeS3File(s3Object, \"Hello Windmill!\")\n * const fileContentAsUtf8Str = (await s3object.toArray()).toString('utf-8')\n * console.log(fileContentAsUtf8Str)\n * ```\n */\nasync writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined): Promise\n\n/**\n * Sign S3 objects to be used by anonymous users in public apps\n * @param s3objects s3 objects to sign\n * @returns signed s3 objects\n */\nasync signS3Objects(s3objects: S3Object[]): Promise\n\n/**\n * Sign S3 object to be used by anonymous users in public apps\n * @param s3object s3 object to sign\n * @returns signed s3 object\n */\nasync signS3Object(s3object: S3Object): Promise\n\n/**\n * Generate a presigned public URL for an array of S3 objects.\n * If an S3 object is not signed yet, it will be signed first.\n * @param s3Objects s3 objects to sign\n * @returns list of signed public URLs\n */\nasync getPresignedS3PublicUrls(s3Objects: S3Object[], { baseUrl }: { baseUrl?: string } = {}): Promise\n\n/**\n * Generate a presigned public URL for an S3 object. If the S3 object is not signed yet, it will be signed first.\n * @param s3Object s3 object to sign\n * @returns signed public URL\n */\nasync getPresignedS3PublicUrl(s3Objects: S3Object, { baseUrl }: { baseUrl?: string } = {}): Promise\n\n/**\n * Get URLs needed for resuming a flow after this step\n * @param approver approver name\n * @param flowLevel if true, generate resume URLs for the parent flow instead of the specific step.\n * This allows pre-approvals that can be consumed by any later suspend step in the same flow.\n * @returns approval page UI URL, resume and cancel API URLs for resuming the flow\n */\nasync getResumeUrls(approver?: string, flowLevel?: boolean): Promise<{\n approvalPage: string;\n resume: string;\n cancel: string;\n}>\n\n/**\n * @deprecated use getResumeUrls instead\n */\ngetResumeEndpoints(approver?: string): Promise<{\n approvalPage: string;\n resume: string;\n cancel: string;\n}>\n\n/**\n * Get an OIDC jwt token for auth to external services (e.g: Vault, AWS) (ee only)\n * @param audience audience of the token\n * @param expiresIn Optional number of seconds until the token expires\n * @returns jwt token\n */\nasync getIdToken(audience: string, expiresIn?: number): Promise\n\n/**\n * Convert a base64-encoded string to Uint8Array\n * @param data - Base64-encoded string\n * @returns Decoded Uint8Array\n */\nbase64ToUint8Array(data: string): Uint8Array\n\n/**\n * Convert a Uint8Array to base64-encoded string\n * @param arrayBuffer - Uint8Array to encode\n * @returns Base64-encoded string\n */\nuint8ArrayToBase64(arrayBuffer: Uint8Array): string\n\n/**\n * Get email from workspace username\n * This method is particularly useful for apps that require the email address of the viewer.\n * Indeed, in the viewer context, WM_USERNAME is set to the username of the viewer but WM_EMAIL is set to the email of the creator of the app.\n * @param username\n * @returns email address\n */\nasync usernameToEmail(username: string): Promise\n\n/**\n * Sends an interactive approval request via Slack, allowing optional customization of the message, approver, and form fields.\n * \n * **[Enterprise Edition Only]** To include form fields in the Slack approval request, go to **Advanced -> Suspend -> Form**\n * and define a form. Learn more at [Windmill Documentation](https://www.windmill.dev/docs/flows/flow_approval#form).\n * \n * @param {Object} options - The configuration options for the Slack approval request.\n * @param {string} options.slackResourcePath - The path to the Slack resource in Windmill.\n * @param {string} options.channelId - The Slack channel ID where the approval request will be sent.\n * @param {string} [options.message] - Optional custom message to include in the Slack approval request.\n * @param {string} [options.approver] - Optional user ID or name of the approver for the request.\n * @param {DefaultArgs} [options.defaultArgsJson] - Optional object defining or overriding the default arguments to a form field.\n * @param {Enums} [options.dynamicEnumsJson] - Optional object overriding the enum default values of an enum form field.\n * @param {string} [options.resumeButtonText] - Optional text for the resume button.\n * @param {string} [options.cancelButtonText] - Optional text for the cancel button.\n * \n * @returns {Promise} Resolves when the Slack approval request is successfully sent.\n * \n * @throws {Error} If the function is not called within a flow or flow preview.\n * @throws {Error} If the `JobService.getSlackApprovalPayload` call fails.\n * \n * **Usage Example:**\n * ```typescript\n * await requestInteractiveSlackApproval({\n * slackResourcePath: \"/u/alex/my_slack_resource\",\n * channelId: \"admins-slack-channel\",\n * message: \"Please approve this request\",\n * approver: \"approver123\",\n * defaultArgsJson: { key1: \"value1\", key2: 42 },\n * dynamicEnumsJson: { foo: [\"choice1\", \"choice2\"], bar: [\"optionA\", \"optionB\"] },\n * resumeButtonText: \"Resume\",\n * cancelButtonText: \"Cancel\",\n * });\n * ```\n * \n * **Note:** This function requires execution within a Windmill flow or flow preview.\n */\nasync requestInteractiveSlackApproval({ slackResourcePath, channelId, message, approver, defaultArgsJson, dynamicEnumsJson, resumeButtonText, cancelButtonText, }: SlackApprovalOptions): Promise\n\n/**\n * Sends an interactive approval request via Teams, allowing optional customization of the message, approver, and form fields.\n * \n * **[Enterprise Edition Only]** To include form fields in the Teams approval request, go to **Advanced -> Suspend -> Form**\n * and define a form. Learn more at [Windmill Documentation](https://www.windmill.dev/docs/flows/flow_approval#form).\n * \n * @param {Object} options - The configuration options for the Teams approval request.\n * @param {string} options.teamName - The Teams team name where the approval request will be sent.\n * @param {string} options.channelName - The Teams channel name where the approval request will be sent.\n * @param {string} [options.message] - Optional custom message to include in the Teams approval request.\n * @param {string} [options.approver] - Optional user ID or name of the approver for the request.\n * @param {DefaultArgs} [options.defaultArgsJson] - Optional object defining or overriding the default arguments to a form field.\n * @param {Enums} [options.dynamicEnumsJson] - Optional object overriding the enum default values of an enum form field.\n * \n * @returns {Promise} Resolves when the Teams approval request is successfully sent.\n * \n * @throws {Error} If the function is not called within a flow or flow preview.\n * @throws {Error} If the `JobService.getTeamsApprovalPayload` call fails.\n * \n * **Usage Example:**\n * ```typescript\n * await requestInteractiveTeamsApproval({\n * teamName: \"admins-teams\",\n * channelName: \"admins-teams-channel\",\n * message: \"Please approve this request\",\n * approver: \"approver123\",\n * defaultArgsJson: { key1: \"value1\", key2: 42 },\n * dynamicEnumsJson: { foo: [\"choice1\", \"choice2\"], bar: [\"optionA\", \"optionB\"] },\n * });\n * ```\n * \n * **Note:** This function requires execution within a Windmill flow or flow preview.\n */\nasync requestInteractiveTeamsApproval({ teamName, channelName, message, approver, defaultArgsJson, dynamicEnumsJson, }: TeamsApprovalOptions): Promise\n\n/**\n * Parse an S3 object from URI string or record format\n * @param s3Object - S3 object as URI string (s3://storage/key) or record\n * @returns S3 object record with storage and s3 key\n */\nparseS3Object(s3Object: S3Object): S3ObjectRecord\n\nsetWorkflowCtx(ctx: WorkflowCtx | null): void\n\nasync sleep(seconds: number): Promise\n\nasync step(name: string, fn: () => T | Promise): Promise\n\n/**\n * Create a task that dispatches to a separate Windmill script.\n * \n * @example\n * const extract = taskScript(\"f/data/extract\");\n * // inside workflow: await extract({ url: \"https://...\" })\n */\ntaskScript(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike\n\n/**\n * Create a task that dispatches to a separate Windmill flow.\n * \n * @example\n * const pipeline = taskFlow(\"f/etl/pipeline\");\n * // inside workflow: await pipeline({ input: data })\n */\ntaskFlow(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike\n\n/**\n * Mark an async function as a workflow-as-code entry point.\n * \n * The function must be **deterministic**: given the same inputs it must call\n * tasks in the same order on every replay. Branching on task results is fine\n * (results are replayed from checkpoint), but branching on external state\n * (current time, random values, external API calls) must use `step()` to\n * checkpoint the value so replays see the same result.\n */\nworkflow(fn: (...args: any[]) => Promise): void\n\n/**\n * Suspend the workflow and wait for an external approval.\n * \n * Use `getResumeUrls()` (wrapped in `step()`) to obtain resume/cancel/approvalPage\n * URLs before calling this function.\n * \n * @example\n * const urls = await step(\"urls\", () => getResumeUrls());\n * await step(\"notify\", () => sendEmail(urls.approvalPage));\n * const { value, approver } = await waitForApproval({ timeout: 3600 });\n */\nwaitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }>\n\n/**\n * Process items in parallel with optional concurrency control.\n * \n * Each item is processed by calling `fn(item)`, which should be a task().\n * Items are dispatched in batches of `concurrency` (default: all at once).\n * \n * @example\n * const process = task(async (item: string) => { ... });\n * const results = await parallel(items, process, { concurrency: 5 });\n */\nasync parallel(items: T[], fn: (item: T) => PromiseLike | R, options?: { concurrency?: number },): Promise\n\n/**\n * Commit Kafka offsets for a trigger with auto_commit disabled.\n * @param triggerPath - Path to the Kafka trigger (from event.wm_trigger.trigger_path)\n * @param topic - Kafka topic name (from event.topic)\n * @param partition - Partition number (from event.partition)\n * @param offset - Message offset to commit (from event.offset)\n */\nasync commitKafkaOffsets(triggerPath: string, topic: string, partition: number, offset: number,): Promise\n\n/**\n * Create a SQL template function for PostgreSQL/datatable queries\n * @param name - Database/datatable name (default: \"main\")\n * @returns SQL template function for building parameterized queries\n * @example\n * let sql = wmill.datatable()\n * let name = 'Robin'\n * let age = 21\n * await sql`\n * SELECT * FROM friends\n * WHERE name = ${name} AND age = ${age}::int\n * `.fetch()\n */\ndatatable(name: string = \"main\"): DatatableSqlTemplateFunction\n\n/**\n * Create a SQL template function for DuckDB/ducklake queries\n * @param name - DuckDB database name (default: \"main\")\n * @returns SQL template function for building parameterized queries\n * @example\n * let sql = wmill.ducklake()\n * let name = 'Robin'\n * let age = 21\n * await sql`\n * SELECT * FROM friends\n * WHERE name = ${name} AND age = ${age}\n * `.fetch()\n */\nducklake(name: string = \"main\"): SqlTemplateFunction\n"; @@ -8,8 +10,8 @@ export declare const WAC_SDK_TYPESCRIPT = "## TypeScript Workflow-as-Code API (w export declare const WAC_SDK_PYTHON = "## Python Workflow-as-Code API (wmill)\n\nImport: `from wmill import workflow, task, task_script, task_flow, step, sleep, wait_for_approval, get_resume_urls, parallel, TaskError`\n\n```python\n# Raised when a WAC task step failed.\n#\n# Attributes:\n# step_key: The checkpoint key of the failed step.\n# child_job_id: The UUID of the failed child job.\n# result: The error result from the child job.\nclass TaskError(Exception):\n def __init__(self, message: str, *, step_key: str = '', child_job_id: str = '', result = None)\n\n# Get URLs needed for resuming a flow after suspension.\n#\n# Args:\n# approver: Optional approver name\n# flow_level: If True, generate resume URLs for the parent flow instead of the\n# specific step. This allows pre-approvals that can be consumed by any later\n# suspend step in the same flow.\n#\n# Returns:\n# Dictionary with approvalPage, resume, and cancel URLs\ndef get_resume_urls(approver: str = None, flow_level: bool = None) -> dict\n\n# Decorator that marks a function as a workflow task.\n#\n# Works in both WAC v1 (sync, HTTP-based dispatch) and WAC v2\n# (async, checkpoint/replay) modes:\n#\n# - **v2 (inside @workflow)**: dispatches as a checkpoint step.\n# - **v1 (WM_JOB_ID set, no @workflow)**: dispatches via HTTP API.\n# - **Standalone**: executes the function body directly.\n#\n# Usage::\n#\n# @task\n# async def extract_data(url: str): ...\n#\n# @task(path=\"f/external_script\", timeout=600, tag=\"gpu\")\n# async def run_external(x: int): ...\ndef task(_func = None, *, path: Optional[str] = None, tag: Optional[str] = None, timeout: Optional[int] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None)\n\n# Create a task that dispatches to a separate Windmill script.\n#\n# Usage::\n#\n# extract = task_script(\"f/data/extract\", timeout=600)\n#\n# @workflow\n# async def main():\n# data = await extract(url=\"https://...\")\ndef task_script(path: str, *, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None)\n\n# Create a task that dispatches to a separate Windmill flow.\n#\n# Usage::\n#\n# pipeline = task_flow(\"f/etl/pipeline\", priority=10)\n#\n# @workflow\n# async def main():\n# result = await pipeline(input=data)\ndef task_flow(path: str, *, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None)\n\n# Decorator marking an async function as a workflow-as-code entry point.\n#\n# The function must be **deterministic**: given the same inputs it must call\n# tasks in the same order on every replay. Branching on task results is fine\n# (results are replayed from checkpoint), but branching on external state\n# (current time, random values, external API calls) must use ``step()`` to\n# checkpoint the value so replays see the same result.\ndef workflow(func)\n\n# Execute ``fn`` inline and checkpoint the result.\n#\n# On replay the cached value is returned without re-executing ``fn``.\n# Use for lightweight deterministic operations (timestamps, random IDs,\n# config reads) that should not incur the overhead of a child job.\nasync def step(name: str, fn)\n\n# Server-side sleep \u2014 suspend the workflow for the given duration without holding a worker.\n#\n# Inside a @workflow, the parent job suspends and auto-resumes after ``seconds``.\n# Outside a workflow, falls back to ``asyncio.sleep``.\nasync def sleep(seconds: int)\n\n# Suspend the workflow and wait for an external approval.\n#\n# Use ``get_resume_urls()`` (wrapped in ``step()``) to obtain\n# resume/cancel/approval URLs before calling this function.\n#\n# Returns a dict with ``value`` (form data), ``approver``, and ``approved``.\n#\n# Args:\n# timeout: Approval timeout in seconds (default 1800).\n# form: Optional form schema for the approval page.\n# self_approval: Whether the user who triggered the flow can approve it (default True).\n#\n# Example::\n#\n# urls = await step(\"urls\", lambda: get_resume_urls())\n# await step(\"notify\", lambda: send_email(urls[\"approvalPage\"]))\n# result = await wait_for_approval(timeout=3600)\nasync def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True) -> dict\n\n# Process items in parallel with optional concurrency control.\n#\n# Each item is processed by calling ``fn(item)``, which should be a @task.\n# Items are dispatched in batches of ``concurrency`` (default: all at once).\n#\n# Example::\n#\n# @task\n# async def process(item: str):\n# ...\n#\n# results = await parallel(items, process, concurrency=5)\nasync def parallel(items, fn, *, concurrency: Optional[int] = None)\n```\n"; export declare const DATATABLE_SDK_TYPESCRIPT = "## TypeScript Datatable API (windmill-client)\n\nImport: `import * as wmill from 'windmill-client'`\n\nSQL statement object with query content, arguments, and execution methods\n```typescript\ntype SqlStatement = {\n /** Raw SQL content with formatted arguments */\n content: string;\n\n /** Argument values keyed by parameter name */\n args: Record;\n\n /**\n * Execute the SQL query and return results\n * @param params - Optional parameters including result collection mode\n * @returns Query results based on the result collection mode\n */\n fetch(\n params?: FetchParams // The union is for auto-completion\n ): Promise>;\n\n /**\n * Execute the SQL query and return only the first row\n * @param params - Optional parameters\n * @returns First row of the query result\n */\n fetchOne(\n params?: Omit, \"resultCollection\">\n ): Promise>;\n\n /**\n * Execute the SQL query and return only the first row as a scalar value\n * @param params - Optional parameters\n * @returns First row of the query result\n */\n fetchOneScalar(\n params?: Omit<\n FetchParams<\"last_statement_first_row_scalar\">,\n \"resultCollection\"\n >\n ): Promise>;\n\n /**\n * Execute the SQL query without fetching rows\n * @param params - Optional parameters\n */\n execute(\n params?: Omit, \"resultCollection\">\n ): Promise;\n};\n```\n\n```typescript\n// Template tag function: sql`SELECT * FROM table WHERE id = ${id}`.fetch()\ninterface DatatableSqlTemplateFunction {\n // Tagged template usage:\n (strings: TemplateStringsArray, ...values: any[]): SqlStatement;\n query(sql: string, ...params: any[]): SqlStatement;\n};\n```\n\nCreate a SQL template function for PostgreSQL/datatable queries\n@param name - Database/datatable name (default: \"main\")\n@returns SQL template function for building parameterized queries\n@example\nlet sql = wmill.datatable()\nlet name = 'Robin'\nlet age = 21\nawait sql`\n SELECT * FROM friends\n WHERE name = ${name} AND age = ${age}::int\n`.fetch()\n```typescript\nfunction datatable(name: string = \"main\"): DatatableSqlTemplateFunction\n```\n"; export declare const DATATABLE_SDK_PYTHON = "## Python Datatable API (wmill)\n\nImport: `import wmill`\n\n# Get a DataTable client for SQL queries.\n# \n# Args:\n# name: Database name (default: \"main\")\n# \n# Returns:\n# DataTableClient instance\ndef datatable(name: str = 'main') -> DataTableClient\n\n# Client for executing SQL queries against Windmill DataTables.\nclass DataTableClient:\n # Initialize DataTableClient.\n # \n # Args:\n # client: Windmill client instance\n # name: DataTable name\n def __init__(client: Windmill, name: str)\n\n # Execute a SQL query against the DataTable.\n # \n # Args:\n # sql: SQL query string with $1, $2, etc. placeholders\n # *args: Positional arguments to bind to query placeholders\n # \n # Returns:\n # SqlQuery instance for fetching results\n def query(sql: str, *args) -> SqlQuery\n\n\n# Query result handler for DataTable and DuckLake queries.\nclass SqlQuery:\n # Initialize SqlQuery.\n # \n # Args:\n # sql: SQL query string\n # fetch_fn: Function to execute the query\n def __init__(sql: str, fetch_fn)\n\n # Execute query and fetch results.\n # \n # Args:\n # result_collection: Optional result collection mode\n # \n # Returns:\n # Query results\n def fetch(result_collection: str | None = None)\n\n # Execute query and fetch first row of results.\n # \n # Returns:\n # First row of query results\n def fetch_one()\n\n # Execute query and fetch first row of results. Return result as a scalar value.\n # \n # Returns:\n # First row of query result as a scalar value\n def fetch_one_scalar()\n\n # Execute query and don't return any results.\n # \n def execute()\n\n\n"; -export declare const OPENFLOW_SCHEMA = "## OpenFlow Schema\n\n{\"OpenFlow\":{\"type\":\"object\",\"description\":\"Top-level flow definition containing metadata, configuration, and the flow structure\",\"properties\":{\"summary\":{\"type\":\"string\",\"description\":\"Short description of what this flow does\"},\"description\":{\"type\":\"string\",\"description\":\"Detailed documentation for this flow\"},\"value\":{\"$ref\":\"#/components/schemas/FlowValue\"},\"schema\":{\"type\":\"object\",\"description\":\"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')\"},\"on_behalf_of_email\":{\"type\":\"string\",\"description\":\"The flow will be run with the permissions of the user with this email.\"}},\"required\":[\"summary\",\"value\"]},\"FlowValue\":{\"type\":\"object\",\"description\":\"The flow structure containing modules and optional preprocessor/failure handlers\",\"properties\":{\"modules\":{\"type\":\"array\",\"description\":\"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}},\"failure_module\":{\"description\":\"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types\",\"$ref\":\"#/components/schemas/FlowModule\"},\"preprocessor_module\":{\"description\":\"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results\",\"$ref\":\"#/components/schemas/FlowModule\"},\"same_worker\":{\"type\":\"boolean\",\"description\":\"If true, all steps run on the same worker for better performance\"},\"concurrent_limit\":{\"type\":\"number\",\"description\":\"Maximum number of concurrent executions of this flow\"},\"concurrency_key\":{\"type\":\"string\",\"description\":\"Expression to group concurrent executions (e.g., by user ID)\"},\"concurrency_time_window_s\":{\"type\":\"number\",\"description\":\"Time window in seconds for concurrent_limit\"},\"debounce_delay_s\":{\"type\":\"integer\",\"description\":\"Delay in seconds to debounce flow executions\"},\"debounce_key\":{\"type\":\"string\",\"description\":\"Expression to group debounced executions\"},\"debounce_args_to_accumulate\":{\"type\":\"array\",\"description\":\"Arguments to accumulate across debounced executions\",\"items\":{\"type\":\"string\"}},\"max_total_debouncing_time\":{\"type\":\"integer\",\"description\":\"Maximum total time in seconds that a job can be debounced\"},\"max_total_debounces_amount\":{\"type\":\"integer\",\"description\":\"Maximum number of times a job can be debounced\"},\"skip_expr\":{\"type\":\"string\",\"description\":\"JavaScript expression to conditionally skip the entire flow\"},\"cache_ttl\":{\"type\":\"number\",\"description\":\"Cache duration in seconds for flow results\"},\"cache_ignore_s3_path\":{\"type\":\"boolean\"},\"delete_after_secs\":{\"type\":\"integer\",\"description\":\"If set, delete the flow job's args, result and logs after this many seconds following job completion\"},\"flow_env\":{\"type\":\"object\",\"description\":\"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).\",\"additionalProperties\":{}},\"priority\":{\"type\":\"number\",\"description\":\"Execution priority (higher numbers run first)\"},\"early_return\":{\"type\":\"string\",\"description\":\"JavaScript expression to return early from the flow\"},\"chat_input_enabled\":{\"type\":\"boolean\",\"description\":\"Whether this flow accepts chat-style input\"},\"notes\":{\"type\":\"array\",\"description\":\"Sticky notes attached to the flow\",\"items\":{\"$ref\":\"#/components/schemas/FlowNote\"}},\"groups\":{\"type\":\"array\",\"description\":\"Semantic groups of modules for organizational purposes\",\"items\":{\"$ref\":\"#/components/schemas/FlowGroup\"}}},\"required\":[\"modules\"]},\"Retry\":{\"type\":\"object\",\"description\":\"Retry configuration for failed module executions\",\"properties\":{\"constant\":{\"type\":\"object\",\"description\":\"Retry with constant delay between attempts\",\"properties\":{\"attempts\":{\"type\":\"integer\",\"description\":\"Number of retry attempts\"},\"seconds\":{\"type\":\"integer\",\"description\":\"Seconds to wait between retries\"}}},\"exponential\":{\"type\":\"object\",\"description\":\"Retry with exponential backoff (delay doubles each time)\",\"properties\":{\"attempts\":{\"type\":\"integer\",\"description\":\"Number of retry attempts\"},\"multiplier\":{\"type\":\"integer\",\"description\":\"Multiplier for exponential backoff\"},\"seconds\":{\"type\":\"integer\",\"minimum\":1,\"description\":\"Initial delay in seconds\"},\"random_factor\":{\"type\":\"integer\",\"minimum\":0,\"maximum\":100,\"description\":\"Random jitter percentage (0-100) to avoid thundering herd\"}}},\"retry_if\":{\"$ref\":\"#/components/schemas/RetryIf\"}}},\"FlowNote\":{\"type\":\"object\",\"description\":\"A sticky note attached to a flow for documentation and annotation\",\"properties\":{\"id\":{\"type\":\"string\",\"description\":\"Unique identifier for the note\"},\"text\":{\"type\":\"string\",\"description\":\"Content of the note\"},\"position\":{\"type\":\"object\",\"description\":\"Position of the note in the flow editor\",\"properties\":{\"x\":{\"type\":\"number\",\"description\":\"X coordinate\"},\"y\":{\"type\":\"number\",\"description\":\"Y coordinate\"}},\"required\":[\"x\",\"y\"]},\"size\":{\"type\":\"object\",\"description\":\"Size of the note in the flow editor\",\"properties\":{\"width\":{\"type\":\"number\",\"description\":\"Width in pixels\"},\"height\":{\"type\":\"number\",\"description\":\"Height in pixels\"}},\"required\":[\"width\",\"height\"]},\"color\":{\"type\":\"string\",\"description\":\"Color of the note (e.g., \\\"yellow\\\", \\\"#ffff00\\\")\"},\"type\":{\"type\":\"string\",\"enum\":[\"free\",\"group\"],\"description\":\"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes\"},\"locked\":{\"type\":\"boolean\",\"default\":false,\"description\":\"Whether the note is locked and cannot be edited or moved\"},\"contained_node_ids\":{\"type\":\"array\",\"items\":{\"type\":\"string\"},\"description\":\"For group notes, the IDs of nodes contained within this group\"}},\"required\":[\"id\",\"text\",\"color\",\"type\"]},\"FlowGroup\":{\"type\":\"object\",\"description\":\"A semantic group of flow modules for organizational purposes. Does not affect execution \\u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.\",\"properties\":{\"summary\":{\"type\":\"string\",\"description\":\"Display name for this group\"},\"note\":{\"type\":\"string\",\"description\":\"Markdown note shown below the group header\"},\"autocollapse\":{\"type\":\"boolean\",\"default\":false,\"description\":\"If true, this group is collapsed by default in the flow editor. UI hint only.\"},\"start_id\":{\"type\":\"string\",\"description\":\"ID of the first flow module in this group (topological entry point)\"},\"end_id\":{\"type\":\"string\",\"description\":\"ID of the last flow module in this group (topological exit point)\"},\"color\":{\"type\":\"string\",\"description\":\"Color for the group in the flow editor\"}},\"required\":[\"start_id\",\"end_id\"]},\"RetryIf\":{\"type\":\"object\",\"description\":\"Conditional retry based on error or result\",\"properties\":{\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables\"}},\"required\":[\"expr\"]},\"StopAfterIf\":{\"type\":\"object\",\"description\":\"Early termination condition for a module\",\"properties\":{\"skip_if_stopped\":{\"type\":\"boolean\",\"description\":\"If true, following steps are skipped when this condition triggers\"},\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop\"},\"error_message\":{\"type\":\"string\",\"nullable\":true,\"description\":\"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.\"}},\"required\":[\"expr\"]},\"FlowModule\":{\"type\":\"object\",\"description\":\"A single step in a flow. Can be a script, subflow, loop, or branch\",\"properties\":{\"id\":{\"type\":\"string\",\"description\":\"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)\"},\"value\":{\"$ref\":\"#/components/schemas/FlowModuleValue\"},\"stop_after_if\":{\"description\":\"Early termination condition evaluated after this step completes\",\"$ref\":\"#/components/schemas/StopAfterIf\"},\"stop_after_all_iters_if\":{\"description\":\"For loops only - early termination condition evaluated after all iterations complete\",\"$ref\":\"#/components/schemas/StopAfterIf\"},\"skip_if\":{\"type\":\"object\",\"description\":\"Conditionally skip this step based on previous results or flow inputs\",\"properties\":{\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'\"}},\"required\":[\"expr\"]},\"sleep\":{\"description\":\"Delay before executing this step (in seconds or as expression)\",\"$ref\":\"#/components/schemas/InputTransform\"},\"cache_ttl\":{\"type\":\"number\",\"description\":\"Cache duration in seconds for this step's results\"},\"cache_ignore_s3_path\":{\"type\":\"boolean\"},\"timeout\":{\"description\":\"Maximum execution time in seconds (static value or expression)\",\"$ref\":\"#/components/schemas/InputTransform\"},\"delete_after_secs\":{\"type\":\"integer\",\"description\":\"If set, delete the step's args, result and logs after this many seconds following job completion\"},\"summary\":{\"type\":\"string\",\"description\":\"Short description of what this step does\"},\"mock\":{\"type\":\"object\",\"description\":\"Mock configuration for testing without executing the actual step\",\"properties\":{\"enabled\":{\"type\":\"boolean\",\"description\":\"If true, return mock value instead of executing\"},\"return_value\":{\"description\":\"Value to return when mocked\"}}},\"suspend\":{\"type\":\"object\",\"description\":\"Configuration for approval/resume steps that wait for user input\",\"properties\":{\"required_events\":{\"type\":\"integer\",\"description\":\"Number of approvals required before continuing\"},\"timeout\":{\"type\":\"integer\",\"description\":\"Timeout in seconds before auto-continuing or canceling\"},\"resume_form\":{\"type\":\"object\",\"description\":\"Form schema for collecting input when resuming\",\"properties\":{\"schema\":{\"type\":\"object\",\"description\":\"JSON Schema for the resume form\"}}},\"user_auth_required\":{\"type\":\"boolean\",\"description\":\"If true, only authenticated users can approve\"},\"user_groups_required\":{\"description\":\"Expression or list of groups that can approve\",\"$ref\":\"#/components/schemas/InputTransform\"},\"self_approval_disabled\":{\"type\":\"boolean\",\"description\":\"If true, the user who started the flow cannot approve\"},\"hide_cancel\":{\"type\":\"boolean\",\"description\":\"If true, hide the cancel button on the approval form\"},\"continue_on_disapprove_timeout\":{\"type\":\"boolean\",\"description\":\"If true, continue flow on timeout instead of canceling\"}}},\"priority\":{\"type\":\"number\",\"description\":\"Execution priority for this step (higher numbers run first)\"},\"continue_on_error\":{\"type\":\"boolean\",\"description\":\"If true, flow continues even if this step fails\"},\"retry\":{\"description\":\"Retry configuration if this step fails\",\"$ref\":\"#/components/schemas/Retry\"},\"debouncing\":{\"description\":\"Debounce configuration for this step (EE only)\",\"type\":\"object\",\"properties\":{\"debounce_delay_s\":{\"type\":\"integer\",\"description\":\"Delay in seconds to debounce this step's executions across flow runs\"},\"debounce_key\":{\"type\":\"string\",\"description\":\"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-\"},\"debounce_args_to_accumulate\":{\"type\":\"array\",\"description\":\"Array-type arguments to accumulate across debounced executions\",\"items\":{\"type\":\"string\"}},\"max_total_debouncing_time\":{\"type\":\"integer\",\"description\":\"Maximum total time in seconds before forced execution\"},\"max_total_debounces_amount\":{\"type\":\"integer\",\"description\":\"Maximum number of debounces before forced execution\"}}}},\"required\":[\"value\",\"id\"]},\"InputTransform\":{\"description\":\"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs\",\"oneOf\":[{\"$ref\":\"#/components/schemas/StaticTransform\"},{\"$ref\":\"#/components/schemas/JavascriptTransform\"},{\"$ref\":\"#/components/schemas/AiTransform\"}],\"discriminator\":{\"propertyName\":\"type\",\"mapping\":{\"static\":\"#/components/schemas/StaticTransform\",\"javascript\":\"#/components/schemas/JavascriptTransform\",\"ai\":\"#/components/schemas/AiTransform\"}}},\"StaticTransform\":{\"type\":\"object\",\"description\":\"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'\",\"properties\":{\"value\":{\"description\":\"The static value. For resources, use format '$res:path/to/resource'\"},\"type\":{\"type\":\"string\",\"enum\":[\"static\"]}},\"required\":[\"type\"]},\"JavascriptTransform\":{\"type\":\"object\",\"description\":\"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value\",\"properties\":{\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)\"},\"type\":{\"type\":\"string\",\"enum\":[\"javascript\"]}},\"required\":[\"expr\",\"type\"]},\"AiTransform\":{\"type\":\"object\",\"description\":\"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"ai\"]}},\"required\":[\"type\"]},\"AIProviderKind\":{\"type\":\"string\",\"description\":\"Supported AI provider types\",\"enum\":[\"openai\",\"azure_openai\",\"anthropic\",\"mistral\",\"deepseek\",\"googleai\",\"groq\",\"openrouter\",\"togetherai\",\"customai\",\"aws_bedrock\"]},\"ProviderConfig\":{\"type\":\"object\",\"description\":\"Complete AI provider configuration with resource reference and model selection\",\"properties\":{\"kind\":{\"$ref\":\"#/components/schemas/AIProviderKind\"},\"resource\":{\"type\":\"string\",\"description\":\"Resource reference in format '$res:{resource_path}' pointing to provider credentials\"},\"model\":{\"type\":\"string\",\"description\":\"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')\"}},\"required\":[\"kind\",\"resource\",\"model\"]},\"StaticProviderTransform\":{\"type\":\"object\",\"description\":\"Static provider configuration passed directly to the AI agent\",\"properties\":{\"value\":{\"$ref\":\"#/components/schemas/ProviderConfig\"},\"type\":{\"type\":\"string\",\"enum\":[\"static\"]}},\"required\":[\"type\",\"value\"]},\"ProviderTransform\":{\"description\":\"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined\",\"oneOf\":[{\"$ref\":\"#/components/schemas/StaticProviderTransform\"},{\"$ref\":\"#/components/schemas/JavascriptTransform\"},{\"$ref\":\"#/components/schemas/AiTransform\"}],\"discriminator\":{\"propertyName\":\"type\",\"mapping\":{\"static\":\"#/components/schemas/StaticProviderTransform\",\"javascript\":\"#/components/schemas/JavascriptTransform\",\"ai\":\"#/components/schemas/AiTransform\"}}},\"MemoryOff\":{\"type\":\"object\",\"description\":\"No conversation memory/context\",\"properties\":{\"kind\":{\"type\":\"string\",\"enum\":[\"off\"]}},\"required\":[\"kind\"]},\"MemoryAuto\":{\"type\":\"object\",\"description\":\"Automatic context management\",\"properties\":{\"kind\":{\"type\":\"string\",\"enum\":[\"auto\"]},\"context_length\":{\"type\":\"integer\",\"description\":\"Maximum number of messages to retain in context\"},\"memory_id\":{\"type\":\"string\",\"description\":\"Identifier for persistent memory across agent invocations\"}},\"required\":[\"kind\"]},\"MemoryMessage\":{\"type\":\"object\",\"description\":\"A single message in conversation history\",\"properties\":{\"role\":{\"type\":\"string\",\"enum\":[\"user\",\"assistant\",\"system\"]},\"content\":{\"type\":\"string\"}},\"required\":[\"role\",\"content\"]},\"MemoryManual\":{\"type\":\"object\",\"description\":\"Explicit message history\",\"properties\":{\"kind\":{\"type\":\"string\",\"enum\":[\"manual\"]},\"messages\":{\"type\":\"array\",\"items\":{\"$ref\":\"#/components/schemas/MemoryMessage\"}}},\"required\":[\"kind\",\"messages\"]},\"MemoryConfig\":{\"description\":\"Conversation memory configuration\",\"oneOf\":[{\"$ref\":\"#/components/schemas/MemoryOff\"},{\"$ref\":\"#/components/schemas/MemoryAuto\"},{\"$ref\":\"#/components/schemas/MemoryManual\"}],\"discriminator\":{\"propertyName\":\"kind\",\"mapping\":{\"off\":\"#/components/schemas/MemoryOff\",\"auto\":\"#/components/schemas/MemoryAuto\",\"manual\":\"#/components/schemas/MemoryManual\"}}},\"StaticMemoryTransform\":{\"type\":\"object\",\"description\":\"Static memory configuration passed directly to the AI agent\",\"properties\":{\"value\":{\"$ref\":\"#/components/schemas/MemoryConfig\"},\"type\":{\"type\":\"string\",\"enum\":[\"static\"]}},\"required\":[\"type\",\"value\"]},\"MemoryTransform\":{\"description\":\"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined\",\"oneOf\":[{\"$ref\":\"#/components/schemas/StaticMemoryTransform\"},{\"$ref\":\"#/components/schemas/JavascriptTransform\"},{\"$ref\":\"#/components/schemas/AiTransform\"}],\"discriminator\":{\"propertyName\":\"type\",\"mapping\":{\"static\":\"#/components/schemas/StaticMemoryTransform\",\"javascript\":\"#/components/schemas/JavascriptTransform\",\"ai\":\"#/components/schemas/AiTransform\"}}},\"FlowModuleValue\":{\"description\":\"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type\",\"oneOf\":[{\"$ref\":\"#/components/schemas/RawScript\"},{\"$ref\":\"#/components/schemas/PathScript\"},{\"$ref\":\"#/components/schemas/PathFlow\"},{\"$ref\":\"#/components/schemas/ForloopFlow\"},{\"$ref\":\"#/components/schemas/WhileloopFlow\"},{\"$ref\":\"#/components/schemas/BranchOne\"},{\"$ref\":\"#/components/schemas/BranchAll\"},{\"$ref\":\"#/components/schemas/Identity\"},{\"$ref\":\"#/components/schemas/AiAgent\"}],\"discriminator\":{\"propertyName\":\"type\",\"mapping\":{\"rawscript\":\"#/components/schemas/RawScript\",\"script\":\"#/components/schemas/PathScript\",\"flow\":\"#/components/schemas/PathFlow\",\"forloopflow\":\"#/components/schemas/ForloopFlow\",\"whileloopflow\":\"#/components/schemas/WhileloopFlow\",\"branchone\":\"#/components/schemas/BranchOne\",\"branchall\":\"#/components/schemas/BranchAll\",\"identity\":\"#/components/schemas/Identity\",\"aiagent\":\"#/components/schemas/AiAgent\"}}},\"RawScript\":{\"type\":\"object\",\"description\":\"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms\",\"properties\":{\"input_transforms\":{\"type\":\"object\",\"description\":\"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments\",\"additionalProperties\":{\"$ref\":\"#/components/schemas/InputTransform\"}},\"content\":{\"type\":\"string\",\"description\":\"The script source code. Should export a 'main' function\"},\"language\":{\"type\":\"string\",\"description\":\"Programming language for this script\",\"enum\":[\"deno\",\"bun\",\"python3\",\"go\",\"bash\",\"powershell\",\"postgresql\",\"mysql\",\"bigquery\",\"snowflake\",\"mssql\",\"oracledb\",\"graphql\",\"nativets\",\"php\",\"rust\",\"ansible\",\"csharp\",\"nu\",\"java\",\"ruby\",\"rlang\",\"duckdb\"]},\"path\":{\"type\":\"string\",\"description\":\"Optional path for saving this script\"},\"lock\":{\"type\":\"string\",\"description\":\"Lock file content for dependencies\"},\"type\":{\"type\":\"string\",\"enum\":[\"rawscript\"]},\"tag\":{\"type\":\"string\",\"description\":\"Worker group tag for execution routing\"},\"concurrent_limit\":{\"type\":\"number\",\"description\":\"Maximum concurrent executions of this script\"},\"concurrency_time_window_s\":{\"type\":\"number\",\"description\":\"Time window for concurrent_limit\"},\"custom_concurrency_key\":{\"type\":\"string\",\"description\":\"Custom key for grouping concurrent executions\"},\"is_trigger\":{\"type\":\"boolean\",\"description\":\"If true, this script is a trigger that can start the flow\"},\"assets\":{\"type\":\"array\",\"description\":\"External resources this script accesses (S3 objects, resources, etc.)\",\"items\":{\"type\":\"object\",\"required\":[\"path\",\"kind\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the asset\"},\"kind\":{\"type\":\"string\",\"description\":\"Type of asset\",\"enum\":[\"s3object\",\"resource\",\"ducklake\",\"datatable\",\"volume\"]},\"access_type\":{\"type\":\"string\",\"nullable\":true,\"description\":\"Access level for this asset\",\"enum\":[\"r\",\"w\",\"rw\"]},\"alt_access_type\":{\"type\":\"string\",\"nullable\":true,\"description\":\"Alternative access level\",\"enum\":[\"r\",\"w\",\"rw\"]}}}}},\"required\":[\"type\",\"content\",\"language\",\"input_transforms\"]},\"PathScript\":{\"type\":\"object\",\"description\":\"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code\",\"properties\":{\"input_transforms\":{\"type\":\"object\",\"description\":\"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments\",\"additionalProperties\":{\"$ref\":\"#/components/schemas/InputTransform\"}},\"path\":{\"type\":\"string\",\"description\":\"Path to the script in the workspace (e.g., 'f/scripts/send_email')\"},\"hash\":{\"type\":\"string\",\"description\":\"Optional specific version hash of the script to use\"},\"type\":{\"type\":\"string\",\"enum\":[\"script\"]},\"tag_override\":{\"type\":\"string\",\"description\":\"Override the script's default worker group tag\"},\"is_trigger\":{\"type\":\"boolean\",\"description\":\"If true, this script is a trigger that can start the flow\"}},\"required\":[\"type\",\"path\",\"input_transforms\"]},\"PathFlow\":{\"type\":\"object\",\"description\":\"Reference to an existing flow by path. Use this to call another flow as a subflow\",\"properties\":{\"input_transforms\":{\"type\":\"object\",\"description\":\"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments\",\"additionalProperties\":{\"$ref\":\"#/components/schemas/InputTransform\"}},\"path\":{\"type\":\"string\",\"description\":\"Path to the flow in the workspace (e.g., 'f/flows/process_user')\"},\"type\":{\"type\":\"string\",\"enum\":[\"flow\"]}},\"required\":[\"type\",\"path\",\"input_transforms\"]},\"ForloopFlow\":{\"type\":\"object\",\"description\":\"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations\",\"properties\":{\"modules\":{\"type\":\"array\",\"description\":\"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}},\"iterator\":{\"description\":\"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'\",\"$ref\":\"#/components/schemas/InputTransform\"},\"skip_failures\":{\"type\":\"boolean\",\"description\":\"If true, iteration failures don't stop the loop. Failed iterations return null\"},\"type\":{\"type\":\"string\",\"enum\":[\"forloopflow\"]},\"parallel\":{\"type\":\"boolean\",\"description\":\"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency\"},\"parallelism\":{\"description\":\"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression\",\"$ref\":\"#/components/schemas/InputTransform\"},\"squash\":{\"type\":\"boolean\"}},\"required\":[\"modules\",\"iterator\",\"skip_failures\",\"type\"]},\"WhileloopFlow\":{\"type\":\"object\",\"description\":\"Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination\",\"properties\":{\"modules\":{\"type\":\"array\",\"description\":\"Steps to execute in each iteration. Use stop_after_if to control when the loop ends\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}},\"skip_failures\":{\"type\":\"boolean\",\"description\":\"If true, iteration failures don't stop the loop. Failed iterations return null\"},\"type\":{\"type\":\"string\",\"enum\":[\"whileloopflow\"]},\"parallel\":{\"type\":\"boolean\",\"description\":\"If true, iterations run concurrently (use with caution in while loops)\"},\"parallelism\":{\"description\":\"Maximum number of concurrent iterations when parallel=true\",\"$ref\":\"#/components/schemas/InputTransform\"},\"squash\":{\"type\":\"boolean\"}},\"required\":[\"modules\",\"skip_failures\",\"type\"]},\"BranchOne\":{\"type\":\"object\",\"description\":\"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes\",\"properties\":{\"branches\":{\"type\":\"array\",\"description\":\"Array of branches to evaluate in order. The first branch with expr evaluating to true executes\",\"items\":{\"type\":\"object\",\"properties\":{\"summary\":{\"type\":\"string\",\"description\":\"Short description of this branch condition\"},\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins\"},\"modules\":{\"type\":\"array\",\"description\":\"Steps to execute if this branch's expr is true\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}}},\"required\":[\"modules\",\"expr\"]}},\"default\":{\"type\":\"array\",\"description\":\"Steps to execute if no branch expressions match\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}},\"type\":{\"type\":\"string\",\"enum\":[\"branchone\"]}},\"required\":[\"branches\",\"default\",\"type\"]},\"BranchAll\":{\"type\":\"object\",\"description\":\"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently\",\"properties\":{\"branches\":{\"type\":\"array\",\"description\":\"Array of branches that all execute (either in parallel or sequentially)\",\"items\":{\"type\":\"object\",\"properties\":{\"summary\":{\"type\":\"string\",\"description\":\"Short description of this branch's purpose\"},\"skip_failure\":{\"type\":\"boolean\",\"description\":\"If true, failure in this branch doesn't fail the entire flow\"},\"modules\":{\"type\":\"array\",\"description\":\"Steps to execute in this branch\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}}},\"required\":[\"modules\"]}},\"type\":{\"type\":\"string\",\"enum\":[\"branchall\"]},\"parallel\":{\"type\":\"boolean\",\"description\":\"If true, all branches execute concurrently. If false, they execute sequentially\"}},\"required\":[\"branches\",\"type\"]},\"AgentTool\":{\"type\":\"object\",\"description\":\"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool\",\"properties\":{\"id\":{\"type\":\"string\",\"description\":\"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')\"},\"summary\":{\"type\":\"string\",\"description\":\"Short description of what this tool does (shown to the AI)\"},\"value\":{\"$ref\":\"#/components/schemas/ToolValue\"}},\"required\":[\"id\",\"value\"]},\"ToolValue\":{\"description\":\"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference\",\"oneOf\":[{\"$ref\":\"#/components/schemas/FlowModuleTool\"},{\"$ref\":\"#/components/schemas/McpToolValue\"},{\"$ref\":\"#/components/schemas/WebsearchToolValue\"}],\"discriminator\":{\"propertyName\":\"tool_type\",\"mapping\":{\"flowmodule\":\"#/components/schemas/FlowModuleTool\",\"mcp\":\"#/components/schemas/McpToolValue\",\"websearch\":\"#/components/schemas/WebsearchToolValue\"}}},\"FlowModuleTool\":{\"description\":\"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module\",\"allOf\":[{\"type\":\"object\",\"properties\":{\"tool_type\":{\"type\":\"string\",\"enum\":[\"flowmodule\"]}},\"required\":[\"tool_type\"]},{\"$ref\":\"#/components/schemas/FlowModuleValue\"}]},\"WebsearchToolValue\":{\"type\":\"object\",\"description\":\"A tool implemented as a websearch tool. The AI can call this like any other websearch tool\",\"properties\":{\"tool_type\":{\"type\":\"string\",\"enum\":[\"websearch\"]}},\"required\":[\"tool_type\"]},\"McpToolValue\":{\"type\":\"object\",\"description\":\"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers\",\"properties\":{\"tool_type\":{\"type\":\"string\",\"enum\":[\"mcp\"]},\"resource_path\":{\"type\":\"string\",\"description\":\"Path to the MCP resource/server configuration\"},\"include_tools\":{\"type\":\"array\",\"description\":\"Whitelist of specific tools to include from this MCP server\",\"items\":{\"type\":\"string\"}},\"exclude_tools\":{\"type\":\"array\",\"description\":\"Blacklist of tools to exclude from this MCP server\",\"items\":{\"type\":\"string\"}}},\"required\":[\"tool_type\",\"resource_path\"]},\"AiAgent\":{\"type\":\"object\",\"description\":\"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task\",\"properties\":{\"input_transforms\":{\"type\":\"object\",\"description\":\"Input parameters for the AI agent mapped to their values\",\"properties\":{\"provider\":{\"$ref\":\"#/components/schemas/ProviderTransform\"},\"output_type\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Output format type.\\nValid values: 'text' (default) - plain text response, 'image' - image generation\\n\"},\"user_message\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax.\"},\"system_prompt\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"System instructions that guide the AI's behavior, persona, and response style. Optional.\"},\"streaming\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Boolean. If true, stream the AI response incrementally.\\nStreaming events include: token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\\n\"},\"memory\":{\"$ref\":\"#/components/schemas/MemoryTransform\"},\"output_schema\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\\n\"},\"user_attachments\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Array of file references (images or PDFs) for the AI agent.\\nFormat: Array<{ bucket: string, key: string }> - S3 object references\\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\\n\"},\"max_completion_tokens\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Integer. Maximum number of tokens the AI will generate in its response.\\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\\n\"},\"temperature\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Float. Controls randomness/creativity of responses.\\nRange: 0.0 to 2.0 (provider-dependent)\\n- 0.0 = deterministic, focused responses\\n- 0.7 = balanced (common default)\\n- 1.0+ = more creative/random\\n\"}},\"required\":[\"provider\",\"user_message\",\"output_type\"]},\"tools\":{\"type\":\"array\",\"description\":\"Array of tools the agent can use. The agent decides which tools to call based on the task\",\"items\":{\"$ref\":\"#/components/schemas/AgentTool\"}},\"type\":{\"type\":\"string\",\"enum\":[\"aiagent\"]},\"omit_output_from_conversation\":{\"type\":\"boolean\",\"default\":false,\"description\":\"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled.\"},\"parallel\":{\"type\":\"boolean\",\"description\":\"If true, the agent can execute multiple tool calls in parallel\"}},\"required\":[\"tools\",\"type\",\"input_transforms\"]},\"Identity\":{\"type\":\"object\",\"description\":\"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"identity\"]},\"flow\":{\"type\":\"boolean\",\"description\":\"If true, marks this as a flow identity (special handling)\"}},\"required\":[\"type\"]},\"FlowStatus\":{\"type\":\"object\",\"properties\":{\"step\":{\"type\":\"integer\"},\"modules\":{\"type\":\"array\",\"items\":{\"$ref\":\"#/components/schemas/FlowStatusModule\"}},\"user_states\":{\"additionalProperties\":true},\"preprocessor_module\":{\"allOf\":[{\"$ref\":\"#/components/schemas/FlowStatusModule\"}]},\"failure_module\":{\"allOf\":[{\"$ref\":\"#/components/schemas/FlowStatusModule\"},{\"type\":\"object\",\"properties\":{\"parent_module\":{\"type\":\"string\"}}}]},\"retry\":{\"type\":\"object\",\"properties\":{\"fail_count\":{\"type\":\"integer\"},\"failed_jobs\":{\"type\":\"array\",\"items\":{\"type\":\"string\",\"format\":\"uuid\"}}}}},\"required\":[\"step\",\"modules\",\"failure_module\"]},\"FlowStatusModule\":{\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"WaitingForPriorSteps\",\"WaitingForEvents\",\"WaitingForExecutor\",\"InProgress\",\"Success\",\"Failure\"]},\"id\":{\"type\":\"string\"},\"job\":{\"type\":\"string\",\"format\":\"uuid\"},\"count\":{\"type\":\"integer\"},\"progress\":{\"type\":\"integer\"},\"iterator\":{\"type\":\"object\",\"properties\":{\"index\":{\"type\":\"integer\"},\"itered\":{\"type\":\"array\",\"items\":{}},\"itered_len\":{\"type\":\"integer\"},\"args\":{}}},\"flow_jobs\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}},\"flow_jobs_success\":{\"type\":\"array\",\"items\":{\"type\":\"boolean\"}},\"flow_jobs_duration\":{\"type\":\"object\",\"properties\":{\"started_at\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}},\"duration_ms\":{\"type\":\"array\",\"items\":{\"type\":\"integer\"}}}},\"branch_chosen\":{\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"branch\",\"default\"]},\"branch\":{\"type\":\"integer\"}},\"required\":[\"type\"]},\"branchall\":{\"type\":\"object\",\"properties\":{\"branch\":{\"type\":\"integer\"},\"len\":{\"type\":\"integer\"}},\"required\":[\"branch\",\"len\"]},\"approvers\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"resume_id\":{\"type\":\"integer\"},\"approver\":{\"type\":\"string\"}},\"required\":[\"resume_id\",\"approver\"]}},\"failed_retries\":{\"type\":\"array\",\"items\":{\"type\":\"string\",\"format\":\"uuid\"}},\"skipped\":{\"type\":\"boolean\"},\"agent_actions\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"oneOf\":[{\"type\":\"object\",\"properties\":{\"job_id\":{\"type\":\"string\",\"format\":\"uuid\"},\"function_name\":{\"type\":\"string\"},\"type\":{\"type\":\"string\",\"enum\":[\"tool_call\"]},\"module_id\":{\"type\":\"string\"}},\"required\":[\"job_id\",\"function_name\",\"type\",\"module_id\"]},{\"type\":\"object\",\"properties\":{\"call_id\":{\"type\":\"string\",\"format\":\"uuid\"},\"function_name\":{\"type\":\"string\"},\"resource_path\":{\"type\":\"string\"},\"type\":{\"type\":\"string\",\"enum\":[\"mcp_tool_call\"]},\"arguments\":{\"type\":\"object\"}},\"required\":[\"call_id\",\"function_name\",\"resource_path\",\"type\"]},{\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"web_search\"]}},\"required\":[\"type\"]},{\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"message\"]}},\"required\":[\"content\",\"type\"]}]}},\"agent_actions_success\":{\"type\":\"array\",\"items\":{\"type\":\"boolean\"}}},\"required\":[\"type\"]}}"; -export declare const CLI_COMMANDS = "# Windmill CLI Commands\n\nThe Windmill CLI (`wmill`) provides commands for managing scripts, flows, apps, and other resources.\n\n## Global Options\n\n- `--workspace ` - Specify the target workspace. This overrides the default workspace.\n- `--debug --verbose` - Show debug/verbose logs\n- `--show-diffs` - Show diff informations when syncing (may show sensitive informations)\n- `--token ` - Specify an API token. This will override any stored token.\n- `--base-url ` - Specify the base URL of the API. If used, --token and --workspace are required and no local remote/workspace already set will be used.\n- `--config-dir ` - Specify a custom config directory. Overrides WMILL_CONFIG_DIR environment variable and default ~/.config location.\n\n## Commands\n\n### app\n\napp related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `app list` - list all apps\n - `--json` - Output as JSON (for piping to jq)\n- `app get ` - get an app's details\n - `--json` - Output as JSON (for piping to jq)\n- `app push [file_path:string] [remote_path:string]` - push a local app. With no args, infers the app from the current directory and the remote path from its location relative to wmill.yaml.\n- `app dev [app_folder:string]` - Start a development server for building apps with live reload and hot module replacement\n - `--port ` - Port to run the dev server on (will find next available port if occupied)\n - `--host ` - Host to bind the dev server to\n - `--entry ` - Entry point file (default: index.ts for Svelte/Vue, index.tsx otherwise)\n - `--no-open` - Don't automatically open the browser\n- `app lint [app_folder:string]` - Lint a raw app folder to validate structure and buildability\n - `--fix` - Attempt to fix common issues (not implemented yet)\n- `app new` - create a new raw app from a template\n - `--summary ` - App summary (short description). Skips the prompt when provided. Triggers non-interactive mode.\n - `--path ` - App path (e.g., f/folder/my_app or u/username/my_app). Skips the prompt when provided. Triggers non-interactive mode.\n - `--framework ` - Framework template: react19 | react18 | svelte5 | vue. Skips the prompt when provided. Triggers non-interactive mode.\n - `--datatable ` - Datatable to wire up. Without this flag in non-interactive mode, no datatable is configured.\n - `--schema ` - Schema to use with --datatable. Created (CREATE SCHEMA IF NOT EXISTS) if it doesn't already exist.\n - `--overwrite` - Overwrite the target directory if it already exists, without prompting.\n - `--no-open-in-desktop` - Do not prompt to open the new app in Claude Desktop.\n- `app generate-agents [app_folder:string]` - regenerate AGENTS.md and DATATABLES.md from remote workspace\n- `app set-permissioned-as ` - Set the on_behalf_of_email for an app (requires admin or wm_deployers group)\n\n### audit\n\nView audit logs (requires admin)\n\n**Subcommands:**\n\n- `audit list` - List audit log entries\n- `audit get ` - Get a specific audit log entry\n - `--json` - Output as JSON (for piping to jq)\n\n### config\n\nShow all available wmill.yaml configuration options\n\n**Options:**\n- `--json` - Output as JSON for programmatic consumption\n\n**Subcommands:**\n\n- `config migrate` - Migrate wmill.yaml from gitBranches/environments to workspaces format\n\n### dependencies\n\nworkspace dependencies related commands\n\n**Alias:** `deps`\n\n**Subcommands:**\n\n- `dependencies push ` - Push workspace dependencies from a local file\n\n### dev\n\nWatch local file changes and live-reload the dev page for preview. Does NOT deploy to the remote workspace \u2014 use wmill sync push for that.\n\n**Options:**\n- `--includes ` - Filter paths given a glob pattern or path\n- `--proxy-port ` - Port for a localhost reverse proxy to the remote Windmill server\n- `--path ` - Watch a specific windmill path (e.g., u/admin/my_script or f/my_flow)\n- `--no-open` - Do not open the browser automatically\n\n### docs\n\nSearch Windmill documentation.\n\n**Arguments:** ``\n\n**Options:**\n- `--json` - Output results as JSON.\n\n### flow\n\nflow related commands\n\n**Options:**\n- `--show-archived` - Enable archived flows in output\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `flow list` - list all flows\n - `--show-archived` - Enable archived flows in output\n - `--json` - Output as JSON (for piping to jq)\n- `flow get ` - get a flow's details\n - `--json` - Output as JSON (for piping to jq)\n- `flow push ` - push a local flow spec. This overrides any remote versions.\n - `--message ` - Deployment message\n- `flow run ` - run a flow by path.\n - `-d --data ` - Inputs specified as a JSON string or a file using @ or stdin using @-.\n - `-s --silent` - Do not ouput anything other then the final output. Useful for scripting.\n- `flow preview ` - preview a local flow without deploying it. Runs the flow definition from local files and uses local PathScripts by default.\n - `-d --data ` - Inputs specified as a JSON string or a file using @ or stdin using @-.\n - `-s --silent` - Do not output anything other then the final output. Useful for scripting.\n - `--remote` - Use deployed workspace scripts for PathScript steps instead of local files.\n- `flow new ` - create a new empty flow\n - `--summary ` - flow summary\n - `--description ` - flow description\n- `flow bootstrap ` - create a new empty flow (alias for new)\n - `--summary ` - flow summary\n - `--description ` - flow description\n- `flow history ` - Show version history for a flow\n - `--json` - Output as JSON (for piping to jq)\n- `flow show-version ` - Show a specific version of a flow\n - `--json` - Output as JSON (for piping to jq)\n- `flow set-permissioned-as ` - Set the on_behalf_of_email for a flow (requires admin or wm_deployers group)\n\n### folder\n\nfolder related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `folder list` - list all folders\n - `--json` - Output as JSON (for piping to jq)\n- `folder get ` - get a folder's details\n - `--json` - Output as JSON (for piping to jq)\n- `folder new ` - create a new folder locally\n - `--summary ` - folder summary\n- `folder push ` - push a local folder to the remote by name. This overrides any remote versions.\n- `folder add-missing` - create default folder.meta.yaml for all subdirectories of f/ that are missing one\n - `-y, --yes` - skip confirmation prompt\n- `folder show-rules ` - Show default_permissioned_as rules for a folder. Use --test-path to see which rule matches a given item path.\n - `--test-path ` - Test which rule matches this item path (e.g. f/prod/jobs/my_script)\n - `--json` - Output as JSON\n\n### generate-metadata\n\nGenerate metadata (locks, schemas) for all scripts, flows, and apps\n\n**Arguments:** `[folder:string]`\n\n**Options:**\n- `--yes` - Skip confirmation prompt\n- `--dry-run` - Show what would be updated without making changes\n- `--lock-only` - Re-generate only the lock files\n- `--schema-only` - Re-generate only script schemas (skips flows and apps)\n- `--skip-scripts` - Skip processing scripts\n- `--skip-flows` - Skip processing flows\n- `--skip-apps` - Skip processing apps\n- `--strict-folder-boundaries` - Only update items inside the specified folder (requires folder argument)\n- `--parallel ` - Number of items to process in parallel\n- `-i --includes ` - Comma separated patterns to specify which files to include\n- `-e --excludes ` - Comma separated patterns to specify which files to exclude\n\n**Subcommands:**\n\n- `generate-metadata rehash [folder:string]`\n - `--skip-scripts` - Skip processing scripts\n - `--skip-flows` - Skip processing flows\n - `--skip-apps` - Skip processing apps\n - `--parallel ` - Number of items to process in parallel\n - `-i --includes ` - Comma separated patterns to specify which files to include\n - `-e --excludes ` - Comma separated patterns to specify which files to exclude\n\n### gitsync-settings\n\nManage git-sync settings between local wmill.yaml and Windmill backend\n\n**Subcommands:**\n\n- `gitsync-settings pull` - Pull git-sync settings from Windmill backend to local wmill.yaml\n - `--repository ` - Specify repository path (e.g., u/user/repo)\n - `--default` - Write settings to top-level defaults instead of overrides\n - `--replace` - Replace existing settings (non-interactive mode)\n - `--override` - Add branch-specific override (non-interactive mode)\n - `--diff` - Show differences without applying changes\n - `--json-output` - Output in JSON format\n - `--with-backend-settings ` - Use provided JSON settings instead of querying backend (for testing)\n - `--yes` - Skip interactive prompts and use default behavior\n - `--promotion ` - Use promotionOverrides from the specified branch instead of regular overrides\n- `gitsync-settings push` - Push git-sync settings from local wmill.yaml to Windmill backend\n - `--repository ` - Specify repository path (e.g., u/user/repo)\n - `--diff` - Show what would be pushed without applying changes\n - `--json-output` - Output in JSON format\n - `--with-backend-settings ` - Use provided JSON settings instead of querying backend (for testing)\n - `--yes` - Skip interactive prompts and use default behavior\n - `--promotion ` - Use promotionOverrides from the specified branch instead of regular overrides\n\n### group\n\nManage workspace groups\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `group list` - List all groups in the workspace\n - `--json` - Output as JSON (for piping to jq)\n- `group get ` - Get group details and members\n - `--json` - Output as JSON (for piping to jq)\n- `group create ` - Create a new group\n - `--summary ` - Group summary/description\n- `group delete ` - Delete a group\n- `group add-user ` - Add a user to a group\n- `group remove-user ` - Remove a user from a group\n\n### hub\n\nHub related commands. EXPERIMENTAL. INTERNAL USE ONLY.\n\n**Subcommands:**\n\n- `hub pull` - pull any supported definitions. EXPERIMENTAL.\n\n### init\n\nBootstrap a windmill project with a wmill.yaml file\n\n**Options:**\n- `--use-default` - Use default settings without checking backend\n- `--use-backend` - Use backend git-sync settings if available\n- `--repository ` - Specify repository path (e.g., u/user/repo) when using backend settings\n- `--bind-profile` - Automatically bind active workspace profile to current Git branch\n- `--no-bind-profile` - Skip workspace profile binding prompt\n\n### instance\n\nsync local with a remote instance or the opposite (push or pull)\n\n**Subcommands:**\n\n- `instance add [instance_name:string] [remote:string] [token:string]` - Add a new instance\n- `instance remove ` - Remove an instance\n- `instance switch ` - Switch the current instance\n- `instance pull` - Pull instance settings, users, configs, instance groups and overwrite local\n - `--yes` - Pull without needing confirmation\n - `--dry-run` - Perform a dry run without making changes\n - `--skip-users` - Skip pulling users\n - `--skip-settings` - Skip pulling settings\n - `--skip-configs` - Skip pulling configs (worker groups)\n - `--skip-groups` - Skip pulling instance groups\n - `--include-workspaces` - Also pull workspaces\n - `--folder-per-instance` - Create a folder per instance\n - `--instance ` - Name of the instance to pull from, override the active instance\n - `--prefix ` - Prefix of the local workspaces to pull, used to create the folders when using --include-workspaces\n - `--prefix-settings` - Store instance yamls inside prefixed folders when using --prefix and --folder-per-instance\n- `instance push` - Push instance settings, users, configs, group and overwrite remote\n - `--yes` - Push without needing confirmation\n - `--dry-run` - Perform a dry run without making changes\n - `--skip-users` - Skip pushing users\n - `--skip-settings` - Skip pushing settings\n - `--skip-configs` - Skip pushing configs (worker groups)\n - `--skip-groups` - Skip pushing instance groups\n - `--include-workspaces` - Also push workspaces\n - `--folder-per-instance` - Create a folder per instance\n - `--instance ` - Name of the instance to push to, override the active instance\n - `--prefix ` - Prefix of the local workspaces folders to push\n - `--prefix-settings` - Store instance yamls inside prefixed folders when using --prefix and --folder-per-instance\n- `instance whoami` - Display information about the currently logged-in user\n- `instance get-config` - Dump the current instance config (global settings + worker configs) as YAML\n - `-o, --output-file ` - Write YAML to a file instead of stdout\n - `--show-secrets` - Include sensitive fields (license key, JWT secret) without prompting\n - `--instance ` - Name of the instance, override the active instance\n- `instance connect-slack`\n - `--bot-token ` - Slack bot token (xoxb-...)\n - `--team-id ` - Slack team id\n - `--team-name ` - Slack team name\n - `--instance ` - Instance profile to connect against (defaults to the active instance)\n\n### job\n\nManage jobs (list, inspect, cancel)\n\n**Subcommands:**\n\n- `job list` - List recent jobs\n- `job get ` - Get job details. For flows: shows step tree with sub-job IDs\n - `--json` - Output as JSON (for piping to jq)\n- `job result ` - Get the result of a completed job (machine-friendly)\n- `job logs ` - Get job logs. For flows: aggregates all step logs\n- `job cancel ` - Cancel a running or queued job\n - `--reason ` - Reason for cancellation\n\n### jobs\n\nPull completed and queued jobs from workspace\n\n**Arguments:** `[workspace:string]`\n\n**Options:**\n- `-c, --completed-output ` - Completed jobs output file (default: completed_jobs.json)\n- `-q, --queued-output ` - Queued jobs output file (default: queued_jobs.json)\n- `--skip-worker-check` - Skip checking for active workers before export\n\n**Subcommands:**\n\n- `jobs pull`\n- `jobs push`\n\n### lint\n\nValidate Windmill flow, schedule, and trigger YAML files in a directory\n\n**Arguments:** `[directory:string]`\n\n**Options:**\n- `--json` - Output results in JSON format\n- `--fail-on-warn` - Exit with code 1 when warnings are emitted\n- `--locks-required` - Fail if scripts or flow inline scripts that need locks have no locks\n- `-w, --watch` - Watch for file changes and re-lint automatically\n\n### queues\n\nList all queues with their metrics\n\n**Arguments:** `[workspace:string] the optional workspace to filter by (default to all workspaces)`\n\n**Options:**\n- `--instance [instance]` - Name of the instance to push to, override the active instance\n- `--base-url [baseUrl]` - If used with --token, will be used as the base url for the instance\n\n### resource\n\nresource related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `resource list` - list all resources\n - `--json` - Output as JSON (for piping to jq)\n- `resource get ` - get a resource's details\n - `--json` - Output as JSON (for piping to jq)\n- `resource new ` - create a new resource locally\n- `resource push ` - push a local resource spec. This overrides any remote versions.\n\n### resource-type\n\nresource type related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `resource-type list` - list all resource types\n - `--schema` - Show schema in the output\n - `--json` - Output as JSON (for piping to jq)\n- `resource-type get ` - get a resource type's details\n - `--json` - Output as JSON (for piping to jq)\n- `resource-type new ` - create a new resource type locally\n- `resource-type push ` - push a local resource spec. This overrides any remote versions.\n- `resource-type generate-namespace` - Create a TypeScript definition file with the RT namespace generated from the resource types\n\n### schedule\n\nschedule related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `schedule list` - list all schedules\n - `--json` - Output as JSON (for piping to jq)\n- `schedule get ` - get a schedule's details\n - `--json` - Output as JSON (for piping to jq)\n- `schedule new ` - create a new schedule locally\n- `schedule push ` - push a local schedule spec. This overrides any remote versions.\n- `schedule enable ` - Enable a schedule\n - `--force` - Bypass the fork-conflict warning when the parent workspace has the same schedule (acknowledges that both crons will fire)\n- `schedule disable ` - Disable a schedule\n- `schedule set-permissioned-as ` - Set the email (run-as user) for a schedule (requires admin or wm_deployers group)\n\n### script\n\nscript related commands\n\n**Options:**\n- `--show-archived` - Show archived scripts instead of active ones\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `script list` - list all scripts\n - `--show-archived` - Show archived scripts instead of active ones\n - `--json` - Output as JSON (for piping to jq)\n- `script push ` - push a local script spec. This overrides any remote versions. Use the script file (.ts, .js, .py, .sh)\n - `--message ` - Deployment message\n- `script get ` - get a script's details\n - `--json` - Output as JSON (for piping to jq)\n- `script show ` - show a script's content (alias for get)\n- `script run ` - run a script by path\n - `-d --data ` - Inputs specified as a JSON string or a file using @ or stdin using @-.\n - `-s --silent` - Do not output anything other then the final output. Useful for scripting.\n- `script preview ` - preview a local script without deploying it. Supports both regular and codebase scripts.\n - `-d --data ` - Inputs specified as a JSON string or a file using @ or stdin using @-.\n - `-s --silent` - Do not output anything other than the final output. Useful for scripting.\n- `script new ` - create a new script\n - `--summary ` - script summary\n - `--description ` - script description\n- `script bootstrap ` - create a new script (alias for new)\n - `--summary ` - script summary\n - `--description ` - script description\n- `script set-permissioned-as ` - Set the on_behalf_of_email for a script (requires admin or wm_deployers group)\n- `script history ` - show version history for a script\n - `--json` - Output as JSON (for piping to jq)\n\n### sync\n\nsync local with a remote workspaces or the opposite (push or pull)\n\n**Subcommands:**\n\n- `sync pull` - Pull any remote changes and apply them locally.\n - `--yes` - Pull without needing confirmation\n - `--dry-run` - Show changes that would be pulled without actually pushing\n - `--plain-secrets` - Pull secrets as plain text\n - `--json` - Use JSON instead of YAML\n - `--skip-variables` - Skip syncing variables (including secrets)\n - `--skip-secrets` - Skip syncing only secrets variables\n - `--include-secrets` - Include secrets in sync (overrides skipSecrets in wmill.yaml)\n - `--skip-resources` - Skip syncing resources\n - `--skip-resource-types` - Skip syncing resource types\n - `--skip-scripts` - Skip syncing scripts\n - `--skip-flows` - Skip syncing flows\n - `--skip-apps` - Skip syncing apps\n - `--skip-folders` - Skip syncing folders\n - `--skip-workspace-dependencies` - Skip syncing workspace dependencies\n - `--skip-scripts-metadata` - Skip syncing scripts metadata, focus solely on logic\n - `--include-schedules` - Include syncing schedules\n - `--include-triggers` - Include syncing triggers\n - `--include-users` - Include syncing users\n - `--include-groups` - Include syncing groups\n - `--include-settings` - Include syncing workspace settings\n - `--include-key` - Include workspace encryption key\n - `--skip-branch-validation` - Skip git branch validation and prompts\n - `--json-output` - Output results in JSON format\n - `-i --includes ` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string). Overrides wmill.yaml includes\n - `-e --excludes ` - Comma separated patterns to specify which file to NOT take into account. Overrides wmill.yaml excludes\n - `--extra-includes ` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string). Useful to still take wmill.yaml into account and act as a second pattern to satisfy\n - `--repository ` - Specify repository path (e.g., u/user/repo) when multiple repositories exist\n - `--promotion ` - Use promotionOverrides from the specified branch instead of regular overrides\n - `--branch, --env ` - [Deprecated: use --workspace] Override the current git branch/environment\n- `sync push` - Push any local changes and apply them remotely.\n - `--yes` - Push without needing confirmation\n - `--dry-run` - Show changes that would be pushed without actually pushing\n - `--plain-secrets` - Push secrets as plain text\n - `--json` - Use JSON instead of YAML\n - `--skip-variables` - Skip syncing variables (including secrets)\n - `--skip-secrets` - Skip syncing only secrets variables\n - `--include-secrets` - Include secrets in sync (overrides skipSecrets in wmill.yaml)\n - `--skip-resources` - Skip syncing resources\n - `--skip-resource-types` - Skip syncing resource types\n - `--skip-scripts` - Skip syncing scripts\n - `--skip-flows` - Skip syncing flows\n - `--skip-apps` - Skip syncing apps\n - `--skip-folders` - Skip syncing folders\n - `--skip-workspace-dependencies` - Skip syncing workspace dependencies\n - `--skip-scripts-metadata` - Skip syncing scripts metadata, focus solely on logic\n - `--include-schedules` - Include syncing schedules\n - `--include-triggers` - Include syncing triggers\n - `--include-users` - Include syncing users\n - `--include-groups` - Include syncing groups\n - `--include-settings` - Include syncing workspace settings\n - `--include-key` - Include workspace encryption key\n - `--skip-branch-validation` - Skip git branch validation and prompts\n - `--json-output` - Output results in JSON format\n - `-i --includes ` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string)\n - `-e --excludes ` - Comma separated patterns to specify which file to NOT take into account.\n - `--extra-includes ` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string). Useful to still take wmill.yaml into account and act as a second pattern to satisfy\n - `--message ` - Include a message that will be added to all scripts/flows/apps updated during this push\n - `--parallel ` - Number of changes to process in parallel\n - `--repository ` - Specify repository path (e.g., u/user/repo) when multiple repositories exist\n - `--branch, --env ` - [Deprecated: use --workspace] Override the current git branch/environment\n - `--lint` - Run lint validation before pushing\n - `--locks-required` - Fail if scripts or flow inline scripts that need locks have no locks\n - `--auto-metadata` - Automatically regenerate stale metadata (locks and schemas) before pushing\n - `--accept-overriding-permissioned-as-with-self` - Accept that items with a different permissioned_as will be updated with your own user\n\n### token\n\nManage API tokens\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `token list` - List API tokens\n - `--json` - Output as JSON (for piping to jq)\n- `token create` - Create a new API token\n - `--label ` - Token label\n - `--expiration ` - Token expiration (ISO 8601 timestamp)\n- `token delete ` - Delete a token by its prefix\n\n### trigger\n\ntrigger related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `trigger list` - list all triggers\n - `--json` - Output as JSON (for piping to jq)\n- `trigger get ` - get a trigger's details\n - `--json` - Output as JSON (for piping to jq)\n - `--kind ` - Trigger kind (http, websocket, kafka, nats, postgres, mqtt, sqs, gcp, azure, email). Recommended for faster lookup\n- `trigger new ` - create a new trigger locally\n - `--kind ` - Trigger kind (required: http, websocket, kafka, nats, postgres, mqtt, sqs, gcp, azure, email)\n- `trigger push ` - push a local trigger spec. This overrides any remote versions.\n- `trigger set-permissioned-as ` - Set the email (run-as user) for a trigger (requires admin or wm_deployers group)\n - `--kind ` - Trigger kind (required: http, websocket, kafka, nats, postgres, mqtt, sqs, gcp, azure, email)\n\n### user\n\nuser related commands\n\n**Subcommands:**\n\n- `user add [password:string]` - Create a user\n - `--superadmin` - Specify to make the new user superadmin.\n - `--company ` - Specify to set the company of the new user.\n - `--name ` - Specify to set the name of the new user.\n- `user remove ` - Delete a user\n- `user create-token` - Create a new API token for the authenticated user\n - `--email ` - Specify credentials to use for authentication. This will not be stored. It will only be used to exchange for a token with the API server, which will not be stored either.\n - `--password ` - Specify credentials to use for authentication. This will not be stored. It will only be used to exchange for a token with the API server, which will not be stored either.\n\n### variable\n\nvariable related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `variable list` - list all variables\n - `--json` - Output as JSON (for piping to jq)\n- `variable get ` - get a variable's details\n - `--json` - Output as JSON (for piping to jq)\n- `variable new ` - create a new variable locally\n- `variable push ` - Push a local variable spec. This overrides any remote versions.\n - `--plain-secrets` - Push secrets as plain text\n- `variable add ` - Create a new variable on the remote. This will update the variable if it already exists.\n - `--plain-secrets` - Push secrets as plain text\n - `--public` - Legacy option, use --plain-secrets instead\n\n### version\n\nShow version information\n\n### worker-groups\n\ndisplay worker groups, pull and push worker groups configs\n\n**Subcommands:**\n\n- `worker-groups pull` - Pull worker groups (similar to `wmill instance pull --skip-users --skip-settings --skip-groups`)\n - `--instance` - Name of the instance to push to, override the active instance\n - `--base-url` - Base url to be passed to the instance settings instead of the local one\n - `--yes` - Pull without needing confirmation\n- `worker-groups push` - Push worker groups (similar to `wmill instance push --skip-users --skip-settings --skip-groups`)\n - `--instance [instance]` - Name of the instance to push to, override the active instance\n - `--base-url [baseUrl]` - If used with --token, will be used as the base url for the instance\n - `--yes` - Push without needing confirmation\n\n### workers\n\nList all workers grouped by worker groups\n\n**Options:**\n- `--instance [instance]` - Name of the instance to push to, override the active instance\n- `--base-url [baseUrl]` - If used with --token, will be used as the base url for the instance\n\n### workspace\n\nworkspace related commands\n\n**Alias:** `profile`\n\n**Subcommands:**\n\n- `workspace switch ` - Switch to another workspace\n- `workspace add [workspace_name:string] [workspace_id:string] [remote:string]` - Add a workspace\n - `-c --create` - Create the workspace if it does not exist\n - `--create-workspace-name ` - Specify the workspace name. Ignored if --create is not specified or the workspace already exists. Will default to the workspace id.\n - `--create-username ` - Specify your own username in the newly created workspace. Ignored if --create is not specified, the workspace already exists or automatic username creation is enabled on the instance.\n- `workspace remove ` - Remove a workspace\n- `workspace whoami` - Show the currently active user\n- `workspace list` - List local workspace profiles\n- `workspace list-remote` - List workspaces on the remote server that you have access to\n - `--as-superadmin` - List ALL workspaces on the instance (requires the token to belong to a superadmin/devops user)\n- `workspace list-forks` - List forked workspaces on the remote server\n- `workspace bind` - Create or update a workspace entry in wmill.yaml from the active profile\n - `--workspace ` - Workspace name (default: current branch or workspaceId)\n - `--branch ` - Git branch to associate (default: workspace name)\n- `workspace unbind` - Remove baseUrl and workspaceId from a workspace entry\n - `--workspace ` - Workspace to unbind\n- `workspace fork [workspace_name:string] [workspace_id:string]` - Create a forked workspace\n - `--create-workspace-name ` - Specify the workspace name. Ignored if --create is not specified or the workspace already exists. Will default to the workspace id.\n - `--color ` - Workspace color (hex code, e.g. #ff0000)\n - `--datatable-behavior ` - How to handle datatables: skip, schema_only, or schema_and_data (default: interactive prompt)\n - `-y --yes` - Skip interactive prompts (defaults datatable behavior to 'skip')\n- `workspace delete-fork ` - Delete a forked workspace and git branch\n - `-y --yes` - Skip confirmation prompt\n- `workspace merge` - Compare and deploy changes between a fork and its parent workspace\n - `--direction ` - Deploy direction: to-parent or to-fork\n - `--all` - Deploy all changed items including conflicts\n - `--skip-conflicts` - Skip items modified in both workspaces\n - `--include ` - Comma-separated kind:path items to include (e.g. script:f/test/main,flow:f/my/flow)\n - `--exclude ` - Comma-separated kind:path items to exclude\n - `--preserve-on-behalf-of` - Preserve original on_behalf_of/permissioned_as values\n - `-y --yes` - Non-interactive mode (deploy without prompts)\n- `workspace connect-slack` - Non-interactively connect Slack to the active workspace using a pre-minted bot token (xoxb-...). Produces the same artifacts as the UI OAuth flow: workspace_settings fields, g/slack group, f/slack_bot folder, and the encrypted bot token variable + resource at f/slack_bot/bot_token.\n - `--bot-token ` - Slack bot token (xoxb-...)\n - `--team-id ` - Slack team id\n - `--team-name ` - Slack team name\n- `workspace disconnect-slack`\n\n"; +export declare const OPENFLOW_SCHEMA = "## OpenFlow Schema\n\n{\"OpenFlow\":{\"type\":\"object\",\"description\":\"Top-level flow definition containing metadata, configuration, and the flow structure\",\"properties\":{\"summary\":{\"type\":\"string\",\"description\":\"Short description of what this flow does\"},\"description\":{\"type\":\"string\",\"description\":\"Detailed documentation for this flow\"},\"value\":{\"$ref\":\"#/components/schemas/FlowValue\"},\"schema\":{\"type\":\"object\",\"description\":\"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')\"},\"on_behalf_of_email\":{\"type\":\"string\",\"description\":\"The flow will be run with the permissions of the user with this email.\"}},\"required\":[\"summary\",\"value\"]},\"FlowValue\":{\"type\":\"object\",\"description\":\"The flow structure containing modules and optional preprocessor/failure handlers\",\"properties\":{\"modules\":{\"type\":\"array\",\"description\":\"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}},\"failure_module\":{\"description\":\"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types\",\"$ref\":\"#/components/schemas/FlowModule\"},\"preprocessor_module\":{\"description\":\"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results\",\"$ref\":\"#/components/schemas/FlowModule\"},\"same_worker\":{\"type\":\"boolean\",\"description\":\"If true, all steps run on the same worker for better performance\"},\"concurrent_limit\":{\"type\":\"number\",\"description\":\"Maximum number of concurrent executions of this flow\"},\"concurrency_key\":{\"type\":\"string\",\"description\":\"Expression to group concurrent executions (e.g., by user ID)\"},\"concurrency_time_window_s\":{\"type\":\"number\",\"description\":\"Time window in seconds for concurrent_limit\"},\"debounce_delay_s\":{\"type\":\"integer\",\"description\":\"Delay in seconds to debounce flow executions\"},\"debounce_key\":{\"type\":\"string\",\"description\":\"Expression to group debounced executions\"},\"debounce_args_to_accumulate\":{\"type\":\"array\",\"description\":\"Arguments to accumulate across debounced executions\",\"items\":{\"type\":\"string\"}},\"max_total_debouncing_time\":{\"type\":\"integer\",\"description\":\"Maximum total time in seconds that a job can be debounced\"},\"max_total_debounces_amount\":{\"type\":\"integer\",\"description\":\"Maximum number of times a job can be debounced\"},\"skip_expr\":{\"type\":\"string\",\"description\":\"JavaScript expression to conditionally skip the entire flow\"},\"cache_ttl\":{\"type\":\"number\",\"description\":\"Cache duration in seconds for flow results\"},\"cache_ignore_s3_path\":{\"type\":\"boolean\"},\"delete_after_secs\":{\"type\":\"integer\",\"description\":\"If set, delete the flow job's args, result and logs after this many seconds following job completion\"},\"flow_env\":{\"type\":\"object\",\"description\":\"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).\",\"additionalProperties\":{}},\"priority\":{\"type\":\"number\",\"description\":\"Execution priority (higher numbers run first)\"},\"early_return\":{\"type\":\"string\",\"description\":\"JavaScript expression to return early from the flow\"},\"chat_input_enabled\":{\"type\":\"boolean\",\"description\":\"Whether this flow accepts chat-style input\"},\"notes\":{\"type\":\"array\",\"description\":\"Sticky notes attached to the flow\",\"items\":{\"$ref\":\"#/components/schemas/FlowNote\"}},\"groups\":{\"type\":\"array\",\"description\":\"Semantic groups of modules for organizational purposes\",\"items\":{\"$ref\":\"#/components/schemas/FlowGroup\"}}},\"required\":[\"modules\"]},\"Retry\":{\"type\":\"object\",\"description\":\"Retry configuration for failed module executions\",\"properties\":{\"constant\":{\"type\":\"object\",\"description\":\"Retry with constant delay between attempts\",\"properties\":{\"attempts\":{\"type\":\"integer\",\"description\":\"Number of retry attempts\"},\"seconds\":{\"type\":\"integer\",\"description\":\"Seconds to wait between retries\"}}},\"exponential\":{\"type\":\"object\",\"description\":\"Retry with exponential backoff (delay doubles each time)\",\"properties\":{\"attempts\":{\"type\":\"integer\",\"description\":\"Number of retry attempts\"},\"multiplier\":{\"type\":\"integer\",\"description\":\"Multiplier for exponential backoff\"},\"seconds\":{\"type\":\"integer\",\"minimum\":1,\"description\":\"Initial delay in seconds\"},\"random_factor\":{\"type\":\"integer\",\"minimum\":0,\"maximum\":100,\"description\":\"Random jitter percentage (0-100) to avoid thundering herd\"}}},\"retry_if\":{\"$ref\":\"#/components/schemas/RetryIf\"}}},\"FlowNote\":{\"type\":\"object\",\"description\":\"A sticky note attached to a flow for documentation and annotation\",\"properties\":{\"id\":{\"type\":\"string\",\"description\":\"Unique identifier for the note\"},\"text\":{\"type\":\"string\",\"description\":\"Content of the note\"},\"position\":{\"type\":\"object\",\"description\":\"Position of the note in the flow editor\",\"properties\":{\"x\":{\"type\":\"number\",\"description\":\"X coordinate\"},\"y\":{\"type\":\"number\",\"description\":\"Y coordinate\"}},\"required\":[\"x\",\"y\"]},\"size\":{\"type\":\"object\",\"description\":\"Size of the note in the flow editor\",\"properties\":{\"width\":{\"type\":\"number\",\"description\":\"Width in pixels\"},\"height\":{\"type\":\"number\",\"description\":\"Height in pixels\"}},\"required\":[\"width\",\"height\"]},\"color\":{\"type\":\"string\",\"description\":\"Color of the note (e.g., \\\"yellow\\\", \\\"#ffff00\\\")\"},\"type\":{\"type\":\"string\",\"enum\":[\"free\",\"group\"],\"description\":\"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes\"},\"locked\":{\"type\":\"boolean\",\"default\":false,\"description\":\"Whether the note is locked and cannot be edited or moved\"},\"contained_node_ids\":{\"type\":\"array\",\"items\":{\"type\":\"string\"},\"description\":\"For group notes, the IDs of nodes contained within this group\"}},\"required\":[\"id\",\"text\",\"color\",\"type\"]},\"FlowGroup\":{\"type\":\"object\",\"description\":\"A semantic group of flow modules for organizational purposes. Does not affect execution \\u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.\",\"properties\":{\"summary\":{\"type\":\"string\",\"description\":\"Display name for this group\"},\"note\":{\"type\":\"string\",\"description\":\"Markdown note shown below the group header\"},\"autocollapse\":{\"type\":\"boolean\",\"default\":false,\"description\":\"If true, this group is collapsed by default in the flow editor. UI hint only.\"},\"start_id\":{\"type\":\"string\",\"description\":\"ID of the first flow module in this group (topological entry point)\"},\"end_id\":{\"type\":\"string\",\"description\":\"ID of the last flow module in this group (topological exit point)\"},\"color\":{\"type\":\"string\",\"description\":\"Color for the group in the flow editor\"}},\"required\":[\"start_id\",\"end_id\"]},\"RetryIf\":{\"type\":\"object\",\"description\":\"Conditional retry based on error or result\",\"properties\":{\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables\"}},\"required\":[\"expr\"]},\"StopAfterIf\":{\"type\":\"object\",\"description\":\"Early termination condition for a module\",\"properties\":{\"skip_if_stopped\":{\"type\":\"boolean\",\"description\":\"If true, following steps are skipped when this condition triggers\"},\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop\"},\"error_message\":{\"type\":\"string\",\"nullable\":true,\"description\":\"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.\"}},\"required\":[\"expr\"]},\"FlowModule\":{\"type\":\"object\",\"description\":\"A single step in a flow. Can be a script, subflow, loop, or branch\",\"properties\":{\"id\":{\"type\":\"string\",\"description\":\"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)\"},\"value\":{\"$ref\":\"#/components/schemas/FlowModuleValue\"},\"stop_after_if\":{\"description\":\"Early termination condition evaluated after this step completes\",\"$ref\":\"#/components/schemas/StopAfterIf\"},\"stop_after_all_iters_if\":{\"description\":\"For loops only - early termination condition evaluated after all iterations complete\",\"$ref\":\"#/components/schemas/StopAfterIf\"},\"skip_if\":{\"type\":\"object\",\"description\":\"Conditionally skip this step based on previous results or flow inputs\",\"properties\":{\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'\"}},\"required\":[\"expr\"]},\"sleep\":{\"description\":\"Delay before executing this step (in seconds or as expression)\",\"$ref\":\"#/components/schemas/InputTransform\"},\"cache_ttl\":{\"type\":\"number\",\"description\":\"Cache duration in seconds for this step's results\"},\"cache_ignore_s3_path\":{\"type\":\"boolean\"},\"timeout\":{\"description\":\"Maximum execution time in seconds (static value or expression)\",\"$ref\":\"#/components/schemas/InputTransform\"},\"delete_after_secs\":{\"type\":\"integer\",\"description\":\"If set, delete the step's args, result and logs after this many seconds following job completion\"},\"summary\":{\"type\":\"string\",\"description\":\"Short description of what this step does\"},\"mock\":{\"type\":\"object\",\"description\":\"Mock configuration for testing without executing the actual step\",\"properties\":{\"enabled\":{\"type\":\"boolean\",\"description\":\"If true, return mock value instead of executing\"},\"return_value\":{\"description\":\"Value to return when mocked\"}}},\"suspend\":{\"type\":\"object\",\"description\":\"Configuration for approval/resume steps that wait for user input\",\"properties\":{\"required_events\":{\"type\":\"integer\",\"description\":\"Number of approvals required before continuing\"},\"timeout\":{\"type\":\"integer\",\"description\":\"Timeout in seconds before auto-continuing or canceling\"},\"resume_form\":{\"type\":\"object\",\"description\":\"Form schema for collecting input when resuming\",\"properties\":{\"schema\":{\"type\":\"object\",\"description\":\"JSON Schema for the resume form\"}}},\"user_auth_required\":{\"type\":\"boolean\",\"description\":\"If true, only authenticated users can approve\"},\"user_groups_required\":{\"description\":\"Expression or list of groups that can approve\",\"$ref\":\"#/components/schemas/InputTransform\"},\"self_approval_disabled\":{\"type\":\"boolean\",\"description\":\"If true, the user who started the flow cannot approve\"},\"hide_cancel\":{\"type\":\"boolean\",\"description\":\"If true, hide the cancel button on the approval form\"},\"continue_on_disapprove_timeout\":{\"type\":\"boolean\",\"description\":\"If true, continue flow on timeout instead of canceling\"}}},\"priority\":{\"type\":\"number\",\"description\":\"Execution priority for this step (higher numbers run first)\"},\"continue_on_error\":{\"type\":\"boolean\",\"description\":\"If true, flow continues even if this step fails\"},\"retry\":{\"description\":\"Retry configuration if this step fails\",\"$ref\":\"#/components/schemas/Retry\"},\"debouncing\":{\"description\":\"Debounce configuration for this step (EE only)\",\"type\":\"object\",\"properties\":{\"debounce_delay_s\":{\"type\":\"integer\",\"description\":\"Delay in seconds to debounce this step's executions across flow runs\"},\"debounce_key\":{\"type\":\"string\",\"description\":\"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-\"},\"debounce_args_to_accumulate\":{\"type\":\"array\",\"description\":\"Array-type arguments to accumulate across debounced executions\",\"items\":{\"type\":\"string\"}},\"max_total_debouncing_time\":{\"type\":\"integer\",\"description\":\"Maximum total time in seconds before forced execution\"},\"max_total_debounces_amount\":{\"type\":\"integer\",\"description\":\"Maximum number of debounces before forced execution\"}}}},\"required\":[\"value\",\"id\"]},\"InputTransform\":{\"description\":\"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs\",\"oneOf\":[{\"$ref\":\"#/components/schemas/StaticTransform\"},{\"$ref\":\"#/components/schemas/JavascriptTransform\"},{\"$ref\":\"#/components/schemas/AiTransform\"}],\"discriminator\":{\"propertyName\":\"type\",\"mapping\":{\"static\":\"#/components/schemas/StaticTransform\",\"javascript\":\"#/components/schemas/JavascriptTransform\",\"ai\":\"#/components/schemas/AiTransform\"}}},\"StaticTransform\":{\"type\":\"object\",\"description\":\"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'\",\"properties\":{\"value\":{\"description\":\"The static value. For resources, use format '$res:path/to/resource'\"},\"type\":{\"type\":\"string\",\"enum\":[\"static\"]}},\"required\":[\"type\"]},\"JavascriptTransform\":{\"type\":\"object\",\"description\":\"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value\",\"properties\":{\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)\"},\"type\":{\"type\":\"string\",\"enum\":[\"javascript\"]}},\"required\":[\"expr\",\"type\"]},\"AiTransform\":{\"type\":\"object\",\"description\":\"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"ai\"]}},\"required\":[\"type\"]},\"AIProviderKind\":{\"type\":\"string\",\"description\":\"Supported AI provider types\",\"enum\":[\"openai\",\"azure_openai\",\"anthropic\",\"mistral\",\"deepseek\",\"googleai\",\"groq\",\"openrouter\",\"togetherai\",\"customai\",\"aws_bedrock\"]},\"ProviderConfig\":{\"type\":\"object\",\"description\":\"Complete AI provider configuration with resource reference and model selection\",\"properties\":{\"kind\":{\"$ref\":\"#/components/schemas/AIProviderKind\"},\"resource\":{\"type\":\"string\",\"description\":\"Resource reference in format '$res:{resource_path}' pointing to provider credentials\"},\"model\":{\"type\":\"string\",\"description\":\"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')\"}},\"required\":[\"kind\",\"resource\",\"model\"]},\"StaticProviderTransform\":{\"type\":\"object\",\"description\":\"Static provider configuration passed directly to the AI agent\",\"properties\":{\"value\":{\"$ref\":\"#/components/schemas/ProviderConfig\"},\"type\":{\"type\":\"string\",\"enum\":[\"static\"]}},\"required\":[\"type\",\"value\"]},\"ProviderTransform\":{\"description\":\"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined\",\"oneOf\":[{\"$ref\":\"#/components/schemas/StaticProviderTransform\"},{\"$ref\":\"#/components/schemas/JavascriptTransform\"},{\"$ref\":\"#/components/schemas/AiTransform\"}],\"discriminator\":{\"propertyName\":\"type\",\"mapping\":{\"static\":\"#/components/schemas/StaticProviderTransform\",\"javascript\":\"#/components/schemas/JavascriptTransform\",\"ai\":\"#/components/schemas/AiTransform\"}}},\"MemoryOff\":{\"type\":\"object\",\"description\":\"No conversation memory/context\",\"properties\":{\"kind\":{\"type\":\"string\",\"enum\":[\"off\"]}},\"required\":[\"kind\"]},\"MemoryAuto\":{\"type\":\"object\",\"description\":\"Automatic context management\",\"properties\":{\"kind\":{\"type\":\"string\",\"enum\":[\"auto\"]},\"context_length\":{\"type\":\"integer\",\"description\":\"Maximum number of messages to retain in context\"},\"memory_id\":{\"type\":\"string\",\"description\":\"Identifier for persistent memory across agent invocations\"}},\"required\":[\"kind\"]},\"MemoryMessage\":{\"type\":\"object\",\"description\":\"A single message in conversation history\",\"properties\":{\"role\":{\"type\":\"string\",\"enum\":[\"user\",\"assistant\",\"system\"]},\"content\":{\"type\":\"string\"}},\"required\":[\"role\",\"content\"]},\"MemoryManual\":{\"type\":\"object\",\"description\":\"Explicit message history\",\"properties\":{\"kind\":{\"type\":\"string\",\"enum\":[\"manual\"]},\"messages\":{\"type\":\"array\",\"items\":{\"$ref\":\"#/components/schemas/MemoryMessage\"}}},\"required\":[\"kind\",\"messages\"]},\"MemoryConfig\":{\"description\":\"Conversation memory configuration\",\"oneOf\":[{\"$ref\":\"#/components/schemas/MemoryOff\"},{\"$ref\":\"#/components/schemas/MemoryAuto\"},{\"$ref\":\"#/components/schemas/MemoryManual\"}],\"discriminator\":{\"propertyName\":\"kind\",\"mapping\":{\"off\":\"#/components/schemas/MemoryOff\",\"auto\":\"#/components/schemas/MemoryAuto\",\"manual\":\"#/components/schemas/MemoryManual\"}}},\"StaticMemoryTransform\":{\"type\":\"object\",\"description\":\"Static memory configuration passed directly to the AI agent\",\"properties\":{\"value\":{\"$ref\":\"#/components/schemas/MemoryConfig\"},\"type\":{\"type\":\"string\",\"enum\":[\"static\"]}},\"required\":[\"type\",\"value\"]},\"MemoryTransform\":{\"description\":\"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined\",\"oneOf\":[{\"$ref\":\"#/components/schemas/StaticMemoryTransform\"},{\"$ref\":\"#/components/schemas/JavascriptTransform\"},{\"$ref\":\"#/components/schemas/AiTransform\"}],\"discriminator\":{\"propertyName\":\"type\",\"mapping\":{\"static\":\"#/components/schemas/StaticMemoryTransform\",\"javascript\":\"#/components/schemas/JavascriptTransform\",\"ai\":\"#/components/schemas/AiTransform\"}}},\"FlowModuleValue\":{\"description\":\"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type\",\"oneOf\":[{\"$ref\":\"#/components/schemas/RawScript\"},{\"$ref\":\"#/components/schemas/PathScript\"},{\"$ref\":\"#/components/schemas/PathFlow\"},{\"$ref\":\"#/components/schemas/ForloopFlow\"},{\"$ref\":\"#/components/schemas/WhileloopFlow\"},{\"$ref\":\"#/components/schemas/BranchOne\"},{\"$ref\":\"#/components/schemas/BranchAll\"},{\"$ref\":\"#/components/schemas/Identity\"},{\"$ref\":\"#/components/schemas/AiAgent\"}],\"discriminator\":{\"propertyName\":\"type\",\"mapping\":{\"rawscript\":\"#/components/schemas/RawScript\",\"script\":\"#/components/schemas/PathScript\",\"flow\":\"#/components/schemas/PathFlow\",\"forloopflow\":\"#/components/schemas/ForloopFlow\",\"whileloopflow\":\"#/components/schemas/WhileloopFlow\",\"branchone\":\"#/components/schemas/BranchOne\",\"branchall\":\"#/components/schemas/BranchAll\",\"identity\":\"#/components/schemas/Identity\",\"aiagent\":\"#/components/schemas/AiAgent\"}}},\"RawScript\":{\"type\":\"object\",\"description\":\"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms\",\"properties\":{\"input_transforms\":{\"type\":\"object\",\"description\":\"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments\",\"additionalProperties\":{\"$ref\":\"#/components/schemas/InputTransform\"}},\"content\":{\"type\":\"string\",\"description\":\"The script source code. Should export a 'main' function\"},\"language\":{\"type\":\"string\",\"description\":\"Programming language for this script\",\"enum\":[\"deno\",\"bun\",\"python3\",\"go\",\"bash\",\"powershell\",\"postgresql\",\"mysql\",\"bigquery\",\"snowflake\",\"mssql\",\"oracledb\",\"graphql\",\"nativets\",\"php\",\"rust\",\"ansible\",\"csharp\",\"nu\",\"java\",\"ruby\",\"rlang\",\"duckdb\"]},\"path\":{\"type\":\"string\",\"description\":\"Optional path for saving this script\"},\"lock\":{\"type\":\"string\",\"description\":\"Lock file content for dependencies\"},\"type\":{\"type\":\"string\",\"enum\":[\"rawscript\"]},\"tag\":{\"type\":\"string\",\"description\":\"Worker group tag for execution routing\"},\"concurrent_limit\":{\"type\":\"number\",\"description\":\"Maximum concurrent executions of this script\"},\"concurrency_time_window_s\":{\"type\":\"number\",\"description\":\"Time window for concurrent_limit\"},\"custom_concurrency_key\":{\"type\":\"string\",\"description\":\"Custom key for grouping concurrent executions\"},\"is_trigger\":{\"type\":\"boolean\",\"description\":\"If true, this script is a trigger that can start the flow\"},\"assets\":{\"type\":\"array\",\"description\":\"External resources this script accesses (S3 objects, resources, etc.)\",\"items\":{\"type\":\"object\",\"required\":[\"path\",\"kind\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the asset\"},\"kind\":{\"type\":\"string\",\"description\":\"Type of asset\",\"enum\":[\"s3object\",\"resource\",\"ducklake\",\"datatable\",\"volume\"]},\"access_type\":{\"type\":\"string\",\"nullable\":true,\"description\":\"Access level for this asset\",\"enum\":[\"r\",\"w\",\"rw\"]},\"alt_access_type\":{\"type\":\"string\",\"nullable\":true,\"description\":\"Alternative access level\",\"enum\":[\"r\",\"w\",\"rw\"]}}}}},\"required\":[\"type\",\"content\",\"language\",\"input_transforms\"]},\"PathScript\":{\"type\":\"object\",\"description\":\"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code\",\"properties\":{\"input_transforms\":{\"type\":\"object\",\"description\":\"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments\",\"additionalProperties\":{\"$ref\":\"#/components/schemas/InputTransform\"}},\"path\":{\"type\":\"string\",\"description\":\"Path to the script in the workspace (e.g., 'f/scripts/send_email')\"},\"hash\":{\"type\":\"string\",\"description\":\"Optional specific version hash of the script to use\"},\"type\":{\"type\":\"string\",\"enum\":[\"script\"]},\"tag_override\":{\"type\":\"string\",\"description\":\"Override the script's default worker group tag\"},\"is_trigger\":{\"type\":\"boolean\",\"description\":\"If true, this script is a trigger that can start the flow\"}},\"required\":[\"type\",\"path\",\"input_transforms\"]},\"PathFlow\":{\"type\":\"object\",\"description\":\"Reference to an existing flow by path. Use this to call another flow as a subflow\",\"properties\":{\"input_transforms\":{\"type\":\"object\",\"description\":\"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments\",\"additionalProperties\":{\"$ref\":\"#/components/schemas/InputTransform\"}},\"path\":{\"type\":\"string\",\"description\":\"Path to the flow in the workspace (e.g., 'f/flows/process_user')\"},\"type\":{\"type\":\"string\",\"enum\":[\"flow\"]}},\"required\":[\"type\",\"path\",\"input_transforms\"]},\"ForloopFlow\":{\"type\":\"object\",\"description\":\"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations\",\"properties\":{\"modules\":{\"type\":\"array\",\"description\":\"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}},\"iterator\":{\"description\":\"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'\",\"$ref\":\"#/components/schemas/InputTransform\"},\"skip_failures\":{\"type\":\"boolean\",\"description\":\"If true, iteration failures don't stop the loop. Failed iterations return null\"},\"type\":{\"type\":\"string\",\"enum\":[\"forloopflow\"]},\"parallel\":{\"type\":\"boolean\",\"description\":\"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency\"},\"parallelism\":{\"description\":\"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression\",\"$ref\":\"#/components/schemas/InputTransform\"},\"squash\":{\"type\":\"boolean\"}},\"required\":[\"modules\",\"iterator\",\"skip_failures\",\"type\"]},\"WhileloopFlow\":{\"type\":\"object\",\"description\":\"Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination\",\"properties\":{\"modules\":{\"type\":\"array\",\"description\":\"Steps to execute in each iteration. Use stop_after_if to control when the loop ends\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}},\"skip_failures\":{\"type\":\"boolean\",\"description\":\"If true, iteration failures don't stop the loop. Failed iterations return null\"},\"type\":{\"type\":\"string\",\"enum\":[\"whileloopflow\"]},\"parallel\":{\"type\":\"boolean\",\"description\":\"If true, iterations run concurrently (use with caution in while loops)\"},\"parallelism\":{\"description\":\"Maximum number of concurrent iterations when parallel=true\",\"$ref\":\"#/components/schemas/InputTransform\"},\"squash\":{\"type\":\"boolean\"}},\"required\":[\"modules\",\"skip_failures\",\"type\"]},\"BranchOne\":{\"type\":\"object\",\"description\":\"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes\",\"properties\":{\"branches\":{\"type\":\"array\",\"description\":\"Array of branches to evaluate in order. The first branch with expr evaluating to true executes\",\"items\":{\"type\":\"object\",\"properties\":{\"summary\":{\"type\":\"string\",\"description\":\"Short description of this branch condition\"},\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins\"},\"modules\":{\"type\":\"array\",\"description\":\"Steps to execute if this branch's expr is true\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}}},\"required\":[\"modules\",\"expr\"]}},\"default\":{\"type\":\"array\",\"description\":\"Steps to execute if no branch expressions match\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}},\"type\":{\"type\":\"string\",\"enum\":[\"branchone\"]}},\"required\":[\"branches\",\"default\",\"type\"]},\"BranchAll\":{\"type\":\"object\",\"description\":\"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently\",\"properties\":{\"branches\":{\"type\":\"array\",\"description\":\"Array of branches that all execute (either in parallel or sequentially)\",\"items\":{\"type\":\"object\",\"properties\":{\"summary\":{\"type\":\"string\",\"description\":\"Short description of this branch's purpose\"},\"skip_failure\":{\"type\":\"boolean\",\"description\":\"If true, failure in this branch doesn't fail the entire flow\"},\"modules\":{\"type\":\"array\",\"description\":\"Steps to execute in this branch\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}}},\"required\":[\"modules\"]}},\"type\":{\"type\":\"string\",\"enum\":[\"branchall\"]},\"parallel\":{\"type\":\"boolean\",\"description\":\"If true, all branches execute concurrently. If false, they execute sequentially\"}},\"required\":[\"branches\",\"type\"]},\"AgentTool\":{\"type\":\"object\",\"description\":\"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool\",\"properties\":{\"id\":{\"type\":\"string\",\"description\":\"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')\"},\"summary\":{\"type\":\"string\",\"description\":\"Short description of what this tool does (shown to the AI)\"},\"value\":{\"$ref\":\"#/components/schemas/ToolValue\"}},\"required\":[\"id\",\"value\"]},\"ToolValue\":{\"description\":\"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference\",\"oneOf\":[{\"$ref\":\"#/components/schemas/FlowModuleTool\"},{\"$ref\":\"#/components/schemas/McpToolValue\"},{\"$ref\":\"#/components/schemas/WebsearchToolValue\"}],\"discriminator\":{\"propertyName\":\"tool_type\",\"mapping\":{\"flowmodule\":\"#/components/schemas/FlowModuleTool\",\"mcp\":\"#/components/schemas/McpToolValue\",\"websearch\":\"#/components/schemas/WebsearchToolValue\"}}},\"FlowModuleTool\":{\"description\":\"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module\",\"allOf\":[{\"type\":\"object\",\"properties\":{\"tool_type\":{\"type\":\"string\",\"enum\":[\"flowmodule\"]}},\"required\":[\"tool_type\"]},{\"$ref\":\"#/components/schemas/FlowModuleValue\"}]},\"WebsearchToolValue\":{\"type\":\"object\",\"description\":\"A tool implemented as a websearch tool. The AI can call this like any other websearch tool\",\"properties\":{\"tool_type\":{\"type\":\"string\",\"enum\":[\"websearch\"]}},\"required\":[\"tool_type\"]},\"McpToolValue\":{\"type\":\"object\",\"description\":\"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers\",\"properties\":{\"tool_type\":{\"type\":\"string\",\"enum\":[\"mcp\"]},\"resource_path\":{\"type\":\"string\",\"description\":\"Path to the MCP resource/server configuration\"},\"include_tools\":{\"type\":\"array\",\"description\":\"Whitelist of specific tools to include from this MCP server\",\"items\":{\"type\":\"string\"}},\"exclude_tools\":{\"type\":\"array\",\"description\":\"Blacklist of tools to exclude from this MCP server\",\"items\":{\"type\":\"string\"}}},\"required\":[\"tool_type\",\"resource_path\"]},\"AiAgent\":{\"type\":\"object\",\"description\":\"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task\",\"properties\":{\"input_transforms\":{\"type\":\"object\",\"description\":\"Input parameters for the AI agent mapped to their values\",\"properties\":{\"provider\":{\"$ref\":\"#/components/schemas/ProviderTransform\"},\"output_type\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Output format type.\\nValid values: 'text' (default) - plain text response, 'image' - image generation\\n\"},\"user_message\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax.\"},\"system_prompt\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"System instructions that guide the AI's behavior, persona, and response style. Optional.\"},\"streaming\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Boolean. If true, stream the AI response incrementally.\\nStreaming events include: token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\\n\"},\"memory\":{\"$ref\":\"#/components/schemas/MemoryTransform\"},\"output_schema\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\\n\"},\"user_attachments\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Array of file references (images or PDFs) for the AI agent.\\nFormat: Array<{ bucket: string, key: string }> - S3 object references\\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\\n\"},\"max_completion_tokens\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Integer. Maximum number of tokens the AI will generate in its response.\\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\\n\"},\"temperature\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Float. Controls randomness/creativity of responses.\\nRange: 0.0 to 2.0 (provider-dependent)\\n- 0.0 = deterministic, focused responses\\n- 0.7 = balanced (common default)\\n- 1.0+ = more creative/random\\n\"},\"max_iterations\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Number. Limits how many times the agent can loop through reasoning and tool use.\\nRange: 1-1000.\\n\"}},\"required\":[\"provider\",\"user_message\",\"output_type\"]},\"tools\":{\"type\":\"array\",\"description\":\"Array of tools the agent can use. The agent decides which tools to call based on the task\",\"items\":{\"$ref\":\"#/components/schemas/AgentTool\"}},\"type\":{\"type\":\"string\",\"enum\":[\"aiagent\"]},\"omit_output_from_conversation\":{\"type\":\"boolean\",\"default\":false,\"description\":\"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled.\"},\"parallel\":{\"type\":\"boolean\",\"description\":\"If true, the agent can execute multiple tool calls in parallel\"}},\"required\":[\"tools\",\"type\",\"input_transforms\"]},\"Identity\":{\"type\":\"object\",\"description\":\"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"identity\"]},\"flow\":{\"type\":\"boolean\",\"description\":\"If true, marks this as a flow identity (special handling)\"}},\"required\":[\"type\"]},\"FlowStatus\":{\"type\":\"object\",\"properties\":{\"step\":{\"type\":\"integer\"},\"modules\":{\"type\":\"array\",\"items\":{\"$ref\":\"#/components/schemas/FlowStatusModule\"}},\"user_states\":{\"additionalProperties\":true},\"preprocessor_module\":{\"allOf\":[{\"$ref\":\"#/components/schemas/FlowStatusModule\"}]},\"failure_module\":{\"allOf\":[{\"$ref\":\"#/components/schemas/FlowStatusModule\"},{\"type\":\"object\",\"properties\":{\"parent_module\":{\"type\":\"string\"}}}]},\"retry\":{\"type\":\"object\",\"properties\":{\"fail_count\":{\"type\":\"integer\"},\"failed_jobs\":{\"type\":\"array\",\"items\":{\"type\":\"string\",\"format\":\"uuid\"}}}}},\"required\":[\"step\",\"modules\",\"failure_module\"]},\"FlowStatusModule\":{\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"WaitingForPriorSteps\",\"WaitingForEvents\",\"WaitingForExecutor\",\"InProgress\",\"Success\",\"Failure\"]},\"id\":{\"type\":\"string\"},\"job\":{\"type\":\"string\",\"format\":\"uuid\"},\"count\":{\"type\":\"integer\"},\"progress\":{\"type\":\"integer\"},\"iterator\":{\"type\":\"object\",\"properties\":{\"index\":{\"type\":\"integer\"},\"itered\":{\"type\":\"array\",\"items\":{}},\"itered_len\":{\"type\":\"integer\"},\"args\":{}}},\"flow_jobs\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}},\"flow_jobs_success\":{\"type\":\"array\",\"items\":{\"type\":\"boolean\"}},\"flow_jobs_duration\":{\"type\":\"object\",\"properties\":{\"started_at\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}},\"duration_ms\":{\"type\":\"array\",\"items\":{\"type\":\"integer\"}}}},\"branch_chosen\":{\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"branch\",\"default\"]},\"branch\":{\"type\":\"integer\"}},\"required\":[\"type\"]},\"branchall\":{\"type\":\"object\",\"properties\":{\"branch\":{\"type\":\"integer\"},\"len\":{\"type\":\"integer\"}},\"required\":[\"branch\",\"len\"]},\"approvers\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"resume_id\":{\"type\":\"integer\"},\"approver\":{\"type\":\"string\"}},\"required\":[\"resume_id\",\"approver\"]}},\"failed_retries\":{\"type\":\"array\",\"items\":{\"type\":\"string\",\"format\":\"uuid\"}},\"skipped\":{\"type\":\"boolean\"},\"agent_actions\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"oneOf\":[{\"type\":\"object\",\"properties\":{\"job_id\":{\"type\":\"string\",\"format\":\"uuid\"},\"function_name\":{\"type\":\"string\"},\"type\":{\"type\":\"string\",\"enum\":[\"tool_call\"]},\"module_id\":{\"type\":\"string\"}},\"required\":[\"job_id\",\"function_name\",\"type\",\"module_id\"]},{\"type\":\"object\",\"properties\":{\"call_id\":{\"type\":\"string\",\"format\":\"uuid\"},\"function_name\":{\"type\":\"string\"},\"resource_path\":{\"type\":\"string\"},\"type\":{\"type\":\"string\",\"enum\":[\"mcp_tool_call\"]},\"arguments\":{\"type\":\"object\"}},\"required\":[\"call_id\",\"function_name\",\"resource_path\",\"type\"]},{\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"web_search\"]}},\"required\":[\"type\"]},{\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"message\"]}},\"required\":[\"content\",\"type\"]}]}},\"agent_actions_success\":{\"type\":\"array\",\"items\":{\"type\":\"boolean\"}}},\"required\":[\"type\"]}}"; +export declare const CLI_COMMANDS = "# Windmill CLI Commands\n\nThe Windmill CLI (`wmill`) provides commands for managing scripts, flows, apps, and other resources.\n\n## Global Options\n\n- `--workspace ` - Specify the target workspace. This overrides the default workspace.\n- `--debug --verbose` - Show debug/verbose logs\n- `--show-diffs` - Show diff informations when syncing (may show sensitive informations)\n- `--token ` - Specify an API token. This will override any stored token.\n- `--base-url ` - Specify the base URL of the API. If used, --token and --workspace are required and no local remote/workspace already set will be used.\n- `--config-dir ` - Specify a custom config directory. Overrides WMILL_CONFIG_DIR environment variable and default ~/.config location.\n\n## Commands\n\n### app\n\napp related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `app list` - list all apps\n - `--json` - Output as JSON (for piping to jq)\n- `app get ` - get an app's details\n - `--json` - Output as JSON (for piping to jq)\n- `app push [file_path:string] [remote_path:string]` - push a local app. With no args, infers the app from the current directory and the remote path from its location relative to wmill.yaml.\n- `app dev [app_folder:string]` - Start a development server for building apps with live reload and hot module replacement\n - `--port ` - Port to run the dev server on (will find next available port if occupied)\n - `--host ` - Host to bind the dev server to\n - `--entry ` - Entry point file (default: index.ts for Svelte/Vue, index.tsx otherwise)\n - `--no-open` - Don't automatically open the browser\n- `app lint [app_folder:string]` - Lint a raw app folder to validate structure and buildability\n - `--fix` - Attempt to fix common issues (not implemented yet)\n- `app new` - create a new raw app from a template\n - `--summary ` - App summary (short description). Skips the prompt when provided. Triggers non-interactive mode.\n - `--path ` - App path (e.g., f/folder/my_app or u/username/my_app). Skips the prompt when provided. Triggers non-interactive mode.\n - `--framework ` - Framework template: react19 | react18 | svelte5 | vue. Skips the prompt when provided. Triggers non-interactive mode.\n - `--datatable ` - Datatable to wire up. Without this flag in non-interactive mode, no datatable is configured.\n - `--schema ` - Schema to use with --datatable. Created (CREATE SCHEMA IF NOT EXISTS) if it doesn't already exist.\n - `--overwrite` - Overwrite the target directory if it already exists, without prompting.\n - `--no-open-in-desktop` - Do not prompt to open the new app in Claude Desktop.\n- `app generate-agents [app_folder:string]` - regenerate AGENTS.md and DATATABLES.md from remote workspace\n- `app set-permissioned-as ` - Set the on_behalf_of_email for an app (requires admin or wm_deployers group)\n\n### audit\n\nView audit logs (requires admin)\n\n**Subcommands:**\n\n- `audit list` - List audit log entries\n- `audit get ` - Get a specific audit log entry\n - `--json` - Output as JSON (for piping to jq)\n\n### config\n\nShow all available wmill.yaml configuration options\n\n**Options:**\n- `--json` - Output as JSON for programmatic consumption\n\n**Subcommands:**\n\n- `config migrate` - Migrate wmill.yaml from gitBranches/environments to workspaces format\n\n### datatable\n\ndatatable related commands\n\n**Subcommands:**\n\n- `datatable list` - list all datatables in the workspace\n - `--json` - Output as JSON (for piping to jq)\n- `datatable run ` - run a SQL query on a datatable\n - `-n --name ` - Datatable name (default: main)\n - `-s --silent` - Output only the final result as JSON. Useful for scripting.\n- `datatable serve` - Serve all datatables as a Postgres-wire endpoint (psql, DBeaver, pgAdmin); the client picks the datatable via the database name in its connection string\n - `--port ` - Port to listen on (default: first free port in 5433-5500)\n - `--host ` - Bind address (default: 127.0.0.1)\n - `--password ` - Password for Postgres clients (default: generate a random password at startup)\n- `datatable psql` - Start a serve listener and launch psql connected to it\n - `-n --name ` - Datatable to connect psql to (default: main)\n - `--port ` - Port the proxy listens on (default: first free port in 5433-5500)\n - `--host ` - Bind address for the proxy (default: 127.0.0.1)\n - `--password ` - Password for the temporary Postgres proxy (default: generate a random password at startup)\n\n### dependencies\n\nworkspace dependencies related commands\n\n**Alias:** `deps`\n\n**Subcommands:**\n\n- `dependencies push ` - Push workspace dependencies from a local file\n\n### dev\n\nWatch local file changes and live-reload the dev page for preview. Does NOT deploy to the remote workspace \u2014 use wmill sync push for that.\n\n**Options:**\n- `--includes ` - Filter paths given a glob pattern or path\n- `--proxy-port ` - Port for a localhost reverse proxy to the remote Windmill server\n- `--path ` - Watch a specific windmill path (e.g., u/admin/my_script or f/my_flow)\n- `--no-open` - Do not open the browser automatically\n\n### docs\n\nSearch Windmill documentation.\n\n**Arguments:** ``\n\n**Options:**\n- `--json` - Output results as JSON.\n\n### ducklake\n\nducklake related commands\n\n**Subcommands:**\n\n- `ducklake list` - list all ducklakes in the workspace\n - `--json` - Output as JSON (for piping to jq)\n- `ducklake run ` - run a SQL query on a ducklake\n - `-n --name ` - Ducklake name (default: main)\n - `-s --silent` - Output only the final result as JSON. Useful for scripting.\n\n### flow\n\nflow related commands\n\n**Options:**\n- `--show-archived` - Enable archived flows in output\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `flow list` - list all flows\n - `--show-archived` - Enable archived flows in output\n - `--json` - Output as JSON (for piping to jq)\n- `flow get ` - get a flow's details\n - `--json` - Output as JSON (for piping to jq)\n- `flow push ` - push a local flow spec. This overrides any remote versions.\n - `--message ` - Deployment message\n- `flow run ` - run a flow by path.\n - `-d --data ` - Inputs specified as a JSON string or a file using @ or stdin using @-.\n - `-s --silent` - Do not ouput anything other then the final output. Useful for scripting.\n- `flow preview ` - preview a local flow without deploying it. Runs the flow definition from local files and uses local PathScripts by default.\n - `-d --data ` - Inputs specified as a JSON string or a file using @ or stdin using @-.\n - `-s --silent` - Do not output anything other then the final output. Useful for scripting.\n - `--remote` - Use deployed workspace scripts for PathScript steps instead of local files.\n- `flow new ` - create a new empty flow\n - `--summary ` - flow summary\n - `--description ` - flow description\n- `flow bootstrap ` - create a new empty flow (alias for new)\n - `--summary ` - flow summary\n - `--description ` - flow description\n- `flow history ` - Show version history for a flow\n - `--json` - Output as JSON (for piping to jq)\n- `flow show-version ` - Show a specific version of a flow\n - `--json` - Output as JSON (for piping to jq)\n- `flow set-permissioned-as ` - Set the on_behalf_of_email for a flow (requires admin or wm_deployers group)\n\n### folder\n\nfolder related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `folder list` - list all folders\n - `--json` - Output as JSON (for piping to jq)\n- `folder get ` - get a folder's details\n - `--json` - Output as JSON (for piping to jq)\n- `folder new ` - create a new folder locally\n - `--summary ` - folder summary\n- `folder push ` - push a local folder to the remote by name. This overrides any remote versions.\n- `folder add-missing` - create default folder.meta.yaml for all subdirectories of f/ that are missing one\n - `-y, --yes` - skip confirmation prompt\n- `folder show-rules ` - Show default_permissioned_as rules for a folder. Use --test-path to see which rule matches a given item path.\n - `--test-path ` - Test which rule matches this item path (e.g. f/prod/jobs/my_script)\n - `--json` - Output as JSON\n\n### generate-metadata\n\nGenerate metadata (locks, schemas) for all scripts, flows, and apps\n\n**Arguments:** `[folder:string]`\n\n**Options:**\n- `--yes` - Skip confirmation prompt\n- `--dry-run` - Show what would be updated without making changes\n- `--lock-only` - Re-generate only the lock files\n- `--schema-only` - Re-generate only script schemas (skips flows and apps)\n- `--skip-scripts` - Skip processing scripts\n- `--skip-flows` - Skip processing flows\n- `--skip-apps` - Skip processing apps\n- `--strict-folder-boundaries` - Only update items inside the specified folder (requires folder argument)\n- `--parallel ` - Number of items to process in parallel\n- `-i --includes ` - Comma separated patterns to specify which files to include\n- `-e --excludes ` - Comma separated patterns to specify which files to exclude\n\n**Subcommands:**\n\n- `generate-metadata rehash [folder:string]`\n - `--skip-scripts` - Skip processing scripts\n - `--skip-flows` - Skip processing flows\n - `--skip-apps` - Skip processing apps\n - `--parallel ` - Number of items to process in parallel\n - `-i --includes ` - Comma separated patterns to specify which files to include\n - `-e --excludes ` - Comma separated patterns to specify which files to exclude\n\n### gitsync-settings\n\nManage git-sync settings between local wmill.yaml and Windmill backend\n\n**Subcommands:**\n\n- `gitsync-settings pull` - Pull git-sync settings from Windmill backend to local wmill.yaml\n - `--repository ` - Specify repository path (e.g., u/user/repo)\n - `--default` - Write settings to top-level defaults instead of overrides\n - `--replace` - Replace existing settings (non-interactive mode)\n - `--override` - Add branch-specific override (non-interactive mode)\n - `--diff` - Show differences without applying changes\n - `--json-output` - Output in JSON format\n - `--with-backend-settings ` - Use provided JSON settings instead of querying backend (for testing)\n - `--yes` - Skip interactive prompts and use default behavior\n - `--promotion ` - Use promotionOverrides from the specified branch instead of regular overrides\n- `gitsync-settings push` - Push git-sync settings from local wmill.yaml to Windmill backend\n - `--repository ` - Specify repository path (e.g., u/user/repo)\n - `--diff` - Show what would be pushed without applying changes\n - `--json-output` - Output in JSON format\n - `--with-backend-settings ` - Use provided JSON settings instead of querying backend (for testing)\n - `--yes` - Skip interactive prompts and use default behavior\n - `--promotion ` - Use promotionOverrides from the specified branch instead of regular overrides\n\n### group\n\nManage workspace groups\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `group list` - List all groups in the workspace\n - `--json` - Output as JSON (for piping to jq)\n- `group get ` - Get group details and members\n - `--json` - Output as JSON (for piping to jq)\n- `group create ` - Create a new group\n - `--summary ` - Group summary/description\n- `group delete ` - Delete a group\n- `group add-user ` - Add a user to a group\n- `group remove-user ` - Remove a user from a group\n\n### hub\n\nHub related commands. EXPERIMENTAL. INTERNAL USE ONLY.\n\n**Subcommands:**\n\n- `hub pull` - pull any supported definitions. EXPERIMENTAL.\n\n### init\n\nBootstrap a windmill project with a wmill.yaml file\n\n**Options:**\n- `--use-default` - Use default settings without checking backend\n- `--use-backend` - Use backend git-sync settings if available\n- `--repository ` - Specify repository path (e.g., u/user/repo) when using backend settings\n- `--bind-profile` - Automatically bind active workspace profile to current Git branch\n- `--no-bind-profile` - Skip workspace profile binding prompt\n\n### instance\n\nsync local with a remote instance or the opposite (push or pull)\n\n**Subcommands:**\n\n- `instance add [instance_name:string] [remote:string] [token:string]` - Add a new instance\n- `instance remove ` - Remove an instance\n- `instance switch ` - Switch the current instance\n- `instance pull` - Pull instance settings, users, configs, instance groups and overwrite local\n - `--yes` - Pull without needing confirmation\n - `--dry-run` - Perform a dry run without making changes\n - `--skip-users` - Skip pulling users\n - `--skip-settings` - Skip pulling settings\n - `--skip-configs` - Skip pulling configs (worker groups)\n - `--skip-groups` - Skip pulling instance groups\n - `--include-workspaces` - Also pull workspaces\n - `--folder-per-instance` - Create a folder per instance\n - `--instance ` - Name of the instance to pull from, override the active instance\n - `--prefix ` - Prefix of the local workspaces to pull, used to create the folders when using --include-workspaces\n - `--prefix-settings` - Store instance yamls inside prefixed folders when using --prefix and --folder-per-instance\n- `instance push` - Push instance settings, users, configs, group and overwrite remote\n - `--yes` - Push without needing confirmation\n - `--dry-run` - Perform a dry run without making changes\n - `--skip-users` - Skip pushing users\n - `--skip-settings` - Skip pushing settings\n - `--skip-configs` - Skip pushing configs (worker groups)\n - `--skip-groups` - Skip pushing instance groups\n - `--include-workspaces` - Also push workspaces\n - `--folder-per-instance` - Create a folder per instance\n - `--instance ` - Name of the instance to push to, override the active instance\n - `--prefix ` - Prefix of the local workspaces folders to push\n - `--prefix-settings` - Store instance yamls inside prefixed folders when using --prefix and --folder-per-instance\n- `instance whoami` - Display information about the currently logged-in user\n- `instance get-config` - Dump the current instance config (global settings + worker configs) as YAML\n - `-o, --output-file ` - Write YAML to a file instead of stdout\n - `--show-secrets` - Include sensitive fields (license key, JWT secret) without prompting\n - `--instance ` - Name of the instance, override the active instance\n- `instance connect-slack`\n - `--bot-token ` - Slack bot token (xoxb-...)\n - `--team-id ` - Slack team id\n - `--team-name ` - Slack team name\n - `--instance ` - Instance profile to connect against (defaults to the active instance)\n\n### job\n\nManage jobs (list, inspect, cancel)\n\n**Subcommands:**\n\n- `job list` - List recent jobs\n- `job get ` - Get job details. For flows: shows step tree with sub-job IDs\n - `--json` - Output as JSON (for piping to jq)\n- `job result ` - Get the result of a completed job (machine-friendly)\n- `job logs ` - Get job logs. For flows: aggregates all step logs\n- `job cancel ` - Cancel a running or queued job\n - `--reason ` - Reason for cancellation\n\n### jobs\n\nPull completed and queued jobs from workspace\n\n**Arguments:** `[workspace:string]`\n\n**Options:**\n- `-c, --completed-output ` - Completed jobs output file (default: completed_jobs.json)\n- `-q, --queued-output ` - Queued jobs output file (default: queued_jobs.json)\n- `--skip-worker-check` - Skip checking for active workers before export\n\n**Subcommands:**\n\n- `jobs pull`\n- `jobs push`\n\n### lint\n\nValidate Windmill flow, schedule, and trigger YAML files in a directory\n\n**Arguments:** `[directory:string]`\n\n**Options:**\n- `--json` - Output results in JSON format\n- `--fail-on-warn` - Exit with code 1 when warnings are emitted\n- `--locks-required` - Fail if scripts or flow inline scripts that need locks have no locks\n- `-w, --watch` - Watch for file changes and re-lint automatically\n\n### protection-rules\n\n**Subcommands:**\n\n- `protection-rules pull [workspace:string]` - Pull protection rules from Windmill into protection-rules.yaml for a workspace\n - `--all` - Pull every workspace defined in wmill.yaml\n - `--dry-run` - Show what would change without writing the file\n - `--json-output` - Output in JSON format\n- `protection-rules push [workspace:string]` - Push protection rules from protection-rules.yaml to Windmill for a workspace (full reconcile: creates, updates, and deletes)\n - `--all` - Push every workspace defined in protection-rules.yaml\n - `--dry-run` - Show what would change without applying\n - `--json-output` - Output in JSON format\n - `--yes` - Skip the confirmation prompt (including deletions)\n\n### queues\n\nList all queues with their metrics\n\n**Arguments:** `[workspace:string] the optional workspace to filter by (default to all workspaces)`\n\n**Options:**\n- `--instance [instance]` - Name of the instance to push to, override the active instance\n- `--base-url [baseUrl]` - If used with --token, will be used as the base url for the instance\n\n### refresh\n\nRefresh wmill-managed project files (AGENTS.cli.md and skills)\n\n**Subcommands:**\n\n- `refresh prompts` - Refresh AGENTS.cli.md and managed skills. User-owned AGENTS.md and CLAUDE.md are never overwritten unless you opt in.\n - `--yes` - Non-interactive: skip the migration prompt for existing AGENTS.md / CLAUDE.md without the expected include; defaults to appending the include.\n\n### resource\n\nresource related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `resource list` - list all resources\n - `--json` - Output as JSON (for piping to jq)\n- `resource get ` - get a resource's details\n - `--json` - Output as JSON (for piping to jq)\n- `resource new ` - create a new resource locally\n- `resource push ` - push a local resource spec. This overrides any remote versions.\n\n### resource-type\n\nresource type related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `resource-type list` - list all resource types\n - `--schema` - Show schema in the output\n - `--json` - Output as JSON (for piping to jq)\n- `resource-type get ` - get a resource type's details\n - `--json` - Output as JSON (for piping to jq)\n- `resource-type new ` - create a new resource type locally\n- `resource-type push ` - push a local resource spec. This overrides any remote versions.\n- `resource-type generate-namespace` - Create a TypeScript definition file with the RT namespace generated from the resource types\n\n### schedule\n\nschedule related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `schedule list` - list all schedules\n - `--json` - Output as JSON (for piping to jq)\n- `schedule get ` - get a schedule's details\n - `--json` - Output as JSON (for piping to jq)\n- `schedule new ` - create a new schedule locally\n- `schedule push ` - push a local schedule spec. This overrides any remote versions.\n- `schedule enable ` - Enable a schedule\n - `--force` - Bypass the fork-conflict warning when the parent workspace has the same schedule (acknowledges that both crons will fire)\n- `schedule disable ` - Disable a schedule\n- `schedule set-permissioned-as ` - Set the email (run-as user) for a schedule (requires admin or wm_deployers group)\n\n### script\n\nscript related commands\n\n**Options:**\n- `--show-archived` - Show archived scripts instead of active ones\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `script list` - list all scripts\n - `--show-archived` - Show archived scripts instead of active ones\n - `--json` - Output as JSON (for piping to jq)\n- `script push ` - push a local script spec. This overrides any remote versions. Use the script file (.ts, .js, .py, .sh)\n - `--message ` - Deployment message\n- `script get ` - get a script's details\n - `--json` - Output as JSON (for piping to jq)\n- `script show ` - show a script's content (alias for get)\n- `script run ` - run a script by path\n - `-d --data ` - Inputs specified as a JSON string or a file using @ or stdin using @-.\n - `-s --silent` - Do not output anything other then the final output. Useful for scripting.\n- `script preview ` - preview a local script without deploying it. Supports both regular and codebase scripts.\n - `-d --data ` - Inputs specified as a JSON string or a file using @ or stdin using @-.\n - `-s --silent` - Do not output anything other than the final output. Useful for scripting.\n- `script new ` - create a new script\n - `--summary ` - script summary\n - `--description ` - script description\n- `script bootstrap ` - create a new script (alias for new)\n - `--summary ` - script summary\n - `--description ` - script description\n- `script set-permissioned-as ` - Set the on_behalf_of_email for a script (requires admin or wm_deployers group)\n- `script history ` - show version history for a script\n - `--json` - Output as JSON (for piping to jq)\n\n### sync\n\nsync local with a remote workspaces or the opposite (push or pull)\n\n**Subcommands:**\n\n- `sync pull` - Pull any remote changes and apply them locally.\n - `--yes` - Pull without needing confirmation\n - `--dry-run` - Show changes that would be pulled without actually pushing\n - `--plain-secrets` - Pull secrets as plain text\n - `--json` - Use JSON instead of YAML\n - `--skip-variables` - Skip syncing variables (including secrets)\n - `--skip-secrets` - Skip syncing only secrets variables\n - `--include-secrets` - Include secrets in sync (overrides skipSecrets in wmill.yaml)\n - `--skip-resources` - Skip syncing resources\n - `--skip-resource-types` - Skip syncing resource types\n - `--skip-scripts` - Skip syncing scripts\n - `--skip-flows` - Skip syncing flows\n - `--skip-apps` - Skip syncing apps\n - `--skip-folders` - Skip syncing folders\n - `--skip-workspace-dependencies` - Skip syncing workspace dependencies\n - `--skip-scripts-metadata` - Skip syncing scripts metadata, focus solely on logic\n - `--include-schedules` - Include syncing schedules\n - `--include-triggers` - Include syncing triggers\n - `--include-users` - Include syncing users\n - `--include-groups` - Include syncing groups\n - `--include-settings` - Include syncing workspace settings\n - `--include-key` - Include workspace encryption key\n - `--skip-branch-validation` - Skip git branch validation and prompts\n - `--json-output` - Output results in JSON format\n - `-i --includes ` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string). Overrides wmill.yaml includes\n - `-e --excludes ` - Comma separated patterns to specify which file to NOT take into account. Overrides wmill.yaml excludes\n - `--extra-includes ` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string). Useful to still take wmill.yaml into account and act as a second pattern to satisfy\n - `--repository ` - Specify repository path (e.g., u/user/repo) when multiple repositories exist\n - `--promotion ` - Use promotionOverrides from the specified branch instead of regular overrides\n - `--branch, --env ` - [Deprecated: use --workspace] Override the current git branch/environment\n- `sync push` - Push any local changes and apply them remotely.\n - `--yes` - Push without needing confirmation\n - `--dry-run` - Show changes that would be pushed without actually pushing\n - `--plain-secrets` - Push secrets as plain text\n - `--json` - Use JSON instead of YAML\n - `--skip-variables` - Skip syncing variables (including secrets)\n - `--skip-secrets` - Skip syncing only secrets variables\n - `--include-secrets` - Include secrets in sync (overrides skipSecrets in wmill.yaml)\n - `--skip-resources` - Skip syncing resources\n - `--skip-resource-types` - Skip syncing resource types\n - `--skip-scripts` - Skip syncing scripts\n - `--skip-flows` - Skip syncing flows\n - `--skip-apps` - Skip syncing apps\n - `--skip-folders` - Skip syncing folders\n - `--skip-workspace-dependencies` - Skip syncing workspace dependencies\n - `--skip-scripts-metadata` - Skip syncing scripts metadata, focus solely on logic\n - `--include-schedules` - Include syncing schedules\n - `--include-triggers` - Include syncing triggers\n - `--include-users` - Include syncing users\n - `--include-groups` - Include syncing groups\n - `--include-settings` - Include syncing workspace settings\n - `--include-key` - Include workspace encryption key\n - `--skip-branch-validation` - Skip git branch validation and prompts\n - `--json-output` - Output results in JSON format\n - `-i --includes ` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string)\n - `-e --excludes ` - Comma separated patterns to specify which file to NOT take into account.\n - `--extra-includes ` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string). Useful to still take wmill.yaml into account and act as a second pattern to satisfy\n - `--message ` - Include a message that will be added to all scripts/flows/apps updated during this push\n - `--parallel ` - Number of changes to process in parallel\n - `--repository ` - Specify repository path (e.g., u/user/repo) when multiple repositories exist\n - `--branch, --env ` - [Deprecated: use --workspace] Override the current git branch/environment\n - `--lint` - Run lint validation before pushing\n - `--locks-required` - Fail if scripts or flow inline scripts that need locks have no locks\n - `--auto-metadata` - Automatically regenerate stale metadata (locks and schemas) before pushing\n - `--accept-overriding-permissioned-as-with-self` - Accept that items with a different permissioned_as will be updated with your own user\n\n### token\n\nManage API tokens\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `token list` - List API tokens\n - `--json` - Output as JSON (for piping to jq)\n- `token create` - Create a new API token\n - `--label ` - Token label\n - `--expiration ` - Token expiration (ISO 8601 timestamp)\n- `token delete ` - Delete a token by its prefix\n\n### trigger\n\ntrigger related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `trigger list` - list all triggers\n - `--json` - Output as JSON (for piping to jq)\n- `trigger get ` - get a trigger's details\n - `--json` - Output as JSON (for piping to jq)\n - `--kind ` - Trigger kind (http, websocket, kafka, nats, postgres, mqtt, sqs, gcp, azure, email). Recommended for faster lookup\n- `trigger new ` - create a new trigger locally\n - `--kind ` - Trigger kind (required: http, websocket, kafka, nats, postgres, mqtt, sqs, gcp, azure, email)\n- `trigger push ` - push a local trigger spec. This overrides any remote versions.\n- `trigger set-permissioned-as ` - Set the email (run-as user) for a trigger (requires admin or wm_deployers group)\n - `--kind ` - Trigger kind (required: http, websocket, kafka, nats, postgres, mqtt, sqs, gcp, azure, email)\n\n### user\n\nuser related commands\n\n**Subcommands:**\n\n- `user add [password:string]` - Create a user\n - `--superadmin` - Specify to make the new user superadmin.\n - `--company ` - Specify to set the company of the new user.\n - `--name ` - Specify to set the name of the new user.\n- `user remove ` - Delete a user\n- `user create-token` - Create a new API token for the authenticated user\n - `--email ` - Specify credentials to use for authentication. This will not be stored. It will only be used to exchange for a token with the API server, which will not be stored either.\n - `--password ` - Specify credentials to use for authentication. This will not be stored. It will only be used to exchange for a token with the API server, which will not be stored either.\n\n### variable\n\nvariable related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `variable list` - list all variables\n - `--json` - Output as JSON (for piping to jq)\n- `variable get ` - get a variable's details\n - `--json` - Output as JSON (for piping to jq)\n- `variable new ` - create a new variable locally\n- `variable push ` - Push a local variable spec. This overrides any remote versions.\n - `--plain-secrets` - Push secrets as plain text\n- `variable add ` - Create a new variable on the remote. This will update the variable if it already exists.\n - `--plain-secrets` - Push secrets as plain text\n - `--public` - Legacy option, use --plain-secrets instead\n\n### version\n\nShow version information\n\n### worker-groups\n\ndisplay worker groups, pull and push worker groups configs\n\n**Subcommands:**\n\n- `worker-groups pull` - Pull worker groups (similar to `wmill instance pull --skip-users --skip-settings --skip-groups`)\n - `--instance` - Name of the instance to push to, override the active instance\n - `--base-url` - Base url to be passed to the instance settings instead of the local one\n - `--yes` - Pull without needing confirmation\n- `worker-groups push` - Push worker groups (similar to `wmill instance push --skip-users --skip-settings --skip-groups`)\n - `--instance [instance]` - Name of the instance to push to, override the active instance\n - `--base-url [baseUrl]` - If used with --token, will be used as the base url for the instance\n - `--yes` - Push without needing confirmation\n\n### workers\n\nList all workers grouped by worker groups\n\n**Options:**\n- `--instance [instance]` - Name of the instance to push to, override the active instance\n- `--base-url [baseUrl]` - If used with --token, will be used as the base url for the instance\n\n### workspace\n\nworkspace related commands\n\n**Alias:** `profile`\n\n**Subcommands:**\n\n- `workspace switch ` - Switch to another workspace\n- `workspace add [workspace_name:string] [workspace_id:string] [remote:string]` - Add a workspace\n - `-c --create` - Create the workspace if it does not exist\n - `--create-workspace-name ` - Specify the workspace name. Ignored if --create is not specified or the workspace already exists. Will default to the workspace id.\n - `--create-username ` - Specify your own username in the newly created workspace. Ignored if --create is not specified, the workspace already exists or automatic username creation is enabled on the instance.\n- `workspace remove ` - Remove a workspace\n- `workspace whoami` - Show the currently active user\n- `workspace list` - List local workspace profiles\n- `workspace list-remote` - List workspaces on the remote server that you have access to\n - `--as-superadmin` - List ALL workspaces on the instance (requires the token to belong to a superadmin/devops user)\n- `workspace list-forks` - List forked workspaces on the remote server\n- `workspace bind` - Create or update a workspace entry in wmill.yaml from the active profile\n - `--workspace ` - Workspace name (default: current branch or workspaceId)\n - `--branch ` - Git branch to associate (default: workspace name)\n- `workspace unbind` - Remove baseUrl and workspaceId from a workspace entry\n - `--workspace ` - Workspace to unbind\n- `workspace fork [workspace_name:string] [workspace_id:string]` - Create a forked workspace\n - `--create-workspace-name ` - Specify the workspace name. Ignored if --create is not specified or the workspace already exists. Will default to the workspace id.\n - `--color ` - Workspace color (hex code, e.g. #ff0000)\n - `--datatable-behavior ` - How to handle datatables: skip, schema_only, or schema_and_data (default: interactive prompt)\n - `-y --yes` - Skip interactive prompts (defaults datatable behavior to 'skip')\n- `workspace delete-fork ` - Delete a forked workspace and git branch\n - `-y --yes` - Skip confirmation prompt\n- `workspace merge` - Compare and deploy changes between a fork and its parent workspace\n - `--direction ` - Deploy direction: to-parent or to-fork\n - `--all` - Deploy all changed items including conflicts\n - `--skip-conflicts` - Skip items modified in both workspaces\n - `--include ` - Comma-separated kind:path items to include (e.g. script:f/test/main,flow:f/my/flow)\n - `--exclude ` - Comma-separated kind:path items to exclude\n - `--preserve-on-behalf-of` - Preserve original on_behalf_of/permissioned_as values\n - `-y --yes` - Non-interactive mode (deploy without prompts)\n- `workspace connect-slack` - Non-interactively connect Slack to the active workspace using a pre-minted bot token (xoxb-...). Produces the same artifacts as the UI OAuth flow: workspace_settings fields, g/slack group, f/slack_bot folder, and the encrypted bot token variable + resource at f/slack_bot/bot_token.\n - `--bot-token ` - Slack bot token (xoxb-...)\n - `--team-id ` - Slack team id\n - `--team-name ` - Slack team name\n- `workspace disconnect-slack`\n\n"; export declare const LANG_BASH = "# Bash\n\n## Structure\n\nDo not include `#!/bin/bash`. Arguments are obtained as positional parameters:\n\n```bash\n# Get arguments\nvar1=\"$1\"\nvar2=\"$2\"\n\necho \"Processing $var1 and $var2\"\n\n# Return JSON by echoing to stdout\necho \"{\\\"result\\\": \\\"$var1\\\", \\\"count\\\": $var2}\"\n```\n\n**Important:**\n- Do not include shebang (`#!/bin/bash`)\n- Arguments are always strings\n- Access with `$1`, `$2`, etc.\n\n## Output\n\nThe script output is captured as the result. For structured data, output valid JSON:\n\n```bash\nname=\"$1\"\ncount=\"$2\"\n\n# Output JSON result\ncat << EOF\n{\n \"name\": \"$name\",\n \"count\": $count,\n \"timestamp\": \"$(date -Iseconds)\"\n}\nEOF\n```\n\n## Environment Variables\n\nEnvironment variables set in Windmill are available:\n\n```bash\n# Access environment variable\necho \"Workspace: $WM_WORKSPACE\"\necho \"Job ID: $WM_JOB_ID\"\n```\n"; export declare const LANG_BIGQUERY = "# BigQuery\n\nArguments use `@name` syntax.\n\nName the parameters by adding comments before the statement:\n\n```sql\n-- @name1 (string)\n-- @name2 (int64) = 0\nSELECT * FROM users WHERE name = @name1 AND age > @name2;\n```\n\n## Receiving an S3Object as a script parameter\n\nDeclare the arg with type `(s3object)`. Windmill renders an S3 file picker for\nit, downloads the file, and binds it as a `STRING` JSON parameter \u2014 Parquet/CSV\nfiles are decoded server-side into a JSON array of records, JSON/JSONL pass\nthrough. Consume with `JSON_EXTRACT_ARRAY` / `JSON_VALUE`:\n\n```sql\n-- @file (s3object)\nSELECT\n CAST(JSON_VALUE(row, '$.id') AS INT64) AS id,\n JSON_VALUE(row, '$.name') AS name\nFROM UNNEST(JSON_EXTRACT_ARRAY(@file)) AS row;\n```\n\n## Streaming query results to S3\n\nAdd a `-- s3` directive at the top of the script to stream the result set to S3\ninstead of returning rows. Windmill writes the file and returns its `S3Object`\nas the script result.\n\n```sql\n-- s3 prefix=exports/users format=parquet\nSELECT id, name FROM users;\n```\n\nAll keys are optional: `prefix` (object key prefix), `storage` (named storage \u2014\nomit to use the workspace default), `format` (`json` (default), `parquet`, or\n`csv`). Use this for large result sets \u2014 rows stream directly to S3 instead of\nbeing buffered, bypassing the 10000-row return cap.\n"; export declare const LANG_BUN = "# TypeScript (Bun)\n\nBun runtime with full npm ecosystem and fastest execution.\n\n## Structure\n\nExport a single **async** function called `main`:\n\n```typescript\nexport async function main(param1: string, param2: number) {\n // Your code here\n return { result: param1, count: param2 };\n}\n```\n\nDo not call the main function. Libraries are installed automatically.\n\n## Resource Types\n\nOn Windmill, credentials and configuration are stored in resources and passed as parameters to main.\n\nUse the `RT` namespace for resource types:\n\n```typescript\nexport async function main(stripe: RT.Stripe) {\n // stripe contains API key and config from the resource\n}\n```\n\nOnly use resource types if you need them to satisfy the instructions. Always use the RT namespace.\n\nBefore using a resource type, check the `rt.d.ts` file in the project root to see all available resource types and their fields. This file is generated by `wmill resource-type generate-namespace`.\n\n## Imports\n\n```typescript\nimport Stripe from \"stripe\";\nimport { someFunction } from \"some-package\";\n```\n\n## Windmill Client\n\nImport the windmill client for platform interactions:\n\n```typescript\nimport * as wmill from \"windmill-client\";\n```\n\nSee the SDK documentation for available methods.\n\n## Preprocessor Scripts\n\nFor preprocessor scripts, the function should be named `preprocessor` and receives an `event` parameter:\n\n```typescript\ntype Event = {\n kind:\n | \"webhook\"\n | \"http\"\n | \"websocket\"\n | \"kafka\"\n | \"email\"\n | \"nats\"\n | \"postgres\"\n | \"sqs\"\n | \"mqtt\"\n | \"gcp\";\n body: any;\n headers: Record;\n query: Record;\n};\n\nexport async function preprocessor(event: Event) {\n return {\n param1: event.body.field1,\n param2: event.query.id,\n };\n}\n```\n\n## S3 Object Operations\n\nWindmill provides built-in support for S3-compatible storage operations. The `wmill.S3Object` type covers both the `s3://storage/key` URI form (`s3:///key` for the workspace default storage) and the `{ s3, storage? }` record form \u2014 always use it instead of redefining your own.\n\n### Receiving an S3Object as a script parameter\n\n```typescript\nimport * as wmill from \"windmill-client\";\n\nexport async function main(file: wmill.S3Object) {\n const content = await wmill.loadS3File(file);\n // ...\n}\n```\n\n### S3 operations\n\n```typescript\nimport * as wmill from \"windmill-client\";\n\n// Load file content from S3\nconst content: Uint8Array = await wmill.loadS3File(s3object);\n\n// Load file as stream\nconst blob: Blob = await wmill.loadS3FileStream(s3object);\n\n// Write file to S3\nconst result: wmill.S3Object = await wmill.writeS3File(\n s3object, // Target path (or undefined to auto-generate)\n fileContent, // string or Blob\n s3ResourcePath // Optional: specific S3 resource to use\n);\n```\n"; From 5566c7b3ff2d5a6b15cb9187aa15ce1c7245b3fb Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 22 May 2026 14:27:49 +0000 Subject: [PATCH 28/71] fix(flows): restore Variables and Resources in flow editor prop picker (#9290) The design system overhaul in 888837431c accidentally dropped the fallback condition that displayed the Variables and Resources sections in the prop picker by default. After that commit, these sections only appeared when the user typed `variable.` or `resource.` in their expression, which meant they effectively disappeared from the flow editor's prop picker for most users. Restore the previous behavior by showing the sections when no input match is active (the equivalent of the old `!filterActive` clause). Fixes WIN-1976 Co-authored-by: Claude Opus 4.7 (1M context) --- frontend/src/lib/components/propertyPicker/PropPicker.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/components/propertyPicker/PropPicker.svelte b/frontend/src/lib/components/propertyPicker/PropPicker.svelte index e48afe0bc9..ae02462942 100644 --- a/frontend/src/lib/components/propertyPicker/PropPicker.svelte +++ b/frontend/src/lib/components/propertyPicker/PropPicker.svelte @@ -375,7 +375,7 @@ {/if} {#if displayContext} - {#if $inputMatches?.some((match) => match.word === 'variable')} + {#if !$inputMatches?.length || $inputMatches?.some((match) => match.word === 'variable')} Variables
{#if displayVariable} @@ -412,7 +412,7 @@ {/if}
{/if} - {#if $inputMatches?.some((match) => match.word === 'resource')} + {#if !$inputMatches?.length || $inputMatches?.some((match) => match.word === 'resource')} Resources
{#if displayResources} From 7003998a575d76c272c6abd0789a1d1f7b722076 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 22 May 2026 14:30:01 +0000 Subject: [PATCH 29/71] fix(auth): tighten token-owner fallback for unscoped tokens (WIN-1978) (#9293) * fix(auth): reject unscoped tokens with cross-workspace forged owners (WIN-1978) An unscoped token (workspace_id IS NULL) whose `owner` field references a user, group, or unprefixed value that is not present in the target workspace must not authenticate. The previous fallback in the `u/` branch granted `(is_admin=false, is_operator=true)` when no `usr` row matched in the target workspace, letting a token holder who could mutate the `token` table cross workspace boundaries with operator privileges. The `g/` branch likewise silently accepted any group name as a "group user", and the no-prefix branch granted operator state from arbitrary owner strings. Both are now rejected unless the owner matches a real user/group membership in the target workspace. Adds an integration regression covering all three forged-owner shapes. Co-Authored-By: Claude Opus 4.7 (1M context) * chore: drop integration regression for auth fallback The test added in the previous commit relies on a sqlx::query! that requires offline-cache regeneration; removing per code-review preference to keep this PR scoped to the auth-layer fix. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- backend/windmill-api-auth/src/auth.rs | 173 +++++++++++++++----------- 1 file changed, 101 insertions(+), 72 deletions(-) diff --git a/backend/windmill-api-auth/src/auth.rs b/backend/windmill-api-auth/src/auth.rs index bda9f54bdd..314ba99450 100644 --- a/backend/windmill-api-auth/src/auth.rs +++ b/backend/windmill-api-auth/src/auth.rs @@ -250,92 +250,121 @@ impl AuthCache { let username_override = username_override_from_label(label); if let Some((prefix, name)) = owner.split_once('/') { if prefix == "u" { - let (is_admin, is_operator) = if super_admin { - (true, false) + let lookup = if super_admin { + Some((true, false)) } else { - let r = sqlx::query!( + sqlx::query!( "SELECT is_admin, operator FROM usr where username = $1 AND \ workspace_id = $2 AND disabled = false", name, &w_id.as_ref().unwrap() ) - .fetch_one(&self.db) + .fetch_optional(&self.db) .await - .ok(); - if let Some(r) = r { - (r.is_admin, r.operator) - } else { - (false, true) - } + .ok() + .flatten() + .map(|r| (r.is_admin, r.operator)) }; - let w_id = &w_id.unwrap(); - let groups = - get_groups_for_user(w_id, &name, &email, &self.db) - .await - .ok() - .unwrap_or_default(); + if let Some((is_admin, is_operator)) = lookup { + let w_id = &w_id.unwrap(); + let groups = + get_groups_for_user(w_id, &name, &email, &self.db) + .await + .ok() + .unwrap_or_default(); - let folders = - get_folders_for_user(w_id, &name, &groups, &self.db) - .await - .ok() - .unwrap_or_default(); + let folders = get_folders_for_user( + w_id, &name, &groups, &self.db, + ) + .await + .ok() + .unwrap_or_default(); - Some(ApiAuthed { - email: email, - username: name.to_string(), - is_admin, - is_operator, - groups, - folders, - scopes: None, - username_override, - token_prefix: Some(safe_token_prefix(token)), - read_only, - }) + Some(ApiAuthed { + email: email, + username: name.to_string(), + is_admin, + is_operator, + groups, + folders, + scopes: None, + username_override, + token_prefix: Some(safe_token_prefix(token)), + read_only, + }) + } else { + tracing::warn!( + "Token owner u/{} is not a member of workspace {}; rejecting auth", + name, + w_id.as_deref().unwrap_or("") + ); + None + } + } else if prefix == "g" { + let group_exists = if super_admin { + true + } else { + sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM group_ WHERE workspace_id = $1 AND name = $2)", + &w_id.as_ref().unwrap(), + name, + ) + .fetch_one(&self.db) + .await + .ok() + .flatten() + .unwrap_or(false) + }; + + if group_exists { + let groups = vec![name.to_string()]; + let folders = get_folders_for_user( + &w_id.unwrap(), + "", + &groups, + &self.db, + ) + .await + .ok() + .unwrap_or_default(); + Some(ApiAuthed { + email: email, + username: format!( + "{}{name}", + windmill_common::users::USERNAME_GROUP_PREFIX + ), + is_admin: false, + groups, + is_operator: false, + folders, + scopes: None, + username_override, + token_prefix: Some(safe_token_prefix(token)), + read_only, + }) + } else { + tracing::warn!( + "Token owner g/{} is not a group in workspace {}; rejecting auth", + name, + w_id.as_deref().unwrap_or("") + ); + None + } } else { - let groups = vec![name.to_string()]; - let folders = get_folders_for_user( - &w_id.unwrap(), - "", - &groups, - &self.db, - ) - .await - .ok() - .unwrap_or_default(); - Some(ApiAuthed { - email: email, - username: format!( - "{}{name}", - windmill_common::users::USERNAME_GROUP_PREFIX - ), - is_admin: false, - groups, - is_operator: false, - folders, - scopes: None, - username_override, - token_prefix: Some(safe_token_prefix(token)), - read_only, - }) + tracing::warn!( + "Token owner '{}' has unrecognised prefix '{}'; rejecting auth", + owner, + prefix + ); + None } } else { - let groups = vec![]; - let folders = vec![]; - Some(ApiAuthed { - email: email, - username: owner, - is_admin: super_admin, - is_operator: true, - groups, - folders, - scopes: None, - username_override, - token_prefix: Some(safe_token_prefix(token)), - read_only, - }) + tracing::warn!( + "Token owner '{}' is missing a prefix (expected u/ or g/); rejecting auth", + owner + ); + None } } (_, Some(email), super_admin, scopes, label, read_only) => { From 1f2d2c11493db20b87615d41c21e5e1c35564739 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 22 May 2026 14:38:51 +0000 Subject: [PATCH 30/71] fix(ResourceEditor): don't reset state when `selected` reverts to undefined (#9295) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bootstrap effect tracked `selected` via its early-return check, so any time `selected` flipped back to `undefined` it would re-run and reinitialize `states[effectiveWorkspace]` to empty — wiping user input. This happens in the React SDK consumer: reactify re-syncs all Svelte props on every React render, and since `selected` isn't passed through, `$props()` reverts it. Move the `selected !== undefined` check inside the existing `untrack` so the effect only tracks `effectiveWorkspace`. Bootstrap still runs once on mount; subsequent `selected` flips no longer retrigger it. Co-authored-by: Claude Opus 4.7 (1M context) --- frontend/src/lib/components/ResourceEditor.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte index 0b573d723c..1aa0fe851f 100644 --- a/frontend/src/lib/components/ResourceEditor.svelte +++ b/frontend/src/lib/components/ResourceEditor.svelte @@ -207,9 +207,9 @@ // Bootstrap: ensure selected is set on mount (edit or new) $effect(() => { - if (selected !== undefined) return if (!effectiveWorkspace) return untrack(() => { + if (selected !== undefined) return selected = effectiveWorkspace if (!initialPath) { // New resource From ace22910c40585a6a2c9abd0c46f7e5e0214e78e Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 22 May 2026 16:17:15 +0000 Subject: [PATCH 31/71] fix(secret-backend): pass DB to Vault migrations + show failure details (#9292) * [ee] fix(secret-backend): pass DB to Vault migrations + surface failure details Companion to windmill-ee-private fix for WIN-1977. The HashiCorp Vault migration always failed under JWT/OIDC auth because the migration constructed VaultBackend without a DB, so every secret hit "Database connection required for JWT authentication". Creating new secrets worked because the runtime path passes the DB. Frontend: when failed_count > 0, the toast and console now show the per-secret failures (path + error, capped at 5 with "...and N more") instead of just aggregate counts. Fixes WIN-1977 Co-Authored-By: Claude Opus 4.7 (1M context) * chore: update ee-repo-ref to 14315067c083d3361512de621b12e41dbe3b017d This commit updates the EE repository reference after PR #587 was merged in windmill-ee-private. Previous ee-repo-ref: 390ed6c851b1915f0b492897c663f8058477680f New ee-repo-ref: 14315067c083d3361512de621b12e41dbe3b017d Automated by sync-ee-ref workflow. * fix(secret-backend): escape failure fields and use
in migration toast Address CI review on PR #9292: - P1 (cubic/codex): backend-supplied workspace_id/path/error are now HTML-escaped before being interpolated into the migration toast, which renders through {@html processMessage(...)} in Toast.svelte. This prevents stored XSS via secret paths or backend errors that contain markup. '/' is intentionally left intact so the toast's path-highlight regex still tags workspace paths. - P2 (pi): swap '\n' for '
' so multi-line failure lists actually break in the toast instead of collapsing to a single run-on line. - Extend the same per-secret failure surfacing (toast + console.error) to the Azure Key Vault and AWS Secrets Manager migration handlers via a shared reportMigrationFailures() helper so all six migration paths report identically. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: windmill-internal-app[bot] --- backend/ee-repo-ref.txt | 2 +- .../SecretBackendConfig.svelte | 102 +++++++++++------- 2 files changed, 67 insertions(+), 37 deletions(-) diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 3e4e2b57c5..1a1930d96e 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -eb18d7b4c0e37fea3f6e1e2cc44e0fddd74ff817 +14315067c083d3361512de621b12e41dbe3b017d diff --git a/frontend/src/lib/components/instanceSettings/SecretBackendConfig.svelte b/frontend/src/lib/components/instanceSettings/SecretBackendConfig.svelte index 7ecec72dfd..00f86920cc 100644 --- a/frontend/src/lib/components/instanceSettings/SecretBackendConfig.svelte +++ b/frontend/src/lib/components/instanceSettings/SecretBackendConfig.svelte @@ -145,17 +145,62 @@ } } + // Toast messages render via {@html} in Toast.svelte, so any backend-supplied + // string interpolated here must be HTML-escaped to prevent stored XSS. + // We deliberately do NOT escape '/' so the toast's path-highlight regex + // (which matches u/.../... and f/.../...) still picks up workspace paths. + function escapeHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + } + + function formatMigrationFailures( + failures: Array<{ workspace_id: string; path: string; error: string }> | undefined + ): string { + if (!failures || failures.length === 0) return '' + const maxShown = 5 + const shown = failures + .slice(0, maxShown) + .map((f) => `• ${escapeHtml(f.workspace_id)}/${escapeHtml(f.path)}: ${escapeHtml(f.error)}`) + .join('
') + const extra = + failures.length > maxShown + ? `
…and ${failures.length - maxShown} more (see backend logs)` + : '' + return `
Failures:
${shown}${extra}` + } + + function reportMigrationFailures( + context: string, + report: { + migrated_count: number + total_secrets: number + failed_count: number + failures?: Array<{ workspace_id: string; path: string; error: string }> + } + ) { + console.error(`${context} failures:`, report.failures) + sendUserToast( + `Migration: ${report.migrated_count}/${report.total_secrets} migrated, ${report.failed_count} failed.${formatMigrationFailures(report.failures)}`, + true, + undefined, + undefined, + 15000 + ) + } + async function migrateSecretsToVault() { if (!$values['secret_backend'] || $values['secret_backend'].type !== 'HashiCorpVault') return migratingToVault = true try { const report = await SettingService.migrateSecretsToVault({ requestBody: getVaultSettings() }) - if (report.failed_count > 0) - sendUserToast( - `Migration: ${report.migrated_count}/${report.total_secrets} migrated, ${report.failed_count} failed`, - true - ) - else + if (report.failed_count > 0) { + reportMigrationFailures('Vault migration', report) + } else sendUserToast(`Migrated ${report.migrated_count}/${report.total_secrets} secrets to Vault`) } catch (error: any) { sendUserToast('Failed: ' + error.message, true) @@ -172,12 +217,9 @@ const report = await SettingService.migrateSecretsToDatabase({ requestBody: getVaultSettings() }) - if (report.failed_count > 0) - sendUserToast( - `Migration: ${report.migrated_count}/${report.total_secrets} migrated, ${report.failed_count} failed`, - true - ) - else + if (report.failed_count > 0) { + reportMigrationFailures('Vault->DB migration', report) + } else sendUserToast( `Migrated ${report.migrated_count}/${report.total_secrets} secrets to database` ) @@ -229,12 +271,9 @@ const report = await SettingService.migrateSecretsToAzureKv({ requestBody: getAzureKvSettings() }) - if (report.failed_count > 0) - sendUserToast( - `Migration: ${report.migrated_count}/${report.total_secrets} migrated, ${report.failed_count} failed`, - true - ) - else + if (report.failed_count > 0) { + reportMigrationFailures('Azure KV migration', report) + } else sendUserToast( `Migrated ${report.migrated_count}/${report.total_secrets} secrets to Azure Key Vault` ) @@ -253,12 +292,9 @@ const report = await SettingService.migrateSecretsFromAzureKv({ requestBody: getAzureKvSettings() }) - if (report.failed_count > 0) - sendUserToast( - `Migration: ${report.migrated_count}/${report.total_secrets} migrated, ${report.failed_count} failed`, - true - ) - else + if (report.failed_count > 0) { + reportMigrationFailures('Azure KV->DB migration', report) + } else sendUserToast( `Migrated ${report.migrated_count}/${report.total_secrets} secrets to database` ) @@ -308,12 +344,9 @@ migratingToAwsSm = true try { const report = await SettingService.migrateSecretsToAwsSm({ requestBody: getAwsSmSettings() }) - if (report.failed_count > 0) - sendUserToast( - `Migration: ${report.migrated_count}/${report.total_secrets} migrated, ${report.failed_count} failed`, - true - ) - else + if (report.failed_count > 0) { + reportMigrationFailures('AWS SM migration', report) + } else sendUserToast( `Migrated ${report.migrated_count}/${report.total_secrets} secrets to AWS Secrets Manager` ) @@ -332,12 +365,9 @@ const report = await SettingService.migrateSecretsFromAwsSm({ requestBody: getAwsSmSettings() }) - if (report.failed_count > 0) - sendUserToast( - `Migration: ${report.migrated_count}/${report.total_secrets} migrated, ${report.failed_count} failed`, - true - ) - else + if (report.failed_count > 0) { + reportMigrationFailures('AWS SM->DB migration', report) + } else sendUserToast( `Migrated ${report.migrated_count}/${report.total_secrets} secrets to database` ) From 05ef8d8e0ba4df4005275b135afb77672a3322f4 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 22 May 2026 17:18:07 +0000 Subject: [PATCH 32/71] nit react-sdk resource editor --- frontend/src/lib/components/ResourceEditor.svelte | 1 + system_prompts/auto-generated/prompts.d.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte index 1aa0fe851f..ac76ec7981 100644 --- a/frontend/src/lib/components/ResourceEditor.svelte +++ b/frontend/src/lib/components/ResourceEditor.svelte @@ -207,6 +207,7 @@ // Bootstrap: ensure selected is set on mount (edit or new) $effect(() => { + selected if (!effectiveWorkspace) return untrack(() => { if (selected !== undefined) return diff --git a/system_prompts/auto-generated/prompts.d.ts b/system_prompts/auto-generated/prompts.d.ts index d187631f5f..32ddcdc4bd 100644 --- a/system_prompts/auto-generated/prompts.d.ts +++ b/system_prompts/auto-generated/prompts.d.ts @@ -11,7 +11,7 @@ export declare const WAC_SDK_PYTHON = "## Python Workflow-as-Code API (wmill)\n\ export declare const DATATABLE_SDK_TYPESCRIPT = "## TypeScript Datatable API (windmill-client)\n\nImport: `import * as wmill from 'windmill-client'`\n\nSQL statement object with query content, arguments, and execution methods\n```typescript\ntype SqlStatement = {\n /** Raw SQL content with formatted arguments */\n content: string;\n\n /** Argument values keyed by parameter name */\n args: Record;\n\n /**\n * Execute the SQL query and return results\n * @param params - Optional parameters including result collection mode\n * @returns Query results based on the result collection mode\n */\n fetch(\n params?: FetchParams // The union is for auto-completion\n ): Promise>;\n\n /**\n * Execute the SQL query and return only the first row\n * @param params - Optional parameters\n * @returns First row of the query result\n */\n fetchOne(\n params?: Omit, \"resultCollection\">\n ): Promise>;\n\n /**\n * Execute the SQL query and return only the first row as a scalar value\n * @param params - Optional parameters\n * @returns First row of the query result\n */\n fetchOneScalar(\n params?: Omit<\n FetchParams<\"last_statement_first_row_scalar\">,\n \"resultCollection\"\n >\n ): Promise>;\n\n /**\n * Execute the SQL query without fetching rows\n * @param params - Optional parameters\n */\n execute(\n params?: Omit, \"resultCollection\">\n ): Promise;\n};\n```\n\n```typescript\n// Template tag function: sql`SELECT * FROM table WHERE id = ${id}`.fetch()\ninterface DatatableSqlTemplateFunction {\n // Tagged template usage:\n (strings: TemplateStringsArray, ...values: any[]): SqlStatement;\n query(sql: string, ...params: any[]): SqlStatement;\n};\n```\n\nCreate a SQL template function for PostgreSQL/datatable queries\n@param name - Database/datatable name (default: \"main\")\n@returns SQL template function for building parameterized queries\n@example\nlet sql = wmill.datatable()\nlet name = 'Robin'\nlet age = 21\nawait sql`\n SELECT * FROM friends\n WHERE name = ${name} AND age = ${age}::int\n`.fetch()\n```typescript\nfunction datatable(name: string = \"main\"): DatatableSqlTemplateFunction\n```\n"; export declare const DATATABLE_SDK_PYTHON = "## Python Datatable API (wmill)\n\nImport: `import wmill`\n\n# Get a DataTable client for SQL queries.\n# \n# Args:\n# name: Database name (default: \"main\")\n# \n# Returns:\n# DataTableClient instance\ndef datatable(name: str = 'main') -> DataTableClient\n\n# Client for executing SQL queries against Windmill DataTables.\nclass DataTableClient:\n # Initialize DataTableClient.\n # \n # Args:\n # client: Windmill client instance\n # name: DataTable name\n def __init__(client: Windmill, name: str)\n\n # Execute a SQL query against the DataTable.\n # \n # Args:\n # sql: SQL query string with $1, $2, etc. placeholders\n # *args: Positional arguments to bind to query placeholders\n # \n # Returns:\n # SqlQuery instance for fetching results\n def query(sql: str, *args) -> SqlQuery\n\n\n# Query result handler for DataTable and DuckLake queries.\nclass SqlQuery:\n # Initialize SqlQuery.\n # \n # Args:\n # sql: SQL query string\n # fetch_fn: Function to execute the query\n def __init__(sql: str, fetch_fn)\n\n # Execute query and fetch results.\n # \n # Args:\n # result_collection: Optional result collection mode\n # \n # Returns:\n # Query results\n def fetch(result_collection: str | None = None)\n\n # Execute query and fetch first row of results.\n # \n # Returns:\n # First row of query results\n def fetch_one()\n\n # Execute query and fetch first row of results. Return result as a scalar value.\n # \n # Returns:\n # First row of query result as a scalar value\n def fetch_one_scalar()\n\n # Execute query and don't return any results.\n # \n def execute()\n\n\n"; export declare const OPENFLOW_SCHEMA = "## OpenFlow Schema\n\n{\"OpenFlow\":{\"type\":\"object\",\"description\":\"Top-level flow definition containing metadata, configuration, and the flow structure\",\"properties\":{\"summary\":{\"type\":\"string\",\"description\":\"Short description of what this flow does\"},\"description\":{\"type\":\"string\",\"description\":\"Detailed documentation for this flow\"},\"value\":{\"$ref\":\"#/components/schemas/FlowValue\"},\"schema\":{\"type\":\"object\",\"description\":\"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')\"},\"on_behalf_of_email\":{\"type\":\"string\",\"description\":\"The flow will be run with the permissions of the user with this email.\"}},\"required\":[\"summary\",\"value\"]},\"FlowValue\":{\"type\":\"object\",\"description\":\"The flow structure containing modules and optional preprocessor/failure handlers\",\"properties\":{\"modules\":{\"type\":\"array\",\"description\":\"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}},\"failure_module\":{\"description\":\"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types\",\"$ref\":\"#/components/schemas/FlowModule\"},\"preprocessor_module\":{\"description\":\"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results\",\"$ref\":\"#/components/schemas/FlowModule\"},\"same_worker\":{\"type\":\"boolean\",\"description\":\"If true, all steps run on the same worker for better performance\"},\"concurrent_limit\":{\"type\":\"number\",\"description\":\"Maximum number of concurrent executions of this flow\"},\"concurrency_key\":{\"type\":\"string\",\"description\":\"Expression to group concurrent executions (e.g., by user ID)\"},\"concurrency_time_window_s\":{\"type\":\"number\",\"description\":\"Time window in seconds for concurrent_limit\"},\"debounce_delay_s\":{\"type\":\"integer\",\"description\":\"Delay in seconds to debounce flow executions\"},\"debounce_key\":{\"type\":\"string\",\"description\":\"Expression to group debounced executions\"},\"debounce_args_to_accumulate\":{\"type\":\"array\",\"description\":\"Arguments to accumulate across debounced executions\",\"items\":{\"type\":\"string\"}},\"max_total_debouncing_time\":{\"type\":\"integer\",\"description\":\"Maximum total time in seconds that a job can be debounced\"},\"max_total_debounces_amount\":{\"type\":\"integer\",\"description\":\"Maximum number of times a job can be debounced\"},\"skip_expr\":{\"type\":\"string\",\"description\":\"JavaScript expression to conditionally skip the entire flow\"},\"cache_ttl\":{\"type\":\"number\",\"description\":\"Cache duration in seconds for flow results\"},\"cache_ignore_s3_path\":{\"type\":\"boolean\"},\"delete_after_secs\":{\"type\":\"integer\",\"description\":\"If set, delete the flow job's args, result and logs after this many seconds following job completion\"},\"flow_env\":{\"type\":\"object\",\"description\":\"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).\",\"additionalProperties\":{}},\"priority\":{\"type\":\"number\",\"description\":\"Execution priority (higher numbers run first)\"},\"early_return\":{\"type\":\"string\",\"description\":\"JavaScript expression to return early from the flow\"},\"chat_input_enabled\":{\"type\":\"boolean\",\"description\":\"Whether this flow accepts chat-style input\"},\"notes\":{\"type\":\"array\",\"description\":\"Sticky notes attached to the flow\",\"items\":{\"$ref\":\"#/components/schemas/FlowNote\"}},\"groups\":{\"type\":\"array\",\"description\":\"Semantic groups of modules for organizational purposes\",\"items\":{\"$ref\":\"#/components/schemas/FlowGroup\"}}},\"required\":[\"modules\"]},\"Retry\":{\"type\":\"object\",\"description\":\"Retry configuration for failed module executions\",\"properties\":{\"constant\":{\"type\":\"object\",\"description\":\"Retry with constant delay between attempts\",\"properties\":{\"attempts\":{\"type\":\"integer\",\"description\":\"Number of retry attempts\"},\"seconds\":{\"type\":\"integer\",\"description\":\"Seconds to wait between retries\"}}},\"exponential\":{\"type\":\"object\",\"description\":\"Retry with exponential backoff (delay doubles each time)\",\"properties\":{\"attempts\":{\"type\":\"integer\",\"description\":\"Number of retry attempts\"},\"multiplier\":{\"type\":\"integer\",\"description\":\"Multiplier for exponential backoff\"},\"seconds\":{\"type\":\"integer\",\"minimum\":1,\"description\":\"Initial delay in seconds\"},\"random_factor\":{\"type\":\"integer\",\"minimum\":0,\"maximum\":100,\"description\":\"Random jitter percentage (0-100) to avoid thundering herd\"}}},\"retry_if\":{\"$ref\":\"#/components/schemas/RetryIf\"}}},\"FlowNote\":{\"type\":\"object\",\"description\":\"A sticky note attached to a flow for documentation and annotation\",\"properties\":{\"id\":{\"type\":\"string\",\"description\":\"Unique identifier for the note\"},\"text\":{\"type\":\"string\",\"description\":\"Content of the note\"},\"position\":{\"type\":\"object\",\"description\":\"Position of the note in the flow editor\",\"properties\":{\"x\":{\"type\":\"number\",\"description\":\"X coordinate\"},\"y\":{\"type\":\"number\",\"description\":\"Y coordinate\"}},\"required\":[\"x\",\"y\"]},\"size\":{\"type\":\"object\",\"description\":\"Size of the note in the flow editor\",\"properties\":{\"width\":{\"type\":\"number\",\"description\":\"Width in pixels\"},\"height\":{\"type\":\"number\",\"description\":\"Height in pixels\"}},\"required\":[\"width\",\"height\"]},\"color\":{\"type\":\"string\",\"description\":\"Color of the note (e.g., \\\"yellow\\\", \\\"#ffff00\\\")\"},\"type\":{\"type\":\"string\",\"enum\":[\"free\",\"group\"],\"description\":\"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes\"},\"locked\":{\"type\":\"boolean\",\"default\":false,\"description\":\"Whether the note is locked and cannot be edited or moved\"},\"contained_node_ids\":{\"type\":\"array\",\"items\":{\"type\":\"string\"},\"description\":\"For group notes, the IDs of nodes contained within this group\"}},\"required\":[\"id\",\"text\",\"color\",\"type\"]},\"FlowGroup\":{\"type\":\"object\",\"description\":\"A semantic group of flow modules for organizational purposes. Does not affect execution \\u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.\",\"properties\":{\"summary\":{\"type\":\"string\",\"description\":\"Display name for this group\"},\"note\":{\"type\":\"string\",\"description\":\"Markdown note shown below the group header\"},\"autocollapse\":{\"type\":\"boolean\",\"default\":false,\"description\":\"If true, this group is collapsed by default in the flow editor. UI hint only.\"},\"start_id\":{\"type\":\"string\",\"description\":\"ID of the first flow module in this group (topological entry point)\"},\"end_id\":{\"type\":\"string\",\"description\":\"ID of the last flow module in this group (topological exit point)\"},\"color\":{\"type\":\"string\",\"description\":\"Color for the group in the flow editor\"}},\"required\":[\"start_id\",\"end_id\"]},\"RetryIf\":{\"type\":\"object\",\"description\":\"Conditional retry based on error or result\",\"properties\":{\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables\"}},\"required\":[\"expr\"]},\"StopAfterIf\":{\"type\":\"object\",\"description\":\"Early termination condition for a module\",\"properties\":{\"skip_if_stopped\":{\"type\":\"boolean\",\"description\":\"If true, following steps are skipped when this condition triggers\"},\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop\"},\"error_message\":{\"type\":\"string\",\"nullable\":true,\"description\":\"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.\"}},\"required\":[\"expr\"]},\"FlowModule\":{\"type\":\"object\",\"description\":\"A single step in a flow. Can be a script, subflow, loop, or branch\",\"properties\":{\"id\":{\"type\":\"string\",\"description\":\"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)\"},\"value\":{\"$ref\":\"#/components/schemas/FlowModuleValue\"},\"stop_after_if\":{\"description\":\"Early termination condition evaluated after this step completes\",\"$ref\":\"#/components/schemas/StopAfterIf\"},\"stop_after_all_iters_if\":{\"description\":\"For loops only - early termination condition evaluated after all iterations complete\",\"$ref\":\"#/components/schemas/StopAfterIf\"},\"skip_if\":{\"type\":\"object\",\"description\":\"Conditionally skip this step based on previous results or flow inputs\",\"properties\":{\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'\"}},\"required\":[\"expr\"]},\"sleep\":{\"description\":\"Delay before executing this step (in seconds or as expression)\",\"$ref\":\"#/components/schemas/InputTransform\"},\"cache_ttl\":{\"type\":\"number\",\"description\":\"Cache duration in seconds for this step's results\"},\"cache_ignore_s3_path\":{\"type\":\"boolean\"},\"timeout\":{\"description\":\"Maximum execution time in seconds (static value or expression)\",\"$ref\":\"#/components/schemas/InputTransform\"},\"delete_after_secs\":{\"type\":\"integer\",\"description\":\"If set, delete the step's args, result and logs after this many seconds following job completion\"},\"summary\":{\"type\":\"string\",\"description\":\"Short description of what this step does\"},\"mock\":{\"type\":\"object\",\"description\":\"Mock configuration for testing without executing the actual step\",\"properties\":{\"enabled\":{\"type\":\"boolean\",\"description\":\"If true, return mock value instead of executing\"},\"return_value\":{\"description\":\"Value to return when mocked\"}}},\"suspend\":{\"type\":\"object\",\"description\":\"Configuration for approval/resume steps that wait for user input\",\"properties\":{\"required_events\":{\"type\":\"integer\",\"description\":\"Number of approvals required before continuing\"},\"timeout\":{\"type\":\"integer\",\"description\":\"Timeout in seconds before auto-continuing or canceling\"},\"resume_form\":{\"type\":\"object\",\"description\":\"Form schema for collecting input when resuming\",\"properties\":{\"schema\":{\"type\":\"object\",\"description\":\"JSON Schema for the resume form\"}}},\"user_auth_required\":{\"type\":\"boolean\",\"description\":\"If true, only authenticated users can approve\"},\"user_groups_required\":{\"description\":\"Expression or list of groups that can approve\",\"$ref\":\"#/components/schemas/InputTransform\"},\"self_approval_disabled\":{\"type\":\"boolean\",\"description\":\"If true, the user who started the flow cannot approve\"},\"hide_cancel\":{\"type\":\"boolean\",\"description\":\"If true, hide the cancel button on the approval form\"},\"continue_on_disapprove_timeout\":{\"type\":\"boolean\",\"description\":\"If true, continue flow on timeout instead of canceling\"}}},\"priority\":{\"type\":\"number\",\"description\":\"Execution priority for this step (higher numbers run first)\"},\"continue_on_error\":{\"type\":\"boolean\",\"description\":\"If true, flow continues even if this step fails\"},\"retry\":{\"description\":\"Retry configuration if this step fails\",\"$ref\":\"#/components/schemas/Retry\"},\"debouncing\":{\"description\":\"Debounce configuration for this step (EE only)\",\"type\":\"object\",\"properties\":{\"debounce_delay_s\":{\"type\":\"integer\",\"description\":\"Delay in seconds to debounce this step's executions across flow runs\"},\"debounce_key\":{\"type\":\"string\",\"description\":\"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-\"},\"debounce_args_to_accumulate\":{\"type\":\"array\",\"description\":\"Array-type arguments to accumulate across debounced executions\",\"items\":{\"type\":\"string\"}},\"max_total_debouncing_time\":{\"type\":\"integer\",\"description\":\"Maximum total time in seconds before forced execution\"},\"max_total_debounces_amount\":{\"type\":\"integer\",\"description\":\"Maximum number of debounces before forced execution\"}}}},\"required\":[\"value\",\"id\"]},\"InputTransform\":{\"description\":\"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs\",\"oneOf\":[{\"$ref\":\"#/components/schemas/StaticTransform\"},{\"$ref\":\"#/components/schemas/JavascriptTransform\"},{\"$ref\":\"#/components/schemas/AiTransform\"}],\"discriminator\":{\"propertyName\":\"type\",\"mapping\":{\"static\":\"#/components/schemas/StaticTransform\",\"javascript\":\"#/components/schemas/JavascriptTransform\",\"ai\":\"#/components/schemas/AiTransform\"}}},\"StaticTransform\":{\"type\":\"object\",\"description\":\"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'\",\"properties\":{\"value\":{\"description\":\"The static value. For resources, use format '$res:path/to/resource'\"},\"type\":{\"type\":\"string\",\"enum\":[\"static\"]}},\"required\":[\"type\"]},\"JavascriptTransform\":{\"type\":\"object\",\"description\":\"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value\",\"properties\":{\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)\"},\"type\":{\"type\":\"string\",\"enum\":[\"javascript\"]}},\"required\":[\"expr\",\"type\"]},\"AiTransform\":{\"type\":\"object\",\"description\":\"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"ai\"]}},\"required\":[\"type\"]},\"AIProviderKind\":{\"type\":\"string\",\"description\":\"Supported AI provider types\",\"enum\":[\"openai\",\"azure_openai\",\"anthropic\",\"mistral\",\"deepseek\",\"googleai\",\"groq\",\"openrouter\",\"togetherai\",\"customai\",\"aws_bedrock\"]},\"ProviderConfig\":{\"type\":\"object\",\"description\":\"Complete AI provider configuration with resource reference and model selection\",\"properties\":{\"kind\":{\"$ref\":\"#/components/schemas/AIProviderKind\"},\"resource\":{\"type\":\"string\",\"description\":\"Resource reference in format '$res:{resource_path}' pointing to provider credentials\"},\"model\":{\"type\":\"string\",\"description\":\"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')\"}},\"required\":[\"kind\",\"resource\",\"model\"]},\"StaticProviderTransform\":{\"type\":\"object\",\"description\":\"Static provider configuration passed directly to the AI agent\",\"properties\":{\"value\":{\"$ref\":\"#/components/schemas/ProviderConfig\"},\"type\":{\"type\":\"string\",\"enum\":[\"static\"]}},\"required\":[\"type\",\"value\"]},\"ProviderTransform\":{\"description\":\"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined\",\"oneOf\":[{\"$ref\":\"#/components/schemas/StaticProviderTransform\"},{\"$ref\":\"#/components/schemas/JavascriptTransform\"},{\"$ref\":\"#/components/schemas/AiTransform\"}],\"discriminator\":{\"propertyName\":\"type\",\"mapping\":{\"static\":\"#/components/schemas/StaticProviderTransform\",\"javascript\":\"#/components/schemas/JavascriptTransform\",\"ai\":\"#/components/schemas/AiTransform\"}}},\"MemoryOff\":{\"type\":\"object\",\"description\":\"No conversation memory/context\",\"properties\":{\"kind\":{\"type\":\"string\",\"enum\":[\"off\"]}},\"required\":[\"kind\"]},\"MemoryAuto\":{\"type\":\"object\",\"description\":\"Automatic context management\",\"properties\":{\"kind\":{\"type\":\"string\",\"enum\":[\"auto\"]},\"context_length\":{\"type\":\"integer\",\"description\":\"Maximum number of messages to retain in context\"},\"memory_id\":{\"type\":\"string\",\"description\":\"Identifier for persistent memory across agent invocations\"}},\"required\":[\"kind\"]},\"MemoryMessage\":{\"type\":\"object\",\"description\":\"A single message in conversation history\",\"properties\":{\"role\":{\"type\":\"string\",\"enum\":[\"user\",\"assistant\",\"system\"]},\"content\":{\"type\":\"string\"}},\"required\":[\"role\",\"content\"]},\"MemoryManual\":{\"type\":\"object\",\"description\":\"Explicit message history\",\"properties\":{\"kind\":{\"type\":\"string\",\"enum\":[\"manual\"]},\"messages\":{\"type\":\"array\",\"items\":{\"$ref\":\"#/components/schemas/MemoryMessage\"}}},\"required\":[\"kind\",\"messages\"]},\"MemoryConfig\":{\"description\":\"Conversation memory configuration\",\"oneOf\":[{\"$ref\":\"#/components/schemas/MemoryOff\"},{\"$ref\":\"#/components/schemas/MemoryAuto\"},{\"$ref\":\"#/components/schemas/MemoryManual\"}],\"discriminator\":{\"propertyName\":\"kind\",\"mapping\":{\"off\":\"#/components/schemas/MemoryOff\",\"auto\":\"#/components/schemas/MemoryAuto\",\"manual\":\"#/components/schemas/MemoryManual\"}}},\"StaticMemoryTransform\":{\"type\":\"object\",\"description\":\"Static memory configuration passed directly to the AI agent\",\"properties\":{\"value\":{\"$ref\":\"#/components/schemas/MemoryConfig\"},\"type\":{\"type\":\"string\",\"enum\":[\"static\"]}},\"required\":[\"type\",\"value\"]},\"MemoryTransform\":{\"description\":\"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined\",\"oneOf\":[{\"$ref\":\"#/components/schemas/StaticMemoryTransform\"},{\"$ref\":\"#/components/schemas/JavascriptTransform\"},{\"$ref\":\"#/components/schemas/AiTransform\"}],\"discriminator\":{\"propertyName\":\"type\",\"mapping\":{\"static\":\"#/components/schemas/StaticMemoryTransform\",\"javascript\":\"#/components/schemas/JavascriptTransform\",\"ai\":\"#/components/schemas/AiTransform\"}}},\"FlowModuleValue\":{\"description\":\"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type\",\"oneOf\":[{\"$ref\":\"#/components/schemas/RawScript\"},{\"$ref\":\"#/components/schemas/PathScript\"},{\"$ref\":\"#/components/schemas/PathFlow\"},{\"$ref\":\"#/components/schemas/ForloopFlow\"},{\"$ref\":\"#/components/schemas/WhileloopFlow\"},{\"$ref\":\"#/components/schemas/BranchOne\"},{\"$ref\":\"#/components/schemas/BranchAll\"},{\"$ref\":\"#/components/schemas/Identity\"},{\"$ref\":\"#/components/schemas/AiAgent\"}],\"discriminator\":{\"propertyName\":\"type\",\"mapping\":{\"rawscript\":\"#/components/schemas/RawScript\",\"script\":\"#/components/schemas/PathScript\",\"flow\":\"#/components/schemas/PathFlow\",\"forloopflow\":\"#/components/schemas/ForloopFlow\",\"whileloopflow\":\"#/components/schemas/WhileloopFlow\",\"branchone\":\"#/components/schemas/BranchOne\",\"branchall\":\"#/components/schemas/BranchAll\",\"identity\":\"#/components/schemas/Identity\",\"aiagent\":\"#/components/schemas/AiAgent\"}}},\"RawScript\":{\"type\":\"object\",\"description\":\"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms\",\"properties\":{\"input_transforms\":{\"type\":\"object\",\"description\":\"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments\",\"additionalProperties\":{\"$ref\":\"#/components/schemas/InputTransform\"}},\"content\":{\"type\":\"string\",\"description\":\"The script source code. Should export a 'main' function\"},\"language\":{\"type\":\"string\",\"description\":\"Programming language for this script\",\"enum\":[\"deno\",\"bun\",\"python3\",\"go\",\"bash\",\"powershell\",\"postgresql\",\"mysql\",\"bigquery\",\"snowflake\",\"mssql\",\"oracledb\",\"graphql\",\"nativets\",\"php\",\"rust\",\"ansible\",\"csharp\",\"nu\",\"java\",\"ruby\",\"rlang\",\"duckdb\"]},\"path\":{\"type\":\"string\",\"description\":\"Optional path for saving this script\"},\"lock\":{\"type\":\"string\",\"description\":\"Lock file content for dependencies\"},\"type\":{\"type\":\"string\",\"enum\":[\"rawscript\"]},\"tag\":{\"type\":\"string\",\"description\":\"Worker group tag for execution routing\"},\"concurrent_limit\":{\"type\":\"number\",\"description\":\"Maximum concurrent executions of this script\"},\"concurrency_time_window_s\":{\"type\":\"number\",\"description\":\"Time window for concurrent_limit\"},\"custom_concurrency_key\":{\"type\":\"string\",\"description\":\"Custom key for grouping concurrent executions\"},\"is_trigger\":{\"type\":\"boolean\",\"description\":\"If true, this script is a trigger that can start the flow\"},\"assets\":{\"type\":\"array\",\"description\":\"External resources this script accesses (S3 objects, resources, etc.)\",\"items\":{\"type\":\"object\",\"required\":[\"path\",\"kind\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the asset\"},\"kind\":{\"type\":\"string\",\"description\":\"Type of asset\",\"enum\":[\"s3object\",\"resource\",\"ducklake\",\"datatable\",\"volume\"]},\"access_type\":{\"type\":\"string\",\"nullable\":true,\"description\":\"Access level for this asset\",\"enum\":[\"r\",\"w\",\"rw\"]},\"alt_access_type\":{\"type\":\"string\",\"nullable\":true,\"description\":\"Alternative access level\",\"enum\":[\"r\",\"w\",\"rw\"]}}}}},\"required\":[\"type\",\"content\",\"language\",\"input_transforms\"]},\"PathScript\":{\"type\":\"object\",\"description\":\"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code\",\"properties\":{\"input_transforms\":{\"type\":\"object\",\"description\":\"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments\",\"additionalProperties\":{\"$ref\":\"#/components/schemas/InputTransform\"}},\"path\":{\"type\":\"string\",\"description\":\"Path to the script in the workspace (e.g., 'f/scripts/send_email')\"},\"hash\":{\"type\":\"string\",\"description\":\"Optional specific version hash of the script to use\"},\"type\":{\"type\":\"string\",\"enum\":[\"script\"]},\"tag_override\":{\"type\":\"string\",\"description\":\"Override the script's default worker group tag\"},\"is_trigger\":{\"type\":\"boolean\",\"description\":\"If true, this script is a trigger that can start the flow\"}},\"required\":[\"type\",\"path\",\"input_transforms\"]},\"PathFlow\":{\"type\":\"object\",\"description\":\"Reference to an existing flow by path. Use this to call another flow as a subflow\",\"properties\":{\"input_transforms\":{\"type\":\"object\",\"description\":\"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments\",\"additionalProperties\":{\"$ref\":\"#/components/schemas/InputTransform\"}},\"path\":{\"type\":\"string\",\"description\":\"Path to the flow in the workspace (e.g., 'f/flows/process_user')\"},\"type\":{\"type\":\"string\",\"enum\":[\"flow\"]}},\"required\":[\"type\",\"path\",\"input_transforms\"]},\"ForloopFlow\":{\"type\":\"object\",\"description\":\"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations\",\"properties\":{\"modules\":{\"type\":\"array\",\"description\":\"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}},\"iterator\":{\"description\":\"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'\",\"$ref\":\"#/components/schemas/InputTransform\"},\"skip_failures\":{\"type\":\"boolean\",\"description\":\"If true, iteration failures don't stop the loop. Failed iterations return null\"},\"type\":{\"type\":\"string\",\"enum\":[\"forloopflow\"]},\"parallel\":{\"type\":\"boolean\",\"description\":\"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency\"},\"parallelism\":{\"description\":\"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression\",\"$ref\":\"#/components/schemas/InputTransform\"},\"squash\":{\"type\":\"boolean\"}},\"required\":[\"modules\",\"iterator\",\"skip_failures\",\"type\"]},\"WhileloopFlow\":{\"type\":\"object\",\"description\":\"Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination\",\"properties\":{\"modules\":{\"type\":\"array\",\"description\":\"Steps to execute in each iteration. Use stop_after_if to control when the loop ends\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}},\"skip_failures\":{\"type\":\"boolean\",\"description\":\"If true, iteration failures don't stop the loop. Failed iterations return null\"},\"type\":{\"type\":\"string\",\"enum\":[\"whileloopflow\"]},\"parallel\":{\"type\":\"boolean\",\"description\":\"If true, iterations run concurrently (use with caution in while loops)\"},\"parallelism\":{\"description\":\"Maximum number of concurrent iterations when parallel=true\",\"$ref\":\"#/components/schemas/InputTransform\"},\"squash\":{\"type\":\"boolean\"}},\"required\":[\"modules\",\"skip_failures\",\"type\"]},\"BranchOne\":{\"type\":\"object\",\"description\":\"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes\",\"properties\":{\"branches\":{\"type\":\"array\",\"description\":\"Array of branches to evaluate in order. The first branch with expr evaluating to true executes\",\"items\":{\"type\":\"object\",\"properties\":{\"summary\":{\"type\":\"string\",\"description\":\"Short description of this branch condition\"},\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins\"},\"modules\":{\"type\":\"array\",\"description\":\"Steps to execute if this branch's expr is true\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}}},\"required\":[\"modules\",\"expr\"]}},\"default\":{\"type\":\"array\",\"description\":\"Steps to execute if no branch expressions match\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}},\"type\":{\"type\":\"string\",\"enum\":[\"branchone\"]}},\"required\":[\"branches\",\"default\",\"type\"]},\"BranchAll\":{\"type\":\"object\",\"description\":\"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently\",\"properties\":{\"branches\":{\"type\":\"array\",\"description\":\"Array of branches that all execute (either in parallel or sequentially)\",\"items\":{\"type\":\"object\",\"properties\":{\"summary\":{\"type\":\"string\",\"description\":\"Short description of this branch's purpose\"},\"skip_failure\":{\"type\":\"boolean\",\"description\":\"If true, failure in this branch doesn't fail the entire flow\"},\"modules\":{\"type\":\"array\",\"description\":\"Steps to execute in this branch\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}}},\"required\":[\"modules\"]}},\"type\":{\"type\":\"string\",\"enum\":[\"branchall\"]},\"parallel\":{\"type\":\"boolean\",\"description\":\"If true, all branches execute concurrently. If false, they execute sequentially\"}},\"required\":[\"branches\",\"type\"]},\"AgentTool\":{\"type\":\"object\",\"description\":\"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool\",\"properties\":{\"id\":{\"type\":\"string\",\"description\":\"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')\"},\"summary\":{\"type\":\"string\",\"description\":\"Short description of what this tool does (shown to the AI)\"},\"value\":{\"$ref\":\"#/components/schemas/ToolValue\"}},\"required\":[\"id\",\"value\"]},\"ToolValue\":{\"description\":\"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference\",\"oneOf\":[{\"$ref\":\"#/components/schemas/FlowModuleTool\"},{\"$ref\":\"#/components/schemas/McpToolValue\"},{\"$ref\":\"#/components/schemas/WebsearchToolValue\"}],\"discriminator\":{\"propertyName\":\"tool_type\",\"mapping\":{\"flowmodule\":\"#/components/schemas/FlowModuleTool\",\"mcp\":\"#/components/schemas/McpToolValue\",\"websearch\":\"#/components/schemas/WebsearchToolValue\"}}},\"FlowModuleTool\":{\"description\":\"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module\",\"allOf\":[{\"type\":\"object\",\"properties\":{\"tool_type\":{\"type\":\"string\",\"enum\":[\"flowmodule\"]}},\"required\":[\"tool_type\"]},{\"$ref\":\"#/components/schemas/FlowModuleValue\"}]},\"WebsearchToolValue\":{\"type\":\"object\",\"description\":\"A tool implemented as a websearch tool. The AI can call this like any other websearch tool\",\"properties\":{\"tool_type\":{\"type\":\"string\",\"enum\":[\"websearch\"]}},\"required\":[\"tool_type\"]},\"McpToolValue\":{\"type\":\"object\",\"description\":\"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers\",\"properties\":{\"tool_type\":{\"type\":\"string\",\"enum\":[\"mcp\"]},\"resource_path\":{\"type\":\"string\",\"description\":\"Path to the MCP resource/server configuration\"},\"include_tools\":{\"type\":\"array\",\"description\":\"Whitelist of specific tools to include from this MCP server\",\"items\":{\"type\":\"string\"}},\"exclude_tools\":{\"type\":\"array\",\"description\":\"Blacklist of tools to exclude from this MCP server\",\"items\":{\"type\":\"string\"}}},\"required\":[\"tool_type\",\"resource_path\"]},\"AiAgent\":{\"type\":\"object\",\"description\":\"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task\",\"properties\":{\"input_transforms\":{\"type\":\"object\",\"description\":\"Input parameters for the AI agent mapped to their values\",\"properties\":{\"provider\":{\"$ref\":\"#/components/schemas/ProviderTransform\"},\"output_type\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Output format type.\\nValid values: 'text' (default) - plain text response, 'image' - image generation\\n\"},\"user_message\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax.\"},\"system_prompt\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"System instructions that guide the AI's behavior, persona, and response style. Optional.\"},\"streaming\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Boolean. If true, stream the AI response incrementally.\\nStreaming events include: token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\\n\"},\"memory\":{\"$ref\":\"#/components/schemas/MemoryTransform\"},\"output_schema\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\\n\"},\"user_attachments\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Array of file references (images or PDFs) for the AI agent.\\nFormat: Array<{ bucket: string, key: string }> - S3 object references\\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\\n\"},\"max_completion_tokens\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Integer. Maximum number of tokens the AI will generate in its response.\\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\\n\"},\"temperature\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Float. Controls randomness/creativity of responses.\\nRange: 0.0 to 2.0 (provider-dependent)\\n- 0.0 = deterministic, focused responses\\n- 0.7 = balanced (common default)\\n- 1.0+ = more creative/random\\n\"},\"max_iterations\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Number. Limits how many times the agent can loop through reasoning and tool use.\\nRange: 1-1000.\\n\"}},\"required\":[\"provider\",\"user_message\",\"output_type\"]},\"tools\":{\"type\":\"array\",\"description\":\"Array of tools the agent can use. The agent decides which tools to call based on the task\",\"items\":{\"$ref\":\"#/components/schemas/AgentTool\"}},\"type\":{\"type\":\"string\",\"enum\":[\"aiagent\"]},\"omit_output_from_conversation\":{\"type\":\"boolean\",\"default\":false,\"description\":\"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled.\"},\"parallel\":{\"type\":\"boolean\",\"description\":\"If true, the agent can execute multiple tool calls in parallel\"}},\"required\":[\"tools\",\"type\",\"input_transforms\"]},\"Identity\":{\"type\":\"object\",\"description\":\"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"identity\"]},\"flow\":{\"type\":\"boolean\",\"description\":\"If true, marks this as a flow identity (special handling)\"}},\"required\":[\"type\"]},\"FlowStatus\":{\"type\":\"object\",\"properties\":{\"step\":{\"type\":\"integer\"},\"modules\":{\"type\":\"array\",\"items\":{\"$ref\":\"#/components/schemas/FlowStatusModule\"}},\"user_states\":{\"additionalProperties\":true},\"preprocessor_module\":{\"allOf\":[{\"$ref\":\"#/components/schemas/FlowStatusModule\"}]},\"failure_module\":{\"allOf\":[{\"$ref\":\"#/components/schemas/FlowStatusModule\"},{\"type\":\"object\",\"properties\":{\"parent_module\":{\"type\":\"string\"}}}]},\"retry\":{\"type\":\"object\",\"properties\":{\"fail_count\":{\"type\":\"integer\"},\"failed_jobs\":{\"type\":\"array\",\"items\":{\"type\":\"string\",\"format\":\"uuid\"}}}}},\"required\":[\"step\",\"modules\",\"failure_module\"]},\"FlowStatusModule\":{\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"WaitingForPriorSteps\",\"WaitingForEvents\",\"WaitingForExecutor\",\"InProgress\",\"Success\",\"Failure\"]},\"id\":{\"type\":\"string\"},\"job\":{\"type\":\"string\",\"format\":\"uuid\"},\"count\":{\"type\":\"integer\"},\"progress\":{\"type\":\"integer\"},\"iterator\":{\"type\":\"object\",\"properties\":{\"index\":{\"type\":\"integer\"},\"itered\":{\"type\":\"array\",\"items\":{}},\"itered_len\":{\"type\":\"integer\"},\"args\":{}}},\"flow_jobs\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}},\"flow_jobs_success\":{\"type\":\"array\",\"items\":{\"type\":\"boolean\"}},\"flow_jobs_duration\":{\"type\":\"object\",\"properties\":{\"started_at\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}},\"duration_ms\":{\"type\":\"array\",\"items\":{\"type\":\"integer\"}}}},\"branch_chosen\":{\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"branch\",\"default\"]},\"branch\":{\"type\":\"integer\"}},\"required\":[\"type\"]},\"branchall\":{\"type\":\"object\",\"properties\":{\"branch\":{\"type\":\"integer\"},\"len\":{\"type\":\"integer\"}},\"required\":[\"branch\",\"len\"]},\"approvers\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"resume_id\":{\"type\":\"integer\"},\"approver\":{\"type\":\"string\"}},\"required\":[\"resume_id\",\"approver\"]}},\"failed_retries\":{\"type\":\"array\",\"items\":{\"type\":\"string\",\"format\":\"uuid\"}},\"skipped\":{\"type\":\"boolean\"},\"agent_actions\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"oneOf\":[{\"type\":\"object\",\"properties\":{\"job_id\":{\"type\":\"string\",\"format\":\"uuid\"},\"function_name\":{\"type\":\"string\"},\"type\":{\"type\":\"string\",\"enum\":[\"tool_call\"]},\"module_id\":{\"type\":\"string\"}},\"required\":[\"job_id\",\"function_name\",\"type\",\"module_id\"]},{\"type\":\"object\",\"properties\":{\"call_id\":{\"type\":\"string\",\"format\":\"uuid\"},\"function_name\":{\"type\":\"string\"},\"resource_path\":{\"type\":\"string\"},\"type\":{\"type\":\"string\",\"enum\":[\"mcp_tool_call\"]},\"arguments\":{\"type\":\"object\"}},\"required\":[\"call_id\",\"function_name\",\"resource_path\",\"type\"]},{\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"web_search\"]}},\"required\":[\"type\"]},{\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"message\"]}},\"required\":[\"content\",\"type\"]}]}},\"agent_actions_success\":{\"type\":\"array\",\"items\":{\"type\":\"boolean\"}}},\"required\":[\"type\"]}}"; -export declare const CLI_COMMANDS = "# Windmill CLI Commands\n\nThe Windmill CLI (`wmill`) provides commands for managing scripts, flows, apps, and other resources.\n\n## Global Options\n\n- `--workspace ` - Specify the target workspace. This overrides the default workspace.\n- `--debug --verbose` - Show debug/verbose logs\n- `--show-diffs` - Show diff informations when syncing (may show sensitive informations)\n- `--token ` - Specify an API token. This will override any stored token.\n- `--base-url ` - Specify the base URL of the API. If used, --token and --workspace are required and no local remote/workspace already set will be used.\n- `--config-dir ` - Specify a custom config directory. Overrides WMILL_CONFIG_DIR environment variable and default ~/.config location.\n\n## Commands\n\n### app\n\napp related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `app list` - list all apps\n - `--json` - Output as JSON (for piping to jq)\n- `app get ` - get an app's details\n - `--json` - Output as JSON (for piping to jq)\n- `app push [file_path:string] [remote_path:string]` - push a local app. With no args, infers the app from the current directory and the remote path from its location relative to wmill.yaml.\n- `app dev [app_folder:string]` - Start a development server for building apps with live reload and hot module replacement\n - `--port ` - Port to run the dev server on (will find next available port if occupied)\n - `--host ` - Host to bind the dev server to\n - `--entry ` - Entry point file (default: index.ts for Svelte/Vue, index.tsx otherwise)\n - `--no-open` - Don't automatically open the browser\n- `app lint [app_folder:string]` - Lint a raw app folder to validate structure and buildability\n - `--fix` - Attempt to fix common issues (not implemented yet)\n- `app new` - create a new raw app from a template\n - `--summary ` - App summary (short description). Skips the prompt when provided. Triggers non-interactive mode.\n - `--path ` - App path (e.g., f/folder/my_app or u/username/my_app). Skips the prompt when provided. Triggers non-interactive mode.\n - `--framework ` - Framework template: react19 | react18 | svelte5 | vue. Skips the prompt when provided. Triggers non-interactive mode.\n - `--datatable ` - Datatable to wire up. Without this flag in non-interactive mode, no datatable is configured.\n - `--schema ` - Schema to use with --datatable. Created (CREATE SCHEMA IF NOT EXISTS) if it doesn't already exist.\n - `--overwrite` - Overwrite the target directory if it already exists, without prompting.\n - `--no-open-in-desktop` - Do not prompt to open the new app in Claude Desktop.\n- `app generate-agents [app_folder:string]` - regenerate AGENTS.md and DATATABLES.md from remote workspace\n- `app set-permissioned-as ` - Set the on_behalf_of_email for an app (requires admin or wm_deployers group)\n\n### audit\n\nView audit logs (requires admin)\n\n**Subcommands:**\n\n- `audit list` - List audit log entries\n- `audit get ` - Get a specific audit log entry\n - `--json` - Output as JSON (for piping to jq)\n\n### config\n\nShow all available wmill.yaml configuration options\n\n**Options:**\n- `--json` - Output as JSON for programmatic consumption\n\n**Subcommands:**\n\n- `config migrate` - Migrate wmill.yaml from gitBranches/environments to workspaces format\n\n### datatable\n\ndatatable related commands\n\n**Subcommands:**\n\n- `datatable list` - list all datatables in the workspace\n - `--json` - Output as JSON (for piping to jq)\n- `datatable run ` - run a SQL query on a datatable\n - `-n --name ` - Datatable name (default: main)\n - `-s --silent` - Output only the final result as JSON. Useful for scripting.\n- `datatable serve` - Serve all datatables as a Postgres-wire endpoint (psql, DBeaver, pgAdmin); the client picks the datatable via the database name in its connection string\n - `--port ` - Port to listen on (default: first free port in 5433-5500)\n - `--host ` - Bind address (default: 127.0.0.1)\n - `--password ` - Password for Postgres clients (default: generate a random password at startup)\n- `datatable psql` - Start a serve listener and launch psql connected to it\n - `-n --name ` - Datatable to connect psql to (default: main)\n - `--port ` - Port the proxy listens on (default: first free port in 5433-5500)\n - `--host ` - Bind address for the proxy (default: 127.0.0.1)\n - `--password ` - Password for the temporary Postgres proxy (default: generate a random password at startup)\n\n### dependencies\n\nworkspace dependencies related commands\n\n**Alias:** `deps`\n\n**Subcommands:**\n\n- `dependencies push ` - Push workspace dependencies from a local file\n\n### dev\n\nWatch local file changes and live-reload the dev page for preview. Does NOT deploy to the remote workspace \u2014 use wmill sync push for that.\n\n**Options:**\n- `--includes ` - Filter paths given a glob pattern or path\n- `--proxy-port ` - Port for a localhost reverse proxy to the remote Windmill server\n- `--path ` - Watch a specific windmill path (e.g., u/admin/my_script or f/my_flow)\n- `--no-open` - Do not open the browser automatically\n\n### docs\n\nSearch Windmill documentation.\n\n**Arguments:** ``\n\n**Options:**\n- `--json` - Output results as JSON.\n\n### ducklake\n\nducklake related commands\n\n**Subcommands:**\n\n- `ducklake list` - list all ducklakes in the workspace\n - `--json` - Output as JSON (for piping to jq)\n- `ducklake run ` - run a SQL query on a ducklake\n - `-n --name ` - Ducklake name (default: main)\n - `-s --silent` - Output only the final result as JSON. Useful for scripting.\n\n### flow\n\nflow related commands\n\n**Options:**\n- `--show-archived` - Enable archived flows in output\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `flow list` - list all flows\n - `--show-archived` - Enable archived flows in output\n - `--json` - Output as JSON (for piping to jq)\n- `flow get ` - get a flow's details\n - `--json` - Output as JSON (for piping to jq)\n- `flow push ` - push a local flow spec. This overrides any remote versions.\n - `--message ` - Deployment message\n- `flow run ` - run a flow by path.\n - `-d --data ` - Inputs specified as a JSON string or a file using @ or stdin using @-.\n - `-s --silent` - Do not ouput anything other then the final output. Useful for scripting.\n- `flow preview ` - preview a local flow without deploying it. Runs the flow definition from local files and uses local PathScripts by default.\n - `-d --data ` - Inputs specified as a JSON string or a file using @ or stdin using @-.\n - `-s --silent` - Do not output anything other then the final output. Useful for scripting.\n - `--remote` - Use deployed workspace scripts for PathScript steps instead of local files.\n- `flow new ` - create a new empty flow\n - `--summary ` - flow summary\n - `--description ` - flow description\n- `flow bootstrap ` - create a new empty flow (alias for new)\n - `--summary ` - flow summary\n - `--description ` - flow description\n- `flow history ` - Show version history for a flow\n - `--json` - Output as JSON (for piping to jq)\n- `flow show-version ` - Show a specific version of a flow\n - `--json` - Output as JSON (for piping to jq)\n- `flow set-permissioned-as ` - Set the on_behalf_of_email for a flow (requires admin or wm_deployers group)\n\n### folder\n\nfolder related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `folder list` - list all folders\n - `--json` - Output as JSON (for piping to jq)\n- `folder get ` - get a folder's details\n - `--json` - Output as JSON (for piping to jq)\n- `folder new ` - create a new folder locally\n - `--summary ` - folder summary\n- `folder push ` - push a local folder to the remote by name. This overrides any remote versions.\n- `folder add-missing` - create default folder.meta.yaml for all subdirectories of f/ that are missing one\n - `-y, --yes` - skip confirmation prompt\n- `folder show-rules ` - Show default_permissioned_as rules for a folder. Use --test-path to see which rule matches a given item path.\n - `--test-path ` - Test which rule matches this item path (e.g. f/prod/jobs/my_script)\n - `--json` - Output as JSON\n\n### generate-metadata\n\nGenerate metadata (locks, schemas) for all scripts, flows, and apps\n\n**Arguments:** `[folder:string]`\n\n**Options:**\n- `--yes` - Skip confirmation prompt\n- `--dry-run` - Show what would be updated without making changes\n- `--lock-only` - Re-generate only the lock files\n- `--schema-only` - Re-generate only script schemas (skips flows and apps)\n- `--skip-scripts` - Skip processing scripts\n- `--skip-flows` - Skip processing flows\n- `--skip-apps` - Skip processing apps\n- `--strict-folder-boundaries` - Only update items inside the specified folder (requires folder argument)\n- `--parallel ` - Number of items to process in parallel\n- `-i --includes ` - Comma separated patterns to specify which files to include\n- `-e --excludes ` - Comma separated patterns to specify which files to exclude\n\n**Subcommands:**\n\n- `generate-metadata rehash [folder:string]`\n - `--skip-scripts` - Skip processing scripts\n - `--skip-flows` - Skip processing flows\n - `--skip-apps` - Skip processing apps\n - `--parallel ` - Number of items to process in parallel\n - `-i --includes ` - Comma separated patterns to specify which files to include\n - `-e --excludes ` - Comma separated patterns to specify which files to exclude\n\n### gitsync-settings\n\nManage git-sync settings between local wmill.yaml and Windmill backend\n\n**Subcommands:**\n\n- `gitsync-settings pull` - Pull git-sync settings from Windmill backend to local wmill.yaml\n - `--repository ` - Specify repository path (e.g., u/user/repo)\n - `--default` - Write settings to top-level defaults instead of overrides\n - `--replace` - Replace existing settings (non-interactive mode)\n - `--override` - Add branch-specific override (non-interactive mode)\n - `--diff` - Show differences without applying changes\n - `--json-output` - Output in JSON format\n - `--with-backend-settings ` - Use provided JSON settings instead of querying backend (for testing)\n - `--yes` - Skip interactive prompts and use default behavior\n - `--promotion ` - Use promotionOverrides from the specified branch instead of regular overrides\n- `gitsync-settings push` - Push git-sync settings from local wmill.yaml to Windmill backend\n - `--repository ` - Specify repository path (e.g., u/user/repo)\n - `--diff` - Show what would be pushed without applying changes\n - `--json-output` - Output in JSON format\n - `--with-backend-settings ` - Use provided JSON settings instead of querying backend (for testing)\n - `--yes` - Skip interactive prompts and use default behavior\n - `--promotion ` - Use promotionOverrides from the specified branch instead of regular overrides\n\n### group\n\nManage workspace groups\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `group list` - List all groups in the workspace\n - `--json` - Output as JSON (for piping to jq)\n- `group get ` - Get group details and members\n - `--json` - Output as JSON (for piping to jq)\n- `group create ` - Create a new group\n - `--summary ` - Group summary/description\n- `group delete ` - Delete a group\n- `group add-user ` - Add a user to a group\n- `group remove-user ` - Remove a user from a group\n\n### hub\n\nHub related commands. EXPERIMENTAL. INTERNAL USE ONLY.\n\n**Subcommands:**\n\n- `hub pull` - pull any supported definitions. EXPERIMENTAL.\n\n### init\n\nBootstrap a windmill project with a wmill.yaml file\n\n**Options:**\n- `--use-default` - Use default settings without checking backend\n- `--use-backend` - Use backend git-sync settings if available\n- `--repository ` - Specify repository path (e.g., u/user/repo) when using backend settings\n- `--bind-profile` - Automatically bind active workspace profile to current Git branch\n- `--no-bind-profile` - Skip workspace profile binding prompt\n\n### instance\n\nsync local with a remote instance or the opposite (push or pull)\n\n**Subcommands:**\n\n- `instance add [instance_name:string] [remote:string] [token:string]` - Add a new instance\n- `instance remove ` - Remove an instance\n- `instance switch ` - Switch the current instance\n- `instance pull` - Pull instance settings, users, configs, instance groups and overwrite local\n - `--yes` - Pull without needing confirmation\n - `--dry-run` - Perform a dry run without making changes\n - `--skip-users` - Skip pulling users\n - `--skip-settings` - Skip pulling settings\n - `--skip-configs` - Skip pulling configs (worker groups)\n - `--skip-groups` - Skip pulling instance groups\n - `--include-workspaces` - Also pull workspaces\n - `--folder-per-instance` - Create a folder per instance\n - `--instance ` - Name of the instance to pull from, override the active instance\n - `--prefix ` - Prefix of the local workspaces to pull, used to create the folders when using --include-workspaces\n - `--prefix-settings` - Store instance yamls inside prefixed folders when using --prefix and --folder-per-instance\n- `instance push` - Push instance settings, users, configs, group and overwrite remote\n - `--yes` - Push without needing confirmation\n - `--dry-run` - Perform a dry run without making changes\n - `--skip-users` - Skip pushing users\n - `--skip-settings` - Skip pushing settings\n - `--skip-configs` - Skip pushing configs (worker groups)\n - `--skip-groups` - Skip pushing instance groups\n - `--include-workspaces` - Also push workspaces\n - `--folder-per-instance` - Create a folder per instance\n - `--instance ` - Name of the instance to push to, override the active instance\n - `--prefix ` - Prefix of the local workspaces folders to push\n - `--prefix-settings` - Store instance yamls inside prefixed folders when using --prefix and --folder-per-instance\n- `instance whoami` - Display information about the currently logged-in user\n- `instance get-config` - Dump the current instance config (global settings + worker configs) as YAML\n - `-o, --output-file ` - Write YAML to a file instead of stdout\n - `--show-secrets` - Include sensitive fields (license key, JWT secret) without prompting\n - `--instance ` - Name of the instance, override the active instance\n- `instance connect-slack`\n - `--bot-token ` - Slack bot token (xoxb-...)\n - `--team-id ` - Slack team id\n - `--team-name ` - Slack team name\n - `--instance ` - Instance profile to connect against (defaults to the active instance)\n\n### job\n\nManage jobs (list, inspect, cancel)\n\n**Subcommands:**\n\n- `job list` - List recent jobs\n- `job get ` - Get job details. For flows: shows step tree with sub-job IDs\n - `--json` - Output as JSON (for piping to jq)\n- `job result ` - Get the result of a completed job (machine-friendly)\n- `job logs ` - Get job logs. For flows: aggregates all step logs\n- `job cancel ` - Cancel a running or queued job\n - `--reason ` - Reason for cancellation\n\n### jobs\n\nPull completed and queued jobs from workspace\n\n**Arguments:** `[workspace:string]`\n\n**Options:**\n- `-c, --completed-output ` - Completed jobs output file (default: completed_jobs.json)\n- `-q, --queued-output ` - Queued jobs output file (default: queued_jobs.json)\n- `--skip-worker-check` - Skip checking for active workers before export\n\n**Subcommands:**\n\n- `jobs pull`\n- `jobs push`\n\n### lint\n\nValidate Windmill flow, schedule, and trigger YAML files in a directory\n\n**Arguments:** `[directory:string]`\n\n**Options:**\n- `--json` - Output results in JSON format\n- `--fail-on-warn` - Exit with code 1 when warnings are emitted\n- `--locks-required` - Fail if scripts or flow inline scripts that need locks have no locks\n- `-w, --watch` - Watch for file changes and re-lint automatically\n\n### protection-rules\n\n**Subcommands:**\n\n- `protection-rules pull [workspace:string]` - Pull protection rules from Windmill into protection-rules.yaml for a workspace\n - `--all` - Pull every workspace defined in wmill.yaml\n - `--dry-run` - Show what would change without writing the file\n - `--json-output` - Output in JSON format\n- `protection-rules push [workspace:string]` - Push protection rules from protection-rules.yaml to Windmill for a workspace (full reconcile: creates, updates, and deletes)\n - `--all` - Push every workspace defined in protection-rules.yaml\n - `--dry-run` - Show what would change without applying\n - `--json-output` - Output in JSON format\n - `--yes` - Skip the confirmation prompt (including deletions)\n\n### queues\n\nList all queues with their metrics\n\n**Arguments:** `[workspace:string] the optional workspace to filter by (default to all workspaces)`\n\n**Options:**\n- `--instance [instance]` - Name of the instance to push to, override the active instance\n- `--base-url [baseUrl]` - If used with --token, will be used as the base url for the instance\n\n### refresh\n\nRefresh wmill-managed project files (AGENTS.cli.md and skills)\n\n**Subcommands:**\n\n- `refresh prompts` - Refresh AGENTS.cli.md and managed skills. User-owned AGENTS.md and CLAUDE.md are never overwritten unless you opt in.\n - `--yes` - Non-interactive: skip the migration prompt for existing AGENTS.md / CLAUDE.md without the expected include; defaults to appending the include.\n\n### resource\n\nresource related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `resource list` - list all resources\n - `--json` - Output as JSON (for piping to jq)\n- `resource get ` - get a resource's details\n - `--json` - Output as JSON (for piping to jq)\n- `resource new ` - create a new resource locally\n- `resource push ` - push a local resource spec. This overrides any remote versions.\n\n### resource-type\n\nresource type related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `resource-type list` - list all resource types\n - `--schema` - Show schema in the output\n - `--json` - Output as JSON (for piping to jq)\n- `resource-type get ` - get a resource type's details\n - `--json` - Output as JSON (for piping to jq)\n- `resource-type new ` - create a new resource type locally\n- `resource-type push ` - push a local resource spec. This overrides any remote versions.\n- `resource-type generate-namespace` - Create a TypeScript definition file with the RT namespace generated from the resource types\n\n### schedule\n\nschedule related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `schedule list` - list all schedules\n - `--json` - Output as JSON (for piping to jq)\n- `schedule get ` - get a schedule's details\n - `--json` - Output as JSON (for piping to jq)\n- `schedule new ` - create a new schedule locally\n- `schedule push ` - push a local schedule spec. This overrides any remote versions.\n- `schedule enable ` - Enable a schedule\n - `--force` - Bypass the fork-conflict warning when the parent workspace has the same schedule (acknowledges that both crons will fire)\n- `schedule disable ` - Disable a schedule\n- `schedule set-permissioned-as ` - Set the email (run-as user) for a schedule (requires admin or wm_deployers group)\n\n### script\n\nscript related commands\n\n**Options:**\n- `--show-archived` - Show archived scripts instead of active ones\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `script list` - list all scripts\n - `--show-archived` - Show archived scripts instead of active ones\n - `--json` - Output as JSON (for piping to jq)\n- `script push ` - push a local script spec. This overrides any remote versions. Use the script file (.ts, .js, .py, .sh)\n - `--message ` - Deployment message\n- `script get ` - get a script's details\n - `--json` - Output as JSON (for piping to jq)\n- `script show ` - show a script's content (alias for get)\n- `script run ` - run a script by path\n - `-d --data ` - Inputs specified as a JSON string or a file using @ or stdin using @-.\n - `-s --silent` - Do not output anything other then the final output. Useful for scripting.\n- `script preview ` - preview a local script without deploying it. Supports both regular and codebase scripts.\n - `-d --data ` - Inputs specified as a JSON string or a file using @ or stdin using @-.\n - `-s --silent` - Do not output anything other than the final output. Useful for scripting.\n- `script new ` - create a new script\n - `--summary ` - script summary\n - `--description ` - script description\n- `script bootstrap ` - create a new script (alias for new)\n - `--summary ` - script summary\n - `--description ` - script description\n- `script set-permissioned-as ` - Set the on_behalf_of_email for a script (requires admin or wm_deployers group)\n- `script history ` - show version history for a script\n - `--json` - Output as JSON (for piping to jq)\n\n### sync\n\nsync local with a remote workspaces or the opposite (push or pull)\n\n**Subcommands:**\n\n- `sync pull` - Pull any remote changes and apply them locally.\n - `--yes` - Pull without needing confirmation\n - `--dry-run` - Show changes that would be pulled without actually pushing\n - `--plain-secrets` - Pull secrets as plain text\n - `--json` - Use JSON instead of YAML\n - `--skip-variables` - Skip syncing variables (including secrets)\n - `--skip-secrets` - Skip syncing only secrets variables\n - `--include-secrets` - Include secrets in sync (overrides skipSecrets in wmill.yaml)\n - `--skip-resources` - Skip syncing resources\n - `--skip-resource-types` - Skip syncing resource types\n - `--skip-scripts` - Skip syncing scripts\n - `--skip-flows` - Skip syncing flows\n - `--skip-apps` - Skip syncing apps\n - `--skip-folders` - Skip syncing folders\n - `--skip-workspace-dependencies` - Skip syncing workspace dependencies\n - `--skip-scripts-metadata` - Skip syncing scripts metadata, focus solely on logic\n - `--include-schedules` - Include syncing schedules\n - `--include-triggers` - Include syncing triggers\n - `--include-users` - Include syncing users\n - `--include-groups` - Include syncing groups\n - `--include-settings` - Include syncing workspace settings\n - `--include-key` - Include workspace encryption key\n - `--skip-branch-validation` - Skip git branch validation and prompts\n - `--json-output` - Output results in JSON format\n - `-i --includes ` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string). Overrides wmill.yaml includes\n - `-e --excludes ` - Comma separated patterns to specify which file to NOT take into account. Overrides wmill.yaml excludes\n - `--extra-includes ` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string). Useful to still take wmill.yaml into account and act as a second pattern to satisfy\n - `--repository ` - Specify repository path (e.g., u/user/repo) when multiple repositories exist\n - `--promotion ` - Use promotionOverrides from the specified branch instead of regular overrides\n - `--branch, --env ` - [Deprecated: use --workspace] Override the current git branch/environment\n- `sync push` - Push any local changes and apply them remotely.\n - `--yes` - Push without needing confirmation\n - `--dry-run` - Show changes that would be pushed without actually pushing\n - `--plain-secrets` - Push secrets as plain text\n - `--json` - Use JSON instead of YAML\n - `--skip-variables` - Skip syncing variables (including secrets)\n - `--skip-secrets` - Skip syncing only secrets variables\n - `--include-secrets` - Include secrets in sync (overrides skipSecrets in wmill.yaml)\n - `--skip-resources` - Skip syncing resources\n - `--skip-resource-types` - Skip syncing resource types\n - `--skip-scripts` - Skip syncing scripts\n - `--skip-flows` - Skip syncing flows\n - `--skip-apps` - Skip syncing apps\n - `--skip-folders` - Skip syncing folders\n - `--skip-workspace-dependencies` - Skip syncing workspace dependencies\n - `--skip-scripts-metadata` - Skip syncing scripts metadata, focus solely on logic\n - `--include-schedules` - Include syncing schedules\n - `--include-triggers` - Include syncing triggers\n - `--include-users` - Include syncing users\n - `--include-groups` - Include syncing groups\n - `--include-settings` - Include syncing workspace settings\n - `--include-key` - Include workspace encryption key\n - `--skip-branch-validation` - Skip git branch validation and prompts\n - `--json-output` - Output results in JSON format\n - `-i --includes ` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string)\n - `-e --excludes ` - Comma separated patterns to specify which file to NOT take into account.\n - `--extra-includes ` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string). Useful to still take wmill.yaml into account and act as a second pattern to satisfy\n - `--message ` - Include a message that will be added to all scripts/flows/apps updated during this push\n - `--parallel ` - Number of changes to process in parallel\n - `--repository ` - Specify repository path (e.g., u/user/repo) when multiple repositories exist\n - `--branch, --env ` - [Deprecated: use --workspace] Override the current git branch/environment\n - `--lint` - Run lint validation before pushing\n - `--locks-required` - Fail if scripts or flow inline scripts that need locks have no locks\n - `--auto-metadata` - Automatically regenerate stale metadata (locks and schemas) before pushing\n - `--accept-overriding-permissioned-as-with-self` - Accept that items with a different permissioned_as will be updated with your own user\n\n### token\n\nManage API tokens\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `token list` - List API tokens\n - `--json` - Output as JSON (for piping to jq)\n- `token create` - Create a new API token\n - `--label ` - Token label\n - `--expiration ` - Token expiration (ISO 8601 timestamp)\n- `token delete ` - Delete a token by its prefix\n\n### trigger\n\ntrigger related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `trigger list` - list all triggers\n - `--json` - Output as JSON (for piping to jq)\n- `trigger get ` - get a trigger's details\n - `--json` - Output as JSON (for piping to jq)\n - `--kind ` - Trigger kind (http, websocket, kafka, nats, postgres, mqtt, sqs, gcp, azure, email). Recommended for faster lookup\n- `trigger new ` - create a new trigger locally\n - `--kind ` - Trigger kind (required: http, websocket, kafka, nats, postgres, mqtt, sqs, gcp, azure, email)\n- `trigger push ` - push a local trigger spec. This overrides any remote versions.\n- `trigger set-permissioned-as ` - Set the email (run-as user) for a trigger (requires admin or wm_deployers group)\n - `--kind ` - Trigger kind (required: http, websocket, kafka, nats, postgres, mqtt, sqs, gcp, azure, email)\n\n### user\n\nuser related commands\n\n**Subcommands:**\n\n- `user add [password:string]` - Create a user\n - `--superadmin` - Specify to make the new user superadmin.\n - `--company ` - Specify to set the company of the new user.\n - `--name ` - Specify to set the name of the new user.\n- `user remove ` - Delete a user\n- `user create-token` - Create a new API token for the authenticated user\n - `--email ` - Specify credentials to use for authentication. This will not be stored. It will only be used to exchange for a token with the API server, which will not be stored either.\n - `--password ` - Specify credentials to use for authentication. This will not be stored. It will only be used to exchange for a token with the API server, which will not be stored either.\n\n### variable\n\nvariable related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `variable list` - list all variables\n - `--json` - Output as JSON (for piping to jq)\n- `variable get ` - get a variable's details\n - `--json` - Output as JSON (for piping to jq)\n- `variable new ` - create a new variable locally\n- `variable push ` - Push a local variable spec. This overrides any remote versions.\n - `--plain-secrets` - Push secrets as plain text\n- `variable add ` - Create a new variable on the remote. This will update the variable if it already exists.\n - `--plain-secrets` - Push secrets as plain text\n - `--public` - Legacy option, use --plain-secrets instead\n\n### version\n\nShow version information\n\n### worker-groups\n\ndisplay worker groups, pull and push worker groups configs\n\n**Subcommands:**\n\n- `worker-groups pull` - Pull worker groups (similar to `wmill instance pull --skip-users --skip-settings --skip-groups`)\n - `--instance` - Name of the instance to push to, override the active instance\n - `--base-url` - Base url to be passed to the instance settings instead of the local one\n - `--yes` - Pull without needing confirmation\n- `worker-groups push` - Push worker groups (similar to `wmill instance push --skip-users --skip-settings --skip-groups`)\n - `--instance [instance]` - Name of the instance to push to, override the active instance\n - `--base-url [baseUrl]` - If used with --token, will be used as the base url for the instance\n - `--yes` - Push without needing confirmation\n\n### workers\n\nList all workers grouped by worker groups\n\n**Options:**\n- `--instance [instance]` - Name of the instance to push to, override the active instance\n- `--base-url [baseUrl]` - If used with --token, will be used as the base url for the instance\n\n### workspace\n\nworkspace related commands\n\n**Alias:** `profile`\n\n**Subcommands:**\n\n- `workspace switch ` - Switch to another workspace\n- `workspace add [workspace_name:string] [workspace_id:string] [remote:string]` - Add a workspace\n - `-c --create` - Create the workspace if it does not exist\n - `--create-workspace-name ` - Specify the workspace name. Ignored if --create is not specified or the workspace already exists. Will default to the workspace id.\n - `--create-username ` - Specify your own username in the newly created workspace. Ignored if --create is not specified, the workspace already exists or automatic username creation is enabled on the instance.\n- `workspace remove ` - Remove a workspace\n- `workspace whoami` - Show the currently active user\n- `workspace list` - List local workspace profiles\n- `workspace list-remote` - List workspaces on the remote server that you have access to\n - `--as-superadmin` - List ALL workspaces on the instance (requires the token to belong to a superadmin/devops user)\n- `workspace list-forks` - List forked workspaces on the remote server\n- `workspace bind` - Create or update a workspace entry in wmill.yaml from the active profile\n - `--workspace ` - Workspace name (default: current branch or workspaceId)\n - `--branch ` - Git branch to associate (default: workspace name)\n- `workspace unbind` - Remove baseUrl and workspaceId from a workspace entry\n - `--workspace ` - Workspace to unbind\n- `workspace fork [workspace_name:string] [workspace_id:string]` - Create a forked workspace\n - `--create-workspace-name ` - Specify the workspace name. Ignored if --create is not specified or the workspace already exists. Will default to the workspace id.\n - `--color ` - Workspace color (hex code, e.g. #ff0000)\n - `--datatable-behavior ` - How to handle datatables: skip, schema_only, or schema_and_data (default: interactive prompt)\n - `-y --yes` - Skip interactive prompts (defaults datatable behavior to 'skip')\n- `workspace delete-fork ` - Delete a forked workspace and git branch\n - `-y --yes` - Skip confirmation prompt\n- `workspace merge` - Compare and deploy changes between a fork and its parent workspace\n - `--direction ` - Deploy direction: to-parent or to-fork\n - `--all` - Deploy all changed items including conflicts\n - `--skip-conflicts` - Skip items modified in both workspaces\n - `--include ` - Comma-separated kind:path items to include (e.g. script:f/test/main,flow:f/my/flow)\n - `--exclude ` - Comma-separated kind:path items to exclude\n - `--preserve-on-behalf-of` - Preserve original on_behalf_of/permissioned_as values\n - `-y --yes` - Non-interactive mode (deploy without prompts)\n- `workspace connect-slack` - Non-interactively connect Slack to the active workspace using a pre-minted bot token (xoxb-...). Produces the same artifacts as the UI OAuth flow: workspace_settings fields, g/slack group, f/slack_bot folder, and the encrypted bot token variable + resource at f/slack_bot/bot_token.\n - `--bot-token ` - Slack bot token (xoxb-...)\n - `--team-id ` - Slack team id\n - `--team-name ` - Slack team name\n- `workspace disconnect-slack`\n\n"; +export declare const CLI_COMMANDS = "# Windmill CLI Commands\n\nThe Windmill CLI (`wmill`) provides commands for managing scripts, flows, apps, and other resources.\n\n## Global Options\n\n- `--workspace ` - Specify the target workspace. This overrides the default workspace.\n- `--debug --verbose` - Show debug/verbose logs\n- `--show-diffs` - Show diff informations when syncing (may show sensitive informations)\n- `--token ` - Specify an API token. This will override any stored token.\n- `--base-url ` - Specify the base URL of the API. If used, --token and --workspace are required and no local remote/workspace already set will be used.\n- `--config-dir ` - Specify a custom config directory. Overrides WMILL_CONFIG_DIR environment variable and default ~/.config location.\n\n## Commands\n\n### app\n\napp related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `app list` - list all apps\n - `--json` - Output as JSON (for piping to jq)\n- `app get ` - get an app's details\n - `--json` - Output as JSON (for piping to jq)\n- `app push [file_path:string] [remote_path:string]` - push a local app. With no args, infers the app from the current directory and the remote path from its location relative to wmill.yaml.\n- `app dev [app_folder:string]` - Start a development server for building apps with live reload and hot module replacement\n - `--port ` - Port to run the dev server on (will find next available port if occupied)\n - `--host ` - Host to bind the dev server to\n - `--entry ` - Entry point file (default: index.ts for Svelte/Vue, index.tsx otherwise)\n - `--no-open` - Don't automatically open the browser\n- `app lint [app_folder:string]` - Lint a raw app folder to validate structure and buildability\n - `--fix` - Attempt to fix common issues (not implemented yet)\n- `app new` - create a new raw app from a template\n - `--summary ` - App summary (short description). Skips the prompt when provided. Triggers non-interactive mode.\n - `--path ` - App path (e.g., f/folder/my_app or u/username/my_app). Skips the prompt when provided. Triggers non-interactive mode.\n - `--framework ` - Framework template: react19 | react18 | svelte5 | vue. Skips the prompt when provided. Triggers non-interactive mode.\n - `--datatable ` - Datatable to wire up. Without this flag in non-interactive mode, no datatable is configured.\n - `--schema ` - Schema to use with --datatable. Created (CREATE SCHEMA IF NOT EXISTS) if it doesn't already exist.\n - `--overwrite` - Overwrite the target directory if it already exists, without prompting.\n - `--no-open-in-desktop` - Do not prompt to open the new app in Claude Desktop.\n- `app generate-agents [app_folder:string]` - regenerate AGENTS.md and DATATABLES.md from remote workspace\n- `app set-permissioned-as ` - Set the on_behalf_of_email for an app (requires admin or wm_deployers group)\n\n### audit\n\nView audit logs (requires admin)\n\n**Subcommands:**\n\n- `audit list` - List audit log entries\n- `audit get ` - Get a specific audit log entry\n - `--json` - Output as JSON (for piping to jq)\n\n### config\n\nShow all available wmill.yaml configuration options\n\n**Options:**\n- `--json` - Output as JSON for programmatic consumption\n\n**Subcommands:**\n\n- `config migrate` - Migrate wmill.yaml from gitBranches/environments to workspaces format\n\n### datatable\n\ndatatable related commands\n\n**Subcommands:**\n\n- `datatable list` - list all datatables in the workspace\n - `--json` - Output as JSON (for piping to jq)\n- `datatable run ` - run a SQL query on a datatable\n - `-n --name ` - Datatable name (default: main)\n - `-s --silent` - Output only the final result as JSON. Useful for scripting.\n- `datatable serve` - Serve all datatables as a Postgres-wire endpoint (psql, DBeaver, pgAdmin); the client picks the datatable via the database name in its connection string\n - `--port ` - Port to listen on (default: first free port in 5433-5500)\n - `--host ` - Bind address (default: 127.0.0.1)\n - `--password ` - Password for Postgres clients (default: generate a random password at startup)\n- `datatable psql` - Start a serve listener and launch psql connected to it\n - `-n --name ` - Datatable to connect psql to (default: main)\n - `--port ` - Port the proxy listens on (default: first free port in 5433-5500)\n - `--host ` - Bind address for the proxy (default: 127.0.0.1)\n - `--password ` - Password for the temporary Postgres proxy (default: generate a random password at startup)\n\n### dependencies\n\nworkspace dependencies related commands\n\n**Alias:** `deps`\n\n**Subcommands:**\n\n- `dependencies push ` - Push workspace dependencies from a local file\n\n### dev\n\nWatch local file changes and live-reload the dev page for preview. Does NOT deploy to the remote workspace \u2014 use wmill sync push for that.\n\n**Options:**\n- `--includes ` - Filter paths given a glob pattern or path\n- `--proxy-port ` - Port for a localhost reverse proxy to the remote Windmill server\n- `--path ` - Watch a specific windmill path (e.g., u/admin/my_script or f/my_flow)\n- `--no-open` - Do not open the browser automatically\n\n### docs\n\nSearch Windmill documentation.\n\n**Arguments:** ``\n\n**Options:**\n- `--json` - Output results as JSON.\n\n### ducklake\n\nducklake related commands\n\n**Subcommands:**\n\n- `ducklake list` - list all ducklakes in the workspace\n - `--json` - Output as JSON (for piping to jq)\n- `ducklake run ` - run a SQL query on a ducklake\n - `-n --name ` - Ducklake name (default: main)\n - `-s --silent` - Output only the final result as JSON. Useful for scripting.\n\n### flow\n\nflow related commands\n\n**Options:**\n- `--show-archived` - Enable archived flows in output\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `flow list` - list all flows\n - `--show-archived` - Enable archived flows in output\n - `--json` - Output as JSON (for piping to jq)\n- `flow get ` - get a flow's details\n - `--json` - Output as JSON (for piping to jq)\n- `flow push ` - push a local flow spec. This overrides any remote versions.\n - `--message ` - Deployment message\n- `flow run ` - run a flow by path.\n - `-d --data ` - Inputs specified as a JSON string or a file using @ or stdin using @-.\n - `-s --silent` - Do not ouput anything other then the final output. Useful for scripting.\n- `flow preview ` - preview a local flow without deploying it. Runs the flow definition from local files and uses local PathScripts by default.\n - `-d --data ` - Inputs specified as a JSON string or a file using @ or stdin using @-.\n - `-s --silent` - Do not output anything other then the final output. Useful for scripting.\n - `--remote` - Use deployed workspace scripts for PathScript steps instead of local files.\n- `flow new ` - create a new empty flow\n - `--summary ` - flow summary\n - `--description ` - flow description\n- `flow bootstrap ` - create a new empty flow (alias for new)\n - `--summary ` - flow summary\n - `--description ` - flow description\n- `flow history ` - Show version history for a flow\n - `--json` - Output as JSON (for piping to jq)\n- `flow show-version ` - Show a specific version of a flow\n - `--json` - Output as JSON (for piping to jq)\n- `flow set-permissioned-as ` - Set the on_behalf_of_email for a flow (requires admin or wm_deployers group)\n\n### folder\n\nfolder related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `folder list` - list all folders\n - `--json` - Output as JSON (for piping to jq)\n- `folder get ` - get a folder's details\n - `--json` - Output as JSON (for piping to jq)\n- `folder new ` - create a new folder locally\n - `--summary ` - folder summary\n- `folder push ` - push a local folder to the remote by name. This overrides any remote versions.\n- `folder add-missing` - create default folder.meta.yaml for all subdirectories of f/ that are missing one\n - `-y, --yes` - skip confirmation prompt\n- `folder show-rules ` - Show default_permissioned_as rules for a folder. Use --test-path to see which rule matches a given item path.\n - `--test-path ` - Test which rule matches this item path (e.g. f/prod/jobs/my_script)\n - `--json` - Output as JSON\n\n### generate-metadata\n\nGenerate metadata (locks, schemas) for all scripts, flows, and apps\n\n**Arguments:** `[folder:string]`\n\n**Options:**\n- `--yes` - Skip confirmation prompt\n- `--dry-run` - Show what would be updated without making changes\n- `--lock-only` - Re-generate only the lock files\n- `--schema-only` - Re-generate only script schemas (skips flows and apps)\n- `--skip-scripts` - Skip processing scripts\n- `--skip-flows` - Skip processing flows\n- `--skip-apps` - Skip processing apps\n- `--strict-folder-boundaries` - Only update items inside the specified folder (requires folder argument)\n- `--parallel ` - Number of items to process in parallel\n- `-i --includes ` - Comma separated patterns to specify which files to include\n- `-e --excludes ` - Comma separated patterns to specify which files to exclude\n\n**Subcommands:**\n\n- `generate-metadata rehash [folder:string]`\n - `--skip-scripts` - Skip processing scripts\n - `--skip-flows` - Skip processing flows\n - `--skip-apps` - Skip processing apps\n - `--parallel ` - Number of items to process in parallel\n - `-i --includes ` - Comma separated patterns to specify which files to include\n - `-e --excludes ` - Comma separated patterns to specify which files to exclude\n\n### gitsync-settings\n\nManage git-sync settings between local wmill.yaml and Windmill backend\n\n**Subcommands:**\n\n- `gitsync-settings pull` - Pull git-sync settings from Windmill backend to local wmill.yaml\n - `--repository ` - Specify repository path (e.g., u/user/repo)\n - `--default` - Write settings to top-level defaults instead of overrides\n - `--replace` - Replace existing settings (non-interactive mode)\n - `--override` - Add branch-specific override (non-interactive mode)\n - `--diff` - Show differences without applying changes\n - `--json-output` - Output in JSON format\n - `--with-backend-settings ` - Use provided JSON settings instead of querying backend (for testing)\n - `--yes` - Skip interactive prompts and use default behavior\n - `--promotion ` - Use promotionOverrides from the specified branch instead of regular overrides\n- `gitsync-settings push` - Push git-sync settings from local wmill.yaml to Windmill backend\n - `--repository ` - Specify repository path (e.g., u/user/repo)\n - `--diff` - Show what would be pushed without applying changes\n - `--json-output` - Output in JSON format\n - `--with-backend-settings ` - Use provided JSON settings instead of querying backend (for testing)\n - `--yes` - Skip interactive prompts and use default behavior\n - `--promotion ` - Use promotionOverrides from the specified branch instead of regular overrides\n\n### group\n\nManage workspace groups\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `group list` - List all groups in the workspace\n - `--json` - Output as JSON (for piping to jq)\n- `group get ` - Get group details and members\n - `--json` - Output as JSON (for piping to jq)\n- `group create ` - Create a new group\n - `--summary ` - Group summary/description\n- `group delete ` - Delete a group\n- `group add-user ` - Add a user to a group\n- `group remove-user ` - Remove a user from a group\n\n### hub\n\nHub related commands. EXPERIMENTAL. INTERNAL USE ONLY.\n\n**Subcommands:**\n\n- `hub pull` - pull any supported definitions. EXPERIMENTAL.\n\n### init\n\nBootstrap a windmill project with a wmill.yaml file\n\n**Options:**\n- `--use-default` - Use default settings without checking backend\n- `--use-backend` - Use backend git-sync settings if available\n- `--repository ` - Specify repository path (e.g., u/user/repo) when using backend settings\n- `--bind-profile` - Automatically bind active workspace profile to current Git branch\n- `--no-bind-profile` - Skip workspace profile binding prompt\n\n### instance\n\nsync local with a remote instance or the opposite (push or pull)\n\n**Subcommands:**\n\n- `instance add [instance_name:string] [remote:string] [token:string]` - Add a new instance\n- `instance remove ` - Remove an instance\n- `instance switch ` - Switch the current instance\n- `instance pull` - Pull instance settings, users, configs, instance groups and overwrite local\n - `--yes` - Pull without needing confirmation\n - `--dry-run` - Perform a dry run without making changes\n - `--skip-users` - Skip pulling users\n - `--skip-settings` - Skip pulling settings\n - `--skip-configs` - Skip pulling configs (worker groups)\n - `--skip-groups` - Skip pulling instance groups\n - `--include-workspaces` - Also pull workspaces\n - `--folder-per-instance` - Create a folder per instance\n - `--instance ` - Name of the instance to pull from, override the active instance\n - `--prefix ` - Prefix of the local workspaces to pull, used to create the folders when using --include-workspaces\n - `--prefix-settings` - Store instance yamls inside prefixed folders when using --prefix and --folder-per-instance\n- `instance push` - Push instance settings, users, configs, group and overwrite remote\n - `--yes` - Push without needing confirmation\n - `--dry-run` - Perform a dry run without making changes\n - `--skip-users` - Skip pushing users\n - `--skip-settings` - Skip pushing settings\n - `--skip-configs` - Skip pushing configs (worker groups)\n - `--skip-groups` - Skip pushing instance groups\n - `--include-workspaces` - Also push workspaces\n - `--folder-per-instance` - Create a folder per instance\n - `--instance ` - Name of the instance to push to, override the active instance\n - `--prefix ` - Prefix of the local workspaces folders to push\n - `--prefix-settings` - Store instance yamls inside prefixed folders when using --prefix and --folder-per-instance\n- `instance whoami` - Display information about the currently logged-in user\n- `instance get-config` - Dump the current instance config (global settings + worker configs) as YAML\n - `-o, --output-file ` - Write YAML to a file instead of stdout\n - `--show-secrets` - Include sensitive fields (license key, JWT secret) without prompting\n - `--instance ` - Name of the instance, override the active instance\n- `instance connect-slack`\n - `--bot-token ` - Slack bot token (xoxb-...)\n - `--team-id ` - Slack team id\n - `--team-name ` - Slack team name\n - `--instance ` - Instance profile to connect against (defaults to the active instance)\n\n### job\n\nManage jobs (list, inspect, cancel)\n\n**Subcommands:**\n\n- `job list` - List recent jobs\n- `job get ` - Get job details. For flows: shows step tree with sub-job IDs\n - `--json` - Output as JSON (for piping to jq)\n- `job result ` - Get the result of a completed job (machine-friendly)\n- `job logs ` - Get job logs. For flows: aggregates all step logs\n- `job cancel ` - Cancel a running or queued job\n - `--reason ` - Reason for cancellation\n- `job rerun ` - Re-run a completed job with the same args. Prints the new job UUID on stdout.\n- `job restart ` - Restart a completed flow at a given top-level step. Prints the new flow job UUID on stdout.\n - `--step ` - Top-level step id to restart the flow from\n - `--iteration ` - For a top-level branchall or for-loop step, the iteration to restart at\n\n### jobs\n\nPull completed and queued jobs from workspace\n\n**Arguments:** `[workspace:string]`\n\n**Options:**\n- `-c, --completed-output ` - Completed jobs output file (default: completed_jobs.json)\n- `-q, --queued-output ` - Queued jobs output file (default: queued_jobs.json)\n- `--skip-worker-check` - Skip checking for active workers before export\n\n**Subcommands:**\n\n- `jobs pull`\n- `jobs push`\n\n### lint\n\nValidate Windmill flow, schedule, and trigger YAML files in a directory\n\n**Arguments:** `[directory:string]`\n\n**Options:**\n- `--json` - Output results in JSON format\n- `--fail-on-warn` - Exit with code 1 when warnings are emitted\n- `--locks-required` - Fail if scripts or flow inline scripts that need locks have no locks\n- `-w, --watch` - Watch for file changes and re-lint automatically\n\n### protection-rules\n\n**Subcommands:**\n\n- `protection-rules pull [workspace:string]` - Pull protection rules from Windmill into protection-rules.yaml for a workspace\n - `--all` - Pull every workspace defined in wmill.yaml\n - `--dry-run` - Show what would change without writing the file\n - `--json-output` - Output in JSON format\n- `protection-rules push [workspace:string]` - Push protection rules from protection-rules.yaml to Windmill for a workspace (full reconcile: creates, updates, and deletes)\n - `--all` - Push every workspace defined in protection-rules.yaml\n - `--dry-run` - Show what would change without applying\n - `--json-output` - Output in JSON format\n - `--yes` - Skip the confirmation prompt (including deletions)\n\n### queues\n\nList all queues with their metrics\n\n**Arguments:** `[workspace:string] the optional workspace to filter by (default to all workspaces)`\n\n**Options:**\n- `--instance [instance]` - Name of the instance to push to, override the active instance\n- `--base-url [baseUrl]` - If used with --token, will be used as the base url for the instance\n\n### refresh\n\nRefresh wmill-managed project files (AGENTS.cli.md and skills)\n\n**Subcommands:**\n\n- `refresh prompts` - Refresh AGENTS.cli.md and managed skills. User-owned AGENTS.md and CLAUDE.md are never overwritten unless you opt in.\n - `--yes` - Non-interactive: skip the migration prompt for existing AGENTS.md / CLAUDE.md without the expected include; defaults to appending the include.\n\n### resource\n\nresource related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `resource list` - list all resources\n - `--json` - Output as JSON (for piping to jq)\n- `resource get ` - get a resource's details\n - `--json` - Output as JSON (for piping to jq)\n- `resource new ` - create a new resource locally\n- `resource push ` - push a local resource spec. This overrides any remote versions.\n\n### resource-type\n\nresource type related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `resource-type list` - list all resource types\n - `--schema` - Show schema in the output\n - `--json` - Output as JSON (for piping to jq)\n- `resource-type get ` - get a resource type's details\n - `--json` - Output as JSON (for piping to jq)\n- `resource-type new ` - create a new resource type locally\n- `resource-type push ` - push a local resource spec. This overrides any remote versions.\n- `resource-type generate-namespace` - Create a TypeScript definition file with the RT namespace generated from the resource types\n\n### schedule\n\nschedule related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `schedule list` - list all schedules\n - `--json` - Output as JSON (for piping to jq)\n- `schedule get ` - get a schedule's details\n - `--json` - Output as JSON (for piping to jq)\n- `schedule new ` - create a new schedule locally\n- `schedule push ` - push a local schedule spec. This overrides any remote versions.\n- `schedule enable ` - Enable a schedule\n - `--force` - Bypass the fork-conflict warning when the parent workspace has the same schedule (acknowledges that both crons will fire)\n- `schedule disable ` - Disable a schedule\n- `schedule set-permissioned-as ` - Set the email (run-as user) for a schedule (requires admin or wm_deployers group)\n\n### script\n\nscript related commands\n\n**Options:**\n- `--show-archived` - Show archived scripts instead of active ones\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `script list` - list all scripts\n - `--show-archived` - Show archived scripts instead of active ones\n - `--json` - Output as JSON (for piping to jq)\n- `script push ` - push a local script spec. This overrides any remote versions. Use the script file (.ts, .js, .py, .sh)\n - `--message ` - Deployment message\n- `script get ` - get a script's details\n - `--json` - Output as JSON (for piping to jq)\n- `script show ` - show a script's content (alias for get)\n- `script run ` - run a script by path\n - `-d --data ` - Inputs specified as a JSON string or a file using @ or stdin using @-.\n - `-s --silent` - Do not output anything other then the final output. Useful for scripting.\n- `script preview ` - preview a local script without deploying it. Supports both regular and codebase scripts.\n - `-d --data ` - Inputs specified as a JSON string or a file using @ or stdin using @-.\n - `-s --silent` - Do not output anything other than the final output. Useful for scripting.\n- `script new ` - create a new script\n - `--summary ` - script summary\n - `--description ` - script description\n- `script bootstrap ` - create a new script (alias for new)\n - `--summary ` - script summary\n - `--description ` - script description\n- `script set-permissioned-as ` - Set the on_behalf_of_email for a script (requires admin or wm_deployers group)\n- `script history ` - show version history for a script\n - `--json` - Output as JSON (for piping to jq)\n\n### sync\n\nsync local with a remote workspaces or the opposite (push or pull)\n\n**Subcommands:**\n\n- `sync pull` - Pull any remote changes and apply them locally.\n - `--yes` - Pull without needing confirmation\n - `--dry-run` - Show changes that would be pulled without actually pushing\n - `--plain-secrets` - Pull secrets as plain text\n - `--json` - Use JSON instead of YAML\n - `--skip-variables` - Skip syncing variables (including secrets)\n - `--skip-secrets` - Skip syncing only secrets variables\n - `--include-secrets` - Include secrets in sync (overrides skipSecrets in wmill.yaml)\n - `--skip-resources` - Skip syncing resources\n - `--skip-resource-types` - Skip syncing resource types\n - `--skip-scripts` - Skip syncing scripts\n - `--skip-flows` - Skip syncing flows\n - `--skip-apps` - Skip syncing apps\n - `--skip-folders` - Skip syncing folders\n - `--skip-workspace-dependencies` - Skip syncing workspace dependencies\n - `--skip-scripts-metadata` - Skip syncing scripts metadata, focus solely on logic\n - `--include-schedules` - Include syncing schedules\n - `--include-triggers` - Include syncing triggers\n - `--include-users` - Include syncing users\n - `--include-groups` - Include syncing groups\n - `--include-settings` - Include syncing workspace settings\n - `--include-key` - Include workspace encryption key\n - `--skip-branch-validation` - Skip git branch validation and prompts\n - `--json-output` - Output results in JSON format\n - `-i --includes ` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string). Overrides wmill.yaml includes\n - `-e --excludes ` - Comma separated patterns to specify which file to NOT take into account. Overrides wmill.yaml excludes\n - `--extra-includes ` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string). Useful to still take wmill.yaml into account and act as a second pattern to satisfy\n - `--repository ` - Specify repository path (e.g., u/user/repo) when multiple repositories exist\n - `--promotion ` - Use promotionOverrides from the specified branch instead of regular overrides\n - `--branch, --env ` - [Deprecated: use --workspace] Override the current git branch/environment\n- `sync push` - Push any local changes and apply them remotely.\n - `--yes` - Push without needing confirmation\n - `--dry-run` - Show changes that would be pushed without actually pushing\n - `--plain-secrets` - Push secrets as plain text\n - `--json` - Use JSON instead of YAML\n - `--skip-variables` - Skip syncing variables (including secrets)\n - `--skip-secrets` - Skip syncing only secrets variables\n - `--include-secrets` - Include secrets in sync (overrides skipSecrets in wmill.yaml)\n - `--skip-resources` - Skip syncing resources\n - `--skip-resource-types` - Skip syncing resource types\n - `--skip-scripts` - Skip syncing scripts\n - `--skip-flows` - Skip syncing flows\n - `--skip-apps` - Skip syncing apps\n - `--skip-folders` - Skip syncing folders\n - `--skip-workspace-dependencies` - Skip syncing workspace dependencies\n - `--skip-scripts-metadata` - Skip syncing scripts metadata, focus solely on logic\n - `--include-schedules` - Include syncing schedules\n - `--include-triggers` - Include syncing triggers\n - `--include-users` - Include syncing users\n - `--include-groups` - Include syncing groups\n - `--include-settings` - Include syncing workspace settings\n - `--include-key` - Include workspace encryption key\n - `--skip-branch-validation` - Skip git branch validation and prompts\n - `--json-output` - Output results in JSON format\n - `-i --includes ` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string)\n - `-e --excludes ` - Comma separated patterns to specify which file to NOT take into account.\n - `--extra-includes ` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string). Useful to still take wmill.yaml into account and act as a second pattern to satisfy\n - `--message ` - Include a message that will be added to all scripts/flows/apps updated during this push\n - `--parallel ` - Number of changes to process in parallel\n - `--repository ` - Specify repository path (e.g., u/user/repo) when multiple repositories exist\n - `--branch, --env ` - [Deprecated: use --workspace] Override the current git branch/environment\n - `--lint` - Run lint validation before pushing\n - `--locks-required` - Fail if scripts or flow inline scripts that need locks have no locks\n - `--auto-metadata` - Automatically regenerate stale metadata (locks and schemas) before pushing\n - `--accept-overriding-permissioned-as-with-self` - Accept that items with a different permissioned_as will be updated with your own user\n\n### token\n\nManage API tokens\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `token list` - List API tokens\n - `--json` - Output as JSON (for piping to jq)\n- `token create` - Create a new API token\n - `--label ` - Token label\n - `--expiration ` - Token expiration (ISO 8601 timestamp)\n- `token delete ` - Delete a token by its prefix\n\n### trigger\n\ntrigger related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `trigger list` - list all triggers\n - `--json` - Output as JSON (for piping to jq)\n- `trigger get ` - get a trigger's details\n - `--json` - Output as JSON (for piping to jq)\n - `--kind ` - Trigger kind (http, websocket, kafka, nats, postgres, mqtt, sqs, gcp, azure, email). Recommended for faster lookup\n- `trigger new ` - create a new trigger locally\n - `--kind ` - Trigger kind (required: http, websocket, kafka, nats, postgres, mqtt, sqs, gcp, azure, email)\n- `trigger push ` - push a local trigger spec. This overrides any remote versions.\n- `trigger set-permissioned-as ` - Set the email (run-as user) for a trigger (requires admin or wm_deployers group)\n - `--kind ` - Trigger kind (required: http, websocket, kafka, nats, postgres, mqtt, sqs, gcp, azure, email)\n\n### user\n\nuser related commands\n\n**Subcommands:**\n\n- `user add [password:string]` - Create a user\n - `--superadmin` - Specify to make the new user superadmin.\n - `--company ` - Specify to set the company of the new user.\n - `--name ` - Specify to set the name of the new user.\n- `user remove ` - Delete a user\n- `user create-token` - Create a new API token for the authenticated user\n - `--email ` - Specify credentials to use for authentication. This will not be stored. It will only be used to exchange for a token with the API server, which will not be stored either.\n - `--password ` - Specify credentials to use for authentication. This will not be stored. It will only be used to exchange for a token with the API server, which will not be stored either.\n\n### variable\n\nvariable related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `variable list` - list all variables\n - `--json` - Output as JSON (for piping to jq)\n- `variable get ` - get a variable's details\n - `--json` - Output as JSON (for piping to jq)\n- `variable new ` - create a new variable locally\n- `variable push ` - Push a local variable spec. This overrides any remote versions.\n - `--plain-secrets` - Push secrets as plain text\n- `variable add ` - Create a new variable on the remote. This will update the variable if it already exists.\n - `--plain-secrets` - Push secrets as plain text\n - `--public` - Legacy option, use --plain-secrets instead\n\n### version\n\nShow version information\n\n### worker-groups\n\ndisplay worker groups, pull and push worker groups configs\n\n**Subcommands:**\n\n- `worker-groups pull` - Pull worker groups (similar to `wmill instance pull --skip-users --skip-settings --skip-groups`)\n - `--instance` - Name of the instance to push to, override the active instance\n - `--base-url` - Base url to be passed to the instance settings instead of the local one\n - `--yes` - Pull without needing confirmation\n- `worker-groups push` - Push worker groups (similar to `wmill instance push --skip-users --skip-settings --skip-groups`)\n - `--instance [instance]` - Name of the instance to push to, override the active instance\n - `--base-url [baseUrl]` - If used with --token, will be used as the base url for the instance\n - `--yes` - Push without needing confirmation\n\n### workers\n\nList all workers grouped by worker groups\n\n**Options:**\n- `--instance [instance]` - Name of the instance to push to, override the active instance\n- `--base-url [baseUrl]` - If used with --token, will be used as the base url for the instance\n\n### workspace\n\nworkspace related commands\n\n**Alias:** `profile`\n\n**Subcommands:**\n\n- `workspace switch ` - Switch to another workspace\n- `workspace add [workspace_name:string] [workspace_id:string] [remote:string]` - Add a workspace\n - `-c --create` - Create the workspace if it does not exist\n - `--create-workspace-name ` - Specify the workspace name. Ignored if --create is not specified or the workspace already exists. Will default to the workspace id.\n - `--create-username ` - Specify your own username in the newly created workspace. Ignored if --create is not specified, the workspace already exists or automatic username creation is enabled on the instance.\n- `workspace remove ` - Remove a workspace\n- `workspace whoami` - Show the currently active user\n- `workspace list` - List local workspace profiles\n- `workspace list-remote` - List workspaces on the remote server that you have access to\n - `--as-superadmin` - List ALL workspaces on the instance (requires the token to belong to a superadmin/devops user)\n- `workspace list-forks` - List forked workspaces on the remote server\n- `workspace bind` - Create or update a workspace entry in wmill.yaml from the active profile\n - `--workspace ` - Workspace name (default: current branch or workspaceId)\n - `--branch ` - Git branch to associate (default: workspace name)\n- `workspace unbind` - Remove baseUrl and workspaceId from a workspace entry\n - `--workspace ` - Workspace to unbind\n- `workspace fork [workspace_name:string] [workspace_id:string]` - Create a forked workspace\n - `--create-workspace-name ` - Specify the workspace name. Ignored if --create is not specified or the workspace already exists. Will default to the workspace id.\n - `--color ` - Workspace color (hex code, e.g. #ff0000)\n - `--datatable-behavior ` - How to handle datatables: skip, schema_only, or schema_and_data (default: interactive prompt)\n - `-y --yes` - Skip interactive prompts (defaults datatable behavior to 'skip')\n- `workspace delete-fork ` - Delete a forked workspace and git branch\n - `-y --yes` - Skip confirmation prompt\n- `workspace merge` - Compare and deploy changes between a fork and its parent workspace\n - `--direction ` - Deploy direction: to-parent or to-fork\n - `--all` - Deploy all changed items including conflicts\n - `--skip-conflicts` - Skip items modified in both workspaces\n - `--include ` - Comma-separated kind:path items to include (e.g. script:f/test/main,flow:f/my/flow)\n - `--exclude ` - Comma-separated kind:path items to exclude\n - `--preserve-on-behalf-of` - Preserve original on_behalf_of/permissioned_as values\n - `-y --yes` - Non-interactive mode (deploy without prompts)\n- `workspace connect-slack` - Non-interactively connect Slack to the active workspace using a pre-minted bot token (xoxb-...). Produces the same artifacts as the UI OAuth flow: workspace_settings fields, g/slack group, f/slack_bot folder, and the encrypted bot token variable + resource at f/slack_bot/bot_token.\n - `--bot-token ` - Slack bot token (xoxb-...)\n - `--team-id ` - Slack team id\n - `--team-name ` - Slack team name\n- `workspace disconnect-slack`\n\n"; export declare const LANG_BASH = "# Bash\n\n## Structure\n\nDo not include `#!/bin/bash`. Arguments are obtained as positional parameters:\n\n```bash\n# Get arguments\nvar1=\"$1\"\nvar2=\"$2\"\n\necho \"Processing $var1 and $var2\"\n\n# Return JSON by echoing to stdout\necho \"{\\\"result\\\": \\\"$var1\\\", \\\"count\\\": $var2}\"\n```\n\n**Important:**\n- Do not include shebang (`#!/bin/bash`)\n- Arguments are always strings\n- Access with `$1`, `$2`, etc.\n\n## Output\n\nThe script output is captured as the result. For structured data, output valid JSON:\n\n```bash\nname=\"$1\"\ncount=\"$2\"\n\n# Output JSON result\ncat << EOF\n{\n \"name\": \"$name\",\n \"count\": $count,\n \"timestamp\": \"$(date -Iseconds)\"\n}\nEOF\n```\n\n## Environment Variables\n\nEnvironment variables set in Windmill are available:\n\n```bash\n# Access environment variable\necho \"Workspace: $WM_WORKSPACE\"\necho \"Job ID: $WM_JOB_ID\"\n```\n"; export declare const LANG_BIGQUERY = "# BigQuery\n\nArguments use `@name` syntax.\n\nName the parameters by adding comments before the statement:\n\n```sql\n-- @name1 (string)\n-- @name2 (int64) = 0\nSELECT * FROM users WHERE name = @name1 AND age > @name2;\n```\n\n## Receiving an S3Object as a script parameter\n\nDeclare the arg with type `(s3object)`. Windmill renders an S3 file picker for\nit, downloads the file, and binds it as a `STRING` JSON parameter \u2014 Parquet/CSV\nfiles are decoded server-side into a JSON array of records, JSON/JSONL pass\nthrough. Consume with `JSON_EXTRACT_ARRAY` / `JSON_VALUE`:\n\n```sql\n-- @file (s3object)\nSELECT\n CAST(JSON_VALUE(row, '$.id') AS INT64) AS id,\n JSON_VALUE(row, '$.name') AS name\nFROM UNNEST(JSON_EXTRACT_ARRAY(@file)) AS row;\n```\n\n## Streaming query results to S3\n\nAdd a `-- s3` directive at the top of the script to stream the result set to S3\ninstead of returning rows. Windmill writes the file and returns its `S3Object`\nas the script result.\n\n```sql\n-- s3 prefix=exports/users format=parquet\nSELECT id, name FROM users;\n```\n\nAll keys are optional: `prefix` (object key prefix), `storage` (named storage \u2014\nomit to use the workspace default), `format` (`json` (default), `parquet`, or\n`csv`). Use this for large result sets \u2014 rows stream directly to S3 instead of\nbeing buffered, bypassing the 10000-row return cap.\n"; export declare const LANG_BUN = "# TypeScript (Bun)\n\nBun runtime with full npm ecosystem and fastest execution.\n\n## Structure\n\nExport a single **async** function called `main`:\n\n```typescript\nexport async function main(param1: string, param2: number) {\n // Your code here\n return { result: param1, count: param2 };\n}\n```\n\nDo not call the main function. Libraries are installed automatically.\n\n## Resource Types\n\nOn Windmill, credentials and configuration are stored in resources and passed as parameters to main.\n\nUse the `RT` namespace for resource types:\n\n```typescript\nexport async function main(stripe: RT.Stripe) {\n // stripe contains API key and config from the resource\n}\n```\n\nOnly use resource types if you need them to satisfy the instructions. Always use the RT namespace.\n\nBefore using a resource type, check the `rt.d.ts` file in the project root to see all available resource types and their fields. This file is generated by `wmill resource-type generate-namespace`.\n\n## Imports\n\n```typescript\nimport Stripe from \"stripe\";\nimport { someFunction } from \"some-package\";\n```\n\n## Windmill Client\n\nImport the windmill client for platform interactions:\n\n```typescript\nimport * as wmill from \"windmill-client\";\n```\n\nSee the SDK documentation for available methods.\n\n## Preprocessor Scripts\n\nFor preprocessor scripts, the function should be named `preprocessor` and receives an `event` parameter:\n\n```typescript\ntype Event = {\n kind:\n | \"webhook\"\n | \"http\"\n | \"websocket\"\n | \"kafka\"\n | \"email\"\n | \"nats\"\n | \"postgres\"\n | \"sqs\"\n | \"mqtt\"\n | \"gcp\";\n body: any;\n headers: Record;\n query: Record;\n};\n\nexport async function preprocessor(event: Event) {\n return {\n param1: event.body.field1,\n param2: event.query.id,\n };\n}\n```\n\n## S3 Object Operations\n\nWindmill provides built-in support for S3-compatible storage operations. The `wmill.S3Object` type covers both the `s3://storage/key` URI form (`s3:///key` for the workspace default storage) and the `{ s3, storage? }` record form \u2014 always use it instead of redefining your own.\n\n### Receiving an S3Object as a script parameter\n\n```typescript\nimport * as wmill from \"windmill-client\";\n\nexport async function main(file: wmill.S3Object) {\n const content = await wmill.loadS3File(file);\n // ...\n}\n```\n\n### S3 operations\n\n```typescript\nimport * as wmill from \"windmill-client\";\n\n// Load file content from S3\nconst content: Uint8Array = await wmill.loadS3File(s3object);\n\n// Load file as stream\nconst blob: Blob = await wmill.loadS3FileStream(s3object);\n\n// Write file to S3\nconst result: wmill.S3Object = await wmill.writeS3File(\n s3object, // Target path (or undefined to auto-generate)\n fileContent, // string or Blob\n s3ResourcePath // Optional: specific S3 resource to use\n);\n```\n"; From fd760538897afb0e7efd3fd7f76294420d7366eb Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 22 May 2026 18:21:56 +0000 Subject: [PATCH 33/71] sdk_resource --- .../src/lib/components/ResourceEditor.svelte | 11 ++- .../routes/test_dev/sdk_resource/+page.svelte | 87 +++++++++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 frontend/src/routes/test_dev/sdk_resource/+page.svelte diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte index ac76ec7981..a7d728b7fb 100644 --- a/frontend/src/lib/components/ResourceEditor.svelte +++ b/frontend/src/lib/components/ResourceEditor.svelte @@ -330,7 +330,16 @@ $effect(() => { if (current) - onChange?.({ path: current.path, args: current.args, description: current.description }) + // Snapshot args so the deep read establishes nested dependency + // tracking (the effect re-runs on mutations inside args, not + // just reference changes) and so consumers receive a plain + // object instead of a $state proxy — important for React + // integrations that diff by reference or JSON.stringify. + onChange?.({ + path: current.path, + args: $state.snapshot(current.args) as Record, + description: current.description + }) }) $effect(() => { diff --git a/frontend/src/routes/test_dev/sdk_resource/+page.svelte b/frontend/src/routes/test_dev/sdk_resource/+page.svelte new file mode 100644 index 0000000000..d220ebef76 --- /dev/null +++ b/frontend/src/routes/test_dev/sdk_resource/+page.svelte @@ -0,0 +1,87 @@ + + +
+
+ + + {#if newResource} + + {:else} + + {/if} + + +
+ +
workspace: {$workspaceStore ?? '(unset)'}
+user: {$userStore?.username ?? '(unset)'}
+onChange: {JSON.stringify(lastChange, null, 2)}
+ + {#if !$workspaceStore || !$userStore} +
+ Waiting for workspace/user to load. If this never resolves, log into the app at /user/login + first so the workspace cookie/store is set. +
+ {:else} + {#key `${newResource}|${resource_type}|${path}`} + { + console.log('onChange', e) + lastChange = e + }} + /> + {/key} + {/if} +
From af48451c5316318ae5112b9a122f5046d9aa169a Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 22 May 2026 19:00:06 +0000 Subject: [PATCH 34/71] make `selected` resilient + snapshot args for React (#9298) * fix(ResourceEditor): make `selected` resilient + snapshot args for React Two issues surfaced via the React SDK (reactify wrapper re-spreads Svelte props on every host re-render): 1. The bindable `selected` prop transiently resets to undefined on each re-spread, flipping `current` through undefined and unmounting the form (input loses focus on every keystroke). Rename the prop to `selectedProp` and derive `selected = selectedProp ?? effectiveWorkspace` so the fallback insulates the component without effects. 2. The onChange dispatch passed `current.args` (a `$state` proxy) directly, so React consumers diffing by reference or JSON.stringify saw the same value forever, and the effect only tracked the args reference (not nested mutations). Wrap with `$state.snapshot` to deep-track and emit a plain object. The bootstrap effect is also restructured: it no longer writes `selected` (the derived handles defaulting) and now guards on `selected in initialStates` so workspace flips remain idempotent. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(ResourceEditor): declare effectiveWorkspace before use in selected Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../src/lib/components/ResourceEditor.svelte | 49 +++++++++---------- 1 file changed, 24 insertions(+), 25 deletions(-) diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte index a7d728b7fb..9a5c69dbd2 100644 --- a/frontend/src/lib/components/ResourceEditor.svelte +++ b/frontend/src/lib/components/ResourceEditor.svelte @@ -36,7 +36,7 @@ onChange, defaultValues = undefined, workspace = undefined, - selected = $bindable() + selected: selectedProp = $bindable() }: Props = $props() type ResourceState = { @@ -50,6 +50,10 @@ const dispatch = createEventDispatcher() let effectiveWorkspace = $derived(workspace ?? $workspaceStore!) + // Fallback to `effectiveWorkspace` insulates against reactify-style + // parents that re-spread props without `selected` — otherwise it + // transiently resets and the form below remounts on every keystroke. + let selected = $derived(selectedProp ?? effectiveWorkspace) let initialPath = path // Per-workspace handles are driven by `useMany`. We track the workspace @@ -205,28 +209,25 @@ }) ) - // Bootstrap: ensure selected is set on mount (edit or new) + // New-resource bootstrap: seed empty state per workspace (edit mode + // is seeded by the lazy-fetch effect below). $effect(() => { - selected - if (!effectiveWorkspace) return + if (!selected) return + if (initialPath) return + if (selected in initialStates) return untrack(() => { - if (selected !== undefined) return - selected = effectiveWorkspace - if (!initialPath) { - // New resource - const s: ResourceState = { - path: '', - description: '', - args: (defaultValues && Object.keys(defaultValues).length > 0 - ? defaultValues - : {}) as any, - labels: undefined, - wsSpecific: false - } - ensureHandle(effectiveWorkspace, s) - initialStates[effectiveWorkspace] = structuredClone(s) - existedInitially[effectiveWorkspace] = false + const s: ResourceState = { + path: '', + description: '', + args: (defaultValues && Object.keys(defaultValues).length > 0 + ? defaultValues + : {}) as any, + labels: undefined, + wsSpecific: false } + ensureHandle(selected, s) + initialStates[selected] = structuredClone(s) + existedInitially[selected] = false }) }) @@ -330,11 +331,9 @@ $effect(() => { if (current) - // Snapshot args so the deep read establishes nested dependency - // tracking (the effect re-runs on mutations inside args, not - // just reference changes) and so consumers receive a plain - // object instead of a $state proxy — important for React - // integrations that diff by reference or JSON.stringify. + // $state.snapshot deep-reads (so the effect re-runs on nested + // args mutations) and returns a plain object (React consumers + // can't diff a $state proxy by reference or JSON.stringify). onChange?.({ path: current.path, args: $state.snapshot(current.args) as Record, From e3fbc20c29c036351bda7c5a126b111536891d39 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 22 May 2026 20:50:45 +0000 Subject: [PATCH 35/71] remove unused workflow --- .github/workflows/spawn-ephemeral-backend.yml | 126 ------------------ 1 file changed, 126 deletions(-) delete mode 100644 .github/workflows/spawn-ephemeral-backend.yml diff --git a/.github/workflows/spawn-ephemeral-backend.yml b/.github/workflows/spawn-ephemeral-backend.yml deleted file mode 100644 index 725890031a..0000000000 --- a/.github/workflows/spawn-ephemeral-backend.yml +++ /dev/null @@ -1,126 +0,0 @@ -name: Spawn Ephemeral Backend - -on: - issue_comment: - types: [created] - pull_request_review_comment: - types: [created] - workflow_dispatch: - inputs: - pr_number: - description: "PR number" - required: true - type: number - -jobs: - check-membership: - if: | - (github.event_name == 'issue_comment' && contains(github.event.comment.body, '/spawnbackend')) || - (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '/spawnbackend')) - uses: ./.github/workflows/check-org-membership.yml - secrets: - access_token: ${{ secrets.ORG_ACCESS_TOKEN }} - - spawn-backend: - needs: check-membership - # Only run on PR comments that contain /spawn-backend, or manual dispatch - if: | - github.event_name == 'workflow_dispatch' || - (github.event.issue.pull_request && needs.check-membership.outputs.is_member == 'true') - runs-on: ubuntu-latest - permissions: - pull-requests: write - contents: read - - steps: - - name: Get PR details - id: pr-details - uses: actions/github-script@v7 - with: - script: | - const prNumber = context.eventName === 'workflow_dispatch' - ? context.payload.inputs.pr_number - : context.issue.number; - - const pr = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: prNumber - }); - - // Get branch name and format it for Cloudflare Pages - // Replace '/' with '-' for the URL - const branchName = pr.data.head.ref; - const formattedBranch = branchName.replace(/\//g, '-'); - const cfFrontendUrl = `https://${formattedBranch}.windmill.pages.dev`; - - core.setOutput('commit_hash', pr.data.head.sha); - core.setOutput('pr_number', prNumber); - core.setOutput('branch_name', branchName); - core.setOutput('cf_frontend_url', cfFrontendUrl); - - - name: Check manager URL - id: check-manager-url - run: | - if [ -z "${{ secrets.EPHEMERAL_BACKEND_QUEUE_URL }}" ]; then - echo "manager_url_set=false" >> $GITHUB_OUTPUT - else - echo "manager_url_set=true" >> $GITHUB_OUTPUT - fi - - - name: Post error comment if manager not running - if: steps.check-manager-url.outputs.manager_url_set == 'false' - uses: actions/github-script@v7 - with: - script: | - const prNumber = context.eventName === 'workflow_dispatch' - ? Number(context.payload.inputs.pr_number) - : context.issue.number; - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - body: `❌ Manager URL not set (did you start the ephemeral backend manager?)\n\nThe ephemeral backend manager needs to be running to spawn backends. Please start the manager first.` - }); - - - name: Fail if manager not running - if: steps.check-manager-url.outputs.manager_url_set == 'false' - run: | - echo "Error: EPHEMERAL_BACKEND_QUEUE_URL secret is not set" - exit 1 - - - name: Trigger Windmill flow - if: steps.check-manager-url.outputs.manager_url_set == 'true' - id: trigger-flow - run: | - JOB_UUID=$(curl -s -X POST "https://app.windmill.dev/api/w/windmill-labs/jobs/run/f/f/all/run_ephemeral_backend" \ - -H "Authorization: Bearer ${{ secrets.WINDMILL_RUN_FLOW_TOKEN }}" \ - -H "Content-Type: application/json" \ - -d '{ - "manager_url": "${{ secrets.EPHEMERAL_BACKEND_QUEUE_URL }}", - "commit_hash": "${{ steps.pr-details.outputs.commit_hash }}", - "pr_number": ${{ steps.pr-details.outputs.pr_number }}, - "cf_frontend_url": "${{ steps.pr-details.outputs.cf_frontend_url }}" - }' | tr -d '"') - - echo "Job UUID: $JOB_UUID" - echo "job_uuid=$JOB_UUID" >> $GITHUB_OUTPUT - - - name: Post comment with job link - if: steps.check-manager-url.outputs.manager_url_set == 'true' - uses: actions/github-script@v7 - with: - script: | - const jobUuid = '${{ steps.trigger-flow.outputs.job_uuid }}'; - const appUrl = `https://app.windmill.dev/public/windmill-labs/a106bad0256c1dfa7a4f9279c42b1a4b#${jobUuid}`; - const prNumber = context.eventName === 'workflow_dispatch' - ? Number(context.payload.inputs.pr_number) - : context.issue.number; - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - body: `🚀 Spawning new ephemeral backend!\n\n${appUrl}` - }); From daab561ec0763468d93e42e8f7f0796dc77be74d Mon Sep 17 00:00:00 2001 From: Alexander Petric Date: Fri, 22 May 2026 16:58:50 -0400 Subject: [PATCH 36/71] feat(typescript-client): add deleteS3File + optional workspace arg on S3 helpers (#9300) * feat(typescript-client): add deleteS3File + optional workspace arg on S3 helpers Customer-requested ergonomics for the TypeScript SDK: - New `deleteS3File(s3object, workspace?)` wrapper around the existing `HelpersService.deleteS3File` (backend endpoint is already there). Saves callers from having to either hand-roll `denoS3LightClientSettings()` + AWS SDK calls, or wire up `HelpersService` directly. - `denoS3LightClientSettings`, `loadS3File`, `loadS3FileStream`, `writeS3File`, and the new `deleteS3File` all gain an optional trailing `workspace?: string` parameter that falls back to the `WM_WORKSPACE` env var via `getWorkspace()`. Mirrors the calling convention customers already expect from helpers like `getVariable` / `runScript`. `build.sh` and `build.jsr.sh` are updated to export `deleteS3File` from both the NPM and JSR entry points. Co-Authored-By: Claude Opus 4.7 (1M context) * chore: regenerate system_prompts auto-generated for new S3 helpers `python system_prompts/generate.py` after adding deleteS3File and the optional workspace param to the existing S3 helpers, so the agent-facing docs (CLI skills, TS SDK prompt, script skills) reflect the new signatures. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- cli/src/guidance/skills.gen.ts | 108 +++++++++++++++--- system_prompts/auto-generated/prompts.ts | 27 ++++- system_prompts/auto-generated/script.md | 27 ++++- .../auto-generated/sdks/typescript.md | 27 ++++- .../skills/write-script-bun/SKILL.md | 27 ++++- .../skills/write-script-bunnative/SKILL.md | 27 ++++- .../skills/write-script-deno/SKILL.md | 27 ++++- .../skills/write-script-nativets/SKILL.md | 27 ++++- typescript-client/build.jsr.sh | 2 +- typescript-client/build.sh | 4 +- typescript-client/client.ts | 60 ++++++++-- 11 files changed, 306 insertions(+), 57 deletions(-) diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index 7cf2ae0adc..65ccdf7c2a 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -605,9 +605,10 @@ async duckdbConnectionSettings(s3_resource_path: string | undefined): Promise +async denoS3LightClientSettings(s3_resource_path: string | undefined, workspace: string | undefined = undefined): Promise /** * Load the content of a file stored in S3. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -618,8 +619,10 @@ async denoS3LightClientSettings(s3_resource_path: string | undefined): Promise +async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise /** * Load the content of a file stored in S3 as a stream. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -629,8 +632,10 @@ async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefi * // if the content is plain text, the blob can be read directly: * console.log(await fileContentBlob.text()); * \`\`\` + * + * @param workspace - Workspace to read from (defaults to the \`WM_WORKSPACE\` env var) */ -async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined): Promise +async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise /** * Persist a file to the S3 bucket. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -640,8 +645,22 @@ async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = * const fileContentAsUtf8Str = (await s3object.toArray()).toString('utf-8') * console.log(fileContentAsUtf8Str) * \`\`\` + * + * @param workspace - Workspace to write to (defaults to the \`WM_WORKSPACE\` env var) */ -async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined): Promise +async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined, workspace: string | undefined = undefined): Promise + +/** + * Permanently delete a file from S3 by key. + * + * \`\`\`typescript + * await wmill.deleteS3File({ s3: "path/to/file.txt" }) + * \`\`\` + * + * @param s3object - S3 object identifying the file to delete (must have \`s3\` set) + * @param workspace - Workspace to delete from (defaults to the \`WM_WORKSPACE\` env var) + */ +async deleteS3File(s3object: S3Object, workspace: string | undefined = undefined): Promise /** * Sign S3 objects to be used by anonymous users in public apps @@ -1296,9 +1315,10 @@ async duckdbConnectionSettings(s3_resource_path: string | undefined): Promise +async denoS3LightClientSettings(s3_resource_path: string | undefined, workspace: string | undefined = undefined): Promise /** * Load the content of a file stored in S3. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -1309,8 +1329,10 @@ async denoS3LightClientSettings(s3_resource_path: string | undefined): Promise +async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise /** * Load the content of a file stored in S3 as a stream. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -1320,8 +1342,10 @@ async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefi * // if the content is plain text, the blob can be read directly: * console.log(await fileContentBlob.text()); * \`\`\` + * + * @param workspace - Workspace to read from (defaults to the \`WM_WORKSPACE\` env var) */ -async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined): Promise +async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise /** * Persist a file to the S3 bucket. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -1331,8 +1355,22 @@ async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = * const fileContentAsUtf8Str = (await s3object.toArray()).toString('utf-8') * console.log(fileContentAsUtf8Str) * \`\`\` + * + * @param workspace - Workspace to write to (defaults to the \`WM_WORKSPACE\` env var) */ -async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined): Promise +async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined, workspace: string | undefined = undefined): Promise + +/** + * Permanently delete a file from S3 by key. + * + * \`\`\`typescript + * await wmill.deleteS3File({ s3: "path/to/file.txt" }) + * \`\`\` + * + * @param s3object - S3 object identifying the file to delete (must have \`s3\` set) + * @param workspace - Workspace to delete from (defaults to the \`WM_WORKSPACE\` env var) + */ +async deleteS3File(s3object: S3Object, workspace: string | undefined = undefined): Promise /** * Sign S3 objects to be used by anonymous users in public apps @@ -2075,9 +2113,10 @@ async duckdbConnectionSettings(s3_resource_path: string | undefined): Promise +async denoS3LightClientSettings(s3_resource_path: string | undefined, workspace: string | undefined = undefined): Promise /** * Load the content of a file stored in S3. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -2088,8 +2127,10 @@ async denoS3LightClientSettings(s3_resource_path: string | undefined): Promise +async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise /** * Load the content of a file stored in S3 as a stream. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -2099,8 +2140,10 @@ async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefi * // if the content is plain text, the blob can be read directly: * console.log(await fileContentBlob.text()); * \`\`\` + * + * @param workspace - Workspace to read from (defaults to the \`WM_WORKSPACE\` env var) */ -async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined): Promise +async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise /** * Persist a file to the S3 bucket. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -2110,8 +2153,22 @@ async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = * const fileContentAsUtf8Str = (await s3object.toArray()).toString('utf-8') * console.log(fileContentAsUtf8Str) * \`\`\` + * + * @param workspace - Workspace to write to (defaults to the \`WM_WORKSPACE\` env var) */ -async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined): Promise +async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined, workspace: string | undefined = undefined): Promise + +/** + * Permanently delete a file from S3 by key. + * + * \`\`\`typescript + * await wmill.deleteS3File({ s3: "path/to/file.txt" }) + * \`\`\` + * + * @param s3object - S3 object identifying the file to delete (must have \`s3\` set) + * @param workspace - Workspace to delete from (defaults to the \`WM_WORKSPACE\` env var) + */ +async deleteS3File(s3object: S3Object, workspace: string | undefined = undefined): Promise /** * Sign S3 objects to be used by anonymous users in public apps @@ -3277,9 +3334,10 @@ async duckdbConnectionSettings(s3_resource_path: string | undefined): Promise +async denoS3LightClientSettings(s3_resource_path: string | undefined, workspace: string | undefined = undefined): Promise /** * Load the content of a file stored in S3. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -3290,8 +3348,10 @@ async denoS3LightClientSettings(s3_resource_path: string | undefined): Promise +async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise /** * Load the content of a file stored in S3 as a stream. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -3301,8 +3361,10 @@ async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefi * // if the content is plain text, the blob can be read directly: * console.log(await fileContentBlob.text()); * \`\`\` + * + * @param workspace - Workspace to read from (defaults to the \`WM_WORKSPACE\` env var) */ -async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined): Promise +async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise /** * Persist a file to the S3 bucket. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -3312,8 +3374,22 @@ async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = * const fileContentAsUtf8Str = (await s3object.toArray()).toString('utf-8') * console.log(fileContentAsUtf8Str) * \`\`\` + * + * @param workspace - Workspace to write to (defaults to the \`WM_WORKSPACE\` env var) */ -async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined): Promise +async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined, workspace: string | undefined = undefined): Promise + +/** + * Permanently delete a file from S3 by key. + * + * \`\`\`typescript + * await wmill.deleteS3File({ s3: "path/to/file.txt" }) + * \`\`\` + * + * @param s3object - S3 object identifying the file to delete (must have \`s3\` set) + * @param workspace - Workspace to delete from (defaults to the \`WM_WORKSPACE\` env var) + */ +async deleteS3File(s3object: S3Object, workspace: string | undefined = undefined): Promise /** * Sign S3 objects to be used by anonymous users in public apps diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index fc8d0b4f28..03ae52b41b 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -1165,9 +1165,10 @@ async duckdbConnectionSettings(s3_resource_path: string | undefined): Promise +async denoS3LightClientSettings(s3_resource_path: string | undefined, workspace: string | undefined = undefined): Promise /** * Load the content of a file stored in S3. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -1178,8 +1179,10 @@ async denoS3LightClientSettings(s3_resource_path: string | undefined): Promise +async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise /** * Load the content of a file stored in S3 as a stream. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -1189,8 +1192,10 @@ async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefi * // if the content is plain text, the blob can be read directly: * console.log(await fileContentBlob.text()); * \`\`\` + * + * @param workspace - Workspace to read from (defaults to the \`WM_WORKSPACE\` env var) */ -async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined): Promise +async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise /** * Persist a file to the S3 bucket. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -1200,8 +1205,22 @@ async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = * const fileContentAsUtf8Str = (await s3object.toArray()).toString('utf-8') * console.log(fileContentAsUtf8Str) * \`\`\` + * + * @param workspace - Workspace to write to (defaults to the \`WM_WORKSPACE\` env var) */ -async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined): Promise +async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined, workspace: string | undefined = undefined): Promise + +/** + * Permanently delete a file from S3 by key. + * + * \`\`\`typescript + * await wmill.deleteS3File({ s3: "path/to/file.txt" }) + * \`\`\` + * + * @param s3object - S3 object identifying the file to delete (must have \`s3\` set) + * @param workspace - Workspace to delete from (defaults to the \`WM_WORKSPACE\` env var) + */ +async deleteS3File(s3object: S3Object, workspace: string | undefined = undefined): Promise /** * Sign S3 objects to be used by anonymous users in public apps diff --git a/system_prompts/auto-generated/script.md b/system_prompts/auto-generated/script.md index 1dbd19aee6..b7b97326a2 100644 --- a/system_prompts/auto-generated/script.md +++ b/system_prompts/auto-generated/script.md @@ -1641,9 +1641,10 @@ async duckdbConnectionSettings(s3_resource_path: string | undefined): Promise +async denoS3LightClientSettings(s3_resource_path: string | undefined, workspace: string | undefined = undefined): Promise /** * Load the content of a file stored in S3. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -1654,8 +1655,10 @@ async denoS3LightClientSettings(s3_resource_path: string | undefined): Promise +async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise /** * Load the content of a file stored in S3 as a stream. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -1665,8 +1668,10 @@ async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefi * // if the content is plain text, the blob can be read directly: * console.log(await fileContentBlob.text()); * ``` + * + * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var) */ -async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined): Promise +async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise /** * Persist a file to the S3 bucket. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -1676,8 +1681,22 @@ async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = * const fileContentAsUtf8Str = (await s3object.toArray()).toString('utf-8') * console.log(fileContentAsUtf8Str) * ``` + * + * @param workspace - Workspace to write to (defaults to the `WM_WORKSPACE` env var) */ -async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined): Promise +async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined, workspace: string | undefined = undefined): Promise + +/** + * Permanently delete a file from S3 by key. + * + * ```typescript + * await wmill.deleteS3File({ s3: "path/to/file.txt" }) + * ``` + * + * @param s3object - S3 object identifying the file to delete (must have `s3` set) + * @param workspace - Workspace to delete from (defaults to the `WM_WORKSPACE` env var) + */ +async deleteS3File(s3object: S3Object, workspace: string | undefined = undefined): Promise /** * Sign S3 objects to be used by anonymous users in public apps diff --git a/system_prompts/auto-generated/sdks/typescript.md b/system_prompts/auto-generated/sdks/typescript.md index 42c8a8e0ba..b2da413851 100644 --- a/system_prompts/auto-generated/sdks/typescript.md +++ b/system_prompts/auto-generated/sdks/typescript.md @@ -236,9 +236,10 @@ async duckdbConnectionSettings(s3_resource_path: string | undefined): Promise +async denoS3LightClientSettings(s3_resource_path: string | undefined, workspace: string | undefined = undefined): Promise /** * Load the content of a file stored in S3. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -249,8 +250,10 @@ async denoS3LightClientSettings(s3_resource_path: string | undefined): Promise +async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise /** * Load the content of a file stored in S3 as a stream. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -260,8 +263,10 @@ async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefi * // if the content is plain text, the blob can be read directly: * console.log(await fileContentBlob.text()); * ``` + * + * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var) */ -async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined): Promise +async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise /** * Persist a file to the S3 bucket. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -271,8 +276,22 @@ async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = * const fileContentAsUtf8Str = (await s3object.toArray()).toString('utf-8') * console.log(fileContentAsUtf8Str) * ``` + * + * @param workspace - Workspace to write to (defaults to the `WM_WORKSPACE` env var) */ -async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined): Promise +async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined, workspace: string | undefined = undefined): Promise + +/** + * Permanently delete a file from S3 by key. + * + * ```typescript + * await wmill.deleteS3File({ s3: "path/to/file.txt" }) + * ``` + * + * @param s3object - S3 object identifying the file to delete (must have `s3` set) + * @param workspace - Workspace to delete from (defaults to the `WM_WORKSPACE` env var) + */ +async deleteS3File(s3object: S3Object, workspace: string | undefined = undefined): Promise /** * Sign S3 objects to be used by anonymous users in public apps diff --git a/system_prompts/auto-generated/skills/write-script-bun/SKILL.md b/system_prompts/auto-generated/skills/write-script-bun/SKILL.md index 16682683cc..5e80f99795 100644 --- a/system_prompts/auto-generated/skills/write-script-bun/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-bun/SKILL.md @@ -391,9 +391,10 @@ async duckdbConnectionSettings(s3_resource_path: string | undefined): Promise +async denoS3LightClientSettings(s3_resource_path: string | undefined, workspace: string | undefined = undefined): Promise /** * Load the content of a file stored in S3. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -404,8 +405,10 @@ async denoS3LightClientSettings(s3_resource_path: string | undefined): Promise +async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise /** * Load the content of a file stored in S3 as a stream. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -415,8 +418,10 @@ async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefi * // if the content is plain text, the blob can be read directly: * console.log(await fileContentBlob.text()); * ``` + * + * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var) */ -async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined): Promise +async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise /** * Persist a file to the S3 bucket. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -426,8 +431,22 @@ async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = * const fileContentAsUtf8Str = (await s3object.toArray()).toString('utf-8') * console.log(fileContentAsUtf8Str) * ``` + * + * @param workspace - Workspace to write to (defaults to the `WM_WORKSPACE` env var) */ -async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined): Promise +async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined, workspace: string | undefined = undefined): Promise + +/** + * Permanently delete a file from S3 by key. + * + * ```typescript + * await wmill.deleteS3File({ s3: "path/to/file.txt" }) + * ``` + * + * @param s3object - S3 object identifying the file to delete (must have `s3` set) + * @param workspace - Workspace to delete from (defaults to the `WM_WORKSPACE` env var) + */ +async deleteS3File(s3object: S3Object, workspace: string | undefined = undefined): Promise /** * Sign S3 objects to be used by anonymous users in public apps diff --git a/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md b/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md index 2fc5913b46..89b1bc6f05 100644 --- a/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md @@ -389,9 +389,10 @@ async duckdbConnectionSettings(s3_resource_path: string | undefined): Promise +async denoS3LightClientSettings(s3_resource_path: string | undefined, workspace: string | undefined = undefined): Promise /** * Load the content of a file stored in S3. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -402,8 +403,10 @@ async denoS3LightClientSettings(s3_resource_path: string | undefined): Promise +async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise /** * Load the content of a file stored in S3 as a stream. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -413,8 +416,10 @@ async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefi * // if the content is plain text, the blob can be read directly: * console.log(await fileContentBlob.text()); * ``` + * + * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var) */ -async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined): Promise +async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise /** * Persist a file to the S3 bucket. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -424,8 +429,22 @@ async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = * const fileContentAsUtf8Str = (await s3object.toArray()).toString('utf-8') * console.log(fileContentAsUtf8Str) * ``` + * + * @param workspace - Workspace to write to (defaults to the `WM_WORKSPACE` env var) */ -async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined): Promise +async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined, workspace: string | undefined = undefined): Promise + +/** + * Permanently delete a file from S3 by key. + * + * ```typescript + * await wmill.deleteS3File({ s3: "path/to/file.txt" }) + * ``` + * + * @param s3object - S3 object identifying the file to delete (must have `s3` set) + * @param workspace - Workspace to delete from (defaults to the `WM_WORKSPACE` env var) + */ +async deleteS3File(s3object: S3Object, workspace: string | undefined = undefined): Promise /** * Sign S3 objects to be used by anonymous users in public apps diff --git a/system_prompts/auto-generated/skills/write-script-deno/SKILL.md b/system_prompts/auto-generated/skills/write-script-deno/SKILL.md index c627f1bbc8..179392981f 100644 --- a/system_prompts/auto-generated/skills/write-script-deno/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-deno/SKILL.md @@ -395,9 +395,10 @@ async duckdbConnectionSettings(s3_resource_path: string | undefined): Promise +async denoS3LightClientSettings(s3_resource_path: string | undefined, workspace: string | undefined = undefined): Promise /** * Load the content of a file stored in S3. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -408,8 +409,10 @@ async denoS3LightClientSettings(s3_resource_path: string | undefined): Promise +async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise /** * Load the content of a file stored in S3 as a stream. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -419,8 +422,10 @@ async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefi * // if the content is plain text, the blob can be read directly: * console.log(await fileContentBlob.text()); * ``` + * + * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var) */ -async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined): Promise +async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise /** * Persist a file to the S3 bucket. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -430,8 +435,22 @@ async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = * const fileContentAsUtf8Str = (await s3object.toArray()).toString('utf-8') * console.log(fileContentAsUtf8Str) * ``` + * + * @param workspace - Workspace to write to (defaults to the `WM_WORKSPACE` env var) */ -async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined): Promise +async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined, workspace: string | undefined = undefined): Promise + +/** + * Permanently delete a file from S3 by key. + * + * ```typescript + * await wmill.deleteS3File({ s3: "path/to/file.txt" }) + * ``` + * + * @param s3object - S3 object identifying the file to delete (must have `s3` set) + * @param workspace - Workspace to delete from (defaults to the `WM_WORKSPACE` env var) + */ +async deleteS3File(s3object: S3Object, workspace: string | undefined = undefined): Promise /** * Sign S3 objects to be used by anonymous users in public apps diff --git a/system_prompts/auto-generated/skills/write-script-nativets/SKILL.md b/system_prompts/auto-generated/skills/write-script-nativets/SKILL.md index 7c0ea92a17..42ad9448a8 100644 --- a/system_prompts/auto-generated/skills/write-script-nativets/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-nativets/SKILL.md @@ -355,9 +355,10 @@ async duckdbConnectionSettings(s3_resource_path: string | undefined): Promise +async denoS3LightClientSettings(s3_resource_path: string | undefined, workspace: string | undefined = undefined): Promise /** * Load the content of a file stored in S3. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -368,8 +369,10 @@ async denoS3LightClientSettings(s3_resource_path: string | undefined): Promise +async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise /** * Load the content of a file stored in S3 as a stream. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -379,8 +382,10 @@ async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefi * // if the content is plain text, the blob can be read directly: * console.log(await fileContentBlob.text()); * ``` + * + * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var) */ -async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined): Promise +async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise /** * Persist a file to the S3 bucket. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. @@ -390,8 +395,22 @@ async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = * const fileContentAsUtf8Str = (await s3object.toArray()).toString('utf-8') * console.log(fileContentAsUtf8Str) * ``` + * + * @param workspace - Workspace to write to (defaults to the `WM_WORKSPACE` env var) */ -async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined): Promise +async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined, workspace: string | undefined = undefined): Promise + +/** + * Permanently delete a file from S3 by key. + * + * ```typescript + * await wmill.deleteS3File({ s3: "path/to/file.txt" }) + * ``` + * + * @param s3object - S3 object identifying the file to delete (must have `s3` set) + * @param workspace - Workspace to delete from (defaults to the `WM_WORKSPACE` env var) + */ +async deleteS3File(s3object: S3Object, workspace: string | undefined = undefined): Promise /** * Sign S3 objects to be used by anonymous users in public apps diff --git a/typescript-client/build.jsr.sh b/typescript-client/build.jsr.sh index ee833f67a6..6dd8968b8f 100755 --- a/typescript-client/build.jsr.sh +++ b/typescript-client/build.jsr.sh @@ -15,6 +15,6 @@ cp "${script_dirpath}/sqlUtils.ts" "${script_dirpath}/src/" echo "" >> "${script_dirpath}/src/index.ts" echo 'export type { DenoS3LightClientSettings } from "./s3Types";' >> "${script_dirpath}/src/index.ts" echo "" >> "${script_dirpath}/src/index.ts" -echo 'export { type Base64, setClient, getVariable, setVariable, getResource, setResource, getResumeUrls, setState, setProgress, getProgress, getState, getIdToken, denoS3LightClientSettings, loadS3FileStream, loadS3File, writeS3File, signS3Objects, signS3Object, getPresignedS3PublicUrls, getPresignedS3PublicUrl, task, runScript, runScriptAsync, runScriptByPath, runScriptByHash, runScriptByPathAsync, runScriptByHashAsync, runFlow, runFlowAsync, waitJob, getRootJobId, setFlowUserState, getFlowUserState, usernameToEmail, requestInteractiveSlackApproval, Sql, requestInteractiveTeamsApproval, appendToResultStream, streamResult, datatable, ducklake, type DatatableSqlTemplateFunction, type SqlTemplateFunction, type S3Object, type S3ObjectRecord, type S3ObjectURI } from "./client";' >> "${script_dirpath}/src/index.ts" +echo 'export { type Base64, setClient, getVariable, setVariable, getResource, setResource, getResumeUrls, setState, setProgress, getProgress, getState, getIdToken, denoS3LightClientSettings, loadS3FileStream, loadS3File, writeS3File, deleteS3File, signS3Objects, signS3Object, getPresignedS3PublicUrls, getPresignedS3PublicUrl, task, runScript, runScriptAsync, runScriptByPath, runScriptByHash, runScriptByPathAsync, runScriptByHashAsync, runFlow, runFlowAsync, waitJob, getRootJobId, setFlowUserState, getFlowUserState, usernameToEmail, requestInteractiveSlackApproval, Sql, requestInteractiveTeamsApproval, appendToResultStream, streamResult, datatable, ducklake, type DatatableSqlTemplateFunction, type SqlTemplateFunction, type S3Object, type S3ObjectRecord, type S3ObjectURI } from "./client";' >> "${script_dirpath}/src/index.ts" diff --git a/typescript-client/build.sh b/typescript-client/build.sh index b270e966c3..beb44f448c 100755 --- a/typescript-client/build.sh +++ b/typescript-client/build.sh @@ -40,7 +40,7 @@ cp "${script_dirpath}/sqlUtils.ts" "${script_dirpath}/src/" echo "" >> "${script_dirpath}/src/index.ts" echo 'export type { DenoS3LightClientSettings } from "./s3Types";' >> "${script_dirpath}/src/index.ts" echo "" >> "${script_dirpath}/src/index.ts" -echo 'export { type Base64, setClient, getVariable, setVariable, getResource, setResource, getResumeUrls, setState, setProgress, getProgress, getState, getIdToken, denoS3LightClientSettings, loadS3FileStream, loadS3File, writeS3File, signS3Objects, signS3Object, getPresignedS3PublicUrls, getPresignedS3PublicUrl, task, taskScript, taskFlow, workflow, step, sleep, parallel, waitForApproval, type TaskOptions, WorkflowCtx, _workflowCtx, setWorkflowCtx, StepSuspend, runScript, runScriptAsync, runScriptByPath, runScriptByHash, runScriptByPathAsync, runScriptByHashAsync, runFlow, runFlowAsync, waitJob, getRootJobId, setFlowUserState, getFlowUserState, usernameToEmail, requestInteractiveSlackApproval, type Sql, requestInteractiveTeamsApproval, appendToResultStream, streamResult, datatable, ducklake, type DatatableSqlTemplateFunction, type SqlTemplateFunction, type S3Object, type S3ObjectRecord, type S3ObjectURI, commitKafkaOffsets } from "./client";' >> "${script_dirpath}/src/index.ts" +echo 'export { type Base64, setClient, getVariable, setVariable, getResource, setResource, getResumeUrls, setState, setProgress, getProgress, getState, getIdToken, denoS3LightClientSettings, loadS3FileStream, loadS3File, writeS3File, deleteS3File, signS3Objects, signS3Object, getPresignedS3PublicUrls, getPresignedS3PublicUrl, task, taskScript, taskFlow, workflow, step, sleep, parallel, waitForApproval, type TaskOptions, WorkflowCtx, _workflowCtx, setWorkflowCtx, StepSuspend, runScript, runScriptAsync, runScriptByPath, runScriptByHash, runScriptByPathAsync, runScriptByHashAsync, runFlow, runFlowAsync, waitJob, getRootJobId, setFlowUserState, getFlowUserState, usernameToEmail, requestInteractiveSlackApproval, type Sql, requestInteractiveTeamsApproval, appendToResultStream, streamResult, datatable, ducklake, type DatatableSqlTemplateFunction, type SqlTemplateFunction, type S3Object, type S3ObjectRecord, type S3ObjectURI, commitKafkaOffsets } from "./client";' >> "${script_dirpath}/src/index.ts" # Build default export by combining client utilities + services # This preserves backward compatibility for `import wmill from "windmill-client"` @@ -63,6 +63,7 @@ import { loadS3FileStream, loadS3File, writeS3File, + deleteS3File, signS3Objects, signS3Object, getPresignedS3PublicUrls, @@ -148,6 +149,7 @@ const wmill = { loadS3FileStream, loadS3File, writeS3File, + deleteS3File, signS3Objects, signS3Object, getPresignedS3PublicUrls, diff --git a/typescript-client/client.ts b/typescript-client/client.ts index ce40bd07e8..f388ff6457 100644 --- a/typescript-client/client.ts +++ b/typescript-client/client.ts @@ -805,14 +805,15 @@ export async function databaseUrlFromResource(path: string): Promise { /** * Get S3 client settings from a resource or workspace default * @param s3_resource_path - Path to S3 resource (uses workspace default if undefined) + * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var) * @returns S3 client configuration settings */ export async function denoS3LightClientSettings( - s3_resource_path: string | undefined + s3_resource_path: string | undefined, + workspace: string | undefined = undefined ): Promise { - const workspace = getWorkspace(); const s3Resource = await HelpersService.s3ResourceInfo({ - workspace: workspace, + workspace: workspace ?? getWorkspace(), requestBody: { s3_resource_path: parseResourceSyntax(s3_resource_path) ?? s3_resource_path, @@ -833,12 +834,19 @@ export async function denoS3LightClientSettings( * const text = new TextDecoder().decode(fileContentStream) * console.log(text); * ``` + * + * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var) */ export async function loadS3File( s3object: S3Object, - s3ResourcePath: string | undefined = undefined + s3ResourcePath: string | undefined = undefined, + workspace: string | undefined = undefined ): Promise { - const fileContentBlob = await loadS3FileStream(s3object, s3ResourcePath); + const fileContentBlob = await loadS3FileStream( + s3object, + s3ResourcePath, + workspace + ); if (fileContentBlob === undefined) { return undefined; } @@ -874,10 +882,13 @@ export async function loadS3File( * // if the content is plain text, the blob can be read directly: * console.log(await fileContentBlob.text()); * ``` + * + * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var) */ export async function loadS3FileStream( s3object: S3Object, - s3ResourcePath: string | undefined = undefined + s3ResourcePath: string | undefined = undefined, + workspace: string | undefined = undefined ): Promise { let s3Obj = s3object && parseS3Object(s3object); let params: Record = {}; @@ -889,12 +900,11 @@ export async function loadS3FileStream( params["storage"] = s3Obj.storage; } const queryParams = new URLSearchParams(params); + const w = workspace ?? getWorkspace(); // We use raw fetch here b/c OpenAPI generated client doesn't handle Blobs nicely const response = await fetch( - `${ - OpenAPI.BASE - }/w/${getWorkspace()}/job_helpers/download_s3_file?${queryParams}`, + `${OpenAPI.BASE}/w/${w}/job_helpers/download_s3_file?${queryParams}`, { method: "GET", headers: { @@ -922,13 +932,16 @@ export async function loadS3FileStream( * const fileContentAsUtf8Str = (await s3object.toArray()).toString('utf-8') * console.log(fileContentAsUtf8Str) * ``` + * + * @param workspace - Workspace to write to (defaults to the `WM_WORKSPACE` env var) */ export async function writeS3File( s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, - contentDisposition: string | undefined = undefined + contentDisposition: string | undefined = undefined, + workspace: string | undefined = undefined ): Promise { let fileContentBlob: Blob; if (typeof fileContent === "string") { @@ -942,7 +955,7 @@ export async function writeS3File( let s3Obj = s3object && parseS3Object(s3object); const response = await HelpersService.fileUpload({ - workspace: getWorkspace(), + workspace: workspace ?? getWorkspace(), fileKey: s3Obj?.s3, fileExtension: undefined, s3ResourcePath: s3ResourcePath, @@ -957,6 +970,31 @@ export async function writeS3File( }; } +/** + * Permanently delete a file from S3 by key. + * + * ```typescript + * await wmill.deleteS3File({ s3: "path/to/file.txt" }) + * ``` + * + * @param s3object - S3 object identifying the file to delete (must have `s3` set) + * @param workspace - Workspace to delete from (defaults to the `WM_WORKSPACE` env var) + */ +export async function deleteS3File( + s3object: S3Object, + workspace: string | undefined = undefined +): Promise { + const s3Obj = parseS3Object(s3object); + if (!s3Obj.s3) { + throw new Error("deleteS3File: s3 key is required"); + } + await HelpersService.deleteS3File({ + workspace: workspace ?? getWorkspace(), + fileKey: s3Obj.s3, + storage: s3Obj.storage, + }); +} + /** * Sign S3 objects to be used by anonymous users in public apps * @param s3objects s3 objects to sign From dcee8cc0d3dd71c3a12f1720e3ce4eb86cdacf4f Mon Sep 17 00:00:00 2001 From: Alexander Petric Date: Fri, 22 May 2026 17:01:15 -0400 Subject: [PATCH 37/71] feat(github-app): hide cloud-only UI on self-managed + admin assignment UI (#9299) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(github-app): hide cloud-only UI on self-managed + admin assignment UI Two related UX fixes for the GitHub App self-managed (GHES) integration: 1. On self-managed instances, the per-installation Export button and the "Import installation from other instance" section in the workspace UI both hide. Both round-trip a JWT carrying only {installation_id, account_id} with no github_base_url, so they would produce broken cloud-style installs on a self-managed instance. The previous Export attempt also failed with "No JWT token received from server" because self-managed installs store an empty JWT by design. 2. New "Workspace assignments" panel in instance settings (GhesAppSettings.svelte) that auto-discovers installations of the configured GHES App and lets the super-admin assign them to specific workspaces. Workspace users without GitHub permissions no longer need to install the App themselves — the admin provisions the link from instance settings. Admin-provisioned installs show a "Provisioned by admin" badge in the workspace UI and can only be removed by the super-admin from instance settings. Backend support is in the EE companion PR windmill-labs/windmill-ee-private#588. Co-Authored-By: Claude Opus 4.7 (1M context) * chore: update ee-repo-ref to da5189cf69a453de3855057f41be0d84e5910707 This commit updates the EE repository reference after PR #588 was merged in windmill-ee-private. Previous ee-repo-ref: d959b83ce413ad531e9cc28e0f8199cdecb73a31 New ee-repo-ref: da5189cf69a453de3855057f41be0d84e5910707 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: windmill-internal-app[bot] --- ...c27f3c29d1b3877c1f2e7c206f0e36ef18701.json | 40 +++ ...89353bef40f1cccf31b7775d0e9a800425f4d.json | 23 ++ ...31637bcb22c6e713a21bdb83204de9cf383d7.json | 22 ++ ...4da1977030cf2db9dadd372188902bb23062f.json | 34 +++ backend/ee-repo-ref.txt | 2 +- backend/windmill-api/openapi.yaml | 105 ++++++++ .../components/GitHubAppIntegration.svelte | 104 +++++--- .../instanceSettings/GhesAppSettings.svelte | 243 ++++++++++++++++++ frontend/src/lib/githubApp.ts | 13 +- 9 files changed, 542 insertions(+), 44 deletions(-) create mode 100644 backend/.sqlx/query-14bc9dd1d02a3d121297509beacc27f3c29d1b3877c1f2e7c206f0e36ef18701.json create mode 100644 backend/.sqlx/query-2f166b5575a614b028c3130fc5089353bef40f1cccf31b7775d0e9a800425f4d.json create mode 100644 backend/.sqlx/query-3c42a56d0ffe39ad217f2ee603431637bcb22c6e713a21bdb83204de9cf383d7.json create mode 100644 backend/.sqlx/query-f5e98ff83301b89f33e4454ae944da1977030cf2db9dadd372188902bb23062f.json diff --git a/backend/.sqlx/query-14bc9dd1d02a3d121297509beacc27f3c29d1b3877c1f2e7c206f0e36ef18701.json b/backend/.sqlx/query-14bc9dd1d02a3d121297509beacc27f3c29d1b3877c1f2e7c206f0e36ef18701.json new file mode 100644 index 0000000000..6310017f18 --- /dev/null +++ b/backend/.sqlx/query-14bc9dd1d02a3d121297509beacc27f3c29d1b3877c1f2e7c206f0e36ef18701.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n (elem->>'installation_id')::bigint as installation_id,\n elem->>'account_id' as account_id,\n elem->>'github_base_url' as github_base_url,\n COALESCE((elem->>'provisioned_by_admin')::bool, false) as \"provisioned_by_admin!\"\n FROM workspace_settings,\n LATERAL jsonb_array_elements(git_app_installations) AS elem\n WHERE workspace_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "installation_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "account_id", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "github_base_url", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "provisioned_by_admin!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null, + null, + null, + null + ] + }, + "hash": "14bc9dd1d02a3d121297509beacc27f3c29d1b3877c1f2e7c206f0e36ef18701" +} diff --git a/backend/.sqlx/query-2f166b5575a614b028c3130fc5089353bef40f1cccf31b7775d0e9a800425f4d.json b/backend/.sqlx/query-2f166b5575a614b028c3130fc5089353bef40f1cccf31b7775d0e9a800425f4d.json new file mode 100644 index 0000000000..5964de4111 --- /dev/null +++ b/backend/.sqlx/query-2f166b5575a614b028c3130fc5089353bef40f1cccf31b7775d0e9a800425f4d.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT COALESCE((elem->>'provisioned_by_admin')::bool, false) as \"is_admin!\"\n FROM workspace_settings,\n LATERAL jsonb_array_elements(git_app_installations) AS elem\n WHERE workspace_id = $1\n AND (elem->>'installation_id')::bigint = $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "is_admin!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "2f166b5575a614b028c3130fc5089353bef40f1cccf31b7775d0e9a800425f4d" +} diff --git a/backend/.sqlx/query-3c42a56d0ffe39ad217f2ee603431637bcb22c6e713a21bdb83204de9cf383d7.json b/backend/.sqlx/query-3c42a56d0ffe39ad217f2ee603431637bcb22c6e713a21bdb83204de9cf383d7.json new file mode 100644 index 0000000000..83a2c7bfc6 --- /dev/null +++ b/backend/.sqlx/query-3c42a56d0ffe39ad217f2ee603431637bcb22c6e713a21bdb83204de9cf383d7.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM workspace_settings WHERE workspace_id = $1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "3c42a56d0ffe39ad217f2ee603431637bcb22c6e713a21bdb83204de9cf383d7" +} diff --git a/backend/.sqlx/query-f5e98ff83301b89f33e4454ae944da1977030cf2db9dadd372188902bb23062f.json b/backend/.sqlx/query-f5e98ff83301b89f33e4454ae944da1977030cf2db9dadd372188902bb23062f.json new file mode 100644 index 0000000000..ac592bafbe --- /dev/null +++ b/backend/.sqlx/query-f5e98ff83301b89f33e4454ae944da1977030cf2db9dadd372188902bb23062f.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n workspace_id,\n (elem->>'installation_id')::bigint as \"installation_id!\",\n COALESCE((elem->>'provisioned_by_admin')::bool, false) as \"provisioned_by_admin!\"\n FROM workspace_settings,\n LATERAL jsonb_array_elements(git_app_installations) AS elem\n WHERE (elem->>'installation_id')::bigint = ANY($1)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "installation_id!", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "provisioned_by_admin!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + null, + null + ] + }, + "hash": "f5e98ff83301b89f33e4454ae944da1977030cf2db9dadd372188902bb23062f" +} diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 1a1930d96e..1e357752b5 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -14315067c083d3361512de621b12e41dbe3b017d +da5189cf69a453de3855057f41be0d84e5910707 diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index e8b1459e20..e2c8cfad4d 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -2574,6 +2574,104 @@ paths: - app_slug - client_id + /github_app/ghes/discover: + get: + summary: Discover GHES App installations + description: | + Lists every installation the configured self-managed GitHub App can see, + annotated with the workspaces in this Windmill instance the + installation is currently assigned to. Super-admin only. + operationId: discoverGhesInstallations + tags: + - Git Sync + responses: + "200": + description: Discovered installations + content: + application/json: + schema: + type: array + items: + type: object + required: + - installation_id + - account_id + - assigned_workspaces + properties: + installation_id: + type: integer + format: int64 + account_id: + type: string + description: GitHub login of the installation's account (org or user) + assigned_workspaces: + type: array + items: + type: object + required: + - workspace_id + - provisioned_by_admin + properties: + workspace_id: + type: string + provisioned_by_admin: + type: boolean + + /github_app/ghes/assign: + post: + summary: Assign GHES installation to a workspace + description: | + Assigns a discovered GHES App installation to a workspace. The resulting + installation is marked as admin-provisioned, so workspace admins cannot + remove it. Super-admin only. + operationId: assignGhesInstallation + tags: + - Git Sync + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - workspace_id + - installation_id + properties: + workspace_id: + type: string + installation_id: + type: integer + format: int64 + responses: + "200": + description: Installation assigned + + /github_app/ghes/assign/{workspace_id}/{installation_id}: + delete: + summary: Unassign GHES installation from a workspace + description: | + Removes an installation (admin-provisioned or otherwise) from a + workspace. Super-admin only. Does not affect the installation on the + GitHub side. + operationId: unassignGhesInstallation + tags: + - Git Sync + parameters: + - name: workspace_id + in: path + required: true + schema: + type: string + - name: installation_id + in: path + required: true + schema: + type: integer + format: int64 + responses: + "200": + description: Installation unassigned + /users/accept_invite: post: summary: accept invite to workspace @@ -27823,6 +27921,13 @@ components: error: type: string description: Error message if token retrieval failed + github_base_url: + type: string + nullable: true + description: Set for self-managed (GHES) installs. Cloud installs omit this field. + provisioned_by_admin: + type: boolean + description: True when the installation was assigned by the instance super-admin from instance settings. Workspace admins cannot remove these. required: - installation_id - account_id diff --git a/frontend/src/lib/components/GitHubAppIntegration.svelte b/frontend/src/lib/components/GitHubAppIntegration.svelte index 7b35dbf84c..7aa136b072 100644 --- a/frontend/src/lib/components/GitHubAppIntegration.svelte +++ b/frontend/src/lib/components/GitHubAppIntegration.svelte @@ -295,13 +295,21 @@ {#each githubState.workspaceGithubInstallations as installation (`current-${installation.installation_id}-${installation.workspace_id}`)} -
+
{#if installation.error} {/if} {installation.account_id} + {#if installation.provisioned_by_admin} + + Provisioned by admin + + {/if}
@@ -310,34 +318,41 @@ {#if installation.error} - Token error + Token error {:else} {installation.repositories.length} repos {/if}
- - + {#if !installation.github_base_url} + + {/if} + {#if !installation.provisioned_by_admin} + + {/if}
@@ -381,7 +396,10 @@ {#if installation.error} - Token error + Token error {:else} {installation.repositories.length} repos {/if} @@ -414,26 +432,28 @@
-
-

Import installation from other instance:

-
- - +
+ + +
-
+ {/if} {/snippet} diff --git a/frontend/src/lib/components/instanceSettings/GhesAppSettings.svelte b/frontend/src/lib/components/instanceSettings/GhesAppSettings.svelte index e94e56be0c..86e3038fdf 100644 --- a/frontend/src/lib/components/instanceSettings/GhesAppSettings.svelte +++ b/frontend/src/lib/components/instanceSettings/GhesAppSettings.svelte @@ -1,6 +1,12 @@
@@ -185,4 +311,121 @@ bind:value={$values['github_enterprise_app'].private_key} >
+ + {#if assignmentsReady} +
+
+

Workspace assignments

+ +
+

+ Assign installations of the configured GitHub App to specific workspaces so workspace users + don't need GitHub permissions to set up sync. Click Refresh to load installations + the App can see (save the config above first if you haven't). +

+ + {#if discoveryError} +

{discoveryError}

+ {:else if loadingDiscovery && discovered.length === 0} +
+ {:else if discovered.length === 0} +

+ The configured GitHub App has no installations yet. Install it on a GitHub account, then + click Refresh. +

+ {:else} + + + + + + + + + + + {#each discovered as install (install.installation_id)} + + + + + + + {/each} + +
+ GitHub account + + The GitHub organization or user the App is installed on (e.g. + windmill-labs). A GitHub App installation is always scoped to exactly + one account. + + Installation IDAssigned to
{install.account_id}{install.installation_id} + {#if install.assigned_workspaces.length === 0} + + {:else} +
+ {#each install.assigned_workspaces as assignment (assignment.workspace_id)} + + {assignment.workspace_id} + + + {/each} +
+ {/if} +
+
+
+
+ {/if} +
+ {/if} diff --git a/frontend/src/lib/githubApp.ts b/frontend/src/lib/githubApp.ts index be82fb3940..f60b1f8538 100644 --- a/frontend/src/lib/githubApp.ts +++ b/frontend/src/lib/githubApp.ts @@ -16,6 +16,13 @@ export interface GitHubAppState { installationCheckInterval: number | undefined isCheckingInstallation: boolean importJwt: string + /** + * True when the instance has a self-managed (GHES) GitHub App configured. + * Used to hide cloud-only UI like the Export/Import buttons, since those + * JWTs carry no `github_base_url` and would round-trip into broken + * github.com-pointed installs. + */ + isGhesSelfManaged: boolean } export interface GitHubRepository { @@ -99,7 +106,8 @@ export function createGitHubAppState(): GitHubAppState { githubInstallationUrl: undefined, installationCheckInterval: undefined, isCheckingInstallation: false, - importJwt: '' + importJwt: '', + isGhesSelfManaged: false } } @@ -137,6 +145,7 @@ export async function loadGithubInstallations( try { const ghesConfig: GetGhesConfigResponse = await GitSyncService.getGhesConfig() if (ghesConfig?.base_url && ghesConfig?.app_slug) { + state.isGhesSelfManaged = true const ghesBaseUrl = ghesConfig.base_url.replace(/\/$/, '') // GHES (self-hosted) uses /github-apps/, github.com and GHE Cloud (*.ghe.com) use /apps/ const hostname = new URL(ghesBaseUrl).hostname @@ -149,10 +158,12 @@ export async function loadGithubInstallations( : ghesConfig.app_slug state.githubInstallationUrl = `${ghesBaseUrl}/${appsPath}/${appPath}/installations/new?state=${stateParam}` } else { + state.isGhesSelfManaged = false state.githubInstallationUrl = `https://github.com/apps/windmill-sync-helper/installations/new?state=${stateParam}` } } catch { // No GHES config — use default github.com URL + state.isGhesSelfManaged = false state.githubInstallationUrl = `https://github.com/apps/windmill-sync-helper/installations/new?state=${stateParam}` } } catch (err) { From 7b11ebe5f5deb0e6c78c51dfdb54fc3c2f77d393 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 22 May 2026 21:35:01 +0000 Subject: [PATCH 38/71] chore(main): release 1.707.0 (#9285) * chore(main): release 1.707.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- CHANGELOG.md | 19 ++ backend/Cargo.lock | 164 +++++++++--------- backend/Cargo.toml | 4 +- .../parsers/windmill-parser-wasm/Cargo.lock | 48 ++--- .../parsers/windmill-parser-wasm/Cargo.toml | 2 +- backend/windmill-api/openapi.yaml | 2 +- benchmarks/lib.ts | 2 +- cli/src/main.ts | 2 +- frontend/package-lock.json | 54 +++++- frontend/package.json | 2 +- lsp/Pipfile | 2 +- openflow.openapi.yaml | 2 +- .../WindmillClient/WindmillClient.psd1 | 2 +- python-client/wmill/pyproject.toml | 2 +- typescript-client/jsr.json | 2 +- typescript-client/package.json | 2 +- version.txt | 2 +- 17 files changed, 189 insertions(+), 124 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5766e7cd05..38bcdbb0f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## [1.707.0](https://github.com/windmill-labs/windmill/compare/v1.706.1...v1.707.0) (2026-05-22) + + +### Features + +* add wmill job rerun subcommand ([#9275](https://github.com/windmill-labs/windmill/issues/9275)) ([e0ffea2](https://github.com/windmill-labs/windmill/commit/e0ffea2deb5acf30815edd3669f4fc4c818b6e19)) +* **github-app:** hide cloud-only UI on self-managed + admin assignment UI ([#9299](https://github.com/windmill-labs/windmill/issues/9299)) ([dcee8cc](https://github.com/windmill-labs/windmill/commit/dcee8cc0d3dd71c3a12f1720e3ce4eb86cdacf4f)) +* **typescript-client:** add deleteS3File + optional workspace arg on S3 helpers ([#9300](https://github.com/windmill-labs/windmill/issues/9300)) ([daab561](https://github.com/windmill-labs/windmill/commit/daab561ec0763468d93e42e8f7f0796dc77be74d)) + + +### Bug Fixes + +* **auth:** tighten token-owner fallback for unscoped tokens (WIN-1978) ([#9293](https://github.com/windmill-labs/windmill/issues/9293)) ([7003998](https://github.com/windmill-labs/windmill/commit/7003998a575d76c272c6abd0789a1d1f7b722076)) +* **cli:** wmill sync pull updates wmill-lock.yaml for raw apps ([#9289](https://github.com/windmill-labs/windmill/issues/9289)) ([486e5f9](https://github.com/windmill-labs/windmill/commit/486e5f947b1649c17d32e3b214c50d4be701a4e8)) +* flow recording teardown crash + rename package to @windmill-labs/components ([#9288](https://github.com/windmill-labs/windmill/issues/9288)) ([13a2fae](https://github.com/windmill-labs/windmill/commit/13a2fae745ba4862006db5ee0811475c1d27fd1d)) +* **flows:** restore Variables and Resources in flow editor prop picker ([#9290](https://github.com/windmill-labs/windmill/issues/9290)) ([5566c7b](https://github.com/windmill-labs/windmill/commit/5566c7b3ff2d5a6b15cb9187aa15ce1c7245b3fb)) +* **ResourceEditor:** don't reset state when `selected` reverts to undefined ([#9295](https://github.com/windmill-labs/windmill/issues/9295)) ([1f2d2c1](https://github.com/windmill-labs/windmill/commit/1f2d2c11493db20b87615d41c21e5e1c35564739)) +* **secret-backend:** pass DB to Vault migrations + show failure details ([#9292](https://github.com/windmill-labs/windmill/issues/9292)) ([ace2291](https://github.com/windmill-labs/windmill/commit/ace22910c40585a6a2c9abd0c46f7e5e0214e78e)) + ## [1.706.1](https://github.com/windmill-labs/windmill/compare/v1.706.0...v1.706.1) (2026-05-22) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index bcd0c4b860..878b87ccfa 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -727,9 +727,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-config" @@ -1846,9 +1846,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" dependencies = [ "allocator-api2", ] @@ -13788,7 +13788,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "async-nats", @@ -13869,7 +13869,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.706.1" +version = "1.707.0" dependencies = [ "async-stream", "async-trait", @@ -13901,7 +13901,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.706.1" +version = "1.707.0" dependencies = [ "axum 0.8.9", "chrono", @@ -13914,7 +13914,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "argon2", @@ -14057,7 +14057,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.706.1" +version = "1.707.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14080,7 +14080,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.706.1" +version = "1.707.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14093,7 +14093,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14119,7 +14119,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.706.1" +version = "1.707.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -14129,7 +14129,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.706.1" +version = "1.707.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14146,7 +14146,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.706.1" +version = "1.707.0" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -14168,7 +14168,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14191,7 +14191,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.706.1" +version = "1.707.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14207,7 +14207,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.706.1" +version = "1.707.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14228,7 +14228,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.706.1" +version = "1.707.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14249,7 +14249,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.706.1" +version = "1.707.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14263,7 +14263,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "async-nats", @@ -14295,7 +14295,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14320,7 +14320,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.706.1" +version = "1.707.0" dependencies = [ "axum 0.8.9", "flate2", @@ -14338,7 +14338,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14360,7 +14360,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.706.1" +version = "1.707.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14380,7 +14380,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.706.1" +version = "1.707.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14410,7 +14410,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14438,7 +14438,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.706.1" +version = "1.707.0" dependencies = [ "lazy_static", "serde", @@ -14450,7 +14450,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.706.1" +version = "1.707.0" dependencies = [ "argon2", "axum 0.8.9", @@ -14475,7 +14475,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.706.1" +version = "1.707.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14489,7 +14489,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.706.1" +version = "1.707.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14522,7 +14522,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.706.1" +version = "1.707.0" dependencies = [ "chrono", "lazy_static", @@ -14536,7 +14536,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14555,7 +14555,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.706.1" +version = "1.707.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -14656,7 +14656,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.706.1" +version = "1.707.0" dependencies = [ "chrono", "itertools 0.14.0", @@ -14675,7 +14675,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.706.1" +version = "1.707.0" dependencies = [ "regex", "serde", @@ -14690,7 +14690,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -14714,7 +14714,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "futures", @@ -14731,7 +14731,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.706.1" +version = "1.707.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -14747,7 +14747,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "async-trait", @@ -14768,7 +14768,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "async-trait", @@ -14799,7 +14799,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "arc-swap", @@ -14824,7 +14824,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "async-stream", @@ -14858,7 +14858,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "futures", @@ -14876,7 +14876,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.706.1" +version = "1.707.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -14885,7 +14885,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "lazy_static", @@ -14897,7 +14897,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "serde_json", @@ -14909,7 +14909,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "gosyn", @@ -14921,7 +14921,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "lazy_static", @@ -14933,7 +14933,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "serde_json", @@ -14945,7 +14945,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "nu-parser", @@ -14956,7 +14956,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14967,7 +14967,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14979,7 +14979,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "rustpython-ast", @@ -14990,7 +14990,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "async-recursion", @@ -15012,7 +15012,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "serde_json", @@ -15024,7 +15024,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "lazy_static", @@ -15038,7 +15038,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -15055,7 +15055,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "lazy_static", @@ -15068,7 +15068,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "serde", @@ -15080,7 +15080,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "lazy_static", @@ -15098,7 +15098,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -15114,7 +15114,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "rustpython-ast", @@ -15130,7 +15130,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "serde", @@ -15141,7 +15141,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "async-recursion", @@ -15178,7 +15178,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "const_format", @@ -15216,7 +15216,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.706.1" +version = "1.707.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -15227,7 +15227,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "async-recursion", @@ -15257,7 +15257,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "async-trait", @@ -15281,7 +15281,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "async-trait", @@ -15314,7 +15314,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "async-trait", @@ -15347,7 +15347,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "async-trait", @@ -15367,7 +15367,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "async-trait", @@ -15401,7 +15401,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "async-trait", @@ -15437,7 +15437,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "async-trait", @@ -15460,7 +15460,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "async-trait", @@ -15484,7 +15484,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "async-nats", @@ -15508,7 +15508,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "async-trait", @@ -15543,7 +15543,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "async-trait", @@ -15571,7 +15571,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "async-trait", @@ -15594,7 +15594,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "bitflags 2.11.1", @@ -15613,7 +15613,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "async-once-cell", @@ -15723,7 +15723,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.706.1" +version = "1.707.0" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 2e691b6a60..8f9cb3b5b4 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.706.1" +version = "1.707.0" authors.workspace = true edition.workspace = true @@ -87,7 +87,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"] [workspace.package] -version = "1.706.1" +version = "1.707.0" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index af04b8e170..bf53c31bc8 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6183,7 +6183,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.706.1" +version = "1.707.0" dependencies = [ "aho-corasick", "anyhow", @@ -6263,7 +6263,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.706.1" +version = "1.707.0" dependencies = [ "proc-macro2", "quote", @@ -6275,7 +6275,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.706.1" +version = "1.707.0" dependencies = [ "convert_case", "serde", @@ -6284,7 +6284,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "lazy_static", @@ -6296,7 +6296,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "serde_json", @@ -6308,7 +6308,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "gosyn", @@ -6320,7 +6320,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "lazy_static", @@ -6332,7 +6332,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "serde_json", @@ -6344,7 +6344,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "nu-parser", @@ -6355,7 +6355,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6366,7 +6366,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6389,7 +6389,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "async-recursion", @@ -6411,7 +6411,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "serde_json", @@ -6423,7 +6423,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "lazy_static", @@ -6437,7 +6437,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "convert_case", @@ -6454,7 +6454,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "lazy_static", @@ -6467,7 +6467,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "serde", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "lazy_static", @@ -6497,7 +6497,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6513,7 +6513,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6529,7 +6529,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6561,7 +6561,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "serde", @@ -6572,7 +6572,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.706.1" +version = "1.707.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index ca8ecfb04d..ebf70501d9 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.toml +++ b/backend/parsers/windmill-parser-wasm/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" members = ["."] [workspace.package] -version = "1.706.1" +version = "1.707.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index e2c8cfad4d..907e83ac37 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.706.1 + version: 1.707.0 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 57355c67cc..928ef837fe 100644 --- a/benchmarks/lib.ts +++ b/benchmarks/lib.ts @@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts"; import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts"; import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts"; -export const VERSION = "v1.706.1"; +export const VERSION = "v1.707.0"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/src/main.ts b/cli/src/main.ts index 170d9e318f..0bada07885 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -87,7 +87,7 @@ export { token, }; -export const VERSION = "1.706.1"; +export const VERSION = "1.707.0"; // Re-exported from constants.ts to maintain backwards compatibility export { WM_FORK_PREFIX } from "./core/constants.ts"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 991c61a1a0..5e54e9c5f4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.706.1", + "version": "1.707.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.706.1", + "version": "1.707.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { @@ -846,6 +846,7 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -857,6 +858,7 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -867,6 +869,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1356,6 +1359,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1504,6 +1508,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1520,6 +1525,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1536,6 +1542,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1552,6 +1559,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1568,6 +1576,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1584,6 +1593,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1600,6 +1610,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1616,6 +1627,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1632,6 +1644,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1648,6 +1661,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1664,6 +1678,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1680,6 +1695,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1696,6 +1712,7 @@ "cpu": [ "wasm32" ], + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1714,6 +1731,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1730,6 +1748,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2035,6 +2054,7 @@ "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -6810,7 +6830,7 @@ "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "devOptional": true, + "dev": true, "license": "MIT", "bin": { "jiti": "bin/jiti.js" @@ -7309,6 +7329,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7329,6 +7350,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7349,6 +7371,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7369,6 +7392,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7389,6 +7413,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7409,6 +7434,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7429,6 +7455,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7449,6 +7476,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7469,6 +7497,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7489,6 +7518,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7509,6 +7539,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -12077,6 +12108,21 @@ } } }, + "node_modules/svelte-check/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/svelte-eslint-parser": { "version": "0.43.0", "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.43.0.tgz", @@ -12807,7 +12853,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/frontend/package.json b/frontend/package.json index 7fe739d254..5ff9c72b97 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.706.1", + "version": "1.707.0", "scripts": { "dev": "vite dev", "build": "vite build", diff --git a/lsp/Pipfile b/lsp/Pipfile index 9379176973..692b153c68 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.706.1" +wmill = ">=1.707.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 5d5e9eca9c..1204a2efc9 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.706.1 + version: 1.707.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index 2c9cda4590..9db4909902 100644 --- a/powershell-client/WindmillClient/WindmillClient.psd1 +++ b/powershell-client/WindmillClient/WindmillClient.psd1 @@ -12,7 +12,7 @@ RootModule = 'WindmillClient.psm1' # Version number of this module. - ModuleVersion = '1.706.1' + ModuleVersion = '1.707.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index 931a5f9a53..fd7c56eafd 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.706.1" +version = "1.707.0" description = "A client library for accessing Windmill server wrapping the Windmill client API" license = "Apache-2.0" homepage = "https://windmill.dev" diff --git a/typescript-client/jsr.json b/typescript-client/jsr.json index 66ecb75f68..e0af5d1079 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.706.1", + "version": "1.707.0", "exports": "./src/index.ts", "publish": { "exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts"] diff --git a/typescript-client/package.json b/typescript-client/package.json index e022ff7056..074cc88860 100644 --- a/typescript-client/package.json +++ b/typescript-client/package.json @@ -1,7 +1,7 @@ { "name": "windmill-client", "description": "Windmill SDK client for browsers and Node.js", - "version": "1.706.1", + "version": "1.707.0", "author": "Ruben Fiszel", "license": "Apache 2.0", "sideEffects": false, diff --git a/version.txt b/version.txt index b5570ce111..28c8ca4e2c 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.706.1 +1.707.0 From de2e243313ee34348675dec600cb412b475d1b4b Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sun, 24 May 2026 23:41:18 +0000 Subject: [PATCH 39/71] feat(queue): per-workspace fairness cap on the shared cloud worker pool (#9303) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(queue): cloud-only per-workspace fairness cap on the shared worker pool On `app.windmill.dev` the cluster runs a single default worker group, so a single workspace flooding the queue can degrade quality of service for everyone else. This adds an opt-in mechanism that caps any single workspace at a configurable share of the shared worker pool when it has been dominating cluster activity for more than a configurable window. Detection signal counts both currently-running jobs and jobs completed in the rolling window, so it catches workspaces hogging slots with long jobs **and** workspaces spamming many tiny jobs (where no individual job's started_at is old, but throughput share dominates). Refresh is coordinated cluster-wide via a single UPDATE on `background_task_state`: the `WHERE updated_at < now() - interval` predicate combined with row-level locking means only one process per refresh cycle actually runs the aggregation, regardless of fleet size. Every other process gets the freshly written value in the same round trip via `UNION ALL ... LIMIT 1`. Heavy aggregation rate stays at ~0.2-0.5 qps for the whole cluster. Pull queries are split: the existing query string and its bind shape stay bit-identical to today, so the planner keeps using the same indexes when fairness is off or no workspace is currently capped. A separate `WORKER_PULL_QUERIES_FAIRNESS` adds `AND workspace_id <> ALL($2::text[])` and is only materialized while the feature is enabled. Hard-gated to `CLOUD_HOSTED=true` + BASE_URL host == app.windmill.dev at three layers: frontend `cloudonly: true`, API setter rejection in `set_global_setting_internal`, runtime check in `fairness_active`. Settings are exposed under Jobs in the instance-settings UI; defaults are off so the change is a no-op for self-hosted. Two-pass pull guarantees no worker idling: if every queued job belongs to a capped workspace, the second pass uses the unmodified pull queries. Cap re-asserts on the next refresh. Fixes WIN-1982 * fix(queue): address CI review findings on workspace fairness Six fixes from the four-reviewer cross-check on #9303: 1. **Aggregation evaluation (Codex P1).** The previous `INSERT ... ON CONFLICT DO UPDATE WHERE updated_at < ...` had the heavy `v2_job_queue ∪ v2_job_completed` aggregation inlined into `VALUES`, which Postgres evaluates for every contender to build the proposed row — losing the "one heavy aggregation per cycle cluster-wide" property the design advertises. Split into three small statements: (a) cheap claim with constant `VALUES`, (b) winner-only `UPDATE ... SET value = jsonb_build_object('overloaded', )` (Postgres only evaluates `SET` per row matching `WHERE`, so losers never compute the aggregation), (c) read for everyone. Heavy query now truly runs ~0.2-0.5 qps cluster-wide regardless of fleet size. 2. **Numeric setting wraparound (cubic P1).** `u64 as u32` and downstream `u32 as i32` could silently flip sign and feed `make_interval(secs => -N)`, making `now() - interval` a future timestamp and disabling the completed-jobs half of the activity signal. Clamp `duration_secs` to [1, 86400] and `min_total_jobs` to [0, u32::MAX] before storing. 3. **`/instance_config` bypass (cubic/Claude/Codex P2).** Bulk config endpoint sidestepped `set_global_setting_internal`'s gate; a self-hosted superadmin could persist `workspace_fairness_*` rows via the bulk path. Mirror the per-key check in `set_instance_config` upsert flow. 4. **DB error coerced to false (Claude P2).** `load_workspace_fairness_enabled` collapsed `Err(_)` to `false` and unconditionally swapped the atomic — a transient DB blip during notify-event propagation toggled the feature off cluster-wide (and triggered a `store_pull_query` rebuild precisely when load is highest). Now propagates the error so the atomic stays at its prior value. 5. **Refresh failure cooldown (Claude P2).** Storing `0` removed the rate limit entirely; every subsequent pull spawned a new refresh task. Leave `LAST_REFRESH_MICROS` at `now_us` (already written by the CAS) so the natural interval acts as the cooldown. 6. **Visibility + duplication (Pi P2).** Mark `make_pull_query_fairness` as `pub(crate)`. Move the duplicated `BASE_URL host == app.windmill.dev` parser into `windmill-common::worker::is_cloud_production_host` and share it between the API setter and the runtime path. Verified locally: - `POST /api/settings/global/workspace_fairness_enabled` → 400 (per-key gate) - `PUT /api/settings/instance_config` with fairness key → 400 (bulk gate) - `cargo check --workspace --features=private,enterprise,quickjs` — clean Refs WIN-1982. * fix(queue): second round of CI review nits on workspace fairness Three issues raised by the Codex/Claude re-review of commit 0b38ff2: 1. Non-cloud deletes were rejected (Codex P2). The cloud gate ran before the Null / empty-string deletion branches in both `set_global_setting_internal` and the bulk `set_instance_config`. A self-hosted instance that inherited stale `workspace_fairness_*` rows from a cloned cloud DB couldn't clear them through the API — the rows stayed in `global_settings` and continued to show up in the YAML export. Now the gate only blocks upserts; Null / empty-string deletes pass through on any host. 2. Deleted numeric knobs kept stale runtime values (Codex P2). When a cloud admin cleared `workspace_fairness_max_percent`, `..._duration_secs`, or `..._min_total_jobs`, the notify-event fired but the numeric loaders ignored `Ok(None)` and left the previous in-memory value pinned until process restart. Loaders now distinguish three outcomes: - `Err(_)`: transient — leave atomic alone (preserves the previous-round fix). - `Ok(None)` / `Ok(Some(invalid))`: reset to the documented default. - `Ok(Some(valid))`: clamp and store. Defaults are extracted to `WORKSPACE_FAIRNESS_*_DEFAULT` constants kept in sync with the `AtomicU32::new(...)` initialisers in `windmill-common/src/worker.rs`. 3. `fairness_active` was `pub` with no cross-crate caller (Claude nit). Tightened to module-private. Verified locally on this non-cloud instance: POST .../workspace_fairness_enabled body=null → 200 (delete passes) POST .../workspace_fairness_enabled body=true → 400 (set blocked) PUT .../instance_config {} → 200 (no-op passes) PUT .../instance_config with fairness key → 400 (bulk set blocked) Skipped the partial index on `v2_job_queue WHERE running = true` that Claude flagged as a residual nit — queue stays under 50k rows per the operator's measurement, so the seq-scan cost (~10 ms × 0.5 qps = ~0.5% of a DB core) is well below the noise floor and the index isn't worth the maintenance cost on job transitions. Refs WIN-1982. --- backend/src/main.rs | 28 +- backend/src/monitor.rs | 127 ++++++++- backend/windmill-api-settings/src/lib.rs | 64 ++++- .../windmill-common/src/global_settings.rs | 9 + backend/windmill-common/src/worker.rs | 76 ++++- backend/windmill-queue/src/jobs.rs | 76 +++-- backend/windmill-queue/src/lib.rs | 1 + .../windmill-queue/src/workspace_fairness.rs | 262 ++++++++++++++++++ .../src/lib/components/instanceSettings.ts | 43 +++ frontend/src/lib/consts.ts | 5 + 10 files changed, 666 insertions(+), 25 deletions(-) create mode 100644 backend/windmill-queue/src/workspace_fairness.rs diff --git a/backend/src/main.rs b/backend/src/main.rs index ecc49c22f6..87285e2ae1 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -59,7 +59,9 @@ use windmill_common::{ RETENTION_PERIOD_SECS_SETTING, RUBY_REPOS_SETTING, SAML_METADATA_SETTING, SCIM_TOKEN_SETTING, SMTP_SETTING, STORE_AUDIT_LOGS_S3_SETTING, TEAMS_SETTING, TIMEOUT_WAIT_RESULT_SETTING, UV_EXCLUDE_NEWER_SETTING, UV_INDEX_STRATEGY_SETTING, - UV_PYTHON_INSTALL_MIRROR_SETTING, WORKSPACE_REGISTRIES_SETTING, + UV_PYTHON_INSTALL_MIRROR_SETTING, WORKSPACE_FAIRNESS_DURATION_SECS_SETTING, + WORKSPACE_FAIRNESS_ENABLED_SETTING, WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING, + WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING, WORKSPACE_REGISTRIES_SETTING, }, scripts::ScriptLang, stats_oss::schedule_stats, @@ -120,7 +122,9 @@ use crate::monitor::{ initial_load, load_disable_password_login, load_fork_workspace_tag_append_fork_suffix, load_keep_job_dir, load_metrics_debug_enabled, load_preview_tags_override, load_require_preexisting_user, load_tag_per_workspace_enabled, - load_tag_per_workspace_workspaces, monitor_db, reload_app_workspaced_route_setting, + load_tag_per_workspace_workspaces, load_workspace_fairness_duration_secs, + load_workspace_fairness_enabled, load_workspace_fairness_max_percent, + load_workspace_fairness_min_total, monitor_db, reload_app_workspaced_route_setting, reload_audit_log_retention_days_setting, reload_base_url_setting, reload_bun_install_min_release_age_setting, reload_bunfig_install_scopes_setting, reload_critical_alert_mute_ui_setting, reload_critical_alerts_on_token_expiry_setting, @@ -1765,6 +1769,26 @@ async fn process_notify_event( tracing::error!("Error loading preview tags override: {e:#}"); } } + WORKSPACE_FAIRNESS_ENABLED_SETTING => { + if let Err(e) = load_workspace_fairness_enabled(db).await { + tracing::error!("Error loading workspace fairness enabled: {e:#}"); + } + } + WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING => { + if let Err(e) = load_workspace_fairness_max_percent(db).await { + tracing::error!("Error loading workspace fairness max percent: {e:#}"); + } + } + WORKSPACE_FAIRNESS_DURATION_SECS_SETTING => { + if let Err(e) = load_workspace_fairness_duration_secs(db).await { + tracing::error!("Error loading workspace fairness duration secs: {e:#}"); + } + } + WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING => { + if let Err(e) = load_workspace_fairness_min_total(db).await { + tracing::error!("Error loading workspace fairness min total: {e:#}"); + } + } SMTP_SETTING => { reload_smtp_config(db).await; } diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index 6f33e6255f..defd47489c 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -69,6 +69,8 @@ use windmill_common::{ RETENTION_PERIOD_SECS_SETTING, SAML_METADATA_SETTING, SCIM_TOKEN_SETTING, STORE_AUDIT_LOGS_S3_SETTING, TIMEOUT_WAIT_RESULT_SETTING, UV_EXCLUDE_NEWER_SETTING, UV_INDEX_STRATEGY_SETTING, UV_PYTHON_INSTALL_MIRROR_SETTING, + WORKSPACE_FAIRNESS_DURATION_SECS_SETTING, WORKSPACE_FAIRNESS_ENABLED_SETTING, + WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING, WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING, }, indexer::load_indexer_config, jwt::JWT_SECRET, @@ -84,7 +86,8 @@ use windmill_common::{ store_suspended_pull_query, Connection, WorkerConfig, DEFAULT_TAGS_PER_WORKSPACE, DEFAULT_TAGS_WORKSPACES, FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX, INDEXER_CONFIG, PREVIEW_TAGS_OVERRIDE, SCRIPT_TOKEN_EXPIRY, SMTP_CONFIG, WINDMILL_DIR, WORKER_CONFIG, - WORKER_GROUP, + WORKER_GROUP, WORKSPACE_FAIRNESS_DURATION_SECS, WORKSPACE_FAIRNESS_ENABLED, + WORKSPACE_FAIRNESS_MAX_PERCENT, WORKSPACE_FAIRNESS_MIN_TOTAL, }, KillpillSender, AUDIT_LOG_RETENTION_DAYS, BASE_URL, CRITICAL_ALERTS_ON_DB_OVERSIZE, CRITICAL_ALERTS_ON_TOKEN_EXPIRY, CRITICAL_ALERT_MUTE_UI_ENABLED, CRITICAL_ERROR_CHANNELS, DB, @@ -248,6 +251,22 @@ pub async fn initial_load( if let Err(e) = load_preview_tags_override(db).await { tracing::error!("Error loading preview tags override: {e:#}"); } + + // Workspace fairness (cloud-only). Load the percentage/duration/min knobs + // *before* the enabled flag so that `load_workspace_fairness_enabled` reads + // current values when re-storing the pull queries. + if let Err(e) = load_workspace_fairness_max_percent(db).await { + tracing::error!("Error loading workspace fairness max percent: {e:#}"); + } + if let Err(e) = load_workspace_fairness_duration_secs(db).await { + tracing::error!("Error loading workspace fairness duration secs: {e:#}"); + } + if let Err(e) = load_workspace_fairness_min_total(db).await { + tracing::error!("Error loading workspace fairness min total: {e:#}"); + } + if let Err(e) = load_workspace_fairness_enabled(db).await { + tracing::error!("Error loading workspace fairness enabled: {e:#}"); + } } if server_mode { @@ -543,6 +562,112 @@ pub async fn load_preview_tags_override(db: &DB) -> error::Result<()> { Ok(()) } +// Upper bound on the duration window. Postgres `make_interval(secs => $1::int4)` is the consumer +// downstream, so this stays comfortably below `i32::MAX` and the subsequent `u32 -> i32` cast in +// `workspace_fairness::refresh_overloaded` cannot wrap into a negative interval (which would +// silently turn `now() - interval` into a future timestamp and disable the completed-jobs half +// of the activity signal). A day is the practical ceiling for a "rolling window" knob. +const WORKSPACE_FAIRNESS_DURATION_SECS_MAX: u64 = 86_400; + +/// Min-total floor is a counting threshold; cap at `u32::MAX` to make wraparound impossible +/// while still leaving more headroom than any realistic cluster will need. +const WORKSPACE_FAIRNESS_MIN_TOTAL_MAX: u64 = u32::MAX as u64; + +// Defaults used when a fairness knob is unset (row missing or row deleted via NULL/empty value). +// Must stay in sync with the `AtomicU32::new(...)` initialisers in `windmill-common/src/worker.rs` +// so a process that has never seen the setting reads the same value as one that just saw it +// cleared. +const WORKSPACE_FAIRNESS_MAX_PERCENT_DEFAULT: u32 = 50; +const WORKSPACE_FAIRNESS_DURATION_SECS_DEFAULT: u32 = 10; +const WORKSPACE_FAIRNESS_MIN_TOTAL_DEFAULT: u32 = 4; + +pub async fn load_workspace_fairness_enabled(db: &DB) -> error::Result<()> { + // Match the convention used by `load_preview_tags_override` / + // `load_fork_workspace_tag_append_fork_suffix`: on transient DB errors, leave the in-memory + // atomic untouched rather than silently toggling the feature off across the whole cluster + // (which would also trigger an unnecessary `store_pull_query` rebuild — exactly when DB load + // is probably highest). + let new_enabled = + match load_value_from_global_settings(db, WORKSPACE_FAIRNESS_ENABLED_SETTING).await? { + Some(serde_json::Value::Bool(t)) => t, + // Setting unset / non-bool → explicit off. + _ => false, + }; + let prev = WORKSPACE_FAIRNESS_ENABLED.swap(new_enabled, Ordering::Relaxed); + // Re-store the pull queries so the fairness variants appear/disappear in + // lockstep with the toggle. + if prev != new_enabled { + let wc = windmill_common::worker::WORKER_CONFIG.load_full(); + store_pull_query(&wc).await; + } + Ok(()) +} + +pub async fn load_workspace_fairness_max_percent(db: &DB) -> error::Result<()> { + // Distinguish three outcomes: + // - `Err(_)`: transient DB issue. Leave the atomic alone (don't clobber a known-good value + // because of a network blip during a notify-event propagation). + // - `Ok(None)` or `Ok(Some(invalid))`: setting is unset / explicitly cleared / corrupt. + // Restore the default so a deletion via the admin UI actually takes effect at runtime + // instead of leaving the stale in-memory value pinned until restart. + // - `Ok(Some(valid))`: clamp and store. + match load_value_from_global_settings(db, WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING).await? { + Some(serde_json::Value::Number(n)) => { + let v = n + .as_u64() + .map(|u| u.clamp(1, 100) as u32) + .unwrap_or(WORKSPACE_FAIRNESS_MAX_PERCENT_DEFAULT); + WORKSPACE_FAIRNESS_MAX_PERCENT.store(v, Ordering::Relaxed); + } + _ => { + WORKSPACE_FAIRNESS_MAX_PERCENT + .store(WORKSPACE_FAIRNESS_MAX_PERCENT_DEFAULT, Ordering::Relaxed); + } + } + Ok(()) +} + +pub async fn load_workspace_fairness_duration_secs(db: &DB) -> error::Result<()> { + // See `load_workspace_fairness_max_percent` for the Err / None / invalid policy. + match load_value_from_global_settings(db, WORKSPACE_FAIRNESS_DURATION_SECS_SETTING).await? { + Some(serde_json::Value::Number(n)) => { + // Clamp to the safe range before narrowing. The downstream `u32 -> i32` cast in + // `workspace_fairness::refresh_overloaded` makes any value above `i32::MAX` toxic + // (sign flip → negative interval → silent disable of the completed-jobs scan). + let v = n + .as_u64() + .map(|u| u.clamp(1, WORKSPACE_FAIRNESS_DURATION_SECS_MAX) as u32) + .unwrap_or(WORKSPACE_FAIRNESS_DURATION_SECS_DEFAULT); + WORKSPACE_FAIRNESS_DURATION_SECS.store(v, Ordering::Relaxed); + } + _ => { + WORKSPACE_FAIRNESS_DURATION_SECS + .store(WORKSPACE_FAIRNESS_DURATION_SECS_DEFAULT, Ordering::Relaxed); + } + } + Ok(()) +} + +pub async fn load_workspace_fairness_min_total(db: &DB) -> error::Result<()> { + // See `load_workspace_fairness_max_percent` for the Err / None / invalid policy. + match load_value_from_global_settings(db, WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING).await? { + Some(serde_json::Value::Number(n)) => { + // Clamp before narrowing — same reasoning as `_duration_secs`, just for the + // counting threshold rather than the interval. + let v = n + .as_u64() + .map(|u| u.min(WORKSPACE_FAIRNESS_MIN_TOTAL_MAX) as u32) + .unwrap_or(WORKSPACE_FAIRNESS_MIN_TOTAL_DEFAULT); + WORKSPACE_FAIRNESS_MIN_TOTAL.store(v, Ordering::Relaxed); + } + _ => { + WORKSPACE_FAIRNESS_MIN_TOTAL + .store(WORKSPACE_FAIRNESS_MIN_TOTAL_DEFAULT, Ordering::Relaxed); + } + } + Ok(()) +} + pub async fn load_fork_workspace_tag_append_fork_suffix(db: &DB) -> error::Result<()> { let value = load_value_from_global_settings(db, FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX_SETTING).await; diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs index 907e0d9b69..3078be9818 100644 --- a/backend/windmill-api-settings/src/lib.rs +++ b/backend/windmill-api-settings/src/lib.rs @@ -54,10 +54,14 @@ use windmill_common::{ AI_CONFIG_SETTING, APP_WORKSPACED_ROUTE_SETTING, AUTOMATE_USERNAME_CREATION_SETTING, CRITICAL_ALERT_MUTE_UI_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING, DISABLE_HUB_SETTING, EMAIL_DOMAIN_SETTING, ENV_SETTINGS, HTTP_ROUTE_WORKSPACED_ROUTE_SETTING, - HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING, RUFF_CONFIG_SETTING, WS_BASE_URL_SETTING, + HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING, RUFF_CONFIG_SETTING, + WORKSPACE_FAIRNESS_DURATION_SECS_SETTING, WORKSPACE_FAIRNESS_ENABLED_SETTING, + WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING, WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING, + WS_BASE_URL_SETTING, }, instance_config::{self, ApplyMode, InstanceConfig}, server::Smtp, + worker::is_cloud_production_host, }; use windmill_common::{error::to_anyhow, PgDatabase}; @@ -446,6 +450,24 @@ pub async fn delete_global_setting(db: &DB, key: &str) -> error::Result<()> { tracing::info!("Unset global setting {}", key); Ok(()) } +/// Returns true when `key` is one of the workspace-fairness settings whose +/// writes must be gated to cloud only. +fn is_workspace_fairness_setting(key: &str) -> bool { + matches!( + key, + WORKSPACE_FAIRNESS_ENABLED_SETTING + | WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING + | WORKSPACE_FAIRNESS_DURATION_SECS_SETTING + | WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING + ) +} + +/// Cloud-and-app.windmill.dev gate for workspace fairness. Must hold to persist +/// the setting; the runtime path additionally verifies before applying the cap. +fn workspace_fairness_settings_allowed() -> bool { + is_cloud_production_host() +} + pub async fn set_global_setting( Extension(db): Extension, authed: ApiAuthed, @@ -468,6 +490,27 @@ pub async fn set_global_setting_internal( value }; + // Hard-gate the cloud-only workspace fairness settings: refuse to persist + // them on any instance that is not CLOUD_HOSTED + app.windmill.dev. This is + // belt-and-suspenders alongside the frontend `{#if isCloudHosted()}` wrap + // and the runtime check in `workspace_fairness::fairness_active`. + // + // Deletes (Null / empty-string) are *allowed* on non-cloud so admins can clear + // stale rows that ended up in `global_settings` via a cloned cloud DB. Without + // this exception a self-hosted instance would be stuck with cloud-only rows + // showing up in its instance-config YAML export. + let is_clearing_value = matches!(&value, serde_json::Value::Null) + || matches!(&value, serde_json::Value::String(s) if s.trim().is_empty()); + if is_workspace_fairness_setting(&key) + && !is_clearing_value + && !workspace_fairness_settings_allowed() + { + return Err(error::Error::BadRequest(format!( + "{} is only configurable on app.windmill.dev cloud (CLOUD_HOSTED + BASE_URL match required)", + key + ))); + } + run_setting_pre_write_hook(db, &key, &value).await?; match value { @@ -726,6 +769,25 @@ async fn set_instance_config( .iter() .any(|(key, _)| key == AI_CONFIG_SETTING); + // Mirror the per-key cloud gate in `set_global_setting_internal`. Without this, the + // bulk endpoint would let a self-hosted superadmin persist `workspace_fairness_*` rows + // even though the per-key API rejects them. The runtime check in + // `workspace_fairness::fairness_active` still keeps the cap inert there, but persisting + // the rows would be a leak of cloud-only config into non-cloud DBs and would advertise + // the feature in the YAML export. + // + // Only block *upserts*; deletes are allowed everywhere so admins can clean up stale + // rows (e.g. from a cloned cloud DB) without flipping `CLOUD_HOSTED` on temporarily. + let upserts_touch_fairness = settings_diff + .upserts + .keys() + .any(|k| is_workspace_fairness_setting(k)); + if upserts_touch_fairness && !workspace_fairness_settings_allowed() { + return Err(error::Error::BadRequest( + "Workspace fairness settings are only configurable on app.windmill.dev cloud (CLOUD_HOSTED + BASE_URL match required)".to_string(), + )); + } + for (key, value) in &settings_diff.upserts { run_setting_pre_write_hook(&db, key, value).await?; } diff --git a/backend/windmill-common/src/global_settings.rs b/backend/windmill-common/src/global_settings.rs index 5b14d809bd..75f3fdf06b 100644 --- a/backend/windmill-common/src/global_settings.rs +++ b/backend/windmill-common/src/global_settings.rs @@ -86,6 +86,15 @@ pub const WORKSPACE_REGISTRIES_SETTING: &str = "workspace_registries"; pub const RESTART_COORDINATION_SETTING: &str = "_restart_coordination"; pub const ALERT_CONFIG_SETTING: &str = "alert_job_queue_waiting"; +// Workspace fairness: cloud-only mechanism that caps any single workspace at +// `workspace_fairness_max_percent`% of the shared worker pool once it has been +// occupying it for more than `workspace_fairness_duration_secs` seconds. See +// `windmill-queue/src/workspace_fairness.rs`. +pub const WORKSPACE_FAIRNESS_ENABLED_SETTING: &str = "workspace_fairness_enabled"; +pub const WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING: &str = "workspace_fairness_max_percent"; +pub const WORKSPACE_FAIRNESS_DURATION_SECS_SETTING: &str = "workspace_fairness_duration_secs"; +pub const WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING: &str = "workspace_fairness_min_total_jobs"; + use std::sync::atomic::AtomicBool; lazy_static::lazy_static! { diff --git a/backend/windmill-common/src/worker.rs b/backend/windmill-common/src/worker.rs index ca141bfebd..5a7c6d2bfa 100644 --- a/backend/windmill-common/src/worker.rs +++ b/backend/windmill-common/src/worker.rs @@ -17,7 +17,7 @@ use std::{ panic::Location, path::{Component, Path, PathBuf}, str::FromStr, - sync::atomic::AtomicBool, + sync::atomic::{AtomicBool, AtomicI64, AtomicU32}, time::Duration, }; #[cfg(windows)] @@ -237,14 +237,30 @@ lazy_static::lazy_static! { }); pub static ref WORKER_PULL_QUERIES: arc_swap::ArcSwap> = arc_swap::ArcSwap::from_pointee(vec![]); + pub static ref WORKER_PULL_QUERIES_FAIRNESS: arc_swap::ArcSwap> = arc_swap::ArcSwap::from_pointee(vec![]); pub static ref WORKER_SUSPENDED_PULL_QUERY: arc_swap::ArcSwap = arc_swap::ArcSwap::from_pointee("".to_string()); + // Workspace fairness (cloud-only). When enabled, a workspace whose footprint over the rolling + // `WORKSPACE_FAIRNESS_DURATION_SECS` window represents >= `WORKSPACE_FAIRNESS_MAX_PERCENT`% of + // all worker activity gets excluded from the pull query, freeing slots for other workspaces. + // The list of overloaded workspaces is computed cluster-wide via a single coordinated UPDATE + // on `background_task_state` so only one process per refresh interval runs the aggregation. + pub static ref WORKSPACE_FAIRNESS_ENABLED: AtomicBool = AtomicBool::new(false); + pub static ref WORKSPACE_FAIRNESS_MAX_PERCENT: AtomicU32 = AtomicU32::new(50); + pub static ref WORKSPACE_FAIRNESS_DURATION_SECS: AtomicU32 = AtomicU32::new(10); + pub static ref WORKSPACE_FAIRNESS_MIN_TOTAL: AtomicU32 = AtomicU32::new(4); + pub static ref WORKSPACE_FAIRNESS_OVERLOADED: arc_swap::ArcSwap> = arc_swap::ArcSwap::from_pointee(vec![]); + pub static ref WORKSPACE_FAIRNESS_LAST_REFRESH_MICROS: AtomicI64 = AtomicI64::new(0); + pub static ref SMTP_CONFIG: arc_swap::ArcSwap> = arc_swap::ArcSwap::from_pointee(None); pub static ref INDEXER_CONFIG: arc_swap::ArcSwap = arc_swap::ArcSwap::from_pointee(TantivyIndexerSettings::default()); pub static ref CLOUD_HOSTED: bool = std::env::var("CLOUD_HOSTED").is_ok(); + /// Host used to gate cloud-only features that must only ever run on the + /// production `app.windmill.dev` cluster, not on staging or self-hosted. + pub static ref CLOUD_PRODUCTION_HOST: &'static str = "app.windmill.dev"; pub static ref CUSTOM_TAGS: Vec = std::env::var("CUSTOM_TAGS") .ok() @@ -289,6 +305,34 @@ pub fn is_native_mode_from_env() -> bool { *NATIVE_MODE || *WORKER_GROUP == "native" } +/// True iff this process is configured to act as the production cloud cluster: +/// `CLOUD_HOSTED=true` AND `BASE_URL`'s host matches `CLOUD_PRODUCTION_HOST`. +/// Centralized so the API setter, the runtime pull path, and any future cloud- +/// only feature share one canonical check (rather than re-implementing the +/// scheme/host parser at each call site). +pub fn is_cloud_production_host() -> bool { + if !*CLOUD_HOSTED { + return false; + } + let base = crate::BASE_URL.load(); + let s = base.as_str(); + if s.is_empty() { + return false; + } + let after_scheme = s + .strip_prefix("https://") + .or_else(|| s.strip_prefix("http://")) + .unwrap_or(s); + let host = after_scheme + .split('/') + .next() + .unwrap_or("") + .split(':') + .next() + .unwrap_or(""); + host == *CLOUD_PRODUCTION_HOST +} + /// Cached resolved native mode flag, updated when worker config is reloaded. /// Use this for hot-path checks (e.g. per-job dispatch) to avoid read-locking WORKER_CONFIG. pub static NATIVE_MODE_RESOLVED: AtomicBool = AtomicBool::new(false); @@ -520,17 +564,43 @@ pub fn make_pull_query(tags: &[String]) -> String { query } +// Variant of `make_pull_query` that additionally excludes jobs whose workspace_id is in the +// overloaded-list bind parameter ($2::text[]). Built as a separate string (rather than reusing +// `make_pull_query` with an always-bound array) so the planner can keep using the same indexes +// when fairness is off — the default `make_pull_query` text stays bit-identical to today's. +// +// `pub(crate)` because only `store_pull_query` consumes it; the resulting query string is what +// crosses crate boundaries via `WORKER_PULL_QUERIES_FAIRNESS`. +pub(crate) fn make_pull_query_fairness(tags: &[String]) -> String { + let query = format_pull_query(format!( + "SELECT id + FROM v2_job_queue + WHERE running = false AND tag IN ({}) AND scheduled_for <= now() + AND workspace_id <> ALL($2::text[]) + ORDER BY priority DESC NULLS LAST, scheduled_for + FOR UPDATE SKIP LOCKED + LIMIT 1", + tags.iter().map(|x| format!("'{x}'")).join(", ") + )); + query +} + pub async fn store_pull_query(wc: &WorkerConfig) { let mut queries = vec![]; + let mut fairness_queries = vec![]; + let fairness_enabled = WORKSPACE_FAIRNESS_ENABLED.load(std::sync::atomic::Ordering::Relaxed); for tags in wc.priority_tags_sorted.iter() { if tags.tags.len() == 0 { tracing::error!("Empty tags in priority tags, skipping"); continue; } - let query = make_pull_query(&tags.tags); - queries.push(query); + queries.push(make_pull_query(&tags.tags)); + if fairness_enabled { + fairness_queries.push(make_pull_query_fairness(&tags.tags)); + } } WORKER_PULL_QUERIES.store(std::sync::Arc::new(queries)); + WORKER_PULL_QUERIES_FAIRNESS.store(std::sync::Arc::new(fairness_queries)); } lazy_static::lazy_static! { diff --git a/backend/windmill-queue/src/jobs.rs b/backend/windmill-queue/src/jobs.rs index 52e6b09c6f..1e1873cc9c 100644 --- a/backend/windmill-queue/src/jobs.rs +++ b/backend/windmill-queue/src/jobs.rs @@ -78,7 +78,8 @@ use windmill_common::{ utils::{not_found_if_none, report_critical_error, StripPath, WarnAfterExt}, worker::{ to_raw_value, CLOUD_HOSTED, DISABLE_FLOW_SCRIPT, NO_LOGS, PREVIEW_TAGS_OVERRIDE, - WORKER_PULL_QUERIES, WORKER_SUSPENDED_PULL_QUERY, + WORKER_PULL_QUERIES, WORKER_PULL_QUERIES_FAIRNESS, WORKER_SUSPENDED_PULL_QUERY, + WORKSPACE_FAIRNESS_OVERLOADED, }, DB, METRICS_ENABLED, }; @@ -3632,28 +3633,67 @@ async fn pull_single_job_and_mark_as_running_no_concurrency_limit<'c>( return Ok((None, false)); } - for query in queries.iter() { - // tracing::info!("Pulling job with query: {}", query); - // let instant = std::time::Instant::now(); + // Workspace fairness (cloud-only): if the fairness refresh has flagged any + // overloaded workspaces, try the fairness-aware pull queries first (which + // exclude those workspace_ids). When fairness is off or no workspace is + // currently capped, this branch is skipped and the hot path is identical + // to today's. Lazy refresh is fired from the same place; it runs at most + // once per process per refresh interval and never blocks this pull. + crate::workspace_fairness::maybe_refresh_overloaded(db); + let overloaded = WORKSPACE_FAIRNESS_OVERLOADED.load_full(); + let fairness_active = !overloaded.is_empty(); - #[cfg(feature = "benchmark")] - add_time!(bench, "pre pull"); + if fairness_active { + let fairness_queries = WORKER_PULL_QUERIES_FAIRNESS.load(); + let overloaded_slice: &[String] = overloaded.as_slice(); + for query in fairness_queries.iter() { + #[cfg(feature = "benchmark")] + add_time!(bench, "pre pull (fairness)"); - let r = sqlx::query_as::<_, PulledJob>(query) - .bind(worker_name) - .fetch_optional(db) - .await?; + let r = sqlx::query_as::<_, PulledJob>(query) + .bind(worker_name) + .bind(overloaded_slice) + .fetch_optional(db) + .await?; - #[cfg(feature = "benchmark")] - add_time!(bench, "post pull"); + #[cfg(feature = "benchmark")] + add_time!(bench, "post pull (fairness)"); - if let Some(pulled_job) = r { - // tracing::info!("pulled job: {:?}", instant.elapsed().as_micros()); - - highest_priority_job = Some(pulled_job); - break; + if let Some(pulled_job) = r { + highest_priority_job = Some(pulled_job); + break; + } + } + } + + if highest_priority_job.is_none() { + // Standard pull path. Also acts as the fallback when fairness filtered + // out every candidate: prefer running a capped workspace's job over + // leaving a worker idle. The cap re-engages on the next refresh as + // soon as the workspace's footprint exceeds the threshold again. + for query in queries.iter() { + // tracing::info!("Pulling job with query: {}", query); + // let instant = std::time::Instant::now(); + + #[cfg(feature = "benchmark")] + add_time!(bench, "pre pull"); + + let r = sqlx::query_as::<_, PulledJob>(query) + .bind(worker_name) + .fetch_optional(db) + .await?; + + #[cfg(feature = "benchmark")] + add_time!(bench, "post pull"); + + if let Some(pulled_job) = r { + // tracing::info!("pulled job: {:?}", instant.elapsed().as_micros()); + + highest_priority_job = Some(pulled_job); + break; + } + // else continue pulling for lower priority tags } - // else continue pulling for lower priority tags } // #[cfg(feature = "benchmark")] diff --git a/backend/windmill-queue/src/lib.rs b/backend/windmill-queue/src/lib.rs index ffd102c081..296b0df2f3 100644 --- a/backend/windmill-queue/src/lib.rs +++ b/backend/windmill-queue/src/lib.rs @@ -14,6 +14,7 @@ pub mod schedule; pub use jobs::*; pub mod flow_status; pub mod tags; +pub mod workspace_fairness; #[cfg(feature = "cloud")] pub mod cloud_usage; diff --git a/backend/windmill-queue/src/workspace_fairness.rs b/backend/windmill-queue/src/workspace_fairness.rs new file mode 100644 index 0000000000..a5b39055a8 --- /dev/null +++ b/backend/windmill-queue/src/workspace_fairness.rs @@ -0,0 +1,262 @@ +//! Per-workspace fairness for the shared worker pool (cloud-only). +//! +//! On `app.windmill.dev` the cluster runs a single default worker group, so a +//! single workspace flooding the queue with jobs can degrade quality of service +//! for everyone else. This module computes the set of "overloaded" workspaces +//! that should be temporarily excluded from the pull query. +//! +//! ## Detection signal +//! +//! A workspace is overloaded when, over the last `WORKSPACE_FAIRNESS_DURATION_SECS` +//! seconds, it has accounted for at least `WORKSPACE_FAIRNESS_MAX_PERCENT`% of +//! cluster activity. "Cluster activity" counts both currently-running jobs and +//! jobs completed within the window — this captures workspaces hogging slots +//! with long-running jobs **and** workspaces spamming many small short-lived +//! jobs (where no individual job's `started_at` is old, but the aggregate +//! throughput share dominates). +//! +//! ## Coordinated refresh +//! +//! The aggregation runs **at most once every `refresh_interval` seconds +//! cluster-wide**, regardless of fleet size. A single `UPDATE` statement on +//! `background_task_state` does double duty: +//! 1. The `WHERE updated_at < now() - $interval` predicate, combined with +//! row-level locking, ensures only the first process to commit per cycle +//! actually recomputes the value. Other processes that race in see the +//! `WHERE` re-evaluated against the now-fresh row and update zero rows. +//! 2. The same round trip falls through to a plain `SELECT` (via +//! `UNION ALL ... LIMIT 1`) so every caller reads the current value. +//! +//! Each process mirrors the result into [`WORKSPACE_FAIRNESS_OVERLOADED`] +//! which the pull path reads at near-zero cost. +//! +//! ## Cloud gating +//! +//! The feature is hard-gated to `CLOUD_HOSTED=true` **and** `BASE_URL` matching +//! `app.windmill.dev` (belt-and-suspenders against an on-prem instance importing +//! cloud's `global_settings` row). When either check fails, [`maybe_refresh_overloaded`] +//! and the pull-side dispatch both treat the feature as disabled. + +use std::sync::atomic::Ordering; +use std::sync::Arc; +use std::time::Duration; + +use sqlx::{Pool, Postgres}; + +use windmill_common::error::Result; +use windmill_common::worker::{ + is_cloud_production_host, WORKSPACE_FAIRNESS_DURATION_SECS, WORKSPACE_FAIRNESS_ENABLED, + WORKSPACE_FAIRNESS_LAST_REFRESH_MICROS, WORKSPACE_FAIRNESS_MAX_PERCENT, + WORKSPACE_FAIRNESS_MIN_TOTAL, WORKSPACE_FAIRNESS_OVERLOADED, +}; + +pub const TASK_STATE_NAME: &str = "workspace_fairness"; + +/// Refresh interval when no workspace is currently capped. Slower cadence to +/// keep DB load minimal during normal operation. +const IDLE_REFRESH_SECS: u32 = 5; + +/// Refresh interval when at least one workspace is capped. Faster cadence so +/// the cap lifts promptly once load drops below threshold. +const ACTIVE_REFRESH_SECS: u32 = 2; + +/// Hard cap on the size of the overloaded list bound into the pull query. +const MAX_OVERLOADED_RETURNED: i64 = 64; + +/// Whether the feature can be active in this process. Combined gate: +/// - `WORKSPACE_FAIRNESS_ENABLED` setting toggled on, AND +/// - `CLOUD_HOSTED=true`, AND +/// - `BASE_URL` host is the production cloud host. +fn fairness_active() -> bool { + WORKSPACE_FAIRNESS_ENABLED.load(Ordering::Relaxed) && is_cloud_production_host() +} + +#[derive(serde::Deserialize)] +struct FairnessState { + #[serde(default)] + overloaded: Vec, +} + +/// Lazy, non-blocking refresh entry point called from the pull path. +/// +/// Cost on the hot path: one atomic load, optionally one compare-exchange. If +/// this process wins the per-interval CAS, the actual refresh is spawned as a +/// `tokio` task — the caller does not wait on it. +pub fn maybe_refresh_overloaded(db: &Pool) { + if !fairness_active() { + // Drain the cached list so the dispatch in jobs.rs falls back to the + // unmodified pull queries within at most one pull cycle. + if !WORKSPACE_FAIRNESS_OVERLOADED.load().is_empty() { + WORKSPACE_FAIRNESS_OVERLOADED.store(Arc::new(vec![])); + } + return; + } + + let interval_us = current_refresh_interval_micros(); + let now_us = chrono::Utc::now().timestamp_micros(); + let last = WORKSPACE_FAIRNESS_LAST_REFRESH_MICROS.load(Ordering::Relaxed); + if now_us.saturating_sub(last) < interval_us { + return; + } + // Single in-flight refresh per process per cycle. If someone beat us, give up. + if WORKSPACE_FAIRNESS_LAST_REFRESH_MICROS + .compare_exchange(last, now_us, Ordering::Relaxed, Ordering::Relaxed) + .is_err() + { + return; + } + + let db = db.clone(); + tokio::spawn(async move { + match tokio::time::timeout(Duration::from_secs(5), refresh_overloaded(&db)).await { + Ok(Ok(())) => {} + // On failure, leave `LAST_REFRESH_MICROS` set to `now_us` (already done by the CAS + // above). The next attempt therefore has to wait a full `current_refresh_interval` + // — exactly the same cooldown as a successful refresh. Previously we wrote `0` + // here, which removed the rate limit entirely and let every subsequent pull spawn + // a fresh refresh task while the DB was under pressure (precisely the moment we + // most need to back off). + Ok(Err(e)) => { + tracing::warn!("workspace fairness refresh failed: {e:#}"); + } + Err(_) => { + tracing::warn!("workspace fairness refresh timed out after 5s"); + } + } + }); +} + +fn current_refresh_interval_micros() -> i64 { + let secs = if WORKSPACE_FAIRNESS_OVERLOADED.load().is_empty() { + IDLE_REFRESH_SECS + } else { + ACTIVE_REFRESH_SECS + }; + (secs as i64) * 1_000_000 +} + +/// Run the coordinated refresh. +/// +/// The previous implementation used a single `INSERT ... ON CONFLICT DO UPDATE +/// WHERE updated_at < ...` statement, which had a fatal flaw: Postgres evaluates +/// the `VALUES` clause (including the expensive `v2_job_queue ∪ v2_job_completed` +/// aggregation inlined there) **for every contender** to build the proposed row, +/// before the conflict-row check decides whether to actually apply the update. +/// So every worker process re-ran the heavy aggregation each cycle, and the +/// claimed "one heavy aggregation per cycle cluster-wide" property did not hold. +/// +/// This version splits the refresh into three small statements: +/// 1. Claim: a cheap upsert with only constant `VALUES`. Returns `Some(...)` +/// iff this process won the right to refresh (row was either missing or +/// had a stale `updated_at`). +/// 2. Winner-only: an `UPDATE ... SET value = ...` whose `SET` expression +/// contains the heavy aggregation. Postgres evaluates `SET` per row +/// matching `WHERE`; we only issue it when `won`, so the aggregation runs +/// exactly once per refresh cycle cluster-wide. +/// 3. Read: every caller reads the current value (winner sees its own fresh +/// write; losers see whatever the winner-from-this-or-the-prior-cycle +/// wrote). +async fn refresh_overloaded(db: &Pool) -> Result<()> { + let duration_secs = WORKSPACE_FAIRNESS_DURATION_SECS + .load(Ordering::Relaxed) + .clamp(1, i32::MAX as u32) as i32; + let max_percent = WORKSPACE_FAIRNESS_MAX_PERCENT + .load(Ordering::Relaxed) + .clamp(1, 100) as i64; + let min_total = WORKSPACE_FAIRNESS_MIN_TOTAL.load(Ordering::Relaxed) as i64; + // Use the tighter of the two intervals as the cluster-wide guard. The + // slower idle cadence is enforced by the per-process CAS gate in + // `maybe_refresh_overloaded`; the DB-side guard only needs to prevent + // two processes from racing into a refresh at the same time. + let refresh_secs = ACTIVE_REFRESH_SECS as i32; + + // Step 1: claim. The VALUES clause is all constants — Postgres has no + // expensive work to do for either the insert-side or the conflict-side. + // Returns Some(true) for the unique winner per cycle, None for losers. + let won = sqlx::query_scalar::<_, bool>( + r#" + INSERT INTO background_task_state (name, value, running, owner, updated_at) + VALUES ($1, '{"overloaded":[]}'::jsonb, false, NULL, NOW()) + ON CONFLICT (name) DO UPDATE + SET updated_at = NOW() + WHERE background_task_state.updated_at + < NOW() - make_interval(secs => $2::int) + RETURNING true + "#, + ) + .bind(TASK_STATE_NAME) + .bind(refresh_secs) + .fetch_optional(db) + .await? + .is_some(); + + // Step 2: winner-only aggregation + value write. `SET` is evaluated per + // updated row, so issuing this statement only when `won` guarantees the + // expensive aggregation never runs for a loser. + if won { + sqlx::query( + r#" + UPDATE background_task_state + SET value = jsonb_build_object('overloaded', ( + WITH active AS ( + SELECT workspace_id FROM v2_job_queue WHERE running = true + UNION ALL + SELECT workspace_id FROM v2_job_completed + WHERE completed_at > NOW() - make_interval(secs => $2::int) + ), + per_ws AS ( + SELECT workspace_id, COUNT(*)::int8 AS c FROM active GROUP BY 1 + ), + total AS (SELECT SUM(c)::int8 AS t FROM per_ws) + SELECT COALESCE(jsonb_agg(workspace_id ORDER BY c DESC), '[]'::jsonb) + FROM ( + SELECT workspace_id, c FROM per_ws, total + WHERE total.t >= $3 + AND per_ws.c * 100 >= $4 * total.t + ORDER BY c DESC + LIMIT $5 + ) capped + )) + WHERE name = $1 + "#, + ) + .bind(TASK_STATE_NAME) + .bind(duration_secs) + .bind(min_total) + .bind(max_percent) + .bind(MAX_OVERLOADED_RETURNED) + .execute(db) + .await?; + } + + // Step 3: read current state (winner reads its own fresh write). + let row: Option = + sqlx::query_scalar("SELECT value FROM background_task_state WHERE name = $1") + .bind(TASK_STATE_NAME) + .fetch_optional(db) + .await?; + + let new_list: Vec = match row { + Some(value) => match serde_json::from_value::(value) { + Ok(s) => s.overloaded, + Err(e) => { + tracing::warn!("workspace fairness state parse error: {e:#}"); + vec![] + } + }, + None => vec![], + }; + + let prev = WORKSPACE_FAIRNESS_OVERLOADED.load(); + if **prev != new_list { + tracing::info!( + "workspace fairness overloaded set changed: {} -> {} ({:?})", + prev.len(), + new_list.len(), + &new_list, + ); + WORKSPACE_FAIRNESS_OVERLOADED.store(Arc::new(new_list)); + } + + Ok(()) +} diff --git a/frontend/src/lib/components/instanceSettings.ts b/frontend/src/lib/components/instanceSettings.ts index a127027dac..8f3cfcfb61 100644 --- a/frontend/src/lib/components/instanceSettings.ts +++ b/frontend/src/lib/components/instanceSettings.ts @@ -303,6 +303,49 @@ export const settings: Record = { storage: 'setting', ee_only: 'You can only adjust this setting to above 30 days in the EE version', cloudonly: false + }, + { + label: 'Workspace fairness — enabled', + description: + 'Cloud-only safeguard against a single workspace dominating the shared worker pool. When a workspace accounts for at least Workspace fairness — max percent of cluster activity over the last Workspace fairness — duration seconds, the pull query temporarily excludes that workspace until its share drops back below the threshold. Idle workers always fall back to running its jobs, so capping never starves the queue.', + key: 'workspace_fairness_enabled', + fieldType: 'boolean', + storage: 'setting', + cloudonly: true, + hideInQuickSetup: true + }, + { + label: 'Workspace fairness — max percent', + description: + 'Maximum percentage of cluster activity a single workspace may sustain before being temporarily excluded from the pull query. Default 50.', + key: 'workspace_fairness_max_percent', + fieldType: 'number', + placeholder: '50', + storage: 'setting', + cloudonly: true, + hideInQuickSetup: true + }, + { + label: 'Workspace fairness — duration (seconds)', + description: + 'Rolling window used to measure workspace share. Activity = currently running jobs ∪ jobs completed in the last N seconds. Default 10.', + key: 'workspace_fairness_duration_secs', + fieldType: 'seconds', + placeholder: '10', + storage: 'setting', + cloudonly: true, + hideInQuickSetup: true + }, + { + label: 'Workspace fairness — minimum total jobs', + description: + 'Cap is only applied when cluster-wide activity exceeds this floor. Prevents over-eager capping on small clusters or quiet periods. Default 4.', + key: 'workspace_fairness_min_total_jobs', + fieldType: 'number', + placeholder: '4', + storage: 'setting', + cloudonly: true, + hideInQuickSetup: true } ], 'Object Storage': [ diff --git a/frontend/src/lib/consts.ts b/frontend/src/lib/consts.ts index d34651da5c..95afa84802 100644 --- a/frontend/src/lib/consts.ts +++ b/frontend/src/lib/consts.ts @@ -50,6 +50,11 @@ export const DEFAULT_TAGS_WORKSPACES_SETTING = 'default_tags_workspaces' export const FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX_SETTING = 'fork_workspace_tag_append_fork_suffix' export const PREVIEW_TAGS_OVERRIDE_SETTING = 'preview_tags_override' +export const WORKSPACE_FAIRNESS_ENABLED_SETTING = 'workspace_fairness_enabled' +export const WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING = 'workspace_fairness_max_percent' +export const WORKSPACE_FAIRNESS_DURATION_SECS_SETTING = 'workspace_fairness_duration_secs' +export const WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING = 'workspace_fairness_min_total_jobs' + export const WORKSPACE_SLACK_BOT_TOKEN_PATH = 'f/slack_bot/bot_token' export const POSTGRES_TYPES = [ From ff685eb2d3e1d5510020f99f7f03d5ab64887992 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sun, 24 May 2026 23:51:19 +0000 Subject: [PATCH 40/71] chore(main): release 1.708.0 (#9304) * chore(main): release 1.708.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- CHANGELOG.md | 7 + backend/Cargo.lock | 156 +++++++++--------- backend/Cargo.toml | 4 +- .../parsers/windmill-parser-wasm/Cargo.lock | 48 +++--- .../parsers/windmill-parser-wasm/Cargo.toml | 2 +- backend/windmill-api/openapi.yaml | 2 +- benchmarks/lib.ts | 2 +- cli/src/main.ts | 2 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- lsp/Pipfile | 2 +- openflow.openapi.yaml | 2 +- .../WindmillClient/WindmillClient.psd1 | 2 +- python-client/wmill/pyproject.toml | 2 +- typescript-client/jsr.json | 2 +- typescript-client/package.json | 2 +- version.txt | 2 +- 17 files changed, 125 insertions(+), 118 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38bcdbb0f4..edf9d417fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.708.0](https://github.com/windmill-labs/windmill/compare/v1.707.0...v1.708.0) (2026-05-24) + + +### Features + +* **queue:** per-workspace fairness cap on the shared cloud worker pool ([#9303](https://github.com/windmill-labs/windmill/issues/9303)) ([de2e243](https://github.com/windmill-labs/windmill/commit/de2e243313ee34348675dec600cb412b475d1b4b)) + ## [1.707.0](https://github.com/windmill-labs/windmill/compare/v1.706.1...v1.707.0) (2026-05-22) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 878b87ccfa..3593ddfd26 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -13788,7 +13788,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "async-nats", @@ -13869,7 +13869,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.707.0" +version = "1.708.0" dependencies = [ "async-stream", "async-trait", @@ -13901,7 +13901,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.707.0" +version = "1.708.0" dependencies = [ "axum 0.8.9", "chrono", @@ -13914,7 +13914,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "argon2", @@ -14057,7 +14057,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.707.0" +version = "1.708.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14080,7 +14080,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.707.0" +version = "1.708.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14093,7 +14093,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14119,7 +14119,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.707.0" +version = "1.708.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -14129,7 +14129,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.707.0" +version = "1.708.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14146,7 +14146,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.707.0" +version = "1.708.0" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -14168,7 +14168,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14191,7 +14191,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.707.0" +version = "1.708.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14207,7 +14207,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.707.0" +version = "1.708.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14228,7 +14228,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.707.0" +version = "1.708.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14249,7 +14249,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.707.0" +version = "1.708.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14263,7 +14263,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "async-nats", @@ -14295,7 +14295,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14320,7 +14320,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.707.0" +version = "1.708.0" dependencies = [ "axum 0.8.9", "flate2", @@ -14338,7 +14338,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14360,7 +14360,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.707.0" +version = "1.708.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14380,7 +14380,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.707.0" +version = "1.708.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14410,7 +14410,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14438,7 +14438,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.707.0" +version = "1.708.0" dependencies = [ "lazy_static", "serde", @@ -14450,7 +14450,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.707.0" +version = "1.708.0" dependencies = [ "argon2", "axum 0.8.9", @@ -14475,7 +14475,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.707.0" +version = "1.708.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14489,7 +14489,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.707.0" +version = "1.708.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14522,7 +14522,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.707.0" +version = "1.708.0" dependencies = [ "chrono", "lazy_static", @@ -14536,7 +14536,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14555,7 +14555,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.707.0" +version = "1.708.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -14656,7 +14656,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.707.0" +version = "1.708.0" dependencies = [ "chrono", "itertools 0.14.0", @@ -14675,7 +14675,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.707.0" +version = "1.708.0" dependencies = [ "regex", "serde", @@ -14690,7 +14690,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -14714,7 +14714,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "futures", @@ -14731,7 +14731,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.707.0" +version = "1.708.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -14747,7 +14747,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "async-trait", @@ -14768,7 +14768,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "async-trait", @@ -14799,7 +14799,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "arc-swap", @@ -14824,7 +14824,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "async-stream", @@ -14858,7 +14858,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "futures", @@ -14876,7 +14876,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.707.0" +version = "1.708.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -14885,7 +14885,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "lazy_static", @@ -14897,7 +14897,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "serde_json", @@ -14909,7 +14909,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "gosyn", @@ -14921,7 +14921,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "lazy_static", @@ -14933,7 +14933,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "serde_json", @@ -14945,7 +14945,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "nu-parser", @@ -14956,7 +14956,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14967,7 +14967,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14979,7 +14979,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "rustpython-ast", @@ -14990,7 +14990,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "async-recursion", @@ -15012,7 +15012,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "serde_json", @@ -15024,7 +15024,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "lazy_static", @@ -15038,7 +15038,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -15055,7 +15055,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "lazy_static", @@ -15068,7 +15068,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "serde", @@ -15080,7 +15080,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "lazy_static", @@ -15098,7 +15098,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -15114,7 +15114,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "rustpython-ast", @@ -15130,7 +15130,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "serde", @@ -15141,7 +15141,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "async-recursion", @@ -15178,7 +15178,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "const_format", @@ -15216,7 +15216,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.707.0" +version = "1.708.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -15227,7 +15227,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "async-recursion", @@ -15257,7 +15257,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "async-trait", @@ -15281,7 +15281,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "async-trait", @@ -15314,7 +15314,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "async-trait", @@ -15347,7 +15347,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "async-trait", @@ -15367,7 +15367,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "async-trait", @@ -15401,7 +15401,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "async-trait", @@ -15437,7 +15437,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "async-trait", @@ -15460,7 +15460,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "async-trait", @@ -15484,7 +15484,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "async-nats", @@ -15508,7 +15508,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "async-trait", @@ -15543,7 +15543,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "async-trait", @@ -15571,7 +15571,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "async-trait", @@ -15594,7 +15594,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "bitflags 2.11.1", @@ -15613,7 +15613,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "async-once-cell", @@ -15723,7 +15723,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.707.0" +version = "1.708.0" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 8f9cb3b5b4..421139a311 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.707.0" +version = "1.708.0" authors.workspace = true edition.workspace = true @@ -87,7 +87,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"] [workspace.package] -version = "1.707.0" +version = "1.708.0" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index bf53c31bc8..02012874ee 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6183,7 +6183,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.707.0" +version = "1.708.0" dependencies = [ "aho-corasick", "anyhow", @@ -6263,7 +6263,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.707.0" +version = "1.708.0" dependencies = [ "proc-macro2", "quote", @@ -6275,7 +6275,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.707.0" +version = "1.708.0" dependencies = [ "convert_case", "serde", @@ -6284,7 +6284,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "lazy_static", @@ -6296,7 +6296,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "serde_json", @@ -6308,7 +6308,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "gosyn", @@ -6320,7 +6320,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "lazy_static", @@ -6332,7 +6332,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "serde_json", @@ -6344,7 +6344,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "nu-parser", @@ -6355,7 +6355,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6366,7 +6366,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6389,7 +6389,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "async-recursion", @@ -6411,7 +6411,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "serde_json", @@ -6423,7 +6423,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "lazy_static", @@ -6437,7 +6437,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "convert_case", @@ -6454,7 +6454,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "lazy_static", @@ -6467,7 +6467,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "serde", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "lazy_static", @@ -6497,7 +6497,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6513,7 +6513,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6529,7 +6529,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6561,7 +6561,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "serde", @@ -6572,7 +6572,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.707.0" +version = "1.708.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index ebf70501d9..5455c8036d 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.toml +++ b/backend/parsers/windmill-parser-wasm/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" members = ["."] [workspace.package] -version = "1.707.0" +version = "1.708.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 907e83ac37..a72a6e31dc 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.707.0 + version: 1.708.0 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 928ef837fe..92955b1387 100644 --- a/benchmarks/lib.ts +++ b/benchmarks/lib.ts @@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts"; import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts"; import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts"; -export const VERSION = "v1.707.0"; +export const VERSION = "v1.708.0"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/src/main.ts b/cli/src/main.ts index 0bada07885..6f4d5b86ce 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -87,7 +87,7 @@ export { token, }; -export const VERSION = "1.707.0"; +export const VERSION = "1.708.0"; // Re-exported from constants.ts to maintain backwards compatibility export { WM_FORK_PREFIX } from "./core/constants.ts"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5e54e9c5f4..ba905cfea3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.707.0", + "version": "1.708.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.707.0", + "version": "1.708.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 5ff9c72b97..3a14adebe0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.707.0", + "version": "1.708.0", "scripts": { "dev": "vite dev", "build": "vite build", diff --git a/lsp/Pipfile b/lsp/Pipfile index 692b153c68..42a5ef1f3c 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.707.0" +wmill = ">=1.708.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 1204a2efc9..d55d9cc804 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.707.0 + version: 1.708.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index 9db4909902..43e112cefe 100644 --- a/powershell-client/WindmillClient/WindmillClient.psd1 +++ b/powershell-client/WindmillClient/WindmillClient.psd1 @@ -12,7 +12,7 @@ RootModule = 'WindmillClient.psm1' # Version number of this module. - ModuleVersion = '1.707.0' + ModuleVersion = '1.708.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index fd7c56eafd..0469acadb9 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.707.0" +version = "1.708.0" description = "A client library for accessing Windmill server wrapping the Windmill client API" license = "Apache-2.0" homepage = "https://windmill.dev" diff --git a/typescript-client/jsr.json b/typescript-client/jsr.json index e0af5d1079..3f296f48c3 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.707.0", + "version": "1.708.0", "exports": "./src/index.ts", "publish": { "exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts"] diff --git a/typescript-client/package.json b/typescript-client/package.json index 074cc88860..ca4b86d198 100644 --- a/typescript-client/package.json +++ b/typescript-client/package.json @@ -1,7 +1,7 @@ { "name": "windmill-client", "description": "Windmill SDK client for browsers and Node.js", - "version": "1.707.0", + "version": "1.708.0", "author": "Ruben Fiszel", "license": "Apache 2.0", "sideEffects": false, diff --git a/version.txt b/version.txt index 28c8ca4e2c..99d6915df5 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.707.0 +1.708.0 From 98bd5e7f2a437b8b534028838b6ed0d7c59f7011 Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Mon, 25 May 2026 16:14:36 +0200 Subject: [PATCH 41/71] feat: add copy button to Path component (#9311) --- frontend/src/lib/components/Path.svelte | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/frontend/src/lib/components/Path.svelte b/frontend/src/lib/components/Path.svelte index 8306c8a410..b4bd67632e 100644 --- a/frontend/src/lib/components/Path.svelte +++ b/frontend/src/lib/components/Path.svelte @@ -5,7 +5,7 @@ @@ -41,9 +65,9 @@
-

Global AI drafts

+

Global local drafts

- Dev-only inspector for the in-memory global draft store. + Dev-only inspector for global local drafts.

{/each} + +
+ + +
diff --git a/frontend/src/lib/components/copilot/chat/global/core.test.ts b/frontend/src/lib/components/copilot/chat/global/core.test.ts index 7dce5eedad..6a7a9c8a9c 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -1104,7 +1104,7 @@ describe('global AI tools', () => { expect(item.value.value).toBeUndefined() }) - it('asks the user a multiple-choice question and returns the selected answer', async () => { + it('asks the user a question and returns the selected answer', async () => { const callbacks: ToolCallbacks = { setToolStatus: vi.fn(), removeToolStatus: vi.fn(), @@ -1138,6 +1138,79 @@ describe('global AI tools', () => { }) ) }) + + it('allows up to ten proposed answers', async () => { + const choices = Array.from({ length: 10 }, (_, index) => `choice-${index + 1}`) + const callbacks: ToolCallbacks = { + setToolStatus: vi.fn(), + removeToolStatus: vi.fn(), + requestUserQuestion: vi.fn(async (_toolId, question) => question.choices[9]) + } + + const raw = await callGlobalTool( + 'askUserQuestion', + { + question: 'Which option should be used?', + choices + }, + callbacks + ) + + expect(raw).toBe('choice-10') + expect(callbacks.requestUserQuestion).toHaveBeenCalledWith( + 'test-askUserQuestion', + expect.objectContaining({ + choices + }) + ) + }) + + it('rejects more than ten proposed answers', async () => { + const callbacks: ToolCallbacks = { + setToolStatus: vi.fn(), + removeToolStatus: vi.fn(), + requestUserQuestion: vi.fn() + } + + await expect( + callGlobalTool( + 'askUserQuestion', + { + question: 'Which option should be used?', + choices: Array.from({ length: 11 }, (_, index) => `choice-${index + 1}`) + }, + callbacks + ) + ).rejects.toThrow() + expect(callbacks.requestUserQuestion).not.toHaveBeenCalled() + }) + + it('returns a custom answer that is not one of the proposed answers', async () => { + const callbacks: ToolCallbacks = { + setToolStatus: vi.fn(), + removeToolStatus: vi.fn(), + requestUserQuestion: vi.fn(async () => 'use deno instead') + } + + const raw = await callGlobalTool( + 'askUserQuestion', + { + question: 'Which script language should be used?', + choices: ['bun', 'python3'] + }, + callbacks + ) + + expect(raw).toBe('use deno instead') + expect(callbacks.setToolStatus).toHaveBeenLastCalledWith( + 'test-askUserQuestion', + expect.objectContaining({ + content: 'User answered question: use deno instead', + result: 'use deno instead', + userQuestion: expect.objectContaining({ selectedChoice: 'use deno instead' }) + }) + ) + }) }) describe('prepareGlobalSystemMessage', () => { diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 11c94e677b..10d055938c 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -134,10 +134,10 @@ const askUserQuestionSchema = z.object({ .min(1) .describe('The concise question to show to the user before continuing.'), choices: z - .array(z.string().min(1).describe('Short answer text shown to the user and returned as-is.')) + .array(z.string().min(1).describe('Proposed answer text shown to the user and returned as-is.')) .min(2) - .max(6) - .describe('Two to six mutually exclusive answer strings.') + .max(10) + .describe('Two to ten mutually exclusive proposed answer strings.') }) const listWorkspaceItemsSchema = z.object({ @@ -503,7 +503,7 @@ Rules: - Variable values are never readable. For secrets, create a secret variable and reference it from resources as "$var:path/to/variable". - Use search_resource_types before write_resource. - Use get_instructions before writing scripts, flows, resources, or apps. For scripts, pass the target language. -- Ask the user when a required decision is ambiguous. +- When a required decision is ambiguous, use askUserQuestion with two to ten clear proposed answer strings instead of guessing. The user can also type a custom answer when none of the proposed answers fit. - Keep context targeted. Flows: @@ -1275,7 +1275,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( askUserQuestionSchema, 'askUserQuestion', - 'Ask the user a multiple-choice question.' + 'Ask the user a question with proposed answers and wait for their selected or custom answer before continuing.' ), fn: async ({ args, toolId, toolCallbacks }) => { const parsed = askUserQuestionSchema.parse(args) From 2f50e8bab0b5ae9ae297c79abfe96df441f405e2 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Mon, 25 May 2026 17:25:11 +0200 Subject: [PATCH 47/71] feat(ai-chat): align footer bar + DropdownV2 mode/autonomy selectors (#9308) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ai-chat): align footer bar, use DropdownV2 for mode/autonomy selectors Co-Authored-By: Claude Opus 4.7 (1M context) * feat(dropdown): add `selected` item prop rendering a trailing check Co-Authored-By: Claude Opus 4.7 (1M context) * style(ai-chat): add small spacing between chat input and footer bar Co-Authored-By: Claude Opus 4.7 (1M context) * feat(ai-chat): always offer the 3 autonomy options in the auto-accept picker Co-Authored-By: Claude Opus 4.7 (1M context) * fix(ai-chat): default autonomy mode to auto-accept on Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(ai-chat): use Button component for footer dropdown triggers Co-Authored-By: Claude Opus 4.7 (1M context) * style(ai-chat): use a hand icon for the auto-accept-off autonomy state Co-Authored-By: Claude Opus 4.7 (1M context) * style(ai-chat): use subtle Button variant for mode and model selectors Co-Authored-By: Claude Opus 4.7 (1M context) * style(ai-chat): tighten spacing between input and footer bar Co-Authored-By: Claude Opus 4.7 (1M context) * feat(ai-chat): reword autonomy levels as ask/auto-accept/bypass permissions Co-Authored-By: Claude Opus 4.7 (1M context) * feat(button): add 2xs unified size with tighter padding Co-Authored-By: Claude Opus 4.7 (1M context) * feat(ai-chat): compact footer bar — 2xs buttons, AtSign context icon, short Yolo label, discreet model Co-Authored-By: Claude Opus 4.7 (1M context) * style(ai-chat): widen the permission selector dropdown Co-Authored-By: Claude Opus 4.7 (1M context) * fix(dropdown): group shortcut + selected check to avoid ml-auto collision Co-Authored-By: Claude Opus 4.7 (1M context) * test(ai-chat): cover getPersistedAutonomyMode default; clarify default comment Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../src/lib/components/DropdownV2Inner.svelte | 15 +- .../components/PathNameAutocomplete.svelte | 2 + .../src/lib/components/common/button/model.ts | 8 +- .../copilot/chat/AIChatDisplay.svelte | 130 +++++++++--------- .../copilot/chat/AIChatManager.svelte.ts | 7 +- .../copilot/chat/AIChatManager.test.ts | 35 +++++ .../components/copilot/chat/ChatMode.svelte | 79 +++++------ .../copilot/chat/ProviderModelSelector.svelte | 68 +++++---- .../components/text_input/TextInput.svelte | 6 + frontend/src/lib/utils.ts | 3 + 10 files changed, 200 insertions(+), 153 deletions(-) diff --git a/frontend/src/lib/components/DropdownV2Inner.svelte b/frontend/src/lib/components/DropdownV2Inner.svelte index b56a5cce70..71cbea82f9 100644 --- a/frontend/src/lib/components/DropdownV2Inner.svelte +++ b/frontend/src/lib/components/DropdownV2Inner.svelte @@ -1,7 +1,7 @@ -
- aiChatManager.allowedModes[k] - ).length < 2} - class="max-w-full" +{#if hasMultiple} + + allowedModeList.map((mode) => ({ + displayName: modeLabel(mode), + selected: aiChatManager.mode === mode, + action: () => aiChatManager.changeMode(mode) + }))} + placement="bottom-start" + fixedHeight={false} + customWidth={170} > - {#snippet trigger()} - -
- - {aiChatManager.mode.charAt(0).toUpperCase() + aiChatManager.mode.slice(1)} mode - - {#if Object.keys(aiChatManager.allowedModes).filter((k) => aiChatManager.allowedModes[k]).length > 1} -
- -
- {/if} -
- - {/snippet} - {#snippet content({ close })} - -
- {#each Object.values(AIMode) as possibleMode} - {#if aiChatManager.allowedModes[possibleMode]} - - {/if} - {/each} -
- - {/snippet} -
-
+ {#snippet buttonReplacement()} + + {/snippet} + +{:else} + +{/if} diff --git a/frontend/src/lib/components/copilot/chat/ProviderModelSelector.svelte b/frontend/src/lib/components/copilot/chat/ProviderModelSelector.svelte index ed6118bc48..7f9d993a6c 100644 --- a/frontend/src/lib/components/copilot/chat/ProviderModelSelector.svelte +++ b/frontend/src/lib/components/copilot/chat/ProviderModelSelector.svelte @@ -1,12 +1,12 @@ -
- - {#snippet trigger()} -
- {providerModel.model} - {#if multipleModels} -
- -
- {/if} -
+{#if multipleModels} + + $copilotInfo.aiModels.map((m) => ({ + displayName: m.model, + selected: m.model === providerModel.model, + action: () => { + $copilotSessionModel = m + storeLocalSetting(COPILOT_SESSION_MODEL_SETTING_NAME, m.model) + storeLocalSetting(COPILOT_SESSION_PROVIDER_SETTING_NAME, m.provider) + } + }))} + placement="bottom-end" + fixedHeight={false} + > + {#snippet buttonReplacement()} + {/snippet} - {#snippet content({ close })} -
- {#each $copilotInfo.aiModels as providerModel} - - {/each} -
- {/snippet} -
-
+ +{:else} + +{/if} diff --git a/frontend/src/lib/components/text_input/TextInput.svelte b/frontend/src/lib/components/text_input/TextInput.svelte index bd5a29dc64..c57f57120a 100644 --- a/frontend/src/lib/components/text_input/TextInput.svelte +++ b/frontend/src/lib/components/text_input/TextInput.svelte @@ -24,6 +24,11 @@ import { ButtonType } from '$lib/components/common/button/model' export const inputSizeClasses = { + '2xs': twMerge( + ButtonType.UnifiedSizingClasses['2xs'], + ButtonType.UnifiedMinHeightClasses['2xs'], + 'px-1 !py-0.5' + ), xs: twMerge( ButtonType.UnifiedSizingClasses.xs, ButtonType.UnifiedMinHeightClasses.xs, @@ -47,6 +52,7 @@ // so the exact centered value there is (content-box height − 2px). Scoped to // the same 1760px breakpoint as the font-size bump; small mode is unchanged. export const inputLeadingClasses: Record = { + '2xs': 'leading-4 min-[1760px]:leading-[calc(1rem_-_2px)]', // h-5 − py-0.5 → 1rem xs: 'leading-4 min-[1760px]:leading-[calc(1rem_-_2px)]', // h-5 − py-0.5 → 1rem sm: 'leading-6 min-[1760px]:leading-[calc(1.5rem_-_2px)]', // h-7 − py-0.5 → 1.5rem md: 'leading-8 min-[1760px]:leading-[calc(2rem_-_2px)]', // h-8, no py → 2rem diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 20d1e496ff..8fb6475806 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -1536,6 +1536,9 @@ export type Item = { separatorTop?: boolean submenuItems?: Item[] shortcut?: string + // Renders a trailing check on the right of the label to mark the + // currently-selected item (for dropdowns used as a single-choice picker). + selected?: boolean } export function isObjectTooBig(obj: any): boolean { From 368e6774194a58058f28d1b4a42f8f4a7ec4ab63 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Mon, 25 May 2026 17:27:25 +0200 Subject: [PATCH 48/71] feat(raw_apps): tab-based editor surface with split-with-preview (#9273) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(raw_apps): custom tab system for source / runnable / preview Replaces the fixed split-pane layout with a tab bar inside the editor area. Each frontend file is a tab, each selected runnable is a tab, and the Preview is pinned to the right (non-closable). Tabs are an alternative discoverability surface to the sidebar — both stay functional, but tabs make navigation viable on small screens with the sidebar collapsed. A "Split with Preview" toggle in the tab bar's trailing slot pairs the active tab with the preview side-by-side for wide-screen multitasking. The toggle hides when Preview is already the active tab. The UI Builder, runnable editor, and preview iframe all stay mounted across tab switches (toggled via `display`) — no bundler restarts, no preview state loss, no editor remounts. - New common/tabs/DraggableTabs.svelte: reusable tab strip with drag-reorder (@windmill-labs/svelte-dnd-action), pinned-left/right slots excluded from the drag zone, hover-revealed X close, middle- click close, keyboard navigation (arrows / Enter / Backspace), and a `trailing` snippet for inline toolbar add-ons. - raw_apps/RawAppEditor.svelte: - Tab state (`tabs`, `activeTabId`, `splitWithPreview`) lives in Windmill. Persisted in localStorage keyed by workspace + app path. - Sidebar file clicks (`handleSelectFile`) and runnable selection (`selectedRunnable` via `bind:`) are mirrored into tabs via an effect — the sidebar interaction is otherwise untouched. - Listener augmented: `setActiveDocument` backfills tabs for files VS Code opens by itself; `setFiles` / `runnables` updates drop stale tabs. - Bundler / inspector / rebuild toolbar moves into the tab bar's trailing slot — always visible regardless of active tab. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(raw_apps): modern tab styling + resizable split-with-preview Two polish passes on the new tab system: DraggableTabs styling: - Remove the bottom border on the tab strip + the accent-coloured border-b-2 on the active tab. The active tab now shares the surface background with the content area below it, so the boundary visually "disappears" — modern IDE-style tabs. - Inactive tabs sit on the darker surface-secondary tab strip and get a subtle right separator so they don't blur into each other. Split-with-Preview is now a real resizable Splitpanes: - The content area is rendered as a Splitpanes (always), with the source/runnable slot on the left and the preview iframe on the right. The user can drag the divider to adjust the ratio when the "Split with Preview" toggle is on. - Iframes never remount across single↔split toggles — pane sizes are driven reactively from (activeTabKind, splitWithPreview), not by adding/removing the Splitpanes itself. - The user's preferred split ratio is remembered while they're dragging and reapplied next time split is enabled. - The inner splitter is CSS-hidden in single mode so the toggle button stays the single canonical way to flip layouts. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(raw_apps): split mode moves preview tab into the right pane Cleaner mental model for split-with-preview. Instead of "split the active tab + always keep the Preview tab around", the Split toggle now physically moves the Preview tab out of the bar and into a permanent right pane. When the user toggles split off, the Preview tab reappears in the bar like any other tab. - New `displayedTabs` derived: filters out the Preview tab when splitWithPreview is on, so the user sees only file/runnable tabs in the bar and a dedicated preview pane on the right. - `toggleSplit` redirects the active tab to the most recent file/runnable when the user toggles split on with Preview active, so they don't end up staring at an empty left pane. - Split toggle is now always visible — the user can flip both ways. The button label flips between "Pin preview to the right" and "Move preview back into a tab" to reflect what's about to happen. - reorderTabs preserves the Preview tab in the underlying `tabs` array even though it's filtered out of the drag set in split mode. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(raw_apps): VS Code-style "Preview" header on the right pane In split mode, the right pane now shows a small "Preview" tab-styled header anchored at its top-left — making the layout read like a real VS Code editor split, where each group has its own tab bar. - Header appears only when `splitWithPreview && activeTabKind !== 'preview'` (i.e. when the right pane is meaningfully separate from the left's content). In single mode with preview active, the right pane is the only thing visible and the main tab bar already labels it. - The header uses the same styling as an active tab: `bg-surface` on a `bg-surface-secondary` strip, h-8, text-xs, no border. - An X button next to the label toggles split off — equivalent to closing the editor in VS Code's split view (preview goes back to living as a tab in the main bar). Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(raw_apps): VS Code-style symmetric tab bars per pane Restructure the editor area so each pane is a self-contained "editor group" with its own tab bar at the top. The Splitpanes is now the topmost element — the divider runs floor-to-ceiling, splitting both the tab bars and the content. Layout (left pane = source / runnable, right pane = preview): - Left pane top: DraggableTabs (file/runnable tabs, Preview tab when split is off) + Split-toggle in the trailing slot. - Right pane top: a custom preview header — "Preview" label styled like an active tab on the left + the preview-affecting toolbar (bundler, inspector, rebuild) on the right. - Each pane independently sized via Splitpanes; iframes + the runnable panel stay mounted and toggled via `display` so state survives every transition. Trade-off: in single-mode with Preview active (paneA=0), the left tab bar is hidden along with the left pane. To switch back to a file tab the user uses the sidebar — which is exactly the discoverability surface tabs were meant to complement, not replace. Button placement by semantic ownership: - Layout control (Split toggle) — left side, with the editor. - Preview-affecting controls (bundler, inspector, rebuild) — right side, with the preview. No close-X on the right; the Split toggle on the left is the canonical way to flip layouts. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(raw_apps): keep tab bar visible when Preview is active in single mode The "VS Code-style" restructure put the tab bar inside the left Pane. When activeTabKind became 'preview' in single mode, the left pane collapsed to width 0 and the entire tab bar disappeared with it — leaving the user with no way to switch back to a file tab except via the sidebar. Move the main tab bar back above the inner Splitpanes (full width, always visible). The preview pseudo-header stays inside the right pane, carrying the bundler / inspector / rebuild toolbar. The splitter only goes through the content area below the tab bar, which is acceptable given how much friction the disappearing-tabs edge case caused. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(raw_apps): per-pane tab bars with mirrored single-mode lists Replace the single tab bar above the inner Splitpanes with one DraggableTabs per pane. Splitter now goes floor-to-ceiling through tabs AND content in split mode. In single mode both bars mirror the full tab list, so the visible pane always carries every tab — fixes the bug where activating Preview hid the tab strip. Clicking Preview while in split mode is a no-op (Preview is permanently visible in the right pane). Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(raw_apps): polish tab strip and sync editor font to text-xs * feat(raw_apps): move logs overlay onto the preview pane * refactor(splitpanes): extract pixel-aware minSize helper * fix(raw_apps): tab hydration loads correct file; closeTab in split mode * fix(raw_apps): lazy-mount UI Builder iframe + add dev:ui-builder script * feat(raw_apps): default split view, blue preview tab, fix dnd ghosting * fix(raw_apps): remove 1px splitter sliver beside preview in single view * fix(raw_apps): tab scrollbar on hover, fix thumb height + resize staleness * refactor(raw_apps): don't persist tab/split layout in localStorage * refactor(raw_apps): derive pane sizes + binding setter instead of effects * style(raw_apps): trim verbose comments * feat(raw_apps): accept appendLogs delta from the UI Builder iframe * fix(raw_apps): exit inspect mode on Escape * fix(raw_apps): Escape clears lingering inspector selection after pick * style(raw_apps): accent-selected styling for active tab, bg-surface strip * fix(raw_apps): address PR review nits (drop debug log, timer/reorder/pane-setter, dev script restore) * fix(raw_apps): clear inspector overlay on the preview iframe, not the source * style(raw_apps): neutral tab look (surface-tertiary/text-emphasis selected, text-hint idle) * chore(raw_apps): bump bundled ui_builder to 61b6fdd --------- Co-authored-by: Claude Opus 4.7 (1M context) --- frontend/package.json | 1 + frontend/scripts/ui_builder_artifact.json | 4 +- .../src/lib/components/ScriptEditor.svelte | 3 +- .../common/tabs/DraggableTabs.svelte | 251 ++++++ .../components/raw_apps/RawAppEditor.svelte | 835 +++++++++++++++--- frontend/src/lib/utils/splitpaneSizing.ts | 9 + 6 files changed, 966 insertions(+), 137 deletions(-) create mode 100644 frontend/src/lib/components/common/tabs/DraggableTabs.svelte create mode 100644 frontend/src/lib/utils/splitpaneSizing.ts diff --git a/frontend/package.json b/frontend/package.json index 3a14adebe0..7a34dc4abc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -3,6 +3,7 @@ "version": "1.708.0", "scripts": { "dev": "vite dev", + "dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev", "build": "vite build", "build:utils": "vite build --config sharedUtils/vite.sharedUtils.config.js", "preview": "vite preview", diff --git a/frontend/scripts/ui_builder_artifact.json b/frontend/scripts/ui_builder_artifact.json index 1cfc1aef8c..8b1a4892f6 100644 --- a/frontend/scripts/ui_builder_artifact.json +++ b/frontend/scripts/ui_builder_artifact.json @@ -1,5 +1,5 @@ { "baseUrl": "https://pub-06154ed168a24e73a86ab84db6bf15d8.r2.dev", - "version": "6715153", - "sha256": "1485930ea5f5309e4bdc09a55aae72eae8230eb74f0928715a0e6fe610703d9b" + "version": "61b6fdd", + "sha256": "d7c316b4429442eed9462756db0fdf13128849b5b9adf4a7b7acc7a26b1a7280" } diff --git a/frontend/src/lib/components/ScriptEditor.svelte b/frontend/src/lib/components/ScriptEditor.svelte index 7e8269913d..9fe173f0ba 100644 --- a/frontend/src/lib/components/ScriptEditor.svelte +++ b/frontend/src/lib/components/ScriptEditor.svelte @@ -1,5 +1,6 @@ + + + +{#snippet tabButton(tab: TabItem)} + {@const isActive = tab.id === activeId} + {@const Icon = tab.icon} + + +{/snippet} + +
+
+
+ +
+
+ {#each pinnedLeft as tab (tab.id)} + {@render tabButton(tab)} + {/each} + +
+ {#each dndMiddle as tab (tab.id)} +
+ {@render tabButton(tab)} +
+ {/each} +
+ + {#each pinnedRight as tab (tab.id)} + {@render tabButton(tab)} + {/each} + + + +
+
+
+ +
+
+
+
+ + {#if trailing} +
+ {@render trailing()} +
+ {/if} +
+ + diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index 5e0a6a1b09..aa6658b4a9 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -1,5 +1,6 @@ @@ -144,6 +154,52 @@ /> {/snippet} + + {#if isServiceAccount} + Service account role + + {#snippet children({ item })} + + + + {/snippet} + + + {#if serviceAccountRole === 'developer'} +
+ + + Add to wm_deployers + + Recommended when this service account will be used as a wmill sync push + / CI deploy identity. Members of wm_deployers can deploy on behalf of + other users in the target workspace. + Learn more. + + +
+ {/if} + {/if}