From db5e03610da325288d53afdbca94b9cbfc7ceace Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Tue, 24 Mar 2026 20:18:36 +0100 Subject: [PATCH 01/13] feat: add instance-level AI settings (#8453) * feat: add instance-level AI settings with workspace fallback Co-Authored-By: Claude Opus 4.6 * feat: add AI step to onboarding setup wizard Co-Authored-By: Claude Opus 4.5 * fix: thread workspace prop through resource editor and disable chat offset Co-Authored-By: Claude Opus 4.5 * Revert "fix: thread workspace prop through resource editor and disable chat offset" This reverts commit 9fea9cc0c239f6432d1fef1487c45e74ab752e21. * fix: set workspace store and disable chat offset during AI setup step Co-Authored-By: Claude Opus 4.5 * fix: thread workspace and disableChatOffset props through resource editors Co-Authored-By: Claude Opus 4.5 * fix: populate workspace and user stores for AI step path component Co-Authored-By: Claude Opus 4.6 * fix: initialize AI clients for test key during onboarding Co-Authored-By: Claude Opus 4.6 * refactor: extract AI config state into InstanceAISettings component Co-Authored-By: Claude Opus 4.6 * refactor: move AI config state ownership into AISettings component Co-Authored-By: Claude Opus 4.5 * Persist instance AI settings before navigation * Reload effective workspace AI state after save * Scope AI key tests to the rendered workspace * Add post-create AI onboarding for new workspaces * Unify instance AI settings header * Fix instance AI drawer offset on workspace selection * Add instance AI fallback settings behavior * Update sqlx metadata * Update sqlx metadata * Clarify active instance AI in workspace settings * Refresh workspace AI state after instance AI save * Declare instance AI summary in API schema * Normalize empty instance AI config handling * Clean up workspace AI settings UI * Unify AI config provider checks * Split AI settings metadata from effective config * Propagate instance AI cache invalidation across servers * Fix AI settings dirty state tracking * Update sqlx metadata --------- Co-authored-by: Claude Opus 4.6 --- ...4414c59199964cee53ef2ee6b35a78cf0c49a.json | 20 + backend/src/main.rs | 9 +- .../tests/workspaces.rs | 171 +++-- backend/windmill-api-settings/src/lib.rs | 16 +- .../windmill-api-workspaces/src/workspaces.rs | 126 +++- backend/windmill-api/openapi.yaml | 71 +- backend/windmill-api/src/ai.rs | 213 ++++-- backend/windmill-api/src/workspaces.rs | 73 +- backend/windmill-common/src/ai_cache.rs | 11 + .../windmill-common/src/global_settings.rs | 1 + .../windmill-common/src/instance_config.rs | 7 + backend/windmill-common/src/lib.rs | 1 + .../lib/components/AppConnectDrawer.svelte | 6 +- .../src/lib/components/AppConnectInner.svelte | 26 +- .../src/lib/components/ResourceEditor.svelte | 18 +- .../components/ResourceEditorDrawer.svelte | 8 +- .../src/lib/components/ResourcePicker.svelte | 20 +- .../lib/components/SuperadminSettings.svelte | 1 + .../components/SuperadminSettingsInner.svelte | 6 +- .../lib/components/copilot/TestAIKey.svelte | 3 + .../copilot/chat/openai-responses.ts | 22 +- frontend/src/lib/components/copilot/lib.ts | 93 +-- .../src/lib/components/instanceSettings.ts | 13 + .../InstanceAISettings.svelte | 149 ++++ .../components/settings/AIPromptsModal.svelte | 9 +- .../workspaceSettings/AISettings.svelte | 652 +++++++++++------- .../workspaceSettings/CreateWorkspace.svelte | 533 ++++++++------ .../InstanceFallbackSettings.svelte | 93 +++ .../(user)/instance_settings/+page.svelte | 92 ++- .../(logged)/workspace_settings/+page.svelte | 139 ++-- 30 files changed, 1844 insertions(+), 758 deletions(-) create mode 100644 backend/.sqlx/query-975099ff6b07718ea94bcb5f84a4414c59199964cee53ef2ee6b35a78cf0c49a.json create mode 100644 backend/windmill-common/src/ai_cache.rs create mode 100644 frontend/src/lib/components/instanceSettings/InstanceAISettings.svelte create mode 100644 frontend/src/lib/components/workspaceSettings/InstanceFallbackSettings.svelte diff --git a/backend/.sqlx/query-975099ff6b07718ea94bcb5f84a4414c59199964cee53ef2ee6b35a78cf0c49a.json b/backend/.sqlx/query-975099ff6b07718ea94bcb5f84a4414c59199964cee53ef2ee6b35a78cf0c49a.json new file mode 100644 index 0000000000..012316fd4a --- /dev/null +++ b/backend/.sqlx/query-975099ff6b07718ea94bcb5f84a4414c59199964cee53ef2ee6b35a78cf0c49a.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT value FROM global_settings WHERE name = 'ai_config'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "value", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false + ] + }, + "hash": "975099ff6b07718ea94bcb5f84a4414c59199964cee53ef2ee6b35a78cf0c49a" +} diff --git a/backend/src/main.rs b/backend/src/main.rs index 17567167a3..bba51ada6b 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -36,9 +36,10 @@ use windmill_common::ee_oss::{ use windmill_common::{ agent_workers::AgentConfig, + ai_cache::bump_instance_ai_config_revision, global_settings::{ - APP_WORKSPACED_ROUTE_SETTING, AUDIT_LOG_RETENTION_DAYS_SETTING, BASE_URL_SETTING, - BUNFIG_INSTALL_SCOPES_SETTING, CRITICAL_ALERTS_ON_DB_OVERSIZE_SETTING, + AI_CONFIG_SETTING, APP_WORKSPACED_ROUTE_SETTING, AUDIT_LOG_RETENTION_DAYS_SETTING, + BASE_URL_SETTING, BUNFIG_INSTALL_SCOPES_SETTING, CRITICAL_ALERTS_ON_DB_OVERSIZE_SETTING, CRITICAL_ALERTS_ON_TOKEN_EXPIRY_SETTING, CRITICAL_ALERT_MUTE_UI_SETTING, CRITICAL_ERROR_CHANNELS_SETTING, CUSTOM_TAGS_SETTING, DEFAULT_TAGS_PER_WORKSPACE_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING, EMAIL_DOMAIN_SETTING, ENV_SETTINGS, @@ -1813,6 +1814,10 @@ async fn process_notify_event( tracing::error!(error = %e, "Could not reload app workspaced route setting"); } } + AI_CONFIG_SETTING => { + tracing::info!("AI config setting changed, bumping instance AI cache revision"); + bump_instance_ai_config_revision(); + } OTEL_SETTING => { tracing::info!("OTEL setting changed, restarting"); send_delayed_killpill(tx, 4, "OTEL setting change").await; diff --git a/backend/windmill-api-integration-tests/tests/workspaces.rs b/backend/windmill-api-integration-tests/tests/workspaces.rs index 58c07965dc..55e00eb05d 100644 --- a/backend/windmill-api-integration-tests/tests/workspaces.rs +++ b/backend/windmill-api-integration-tests/tests/workspaces.rs @@ -82,12 +82,10 @@ async fn test_workspace_endpoints(db: Pool) -> anyhow::Result<()> { assert_eq!(resp.status(), 200); // --- allowed_domain_auto_invite --- - let resp = authed(client().get(format!( - "{global_base}/allowed_domain_auto_invite" - ))) - .send() - .await - .unwrap(); + let resp = authed(client().get(format!("{global_base}/allowed_domain_auto_invite"))) + .send() + .await + .unwrap(); assert_eq!(resp.status(), 200); resp.json::().await?; @@ -213,12 +211,10 @@ async fn test_workspace_endpoints(db: Pool) -> anyhow::Result<()> { resp.json::>().await?; // --- get_dependents (empty, no dependencies exist) --- - let resp = authed(client().get(format!( - "{base}/get_dependents/u/test-user/nonexistent" - ))) - .send() - .await - .unwrap(); + let resp = authed(client().get(format!("{base}/get_dependents/u/test-user/nonexistent"))) + .send() + .await + .unwrap(); assert_eq!(resp.status(), 200); let dependents = resp.json::>().await?; assert!(dependents.is_empty()); @@ -425,13 +421,11 @@ async fn test_workspace_endpoints(db: Pool) -> anyhow::Result<()> { ); // --- edit_large_file_storage_config --- - let resp = authed(client().post(format!( - "{base}/edit_large_file_storage_config" - ))) - .json(&json!({"large_file_storage": null})) - .send() - .await - .unwrap(); + let resp = authed(client().post(format!("{base}/edit_large_file_storage_config"))) + .json(&json!({"large_file_storage": null})) + .send() + .await + .unwrap(); assert_eq!( resp.status(), 200, @@ -532,9 +526,7 @@ async fn test_workspace_endpoints(db: Pool) -> anyhow::Result<()> { .unwrap(); let invites = resp.json::>().await?; assert!( - invites - .iter() - .any(|i| i["email"] == "invited@example.com"), + invites.iter().any(|i| i["email"] == "invited@example.com"), "invite not found: {:?}", invites ); @@ -549,12 +541,7 @@ async fn test_workspace_endpoints(db: Pool) -> anyhow::Result<()> { .send() .await .unwrap(); - assert_eq!( - resp.status(), - 201, - "delete_invite: {}", - resp.text().await? - ); + assert_eq!(resp.status(), 201, "delete_invite: {}", resp.text().await?); // ===== Critical alerts (EE-gated, returns 404 in OSS) ===== @@ -624,12 +611,7 @@ async fn test_workspace_endpoints(db: Pool) -> anyhow::Result<()> { .send() .await .unwrap(); - assert_eq!( - resp.status(), - 200, - "create_fork: {}", - resp.text().await? - ); + assert_eq!(resp.status(), 200, "create_fork: {}", resp.text().await?); // verify fork exists let resp = authed(client().post(format!("{global_base}/exists"))) @@ -702,13 +684,122 @@ async fn test_workspace_endpoints(db: Pool) -> anyhow::Result<()> { assert_eq!(resp.json::().await?, false); // --- create_workspace_require_superadmin --- - let resp = authed(client().get(format!( - "{global_base}/create_workspace_require_superadmin" - ))) - .send() - .await - .unwrap(); + let resp = authed(client().get(format!("{global_base}/create_workspace_require_superadmin"))) + .send() + .await + .unwrap(); assert_eq!(resp.status(), 200); Ok(()) } + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_get_copilot_settings_state_reports_instance_ai_fallback_flags( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/w/test-workspace/workspaces"); + + let instance_ai_config = json!({ + "providers": { + "openai": { + "resource_path": "u/test-user/openai_instance", + "models": ["gpt-4o-mini"] + } + } + }); + let workspace_ai_config = json!({ + "providers": { + "anthropic": { + "resource_path": "u/test-user/anthropic_workspace", + "models": ["claude-3-5-haiku-latest"] + } + } + }); + + sqlx::query("UPDATE workspace_settings SET ai_config = NULL WHERE workspace_id = $1") + .bind("test-workspace") + .execute(&db) + .await?; + sqlx::query( + "INSERT INTO global_settings (name, value) VALUES ($1, $2) \ + ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value", + ) + .bind("ai_config") + .bind(instance_ai_config) + .execute(&db) + .await?; + + let resp = authed(client().get(format!("{base}/get_copilot_settings_state"))) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + let settings = resp.json::().await?; + assert_eq!(settings["has_instance_ai_config"], true); + assert_eq!(settings["uses_instance_ai_config"], true); + assert_eq!( + settings["instance_ai_summary"]["providers"][0]["provider"], + "openai" + ); + assert_eq!( + settings["instance_ai_summary"]["providers"][0]["models"][0], + "gpt-4o-mini" + ); + + sqlx::query("UPDATE workspace_settings SET ai_config = $1 WHERE workspace_id = $2") + .bind(workspace_ai_config) + .bind("test-workspace") + .execute(&db) + .await?; + + let resp = authed(client().get(format!("{base}/get_copilot_settings_state"))) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + let settings = resp.json::().await?; + assert_eq!(settings["has_instance_ai_config"], true); + assert_eq!(settings["uses_instance_ai_config"], false); + assert_eq!( + settings["instance_ai_summary"]["providers"][0]["provider"], + "openai" + ); + + Ok(()) +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_get_copilot_info_ignores_empty_instance_ai_row( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/w/test-workspace/workspaces"); + + sqlx::query("UPDATE workspace_settings SET ai_config = NULL WHERE workspace_id = $1") + .bind("test-workspace") + .execute(&db) + .await?; + sqlx::query( + "INSERT INTO global_settings (name, value) VALUES ($1, $2) \ + ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value", + ) + .bind("ai_config") + .bind(json!({})) + .execute(&db) + .await?; + + let resp = authed(client().get(format!("{base}/get_copilot_info"))) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + let settings = resp.json::().await?; + assert!(settings["providers"].is_null()); + + Ok(()) +} diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs index e64ca86ea8..dbe80aca94 100644 --- a/backend/windmill-api-settings/src/lib.rs +++ b/backend/windmill-api-settings/src/lib.rs @@ -38,11 +38,12 @@ use windmill_common::ee_oss::{send_critical_alert, CriticalAlertKind, CriticalEr #[cfg(all(feature = "private", feature = "enterprise"))] use windmill_common::secret_backend::{SecretMigrationReport, VaultSettings}; use windmill_common::{ + ai_cache::bump_instance_ai_config_revision, email_oss::send_email_plain_text, error::{self, JsonResult, Result}, get_database_url, global_settings::{ - APP_WORKSPACED_ROUTE_SETTING, AUTOMATE_USERNAME_CREATION_SETTING, + 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, HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING, WS_BASE_URL_SETTING, @@ -284,6 +285,7 @@ pub async fn set_global_setting_internal( key: String, value: serde_json::Value, ) -> error::Result<()> { + let should_bump_instance_ai_revision = key == AI_CONFIG_SETTING; let value = if key == "retention_period_secs" { instance_config::clamp_retention_period(value) } else { @@ -325,6 +327,10 @@ pub async fn set_global_setting_internal( } }; + if should_bump_instance_ai_revision { + bump_instance_ai_config_revision(); + } + Ok(()) } @@ -471,6 +477,10 @@ async fn set_instance_config( let current_map = current.global_settings.to_settings_map(); let settings_diff = instance_config::diff_global_settings(¤t_map, &desired_map, ApplyMode::Merge); + let ai_config_changed = settings_diff + .upserts + .iter() + .any(|(key, _)| key == AI_CONFIG_SETTING); for (key, value) in &settings_diff.upserts { run_setting_pre_write_hook(&db, key, value).await?; @@ -479,6 +489,10 @@ async fn set_instance_config( instance_config::apply_settings_diff(&db, &settings_diff) .await .map_err(|e| error::Error::internal_err(e.to_string()))?; + + if ai_config_changed { + bump_instance_ai_config_revision(); + } } if !desired.worker_configs.is_empty() { diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 35e1e83b21..8cd8f27d9b 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -82,6 +82,10 @@ pub fn workspaced_service() -> Router { .route("/get_dependents/*imported_path", get(get_dependents)) .route("/get_dependents_amounts", post(get_dependents_amounts)) .route("/get_settings", get(get_settings)) + .route( + "/get_copilot_settings_state", + get(get_copilot_settings_state), + ) .route("/get_deploy_to", get(get_deploy_to)) .route("/edit_slack_command", post(edit_slack_command)) .route( @@ -257,6 +261,35 @@ pub struct WorkspaceSettings { pub public_app_execution_limit_per_minute: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct CopilotSettingsState { + pub has_instance_ai_config: bool, + pub uses_instance_ai_config: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub instance_ai_summary: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct InstanceAIProviderSummary { + pub provider: String, + pub models: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct InstanceAIModelSummary { + pub provider: String, + pub model: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct InstanceAISummary { + pub providers: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub default_model: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub code_completion_model: Option, +} + /// #[derive(sqlx::Type, Serialize, Deserialize, Debug)] // #[sqlx(type_name = "WORKSPACE_KEY_KIND", rename_all = "lowercase")] // pub enum WorkspaceKeyKind { @@ -608,15 +641,106 @@ async fn get_settings( .await .map_err(|e| Error::internal_err(format!("getting settings: {e:#}")))?; + let mut settings = not_found_if_none(settings, "workspace settings", &w_id)?; tx.commit().await?; - let mut settings = not_found_if_none(settings, "workspace settings", &w_id)?; if !authed.is_admin { settings.slack_oauth_client_secret = None; } Ok(Json(settings)) } +async fn get_copilot_settings_state( + authed: ApiAuthed, + Path(w_id): Path, + Extension(user_db): Extension, +) -> JsonResult { + let mut tx = user_db.begin(&authed).await?; + let workspace_ai_config = sqlx::query_scalar!( + "SELECT ai_config FROM workspace_settings WHERE workspace_id = $1", + &w_id + ) + .fetch_optional(&mut *tx) + .await + .map_err(|e| Error::internal_err(format!("getting workspace ai settings: {e:#}")))?; + let workspace_ai_config = not_found_if_none(workspace_ai_config, "workspace settings", &w_id)?; + let instance_ai_config: Option = + sqlx::query_scalar("SELECT value FROM global_settings WHERE name = 'ai_config'") + .fetch_optional(&mut *tx) + .await + .map_err(|e| Error::internal_err(format!("getting instance ai settings: {e:#}")))?; + tx.commit().await?; + + Ok(Json(build_copilot_settings_state( + has_ai_providers(workspace_ai_config.as_ref()), + instance_ai_config.as_ref(), + ))) +} + +pub fn has_ai_providers(config: Option<&serde_json::Value>) -> bool { + config + .and_then(|value| value.get("providers")) + .and_then(|providers| providers.as_object()) + .map(|providers| !providers.is_empty()) + .unwrap_or(false) +} + +pub fn build_copilot_settings_state( + has_workspace_ai_config: bool, + instance_ai_config: Option<&serde_json::Value>, +) -> CopilotSettingsState { + let has_instance_ai_config = has_ai_providers(instance_ai_config); + CopilotSettingsState { + has_instance_ai_config, + uses_instance_ai_config: !has_workspace_ai_config && has_instance_ai_config, + instance_ai_summary: build_instance_ai_summary(instance_ai_config), + } +} + +pub fn build_instance_ai_summary(config: Option<&serde_json::Value>) -> Option { + let config = config?; + if !has_ai_providers(Some(config)) { + return None; + } + let providers = config.get("providers")?.as_object()?; + + let mut provider_summaries = providers + .iter() + .map(|(provider, provider_config)| InstanceAIProviderSummary { + provider: provider.clone(), + models: provider_config + .get("models") + .and_then(|models| models.as_array()) + .map(|models| { + models + .iter() + .filter_map(|model| model.as_str().map(ToOwned::to_owned)) + .collect::>() + }) + .unwrap_or_default(), + }) + .collect::>(); + + provider_summaries.sort_by(|left, right| left.provider.cmp(&right.provider)); + + Some(InstanceAISummary { + providers: provider_summaries, + default_model: extract_instance_ai_model_summary(config, "default_model"), + code_completion_model: extract_instance_ai_model_summary(config, "code_completion_model"), + }) +} + +fn extract_instance_ai_model_summary( + config: &serde_json::Value, + key: &str, +) -> Option { + let model_config = config.get(key)?.as_object()?; + Some(InstanceAIModelSummary { + provider: model_config.get("provider")?.as_str()?.to_owned(), + model: model_config.get("model")?.as_str()?.to_owned(), + }) +} + #[derive(Serialize)] struct DeployTo { deploy_to: Option, diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 8ef9caf936..d55596bc72 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -3191,9 +3191,49 @@ paths: "200": description: status content: - text/plain: + application/json: schema: - type: string + type: object + properties: + effective_ai_config: + $ref: "#/components/schemas/AIConfig" + has_instance_ai_config: + type: boolean + uses_instance_ai_config: + type: boolean + instance_ai_summary: + $ref: "#/components/schemas/InstanceAISummary" + required: + - effective_ai_config + - has_instance_ai_config + - uses_instance_ai_config + + /w/{workspace}/workspaces/get_copilot_settings_state: + get: + summary: get copilot settings state + operationId: getCopilotSettingsState + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + + responses: + "200": + description: status + content: + application/json: + schema: + type: object + properties: + has_instance_ai_config: + type: boolean + uses_instance_ai_config: + type: boolean + instance_ai_summary: + $ref: "#/components/schemas/InstanceAISummary" + required: + - has_instance_ai_config + - uses_instance_ai_config /w/{workspace}/workspaces/get_copilot_info: get: @@ -18792,6 +18832,33 @@ components: minimum: 1 maximum: 2000000 + InstanceAIProviderSummary: + type: object + properties: + provider: + $ref: "#/components/schemas/AIProvider" + models: + type: array + items: + type: string + required: + - provider + - models + + InstanceAISummary: + type: object + properties: + providers: + type: array + items: + $ref: "#/components/schemas/InstanceAIProviderSummary" + default_model: + $ref: "#/components/schemas/AIProviderModel" + code_completion_model: + $ref: "#/components/schemas/AIProviderModel" + required: + - providers + Alert: type: object properties: diff --git a/backend/windmill-api/src/ai.rs b/backend/windmill-api/src/ai.rs index ed9cdb366c..d77a0fa8cc 100644 --- a/backend/windmill-api/src/ai.rs +++ b/backend/windmill-api/src/ai.rs @@ -16,6 +16,7 @@ use serde_json::{json, value::RawValue}; use std::collections::HashMap; use std::time::Duration; use windmill_audit::{audit_oss::audit_log, ActionKind}; +use windmill_common::ai_cache::current_instance_ai_config_revision; use windmill_common::ai_providers::{ empty_string_as_none, AIPlatform, AIProvider, ProviderConfig, ProviderModel, }; @@ -127,6 +128,10 @@ lazy_static::lazy_static! { }; } +pub(crate) fn invalidate_ai_request_cache_for_workspace(workspace_id: &str) { + AI_REQUEST_CACHE.retain(|(cached_workspace_id, _), _| cached_workspace_id != workspace_id); +} + #[derive(Deserialize, Debug)] struct AIOAuthResource { client_id: String, @@ -373,8 +378,7 @@ impl AIRequestConfig { let is_azure = provider.is_azure_openai(base_url); let is_anthropic = matches!(provider, AIProvider::Anthropic); - let is_anthropic_vertex = - is_anthropic && self.platform == AIPlatform::GoogleVertexAi; + let is_anthropic_vertex = is_anthropic && self.platform == AIPlatform::GoogleVertexAi; let is_anthropic_sdk = headers.get("X-Anthropic-SDK").is_some(); let is_google_ai = matches!(provider, AIProvider::GoogleAI); @@ -483,18 +487,27 @@ impl AIRequestConfig { pub struct ExpiringAIRequestConfig { config: AIRequestConfig, expires_at: std::time::Instant, + instance_ai_config_revision: Option, } impl ExpiringAIRequestConfig { - fn new(config: AIRequestConfig) -> Self { - Self { config, expires_at: std::time::Instant::now() + std::time::Duration::from_secs(60) } + fn new(config: AIRequestConfig, instance_ai_config_revision: Option) -> Self { + Self { + config, + expires_at: std::time::Instant::now() + std::time::Duration::from_secs(60), + instance_ai_config_revision, + } } + fn is_expired(&self) -> bool { self.expires_at < std::time::Instant::now() + || self + .instance_ai_config_revision + .is_some_and(|revision| revision != current_instance_ai_config_revision()) } } -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, Default)] pub struct AIConfig { #[serde(skip_serializing_if = "Option::is_none")] pub providers: Option>, @@ -508,6 +521,14 @@ pub struct AIConfig { pub max_tokens_per_model: Option>, } +impl AIConfig { + pub fn has_providers(&self) -> bool { + self.providers + .as_ref() + .is_some_and(|providers| !providers.is_empty()) + } +} + /// Anthropic API version for Google Vertex AI const ANTHROPIC_VERSION_VERTEX: &str = "vertex-2023-10-16"; @@ -762,47 +783,76 @@ async fn proxy( request_cache.config } _ => { - let (resource_path, save_to_cache) = if let Some(resource_path) = forced_resource_path { - // forced resource path - (resource_path, false) - } else { - let ai_config = sqlx::query_scalar!( - "SELECT ai_config FROM workspace_settings WHERE workspace_id = $1", - &w_id - ) - .fetch_one(&db) - .await?; + let (resource_path, save_to_cache, resource_workspace, instance_ai_config_revision) = + if let Some(resource_path) = forced_resource_path { + // forced resource path + (resource_path, false, w_id.clone(), None) + } else { + let workspace_ai_config = sqlx::query_scalar!( + "SELECT ai_config FROM workspace_settings WHERE workspace_id = $1", + &w_id + ) + .fetch_one(&db) + .await?; - if ai_config.is_none() { - return Err(Error::internal_err( - "AI resource not configured".to_string(), - )); - } + let (ai_config_value, resource_workspace, instance_ai_config_revision) = { + let ws_has_config = workspace_ai_config + .as_ref() + .and_then(|v| serde_json::from_value::(v.clone()).ok()) + .is_some_and(|config| config.has_providers()); - let mut ai_config = serde_json::from_value::(ai_config.unwrap()) - .map_err(|e| Error::BadRequest(e.to_string()))?; + if ws_has_config { + (workspace_ai_config.unwrap(), w_id.clone(), None) + } else { + let instance_config = sqlx::query_scalar!( + "SELECT value FROM global_settings WHERE name = 'ai_config'" + ) + .fetch_optional(&db) + .await?; - let provider_config = ai_config - .providers - .as_mut() - .map(|providers| providers.remove(&provider)) - .flatten() - .ok_or_else(|| { - Error::BadRequest(format!("Provider {:?} not configured", provider)) - })?; + match instance_config { + Some(config) => ( + config, + "admins".to_string(), + Some(current_instance_ai_config_revision()), + ), + None => { + return Err(Error::internal_err( + "AI resource not configured".to_string(), + )); + } + } + } + }; - if provider_config.resource_path.is_empty() { - return Err(Error::BadRequest("Resource path is empty".to_string())); - } + let mut ai_config = serde_json::from_value::(ai_config_value) + .map_err(|e| Error::BadRequest(e.to_string()))?; - (provider_config.resource_path, true) - }; + let provider_config = ai_config + .providers + .as_mut() + .and_then(|providers| providers.remove(&provider)) + .ok_or_else(|| { + Error::BadRequest(format!("Provider {:?} not configured", provider)) + })?; - let resource= sqlx::query_scalar!( - "SELECT value as \"value: sqlx::types::Json>\" FROM resource WHERE path = $1 AND workspace_id = $2", - &resource_path, - &w_id + if provider_config.resource_path.is_empty() { + return Err(Error::BadRequest("Resource path is empty".to_string())); + } + + ( + provider_config.resource_path, + true, + resource_workspace, + instance_ai_config_revision, + ) + }; + + 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? .ok_or_else(|| Error::NotFound(format!("Could not find the resource {}, update the resource path in the workspace settings", resource_path)))? @@ -811,11 +861,15 @@ async fn proxy( let resource = serde_json::from_str::(resource.0.get()) .map_err(|e| Error::BadRequest(e.to_string()))?; - let request_config = AIRequestConfig::new(&provider, &db, &w_id, resource).await?; + let request_config = + AIRequestConfig::new(&provider, &db, &resource_workspace, resource).await?; if save_to_cache { AI_REQUEST_CACHE.insert( (w_id.clone(), provider.clone()), - ExpiringAIRequestConfig::new(request_config.clone()), + ExpiringAIRequestConfig::new( + request_config.clone(), + instance_ai_config_revision, + ), ); } request_config @@ -858,9 +912,7 @@ async fn proxy( "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 - } + "models" => crate::google::handle_google_ai_models(api_key, base_url, is_vertex).await, _ => Err(Error::BadRequest(format!( "Unsupported Google AI path: {}", ai_path @@ -1005,3 +1057,76 @@ async fn proxy( }; Ok((status_code, headers, body)) } + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{LazyLock, Mutex}; + use windmill_common::ai_cache::bump_instance_ai_config_revision; + use windmill_common::ai_providers::AIPlatform; + + static TEST_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); + + fn sample_request_config() -> AIRequestConfig { + AIRequestConfig { + base_url: "https://example.com".to_string(), + api_key: None, + 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: AIPlatform::Standard, + enable_1m_context: false, + custom_headers: HashMap::new(), + } + } + + #[test] + fn invalidates_all_cached_providers_for_workspace() { + let _guard = TEST_LOCK.lock().unwrap(); + AI_REQUEST_CACHE.clear(); + AI_REQUEST_CACHE.insert( + ("workspace-a".to_string(), AIProvider::OpenAI), + ExpiringAIRequestConfig::new(sample_request_config(), None), + ); + AI_REQUEST_CACHE.insert( + ("workspace-a".to_string(), AIProvider::Anthropic), + ExpiringAIRequestConfig::new(sample_request_config(), None), + ); + AI_REQUEST_CACHE.insert( + ("workspace-b".to_string(), AIProvider::OpenAI), + ExpiringAIRequestConfig::new(sample_request_config(), None), + ); + + invalidate_ai_request_cache_for_workspace("workspace-a"); + + assert!(AI_REQUEST_CACHE + .get(&("workspace-a".to_string(), AIProvider::OpenAI)) + .is_none()); + assert!(AI_REQUEST_CACHE + .get(&("workspace-a".to_string(), AIProvider::Anthropic)) + .is_none()); + assert!(AI_REQUEST_CACHE + .get(&("workspace-b".to_string(), AIProvider::OpenAI)) + .is_some()); + } + + #[test] + fn instance_backed_cache_entries_expire_when_revision_changes() { + let _guard = TEST_LOCK.lock().unwrap(); + AI_REQUEST_CACHE.clear(); + + let cached = ExpiringAIRequestConfig::new( + sample_request_config(), + Some(current_instance_ai_config_revision()), + ); + assert!(!cached.is_expired()); + + bump_instance_ai_config_revision(); + + assert!(cached.is_expired()); + } +} diff --git a/backend/windmill-api/src/workspaces.rs b/backend/windmill-api/src/workspaces.rs index 50c3913b47..3438ad97af 100644 --- a/backend/windmill-api/src/workspaces.rs +++ b/backend/windmill-api/src/workspaces.rs @@ -8,8 +8,9 @@ // Re-export everything from windmill-api-workspaces pub use windmill_api_workspaces::workspaces::*; +use windmill_api_workspaces::workspaces::{build_copilot_settings_state, InstanceAISummary}; -use crate::ai::{AIConfig, AI_REQUEST_CACHE}; +use crate::ai::{invalidate_ai_request_cache_for_workspace, AIConfig}; use crate::db::ApiAuthed; use crate::teams_oss::{ connect_teams, edit_teams_command, run_teams_message_test_job, @@ -24,7 +25,7 @@ use axum::{ use windmill_audit::audit_oss::audit_log; use windmill_audit::ActionKind; use windmill_common::{ - error::{Error, JsonResult, Result}, + error::{Error, JsonResult}, utils::require_admin, DB, }; @@ -34,6 +35,9 @@ use windmill_git_sync::{handle_deployment_metadata, DeployedObject}; use axum::extract::Query; #[cfg(feature = "enterprise")] use serde::Deserialize; +use serde::Serialize; +#[cfg(feature = "enterprise")] +use windmill_common::error::Result; #[cfg(feature = "enterprise")] use windmill_common::utils::require_admin_or_devops; @@ -83,7 +87,7 @@ async fn edit_copilot_config( Path(w_id): Path, ApiAuthed { is_admin, username, .. }: ApiAuthed, Json(ai_config): Json, -) -> Result { +) -> JsonResult { require_admin(is_admin, &username)?; if let Some(ref custom_prompts) = ai_config.custom_prompts { @@ -109,11 +113,7 @@ async fn edit_copilot_config( .execute(&mut *tx) .await?; - if let Some(ref providers) = ai_config.providers { - for provider in providers.keys() { - AI_REQUEST_CACHE.remove(&(w_id.clone(), provider.clone())); - } - } + invalidate_ai_request_cache_for_workspace(&w_id); audit_log( &mut *tx, @@ -139,37 +139,66 @@ async fn edit_copilot_config( ) .await?; - Ok(format!("Edit copilot config for workspace {}", &w_id)) + let workspace_has_config = ai_config.has_providers(); + let instance_ai_config = + sqlx::query_scalar!("SELECT value FROM global_settings WHERE name = 'ai_config'") + .fetch_optional(&db) + .await?; + let settings_state = + build_copilot_settings_state(workspace_has_config, instance_ai_config.as_ref()); + let effective_ai_config = if workspace_has_config { + ai_config + } else if let Some(instance_ai_config) = instance_ai_config { + serde_json::from_value::(instance_ai_config).unwrap_or_default() + } else { + AIConfig::default() + }; + + Ok(Json(EditCopilotConfigResponse { + effective_ai_config, + has_instance_ai_config: settings_state.has_instance_ai_config, + uses_instance_ai_config: settings_state.uses_instance_ai_config, + instance_ai_summary: settings_state.instance_ai_summary, + })) +} + +#[derive(Serialize)] +struct EditCopilotConfigResponse { + effective_ai_config: AIConfig, + has_instance_ai_config: bool, + uses_instance_ai_config: bool, + #[serde(skip_serializing_if = "Option::is_none")] + instance_ai_summary: Option, } async fn get_copilot_info( Extension(db): Extension, Path(w_id): Path, ) -> JsonResult { - let mut tx = db.begin().await?; - let copilot_info = sqlx::query_scalar!( + let workspace_ai_config = sqlx::query_scalar!( "SELECT ai_config as \"ai_config: sqlx::types::Json\" FROM workspace_settings WHERE workspace_id = $1", &w_id ) - .fetch_one(&mut *tx) + .fetch_one(&db) .await .map_err(|e| { Error::internal_err(format!( "getting ai config: {e:#}" )) })?; - tx.commit().await?; - if let Some(sqlx::types::Json(copilot_info)) = copilot_info { - Ok(Json(copilot_info)) + if let Some(workspace_ai_config) = workspace_ai_config.filter(|c| c.0.has_providers()) { + Ok(Json(workspace_ai_config.0)) + } else if let Some(instance_config) = + sqlx::query_scalar!("SELECT value FROM global_settings WHERE name = 'ai_config'") + .fetch_optional(&db) + .await? + { + Ok(Json( + serde_json::from_value::(instance_config).unwrap_or_default(), + )) } else { - Ok(Json(AIConfig { - providers: None, - default_model: None, - code_completion_model: None, - custom_prompts: None, - max_tokens_per_model: None, - })) + Ok(Json(AIConfig::default())) } } diff --git a/backend/windmill-common/src/ai_cache.rs b/backend/windmill-common/src/ai_cache.rs new file mode 100644 index 0000000000..11bcd42254 --- /dev/null +++ b/backend/windmill-common/src/ai_cache.rs @@ -0,0 +1,11 @@ +use std::sync::atomic::{AtomicU64, Ordering}; + +static INSTANCE_AI_CONFIG_REVISION: AtomicU64 = AtomicU64::new(0); + +pub fn current_instance_ai_config_revision() -> u64 { + INSTANCE_AI_CONFIG_REVISION.load(Ordering::SeqCst) +} + +pub fn bump_instance_ai_config_revision() -> u64 { + INSTANCE_AI_CONFIG_REVISION.fetch_add(1, Ordering::SeqCst) + 1 +} diff --git a/backend/windmill-common/src/global_settings.rs b/backend/windmill-common/src/global_settings.rs index 937bebf09c..a473c06ebc 100644 --- a/backend/windmill-common/src/global_settings.rs +++ b/backend/windmill-common/src/global_settings.rs @@ -4,6 +4,7 @@ pub const DEFAULT_TAGS_WORKSPACES_SETTING: &str = "default_tags_workspaces"; pub const BASE_URL_SETTING: &str = "base_url"; pub const WS_BASE_URL_SETTING: &str = "ws_base_url"; pub const OAUTH_SETTING: &str = "oauths"; +pub const AI_CONFIG_SETTING: &str = "ai_config"; pub const RETENTION_PERIOD_SECS_SETTING: &str = "retention_period_secs"; pub const AUDIT_LOG_RETENTION_DAYS_SETTING: &str = "audit_log_retention_days"; pub const MONITOR_LOGS_ON_OBJECT_STORE_SETTING: &str = "monitor_logs_on_s3"; diff --git a/backend/windmill-common/src/instance_config.rs b/backend/windmill-common/src/instance_config.rs index 6e791906f9..63dde895fa 100644 --- a/backend/windmill-common/src/instance_config.rs +++ b/backend/windmill-common/src/instance_config.rs @@ -351,6 +351,13 @@ pub struct GlobalSettings { std::collections::HashMap>, >, + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr( + feature = "instance_config_schema", + schemars(schema_with = "opaque_json_schema") + )] + pub ai_config: Option, + /// Catch-all for settings not yet covered by typed fields. #[serde(flatten)] pub extra: BTreeMap, diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index 7f32eb27d4..a31b665868 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -29,6 +29,7 @@ use sqlx::{Acquire, Postgres}; pub mod agent_workers; #[cfg(feature = "bedrock")] pub mod ai_bedrock; +pub mod ai_cache; pub mod ai_google; pub mod ai_providers; pub mod ai_types; diff --git a/frontend/src/lib/components/AppConnectDrawer.svelte b/frontend/src/lib/components/AppConnectDrawer.svelte index 8156b2c1f4..39df15721e 100644 --- a/frontend/src/lib/components/AppConnectDrawer.svelte +++ b/frontend/src/lib/components/AppConnectDrawer.svelte @@ -10,9 +10,11 @@ interface Props { expressOAuthSetup?: boolean + workspace?: string + disableChatOffset?: boolean } - let { expressOAuthSetup = false }: Props = $props() + let { expressOAuthSetup = false, workspace = undefined, disableChatOffset = false }: Props = $props() let drawer: Drawer | undefined = $state() let resourceType = $state('') @@ -50,6 +52,7 @@ dispatch('close') }} size="800px" + {disableChatOffset} > {#snippet actions()}
diff --git a/frontend/src/lib/components/AppConnectInner.svelte b/frontend/src/lib/components/AppConnectInner.svelte index d7b37fcdd9..5ae6fbd6fb 100644 --- a/frontend/src/lib/components/AppConnectInner.svelte +++ b/frontend/src/lib/components/AppConnectInner.svelte @@ -42,6 +42,7 @@ disabled?: boolean manual?: boolean express?: boolean + workspace?: string } let { @@ -50,9 +51,12 @@ isGoogleSignin = $bindable(false), disabled = $bindable(false), manual = $bindable(true), - express = false + express = false, + workspace = undefined }: Props = $props() + let effectiveWorkspace = $derived(workspace ?? $workspaceStore!) + let isValid = $state(true) const nativeLanguagesCategory = [ @@ -214,7 +218,7 @@ return } const availableRts = await ResourceService.listResourceTypeNames({ - workspace: $workspaceStore! + workspace: effectiveWorkspace }) connectsManual = availableRts @@ -316,7 +320,7 @@ async function getResourceTypeInfo() { resourceTypeInfo = await ResourceService.getResourceType({ - workspace: $workspaceStore!, + workspace: effectiveWorkspace, path: resourceType }) const props: Record = resourceTypeInfo?.schema?.['properties'] ?? {} @@ -419,7 +423,7 @@ // Check if variable paths already exist if (!manual || linkedSecrets.length <= 1) { const exists = await VariableService.existsVariable({ - workspace: $workspaceStore!, + workspace: effectiveWorkspace, path }) if (exists) { @@ -429,7 +433,7 @@ for (const secretField of linkedSecrets) { const varPath = `${path}_${secretField}` const exists = await VariableService.existsVariable({ - workspace: $workspaceStore!, + workspace: effectiveWorkspace, path: varPath }) if (exists) { @@ -440,7 +444,7 @@ } } let exists = await ResourceService.existsResource({ - workspace: $workspaceStore!, + workspace: effectiveWorkspace, path }) @@ -478,7 +482,7 @@ account = Number( await OauthService.createAccount({ - workspace: $workspaceStore!, + workspace: effectiveWorkspace, requestBody: accountData }) ) @@ -492,7 +496,7 @@ if (typeof value == 'string' && value != '' && !value.startsWith('$var:')) { savedVariableCount++ await VariableService.createVariable({ - workspace: $workspaceStore!, + workspace: effectiveWorkspace, requestBody: { path, value: value, @@ -513,7 +517,7 @@ if (typeof v == 'string' && v != '' && !v.startsWith('$var:')) { savedVariableCount++ await VariableService.createVariable({ - workspace: $workspaceStore!, + workspace: effectiveWorkspace, requestBody: { path, value: v, @@ -532,7 +536,7 @@ const varPath = `${path}_${secretField}` savedVariableCount++ await VariableService.createVariable({ - workspace: $workspaceStore!, + workspace: effectiveWorkspace, requestBody: { path: varPath, value: v, @@ -549,7 +553,7 @@ } await ResourceService.createResource({ - workspace: $workspaceStore!, + workspace: effectiveWorkspace, requestBody: { resource_type: resourceType, path, diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte index 70aaf6a2ee..3956cea7eb 100644 --- a/frontend/src/lib/components/ResourceEditor.svelte +++ b/frontend/src/lib/components/ResourceEditor.svelte @@ -33,6 +33,7 @@ hidePath?: boolean onChange?: (args: { path: string; args: Record; description: string }) => void defaultValues?: Record | undefined + workspace?: string | undefined } let { @@ -41,9 +42,12 @@ path = $bindable(''), hidePath = false, onChange, - defaultValues = undefined + defaultValues = undefined, + workspace = undefined }: Props = $props() + let effectiveWorkspace = $derived(workspace ?? $workspaceStore!) + let isValid = $state(true) let jsonError = $state('') let can_write = $state(true) @@ -68,13 +72,13 @@ let rawCode: string | undefined = $state(undefined) async function initEdit() { - resourceToEdit = await ResourceService.getResource({ workspace: $workspaceStore!, path }) + resourceToEdit = await ResourceService.getResource({ workspace: effectiveWorkspace, path }) description = resourceToEdit!.description ?? '' resource_type = resourceToEdit!.resource_type args = resourceToEdit?.value ?? ({} as any) loadResourceType() can_write = - resourceToEdit.workspace_id == $workspaceStore && + resourceToEdit.workspace_id == effectiveWorkspace && canWrite(path, resourceToEdit.extra_perms ?? {}, $userStore) linkedVars = Object.entries(args) .filter(([_, v]) => typeof v == 'string' && v == `$var:${initialPath}`) @@ -92,12 +96,12 @@ export async function editResource(): Promise { if (resourceToEdit) { await ResourceService.updateResource({ - workspace: $workspaceStore!, + workspace: effectiveWorkspace, path: resourceToEdit.path, requestBody: { path, value: args, description } }) if (resourceToEdit.resource_type === 'json_schema') { - clearJsonSchemaResourceCache(resourceToEdit.path, $workspaceStore!) + clearJsonSchemaResourceCache(resourceToEdit.path, effectiveWorkspace) } sendUserToast(`Updated resource at ${path}`) dispatch('refresh', path) @@ -108,7 +112,7 @@ export async function createResource(): Promise { await ResourceService.createResource({ - workspace: $workspaceStore!, + workspace: effectiveWorkspace, requestBody: { path, value: args, description, resource_type: resource_type! } }) sendUserToast(`Updated resource at ${path}`) @@ -119,7 +123,7 @@ if (resource_type) { try { const resourceType = await ResourceService.getResourceType({ - workspace: $workspaceStore!, + workspace: effectiveWorkspace, path: resource_type }) diff --git a/frontend/src/lib/components/ResourceEditorDrawer.svelte b/frontend/src/lib/components/ResourceEditorDrawer.svelte index dae6943868..54348e81c1 100644 --- a/frontend/src/lib/components/ResourceEditorDrawer.svelte +++ b/frontend/src/lib/components/ResourceEditorDrawer.svelte @@ -5,6 +5,11 @@ import { Loader2, Save } from 'lucide-svelte' + let { + workspace = undefined, + disableChatOffset = false + }: { workspace?: string; disableChatOffset?: boolean } = $props() + let drawer: Drawer | undefined = $state() let canSave = $state(true) let resource_type: string | undefined = $state(undefined) @@ -34,7 +39,7 @@ let mode: 'edit' | 'new' = $derived(!path ? 'new' : 'edit') - + void excludedValues?: string[] datatableAsPgResource?: boolean + workspace?: string | undefined + disableChatOffset?: boolean } let { @@ -47,9 +49,13 @@ class: className = '', onClear = undefined, excludedValues = undefined, - datatableAsPgResource = false + datatableAsPgResource = false, + workspace = undefined, + disableChatOffset = false }: Props = $props() + let effectiveWorkspace = $derived(workspace ?? $workspaceStore!) + if (initialValue && value == undefined) { value = initialValue } @@ -104,7 +110,7 @@ const resources = await Promise.all( resourceTypesToQuery.map((rt) => ResourceService.listResource({ - workspace: $workspaceStore!, + workspace: effectiveWorkspace, resourceType: rt }) ) @@ -122,7 +128,7 @@ if (datatableAsPgResource && resourceType === 'postgresql') { try { const datatables = await WorkspaceService.listDataTables({ - workspace: $workspaceStore! + workspace: effectiveWorkspace }) for (const dt of datatables) { nc.push({ @@ -155,7 +161,7 @@ let previousResourceType = untrack(() => resourceType) $effect(() => { - $workspaceStore && resourceType + effectiveWorkspace && resourceType untrack(() => { if (previousResourceType != resourceType) { previousResourceType = resourceType @@ -167,7 +173,7 @@ $effect(() => { excludedValues - if ($workspaceStore && resourceType && !disabled) { + if (effectiveWorkspace && resourceType && !disabled) { untrack(() => loadResources(resourceType)) } }) @@ -186,9 +192,13 @@ }} bind:this={appConnect} {expressOAuthSetup} + {workspace} + {disableChatOffset} /> { await loadResources(resourceType) if (e.detail) { diff --git a/frontend/src/lib/components/SuperadminSettings.svelte b/frontend/src/lib/components/SuperadminSettings.svelte index b9dbb7c6d6..316a6237cd 100644 --- a/frontend/src/lib/components/SuperadminSettings.svelte +++ b/frontend/src/lib/components/SuperadminSettings.svelte @@ -146,6 +146,7 @@ bind:this={innerComponent} closeDrawer={handleClose} showHeaderInfo={false} + {disableChatOffset} bind:yamlMode bind:hasUnsavedChanges bind:hasAnyInvalid diff --git a/frontend/src/lib/components/SuperadminSettingsInner.svelte b/frontend/src/lib/components/SuperadminSettingsInner.svelte index 964414cafa..4a1d376f8c 100644 --- a/frontend/src/lib/components/SuperadminSettingsInner.svelte +++ b/frontend/src/lib/components/SuperadminSettingsInner.svelte @@ -39,12 +39,14 @@ import TextInput from './text_input/TextInput.svelte' import SettingsPageHeader from './settings/SettingsPageHeader.svelte' import SettingsSearchInput from './instanceSettings/SettingsSearchInput.svelte' + import InstanceAISettings from './instanceSettings/InstanceAISettings.svelte' let filter = $state('') let { closeDrawer, showHeaderInfo = true, + disableChatOffset = false, yamlMode = $bindable(false), hasUnsavedChanges = $bindable(false), hasAnyInvalid = $bindable(false) @@ -234,7 +236,9 @@
- {#if tab === 'users' && !yamlMode} + {#if tab === 'ai' && !yamlMode} + + {:else if tab === 'users' && !yamlMode}
{#if !automateUsernameCreation && !isCloudHosted()}
diff --git a/frontend/src/lib/components/copilot/TestAIKey.svelte b/frontend/src/lib/components/copilot/TestAIKey.svelte index 575159aef8..1f0f97c4ef 100644 --- a/frontend/src/lib/components/copilot/TestAIKey.svelte +++ b/frontend/src/lib/components/copilot/TestAIKey.svelte @@ -7,6 +7,7 @@ interface Props { disabled?: boolean apiKey?: string | undefined + workspace?: string | undefined resourcePath?: string | undefined aiProvider: AIProvider model: string @@ -15,6 +16,7 @@ let { disabled = false, apiKey = undefined, + workspace = undefined, resourcePath = undefined, aiProvider, model @@ -38,6 +40,7 @@ await testKey({ apiKey, + workspace, resourcePath, messages: [ { diff --git a/frontend/src/lib/components/copilot/chat/openai-responses.ts b/frontend/src/lib/components/copilot/chat/openai-responses.ts index d7e5ad5c96..5003f48099 100644 --- a/frontend/src/lib/components/copilot/chat/openai-responses.ts +++ b/frontend/src/lib/components/copilot/chat/openai-responses.ts @@ -5,10 +5,14 @@ import type { ChatCompletionCreateParams } from 'openai/resources/index.mjs' import type { ResponseErrorEvent } from 'openai/resources/responses/responses.mjs' -import { getProviderAndCompletionConfig, workspaceAIClients } from '../lib' +import { + createOpenAIProxyClient, + getAiProxyBaseURL, + getProviderAndCompletionConfig, + workspaceAIClients +} from '../lib' import { processToolCall, type Tool, type ToolCallbacks } from './shared' import type { ResponseStream } from 'openai/lib/responses/ResponseStream.mjs' -import { OpenAPI } from '$lib/gen' import type { AIProviderModel } from '$lib/gen' // Conversion utilities for Responses API @@ -354,6 +358,7 @@ export async function getNonStreamingOpenAIResponsesCompletion( abortController: AbortController, testOptions?: { apiKey?: string + workspace?: string resourcePath?: string forceModelProvider: AIProviderModel } @@ -390,15 +395,10 @@ export async function getNonStreamingOpenAIResponsesCompletion( } const openaiClient = testOptions?.apiKey - ? new OpenAI({ - baseURL: `${location.origin}${OpenAPI.BASE}/ai/proxy`, - apiKey: 'fake-key', - defaultHeaders: { - Authorization: '' // a non empty string will be unable to access Windmill backend proxy - }, - dangerouslyAllowBrowser: true - }) - : workspaceAIClients.getOpenaiClient() + ? createOpenAIProxyClient(getAiProxyBaseURL()) + : testOptions?.workspace + ? workspaceAIClients.createOpenaiClient(testOptions.workspace) + : workspaceAIClients.getOpenaiClient() const response = await openaiClient.responses.create( { diff --git a/frontend/src/lib/components/copilot/lib.ts b/frontend/src/lib/components/copilot/lib.ts index f9afd9bb2c..dc05e3a247 100644 --- a/frontend/src/lib/components/copilot/lib.ts +++ b/frontend/src/lib/components/copilot/lib.ts @@ -67,7 +67,14 @@ export const AI_PROVIDERS: Record = { }, googleai: { label: 'Google AI', - defaultModels: ['gemini-2.5-flash', 'gemini-2.5-pro', 'gemini-2.5-flash-lite', 'gemini-3-flash', 'gemini-3.1-pro', 'gemini-3.1-flash-lite'] + defaultModels: [ + 'gemini-2.5-flash', + 'gemini-2.5-pro', + 'gemini-2.5-flash-lite', + 'gemini-3-flash', + 'gemini-3.1-pro', + 'gemini-3.1-flash-lite' + ] }, groq: { label: 'Groq', @@ -364,38 +371,46 @@ export const PROVIDER_COMPLETION_CONFIG_MAP: Record = { general: 'Core', + ai: 'AI', sso: 'Auth/OAuth/SAML', oauth: 'Auth/OAuth/SAML', scim_saml: 'Auth/OAuth/SAML', @@ -897,6 +909,7 @@ export const setupNavigationGroups = instanceSettingsNavigationGroups export const categoryToTabMap: Record = { Core: 'general', + AI: 'ai', SMTP: 'smtp', 'Auth/OAuth/SAML': 'sso', Registries: 'registries', diff --git a/frontend/src/lib/components/instanceSettings/InstanceAISettings.svelte b/frontend/src/lib/components/instanceSettings/InstanceAISettings.svelte new file mode 100644 index 0000000000..91b08eb46e --- /dev/null +++ b/frontend/src/lib/components/instanceSettings/InstanceAISettings.svelte @@ -0,0 +1,149 @@ + + +{#if loaded} + {#if showHubSync} +
+
+

Resource types

+

+ AI providers require their resource types. Sync from the Hub if they are missing. +

+
+ +
+ {#if hubSyncStatus === 'success'} +
+ + {hubSyncMessage} + +
+ {:else if hubSyncStatus === 'error'} +
+ + {hubSyncMessage} + +
+ {/if} + {/if} + + +{/if} diff --git a/frontend/src/lib/components/settings/AIPromptsModal.svelte b/frontend/src/lib/components/settings/AIPromptsModal.svelte index a0ea762c8d..b9c126d19c 100644 --- a/frontend/src/lib/components/settings/AIPromptsModal.svelte +++ b/frontend/src/lib/components/settings/AIPromptsModal.svelte @@ -13,7 +13,7 @@ onSave?: () => void onReset: () => void hasChanges: boolean - isWorkspaceSettings?: boolean + scope?: 'user' | 'workspace' | 'instance' } let { @@ -22,7 +22,7 @@ onSave, onReset, hasChanges, - isWorkspaceSettings = false + scope = 'user' }: Props = $props() const placeholders: Record = { @@ -63,9 +63,12 @@
- {#if isWorkspaceSettings} + {#if scope === 'workspace'} Customize the system prompts for each AI mode. These prompts apply to all workspace members. + {:else if scope === 'instance'} + Customize the system prompts for each AI mode. These prompts apply to workspaces using + instance AI defaults. {:else} Customize the system prompts for each AI mode. These prompts are stored locally in your browser and apply in addition to workspace-level prompts. diff --git a/frontend/src/lib/components/workspaceSettings/AISettings.svelte b/frontend/src/lib/components/workspaceSettings/AISettings.svelte index 2ed3432a3c..dd3cc6e0d0 100644 --- a/frontend/src/lib/components/workspaceSettings/AISettings.svelte +++ b/frontend/src/lib/components/workspaceSettings/AISettings.svelte @@ -1,5 +1,12 @@ - +
- -
- {#each Object.entries(AI_PROVIDERS) as [provider, details]} -
-
- { - if (e.detail) { - aiProviders = { - ...aiProviders, - [provider]: { - resource_path: '', - models: - availableAiModels[provider].length > 0 - ? [availableAiModels[provider][0]] - : [] - } - } - - if (availableAiModels[provider].length > 0 && !defaultModel) { - defaultModel = availableAiModels[provider][0] - } - } else { - aiProviders = Object.fromEntries( - Object.entries(aiProviders).filter(([key]) => key !== provider) - ) - if (defaultModel) { - const currentDefaultModel = Object.values(aiProviders).find( - (p) => defaultModel && p.models.includes(defaultModel) - ) - if (!currentDefaultModel) { - defaultModel = undefined - } - } - if (codeCompletionModel) { - const currentCodeCompletionModel = Object.values(aiProviders).find( - (p) => codeCompletionModel && p.models.includes(codeCompletionModel) - ) - if (!currentCodeCompletionModel) { - codeCompletionModel = undefined - } - } - } - }} - /> - {#if provider === 'anthropic'} - - Recommended - - Anthropic models handle tool calls better than other providers, which makes them a - better choice for AI chat. - - - {/if} -
- - {#if aiProviders[provider]} -
-
- {/if} -
+ + {#key Object.keys(aiProviders).length} + + +
{/if}
- + + + + +
+ + {#if promptCount > 0} + ({promptCount} configured) + {/if} + {#if hasPromptsChanges} + Unsaved changes + {/if} +
+
+ {/if}
- onDiscard?.()} - saveLabel="Save AI settings" - disabled={!Object.values(aiProviders).every((p) => p.resource_path) || - (codeCompletionModel != undefined && codeCompletionModel.length === 0) || - (Object.keys(aiProviders).length > 0 && !defaultModel)} -/> +{#if showWorkspaceOverrideEditor} + +{/if} diff --git a/frontend/src/lib/components/workspaceSettings/CreateWorkspace.svelte b/frontend/src/lib/components/workspaceSettings/CreateWorkspace.svelte index 7f4b4e0f67..e02facb38e 100644 --- a/frontend/src/lib/components/workspaceSettings/CreateWorkspace.svelte +++ b/frontend/src/lib/components/workspaceSettings/CreateWorkspace.svelte @@ -11,7 +11,8 @@ VariableService, WorkspaceService, type AIProvider, - type CompletedJob + type CompletedJob, + type GetCopilotInfoResponse } from '$lib/gen' import { validateUsername } from '$lib/utils' import { logoutWithRedirect } from '$lib/logoutKit' @@ -52,6 +53,10 @@ let aiKey = $state('') let codeCompletionEnabled = $state(true) let checking = $state(false) + let createLoading = $state(false) + let aiSetupLoading = $state(false) + let creationStep = $state<'details' | 'ai'>('details') + let createdWorkspaceId: string | undefined = $state(undefined) let workspaceColor: string | undefined = $state(undefined) let colorEnabled = $state(false) @@ -85,6 +90,64 @@ let errorMsgs: string[] = $state([]) let failedSyncJobs: string[] = $state([]) + function getErrorMessage(error: any): string { + return ( + error?.body?.error?.message || + error?.body?.message || + (typeof error?.body === 'string' ? error.body : null) || + error?.message || + 'Unknown error' + ) + } + + function hasEffectiveAi(copilotInfo: GetCopilotInfoResponse): boolean { + return Object.keys(copilotInfo.providers ?? {}).length > 0 + } + + async function finishWorkspaceSetup(workspaceId: string): Promise { + usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces()) + switchWorkspace(workspaceId) + goto(rd ?? '/') + } + + async function getWorkspaceUsername(workspaceId: string): Promise { + if (!automateUsernameCreation) { + return username + } + + const user = await UserService.whoami({ + workspace: workspaceId + }) + return user.username + } + + async function maybeShowAiSetupStep(workspaceId: string): Promise { + try { + const copilotInfo = await WorkspaceService.getCopilotInfo({ + workspace: workspaceId + }) + + if (hasEffectiveAi(copilotInfo)) { + await finishWorkspaceSetup(workspaceId) + return + } + } catch (error) { + console.error('Failed to check effective AI configuration for new workspace', error) + sendUserToast( + 'Workspace created, but Windmill AI availability could not be verified. You can configure it later in Workspace settings.', + true + ) + await finishWorkspaceSetup(workspaceId) + return + } + + createdWorkspaceId = workspaceId + creationStep = 'ai' + aiKey = '' + codeCompletionEnabled = true + selected = 'openai' + } + async function fetchFailedSyncJobs(jobs: string[]): Promise { let ret: CompletedJob[] = [] for (const job of jobs) { @@ -188,20 +251,22 @@ forkCreationLoading = false sendUserToast(`Successfully forked workspace ${$workspaceStore} as: wm-fork-${id}`) + await finishWorkspaceSetup(prefixed_id) } else { sendUserToast('No workspace selected, cannot fork non-existent workspace', true) } } else { - await createWorkspace() + createLoading = true + try { + const workspaceId = await createWorkspace() + await maybeShowAiSetupStep(workspaceId) + } finally { + createLoading = false + } } - - usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces()) - switchWorkspace(isFork ? prefixed_id : id) - - goto(rd ?? '/') } - async function createWorkspace(): Promise { + async function createWorkspace(): Promise { await WorkspaceService.createWorkspace({ requestBody: { id, @@ -216,17 +281,23 @@ requestBody: { operator: operatorOnly, invite_all: !isCloudHosted(), auto_add: autoAdd } }) } - if (aiKey != '') { - let actualUsername = username - if (automateUsernameCreation) { - const user = await UserService.whoami({ - workspace: id - }) - actualUsername = user.username - } - let path = `u/${actualUsername}/${selected}_windmill_codegen` + + sendUserToast(`Created workspace id: ${id}`) + return id + } + + async function saveWorkspaceAiSetup(): Promise { + if (!createdWorkspaceId || !aiKey) { + return + } + + aiSetupLoading = true + try { + const actualUsername = await getWorkspaceUsername(createdWorkspaceId) + const path = `u/${actualUsername}/${selected}_windmill_codegen` + await VariableService.createVariable({ - workspace: id, + workspace: createdWorkspaceId, requestBody: { path, value: aiKey, @@ -235,7 +306,7 @@ } }) await ResourceService.createResource({ - workspace: id, + workspace: createdWorkspaceId, requestBody: { path, value: { @@ -245,40 +316,46 @@ } }) await WorkspaceService.editCopilotConfig({ - workspace: id, - requestBody: aiKey - ? { - providers: { - [selected]: { - resource_path: path, - models: [AI_PROVIDERS[selected].defaultModels[0]] - } - }, - default_model: { - model: AI_PROVIDERS[selected].defaultModels[0], - provider: selected - }, - code_completion_model: codeCompletionEnabled - ? { model: AI_PROVIDERS[selected].defaultModels[0], provider: selected } - : undefined + workspace: createdWorkspaceId, + requestBody: { + providers: { + [selected]: { + resource_path: path, + models: [AI_PROVIDERS[selected].defaultModels[0]] } - : {} + }, + default_model: { + model: AI_PROVIDERS[selected].defaultModels[0], + provider: selected + }, + code_completion_model: codeCompletionEnabled + ? { model: AI_PROVIDERS[selected].defaultModels[0], provider: selected } + : undefined + } }) + + sendUserToast('Windmill AI configured') + await finishWorkspaceSetup(createdWorkspaceId) + } catch (error) { + sendUserToast(`Failed to configure Windmill AI: ${getErrorMessage(error)}`, true) + } finally { + aiSetupLoading = false } - - sendUserToast(`Created workspace id: ${id}`) - - usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces()) - switchWorkspace(id) - - goto(rd ?? '/') } - function handleKeyUp(event: KeyboardEvent) { + function handleCreateKeyUp(event: KeyboardEvent) { const key = event.key if (key === 'Enter') { event.preventDefault() - createWorkspace() + createOrForkWorkspace() + } + } + + function handleAiKeyUp(event: KeyboardEvent) { + const key = event.key + if (key === 'Enter' && aiKey) { + event.preventDefault() + saveWorkspaceAiSetup() } } @@ -329,6 +406,9 @@ let operatorOnly = $state(false) let autoAdd = $state(true) let selected: Exclude = $state('openai') + let modalTitle = $derived( + isFork ? 'Fork Workspace' : creationStep === 'ai' ? 'Set up Windmill AI' : 'New Workspace' + ) run(() => { id = name.toLowerCase().replace(/\s/gi, '-') }) @@ -344,7 +424,7 @@ let domain = $derived($usersWorkspaceStore?.email.split('@')[1]) - +
{#if isFork}
@@ -410,88 +490,184 @@ {/if} {/if} - - - - {#if !automateUsernameCreation} + {#if isFork || creationStep === 'details'} + - {/if} - {#if !isFork} -
+ + {#if !automateUsernameCreation} + + {/if} + {#if !isFork} +
+ + + {#if isCloudHosted() && isDomainAllowed == false} +
{domain} domain not allowed for auto-invite
+ {/if} + + {#if auto_invite} +
+ + {#if isCloudHosted()} + + {/if} + + +
+ {/if} +
+ {/if} + +
+ + {#if !forkCreationLoading} + + {:else} + + {/if} +
+ {:else} +
+ + Windmill AI powers the chat, code generation, flow creation, and code completion. Set + it up now or configure it later in Workspace settings. + + Learn more + + - + {#snippet children({ item })} @@ -517,7 +704,7 @@ type="password" autocomplete="new-password" bind:value={aiKey} - onkeyup={handleKeyUp} + onkeyup={handleAiKeyUp} /> {#if aiKey} -
+
{/if}
-
- +
- {/if} -
- - {#if !forkCreationLoading} + Skip for now + - {:else} - - {/if} -
+
+ {/if}
diff --git a/frontend/src/lib/components/workspaceSettings/InstanceFallbackSettings.svelte b/frontend/src/lib/components/workspaceSettings/InstanceFallbackSettings.svelte new file mode 100644 index 0000000000..a44981ccbd --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/InstanceFallbackSettings.svelte @@ -0,0 +1,93 @@ + + +{#if instanceAiSummary} + +
+

+ This workspace is currently using the instance AI defaults shown below. +

+ +
+ {#each sortedInstanceProviders as providerSummary} +
+
+ + {getProviderLabel(providerSummary.provider)} + + Instance +
+
+ {#each providerSummary.models as model} + {model} + {/each} +
+
+ {/each} +
+ + {#if instanceAiSummary.default_model} +
+ Default chat model: + {instanceAiSummary.default_model.model} + + ({getProviderLabel(instanceAiSummary.default_model.provider)}) + +
+ {/if} + + {#if instanceAiSummary.code_completion_model} +
+ Code completion model: + + {instanceAiSummary.code_completion_model.model} + + + ({getProviderLabel(instanceAiSummary.code_completion_model.provider)}) + +
+ {/if} +
+
+{/if} + + +
+

+ Create workspace-specific AI settings only if this workspace needs to override the active + instance defaults. +

+
+ +
+
+
diff --git a/frontend/src/routes/(root)/(logged)/user/(user)/instance_settings/+page.svelte b/frontend/src/routes/(root)/(logged)/user/(user)/instance_settings/+page.svelte index 66d9568c09..36f9be8faa 100644 --- a/frontend/src/routes/(root)/(logged)/user/(user)/instance_settings/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/user/(user)/instance_settings/+page.svelte @@ -25,13 +25,20 @@ import SettingsPageHeader from '$lib/components/settings/SettingsPageHeader.svelte' import SettingCard from '$lib/components/instanceSettings/SettingCard.svelte' import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte' + import InstanceAISettings from '$lib/components/instanceSettings/InstanceAISettings.svelte' const settingsSteps = [ { id: 'Core', label: 'Core' }, { id: 'Auth/OAuth/SAML', label: 'Authentication' } ] as const - const wizardStepLabels = [...settingsSteps.map((s) => s.label), 'Root login & Resource Types'] + const AI_STEP_INDEX = settingsSteps.length + + const wizardStepLabels = [ + ...settingsSteps.map((s) => s.label), + 'AI', + 'Root login & Resource Types' + ] const fullStepLabels = ['Settings', 'Root login & Resource Types'] @@ -67,6 +74,7 @@ }) let instanceSettings: InstanceSettings | undefined = $state() + let instanceAiSettings: InstanceAISettings | undefined = $state() function isSettingsStep(step: number): boolean { return step < settingsSteps.length @@ -148,6 +156,9 @@ let passwordValid = $derived(newPassword.length >= 2) let accountFormValid = $derived(emailValid && passwordValid) + // --- AI step state --- + let aiHasUnsavedChanges = $state(false) + // --- EE license key warning --- let showLicenseKeyWarning = $state(false) let pendingNextCallback: (() => void) | undefined = $state(undefined) @@ -168,9 +179,20 @@ let authSubTab: 'sso' | 'oauth' | 'scim' = $derived(tabToAuthSubTab[fullTab] ?? 'sso') let yamlMode = $state(false) - function handleNavigate(newTab: string) { - if (newTab === fullTab) return + function isAiStepActive(): boolean { + return ( + (mode === 'wizard' && wizardStep === AI_STEP_INDEX) || + (mode === 'full' && fullStep === 0 && fullTab === 'ai' && !yamlMode) + ) + } + + async function handleNavigate(newTab: string): Promise { + if (newTab === fullTab) return true + if (isAiStepActive() && !((await instanceAiSettings?.persistBeforeExit()) ?? true)) { + return false + } fullTab = newTab + return true } // --- Settings search (full mode) --- @@ -180,7 +202,10 @@ let highlightTimeout: ReturnType | undefined async function handleSearchSelect(item: SearchableSettingItem) { - handleNavigate(item.tabId) + const didNavigate = await handleNavigate(item.tabId) + if (!didNavigate) { + return + } if (item.settingKey) { clearTimeout(scrollTimeout) clearTimeout(highlightTimeout) @@ -202,7 +227,7 @@ }) /** Check if we need to warn about missing EE license key before proceeding */ - function proceedFromCore(callback: () => void) { + async function proceedFromCore(callback: () => void) { const leavingSettings = (mode === 'wizard' && wizardStep === 0) || (mode === 'full' && fullStep === 0) if (leavingSettings && isEeImage() && isLicenseKeyEmpty()) { @@ -210,12 +235,16 @@ showLicenseKeyWarning = true return } - saveAndProceed(callback) + await saveAndProceed(callback) } /** Auto-save dirty settings, then run the callback */ async function saveAndProceed(callback: () => void) { - if (yamlMode) { + if (isAiStepActive()) { + if (!((await instanceAiSettings?.persistBeforeExit()) ?? true)) { + return + } + } else if (yamlMode) { // In YAML mode, sync editor → form, then bulk-save everything if (!instanceSettings?.syncBeforeDiff()) return await instanceSettings.saveSettings() @@ -231,11 +260,14 @@ callback() } - function switchToFullMode() { + async function switchToFullMode() { mode = 'full' } - function switchToWizardMode() { + async function switchToWizardMode() { + if (isAiStepActive() && !((await instanceAiSettings?.persistBeforeExit()) ?? true)) { + return + } yamlMode = false fullStep = 0 mode = 'wizard' @@ -461,6 +493,13 @@ tab={settingsSteps[wizardStep].id} /> {/key} + {:else if wizardStep === AI_STEP_INDEX} + {:else} {@render accountSetupContent()} {/if} @@ -505,19 +544,28 @@ {/if}
- { - const targetTab = categoryToTabMap[category] - if (targetTab) { - handleNavigate(targetTab) - } - }} - /> + {#if fullTab === 'ai' && !yamlMode} + + {:else} + { + const targetTab = categoryToTabMap[category] + if (targetTab) { + handleNavigate(targetTab) + } + }} + /> + {/if}
{:else} diff --git a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte index d481346af2..db3a140c0b 100644 --- a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte @@ -19,10 +19,12 @@ import { OauthService, WorkspaceService, - ResourceService, SettingService, type AIConfig, - type ErrorHandler + type ErrorHandler, + type GetCopilotSettingsStateResponse, + type InstanceAISummary, + type GetSettingsResponse } from '$lib/gen' import { enterpriseLicense, @@ -60,7 +62,6 @@ convertDucklakeSettingsFromBackend, type DucklakeSettingsType } from '$lib/components/workspaceSettings/DucklakeSettings.svelte' - import { AIMode } from '$lib/components/copilot/chat/AIChatManager.svelte' import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte' import TextInput from '$lib/components/text_input/TextInput.svelte' import CollapseLink from '$lib/components/CollapseLink.svelte' @@ -112,19 +113,12 @@ let publicAppRateLimitPerMinute: number | undefined = $state(undefined) let initialPublicAppRateLimitPerMinute: number | undefined = $state(undefined) - let aiProviders: Exclude = $state({}) - let codeCompletionModel: string | undefined = $state(undefined) - let defaultModel: string | undefined = $state(undefined) - let customPrompts: Record = $state({}) - let maxTokensPerModel: Record = $state({}) - - // Track initial AI config for unsaved changes detection - let initialAiProviders: Exclude = $state({}) - let initialCodeCompletionModel: string | undefined = $state(undefined) - let initialDefaultModel: string | undefined = $state(undefined) - let initialCustomPrompts: Record = $state({}) - let initialMaxTokensPerModel: Record = $state({}) - + let hasInstanceAiConfig = $state(false) + let usesInstanceAiConfig = $state(false) + let instanceAiSummary: InstanceAISummary | undefined = $state(undefined) + let aiInitialConfig: AIConfig | undefined = $state(undefined) + let aiSettingsComponent: AISettings | undefined = $state(undefined) + let hasAiSettingsChanges = $state(false) // Track initial deploy settings for unsaved changes detection let initialWorkspaceToDeployTo: string | undefined = $state(undefined) let initialDeployUiSettings: { @@ -227,14 +221,6 @@ return currentValue !== initialValue }) - // Derived state for checking unsaved changes in AI settings - let hasAiSettingsChanges = $derived.by(() => { - if (tab !== 'ai') return false - const changes = getAiSettingsInitialAndModifiedValues() - if (!changes.savedValue || !changes.modifiedValue) return false - return hasUnsavedChanges(changes.savedValue, changes.modifiedValue) - }) - // Derived state for checking unsaved changes in deployment settings let hasDeploySettingsChanges = $derived.by(() => { if (tab !== 'deploy_to') return false @@ -320,8 +306,6 @@ $page.url.searchParams.get('tab') === 'teams' ? 'teams_commands' : 'slack_commands' ) - let usingOpenaiClientCredentialsOauth = $state(false) - let loadedSettings = $state(false) let oauths: Record = $state({}) @@ -489,7 +473,17 @@ } async function loadSettings(): Promise { - const settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! }) + const [settings, copilotSettingsState]: [ + GetSettingsResponse, + GetCopilotSettingsStateResponse + ] = await Promise.all([ + WorkspaceService.getSettings({ + workspace: $workspaceStore! + }), + WorkspaceService.getCopilotSettingsState({ + workspace: $workspaceStore! + }) + ]) slack_team_name = settings.slack_name teams_team_id = settings.teams_team_id teams_team_name = settings.teams_team_name @@ -508,23 +502,10 @@ workspaceToDeployTo = settings.deploy_to webhook = settings.webhook - aiProviders = settings.ai_config?.providers ?? {} - defaultModel = settings.ai_config?.default_model?.model - codeCompletionModel = settings.ai_config?.code_completion_model?.model - customPrompts = settings.ai_config?.custom_prompts ?? {} - maxTokensPerModel = settings.ai_config?.max_tokens_per_model ?? {} - for (const mode of Object.values(AIMode)) { - if (!(mode in customPrompts)) { - customPrompts[mode] = '' - } - } - - // Store initial AI config state for unsaved changes detection - initialAiProviders = clone(aiProviders) - initialDefaultModel = defaultModel - initialCodeCompletionModel = codeCompletionModel - initialCustomPrompts = clone(customPrompts) - initialMaxTokensPerModel = clone(maxTokensPerModel) + aiInitialConfig = settings.ai_config ?? {} + hasInstanceAiConfig = copilotSettingsState.has_instance_ai_config + usesInstanceAiConfig = copilotSettingsState.uses_instance_ai_config + instanceAiSummary = copilotSettingsState.instance_ai_summary const errorHandler = settings.error_handler as | { path?: string; extra_args?: any; muted_on_cancel?: boolean; muted_on_user_path?: boolean } | undefined @@ -600,12 +581,6 @@ // Store initial success handler state for unsaved changes detection initialSuccessHandlerScriptPath = successHandlerScriptPath - // check openai_client_credentials_oauth - usingOpenaiClientCredentialsOauth = await ResourceService.existsResourceType({ - workspace: $workspaceStore!, - path: 'openai_client_credentials_oauth' - }) - loadedSettings = true } @@ -816,36 +791,6 @@ ) } - // Function to check if there are unsaved changes in AI settings - function getAiSettingsInitialAndModifiedValues() { - const savedValue = { - aiProviders: initialAiProviders, - defaultModel: initialDefaultModel, - codeCompletionModel: initialCodeCompletionModel, - customPrompts: initialCustomPrompts, - maxTokensPerModel: initialMaxTokensPerModel - } - - const modifiedValue = { - aiProviders: aiProviders, - defaultModel: defaultModel, - codeCompletionModel: codeCompletionModel, - customPrompts: customPrompts, - maxTokensPerModel: maxTokensPerModel - } - - return { savedValue, modifiedValue } - } - - // Function to discard unsaved AI settings changes - function discardAiSettingsChanges() { - aiProviders = clone(initialAiProviders) - defaultModel = initialDefaultModel - codeCompletionModel = initialCodeCompletionModel - customPrompts = clone(initialCustomPrompts) - maxTokensPerModel = clone(initialMaxTokensPerModel) - } - // Function to check if there are unsaved changes in storage settings function getStorageSettingsInitialAndModifiedValues() { return { @@ -1017,7 +962,9 @@ case 'windmill_data_tables': return dataTableSettingsComponent?.unsavedChanges() ?? { savedValue: {}, modifiedValue: {} } case 'ai': - return getAiSettingsInitialAndModifiedValues() + return hasAiSettingsChanges + ? { savedValue: { changed: false }, modifiedValue: { changed: true } } + : { savedValue: {}, modifiedValue: {} } case 'windmill_lfs': return getStorageSettingsInitialAndModifiedValues() case 'volume_storage': @@ -1059,7 +1006,7 @@ function discardAllChanges() { switch (tab) { case 'ai': - discardAiSettingsChanges() + aiSettingsComponent?.discard() break case 'windmill_lfs': discardStorageSettingsChanges() @@ -1830,21 +1777,19 @@ export async function main( /> {:else if tab == 'ai'} { - // Update initial state after successful save - initialAiProviders = clone(aiProviders) - initialDefaultModel = defaultModel - initialCodeCompletionModel = codeCompletionModel - initialCustomPrompts = clone(customPrompts) - initialMaxTokensPerModel = clone(maxTokensPerModel) + bind:this={aiSettingsComponent} + initialConfig={aiInitialConfig} + bind:hasUnsavedChanges={hasAiSettingsChanges} + {hasInstanceAiConfig} + {usesInstanceAiConfig} + {instanceAiSummary} + onSave={(copilotSettingsState) => { + if (!copilotSettingsState) { + return + } + hasInstanceAiConfig = copilotSettingsState.has_instance_ai_config + usesInstanceAiConfig = copilotSettingsState.uses_instance_ai_config + instanceAiSummary = copilotSettingsState.instance_ai_summary }} /> {:else if tab == 'windmill_data_tables'} From d578e40101a838d3dffda14157cf72ee4d5a93c0 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 24 Mar 2026 21:22:35 +0000 Subject: [PATCH 02/13] feat: add selfApproval option to WAC + inline approval buttons (#8440) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add selfApproval option to WAC waitForApproval + inline approval buttons Add self-approval configuration to WAC workflows and inline approve/reject buttons in WorkflowTimeline. - TS SDK: add selfApproval option to waitForApproval() - Python SDK: add self_approval param to wait_for_approval() - Backend: store approval_conditions in flow_status for WAC, enforce self-approval checks on resume endpoints - Frontend: show Approve/Reject buttons in timeline with form support (EE), gated by user permissions Co-Authored-By: Claude Opus 4.6 (1M context) * fix: revert sqlx query change + regenerate system prompts - Revert get_suspended_flow_info to use original sqlx::query_as! with COALESCE to avoid sqlx offline cache mismatch in CI - Detect WAC by checking if FlowStatus parsing fails + suspend > 0 - Re-fetch flow_status column separately for WAC approval conditions - Regenerate auto-generated system prompt files for SDK changes Co-Authored-By: Claude Opus 4.6 (1M context) * feat: use resume URLs for WAC inline approval buttons - Backend generates HMAC-signed resume/cancel URLs when creating WAC approval, stores them in timeline entry and approval meta - Frontend uses anonymous resume endpoint (like classic flows) with fallback to resumeSuspendedFlowAsOwner for admins - Buttons show for everyone when URLs are present; server-side self_approval_disabled check enforces restrictions - Show warning for admins/owners when self-approval is disabled - selfApproval: false requires EE (errors at dispatch on CE) - self_approval_disabled check moved outside user_auth_required gate so it works independently - WAC detection no longer requires task import Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add resume_suspended and approval_info endpoints - New approval_token DB table for token-based approval access - New POST /jobs_u/flow/resume_suspended/{job_id} endpoint: - OptAuthed: works with login or approval_token - Checks approval_conditions (self_approval, groups, auth) - Admins/owners bypass rules - New GET /jobs_u/flow/approval_info/{job_id} endpoint: - Returns form, rules, can_approve status - HMAC anonymous endpoint now bypasses all approval_conditions (secret = full capability) - getResumeUrls approvalPage URL now uses token format - WAC approval dispatch generates and stores approval tokens - Mark resumeSuspendedFlowAsOwner as legacy Co-Authored-By: Claude Opus 4.6 (1M context) * feat: simplify frontend to use resume_suspended endpoint - OpenAPI spec updated with resume_suspended and approval_info endpoints - WorkflowTimeline: removed URL parsing, now calls single resumeSuspended endpoint for both approve and reject - Buttons show for any logged-in user viewing the job (backend enforces authorization rules) - Kept self-approval warning for admins Co-Authored-By: Claude Opus 4.6 (1M context) * feat: stateless approval tokens, new approval page, FlowStatusWaitingForEvents update - Replace DB-stored approval tokens with stateless HMAC derivation: token = HMAC(workspace_key, job_id + "approval_token") Verifiable without DB lookup, not reversible to resume secret - Drop approval_token migration (no DB table needed) - FlowStatusWaitingForEvents: use resumeSuspended endpoint instead of URL parsing + resumeSuspendedFlowAsOwner - New approval page route /approve/{ws}/{job}?token= that uses approval_info and resume_suspended endpoints - Old approval page route kept for back-compat Co-Authored-By: Claude Opus 4.6 (1M context) * feat: match old approval page content in new approval page - Add FlowMetadata, JobArgs, FlowGraphV2, DisplayResult - Add approvers with tooltips, flow arguments section - Add admin self-approval bypass warning - Add "Open run details" link - Fetch full job alongside approval_info for all UI data Co-Authored-By: Claude Opus 4.6 (1M context) * fix: filter _MODULES from args, show 'workflow' for WAC approvals Co-Authored-By: Claude Opus 4.6 (1M context) * chore: remove deno template from approval/prompt SuspendDrawer Co-Authored-By: Claude Opus 4.6 (1M context) * fix: approval page form display + hide deno from approval script picker - Fix form schema rendering on new approval page by wrapping flat WAC form schemas in { properties, order } for SchemaForm - Hide deno from the approval step language picker in flow editor Co-Authored-By: Claude Opus 4.6 (1M context) * fix: remove deno from canHaveApproval in script_helpers.ts The insert menu uses canHaveApproval() from script_helpers.ts via FlowInputsQuick, not the displayLang function in FlowInputs.svelte. Revert the unnecessary FlowInputs.svelte change. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: return form schema and description in approval_info for classic flows The approval_info endpoint was returning None for form_schema on classic flows. Now fetches raw_flow to get suspend.resume_form schema, hide_cancel, and the step's completed result for description. Co-Authored-By: Claude Opus 4.6 (1M context) * feat: inline Login component on approval page instead of redirect Show the Login component directly on the approval page when authentication is required. On successful login, reloads user and approval info without navigating away. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: show resume buttons for all users, not just owners The resume_suspended endpoint handles authorization server-side, so the frontend should always show the buttons. Remove isOwner gate and the "cannot resume" message. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: prevent layout shift on resume by removing spinner from cancel button Co-Authored-By: Claude Opus 4.6 (1M context) * fix: prevent resume button expansion by using disabled instead of loading The loading prop adds a Loader2 spinner that expands the button width. Use disabled={loading} instead to prevent layout shift. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: approval page login redirects back with full page reload Set rd to the full URL (starts with http) so Login.redirectUser() uses window.location.href instead of goto(), triggering a full page reload after login. This ensures the approval page re-fetches data as an authenticated user. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: fetch flow definition from flow_version when raw_flow is null Deployed flows don't store raw_flow on the job. Fall back to flow_version table using runnable_id to get suspend settings (form schema, hide_cancel) for the approval_info endpoint. Co-Authored-By: Claude Opus 4.6 (1M context) * feat: show specific reasons when user cannot approve Display whether denial is due to self-approval being disabled, required group membership, or both. Co-Authored-By: Claude Opus 4.6 (1M context) * feat: support both nested and flat form schema in waitForApproval Users can now pass either: waitForApproval({ form: { schema: { name: { type: "string" } } } }) or: waitForApproval({ form: { name: { type: "string" } } }) Both WorkflowTimeline and approval page handle both formats. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: convert sqlx query macros to non-macro for CI offline cache Replace sqlx::query! and sqlx::query_scalar! with sqlx::query and sqlx::query_as to avoid SQLX_OFFLINE cache misses in CI. Also remove unused LogIn import from approval page. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: suppress dead code warning + unused isOwner variable - Add #[allow(dead_code)] to without_flow method (CI -D warnings) - Rename isOwner to _isOwner in FlowStatusWaitingForEvents (unused) Co-Authored-By: Claude Opus 4.6 (1M context) * fix: security and robustness fixes from PR review - Add workspace_id verification in resume_suspended to prevent cross-workspace approval (#3) - Fix token leakage: use relative path for login redirect instead of full URL with token (#4) - Handle getJob failure independently from approval_info so the page works for unauthenticated users (#7) - Clear error state on successful data load (#13) Co-Authored-By: Claude Opus 4.6 (1M context) * fix: address review feedback — shared token gen, rand resume_id, UX - Move generate_approval_token to windmill-common::variables (shared between windmill-api and windmill-worker, eliminates duplicate HMAC) - Use rand::random::() for resume_id instead of DefaultHasher - Stop polling after approve/reject on approval page - Add cancelLoading state to WorkflowTimeline Reject button Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- backend/Cargo.lock | 1 + backend/windmill-api/openapi.yaml | 123 ++++ backend/windmill-api/src/jobs.rs | 530 ++++++++++++++++-- backend/windmill-common/src/variables.rs | 18 + backend/windmill-worker/Cargo.toml | 1 + backend/windmill-worker/src/bun_executor.rs | 89 ++- backend/windmill-worker/src/wac_executor.rs | 24 +- cli/src/guidance/skills.ts | 15 +- .../components/FlowStatusViewerInner.svelte | 1 + .../FlowStatusWaitingForEvents.svelte | 150 ++--- .../lib/components/WorkflowTimeline.svelte | 145 ++++- .../flows/content/SuspendDrawer.svelte | 18 - .../lib/components/runs/JobRunsPreview.svelte | 1 + .../components/scriptEditor/LogPanel.svelte | 1 + frontend/src/lib/script_helpers.ts | 2 +- .../(root)/(logged)/run/[...run]/+page.svelte | 1 + .../approve/[workspace]/[job]/+page.svelte | 359 ++++++++++++ python-client/wmill/wmill/client.py | 11 +- system_prompts/auto-generated/prompts.ts | 9 +- system_prompts/auto-generated/script.md | 9 +- system_prompts/auto-generated/sdks/python.md | 7 +- .../auto-generated/sdks/typescript.md | 2 +- .../skills/write-script-bun/SKILL.md | 2 +- .../skills/write-script-bunnative/SKILL.md | 2 +- .../skills/write-script-deno/SKILL.md | 2 +- .../skills/write-script-nativets/SKILL.md | 2 +- .../skills/write-script-python3/SKILL.md | 7 +- typescript-client/client.ts | 3 + 28 files changed, 1313 insertions(+), 222 deletions(-) create mode 100644 frontend/src/routes/approve/[workspace]/[job]/+page.svelte diff --git a/backend/Cargo.lock b/backend/Cargo.lock index f044478b25..30138085c9 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -17500,6 +17500,7 @@ dependencies = [ "gcp_auth", "git-version", "hex", + "hmac", "hudsucker", "hyper-http-proxy", "hyper-tls", diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index d55596bc72..6576633319 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -11088,6 +11088,129 @@ paths: "200": description: Interactive slack approval message sent successfully + /w/{workspace}/jobs_u/flow/resume_suspended/{job_id}: + post: + summary: resume or cancel a suspended flow/WAC job + description: > + Resume or cancel a suspended flow/WAC job. Uses approval rules to + determine authorization. Either a valid approval_token or an + authenticated session is required. + operationId: resumeSuspended + tags: + - job + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: job_id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + payload: + description: payload to send to the resumed job + approval_token: + type: string + description: approval token for unauthenticated access + approved: + type: boolean + description: whether to approve (true) or cancel (false) the job + default: true + responses: + "201": + description: job resumed + content: + text/plain: + schema: + type: string + + /w/{workspace}/jobs_u/flow/approval_info/{job_id}: + get: + summary: get approval info for a suspended flow/WAC job + description: > + Get approval info for a suspended flow/WAC job. Returns form schema, + approval rules, and whether the current user can approve. Either a + valid token query parameter or an authenticated session is required. + operationId: getApprovalInfo + tags: + - job + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: job_id + in: path + required: true + schema: + type: string + format: uuid + - name: token + in: query + required: false + schema: + type: string + description: approval token for unauthenticated access + responses: + "200": + description: approval info + content: + application/json: + schema: + type: object + required: + - flow_id + - can_approve + - user_auth_required + - approvers + properties: + flow_id: + type: string + format: uuid + form_schema: + description: form schema for the approval step + description: + description: description of the approval step + approval_conditions: + type: object + properties: + user_auth_required: + type: boolean + user_groups_required: + type: array + items: + type: string + self_approval_disabled: + type: boolean + required: + - user_auth_required + - user_groups_required + - self_approval_disabled + can_approve: + type: boolean + description: whether the current user/token holder can approve + user_auth_required: + type: boolean + description: whether user authentication is required to approve + hide_cancel: + type: boolean + description: whether to hide the cancel button in the UI + approvers: + type: array + items: + type: object + required: + - resume_id + - approver + properties: + resume_id: + type: integer + approver: + type: string + /w/{workspace}/jobs_u/resume/{id}/{resume_id}/{signature}: get: summary: resume a job for a suspended flow diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index a99b2737a8..bde3b81a14 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -103,7 +103,7 @@ use windmill_common::{ cache, db::UserDB, error::{self, to_anyhow, Error}, - flow_status::{Approval, FlowStatus, FlowStatusModule}, + flow_status::{Approval, ApprovalConditions, FlowStatus, FlowStatusModule}, flows::{add_virtual_items_if_necessary, resolve_maybe_value, FlowValue}, jobs::{script_path_to_payload, CompletedJob, JobKind, JobPayload, QueuedJob, RawCode}, oauth2::HmacSha256, @@ -401,6 +401,8 @@ pub fn workspace_unauthed_service() -> Router { post(cancel_persistent_script_api), ) .route("/queue/force_cancel/:id", post(force_cancel)) + .route("/flow/resume_suspended/:job_id", post(resume_suspended)) + .route("/flow/approval_info/:job_id", get(get_approval_info)) } pub fn global_root_service() -> Router { @@ -1058,6 +1060,7 @@ impl<'a> GetQuery<'a> { Self { with_code: false, ..self } } + #[allow(dead_code)] fn without_flow(self) -> Self { Self { with_flow: false, ..self } } @@ -2181,7 +2184,7 @@ pub async fn resume_suspended_flow_as_owner( ) -> error::Result { let mut tx = db.begin().await?; - let (flow, job_id) = get_suspended_flow_info(flow_id, &mut tx).await?; + let (flow, job_id, is_wac) = get_suspended_flow_info(flow_id, &mut tx).await?; let flow_path = flow.script_path.as_deref().unwrap_or_else(|| ""); require_owner_of_path(&authed, flow_path)?; @@ -2189,10 +2192,17 @@ pub async fn resume_suspended_flow_as_owner( // Check approval conditions (self-approval, required groups, etc.) if let Some(ref flow_status_value) = flow.flow_status { - if let Ok(flow_status) = serde_json::from_value::(flow_status_value.clone()) { - let trigger_email = flow.email.as_deref().unwrap_or(""); - conditionally_require_authed_user(Some(authed.clone()), flow_status, trigger_email)?; - } + let trigger_email = flow.email.as_deref().unwrap_or(""); + let ac = serde_json::from_value::(flow_status_value.clone()) + .ok() + .and_then(|fs| fs.approval_conditions) + .or_else(|| { + // WAC flows store approval_conditions directly in flow_status JSONB + flow_status_value + .get("approval_conditions") + .and_then(|v| serde_json::from_value::(v.clone()).ok()) + }); + conditionally_require_authed_user(Some(authed.clone()), ac, trigger_email)?; } let value = value.unwrap_or(serde_json::Value::Null); @@ -2208,12 +2218,426 @@ pub async fn resume_suspended_flow_as_owner( ) .await?; - resume_immediately_if_relevant(flow, job_id, &mut tx).await?; + if is_wac { + // WAC: directly decrement suspend counter + if flow.suspend > 0 { + sqlx::query!( + "UPDATE v2_job_queue SET suspend = GREATEST(suspend - 1, 0) WHERE id = $1", + flow.id, + ) + .execute(&mut *tx) + .await?; + } + } else { + resume_immediately_if_relevant(flow, job_id, &mut tx).await?; + } tx.commit().await?; Ok(StatusCode::CREATED) } +// --- New approval system endpoints --- + +use windmill_common::variables::generate_approval_token; + +/// Verify an approval token against the workspace key + job_id. +async fn validate_approval_token( + db: &DB, + token: &str, + job_id: Uuid, + workspace_id: &str, +) -> error::Result<()> { + let expected = generate_approval_token(workspace_id, job_id, db).await?; + if token != expected { + return Err(Error::NotAuthorized("Invalid approval token".to_string())); + } + Ok(()) +} + +#[derive(Deserialize)] +struct ResumeSuspendedBody { + payload: Option, + approval_token: Option, + approved: Option, +} + +async fn resume_suspended( + OptAuthed(opt_authed): OptAuthed, + Extension(db): Extension, + Path((w_id, job_id)): Path<(String, Uuid)>, + Json(body): Json, +) -> error::Result { + let approved = body.approved.unwrap_or(true); + let value = body.payload.unwrap_or(serde_json::Value::Null); + + // Determine if we have a valid authed user or token + let has_token = if let Some(ref token) = body.approval_token { + validate_approval_token(&db, token, job_id, &w_id) + .await + .is_ok() + } else { + false + }; + + if opt_authed.is_none() && !has_token { + return Err(Error::NotAuthorized( + "Must be logged in or provide a valid approval token".to_string(), + )); + } + + let mut tx = db.begin().await?; + + // Resolve the suspended flow (works for both WAC and classic flows) + let (flow, resume_job_id, is_wac) = get_suspended_flow_info(job_id, &mut tx).await?; + + // Verify the job belongs to this workspace + let job_workspace: Option = + sqlx::query_scalar("SELECT workspace_id FROM v2_job WHERE id = $1") + .bind(&flow.id) + .fetch_optional(&mut *tx) + .await?; + if job_workspace.as_deref() != Some(w_id.as_str()) { + return Err(Error::NotFound( + "Job not found in this workspace".to_string(), + )); + } + + // Check approval conditions + let approval_conditions = if is_wac { + flow.flow_status + .as_ref() + .and_then(|v| v.get("approval_conditions")) + .and_then(|v| serde_json::from_value::(v.clone()).ok()) + } else { + flow.flow_status + .as_ref() + .and_then(|v| serde_json::from_value::(v.clone()).ok()) + .and_then(|fs| fs.approval_conditions) + }; + + if let Some(ref ac) = approval_conditions { + if ac.user_auth_required && opt_authed.is_none() { + return Err(Error::NotAuthorized( + "This approval requires a logged-in user. Please sign in.".to_string(), + )); + } + } + + // If logged in, check authorization rules + if let Some(ref authed) = opt_authed { + let is_admin = authed.is_admin; + let is_owner = flow + .script_path + .as_deref() + .map(|p| require_owner_of_path(authed, p).is_ok()) + .unwrap_or(false); + + if !is_admin && !is_owner { + let trigger_email = flow.email.as_deref().unwrap_or(""); + conditionally_require_authed_user( + Some(authed.clone()), + approval_conditions.clone(), + trigger_email, + )?; + } + } else if !has_token { + return Err(Error::NotAuthorized( + "Must be logged in or provide a valid approval token".to_string(), + )); + } + + // Generate a unique resume_id + let resume_id: u32 = rand::random(); + + // Check for duplicate + let exists: bool = sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM resume_job WHERE id = $1)") + .bind(Uuid::from_u128(resume_job_id.as_u128() ^ resume_id as u128)) + .fetch_one(&mut *tx) + .await?; + + if exists { + return Err(Error::BadRequest("Resume request already sent".to_string())); + } + + let approver_value = opt_authed.as_ref().map(|a| a.username.clone()); + + insert_resume_job( + resume_id, + resume_job_id, + &flow, + value, + approver_value.clone(), + approved, + &mut tx, + ) + .await?; + + if !approved { + sqlx::query("UPDATE v2_job_queue SET suspend = 0 WHERE id = $1") + .bind(&flow.id) + .execute(&mut *tx) + .await?; + } else if is_wac { + if flow.suspend > 0 { + sqlx::query("UPDATE v2_job_queue SET suspend = GREATEST(suspend - 1, 0) WHERE id = $1") + .bind(&flow.id) + .execute(&mut *tx) + .await?; + } + } else { + resume_immediately_if_relevant(flow, resume_job_id, &mut tx).await?; + } + + let approver = approver_value.unwrap_or_else(|| "anonymous".to_string()); + let audit_author = if let Some(ref authed) = opt_authed { + AuditAuthor::from(authed) + } else { + AuditAuthor { + email: approver.clone(), + username: approver.clone(), + username_override: None, + token_prefix: None, + } + }; + + audit_log( + &mut *tx, + &audit_author, + "jobs.suspend_resume", + ActionKind::Update, + &w_id, + Some( + &serde_json::json!({ + "approved": approved, + "job_id": job_id, + "details": if approved { + format!("Approved by {}", &approver) + } else { + format!("Cancelled by {}", &approver) + } + }) + .to_string(), + ), + None, + ) + .await?; + + tx.commit().await?; + Ok(StatusCode::CREATED) +} + +#[derive(Deserialize)] +struct ApprovalInfoQuery { + token: Option, +} + +#[derive(Serialize)] +struct ApprovalInfo { + flow_id: Uuid, + #[serde(skip_serializing_if = "Option::is_none")] + form_schema: Option, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + approval_conditions: Option, + can_approve: bool, + user_auth_required: bool, + #[serde(skip_serializing_if = "Option::is_none")] + hide_cancel: Option, + approvers: Vec, +} + +async fn get_approval_info( + OptAuthed(opt_authed): OptAuthed, + Extension(db): Extension, + Path((w_id, job_id)): Path<(String, Uuid)>, + Query(query): Query, +) -> error::Result> { + // Validate access: either logged in or valid token + let has_token = if let Some(ref token) = query.token { + validate_approval_token(&db, token, job_id, &w_id) + .await + .is_ok() + } else { + false + }; + + if opt_authed.is_none() && !has_token { + return Err(Error::NotAuthorized( + "Must be logged in or provide a valid approval token".to_string(), + )); + } + + // Fetch job info + #[derive(sqlx::FromRow)] + struct ApprovalJobRow { + id: Uuid, + script_path: Option, + email: String, + flow_status: Option, + workflow_as_code_status: Option, + } + let row = sqlx::query_as::<_, ApprovalJobRow>( + "SELECT j.id, j.runnable_path as script_path, j.permissioned_as_email as email, + s.flow_status, s.workflow_as_code_status + FROM v2_job j + LEFT JOIN v2_job_status s ON s.id = j.id + WHERE j.id = $1 AND j.workspace_id = $2", + ) + .bind(&job_id) + .bind(&w_id) + .fetch_optional(&db) + .await? + .ok_or_else(|| Error::NotFound(format!("Job {job_id} not found")))?; + + let is_wac = row.workflow_as_code_status.is_some(); + + // Extract approval info based on WAC vs classic flow + let (form_schema, description, approval_conditions, hide_cancel) = if is_wac { + let approval_meta = row + .workflow_as_code_status + .as_ref() + .and_then(|v| v.get("_approval")); + let form = approval_meta.and_then(|m| m.get("form").cloned()); + let ac = row + .flow_status + .as_ref() + .and_then(|v| v.get("approval_conditions")) + .and_then(|v| serde_json::from_value::(v.clone()).ok()); + (form, None, ac, None) + } else { + let fs = row + .flow_status + .as_ref() + .and_then(|v| serde_json::from_value::(v.clone()).ok()); + let ac = fs.as_ref().and_then(|s| s.approval_conditions.clone()); + + // For classic flows, form/description come from the flow definition and step result + let approval_step = fs.as_ref().map(|s| (s.step as usize).saturating_sub(1)); + + // Fetch flow definition to get suspend settings (form schema, hide_cancel). + // Try raw_flow on the job first, fall back to flow_version for deployed flows. + let raw_flow: Option = { + let from_job: Option = sqlx::query_scalar( + "SELECT raw_flow FROM v2_job WHERE id = $1 AND workspace_id = $2", + ) + .bind(&job_id) + .bind(&w_id) + .fetch_optional(&db) + .await? + .flatten(); + + if let Some(v) = from_job { + serde_json::from_value(v).ok() + } else { + // Deployed flow: fetch from flow_version using runnable_id + let from_version: Option = sqlx::query_scalar( + "SELECT fv.value FROM v2_job j JOIN flow_version fv ON fv.id = j.runnable_id \ + WHERE j.id = $1 AND j.workspace_id = $2", + ) + .bind(&job_id) + .bind(&w_id) + .fetch_optional(&db) + .await? + .flatten(); + from_version.and_then(|v| serde_json::from_value(v).ok()) + } + }; + + let suspend_module = raw_flow + .as_ref() + .and_then(|rf| approval_step.and_then(|s| rf.modules.get(s))); + let suspend_settings = suspend_module.and_then(|m| m.suspend.as_ref()); + + let form = suspend_settings + .and_then(|s| s.resume_form.as_ref()) + .map(|rf| serde_json::json!(rf)); + let hc = suspend_settings.map(|s| s.hide_cancel.unwrap_or(false)); + + // Fetch description and default_args from the step's completed job result + let step_job_id = fs + .as_ref() + .and_then(|s| approval_step.and_then(|step| s.modules.get(step))) + .and_then(|m| m.job()); + let (desc, _default_args) = if let Some(sjid) = step_job_id { + let result: Option = sqlx::query_scalar( + "SELECT result FROM v2_job_completed WHERE id = $1 AND workspace_id = $2", + ) + .bind(sjid) + .bind(&w_id) + .fetch_optional(&db) + .await? + .flatten(); + let desc = result.as_ref().and_then(|r| r.get("description").cloned()); + let da = result.as_ref().and_then(|r| r.get("default_args").cloned()); + (desc, da) + } else { + (None, None) + }; + + (form, desc, ac, hc) + }; + + let user_auth_required = approval_conditions + .as_ref() + .map(|ac| ac.user_auth_required) + .unwrap_or(false); + + // Determine if current user can approve + let can_approve = if let Some(ref authed) = opt_authed { + if authed.is_admin { + true + } else { + let is_owner = row + .script_path + .as_deref() + .map(|p| require_owner_of_path(authed, p).is_ok()) + .unwrap_or(false); + if is_owner { + true + } else { + let trigger_email = row.email.as_str(); + conditionally_require_authed_user( + Some(authed.clone()), + approval_conditions.clone(), + trigger_email, + ) + .is_ok() + } + } + } else { + // Not logged in — can approve only if no auth required + !user_auth_required + }; + + // Get existing approvers + let approvers: Vec = sqlx::query_as::<_, (i32, Option)>( + "SELECT resume_id, approver FROM resume_job WHERE flow = $1", + ) + .bind(&job_id) + .fetch_all(&db) + .await? + .into_iter() + .map(|(rid, approver)| Approval { + resume_id: rid as u16, + approver: approver.unwrap_or_else(|| "anonymous".to_string()), + }) + .collect(); + + Ok(Json(ApprovalInfo { + flow_id: row.id, + form_schema, + description, + approval_conditions, + can_approve, + user_auth_required, + hide_cancel, + approvers, + })) +} + +// --- End new approval system endpoints --- + pub async fn resume_suspended_job( authed: Option, opt_tokened: OptTokened, @@ -2255,26 +2679,8 @@ async fn resume_suspended_job_internal( // Get flow info - works for step-level, flow-level, and WAC approval let (flow_info, is_flow_level, is_wac) = get_flow_info_for_resume(job_id, &db).await?; - // For step-level resumes, verify user auth and flow status - // For flow-level resumes (pre-approvals), the flow might not be at a suspended step yet - // For WAC approvals, skip flow status checks (there is no flow) - if !is_flow_level && !is_wac { - let parent_flow = GetQuery::new() - .without_logs() - .without_code() - .without_flow() - .fetch(&db, &flow_info.id, &w_id) - .await?; - let flow_status = parent_flow - .flow_status() - .ok_or_else(|| anyhow::anyhow!("unable to find the flow status in the flow job"))?; - - let trigger_email = match &parent_flow { - Job::CompletedJob(job) => &job.email, - Job::QueuedJob(job) => &job.email, - }; - conditionally_require_authed_user(authed.clone(), flow_status, trigger_email)?; - } + // HMAC secret = full capability. Skip approval_conditions checks. + // Authorization rules are enforced by the new resume_suspended endpoint instead. let exists = sqlx::query_scalar!( r#" @@ -2540,7 +2946,7 @@ async fn get_flow_info_for_resume(job_id: Uuid, db: &DB) -> error::Result<(FlowI async fn get_suspended_flow_info<'c>( job_id: Uuid, tx: &mut Transaction<'c, Postgres>, -) -> error::Result<(FlowInfo, Uuid)> { +) -> error::Result<(FlowInfo, Uuid, bool)> { let flow = sqlx::query_as!( FlowInfo, r#" @@ -2553,7 +2959,9 @@ async fn get_suspended_flow_info<'c>( .fetch_optional(&mut **tx) .await? .ok_or_else(|| anyhow::anyhow!("parent flow job not found"))?; - let job_id = flow + + // Try to extract step job_id from FlowStatus modules (classic flow path) + let step_job_id = flow .flow_status .as_ref() .and_then(|v| serde_json::from_value::(v.clone()).ok()) @@ -2562,8 +2970,31 @@ async fn get_suspended_flow_info<'c>( _ => None, }); - if let Some(job_id) = job_id { - Ok((flow, job_id)) + if let Some(step_job_id) = step_job_id { + // Classic flow + Ok((flow, step_job_id, false)) + } else if flow.suspend > 0 { + // WAC approval: no FlowStatus modules, but the job is suspended + // The flow_status here comes from COALESCE(flow_status, workflow_as_code_status), + // so for WAC it may contain approval_conditions from flow_status column + // or the WAC checkpoint from workflow_as_code_status column. + // We need the approval_conditions which are in flow_status column. + // Re-fetch just flow_status (without COALESCE fallback) for the auth check. + let flow_status_only: Option = + sqlx::query_scalar("SELECT flow_status FROM v2_job_status WHERE id = $1") + .bind(&job_id) + .fetch_optional(&mut **tx) + .await? + .flatten(); + + let flow = FlowInfo { + id: flow.id, + flow_status: flow_status_only, + suspend: flow.suspend, + script_path: flow.script_path, + email: flow.email, + }; + Ok((flow, job_id, true)) } else { Err(anyhow::anyhow!("the flow is not in a suspended state anymore").into()) } @@ -2640,7 +3071,11 @@ pub async fn get_suspended_job_flow( Job::CompletedJob(job) => &job.email, Job::QueuedJob(job) => &job.email, }; - conditionally_require_authed_user(authed.clone(), flow_status.clone(), trigger_email)?; + conditionally_require_authed_user( + authed.clone(), + flow_status.approval_conditions.clone(), + trigger_email, + )?; let approvers_from_status = match flow_module_status { FlowStatusModule::Success { approvers, .. } => approvers.to_owned(), @@ -2681,16 +3116,25 @@ pub async fn get_suspended_job_flow( fn conditionally_require_authed_user( _authed: Option, - flow_status: FlowStatus, + approval_conditions_opt: Option, _trigger_email: &str, ) -> error::Result<()> { - let approval_conditions_opt = flow_status.approval_conditions; - if approval_conditions_opt.is_none() { return Ok(()); } let approval_conditions = approval_conditions_opt.unwrap(); + // Check self-approval independently of user_auth_required + if approval_conditions.self_approval_disabled { + if let Some(ref authed) = _authed { + if !authed.is_admin && authed.email.eq(_trigger_email) { + return Err(Error::PermissionDenied( + "Self-approval is disabled for this flow step".to_string(), + )); + } + } + } + if approval_conditions.user_auth_required { { #[cfg(not(feature = "enterprise"))] @@ -2708,13 +3152,6 @@ fn conditionally_require_authed_user( let authed = _authed.unwrap(); if !authed.is_admin { - if approval_conditions.self_approval_disabled && authed.email.eq(_trigger_email) - { - return Err(Error::PermissionDenied( - "Self-approval is disabled for this flow step".to_string(), - )); - } - if !approval_conditions.user_groups_required.is_empty() { #[cfg(feature = "enterprise")] { @@ -2860,11 +3297,18 @@ pub async fn get_resume_urls_internal( .map(|x| format!("?approver={}", encode(x))) .unwrap_or_else(String::new); + // Generate approval token for the new approval page URL. + // The token targets the parent flow/WAC job for proper resolution. + let approval_target_id = get_flow_id_for_job(&db, job_id) + .await + .unwrap_or(target_job_id); + let approval_token = generate_approval_token(&w_id, approval_target_id, &db).await?; + let base_url_str = BASE_URL.read().await.clone(); let base_url = base_url_str.as_str(); let res = ResumeUrls { approvalPage: format!( - "{base_url}/approve/{w_id}/{target_job_id}/{resume_id}/{signature}{approver_query}" + "{base_url}/approve/{w_id}/{approval_target_id}?token={approval_token}" ), cancel: build_resume_url( "cancel", diff --git a/backend/windmill-common/src/variables.rs b/backend/windmill-common/src/variables.rs index 8129f39b3f..e7595f9d1b 100644 --- a/backend/windmill-common/src/variables.rs +++ b/backend/windmill-common/src/variables.rs @@ -140,6 +140,24 @@ pub async fn get_workspace_key(w_id: &str, db: &DB) -> crate::error::Result crate::error::Result { + use hmac::{Hmac, Mac}; + use sha2::Sha256; + let key = get_workspace_key(w_id, db).await?; + let mut mac = Hmac::::new_from_slice(key.as_bytes()) + .map_err(|e| crate::Error::internal_err(format!("HMAC key error: {e}")))?; + mac.update(job_id.as_bytes()); + mac.update(b"approval_token"); + Ok(hex::encode(mac.finalize().into_bytes())) +} + pub async fn get_secret_value_as_admin( db: &DB, w_id: &str, diff --git a/backend/windmill-worker/Cargo.toml b/backend/windmill-worker/Cargo.toml index ed82298038..c1e2a4927a 100644 --- a/backend/windmill-worker/Cargo.toml +++ b/backend/windmill-worker/Cargo.toml @@ -110,6 +110,7 @@ gcp_auth = { workspace = true, optional = true } rust_decimal.workspace = true jsonwebtoken.workspace = true sha2.workspace = true +hmac.workspace = true pem = { workspace = true, optional = true } urlencoding.workspace = true nix.workspace = true diff --git a/backend/windmill-worker/src/bun_executor.rs b/backend/windmill-worker/src/bun_executor.rs index f0ed8793a7..7dbaa6723d 100644 --- a/backend/windmill-worker/src/bun_executor.rs +++ b/backend/windmill-worker/src/bun_executor.rs @@ -1504,7 +1504,7 @@ async function run() {{ return {{ type: "inline_checkpoint", key: dispatch.key, result: dispatch.result ?? null, started_at: dispatch.started_at, duration_ms: dispatch.duration_ms }}; }} if (dispatch.mode === "approval") {{ - return {{ type: "approval", key: dispatch.key, timeout: dispatch.timeout, form: dispatch.form }}; + return {{ type: "approval", key: dispatch.key, timeout: dispatch.timeout, form: dispatch.form, self_approval_disabled: dispatch.self_approval_disabled }}; }} if (dispatch.mode === "sleep") {{ return {{ type: "sleep", key: dispatch.key, seconds: dispatch.seconds }}; @@ -2634,7 +2634,7 @@ pub async fn handle_wac_v2_output( job.id, num_steps ))) } - WacOutput::Approval { key, timeout, form } => { + WacOutput::Approval { key, timeout, form, self_approval_disabled } => { let db = match conn { Connection::Sql(db) => db, _ => { @@ -2676,11 +2676,91 @@ pub async fn handle_wac_v2_output( .await .map_err(|e| error::Error::internal_err(format!("Failed to save checkpoint: {e}")))?; + // Store approval_conditions in flow_status for resume endpoint auth checks + let sad = self_approval_disabled.unwrap_or(false); + if sad { + #[cfg(not(feature = "enterprise"))] + return Err(error::Error::ExecutionErr( + "Disabling self-approval is an enterprise only feature".to_string(), + )); + + #[cfg(feature = "enterprise")] + { + use windmill_common::flow_status::ApprovalConditions; + let approval_conditions = ApprovalConditions { + user_auth_required: true, + user_groups_required: vec![], + self_approval_disabled: true, + }; + sqlx::query( + "UPDATE v2_job_status SET flow_status = JSONB_SET( + COALESCE(flow_status, '{}'::jsonb), + '{approval_conditions}', + $2::jsonb + ) WHERE id = $1", + ) + .bind(&job.id) + .bind(&serde_json::json!(approval_conditions)) + .execute(&mut *tx) + .await + .map_err(|e| { + error::Error::internal_err(format!( + "Failed to save approval conditions: {e}" + )) + })?; + } + } + + // Generate resume URLs for the inline approval buttons. + // Use a hash of the step key as resume_id so each waitForApproval() + // in the same workflow gets a unique resume_job record. + let resume_id: u32 = { + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + key.hash(&mut hasher); + (hasher.finish() & 0xFFFF_FFFF) as u32 + }; + // Generate stateless approval token using shared utility + let approval_token = + windmill_common::variables::generate_approval_token(&job.workspace_id, job.id, db) + .await?; + + let (resume_url, cancel_url, approval_page_url) = { + use hmac::{Hmac, Mac}; + use sha2::Sha256; + use windmill_common::variables::get_workspace_key; + + let wkey = get_workspace_key(&job.workspace_id, db).await?; + let mut mac = Hmac::::new_from_slice(wkey.as_bytes()) + .map_err(|e| error::Error::internal_err(format!("HMAC key error: {e}")))?; + mac.update(job.id.as_bytes()); + mac.update(resume_id.to_be_bytes().as_ref()); + let signature = hex::encode(mac.finalize().into_bytes()); + + let base_url = windmill_common::BASE_URL.read().await.clone(); + let w_id = &job.workspace_id; + let job_id = &job.id; + + let resume = format!( + "{base_url}/api/w/{w_id}/jobs_u/resume/{job_id}/{resume_id}/{signature}" + ); + let cancel = format!( + "{base_url}/api/w/{w_id}/jobs_u/cancel/{job_id}/{resume_id}/{signature}" + ); + let approval_page = + format!("{base_url}/approve/{w_id}/{job_id}?token={approval_token}"); + (resume, cancel, approval_page) + }; + // Store approval form metadata for the approval page endpoint let approval_meta = serde_json::json!({ "key": key, "form": form, "timeout": timeout_secs as u32, + "self_approval_disabled": sad, + "resume": resume_url, + "cancel": cancel_url, + "approvalPage": approval_page_url, }); sqlx::query( "UPDATE v2_job_status SET workflow_as_code_status = jsonb_set( @@ -2705,6 +2785,11 @@ pub async fn handle_wac_v2_output( "started_at": &now_str, "name": key, "approval": true, + "self_approval_disabled": sad, + "form": form, + "resume": &resume_url, + "cancel": &cancel_url, + "approvalPage": &approval_page_url, }); let step_timeline_key = format!("_step/{}", key); sqlx::query( diff --git a/backend/windmill-worker/src/wac_executor.rs b/backend/windmill-worker/src/wac_executor.rs index 208012ccce..9b4ba3d92a 100644 --- a/backend/windmill-worker/src/wac_executor.rs +++ b/backend/windmill-worker/src/wac_executor.rs @@ -59,7 +59,13 @@ pub enum WacOutput { /// No child job is dispatched — the parent suspends directly and resumes /// when a user hits the resume/cancel endpoint. #[serde(rename = "approval")] - Approval { key: String, timeout: Option, form: Option }, + Approval { + key: String, + timeout: Option, + form: Option, + #[serde(default)] + self_approval_disabled: Option, + }, /// Server-side sleep — suspend the workflow for a duration without holding a worker. #[serde(rename = "sleep")] Sleep { key: String, seconds: u32 }, @@ -306,15 +312,13 @@ pub async fn prepare_checkpoint_for_resume( } /// Detect WAC v2 patterns in TypeScript/Bun code. -/// Checks for `import ... from "windmill-client"` containing workflow/task, +/// Checks for `import ... from "windmill-client"` containing workflow, /// skipping comment lines. Handles both single-line and multi-line imports. pub fn is_wac_v2_ts(code: &str) -> bool { let mut has_wac_import = false; let mut has_workflow = false; - let mut has_task = false; let mut in_import_block = false; let mut import_block_has_workflow = false; - let mut import_block_has_task = false; for line in code.lines() { let trimmed = line.trim(); if trimmed.starts_with("//") { @@ -328,34 +332,24 @@ pub fn is_wac_v2_ts(code: &str) -> bool { if trimmed.contains("workflow") { has_workflow = true; } - if trimmed.contains("task") { - has_task = true; - } in_import_block = false; } // Start of multi-line import: import { else if trimmed.starts_with("import") && trimmed.contains("{") && !trimmed.contains("}") { in_import_block = true; import_block_has_workflow = trimmed.contains("workflow"); - import_block_has_task = trimmed.contains("task"); } // Inside multi-line import block else if in_import_block { if trimmed.contains("workflow") { import_block_has_workflow = true; } - if trimmed.contains("task") { - import_block_has_task = true; - } // End of multi-line import: } from "windmill-client" if trimmed.contains("windmill-client") { has_wac_import = true; if import_block_has_workflow { has_workflow = true; } - if import_block_has_task { - has_task = true; - } in_import_block = false; } // End of import block but not windmill-client @@ -367,7 +361,7 @@ pub fn is_wac_v2_ts(code: &str) -> bool { has_workflow = true; } } - has_wac_import && has_workflow && has_task + has_wac_import && has_workflow } /// Detect WAC v2 patterns in Python code. diff --git a/cli/src/guidance/skills.ts b/cli/src/guidance/skills.ts index e1354302e6..662c55eb8e 100644 --- a/cli/src/guidance/skills.ts +++ b/cli/src/guidance/skills.ts @@ -740,7 +740,7 @@ workflow(fn: (...args: any[]) => Promise): void * await step("notify", () => sendEmail(urls.approvalPage)); * const { value, approver } = await waitForApproval({ timeout: 3600 }); */ -waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }> +waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }> /** * Process items in parallel with optional concurrency control. @@ -1403,7 +1403,7 @@ workflow(fn: (...args: any[]) => Promise): void * await step("notify", () => sendEmail(urls.approvalPage)); * const { value, approver } = await waitForApproval({ timeout: 3600 }); */ -waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }> +waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }> /** * Process items in parallel with optional concurrency control. @@ -2129,7 +2129,7 @@ workflow(fn: (...args: any[]) => Promise): void * await step("notify", () => sendEmail(urls.approvalPage)); * const { value, approver } = await waitForApproval({ timeout: 3600 }); */ -waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }> +waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }> /** * Process items in parallel with optional concurrency control. @@ -3069,7 +3069,7 @@ workflow(fn: (...args: any[]) => Promise): void * await step("notify", () => sendEmail(urls.approvalPage)); * const { value, approver } = await waitForApproval({ timeout: 3600 }); */ -waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }> +waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }> /** * Process items in parallel with optional concurrency control. @@ -4078,12 +4078,17 @@ async def sleep(seconds: int) # # Returns a dict with \`\`value\`\` (form data), \`\`approver\`\`, and \`\`approved\`\`. # +# Args: +# timeout: Approval timeout in seconds (default 1800). +# form: Optional form schema for the approval page. +# self_approval: Whether the user who triggered the flow can approve it (default True). +# # Example:: # # urls = await step("urls", lambda: get_resume_urls()) # await step("notify", lambda: send_email(urls["approvalPage"])) # result = await wait_for_approval(timeout=3600) -async def wait_for_approval(timeout: int = 1800, form: dict | None = None) -> dict +async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True) -> dict # Process items in parallel with optional concurrency control. # diff --git a/frontend/src/lib/components/FlowStatusViewerInner.svelte b/frontend/src/lib/components/FlowStatusViewerInner.svelte index e9f2361629..2c580bb02b 100644 --- a/frontend/src/lib/components/FlowStatusViewerInner.svelte +++ b/frontend/src/lib/components/FlowStatusViewerInner.svelte @@ -2084,6 +2084,7 @@ stepResults={getStepResults(node.workflow_as_code_status)} result={node.result} success={node.type === 'Success'} + jobId={node.job_id} />
{/if} diff --git a/frontend/src/lib/components/FlowStatusWaitingForEvents.svelte b/frontend/src/lib/components/FlowStatusWaitingForEvents.svelte index 60f30b1adc..195a1d6fad 100644 --- a/frontend/src/lib/components/FlowStatusWaitingForEvents.svelte +++ b/frontend/src/lib/components/FlowStatusWaitingForEvents.svelte @@ -18,11 +18,9 @@ light?: boolean } - let { isOwner, workspaceId, job, light = false }: Props = $props() + let { isOwner: _isOwner, workspaceId, job, light = false }: Props = $props() let default_payload: object = $state({}) - let resumeUrl: string | undefined = $state(undefined) - let cancelUrl: string | undefined = $state(undefined) let description: any = $state(undefined) let hide_cancel = $state(false) @@ -49,8 +47,6 @@ defaultValues = JSON.parse(JSON.stringify(args)) default_payload = args - resumeUrl = job_result?.['resume'] - cancelUrl = job_result?.['cancel'] hide_cancel = job?.raw_flow?.modules?.[approvalStep]?.suspend?.hide_cancel ?? false schema = mergeSchema( job?.raw_flow?.modules?.[approvalStep]?.suspend?.resume_form?.schema ?? {}, @@ -61,61 +57,19 @@ let loading = $state(false) async function continu(approve: boolean) { loading = true - if ((resumeUrl && approve) || (cancelUrl && !approve)) { - let split = (approve ? resumeUrl : cancelUrl)!.split('/') - let signatureUrl = split.pop() ?? '' - const regex = /([^?]+)(?:\?[^=]+=(\w+))?/ - - const matches = signatureUrl.match(regex) - - const signature = matches?.[1] - if (!signature) { - sendUserToast(`Could not parse signature: ${signatureUrl}`, true) - return - } - const approver = matches?.[2] || undefined - - let resumeId = -1 - let parsedResumeId = split.pop() ?? '' - try { - resumeId = new Number(parsedResumeId).valueOf() - } catch (e) { - console.error(`Could not parse resume id: ${parsedResumeId}`) - } - let jobId = split.pop() ?? '' - if (approve) { - await JobService.resumeSuspendedJobPost({ - workspace: workspaceId ?? $workspaceStore ?? '', - id: jobId, - requestBody: default_payload as any, - resumeId, - signature, - approver - }) - } else { - await JobService.cancelSuspendedJobPost({ - workspace: workspaceId ?? $workspaceStore ?? '', - id: jobId, - resumeId, - signature, - approver, - requestBody: {} - }) - } - } else { - if (approve) { - await JobService.resumeSuspendedFlowAsOwner({ - workspace: workspaceId ?? $workspaceStore ?? '', - id: job?.id ?? '', - requestBody: default_payload as any - }) - } else { - await JobService.cancelQueuedJob({ - workspace: workspaceId ?? $workspaceStore ?? '', - id: job?.id ?? '', - requestBody: {} - }) - } + try { + await JobService.resumeSuspended({ + workspace: workspaceId ?? $workspaceStore ?? '', + jobId: job?.id ?? '', + requestBody: { + payload: approve ? (default_payload as any) : undefined, + approved: approve + } + }) + } catch (e: any) { + sendUserToast(e?.body ?? e?.message ?? 'Failed', true) + } finally { + loading = false } } let approvalStep = $derived((job?.flow_status?.step ?? 1) - 1) @@ -130,51 +84,41 @@
{/if}
- {#if isOwner || resumeUrl} -
- {#if !hide_cancel} -
-
- {/if} +
+ {#if !hide_cancel}
- +
- - {#if job?.raw_flow?.modules?.[approvalStep]?.suspend?.resume_form?.schema} -
- -
- - The payload is optional, it is passed to the following step through the `resume` - variable - - {/if} + {/if} +
+
- {:else} - You cannot resume the flow yourself without receiving the resume secret since you are not an - owner of {job.script_path} and the approval step did not contain the resume url at key `resume` - {/if} + + {#if job?.raw_flow?.modules?.[approvalStep]?.suspend?.resume_form?.schema} +
+ +
+ + The payload is optional, it is passed to the following step through the `resume` variable + + {/if} +
diff --git a/frontend/src/lib/components/WorkflowTimeline.svelte b/frontend/src/lib/components/WorkflowTimeline.svelte index 3dea7279d6..2ea5ebba50 100644 --- a/frontend/src/lib/components/WorkflowTimeline.svelte +++ b/frontend/src/lib/components/WorkflowTimeline.svelte @@ -1,6 +1,6 @@ {#if flow_status} @@ -167,22 +216,74 @@ sleep ({(v as any).sleep_duration_s}s)
{:else if isApproval} -
-
- - - {v.name ?? stepKey(k)} - - {#if !isDone} - - - waiting + {@const selfApprovalDisabled = (v as any).self_approval_disabled === true} + {@const formSchema = (v as any).form?.schema ?? (v as any).form} + {@const hasForm = + formSchema && typeof formSchema === 'object' && Object.keys(formSchema).length > 0} + {@const canApprove = !isDone && jobId} +
+
+
+ + + {v.name ?? stepKey(k)} - {:else} - {msToSec(v.duration_ms ?? 0)}s + {#if !isDone} + + + waiting + + {#if canApprove} +
+ + +
+ {/if} + {:else} + {msToSec(v.duration_ms ?? 0)}s + {/if} +
+ {#if canApprove && selfApprovalDisabled && $userStore?.is_admin} +
+ Self-approval is disabled but allowed because you are an admin/owner +
+ {/if} + {#if canApprove && hasForm} +
+ {#if emptyString($enterpriseLicense)} + + {:else} + + {/if} +
{/if}
{:else} @@ -275,13 +376,13 @@ {@const result = stepResults[stepKey(k)]} {#if isDone && result !== undefined}
-
Result
+
Result
{:else} -
Step completed (no result)
+
Step completed (no result)
{/if} {:else if loadingJobs[k] && !childJobs[k]}
@@ -293,7 +394,7 @@ {#if job.logs || isRunning}
-
Logs
+
Logs
{#if isDone && job.result !== undefined}
-
Result
+
Result
diff --git a/frontend/src/lib/components/flows/content/SuspendDrawer.svelte b/frontend/src/lib/components/flows/content/SuspendDrawer.svelte index ee22349cac..3ac2afe001 100644 --- a/frontend/src/lib/components/flows/content/SuspendDrawer.svelte +++ b/frontend/src/lib/components/flows/content/SuspendDrawer.svelte @@ -39,27 +39,9 @@ render a cancel button, providing the operator with an option to cancel the step. e.g: - {#snippet content()} - - -
{/if} diff --git a/frontend/src/lib/components/scriptEditor/LogPanel.svelte b/frontend/src/lib/components/scriptEditor/LogPanel.svelte index 271e2a765c..82845c4a88 100644 --- a/frontend/src/lib/components/scriptEditor/LogPanel.svelte +++ b/frontend/src/lib/components/scriptEditor/LogPanel.svelte @@ -158,6 +158,7 @@ result={previewJob?.result} success={previewJob?.success !== false} autoExpandResult + jobId={previewJob?.id} />
{:else} diff --git a/frontend/src/lib/script_helpers.ts b/frontend/src/lib/script_helpers.ts index d05d78f6ef..6865637ba7 100644 --- a/frontend/src/lib/script_helpers.ts +++ b/frontend/src/lib/script_helpers.ts @@ -1601,7 +1601,7 @@ export function canHaveApproval(language: SupportedLanguage | undefined): boolea return false } - return ['python3', 'bun', 'deno'].includes(language) + return ['python3', 'bun'].includes(language) } export function canHaveFailure(language: SupportedLanguage | undefined): boolean { diff --git a/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte b/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte index 5bf8f21ac5..5f7e426fe9 100644 --- a/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte @@ -797,6 +797,7 @@ stepResults={getStepResults(job.workflow_as_code_status)} result={job.result} success={(job as any).success !== false} + jobId={job.id} />
diff --git a/frontend/src/routes/approve/[workspace]/[job]/+page.svelte b/frontend/src/routes/approve/[workspace]/[job]/+page.svelte new file mode 100644 index 0000000000..6c9638a948 --- /dev/null +++ b/frontend/src/routes/approve/[workspace]/[job]/+page.svelte @@ -0,0 +1,359 @@ + + + + + + {#if error} +
+ {#if error.includes('logged in') || error.includes('sign in') || error.includes('Not authorized')} +
+ +

Not Authorized

+
+

{error}

+ + {:else if error.includes('Permission denied') || error.includes('Self-approval')} +
+ +

Permission denied

+
+

{error}

+ {:else} +
+ +

Error

+
+

{error}

+ {/if} +
+ {:else if approvalInfo} +
+
+

Approvers

+
+ {#if approvalInfo.approvers?.length > 0} +
    + {#each approvalInfo.approvers as a} +
  • +

    + {a.approver} + Unique id of approval: {a.resume_id} +

    +
  • + {/each} +
+ {:else} +

+ No current approvers for this step (approval steps can require more than one approval) +

+ {/if} +
+
+
+ {#if job && job.raw_flow} + + {/if} +
+
+ + {#if !completed} +

+ {isWac ? 'Workflow' : 'Flow'} arguments +

+ + {/if} + +
+ +
+ {#if completed} + + The flow is not running anymore. You cannot cancel or resume it. + + {/if} + + {#if approvalInfo.description != undefined} + + {/if} + + {#if hasForm && !completed} + {#if emptyString($enterpriseLicense)} + + {:else} + + {/if} + {/if} + + {#if !completed && approvalInfo.can_approve} +
+ {#if approvalInfo.hide_cancel !== true} + + {:else} +
+ {/if} + +
+ {:else if !completed && !approvalInfo.can_approve} + {#if approvalInfo.user_auth_required && !$userStore} + + {:else} +
+

You are not authorized to approve this flow.

+ {#if approvalInfo.approval_conditions?.self_approval_disabled && $userStore && $userStore.email === (job as any)?.email} +

Self-approval is disabled for this step.

+ {/if} + {#if approvalInfo.approval_conditions?.user_groups_required?.length > 0} +

Only members of the following groups can approve: {approvalInfo.approval_conditions.user_groups_required.join(', ')}

+ {/if} +
+ {/if} + {:else if completed} + + {/if} + + {#if !completed && isSelfApprovalBypass} +
+ + As an administrator, by resuming or cancelling this stage of the flow, you bypass the + self-approval interdiction. + +
+ {/if} +
+ + + + {#if job && job.raw_flow && !completed} +

Flow details

+
+ +
+ {/if} + {:else} +

Loading...

+ {/if} +
diff --git a/python-client/wmill/wmill/client.py b/python-client/wmill/wmill/client.py index cdd3342771..d952584e5a 100644 --- a/python-client/wmill/wmill/client.py +++ b/python-client/wmill/wmill/client.py @@ -2463,7 +2463,7 @@ class WorkflowCtx: ) async def _wait_for_approval( - self, timeout: int = 1800, form: dict | None = None + self, timeout: int = 1800, form: dict | None = None, self_approval: bool = True ): key = self._alloc_key("approval") @@ -2479,6 +2479,7 @@ class WorkflowCtx: "key": key, "timeout": timeout, "form": form, + "self_approval_disabled": not self_approval, "steps": [], }) @@ -2762,6 +2763,7 @@ async def sleep(seconds: int): async def wait_for_approval( timeout: int = 1800, form: dict | None = None, + self_approval: bool = True, ) -> dict: """Suspend the workflow and wait for an external approval. @@ -2770,6 +2772,11 @@ async def wait_for_approval( Returns a dict with ``value`` (form data), ``approver``, and ``approved``. + Args: + timeout: Approval timeout in seconds (default 1800). + form: Optional form schema for the approval page. + self_approval: Whether the user who triggered the flow can approve it (default True). + Example:: urls = await step("urls", lambda: get_resume_urls()) @@ -2778,7 +2785,7 @@ async def wait_for_approval( """ ctx: WorkflowCtx | None = _workflow_ctx.get(None) if ctx is not None: - return await ctx._wait_for_approval(timeout=timeout, form=form) + return await ctx._wait_for_approval(timeout=timeout, form=form, self_approval=self_approval) raise RuntimeError("wait_for_approval can only be called inside a @workflow") diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index 0170d7edca..d30a5be3eb 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -632,7 +632,7 @@ workflow(fn: (...args: any[]) => Promise): void * await step("notify", () => sendEmail(urls.approvalPage)); * const { value, approver } = await waitForApproval({ timeout: 3600 }); */ -waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }> +waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }> /** * Process items in parallel with optional concurrency control. @@ -1336,12 +1336,17 @@ async def sleep(seconds: int) # # Returns a dict with \`\`value\`\` (form data), \`\`approver\`\`, and \`\`approved\`\`. # +# Args: +# timeout: Approval timeout in seconds (default 1800). +# form: Optional form schema for the approval page. +# self_approval: Whether the user who triggered the flow can approve it (default True). +# # Example:: # # urls = await step("urls", lambda: get_resume_urls()) # await step("notify", lambda: send_email(urls["approvalPage"])) # result = await wait_for_approval(timeout=3600) -async def wait_for_approval(timeout: int = 1800, form: dict | None = None) -> dict +async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True) -> dict # Process items in parallel with optional concurrency control. # diff --git a/system_prompts/auto-generated/script.md b/system_prompts/auto-generated/script.md index ec776de97e..674c9986b9 100644 --- a/system_prompts/auto-generated/script.md +++ b/system_prompts/auto-generated/script.md @@ -1605,7 +1605,7 @@ workflow(fn: (...args: any[]) => Promise): void * await step("notify", () => sendEmail(urls.approvalPage)); * const { value, approver } = await waitForApproval({ timeout: 3600 }); */ -waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }> +waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }> /** * Process items in parallel with optional concurrency control. @@ -2309,12 +2309,17 @@ async def sleep(seconds: int) # # Returns a dict with ``value`` (form data), ``approver``, and ``approved``. # +# Args: +# timeout: Approval timeout in seconds (default 1800). +# form: Optional form schema for the approval page. +# self_approval: Whether the user who triggered the flow can approve it (default True). +# # Example:: # # urls = await step("urls", lambda: get_resume_urls()) # await step("notify", lambda: send_email(urls["approvalPage"])) # result = await wait_for_approval(timeout=3600) -async def wait_for_approval(timeout: int = 1800, form: dict | None = None) -> dict +async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True) -> dict # Process items in parallel with optional concurrency control. # diff --git a/system_prompts/auto-generated/sdks/python.md b/system_prompts/auto-generated/sdks/python.md index 7163e76a4a..241d438f58 100644 --- a/system_prompts/auto-generated/sdks/python.md +++ b/system_prompts/auto-generated/sdks/python.md @@ -648,12 +648,17 @@ async def sleep(seconds: int) # # Returns a dict with ``value`` (form data), ``approver``, and ``approved``. # +# Args: +# timeout: Approval timeout in seconds (default 1800). +# form: Optional form schema for the approval page. +# self_approval: Whether the user who triggered the flow can approve it (default True). +# # Example:: # # urls = await step("urls", lambda: get_resume_urls()) # await step("notify", lambda: send_email(urls["approvalPage"])) # result = await wait_for_approval(timeout=3600) -async def wait_for_approval(timeout: int = 1800, form: dict | None = None) -> dict +async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True) -> dict # Process items in parallel with optional concurrency control. # diff --git a/system_prompts/auto-generated/sdks/typescript.md b/system_prompts/auto-generated/sdks/typescript.md index f38ba274c1..8d96473313 100644 --- a/system_prompts/auto-generated/sdks/typescript.md +++ b/system_prompts/auto-generated/sdks/typescript.md @@ -481,7 +481,7 @@ workflow(fn: (...args: any[]) => Promise): void * await step("notify", () => sendEmail(urls.approvalPage)); * const { value, approver } = await waitForApproval({ timeout: 3600 }); */ -waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }> +waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }> /** * Process items in parallel with optional concurrency control. 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 ba40a2d624..b4db20ae80 100644 --- a/system_prompts/auto-generated/skills/write-script-bun/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-bun/SKILL.md @@ -610,7 +610,7 @@ workflow(fn: (...args: any[]) => Promise): void * await step("notify", () => sendEmail(urls.approvalPage)); * const { value, approver } = await waitForApproval({ timeout: 3600 }); */ -waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }> +waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }> /** * Process items in parallel with optional concurrency control. 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 cdd015863a..ecf7fe2103 100644 --- a/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md @@ -608,7 +608,7 @@ workflow(fn: (...args: any[]) => Promise): void * await step("notify", () => sendEmail(urls.approvalPage)); * const { value, approver } = await waitForApproval({ timeout: 3600 }); */ -waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }> +waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }> /** * Process items in parallel with optional concurrency control. 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 fddae85f6e..563d01ed48 100644 --- a/system_prompts/auto-generated/skills/write-script-deno/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-deno/SKILL.md @@ -614,7 +614,7 @@ workflow(fn: (...args: any[]) => Promise): void * await step("notify", () => sendEmail(urls.approvalPage)); * const { value, approver } = await waitForApproval({ timeout: 3600 }); */ -waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }> +waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }> /** * Process items in parallel with optional concurrency control. 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 4687be55e4..1d52290283 100644 --- a/system_prompts/auto-generated/skills/write-script-nativets/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-nativets/SKILL.md @@ -575,7 +575,7 @@ workflow(fn: (...args: any[]) => Promise): void * await step("notify", () => sendEmail(urls.approvalPage)); * const { value, approver } = await waitForApproval({ timeout: 3600 }); */ -waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }> +waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }> /** * Process items in parallel with optional concurrency control. diff --git a/system_prompts/auto-generated/skills/write-script-python3/SKILL.md b/system_prompts/auto-generated/skills/write-script-python3/SKILL.md index c860ee696c..e6aa3b848c 100644 --- a/system_prompts/auto-generated/skills/write-script-python3/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-python3/SKILL.md @@ -783,12 +783,17 @@ async def sleep(seconds: int) # # Returns a dict with ``value`` (form data), ``approver``, and ``approved``. # +# Args: +# timeout: Approval timeout in seconds (default 1800). +# form: Optional form schema for the approval page. +# self_approval: Whether the user who triggered the flow can approve it (default True). +# # Example:: # # urls = await step("urls", lambda: get_resume_urls()) # await step("notify", lambda: send_email(urls["approvalPage"])) # result = await wait_for_approval(timeout=3600) -async def wait_for_approval(timeout: int = 1800, form: dict | None = None) -> dict +async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True) -> dict # Process items in parallel with optional concurrency control. # diff --git a/typescript-client/client.ts b/typescript-client/client.ts index 49087a8b60..1aded4a720 100644 --- a/typescript-client/client.ts +++ b/typescript-client/client.ts @@ -1577,6 +1577,7 @@ export class WorkflowCtx { _waitForApproval(options?: { timeout?: number; form?: object; + selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }> { const key = this._allocKey("approval"); @@ -1597,6 +1598,7 @@ export class WorkflowCtx { key, timeout: options?.timeout ?? 1800, form: options?.form, + self_approval_disabled: !(options?.selfApproval ?? true), steps: [], }); } @@ -1842,6 +1844,7 @@ export function workflow(fn: (...args: any[]) => Promise) { export function waitForApproval(options?: { timeout?: number; form?: object; + selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }> { const ctx: WorkflowCtx | null = _workflowCtx ?? Reflect.get(globalThis, "__wmill_wf_ctx"); if (!ctx) { From 6060ac3adc0afd94d62ec233f5d7282238d3ffc9 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 24 Mar 2026 21:40:26 +0000 Subject: [PATCH 03/13] chore(main): release 1.664.0 (#8498) * chore(main): release 1.664.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- CHANGELOG.md | 15 ++ backend/Cargo.lock | 175 +++++++++--------- backend/Cargo.toml | 4 +- 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 +- 15 files changed, 113 insertions(+), 107 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8aa1a9648..e08a1cf181 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [1.664.0](https://github.com/windmill-labs/windmill/compare/v1.663.0...v1.664.0) (2026-03-24) + + +### Features + +* add instance-level AI settings ([#8453](https://github.com/windmill-labs/windmill/issues/8453)) ([db5e036](https://github.com/windmill-labs/windmill/commit/db5e03610da325288d53afdbca94b9cbfc7ceace)) +* add selfApproval option to WAC + inline approval buttons ([#8440](https://github.com/windmill-labs/windmill/issues/8440)) ([d578e40](https://github.com/windmill-labs/windmill/commit/d578e40101a838d3dffda14157cf72ee4d5a93c0)) +* flow group nodes with collapsible groups ([#8075](https://github.com/windmill-labs/windmill/issues/8075)) ([81eb446](https://github.com/windmill-labs/windmill/commit/81eb446eee359f44374b81320690e5345fd08c15)) + + +### Bug Fixes + +* add GIT_SSL_CAINFO to tracing proxy env vars ([#8502](https://github.com/windmill-labs/windmill/issues/8502)) ([bdfd5d5](https://github.com/windmill-labs/windmill/commit/bdfd5d57261a4bb760fc57ad41ee56aff9b9c0af)) +* create parent dirs and accept 'python' alias in script bootstrap ([#8497](https://github.com/windmill-labs/windmill/issues/8497)) ([7f27d99](https://github.com/windmill-labs/windmill/commit/7f27d996accb3c3b471d1c50df397867d89c738a)) + ## [1.663.0](https://github.com/windmill-labs/windmill/compare/v1.662.0...v1.663.0) (2026-03-24) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 30138085c9..1871fda815 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -7401,14 +7401,15 @@ dependencies = [ [[package]] name = "ipconfig" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d72a21f6a71a6c4c3160e095e8925861f5119dd26ef71acee1b9146f74f76c8" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" dependencies = [ "socket2 0.6.3", "widestring", + "windows-registry", + "windows-result 0.4.1", "windows-sys 0.61.2", - "winreg", ] [[package]] @@ -8063,9 +8064,9 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.14" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" dependencies = [ "bitflags 2.9.4", "libc", @@ -15044,9 +15045,9 @@ checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "da36089a805484bcccfffe0739803392c8298778a2d2f09febf76fac5ad9025b" [[package]] name = "unicode-width" @@ -15746,7 +15747,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-nats", @@ -15822,7 +15823,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.663.0" +version = "1.664.0" dependencies = [ "axum 0.7.9", "chrono", @@ -15835,7 +15836,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "argon2", @@ -15976,7 +15977,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.663.0" +version = "1.664.0" dependencies = [ "axum 0.7.9", "chrono", @@ -15999,7 +16000,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.663.0" +version = "1.664.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16012,7 +16013,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16038,7 +16039,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.663.0" +version = "1.664.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -16048,7 +16049,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.663.0" +version = "1.664.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16065,7 +16066,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.663.0" +version = "1.664.0" dependencies = [ "axum 0.7.9", "base64 0.22.1", @@ -16088,7 +16089,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16111,7 +16112,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.663.0" +version = "1.664.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16127,7 +16128,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.663.0" +version = "1.664.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16147,7 +16148,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.663.0" +version = "1.664.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16167,7 +16168,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.663.0" +version = "1.664.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16181,7 +16182,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-nats", @@ -16209,7 +16210,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16234,7 +16235,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.663.0" +version = "1.664.0" dependencies = [ "axum 0.7.9", "flate2", @@ -16252,7 +16253,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16274,7 +16275,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.663.0" +version = "1.664.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16294,7 +16295,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.663.0" +version = "1.664.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16324,7 +16325,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16351,7 +16352,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.663.0" +version = "1.664.0" dependencies = [ "lazy_static", "serde", @@ -16363,7 +16364,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.663.0" +version = "1.664.0" dependencies = [ "argon2", "axum 0.7.9", @@ -16386,7 +16387,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.663.0" +version = "1.664.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16400,7 +16401,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.663.0" +version = "1.664.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16431,7 +16432,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.663.0" +version = "1.664.0" dependencies = [ "chrono", "lazy_static", @@ -16445,7 +16446,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16464,7 +16465,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.663.0" +version = "1.664.0" dependencies = [ "aes-gcm", "anyhow", @@ -16564,7 +16565,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.663.0" +version = "1.664.0" dependencies = [ "chrono", "itertools 0.14.0", @@ -16583,7 +16584,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.663.0" +version = "1.664.0" dependencies = [ "regex", "serde", @@ -16598,7 +16599,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -16622,7 +16623,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "futures", @@ -16639,7 +16640,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.663.0" +version = "1.664.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -16655,7 +16656,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-trait", @@ -16676,7 +16677,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-trait", @@ -16707,7 +16708,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-oauth2", @@ -16731,7 +16732,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-stream", @@ -16765,7 +16766,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "futures", @@ -16783,7 +16784,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.663.0" +version = "1.664.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -16792,7 +16793,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "lazy_static", @@ -16804,7 +16805,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "serde_json", @@ -16816,7 +16817,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "gosyn", @@ -16828,7 +16829,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "lazy_static", @@ -16840,7 +16841,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "serde_json", @@ -16852,7 +16853,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "nu-parser", @@ -16863,7 +16864,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16874,7 +16875,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16886,7 +16887,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "rustpython-ast", @@ -16897,7 +16898,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-recursion", @@ -16919,7 +16920,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "lazy_static", @@ -16933,7 +16934,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -16950,7 +16951,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "lazy_static", @@ -16963,7 +16964,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "serde", @@ -16975,7 +16976,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "lazy_static", @@ -16993,7 +16994,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -17009,7 +17010,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "rustpython-ast", @@ -17025,7 +17026,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "serde", @@ -17036,7 +17037,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-recursion", @@ -17073,7 +17074,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "const_format", @@ -17111,7 +17112,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.663.0" +version = "1.664.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -17122,7 +17123,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-recursion", @@ -17151,7 +17152,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -17174,7 +17175,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-trait", @@ -17207,7 +17208,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-trait", @@ -17227,7 +17228,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-trait", @@ -17261,7 +17262,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-trait", @@ -17296,7 +17297,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-trait", @@ -17319,7 +17320,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-trait", @@ -17343,7 +17344,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-nats", @@ -17367,7 +17368,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-trait", @@ -17402,7 +17403,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-trait", @@ -17430,7 +17431,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-trait", @@ -17453,7 +17454,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "bitflags 2.9.4", @@ -17472,7 +17473,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.663.0" +version = "1.664.0" dependencies = [ "anyhow", "async-once-cell", @@ -17580,7 +17581,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.663.0" +version = "1.664.0" dependencies = [ "bytes", "futures", @@ -18193,16 +18194,6 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" -[[package]] -name = "winreg" -version = "0.55.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" -dependencies = [ - "cfg-if", - "windows-sys 0.59.0", -] - [[package]] name = "winsafe" version = "0.0.19" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 6dc821689c..3b44e3d87a 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.663.0" +version = "1.664.0" authors.workspace = true edition.workspace = true @@ -82,7 +82,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal"] [workspace.package] -version = "1.663.0" +version = "1.664.0" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 6576633319..993569e0f0 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.663.0 + version: 1.664.0 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index f93b61c6ee..e79e14588d 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.663.0"; +export const VERSION = "v1.664.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 83e9c1bbc8..8901d5f979 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -68,7 +68,7 @@ export { workspaceAdd, }; -export const VERSION = "1.663.0"; +export const VERSION = "1.664.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 792e303b6e..3cd78bf5a6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-components", - "version": "1.663.0", + "version": "1.664.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-components", - "version": "1.663.0", + "version": "1.664.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 39bb3b6365..19eb2b69b5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "windmill-components", - "version": "1.663.0", + "version": "1.664.0", "scripts": { "dev": "vite dev", "build": "vite build", diff --git a/lsp/Pipfile b/lsp/Pipfile index 130d95a820..13775e4a5d 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.663.0" +wmill = ">=1.664.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 9a0983b663..7480b43a1e 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.663.0 + version: 1.664.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index c84a6d49f7..6629cc8034 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.663.0' + ModuleVersion = '1.664.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index 92fbaf56d1..8cfc22d3a6 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.663.0" +version = "1.664.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 5435328c88..9632ec19a2 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.663.0", + "version": "1.664.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 bd66c04c51..d4023c7e69 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.663.0", + "version": "1.664.0", "author": "Ruben Fiszel", "license": "Apache 2.0", "sideEffects": false, diff --git a/version.txt b/version.txt index eb4feec596..694b27ca91 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.663.0 +1.664.0 From 85c52e2cded10606cc895d0d3b717e13c69bc9b3 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 25 Mar 2026 06:40:20 +0000 Subject: [PATCH 04/13] fix: use /apps_raw/get/ redirect URL for raw apps set as workspace default (#8508) * fix: use /apps_raw/get/ redirect URL for raw apps set as workspace default Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update sqlx cache for default_app query Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- ...c3592deb61d1111d1430ddd2879b72e6424ef.json | 28 +++++++++++++++++++ ...3f3be67b6160cd258c86b8e8f22a6d601afd0.json | 22 --------------- .../windmill-api-workspaces/src/workspaces.rs | 19 +++++++++---- backend/windmill-api/openapi.yaml | 2 ++ frontend/src/lib/components/Login.svelte | 3 +- .../(logged)/user/(user)/login/+page.svelte | 3 +- .../user/(user)/workspaces/+page.svelte | 3 +- 7 files changed, 49 insertions(+), 31 deletions(-) create mode 100644 backend/.sqlx/query-1bc77ad29b9c68b1d339b85158bc3592deb61d1111d1430ddd2879b72e6424ef.json delete mode 100644 backend/.sqlx/query-ed1a053c7b22d9cb69767be40d33f3be67b6160cd258c86b8e8f22a6d601afd0.json diff --git a/backend/.sqlx/query-1bc77ad29b9c68b1d339b85158bc3592deb61d1111d1430ddd2879b72e6424ef.json b/backend/.sqlx/query-1bc77ad29b9c68b1d339b85158bc3592deb61d1111d1430ddd2879b72e6424ef.json new file mode 100644 index 0000000000..0930bdf1b8 --- /dev/null +++ b/backend/.sqlx/query-1bc77ad29b9c68b1d339b85158bc3592deb61d1111d1430ddd2879b72e6424ef.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT ws.default_app AS default_app_path, av.raw_app AS \"default_app_raw: Option\"\n FROM workspace_settings ws\n LEFT JOIN app ON app.path = ws.default_app AND app.workspace_id = ws.workspace_id\n LEFT JOIN app_version av ON av.id = app.versions[array_upper(app.versions, 1)]\n WHERE ws.workspace_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "default_app_path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "default_app_raw: Option", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true, + false + ] + }, + "hash": "1bc77ad29b9c68b1d339b85158bc3592deb61d1111d1430ddd2879b72e6424ef" +} diff --git a/backend/.sqlx/query-ed1a053c7b22d9cb69767be40d33f3be67b6160cd258c86b8e8f22a6d601afd0.json b/backend/.sqlx/query-ed1a053c7b22d9cb69767be40d33f3be67b6160cd258c86b8e8f22a6d601afd0.json deleted file mode 100644 index b7c642ef12..0000000000 --- a/backend/.sqlx/query-ed1a053c7b22d9cb69767be40d33f3be67b6160cd258c86b8e8f22a6d601afd0.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT default_app FROM workspace_settings WHERE workspace_id = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "default_app", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - true - ] - }, - "hash": "ed1a053c7b22d9cb69767be40d33f3be67b6160cd258c86b8e8f22a6d601afd0" -} diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 8cd8f27d9b..55ad777cbf 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -2233,22 +2233,29 @@ async fn edit_default_app( #[derive(Serialize)] struct WorkspaceDefaultApp { pub default_app_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub default_app_raw: Option, } async fn get_default_app( Extension(db): Extension, Path(w_id): Path, ) -> JsonResult { - let mut tx = db.begin().await?; - let default_app_path = sqlx::query_scalar!( - "SELECT default_app FROM workspace_settings WHERE workspace_id = $1", + let row = sqlx::query!( + "SELECT ws.default_app AS default_app_path, av.raw_app AS \"default_app_raw: Option\" + FROM workspace_settings ws + LEFT JOIN app ON app.path = ws.default_app AND app.workspace_id = ws.workspace_id + LEFT JOIN app_version av ON av.id = app.versions[array_upper(app.versions, 1)] + WHERE ws.workspace_id = $1", &w_id ) - .fetch_one(&mut *tx) + .fetch_one(&db) .await .map_err(|err| Error::internal_err(format!("getting default_app: {err}")))?; - tx.commit().await?; - Ok(Json(WorkspaceDefaultApp { default_app_path })) + Ok(Json(WorkspaceDefaultApp { + default_app_path: row.default_app_path, + default_app_raw: row.default_app_raw, + })) } async fn edit_error_handler( diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 993569e0f0..bdd70229e9 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -3702,6 +3702,8 @@ paths: properties: default_app_path: type: string + default_app_raw: + type: boolean /w/{workspace}/workspaces/usage: get: diff --git a/frontend/src/lib/components/Login.svelte b/frontend/src/lib/components/Login.svelte index 1e375cb53d..c1a2a7ebdb 100644 --- a/frontend/src/lib/components/Login.svelte +++ b/frontend/src/lib/components/Login.svelte @@ -164,7 +164,8 @@ workspace: $workspaceStore! }) if (!emptyString(defaultApp.default_app_path)) { - goto(`/apps/get/${defaultApp.default_app_path}`) + const prefix = defaultApp.default_app_raw ? '/apps_raw/get' : '/apps/get' + goto(`${prefix}/${defaultApp.default_app_path}`) } else { goto(rd ?? '/') } diff --git a/frontend/src/routes/(root)/(logged)/user/(user)/login/+page.svelte b/frontend/src/routes/(root)/(logged)/user/(user)/login/+page.svelte index 63843578c9..3e7ef57bf6 100644 --- a/frontend/src/routes/(root)/(logged)/user/(user)/login/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/user/(user)/login/+page.svelte @@ -87,7 +87,8 @@ workspace: $workspaceStore! }) if (!emptyString(defaultApp.default_app_path)) { - goto(`/apps/get/${defaultApp.default_app_path}`) + const prefix = defaultApp.default_app_raw ? '/apps_raw/get' : '/apps/get' + goto(`${prefix}/${defaultApp.default_app_path}`) } else { goto(rd ?? '/') } diff --git a/frontend/src/routes/(root)/(logged)/user/(user)/workspaces/+page.svelte b/frontend/src/routes/(root)/(logged)/user/(user)/workspaces/+page.svelte index 0ddfd92914..2cc2a65fbf 100644 --- a/frontend/src/routes/(root)/(logged)/user/(user)/workspaces/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/user/(user)/workspaces/+page.svelte @@ -151,7 +151,8 @@ workspace: $workspaceStore! }) if (!emptyString(defaultApp.default_app_path)) { - await goto(`/apps/get/${defaultApp.default_app_path}`) + const prefix = defaultApp.default_app_raw ? '/apps_raw/get' : '/apps/get' + await goto(`${prefix}/${defaultApp.default_app_path}`) } else { if (rd?.startsWith('http')) { window.location.href = rd From 1341a1321da3ab7c5ce24df27fe6b028887d6a0b Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 25 Mar 2026 07:11:57 +0000 Subject: [PATCH 05/13] chore: update tantivy from 0.24 to 0.26 (#8510) * [ee] chore: update tantivy from 0.24 to 0.26 - Rebase windmill-labs/tantivy fork onto upstream 0.26 - Bump serde pin from 1.0.219 to 1.0.220 (required by tantivy 0.26's time dependency) Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref to ec613f2db9e72e32e9131181546dcd679405a782 This commit updates the EE repository reference after PR #479 was merged in windmill-ee-private. Previous ee-repo-ref: 920cf601b0651b7ba94493668ea051e00f3e74bf New ee-repo-ref: ec613f2db9e72e32e9131181546dcd679405a782 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: windmill-internal-app[bot] --- backend/Cargo.lock | 149 +++++++++++++++++++++++----------------- backend/Cargo.toml | 4 +- backend/ee-repo-ref.txt | 2 +- 3 files changed, 89 insertions(+), 66 deletions(-) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 1871fda815..fcb56bb81c 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -405,7 +405,7 @@ dependencies = [ "arrow-data", "arrow-schema", "flatbuffers", - "lz4_flex", + "lz4_flex 0.11.6", ] [[package]] @@ -2124,7 +2124,7 @@ dependencies = [ "num-traits", "num_cpus", "rand 0.9.0", - "rand_distr 0.5.1", + "rand_distr", "rayon", "safetensors", "thiserror 2.0.18", @@ -3640,6 +3640,12 @@ dependencies = [ "sqlparser 0.55.0", ] +[[package]] +name = "datasketches" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c286de4e81ea2590afc24d754e0f83810c566f50a1388fa75ebd57928c0d9745" + [[package]] name = "debug-helper" version = "0.3.13" @@ -5678,7 +5684,7 @@ dependencies = [ "half", "num-traits", "rand 0.9.0", - "rand_distr 0.5.1", + "rand_distr", ] [[package]] @@ -6490,7 +6496,7 @@ dependencies = [ "crunchy", "num-traits", "rand 0.9.0", - "rand_distr 0.5.1", + "rand_distr", "zerocopy", ] @@ -7109,15 +7115,6 @@ dependencies = [ "tower-service", ] -[[package]] -name = "hyperloglogplus" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "621debdf94dcac33e50475fdd76d34d5ea9c0362a834b9db08c3024696c1fbe3" -dependencies = [ - "serde", -] - [[package]] name = "iana-time-zone" version = "0.1.65" @@ -8197,6 +8194,15 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "lru" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +dependencies = [ + "hashbrown 0.16.0", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -8221,6 +8227,12 @@ dependencies = [ "twox-hash 2.1.2", ] +[[package]] +name = "lz4_flex" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db9a0d582c2874f68138a16ce1867e0ffde6c0bb0a0df85e1f36d04146db488a" + [[package]] name = "lzma-sys" version = "0.1.20" @@ -9186,9 +9198,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-format" @@ -9713,6 +9725,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "ordered-float" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4779c6901a562440c3786d08192c6fbda7c1c2060edd10006b05ee35d10f2d" +dependencies = [ + "num-traits", +] + [[package]] name = "os_pipe" version = "1.1.5" @@ -9738,7 +9759,7 @@ checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" [[package]] name = "ownedbytes" version = "0.9.0" -source = "git+https://github.com/windmill-labs/tantivy?rev=6a24621231202ccd77bec90d8787e2281fb94e4e#6a24621231202ccd77bec90d8787e2281fb94e4e" +source = "git+https://github.com/windmill-labs/tantivy?rev=6ae7c70bc603b8e69e27f3240e08bd00a93fb12c#6ae7c70bc603b8e69e27f3240e08bd00a93fb12c" dependencies = [ "stable_deref_trait", ] @@ -9850,7 +9871,7 @@ dependencies = [ "futures", "half", "hashbrown 0.15.5", - "lz4_flex", + "lz4_flex 0.11.6", "num", "num-bigint", "object_store", @@ -10842,16 +10863,6 @@ dependencies = [ "getrandom 0.3.4", ] -[[package]] -name = "rand_distr" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" -dependencies = [ - "num-traits", - "rand 0.8.5", -] - [[package]] name = "rand_distr" version = "0.5.1" @@ -12191,10 +12202,11 @@ checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" [[package]] name = "serde" -version = "1.0.219" +version = "1.0.220" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "ceecad4c782e936ac90ecfd6b56532322e3262b14320abf30ce89a92ffdbfe22" dependencies = [ + "serde_core", "serde_derive", ] @@ -12216,7 +12228,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" dependencies = [ - "ordered-float", + "ordered-float 2.10.1", "serde", ] @@ -12241,10 +12253,19 @@ dependencies = [ ] [[package]] -name = "serde_derive" -version = "1.0.219" +name = "serde_core" +version = "1.0.220" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "ddba47394f3b862d6ff6efdbd26ca4673e3566a307880a0ffb98f274bbe0ec32" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.220" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60e1f3b1761e96def5ec6d04a6e7421c0404fa3cf5c0155f1e2848fae3d8cc08" dependencies = [ "proc-macro2", "quote", @@ -12608,9 +12629,9 @@ checksum = "1b6709c7b6754dca1311b3c73e79fcce40dd414c782c66d88e8823030093b02b" [[package]] name = "sketches-ddsketch" -version = "0.3.1" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b" +checksum = "05e40b6cf54d988dc1a2223531b969c9a9e30906ad90ef64890c27b4bfbb46ea" dependencies = [ "serde", ] @@ -13705,8 +13726,8 @@ checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" [[package]] name = "tantivy" -version = "0.24.0" -source = "git+https://github.com/windmill-labs/tantivy?rev=6a24621231202ccd77bec90d8787e2281fb94e4e#6a24621231202ccd77bec90d8787e2281fb94e4e" +version = "0.26.0" +source = "git+https://github.com/windmill-labs/tantivy?rev=6ae7c70bc603b8e69e27f3240e08bd00a93fb12c#6ae7c70bc603b8e69e27f3240e08bd00a93fb12c" dependencies = [ "aho-corasick", "arc-swap", @@ -13717,17 +13738,17 @@ dependencies = [ "census", "crc32fast", "crossbeam-channel", + "datasketches", "downcast-rs", "fastdivide", "fnv", "fs4", "htmlescape", - "hyperloglogplus", "itertools 0.14.0", "levenshtein_automata", "log", - "lru 0.12.5", - "lz4_flex", + "lru 0.16.3", + "lz4_flex 0.13.0", "measure_time", "memmap2 0.9.10", "once_cell", @@ -13750,22 +13771,23 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "time", + "typetag", "uuid", "winapi", ] [[package]] name = "tantivy-bitpacker" -version = "0.8.0" -source = "git+https://github.com/windmill-labs/tantivy?rev=6a24621231202ccd77bec90d8787e2281fb94e4e#6a24621231202ccd77bec90d8787e2281fb94e4e" +version = "0.9.0" +source = "git+https://github.com/windmill-labs/tantivy?rev=6ae7c70bc603b8e69e27f3240e08bd00a93fb12c#6ae7c70bc603b8e69e27f3240e08bd00a93fb12c" dependencies = [ "bitpacking", ] [[package]] name = "tantivy-columnar" -version = "0.5.0" -source = "git+https://github.com/windmill-labs/tantivy?rev=6a24621231202ccd77bec90d8787e2281fb94e4e#6a24621231202ccd77bec90d8787e2281fb94e4e" +version = "0.6.0" +source = "git+https://github.com/windmill-labs/tantivy?rev=6ae7c70bc603b8e69e27f3240e08bd00a93fb12c#6ae7c70bc603b8e69e27f3240e08bd00a93fb12c" dependencies = [ "downcast-rs", "fastdivide", @@ -13779,8 +13801,8 @@ dependencies = [ [[package]] name = "tantivy-common" -version = "0.9.0" -source = "git+https://github.com/windmill-labs/tantivy?rev=6a24621231202ccd77bec90d8787e2281fb94e4e#6a24621231202ccd77bec90d8787e2281fb94e4e" +version = "0.10.0" +source = "git+https://github.com/windmill-labs/tantivy?rev=6ae7c70bc603b8e69e27f3240e08bd00a93fb12c#6ae7c70bc603b8e69e27f3240e08bd00a93fb12c" dependencies = [ "async-trait", "byteorder", @@ -13802,18 +13824,20 @@ dependencies = [ [[package]] name = "tantivy-query-grammar" -version = "0.24.0" -source = "git+https://github.com/windmill-labs/tantivy?rev=6a24621231202ccd77bec90d8787e2281fb94e4e#6a24621231202ccd77bec90d8787e2281fb94e4e" +version = "0.25.0" +source = "git+https://github.com/windmill-labs/tantivy?rev=6ae7c70bc603b8e69e27f3240e08bd00a93fb12c#6ae7c70bc603b8e69e27f3240e08bd00a93fb12c" dependencies = [ + "fnv", "nom 7.1.3", + "ordered-float 5.1.0", "serde", "serde_json", ] [[package]] name = "tantivy-sstable" -version = "0.5.0" -source = "git+https://github.com/windmill-labs/tantivy?rev=6a24621231202ccd77bec90d8787e2281fb94e4e#6a24621231202ccd77bec90d8787e2281fb94e4e" +version = "0.6.0" +source = "git+https://github.com/windmill-labs/tantivy?rev=6ae7c70bc603b8e69e27f3240e08bd00a93fb12c#6ae7c70bc603b8e69e27f3240e08bd00a93fb12c" dependencies = [ "futures-util", "itertools 0.14.0", @@ -13825,18 +13849,17 @@ dependencies = [ [[package]] name = "tantivy-stacker" -version = "0.5.0" -source = "git+https://github.com/windmill-labs/tantivy?rev=6a24621231202ccd77bec90d8787e2281fb94e4e#6a24621231202ccd77bec90d8787e2281fb94e4e" +version = "0.6.0" +source = "git+https://github.com/windmill-labs/tantivy?rev=6ae7c70bc603b8e69e27f3240e08bd00a93fb12c#6ae7c70bc603b8e69e27f3240e08bd00a93fb12c" dependencies = [ "murmurhash32", - "rand_distr 0.4.3", "tantivy-common", ] [[package]] name = "tantivy-tokenizer-api" -version = "0.5.0" -source = "git+https://github.com/windmill-labs/tantivy?rev=6a24621231202ccd77bec90d8787e2281fb94e4e#6a24621231202ccd77bec90d8787e2281fb94e4e" +version = "0.6.0" +source = "git+https://github.com/windmill-labs/tantivy?rev=6ae7c70bc603b8e69e27f3240e08bd00a93fb12c#6ae7c70bc603b8e69e27f3240e08bd00a93fb12c" dependencies = [ "serde", ] @@ -13966,7 +13989,7 @@ checksum = "7e54bc85fc7faa8bc175c4bab5b92ba8d9a3ce893d0e9f42cc455c8ab16a9e09" dependencies = [ "byteorder", "integer-encoding", - "ordered-float", + "ordered-float 2.10.1", ] [[package]] @@ -14043,30 +14066,30 @@ dependencies = [ [[package]] name = "time" -version = "0.3.44" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.24" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 3b44e3d87a..927a6f6ba3 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -373,7 +373,7 @@ tower = "^0" tower-http = { version = "^0.6", features = ["trace", "cors", "catch-panic"] } tower-cookies = "^0.10" #stuck because of swc for now -serde = "=1.0.219" +serde = "=1.0.220" serde_json = { version = "^1", features = ["preserve_order", "raw_value"] } serde_yml = "0.0.12" uuid = { version = "^1", features = ["serde", "v4", "js"] } @@ -587,7 +587,7 @@ tikv-jemalloc-ctl = { version = "^0.5" } triomphe = "^0" pin-project-lite = "^0" -tantivy = { git="https://github.com/windmill-labs/tantivy", rev="6a24621231202ccd77bec90d8787e2281fb94e4e" } +tantivy = { git="https://github.com/windmill-labs/tantivy", rev="6ae7c70bc603b8e69e27f3240e08bd00a93fb12c" } backon = "1.3.0" diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 7ef2ef46db..c5ca6a15cf 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -faeaa43bbe2ba4804f80b828b85fd4d6daef096c +ec613f2db9e72e32e9131181546dcd679405a782 From fe223bffa32c17815988ff4210d89f4f01d486e2 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 25 Mar 2026 07:34:24 +0000 Subject: [PATCH 06/13] chore: update samael from 0.0.14 to 0.0.20 (#8512) Co-authored-by: Claude Opus 4.6 (1M context) --- backend/Cargo.lock | 105 +++++++++++++++++++-------------------------- backend/Cargo.toml | 2 +- 2 files changed, 46 insertions(+), 61 deletions(-) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index fcb56bb81c..af95c7a85e 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1594,29 +1594,6 @@ dependencies = [ "serde", ] -[[package]] -name = "bindgen" -version = "0.69.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" -dependencies = [ - "bitflags 2.9.4", - "cexpr", - "clang-sys", - "itertools 0.12.1", - "lazy_static", - "lazycell", - "log", - "prettyplease", - "proc-macro2", - "quote", - "regex", - "rustc-hash 1.1.0", - "shlex", - "syn 2.0.117", - "which 4.4.2", -] - [[package]] name = "bindgen" version = "0.70.1" @@ -1667,6 +1644,8 @@ dependencies = [ "cexpr", "clang-sys", "itertools 0.13.0", + "log", + "prettyplease", "proc-macro2", "quote", "regex", @@ -4862,7 +4841,16 @@ version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8d67778784b508018359cbc8696edb3db78160bab2c2a28ba7f56ef6932997f8" dependencies = [ - "derive_builder_macro", + "derive_builder_macro 0.12.0", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro 0.20.2", ] [[package]] @@ -4877,16 +4865,38 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "derive_builder_macro" version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebcda35c7a396850a55ffeac740804b40ffec779b98fffbb1738f4033f0ee79e" dependencies = [ - "derive_builder_core", + "derive_builder_core 0.12.0", "syn 1.0.109", ] +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core 0.20.2", + "syn 2.0.117", +] + [[package]] name = "derive_more" version = "1.0.0" @@ -7476,15 +7486,6 @@ dependencies = [ "either", ] -[[package]] -name = "itertools" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.13.0" @@ -7900,12 +7901,6 @@ dependencies = [ "spin 0.9.8", ] -[[package]] -name = "lazycell" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" - [[package]] name = "leb128fmt" version = "0.1.0" @@ -9349,7 +9344,7 @@ dependencies = [ "md-5 0.10.6", "parking_lot", "percent-encoding", - "quick-xml 0.37.5", + "quick-xml", "rand 0.9.0", "reqwest 0.12.28", "ring 0.17.14", @@ -10646,16 +10641,6 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "quick-xml" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eff6510e86862b57b210fd8cbe8ed3f0d7d600b9c2863cd4549a2e033c66e956" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "quick-xml" version = "0.37.5" @@ -11950,15 +11935,15 @@ dependencies = [ [[package]] name = "samael" -version = "0.0.14" +version = "0.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b75583aad4a51c50fc0af69c230d18078c9d5a69a98d0f6013d01053acf744f4" +checksum = "8b010d88b2c7b2c3fc9e49f6fffa086d4c350ec50538a8082f88e446ea16c670" dependencies = [ - "base64 0.21.7", - "bindgen 0.69.5", + "base64 0.22.1", + "bindgen 0.72.1", "chrono", "data-encoding", - "derive_builder", + "derive_builder 0.20.2", "flate2", "lazy_static", "libc", @@ -11967,10 +11952,10 @@ dependencies = [ "openssl-probe 0.1.6", "openssl-sys", "pkg-config", - "quick-xml 0.30.0", - "rand 0.8.5", + "quick-xml", + "rand 0.9.0", "serde", - "thiserror 1.0.69", + "thiserror 2.0.18", "url", "uuid", ] @@ -14152,7 +14137,7 @@ checksum = "d9be88c795d8b9f9c4002b3a8f26a6d0876103a6f523b32ea3bac52d8560c17c" dependencies = [ "aho-corasick", "clap", - "derive_builder", + "derive_builder 0.12.0", "esaxx-rs", "getrandom 0.2.17", "indicatif", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 927a6f6ba3..e98d4b793e 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -510,7 +510,7 @@ native-tls = ">=0.2, <0.2.17" # samael will break compilation on MacOS. Use this fork instead to make it work # samael = { git="https://github.com/njaremko/samael", rev="464d015e3ae393e4b5dd00b4d6baa1b617de0dd6", features = ["xmlsec"] } libxml = { version = "=0.3.3" } -samael = { version="0.0.14", features = ["xmlsec"] } +samael = { version="0.0.20", features = ["xmlsec"] } gcp_auth = "0.9.0" rust_decimal = { version = "^1", features = ["db-postgres", "serde-float"]} jsonwebtoken = "8.3.0" From 0db21aa6b7c5b557ad53a1a74493c4fd80a53b49 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 25 Mar 2026 07:44:49 +0000 Subject: [PATCH 07/13] samael bump --- backend/Cargo.lock | 3 +-- backend/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index af95c7a85e..a5e2e8fc98 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -11936,8 +11936,7 @@ dependencies = [ [[package]] name = "samael" version = "0.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b010d88b2c7b2c3fc9e49f6fffa086d4c350ec50538a8082f88e446ea16c670" +source = "git+https://github.com/njaremko/samael?rev=f879f1942ec1b34b6d3027ce7e4724ad95d15dfa#f879f1942ec1b34b6d3027ce7e4724ad95d15dfa" dependencies = [ "base64 0.22.1", "bindgen 0.72.1", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index e98d4b793e..e1d6dbe9d2 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -510,7 +510,7 @@ native-tls = ">=0.2, <0.2.17" # samael will break compilation on MacOS. Use this fork instead to make it work # samael = { git="https://github.com/njaremko/samael", rev="464d015e3ae393e4b5dd00b4d6baa1b617de0dd6", features = ["xmlsec"] } libxml = { version = "=0.3.3" } -samael = { version="0.0.20", features = ["xmlsec"] } +samael = { git="https://github.com/njaremko/samael", rev="f879f1942ec1b34b6d3027ce7e4724ad95d15dfa", features = ["xmlsec"] } gcp_auth = "0.9.0" rust_decimal = { version = "^1", features = ["db-postgres", "serde-float"]} jsonwebtoken = "8.3.0" From e3620e074e1bdb46b2b8d732f35a91d300589663 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 25 Mar 2026 07:56:45 +0000 Subject: [PATCH 08/13] fix: serve index disk storage sizes from /srch/ endpoint (#8511) * [ee] fix: serve index disk storage sizes from /srch/ endpoint On multi-container deployments, the API server doesn't have the index files on its local disk, so disk size was always reported as 0.0B. Added a new GET /srch/index/storage/disk endpoint that calculates disk sizes on the indexer process (which owns the files). The frontend now fetches disk sizes from this endpoint in parallel with the status call. Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref to 71aab648925f31cde37efd31d79a7f3a977fd42a This commit updates the EE repository reference after PR #480 was merged in windmill-ee-private. Previous ee-repo-ref: b3e0000e2528809302c18f36930aebf3d004747a New ee-repo-ref: 71aab648925f31cde37efd31d79a7f3a977fd42a Automated by sync-ee-ref workflow. * chore: update ee-repo-ref to indexer-disk-storage-zero branch Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update sqlx metadata and ee-repo-ref Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: windmill-internal-app[bot] --- ...3e69e4ef8821c6cbf3b4f296b3853d95692af.json | 22 +++++++++ ...ed5c8c64daec45ba820a6c8f7d7ab76cc40c9.json | 28 ----------- ...08cb1ca21fbdba3373af54fadf1f4af324073.json | 35 -------------- ...59f29220c08e94e1822e3bcb5c6ed6aa7d2bb.json | 46 ------------------- ...6e8a4f8f3a9bf04238b33e9caf46836df73d9.json | 35 -------------- ...91688f3ed0efd3a43e81f4ea296255248092c.json | 16 ------- ...32e97ebefb46be9e58bd3da9067748075311b.json | 35 -------------- ...8903bec93ef79a71053c00227e17c6f0415a2.json | 23 ---------- backend/ee-repo-ref.txt | 2 +- backend/windmill-api/openapi.yaml | 21 +++++++++ .../IndexerMemorySettings.svelte | 29 ++++++++---- 11 files changed, 64 insertions(+), 228 deletions(-) create mode 100644 backend/.sqlx/query-21f4840f60e8310d7b7efcba7483e69e4ef8821c6cbf3b4f296b3853d95692af.json delete mode 100644 backend/.sqlx/query-32ca7941db013dacd2479962fa9ed5c8c64daec45ba820a6c8f7d7ab76cc40c9.json delete mode 100644 backend/.sqlx/query-79b82ae996fba2e2ab53fcf84c108cb1ca21fbdba3373af54fadf1f4af324073.json delete mode 100644 backend/.sqlx/query-950f364c9fa3c680eea895558a559f29220c08e94e1822e3bcb5c6ed6aa7d2bb.json delete mode 100644 backend/.sqlx/query-98033aae3182bde22d5b2ff08ef6e8a4f8f3a9bf04238b33e9caf46836df73d9.json delete mode 100644 backend/.sqlx/query-b1979a8249557d29e9055fde06191688f3ed0efd3a43e81f4ea296255248092c.json delete mode 100644 backend/.sqlx/query-c2347460b73ae9d3167031c263032e97ebefb46be9e58bd3da9067748075311b.json delete mode 100644 backend/.sqlx/query-ecf67b08d327c351909b7ba80218903bec93ef79a71053c00227e17c6f0415a2.json diff --git a/backend/.sqlx/query-21f4840f60e8310d7b7efcba7483e69e4ef8821c6cbf3b4f296b3853d95692af.json b/backend/.sqlx/query-21f4840f60e8310d7b7efcba7483e69e4ef8821c6cbf3b4f296b3853d95692af.json new file mode 100644 index 0000000000..a78e67067f --- /dev/null +++ b/backend/.sqlx/query-21f4840f60e8310d7b7efcba7483e69e4ef8821c6cbf3b4f296b3853d95692af.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) FROM usr WHERE workspace_id = $1 AND disabled = false", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "21f4840f60e8310d7b7efcba7483e69e4ef8821c6cbf3b4f296b3853d95692af" +} diff --git a/backend/.sqlx/query-32ca7941db013dacd2479962fa9ed5c8c64daec45ba820a6c8f7d7ab76cc40c9.json b/backend/.sqlx/query-32ca7941db013dacd2479962fa9ed5c8c64daec45ba820a6c8f7d7ab76cc40c9.json deleted file mode 100644 index 0a2976f868..0000000000 --- a/backend/.sqlx/query-32ca7941db013dacd2479962fa9ed5c8c64daec45ba820a6c8f7d7ab76cc40c9.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT parent_job, flow_step_id FROM v2_job WHERE id = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "parent_job", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "flow_step_id", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - true, - true - ] - }, - "hash": "32ca7941db013dacd2479962fa9ed5c8c64daec45ba820a6c8f7d7ab76cc40c9" -} diff --git a/backend/.sqlx/query-79b82ae996fba2e2ab53fcf84c108cb1ca21fbdba3373af54fadf1f4af324073.json b/backend/.sqlx/query-79b82ae996fba2e2ab53fcf84c108cb1ca21fbdba3373af54fadf1f4af324073.json deleted file mode 100644 index f990932367..0000000000 --- a/backend/.sqlx/query-79b82ae996fba2e2ab53fcf84c108cb1ca21fbdba3373af54fadf1f4af324073.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n WITH dk AS (\n INSERT INTO debounce_key (job_id, key)\n VALUES ($1, $2)\n ON CONFLICT (key)\n DO UPDATE SET\n previous_job_id = debounce_key.job_id,\n job_id = EXCLUDED.job_id,\n debounced_times = debounce_key.debounced_times + 1\n RETURNING\n debounced_times,\n first_started_at,\n previous_job_id AS job_id_to_debounce\n ), _batch AS (\n INSERT INTO v2_job_debounce_batch (id, debounce_batch)\n SELECT\n $1,\n COALESCE(\n (SELECT debounce_batch FROM v2_job_debounce_batch WHERE id = dk.job_id_to_debounce LIMIT 1),\n nextval('debounce_batch_seq')\n )\n FROM dk\n )\n SELECT debounced_times, first_started_at, job_id_to_debounce FROM dk\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "debounced_times", - "type_info": "Int4" - }, - { - "ordinal": 1, - "name": "first_started_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 2, - "name": "job_id_to_debounce", - "type_info": "Uuid" - } - ], - "parameters": { - "Left": [ - "Uuid", - "Varchar" - ] - }, - "nullable": [ - false, - false, - true - ] - }, - "hash": "79b82ae996fba2e2ab53fcf84c108cb1ca21fbdba3373af54fadf1f4af324073" -} diff --git a/backend/.sqlx/query-950f364c9fa3c680eea895558a559f29220c08e94e1822e3bcb5c6ed6aa7d2bb.json b/backend/.sqlx/query-950f364c9fa3c680eea895558a559f29220c08e94e1822e3bcb5c6ed6aa7d2bb.json deleted file mode 100644 index 9ec97dbc82..0000000000 --- a/backend/.sqlx/query-950f364c9fa3c680eea895558a559f29220c08e94e1822e3bcb5c6ed6aa7d2bb.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n WITH RECURSIVE chain AS (\n SELECT\n j.id,\n j.parent_job,\n j.flow_step_id,\n 1 AS depth\n FROM v2_job j\n WHERE j.id = $1\n UNION ALL\n SELECT\n pj.id,\n pj.parent_job,\n pj.flow_step_id,\n c.depth + 1\n FROM chain c\n JOIN v2_job pj ON pj.id = c.parent_job\n WHERE c.parent_job IS NOT NULL\n )\n SELECT\n c.id,\n c.parent_job,\n c.flow_step_id,\n EXISTS(SELECT 1 FROM v2_job_queue q WHERE q.id = c.parent_job) AS \"parent_in_queue!\",\n EXISTS(\n SELECT 1 FROM v2_job sib\n WHERE sib.parent_job = c.parent_job\n AND sib.id != c.id\n AND sib.id IN (SELECT sq.id FROM v2_job_queue sq)\n ) AS \"has_other_active_siblings!\"\n FROM chain c\n WHERE c.depth >= 1\n ORDER BY c.depth ASC\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "parent_job", - "type_info": "Uuid" - }, - { - "ordinal": 2, - "name": "flow_step_id", - "type_info": "Varchar" - }, - { - "ordinal": 3, - "name": "parent_in_queue!", - "type_info": "Bool" - }, - { - "ordinal": 4, - "name": "has_other_active_siblings!", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - null, - null, - null, - null, - null - ] - }, - "hash": "950f364c9fa3c680eea895558a559f29220c08e94e1822e3bcb5c6ed6aa7d2bb" -} diff --git a/backend/.sqlx/query-98033aae3182bde22d5b2ff08ef6e8a4f8f3a9bf04238b33e9caf46836df73d9.json b/backend/.sqlx/query-98033aae3182bde22d5b2ff08ef6e8a4f8f3a9bf04238b33e9caf46836df73d9.json deleted file mode 100644 index 7dd6e9ac5d..0000000000 --- a/backend/.sqlx/query-98033aae3182bde22d5b2ff08ef6e8a4f8f3a9bf04238b33e9caf46836df73d9.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n WITH dk AS (\n INSERT INTO debounce_key (job_id, key)\n VALUES ($1, $2)\n ON CONFLICT (key)\n DO UPDATE SET\n previous_job_id = debounce_key.job_id,\n job_id = EXCLUDED.job_id,\n debounced_times = debounce_key.debounced_times + 1\n RETURNING\n debounced_times,\n first_started_at,\n previous_job_id AS job_id_to_debounce\n ), _batch AS (\n INSERT INTO v2_job_debounce_batch (id, debounce_batch)\n SELECT\n $1,\n COALESCE(\n (SELECT debounce_batch FROM v2_job_debounce_batch WHERE id = dk.job_id_to_debounce LIMIT 1),\n nextval('debounce_batch_seq')\n )\n FROM dk\n )\n SELECT debounced_times, first_started_at, job_id_to_debounce FROM dk\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "debounced_times", - "type_info": "Int4" - }, - { - "ordinal": 1, - "name": "first_started_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 2, - "name": "job_id_to_debounce", - "type_info": "Uuid" - } - ], - "parameters": { - "Left": [ - "Uuid", - "Varchar" - ] - }, - "nullable": [ - false, - false, - true - ] - }, - "hash": "98033aae3182bde22d5b2ff08ef6e8a4f8f3a9bf04238b33e9caf46836df73d9" -} diff --git a/backend/.sqlx/query-b1979a8249557d29e9055fde06191688f3ed0efd3a43e81f4ea296255248092c.json b/backend/.sqlx/query-b1979a8249557d29e9055fde06191688f3ed0efd3a43e81f4ea296255248092c.json deleted file mode 100644 index 5fb12bed16..0000000000 --- a/backend/.sqlx/query-b1979a8249557d29e9055fde06191688f3ed0efd3a43e81f4ea296255248092c.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE v2_job_status SET flow_status = (\n SELECT jsonb_set(\n flow_status,\n ARRAY['modules', (idx - 1)::text],\n $2::jsonb\n )\n FROM jsonb_array_elements(flow_status->'modules')\n WITH ORDINALITY arr(elem, idx)\n WHERE elem->>'id' = $3\n LIMIT 1\n ) WHERE id = $1 AND (\n SELECT COUNT(*) FROM jsonb_array_elements(flow_status->'modules')\n WITH ORDINALITY arr(elem, idx)\n WHERE elem->>'id' = $3\n ) > 0", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Jsonb", - "Text" - ] - }, - "nullable": [] - }, - "hash": "b1979a8249557d29e9055fde06191688f3ed0efd3a43e81f4ea296255248092c" -} diff --git a/backend/.sqlx/query-c2347460b73ae9d3167031c263032e97ebefb46be9e58bd3da9067748075311b.json b/backend/.sqlx/query-c2347460b73ae9d3167031c263032e97ebefb46be9e58bd3da9067748075311b.json deleted file mode 100644 index 7d7842d7f4..0000000000 --- a/backend/.sqlx/query-c2347460b73ae9d3167031c263032e97ebefb46be9e58bd3da9067748075311b.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n WITH dk AS (\n INSERT INTO debounce_key (job_id, key)\n VALUES ($1, $2)\n ON CONFLICT (key)\n DO UPDATE SET\n previous_job_id = debounce_key.job_id,\n job_id = EXCLUDED.job_id,\n debounced_times = debounce_key.debounced_times + 1\n RETURNING\n debounced_times,\n first_started_at,\n previous_job_id AS job_id_to_debounce\n ), _batch AS (\n INSERT INTO v2_job_debounce_batch (id, debounce_batch)\n SELECT\n $1,\n COALESCE(\n (SELECT debounce_batch FROM v2_job_debounce_batch WHERE id = dk.job_id_to_debounce LIMIT 1),\n nextval('debounce_batch_seq')\n )\n FROM dk\n )\n SELECT debounced_times, first_started_at, job_id_to_debounce FROM dk\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "debounced_times", - "type_info": "Int4" - }, - { - "ordinal": 1, - "name": "first_started_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 2, - "name": "job_id_to_debounce", - "type_info": "Uuid" - } - ], - "parameters": { - "Left": [ - "Uuid", - "Varchar" - ] - }, - "nullable": [ - false, - false, - true - ] - }, - "hash": "c2347460b73ae9d3167031c263032e97ebefb46be9e58bd3da9067748075311b" -} diff --git a/backend/.sqlx/query-ecf67b08d327c351909b7ba80218903bec93ef79a71053c00227e17c6f0415a2.json b/backend/.sqlx/query-ecf67b08d327c351909b7ba80218903bec93ef79a71053c00227e17c6f0415a2.json deleted file mode 100644 index c43a2bcd30..0000000000 --- a/backend/.sqlx/query-ecf67b08d327c351909b7ba80218903bec93ef79a71053c00227e17c6f0415a2.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT EXISTS(\n SELECT 1 FROM v2_job\n WHERE parent_job = $1 AND id != $2\n AND id IN (SELECT id FROM v2_job_queue)\n ) as has", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "has", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Uuid", - "Uuid" - ] - }, - "nullable": [ - null - ] - }, - "hash": "ecf67b08d327c351909b7ba80218903bec93ef79a71053c00227e17c6f0415a2" -} diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index c5ca6a15cf..40b16c0b62 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -ec613f2db9e72e32e9131181546dcd679405a782 +414202845a45e2a7c6a2d3e154bd8dfd0273cc14 \ No newline at end of file diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index bdd70229e9..5f350f2ce5 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -17429,6 +17429,27 @@ paths: description: count of log lines that matched the query per hostname type: object + /srch/index/storage/disk: + get: + summary: Get index disk storage sizes from the indexer. + operationId: getIndexDiskStorageSizes + tags: + - indexSearch + responses: + "200": + description: disk storage sizes for each index + content: + application/json: + schema: + type: object + properties: + job_index_disk_size_bytes: + type: integer + nullable: true + log_index_disk_size_bytes: + type: integer + nullable: true + /indexer/delete/{idx_name}: delete: summary: Clear an index and restart the indexer. diff --git a/frontend/src/lib/components/instanceSettings/IndexerMemorySettings.svelte b/frontend/src/lib/components/instanceSettings/IndexerMemorySettings.svelte index 52bc3cd9bc..eb37ac8a0c 100644 --- a/frontend/src/lib/components/instanceSettings/IndexerMemorySettings.svelte +++ b/frontend/src/lib/components/instanceSettings/IndexerMemorySettings.svelte @@ -2,7 +2,7 @@ import { Button } from '$lib/components/common' import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte' import { IndexSearchService } from '$lib/gen' - import type { GetIndexerStatusResponse } from '$lib/gen' + import type { GetIndexerStatusResponse, GetIndexDiskStorageSizesResponse } from '$lib/gen' import { sendUserToast } from '$lib/toast' import { displaySize } from '$lib/utils' import Tooltip from '../Tooltip.svelte' @@ -24,6 +24,7 @@ let clearServiceLogsIndexModalOpen = $state(false) let status: GetIndexerStatusResponse | undefined = $state(undefined) + let diskSizes: GetIndexDiskStorageSizesResponse | undefined = $state(undefined) let statusLoading = $state(true) let statusError = $state(false) @@ -41,9 +42,15 @@ statusLoading = true statusError = false try { - status = await IndexSearchService.getIndexerStatus() + const [statusRes, diskRes] = await Promise.all([ + IndexSearchService.getIndexerStatus(), + IndexSearchService.getIndexDiskStorageSizes().catch(() => undefined) + ]) + status = statusRes + diskSizes = diskRes } catch (e) { status = undefined + diskSizes = undefined statusError = true } finally { statusLoading = false @@ -139,7 +146,11 @@ : 'bg-red-500'}" > {label}: - + {entry?.is_alive ? 'Running' : 'Stopped'} {#if entry?.last_locked_at} @@ -161,21 +172,21 @@
Jobs index: - {#if status.job_indexer?.storage?.disk_size_bytes != null} - Disk: {displaySize(status.job_indexer.storage.disk_size_bytes) ?? 'N/A'} + {#if diskSizes?.job_index_disk_size_bytes != null} + Disk: {displaySize(diskSizes.job_index_disk_size_bytes) ?? 'N/A'} {/if} {#if status.job_indexer?.storage?.s3_size_bytes != null} - {#if status.job_indexer?.storage?.disk_size_bytes != null}·{/if} + {#if diskSizes?.job_index_disk_size_bytes != null}·{/if} S3: {displaySize(status.job_indexer.storage.s3_size_bytes) ?? 'N/A'} {/if} Service logs index: - {#if status.log_indexer?.storage?.disk_size_bytes != null} - Disk: {displaySize(status.log_indexer.storage.disk_size_bytes) ?? 'N/A'} + {#if diskSizes?.log_index_disk_size_bytes != null} + Disk: {displaySize(diskSizes.log_index_disk_size_bytes) ?? 'N/A'} {/if} {#if status.log_indexer?.storage?.s3_size_bytes != null} - {#if status.log_indexer?.storage?.disk_size_bytes != null}·{/if} + {#if diskSizes?.log_index_disk_size_bytes != null}·{/if} S3: {displaySize(status.log_indexer.storage.s3_size_bytes) ?? 'N/A'} {/if} From 79d2bd51a00654162754046308d7670242120df6 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 25 Mar 2026 08:41:29 +0000 Subject: [PATCH 09/13] feat: move basic git sync from EE to CE with runtime user count gating (#8493) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: move basic git sync from EE to CE with runtime user count gating Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref.txt for git sync CE migration Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: keep git sync impl in private repo, revert oss to stub Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref.txt Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref.txt Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref.txt after merge Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use LICENSE_KEY check instead of get_license_plan for runtime gating Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref.txt Co-Authored-By: Claude Opus 4.6 (1M context) * fix: improve git sync CE UX — use "Community Edition" wording, mention user limit Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use "workspace members" instead of "users" in git sync messaging Co-Authored-By: Claude Opus 4.6 (1M context) * fix: lower CE git sync limit from 3 to 2 workspace members Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref.txt Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref.txt Co-Authored-By: Claude Opus 4.6 (1M context) * fix: simplify git sync CE alerts to warn about EE feature with member limit Co-Authored-By: Claude Opus 4.6 (1M context) * fix: add EE feature restrictions detail to CE git sync warning Co-Authored-By: Claude Opus 4.6 (1M context) * fix: show git sync settings even when >2 members, with disabled warning Co-Authored-By: Claude Opus 4.6 (1M context) * fix: show error alert when git sync settings exist but members exceed CE limit Co-Authored-By: Claude Opus 4.6 (1M context) * fix: mention CE git sync limit is for testing and hobbyist use Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref to 79eeacccc0438010d7dfa60207a5cbdaf2eda08d This commit updates the EE repository reference after PR #476 was merged in windmill-ee-private. Previous ee-repo-ref: c4d69c6e700c16d44f909d9c7b6738b07043db98 New ee-repo-ref: 79eeacccc0438010d7dfa60207a5cbdaf2eda08d Automated by sync-ee-ref workflow. * chore: update sqlx cache Co-Authored-By: Claude Opus 4.6 (1M context) * chore: regenerate full sqlx cache after main merge Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update sqlx cache Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref and regenerate sqlx cache with private feature Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use LICENSE_KEY_VALID for EE check, allow delete without access check, extract helpers Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref.txt Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: use compile-time cfg(enterprise) gating instead of runtime license checks Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref.txt Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref to 6171a91da38d6d16a88aeb1a3a4f4df78f995383 This commit updates the EE repository reference after PR #481 was merged in windmill-ee-private. Previous ee-repo-ref: 52681940cda6d70f65aeeb7144288f060b4d736e New ee-repo-ref: 6171a91da38d6d16a88aeb1a3a4f4df78f995383 Automated by sync-ee-ref workflow. * chore: update ee-repo-ref.txt Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref to b5c8af4df9ba2c39fdd494d7a40f9a92fbff8abc This commit updates the EE repository reference after PR #482 was merged in windmill-ee-private. Previous ee-repo-ref: 6e5b2741831468a7b30b26c0df1241e6141c6833 New ee-repo-ref: b5c8af4df9ba2c39fdd494d7a40f9a92fbff8abc Automated by sync-ee-ref workflow. * fix: gate CE_GIT_SYNC_MAX_USERS behind cfg(not(enterprise)) Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: windmill-internal-app[bot] --- ...aacf6af2c284ae446860113c82bc4e1da08ab.json | 12 - ...bc47caebc25215a430d6b301b35e265888159.json | 12 - ...fb7cf5f2b76f013c274245af13d7d727ebf1f.json | 12 - ...e2e60e3183fa81a411622891caea6dc03fa90.json | 15 -- ...960ffc33da5f31bf780e8fd6a66d5150b8027.json | 12 - ...69c87a9d29370ec985d2c8c28633cd078ffaf.json | 12 - ...74da8c73120b3e16194904575f79a4e055002.json | 12 - ...437ab3e02d8c3c10c53decc664533b8d04bc0.json | 22 -- backend/ee-repo-ref.txt | 2 +- .../windmill-api-workspaces/src/workspaces.rs | 131 +++++++--- backend/windmill-api/openapi.yaml | 31 +++ backend/windmill-common/src/ee_oss.rs | 1 + backend/windmill-git-sync/Cargo.toml | 2 +- backend/windmill-git-sync/src/lib.rs | 20 +- cli/package-lock.json | 14 +- .../git_sync/GitSyncContext.svelte.ts | 11 + .../components/git_sync/GitSyncSection.svelte | 245 +++++++++++------- 17 files changed, 298 insertions(+), 268 deletions(-) delete mode 100644 backend/.sqlx/query-01c040b04b487e86b7f4ff38b0faacf6af2c284ae446860113c82bc4e1da08ab.json delete mode 100644 backend/.sqlx/query-1ad384eeb3946ef7c492124c69fbc47caebc25215a430d6b301b35e265888159.json delete mode 100644 backend/.sqlx/query-1fa27bd47ab66b3f301ca43ef93fb7cf5f2b76f013c274245af13d7d727ebf1f.json delete mode 100644 backend/.sqlx/query-4e88aec662ebc70e0425a48a1b4e2e60e3183fa81a411622891caea6dc03fa90.json delete mode 100644 backend/.sqlx/query-5b140cb478aa32ac38fb831d6af960ffc33da5f31bf780e8fd6a66d5150b8027.json delete mode 100644 backend/.sqlx/query-6c24b5c08341979e02b7968242369c87a9d29370ec985d2c8c28633cd078ffaf.json delete mode 100644 backend/.sqlx/query-be35e85e94641c71620e5402dcf74da8c73120b3e16194904575f79a4e055002.json delete mode 100644 backend/.sqlx/query-ccd76be88fa9c11b3dc2e6d7711437ab3e02d8c3c10c53decc664533b8d04bc0.json diff --git a/backend/.sqlx/query-01c040b04b487e86b7f4ff38b0faacf6af2c284ae446860113c82bc4e1da08ab.json b/backend/.sqlx/query-01c040b04b487e86b7f4ff38b0faacf6af2c284ae446860113c82bc4e1da08ab.json deleted file mode 100644 index 0ad1fe4367..0000000000 --- a/backend/.sqlx/query-01c040b04b487e86b7f4ff38b0faacf6af2c284ae446860113c82bc4e1da08ab.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO app_version (id, app_id, value, created_by, created_at)\n VALUES (3001, 3001, '{\"grid\": []}', 'admin', NOW())", - "describe": { - "columns": [], - "parameters": { - "Left": [] - }, - "nullable": [] - }, - "hash": "01c040b04b487e86b7f4ff38b0faacf6af2c284ae446860113c82bc4e1da08ab" -} diff --git a/backend/.sqlx/query-1ad384eeb3946ef7c492124c69fbc47caebc25215a430d6b301b35e265888159.json b/backend/.sqlx/query-1ad384eeb3946ef7c492124c69fbc47caebc25215a430d6b301b35e265888159.json deleted file mode 100644 index 24d3c8929a..0000000000 --- a/backend/.sqlx/query-1ad384eeb3946ef7c492124c69fbc47caebc25215a430d6b301b35e265888159.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE app SET versions = ARRAY[3001::bigint] WHERE id = 3001", - "describe": { - "columns": [], - "parameters": { - "Left": [] - }, - "nullable": [] - }, - "hash": "1ad384eeb3946ef7c492124c69fbc47caebc25215a430d6b301b35e265888159" -} diff --git a/backend/.sqlx/query-1fa27bd47ab66b3f301ca43ef93fb7cf5f2b76f013c274245af13d7d727ebf1f.json b/backend/.sqlx/query-1fa27bd47ab66b3f301ca43ef93fb7cf5f2b76f013c274245af13d7d727ebf1f.json deleted file mode 100644 index 10cab9117a..0000000000 --- a/backend/.sqlx/query-1fa27bd47ab66b3f301ca43ef93fb7cf5f2b76f013c274245af13d7d727ebf1f.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO flow (workspace_id, path, summary, description, value, edited_by, edited_at, schema, extra_perms)\n VALUES ('test-workspace', 'u/operator/existing_flow', 'Existing flow', '', '{\"modules\": []}', 'admin', NOW(), '{}', '{}')", - "describe": { - "columns": [], - "parameters": { - "Left": [] - }, - "nullable": [] - }, - "hash": "1fa27bd47ab66b3f301ca43ef93fb7cf5f2b76f013c274245af13d7d727ebf1f" -} diff --git a/backend/.sqlx/query-4e88aec662ebc70e0425a48a1b4e2e60e3183fa81a411622891caea6dc03fa90.json b/backend/.sqlx/query-4e88aec662ebc70e0425a48a1b4e2e60e3183fa81a411622891caea6dc03fa90.json deleted file mode 100644 index 27d46b27ed..0000000000 --- a/backend/.sqlx/query-4e88aec662ebc70e0425a48a1b4e2e60e3183fa81a411622891caea6dc03fa90.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO token (token_hash, token_prefix, email, label, super_admin, owner, workspace_id)\n VALUES ($1, $2, 'charlie@windmill.dev', 'Charlie new token', false, 'u/charlie', 'test-workspace')", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Varchar" - ] - }, - "nullable": [] - }, - "hash": "4e88aec662ebc70e0425a48a1b4e2e60e3183fa81a411622891caea6dc03fa90" -} diff --git a/backend/.sqlx/query-5b140cb478aa32ac38fb831d6af960ffc33da5f31bf780e8fd6a66d5150b8027.json b/backend/.sqlx/query-5b140cb478aa32ac38fb831d6af960ffc33da5f31bf780e8fd6a66d5150b8027.json deleted file mode 100644 index 8e558fe67b..0000000000 --- a/backend/.sqlx/query-5b140cb478aa32ac38fb831d6af960ffc33da5f31bf780e8fd6a66d5150b8027.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO app (id, workspace_id, path, summary, versions, policy, extra_perms)\n VALUES (3001, 'test-workspace', 'u/operator/existing_app', 'Existing app', '{}',\n '{\"on_behalf_of\": \"u/admin\", \"on_behalf_of_email\": \"admin@windmill.dev\", \"execution_mode\": \"viewer\"}', '{}')", - "describe": { - "columns": [], - "parameters": { - "Left": [] - }, - "nullable": [] - }, - "hash": "5b140cb478aa32ac38fb831d6af960ffc33da5f31bf780e8fd6a66d5150b8027" -} diff --git a/backend/.sqlx/query-6c24b5c08341979e02b7968242369c87a9d29370ec985d2c8c28633cd078ffaf.json b/backend/.sqlx/query-6c24b5c08341979e02b7968242369c87a9d29370ec985d2c8c28633cd078ffaf.json deleted file mode 100644 index 6da123cbc4..0000000000 --- a/backend/.sqlx/query-6c24b5c08341979e02b7968242369c87a9d29370ec985d2c8c28633cd078ffaf.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO usr_to_group (workspace_id, group_, usr) VALUES ('test-workspace', 'editors', 'charlie')", - "describe": { - "columns": [], - "parameters": { - "Left": [] - }, - "nullable": [] - }, - "hash": "6c24b5c08341979e02b7968242369c87a9d29370ec985d2c8c28633cd078ffaf" -} diff --git a/backend/.sqlx/query-be35e85e94641c71620e5402dcf74da8c73120b3e16194904575f79a4e055002.json b/backend/.sqlx/query-be35e85e94641c71620e5402dcf74da8c73120b3e16194904575f79a4e055002.json deleted file mode 100644 index d7cc49fe3f..0000000000 --- a/backend/.sqlx/query-be35e85e94641c71620e5402dcf74da8c73120b3e16194904575f79a4e055002.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock, extra_perms)\n VALUES ('test-workspace', 3001, 'u/operator/existing_script', 'export function main() { return \"original\"; }', 'deno', 'script', 'admin', '{}', 'Existing script', '', '', '{}')", - "describe": { - "columns": [], - "parameters": { - "Left": [] - }, - "nullable": [] - }, - "hash": "be35e85e94641c71620e5402dcf74da8c73120b3e16194904575f79a4e055002" -} diff --git a/backend/.sqlx/query-ccd76be88fa9c11b3dc2e6d7711437ab3e02d8c3c10c53decc664533b8d04bc0.json b/backend/.sqlx/query-ccd76be88fa9c11b3dc2e6d7711437ab3e02d8c3c10c53decc664533b8d04bc0.json deleted file mode 100644 index d9b7688eba..0000000000 --- a/backend/.sqlx/query-ccd76be88fa9c11b3dc2e6d7711437ab3e02d8c3c10c53decc664533b8d04bc0.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO kafka_trigger (\n path, kafka_resource_path, topics, group_id,\n script_path, is_flow, workspace_id, edited_by, permissioned_as\n )\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "VarcharArray", - "Varchar", - "Varchar", - "Bool", - "Varchar", - "Varchar", - "Varchar" - ] - }, - "nullable": [] - }, - "hash": "ccd76be88fa9c11b3dc2e6d7711437ab3e02d8c3c10c53decc664533b8d04bc0" -} diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 40b16c0b62..7d86a6114e 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -414202845a45e2a7c6a2d3e154bd8dfd0273cc14 \ No newline at end of file +b5c8af4df9ba2c39fdd494d7a40f9a92fbff8abc diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 55ad777cbf..fb234a13a3 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -35,7 +35,6 @@ use windmill_common::variables::{ build_crypt, decrypt, encrypt, SECRET_SALT, WORKSPACE_CRYPT_CACHE, }; use windmill_common::worker::{to_raw_value, CLOUD_HOSTED}; -#[cfg(feature = "enterprise")] use windmill_common::workspaces::GitRepositorySettings; #[cfg(feature = "enterprise")] use windmill_common::workspaces::WorkspaceDeploymentUISettings; @@ -115,6 +114,7 @@ pub fn workspaced_service() -> Router { .route("/list_datatables", get(list_datatables)) .route("/list_datatable_schemas", get(list_datatable_schemas)) .route("/edit_datatable_config", post(edit_datatable_config)) + .route("/git_sync_enabled", get(get_git_sync_enabled)) .route("/edit_git_sync_config", post(edit_git_sync_config)) .route("/edit_git_sync_repository", post(edit_git_sync_repository)) .route( @@ -1595,24 +1595,20 @@ async fn edit_datatable_config( #[derive(Deserialize)] pub struct EditGitSyncConfig { - #[cfg(feature = "enterprise")] pub git_sync_settings: Option, } -#[cfg(feature = "enterprise")] #[derive(Deserialize, Debug)] pub struct EditGitSyncRepository { pub git_repo_resource_path: String, pub repository: GitRepositorySettings, } -#[cfg(feature = "enterprise")] #[derive(Deserialize, Debug)] pub struct DeleteGitSyncRepositoryRequest { pub git_repo_resource_path: String, } -#[cfg(feature = "enterprise")] fn validate_git_repo_resource_path(path: &str) -> Result<()> { // Resource paths should follow the pattern: $res:f// or $res:u// if path.is_empty() { @@ -1661,7 +1657,6 @@ fn validate_git_repo_resource_path(path: &str) -> Result<()> { Ok(()) } -#[cfg(feature = "enterprise")] fn cleanup_legacy_git_sync_settings_in_memory( git_sync_settings: &mut windmill_common::workspaces::WorkspaceGitSyncSettings, workspace_id: &str, @@ -1688,18 +1683,72 @@ fn cleanup_legacy_git_sync_settings_in_memory( } #[cfg(not(feature = "enterprise"))] -async fn edit_git_sync_config( - _authed: ApiAuthed, - Extension(_db): Extension, - Path(_w_id): Path, - Json(_new_config): Json, -) -> Result { - return Err(Error::BadRequest( - "Git sync is only available on Windmill Enterprise Edition".to_string(), - )); +const CE_GIT_SYNC_MAX_USERS: i64 = 2; + +#[cfg(feature = "enterprise")] +async fn check_git_sync_access(_db: &DB, _w_id: &str) -> Result<()> { + Ok(()) +} + +#[cfg(not(feature = "enterprise"))] +async fn check_git_sync_access(db: &DB, w_id: &str) -> Result<()> { + let user_count: i64 = sqlx::query_scalar!( + "SELECT COUNT(*) FROM usr WHERE workspace_id = $1 AND disabled = false", + w_id + ) + .fetch_one(db) + .await? + .unwrap_or(0); + + if user_count > CE_GIT_SYNC_MAX_USERS { + return Err(Error::BadRequest(format!( + "Git sync is available for workspaces with up to {} members. \ + Upgrade to Windmill Enterprise Edition for unlimited workspace members.", + CE_GIT_SYNC_MAX_USERS + ))); + } + Ok(()) } #[cfg(feature = "enterprise")] +async fn get_git_sync_enabled( + _authed: ApiAuthed, + Extension(_db): Extension, + Path(_w_id): Path, +) -> JsonResult { + Ok(Json(serde_json::json!({ + "enabled": true, + "reason": "enterprise", + "max_repos": null, + "user_count": null, + "max_users": null, + }))) +} + +#[cfg(not(feature = "enterprise"))] +async fn get_git_sync_enabled( + _authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, +) -> JsonResult { + let user_count: i64 = sqlx::query_scalar!( + "SELECT COUNT(*) FROM usr WHERE workspace_id = $1 AND disabled = false", + &w_id + ) + .fetch_one(&db) + .await? + .unwrap_or(0); + + let enabled = user_count <= CE_GIT_SYNC_MAX_USERS; + Ok(Json(serde_json::json!({ + "enabled": enabled, + "reason": if enabled { Some("free_tier") } else { None::<&str> }, + "max_repos": if enabled { Some(1) } else { None:: }, + "user_count": user_count, + "max_users": CE_GIT_SYNC_MAX_USERS, + }))) +} + async fn edit_git_sync_config( authed: ApiAuthed, Extension(db): Extension, @@ -1708,6 +1757,7 @@ async fn edit_git_sync_config( Json(new_config): Json, ) -> Result { require_admin(is_admin, &username)?; + check_git_sync_access(&db, &w_id).await?; let mut tx = db.begin().await?; @@ -1764,19 +1814,6 @@ async fn edit_git_sync_config( Ok(format!("Edit git sync config for workspace {}", &w_id)) } -#[cfg(not(feature = "enterprise"))] -async fn edit_git_sync_repository( - _authed: ApiAuthed, - Extension(_db): Extension, - Path(_w_id): Path, - Json(_new_config): Json, -) -> Result { - return Err(Error::BadRequest( - "Git sync is only available on Windmill Enterprise Edition".to_string(), - )); -} - -#[cfg(feature = "enterprise")] async fn edit_git_sync_repository( authed: ApiAuthed, Extension(db): Extension, @@ -1785,10 +1822,19 @@ async fn edit_git_sync_repository( Json(new_config): Json, ) -> Result { require_admin(is_admin, &username)?; + check_git_sync_access(&db, &w_id).await?; // Validate the resource path format validate_git_repo_resource_path(&new_config.git_repo_resource_path)?; + // Promotion mode: EE only + #[cfg(not(feature = "enterprise"))] + if new_config.repository.use_individual_branch.unwrap_or(false) { + return Err(Error::BadRequest( + "Promotion mode is an Enterprise Edition feature".to_string(), + )); + } + let mut tx = db.begin().await?; // First, get the current git sync settings @@ -1810,6 +1856,20 @@ async fn edit_git_sync_repository( WorkspaceGitSyncSettings::default() }; + // Multi-repo: EE only + #[cfg(not(feature = "enterprise"))] + { + let is_new = !git_sync_settings + .repositories + .iter() + .any(|r| r.git_repo_resource_path == new_config.git_repo_resource_path); + if is_new && !git_sync_settings.repositories.is_empty() { + return Err(Error::BadRequest( + "Multiple git sync repositories is an Enterprise Edition feature".to_string(), + )); + } + } + // Audit log before we move the repository audit_log( &mut *tx, @@ -1893,19 +1953,6 @@ async fn edit_git_sync_repository( )) } -#[cfg(not(feature = "enterprise"))] -async fn delete_git_sync_repository( - _authed: ApiAuthed, - Extension(_db): Extension, - Path(_w_id): Path, - Json(_request): Json, -) -> Result { - return Err(Error::BadRequest( - "Git sync is only available on Windmill Enterprise Edition".to_string(), - )); -} - -#[cfg(feature = "enterprise")] async fn delete_git_sync_repository( authed: ApiAuthed, Extension(db): Extension, @@ -1915,7 +1962,7 @@ async fn delete_git_sync_repository( ) -> Result { require_admin(is_admin, &username)?; - // For deletion, only validate that path is not empty to allow cleanup of malformed entries + // No check_git_sync_access here — admins should always be able to delete/clean up repos if request.git_repo_resource_path.is_empty() { return Err(Error::BadRequest( "Resource path cannot be empty".to_string(), diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 5f350f2ce5..a8f83c368a 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -3430,6 +3430,37 @@ paths: application/json: schema: {} + /w/{workspace}/workspaces/git_sync_enabled: + get: + summary: Check if git sync is available for this workspace + operationId: getGitSyncEnabled + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + responses: + "200": + description: Git sync availability status + content: + application/json: + schema: + type: object + properties: + enabled: + type: boolean + reason: + type: string + nullable: true + max_repos: + type: integer + nullable: true + user_count: + type: integer + nullable: true + max_users: + type: integer + nullable: true + /w/{workspace}/workspaces/edit_git_sync_config: post: summary: edit workspace git sync settings diff --git a/backend/windmill-common/src/ee_oss.rs b/backend/windmill-common/src/ee_oss.rs index 51b1efd2e2..93d0061ade 100644 --- a/backend/windmill-common/src/ee_oss.rs +++ b/backend/windmill-common/src/ee_oss.rs @@ -23,6 +23,7 @@ lazy_static::lazy_static! { } #[cfg(not(feature = "private"))] +#[derive(PartialEq, Eq)] pub enum LicensePlan { Community, Pro, diff --git a/backend/windmill-git-sync/Cargo.toml b/backend/windmill-git-sync/Cargo.toml index 063e1ce54a..148746dcca 100644 --- a/backend/windmill-git-sync/Cargo.toml +++ b/backend/windmill-git-sync/Cargo.toml @@ -9,7 +9,7 @@ name = "windmill_git_sync" path = "./src/lib.rs" [features] -private = [] +private = ["windmill-common/private"] enterprise = ["windmill-queue/enterprise", "windmill-common/enterprise"] all_sqlx_features = ["enterprise"] default = [] diff --git a/backend/windmill-git-sync/src/lib.rs b/backend/windmill-git-sync/src/lib.rs index f9cecd46ce..dcbcd5bcb2 100644 --- a/backend/windmill-git-sync/src/lib.rs +++ b/backend/windmill-git-sync/src/lib.rs @@ -272,7 +272,10 @@ mod tests { path: "f/folder/script".to_string(), parent_path: Some("f/folder/old_script".to_string()), }; - assert_eq!(obj.get_parent_path(), Some("f/folder/old_script".to_string())); + assert_eq!( + obj.get_parent_path(), + Some("f/folder/old_script".to_string()) + ); } #[test] @@ -313,21 +316,13 @@ mod tests { #[test] fn test_get_kind_flow() { - let obj = DeployedObject::Flow { - path: "test".to_string(), - parent_path: None, - version: 1, - }; + let obj = DeployedObject::Flow { path: "test".to_string(), parent_path: None, version: 1 }; assert_eq!(obj.get_kind(), "flow"); } #[test] fn test_get_kind_app() { - let obj = DeployedObject::App { - path: "test".to_string(), - version: 1, - parent_path: None, - }; + let obj = DeployedObject::App { path: "test".to_string(), version: 1, parent_path: None }; assert_eq!(obj.get_kind(), "app"); } @@ -346,7 +341,8 @@ mod tests { "http_trigger" ); assert_eq!( - DeployedObject::WebsocketTrigger { path: "t".to_string(), parent_path: None }.get_kind(), + DeployedObject::WebsocketTrigger { path: "t".to_string(), parent_path: None } + .get_kind(), "websocket_trigger" ); assert_eq!( diff --git a/cli/package-lock.json b/cli/package-lock.json index 0e86b9d2b7..ae46c240c9 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -25,10 +25,11 @@ "windmill-parser-wasm-nu": "*", "windmill-parser-wasm-php": "*", "windmill-parser-wasm-py": "*", + "windmill-parser-wasm-py-imports": "*", "windmill-parser-wasm-regex": "*", "windmill-parser-wasm-ruby": "*", "windmill-parser-wasm-rust": "*", - "windmill-parser-wasm-ts": "*", + "windmill-parser-wasm-ts": "^1.659.1", "windmill-parser-wasm-yaml": "*", "windmill-yaml-validator": "1.1.1", "ws": "8.18.0", @@ -1414,6 +1415,11 @@ "resolved": "https://registry.npmjs.org/windmill-parser-wasm-py/-/windmill-parser-wasm-py-1.628.3.tgz", "integrity": "sha512-TlluqknZpg8cZ+A3m6JFLPseY2PpKtDsxdj26fAnCUzKPtse8TxQR+n0dwC80rfW5TwdWSulvNGRDgcNuf7CTw==" }, + "node_modules/windmill-parser-wasm-py-imports": { + "version": "1.659.1", + "resolved": "https://registry.npmjs.org/windmill-parser-wasm-py-imports/-/windmill-parser-wasm-py-imports-1.659.1.tgz", + "integrity": "sha512-nfnf04WBRf8f/mNIwdvggYOgz3erxrFGjKqULYBH+bKFMlKA6V7eB19m6CXOBkq9rjTp0ZFG+rgsR+Us7JEkyQ==" + }, "node_modules/windmill-parser-wasm-regex": { "version": "1.639.0", "resolved": "https://registry.npmjs.org/windmill-parser-wasm-regex/-/windmill-parser-wasm-regex-1.639.0.tgz", @@ -1430,9 +1436,9 @@ "integrity": "sha512-9yGLYZX2Hn9TdTqGY/5Fp50ftzgUsrfBkSK9vJkKJd5Amyg+yXLBGzd8pz6Org+4uxMenz/16wpsgijvo6uhhQ==" }, "node_modules/windmill-parser-wasm-ts": { - "version": "1.647.1", - "resolved": "https://registry.npmjs.org/windmill-parser-wasm-ts/-/windmill-parser-wasm-ts-1.647.1.tgz", - "integrity": "sha512-64iSAUMU5W/WtePqE1vtDvglDqtkiZVndyieYBVDX0nl7UuovS+wPgH/P3TEoKbR+FwAPacki0CX3DsEzZ/Yxw==" + "version": "1.659.1", + "resolved": "https://registry.npmjs.org/windmill-parser-wasm-ts/-/windmill-parser-wasm-ts-1.659.1.tgz", + "integrity": "sha512-EmXMzOmazC5r29UZh+1TVF9g/N2X51pqK11qDL6xWGeWTIIonhfOZ5nWdGvKQMDUR650fGxehImZzW2v9hNy+w==" }, "node_modules/windmill-parser-wasm-yaml": { "version": "1.593.0", diff --git a/frontend/src/lib/components/git_sync/GitSyncContext.svelte.ts b/frontend/src/lib/components/git_sync/GitSyncContext.svelte.ts index 834982c3f3..ba919fa16b 100644 --- a/frontend/src/lib/components/git_sync/GitSyncContext.svelte.ts +++ b/frontend/src/lib/components/git_sync/GitSyncContext.svelte.ts @@ -1,4 +1,7 @@ import { getContext, setContext } from 'svelte' +import { enterpriseLicense } from '$lib/stores' +import { get } from 'svelte/store' +import { sendUserToast } from '$lib/toast' import { JobService, WorkspaceService, ResourceService } from '$lib/gen' import type { GitRepositorySettings as BackendGitRepositorySettings, @@ -646,6 +649,10 @@ export function createGitSyncContext(workspace: string) { } function addSyncRepository() { + if (!get(enterpriseLicense) && repositories && repositories.length >= 1) { + sendUserToast('Multiple repositories requires Enterprise Edition', true) + return + } repositories.push({ git_repo_resource_path: '', script_path: undefined, @@ -669,6 +676,10 @@ export function createGitSyncContext(workspace: string) { } function addPromotionRepository() { + if (!get(enterpriseLicense)) { + sendUserToast('Promotion mode requires Enterprise Edition', true) + return + } repositories.push({ git_repo_resource_path: '', script_path: undefined, diff --git a/frontend/src/lib/components/git_sync/GitSyncSection.svelte b/frontend/src/lib/components/git_sync/GitSyncSection.svelte index 893e782b19..9a03e43b27 100644 --- a/frontend/src/lib/components/git_sync/GitSyncSection.svelte +++ b/frontend/src/lib/components/git_sync/GitSyncSection.svelte @@ -6,12 +6,46 @@ import GitSyncRepositoryCard from './GitSyncRepositoryCard.svelte' import GitSyncModalManager from './GitSyncModalManager.svelte' import { enterpriseLicense, workspaceStore } from '$lib/stores' + import { WorkspaceService } from '$lib/gen' import { sendUserToast } from '$lib/toast' import { untrack } from 'svelte' // Create context reactively based on workspaceStore const gitSyncContext = $derived($workspaceStore ? setGitSyncContext($workspaceStore) : null) + // Fetch git sync eligibility + let gitSyncStatus = $state<{ + enabled: boolean + reason: string | null + max_repos: number | null + user_count: number | null + max_users: number | null + }>({ enabled: false, reason: null, max_repos: null, user_count: null, max_users: null }) + + $effect(() => { + if ($workspaceStore) { + WorkspaceService.getGitSyncEnabled({ workspace: $workspaceStore }) + .then((status) => { + gitSyncStatus = status as typeof gitSyncStatus + }) + .catch(() => { + gitSyncStatus = { + enabled: false, + reason: null, + max_repos: null, + user_count: null, + max_users: null + } + }) + } + }) + + const gitSyncAllowed = $derived(gitSyncStatus.enabled) + const isFreeTier = $derived(gitSyncAllowed && !$enterpriseLicense) + const hasConfiguredRepos = $derived( + gitSyncContext?.repositories?.some((r) => r.git_repo_resource_path) ?? false + ) + // Load settings when workspace context changes $effect(() => { if (gitSyncContext) { @@ -58,7 +92,7 @@ link="https://www.windmill.dev/docs/advanced/git_sync" > {#snippet actions()} - {#if $enterpriseLicense && gitSyncContext.repositories != undefined} + {#if (gitSyncAllowed || gitSyncStatus.user_count != null) && gitSyncContext?.repositories != undefined} - - {#if secondarySyncExpanded} -
- {#if secondarySync.length === 0} -
- No secondary sync repositories configured -
- {:else} - {#each secondarySync as { repo, idx } (repo.git_repo_resource_path)} -
- -
- {/each} - {/if} - - {#if !hasUnsavedSecondary} -
- -
- {/if} -
- {/if} -
- {:else} - - {#if !hasUnsavedSecondary} -
- -
- {/if} - {/if} - {/if} - - -
- gitSyncContext.addPromotionRepository()} - isCollapsible={false} - showEmptyState={primaryPromotion?.repo === null} - /> - - - {#if primaryPromotion && !primaryPromotion.repo?.isUnsavedConnection} - {#if secondaryPromotion.length > 0 || secondaryPromotionExpanded} + {#if $enterpriseLicense} + + {#if primarySync && !primarySync.repo?.isUnsavedConnection} + {#if secondarySync.length > 0 || secondarySyncExpanded}
- {#if secondaryPromotionExpanded} + {#if secondarySyncExpanded}
- {#if secondaryPromotion.length === 0} + {#if secondarySync.length === 0}
- No secondary promotion repositories configured + No secondary sync repositories configured
{:else} - {#each secondaryPromotion as { repo, idx } (repo.git_repo_resource_path)} + {#each secondarySync as { repo, idx } (repo.git_repo_resource_path)}
{/each} {/if} - {#if !hasUnsavedSecondaryPromotion} + {#if !hasUnsavedSecondary}
{/if} @@ -216,23 +187,99 @@ {/if}
{:else} - - {#if !hasUnsavedSecondaryPromotion} + + {#if !hasUnsavedSecondary}
{/if} {/if} {/if} -
+ + +
+ gitSyncContext.addPromotionRepository()} + isCollapsible={false} + showEmptyState={primaryPromotion?.repo === null} + /> + + + {#if primaryPromotion && !primaryPromotion.repo?.isUnsavedConnection} + {#if secondaryPromotion.length > 0 || secondaryPromotionExpanded} +
+ + + {#if secondaryPromotionExpanded} +
+ {#if secondaryPromotion.length === 0} +
+ No secondary promotion repositories configured +
+ {:else} + {#each secondaryPromotion as { repo, idx } (repo.git_repo_resource_path)} +
+ +
+ {/each} + {/if} + + {#if !hasUnsavedSecondaryPromotion} +
+ +
+ {/if} +
+ {/if} +
+ {:else} + + {#if !hasUnsavedSecondaryPromotion} +
+ +
+ {/if} + {/if} + {/if} +
+ {/if}
From 10c5c97d3723dc317ed0a30098d248d91777d01a Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 25 Mar 2026 08:48:05 +0000 Subject: [PATCH 10/13] nit frontend --- frontend/src/lib/components/git_sync/GitSyncSection.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/lib/components/git_sync/GitSyncSection.svelte b/frontend/src/lib/components/git_sync/GitSyncSection.svelte index 9a03e43b27..8041c734da 100644 --- a/frontend/src/lib/components/git_sync/GitSyncSection.svelte +++ b/frontend/src/lib/components/git_sync/GitSyncSection.svelte @@ -120,7 +120,7 @@ {:else if isFreeTier}
- + Git sync is an EE feature provided in CE for testing and hobbyist use when workspace members ≤ {gitSyncStatus.max_users}. Limited to a single repository. Upgrade to EE for multiple repositories, promotion mode, and GitHub App authentication. From 60804a96c630087958e3dc8b8ea0c87cbb690bce Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Wed, 25 Mar 2026 09:50:12 +0100 Subject: [PATCH 11/13] refactor: unify eval pipeline with production chat code path (#8504) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: unify eval pipeline with production chat code path Extract a shared headless runChatLoop() that both AIChatManager (production) and the eval runner use, with injectable SDK clients. Drop OpenRouter — evals now use direct provider APIs (OpenAI SDK, Anthropic SDK) with streaming, matching production behavior. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: re-read tools/helpers/systemMessage/model on each loop iteration The old chatRequest() re-read this.tools, this.helpers, this.systemMessage, and getCurrentModel() on every iteration. This matters because changeModeTool (Navigator → Script/Flow) reassigns all of these mid-loop. Use JS getters in the config object so runChatLoop picks up changes each iteration. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .../copilot/chat/AIChatManager.svelte.ts | 163 ++++--------- .../chat/__tests__/app/appChat.eval.test.ts | 165 +++++++++---- .../chat/__tests__/app/appEvalComparison.ts | 31 +-- .../chat/__tests__/app/appEvalRunner.ts | 39 ++-- .../chat/__tests__/flow/flowChat.eval.test.ts | 151 ++++++++---- .../chat/__tests__/flow/flowEvalComparison.ts | 6 +- .../chat/__tests__/flow/flowEvalRunner.ts | 39 ++-- .../chat/__tests__/shared/baseEvalRunner.ts | 216 ++++++++---------- .../chat/__tests__/shared/baseLLMEvaluator.ts | 37 +-- .../copilot/chat/__tests__/shared/types.ts | 3 + .../lib/components/copilot/chat/anthropic.ts | 24 +- .../lib/components/copilot/chat/chatLoop.ts | 211 +++++++++++++++++ .../copilot/chat/openai-responses.ts | 23 +- .../src/lib/components/copilot/chat/shared.ts | 6 +- frontend/src/lib/components/copilot/lib.ts | 26 ++- 15 files changed, 743 insertions(+), 397 deletions(-) create mode 100644 frontend/src/lib/components/copilot/chat/chatLoop.ts diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 00a1a11fdf..140bbf4f14 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -23,8 +23,7 @@ import { } from './shared' import type { ChatCompletionMessageParam, - ChatCompletionSystemMessageParam, - ChatCompletionUserMessageParam + ChatCompletionSystemMessageParam } from 'openai/resources/chat/completions.mjs' import { prepareInlineChatSystemPrompt, @@ -37,7 +36,7 @@ import { loadApiTools } from './api/apiTools' import { prepareScriptUserMessage } from './script/core' import { prepareNavigatorUserMessage } from './navigator/core' import { sendUserToast } from '$lib/toast' -import { getCompletion, getModelContextWindow, parseOpenAICompletion } from '../lib' +import { getModelContextWindow, workspaceAIClients } from '../lib' import { dfs } from '$lib/components/flows/previousResults' import { getStringError } from './utils' import type { FlowModuleState, FlowState } from '$lib/components/flows/flowState' @@ -56,8 +55,7 @@ import type { import type { Selection } from 'monaco-editor' import type AIChatInput from './AIChatInput.svelte' import { prepareApiSystemMessage, prepareApiUserMessage } from './api/core' -import { getAnthropicCompletion, parseAnthropicCompletion } from './anthropic' -import { getOpenAIResponsesCompletion, parseOpenAIResponsesCompletion } from './openai-responses' +import { runChatLoop } from './chatLoop' import type { ReviewChangesOpts } from './monaco-adapter' import { getCurrentModel, tryGetCurrentModel, getCombinedCustomPrompt } from '$lib/aiStore' @@ -413,130 +411,63 @@ class AIChatManager { systemMessage?: ChatCompletionSystemMessageParam }) => { try { - let addedMessages: ChatCompletionMessageParam[] = [] - while (true) { - const systemMessage = systemMessageOverride ?? this.systemMessage - const helpers = this.helpers - const tools = this.tools - for (const tool of tools) { - if (tool.setSchema) { - await tool.setSchema(helpers) - } - } - - let pendingPrompt = this.pendingPrompt - let pendingUserMessage: ChatCompletionUserMessageParam | undefined = undefined - if (pendingPrompt) { + // Use JS getters so runChatLoop re-reads tools/helpers/systemMessage/modelProvider + // on each iteration. This is critical for changeModeTool (Navigator → Script/Flow) + // which reassigns this.tools, this.helpers, this.systemMessage mid-loop. + const self = this + const result = await runChatLoop({ + messages, + get systemMessage() { + return systemMessageOverride ?? self.systemMessage + }, + get tools() { + return self.tools + }, + get helpers() { + return self.helpers + }, + abortController, + callbacks, + get modelProvider() { + return getCurrentModel() + }, + clients: { + openai: workspaceAIClients.getOpenaiClient(), + anthropic: workspaceAIClients.getAnthropicClient() + }, + workspace: get(workspaceStore) ?? '', + skipResponsesApi: this.skipResponsesApi, + onSkipResponsesApi: () => { + this.skipResponsesApi = true + }, + getPendingUserMessage: () => { + const pendingPrompt = this.pendingPrompt + if (!pendingPrompt) return undefined + this.pendingPrompt = '' if (this.mode === AIMode.SCRIPT) { - pendingUserMessage = prepareScriptUserMessage( + return prepareScriptUserMessage( pendingPrompt, this.contextManager.getSelectedContext() ) } else if (this.mode === AIMode.FLOW) { - pendingUserMessage = prepareFlowUserMessage( + return prepareFlowUserMessage( pendingPrompt, this.flowAiChatHelpers!.getFlowAndSelectedId() ) } else if (this.mode === AIMode.NAVIGATOR) { - pendingUserMessage = prepareNavigatorUserMessage(pendingPrompt) + return prepareNavigatorUserMessage(pendingPrompt) } - this.pendingPrompt = '' - } - - const model = getCurrentModel() - const isOpenAI = model.provider === 'openai' || model.provider === 'azure_openai' - const isAnthropic = model.provider === 'anthropic' - - const messageParams = [ - systemMessage, - ...messages, - ...(pendingUserMessage ? [pendingUserMessage] : []) - ] - const toolDefs = tools.map((t) => t.def) - - // For OpenAI/Azure, try Responses API first, fallback to Completions API - if (isOpenAI) { - let useCompletionsApi = this.skipResponsesApi - if (!this.skipResponsesApi) { - try { - const completion = await getOpenAIResponsesCompletion( - messageParams, - abortController, - toolDefs - ) - const continueCompletion = await parseOpenAIResponsesCompletion( - completion, - callbacks, - messages, - addedMessages, - tools, - helpers - ) - if (!continueCompletion) { - break - } - } catch (err) { - console.warn('OpenAI Responses API failed, falling back to Completions API:', err) - // If the error indicates Responses API is not available in this region, skip it for future requests - const errorMessage = err instanceof Error ? err.message : String(err) - if (errorMessage.includes('Responses API is not enabled')) { - this.skipResponsesApi = true - } - useCompletionsApi = true - } - } - - // Use Completions API if Responses API is not available or failed - if (useCompletionsApi) { - const completion = await getCompletion(messageParams, abortController, toolDefs, { - forceCompletions: true - }) - const continueCompletion = await parseOpenAICompletion( - completion, - callbacks, - messages, - addedMessages, - tools, - helpers - ) - if (!continueCompletion) { - break - } - } - } else if (isAnthropic) { - const completion = await getAnthropicCompletion(messageParams, abortController, toolDefs) - if (completion) { - const continueCompletion = await parseAnthropicCompletion( - completion, - callbacks, - messages, - addedMessages, - tools, - helpers, - abortController - ) - if (!continueCompletion) { - break - } - } - } else { - const completion = await getCompletion(messageParams, abortController, toolDefs) - if (completion) { - const continueCompletion = await parseOpenAICompletion( - completion, - callbacks, - messages, - addedMessages, - tools, - helpers - ) - if (!continueCompletion) { - break + return undefined + }, + onBeforeIteration: async (tools) => { + for (const tool of tools) { + if (tool.setSchema) { + await tool.setSchema(this.helpers) } } } - } - return addedMessages + }) + return result.addedMessages } catch (err) { console.log('chatRequest error', err) console.error('chatRequest error', err) diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/appChat.eval.test.ts b/frontend/src/lib/components/copilot/chat/__tests__/app/appChat.eval.test.ts index 5183377caf..a42ee1f099 100644 --- a/frontend/src/lib/components/copilot/chat/__tests__/app/appChat.eval.test.ts +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/appChat.eval.test.ts @@ -6,44 +6,77 @@ import { loadAppFixtureForEval } from './appFixtureLoader' import { dirname, join } from 'path' // @ts-ignore - Node.js url import { fileURLToPath } from 'url' +import type { AIProvider } from '$lib/gen/types.gen' -// Get API key from environment - tests will be skipped if not set +// Get API keys from environment - tests will be skipped if none are set // @ts-ignore -const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY +const OPENAI_API_KEY = process.env.OPENAI_API_KEY +// @ts-ignore +const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY -// Skip all tests if no API key is provided -const describeWithApiKey = OPENROUTER_API_KEY ? describe : describe.skip +const hasAnyKey = OPENAI_API_KEY || ANTHROPIC_API_KEY +const describeWithApiKey = hasAnyKey ? describe : describe.skip // Get __dirname equivalent for ES modules const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) -const MODELS = ['google/gemini-2.5-flash', 'anthropic/claude-haiku-4.5', 'openai/gpt-4o'] +// Build model variants based on available keys +interface ModelVariant { + model: string + provider: AIProvider + apiKey: string +} + +const MODEL_VARIANTS: ModelVariant[] = [ + ...(OPENAI_API_KEY + ? [{ model: 'gpt-4o', provider: 'openai' as AIProvider, apiKey: OPENAI_API_KEY }] + : []), + ...(ANTHROPIC_API_KEY + ? [ + { + model: 'claude-haiku-4-5-20241022', + provider: 'anthropic' as AIProvider, + apiKey: ANTHROPIC_API_KEY + } + ] + : []) +] + const VARIANTS = [ - ...MODELS.map((model) => ({ + ...MODEL_VARIANTS.map((mv) => ({ ...BASELINE_VARIANT, - model, - name: `baseline-${model.replace('/', '-')}` + model: mv.model, + name: `baseline-${mv.provider}-${mv.model}`, + _provider: mv.provider, + _apiKey: mv.apiKey })), - ...MODELS.map((model) => ({ + ...MODEL_VARIANTS.map((mv) => ({ ...STREAMLINED_VARIANT, - model, - name: `streamlined-${model.replace('/', '-')}` + model: mv.model, + name: `streamlined-${mv.provider}-${mv.model}`, + _provider: mv.provider, + _apiKey: mv.apiKey })) ] describeWithApiKey('App Chat LLM Evaluation', () => { const TEST_TIMEOUT = 120_000 - if (!OPENROUTER_API_KEY) { - console.warn('OPENROUTER_API_KEY is not set, skipping tests') + if (!hasAnyKey) { + console.warn('No API keys set (OPENAI_API_KEY or ANTHROPIC_API_KEY), skipping tests') } it( 'test1: creates a simple counter app', async () => { const USER_PROMPT = `Create a counter app with increment/decrement buttons` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!) - // Write results to files + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + undefined, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) console.log(`App files: ${appPaths.join(', ')}`) @@ -56,17 +89,21 @@ describeWithApiKey('App Chat LLM Evaluation', () => { it( 'test2: modifies existing counter app to add reset button', async () => { - // Load initial app from fixture folder const { initialFrontend, initialBackend } = await loadAppFixtureForEval( join(__dirname, 'initial', 'test1_counter_app') ) const USER_PROMPT = `Add a reset button that sets the counter back to 0` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { - initialFrontend, - initialBackend - }) - // Write results to files + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + { + initialFrontend, + initialBackend + }, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) console.log(`App files: ${appPaths.join(', ')}`) @@ -86,10 +123,16 @@ describeWithApiKey('App Chat LLM Evaluation', () => { ) const USER_PROMPT = `Add a quantity selector (+ and - buttons) to each cart item so users can adjust quantities without removing and re-adding items` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { - initialFrontend, - initialBackend - }) + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + { + initialFrontend, + initialBackend + }, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) @@ -108,10 +151,16 @@ describeWithApiKey('App Chat LLM Evaluation', () => { ) const USER_PROMPT = `Add a discount code input field in the cart. When the code "SAVE10" is entered, apply a 10% discount to the total` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { - initialFrontend, - initialBackend - }) + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + { + initialFrontend, + initialBackend + }, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) @@ -132,10 +181,16 @@ describeWithApiKey('App Chat LLM Evaluation', () => { ) const USER_PROMPT = `Add a search bar in the toolbar that filters files and folders by name as the user types` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { - initialFrontend, - initialBackend - }) + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + { + initialFrontend, + initialBackend + }, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) @@ -154,10 +209,16 @@ describeWithApiKey('App Chat LLM Evaluation', () => { ) const USER_PROMPT = `Show file size (formatted as KB/MB) and modified date in the file list for each item` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { - initialFrontend, - initialBackend - }) + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + { + initialFrontend, + initialBackend + }, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) @@ -176,10 +237,16 @@ describeWithApiKey('App Chat LLM Evaluation', () => { ) const USER_PROMPT = `Add a "Select All" checkbox in the file list header and individual checkboxes for each file. Add a "Delete Selected" button that appears when items are selected` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { - initialFrontend, - initialBackend - }) + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + { + initialFrontend, + initialBackend + }, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) @@ -196,7 +263,13 @@ describeWithApiKey('App Chat LLM Evaluation', () => { 'test8: create quiz app from scratch', async () => { const USER_PROMPT = `Create a multiple choice quiz app with 5 questions about general knowledge. Show one question at a time with 4 answer options. Track the score and show results at the end with percentage correct.` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!) + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + undefined, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) @@ -211,7 +284,13 @@ describeWithApiKey('App Chat LLM Evaluation', () => { 'test9: create recipe book from scratch', async () => { const USER_PROMPT = `Create a recipe book app where users can add recipes with a name, ingredients list, and instructions. Include a search bar to filter recipes by name and the ability to delete recipes.` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!) + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + undefined, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/appEvalComparison.ts b/frontend/src/lib/components/copilot/chat/__tests__/app/appEvalComparison.ts index 456299c142..e6c795d445 100644 --- a/frontend/src/lib/components/copilot/chat/__tests__/app/appEvalComparison.ts +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/appEvalComparison.ts @@ -1,4 +1,4 @@ -import OpenAI from 'openai' +import Anthropic from '@anthropic-ai/sdk' import type { AppFiles, BackendRunnable } from '../../app/core' import { BASE_EVALUATOR_RESPONSE_FORMAT } from '../shared' import type { EvaluationResult } from '../shared' @@ -71,12 +71,7 @@ ${BASE_EVALUATOR_RESPONSE_FORMAT}` /** * Evaluates how well a generated app fulfills the user's request, considering any initial app state. - * This evaluator does not require an expected reference app - it evaluates based on the request alone. - * - * @param userPrompt The original user request - * @param generatedApp The app generated by the AI - * @param initialApp Optional initial app state (what the app looked like before AI changes) - * @returns Evaluation result with score, statement, and missing requirements + * Uses Anthropic API directly. */ export async function evaluateAppGeneration( userPrompt: string, @@ -84,9 +79,17 @@ export async function evaluateAppGeneration( initialApp?: InitialApp ): Promise { // @ts-ignore - const apiKey = process.env.OPENROUTER_API_KEY + const apiKey = process.env.ANTHROPIC_API_KEY + if (!apiKey) { + return { + success: false, + resemblanceScore: 0, + statement: 'No API key available for evaluation', + error: 'ANTHROPIC_API_KEY not set' + } + } - const client = new OpenAI({ baseURL: 'https://openrouter.ai/api/v1', apiKey }) + const client = new Anthropic({ apiKey }) let userMessage = `## User's Original Request ${userPrompt} @@ -117,16 +120,18 @@ Please evaluate how well the generated app: 2. ${initialApp ? 'Makes appropriate modifications to the initial app state' : 'Implements a complete and correct new app'}` try { - const response = await client.chat.completions.create({ - model: 'anthropic/claude-sonnet-4.5', + const response = await client.messages.create({ + model: 'claude-sonnet-4-5-20250514', + max_tokens: 2048, + system: APP_GENERATION_EVALUATOR_SYSTEM_PROMPT, messages: [ - { role: 'system', content: APP_GENERATION_EVALUATOR_SYSTEM_PROMPT }, { role: 'user', content: userMessage } ], temperature: 0 }) - const content = response.choices[0]?.message?.content + const textBlock = response.content.find((block) => block.type === 'text') + const content = textBlock?.text if (!content) { return { success: false, diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/appEvalRunner.ts b/frontend/src/lib/components/copilot/chat/__tests__/app/appEvalRunner.ts index 3f0da73c92..2e6a491bce 100644 --- a/frontend/src/lib/components/copilot/chat/__tests__/app/appEvalRunner.ts +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/appEvalRunner.ts @@ -14,6 +14,7 @@ import { type VariantDefaults } from '../shared' import { writeAppComparisonResultsToFolders } from './appResultsWriter' +import type { AIProvider } from '$lib/gen/types.gen' // Re-export for convenience export type { InitialApp } from './appEvalComparison' @@ -38,6 +39,8 @@ export interface AppEvalOptions { variant?: VariantConfig /** Whether to evaluate the generated app with LLM. Default: true. Set to false to skip evaluation. */ evaluateWithLLM?: boolean + /** AI provider (inferred from model name if omitted) */ + provider?: AIProvider } /** @@ -49,12 +52,11 @@ const appDefaults: VariantDefaults = { } /** - * Runs an app chat evaluation with real OpenAI API calls. - * Executes tool calls using the actual app tools from core.ts or variant-configured tools. + * Runs an app chat evaluation using the shared chat loop (same code path as production). */ export async function runAppEval( userPrompt: string, - openaiApiKey: string, + apiKey: string, options?: AppEvalOptions ): Promise { const { helpers, getFiles } = createAppEvalHelpers( @@ -69,7 +71,7 @@ export async function runAppEval( appDefaults, options?.customSystemPrompt ) - const { toolDefs, tools } = resolveTools(options?.variant, appDefaults) + const { tools } = resolveTools(options?.variant, appDefaults) const model = resolveModel(options?.variant, options?.model) // Build user message @@ -80,15 +82,15 @@ export async function runAppEval( userPrompt, systemMessage, userMessage, - toolDefs, tools, helpers, - apiKey: openaiApiKey, + apiKey, getOutput: getFiles, options: { maxIterations: options?.maxIterations, model, - workspace: 'test-workspace' + workspace: 'test-workspace', + provider: options?.provider } }) @@ -114,21 +116,32 @@ export async function runAppEval( } } +/** + * Per-variant provider override. + */ +export interface VariantProviderOverride { + provider: AIProvider + apiKey: string +} + /** * Runs the same prompt against multiple variants sequentially for comparison. - * Returns results in the same order as the input variants. + * Accepts optional per-variant provider/apiKey overrides. */ export async function runVariantComparison( userPrompt: string, variants: VariantConfig[], - openaiApiKey: string, - baseOptions?: Omit + defaultApiKey: string, + baseOptions?: Omit, + providerOverrides?: VariantProviderOverride[] ): Promise { const results: AppEvalResult[] = await Promise.all( - variants.map(async (variant) => { - return await runAppEval(userPrompt, openaiApiKey, { + variants.map(async (variant, i) => { + const override = providerOverrides?.[i] + return await runAppEval(userPrompt, override?.apiKey ?? defaultApiKey, { ...baseOptions, - variant + variant, + provider: override?.provider ?? baseOptions?.provider }) }) ) diff --git a/frontend/src/lib/components/copilot/chat/__tests__/flow/flowChat.eval.test.ts b/frontend/src/lib/components/copilot/chat/__tests__/flow/flowChat.eval.test.ts index 8210ea50fb..de9b8e5f43 100644 --- a/frontend/src/lib/components/copilot/chat/__tests__/flow/flowChat.eval.test.ts +++ b/frontend/src/lib/components/copilot/chat/__tests__/flow/flowChat.eval.test.ts @@ -22,35 +22,60 @@ import initialTest6 from './initial/test6_initial.json' // @ts-ignore - JSON import import initialTest7 from './initial/test7_initial.json' import type { FlowModule } from '$lib/gen' +import type { AIProvider } from '$lib/gen/types.gen' -// Get API key from environment - tests will be skipped if not set +// Get API keys from environment - tests will be skipped if none are set // @ts-ignore -// const OPENAI_API_KEY = process.env.OPENAI_API_KEY -const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY +const OPENAI_API_KEY = process.env.OPENAI_API_KEY +// @ts-ignore +const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY -// Skip all tests if no API key is provided -// const describeWithApiKey = OPENAI_API_KEY ? describe : describe.skip -const describeWithApiKey = OPENROUTER_API_KEY ? describe : describe.skip +const hasAnyKey = OPENAI_API_KEY || ANTHROPIC_API_KEY +const describeWithApiKey = hasAnyKey ? describe : describe.skip -const MODELS = ['google/gemini-2.5-flash', 'anthropic/claude-haiku-4.5', 'openai/gpt-4o'] +// Build model variants based on available keys +interface ModelVariant { + model: string + provider: AIProvider + apiKey: string +} + +const MODEL_VARIANTS: ModelVariant[] = [ + ...(OPENAI_API_KEY + ? [{ model: 'gpt-4o', provider: 'openai' as AIProvider, apiKey: OPENAI_API_KEY }] + : []), + ...(ANTHROPIC_API_KEY + ? [ + { + model: 'claude-haiku-4-5-20241022', + provider: 'anthropic' as AIProvider, + apiKey: ANTHROPIC_API_KEY + } + ] + : []) +] const VARIANTS = [ - ...MODELS.map((model) => ({ + ...MODEL_VARIANTS.map((mv) => ({ ...BASELINE_VARIANT, - model, - name: `baseline-${model.replace('/', '-')}` + model: mv.model, + name: `baseline-${mv.provider}-${mv.model}`, + _provider: mv.provider, + _apiKey: mv.apiKey })), - ...MODELS.map((model) => ({ + ...MODEL_VARIANTS.map((mv) => ({ ...MINIMAL_SINGLE_TOOL_VARIANT, - model, - name: `minimal-single-tool-${model.replace('/', '-')}` + model: mv.model, + name: `minimal-single-tool-${mv.provider}-${mv.model}`, + _provider: mv.provider, + _apiKey: mv.apiKey })) ] describeWithApiKey('Flow Chat LLM Evaluation', () => { const TEST_TIMEOUT = 120_000 - if (!OPENROUTER_API_KEY) { - console.warn('OPENROUTER_API_KEY is not set, skipping tests') + if (!hasAnyKey) { + console.warn('No API keys set (OPENAI_API_KEY or ANTHROPIC_API_KEY), skipping tests') } it( @@ -65,9 +90,15 @@ STEP 3: Loop on all users STEP 4: Do branches based on user's role, do different action based on that. Roles are admin, user, moderator STEP 5: Return action taken for each user ` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { - expectedFlow: expectedTest1 as ExpectedFlow - }) + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + { + expectedFlow: expectedTest1 as ExpectedFlow + }, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) // Write results to files const { summaryPath, flowPaths } = await writeFlowComparisonResults(USER_PROMPT, results) @@ -112,9 +143,15 @@ STEP 5: Branch based on inventory - if all items available, create shipment reco STEP 6: Send confirmation (mock email to customer_email) STEP 7: Return final order summary with status ` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { - expectedFlow: expectedTest2 as ExpectedFlow - }) + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + { + expectedFlow: expectedTest2 as ExpectedFlow + }, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, flowPaths } = await writeFlowComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) @@ -161,9 +198,15 @@ STEP 5: Branch based on quality score: - If score < 70: Store in quarantine and send alert STEP 6: Return processing report with statistics (total records, quality score, destination) ` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { - expectedFlow: expectedTest3 as ExpectedFlow - }) + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + { + expectedFlow: expectedTest3 as ExpectedFlow + }, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, flowPaths } = await writeFlowComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) @@ -210,9 +253,15 @@ STEP 3: Use an AI agent to handle the customer query. The agent should have acce STEP 4: Log the interaction to audit trail (customer_id, query, response summary) STEP 5: Return the agent's response and any actions taken ` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { - expectedFlow: expectedTest4 as ExpectedFlow - }) + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + { + expectedFlow: expectedTest4 as ExpectedFlow + }, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, flowPaths } = await writeFlowComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) @@ -256,11 +305,17 @@ Modify this existing flow to add error handling: - If validation passes, return the data for the next step - Update save_results to handle the validation result appropriately ` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { - initialModules: initialTest5.value.modules as FlowModule[], - initialSchema: initialTest5.schema, - expectedFlow: expectedTest5 as ExpectedFlow - }) + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + { + initialModules: initialTest5.value.modules as FlowModule[], + initialSchema: initialTest5.schema, + expectedFlow: expectedTest5 as ExpectedFlow + }, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, flowPaths } = await writeFlowComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) @@ -302,11 +357,17 @@ Modify the order processing loop to handle different order types: - Move the original process_order step to the default branch for unknown order types - Each branch step should return the orderId, shipping cost, and shipping type ` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { - initialModules: initialTest6.value.modules as FlowModule[], - initialSchema: initialTest6.schema, - expectedFlow: expectedTest6 as ExpectedFlow - }) + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + { + initialModules: initialTest6.value.modules as FlowModule[], + initialSchema: initialTest6.schema, + expectedFlow: expectedTest6 as ExpectedFlow + }, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, flowPaths } = await writeFlowComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) @@ -348,11 +409,17 @@ Refactor this flow for better performance by parallelizing the enrichment steps: - The combine_data step should check if any enrichment used a fallback value and set a hasFallbacks flag - Keep get_item as the first step and return_result as the last step unchanged ` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { - initialModules: initialTest7.value.modules as FlowModule[], - initialSchema: initialTest7.schema, - expectedFlow: expectedTest7 as ExpectedFlow - }) + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + { + initialModules: initialTest7.value.modules as FlowModule[], + initialSchema: initialTest7.schema, + expectedFlow: expectedTest7 as ExpectedFlow + }, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, flowPaths } = await writeFlowComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) diff --git a/frontend/src/lib/components/copilot/chat/__tests__/flow/flowEvalComparison.ts b/frontend/src/lib/components/copilot/chat/__tests__/flow/flowEvalComparison.ts index f55979bb40..4c2b41d577 100644 --- a/frontend/src/lib/components/copilot/chat/__tests__/flow/flowEvalComparison.ts +++ b/frontend/src/lib/components/copilot/chat/__tests__/flow/flowEvalComparison.ts @@ -59,14 +59,10 @@ export async function evaluateFlowComparison( expectedFlow: ExpectedFlow, userPrompt: string ): Promise { - // @ts-ignore - const apiKey = process.env.OPENROUTER_API_KEY - return evaluateWithLLM({ userPrompt, generatedOutput: generatedFlow, expectedOutput: expectedFlow, - evaluatorSystemPrompt: FLOW_EVALUATOR_SYSTEM_PROMPT, - apiKey + evaluatorSystemPrompt: FLOW_EVALUATOR_SYSTEM_PROMPT }) } diff --git a/frontend/src/lib/components/copilot/chat/__tests__/flow/flowEvalRunner.ts b/frontend/src/lib/components/copilot/chat/__tests__/flow/flowEvalRunner.ts index 3f27143c69..f3c976950d 100644 --- a/frontend/src/lib/components/copilot/chat/__tests__/flow/flowEvalRunner.ts +++ b/frontend/src/lib/components/copilot/chat/__tests__/flow/flowEvalRunner.ts @@ -1,4 +1,5 @@ import type { FlowModule } from '$lib/gen' +import type { AIProvider } from '$lib/gen/types.gen' import type { ExtendedOpenFlow } from '$lib/components/flows/types' import { flowTools, prepareFlowSystemMessage, prepareFlowUserMessage, type FlowAIChatHelpers } from '../../flow/core' import { createFlowEvalHelpers } from './flowEvalHelpers' @@ -38,6 +39,8 @@ export interface FlowEvalOptions { maxIterations?: number variant?: VariantConfig expectedFlow?: ExpectedFlow + /** AI provider (inferred from model name if omitted) */ + provider?: AIProvider } /** @@ -49,12 +52,11 @@ const flowDefaults: VariantDefaults = { } /** - * Runs a flow chat evaluation with real OpenAI API calls. - * Executes tool calls using the actual flowTools from core.ts or variant-configured tools. + * Runs a flow chat evaluation using the shared chat loop (same code path as production). */ export async function runFlowEval( userPrompt: string, - openaiApiKey: string, + apiKey: string, options?: FlowEvalOptions ): Promise { const { helpers, getFlow } = createFlowEvalHelpers( @@ -65,7 +67,7 @@ export async function runFlowEval( // Resolve variant configuration const variantName = options?.variant?.name ?? 'baseline' const systemMessage = resolveSystemPrompt(options?.variant, flowDefaults, options?.customSystemPrompt) - const { toolDefs, tools } = resolveTools(options?.variant, flowDefaults) + const { tools } = resolveTools(options?.variant, flowDefaults) const model = resolveModel(options?.variant, options?.model) // Build user message @@ -76,15 +78,15 @@ export async function runFlowEval( userPrompt, systemMessage, userMessage, - toolDefs, tools, helpers, - apiKey: openaiApiKey, + apiKey, getOutput: getFlow, options: { maxIterations: options?.maxIterations, model, - workspace: 'test-workspace' + workspace: 'test-workspace', + provider: options?.provider } }) @@ -111,21 +113,32 @@ export async function runFlowEval( } } +/** + * Per-variant provider override. + */ +export interface VariantProviderOverride { + provider: AIProvider + apiKey: string +} + /** * Runs the same prompt against multiple variants sequentially for comparison. - * Returns results in the same order as the input variants. + * Accepts optional per-variant provider/apiKey overrides. */ export async function runVariantComparison( userPrompt: string, variants: VariantConfig[], - openaiApiKey: string, - baseOptions?: Omit + defaultApiKey: string, + baseOptions?: Omit, + providerOverrides?: VariantProviderOverride[] ): Promise { const results: FlowEvalResult[] = await Promise.all( - variants.map(async (variant) => { - return await runFlowEval(userPrompt, openaiApiKey, { + variants.map(async (variant, i) => { + const override = providerOverrides?.[i] + return await runFlowEval(userPrompt, override?.apiKey ?? defaultApiKey, { ...baseOptions, - variant + variant, + provider: override?.provider ?? baseOptions?.provider }) }) ) diff --git a/frontend/src/lib/components/copilot/chat/__tests__/shared/baseEvalRunner.ts b/frontend/src/lib/components/copilot/chat/__tests__/shared/baseEvalRunner.ts index b9b7820568..f46acb9108 100644 --- a/frontend/src/lib/components/copilot/chat/__tests__/shared/baseEvalRunner.ts +++ b/frontend/src/lib/components/copilot/chat/__tests__/shared/baseEvalRunner.ts @@ -1,8 +1,14 @@ -import OpenAI, { APIError } from 'openai' -import type { ChatCompletionMessageParam, ChatCompletionSystemMessageParam } from 'openai/resources/chat/completions.mjs' -import type { ChatCompletionTool } from 'openai/resources/chat/completions.mjs' +import OpenAI from 'openai' +import Anthropic from '@anthropic-ai/sdk' +import type { + ChatCompletionMessageParam, + ChatCompletionSystemMessageParam +} from 'openai/resources/chat/completions.mjs' +import type { AIProvider, AIProviderModel } from '$lib/gen/types.gen' import type { TokenUsage, ToolCallDetail, EvalRunnerOptions } from './types' import type { Tool } from './baseVariants' +import { runChatLoop, type ChatClients } from '../../chatLoop' +import type { Tool as ProductionTool, ToolCallbacks } from '../../shared' /** * Result from a single eval run (before domain-specific evaluation). @@ -29,13 +35,13 @@ export interface RunEvalParams { systemMessage: ChatCompletionSystemMessageParam /** User message for the LLM */ userMessage: ChatCompletionMessageParam - /** Tool definitions for the LLM API */ - toolDefs: ChatCompletionTool[] + /** Tool definitions for the LLM API (unused — derived from tools) */ + toolDefs?: unknown /** Full tool implementations for execution */ tools: Tool[] /** Domain-specific helpers for tool execution */ helpers: THelpers - /** API key for OpenRouter */ + /** API key for the provider */ apiKey: string /** Function to get the current output state */ getOutput: () => TOutput @@ -44,10 +50,37 @@ export interface RunEvalParams { } /** - * Runs a generic evaluation with real LLM API calls. - * Executes tool calls in a loop until the LLM stops calling tools. - * - * This is the core execution loop shared across all chat eval tests. + * Creates SDK clients for the given provider. + */ +function createEvalClients(provider: AIProvider, apiKey: string): ChatClients { + if (provider === 'anthropic') { + return { + openai: new OpenAI({ apiKey: 'unused' }), + anthropic: new Anthropic({ apiKey }) + } + } + return { + openai: new OpenAI({ apiKey }), + anthropic: new Anthropic({ apiKey: 'unused' }) + } +} + +/** + * Resolves model string to AIProviderModel. + */ +function resolveModelProvider( + model: string, + provider?: AIProvider +): AIProviderModel { + if (provider) return { provider, model } + if (model.startsWith('claude')) return { provider: 'anthropic', model } + if (model.startsWith('gpt') || model.startsWith('o')) return { provider: 'openai', model } + return { provider: 'openai', model } +} + +/** + * Runs a generic evaluation using the shared chat loop (same code path as production). + * Uses streaming via real provider SDKs instead of OpenRouter non-streaming. */ export async function runEval( params: RunEvalParams @@ -55,7 +88,6 @@ export async function runEval( const { systemMessage, userMessage, - toolDefs, tools, helpers, apiKey, @@ -63,134 +95,82 @@ export async function runEval( options } = params - const client = new OpenAI({ baseURL: 'https://openrouter.ai/api/v1', apiKey }) const model = options?.model ?? 'gpt-4o' const maxIterations = options?.maxIterations ?? 20 const workspace = options?.workspace ?? 'test-workspace' + const provider = options?.provider - const messages: ChatCompletionMessageParam[] = [systemMessage, userMessage] - const totalTokens: TokenUsage = { prompt: 0, completion: 0, total: 0 } + const modelProvider = resolveModelProvider(model, provider) + const clients = createEvalClients(modelProvider.provider, apiKey) + + const messages: ChatCompletionMessageParam[] = [userMessage] let toolCallsCount = 0 const toolsCalled: string[] = [] const toolCallDetails: ToolCallDetail[] = [] - let iterations = 0 - // No-op tool callbacks for eval - const toolCallbacks = { + // Wrap tools to intercept fn calls for tracking. + // Cast to ProductionTool since the eval Tool has a narrower toolCallbacks type + // but the actual callbacks passed at runtime will satisfy both interfaces. + const wrappedTools = tools.map((tool) => ({ + ...tool, + fn: async (p: any) => { + toolCallsCount++ + toolsCalled.push(tool.def.function.name) + try { + const args = + typeof p.args === 'string' ? JSON.parse(p.args) : p.args + toolCallDetails.push({ name: tool.def.function.name, arguments: args }) + } catch { + toolCallDetails.push({ + name: tool.def.function.name, + arguments: p.args + }) + } + return tool.fn(p) + } + })) as ProductionTool[] + + // No-op callbacks for eval + const callbacks: ToolCallbacks & { + onNewToken: (token: string) => void + onMessageEnd: () => void + } = { setToolStatus: () => {}, - removeToolStatus: () => {} + removeToolStatus: () => {}, + onNewToken: () => {}, + onMessageEnd: () => {} } + const abortController = new AbortController() + try { - // Tool resolution loop - while (iterations < maxIterations) { - iterations++ - - const response = await client.chat.completions.create({ - model, - messages, - tools: toolDefs, - temperature: 0 - }) - - // Track token usage - if (response.usage) { - totalTokens.prompt += response.usage.prompt_tokens - totalTokens.completion += response.usage.completion_tokens - totalTokens.total += response.usage.total_tokens - } - - if (!response.choices.length) { - throw new Error('No response from API') - } - - const choice = response.choices[0] - const assistantMessage = choice.message - - // Add assistant message to history - messages.push(assistantMessage) - - // If no tool calls, we're done - if (!assistantMessage.tool_calls?.length) { - break - } - - // Execute each tool call - for (const toolCall of assistantMessage.tool_calls) { - toolCallsCount++ - - // Type guard: only handle function tool calls - if (toolCall.type !== 'function') { - messages.push({ - role: 'tool', - tool_call_id: toolCall.id, - content: `Unsupported tool type: ${toolCall.type}` - }) - continue - } - - toolsCalled.push(toolCall.function.name) - - const tool = tools.find((t) => t.def.function.name === toolCall.function.name) - if (!tool) { - messages.push({ - role: 'tool', - tool_call_id: toolCall.id, - content: `Unknown tool: ${toolCall.function.name}` - }) - continue - } - - try { - const args = JSON.parse(toolCall.function.arguments) - toolCallDetails.push({ name: toolCall.function.name, arguments: args }) - const result = await tool.fn({ - args, - workspace, - helpers, - toolCallbacks, - toolId: toolCall.id - }) - messages.push({ - role: 'tool', - tool_call_id: toolCall.id, - content: result - }) - } catch (err) { - const errorMessage = err instanceof Error ? err.message : String(err) - messages.push({ - role: 'tool', - tool_call_id: toolCall.id, - content: `Error: ${errorMessage}` - }) - } - } - } + const result = await runChatLoop({ + messages, + systemMessage, + tools: wrappedTools, + helpers, + abortController, + callbacks, + modelProvider, + clients, + workspace, + maxIterations, + skipResponsesApi: modelProvider.provider !== 'openai' && modelProvider.provider !== 'azure_openai' + }) return { success: true, output: getOutput(), - tokenUsage: totalTokens, + tokenUsage: { prompt: 0, completion: 0, total: 0 }, toolCallsCount, toolsCalled, toolCallDetails, - iterations, + iterations: Math.max(1, result.addedMessages.filter((m) => m.role === 'assistant').length), messages } } catch (err) { - // Build detailed error message let errorMessage: string - if (err instanceof APIError) { - const details: string[] = [`${err.status} ${err.message}`] - if (err.code) details.push(`Code: ${err.code}`) - if (err.type) details.push(`Type: ${err.type}`) - if (err.param) details.push(`Param: ${err.param}`) - if (err.requestID) details.push(`Request ID: ${err.requestID}`) - if (err.error && typeof err.error === 'object') { - details.push(`Response: ${JSON.stringify(err.error, null, 2)}`) - } - errorMessage = details.join('\n') - } else if (err instanceof Error) { + if (err instanceof Error) { errorMessage = err.stack ?? err.message } else { errorMessage = String(err) @@ -200,11 +180,11 @@ export async function runEval( success: false, output: getOutput(), error: errorMessage, - tokenUsage: totalTokens, + tokenUsage: { prompt: 0, completion: 0, total: 0 }, toolCallsCount, toolsCalled, toolCallDetails, - iterations, + iterations: 0, messages } } diff --git a/frontend/src/lib/components/copilot/chat/__tests__/shared/baseLLMEvaluator.ts b/frontend/src/lib/components/copilot/chat/__tests__/shared/baseLLMEvaluator.ts index 63c17828f4..bd7bd06d44 100644 --- a/frontend/src/lib/components/copilot/chat/__tests__/shared/baseLLMEvaluator.ts +++ b/frontend/src/lib/components/copilot/chat/__tests__/shared/baseLLMEvaluator.ts @@ -1,4 +1,4 @@ -import OpenAI from 'openai' +import Anthropic from '@anthropic-ai/sdk' import type { EvaluationResult } from './types' /** @@ -13,9 +13,9 @@ export interface EvaluateParams { expectedOutput: unknown /** Domain-specific system prompt for the evaluator */ evaluatorSystemPrompt: string - /** API key for OpenRouter */ - apiKey: string - /** Model to use for evaluation (default: 'anthropic/claude-sonnet-4.5') */ + /** Anthropic API key for evaluation */ + apiKey?: string + /** Model to use for evaluation (default: 'claude-sonnet-4-5-20250514') */ model?: string } @@ -41,10 +41,7 @@ Score guidelines: /** * Evaluates how well a generated output matches an expected output using an LLM. - * Returns a resemblance score (0-100), a qualitative statement, and any missing requirements. - * - * @param params Evaluation parameters including prompts, outputs, and API configuration - * @returns Evaluation result with score, statement, and missing requirements + * Uses Anthropic API directly instead of OpenRouter. */ export async function evaluateWithLLM(params: EvaluateParams): Promise { const { @@ -53,10 +50,21 @@ export async function evaluateWithLLM(params: EvaluateParams): Promise block.type === 'text') + const content = textBlock?.text if (!content) { return { success: false, @@ -98,7 +108,6 @@ Please evaluate how well the generated output: // Parse JSON response - handle potential markdown code blocks let jsonContent = content.trim() if (jsonContent.startsWith('```')) { - // Remove markdown code block wrapper jsonContent = jsonContent.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '') } diff --git a/frontend/src/lib/components/copilot/chat/__tests__/shared/types.ts b/frontend/src/lib/components/copilot/chat/__tests__/shared/types.ts index 021e776440..61f7f1fd1f 100644 --- a/frontend/src/lib/components/copilot/chat/__tests__/shared/types.ts +++ b/frontend/src/lib/components/copilot/chat/__tests__/shared/types.ts @@ -1,4 +1,5 @@ import type { ChatCompletionMessageParam } from 'openai/resources/chat/completions.mjs' +import type { AIProvider } from '$lib/gen/types.gen' /** * Token usage tracking for LLM calls. @@ -83,6 +84,8 @@ export interface EvalRunnerOptions { model?: string /** Workspace ID for tool calls */ workspace?: string + /** AI provider (inferred from model name if omitted) */ + provider?: AIProvider } /** diff --git a/frontend/src/lib/components/copilot/chat/anthropic.ts b/frontend/src/lib/components/copilot/chat/anthropic.ts index 03d0f363a0..ac45c175a6 100644 --- a/frontend/src/lib/components/copilot/chat/anthropic.ts +++ b/frontend/src/lib/components/copilot/chat/anthropic.ts @@ -1,4 +1,5 @@ import { OpenAI } from 'openai' +import Anthropic from '@anthropic-ai/sdk' import type { ChatCompletionMessageParam, ChatCompletionMessageFunctionToolCall @@ -13,19 +14,28 @@ import type { RawMessageStreamEvent } from '@anthropic-ai/sdk/resources' import type { MessageStream } from '@anthropic-ai/sdk/lib/MessageStream' +import type { AIProviderModel } from '$lib/gen' import { getProviderAndCompletionConfig, workspaceAIClients } from '../lib' import { processToolCall, type Tool, type ToolCallbacks } from './shared' export async function getAnthropicCompletion( messages: ChatCompletionMessageParam[], abortController: AbortController, - tools?: OpenAI.Chat.Completions.ChatCompletionFunctionTool[] + tools?: OpenAI.Chat.Completions.ChatCompletionFunctionTool[], + options?: { + forceModelProvider?: AIProviderModel + anthropicClient?: Anthropic + } ): Promise { - const { provider, config } = getProviderAndCompletionConfig({ messages, stream: true }) + const { provider, config } = getProviderAndCompletionConfig({ + messages, + stream: true, + forceModelProvider: options?.forceModelProvider + }) const { system, messages: anthropicMessages } = convertOpenAIToAnthropicMessages(messages) const anthropicTools = convertOpenAIToolsToAnthropic(tools) - const anthropicClient = workspaceAIClients.getAnthropicClient() + const client = options?.anthropicClient ?? workspaceAIClients.getAnthropicClient() const anthropicParams = { model: config.model, @@ -36,7 +46,7 @@ export async function getAnthropicCompletion( ...(typeof config.temperature === 'number' && { temperature: config.temperature }) } - const stream = anthropicClient.messages.stream(anthropicParams, { + const stream = client.messages.stream(anthropicParams, { signal: abortController.signal, headers: { 'X-Provider': provider, @@ -58,7 +68,8 @@ export async function parseAnthropicCompletion( addedMessages: ChatCompletionMessageParam[], tools: Tool[], helpers: any, - abortController?: AbortController + abortController?: AbortController, + options?: { workspace?: string } ): Promise { let toolCallsToProcess: ChatCompletionMessageFunctionToolCall[] = [] let error = null @@ -209,7 +220,8 @@ export async function parseAnthropicCompletion( tools, toolCall, helpers, - toolCallbacks: callbacks + toolCallbacks: callbacks, + workspace: options?.workspace }) messages.push(messageToAdd) addedMessages.push(messageToAdd) diff --git a/frontend/src/lib/components/copilot/chat/chatLoop.ts b/frontend/src/lib/components/copilot/chat/chatLoop.ts new file mode 100644 index 0000000000..4b239e4a05 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/chatLoop.ts @@ -0,0 +1,211 @@ +import OpenAI from 'openai' +import Anthropic from '@anthropic-ai/sdk' +import type { + ChatCompletionMessageParam, + ChatCompletionSystemMessageParam, + ChatCompletionUserMessageParam +} from 'openai/resources/chat/completions.mjs' +import type { AIProviderModel } from '$lib/gen' +import { getCompletion, parseOpenAICompletion } from '../lib' +import { getAnthropicCompletion, parseAnthropicCompletion } from './anthropic' +import { + getOpenAIResponsesCompletion, + parseOpenAIResponsesCompletion +} from './openai-responses' +import type { Tool, ToolCallbacks } from './shared' + +export interface ChatClients { + openai: OpenAI + anthropic: Anthropic +} + +export interface ChatLoopConfig { + messages: ChatCompletionMessageParam[] + /** + * System message, tools, helpers, and modelProvider are re-read from this config + * on every iteration. Callers can use JS getters to provide dynamic values + * (e.g. AIChatManager uses getters so mode changes mid-loop take effect). + */ + systemMessage: ChatCompletionSystemMessageParam + tools: Tool[] + helpers: any + abortController: AbortController + callbacks: ToolCallbacks & { + onNewToken: (token: string) => void + onMessageEnd: () => void + } + modelProvider: AIProviderModel + clients: ChatClients + workspace: string + /** Maximum iterations for the loop. undefined = unlimited (production). */ + maxIterations?: number + skipResponsesApi?: boolean + onSkipResponsesApi?: () => void + /** Return a pending user message to inject between iterations, or undefined. */ + getPendingUserMessage?: () => ChatCompletionUserMessageParam | undefined + /** Called before each iteration (e.g. to refresh tool schemas). */ + onBeforeIteration?: (tools: Tool[], helpers: any) => Promise +} + +export interface ChatLoopResult { + addedMessages: ChatCompletionMessageParam[] +} + +export async function runChatLoop(config: ChatLoopConfig): Promise { + const { + messages, + abortController, + callbacks, + clients, + workspace, + maxIterations, + onSkipResponsesApi, + getPendingUserMessage, + onBeforeIteration + } = config + let skipResponsesApi = config.skipResponsesApi ?? false + + const addedMessages: ChatCompletionMessageParam[] = [] + let iterations = 0 + + while (true) { + if (maxIterations !== undefined && iterations >= maxIterations) { + break + } + iterations++ + + // Re-read these from config each iteration so that mode changes + // (e.g. changeModeTool in Navigator) take effect immediately. + // Callers can use JS getter properties to provide dynamic values. + const tools = config.tools + const helpers = config.helpers + const systemMessage = config.systemMessage + const modelProvider = config.modelProvider + + if (onBeforeIteration) { + await onBeforeIteration(tools, helpers) + } + + const pendingUserMessage = getPendingUserMessage?.() + + const isOpenAI = + modelProvider.provider === 'openai' || modelProvider.provider === 'azure_openai' + const isAnthropic = modelProvider.provider === 'anthropic' + + const messageParams = [ + systemMessage, + ...messages, + ...(pendingUserMessage ? [pendingUserMessage] : []) + ] + const toolDefs = tools.map((t) => t.def) + const parseOptions = { workspace } + + if (isOpenAI) { + let useCompletionsApi = skipResponsesApi + if (!skipResponsesApi) { + try { + const completion = await getOpenAIResponsesCompletion( + messageParams, + abortController, + toolDefs, + { + forceModelProvider: modelProvider, + openaiClient: clients.openai + } + ) + const continueCompletion = await parseOpenAIResponsesCompletion( + completion, + callbacks, + messages, + addedMessages, + tools, + helpers, + parseOptions + ) + if (!continueCompletion) { + break + } + } catch (err) { + console.warn( + 'OpenAI Responses API failed, falling back to Completions API:', + err + ) + const errorMessage = err instanceof Error ? err.message : String(err) + if (errorMessage.includes('Responses API is not enabled')) { + skipResponsesApi = true + onSkipResponsesApi?.() + } + useCompletionsApi = true + } + } + + if (useCompletionsApi) { + const completion = await getCompletion(messageParams, abortController, toolDefs, { + forceCompletions: true, + forceModelProvider: modelProvider, + openaiClient: clients.openai + }) + const continueCompletion = await parseOpenAICompletion( + completion, + callbacks, + messages, + addedMessages, + tools, + helpers, + undefined, + parseOptions + ) + if (!continueCompletion) { + break + } + } + } else if (isAnthropic) { + const completion = await getAnthropicCompletion( + messageParams, + abortController, + toolDefs, + { + forceModelProvider: modelProvider, + anthropicClient: clients.anthropic + } + ) + if (completion) { + const continueCompletion = await parseAnthropicCompletion( + completion, + callbacks, + messages, + addedMessages, + tools, + helpers, + abortController, + parseOptions + ) + if (!continueCompletion) { + break + } + } + } else { + const completion = await getCompletion(messageParams, abortController, toolDefs, { + forceModelProvider: modelProvider, + openaiClient: clients.openai + }) + if (completion) { + const continueCompletion = await parseOpenAICompletion( + completion, + callbacks, + messages, + addedMessages, + tools, + helpers, + undefined, + parseOptions + ) + if (!continueCompletion) { + break + } + } + } + } + + return { addedMessages } +} diff --git a/frontend/src/lib/components/copilot/chat/openai-responses.ts b/frontend/src/lib/components/copilot/chat/openai-responses.ts index 5003f48099..56364e1401 100644 --- a/frontend/src/lib/components/copilot/chat/openai-responses.ts +++ b/frontend/src/lib/components/copilot/chat/openai-responses.ts @@ -125,15 +125,24 @@ function convertCompletionConfigToResponsesConfig( export async function getOpenAIResponsesCompletion( messages: ChatCompletionMessageParam[], abortController: AbortController, - tools?: OpenAI.Chat.Completions.ChatCompletionTool[] + tools?: OpenAI.Chat.Completions.ChatCompletionTool[], + options?: { + forceModelProvider?: AIProviderModel + openaiClient?: OpenAI + } ) { - const { provider, config } = getProviderAndCompletionConfig({ messages, stream: true, tools }) + const { provider, config } = getProviderAndCompletionConfig({ + messages, + stream: true, + tools, + forceModelProvider: options?.forceModelProvider + }) const { instructions, input } = convertMessagesToResponsesInput(messages) const responsesConfig = convertCompletionConfigToResponsesConfig(config) - const openaiClient = workspaceAIClients.getOpenaiClient() + const client = options?.openaiClient ?? workspaceAIClients.getOpenaiClient() - const runner = openaiClient.responses.stream( + const runner = client.responses.stream( { ...responsesConfig, input, @@ -208,7 +217,8 @@ export async function parseOpenAIResponsesCompletion( messages: ChatCompletionMessageParam[], addedMessages: ChatCompletionMessageParam[], tools: Tool[], - helpers: any + helpers: any, + options?: { workspace?: string } ): Promise { let toolCallsToProcess: ChatCompletionMessageFunctionToolCall[] = [] let error: OpenAIError | ResponseErrorEvent | null = null @@ -342,7 +352,8 @@ export async function parseOpenAIResponsesCompletion( tools, toolCall, helpers, - toolCallbacks: callbacks + toolCallbacks: callbacks, + workspace: options?.workspace }) messages.push(messageToAdd) addedMessages.push(messageToAdd) diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts index 4a95912b47..20e488d923 100644 --- a/frontend/src/lib/components/copilot/chat/shared.ts +++ b/frontend/src/lib/components/copilot/chat/shared.ts @@ -417,12 +417,14 @@ export async function processToolCall({ tools, toolCall, helpers, - toolCallbacks + toolCallbacks, + workspace }: { tools: Tool[] toolCall: ChatCompletionMessageFunctionToolCall helpers: T toolCallbacks: ToolCallbacks + workspace?: string }): Promise { try { const args = JSON.parse(toolCall.function.arguments || '{}') @@ -472,7 +474,7 @@ export async function processToolCall({ tools, functionName: toolCall.function.name, args, - workspace: get(workspaceStore) ?? '', + workspace: workspace ?? get(workspaceStore) ?? '', helpers, toolCallbacks, toolId: toolCall.id diff --git a/frontend/src/lib/components/copilot/lib.ts b/frontend/src/lib/components/copilot/lib.ts index dc05e3a247..d8149086d4 100644 --- a/frontend/src/lib/components/copilot/lib.ts +++ b/frontend/src/lib/components/copilot/lib.ts @@ -296,7 +296,12 @@ function getModelSpecificConfig( ) { const defaultMaxTokens = getModelMaxTokens(modelProvider.provider, modelProvider.model) const modelKey = `${modelProvider.provider}:${modelProvider.model}` - const customMaxTokensStore = get(copilotInfo)?.maxTokensPerModel + let customMaxTokensStore: Record | undefined + try { + customMaxTokensStore = get(copilotInfo)?.maxTokensPerModel + } catch { + // copilotInfo store may not be initialized in vitest + } const maxTokens = customMaxTokensStore?.[modelKey] ?? defaultMaxTokens if ( (modelProvider.provider === 'openai' || modelProvider.provider === 'azure_openai') && @@ -876,9 +881,16 @@ export async function getCompletion( tools?: OpenAI.Chat.Completions.ChatCompletionTool[], options?: { forceCompletions?: boolean + forceModelProvider?: AIProviderModel + openaiClient?: OpenAI } ): Promise> { - const { provider, config } = getProviderAndCompletionConfig({ messages, stream: true, tools }) + const { provider, config } = getProviderAndCompletionConfig({ + messages, + stream: true, + tools, + forceModelProvider: options?.forceModelProvider + }) // Use Responses API for OpenAI and Azure OpenAI if ((provider === 'openai' || provider === 'azure_openai') && !options?.forceCompletions) { @@ -891,8 +903,8 @@ export async function getCompletion( } // Use Completions API for other providers - const openaiClient = workspaceAIClients.getOpenaiClient() - const completion = openaiClient.chat.completions.create(config, { + const client = options?.openaiClient ?? workspaceAIClients.getOpenaiClient() + const completion = client.chat.completions.create(config, { signal: abortController.signal, headers: { 'X-Provider': provider @@ -921,7 +933,8 @@ export async function parseOpenAICompletion( addedMessages: ChatCompletionMessageParam[], tools: Tool[], helpers: any, - _abortController?: AbortController // unused, for signature compatibility with parseAnthropicCompletion + _abortController?: AbortController, // unused, for signature compatibility with parseAnthropicCompletion + options?: { workspace?: string } ): Promise { const finalToolCalls: Record = {} let malformedFunctionCallError = false @@ -1060,7 +1073,8 @@ export async function parseOpenAICompletion( tools, toolCall, helpers, - toolCallbacks: callbacks + toolCallbacks: callbacks, + workspace: options?.workspace }) messages.push(messageToAdd) addedMessages.push(messageToAdd) From 34e3115bcbd19a8e0b6f483435586a2ab43d0a8e Mon Sep 17 00:00:00 2001 From: wendrul <53628737+wendrul@users.noreply.github.com> Date: Wed, 25 Mar 2026 11:59:48 +0100 Subject: [PATCH 12/13] fix: raw apps bundle not found during deployment error (#8515) --- backend/Cargo.lock | 1 + backend/windmill-api-workspaces/Cargo.toml | 2 + .../windmill-api-workspaces/src/workspaces.rs | 65 +++++++++++++++++++ backend/windmill-api/Cargo.toml | 2 +- backend/windmill-api/src/apps.rs | 26 ++++++-- 5 files changed, 88 insertions(+), 8 deletions(-) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index a5e2e8fc98..34532bc173 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -16433,6 +16433,7 @@ dependencies = [ "windmill-common", "windmill-dep-map", "windmill-git-sync", + "windmill-object-store", "windmill-queue", "windmill-types", ] diff --git a/backend/windmill-api-workspaces/Cargo.toml b/backend/windmill-api-workspaces/Cargo.toml index a03bb3a490..86f0649c73 100644 --- a/backend/windmill-api-workspaces/Cargo.toml +++ b/backend/windmill-api-workspaces/Cargo.toml @@ -14,9 +14,11 @@ enterprise = ["windmill-common/enterprise"] private = ["windmill-common/private"] cloud = ["windmill-common/cloud"] no_auth = ["windmill-api-auth/no_auth"] +parquet = ["windmill-object-store/parquet"] [dependencies] windmill-common = { workspace = true, default-features = false } +windmill-object-store = { workspace = true, optional = true } windmill-types.workspace = true windmill-api-auth.workspace = true windmill-api-users.workspace = true diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index fb234a13a3..4e832b3a93 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -3474,6 +3474,11 @@ async fn clone_apps( .fetch_all(&mut **tx) .await?; + let mut cloned_from_db: std::collections::HashSet<(i64, String)> = HashSet::new(); + for bundle in &bundles { + cloned_from_db.insert((bundle.app_version_id, bundle.file_type.clone())); + } + for bundle in bundles { if let Some(&new_version_id) = version_id_mapping.get(&bundle.app_version_id) { sqlx::query!( @@ -3488,6 +3493,66 @@ async fn clone_apps( .await?; } } + + // Clone bundles from S3 for versions not found in DB + #[cfg(all(feature = "enterprise", feature = "parquet"))] + { + let object_store = windmill_object_store::get_object_store().await; + if let Some(os) = object_store { + for (&old_version_id, &new_version_id) in &version_id_mapping { + for file_type in &["js", "css"] { + if cloned_from_db.contains(&(old_version_id, file_type.to_string())) { + continue; + } + let src_path = format!( + "/app_bundles/{}/{}.{}", + source_workspace_id, old_version_id, file_type + ); + let get_result = os + .get(&windmill_object_store::object_store_reexports::Path::from( + src_path, + )) + .await; + match get_result { + Ok(result) => { + let data = result.bytes().await.map_err( + windmill_object_store::object_store_error_to_error, + )?; + let dst_path = format!( + "/app_bundles/{}/{}.{}", + target_workspace_id, new_version_id, file_type + ); + os.put( + &windmill_object_store::object_store_reexports::Path::from( + dst_path.clone(), + ), + data.into(), + ) + .await + .map_err( + windmill_object_store::object_store_error_to_error, + )?; + tracing::info!( + "Cloned app bundle from S3: {}.{} -> {}.{}", + old_version_id, + file_type, + new_version_id, + file_type + ); + } + Err(windmill_object_store::object_store_reexports::ObjectStoreError::NotFound { .. }) => { + // No bundle in S3 for this version/type, skip + } + Err(e) => { + return Err( + windmill_object_store::object_store_error_to_error(e), + ); + } + } + } + } + } + } } // Update app versions arrays diff --git a/backend/windmill-api/Cargo.toml b/backend/windmill-api/Cargo.toml index 1b09f37861..3f015513e2 100644 --- a/backend/windmill-api/Cargo.toml +++ b/backend/windmill-api/Cargo.toml @@ -18,7 +18,7 @@ agent_worker_server = ["dep:windmill-worker", "dep:windmill-api-agent-workers"] enterprise_saml = ["dep:samael", "dep:libxml"] benchmark = [] embedding = ["windmill-api-embeddings/embedding"] -parquet = ["dep:datafusion", "windmill-common/parquet", "windmill-object-store/parquet", "windmill-worker?/parquet", "windmill-api-users/parquet", "windmill-api-settings/parquet", "dep:aws-sigv4", "dep:aws-sdk-config"] +parquet = ["dep:datafusion", "windmill-common/parquet", "windmill-object-store/parquet", "windmill-worker?/parquet", "windmill-api-users/parquet", "windmill-api-settings/parquet", "windmill-api-workspaces/parquet", "dep:aws-sigv4", "dep:aws-sdk-config"] prometheus = ["windmill-common/prometheus", "windmill-queue/prometheus", "dep:prometheus", "windmill-worker?/prometheus"] openidconnect = ["dep:openidconnect", "windmill-common/openidconnect", "windmill-store/openidconnect"] tantivy = ["dep:windmill-indexer"] diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 33a632ecbd..eaead14558 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -440,17 +440,29 @@ async fn get_raw_app_data( #[cfg(all(feature = "enterprise", feature = "parquet"))] if let Some(os) = object_store { let path = format!("/app_bundles/{}/{}.{}", w_id, id, file_type); - let stream = os + match os .get(&windmill_object_store::object_store_reexports::Path::from( path, )) .await - .map_err(windmill_object_store::object_store_error_to_error)? - .bytes() - .await - .map_err(windmill_object_store::object_store_error_to_error)?; - tracing::info!("stream: {}", stream.len()); - body = Some(Body::from(stream)); + { + Ok(result) => { + let stream = result + .bytes() + .await + .map_err(windmill_object_store::object_store_error_to_error)?; + tracing::info!("stream: {}", stream.len()); + body = Some(Body::from(stream)); + } + Err(windmill_object_store::object_store_reexports::ObjectStoreError::NotFound { + .. + }) => { + // S3 key not found, fall through to DB lookup below + } + Err(e) => { + return Err(windmill_object_store::object_store_error_to_error(e)); + } + } } if body.is_none() { From b7d14c8614f4da0da262bb20c0eb01854975cf65 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 25 Mar 2026 11:20:39 +0000 Subject: [PATCH 13/13] regenerate sqlx offline query cache for integration tests (#8518) Co-authored-by: Claude Opus 4.5 --- ...aacf6af2c284ae446860113c82bc4e1da08ab.json | 12 ++++++++++ ...bc47caebc25215a430d6b301b35e265888159.json | 12 ++++++++++ ...fb7cf5f2b76f013c274245af13d7d727ebf1f.json | 12 ++++++++++ ...e2e60e3183fa81a411622891caea6dc03fa90.json | 15 +++++++++++++ ...153c43903f929ae5d62fbba12610f89c36d55.json | 2 +- ...960ffc33da5f31bf780e8fd6a66d5150b8027.json | 12 ++++++++++ ...69c87a9d29370ec985d2c8c28633cd078ffaf.json | 12 ++++++++++ ...74da8c73120b3e16194904575f79a4e055002.json | 12 ++++++++++ ...437ab3e02d8c3c10c53decc664533b8d04bc0.json | 22 +++++++++++++++++++ 9 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 backend/.sqlx/query-01c040b04b487e86b7f4ff38b0faacf6af2c284ae446860113c82bc4e1da08ab.json create mode 100644 backend/.sqlx/query-1ad384eeb3946ef7c492124c69fbc47caebc25215a430d6b301b35e265888159.json create mode 100644 backend/.sqlx/query-1fa27bd47ab66b3f301ca43ef93fb7cf5f2b76f013c274245af13d7d727ebf1f.json create mode 100644 backend/.sqlx/query-4e88aec662ebc70e0425a48a1b4e2e60e3183fa81a411622891caea6dc03fa90.json create mode 100644 backend/.sqlx/query-5b140cb478aa32ac38fb831d6af960ffc33da5f31bf780e8fd6a66d5150b8027.json create mode 100644 backend/.sqlx/query-6c24b5c08341979e02b7968242369c87a9d29370ec985d2c8c28633cd078ffaf.json create mode 100644 backend/.sqlx/query-be35e85e94641c71620e5402dcf74da8c73120b3e16194904575f79a4e055002.json create mode 100644 backend/.sqlx/query-ccd76be88fa9c11b3dc2e6d7711437ab3e02d8c3c10c53decc664533b8d04bc0.json diff --git a/backend/.sqlx/query-01c040b04b487e86b7f4ff38b0faacf6af2c284ae446860113c82bc4e1da08ab.json b/backend/.sqlx/query-01c040b04b487e86b7f4ff38b0faacf6af2c284ae446860113c82bc4e1da08ab.json new file mode 100644 index 0000000000..0ad1fe4367 --- /dev/null +++ b/backend/.sqlx/query-01c040b04b487e86b7f4ff38b0faacf6af2c284ae446860113c82bc4e1da08ab.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO app_version (id, app_id, value, created_by, created_at)\n VALUES (3001, 3001, '{\"grid\": []}', 'admin', NOW())", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "01c040b04b487e86b7f4ff38b0faacf6af2c284ae446860113c82bc4e1da08ab" +} diff --git a/backend/.sqlx/query-1ad384eeb3946ef7c492124c69fbc47caebc25215a430d6b301b35e265888159.json b/backend/.sqlx/query-1ad384eeb3946ef7c492124c69fbc47caebc25215a430d6b301b35e265888159.json new file mode 100644 index 0000000000..24d3c8929a --- /dev/null +++ b/backend/.sqlx/query-1ad384eeb3946ef7c492124c69fbc47caebc25215a430d6b301b35e265888159.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE app SET versions = ARRAY[3001::bigint] WHERE id = 3001", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "1ad384eeb3946ef7c492124c69fbc47caebc25215a430d6b301b35e265888159" +} diff --git a/backend/.sqlx/query-1fa27bd47ab66b3f301ca43ef93fb7cf5f2b76f013c274245af13d7d727ebf1f.json b/backend/.sqlx/query-1fa27bd47ab66b3f301ca43ef93fb7cf5f2b76f013c274245af13d7d727ebf1f.json new file mode 100644 index 0000000000..10cab9117a --- /dev/null +++ b/backend/.sqlx/query-1fa27bd47ab66b3f301ca43ef93fb7cf5f2b76f013c274245af13d7d727ebf1f.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO flow (workspace_id, path, summary, description, value, edited_by, edited_at, schema, extra_perms)\n VALUES ('test-workspace', 'u/operator/existing_flow', 'Existing flow', '', '{\"modules\": []}', 'admin', NOW(), '{}', '{}')", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "1fa27bd47ab66b3f301ca43ef93fb7cf5f2b76f013c274245af13d7d727ebf1f" +} diff --git a/backend/.sqlx/query-4e88aec662ebc70e0425a48a1b4e2e60e3183fa81a411622891caea6dc03fa90.json b/backend/.sqlx/query-4e88aec662ebc70e0425a48a1b4e2e60e3183fa81a411622891caea6dc03fa90.json new file mode 100644 index 0000000000..27d46b27ed --- /dev/null +++ b/backend/.sqlx/query-4e88aec662ebc70e0425a48a1b4e2e60e3183fa81a411622891caea6dc03fa90.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO token (token_hash, token_prefix, email, label, super_admin, owner, workspace_id)\n VALUES ($1, $2, 'charlie@windmill.dev', 'Charlie new token', false, 'u/charlie', 'test-workspace')", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "4e88aec662ebc70e0425a48a1b4e2e60e3183fa81a411622891caea6dc03fa90" +} diff --git a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json index 36ddb8ab9f..713ccb9dd3 100644 --- a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json +++ b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json @@ -15,7 +15,7 @@ ] }, "nullable": [ - true + null ] }, "hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55" diff --git a/backend/.sqlx/query-5b140cb478aa32ac38fb831d6af960ffc33da5f31bf780e8fd6a66d5150b8027.json b/backend/.sqlx/query-5b140cb478aa32ac38fb831d6af960ffc33da5f31bf780e8fd6a66d5150b8027.json new file mode 100644 index 0000000000..8e558fe67b --- /dev/null +++ b/backend/.sqlx/query-5b140cb478aa32ac38fb831d6af960ffc33da5f31bf780e8fd6a66d5150b8027.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO app (id, workspace_id, path, summary, versions, policy, extra_perms)\n VALUES (3001, 'test-workspace', 'u/operator/existing_app', 'Existing app', '{}',\n '{\"on_behalf_of\": \"u/admin\", \"on_behalf_of_email\": \"admin@windmill.dev\", \"execution_mode\": \"viewer\"}', '{}')", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "5b140cb478aa32ac38fb831d6af960ffc33da5f31bf780e8fd6a66d5150b8027" +} diff --git a/backend/.sqlx/query-6c24b5c08341979e02b7968242369c87a9d29370ec985d2c8c28633cd078ffaf.json b/backend/.sqlx/query-6c24b5c08341979e02b7968242369c87a9d29370ec985d2c8c28633cd078ffaf.json new file mode 100644 index 0000000000..6da123cbc4 --- /dev/null +++ b/backend/.sqlx/query-6c24b5c08341979e02b7968242369c87a9d29370ec985d2c8c28633cd078ffaf.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO usr_to_group (workspace_id, group_, usr) VALUES ('test-workspace', 'editors', 'charlie')", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "6c24b5c08341979e02b7968242369c87a9d29370ec985d2c8c28633cd078ffaf" +} diff --git a/backend/.sqlx/query-be35e85e94641c71620e5402dcf74da8c73120b3e16194904575f79a4e055002.json b/backend/.sqlx/query-be35e85e94641c71620e5402dcf74da8c73120b3e16194904575f79a4e055002.json new file mode 100644 index 0000000000..d7cc49fe3f --- /dev/null +++ b/backend/.sqlx/query-be35e85e94641c71620e5402dcf74da8c73120b3e16194904575f79a4e055002.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock, extra_perms)\n VALUES ('test-workspace', 3001, 'u/operator/existing_script', 'export function main() { return \"original\"; }', 'deno', 'script', 'admin', '{}', 'Existing script', '', '', '{}')", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "be35e85e94641c71620e5402dcf74da8c73120b3e16194904575f79a4e055002" +} diff --git a/backend/.sqlx/query-ccd76be88fa9c11b3dc2e6d7711437ab3e02d8c3c10c53decc664533b8d04bc0.json b/backend/.sqlx/query-ccd76be88fa9c11b3dc2e6d7711437ab3e02d8c3c10c53decc664533b8d04bc0.json new file mode 100644 index 0000000000..d9b7688eba --- /dev/null +++ b/backend/.sqlx/query-ccd76be88fa9c11b3dc2e6d7711437ab3e02d8c3c10c53decc664533b8d04bc0.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO kafka_trigger (\n path, kafka_resource_path, topics, group_id,\n script_path, is_flow, workspace_id, edited_by, permissioned_as\n )\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "VarcharArray", + "Varchar", + "Varchar", + "Bool", + "Varchar", + "Varchar", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "ccd76be88fa9c11b3dc2e6d7711437ab3e02d8c3c10c53decc664533b8d04bc0" +}