From fbf9f04e107c674474f61c1559a2f1e56dfeeaba Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 28 Jul 2026 14:29:06 +0200 Subject: [PATCH 01/24] fix: surface the real postgres error when data table migrations fail (#10371) * fix: surface the real postgres error when data table migrations fail Co-Authored-By: Claude Opus 5 (1M context) * fix: address review nits on the data table migration error fix Co-Authored-By: Claude Opus 5 (1M context) * fix: name the exact grant a data table migration needs Co-Authored-By: Claude Opus 5 (1M context) * fix: quote both identifiers in the data table grant hint Co-Authored-By: Claude Opus 5 (1M context) * feat: add a data table connection and privilege check to workspace settings Co-Authored-By: Claude Opus 5 (1M context) * fix: report data table privileges from the capability fields, not the grant list Co-Authored-By: Claude Opus 5 (1M context) * fix: read grant targets from the server and drop the public schema guess Co-Authored-By: Claude Opus 5 (1M context) * fix: render the search_path suggestion server-side and pin the granted database Co-Authored-By: Claude Opus 5 (1M context) * fix: key the connection check on request identity, not the data table name Co-Authored-By: Claude Opus 5 (1M context) * fix: declare the data table check schema field nullable and required Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- backend/tests/datatable_migrations_grants.rs | 300 ++++++++++++++++++ .../fixtures/datatable_migrations_grants.sql | 35 ++ backend/windmill-api-settings/src/lib.rs | 4 +- .../src/datatable_migrations.rs | 125 +++++++- .../windmill-api-workspaces/src/workspaces.rs | 156 ++++++++- backend/windmill-api/openapi.yaml | 46 +++ backend/windmill-common/src/error.rs | 87 +++++ backend/windmill-common/src/lib.rs | 2 +- backend/windmill-common/src/query_builders.rs | 8 +- .../DataTableSettings.svelte | 116 ++++++- 10 files changed, 849 insertions(+), 30 deletions(-) create mode 100644 backend/tests/datatable_migrations_grants.rs create mode 100644 backend/tests/fixtures/datatable_migrations_grants.sql diff --git a/backend/tests/datatable_migrations_grants.rs b/backend/tests/datatable_migrations_grants.rs new file mode 100644 index 0000000000..ebe9aeba4d --- /dev/null +++ b/backend/tests/datatable_migrations_grants.rs @@ -0,0 +1,300 @@ +//! Regression test for running data table migrations against a database whose +//! role only holds DML grants. +//! +//! Two failure modes are pinned here: +//! - the Postgres message must reach the caller. `tokio_postgres::Error`'s +//! `Display` renders only the error kind, so interpolating it with `{}` +//! produced a bare `Failed to ensure _wm_migrations table: db error`. +//! - `CREATE TABLE IF NOT EXISTS` checks CREATE on the schema *before* it +//! checks existence, so the run must probe for `_wm_migrations` first or an +//! unprivileged role can never migrate, even against a pre-created table. +//! +//! Plus the privilege report that surfaces the same state from workspace +//! settings before anyone reaches a migration. + +use serde_json::{json, Value}; +use sqlx::{Pool, Postgres}; + +use windmill_test_utils::*; + +const ROLE: &str = "wm_dtmig_test_role"; +const ROLE_PASSWORD: &str = "wm_dtmig_test_pwd"; +/// Deliberately hyphenated: it only parses inside double quotes, so it pins that +/// the emitted recovery statement quotes the role rather than interpolating it. +const NOSCHEMA_ROLE: &str = "wm-dtmig-noschema"; + +fn authed(b: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + b.header("Authorization", "Bearer DTMIG_ADMIN_TOKEN") +} + +/// Point the fixture's data table at this test's own database, connecting as a +/// role that may read and write but not create: `GRANT USAGE` without `CREATE`, +/// and the schema's own CREATE revoked from PUBLIC so the outcome does not +/// depend on the server's default `public` grants (relaxed before Postgres 15). +async fn setup_unprivileged_datatable_role(db: &Pool) -> anyhow::Result<()> { + let opts = (*db.connect_options()).clone(); + let dbname = opts.get_database().expect("test database name").to_string(); + + sqlx::query(&format!( + // Roles are cluster objects, not per-test-database ones. A previous run + // leaving the role behind raises duplicate_object; the tests in this + // binary run in parallel, so two sessions can also clear that check + // together and collide on pg_authid's unique index instead. + "DO $$ BEGIN \ + CREATE ROLE {ROLE} LOGIN PASSWORD '{ROLE_PASSWORD}'; \ + EXCEPTION WHEN duplicate_object OR unique_violation THEN NULL; \ + END $$" + )) + .execute(db) + .await?; + sqlx::raw_sql(&format!( + "REVOKE CREATE ON SCHEMA public FROM PUBLIC; \ + GRANT CONNECT ON DATABASE \"{dbname}\" TO {ROLE}; \ + GRANT USAGE ON SCHEMA public TO {ROLE};" + )) + .execute(db) + .await?; + + sqlx::query( + "INSERT INTO resource (workspace_id, path, value, resource_type, created_by) \ + VALUES ('dtmig-ws', 'u/dtmig-admin/pg', $1, 'postgresql', 'dtmig-admin')", + ) + .bind(json!({ + "host": opts.get_host(), + "port": opts.get_port(), + "dbname": dbname, + "user": ROLE, + "password": ROLE_PASSWORD, + "sslmode": "disable", + })) + .execute(db) + .await?; + + Ok(()) +} + +#[sqlx::test(fixtures("datatable_migrations_grants"))] +async fn test_run_migrations_without_create_privilege(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + setup_unprivileged_datatable_role(&db).await?; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let url = + format!("http://localhost:{port}/api/w/dtmig-ws/workspaces/run_datatable_migrations/main"); + + // No `_wm_migrations` yet and no way to create one: the caller must be told + // what Postgres actually refused, not "db error". + let resp = authed(reqwest::Client::new().post(&url)).send().await?; + assert_eq!(resp.status(), 500); + let body = resp.text().await?; + assert!( + body.contains("permission denied for schema"), + "the Postgres message should reach the caller, got: {body}" + ); + // The suggested statement must be complete and quoted, not a placeholder. + assert!( + body.contains(&format!("GRANT CREATE ON SCHEMA \"public\" TO \"{ROLE}\"")), + "the hint should name the actual role and schema, got: {body}" + ); + + // Once an operator has created the bookkeeping table and granted DML on it, + // migrations run even though the role still cannot create tables. + sqlx::raw_sql(&format!( + "CREATE TABLE _wm_migrations ( \ + datatable TEXT NOT NULL, \ + version BIGINT NOT NULL, \ + installed_at TIMESTAMPTZ NOT NULL DEFAULT now(), \ + PRIMARY KEY (datatable, version)); \ + GRANT SELECT, INSERT, UPDATE, DELETE ON _wm_migrations TO {ROLE};" + )) + .execute(&db) + .await?; + + let resp = authed(reqwest::Client::new().post(&url)).send().await?; + let status = resp.status(); + let body = resp.text().await?; + assert_eq!( + status, 200, + "run should succeed on a pre-created table: {body}" + ); + + Ok(()) +} + +#[sqlx::test(fixtures("datatable_migrations_grants"))] +async fn test_datatable_connection_report(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + setup_unprivileged_datatable_role(&db).await?; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let url = + format!("http://localhost:{port}/api/w/dtmig-ws/workspaces/test_datatable_connection/main"); + + // The report is a privilege disclosure about the data table's database, so + // it stays behind the same bar as editing the data table config. + let resp = reqwest::Client::new() + .get(&url) + .header("Authorization", "Bearer DTMIG_USER_TOKEN") + .send() + .await?; + assert_eq!(resp.status(), 403, "non-admins must not get the report"); + + let report: Value = authed(reqwest::Client::new().get(&url)) + .send() + .await? + .json() + .await?; + assert_eq!(report["user"], ROLE); + assert_eq!(report["schema"], "public"); + assert_eq!(report["can_create_table"], false); + assert_eq!(report["can_create_schema"], false); + let grants = report["suggested_grants"].as_array().unwrap(); + assert!( + grants + .iter() + .any(|g| g.as_str().unwrap() + == format!("GRANT CREATE ON SCHEMA \"public\" TO \"{ROLE}\"")), + "missing schema grant: {report}" + ); + // Pin the name, not just the shape: the endpoint reads it from + // `current_database()` rather than the resource, and a prefix assertion + // would pass either way. + let dbname = (*db.connect_options()) + .clone() + .get_database() + .expect("test database name") + .to_string(); + assert!( + grants.iter().any(|g| g.as_str().unwrap() + == format!("GRANT CREATE ON DATABASE \"{dbname}\" TO \"{ROLE}\"")), + "missing database grant for {dbname}: {report}" + ); + + // A pre-created bookkeeping table lets migration *tracking* work, but the + // role still cannot create anything: the report must keep saying so rather + // than falling silent because nothing needs creating right now. + sqlx::raw_sql(&format!( + "CREATE TABLE _wm_migrations ( \ + datatable TEXT NOT NULL, \ + version BIGINT NOT NULL, \ + installed_at TIMESTAMPTZ NOT NULL DEFAULT now(), \ + PRIMARY KEY (datatable, version)); \ + GRANT SELECT, INSERT, UPDATE, DELETE ON _wm_migrations TO {ROLE};" + )) + .execute(&db) + .await?; + + let report: Value = authed(reqwest::Client::new().get(&url)) + .send() + .await? + .json() + .await?; + assert_eq!(report["migrations_table_exists"], true); + assert_eq!(report["can_create_table"], false); + assert!( + report["suggested_grants"] + .as_array() + .unwrap() + .iter() + .any(|g| g.as_str().unwrap().contains("ON SCHEMA")), + "an existing bookkeeping table must not suppress the schema grant: {report}" + ); + + // Granting the privileges clears the suggestions. + sqlx::raw_sql(&format!( + "GRANT CREATE ON SCHEMA public TO {ROLE}; \ + GRANT CREATE ON DATABASE \"{dbname}\" TO {ROLE};" + )) + .execute(&db) + .await?; + + let report: Value = authed(reqwest::Client::new().get(&url)) + .send() + .await? + .json() + .await?; + assert_eq!(report["can_create_table"], true); + assert_eq!(report["can_create_schema"], true); + assert_eq!(report["suggested_grants"].as_array().unwrap().len(), 0); + + Ok(()) +} + +/// Point the fixture's second data table at a role whose `search_path` resolves +/// to nothing, the one state where no grant helps. +async fn setup_schemaless_datatable_role(db: &Pool) -> anyhow::Result<()> { + let opts = (*db.connect_options()).clone(); + let dbname = opts.get_database().expect("test database name").to_string(); + + sqlx::query(&format!( + "DO $$ BEGIN \ + CREATE ROLE \"{NOSCHEMA_ROLE}\" LOGIN PASSWORD '{ROLE_PASSWORD}'; \ + EXCEPTION WHEN duplicate_object OR unique_violation THEN NULL; \ + END $$" + )) + .execute(db) + .await?; + // Cluster-wide for this role, which is why it gets one of its own rather + // than sharing the role the other assertions connect with. + sqlx::raw_sql(&format!( + "ALTER ROLE \"{NOSCHEMA_ROLE}\" SET search_path = wm_dtmig_absent_schema; \ + GRANT CONNECT ON DATABASE \"{dbname}\" TO \"{NOSCHEMA_ROLE}\";" + )) + .execute(db) + .await?; + + sqlx::query( + "INSERT INTO resource (workspace_id, path, value, resource_type, created_by) \ + VALUES ('dtmig-ws', 'u/dtmig-admin/pg_noschema', $1, 'postgresql', 'dtmig-admin')", + ) + .bind(json!({ + "host": opts.get_host(), + "port": opts.get_port(), + "dbname": dbname, + "user": NOSCHEMA_ROLE, + "password": ROLE_PASSWORD, + "sslmode": "disable", + })) + .execute(db) + .await?; + + Ok(()) +} + +#[sqlx::test(fixtures("datatable_migrations_grants"))] +async fn test_datatable_connection_without_a_resolvable_schema( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + setup_schemaless_datatable_role(&db).await?; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let report: Value = authed(reqwest::Client::new().get(format!( + "http://localhost:{port}/api/w/dtmig-ws/workspaces/test_datatable_connection/noschema" + ))) + .send() + .await? + .json() + .await?; + + assert!(report["schema"].is_null(), "expected no schema: {report}"); + // No grant fixes an empty search_path, so suggesting one would send the + // reader after a statement that changes nothing. + assert!( + !report["suggested_grants"] + .as_array() + .unwrap() + .iter() + .any(|g| g.as_str().unwrap().contains("ON SCHEMA")), + "an empty search_path must not yield a schema grant: {report}" + ); + assert_eq!( + report["suggested_search_path"], + format!("ALTER ROLE \"{NOSCHEMA_ROLE}\" SET search_path = public") + ); + + Ok(()) +} diff --git a/backend/tests/fixtures/datatable_migrations_grants.sql b/backend/tests/fixtures/datatable_migrations_grants.sql new file mode 100644 index 0000000000..81feb9ce91 --- /dev/null +++ b/backend/tests/fixtures/datatable_migrations_grants.sql @@ -0,0 +1,35 @@ +-- Fixture for the data table migration bookkeeping-grants regression test. +-- Workspace + admin token + a data table pointing at a postgres resource; the +-- test fills that resource in with credentials for a deliberately unprivileged +-- role, since the database name is allocated per test run. + +INSERT INTO workspace (id, name, owner) VALUES + ('dtmig-ws', 'DTMIG WS', 'dtmig-admin'); + +INSERT INTO workspace_key (workspace_id, kind, key) VALUES + ('dtmig-ws', 'cloud', 'dtmig-key'); + +INSERT INTO workspace_settings (workspace_id, datatable) VALUES + ('dtmig-ws', '{"datatables": {"main": {"database": {"resource_type": "postgresql", "resource_path": "u/dtmig-admin/pg"}, "migrations_enabled": true}, "noschema": {"database": {"resource_type": "postgresql", "resource_path": "u/dtmig-admin/pg_noschema"}, "migrations_enabled": true}}}'); + +INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES + ('dtmig-ws', 'all', 'All users', '{}'); + +INSERT INTO password(email, password_hash, login_type, super_admin, verified, name, username) + VALUES ('dtmig-admin@windmill.dev', 'x', 'password', true, true, 'DTMIG Admin', 'dtmig-admin'); + +INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES + ('dtmig-ws', 'dtmig-admin@windmill.dev', 'dtmig-admin', true, 'Admin'); + +INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin) + VALUES (encode(sha256('DTMIG_ADMIN_TOKEN'::bytea), 'hex'), 'DTMIG_ADM', 'DTMIG_ADMIN_TOKEN', 'dtmig-admin@windmill.dev', 't', true); + +-- Non-admin member, to pin that the privilege report stays admin-only. +INSERT INTO password(email, password_hash, login_type, super_admin, verified, name, username) + VALUES ('dtmig-user@windmill.dev', 'x', 'password', false, true, 'DTMIG User', 'dtmig-user'); + +INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES + ('dtmig-ws', 'dtmig-user@windmill.dev', 'dtmig-user', false, 'User'); + +INSERT INTO token(token_hash, token_prefix, token, email, label) + VALUES (encode(sha256('DTMIG_USER_TOKEN'::bytea), 'hex'), 'DTMIG_USR', 'DTMIG_USER_TOKEN', 'dtmig-user@windmill.dev', 't'); diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs index bd34ca35ef..eca0001eab 100644 --- a/backend/windmill-api-settings/src/lib.rs +++ b/backend/windmill-api-settings/src/lib.rs @@ -53,7 +53,7 @@ use windmill_common::{ auth::is_super_admin_email, ee_oss::{get_license_plan, LicensePlan}, email_oss::send_email_plain_text, - error::{self, JsonResult, Result}, + error::{self, pg_error_message, JsonResult, Result}, get_database_url, global_settings::{ AI_CONFIG_SETTING, APP_WORKSPACED_ROUTE_SETTING, AUTOMATE_USERNAME_CREATION_SETTING, @@ -1705,7 +1705,7 @@ async fn setup_custom_instance_pg_database_inner( .map_err(|e| { error::Error::ExecutionErr(format!( "Failed to grant permissions to custom_instance_user: {}", - e.to_string(), + pg_error_message(&e), )) })?; diff --git a/backend/windmill-api-workspaces/src/datatable_migrations.rs b/backend/windmill-api-workspaces/src/datatable_migrations.rs index fef9963933..73e2fb85cc 100644 --- a/backend/windmill-api-workspaces/src/datatable_migrations.rs +++ b/backend/windmill-api-workspaces/src/datatable_migrations.rs @@ -21,14 +21,16 @@ use chrono::Utc; use serde::{Deserialize, Serialize}; use sqlx::{Postgres, Transaction}; use std::collections::{HashMap, HashSet}; +use tokio_postgres::error::SqlState; use windmill_api_auth::{require_super_admin, ApiAuthed}; use windmill_api_jobs::run_wait_result_internal; use windmill_audit::audit_oss::audit_log; use windmill_audit::ActionKind; use windmill_common::db::UserDB; -use windmill_common::error::{Error, JsonResult, Result}; +use windmill_common::error::{pg_error_message, Error, JsonResult, Result}; use windmill_common::jobs::{JobPayload, RawCode}; +use windmill_common::query_builders::{render_db_quoted_identifier, DbType}; use windmill_common::runnable_settings::{ConcurrencySettingsWithCustom, DebouncingSettings}; use windmill_common::scripts::ScriptLang; use windmill_common::users::username_to_permissioned_as; @@ -222,7 +224,31 @@ async fn run_datatable_migration_job( /// key would let one data table's migration mark another's same-version /// migration as already applied (and rollback could touch the wrong row). async fn ensure_wm_migrations_schema(client: &tokio_postgres::Client) -> Result<()> { - client + // `CREATE TABLE IF NOT EXISTS` checks CREATE on the schema before it checks + // existence, so probing first is what lets a data table whose role only holds + // DML grants keep migrating against an already-created bookkeeping table. + // `to_regclass` resolves through search_path, like the unqualified statements + // the rest of this module runs against it. Takes no parameters, so it goes + // through the simple protocol: a named prepared statement is what stalls + // behind a transaction-pooling proxy (see `pg_get_full_schema`). + let rows = client + .simple_query("SELECT to_regclass('_wm_migrations') IS NOT NULL AS present") + .await + .map_err(|e| { + Error::internal_err(format!( + "Failed to look up _wm_migrations table: {}", + pg_error_message(&e) + )) + })?; + let exists = rows.iter().any(|msg| match msg { + tokio_postgres::SimpleQueryMessage::Row(row) => row.get("present") == Some("t"), + _ => false, + }); + if exists { + return Ok(()); + } + + let Err(e) = client .batch_execute( "CREATE TABLE IF NOT EXISTS _wm_migrations (\ datatable TEXT NOT NULL, \ @@ -231,10 +257,53 @@ async fn ensure_wm_migrations_schema(client: &tokio_postgres::Client) -> Result< PRIMARY KEY (datatable, version))", ) .await - .map_err(|e| { - Error::internal_err(format!("Failed to ensure _wm_migrations table: {}", e)) - })?; - Ok(()) + else { + return Ok(()); + }; + + let mut msg = format!( + "Failed to ensure _wm_migrations table: {}", + pg_error_message(&e) + ); + // A role with only table-level grants cannot create it: since Postgres 15 the + // `public` schema no longer grants CREATE to PUBLIC, so this is the usual + // failure on a bring-your-own database. + if e.code() == Some(&SqlState::INSUFFICIENT_PRIVILEGE) { + // Windmill connects as the role that lacks the privilege, so it cannot + // grant it: hand over the statement a schema owner has to run instead. + // Keep it ahead of the explanation below — the UI collapses everything + // past the first couple of lines behind a "Show more". + if let Some((user, schema)) = connection_identity(client).await { + // Both come back unquoted, so a mixed-case or hyphenated name would + // otherwise render a statement that targets a different schema. + msg.push_str(&format!( + ". Run: GRANT CREATE ON SCHEMA {} TO {}", + render_db_quoted_identifier(&schema, DbType::Postgresql), + render_db_quoted_identifier(&user, DbType::Postgresql), + )); + } + msg.push_str( + ". Applied migrations are recorded in a `_wm_migrations` table in the data \ + table's own database, so its user needs to be able to create it", + ); + } + Err(Error::internal_err(msg)) +} + +/// The role and default schema of a data table connection, for grant hints. +/// Both come from the server so the statement we suggest names what the +/// connection actually resolves to, not what the resource happens to say. +async fn connection_identity(client: &tokio_postgres::Client) -> Option<(String, String)> { + let rows = client + .simple_query("SELECT current_user AS usr, current_schema() AS sch") + .await + .ok()?; + rows.iter().find_map(|msg| match msg { + tokio_postgres::SimpleQueryMessage::Row(row) => { + Some((row.get("usr")?.to_string(), row.get("sch")?.to_string())) + } + _ => None, + }) } /// Open a connection to a data table's own database and hold the session-level @@ -265,7 +334,12 @@ async fn lock_datatable_migration_runs( client .batch_execute("SELECT pg_advisory_lock(hashtext('windmill_datatable_migrations')::int8)") .await - .map_err(|e| Error::internal_err(format!("Failed to acquire migration lock: {}", e)))?; + .map_err(|e| { + Error::internal_err(format!( + "Failed to acquire migration lock: {}", + pg_error_message(&e) + )) + })?; Ok(client) } @@ -287,7 +361,7 @@ async fn read_applied_versions_on_client( Err(e) if e.as_db_error().map(|d| d.code().code()) == Some("42P01") => Ok(HashSet::new()), Err(e) => Err(Error::internal_err(format!( "Failed to read _wm_migrations: {}", - e + pg_error_message(&e) ))), } } @@ -366,7 +440,12 @@ async fn run_datatable_migrations( &[&datatable_name, &m.timestamp], ) .await - .map_err(|e| Error::internal_err(format!("Failed to record migration: {}", e)))?; + .map_err(|e| { + Error::internal_err(format!( + "Failed to record migration: {}", + pg_error_message(&e) + )) + })?; applied.push(AppliedMigration { version: m.timestamp, name: m.name }); } @@ -433,7 +512,12 @@ async fn rollback_datatable_migrations( &[&datatable_name, &only], ) .await - .map_err(|e| Error::internal_err(format!("Failed to read _wm_migrations: {}", e)))?, + .map_err(|e| { + Error::internal_err(format!( + "Failed to read _wm_migrations: {}", + pg_error_message(&e) + )) + })?, None => client .query_opt( "SELECT version FROM _wm_migrations WHERE datatable = $1 \ @@ -441,7 +525,12 @@ async fn rollback_datatable_migrations( &[&datatable_name], ) .await - .map_err(|e| Error::internal_err(format!("Failed to read _wm_migrations: {}", e)))?, + .map_err(|e| { + Error::internal_err(format!( + "Failed to read _wm_migrations: {}", + pg_error_message(&e) + )) + })?, }; let version: i64 = match target { @@ -492,7 +581,12 @@ async fn rollback_datatable_migrations( &[&datatable_name, &version], ) .await - .map_err(|e| Error::internal_err(format!("Failed to drop migration record: {}", e)))?; + .map_err(|e| { + Error::internal_err(format!( + "Failed to drop migration record: {}", + pg_error_message(&e) + )) + })?; Ok(Json(RollbackDatatableMigrationsResult { rolled_back: vec![RolledBackMigration { version, name: definition.name }], @@ -940,7 +1034,10 @@ async fn mark_datatable_version_installed( ) .await .map_err(|e| { - Error::internal_err(format!("Failed to mark initial migration installed: {}", e)) + Error::internal_err(format!( + "Failed to mark initial migration installed: {}", + pg_error_message(&e) + )) })?; Ok(()) } @@ -1378,7 +1475,7 @@ fn ignore_missing_wm_migrations(e: tokio_postgres::Error) -> Result<()> { Some("42P01") => Ok(()), _ => Err(Error::internal_err(format!( "Failed to update _wm_migrations: {}", - e + pg_error_message(&e) ))), } } diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index c8f67c9d2e..b7e2ab72c1 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -34,6 +34,7 @@ use windmill_audit::audit_oss::{audit_log, AuditAuthorable}; use windmill_audit::ActionKind; use windmill_common::db::UserDB; use windmill_common::global_settings::HTTP_ROUTE_WORKSPACED_ROUTE; +use windmill_common::query_builders::{render_db_quoted_identifier, DbType}; use windmill_common::users::username_to_permissioned_as; use windmill_common::variables::{ build_crypt, decrypt, encrypt, SECRET_SALT, WORKSPACE_CRYPT_CACHE, @@ -51,7 +52,7 @@ use windmill_common::workspaces::{ use windmill_common::workspaces::{Ducklake, DucklakeCatalogResourceType}; use windmill_common::PgDatabase; use windmill_common::{ - error::{Error, JsonResult, Result}, + error::{pg_error_message, Error, JsonResult, Result}, global_settings::{ AUTOMATE_USERNAME_CREATION_SETTING, DISABLE_WORKSPACE_INVITE_EMAILS_SETTING, }, @@ -133,6 +134,10 @@ pub fn workspaced_service() -> Router { get(get_datatable_table_schema), ) .route("/edit_datatable_config", post(edit_datatable_config)) + .route( + "/test_datatable_connection/{datatable_name}", + get(test_datatable_connection), + ) .merge(crate::datatable_migrations::routes()) .route("/git_sync_enabled", get(get_git_sync_enabled)) .route("/git_sync_deploy_mode", get(get_git_sync_deploy_mode)) @@ -2023,6 +2028,127 @@ struct DataTableTableSchema { columns: ColumnMap, } +#[derive(Serialize, Debug)] +struct DataTableConnectionCheck { + /// The role the data table actually connects as, and the schema its + /// unqualified statements resolve to. Both are read from the server rather + /// than the resource, which need not spell either of them out. + user: String, + schema: Option, + /// Whether that role can create tables in `schema` / schemas in the database. + can_create_table: bool, + can_create_schema: bool, + /// Whether the migration bookkeeping table is already present. Informative + /// only: it explains why migration *tracking* can work without CREATE, and + /// grants nothing beyond that. + migrations_table_exists: bool, + /// Statements to run for the privileges that are missing, empty when there + /// are none. Windmill connects as the role that lacks them, so it can only + /// name them for a schema owner to run. + suggested_grants: Vec, + /// Statement that gives the session a schema to work in, when `search_path` + /// resolves to none. Rendered here rather than by the caller so identifier + /// quoting stays in one place. + #[serde(skip_serializing_if = "Option::is_none")] + suggested_search_path: Option, +} + +/// Report what the data table's own database lets its role do. Surfacing this +/// from the settings page is the difference between finding out here and finding +/// out on a first schema change, when the failure reads as a Postgres refusal +/// deep inside a migration. +async fn test_datatable_connection( + authed: ApiAuthed, + Extension(db): Extension, + Path((w_id, datatable_name)): Path<(String, String)>, +) -> JsonResult { + require_admin(authed.is_admin, &authed.username)?; + + let db_resource = get_datatable_resource_from_db_unchecked(&db, &w_id, &datatable_name).await?; + let pg_db: PgDatabase = serde_json::from_value(db_resource) + .map_err(|e| Error::internal_err(format!("Failed to parse database credentials: {}", e)))?; + let (client, connection) = pg_db.connect(Some(&db)).await?; + let join_handle = tokio::spawn(async move { connection.await }); + + // One round trip, no side effects: `has_*_privilege` answers for the + // connected role without attempting the operation. + let rows = client + .simple_query( + "SELECT current_user AS usr, \ + current_schema() AS sch, \ + current_database() AS db, \ + has_schema_privilege(current_schema(), 'CREATE') AS can_create_table, \ + has_database_privilege(current_database(), 'CREATE') AS can_create_schema, \ + to_regclass('_wm_migrations') IS NOT NULL AS has_migrations_table", + ) + .await + .map_err(|e| { + Error::internal_err(format!( + "Failed to inspect data table privileges: {}", + pg_error_message(&e) + )) + }); + + drop(client); + let _ = windmill_common::shutdown_pg_connection(join_handle).await; + + let row = rows? + .into_iter() + .find_map(|msg| match msg { + tokio_postgres::SimpleQueryMessage::Row(row) => Some(row), + _ => None, + }) + .ok_or_else(|| Error::internal_err("Privilege query returned no row".to_string()))?; + + let user = row.get("usr").unwrap_or_default().to_string(); + let schema = row.get("sch").map(str::to_string); + let can_create_table = row.get("can_create_table") == Some("t"); + let can_create_schema = row.get("can_create_schema") == Some("t"); + let migrations_table_exists = row.get("has_migrations_table") == Some("t"); + + let quoted_user = render_db_quoted_identifier(&user, DbType::Postgresql); + let mut suggested_grants = Vec::new(); + // Suggest on the capability alone: an existing `_wm_migrations` spares only + // that one table, and says nothing about the tables a migration will create. + // A NULL `current_schema()` means search_path resolves to nothing, and no + // grant fixes that — an unqualified CREATE fails with `no schema has been + // selected to create in` whoever holds the privilege — so suggest nothing + // and let `schema: null` carry the diagnosis. + if let (false, Some(target)) = (can_create_table, schema.as_deref()) { + suggested_grants.push(format!( + "GRANT CREATE ON SCHEMA {} TO {}", + render_db_quoted_identifier(target, DbType::Postgresql), + quoted_user + )); + } + if !can_create_schema { + // Named from the server like every other identifier here: behind a + // pooler the resource's dbname can be an alias for another database. + let dbname = row.get("db").unwrap_or(pg_db.dbname.as_str()); + suggested_grants.push(format!( + "GRANT CREATE ON DATABASE {} TO {}", + render_db_quoted_identifier(dbname, DbType::Postgresql), + quoted_user + )); + } + + // An empty search_path is not a privilege problem, so it gets a statement of + // its own rather than a grant. + let suggested_search_path = schema + .is_none() + .then(|| format!("ALTER ROLE {quoted_user} SET search_path = public")); + + Ok(Json(DataTableConnectionCheck { + user, + schema, + can_create_table, + can_create_schema, + migrations_table_exists, + suggested_grants, + suggested_search_path, + })) +} + async fn list_datatable_schemas( _authed: ApiAuthed, Extension(db): Extension, @@ -2139,7 +2265,9 @@ async fn get_datatable_schema(db: &DB, w_id: &str, datatable_name: &str) -> Resu &[], ) .await - .map_err(|e| Error::internal_err(format!("Failed to query schemas: {}", e)))?; + .map_err(|e| { + Error::internal_err(format!("Failed to query schemas: {}", pg_error_message(&e))) + })?; // Build hierarchical structure: schema -> table -> column -> compact_type let mut schema_map: SchemaMap = HashMap::new(); @@ -2173,7 +2301,9 @@ async fn get_datatable_schema(db: &DB, w_id: &str, datatable_name: &str) -> Resu &[&schema_names], ) .await - .map_err(|e| Error::internal_err(format!("Failed to query columns: {}", e)))?; + .map_err(|e| { + Error::internal_err(format!("Failed to query columns: {}", pg_error_message(&e))) + })?; for row in rows { let table_schema: String = row.get(0); @@ -2221,7 +2351,9 @@ async fn get_datatable_tables(db: &DB, w_id: &str, datatable_name: &str) -> Resu &[], ) .await - .map_err(|e| Error::internal_err(format!("Failed to query schemas: {}", e)))?; + .map_err(|e| { + Error::internal_err(format!("Failed to query schemas: {}", pg_error_message(&e))) + })?; let mut table_map: TableListMap = HashMap::new(); let schema_names: Vec = schema_rows @@ -2247,7 +2379,9 @@ async fn get_datatable_tables(db: &DB, w_id: &str, datatable_name: &str) -> Resu &[&schema_names], ) .await - .map_err(|e| Error::internal_err(format!("Failed to query tables: {}", e)))?; + .map_err(|e| { + Error::internal_err(format!("Failed to query tables: {}", pg_error_message(&e))) + })?; for row in rows { let table_schema: String = row.get(0); @@ -2299,7 +2433,9 @@ async fn get_datatable_table_columns( &[&schema_name, &table_name], ) .await - .map_err(|e| Error::internal_err(format!("Failed to query columns: {}", e)))?; + .map_err(|e| { + Error::internal_err(format!("Failed to query columns: {}", pg_error_message(&e))) + })?; if rows.is_empty() { return Err(Error::NotFound(format!( @@ -2671,7 +2807,10 @@ async fn create_pg_database( ) .await .map_err(|e| { - Error::internal_err(format!("Failed to check database existence: {}", e)) + Error::internal_err(format!( + "Failed to check database existence: {}", + pg_error_message(&e) + )) })?; let db_exists: bool = row.get(0); @@ -2690,7 +2829,8 @@ async fn create_pg_database( .map_err(|e| { Error::internal_err(format!( "Failed to create database '{}': {}", - req.target_dbname, e + req.target_dbname, + pg_error_message(&e) )) })?; diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 7927a7f00d..1dd692752f 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -4633,6 +4633,52 @@ paths: items: $ref: "#/components/schemas/DataTableSchema" + /w/{workspace}/workspaces/test_datatable_connection/{datatable_name}: + get: + summary: check what the data table's database lets its role do + operationId: testDataTableConnection + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: datatable_name + in: path + required: true + schema: + type: string + responses: + "200": + description: connection and privilege report + content: + application/json: + schema: + type: object + properties: + user: + type: string + schema: + type: string + nullable: true + can_create_table: + type: boolean + can_create_schema: + type: boolean + migrations_table_exists: + type: boolean + suggested_grants: + type: array + items: + type: string + suggested_search_path: + type: string + required: + - user + - schema + - can_create_table + - can_create_schema + - migrations_table_exists + - suggested_grants + /w/{workspace}/workspaces/list_datatable_tables: get: summary: list tables of all connected Datatables diff --git a/backend/windmill-common/src/error.rs b/backend/windmill-common/src/error.rs index f202bf27f9..0d5ebdc2c3 100644 --- a/backend/windmill-common/src/error.rs +++ b/backend/windmill-common/src/error.rs @@ -247,6 +247,41 @@ pub fn to_anyhow(e: T) -> anyhow:: From::from(e) } +/// Render a `tokio_postgres` error for a user-facing message. +/// +/// The pinned rust-postgres build prints only the error *kind* in its `Display` +/// impl, so `format!("{e}")` on one of these yields the useless `db error` and +/// drops the Postgres message. Interpolate errors from a `tokio_postgres::Client` +/// through this instead. +pub fn pg_error_message(e: &tokio_postgres::Error) -> String { + match e.as_db_error() { + Some(db_err) => format_db_error(db_err.message(), db_err.detail(), db_err.hint()), + // Non-database failures (io, tls, protocol) keep their message in the cause. + None => error_source_chain(e), + } +} + +fn format_db_error(message: &str, detail: Option<&str>, hint: Option<&str>) -> String { + let mut msg = message.to_string(); + if let Some(detail) = detail { + msg.push_str(&format!(" ({detail})")); + } + if let Some(hint) = hint { + msg.push_str(&format!(". Hint: {hint}")); + } + msg +} + +fn error_source_chain(e: &dyn std::error::Error) -> String { + let mut msg = e.to_string(); + let mut source = e.source(); + while let Some(cause) = source { + msg.push_str(&format!(": {cause}")); + source = cause.source(); + } + msg +} + impl IntoResponse for Error { fn into_response(self) -> axum::response::Response { let status = match self { @@ -419,4 +454,56 @@ mod tests { let rendered = Error::JsonErr(v).to_string(); assert_eq!(rendered, "[\n 1,\n 2,\n 3\n]"); } + + #[test] + fn db_error_renders_message_with_detail_and_hint() { + assert_eq!( + super::format_db_error("permission denied for schema public", None, None), + "permission denied for schema public" + ); + assert_eq!( + super::format_db_error("insert violates foreign key", Some("Key (id)=(1)"), None), + "insert violates foreign key (Key (id)=(1))" + ); + assert_eq!( + super::format_db_error( + "column does not exist", + None, + Some("Perhaps you meant \"b\"") + ), + "column does not exist. Hint: Perhaps you meant \"b\"" + ); + } + + #[test] + fn non_db_error_walks_the_source_chain() { + #[derive(Debug)] + struct Layer(&'static str, Option>); + impl std::fmt::Display for Layer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.0) + } + } + impl std::error::Error for Layer { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + self.1 + .as_ref() + .map(|c| c.as_ref() as &(dyn std::error::Error + 'static)) + } + } + + // The pinned rust-postgres build renders only the kind, so everything + // actionable is in the causes: they must all reach the message. + let err = Layer( + "error connecting to server", + Some(Box::new(Layer( + "tcp connect error", + Some(Box::new(Layer("timed out", None))), + ))), + ); + assert_eq!( + super::error_source_chain(&err), + "error connecting to server: tcp connect error: timed out" + ); + } } diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index 1eda7503af..30a5edad32 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -1257,7 +1257,7 @@ pub async fn create_custom_instance_database( tracing::warn!( "Failed to grant permissions on '{}': {}. Continuing.", dbname, - e + crate::error::pg_error_message(&e) ); } diff --git a/backend/windmill-common/src/query_builders.rs b/backend/windmill-common/src/query_builders.rs index c0eedadbc4..c315a564af 100644 --- a/backend/windmill-common/src/query_builders.rs +++ b/backend/windmill-common/src/query_builders.rs @@ -1,6 +1,8 @@ use serde::{Deserialize, Deserializer, Serialize}; use windmill_types::scripts::ScriptLang; +use crate::error::pg_error_message; + fn deserialize_bool_from_null<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, @@ -4848,7 +4850,7 @@ fn required_str<'a>( column: &str, ) -> Result<&'a str, String> { row.try_get(column) - .map_err(|e| format!("Failed to read column {}: {}", column, e))? + .map_err(|e| format!("Failed to read column {}: {}", column, pg_error_message(&e)))? .ok_or_else(|| format!("Unexpected NULL in column {}", column)) } @@ -4894,7 +4896,7 @@ pub async fn pg_get_full_schema( ) .await .map(simple_query_rows) - .map_err(|e| format!("Failed to query columns: {}", e))?; + .map_err(|e| format!("Failed to query columns: {}", pg_error_message(&e)))?; let fk_rows = client .simple_query( @@ -4922,7 +4924,7 @@ pub async fn pg_get_full_schema( ) .await .map(simple_query_rows) - .map_err(|e| format!("Failed to query foreign keys: {}", e))?; + .map_err(|e| format!("Failed to query foreign keys: {}", pg_error_message(&e)))?; let mut result: FullDatabaseSchema = std::collections::HashMap::new(); diff --git a/frontend/src/lib/components/workspaceSettings/DataTableSettings.svelte b/frontend/src/lib/components/workspaceSettings/DataTableSettings.svelte index f652ed8704..069ef6093d 100644 --- a/frontend/src/lib/components/workspaceSettings/DataTableSettings.svelte +++ b/frontend/src/lib/components/workspaceSettings/DataTableSettings.svelte @@ -51,7 +51,7 @@ - + (darkVariant = getDarkModeVariant())} + on:close={removeHash} + {disableChatOffset} +>
{#if scopes == undefined}
-
- Theme +
+
+ Theme +
+
+ Dark variant + { + darkVariant = v + setDarkModeVariant(v) + }} + > + {#snippet children({ item })} + + + {/snippet} + +
Windmill diff --git a/frontend/src/lib/components/apps/editor/PublicApp.svelte b/frontend/src/lib/components/apps/editor/PublicApp.svelte index 8995684e28..c0cf693e0f 100644 --- a/frontend/src/lib/components/apps/editor/PublicApp.svelte +++ b/frontend/src/lib/components/apps/editor/PublicApp.svelte @@ -1,5 +1,6 @@ - + { + darkVariant = getAppliedDarkModeVariant() + }} +/> diff --git a/frontend/src/lib/components/sidebar/sidebarChrome.ts b/frontend/src/lib/components/sidebar/sidebarChrome.ts index 110435c058..71ff703d27 100644 --- a/frontend/src/lib/components/sidebar/sidebarChrome.ts +++ b/frontend/src/lib/components/sidebar/sidebarChrome.ts @@ -1,5 +1,6 @@ -// Sidebar rail background. Deliberately raw hex — no theme token exists for -// the rail chrome — kept here as the single source for every host that renders -// or mimics the sidebar (app layout, kitchen-sink harness). +// Sidebar rail background. Kept here as the single source for every host that +// renders or mimics the sidebar (app layout, kitchen-sink harness). +// The dark value is a theme-driven CSS variable so it adapts across dark +// variants (default vs GitHub dark) — see tailwind.config.cjs `--sidebar-bg-dark`. export const SIDEBAR_BG = '#F3F3F7' -export const SIDEBAR_BG_DARK = '#1e232e' +export const SIDEBAR_BG_DARK = 'var(--sidebar-bg-dark)' diff --git a/frontend/src/lib/components/vscode.ts b/frontend/src/lib/components/vscode.ts index 1277ac1b4a..f30ffcd780 100644 --- a/frontend/src/lib/components/vscode.ts +++ b/frontend/src/lib/components/vscode.ts @@ -1,6 +1,7 @@ import '@codingame/monaco-vscode-standalone-typescript-language-features' import { editor as meditor, Uri as mUri } from 'monaco-editor' +import { getAppliedDarkModeVariant } from '$lib/darkModeVariant' export let isInitialized = false export let isInitializing = false @@ -12,6 +13,15 @@ import { import getLanguagesServiceOverride from '@codingame/monaco-vscode-languages-service-override' import { getCssColor } from '$lib/utils' +// Monaco theme name matching the current document theme (light / dark / GitHub dark variant). +export function getEditorTheme(): string { + const classes = document.documentElement.classList + if (!classes.contains('dark')) { + return 'myTheme' + } + return getAppliedDarkModeVariant() === 'github' ? 'github-dark' : 'nord' +} + export function buildWorkerDefinition() { const envEnhanced = getEnhancedMonacoEnvironment() const getWorker = (moduleId: string, label: string) => { @@ -248,6 +258,47 @@ export async function initializeVscode(caller?: string, htmlContainer?: HTMLElem } }) + meditor.defineTheme('github-dark', { + base: 'vs-dark', + inherit: true, + rules: [ + { background: '0D1117', token: '' }, + { foreground: '8b949e', token: 'comment' }, + { foreground: 'a5d6ff', token: 'string' }, + { foreground: '79c0ff', token: 'constant.numeric' }, + { foreground: '79c0ff', token: 'constant.language' }, + { foreground: 'ff7b72', token: 'keyword' }, + { foreground: 'ff7b72', token: 'storage' }, + { foreground: 'ff7b72', token: 'storage.type' }, + { foreground: 'ffa657', token: 'entity.name.class' }, + { foreground: 'ffa657', fontStyle: 'bold', token: 'entity.other.inherited-class' }, + { foreground: 'd2a8ff', token: 'entity.name.function' }, + { foreground: '7ee787', token: 'entity.name.tag' }, + { foreground: '79c0ff', token: 'entity.other.attribute-name' }, + { foreground: 'd2a8ff', token: 'support.function' }, + { foreground: 'f0f6fc', background: 'f85149', token: 'invalid' }, + { foreground: 'f0f6fc', background: 'bd561d', token: 'invalid.deprecated' }, + { foreground: '79c0ff', token: 'constant.color.other.rgb-value' }, + { foreground: '79c0ff', token: 'constant.character.escape' }, + { foreground: 'ffa657', token: 'variable.other.constant' }, + { token: 'string.value.json', foreground: 'a5d6ff' }, // string values in JSON + { token: 'keyword.json', foreground: '79c0ff' } // true, false, null in JSON + ], + colors: { + 'editor.foreground': '#e6edf3', + 'editor.background': getCssColor('surface-input', { format: 'hex-github-dark' }), + 'editor.selectionBackground': '#3392FF44', + 'editor.inactiveSelectionBackground': '#3392FF22', + 'editor.lineHighlightBackground': '#161b22', + 'editorCursor.foreground': '#e6edf3', + 'editorWhitespace.foreground': '#484f58', + 'editorIndentGuide.background1': '#21262d', + 'editorIndentGuide.activeBackground1': '#30363d', + 'editorLineNumber.foreground': '#6e7681', + 'editorLineNumber.activeForeground': '#e6edf3' + } + }) + meditor.defineTheme('myTheme', { base: 'vs', inherit: true, @@ -262,11 +313,7 @@ export async function initializeVscode(caller?: string, htmlContainer?: HTMLElem } }) - if (document.documentElement.classList.contains('dark')) { - meditor.setTheme('nord') - } else { - meditor.setTheme('myTheme') - } + meditor.setTheme(getEditorTheme()) } catch (e) { console.error('Failed to initialize monaco services', e) } finally { diff --git a/frontend/src/lib/darkModeVariant.ts b/frontend/src/lib/darkModeVariant.ts new file mode 100644 index 0000000000..1e1bbff28b --- /dev/null +++ b/frontend/src/lib/darkModeVariant.ts @@ -0,0 +1,27 @@ +export type DarkModeVariant = 'default' | 'github' + +const DARK_MODE_VARIANT_KEY = 'dark-mode-variant' + +export function getDarkModeVariant(): DarkModeVariant { + return window.localStorage.getItem(DARK_MODE_VARIANT_KEY) === 'github' ? 'github' : 'default' +} + +// The variant currently applied to the DOM (the `github-dark` class) — the +// runtime source of truth, as opposed to the persisted preference above. +export function getAppliedDarkModeVariant(): DarkModeVariant { + return typeof document !== 'undefined' && + document.documentElement.classList.contains('github-dark') + ? 'github' + : 'default' +} + +// The `github-dark` class only takes effect when `dark` is also present (see tailwind.config.cjs). +// It lives on permanently so it survives the many independent `dark` toggle sites. +export function applyDarkModeVariant(variant: DarkModeVariant = getDarkModeVariant()): void { + document.documentElement.classList.toggle('github-dark', variant === 'github') +} + +export function setDarkModeVariant(variant: DarkModeVariant): void { + window.localStorage.setItem(DARK_MODE_VARIANT_KEY, variant) + applyDarkModeVariant(variant) +} diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 28d33335ae..e9acfdacab 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -2029,6 +2029,8 @@ export function validateRetryConfig(retry: Retry | undefined): string | null { } export type CssColor = keyof (typeof tokensFile)['tokens']['light'] import tokensFile from './assets/tokens/tokens.json' +// Hand-authored variant kept out of the Figma-generated tokens.json. +import githubDarkTokens from './assets/tokens/githubDark.json' import { darkModeName, lightModeName } from './assets/tokens/colorTokensConfig' import BarsStaggered from './components/icons/BarsStaggered.svelte' import { GitIcon } from './components/icons' @@ -2041,7 +2043,7 @@ export function getCssColor( format = 'css-var' }: { alpha?: number - format?: 'css-var' | 'hex-dark' | 'hex-light' + format?: 'css-var' | 'hex-dark' | 'hex-light' | 'hex-github-dark' } ): string { if (format === 'hex-light') { @@ -2050,6 +2052,9 @@ export function getCssColor( if (format === 'hex-dark') { return tokensFile.tokens[darkModeName][color] } + if (format === 'hex-github-dark') { + return githubDarkTokens[color] + } return `rgb(var(--color-${color}) / ${alpha})` } diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index 7dbe5ca376..9de1123ff9 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -1052,7 +1052,7 @@ style:width="{railWidth}rem" >
{#if !isCollapsed} diff --git a/frontend/src/routes/(root)/+layout.svelte b/frontend/src/routes/(root)/+layout.svelte index 8b7736965d..3db5efcf60 100644 --- a/frontend/src/routes/(root)/+layout.svelte +++ b/frontend/src/routes/(root)/+layout.svelte @@ -19,6 +19,7 @@ // import EditorTheme from '$lib/components/EditorTheme.svelte' import { computeDrift } from '$lib/forLater' import { setLicense } from '$lib/enterpriseUtils' + import { applyDarkModeVariant } from '$lib/darkModeVariant' import { deepEqual } from 'fast-equals' interface Props { children?: import('svelte').Snippet @@ -255,6 +256,8 @@ } else { document.documentElement.classList.remove('dark') } + + applyDarkModeVariant() {@render children?.()} diff --git a/frontend/src/routes/user/login_callback/[client_name]/+page.svelte b/frontend/src/routes/user/login_callback/[client_name]/+page.svelte index 737da14321..1aeb57f223 100644 --- a/frontend/src/routes/user/login_callback/[client_name]/+page.svelte +++ b/frontend/src/routes/user/login_callback/[client_name]/+page.svelte @@ -1,5 +1,6 @@ + + + {#if state?.duration_ms} +
+ {msToSec(state.duration_ms)}s +
+ {/if} +
+
+ +
+ +
+ {data.module.summary || 'Error handler'} +
+
+ {#snippet text()} + Marks where the error handler ran during the last run. It is not a step of the flow: edit + it from the error handler button above the graph. + {/snippet} +
+ + + + {#snippet text()} + Hide this marker until the next run. The error handler itself is kept. + {/snippet} + +
+
+
From 7ac2909b302c7ffa8589e9676c5cc006b8f5708f Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 28 Jul 2026 18:59:01 +0200 Subject: [PATCH 14/24] fix: don't crash the flow editor when a step with an error handler marker is deleted (#10393) * fix(frontend): don't anchor an error handler marker to a deleted step * test(frontend): pin that topologicalSort tolerates a missing parent node --- .../components/graph/graphBuilder.svelte.ts | 11 ++++++++- .../lib/components/graph/graphBuilder.test.ts | 23 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 frontend/src/lib/components/graph/graphBuilder.test.ts diff --git a/frontend/src/lib/components/graph/graphBuilder.svelte.ts b/frontend/src/lib/components/graph/graphBuilder.svelte.ts index 7a568bdbe4..aefe57da68 100644 --- a/frontend/src/lib/components/graph/graphBuilder.svelte.ts +++ b/frontend/src/lib/components/graph/graphBuilder.svelte.ts @@ -402,7 +402,10 @@ export function topologicalSort( if (visited.has(id)) return visited.add(id) - const node = nodeMap.get(id)! + // A parent id with no node is a bug in whoever built the graph, but the whole editor + // unmounts if this throws, so skip it and let the rest of the graph render. + const node = nodeMap.get(id) + if (!node) return node.parentIds?.forEach(visit) result.push(node) } @@ -1245,6 +1248,12 @@ export function graphBuilder( }) Object.entries(toAdd).forEach((x) => { + // Run state outlives the flow it ran on: a step deleted since the run keeps its marker + // in `flowModuleStates`. Anchoring the marker to a step that is no longer in the graph + // leaves a parent id no node answers to, which the layout cannot sort. + if (!nodes.some((n) => n.id === x[0])) { + return + } addFailureNode({ ...failureModule, id: x[1] }) addEdge(x[0], x[1], undefined, undefined, { type: 'empty' }) }) diff --git a/frontend/src/lib/components/graph/graphBuilder.test.ts b/frontend/src/lib/components/graph/graphBuilder.test.ts new file mode 100644 index 0000000000..1203ab57cd --- /dev/null +++ b/frontend/src/lib/components/graph/graphBuilder.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect, vi } from 'vitest' + +// Mock modules that transitively import CSS/Monaco +vi.mock('monaco-editor', () => ({})) +vi.mock('@xyflow/svelte', () => ({})) +vi.mock('./renderers/nodes/AssetNode.svelte', () => ({ + assetDisplaysAsOutputInFlowGraph: () => false +})) +vi.mock('../modulesTest.svelte', () => ({})) + +import { topologicalSort } from './graphBuilder.svelte' + +describe('topologicalSort', () => { + it('skips parent ids with no node instead of throwing', () => { + const sorted = topologicalSort([ + { id: 'a' }, + { id: 'b', parentIds: ['a'] }, + { id: 'orphan', parentIds: ['deleted'] } + ]) + + expect(sorted.map((n) => n.id)).toEqual(['orphan', 'b', 'a']) + }) +}) From 0de18dc017fbe2169f9ccf8dcf903c7684df2dae Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 28 Jul 2026 18:59:14 +0200 Subject: [PATCH 15/24] chore(main): release 1.775.0 (#10392) * chore(main): release 1.775.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- CHANGELOG.md | 14 ++ backend/Cargo.lock | 170 +++++++++--------- backend/Cargo.toml | 4 +- .../parsers/windmill-parser-wasm/Cargo.lock | 48 ++--- .../parsers/windmill-parser-wasm/Cargo.toml | 2 +- backend/windmill-api/openapi.yaml | 2 +- benchmarks/lib.ts | 2 +- cli/src/core/constants.ts | 2 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- lsp/Pipfile | 2 +- openflow.openapi.yaml | 2 +- .../WindmillClient/WindmillClient.psd1 | 2 +- python-client/wmill/pyproject.toml | 2 +- typescript-client/jsr.json | 2 +- typescript-client/package.json | 2 +- version.txt | 2 +- 17 files changed, 139 insertions(+), 125 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fdcf834d59..3eb210ce41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [1.775.0](https://github.com/windmill-labs/windmill/compare/v1.774.0...v1.775.0) (2026-07-28) + + +### Features + +* add github dark mode variant switchable in user settings ([#10002](https://github.com/windmill-labs/windmill/issues/10002)) ([bd24664](https://github.com/windmill-labs/windmill/commit/bd246644c962f754a8477353d49ed39ba9b87ffd)) +* validate and make explicit the hub project's resource type export ([#10388](https://github.com/windmill-labs/windmill/issues/10388)) ([91b8ce5](https://github.com/windmill-labs/windmill/commit/91b8ce581a15baf7fc9ea4e983b50f233cf5e023)) + + +### Bug Fixes + +* render the flow editor's error handler node as an inert run marker ([#10391](https://github.com/windmill-labs/windmill/issues/10391)) ([bff6545](https://github.com/windmill-labs/windmill/commit/bff654596fb9cf8b086919367614ba0915cd7b87)) +* truncate strings on char boundaries to avoid panics on multibyte input ([#10390](https://github.com/windmill-labs/windmill/issues/10390)) ([faa2aaf](https://github.com/windmill-labs/windmill/commit/faa2aaf214444f4fbb129f10a46060d4a8ab3ae8)) + ## [1.774.0](https://github.com/windmill-labs/windmill/compare/v1.773.0...v1.774.0) (2026-07-28) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 51c2338ec8..8a96ea40aa 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -4482,13 +4482,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -7073,7 +7073,7 @@ dependencies = [ "bitflags 2.13.1", "libc", "plain", - "redox_syscall 0.9.0", + "redox_syscall 0.9.1", ] [[package]] @@ -9920,9 +9920,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5102a6aaa05aa011a238e178e6bca86d2cb56fc9f586d37cb80f5bca6e07759" +checksum = "07507be7b4a5f9f26eeb41eeaebb1f5a7ff29dfb29739facc21d35bf8b11c21e" dependencies = [ "bitflags 2.13.1", ] @@ -14489,7 +14489,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "async-nats", @@ -14574,7 +14574,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.774.0" +version = "1.775.0" dependencies = [ "async-stream", "async-trait", @@ -14607,7 +14607,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.774.0" +version = "1.775.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14620,7 +14620,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "argon2", @@ -14759,7 +14759,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.774.0" +version = "1.775.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14782,7 +14782,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.774.0" +version = "1.775.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14797,7 +14797,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14823,7 +14823,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.774.0" +version = "1.775.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -14833,7 +14833,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.774.0" +version = "1.775.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14850,7 +14850,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.774.0" +version = "1.775.0" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -14872,7 +14872,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14895,7 +14895,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.774.0" +version = "1.775.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14911,7 +14911,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.774.0" +version = "1.775.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14932,7 +14932,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.774.0" +version = "1.775.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14953,7 +14953,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.774.0" +version = "1.775.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14967,7 +14967,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "async-nats", @@ -15002,7 +15002,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15027,7 +15027,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.774.0" +version = "1.775.0" dependencies = [ "axum 0.8.9", "flate2", @@ -15045,7 +15045,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15067,7 +15067,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.774.0" +version = "1.775.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15087,7 +15087,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.774.0" +version = "1.775.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15124,7 +15124,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15152,7 +15152,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.774.0" +version = "1.775.0" dependencies = [ "lazy_static", "serde", @@ -15164,7 +15164,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.774.0" +version = "1.775.0" dependencies = [ "argon2", "axum 0.8.9", @@ -15189,7 +15189,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.774.0" +version = "1.775.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15203,7 +15203,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.774.0" +version = "1.775.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15238,7 +15238,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.774.0" +version = "1.775.0" dependencies = [ "chrono", "lazy_static", @@ -15252,7 +15252,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15271,7 +15271,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.774.0" +version = "1.775.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -15375,7 +15375,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.774.0" +version = "1.775.0" dependencies = [ "chrono", "itertools 0.14.0", @@ -15394,7 +15394,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.774.0" +version = "1.775.0" dependencies = [ "regex", "serde", @@ -15409,7 +15409,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -15433,7 +15433,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "futures", @@ -15450,7 +15450,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.774.0" +version = "1.775.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -15466,7 +15466,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "async-trait", @@ -15487,7 +15487,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "async-trait", @@ -15518,7 +15518,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "arc-swap", @@ -15543,7 +15543,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "async-stream", @@ -15577,7 +15577,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "futures", @@ -15595,7 +15595,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.774.0" +version = "1.775.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -15604,7 +15604,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "lazy_static", @@ -15616,7 +15616,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "serde_json", @@ -15628,7 +15628,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "gosyn", @@ -15640,7 +15640,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "lazy_static", @@ -15652,7 +15652,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "serde_json", @@ -15664,7 +15664,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "nu-parser", @@ -15675,7 +15675,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -15686,7 +15686,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -15698,7 +15698,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "rustpython-ast", @@ -15709,7 +15709,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "async-recursion", @@ -15731,7 +15731,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "serde_json", @@ -15743,7 +15743,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "lazy_static", @@ -15757,7 +15757,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -15774,7 +15774,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "lazy_static", @@ -15787,7 +15787,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "serde", @@ -15799,7 +15799,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "lazy_static", @@ -15817,7 +15817,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -15833,7 +15833,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "rustpython-ast", @@ -15849,7 +15849,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "serde", @@ -15860,7 +15860,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "async-recursion", @@ -15899,7 +15899,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "const_format", @@ -15939,7 +15939,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.774.0" +version = "1.775.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -15950,7 +15950,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "async-recursion", @@ -15984,7 +15984,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "async-trait", @@ -16008,7 +16008,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "async-trait", @@ -16041,7 +16041,7 @@ dependencies = [ [[package]] name = "windmill-trigger-amqp" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "async-trait", @@ -16068,7 +16068,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "async-trait", @@ -16101,7 +16101,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "async-trait", @@ -16121,7 +16121,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "async-trait", @@ -16155,7 +16155,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "async-trait", @@ -16191,7 +16191,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "async-trait", @@ -16214,7 +16214,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "async-trait", @@ -16238,7 +16238,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "async-nats", @@ -16262,7 +16262,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "async-trait", @@ -16297,7 +16297,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "async-trait", @@ -16325,7 +16325,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "async-trait", @@ -16350,7 +16350,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "bitflags 2.13.1", @@ -16369,7 +16369,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "async-once-cell", @@ -16484,7 +16484,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.774.0" +version = "1.775.0" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 27421f1052..41b511effd 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.774.0" +version = "1.775.0" authors.workspace = true edition.workspace = true @@ -88,7 +88,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"] [workspace.package] -version = "1.774.0" +version = "1.775.0" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index 1c110c1a3a..ae0e40d701 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.774.0" +version = "1.775.0" dependencies = [ "aho-corasick", "anyhow", @@ -6272,7 +6272,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.774.0" +version = "1.775.0" dependencies = [ "proc-macro2", "quote", @@ -6284,7 +6284,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.774.0" +version = "1.775.0" dependencies = [ "convert_case", "serde", @@ -6293,7 +6293,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "lazy_static", @@ -6305,7 +6305,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "serde_json", @@ -6317,7 +6317,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "gosyn", @@ -6329,7 +6329,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "lazy_static", @@ -6341,7 +6341,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "serde_json", @@ -6353,7 +6353,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "nu-parser", @@ -6364,7 +6364,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6375,7 +6375,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6387,7 +6387,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6398,7 +6398,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "async-recursion", @@ -6420,7 +6420,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "serde_json", @@ -6432,7 +6432,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "lazy_static", @@ -6446,7 +6446,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "convert_case", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "lazy_static", @@ -6476,7 +6476,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "serde", @@ -6488,7 +6488,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "lazy_static", @@ -6506,7 +6506,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6522,7 +6522,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6538,7 +6538,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6570,7 +6570,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "serde", @@ -6581,7 +6581,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.774.0" +version = "1.775.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index 4f372a5a5f..1fc6a3bddb 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.toml +++ b/backend/parsers/windmill-parser-wasm/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" members = ["."] [workspace.package] -version = "1.774.0" +version = "1.775.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index aaec1d2fb3..b695cfa6bd 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.774.0 + version: 1.775.0 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 2a211772a9..392090630e 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.774.0"; +export const VERSION = "v1.775.0"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/src/core/constants.ts b/cli/src/core/constants.ts index 2039ac17b6..1228b95cf1 100644 --- a/cli/src/core/constants.ts +++ b/cli/src/core/constants.ts @@ -10,4 +10,4 @@ export const WM_FORK_PREFIX = "wm-fork"; // (e.g. utils.ts) can read it without importing main.ts and creating a circular // dependency (main → workspace → utils → main) that triggers a TDZ. // Re-exported from main.ts for backwards compatibility. -export const VERSION = "1.774.0"; +export const VERSION = "1.775.0"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 928a866e7d..4382a820d9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.774.0", + "version": "1.775.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.774.0", + "version": "1.775.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 15b367e6d5..5179a62a52 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.774.0", + "version": "1.775.0", "scripts": { "dev": "vite dev", "dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev", diff --git a/lsp/Pipfile b/lsp/Pipfile index b5410d4f7b..4a26d80da0 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.774.0" +wmill = ">=1.775.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 32f6991584..65b50c6475 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.774.0 + version: 1.775.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index c1071fae9f..395c56e99a 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.774.0' + ModuleVersion = '1.775.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index f3296b4149..cb3ff3a9eb 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.774.0" +version = "1.775.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 f5dce5c510..8555ae900b 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.774.0", + "version": "1.775.0", "exports": "./src/index.ts", "publish": { "exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts", "./wacError.ts"] diff --git a/typescript-client/package.json b/typescript-client/package.json index 55f118db9e..6a5df397a8 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.774.0", + "version": "1.775.0", "author": "Ruben Fiszel", "license": "Apache 2.0", "homepage": "https://github.com/windmill-labs/windmill/tree/main/typescript-client#readme", diff --git a/version.txt b/version.txt index 9ec4850224..cce24028ca 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.774.0 +1.775.0 From d842f765a457f0c80a2cd1eb06bc703167512160 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Tue, 28 Jul 2026 19:34:17 +0200 Subject: [PATCH 16/24] fix(ai): render pending parallel tool calls as faded queued cards (#10208) * fix(ai): show queued state instead of spinner for pending parallel tool calls Co-Authored-By: Claude Fable 5 * fix(ai): clear queued tool cards on chat cancel or stream error Co-Authored-By: Claude Fable 5 * fix(ai): render queued tool calls faded with imperative labels Co-Authored-By: Claude Fable 5 * fix(ai): keep queued labels past preAction, humanize camelCase names Co-Authored-By: Claude Fable 5 * style: drop narrating comment in queuedToolStatus Co-Authored-By: Claude Fable 5 * fix(ai): run preAction at tool promotion, dedupe queued-state comments Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .../copilot/chat/AIChatManager.svelte.ts | 3 +- .../copilot/chat/ToolExecutionDisplay.svelte | 10 ++- .../lib/components/copilot/chat/anthropic.ts | 32 ++++--- .../components/copilot/chat/global/core.ts | 3 + .../copilot/chat/openai-responses.ts | 12 +-- .../components/copilot/chat/shared.test.ts | 62 +++++++++++++ .../src/lib/components/copilot/chat/shared.ts | 44 ++++++++- .../components/copilot/lib.toolCalls.test.ts | 89 +++++++++++++++++++ frontend/src/lib/components/copilot/lib.ts | 32 +++++-- 9 files changed, 261 insertions(+), 26 deletions(-) diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index a1f90c546d..4bfb6f90df 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -3828,10 +3828,11 @@ export class AIChatManager { cancelLoadingTools = (messageText: 'Canceled' | 'Error' = 'Canceled') => { this.displayMessages = this.displayMessages.map((message) => { - if (message.role === 'tool' && message.isLoading) { + if (message.role === 'tool' && (message.isLoading || message.isQueued)) { return { ...message, isLoading: false, + isQueued: false, // A question's card disappears once canceled, so keep the question // itself readable in the collapsed header. content: message.userQuestion diff --git a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte index 8360ed962f..a1ee9984e5 100644 --- a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte @@ -26,6 +26,7 @@ const isSuccessful = $derived( !message.isLoading && + !message.isQueued && !message.error && !message.needsConfirmation && !message.isStreamingArguments @@ -64,7 +65,14 @@ {#if activeUserQuestion} {:else} -
+ +
{#snippet headerButton()}
{/if}
@@ -1067,26 +1048,3 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. --> {/if}
- - diff --git a/frontend/src/lib/components/copilot/chat/ChatTypingIndicator.svelte b/frontend/src/lib/components/copilot/chat/ChatTypingIndicator.svelte index 7c52de5cdf..1afe8aff4a 100644 --- a/frontend/src/lib/components/copilot/chat/ChatTypingIndicator.svelte +++ b/frontend/src/lib/components/copilot/chat/ChatTypingIndicator.svelte @@ -1,26 +1,37 @@ - - - - - - - {label ? label + ' · ' : ''}{formatElapsed(loadingElapsedMs)} - + + Waiting for your input + +{:else} + + + + + + + {label ? label + ' · ' : ''}{formatElapsed(elapsedMs)} + +{/if} diff --git a/frontend/src/lib/components/copilot/chat/shared.test.ts b/frontend/src/lib/components/copilot/chat/shared.test.ts index 0588d2a171..719638d700 100644 --- a/frontend/src/lib/components/copilot/chat/shared.test.ts +++ b/frontend/src/lib/components/copilot/chat/shared.test.ts @@ -888,6 +888,55 @@ describe('isActiveUserQuestion', () => { }) }) +describe('pendingUserAction', () => { + const toolMessage = (overrides: Partial = {}): ToolDisplayMessage => ({ + role: 'tool', + tool_call_id: 'call_p', + content: 'running', + isLoading: true, + ...overrides + }) + + const question = toolMessage({ userQuestion: { question: 'Pick one', choices: ['a'] } }) + + it('distinguishes an unanswered question from a staged confirmation', async () => { + const { pendingUserAction } = await import('./shared') + expect(pendingUserAction([question])).toBe('question') + expect(pendingUserAction([toolMessage({ needsConfirmation: true })])).toBe('confirmation') + }) + + it('is undefined for a tool the AI is running on its own', async () => { + const { pendingUserAction } = await import('./shared') + expect(pendingUserAction([toolMessage()])).toBe(undefined) + expect(pendingUserAction([toolMessage({ needsConfirmation: true, isLoading: false })])).toBe( + undefined + ) + }) + + // A multi-tool turn creates every card before running the calls one at a time, + // so the blocked card is not the last message. + it('finds a blocked card sitting behind queued ones', async () => { + const { pendingUserAction } = await import('./shared') + expect(pendingUserAction([question, toolMessage(), toolMessage()])).toBe('question') + expect(pendingUserAction([toolMessage({ needsConfirmation: true }), toolMessage()])).toBe( + 'confirmation' + ) + }) + + // Text emitted between two tool calls lands as an assistant card between them. + it('finds a blocked card behind an interleaved assistant card', async () => { + const { pendingUserAction } = await import('./shared') + const assistant: DisplayMessage = { role: 'assistant', content: 'and also…' } + expect(pendingUserAction([question, assistant, toolMessage()])).toBe('question') + }) + + it('stops at the previous turn rather than reviving its resolved cards', async () => { + const { pendingUserAction } = await import('./shared') + const userMessage: DisplayMessage = { role: 'user', index: 0, content: 'go on' } + expect(pendingUserAction([question, userMessage, toolMessage()])).toBe(undefined) + }) +}) + describe('pollJobCompletion detach', () => { function makeCallbacks() { return { diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts index 76a1595c7b..131392a665 100644 --- a/frontend/src/lib/components/copilot/chat/shared.ts +++ b/frontend/src/lib/components/copilot/chat/shared.ts @@ -650,6 +650,26 @@ export function isActiveUserQuestion(message: DisplayMessage | undefined): boole ) } +// The loop is parked on the user: an unanswered askUserQuestion, or a tool call +// staged for confirmation. The manager stays `loading` through both, so anything +// rendering progress must ask here first or it reports "the AI is working". +export type PendingUserAction = 'question' | 'confirmation' + +// Scans back to the turn boundary, not just the last message: a turn's cards are +// created up front and run one at a time, and text between two tool calls pushes +// an assistant card between them, so the blocked card is rarely last. Only cards +// of a live turn can match — every resolution path clears `isLoading`. +export function pendingUserAction(messages: DisplayMessage[]): PendingUserAction | undefined { + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i] + if (message.role === 'user') break + if (message.role !== 'tool') continue + if (isActiveUserQuestion(message)) return 'question' + if (message.needsConfirmation && message.isLoading) return 'confirmation' + } + return undefined +} + // Fires after every tool call resolves, with the tool name. Lets a host (e.g. // the sessions page) react to mutating tools — refreshing previews — without // the tool layer knowing about the UI. Single slot; the consumer filters by name diff --git a/frontend/src/lib/components/sessions/SessionStatusDot.svelte b/frontend/src/lib/components/sessions/SessionStatusDot.svelte index 4c0ef4c408..646163bcaf 100644 --- a/frontend/src/lib/components/sessions/SessionStatusDot.svelte +++ b/frontend/src/lib/components/sessions/SessionStatusDot.svelte @@ -1,11 +1,5 @@ diff --git a/frontend/src/lib/components/git_sync/GitSyncContext.svelte.ts b/frontend/src/lib/components/git_sync/GitSyncContext.svelte.ts index c69245b0e5..5856c3bfee 100644 --- a/frontend/src/lib/components/git_sync/GitSyncContext.svelte.ts +++ b/frontend/src/lib/components/git_sync/GitSyncContext.svelte.ts @@ -2,6 +2,7 @@ import { getContext, setContext } from 'svelte' import { enterpriseLicense } from '$lib/stores' import { get } from 'svelte/store' import { sendUserToast } from '$lib/toast' +import { apiErrorMessage } from '$lib/utils' import { JobService, WorkspaceService, ResourceService } from '$lib/gen' import type { GitRepositorySettings as BackendGitRepositorySettings, @@ -373,7 +374,7 @@ export function createGitSyncContext(workspace: string) { } repo.detectionState = 'error' - repo.detectionError = error?.message || error?.toString() || 'Failed to detect repository' + repo.detectionError = apiErrorMessage(error) || 'Failed to detect repository' repo.detectionJobStatus = 'failure' } } @@ -599,11 +600,7 @@ export function createGitSyncContext(workspace: string) { }) } catch (error: any) { // Initialize the job entry if it doesn't exist (e.g., job creation failed) - const errorMessage = - (typeof error?.body === 'string' ? error.body : error?.body?.message) || - error?.message || - error?.toString() || - 'Failed to run test job' + const errorMessage = apiErrorMessage(error) || 'Failed to run test job' if (!gitSyncTestJobs[idx]) { gitSyncTestJobs[idx] = { jobId: '', diff --git a/frontend/src/lib/components/git_sync/GitSyncModalManager.svelte b/frontend/src/lib/components/git_sync/GitSyncModalManager.svelte index 05925d6e26..610dafb562 100644 --- a/frontend/src/lib/components/git_sync/GitSyncModalManager.svelte +++ b/frontend/src/lib/components/git_sync/GitSyncModalManager.svelte @@ -4,6 +4,7 @@ import PullWorkspaceModal from '$lib/components/git_sync/PullWorkspaceModal.svelte' import GitSyncSuccessModal from '$lib/components/git_sync/GitSyncSuccessModal.svelte' import { sendUserToast } from '$lib/toast' + import { apiErrorMessage } from '$lib/utils' const gitSyncContext = $state(getGitSyncContext()) @@ -15,11 +16,17 @@ // If this was a repository initialization, auto-save the connection if (repo.isUnsavedConnection && repo.detectionState === 'no-wmill') { - gitSyncContext.saveRepository(idx).then(() => { - sendUserToast('Repository initialized and connection saved successfully') - }).catch((error) => { - sendUserToast('Repository initialized but failed to save connection: ' + error.message, true) - }) + gitSyncContext + .saveRepository(idx) + .then(() => { + sendUserToast('Repository initialized and connection saved successfully') + }) + .catch((error) => { + sendUserToast( + 'Repository initialized but failed to save connection: ' + apiErrorMessage(error), + true + ) + }) } else { sendUserToast('Successfully pushed to git repository') } @@ -27,7 +34,6 @@ gitSyncContext.closePushModal() } - function handlePullSuccess() { sendUserToast('Successfully pulled from git repository') gitSyncContext.closePullModal() @@ -54,7 +60,7 @@ sendUserToast('Connection saved successfully without initializing repository') gitSyncContext.closePushModal() } catch (error: any) { - sendUserToast('Failed to save connection: ' + error.message, true) + sendUserToast('Failed to save connection: ' + apiErrorMessage(error), true) } } diff --git a/frontend/src/lib/components/git_sync/GitSyncRepositoryCard.svelte b/frontend/src/lib/components/git_sync/GitSyncRepositoryCard.svelte index 30a8c3ef47..18a8adddfc 100644 --- a/frontend/src/lib/components/git_sync/GitSyncRepositoryCard.svelte +++ b/frontend/src/lib/components/git_sync/GitSyncRepositoryCard.svelte @@ -16,6 +16,7 @@ import GitSyncFilterSettings from '$lib/components/workspaceSettings/GitSyncFilterSettings.svelte' import DetectionFlow from './DetectionFlow.svelte' import { sendUserToast } from '$lib/toast' + import { apiErrorMessage } from '$lib/utils' import { fade } from 'svelte/transition' import { workspaceStore, userWorkspaces, enterpriseLicense } from '$lib/stores' import type { GitSyncRepository } from './GitSyncContext.svelte' @@ -114,7 +115,10 @@ repo.use_individual_branch = prevIndiv repo.group_by_folder = prevGbf } - sendUserToast(`Could not ${v ? 'enable' : 'disable'} Git promotion: ${e}`, true) + sendUserToast( + `Could not ${v ? 'enable' : 'disable'} Git promotion: ${apiErrorMessage(e)}`, + true + ) } finally { savingDevPromotion = false } @@ -128,7 +132,7 @@ await gitSyncContext.saveRepository(idx) } catch (e) { if (repo) repo.group_by_folder = prev - sendUserToast(`Could not change promotion granularity: ${e}`, true) + sendUserToast(`Could not change promotion granularity: ${apiErrorMessage(e)}`, true) } finally { savingDevPromotion = false } @@ -353,7 +357,7 @@ sendUserToast('Repository settings updated') } catch (error: any) { console.error('Failed to save repository:', error) - sendUserToast('Failed to save repository: ' + error.message, true) + sendUserToast('Failed to save repository: ' + apiErrorMessage(error), true) } } @@ -379,7 +383,7 @@ sendUserToast('Repository connection removed successfully') } catch (error: any) { console.error('Failed to remove repository:', error) - sendUserToast('Failed to remove repository: ' + error.message, true) + sendUserToast('Failed to remove repository: ' + apiErrorMessage(error), true) } finally { confirmingDelete = false } diff --git a/frontend/src/lib/components/git_sync/PullWorkspaceModal.svelte b/frontend/src/lib/components/git_sync/PullWorkspaceModal.svelte index 94845aa646..b42e290215 100644 --- a/frontend/src/lib/components/git_sync/PullWorkspaceModal.svelte +++ b/frontend/src/lib/components/git_sync/PullWorkspaceModal.svelte @@ -15,6 +15,7 @@ import { JobService, ResourceService } from '$lib/gen' import { workspaceStore, userWorkspaces } from '$lib/stores' import { sendUserToast } from '$lib/toast' + import { apiErrorMessage } from '$lib/utils' import hubPaths from '$lib/hubPaths.json' import { jobManager } from '$lib/services/JobManager' import type { SyncResponse, SettingsResponse, SettingsObject } from '$lib/git-sync' @@ -100,7 +101,7 @@ onSettingsSaved?.() } catch (error) { console.error('Failed to save settings:', error) - sendUserToast('Failed to save updated settings', true) + sendUserToast('Failed to save updated settings: ' + apiErrorMessage(error), true) } } @@ -289,7 +290,7 @@ } } catch (error: any) { console.error('Failed to apply settings:', error) - sendUserToast('Failed to apply settings: ' + error.message, true) + sendUserToast('Failed to apply settings: ' + apiErrorMessage(error), true) } finally { isApplying = false } diff --git a/frontend/src/lib/components/workspaceSettings/projectInstall.ts b/frontend/src/lib/components/workspaceSettings/projectInstall.ts index 1827e00283..387dc82ade 100644 --- a/frontend/src/lib/components/workspaceSettings/projectInstall.ts +++ b/frontend/src/lib/components/workspaceSettings/projectInstall.ts @@ -20,6 +20,7 @@ import { } from '../triggers/workspaceTriggersList' import { updatePolicy } from '$lib/components/apps/editor/appPolicy' import { updateRawAppPolicy } from '$lib/sharedUtils' +import { apiErrorMessage as errorMessage } from '$lib/utils' import type { App } from '$lib/components/apps/types' import { runScriptAndPollResult } from '$lib/components/jobs/utils' import { @@ -74,18 +75,6 @@ export function varContainmentViolation(value: any, folder: string): string | un return undefined } -// Surface the backend's explanation: API errors carry the real message in -// `.body` (plain text for Windmill 4xx), while `.message` is the generic -// status text ("Bad Request"). Prefer the body so e.g. a path/route_path -// collision reads as the actual reason, not just "Bad Request". -function errorMessage(e: any): string { - const body = e?.body - if (typeof body === 'string' && body.trim() !== '') return body - if (body && typeof body === 'object') - return body.error?.message ?? body.message ?? JSON.stringify(body) - return e?.message ?? String(e) -} - // Recompute an app's execution policy from its (retargeted) value, mirroring // what the editor does on deploy. `triggerables_v2` is keyed by // `:rawscript/`; retargeting rewrites that diff --git a/frontend/src/lib/components/workspaceSettings/projectMigrations.ts b/frontend/src/lib/components/workspaceSettings/projectMigrations.ts index b4e72389c0..8ff4da597d 100644 --- a/frontend/src/lib/components/workspaceSettings/projectMigrations.ts +++ b/frontend/src/lib/components/workspaceSettings/projectMigrations.ts @@ -19,6 +19,7 @@ import { type DatabaseSchema } from '$lib/components/datatableSchemaSql' import { WorkspaceService } from '$lib/gen' +import { apiErrorMessage } from '$lib/utils' import type { FetchedItem } from './projectBundle' export interface GeneratedMigration { @@ -229,14 +230,7 @@ function orderByFkDependency(schema: DatabaseSchema, tables: ResolvedTable[]): R // Pull a readable one-line message out of an API error for embedding in a SQL // comment (collapse whitespace so it can't break out of the `--` line). function errorText(e: any): string { - const body = e?.body - const raw = - typeof body === 'string' && body.trim() - ? body - : body && typeof body === 'object' - ? (body.error?.message ?? body.message ?? JSON.stringify(body)) - : (e?.message ?? String(e)) - return String(raw).replace(/\s+/g, ' ').trim() + return apiErrorMessage(e).replace(/\s+/g, ' ').trim() } /** diff --git a/frontend/src/lib/utils.test.ts b/frontend/src/lib/utils.test.ts index 38abc13d04..b0d18473f1 100644 --- a/frontend/src/lib/utils.test.ts +++ b/frontend/src/lib/utils.test.ts @@ -9,7 +9,8 @@ import { isTagTemplate, getQueryStmtCountHeuristic, isJobResolvable, - parseDbInputFromAssetSyntax + parseDbInputFromAssetSyntax, + apiErrorMessage } from './utils' // Mirrors the backend invariant that only `status = 'failure'` rows can carry a @@ -572,3 +573,28 @@ describe('findMatchingCustomTag', () => { ).toBeUndefined() }) }) + +// The generated client fills `message` with a canned per-status string ("Bad +// Request") and keeps the server's explanation in `body`, so preferring `body` +// is what makes a 4xx readable at all. +describe('apiErrorMessage', () => { + it('prefers a plain-text body over the canned status message', () => { + expect(apiErrorMessage({ message: 'Bad Request', body: 'Promotion mode is EE only' })).toBe( + 'Promotion mode is EE only' + ) + }) + + it('digs the message out of a JSON body', () => { + expect(apiErrorMessage({ message: 'Bad Request', body: { error: { message: 'nope' } } })).toBe( + 'nope' + ) + }) + + it('falls back to message when the body is blank', () => { + expect(apiErrorMessage({ message: 'Bad Request', body: ' ' })).toBe('Bad Request') + }) + + it('handles a plain Error', () => { + expect(apiErrorMessage(new Error('boom'))).toBe('boom') + }) +}) diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index e9acfdacab..2e538a635f 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -2464,3 +2464,14 @@ export function parsePrettyDate(text: string): Date | null { const date = new Date(text) return isNaN(date.getTime()) ? null : date } + +// Surface the backend's explanation: API errors carry the real message in +// `.body` (plain text for Windmill 4xx), while `.message` is only the generic +// status text ("Bad Request") the generated client fills in per status code. +export function apiErrorMessage(e: any): string { + const body = e?.body + if (typeof body === 'string' && body.trim() !== '') return body + if (body && typeof body === 'object') + return body.error?.message ?? body.message ?? JSON.stringify(body) + return e?.message ?? String(e) +} From 9c37b0217c79390271d9855abe135df02f35c41f Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 29 Jul 2026 11:13:16 +0200 Subject: [PATCH 21/24] fix(cli): compile runes in .svelte.ts / .svelte.js modules (#10400) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cli): compile runes in .svelte.ts / .svelte.js modules The svelte plugin only ran on `/\.svelte$/`, so a rune module like `lib.svelte.ts` was bundled as plain TypeScript: the types were stripped and `$state(0)` survived as a call to an undefined global, blowing up at runtime with "ReferenceError: $state is not defined". Route those files through `compileModule`. It parses with plain acorn and chokes on TypeScript, so types come off first via esbuild's transform — which is what vite-plugin-svelte gets for free by running after Vite's own esbuild transform. `wmill app dev` picks this up too; watch mode builds its plugin list with the same `createFrameworkPlugins`. Co-Authored-By: Claude Opus 5 (1M context) * chore: pin ui_builder to 1ffb28e Picks up the matching rune-module fix in the in-editor builder (windmill-labs/windmill-code-ui-builder#24), so `.svelte.ts` modules compile in the editor as well as through the CLI. Co-Authored-By: Claude Opus 5 (1M context) * fix(cli): compile raw apps with the app's own svelte compiler Svelte 5.52.0 moved delegated event handlers off `element.__click` onto a Symbol-keyed map. A raw app supplies its own Svelte *runtime* via package.json, but `import("svelte/compiler")` resolves against the CLI, whose own svelte floats independently — so the two can land on opposite sides of that change and the app builds, renders, and has every onclick/oninput silently dead. Resolve the compiler from the app's node_modules instead, so compiler and runtime are the same install by construction, and raise the CLI's own floor past the break for the fallback path. Also pin ui_builder to 013bf67, which carries the matching fix for the in-editor builder (windmill-labs/windmill-code-ui-builder#25), and move the Svelte raw-app template onto the same range. Those two go together: the new builder rejects a runtime that sits on the far side of the ABI break from its compiler. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- cli/bun.lock | 12 +- cli/package.json | 2 +- cli/src/commands/app/bundle.ts | 107 +++++++++++++--- ...pp_svelte_compiler_resolution_unit.test.ts | 89 +++++++++++++ cli/test/raw_app_svelte_module_unit.test.ts | 118 ++++++++++++++++++ frontend/scripts/ui_builder_artifact.json | 4 +- .../src/lib/components/raw_apps/templates.ts | 2 +- 7 files changed, 305 insertions(+), 29 deletions(-) create mode 100644 cli/test/raw_app_svelte_compiler_resolution_unit.test.ts create mode 100644 cli/test/raw_app_svelte_module_unit.test.ts diff --git a/cli/bun.lock b/cli/bun.lock index c783e411c7..c48e0f3f22 100644 --- a/cli/bun.lock +++ b/cli/bun.lock @@ -17,7 +17,7 @@ "minimatch": "^10.0.0", "open": "^10.0.0", "pg-gateway": "0.3.0-beta.4", - "svelte": "^5.45.2", + "svelte": "^5.56.8", "tar-stream": "^3.1.7", "windmill-parser-wasm-asset": "1.753.0", "windmill-parser-wasm-csharp": "1.510.1", @@ -156,7 +156,7 @@ "@stoplight/yaml-ast-parser": ["@stoplight/yaml-ast-parser@0.0.50", "", {}, "sha512-Pb6M8TDO9DtSVla9yXSTAxmo9GVEouq5P40DWXdOie69bXogZTkgvopCq+yEvTMA0F6PEvdJmbtTV3ccIp11VQ=="], - "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.9", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA=="], + "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.11", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw=="], "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], @@ -180,7 +180,7 @@ "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], - "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], + "aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="], "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], @@ -206,7 +206,7 @@ "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], - "devalue": ["devalue@5.6.3", "", {}, "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg=="], + "devalue": ["devalue@5.8.2", "", {}, "sha512-DObPPAfdtFbXjxLqK8s2Xk9ZuWz5+ZoFEhC7J76es4GU/rEiXwHTmbImoCdyoCOcBH1UF3+Cz6Z2sYD4hyl5TA=="], "diff": ["diff@5.2.2", "", {}, "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A=="], @@ -214,7 +214,7 @@ "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], - "esrap": ["esrap@2.2.3", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } }, "sha512-8fOS+GIGCQZl/ZIlhl59htOlms6U8NvX6ZYgYHpRU/b6tVSh3uHkOHZikl3D4cMbYM0JlpBe+p/BkZEi8J9XIQ=="], + "esrap": ["esrap@2.3.0", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-GQ/7RN8uOtEfNpzZzBMTzW9JBcX42oaSVtPzdF+6cEL8pqIL094iUpr9jzYGn4O4P/1S60dJ6izyT8F4LYARng=="], "events-universal": ["events-universal@1.0.1", "", { "dependencies": { "bare-events": "^2.7.0" } }, "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw=="], @@ -274,7 +274,7 @@ "string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], - "svelte": ["svelte@5.53.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.3", "esm-env": "^1.2.1", "esrap": "^2.2.2", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-yGONuIrcl/BMmqbm6/52Q/NYzfkta7uVlos5NSzGTfNJTTFtPPzra6rAQoQIwAqupeM3s9uuTf5PvioeiCdg9g=="], + "svelte": ["svelte@5.56.8", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.12", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-PY8LOw7xP6c8IOiVqdo0sbbZVYhXRSfklOQLAUyGBKqjTX0wx/z4l/9J+PmBpmlLnxzEb1NqltxQ5/wZme/Cmg=="], "tar-stream": ["tar-stream@3.1.7", "", { "dependencies": { "b4a": "^1.6.4", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ=="], diff --git a/cli/package.json b/cli/package.json index 548588a402..12f8e6158f 100644 --- a/cli/package.json +++ b/cli/package.json @@ -26,7 +26,7 @@ "minimatch": "^10.0.0", "open": "^10.0.0", "pg-gateway": "0.3.0-beta.4", - "svelte": "^5.45.2", + "svelte": "^5.56.8", "tar-stream": "^3.1.7", "windmill-parser-wasm-asset": "1.753.0", "windmill-parser-wasm-csharp": "1.510.1", diff --git a/cli/src/commands/app/bundle.ts b/cli/src/commands/app/bundle.ts index c515e1e86e..9987713e4f 100644 --- a/cli/src/commands/app/bundle.ts +++ b/cli/src/commands/app/bundle.ts @@ -2,6 +2,8 @@ import * as fs from "node:fs"; import * as path from "node:path"; import process from "node:process"; import { spawn } from "node:child_process"; +import { createRequire } from "node:module"; +import { pathToFileURL } from "node:url"; import * as log from "../../core/log.ts"; import { colors } from "@cliffy/ansi/colors"; import * as windmillUtils from "@windmill-labs/shared-utils"; @@ -78,38 +80,70 @@ export function detectFrameworks(appDir: string): { svelte: boolean; vue: boolea } } +/** + * Loads the Svelte compiler out of the *app's* node_modules. + * + * The app brings its own Svelte runtime via package.json, and compiler and + * runtime have to agree on internals: Svelte 5.52.0 moved delegated event + * handlers off `element.__click` onto a Symbol-keyed map, so an older + * compiler's `onclick` output is silently ignored by a newer runtime — the app + * builds and renders with every handler dead. A bare `import("svelte/compiler")` + * resolves against this CLI instead, whose own svelte floats independently of + * the app's, which is exactly how the two drift apart. + * + * Falls back to the CLI's own compiler when the app has none resolvable. + */ +export async function loadSvelteCompiler(appDir: string): Promise { + try { + const requireFromApp = createRequire( + path.join(path.resolve(appDir), "package.json") + ); + const entry = requireFromApp.resolve("svelte/compiler"); + return await import(pathToFileURL(entry).href); + } catch { + return await import("svelte/compiler"); + } +} + /** * Creates a Svelte esbuild plugin * Uses the svelte compiler from the project's node_modules */ function createSveltePlugin(appDir: string): any { + // Resolved once per build, not per file. + let compilerPromise: Promise | undefined; + const svelteCompiler = () => + (compilerPromise ??= loadSvelteCompiler(appDir)); + + // This converts a message in Svelte's format to esbuild's format + const messageConverter = + (source: string, filename: string) => + ({ message, start, end }: any) => { + let location; + if (start && end) { + const lineText = source.split(/\r\n|\r|\n/g)[start.line - 1]; + const lineEnd = start.line === end.line ? end.column : lineText.length; + location = { + file: filename, + line: start.line, + column: start.column, + length: lineEnd - start.column, + lineText, + }; + } + return { text: message, location }; + }; + return { name: "svelte", setup(build: any) { build.onLoad({ filter: /\.svelte$/ }, async (args: any) => { - // Import svelte compiler from the project's node_modules - const svelte = await import("svelte/compiler"); + const svelte = await svelteCompiler(); // Load the file from the file system const source = await readTextFile(args.path); const filename = path.relative(process.cwd(), args.path); - - // This converts a message in Svelte's format to esbuild's format - const convertMessage = ({ message, start, end }: any) => { - let location; - if (start && end) { - const lineText = source.split(/\r\n|\r|\n/g)[start.line - 1]; - const lineEnd = start.line === end.line ? end.column : lineText.length; - location = { - file: filename, - line: start.line, - column: start.column, - length: lineEnd - start.column, - lineText, - }; - } - return { text: message, location }; - }; + const convertMessage = messageConverter(source, filename); // Convert Svelte syntax to JavaScript try { @@ -120,6 +154,41 @@ function createSveltePlugin(appDir: string): any { return { errors: [convertMessage(e)] }; } }); + + // `lib.svelte.ts` / `lib.svelte.js` are plain modules that may use runes. + // They need `compileModule`, otherwise `$state`/`$derived` sail through + // esbuild untouched and the bundle throws "$state is not defined". + build.onLoad({ filter: /\.svelte\.[jt]s$/ }, async (args: any) => { + const svelte = await svelteCompiler(); + + const source = await readTextFile(args.path); + const filename = path.relative(process.cwd(), args.path); + const convertMessage = messageConverter(source, filename); + + try { + // `compileModule` parses with plain acorn and chokes on TypeScript, so + // types have to come off first (vite-plugin-svelte gets this for free + // by running after Vite's esbuild transform). + const code = filename.endsWith(".ts") + ? ( + await build.esbuild.transform(source, { + loader: "ts", + sourcefile: filename, + }) + ).code + : source; + + const { js, warnings } = svelte.compileModule(code, { filename }); + const contents = js.code + `//# sourceMappingURL=` + js.map.toUrl(); + return { + contents, + loader: "js", + warnings: warnings.map(convertMessage), + }; + } catch (e: any) { + return { errors: [convertMessage(e)] }; + } + }); }, }; } diff --git a/cli/test/raw_app_svelte_compiler_resolution_unit.test.ts b/cli/test/raw_app_svelte_compiler_resolution_unit.test.ts new file mode 100644 index 0000000000..5fbf05384e --- /dev/null +++ b/cli/test/raw_app_svelte_compiler_resolution_unit.test.ts @@ -0,0 +1,89 @@ +/** + * A raw app brings its own Svelte *runtime* via package.json, so it has to be + * compiled with its own *compiler* too — the two share internals that change + * between versions. Svelte 5.52.0 moved delegated event handlers off + * `element.__click` onto a Symbol-keyed map, so compiling with a version on the + * other side of that line produces an app that builds, renders, and has every + * `onclick` silently dead. + * + * `import("svelte/compiler")` resolves against the CLI, whose svelte floats + * independently of the app's. These cover the app-local resolution that avoids + * that drift. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; +import { loadSvelteCompiler } from "../src/commands/app/bundle.ts"; + +let tempDir: string; + +/** A stand-in svelte install, identifiable by a version the real one never has. */ +function installStubSvelte(dir: string, version: string) { + const pkgDir = path.join(dir, "node_modules", "svelte"); + fs.mkdirSync(pkgDir, { recursive: true }); + fs.writeFileSync( + path.join(pkgDir, "package.json"), + JSON.stringify({ + name: "svelte", + version, + type: "module", + exports: { "./compiler": "./compiler.js" }, + }), + "utf-8", + ); + fs.writeFileSync( + path.join(pkgDir, "compiler.js"), + `export const VERSION = ${JSON.stringify(version)};\n` + + `export function compile() { return { js: { code: "", map: null }, warnings: [] }; }\n` + + `export function compileModule() { return { js: { code: "", map: null }, warnings: [] }; }\n`, + "utf-8", + ); +} + +beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "raw-app-svelte-compiler-")); + fs.writeFileSync( + path.join(tempDir, "package.json"), + JSON.stringify({ name: "app", private: true, dependencies: { svelte: "*" } }), + "utf-8", + ); +}); + +afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +describe("loadSvelteCompiler", () => { + test("uses the app's own compiler rather than the CLI's", async () => { + installStubSvelte(tempDir, "0.0.0-app-local"); + + const compiler = await loadSvelteCompiler(tempDir); + + expect(compiler.VERSION).toBe("0.0.0-app-local"); + }); + + test("resolves against the app even when given a relative dir", async () => { + installStubSvelte(tempDir, "0.0.0-relative"); + const cwd = process.cwd(); + try { + process.chdir(path.dirname(tempDir)); + const compiler = await loadSvelteCompiler(path.basename(tempDir)); + expect(compiler.VERSION).toBe("0.0.0-relative"); + } finally { + process.chdir(cwd); + } + }); + + test("falls back to the CLI's compiler when the app has none", async () => { + const compiler = await loadSvelteCompiler(tempDir); + + // The real thing, not a stub — and new enough to emit the Symbol-map form + // of delegated handlers that a modern runtime reads. + expect(typeof compiler.compile).toBe("function"); + expect(compiler.VERSION).not.toContain("app-local"); + const [major, minor] = compiler.VERSION.split(".").map(Number); + expect(major > 5 || (major === 5 && minor >= 52)).toBe(true); + }); +}); diff --git a/cli/test/raw_app_svelte_module_unit.test.ts b/cli/test/raw_app_svelte_module_unit.test.ts new file mode 100644 index 0000000000..736e048100 --- /dev/null +++ b/cli/test/raw_app_svelte_module_unit.test.ts @@ -0,0 +1,118 @@ +/** + * `lib.svelte.ts` / `lib.svelte.js` modules are plain modules that may use + * runes. They need `svelte.compileModule`; without it esbuild happily bundles + * `$state(...)` as an ordinary call and the app dies at runtime with + * "ReferenceError: $state is not defined". + */ + +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; +import { createBundle } from "../src/commands/app/bundle.ts"; + +let tempDir: string; +let originalCwd: string; + +// Runes used as bare calls, ignoring the `$state(...)` mentions inside Svelte's +// own warning message template literals. +function bareRuneCalls(js: string): string[] { + return [...js.matchAll(/(^|[^.\w$`])\$(state|derived|effect|props)\s*\(/g)].map( + (m) => m[0] + ); +} + +function writeApp(files: Record) { + for (const [name, content] of Object.entries(files)) { + fs.writeFileSync(path.join(tempDir, name), content, "utf-8"); + } +} + +async function bundle(entry: string): Promise { + const { js } = await createBundle({ + entryPoint: path.join(tempDir, entry), + minify: false, + sourcemap: false, + }); + return js; +} + +beforeAll(() => { + originalCwd = process.cwd(); + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "raw-app-svelte-module-")); + fs.writeFileSync( + path.join(tempDir, "package.json"), + JSON.stringify({ name: "app", private: true, dependencies: { svelte: "*" } }), + "utf-8" + ); + // Reuse the CLI's own svelte install instead of paying for an npm install; + // `ensureNodeModules` only checks that the directory is there. + fs.symlinkSync( + path.join(originalCwd, "node_modules"), + path.join(tempDir, "node_modules") + ); + process.chdir(tempDir); +}); + +afterAll(() => { + process.chdir(originalCwd); + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +describe("svelte plugin: .svelte.ts modules", () => { + test("compiles runes in a TypeScript rune module and the bundle runs", async () => { + writeApp({ + "lib.svelte.ts": `export class Cycle { + #options: T[] = []; + #index = $state(0); + current = $derived(this.#options[this.#index]); + + constructor(options: T[]) { + this.#options = options; + } + + next() { + this.#index = (this.#index + 1) % this.#options.length; + } +} +`, + "ts_entry.ts": `import { Cycle } from './lib.svelte'; + +const cycle = new Cycle(['a', 'b', 'c']); +cycle.next(); +(globalThis as any).__cycleResult = cycle.current; +`, + }); + + const js = await bundle("ts_entry.ts"); + + expect(bareRuneCalls(js)).toEqual([]); + // Runtime is the real check: unfixed, this throws + // "ReferenceError: $state is not defined". + new Function(js)(); + expect((globalThis as any).__cycleResult).toBe("b"); + }); + + test("compiles runes in a JavaScript rune module", async () => { + writeApp({ + "counter.svelte.js": `export const counter = $state({ n: 0 }); + +export function bump() { + counter.n += 1; +} +`, + "js_entry.ts": `import { counter, bump } from './counter.svelte.js'; + +bump(); +bump(); +(globalThis as any).__counterResult = counter.n; +`, + }); + + const js = await bundle("js_entry.ts"); + + expect(bareRuneCalls(js)).toEqual([]); + new Function(js)(); + expect((globalThis as any).__counterResult).toBe(2); + }); +}); diff --git a/frontend/scripts/ui_builder_artifact.json b/frontend/scripts/ui_builder_artifact.json index 01cfbb2888..544f788f83 100644 --- a/frontend/scripts/ui_builder_artifact.json +++ b/frontend/scripts/ui_builder_artifact.json @@ -1,5 +1,5 @@ { "baseUrl": "https://pub-06154ed168a24e73a86ab84db6bf15d8.r2.dev", - "version": "76ee616", - "sha256": "1d696d38a152179ef6e1cada5df302d078cc677b6dbc244b5c42335e11feabac" + "version": "013bf67", + "sha256": "4ad957f62a1cf2fe592144f7da702a899294a330acae40ef2d85ffd726326c91" } diff --git a/frontend/src/lib/components/raw_apps/templates.ts b/frontend/src/lib/components/raw_apps/templates.ts index 335ae4d8e8..22fa773f85 100644 --- a/frontend/src/lib/components/raw_apps/templates.ts +++ b/frontend/src/lib/components/raw_apps/templates.ts @@ -136,7 +136,7 @@ export const svelte5Template = { '/index.css': indexCss, '/package.json': `{ "dependencies": { - "svelte": "^5.55.5", + "svelte": "^5.56.8", "windmill-client": "^1" } }` From b1d26bd26814adb846175ae4a958746cc61b7d6f Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 29 Jul 2026 12:11:41 +0200 Subject: [PATCH 22/24] chore: pin ui_builder to 2471791 (#10402) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picks up the language-server fix in the in-editor builder (windmill-labs/windmill-code-ui-builder#26), so .svelte files get IntelliSense, diagnostics and go-to-definition again. The bundled extension's language server was dying on startup with Cannot read properties of undefined (reading 'useCaseSensitiveFileNames') Builds and runtime were unaffected — this is editor language support only, and it predates the rune-module and delegated-event fixes already pinned here. Co-authored-by: Claude Opus 5 (1M context) --- frontend/scripts/ui_builder_artifact.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/scripts/ui_builder_artifact.json b/frontend/scripts/ui_builder_artifact.json index 544f788f83..dcba3214de 100644 --- a/frontend/scripts/ui_builder_artifact.json +++ b/frontend/scripts/ui_builder_artifact.json @@ -1,5 +1,5 @@ { "baseUrl": "https://pub-06154ed168a24e73a86ab84db6bf15d8.r2.dev", - "version": "013bf67", - "sha256": "4ad957f62a1cf2fe592144f7da702a899294a330acae40ef2d85ffd726326c91" + "version": "2471791", + "sha256": "04048791fb6b2ebca5cc30447e163826cdb143d65cce07a629252e131ac83618" } From 64ec1aa490ab5537a73bcb6f6b7e926c6339af39 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 29 Jul 2026 12:12:07 +0200 Subject: [PATCH 23/24] chore(main): release 1.775.2 (#10397) * chore(main): release 1.775.2 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- CHANGELOG.md | 9 + backend/Cargo.lock | 158 +++++++++--------- backend/Cargo.toml | 4 +- .../parsers/windmill-parser-wasm/Cargo.lock | 48 +++--- .../parsers/windmill-parser-wasm/Cargo.toml | 2 +- backend/windmill-api/openapi.yaml | 2 +- benchmarks/lib.ts | 2 +- cli/src/core/constants.ts | 2 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- lsp/Pipfile | 2 +- openflow.openapi.yaml | 2 +- .../WindmillClient/WindmillClient.psd1 | 2 +- python-client/wmill/pyproject.toml | 2 +- typescript-client/jsr.json | 2 +- typescript-client/package.json | 2 +- version.txt | 2 +- 17 files changed, 128 insertions(+), 119 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac51063de9..a69d7b0389 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## [1.775.2](https://github.com/windmill-labs/windmill/compare/v1.775.1...v1.775.2) (2026-07-29) + + +### Bug Fixes + +* **cli:** compile runes in .svelte.ts / .svelte.js modules ([#10400](https://github.com/windmill-labs/windmill/issues/10400)) ([9c37b02](https://github.com/windmill-labs/windmill/commit/9c37b0217c79390271d9855abe135df02f35c41f)) +* distinguish waiting-on-user from streaming in ai sessions ([#10396](https://github.com/windmill-labs/windmill/issues/10396)) ([a17ccbd](https://github.com/windmill-labs/windmill/commit/a17ccbdd0d3c9f2f6234c4cc70d0917e83eeaceb)) +* surface the real reason git sync settings saves are rejected ([#10398](https://github.com/windmill-labs/windmill/issues/10398)) ([33d2845](https://github.com/windmill-labs/windmill/commit/33d28456cc8ef9f64301eb3dd6dad6d7325018e3)) + ## [1.775.1](https://github.com/windmill-labs/windmill/compare/v1.775.0...v1.775.1) (2026-07-28) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 11167f13cf..7dd3c795aa 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -14489,7 +14489,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "async-nats", @@ -14574,7 +14574,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.775.1" +version = "1.775.2" dependencies = [ "async-stream", "async-trait", @@ -14607,7 +14607,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.775.1" +version = "1.775.2" dependencies = [ "axum 0.8.9", "chrono", @@ -14620,7 +14620,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "argon2", @@ -14759,7 +14759,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.775.1" +version = "1.775.2" dependencies = [ "axum 0.8.9", "chrono", @@ -14782,7 +14782,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.775.1" +version = "1.775.2" dependencies = [ "axum 0.8.9", "chrono", @@ -14797,7 +14797,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "axum 0.8.9", @@ -14823,7 +14823,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.775.1" +version = "1.775.2" dependencies = [ "reqwest 0.12.28", "serde", @@ -14833,7 +14833,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.775.1" +version = "1.775.2" dependencies = [ "axum 0.8.9", "chrono", @@ -14850,7 +14850,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.775.1" +version = "1.775.2" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -14872,7 +14872,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "axum 0.8.9", @@ -14895,7 +14895,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.775.1" +version = "1.775.2" dependencies = [ "axum 0.8.9", "chrono", @@ -14911,7 +14911,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.775.1" +version = "1.775.2" dependencies = [ "axum 0.8.9", "chrono", @@ -14932,7 +14932,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.775.1" +version = "1.775.2" dependencies = [ "axum 0.8.9", "chrono", @@ -14953,7 +14953,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.775.1" +version = "1.775.2" dependencies = [ "axum 0.8.9", "chrono", @@ -14967,7 +14967,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "async-nats", @@ -15002,7 +15002,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "axum 0.8.9", @@ -15027,7 +15027,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.775.1" +version = "1.775.2" dependencies = [ "axum 0.8.9", "flate2", @@ -15045,7 +15045,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "axum 0.8.9", @@ -15067,7 +15067,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.775.1" +version = "1.775.2" dependencies = [ "axum 0.8.9", "chrono", @@ -15087,7 +15087,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.775.1" +version = "1.775.2" dependencies = [ "axum 0.8.9", "chrono", @@ -15124,7 +15124,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "axum 0.8.9", @@ -15152,7 +15152,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.775.1" +version = "1.775.2" dependencies = [ "lazy_static", "serde", @@ -15164,7 +15164,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.775.1" +version = "1.775.2" dependencies = [ "argon2", "axum 0.8.9", @@ -15189,7 +15189,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.775.1" +version = "1.775.2" dependencies = [ "axum 0.8.9", "chrono", @@ -15203,7 +15203,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.775.1" +version = "1.775.2" dependencies = [ "axum 0.8.9", "chrono", @@ -15238,7 +15238,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.775.1" +version = "1.775.2" dependencies = [ "chrono", "lazy_static", @@ -15252,7 +15252,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "axum 0.8.9", @@ -15271,7 +15271,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.775.1" +version = "1.775.2" dependencies = [ "aes-gcm", "aho-corasick", @@ -15375,7 +15375,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.775.1" +version = "1.775.2" dependencies = [ "chrono", "itertools 0.14.0", @@ -15394,7 +15394,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.775.1" +version = "1.775.2" dependencies = [ "regex", "serde", @@ -15409,7 +15409,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "astral-tokio-tar", @@ -15433,7 +15433,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "futures", @@ -15450,7 +15450,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.775.1" +version = "1.775.2" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -15466,7 +15466,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "async-trait", @@ -15487,7 +15487,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "async-trait", @@ -15518,7 +15518,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "arc-swap", @@ -15543,7 +15543,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "async-stream", @@ -15577,7 +15577,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "futures", @@ -15595,7 +15595,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.775.1" +version = "1.775.2" dependencies = [ "convert_case 0.6.0", "serde", @@ -15604,7 +15604,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "lazy_static", @@ -15616,7 +15616,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "serde_json", @@ -15628,7 +15628,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "gosyn", @@ -15640,7 +15640,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "lazy_static", @@ -15652,7 +15652,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "serde_json", @@ -15664,7 +15664,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "nu-parser", @@ -15675,7 +15675,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "itertools 0.14.0", @@ -15686,7 +15686,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "itertools 0.14.0", @@ -15698,7 +15698,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "rustpython-ast", @@ -15709,7 +15709,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "async-recursion", @@ -15731,7 +15731,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "serde_json", @@ -15743,7 +15743,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "lazy_static", @@ -15757,7 +15757,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -15774,7 +15774,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "lazy_static", @@ -15787,7 +15787,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "serde", @@ -15799,7 +15799,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "lazy_static", @@ -15817,7 +15817,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -15833,7 +15833,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "rustpython-ast", @@ -15849,7 +15849,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "serde", @@ -15860,7 +15860,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "async-recursion", @@ -15899,7 +15899,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "const_format", @@ -15939,7 +15939,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.775.1" +version = "1.775.2" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -15950,7 +15950,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "async-recursion", @@ -15984,7 +15984,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "async-trait", @@ -16008,7 +16008,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "async-trait", @@ -16041,7 +16041,7 @@ dependencies = [ [[package]] name = "windmill-trigger-amqp" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "async-trait", @@ -16068,7 +16068,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "async-trait", @@ -16101,7 +16101,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "async-trait", @@ -16121,7 +16121,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "async-trait", @@ -16155,7 +16155,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "async-trait", @@ -16191,7 +16191,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "async-trait", @@ -16214,7 +16214,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "async-trait", @@ -16238,7 +16238,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "async-nats", @@ -16262,7 +16262,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "async-trait", @@ -16297,7 +16297,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "async-trait", @@ -16325,7 +16325,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "async-trait", @@ -16350,7 +16350,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "bitflags 2.13.1", @@ -16369,7 +16369,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "async-once-cell", @@ -16484,7 +16484,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.775.1" +version = "1.775.2" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 49beac0b8a..a6fffe33f4 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.775.1" +version = "1.775.2" authors.workspace = true edition.workspace = true @@ -88,7 +88,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"] [workspace.package] -version = "1.775.1" +version = "1.775.2" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index e3639e30c2..0e4e12eb89 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.775.1" +version = "1.775.2" dependencies = [ "aho-corasick", "anyhow", @@ -6272,7 +6272,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.775.1" +version = "1.775.2" dependencies = [ "proc-macro2", "quote", @@ -6284,7 +6284,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.775.1" +version = "1.775.2" dependencies = [ "convert_case", "serde", @@ -6293,7 +6293,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "lazy_static", @@ -6305,7 +6305,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "serde_json", @@ -6317,7 +6317,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "gosyn", @@ -6329,7 +6329,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "lazy_static", @@ -6341,7 +6341,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "serde_json", @@ -6353,7 +6353,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "nu-parser", @@ -6364,7 +6364,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6375,7 +6375,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6387,7 +6387,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "rustpython-ast", @@ -6398,7 +6398,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "async-recursion", @@ -6420,7 +6420,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "serde_json", @@ -6432,7 +6432,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "lazy_static", @@ -6446,7 +6446,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "convert_case", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "lazy_static", @@ -6476,7 +6476,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "serde", @@ -6488,7 +6488,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "lazy_static", @@ -6506,7 +6506,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6522,7 +6522,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "rustpython-ast", @@ -6538,7 +6538,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6570,7 +6570,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "serde", @@ -6581,7 +6581,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.775.1" +version = "1.775.2" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index 9ee10c7fbb..1cb5004022 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.toml +++ b/backend/parsers/windmill-parser-wasm/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" members = ["."] [workspace.package] -version = "1.775.1" +version = "1.775.2" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 381cf1b999..21eca505fd 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.775.1 + version: 1.775.2 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index e77bfb67ba..57c877b441 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.775.1"; +export const VERSION = "v1.775.2"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/src/core/constants.ts b/cli/src/core/constants.ts index bd5add7ecd..59de21e2bf 100644 --- a/cli/src/core/constants.ts +++ b/cli/src/core/constants.ts @@ -10,4 +10,4 @@ export const WM_FORK_PREFIX = "wm-fork"; // (e.g. utils.ts) can read it without importing main.ts and creating a circular // dependency (main → workspace → utils → main) that triggers a TDZ. // Re-exported from main.ts for backwards compatibility. -export const VERSION = "1.775.1"; +export const VERSION = "1.775.2"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ad6838fa37..4ab6621c30 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.775.1", + "version": "1.775.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.775.1", + "version": "1.775.2", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 4bf61cee55..01e68d2acf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.775.1", + "version": "1.775.2", "scripts": { "dev": "vite dev", "dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev", diff --git a/lsp/Pipfile b/lsp/Pipfile index a69114c915..24c5b9197c 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.775.1" +wmill = ">=1.775.2" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 8ee13fa39f..833d43cb91 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.775.1 + version: 1.775.2 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index 7bdcf6a788..d54c4c5a88 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.775.1' + ModuleVersion = '1.775.2' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index 55b38a4e1d..90b6989676 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.775.1" +version = "1.775.2" 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 abde746fc4..7721bf829b 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.775.1", + "version": "1.775.2", "exports": "./src/index.ts", "publish": { "exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts", "./wacError.ts"] diff --git a/typescript-client/package.json b/typescript-client/package.json index 0d9ac57f69..b0ac36d410 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.775.1", + "version": "1.775.2", "author": "Ruben Fiszel", "license": "Apache 2.0", "homepage": "https://github.com/windmill-labs/windmill/tree/main/typescript-client#readme", diff --git a/version.txt b/version.txt index 393fbf5b71..765699e2a4 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.775.1 +1.775.2 From 9cd6a70f6ace956f679c1ce1fb46543cda569183 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Wed, 29 Jul 2026 17:46:26 +0200 Subject: [PATCH 24/24] fix: sidebar workspace toggle navigates home when already in workspace mode (#10405) * feat: sidebar workspace toggle navigates home when already in workspace mode Co-Authored-By: Claude Opus 5 (1M context) * fix: fire the toggle home navigation on keyboard activation too Co-Authored-By: Claude Opus 5 (1M context) * fix: one activation per key press and no duplicate history entry from home Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- .../toggleButton-v2/ToggleButton.svelte | 14 ++++++++++++- .../sessions/SessionModeSwitch.svelte | 20 +++++++++++++++++-- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/components/common/toggleButton-v2/ToggleButton.svelte b/frontend/src/lib/components/common/toggleButton-v2/ToggleButton.svelte index 042bb09865..621e8383d0 100644 --- a/frontend/src/lib/components/common/toggleButton-v2/ToggleButton.svelte +++ b/frontend/src/lib/components/common/toggleButton-v2/ToggleButton.svelte @@ -21,6 +21,11 @@ item: ToggleGroupElements['item'] value: ToggleGroupItemProps class?: string + // Fires on activation, including on the already-selected button — which the + // group's onSelected never reports (melt treats it as a deselection and the + // group keeps the current value). Keyboard activation needs its own keydown + // branch below: melt preventDefaults Enter/Space, so no click is synthesized. + onActivate?: (e: Event) => void } let { @@ -38,7 +43,8 @@ id = undefined, item, value, - class: className = '' + class: className = '', + onActivate }: Props = $props() // Handle backward compatibility: small prop maps to size="sm" @@ -74,6 +80,12 @@ )} use:melt={$item(value)} style={selectedColor ? `--selected-color: ${selectedColor}` : ''} + onclick={onActivate} + onkeydown={(e) => { + // `repeat` filtered out: a held key keeps firing keydown, and one activation + // must mean one call however the consumer's side effect behaves. + if (!e.repeat && (e.key === 'Enter' || e.key === ' ')) onActivate?.(e) + }} > {#if icon} {@const SvelteComponent = icon} diff --git a/frontend/src/lib/components/sessions/SessionModeSwitch.svelte b/frontend/src/lib/components/sessions/SessionModeSwitch.svelte index c4d29c9791..d721b6f16f 100644 --- a/frontend/src/lib/components/sessions/SessionModeSwitch.svelte +++ b/frontend/src/lib/components/sessions/SessionModeSwitch.svelte @@ -1,8 +1,11 @@