From 1fa4d919b30ac9eff2d1789fba2695450ba115e7 Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Thu, 26 Mar 2026 18:40:51 +0100 Subject: [PATCH 001/111] fix: upload_s3_file not working in VS Code extension (#8547) --- .../src/lib/components/common/fileUpload/FileUpload.svelte | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/src/lib/components/common/fileUpload/FileUpload.svelte b/frontend/src/lib/components/common/fileUpload/FileUpload.svelte index a7ba9a1c34..dfe5c158cc 100644 --- a/frontend/src/lib/components/common/fileUpload/FileUpload.svelte +++ b/frontend/src/lib/components/common/fileUpload/FileUpload.svelte @@ -6,6 +6,7 @@ import { sendUserToast } from '$lib/toast' import { workspaceStore } from '$lib/stores' import { AppService, HelpersService } from '$lib/gen' + import { OpenAPI } from '$lib/gen/core/OpenAPI' import { writable, type Writable } from 'svelte/store' import { Ban, CheckCheck, FileWarning, Files, RefreshCcw, Trash, XIcon } from 'lucide-svelte' import { twMerge } from 'tailwind-merge' @@ -334,6 +335,9 @@ true ) xhr?.setRequestHeader('Content-Type', 'application/octet-stream') + if (OpenAPI.TOKEN) { + xhr?.setRequestHeader('Authorization', `Bearer ${OpenAPI.TOKEN}`) + } xhr?.send(fileToUpload) })) as any From 71549c3db053bcc209c7065ac8cd42f1e8047cc3 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 26 Mar 2026 18:14:10 +0000 Subject: [PATCH 002/111] fix: resolve parent_hash race condition in sync push with auto_parent (#8545) * fix: resolve parent_hash race condition in sync push with auto_parent During concurrent sync push operations (parallel CLI groups or separate CI pipelines), multiple requests could read the same remote script hash and both try to create a new version with the same parent_hash, causing "the lineage must be linear" errors. Adds an opt-in `auto_parent` field to the create_script API. When set, the backend resolves the parent_hash to the current head script at that path within the transaction, atomically. This eliminates the client-side race window where the parent could change between read and write. The CLI now sends `auto_parent: true` when updating existing scripts, so sync push is resilient to concurrent deployments. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: add missing auto_parent field in clone_script NewScript initializer Co-Authored-By: Claude Opus 4.5 * fix: add advisory lock to serialize concurrent auto_parent script creates Co-Authored-By: Claude Opus 4.5 * sqlx * fix: add sqlx anchor for CE-only user count query Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.6 (1M context) --- ...2f88594825dbaa647290a58bd63df61b531a7.json | 2 +- ...96cc3ba1957042a48ac5f9629ada25b3e78ef.json | 2 +- ...153c43903f929ae5d62fbba12610f89c36d55.json | 2 +- ...2561a7d098f2af5287e1e6c339e15080378be.json | 23 +++++++++++++++++++ ...8251dbb3c6d4c095efa015823f0324ab27d7f.json | 2 +- ...8e702fc1577d3fa4ff1ff2f1e089971ff5e32.json | 2 +- ...2d14755474cba82b3b388a47585a8bb325b1a.json | 17 -------------- backend/windmill-api-scripts/src/scripts.rs | 22 +++++++++++++++++- .../windmill-api-workspaces/src/workspaces.rs | 12 ++++++++++ backend/windmill-common/src/scripts.rs | 1 + backend/windmill-types/src/scripts.rs | 2 ++ cli/src/commands/script/script.ts | 1 + 12 files changed, 65 insertions(+), 23 deletions(-) create mode 100644 backend/.sqlx/query-6d070f476538aa6fcd6227fe5312561a7d098f2af5287e1e6c339e15080378be.json delete mode 100644 backend/.sqlx/query-9f07510019ebe6f0c5fa17bf31c2d14755474cba82b3b388a47585a8bb325b1a.json diff --git a/backend/.sqlx/query-446404eda9b9632c9a1384af6bf2f88594825dbaa647290a58bd63df61b531a7.json b/backend/.sqlx/query-446404eda9b9632c9a1384af6bf2f88594825dbaa647290a58bd63df61b531a7.json index e2c5050f1d..9768a13f3d 100644 --- a/backend/.sqlx/query-446404eda9b9632c9a1384af6bf2f88594825dbaa647290a58bd63df61b531a7.json +++ b/backend/.sqlx/query-446404eda9b9632c9a1384af6bf2f88594825dbaa647290a58bd63df61b531a7.json @@ -21,7 +21,7 @@ { "ordinal": 3, "name": "item_path", - "type_info": "Varchar" + "type_info": "Text" }, { "ordinal": 4, diff --git a/backend/.sqlx/query-51c3274a8092d80503a6b97ef3896cc3ba1957042a48ac5f9629ada25b3e78ef.json b/backend/.sqlx/query-51c3274a8092d80503a6b97ef3896cc3ba1957042a48ac5f9629ada25b3e78ef.json index 0f2c7ab318..fa4a6fc50e 100644 --- a/backend/.sqlx/query-51c3274a8092d80503a6b97ef3896cc3ba1957042a48ac5f9629ada25b3e78ef.json +++ b/backend/.sqlx/query-51c3274a8092d80503a6b97ef3896cc3ba1957042a48ac5f9629ada25b3e78ef.json @@ -21,7 +21,7 @@ { "ordinal": 3, "name": "item_path", - "type_info": "Varchar" + "type_info": "Text" }, { "ordinal": 4, diff --git a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json index 36ddb8ab9f..713ccb9dd3 100644 --- a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json +++ b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json @@ -15,7 +15,7 @@ ] }, "nullable": [ - true + null ] }, "hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55" diff --git a/backend/.sqlx/query-6d070f476538aa6fcd6227fe5312561a7d098f2af5287e1e6c339e15080378be.json b/backend/.sqlx/query-6d070f476538aa6fcd6227fe5312561a7d098f2af5287e1e6c339e15080378be.json new file mode 100644 index 0000000000..6d6acec840 --- /dev/null +++ b/backend/.sqlx/query-6d070f476538aa6fcd6227fe5312561a7d098f2af5287e1e6c339e15080378be.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT pg_advisory_xact_lock(hashtext($1 || '/' || $2))", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "pg_advisory_xact_lock", + "type_info": "Void" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "6d070f476538aa6fcd6227fe5312561a7d098f2af5287e1e6c339e15080378be" +} diff --git a/backend/.sqlx/query-8b25c4252da77cd2fe1b3916b518251dbb3c6d4c095efa015823f0324ab27d7f.json b/backend/.sqlx/query-8b25c4252da77cd2fe1b3916b518251dbb3c6d4c095efa015823f0324ab27d7f.json index 4692b430ec..9995bb1b51 100644 --- a/backend/.sqlx/query-8b25c4252da77cd2fe1b3916b518251dbb3c6d4c095efa015823f0324ab27d7f.json +++ b/backend/.sqlx/query-8b25c4252da77cd2fe1b3916b518251dbb3c6d4c095efa015823f0324ab27d7f.json @@ -13,7 +13,7 @@ "Left": [ "Varchar", "Varchar", - "Varchar", + "Text", "Jsonb", "Varchar" ] diff --git a/backend/.sqlx/query-92fb6afe3b7041b2954340094c08e702fc1577d3fa4ff1ff2f1e089971ff5e32.json b/backend/.sqlx/query-92fb6afe3b7041b2954340094c08e702fc1577d3fa4ff1ff2f1e089971ff5e32.json index 191630bd35..49fd50d7e6 100644 --- a/backend/.sqlx/query-92fb6afe3b7041b2954340094c08e702fc1577d3fa4ff1ff2f1e089971ff5e32.json +++ b/backend/.sqlx/query-92fb6afe3b7041b2954340094c08e702fc1577d3fa4ff1ff2f1e089971ff5e32.json @@ -21,7 +21,7 @@ { "ordinal": 3, "name": "item_path", - "type_info": "Varchar" + "type_info": "Text" }, { "ordinal": 4, diff --git a/backend/.sqlx/query-9f07510019ebe6f0c5fa17bf31c2d14755474cba82b3b388a47585a8bb325b1a.json b/backend/.sqlx/query-9f07510019ebe6f0c5fa17bf31c2d14755474cba82b3b388a47585a8bb325b1a.json deleted file mode 100644 index 25a32e5338..0000000000 --- a/backend/.sqlx/query-9f07510019ebe6f0c5fa17bf31c2d14755474cba82b3b388a47585a8bb325b1a.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO deployment_metadata (workspace_id, path, script_hash, deployment_msg) VALUES ($1, $2, $3, $4) ON CONFLICT (workspace_id, script_hash) WHERE script_hash IS NOT NULL DO UPDATE SET deployment_msg = EXCLUDED.deployment_msg", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "Int8", - "Text" - ] - }, - "nullable": [] - }, - "hash": "9f07510019ebe6f0c5fa17bf31c2d14755474cba82b3b388a47585a8bb325b1a" -} diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index 2729357328..e5b66f5c28 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -605,7 +605,7 @@ impl HandleDeploymentMetadata { } async fn create_script_internal<'c>( - ns: NewScript, + mut ns: NewScript, w_id: String, authed: ApiAuthed, db: sqlx::Pool, @@ -675,6 +675,17 @@ async fn create_script_internal<'c>( .to_owned(), )); }; + // When auto_parent is set, serialize concurrent creates for the same (workspace, path) + // so the clashing_script query always sees the latest committed head. + if ns.auto_parent.unwrap_or(false) { + sqlx::query_scalar!( + "SELECT pg_advisory_xact_lock(hashtext($1 || '/' || $2))", + &w_id, + &ns.path + ) + .fetch_one(&mut *tx) + .await?; + } let clashing_script = sqlx::query_as::<_, Script>( "SELECT * FROM script WHERE path = $1 AND archived = false AND workspace_id = $2", ) @@ -687,6 +698,15 @@ async fn create_script_internal<'c>( perms: serde_json::Value, p_path: String, } + // When auto_parent is set, resolve parent_hash to the current head for this path + // within the transaction. The advisory lock above ensures the second concurrent + // request waits until the first commits, so this query sees the updated head. + if ns.auto_parent.unwrap_or(false) { + if let Some(ref cs) = clashing_script { + ns.parent_hash = Some(cs.hash.clone()); + } + } + let parent_hashes_and_perms: Option = match (&ns.parent_hash, clashing_script) { (None, None) => Ok(None), (None, Some(s)) if !s.draft_only.unwrap_or(false) => Err(Error::BadRequest(format!( diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 4e832b3a93..9f12ab4ee2 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -1690,6 +1690,18 @@ async fn check_git_sync_access(_db: &DB, _w_id: &str) -> Result<()> { Ok(()) } +// Anchor the CE-only query for `cargo sqlx prepare` (which runs with --features enterprise) +#[cfg(feature = "enterprise")] +#[allow(dead_code)] +async fn _sqlx_anchor_ce_user_count(db: &DB, w_id: &str) { + let _ = sqlx::query_scalar!( + "SELECT COUNT(*) FROM usr WHERE workspace_id = $1 AND disabled = false", + w_id + ) + .fetch_one(db) + .await; +} + #[cfg(not(feature = "enterprise"))] async fn check_git_sync_access(db: &DB, w_id: &str) -> Result<()> { let user_count: i64 = sqlx::query_scalar!( diff --git a/backend/windmill-common/src/scripts.rs b/backend/windmill-common/src/scripts.rs index d3035b1b49..50790fe65b 100644 --- a/backend/windmill-common/src/scripts.rs +++ b/backend/windmill-common/src/scripts.rs @@ -428,6 +428,7 @@ pub async fn clone_script<'c>( preserve_on_behalf_of: None, assets: s.assets, modules: s.modules, + auto_parent: None, }; let new_hash = hash_script(&ns); diff --git a/backend/windmill-types/src/scripts.rs b/backend/windmill-types/src/scripts.rs index 5eca2be82c..de26f0e484 100644 --- a/backend/windmill-types/src/scripts.rs +++ b/backend/windmill-types/src/scripts.rs @@ -510,6 +510,8 @@ pub struct NewScript { pub assets: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub modules: Option>, + #[serde(default)] + pub auto_parent: Option, } // IMPORTANT: update this Hash impl when adding fields to NewScript diff --git a/cli/src/commands/script/script.ts b/cli/src/commands/script/script.ts index 10015db776..dd0fd30470 100644 --- a/cli/src/commands/script/script.ts +++ b/cli/src/commands/script/script.ts @@ -494,6 +494,7 @@ export async function handleFile( const body = { ...requestBodyCommon, parent_hash: remote.hash, + auto_parent: true, }; const execTime = await createScript( bundleContent, From 8866bd44cffff21ad7cd50aae1326f546f3e3efd Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 26 Mar 2026 18:20:46 +0000 Subject: [PATCH 003/111] nit backend tests --- .../tests/scripts.rs | 155 +++++++++++++----- 1 file changed, 116 insertions(+), 39 deletions(-) diff --git a/backend/windmill-api-integration-tests/tests/scripts.rs b/backend/windmill-api-integration-tests/tests/scripts.rs index 74fd9c8611..f5e78f880f 100644 --- a/backend/windmill-api-integration-tests/tests/scripts.rs +++ b/backend/windmill-api-integration-tests/tests/scripts.rs @@ -108,7 +108,10 @@ async fn test_script_endpoints(db: Pool) -> anyhow::Result<()> { let resp = authed_get(port, "raw/p", "u/test-user/test_script.ts").await; assert_eq!(resp.status(), 200); let body = resp.text().await?; - assert!(body.contains("return 42"), "expected script content, got: {body}"); + assert!( + body.contains("return 42"), + "expected script content, got: {body}" + ); // --- raw by hash (requires .ts suffix) --- let resp = authed_get(port, "raw/h", &format!("{hash}.ts")).await; @@ -131,12 +134,10 @@ async fn test_script_endpoints(db: Pool) -> anyhow::Result<()> { assert!(list.iter().any(|s| s["path"] == "u/test-user/test_script")); // list with path_start filter - let resp = authed(client().get(format!( - "{base}/list?path_start=u/test-user/another" - ))) - .send() - .await - .unwrap(); + let resp = authed(client().get(format!("{base}/list?path_start=u/test-user/another"))) + .send() + .await + .unwrap(); assert_eq!(resp.status(), 200); let list = resp.json::>().await?; assert_eq!(list.len(), 1); @@ -233,12 +234,7 @@ async fn test_script_endpoints(db: Pool) -> anyhow::Result<()> { .send() .await .unwrap(); - assert_eq!( - resp.status(), - 200, - "history_update: {}", - resp.text().await? - ); + assert_eq!(resp.status(), 200, "history_update: {}", resp.text().await?); // --- toggle_workspace_error_handler (EE-gated, expect 400 in OSS) --- let resp = authed(client().post(script_url( @@ -268,22 +264,13 @@ async fn test_script_endpoints(db: Pool) -> anyhow::Result<()> { .send() .await .unwrap(); - assert_eq!( - resp.status(), - 200, - "tokened_raw: {}", - resp.text().await? - ); + assert_eq!(resp.status(), 200, "tokened_raw: {}", resp.text().await?); // --- archive by path --- - let resp = authed(client().post(script_url( - port, - "archive/p", - "u/test-user/another_script", - ))) - .send() - .await - .unwrap(); + let resp = authed(client().post(script_url(port, "archive/p", "u/test-user/another_script"))) + .send() + .await + .unwrap(); assert_eq!(resp.status(), 200); // archived script should still be gettable @@ -333,12 +320,10 @@ async fn test_script_endpoints(db: Pool) -> anyhow::Result<()> { // ===== Hub endpoints (require external network, expect 500 or 200) ===== // --- hub/top --- - let resp = authed(client().get(format!( - "http://localhost:{port}/api/scripts/hub/top" - ))) - .send() - .await - .unwrap(); + let resp = authed(client().get(format!("http://localhost:{port}/api/scripts/hub/top"))) + .send() + .await + .unwrap(); assert!( resp.status() == 200 || resp.status() == 500, "hub/top: unexpected status {}", @@ -372,12 +357,10 @@ async fn test_script_endpoints(db: Pool) -> anyhow::Result<()> { ); // --- integrations hub/list --- - let resp = authed(client().get(format!( - "http://localhost:{port}/api/integrations/hub/list" - ))) - .send() - .await - .unwrap(); + let resp = authed(client().get(format!("http://localhost:{port}/api/integrations/hub/list"))) + .send() + .await + .unwrap(); assert!( resp.status() == 200 || resp.status() == 500, "integrations hub/list: unexpected status {}", @@ -386,3 +369,97 @@ async fn test_script_endpoints(db: Pool) -> anyhow::Result<()> { Ok(()) } + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_auto_parent_resolves_parent_hash(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/w/test-workspace/scripts"); + + // Create v1 + let resp = authed(client().post(format!("{base}/create"))) + .json(&new_script( + "u/test-user/auto_parent_test", + "v1", + "export async function main() { return 1; }", + )) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 201, "create v1: {}", resp.text().await?); + + // Get the hash of v1 + let resp = authed_get(port, "get/p", "u/test-user/auto_parent_test").await; + let body = resp.json::().await?; + let v1_hash = body["hash"].as_str().unwrap().to_string(); + + // Create v2 using auto_parent (no parent_hash provided) + let mut v2 = new_script( + "u/test-user/auto_parent_test", + "v2", + "export async function main() { return 2; }", + ); + v2["auto_parent"] = json!(true); + let resp = authed(client().post(format!("{base}/create"))) + .json(&v2) + .send() + .await + .unwrap(); + assert_eq!( + resp.status(), + 201, + "create v2 with auto_parent: {}", + resp.text().await? + ); + + // Get v2 and verify its parent_hash points to v1 + let resp = authed_get(port, "get/p", "u/test-user/auto_parent_test").await; + let body = resp.json::().await?; + assert_eq!(body["summary"], "v2"); + let v2_hash = body["hash"].as_str().unwrap().to_string(); + assert_ne!(v2_hash, v1_hash); + + // v2's parent_hashes should contain v1 + let parent_hashes = body["parent_hashes"].as_array().unwrap(); + assert!( + parent_hashes + .iter() + .any(|h| h.as_str() == Some(v1_hash.as_str())), + "v2 parent_hashes should contain v1 hash {v1_hash}, got: {parent_hashes:?}" + ); + + // Create v3 with auto_parent to confirm it chains correctly + let mut v3 = new_script( + "u/test-user/auto_parent_test", + "v3", + "export async function main() { return 3; }", + ); + v3["auto_parent"] = json!(true); + let resp = authed(client().post(format!("{base}/create"))) + .json(&v3) + .send() + .await + .unwrap(); + assert_eq!( + resp.status(), + 201, + "create v3 with auto_parent: {}", + resp.text().await? + ); + + let resp = authed_get(port, "get/p", "u/test-user/auto_parent_test").await; + let body = resp.json::().await?; + assert_eq!(body["summary"], "v3"); + + // v3's parent_hashes should contain v2 (and transitively v1) + let parent_hashes = body["parent_hashes"].as_array().unwrap(); + assert!( + parent_hashes + .iter() + .any(|h| h.as_str() == Some(v2_hash.as_str())), + "v3 parent_hashes should contain v2 hash {v2_hash}, got: {parent_hashes:?}" + ); + + Ok(()) +} From d760ea5eaf4dc33007f1fd3e5e07b86925a0aa11 Mon Sep 17 00:00:00 2001 From: wendrul <53628737+wendrul@users.noreply.github.com> Date: Thu, 26 Mar 2026 19:28:18 +0100 Subject: [PATCH 004/111] fix: add relative imports to the dependency list in deploymentUI (#8548) * prepare sqlx * Add relative imports to getDependencies of deployUI * nit * fix: correct get_imports doc comment, add tracing, use Set for dedup - Fix copy-pasted doc comment on get_imports (said "get dependents") - Add tracing::debug to get_imports handler to match get_dependents - Use Set for O(1) duplicate detection in deploy dependency traversal Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Ruben Fiszel Co-authored-by: Claude Opus 4.6 (1M context) --- ...20e383a998a54c95355bb85fe7e762a0d9765.json | 23 +++++++++++++++++ .../windmill-api-workspaces/src/workspaces.rs | 25 +++++++++++++++++++ backend/windmill-api/openapi.yaml | 24 ++++++++++++++++++ .../src/scoped_dependency_map.rs | 23 ++++++++++++++++- .../src/lib/components/DeployWorkspace.svelte | 12 +++++++++ 5 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 backend/.sqlx/query-52d765c87cb8da0ca71fb53156820e383a998a54c95355bb85fe7e762a0d9765.json diff --git a/backend/.sqlx/query-52d765c87cb8da0ca71fb53156820e383a998a54c95355bb85fe7e762a0d9765.json b/backend/.sqlx/query-52d765c87cb8da0ca71fb53156820e383a998a54c95355bb85fe7e762a0d9765.json new file mode 100644 index 0000000000..16ae512f37 --- /dev/null +++ b/backend/.sqlx/query-52d765c87cb8da0ca71fb53156820e383a998a54c95355bb85fe7e762a0d9765.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT DISTINCT imported_path as \"imported_path!\"\n FROM dependency_map\n WHERE workspace_id = $1\n AND importer_path = $2\n AND imported_path NOT LIKE 'dependencies/%'\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "imported_path!", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "52d765c87cb8da0ca71fb53156820e383a998a54c95355bb85fe7e762a0d9765" +} diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 9f12ab4ee2..646a2369b9 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -79,6 +79,7 @@ pub fn workspaced_service() -> Router { .route("/rebuild_dependency_map", post(rebuild_dependency_map)) .route("/get_dependency_map", get(get_dependency_map)) .route("/get_dependents/*imported_path", get(get_dependents)) + .route("/get_imports/*importer_path", get(get_imports)) .route("/get_dependents_amounts", post(get_dependents_amounts)) .route("/get_settings", get(get_settings)) .route( @@ -4358,6 +4359,30 @@ async fn get_dependents( Ok(Json(dependents)) } +async fn get_imports( + Extension(db): Extension, + Path((w_id, importer_path)): Path<(String, String)>, + _authed: ApiAuthed, +) -> JsonResult> { + tracing::debug!( + workspace_id = %w_id, + importer_path = %importer_path, + "API: Getting imports for importer path" + ); + + let imports = ScopedDependencyMap::get_imports(&importer_path, &w_id, &db).await?; + + tracing::debug!( + workspace_id = %w_id, + importer_path = %importer_path, + imports_count = imports.len(), + "API: Found imports: {:?}", + imports + ); + + Ok(Json(imports)) +} + #[derive(Serialize, Debug)] struct DependentsAmount { imported_path: String, diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 2b83550ab2..1275d648c2 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -2714,6 +2714,30 @@ paths: items: $ref: "#/components/schemas/DependencyDependent" + /w/{workspace}/workspaces/get_imports/{importer_path}: + get: + summary: get script imports for an importer path + operationId: getImports + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: importer_path + in: path + required: true + schema: + type: string + description: The script path to get imports for + responses: + "200": + description: list of imported script paths + content: + application/json: + schema: + type: array + items: + type: string + /w/{workspace}/workspaces/get_dependents_amounts: post: summary: get dependents amounts for multiple imported paths diff --git a/backend/windmill-dep-map/src/scoped_dependency_map.rs b/backend/windmill-dep-map/src/scoped_dependency_map.rs index 9821b8e610..fcee6fe5f1 100644 --- a/backend/windmill-dep-map/src/scoped_dependency_map.rs +++ b/backend/windmill-dep-map/src/scoped_dependency_map.rs @@ -445,7 +445,28 @@ SELECT importer_node_id, imported_path, imported_lockfile_hash } } - /// Get dependents of any imported path - returns scripts/flows/apps that depend on it + /// Get imports of a given importer path - returns paths that the importer depends on + pub async fn get_imports<'c>( + importer_path: &str, + workspace_id: &str, + e: impl PgExecutor<'c>, + ) -> Result> { + sqlx::query_scalar!( + r#" + SELECT DISTINCT imported_path as "imported_path!" + FROM dependency_map + WHERE workspace_id = $1 + AND importer_path = $2 + AND imported_path NOT LIKE 'dependencies/%' + "#, + workspace_id, + importer_path + ) + .fetch_all(e) + .await + .map_err(Error::from) + } + pub async fn get_dependents<'c>( imported_path: &str, workspace_id: &str, diff --git a/frontend/src/lib/components/DeployWorkspace.svelte b/frontend/src/lib/components/DeployWorkspace.svelte index 9f1fd71146..3645bd587b 100644 --- a/frontend/src/lib/components/DeployWorkspace.svelte +++ b/frontend/src/lib/components/DeployWorkspace.svelte @@ -262,13 +262,25 @@ return getTriggerDependency(additionalInformation.triggers.kind, path, $workspaceStore!) } throw new Error('Missing trigger information') + } else if (kind == 'script') { + const imports = await WorkspaceService.getImports({ + workspace: $workspaceStore!, + importerPath: path + }) + return imports.map((importedPath) => ({ kind: 'script' as Kind, path: importedPath })) } return [] } let toProcess = [{ kind, path }] + let processedSet = new Set() let processed: { kind: Kind; path: string }[] = [] while (toProcess.length > 0) { const { kind, path } = toProcess.pop()! + const key = `${kind}:${path}` + if (processedSet.has(key)) { + continue + } + processedSet.add(key) toProcess.push(...(await rec(kind, path))) processed.push({ kind, path }) } From 264fa33917628f2eed4237787fa69fe48a471453 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 26 Mar 2026 18:46:25 +0000 Subject: [PATCH 005/111] chore(main): release 1.666.0 (#8543) * chore(main): release 1.666.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- CHANGELOG.md | 16 ++ backend/Cargo.lock | 158 +++++++++--------- backend/Cargo.toml | 4 +- backend/windmill-api/openapi.yaml | 2 +- benchmarks/lib.ts | 2 +- cli/src/main.ts | 2 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- lsp/Pipfile | 2 +- openflow.openapi.yaml | 2 +- .../WindmillClient/WindmillClient.psd1 | 2 +- python-client/wmill/pyproject.toml | 2 +- typescript-client/jsr.json | 2 +- typescript-client/package.json | 2 +- version.txt | 2 +- 15 files changed, 110 insertions(+), 94 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9d6640b9f..3cc5b9ad23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## [1.666.0](https://github.com/windmill-labs/windmill/compare/v1.665.0...v1.666.0) (2026-03-26) + + +### Features + +* add PDF input support to AI agent ([#8525](https://github.com/windmill-labs/windmill/issues/8525)) ([e44504c](https://github.com/windmill-labs/windmill/commit/e44504c6e93e7a4ee94ced03ab626b79a4fd0754)) + + +### Bug Fixes + +* add relative imports to the dependency list in deploymentUI ([#8548](https://github.com/windmill-labs/windmill/issues/8548)) ([d760ea5](https://github.com/windmill-labs/windmill/commit/d760ea5eaf4dc33007f1fd3e5e07b86925a0aa11)) +* filter null entries in FileUpload initialValue to prevent s3 access error ([#8544](https://github.com/windmill-labs/windmill/issues/8544)) ([1a73012](https://github.com/windmill-labs/windmill/commit/1a73012e0737a6ebea8307013dc0f79982269d91)) +* pass pre-bound TcpListener to run_server to fix Windows CI test race ([#8542](https://github.com/windmill-labs/windmill/issues/8542)) ([d7f4b95](https://github.com/windmill-labs/windmill/commit/d7f4b950ce6e966ed1b410e03d48fe96bc036e73)) +* resolve parent_hash race condition in sync push with auto_parent ([#8545](https://github.com/windmill-labs/windmill/issues/8545)) ([71549c3](https://github.com/windmill-labs/windmill/commit/71549c3db053bcc209c7065ac8cd42f1e8047cc3)) +* upload_s3_file not working in VS Code extension ([#8547](https://github.com/windmill-labs/windmill/issues/8547)) ([1fa4d91](https://github.com/windmill-labs/windmill/commit/1fa4d919b30ac9eff2d1789fba2695450ba115e7)) + ## [1.665.0](https://github.com/windmill-labs/windmill/compare/v1.664.0...v1.665.0) (2026-03-26) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 684fef7714..031344cc61 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -2352,9 +2352,9 @@ dependencies = [ [[package]] name = "cmake" -version = "0.1.57" +version = "0.1.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" dependencies = [ "cc", ] @@ -15059,9 +15059,9 @@ checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" [[package]] name = "unicode-segmentation" -version = "1.13.1" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da36089a805484bcccfffe0739803392c8298778a2d2f09febf76fac5ad9025b" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-width" @@ -15761,7 +15761,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "async-nats", @@ -15837,7 +15837,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.665.0" +version = "1.666.0" dependencies = [ "axum 0.7.9", "chrono", @@ -15850,7 +15850,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "argon2", @@ -15991,7 +15991,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.665.0" +version = "1.666.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16014,7 +16014,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.665.0" +version = "1.666.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16027,7 +16027,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16053,7 +16053,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.665.0" +version = "1.666.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -16063,7 +16063,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.665.0" +version = "1.666.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16080,7 +16080,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.665.0" +version = "1.666.0" dependencies = [ "axum 0.7.9", "base64 0.22.1", @@ -16103,7 +16103,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16126,7 +16126,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.665.0" +version = "1.666.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16142,7 +16142,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.665.0" +version = "1.666.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16162,7 +16162,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.665.0" +version = "1.666.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16182,7 +16182,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.665.0" +version = "1.666.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16196,7 +16196,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "async-nats", @@ -16225,7 +16225,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16250,7 +16250,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.665.0" +version = "1.666.0" dependencies = [ "axum 0.7.9", "flate2", @@ -16268,7 +16268,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16290,7 +16290,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.665.0" +version = "1.666.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16310,7 +16310,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.665.0" +version = "1.666.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16340,7 +16340,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16367,7 +16367,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.665.0" +version = "1.666.0" dependencies = [ "lazy_static", "serde", @@ -16379,7 +16379,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.665.0" +version = "1.666.0" dependencies = [ "argon2", "axum 0.7.9", @@ -16403,7 +16403,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.665.0" +version = "1.666.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16417,7 +16417,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.665.0" +version = "1.666.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16449,7 +16449,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.665.0" +version = "1.666.0" dependencies = [ "chrono", "lazy_static", @@ -16463,7 +16463,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16482,7 +16482,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.665.0" +version = "1.666.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -16583,7 +16583,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.665.0" +version = "1.666.0" dependencies = [ "chrono", "itertools 0.14.0", @@ -16602,7 +16602,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.665.0" +version = "1.666.0" dependencies = [ "regex", "serde", @@ -16617,7 +16617,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -16641,7 +16641,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "futures", @@ -16658,7 +16658,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.665.0" +version = "1.666.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -16674,7 +16674,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "async-trait", @@ -16695,7 +16695,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "async-trait", @@ -16726,7 +16726,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "async-oauth2", @@ -16750,7 +16750,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "async-stream", @@ -16784,7 +16784,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "futures", @@ -16802,7 +16802,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.665.0" +version = "1.666.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -16811,7 +16811,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "lazy_static", @@ -16823,7 +16823,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "serde_json", @@ -16835,7 +16835,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "gosyn", @@ -16847,7 +16847,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "lazy_static", @@ -16859,7 +16859,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "serde_json", @@ -16871,7 +16871,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "nu-parser", @@ -16882,7 +16882,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16893,7 +16893,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16905,7 +16905,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "rustpython-ast", @@ -16916,7 +16916,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "async-recursion", @@ -16938,7 +16938,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "lazy_static", @@ -16952,7 +16952,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -16969,7 +16969,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "lazy_static", @@ -16982,7 +16982,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "serde", @@ -16994,7 +16994,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "lazy_static", @@ -17012,7 +17012,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -17028,7 +17028,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "rustpython-ast", @@ -17044,7 +17044,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "serde", @@ -17055,7 +17055,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "async-recursion", @@ -17092,7 +17092,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "const_format", @@ -17130,7 +17130,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.665.0" +version = "1.666.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -17141,7 +17141,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "async-recursion", @@ -17170,7 +17170,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -17193,7 +17193,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "async-trait", @@ -17226,7 +17226,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "async-trait", @@ -17246,7 +17246,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "async-trait", @@ -17280,7 +17280,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "async-trait", @@ -17315,7 +17315,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "async-trait", @@ -17338,7 +17338,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "async-trait", @@ -17362,7 +17362,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "async-nats", @@ -17386,7 +17386,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "async-trait", @@ -17421,7 +17421,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "async-trait", @@ -17449,7 +17449,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "async-trait", @@ -17472,7 +17472,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "bitflags 2.9.4", @@ -17491,7 +17491,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.665.0" +version = "1.666.0" dependencies = [ "anyhow", "async-once-cell", @@ -17599,7 +17599,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.665.0" +version = "1.666.0" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 8204082a5b..ee1c26c9f7 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.665.0" +version = "1.666.0" authors.workspace = true edition.workspace = true @@ -82,7 +82,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal"] [workspace.package] -version = "1.665.0" +version = "1.666.0" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 1275d648c2..aa3505eec7 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.665.0 + version: 1.666.0 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 9af7dc3af2..829bd04e08 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.665.0"; +export const VERSION = "v1.666.0"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/src/main.ts b/cli/src/main.ts index 30dd5d17fd..23e921d0b8 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -68,7 +68,7 @@ export { workspaceAdd, }; -export const VERSION = "1.665.0"; +export const VERSION = "1.666.0"; // Re-exported from constants.ts to maintain backwards compatibility export { WM_FORK_PREFIX } from "./core/constants.ts"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 914eb790d1..1c3a993774 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-components", - "version": "1.665.0", + "version": "1.666.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-components", - "version": "1.665.0", + "version": "1.666.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index dadb95ca8e..b7eed033fb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "windmill-components", - "version": "1.665.0", + "version": "1.666.0", "scripts": { "dev": "vite dev", "build": "vite build", diff --git a/lsp/Pipfile b/lsp/Pipfile index 9dccc5a54f..d2a3a0360a 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.665.0" +wmill = ">=1.666.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 44f26b14b4..b6065e0a91 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.665.0 + version: 1.666.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index 93de641acd..f68c5d40bc 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.665.0' + ModuleVersion = '1.666.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index 5ae8ca2bbc..fdcefd7e6f 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.665.0" +version = "1.666.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 50ac3f664a..b75bb4c78c 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.665.0", + "version": "1.666.0", "exports": "./src/index.ts", "publish": { "exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts"] diff --git a/typescript-client/package.json b/typescript-client/package.json index 7b70788845..21a7e3055e 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.665.0", + "version": "1.666.0", "author": "Ruben Fiszel", "license": "Apache 2.0", "sideEffects": false, diff --git a/version.txt b/version.txt index 433b0ebcf9..8a00e52c64 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.665.0 +1.666.0 From c0aafee9a9923d5dc2fa3b99da4378e923933a06 Mon Sep 17 00:00:00 2001 From: Tristan TR <69242752+tristantr@users.noreply.github.com> Date: Thu, 26 Mar 2026 19:52:15 +0100 Subject: [PATCH 006/111] feat: improve-replay-ui (#8250) * Improve UI of script record * Improve UI for scripts * Remove Result & Logs loading container while flow not finised * Improve Graph view * Add click on a step mention * Fix spacing when empty * Fix step duration disappearing in recorded flows * Modernize timeline tab * Improve Script recording result UI * feat: externalize recording player controls for fake-window embedding Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: reorder FlowViewer tab sync effects for clarity Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: eliminate tab sync effects in FlowViewer, use selectedTab directly Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: remove unnecessary untrack in FlowViewer tab init Co-Authored-By: Claude Opus 4.6 (1M context) * fix: skip tab auto-selection when selectedTab is controlled externally Co-Authored-By: Claude Opus 4.6 (1M context) * feat: export recording types from package Co-Authored-By: Claude Opus 4.6 (1M context) * fix: non-null assertion for recording.flow in FlowGraphViewer Co-Authored-By: Claude Opus 4.6 (1M context) * fix: replace banned $bindable(default_value) pattern and simplify tab sync Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use svelte 5 onclick syntax on replay page Co-Authored-By: Claude Opus 4.6 (1M context) * fix: skip db clock endpoint during replay mode Co-Authored-By: Claude Opus 4.6 (1M context) * fix: remove line numbers from script recording code display Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: hugocasa Co-authored-by: Claude Opus 4.6 (1M context) --- frontend/package.json | 7 + .../src/lib/components/FlowGraphViewer.svelte | 12 +- .../lib/components/FlowGraphViewerStep.svelte | 7 +- .../lib/components/FlowStatusViewer.svelte | 2 + .../components/FlowStatusViewerInner.svelte | 8 +- .../src/lib/components/FlowTimeline.svelte | 160 ++++++------ frontend/src/lib/components/FlowViewer.svelte | 76 ++++-- frontend/src/lib/components/JobLoader.svelte | 4 +- .../src/lib/components/ScriptEditor.svelte | 15 +- .../src/lib/components/TimelineBar.svelte | 24 +- .../lib/components/graph/FlowGraphV2.svelte | 1 + .../recording/FlowRecordingReplay.svelte | 182 +++++++++----- .../recording/ScriptRecordingReplay.svelte | 237 ++++++++++++------ frontend/src/lib/forLater.ts | 4 + .../(root)/(logged)/replay/+page.svelte | 11 +- 15 files changed, 457 insertions(+), 293 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index b7eed033fb..14e7119c7a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -295,6 +295,10 @@ "svelte": "./package/components/recording/ScriptRecordingReplay.svelte", "default": "./package/components/recording/ScriptRecordingReplay.svelte" }, + "./components/recording/types": { + "types": "./package/components/recording/types.d.ts", + "default": "./package/components/recording/types.js" + }, "./components/FlowWrapper.svelte": { "types": "./package/components/FlowWrapper.svelte.d.ts", "svelte": "./package/components/FlowWrapper.svelte", @@ -500,6 +504,9 @@ "components/ScriptRecordingReplay.svelte": [ "./package/components/recording/ScriptRecordingReplay.svelte.d.ts" ], + "components/recording/types": [ + "./package/components/recording/types.d.ts" + ], "components/FlowBuilder.svelte": [ "./package/components/FlowBuilder.svelte.d.ts" ], diff --git a/frontend/src/lib/components/FlowGraphViewer.svelte b/frontend/src/lib/components/FlowGraphViewer.svelte index 9bfbca3ee4..62e5bdac8e 100644 --- a/frontend/src/lib/components/FlowGraphViewer.svelte +++ b/frontend/src/lib/components/FlowGraphViewer.svelte @@ -26,6 +26,7 @@ workspace?: string | undefined minHeight?: number noBorder?: boolean + hideDefaultInputs?: boolean } let { @@ -38,7 +39,8 @@ stepDetail = $bindable(undefined), workspace = $workspaceStore, minHeight = 400, - noBorder = false + noBorder = false, + hideDefaultInputs = false }: Props = $props() const dispatch = createEventDispatcher() @@ -47,7 +49,9 @@
{#if !noGraph}
@@ -81,14 +85,14 @@ />
{/if} - {#if !noSide} + {#if !noSide && !(hideDefaultInputs && stepDetail == undefined)} {/if}
diff --git a/frontend/src/lib/components/FlowGraphViewerStep.svelte b/frontend/src/lib/components/FlowGraphViewerStep.svelte index 8c560abef0..077edc63e6 100644 --- a/frontend/src/lib/components/FlowGraphViewerStep.svelte +++ b/frontend/src/lib/components/FlowGraphViewerStep.svelte @@ -23,9 +23,10 @@ schema?: any | undefined stepDetail?: FlowModule | string | undefined jobScriptHash?: string | undefined + hideDefaultInputs?: boolean } - let { schema = undefined, stepDetail = undefined, jobScriptHash = undefined }: Props = $props() + let { schema = undefined, stepDetail = undefined, jobScriptHash = undefined, hideDefaultInputs = false }: Props = $props() let codeViewer: Drawer | undefined = $state() @@ -92,10 +93,10 @@
{#if stepDetail == undefined}
-

+

Click on a step to see its details

- {#if schema} + {#if schema && !hideDefaultInputs}

Flow Inputs

{/if} diff --git a/frontend/src/lib/components/FlowStatusViewer.svelte b/frontend/src/lib/components/FlowStatusViewer.svelte index 1642193a46..4b4e08c1b9 100644 --- a/frontend/src/lib/components/FlowStatusViewer.svelte +++ b/frontend/src/lib/components/FlowStatusViewer.svelte @@ -44,6 +44,7 @@ workspaceId = undefined, flowState = $bindable({}), selectedJobStep = $bindable(undefined), + hideFlowResult = false, hideTimeline = false, hideDownloadInGraph = false, hideNodeDefinition = false, @@ -175,6 +176,7 @@ } }} {showLogsWithResult} + {hideFlowResult} notes={notesProp} groups={groupsProp} /> diff --git a/frontend/src/lib/components/FlowStatusViewerInner.svelte b/frontend/src/lib/components/FlowStatusViewerInner.svelte index 8aaeadb5ef..d35ae53c47 100644 --- a/frontend/src/lib/components/FlowStatusViewerInner.svelte +++ b/frontend/src/lib/components/FlowStatusViewerInner.svelte @@ -136,6 +136,7 @@ } showLogsWithResult?: boolean showJobDetailHeader?: boolean + hideFlowResult?: boolean notes?: FlowNote[] groups?: FlowValue['groups'] } @@ -178,6 +179,7 @@ toolCallStore, showLogsWithResult = false, showJobDetailHeader = false, + hideFlowResult = false, notes: notesProp = undefined, groups: groupsProp = undefined }: Props = $props() @@ -1356,7 +1358,7 @@ />
{/if} - {:else if render} + {:else if render && !hideFlowResult}
{#if showLogsWithResult && job} @@ -2141,7 +2143,7 @@ likely did not run yet

{/if} - {:else}

Select a node to see its details here

{/if}
@@ -2157,7 +2159,7 @@ {#if node?.job_id} {:else} -
Select a node with a job to see HTTP request traces
{/if} diff --git a/frontend/src/lib/components/FlowTimeline.svelte b/frontend/src/lib/components/FlowTimeline.svelte index 3fbb69503a..9bff159a03 100644 --- a/frontend/src/lib/components/FlowTimeline.svelte +++ b/frontend/src/lib/components/FlowTimeline.svelte @@ -81,36 +81,31 @@ }} /> {#if items} -
-
-
{min ? displayDate(new Date(min), true) : ''}
{#if max && min} - {/if}
{max ? displayDate(new Date(max), true) : ''}{#if !max && min}{#if now} - {msToSec(now - min, 1)}s - {/if}{/if}
-
-
-
-
-
Waiting for executor/Suspend
-
+
+
+
+ {min ? displayDate(new Date(min), true) : ''} +
+
+
+
+ Wait
- -
-
Execution
-
+
+
+ Execution
+ {#if max && min} + {msToSec(max - min, 1)}s + {/if} + {#if !max && min}{#if now} + {msToSec(now - min, 1)}s + {/if}{/if}
{#if selfWaitTime} -
- root: +
+ root: x.created_at && x.started_at)} -
-
-
+
+
{k.startsWith('subflow:') ? k.substring(8) : k} {#if localModuleStates[k]?.selectedForloop && (typ == 'forloopflow' || typ == 'whileloopflow')} @@ -141,70 +136,67 @@ {/if}
-
- {#if subItems?.length > 1} -
- {subItems?.length} jobs -
- {/if} - {#if min && total} - subItems?.[index]?.id} - > - {#snippet item({ index, style })} - {@const b = subItems?.[index]} - {#if b?.created_at} - - {@const waitingLen = b?.created_at - ? b.started_at - ? b.started_at - b?.created_at - : b.duration_ms - ? 0 - : now - b?.created_at - : 0} -
+ {#if subItems?.length > 1} + + {subItems?.length} jobs + + {/if} +
+
+ {#if min && total} + subItems?.[index]?.id} + > + {#snippet item({ index, style })} + {@const b = subItems?.[index]} + {#if b?.created_at} + {@const waitingLen = b?.created_at + ? b.started_at + ? b.started_at - b?.created_at + : b.duration_ms + ? 0 + : now - b?.created_at + : 0} +
+ + {#if b.started_at} - {#if b.started_at} - - {/if} -
- {:else} -
-
- -
-
- {/if} - {/snippet} -
- {/if}
+ {/if} +
+ {:else} +
+ {/if} + {/snippet} + + {/if} +
{/each}
+ {:else} {/if} diff --git a/frontend/src/lib/components/FlowViewer.svelte b/frontend/src/lib/components/FlowViewer.svelte index 2355ddf68d..1e321584e3 100644 --- a/frontend/src/lib/components/FlowViewer.svelte +++ b/frontend/src/lib/components/FlowViewer.svelte @@ -20,7 +20,7 @@ schema?: any } - type TabValue = 'ui' | 'raw' | 'schema' | 'diff' + export type TabValue = 'ui' | 'raw' | 'schema' | 'diff' interface Props { flow: { @@ -33,10 +33,16 @@ noSide?: boolean noGraph?: boolean initTab?: TabValue + selectedTab?: TabValue + hideTabs?: boolean noSummary?: boolean + noInput?: boolean + hideDefaultInputs?: boolean + showStepHint?: boolean noGraphDownload?: boolean availableVersions?: Array<{ id: number; deployment_msg?: string }> selectedVersionId?: number + graphContent?: import('svelte').Snippet } let { @@ -46,9 +52,15 @@ noGraph = false, availableVersions = undefined, initTab = undefined, + selectedTab = $bindable(), + hideTabs = false, noSummary = false, + noInput = false, + hideDefaultInputs = false, + showStepHint = false, noGraphDownload = false, - selectedVersionId = undefined + selectedVersionId = undefined, + graphContent = undefined }: Props = $props() let open: { [id: number]: boolean } = {} @@ -59,7 +71,10 @@ let previousVersionId: number | undefined = $state(undefined) let previousFlow: PreviousFlow | undefined = $state(undefined) - let tab: TabValue = $state(untrack(() => initTab) ?? 'diff') + const tabControlledExternally = selectedTab !== undefined + if (!tabControlledExternally) { + selectedTab = initTab ?? 'diff' + } let previousFlowCache: Record = {} @@ -90,16 +105,16 @@ }) $effect.pre(() => { - if (initTab) { + if (initTab || tabControlledExternally) { return } if (availableVersions && availableVersions.length > 0) { - tab = 'diff' + selectedTab = 'diff' } else { if (noGraph) { - tab = 'schema' + selectedTab = 'schema' } else { - tab = 'ui' + selectedTab = 'ui' } } }) @@ -127,7 +142,7 @@ - + {#if availableVersions && availableVersions.length > 0} {/if} @@ -167,23 +182,38 @@ {/if} -
- {#if !noSummary} -

{flow.summary}

-
{flow.description ?? ''}
- {/if} + {#if graphContent} + {@render graphContent()} + {:else} +
+ {#if showStepHint} +

Click on a step to see its details

+ {/if} + {#if !noSummary} +

{flow.summary}

+
{flow.description ?? ''}
+ {/if} -

- Flow Input -

- {#if flow.schema && flow.schema.properties && Object.keys(flow.schema.properties).length > 0 && flow.schema} - - {:else} -
No inputs
- {/if} + {#if !noInput} +

+ Flow Input +

+ {#if flow.schema && flow.schema.properties && Object.keys(flow.schema.properties).length > 0 && flow.schema} + + {:else} +
No inputs
+ {/if} + {/if} - -
+ +
+ {/if}
diff --git a/frontend/src/lib/components/JobLoader.svelte b/frontend/src/lib/components/JobLoader.svelte index d8a0adfd35..78429177a7 100644 --- a/frontend/src/lib/components/JobLoader.svelte +++ b/frontend/src/lib/components/JobLoader.svelte @@ -107,7 +107,7 @@ $effect(() => { if (noLogs != lastNoLogs) { lastNoLogs = noLogs - if (!noLogs) { + if (!noLogs && !getActiveReplay()) { currentEventSource?.onerror?.(new Event(noLogsChangeRestartEvent)) const lastJobId = lastCompletedJobId if (lastJobId && (job || lastCallbacks?.loadExtraLogs)) { @@ -255,7 +255,7 @@ } } export async function getLogs() { - if (job) { + if (job && !getActiveReplay()) { refreshLogOffset() const getUpdate = await JobService.getJobUpdates({ workspace: workspace!, diff --git a/frontend/src/lib/components/ScriptEditor.svelte b/frontend/src/lib/components/ScriptEditor.svelte index 2690301f28..2bc5fe8575 100644 --- a/frontend/src/lib/components/ScriptEditor.svelte +++ b/frontend/src/lib/components/ScriptEditor.svelte @@ -632,6 +632,10 @@ } export async function runTest() { + // Discard any previous recording when running a normal test + if (!scriptRecording.active) { + lastRecording = undefined + } // Not defined if JobProgressBar not loaded jobProgressBar?.reset() // Flush module edits back to modules map before running preview @@ -1530,16 +1534,7 @@ displayName: 'Test & record', icon: Disc, action: () => recordAndTest() - }, - ...(lastRecording - ? [ - { - displayName: 'Download recording', - icon: Download, - action: () => downloadRecording() - } - ] - : []) + } ]} />
diff --git a/frontend/src/lib/components/TimelineBar.svelte b/frontend/src/lib/components/TimelineBar.svelte index e4fb511450..5d1f53e961 100644 --- a/frontend/src/lib/components/TimelineBar.svelte +++ b/frontend/src/lib/components/TimelineBar.svelte @@ -14,6 +14,7 @@ running: boolean concat?: boolean gray?: boolean + spacerClass?: string } let { @@ -25,25 +26,26 @@ id, running, concat = false, - gray = false + gray = false, + spacerClass = '' }: Props = $props() {#if min && started_at != undefined} {#if !concat} -
+
{/if} {#snippet text()} 0} {@const narrow = len / total < 0.09} - {@const endPos = started_at != undefined && min != undefined ? (started_at - min + len) / total : 1} + {@const endPos = + started_at != undefined && min != undefined ? (started_at - min + len) / total : 1} {@const nearStart = endPos < 0.15} - {#if len}{msToSec(len, 1)}s{/if} {/if} diff --git a/frontend/src/lib/components/graph/FlowGraphV2.svelte b/frontend/src/lib/components/graph/FlowGraphV2.svelte index 6a4d208579..47626fb41c 100644 --- a/frontend/src/lib/components/graph/FlowGraphV2.svelte +++ b/frontend/src/lib/components/graph/FlowGraphV2.svelte @@ -921,6 +921,7 @@ document.addEventListener('keydown', globalKeyDownHandler) + return () => { document.removeEventListener('keydown', globalKeyDownHandler) } diff --git a/frontend/src/lib/components/recording/FlowRecordingReplay.svelte b/frontend/src/lib/components/recording/FlowRecordingReplay.svelte index c5d94a7b95..afd2cee94c 100644 --- a/frontend/src/lib/components/recording/FlowRecordingReplay.svelte +++ b/frontend/src/lib/components/recording/FlowRecordingReplay.svelte @@ -2,7 +2,8 @@ import type { Job } from '$lib/gen' import { workspaceStore } from '$lib/stores' import FlowStatusViewer from '$lib/components/FlowStatusViewer.svelte' - import FlowViewer from '$lib/components/FlowViewer.svelte' + import FlowViewer, { type TabValue } from '$lib/components/FlowViewer.svelte' + import FlowGraphViewer from '$lib/components/FlowGraphViewer.svelte' import FlowProgressBar from '$lib/components/flows/FlowProgressBar.svelte' import FlowExecutionStatus from '$lib/components/runs/FlowExecutionStatus.svelte' import { setActiveReplay } from './flowRecording.svelte' @@ -13,21 +14,37 @@ import { InfoIcon, LogOut, Play, Square } from 'lucide-svelte' import { onDestroy } from 'svelte' - interface Props { - recording: FlowRecording - } - - let { recording }: Props = $props() - type ReplayState = 'loaded' | 'playing' - let replayState: ReplayState = $state('loaded') + interface Props { + recording: FlowRecording + selectedTab?: TabValue + replayState?: ReplayState + hideControls?: boolean + hideTabs?: boolean + } + + let { + recording, + selectedTab = $bindable(), + replayState = $bindable(), + hideControls = false, + hideTabs = false + }: Props = $props() + + if (selectedTab === undefined) { + selectedTab = 'ui' + } + if (replayState === undefined) { + replayState = 'loaded' + } + let rootJobId: string | undefined = $state(undefined) let rootInitialJob: Job | undefined = $state(undefined) let job: Job | undefined = $state(undefined) let done = $derived((job as any)?.type === 'CompletedJob') - function stop() { + export function stop() { setActiveReplay(undefined) job = undefined initRecording() @@ -36,10 +53,7 @@ function findRootJobId(data: FlowRecording): string | undefined { for (const [id, recorded] of Object.entries(data.jobs)) { const j = recorded.initial_job - if ( - (j.job_kind === 'flow' || j.job_kind === 'flowpreview') && - !j.parent_job - ) { + if ((j.job_kind === 'flow' || j.job_kind === 'flowpreview') && !j.parent_job) { return id } } @@ -81,17 +95,19 @@ for (const mod of fs.modules) { const durations = mod.flow_jobs_duration if (durations?.started_at) { - durations.started_at = durations.started_at.map( - (d: string) => offsetDate(d) ?? d - ) + durations.started_at = durations.started_at.map((d: string) => offsetDate(d) ?? d) } } } for (const recorded of Object.values(data.jobs)) { offsetJobTimestamps(recorded.initial_job) + if (recorded.initial_job?.flow_status) offsetFlowStatus(recorded.initial_job.flow_status) for (const event of recorded.events) { - if (event.data?.job) offsetJobTimestamps(event.data.job) + if (event.data?.job) { + offsetJobTimestamps(event.data.job) + if (event.data.job.flow_status) offsetFlowStatus(event.data.job.flow_status) + } if (event.data?.flow_status) offsetFlowStatus(event.data.flow_status) } } @@ -141,22 +157,27 @@ // Push the root's completed event to fire after all sub-job events let completedIdx = -1 for (let i = rootEvents.length - 1; i >= 0; i--) { - if (rootEvents[i].data.completed) { completedIdx = i; break } + if (rootEvents[i].data.completed) { + completedIdx = i + break + } } if (completedIdx >= 0 && rootEvents[completedIdx].t < maxSubJobT) { rootEvents[completedIdx].t = maxSubJobT + 50 } } - function startReplay() { + export function startReplay() { + if (!rootJobId) return // JSON round-trip to unwrap reactive proxies and strip non-cloneable properties const snapshot = JSON.parse(JSON.stringify(recording)) as FlowRecording - fixEventOrdering(snapshot, rootJobId!) - rebaseTimestamps(snapshot, rootJobId!) + fixEventOrdering(snapshot, rootJobId) + rebaseTimestamps(snapshot, rootJobId) setActiveReplay(snapshot) - rootInitialJob = buildInitialJob(snapshot, rootJobId!) + rootInitialJob = buildInitialJob(snapshot, rootJobId) job = undefined replayState = 'playing' + selectedTab = 'ui' } onDestroy(() => { @@ -173,52 +194,81 @@

-{:else if replayState === 'loaded'} +{:else}
-
-
-

{recording.flow_path}

- - - {#snippet text()} - - Recorded {new Date(recording.recorded_at).toLocaleString()} — - {(recording.total_duration_ms / 1000).toFixed(1)}s - - {/snippet} - + {#if !hideControls} +
+
+

+ {replayState === 'playing' ? 'Replaying: ' : ''}{recording.flow_path} +

+ + + {#snippet text()} + + Recorded {new Date(recording.recorded_at).toLocaleString()} — + {(recording.total_duration_ms / 1000).toFixed(1)}s + + {/snippet} + +
+ {#if replayState === 'loaded'} + + {:else} + + {/if}
- -
- -
-{:else if replayState === 'playing' && rootJobId} -
-
-

Replaying: {recording.flow_path}

- -
- - {#if job} - {/if} - + + + {#snippet graphContent()} + {#if replayState === 'playing' && rootJobId} +
+ + {#if job} + + {/if} + +
+ {:else} +
+

Click on a step to see its details

+ +
+ {/if} + {/snippet} +
{/if} diff --git a/frontend/src/lib/components/recording/ScriptRecordingReplay.svelte b/frontend/src/lib/components/recording/ScriptRecordingReplay.svelte index 8c3d28a71a..0327044b3b 100644 --- a/frontend/src/lib/components/recording/ScriptRecordingReplay.svelte +++ b/frontend/src/lib/components/recording/ScriptRecordingReplay.svelte @@ -12,6 +12,7 @@ import { json as jsonLang } from 'svelte-highlight/languages' import HighlightTheme from '$lib/components/HighlightTheme.svelte' import JobArgs from '$lib/components/JobArgs.svelte' + import SchemaForm from '$lib/components/SchemaForm.svelte' import DisplayResult from '$lib/components/DisplayResult.svelte' import LogViewer from '$lib/components/LogViewer.svelte' import { ClipboardCopy, InfoIcon, LogOut, Play, Square } from 'lucide-svelte' @@ -19,15 +20,26 @@ import { onDestroy, tick } from 'svelte' import JobLoader from '$lib/components/JobLoader.svelte' - interface Props { - recording: ScriptRecording - } - - let { recording }: Props = $props() + export type ScriptTabValue = 'parameters' | 'code' | 'args' | 'schema' | 'result' type ReplayState = 'loaded' | 'playing' - let replayState: ReplayState = $state('loaded') + interface Props { + recording: ScriptRecording + selectedTab?: ScriptTabValue + replayState?: ReplayState + hideControls?: boolean + hideTabs?: boolean + } + + let { + recording, + selectedTab = $bindable(), + replayState = $bindable(), + hideControls = false, + hideTabs = false + }: Props = $props() + let jobId: string | undefined = $state(undefined) let job: Job | undefined = $state(undefined) let jobLoader: JobLoader | undefined = $state(undefined) @@ -35,7 +47,18 @@ let scriptRecordingStore = createScriptRecording() - function stop() { + let schema = $derived(recording.schema) + + if (selectedTab === undefined) { + if (schema && recording.args) selectedTab = 'parameters' + else if (recording.args && Object.keys(recording.args).length > 0) selectedTab = 'args' + else selectedTab = 'code' + } + if (replayState === undefined) { + replayState = 'loaded' + } + + export function stop() { setActiveReplay(undefined) job = undefined replayState = 'loaded' @@ -85,13 +108,14 @@ initRecording() - async function startReplay() { + export async function startReplay() { const snapshot = JSON.parse(JSON.stringify(recording)) as ScriptRecording rebaseTimestamps(snapshot) const replayData = scriptRecordingStore.toReplayData(snapshot) setActiveReplay(replayData) job = undefined replayState = 'playing' + selectedTab = 'result' await tick() if (jobLoader && jobId) { jobLoader.watchJob(jobId) @@ -101,8 +125,6 @@ onDestroy(() => { setActiveReplay(undefined) }) - - let schema = $derived(recording.schema) @@ -115,48 +137,141 @@

-{:else if replayState === 'loaded'} -
-
-
-

{recording.script_path || 'Untitled script'}

- {recording.language} - - - {#snippet text()} - - Recorded {new Date(recording.recorded_at).toLocaleString()} — - {(recording.total_duration_ms / 1000).toFixed(1)}s - - {/snippet} - +{:else} +
+ {#if !hideControls} +
+
+

+ {replayState === 'playing' ? 'Replaying: ' : ''}{recording.script_path || + 'Untitled script'} +

+ + {recording.language} + + + + {#snippet text()} + + Recorded {new Date(recording.recorded_at).toLocaleString()} — + {(recording.total_duration_ms / 1000).toFixed(1)}s + + {/snippet} + +
+ {#if replayState === 'loaded'} + + {:else} + + {/if}
- -
- - {#if recording.args && Object.keys(recording.args).length > 0} - {/if} - - + {#if replayState === 'playing'} + + {/if} + + + {#if replayState === 'playing'} + + {/if} + {#if schema && recording.args} + + {/if} + {#if recording.args && Object.keys(recording.args).length > 0} + + {/if} + {#if !schema || !recording.args} + + {/if} {#if schema} {/if} {#snippet content()} + + {#if replayState === 'playing' && jobId} +
+
+

Result

+
+ {#if job !== undefined && job.type === 'CompletedJob' && job.result !== undefined} + + {:else if done} +
+ No output available +
+ {:else} +
+ Waiting for result... +
+ {/if} +
+
+
+

Logs

+
+ +
+
+
+ {/if} +
+ + {#if schema && recording.args} +
+
+ +
+
+ +
+
+ {/if} +
+ + {#if recording.args && Object.keys(recording.args).length > 0} +
+ +
+ {/if} +
-
+
@@ -180,46 +295,4 @@ {/snippet}
-{:else if replayState === 'playing' && jobId} -
-
-

Replaying: {recording.script_path || 'Untitled script'}

- -
- - - {#if done && job} -
-

Result

-
- {#if job.type === 'CompletedJob' && job.result !== undefined} - - {:else} -
No result available
- {/if} -
-
- {/if} - -
- -
-
{/if} diff --git a/frontend/src/lib/forLater.ts b/frontend/src/lib/forLater.ts index ea290e2a53..b3ce8001e2 100644 --- a/frontend/src/lib/forLater.ts +++ b/frontend/src/lib/forLater.ts @@ -1,6 +1,7 @@ import { get } from 'svelte/store' import { dbClockDrift } from './stores' import { JobService } from './gen' +import { getActiveReplay } from './components/recording/flowRecording.svelte' import pLimit from 'p-limit' function subtractSeconds(date: Date, seconds: number): Date { @@ -26,6 +27,9 @@ export function forLater(scheduledString: string): boolean { const limit = pLimit(1) export function getDbClockNow() { + if (getActiveReplay()) { + return new Date() + } let drift = get(dbClockDrift) if (drift == undefined) { limit(() => computeDrift()) diff --git a/frontend/src/routes/(root)/(logged)/replay/+page.svelte b/frontend/src/routes/(root)/(logged)/replay/+page.svelte index 559961aff0..d89bcbb9b0 100644 --- a/frontend/src/routes/(root)/(logged)/replay/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/replay/+page.svelte @@ -51,14 +51,14 @@
{#if flowRecording}
-
{:else if scriptRecording}
-
@@ -70,12 +70,7 @@

Upload a recording JSON file to replay a flow or script execution offline.

- + Drag and drop a recording file
From e2cc6e4709404e14ff23a515001ba931caba95dd Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 26 Mar 2026 20:58:23 +0000 Subject: [PATCH 007/111] nit sqlx --- ...1c2d14755474cba82b3b388a47585a8bb325b1a.json | 17 +++++++++++++++++ backend/ee-repo-ref.txt | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 backend/.sqlx/query-9f07510019ebe6f0c5fa17bf31c2d14755474cba82b3b388a47585a8bb325b1a.json diff --git a/backend/.sqlx/query-9f07510019ebe6f0c5fa17bf31c2d14755474cba82b3b388a47585a8bb325b1a.json b/backend/.sqlx/query-9f07510019ebe6f0c5fa17bf31c2d14755474cba82b3b388a47585a8bb325b1a.json new file mode 100644 index 0000000000..25a32e5338 --- /dev/null +++ b/backend/.sqlx/query-9f07510019ebe6f0c5fa17bf31c2d14755474cba82b3b388a47585a8bb325b1a.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO deployment_metadata (workspace_id, path, script_hash, deployment_msg) VALUES ($1, $2, $3, $4) ON CONFLICT (workspace_id, script_hash) WHERE script_hash IS NOT NULL DO UPDATE SET deployment_msg = EXCLUDED.deployment_msg", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Int8", + "Text" + ] + }, + "nullable": [] + }, + "hash": "9f07510019ebe6f0c5fa17bf31c2d14755474cba82b3b388a47585a8bb325b1a" +} diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index ce0a80c162..840129b249 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -6db424512b0d02f86489e85f0026581b7637d6e6 +01688af32ccd48a39f993043c1ce8f337b5c9eff \ No newline at end of file From 9e235937ce41323c83815f08a99c5ce9e4840b6b Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 27 Mar 2026 08:53:46 +0000 Subject: [PATCH 008/111] add WAC v2 benchmarks and improve benchmark infrastructure (#8550) Co-authored-by: Claude Opus 4.6 (1M context) --- .github/workflows/benchmark.yml | 44 +++++++ benchmarks/Dockerfile | 16 ++- benchmarks/README.md | 122 ++++++++---------- benchmarks/benchmark_graphs.ts | 34 ++--- benchmarks/benchmark_oneoff.ts | 37 +++++- benchmarks/benchmark_suite.ts | 13 +- benchmarks/graphs_config.json | 70 ++++++++++ benchmarks/lib.ts | 220 ++++++++++++++++++++++++++++++++ benchmarks/main.ts | 20 --- benchmarks/suite_wac.json | 30 +++++ benchmarks/worker.ts | 90 ------------- 11 files changed, 474 insertions(+), 222 deletions(-) create mode 100644 benchmarks/suite_wac.json diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 195821b2dd..d420ff1f00 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -290,6 +290,49 @@ jobs: path: | *.json + benchmark_wac: + runs-on: ubicloud-standard-8 + services: + postgres: + image: postgres + env: + POSTGRES_DB: windmill + POSTGRES_PASSWORD: changeme + POSTGRES_INITDB_ARGS: "-c shared_buffers=2GB -c work_mem=32MB -c effective_cache_size=4GB" + options: >- + --health-cmd pg_isready --health-interval 10s --health-timeout 5s + --health-retries 5 + --shm-size=2g + windmill: + image: ghcr.io/windmill-labs/windmill-ee:main + env: + DATABASE_URL: postgres://postgres:changeme@postgres:5432/windmill + LICENSE_KEY: ${{ secrets.WM_LICENSE_KEY_CI }} + WORKER_GROUP: main + WORKER_TAGS: deno,bun,go,python3,bash,dependency,flow,nativets + options: >- + --pull always --health-interval 10s --health-timeout 5s + --health-retries 5 --health-cmd "curl + http://localhost:8000/api/version" + ports: + - 8000:8000 + steps: + - uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + - name: benchmark + timeout-minutes: 30 + run: deno run -A -r + https://raw.githubusercontent.com/windmill-labs/windmill/${GITHUB_REF##ref/head/}/benchmarks/benchmark_suite.ts + -c + https://raw.githubusercontent.com/windmill-labs/windmill/${GITHUB_REF##ref/head/}/benchmarks/suite_wac.json + - name: Save benchmark results + uses: actions/upload-artifact@v4 + with: + name: benchmark_wac + path: | + *.json + benchmark_graphs: runs-on: ubicloud needs: @@ -297,6 +340,7 @@ jobs: - benchmark_dedicated - benchmark_4workers - benchmark_8workers + - benchmark_wac steps: - uses: denoland/setup-deno@v2 with: diff --git a/benchmarks/Dockerfile b/benchmarks/Dockerfile index c8f3fe83d5..7655e5f2d2 100644 --- a/benchmarks/Dockerfile +++ b/benchmarks/Dockerfile @@ -1,14 +1,20 @@ -FROM denoland/deno:alpine-1.26.2 +FROM denoland/deno:alpine-2.1.4 WORKDIR /app USER deno +ADD ./lib.ts . +ADD ./action.ts . ADD ./main.ts . -RUN deno cache --unstable main.ts +RUN deno cache main.ts ADD ./worker.ts . -RUN deno cache --unstable worker.ts +RUN deno cache worker.ts ADD ./scraper.ts . -RUN deno cache --unstable scraper.ts +RUN deno cache scraper.ts +ADD ./benchmark_oneoff.ts . +RUN deno cache benchmark_oneoff.ts +ADD ./benchmark_suite.ts . +RUN deno cache benchmark_suite.ts -ENTRYPOINT [ "/tini", "--", "docker-entrypoint.sh", "run", "--unstable", "-A", "main.ts" ] \ No newline at end of file +ENTRYPOINT [ "/tini", "--", "docker-entrypoint.sh", "run", "-A", "main.ts" ] diff --git a/benchmarks/README.md b/benchmarks/README.md index c358471626..fc758c006f 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -1,85 +1,71 @@ # Benchmarks -This folder includes a small deno/ts utility to benchmark execution of jobs & -flows. +Deno/TS benchmark suite for measuring Windmill job and flow execution throughput. -## Installation +## Quick Start -Install the `wmill` CLI tool using -`deno install --unstable -A https://deno.land/x/wmillbench/main.ts`. +```bash +# Install Deno +curl -fsSL https://deno.land/install.sh | sh -Update to the latest version using `wmillbench upgrade`. +# Run a single benchmark +deno run -A benchmark_oneoff.ts --kind noop --jobs 10000 -To build a local version, you can just run: -``` -deno install -A main.ts +# Run the full suite +deno run -A benchmark_suite.ts -c suite_config.json + +# Run WAC v2 benchmarks (workflow-as-code vs flow comparison) +deno run -A benchmark_suite.ts -c suite_wac.json ``` -## Quickstart +## Benchmark Kinds -Have your instance expose prometheus metrics (METRICS_ADDR=true). +### Script benchmarks +- `noop` — Empty jobs (measures pure scheduling overhead) +- `deno`, `bun`, `python`, `go`, `bash` — Language runtimes +- `nativets` — BunNative (no isolation) +- `dedicated`, `dedicated_nativets` — Dedicated worker mode -Then +### Flow benchmarks +- `2steps` — 2-step flow (deno + identity) +- `bigscriptinflow` — Flow with large raw bash script +- `flow_seq_2_bun` — 2 sequential bun steps +- `flow_par_2_bun` — 2 parallel bun steps (branchall) +- `flow_seq_3_bun` — 3 sequential bun steps +- `flow:` — Custom flow by path +- `script:` — Custom script by path -``` -wmillbench -e admin@windmill.dev -p changeme --host YOUR_HOST +### WAC v2 benchmarks (workflow-as-code) +- `wac_seq_2` — 2 sequential tasks +- `wac_par_2` — 2 parallel tasks (Promise.all) +- `wac_seq_3` — 3 sequential tasks +- `wac_inline_2` — 2 inline steps (no child jobs) + +## Suite Configs + +| File | Description | +|------|-------------| +| `suite_config.json` | Main benchmark suite (noop, languages, flows) | +| `suite_dedicated.json` | Dedicated worker benchmarks | +| `suite_dedicated_nativets.json` | Dedicated NativeTS benchmarks | +| `suite_wac.json` | WAC v2 vs flow comparison benchmarks | + +## Interactive Benchmark Tool + +```bash +deno run -A main.ts -e admin@windmill.dev -p changeme --host http://localhost:8000 ``` -## Usage +Options: `--workers`, `--seconds`, `--maximum-throughput`, `--use-flows`, `--script-pattern`, `--export-json`, `--export-csv` -Usage: wmillbench +## Graph Generation -Description: - -Run Benchmark to measure throughput of windmill. - -Options: - --h, --help - Show this help. --V, --version - Show the version number for this program. ---host - The windmill host to benchmark. (Default: "http://127.0.0.1:8000/") ---workers - The number of workers to run at once. (Default: 1) --s, --seconds - How long to run the benchmark for (in seconds). (Default: 30) --e, --email - The email to use to login. --p, --password - The password to use to login. --t, --token - The token to use when talking to the API server. Preferred over manual login. --w, --workspace - The workspace to spawn scripts from. (Default: "starter") --m, --metrics - The url to scrape metrics from. (Default: "http://localhost:8001/metrics") ---export-json - If set, exports will be into a JSON file. ---export-csv - If set, exports will be into a csv file. ---export-histograms [histograms...] - Mark metrics (without label) that are reported as histograms to export. ---export-simple [simple...] - Mark metrics (without label) that are reported as simple values. ---maximum-throughput - Maximum number of jobs/flows to start in one second. (Default: Infinity) ---use-flows - Run flows instead of jobs. ---histogram-buckets [buckets...] - Define what buckets to collect from histograms. (Default: [ "+Inf", "10", "5", "2.5", "2.5", "1", "0.5", "0.25", "0.1", "0.05", "0.025", "0.01", "0.005" ]) - -Environment variables: - -WM_TOKEN - The token to use when talking to the API server. Preferred -over manual login. WM_WORKSPACE - The workspace to spawn scripts -from. - - - -This will run a simple benchmark against localhost (the default admin email + -password are set above), all execution is done in the "bench" workspace (as set -via `--workspace`). - -Metrics are exported to JSON will only include mean & stdev, histograms get one -entry for each bucket. CSV will include a full list of all values scraped. - -## NOOP jobs benchmark - -A specific benchmark creating a set of NOOP jobs all at once in windmill is also available. -in `benchmarks_noop.ts` - -You can build it locally with: -``` -deno install -A benchmarks_noop.ts -``` -and then -``` -benchmarks_noop -e admin@windmill.dev -p changeme --host YOUR_HOST +```bash +deno run -A benchmark_graphs.ts -c graphs_config.json ``` -By default it creates 10000 jobs in Windmill in a single batch, but this is parametrizable. \ No newline at end of file +Generates SVG graphs from `*_benchmark.json` data files. + +## CI + +The GitHub Actions workflow (`.github/workflows/benchmark.yml`) runs hourly with 1/4/8 worker configurations plus WAC benchmarks. Results are committed to the `benchmarks` branch. diff --git a/benchmarks/benchmark_graphs.ts b/benchmarks/benchmark_graphs.ts index 23b3b763f7..bfaad7dbb6 100644 --- a/benchmarks/benchmark_graphs.ts +++ b/benchmarks/benchmark_graphs.ts @@ -3,32 +3,20 @@ import { UpgradeCommand } from "https://deno.land/x/cliffy@v0.25.7/command/upgra import { DenoLandProvider } from "https://deno.land/x/cliffy@v0.25.7/command/upgrade/mod.ts"; import { drawGraph, drawGraphMulti } from "./graph.ts"; -import { VERSION } from "./lib.ts"; +import { VERSION, loadJsonConfig } from "./lib.ts"; -type GraphsConfig = [ - { - graph_title: string; - benchmarks: { - kind: string; - workers: number; - label: string; - }[]; - jobs: number; - } -]; +type GraphsConfig = { + graph_title: string; + benchmarks: { + kind: string; + workers: number; + label: string; + }[]; +}[]; async function main({ configPath }: { configPath: string }) { - async function getConfig(configPath: string): Promise { - if (configPath.startsWith("http")) { - const response = await fetch(configPath); - return await response.json(); - } else { - return JSON.parse(await Deno.readTextFile(configPath)); - } - } - try { - const config = await getConfig(configPath); + const config = await loadJsonConfig(configPath); for (const graphConfig of config || []) { const data: { @@ -81,7 +69,7 @@ async function main({ configPath }: { configPath: string }) { } await new Command() - .name("wmillbenchsuite") + .name("wmillbenchgraphs") .description("Create and save graphs from benchmark data.") .version(VERSION) .option("-c --config-path ", "The path of the config file", { diff --git a/benchmarks/benchmark_oneoff.ts b/benchmarks/benchmark_oneoff.ts index 0cd4c3483f..5f075d2034 100644 --- a/benchmarks/benchmark_oneoff.ts +++ b/benchmarks/benchmark_oneoff.ts @@ -10,7 +10,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"; -import { VERSION, createBenchScript, getFlowPayload, login } from "./lib.ts"; +import { VERSION, createBenchScript, createWacBenchScript, getFlowPayload, login, WAC_KINDS, STEPS_PER_WORKFLOW } from "./lib.ts"; async function verifyOutputs(uuids: string[], workspace: string) { console.log("Verifying outputs"); @@ -38,6 +38,8 @@ async function verifyOutputs(uuids: string[], workspace: string) { } export const NON_TEST_TAGS = ["deno", "python", "go", "bash", "dedicated", "bun", "nativets", "dedicated_nativets", "flow"] + +const FLOW_COMPARISON_KINDS = ["flow_seq_2_bun", "flow_par_2_bun", "flow_seq_3_bun"]; export async function main({ host, email, @@ -151,6 +153,8 @@ export async function main({ ) ) { await createBenchScript(kind, workspace); + } else if (WAC_KINDS.includes(kind)) { + await createWacBenchScript(kind, workspace); } @@ -173,6 +177,20 @@ export async function main({ kind: "script", path: "f/benchmarks/" + kind, }); + } else if (WAC_KINDS.includes(kind)) { + // WAC v2 scripts are deployed as bun scripts, run via script path + nStepsFlow = STEPS_PER_WORKFLOW[kind] ?? 0; + body = JSON.stringify({ + kind: "script", + path: "f/benchmarks/" + kind, + }); + } else if (FLOW_COMPARISON_KINDS.includes(kind)) { + nStepsFlow = STEPS_PER_WORKFLOW[kind] ?? 0; + const payload = getFlowPayload(kind); + body = JSON.stringify({ + kind: "flow", + flow_value: payload.value, + }); } else if (["2steps", "bigscriptinflow"].includes(kind)) { nStepsFlow = kind == "2steps" ? 2 : 1; const payload = getFlowPayload(kind); @@ -182,7 +200,7 @@ export async function main({ }); } else if (kind.startsWith("flow:")) { console.log("Detected custom flow "); - let flow_path = kind.substr(5); + let flow_path = kind.substring(5); nStepsFlow = await getFlowStepCount(config.workspace_id, flow_path); console.log(`Total steps of flow including sub-flows: ${nStepsFlow}`); body = JSON.stringify({ @@ -193,7 +211,7 @@ export async function main({ console.log("Detected custom script"); body = JSON.stringify({ kind: "script", - path: kind.substr(7), + path: kind.substring(7), }); } else if (kind == "bigrawscript") { noVerify = true; @@ -281,6 +299,9 @@ export async function main({ let lastElapsed = 0; let lastCompletedJobs = 0; + // Timeout: 10 minutes for the polling loop to prevent hanging forever + // (e.g. if WAC suspend/resume fails or jobs get stuck) + const POLL_TIMEOUT_MS = 10 * 60 * 1000; let didStart = false; while (completedJobs < jobsSent) { const loopStart = Date.now(); @@ -292,6 +313,10 @@ export async function main({ } } else { const elapsed = start ? Date.now() - start : 0; + if (elapsed > POLL_TIMEOUT_MS) { + console.error(`\nTimeout: benchmark did not complete within ${POLL_TIMEOUT_MS / 1000}s (${completedJobs}/${jobsSent} completed)`); + break; + } completedJobs = await getCompletedJobsCount(NON_TEST_TAGS); if (nStepsFlow > 0) { completedJobs = Math.floor(completedJobs / (nStepsFlow + 1)); @@ -338,7 +363,9 @@ export async function main({ kind !== "nativets" && kind !== "dedicated_nativets" && !kind.startsWith("flow:") && - !kind.startsWith("script:") + !kind.startsWith("script:") && + !WAC_KINDS.includes(kind) && + !FLOW_COMPARISON_KINDS.includes(kind) ) { await verifyOutputs(uuids, config.workspace_id); } @@ -387,7 +414,7 @@ if (import.meta.main) { ) .option( "--kind ", - "Specifiy the benchmark kind among: deno, identity, python, go, bash, dedicated, bun, noop, 2steps, nativets, dedicated_nativets", + "Specifiy the benchmark kind among: deno, identity, python, go, bash, dedicated, bun, noop, 2steps, nativets, dedicated_nativets, wac_seq_2, wac_par_2, wac_seq_3, wac_inline_2, flow_seq_2_bun, flow_par_2_bun, flow_seq_3_bun", { required: true, } diff --git a/benchmarks/benchmark_suite.ts b/benchmarks/benchmark_suite.ts index f4840dda05..caba48aa72 100644 --- a/benchmarks/benchmark_suite.ts +++ b/benchmarks/benchmark_suite.ts @@ -4,7 +4,7 @@ import { DenoLandProvider } from "https://deno.land/x/cliffy@v0.25.7/command/upg import { main as runBenchmark } from "./benchmark_oneoff.ts"; -import { VERSION } from "./lib.ts"; +import { VERSION, loadJsonConfig } from "./lib.ts"; type Config = { kind: string; @@ -50,21 +50,12 @@ async function main({ workers: number; factor?: number; }) { - async function getConfig(configPath: string): Promise { - if (configPath.startsWith("http")) { - const response = await fetch(configPath); - return await response.json(); - } else { - return JSON.parse(await Deno.readTextFile(configPath)); - } - } - if (!Deno.args.includes("--no-warm-up")) { await warmUp(host, email, password, token, workspace); } try { - const config = await getConfig(configPath); + const config = await loadJsonConfig(configPath); for (const benchmark of config) { try { console.log( diff --git a/benchmarks/graphs_config.json b/benchmarks/graphs_config.json index 174990ecb2..8e37695d66 100644 --- a/benchmarks/graphs_config.json +++ b/benchmarks/graphs_config.json @@ -223,5 +223,75 @@ "label": "noop" } ] + }, + { + "graph_title": "WAC v2 sequential vs flow sequential (2 steps, bun)", + "benchmarks": [ + { + "kind": "wac_seq_2", + "workers": 1, + "label": "WAC v2 sequential" + }, + { + "kind": "flow_seq_2_bun", + "workers": 1, + "label": "Flow sequential" + } + ] + }, + { + "graph_title": "WAC v2 parallel vs flow parallel (2 steps, bun)", + "benchmarks": [ + { + "kind": "wac_par_2", + "workers": 1, + "label": "WAC v2 parallel" + }, + { + "kind": "flow_par_2_bun", + "workers": 1, + "label": "Flow parallel" + } + ] + }, + { + "graph_title": "WAC v2 sequential vs flow sequential (3 steps, bun)", + "benchmarks": [ + { + "kind": "wac_seq_3", + "workers": 1, + "label": "WAC v2 sequential" + }, + { + "kind": "flow_seq_3_bun", + "workers": 1, + "label": "Flow sequential" + } + ] + }, + { + "graph_title": "WAC v2 patterns comparison", + "benchmarks": [ + { + "kind": "wac_seq_2", + "workers": 1, + "label": "sequential 2-task" + }, + { + "kind": "wac_par_2", + "workers": 1, + "label": "parallel 2-task" + }, + { + "kind": "wac_seq_3", + "workers": 1, + "label": "sequential 3-task" + }, + { + "kind": "wac_inline_2", + "workers": 1, + "label": "inline 2-step" + } + ] } ] \ No newline at end of file diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 829bd04e08..ac0e20f064 100644 --- a/benchmarks/lib.ts +++ b/benchmarks/lib.ts @@ -132,6 +132,119 @@ export async function createBenchScript( } } +// WAC v2 benchmark script content patterns +const WAC_SCRIPTS: Record = { + wac_seq_2: [ + 'import { task, workflow } from "windmill-client";', + "const step_a = task(async () => { return 1; });", + "const step_b = task(async () => { return 2; });", + "export const main = workflow(async () => {", + " const a = await step_a();", + " const b = await step_b();", + " return { a, b };", + "});", + ].join("\n"), + + wac_par_2: [ + 'import { task, workflow } from "windmill-client";', + "const step_a = task(async () => { return 1; });", + "const step_b = task(async () => { return 2; });", + "export const main = workflow(async () => {", + " const [a, b] = await Promise.all([step_a(), step_b()]);", + " return { a, b };", + "});", + ].join("\n"), + + wac_seq_3: [ + 'import { task, workflow } from "windmill-client";', + "const step_a = task(async () => { return 1; });", + "const step_b = task(async () => { return 2; });", + "const step_c = task(async () => { return 3; });", + "export const main = workflow(async () => {", + " const a = await step_a();", + " const b = await step_b();", + " const c = await step_c();", + " return { a, b, c };", + "});", + ].join("\n"), + + wac_inline_2: [ + 'import { step, workflow } from "windmill-client";', + "export const main = workflow(async () => {", + ' const a = await step("a", () => 1);', + ' const b = await step("b", () => 2);', + " return { a, b };", + "});", + ].join("\n"), +}; + +export const WAC_KINDS = Object.keys(WAC_SCRIPTS); + +// Number of child jobs created per workflow instance (used to compute throughput) +// For task(): each task creates a child job. For step(): no child job. +// Total completed jobs per workflow = nSteps + 1 (children + parent) +export const STEPS_PER_WORKFLOW: Record = { + wac_seq_2: 2, + wac_par_2: 2, + wac_seq_3: 3, + wac_inline_2: 0, // inline steps don't create child jobs + flow_seq_2_bun: 2, + flow_par_2_bun: 2, + flow_seq_3_bun: 3, +}; + +export async function createWacBenchScript( + wacPattern: string, + workspace: string, +) { + const scriptContent = WAC_SCRIPTS[wacPattern]; + if (!scriptContent) { + throw new Error("Unknown WAC pattern: " + wacPattern); + } + + const path = `f/benchmarks/${wacPattern}`; + const exists = await windmill.ScriptService.existsScriptByPath({ + workspace, + path, + }); + + if (exists) { + await windmill.ScriptService.deleteScriptByPath({ + workspace, + path, + }); + } + + const hash = await windmill.ScriptService.createScript({ + workspace, + requestBody: { + path, + content: scriptContent, + summary: wacPattern + " WAC v2 benchmark", + description: "", + language: "bun" as api.NewScript.language, + schema: { + $schema: "https://json-schema.org/draft/2020-12/schema", + properties: {}, + required: [], + type: "object", + }, + }, + }); + + await waitForDeployment(workspace, hash); + console.log("Created WAC v2 benchmark script at path", path); +} + +export async function loadJsonConfig(configPath: string): Promise { + if (configPath.startsWith("http")) { + const response = await fetch(configPath); + return await response.json(); + } else { + return JSON.parse(await Deno.readTextFile(configPath)); + } +} + export const getFlowPayload = (flowPattern: string): api.FlowPreview => { if (flowPattern == "branchone") { return { @@ -260,6 +373,113 @@ export const getFlowPayload = (flowPattern: string): api.FlowPreview => { ], }, }; + } else if (flowPattern == "flow_seq_2_bun") { + return { + path: "flow_seq_2_bun", + args: {}, + value: { + modules: [ + { + id: "a", + value: { + input_transforms: {}, + language: "bun" as api.RawScript.language, + type: "rawscript", + content: "export function main() { return 1; }", + }, + }, + { + id: "b", + value: { + input_transforms: {}, + language: "bun" as api.RawScript.language, + type: "rawscript", + content: "export function main() { return 2; }", + }, + }, + ], + }, + }; + } else if (flowPattern == "flow_par_2_bun") { + return { + path: "flow_par_2_bun", + args: {}, + value: { + modules: [ + { + id: "a", + value: { + type: "branchall", + parallel: true, + branches: [ + { + modules: [ + { + id: "b", + value: { + input_transforms: {}, + language: "bun" as api.RawScript.language, + type: "rawscript", + content: "export function main() { return 1; }", + }, + }, + ], + }, + { + modules: [ + { + id: "c", + value: { + input_transforms: {}, + language: "bun" as api.RawScript.language, + type: "rawscript", + content: "export function main() { return 2; }", + }, + }, + ], + }, + ], + }, + }, + ], + }, + }; + } else if (flowPattern == "flow_seq_3_bun") { + return { + path: "flow_seq_3_bun", + args: {}, + value: { + modules: [ + { + id: "a", + value: { + input_transforms: {}, + language: "bun" as api.RawScript.language, + type: "rawscript", + content: "export function main() { return 1; }", + }, + }, + { + id: "b", + value: { + input_transforms: {}, + language: "bun" as api.RawScript.language, + type: "rawscript", + content: "export function main() { return 2; }", + }, + }, + { + id: "c", + value: { + input_transforms: {}, + language: "bun" as api.RawScript.language, + type: "rawscript", + content: "export function main() { return 3; }", + }, + }, + ], + }, + }; } else { return { path: "2steps", diff --git a/benchmarks/main.ts b/benchmarks/main.ts index 4d8d144cd4..1f681a9c03 100644 --- a/benchmarks/main.ts +++ b/benchmarks/main.ts @@ -264,24 +264,6 @@ export async function main({ ); const shutdown_start = Date.now(); - // let zombie_jobs = 0; - // let incorrect_results = 0; - // workers.forEach((worker, i) => { - // const l = (evt: MessageEvent) => { - // if (evt.data.type === "zombie_jobs") { - // zombie_jobs += evt.data.zombie_jobs; - // incorrect_results += evt.data.incorrect_results; - // worker.removeEventListener("message", l); - // workers = workers.filter((w) => w != worker); - // jobsSent[i] = evt.data.jobs_sent; - // worker.terminate(); - // } - // }; - // worker.addEventListener("message", l); - // worker.postMessage( - // Number.isSafeInteger(zombieTimeout) ? zombieTimeout : 90000 - // ); - // }); workers.forEach((worker, i) => { const l = (evt: MessageEvent) => { if (evt.data.type === "done") { @@ -327,8 +309,6 @@ export async function main({ console.log("time (s + tts):", time); console.log("throughput /s (jobs/time):", sum / time); - // console.log("zombie jobs: ", zombie_jobs); - // console.log("incorrect results: ", incorrect_results); console.log( "queue length:", ( diff --git a/benchmarks/suite_wac.json b/benchmarks/suite_wac.json new file mode 100644 index 0000000000..e677952761 --- /dev/null +++ b/benchmarks/suite_wac.json @@ -0,0 +1,30 @@ +[ + { + "kind": "wac_seq_2", + "jobs": 250 + }, + { + "kind": "wac_par_2", + "jobs": 250 + }, + { + "kind": "wac_seq_3", + "jobs": 200 + }, + { + "kind": "wac_inline_2", + "jobs": 500 + }, + { + "kind": "flow_seq_2_bun", + "jobs": 250 + }, + { + "kind": "flow_par_2_bun", + "jobs": 250 + }, + { + "kind": "flow_seq_3_bun", + "jobs": 200 + } +] diff --git a/benchmarks/worker.ts b/benchmarks/worker.ts index cd56d0fb45..ae06fa9779 100644 --- a/benchmarks/worker.ts +++ b/benchmarks/worker.ts @@ -139,96 +139,6 @@ while (cont) { clearInterval(updateStatusInterval); -// const end_time = Date.now() + complete_timeout; - -// let incorrect_results = 0; -// const enc = (s: string) => new TextEncoder().encode(s); - -// let last_queue_length = await getQueueCount(); -// console.log(`waiting for ${last_queue_length} jobs to complete...`); - -// while ( -// outstanding.length > 0 && -// last_queue_length > 0 && -// Date.now() < end_time -// ) { -// try { -// if (!config.hideProgress) { -// await Deno.stdout.write( -// enc( -// "\rwaiting for jobs to complete: outstanding " + -// outstanding.length + -// " - queue" + -// last_queue_length + -// "\n" -// ) -// ); -// } -// last_queue_length = await getQueueCount(); - -// const uuid = outstanding.shift()!; - -// let r: Job; -// try { -// r = await windmill.JobService.getJob({ -// workspace: config.workspace_id, -// id: uuid, -// }); -// } catch (e) { -// console.log("job not found: " + uuid + " " + e.message); -// continue; -// } -// if (r.type == "QueuedJob") { -// outstanding.push(uuid); - -// if (!config.hideProgress) { -// await Deno.stdout.write( -// enc(`uuid: ${uuid}, queue length: ${last_queue_length}\r`) -// ); -// } -// } else { -// r = r as api.CompletedJob; -// try { -// if ( -// ![ -// "httpversion", -// "identity", -// "httpslow", -// "noop", -// "dedicated", -// ].includes(config.scriptPattern) && -// r.result != uuid -// ) { -// console.log( -// "job did not return correct UUID: " + -// r.result + -// " != " + -// uuid + -// "job: \n" + -// JSON.stringify(r, null, 2) -// ); -// incorrect_results++; -// } else { -// // console.log(r.result); -// } -// } catch (e) { -// console.log("error during wait: ", e); -// outstanding.push(uuid); -// } -// } -// } catch (e) { -// console.log("error while waiting for outstanding jobs, sleeing: ", e); -// await sleep(0.5); -// } -// } - -// self.postMessage({ -// type: "zombie_jobs", -// zombie_jobs: outstanding.length, -// incorrect_results, -// jobs_sent: total_spawned, -// }); - self.postMessage({ type: "done", jobs_sent: total_spawned, From 0389d9601cd540bfcad2270ed608193c3fa5a297 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 27 Mar 2026 09:55:04 +0000 Subject: [PATCH 009/111] chore: upgrade axum 0.7 to 0.8 (#8539) * chore: upgrade axum 0.7 to 0.8 and related dependencies Co-Authored-By: Claude Opus 4.6 (1M context) * test: add route reachability tests for ~80 previously untested endpoints Co-Authored-By: Claude Opus 4.6 (1M context) * fix: switch feature-gated trigger handlers from axum::async_trait to async_trait crate Co-Authored-By: Claude Opus 4.6 (1M context) * fix: update new trash routes to axum 0.8 path syntax Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref to latest EE commit Co-Authored-By: Claude Opus 4.6 (1M context) * test: upgrade route tests to assert 2xx responses with proper data setup Co-Authored-By: Claude Opus 4.6 (1M context) * test: restore npm_proxy and ai_routes tests using local echo servers Co-Authored-By: Claude Opus 4.6 (1M context) * fix: gate workspace fork test behind enterprise feature flag Co-Authored-By: Claude Opus 4.6 (1M context) * test: add ~40 more endpoint tests (jobs authed, health, favorites, ACLs, reachability) Co-Authored-By: Claude Opus 4.6 (1M context) * fix: address review findings from axum 0.8 upgrade - Use cookie value_trimmed() instead of value() for cookie 0.18 compat - Update comments still referencing old :workspace_id syntax Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref to 61ae055ea31481f1899953e9d5f65566b8c707b1 This commit updates the EE repository reference after PR #486 was merged in windmill-ee-private. Previous ee-repo-ref: 0059d175a6fdddf52998b183bf91059b224704ac New ee-repo-ref: 61ae055ea31481f1899953e9d5f65566b8c707b1 Automated by sync-ee-ref workflow. * test: add test for new get_imports endpoint Co-Authored-By: Claude Opus 4.6 (1M context) * fix: remove unused import in raw_apps test Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: windmill-internal-app[bot] --- backend/Cargo.lock | 265 +++++++++------ backend/Cargo.toml | 22 +- backend/ee-repo-ref.txt | 2 +- backend/windmill-api-auth/src/auth.rs | 9 +- backend/windmill-api-auth/src/lib.rs | 26 +- backend/windmill-api-configs/src/lib.rs | 6 +- .../src/lib.rs | 4 +- backend/windmill-api-flows/src/flows.rs | 32 +- .../windmill-api-groups/src/folder_history.rs | 2 +- backend/windmill-api-groups/src/folders.rs | 16 +- .../windmill-api-groups/src/granular_acls.rs | 6 +- backend/windmill-api-groups/src/groups.rs | 22 +- backend/windmill-api-inputs/src/lib.rs | 4 +- .../windmill-api-integration-tests/Cargo.toml | 1 + .../tests/ai_routes.rs | 106 ++++++ .../tests/audit.rs | 35 ++ .../tests/capture_unauthed.rs | 83 +++++ .../tests/concurrency_groups.rs | 48 +++ .../tests/favorites.rs | 72 ++++ .../tests/folder_history.rs | 51 +++ .../tests/granular_acls.rs | 54 +++ .../tests/group_history.rs | 32 ++ .../tests/health.rs | 42 +++ .../tests/inputs.rs | 76 +++++ .../tests/job_metrics.rs | 59 ++++ .../tests/jobs_authed.rs | 308 ++++++++++++++++++ .../tests/jobs_unauthed.rs | 250 ++++++++++++++ .../tests/npm_proxy.rs | 85 +++++ .../tests/raw_apps.rs | 33 ++ .../tests/service_logs.rs | 36 ++ .../tests/settings.rs | 116 +++++++ .../tests/trash.rs | 41 +++ .../tests/workspace_deps.rs | 39 +++ .../tests/workspaces.rs | 117 ++++--- .../src/concurrency_groups.rs | 4 +- backend/windmill-api-jobs/src/job_metrics.rs | 6 +- backend/windmill-api-jobs/src/types.rs | 8 +- backend/windmill-api-npm-proxy/src/lib.rs | 8 +- backend/windmill-api-schedule/src/lib.rs | 10 +- backend/windmill-api-scripts/src/scripts.rs | 46 +-- backend/windmill-api-settings/src/lib.rs | 6 +- backend/windmill-api-users/src/users.rs | 26 +- .../windmill-api-workspaces/src/workspaces.rs | 14 +- backend/windmill-api/src/ai.rs | 4 +- backend/windmill-api/src/apps.rs | 54 +-- backend/windmill-api/src/args.rs | 3 +- backend/windmill-api/src/audit.rs | 2 +- backend/windmill-api/src/capture.rs | 18 +- backend/windmill-api/src/drafts.rs | 2 +- backend/windmill-api/src/flows.rs | 2 +- backend/windmill-api/src/google.rs | 54 +-- backend/windmill-api/src/group_history.rs | 2 +- backend/windmill-api/src/jobs.rs | 108 +++--- backend/windmill-api/src/lib.rs | 48 +-- backend/windmill-api/src/raw_apps.rs | 2 +- backend/windmill-api/src/resources.rs | 2 +- backend/windmill-api/src/scim_oss.rs | 6 +- backend/windmill-api/src/scripts.rs | 2 +- backend/windmill-api/src/service_logs.rs | 2 +- backend/windmill-api/src/trash.rs | 6 +- backend/windmill-api/src/triggers/handler.rs | 4 +- .../windmill-api/src/triggers/http/handler.rs | 2 +- .../src/triggers/http/http_trigger_args.rs | 3 +- backend/windmill-api/src/users.rs | 4 +- .../src/workspace_dependencies.rs | 6 +- backend/windmill-api/src/workspaces.rs | 2 +- .../windmill-native-triggers/src/handler.rs | 6 +- .../src/workspace_integrations.rs | 14 +- backend/windmill-oauth/src/lib.rs | 2 +- backend/windmill-object-store/src/lib.rs | 4 +- backend/windmill-store/src/resources.rs | 28 +- backend/windmill-store/src/variables.rs | 10 +- backend/windmill-test-utils/Cargo.toml | 1 + backend/windmill-test-utils/src/lib.rs | 2 +- .../windmill-trigger-email/src/handler_oss.rs | 2 +- .../windmill-trigger-gcp/src/handler_oss.rs | 2 +- backend/windmill-trigger-http/src/handler.rs | 3 +- .../windmill-trigger-kafka/src/handler_oss.rs | 2 +- backend/windmill-trigger-mqtt/src/handler.rs | 2 +- .../windmill-trigger-nats/src/handler_oss.rs | 2 +- .../windmill-trigger-postgres/src/handler.rs | 30 +- .../windmill-trigger-sqs/src/handler_oss.rs | 2 +- .../windmill-trigger-websocket/src/handler.rs | 2 +- backend/windmill-trigger/src/handler.rs | 10 +- 84 files changed, 2176 insertions(+), 514 deletions(-) create mode 100644 backend/windmill-api-integration-tests/tests/ai_routes.rs create mode 100644 backend/windmill-api-integration-tests/tests/audit.rs create mode 100644 backend/windmill-api-integration-tests/tests/capture_unauthed.rs create mode 100644 backend/windmill-api-integration-tests/tests/concurrency_groups.rs create mode 100644 backend/windmill-api-integration-tests/tests/favorites.rs create mode 100644 backend/windmill-api-integration-tests/tests/folder_history.rs create mode 100644 backend/windmill-api-integration-tests/tests/granular_acls.rs create mode 100644 backend/windmill-api-integration-tests/tests/group_history.rs create mode 100644 backend/windmill-api-integration-tests/tests/health.rs create mode 100644 backend/windmill-api-integration-tests/tests/inputs.rs create mode 100644 backend/windmill-api-integration-tests/tests/job_metrics.rs create mode 100644 backend/windmill-api-integration-tests/tests/jobs_authed.rs create mode 100644 backend/windmill-api-integration-tests/tests/jobs_unauthed.rs create mode 100644 backend/windmill-api-integration-tests/tests/npm_proxy.rs create mode 100644 backend/windmill-api-integration-tests/tests/raw_apps.rs create mode 100644 backend/windmill-api-integration-tests/tests/service_logs.rs create mode 100644 backend/windmill-api-integration-tests/tests/settings.rs create mode 100644 backend/windmill-api-integration-tests/tests/trash.rs create mode 100644 backend/windmill-api-integration-tests/tests/workspace_deps.rs diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 031344cc61..df09b225b5 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1363,32 +1363,23 @@ checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" dependencies = [ "async-trait", "axum-core 0.4.5", - "axum-macros", "bytes", "futures-util", "http 1.4.0", "http-body 1.0.1", "http-body-util", - "hyper 1.8.1", - "hyper-util", "itoa", "matchit 0.7.3", "memchr", "mime", - "multer", "percent-encoding", "pin-project-lite", "rustversion", "serde", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", "sync_wrapper", - "tokio", "tower 0.5.3", "tower-layer", "tower-service", - "tracing", ] [[package]] @@ -1398,6 +1389,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5" dependencies = [ "axum-core 0.5.6", + "axum-macros", "bytes", "form_urlencoded", "futures-util", @@ -1410,6 +1402,7 @@ dependencies = [ "matchit 0.8.4", "memchr", "mime", + "multer", "percent-encoding", "pin-project-lite", "rustversion", @@ -1443,7 +1436,6 @@ dependencies = [ "sync_wrapper", "tower-layer", "tower-service", - "tracing", ] [[package]] @@ -1467,9 +1459,9 @@ dependencies = [ [[package]] name = "axum-macros" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" +checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" dependencies = [ "proc-macro2", "quote", @@ -2539,16 +2531,6 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147be55d677052dabc6b22252d5dd0fd4c29c8c27aa4f2fbef0f94aa003b406f" -[[package]] -name = "cookie" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7efb37c3e1ccb1ff97164ad95ac1606e8ccd35b3fa0a7d99a304c7f4a428cc24" -dependencies = [ - "time", - "version_check", -] - [[package]] name = "cookie" version = "0.18.1" @@ -4552,9 +4534,9 @@ dependencies = [ "log", "once_cell", "opentelemetry 0.27.1", - "opentelemetry-http", - "opentelemetry-otlp", - "opentelemetry-semantic-conventions", + "opentelemetry-http 0.27.0", + "opentelemetry-otlp 0.27.0", + "opentelemetry-semantic-conventions 0.27.0", "opentelemetry_sdk 0.27.1", "pin-project", "serde", @@ -6333,7 +6315,7 @@ dependencies = [ "thiserror 1.0.69", "tokio", "tokio-retry2", - "tonic", + "tonic 0.12.3", "tower 0.4.13", "tracing", ] @@ -6346,7 +6328,7 @@ checksum = "886aa8ec755382a1fdf4651f6e6ec01f2f3bf49f2cb0f068b9a74cafd574a715" dependencies = [ "prost", "prost-types", - "tonic", + "tonic 0.12.3", ] [[package]] @@ -9551,9 +9533,9 @@ dependencies = [ [[package]] name = "opentelemetry" -version = "0.29.1" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e87237e2775f74896f9ad219d26a2081751187eb7c9f5c58dde20a23b95d16c" +checksum = "aaf416e4cb72756655126f7dd7bb0af49c674f4c1b9903e80c009e0c37e552e6" dependencies = [ "futures-core", "futures-sink", @@ -9565,11 +9547,11 @@ dependencies = [ [[package]] name = "opentelemetry-appender-tracing" -version = "0.27.0" +version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab5feffc321035ad94088a7e5333abb4d84a8726e54a802e736ce9dd7237e85b" +checksum = "e68f63eca5fad47e570e00e893094fc17be959c80c79a7d6ec1abdd5ae6ffc16" dependencies = [ - "opentelemetry 0.27.1", + "opentelemetry 0.30.0", "tracing", "tracing-core", "tracing-subscriber", @@ -9587,6 +9569,19 @@ dependencies = [ "opentelemetry 0.27.1", ] +[[package]] +name = "opentelemetry-http" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50f6639e842a97dbea8886e3439710ae463120091e2e064518ba8e716e6ac36d" +dependencies = [ + "async-trait", + "bytes", + "http 1.4.0", + "opentelemetry 0.30.0", + "reqwest 0.12.28", +] + [[package]] name = "opentelemetry-otlp" version = "0.27.0" @@ -9597,14 +9592,33 @@ dependencies = [ "futures-core", "http 1.4.0", "opentelemetry 0.27.1", - "opentelemetry-http", + "opentelemetry-http 0.27.0", "opentelemetry-proto 0.27.0", "opentelemetry_sdk 0.27.1", "prost", "serde_json", "thiserror 1.0.69", "tokio", - "tonic", + "tonic 0.12.3", + "tracing", +] + +[[package]] +name = "opentelemetry-otlp" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbee664a43e07615731afc539ca60c6d9f1a9425e25ca09c57bc36c87c55852b" +dependencies = [ + "http 1.4.0", + "opentelemetry 0.30.0", + "opentelemetry-http 0.30.0", + "opentelemetry-proto 0.30.0", + "opentelemetry_sdk 0.30.0", + "prost", + "reqwest 0.12.28", + "thiserror 2.0.18", + "tokio", + "tonic 0.13.1", "tracing", ] @@ -9619,23 +9633,22 @@ dependencies = [ "opentelemetry_sdk 0.27.1", "prost", "serde", - "tonic", + "tonic 0.12.3", ] [[package]] name = "opentelemetry-proto" -version = "0.29.0" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c40da242381435e18570d5b9d50aca2a4f4f4d8e146231adb4e7768023309b3" +checksum = "2e046fd7660710fe5a05e8748e70d9058dc15c94ba914e7c4faa7c728f0e8ddc" dependencies = [ "base64 0.22.1", "hex", - "opentelemetry 0.29.1", - "opentelemetry_sdk 0.29.0", + "opentelemetry 0.30.0", + "opentelemetry_sdk 0.30.0", "prost", "serde", - "tonic", - "tracing", + "tonic 0.13.1", ] [[package]] @@ -9644,6 +9657,12 @@ version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc1b6902ff63b32ef6c489e8048c5e253e2e4a803ea3ea7e783914536eb15c52" +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d059a296a47436748557a353c5e6c5705b9470ef6c95cfc52c21a8814ddac2" + [[package]] name = "opentelemetry_sdk" version = "0.27.1" @@ -9660,26 +9679,25 @@ dependencies = [ "rand 0.8.5", "serde_json", "thiserror 1.0.69", - "tokio", - "tokio-stream", "tracing", ] [[package]] name = "opentelemetry_sdk" -version = "0.29.0" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afdefb21d1d47394abc1ba6c57363ab141be19e27cc70d0e422b7f303e4d290b" +checksum = "11f644aa9e5e31d11896e024305d7e3c98a88884d9f8919dbf37a9991bc47a4b" dependencies = [ "futures-channel", "futures-executor", "futures-util", - "glob", - "opentelemetry 0.29.1", + "opentelemetry 0.30.0", "percent-encoding", "rand 0.9.0", "serde_json", "thiserror 2.0.18", + "tokio", + "tokio-stream", ] [[package]] @@ -11124,6 +11142,7 @@ dependencies = [ "base64 0.22.1", "bytes", "encoding_rs", + "futures-channel", "futures-core", "futures-util", "h2 0.4.13", @@ -14516,7 +14535,6 @@ dependencies = [ "percent-encoding", "pin-project", "prost", - "rustls-native-certs 0.8.3", "rustls-pemfile 2.2.0", "socket2 0.5.10", "tokio", @@ -14529,6 +14547,37 @@ dependencies = [ "webpki-roots 0.26.11", ] +[[package]] +name = "tonic" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e581ba15a835f4d9ea06c55ab1bd4dce26fc53752c69a04aac00703bfb49ba9" +dependencies = [ + "async-trait", + "axum 0.8.4", + "base64 0.22.1", + "bytes", + "h2 0.4.13", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.8.1", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "prost", + "rustls-native-certs 0.8.3", + "socket2 0.5.10", + "tokio", + "tokio-rustls 0.26.4", + "tokio-stream", + "tower 0.5.3", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower" version = "0.4.13" @@ -14557,7 +14606,9 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", + "indexmap 2.11.1", "pin-project-lite", + "slab", "sync_wrapper", "tokio", "tokio-util", @@ -14568,13 +14619,12 @@ dependencies = [ [[package]] name = "tower-cookies" -version = "0.10.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fd0118512cf0b3768f7fcccf0bef1ae41d68f2b45edc1e77432b36c97c56c6d" +checksum = "151b5a3e3c45df17466454bb74e9ecedecc955269bdedbf4d150dfa393b55a36" dependencies = [ - "async-trait", - "axum-core 0.4.5", - "cookie 0.18.1", + "axum-core 0.5.6", + "cookie", "futures-util", "http 1.4.0", "parking_lot", @@ -14689,14 +14739,14 @@ dependencies = [ [[package]] name = "tracing-opentelemetry" -version = "0.28.0" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97a971f6058498b5c0f1affa23e7ea202057a7301dbff68e968b2d578bcbd053" +checksum = "ddcf5959f39507d0d04d6413119c04f33b623f4f951ebcbdddddfad2d0623a9c" dependencies = [ "js-sys", "once_cell", - "opentelemetry 0.27.1", - "opentelemetry_sdk 0.27.1", + "opentelemetry 0.30.0", + "opentelemetry_sdk 0.30.0", "smallvec", "tracing", "tracing-core", @@ -15768,7 +15818,7 @@ dependencies = [ "aws-config", "aws-credential-types", "aws-sdk-sqs", - "axum 0.7.9", + "axum 0.8.4", "base64 0.22.1", "chrono", "constant_time_eq 0.3.1", @@ -15839,7 +15889,7 @@ dependencies = [ name = "windmill-alerting" version = "1.666.0" dependencies = [ - "axum 0.7.9", + "axum 0.8.4", "chrono", "serde", "serde_json", @@ -15866,14 +15916,14 @@ dependencies = [ "aws-sdk-config", "aws-sigv4", "aws-smithy-types", - "axum 0.7.9", + "axum 0.8.4", "base32", "base64 0.22.1", "bytes", "chrono", "chrono-tz", "const_format", - "cookie 0.17.0", + "cookie", "cron", "dashmap 6.1.0", "datafusion", @@ -15993,7 +16043,7 @@ dependencies = [ name = "windmill-api-agent-workers" version = "1.666.0" dependencies = [ - "axum 0.7.9", + "axum 0.8.4", "chrono", "http 1.4.0", "hyper 1.8.1", @@ -16016,7 +16066,7 @@ dependencies = [ name = "windmill-api-assets" version = "1.666.0" dependencies = [ - "axum 0.7.9", + "axum 0.8.4", "chrono", "serde", "serde_json", @@ -16030,7 +16080,7 @@ name = "windmill-api-auth" version = "1.666.0" dependencies = [ "anyhow", - "axum 0.7.9", + "axum 0.8.4", "chrono", "http 1.4.0", "itertools 0.14.0", @@ -16065,7 +16115,7 @@ dependencies = [ name = "windmill-api-configs" version = "1.666.0" dependencies = [ - "axum 0.7.9", + "axum 0.8.4", "chrono", "itertools 0.14.0", "serde", @@ -16082,7 +16132,7 @@ dependencies = [ name = "windmill-api-debug" version = "1.666.0" dependencies = [ - "axum 0.7.9", + "axum 0.8.4", "base64 0.22.1", "chrono", "ed25519-dalek", @@ -16106,7 +16156,7 @@ name = "windmill-api-embeddings" version = "1.666.0" dependencies = [ "anyhow", - "axum 0.7.9", + "axum 0.8.4", "candle-core", "candle-nn", "candle-transformers", @@ -16128,7 +16178,7 @@ dependencies = [ name = "windmill-api-flow-conversations" version = "1.666.0" dependencies = [ - "axum 0.7.9", + "axum 0.8.4", "chrono", "serde", "sql-builder", @@ -16144,7 +16194,7 @@ dependencies = [ name = "windmill-api-flows" version = "1.666.0" dependencies = [ - "axum 0.7.9", + "axum 0.8.4", "chrono", "hyper 1.8.1", "serde", @@ -16164,7 +16214,7 @@ dependencies = [ name = "windmill-api-groups" version = "1.666.0" dependencies = [ - "axum 0.7.9", + "axum 0.8.4", "chrono", "lazy_static", "regex", @@ -16184,7 +16234,7 @@ dependencies = [ name = "windmill-api-inputs" version = "1.666.0" dependencies = [ - "axum 0.7.9", + "axum 0.8.4", "chrono", "serde", "serde_json", @@ -16228,7 +16278,7 @@ name = "windmill-api-jobs" version = "1.666.0" dependencies = [ "anyhow", - "axum 0.7.9", + "axum 0.8.4", "base64 0.22.1", "chrono", "http 1.4.0", @@ -16252,7 +16302,7 @@ dependencies = [ name = "windmill-api-npm-proxy" version = "1.666.0" dependencies = [ - "axum 0.7.9", + "axum 0.8.4", "flate2", "reqwest 0.13.1", "serde", @@ -16271,7 +16321,7 @@ name = "windmill-api-openapi" version = "1.666.0" dependencies = [ "anyhow", - "axum 0.7.9", + "axum 0.8.4", "http 1.4.0", "indexmap 2.12.0", "itertools 0.14.0", @@ -16292,7 +16342,7 @@ dependencies = [ name = "windmill-api-schedule" version = "1.666.0" dependencies = [ - "axum 0.7.9", + "axum 0.8.4", "chrono", "chrono-tz", "serde", @@ -16312,7 +16362,7 @@ dependencies = [ name = "windmill-api-scripts" version = "1.666.0" dependencies = [ - "axum 0.7.9", + "axum 0.8.4", "chrono", "futures", "http 1.4.0", @@ -16343,7 +16393,7 @@ name = "windmill-api-settings" version = "1.666.0" dependencies = [ "anyhow", - "axum 0.7.9", + "axum 0.8.4", "base64 0.22.1", "bytes", "chrono", @@ -16382,7 +16432,7 @@ name = "windmill-api-users" version = "1.666.0" dependencies = [ "argon2", - "axum 0.7.9", + "axum 0.8.4", "chrono", "http 1.4.0", "hyper 1.8.1", @@ -16405,7 +16455,7 @@ dependencies = [ name = "windmill-api-workers" version = "1.666.0" dependencies = [ - "axum 0.7.9", + "axum 0.8.4", "chrono", "serde", "serde_json", @@ -16419,7 +16469,7 @@ dependencies = [ name = "windmill-api-workspaces" version = "1.666.0" dependencies = [ - "axum 0.7.9", + "axum 0.8.4", "chrono", "hex", "http 1.4.0", @@ -16466,7 +16516,7 @@ name = "windmill-autoscaling" version = "1.666.0" dependencies = [ "anyhow", - "axum 0.7.9", + "axum 0.8.4", "k8s-openapi", "kube", "serde", @@ -16497,7 +16547,7 @@ dependencies = [ "aws-sdk-sts", "aws-smithy-types", "aws-smithy-types-convert", - "axum 0.7.9", + "axum 0.8.4", "backon", "base64 0.22.1", "bitflags 2.9.4", @@ -16528,11 +16578,11 @@ dependencies = [ "native-tls", "once_cell", "openidconnect", - "opentelemetry 0.27.1", + "opentelemetry 0.30.0", "opentelemetry-appender-tracing", - "opentelemetry-otlp", - "opentelemetry-semantic-conventions", - "opentelemetry_sdk 0.27.1", + "opentelemetry-otlp 0.30.0", + "opentelemetry-semantic-conventions 0.30.0", + "opentelemetry_sdk 0.30.0", "pep440_rs", "phf 0.11.3", "pin-project-lite", @@ -16565,7 +16615,7 @@ dependencies = [ "tokio-postgres 0.7.13", "tokio-stream", "tokio-util", - "tonic", + "tonic 0.13.1", "tracing", "tracing-appender", "tracing-opentelemetry", @@ -16699,7 +16749,7 @@ version = "1.666.0" dependencies = [ "anyhow", "async-trait", - "axum 0.7.9", + "axum 0.8.4", "backon", "base64 0.22.1", "chrono", @@ -16730,7 +16780,7 @@ version = "1.666.0" dependencies = [ "anyhow", "async-oauth2", - "axum 0.7.9", + "axum 0.8.4", "base64 0.22.1", "chrono", "hex", @@ -16759,7 +16809,7 @@ dependencies = [ "aws-credential-types", "aws-sdk-sts", "aws-smithy-types-convert", - "axum 0.7.9", + "axum 0.8.4", "bytes", "chrono", "datafusion", @@ -17059,7 +17109,7 @@ version = "1.666.0" dependencies = [ "anyhow", "async-recursion", - "axum 0.7.9", + "axum 0.8.4", "backon", "chrono", "chrono-tz", @@ -17145,7 +17195,7 @@ version = "1.666.0" dependencies = [ "anyhow", "async-recursion", - "axum 0.7.9", + "axum 0.8.4", "chrono", "futures", "http 1.4.0", @@ -17173,7 +17223,8 @@ name = "windmill-test-utils" version = "1.666.0" dependencies = [ "anyhow", - "axum 0.7.9", + "async-trait", + "axum 0.8.4", "chrono", "futures", "serde", @@ -17197,7 +17248,7 @@ version = "1.666.0" dependencies = [ "anyhow", "async-trait", - "axum 0.7.9", + "axum 0.8.4", "chrono", "http 1.4.0", "hyper 1.8.1", @@ -17230,7 +17281,7 @@ version = "1.666.0" dependencies = [ "anyhow", "async-trait", - "axum 0.7.9", + "axum 0.8.4", "base64 0.22.1", "lazy_static", "regex", @@ -17250,7 +17301,7 @@ version = "1.666.0" dependencies = [ "anyhow", "async-trait", - "axum 0.7.9", + "axum 0.8.4", "base64 0.22.1", "bytes", "chrono", @@ -17268,7 +17319,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-util", - "tonic", + "tonic 0.13.1", "tower-http", "tracing", "windmill-api-auth", @@ -17284,7 +17335,7 @@ version = "1.666.0" dependencies = [ "anyhow", "async-trait", - "axum 0.7.9", + "axum 0.8.4", "base64 0.22.1", "constant_time_eq 0.3.1", "futures", @@ -17319,7 +17370,7 @@ version = "1.666.0" dependencies = [ "anyhow", "async-trait", - "axum 0.7.9", + "axum 0.8.4", "base64 0.22.1", "itertools 0.14.0", "rdkafka", @@ -17342,7 +17393,7 @@ version = "1.666.0" dependencies = [ "anyhow", "async-trait", - "axum 0.7.9", + "axum 0.8.4", "base64 0.22.1", "bytes", "itertools 0.14.0", @@ -17367,7 +17418,7 @@ dependencies = [ "anyhow", "async-nats", "async-trait", - "axum 0.7.9", + "axum 0.8.4", "base64 0.22.1", "itertools 0.14.0", "nkeys", @@ -17390,7 +17441,7 @@ version = "1.666.0" dependencies = [ "anyhow", "async-trait", - "axum 0.7.9", + "axum 0.8.4", "byteorder", "bytes", "chrono", @@ -17430,7 +17481,7 @@ dependencies = [ "aws-sdk-sqs", "aws-sdk-sts", "aws-smithy-types", - "axum 0.7.9", + "axum 0.8.4", "backon", "chrono", "itertools 0.14.0", @@ -17453,7 +17504,7 @@ version = "1.666.0" dependencies = [ "anyhow", "async-trait", - "axum 0.7.9", + "axum 0.8.4", "futures", "http 1.4.0", "itertools 0.14.0", @@ -17502,7 +17553,7 @@ dependencies = [ "aws-credential-types", "aws-sdk-bedrockruntime", "aws-smithy-types", - "axum 0.7.9", + "axum 0.8.4", "backon", "base64 0.22.1", "bit-vec 0.6.3", @@ -17535,8 +17586,8 @@ dependencies = [ "native-tls", "nix 0.27.1", "once_cell", - "opentelemetry 0.27.1", - "opentelemetry-proto 0.29.0", + "opentelemetry 0.30.0", + "opentelemetry-proto 0.30.0", "oracle", "pem 3.0.6", "pep440_rs", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index ee1c26c9f7..b8e4d3d593 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -362,7 +362,7 @@ reqwest-middleware = { version = "^0", features = ["json"] } bitflags = "2.9.4" memchr = "2.7.4" -axum = { version = "^0.7", features = ["multipart", "macros"] } +axum = { version = "^0.8", features = ["multipart", "macros"] } headers = "^0" hyper = { version = "^1", features = ["full"] } hyper-tls = "^0.6" @@ -371,7 +371,7 @@ tokio = { version = "=1.46.1", features = ["full", "tracing", "time"] } tokio-stream = { version = "0.1.17" } tower = "^0" tower-http = { version = "^0.6", features = ["trace", "cors", "catch-panic"] } -tower-cookies = "^0.10" +tower-cookies = "^0.11" #stuck because of swc for now serde = "=1.0.220" serde_json = { version = "^1", features = ["preserve_order", "raw_value"] } @@ -386,7 +386,7 @@ tracing = "^0" tracing-subscriber = { version = "^0", features = ["env-filter", "json"] } tracing-appender = "^0" prometheus = { version = "^0", default-features = false } -cookie = { version = "0.17.0" } +cookie = { version = "0.18.0" } phf = { version = "0.11", features = ["macros"] } rust-embed = { version = "^6", features = ["interpolate-folder-path"] } mime_guess = "^2" @@ -566,18 +566,18 @@ flate2 = "^1" http = "^1" async-stream = "^0" -opentelemetry = "0.27.0" -tracing-opentelemetry = "0.28.0" -opentelemetry_sdk = { version = "0.27.1", features = ["rt-tokio"] } -opentelemetry-otlp = { version = "0.27.0", features = ["grpc-tonic", "tls"] } -opentelemetry-appender-tracing = "0.27.0" -opentelemetry-semantic-conventions = { version = "0.27.0", features = ["semconv_experimental"] } -opentelemetry-proto = { version = "0.29.0", features = ["with-serde", "gen-tonic"] } +opentelemetry = "0.30.0" +tracing-opentelemetry = "0.31.0" +opentelemetry_sdk = { version = "0.30.0", features = ["rt-tokio"] } +opentelemetry-otlp = { version = "0.30.0", features = ["grpc-tonic", "tls"] } +opentelemetry-appender-tracing = "0.30.0" +opentelemetry-semantic-conventions = { version = "0.30.0", features = ["semconv_experimental"] } +opentelemetry-proto = { version = "0.30.0", features = ["with-serde", "gen-tonic"] } prost = "0.13" bollard = "0.18.1" -tonic = { version = "=0.12.3", features = ["tls-native-roots"] } +tonic = { version = "^0.13", features = ["tls-native-roots"] } byteorder = "1.5.0" tikv-jemallocator = { version = "0.5" } diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 840129b249..3915abda7e 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -01688af32ccd48a39f993043c1ce8f337b5c9eff \ No newline at end of file +61ae055ea31481f1899953e9d5f65566b8c707b1 diff --git a/backend/windmill-api-auth/src/auth.rs b/backend/windmill-api-auth/src/auth.rs index 8e3a4c7822..f10ae321b9 100644 --- a/backend/windmill-api-auth/src/auth.rs +++ b/backend/windmill-api-auth/src/auth.rs @@ -1,7 +1,6 @@ #[cfg(feature = "enterprise")] use crate::ee_oss::ExternalJwks; use axum::{ - async_trait, extract::{FromRequestParts, OriginalUri, Query}, Extension, Json, }; @@ -451,7 +450,11 @@ pub(crate) async fn extract_token(parts: &mut Parts, state: &S) None => Extension::::from_request_parts(parts, state) .await .ok() - .and_then(|cookies| cookies.get(COOKIE_NAME).map(|c| c.value().to_owned())), + .and_then(|cookies| { + cookies + .get(COOKIE_NAME) + .map(|c| c.value_trimmed().to_owned()) + }), }; #[derive(Deserialize)] @@ -504,7 +507,6 @@ impl BruteForceCounter { } } -#[async_trait] impl FromRequestParts for Tokened where S: Send + Sync, @@ -535,7 +537,6 @@ where } } -#[async_trait] impl FromRequestParts for OptTokened where S: Send + Sync, diff --git a/backend/windmill-api-auth/src/lib.rs b/backend/windmill-api-auth/src/lib.rs index aceef77e01..4e230c019b 100644 --- a/backend/windmill-api-auth/src/lib.rs +++ b/backend/windmill-api-auth/src/lib.rs @@ -12,8 +12,7 @@ pub mod ee; pub mod ee_oss; pub mod scopes; -use axum::async_trait; -use axum::extract::FromRequestParts; +use axum::extract::{FromRequestParts, OptionalFromRequestParts}; use http::request::Parts; use windmill_audit::audit_oss::AuditAuthorable; @@ -345,7 +344,6 @@ pub async fn maybe_refresh_folders( // ------------ FromRequestParts impls (direct call to auth module) ------------ -#[async_trait] impl FromRequestParts for ApiAuthed where S: Send + Sync, @@ -361,7 +359,24 @@ where } } -#[async_trait] +impl OptionalFromRequestParts for ApiAuthed +where + S: Send + Sync, +{ + type Rejection = std::convert::Infallible; + + async fn from_request_parts( + parts: &mut Parts, + state: &S, + ) -> std::result::Result, Self::Rejection> { + Ok( + >::from_request_parts(parts, state) + .await + .ok(), + ) + } +} + impl FromRequestParts for OptJobAuthed where S: Send + Sync, @@ -397,7 +412,6 @@ fn empty_parts() -> Parts { #[derive(Clone, Debug)] pub struct OptAuthed(pub Option); -#[async_trait] impl FromRequestParts for OptAuthed where S: Send + Sync, @@ -408,7 +422,7 @@ where parts: &mut Parts, state: &S, ) -> std::result::Result { - ApiAuthed::from_request_parts(parts, state) + >::from_request_parts(parts, state) .await .map(|authed| Self(Some(authed))) .or_else(|_| Ok(Self(None))) diff --git a/backend/windmill-api-configs/src/lib.rs b/backend/windmill-api-configs/src/lib.rs index a05f2dbaa8..8485de5e95 100644 --- a/backend/windmill-api-configs/src/lib.rs +++ b/backend/windmill-api-configs/src/lib.rs @@ -28,11 +28,11 @@ use windmill_api_auth::{require_devops_role, ApiAuthed}; pub fn global_service() -> Router { Router::new() .route("/list_worker_groups", get(list_worker_groups)) - .route("/update/:name", post(update_config).delete(delete_config)) - .route("/get/:name", get(get_config)) + .route("/update/{name}", post(update_config).delete(delete_config)) + .route("/get/{name}", get(get_config)) .route("/list", get(list_configs)) .route( - "/list_autoscaling_events/:worker_group", + "/list_autoscaling_events/{worker_group}", get(list_autoscaling_events), ) .route( diff --git a/backend/windmill-api-flow-conversations/src/lib.rs b/backend/windmill-api-flow-conversations/src/lib.rs index bc37c9863d..70c96d1405 100644 --- a/backend/windmill-api-flow-conversations/src/lib.rs +++ b/backend/windmill-api-flow-conversations/src/lib.rs @@ -21,8 +21,8 @@ use windmill_common::{ pub fn workspaced_service() -> Router { Router::new() .route("/list", get(list_conversations)) - .route("/delete/:conversation_id", delete(delete_conversation)) - .route("/:conversation_id/messages", get(list_messages)) + .route("/delete/{conversation_id}", delete(delete_conversation)) + .route("/{conversation_id}/messages", get(list_messages)) } #[derive(Serialize, FromRow, Debug)] diff --git a/backend/windmill-api-flows/src/flows.rs b/backend/windmill-api-flows/src/flows.rs index e5a78da5b3..89fd9629b7 100644 --- a/backend/windmill-api-flows/src/flows.rs +++ b/backend/windmill-api-flows/src/flows.rs @@ -61,26 +61,26 @@ pub fn workspaced_service() -> Router { .route("/list", get(list_flows)) .route("/list_search", get(list_search_flows)) .route("/create", post(create_flow)) - .route("/update/*path", post(update_flow)) - .route("/archive/*path", post(archive_flow_by_path)) - .route("/delete/*path", delete(delete_flow_by_path)) - .route("/list_tokens/*path", get(list_tokens)) - .route("/get/*path", get(get_flow_by_path)) - .route("/deployment_status/p/*path", get(get_deployment_status)) - .route("/get/draft/*path", get(get_flow_by_path_w_draft)) - .route("/exists/*path", get(exists_flow_by_path)) + .route("/update/{*path}", post(update_flow)) + .route("/archive/{*path}", post(archive_flow_by_path)) + .route("/delete/{*path}", delete(delete_flow_by_path)) + .route("/list_tokens/{*path}", get(list_tokens)) + .route("/get/{*path}", get(get_flow_by_path)) + .route("/deployment_status/p/{*path}", get(get_deployment_status)) + .route("/get/draft/{*path}", get(get_flow_by_path_w_draft)) + .route("/exists/{*path}", get(exists_flow_by_path)) .route("/list_paths", get(list_paths)) - .route("/history/p/*path", get(get_flow_history)) - .route("/get_latest_version/*path", get(get_latest_version)) + .route("/history/p/{*path}", get(get_flow_history)) + .route("/get_latest_version/{*path}", get(get_latest_version)) .route( - "/list_paths_from_workspace_runnable/:runnable_kind/*path", + "/list_paths_from_workspace_runnable/{runnable_kind}/{*path}", get(list_paths_from_workspace_runnable), ) - .route("/history_update/v/:version", post(update_flow_history)) - .route("/get/v/:version", get(get_flow_version_by_id)) - .route("/get/v/:version/p/*path", get(get_flow_version)) + .route("/history_update/v/{version}", post(update_flow_history)) + .route("/get/v/{version}", get(get_flow_version_by_id)) + .route("/get/v/{version}/p/{*path}", get(get_flow_version)) .route( - "/toggle_workspace_error_handler/*path", + "/toggle_workspace_error_handler/{*path}", post(toggle_workspace_error_handler), ) } @@ -88,7 +88,7 @@ pub fn workspaced_service() -> Router { pub fn global_service() -> Router { Router::new() .route("/hub/list", get(list_hub_flows)) - .route("/hub/get/:id", get(get_hub_flow_by_id)) + .route("/hub/get/{id}", get(get_hub_flow_by_id)) } #[derive(Serialize, FromRow)] diff --git a/backend/windmill-api-groups/src/folder_history.rs b/backend/windmill-api-groups/src/folder_history.rs index b11f328e3d..8ce1086dbf 100644 --- a/backend/windmill-api-groups/src/folder_history.rs +++ b/backend/windmill-api-groups/src/folder_history.rs @@ -22,7 +22,7 @@ use serde::Serialize; use sqlx::FromRow; pub fn workspaced_service() -> Router { - Router::new().route("/get/:name", get(get_folder_permission_history)) + Router::new().route("/get/{name}", get(get_folder_permission_history)) } #[derive(Serialize, FromRow)] diff --git a/backend/windmill-api-groups/src/folders.rs b/backend/windmill-api-groups/src/folders.rs index 89a64621cc..ab1541adc8 100644 --- a/backend/windmill-api-groups/src/folders.rs +++ b/backend/windmill-api-groups/src/folders.rs @@ -40,14 +40,14 @@ pub fn workspaced_service() -> Router { .route("/list", get(list_folders)) .route("/listnames", get(list_foldernames)) .route("/create", post(create_folder)) - .route("/get/:name", get(get_folder)) - .route("/exists/:name", get(exists_folder)) - .route("/update/:name", post(update_folder)) - .route("/getusage/:name", get(get_folder_usage)) - .route("/delete/:name", delete(delete_folder)) - .route("/addowner/:name", post(add_owner)) - .route("/removeowner/:name", post(remove_owner)) - .route("/is_owner/*path", get(is_owner_api)) + .route("/get/{name}", get(get_folder)) + .route("/exists/{name}", get(exists_folder)) + .route("/update/{name}", post(update_folder)) + .route("/getusage/{name}", get(get_folder_usage)) + .route("/delete/{name}", delete(delete_folder)) + .route("/addowner/{name}", post(add_owner)) + .route("/removeowner/{name}", post(remove_owner)) + .route("/is_owner/{*path}", get(is_owner_api)) } #[derive(FromRow, Serialize, Deserialize, Clone)] diff --git a/backend/windmill-api-groups/src/granular_acls.rs b/backend/windmill-api-groups/src/granular_acls.rs index da9267419d..d7ea8418f9 100644 --- a/backend/windmill-api-groups/src/granular_acls.rs +++ b/backend/windmill-api-groups/src/granular_acls.rs @@ -48,9 +48,9 @@ const KINDS: [&str; 19] = [ pub fn workspaced_service() -> Router { Router::new() - .route("/get/*path", get(get_granular_acls)) - .route("/add/*path", post(add_granular_acl)) - .route("/remove/*path", post(remove_granular_acl)) + .route("/get/{*path}", get(get_granular_acls)) + .route("/add/{*path}", post(add_granular_acl)) + .route("/remove/{*path}", post(remove_granular_acl)) } #[derive(Serialize, Deserialize)] diff --git a/backend/windmill-api-groups/src/groups.rs b/backend/windmill-api-groups/src/groups.rs index ad0dde1781..9acc840832 100644 --- a/backend/windmill-api-groups/src/groups.rs +++ b/backend/windmill-api-groups/src/groups.rs @@ -33,24 +33,24 @@ pub fn workspaced_service() -> Router { .route("/list", get(list_groups)) .route("/listnames", get(list_group_names)) .route("/create", post(create_group)) - .route("/get/:name", get(get_group)) - .route("/update/:name", post(update_group)) - .route("/delete/:name", delete(delete_group)) - .route("/adduser/:name", post(add_user)) - .route("/removeuser/:name", post(remove_user)) - .route("/is_owner/:name", get(is_owner)) + .route("/get/{name}", get(get_group)) + .route("/update/{name}", post(update_group)) + .route("/delete/{name}", delete(delete_group)) + .route("/adduser/{name}", post(add_user)) + .route("/removeuser/{name}", post(remove_user)) + .route("/is_owner/{name}", get(is_owner)) } pub fn global_service() -> Router { Router::new() .route("/list", get(list_igroups)) .route("/list_with_workspaces", get(list_igroups_with_workspaces)) - .route("/get/:name", get(get_igroup)) + .route("/get/{name}", get(get_igroup)) .route("/create", post(create_igroup)) - .route("/update/:name", post(update_igroup)) - .route("/delete/:name", delete(delete_igroup)) - .route("/adduser/:name", post(add_user_igroup)) - .route("/removeuser/:name", post(remove_user_igroup)) + .route("/update/{name}", post(update_igroup)) + .route("/delete/{name}", delete(delete_igroup)) + .route("/adduser/{name}", post(add_user_igroup)) + .route("/removeuser/{name}", post(remove_user_igroup)) .route("/export", get(export_igroups)) .route("/overwrite", post(overwrite_igroups)) } diff --git a/backend/windmill-api-inputs/src/lib.rs b/backend/windmill-api-inputs/src/lib.rs index e253d02ea1..9915b9459d 100644 --- a/backend/windmill-api-inputs/src/lib.rs +++ b/backend/windmill-api-inputs/src/lib.rs @@ -33,9 +33,9 @@ pub fn workspaced_service() -> Router { .route("/list", get(list_saved_inputs)) .route("/create", post(create_input)) .route("/update", post(update_input)) - .route("/delete/:id", post(delete_input)) + .route("/delete/{id}", post(delete_input)) .route( - "/:job_or_input_id/args", + "/{job_or_input_id}/args", get(get_args_from_history_or_saved_input), ) } diff --git a/backend/windmill-api-integration-tests/Cargo.toml b/backend/windmill-api-integration-tests/Cargo.toml index cb4827e857..eb432e463f 100644 --- a/backend/windmill-api-integration-tests/Cargo.toml +++ b/backend/windmill-api-integration-tests/Cargo.toml @@ -40,3 +40,4 @@ aws-config = { workspace = true, optional = true } aws-credential-types = { workspace = true, optional = true } aws-sdk-sqs = { workspace = true, optional = true } base64 = { workspace = true, optional = true } +axum.workspace = true diff --git a/backend/windmill-api-integration-tests/tests/ai_routes.rs b/backend/windmill-api-integration-tests/tests/ai_routes.rs new file mode 100644 index 0000000000..543c3ed2a8 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/ai_routes.rs @@ -0,0 +1,106 @@ +use serde_json::json; +use sqlx::{Pool, Postgres}; +use windmill_test_utils::*; + +fn client() -> reqwest::Client { + reqwest::Client::new() +} + +fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + builder.header("Authorization", "Bearer SECRET_TOKEN") +} + +fn assert_2xx(status: u16, body: &str, endpoint: &str) { + assert!( + (200..300).contains(&status), + "{endpoint} returned {status}: {body}", + ); +} + +/// Start a mock AI API that echoes back a valid chat completion response. +async fn start_mock_ai_api() -> u16 { + use axum::{routing::post, Json, Router}; + + let app = Router::new().fallback(post(|| async { + Json(json!({ + "id": "chatcmpl-test", + "object": "chat.completion", + "choices": [{"message": {"role": "assistant", "content": "hello"}}] + })) + })); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + port +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_ai_proxy_endpoints(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + // Start mock AI API + let mock_port = start_mock_ai_api().await; + let mock_url = format!("http://127.0.0.1:{mock_port}/v1"); + + // Create an openai resource pointing to the mock + let resp = authed( + client() + .post(format!( + "http://localhost:{port}/api/w/test-workspace/resources/create" + )) + .json(&json!({ + "path": "f/ai/openai_config", + "resource_type": "openai", + "value": { + "api_key": "test-key", + "base_url": mock_url + } + })), + ) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "create openai resource", + ); + + // Set ai_config on workspace_settings directly via SQL + sqlx::query( + "UPDATE workspace_settings SET ai_config = $1::jsonb WHERE workspace_id = 'test-workspace'", + ) + .bind(json!({ + "providers": { + "openai": { + "resource_path": "f/ai/openai_config", + "models": ["gpt-4"] + } + } + })) + .execute(&db) + .await?; + + // POST /w/{ws}/ai/proxy/chat/completions + let resp = authed( + client() + .post(format!( + "http://localhost:{port}/api/w/test-workspace/ai/proxy/chat/completions" + )) + .header("X-Provider", "openai") + .json(&json!({ + "model": "gpt-4", + "messages": [{"role": "user", "content": "hi"}] + })), + ) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "POST /ai/proxy/chat/completions", + ); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/audit.rs b/backend/windmill-api-integration-tests/tests/audit.rs new file mode 100644 index 0000000000..31994d8165 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/audit.rs @@ -0,0 +1,35 @@ +use sqlx::{Pool, Postgres}; +use windmill_test_utils::*; + +fn client() -> reqwest::Client { + reqwest::Client::new() +} + +fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + builder.header("Authorization", "Bearer SECRET_TOKEN") +} + +fn assert_2xx(status: u16, body: &str, endpoint: &str) { + assert!( + (200..300).contains(&status), + "{endpoint} returned {status}: {body}", + ); +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_audit_endpoints(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/w/test-workspace/audit"); + + // GET /list returns 200 (empty array) + let resp = authed(client().get(format!("{base}/list"))).send().await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /audit/list", + ); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/capture_unauthed.rs b/backend/windmill-api-integration-tests/tests/capture_unauthed.rs new file mode 100644 index 0000000000..f30a6e31b3 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/capture_unauthed.rs @@ -0,0 +1,83 @@ +use serde_json::json; +use sqlx::{Pool, Postgres}; +use windmill_test_utils::*; + +fn client() -> reqwest::Client { + reqwest::Client::new() +} + +fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + builder.header("Authorization", "Bearer SECRET_TOKEN") +} + +fn assert_2xx(status: u16, body: &str, endpoint: &str) { + assert!( + (200..300).contains(&status), + "{endpoint} returned {status}: {body}", + ); +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_capture_endpoints(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + // POST /capture/set_config → 200 (authed) + let resp = authed( + client() + .post(format!( + "http://localhost:{port}/api/w/test-workspace/capture/set_config" + )) + .json(&json!({ + "trigger_kind": "webhook", + "path": "u/test-user/test_capture", + "is_flow": false + })), + ) + .send() + .await?; + let status = resp.status().as_u16(); + let body = resp.text().await?; + assert_2xx(status, &body, "POST /capture/set_config"); + + // GET /capture/list/{...} → 200 (authed) + let resp = authed(client().get(format!( + "http://localhost:{port}/api/w/test-workspace/capture/list/script/u/test-user/test_capture" + ))) + .send() + .await?; + let status = resp.status().as_u16(); + let body = resp.text().await?; + assert_2xx( + status, + &body, + "GET /capture/list/script/u/test-user/test_capture", + ); + + // POST /capture/ping_config/{trigger_kind}/{runnable_kind}/{*path} → 200 + let resp = authed(client().post(format!( + "http://localhost:{port}/api/w/test-workspace/capture/ping_config/webhook/script/u/test-user/test_capture" + ))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "POST /capture/ping_config", + ); + + // GET /capture/get_configs/{runnable_kind}/{*path} → 200 + let resp = authed(client().get(format!( + "http://localhost:{port}/api/w/test-workspace/capture/get_configs/script/u/test-user/test_capture" + ))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /capture/get_configs", + ); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/concurrency_groups.rs b/backend/windmill-api-integration-tests/tests/concurrency_groups.rs new file mode 100644 index 0000000000..e4cf4ab8be --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/concurrency_groups.rs @@ -0,0 +1,48 @@ +use sqlx::{Pool, Postgres}; +use windmill_test_utils::*; + +fn client() -> reqwest::Client { + reqwest::Client::new() +} + +fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + builder.header("Authorization", "Bearer SECRET_TOKEN") +} + +fn assert_2xx(status: u16, body: &str, endpoint: &str) { + assert!( + (200..300).contains(&status), + "{endpoint} returned {status}: {body}", + ); +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_concurrency_groups_2xx(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let resp = authed(client().get(format!( + "http://localhost:{port}/api/concurrency_groups/list" + ))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /api/concurrency_groups/list", + ); + + let resp = authed(client().get(format!( + "http://localhost:{port}/api/w/test-workspace/concurrency_groups/list_jobs" + ))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /api/w/test-workspace/concurrency_groups/list_jobs", + ); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/favorites.rs b/backend/windmill-api-integration-tests/tests/favorites.rs new file mode 100644 index 0000000000..888425eef7 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/favorites.rs @@ -0,0 +1,72 @@ +use serde_json::json; +use sqlx::{Pool, Postgres}; +use windmill_test_utils::*; + +fn client() -> reqwest::Client { + reqwest::Client::new() +} + +fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + builder.header("Authorization", "Bearer SECRET_TOKEN") +} + +fn assert_2xx(status: u16, body: &str, endpoint: &str) { + assert!( + (200..300).contains(&status), + "{endpoint} returned {status}: {body}", + ); +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_favorites_endpoints(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let ws = format!("http://localhost:{port}/api/w/test-workspace"); + + // Setup: create a script to favorite + let resp = authed(client().post(format!("{ws}/scripts/create"))) + .json(&json!({ + "path": "u/test-user/test_fav_script", + "summary": "test", + "description": "", + "content": "export function main() { return 1; }", + "language": "deno", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": {}, + "required": [] + } + })) + .send() + .await?; + let status = resp.status().as_u16(); + let body = resp.text().await?; + assert_2xx(status, &body, "POST /scripts/create (setup)"); + + let fav_body = json!({ + "favorite_kind": "script", + "path": "u/test-user/test_fav_script" + }); + + // POST /favorites/star → 200 + let resp = authed(client().post(format!("{ws}/favorites/star"))) + .json(&fav_body) + .send() + .await?; + let status = resp.status().as_u16(); + let body = resp.text().await?; + assert_2xx(status, &body, "POST /favorites/star"); + + // POST /favorites/unstar → 200 + let resp = authed(client().post(format!("{ws}/favorites/unstar"))) + .json(&fav_body) + .send() + .await?; + let status = resp.status().as_u16(); + let body = resp.text().await?; + assert_2xx(status, &body, "POST /favorites/unstar"); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/folder_history.rs b/backend/windmill-api-integration-tests/tests/folder_history.rs new file mode 100644 index 0000000000..065977f386 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/folder_history.rs @@ -0,0 +1,51 @@ +use serde_json::json; +use sqlx::{Pool, Postgres}; +use windmill_test_utils::*; + +fn client() -> reqwest::Client { + reqwest::Client::new() +} + +fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + builder.header("Authorization", "Bearer SECRET_TOKEN") +} + +fn assert_2xx(status: u16, body: &str, endpoint: &str) { + assert!( + (200..300).contains(&status), + "{endpoint} returned {status}: {body}", + ); +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_folder_history_endpoints(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + // Create a folder first + let resp = authed( + client() + .post(format!( + "http://localhost:{port}/api/w/test-workspace/folders/create" + )) + .json(&json!({"name": "test_hist_folder", "owners": ["u/test-user"]})), + ) + .send() + .await?; + let status = resp.status().as_u16(); + let body = resp.text().await?; + assert_2xx(status, &body, "POST /folders/create"); + + // GET /folders_history/get/{folder} → 200 (empty array) + let resp = authed(client().get(format!( + "http://localhost:{port}/api/w/test-workspace/folders_history/get/test_hist_folder" + ))) + .send() + .await?; + let status = resp.status().as_u16(); + let body = resp.text().await?; + assert_2xx(status, &body, "GET /folders_history/get/test_hist_folder"); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/granular_acls.rs b/backend/windmill-api-integration-tests/tests/granular_acls.rs new file mode 100644 index 0000000000..c9cbbf0cf4 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/granular_acls.rs @@ -0,0 +1,54 @@ +use serde_json::json; +use sqlx::{Pool, Postgres}; +use windmill_test_utils::*; + +fn client() -> reqwest::Client { + reqwest::Client::new() +} + +fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + builder.header("Authorization", "Bearer SECRET_TOKEN") +} + +fn assert_2xx(status: u16, body: &str, endpoint: &str) { + assert!( + (200..300).contains(&status), + "{endpoint} returned {status}: {body}", + ); +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_granular_acls_endpoints(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/w/test-workspace/acls"); + + // GET /acls/get/group_/all → 200 + let resp = authed(client().get(format!("{base}/get/group_/all"))) + .send() + .await?; + let status = resp.status().as_u16(); + let body = resp.text().await?; + assert_2xx(status, &body, "GET /acls/get/group_/all"); + + // POST /acls/add/group_/all → 200 + let resp = authed(client().post(format!("{base}/add/group_/all"))) + .json(&json!({"owner": "u/test-user-2", "write": true})) + .send() + .await?; + let status = resp.status().as_u16(); + let body = resp.text().await?; + assert_2xx(status, &body, "POST /acls/add/group_/all"); + + // POST /acls/remove/group_/all → 200 + let resp = authed(client().post(format!("{base}/remove/group_/all"))) + .json(&json!({"owner": "u/test-user-2"})) + .send() + .await?; + let status = resp.status().as_u16(); + let body = resp.text().await?; + assert_2xx(status, &body, "POST /acls/remove/group_/all"); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/group_history.rs b/backend/windmill-api-integration-tests/tests/group_history.rs new file mode 100644 index 0000000000..5a18901454 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/group_history.rs @@ -0,0 +1,32 @@ +use sqlx::{Pool, Postgres}; +use windmill_test_utils::*; + +fn client() -> reqwest::Client { + reqwest::Client::new() +} + +fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + builder.header("Authorization", "Bearer SECRET_TOKEN") +} + +fn assert_2xx(status: u16, body: &str, endpoint: &str) { + assert!( + (200..300).contains(&status), + "{endpoint} returned {status}: {body}", + ); +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_group_history_2xx(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/w/test-workspace/groups_history"); + + let resp = authed(client().get(format!("{base}/get/all"))) + .send() + .await?; + assert_2xx(resp.status().as_u16(), &resp.text().await?, "GET /get/all"); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/health.rs b/backend/windmill-api-integration-tests/tests/health.rs new file mode 100644 index 0000000000..c804373697 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/health.rs @@ -0,0 +1,42 @@ +use serde_json::json; +use sqlx::{Pool, Postgres}; +use windmill_test_utils::*; + +fn client() -> reqwest::Client { + reqwest::Client::new() +} + +fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + builder.header("Authorization", "Bearer SECRET_TOKEN") +} + +fn assert_2xx(status: u16, body: &str, endpoint: &str) { + assert!( + (200..300).contains(&status), + "{endpoint} returned {status}: {body}", + ); +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_health_endpoints(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/health"); + + // GET /health/status → 200 (no auth required) + let resp = client().get(format!("{base}/status")).send().await?; + let status = resp.status().as_u16(); + let body = resp.text().await?; + assert_2xx(status, &body, "GET /health/status"); + + // GET /health/detailed → 200 (authed) + let resp = authed(client().get(format!("{base}/detailed"))) + .send() + .await?; + let status = resp.status().as_u16(); + let body = resp.text().await?; + assert_2xx(status, &body, "GET /health/detailed"); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/inputs.rs b/backend/windmill-api-integration-tests/tests/inputs.rs new file mode 100644 index 0000000000..6f806efdb5 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/inputs.rs @@ -0,0 +1,76 @@ +use serde_json::json; +use sqlx::{Pool, Postgres}; +use windmill_test_utils::*; + +fn client() -> reqwest::Client { + reqwest::Client::new() +} + +fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + builder.header("Authorization", "Bearer SECRET_TOKEN") +} + +fn assert_2xx(status: u16, body: &str, endpoint: &str) { + assert!( + (200..300).contains(&status), + "{endpoint} returned {status}: {body}", + ); +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_inputs_endpoints(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/w/test-workspace/inputs"); + + // GET /history with fake runnable → 200 empty array + let resp = authed(client().get(format!( + "{base}/history?runnable_id=u/test-user/test&runnable_type=ScriptPath" + ))) + .send() + .await?; + let status = resp.status().as_u16(); + let body = resp.text().await?; + assert_2xx(status, &body, "GET /inputs/history"); + + // GET /list with fake runnable → 200 empty array + let resp = authed(client().get(format!( + "{base}/list?runnable_id=u/test-user/test&runnable_type=ScriptPath" + ))) + .send() + .await?; + let status = resp.status().as_u16(); + let body = resp.text().await?; + assert_2xx(status, &body, "GET /inputs/list"); + + // POST /create → 200, returns UUID + let resp = authed(client().post(format!( + "{base}/create?runnable_id=u/test-user/test&runnable_type=ScriptPath" + ))) + .json(&json!({"name": "test_input", "args": {}})) + .send() + .await?; + let status = resp.status().as_u16(); + let body = resp.text().await?; + assert_2xx(status, &body, "POST /inputs/create"); + let input_id: String = serde_json::from_str(&body)?; + + // GET /{id}/args → 200 + let resp = authed(client().get(format!("{base}/{input_id}/args"))) + .send() + .await?; + let status = resp.status().as_u16(); + let body = resp.text().await?; + assert_2xx(status, &body, "GET /inputs/{id}/args"); + + // POST /delete/{id} → 200 + let resp = authed(client().post(format!("{base}/delete/{input_id}"))) + .send() + .await?; + let status = resp.status().as_u16(); + let body = resp.text().await?; + assert_2xx(status, &body, "POST /inputs/delete/{id}"); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/job_metrics.rs b/backend/windmill-api-integration-tests/tests/job_metrics.rs new file mode 100644 index 0000000000..7408e4d3e3 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/job_metrics.rs @@ -0,0 +1,59 @@ +use serde_json::json; +use sqlx::{Pool, Postgres}; +use windmill_test_utils::*; + +fn client() -> reqwest::Client { + reqwest::Client::new() +} + +fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + builder.header("Authorization", "Bearer SECRET_TOKEN") +} + +fn assert_2xx(status: u16, body: &str, endpoint: &str) { + assert!( + (200..300).contains(&status), + "{endpoint} returned {status}: {body}", + ); +} + +const FAKE_UUID: &str = "00000000-0000-0000-0000-000000000000"; + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_job_metrics_2xx(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/w/test-workspace/job_metrics"); + + let resp = authed(client().post(format!("{base}/get/{FAKE_UUID}"))) + .json(&json!({})) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "POST /get/{id}", + ); + + let resp = authed(client().post(format!("{base}/set_progress/{FAKE_UUID}"))) + .json(&json!({"percent": 50})) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "POST /set_progress/{id}", + ); + + let resp = authed(client().get(format!("{base}/get_progress/{FAKE_UUID}"))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /get_progress/{id}", + ); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/jobs_authed.rs b/backend/windmill-api-integration-tests/tests/jobs_authed.rs new file mode 100644 index 0000000000..4e82a4aba1 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/jobs_authed.rs @@ -0,0 +1,308 @@ +use serde_json::json; +use sqlx::{Pool, Postgres}; +use uuid::Uuid; +use windmill_test_utils::*; + +fn client() -> reqwest::Client { + reqwest::Client::new() +} + +fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + builder.header("Authorization", "Bearer SECRET_TOKEN") +} + +fn assert_2xx(status: u16, body: &str, endpoint: &str) { + assert!( + (200..300).contains(&status), + "{endpoint} returned {status}: {body}", + ); +} + +fn assert_route_reachable(status: u16, body: &str, endpoint: &str) { + assert!( + status != 404 || !body.is_empty(), + "Router-level 404 for {endpoint}", + ); +} + +async fn insert_completed_job(db: &Pool) -> Uuid { + let id = Uuid::new_v4(); + sqlx::query( + "INSERT INTO v2_job (id, workspace_id, created_by, permissioned_as, kind, tag, args) + VALUES ($1, 'test-workspace', 'test-user', 'u/test-user', 'script', 'deno', '{}'::jsonb)", + ) + .bind(id) + .execute(db) + .await + .unwrap(); + sqlx::query( + "INSERT INTO v2_job_completed (id, workspace_id, duration_ms, result, status) + VALUES ($1, 'test-workspace', 100, '42'::jsonb, 'success')", + ) + .bind(id) + .execute(db) + .await + .unwrap(); + id +} + +async fn create_script(port: u16) -> String { + let base = format!("http://localhost:{port}/api/w/test-workspace/scripts"); + let resp = authed(client().post(format!("{base}/create"))) + .json(&json!({ + "path": "u/test-user/test_job_script", + "summary": "test", + "description": "", + "content": "export function main() { return 42; }", + "language": "deno", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": {}, + "required": [] + } + })) + .send() + .await + .unwrap(); + assert!( + resp.status().is_success(), + "create script: {}", + resp.status() + ); + "u/test-user/test_job_script".to_string() +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_jobs_authed_list_and_count(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/w/test-workspace/jobs"); + + // --- List/count endpoints (2xx with empty results) --- + + let resp = authed(client().get(format!("{base}/list"))).send().await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /jobs/list", + ); + + let resp = authed(client().get(format!("{base}/queue/list"))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /jobs/queue/list", + ); + + let resp = authed(client().get(format!("{base}/queue/count"))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /jobs/queue/count", + ); + + let resp = authed(client().get(format!("{base}/completed/list"))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /jobs/completed/list", + ); + + let resp = authed(client().get(format!("{base}/completed/count"))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /jobs/completed/count", + ); + + // --- Global endpoints --- + + let resp = client() + .get(format!("http://localhost:{port}/api/jobs/db_clock")) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /jobs/db_clock", + ); + + let resp = authed(client().get(format!( + "http://localhost:{port}/api/jobs/completed/count_by_tag" + ))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /jobs/completed/count_by_tag", + ); + + Ok(()) +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_jobs_authed_completed_endpoints(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/w/test-workspace/jobs"); + + let job_id = insert_completed_job(&db).await; + + let resp = authed(client().get(format!("{base}/completed/get/{job_id}"))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /jobs/completed/get", + ); + + let resp = authed(client().get(format!("{base}/completed/get_result/{job_id}"))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /jobs/completed/get_result", + ); + + let resp = authed(client().get(format!("{base}/completed/get_result_maybe/{job_id}"))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /jobs/completed/get_result_maybe", + ); + + let resp = authed(client().get(format!("{base}/completed/get_timing/{job_id}"))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /jobs/completed/get_timing", + ); + + Ok(()) +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_jobs_authed_run_endpoints(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/w/test-workspace/jobs"); + + // Run preview — no pre-existing script needed + let resp = authed(client().post(format!("{base}/run/preview"))) + .json(&json!({ + "content": "export function main() { return 1; }", + "language": "deno", + "args": {} + })) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "POST /jobs/run/preview", + ); + + // Run preview flow + let resp = authed(client().post(format!("{base}/run/preview_flow"))) + .json(&json!({ + "value": {"modules": []}, + "args": {} + })) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "POST /jobs/run/preview_flow", + ); + + Ok(()) +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_jobs_authed_reachability(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/w/test-workspace/jobs"); + let fake = Uuid::nil(); + + // These need complex runtime but should hit the handler (not 404) + + let resp = authed(client().post(format!("{base}/flow/resume/{fake}"))) + .json(&json!({})) + .send() + .await?; + assert_route_reachable( + resp.status().as_u16(), + &resp.text().await?, + "POST /jobs/flow/resume", + ); + + let resp = authed(client().get(format!("{base}/job_signature/{fake}/1"))) + .send() + .await?; + assert_route_reachable( + resp.status().as_u16(), + &resp.text().await?, + "GET /jobs/job_signature", + ); + + let resp = authed(client().get(format!("{base}/resume_urls/{fake}/1"))) + .send() + .await?; + assert_route_reachable( + resp.status().as_u16(), + &resp.text().await?, + "GET /jobs/resume_urls", + ); + + let resp = authed(client().get(format!("{base}/result_by_id/{fake}/step1"))) + .send() + .await?; + assert_route_reachable( + resp.status().as_u16(), + &resp.text().await?, + "GET /jobs/result_by_id", + ); + + let resp = authed(client().post(format!("{base}/restart/f/{fake}"))) + .send() + .await?; + assert_route_reachable( + resp.status().as_u16(), + &resp.text().await?, + "POST /jobs/restart/f", + ); + + let resp = authed(client().post(format!("{base}/run/workflow_as_code/{fake}/main"))) + .json(&json!({})) + .send() + .await?; + assert_route_reachable( + resp.status().as_u16(), + &resp.text().await?, + "POST /jobs/run/workflow_as_code", + ); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/jobs_unauthed.rs b/backend/windmill-api-integration-tests/tests/jobs_unauthed.rs new file mode 100644 index 0000000000..fa8b6cb66f --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/jobs_unauthed.rs @@ -0,0 +1,250 @@ +use serde_json::json; +use sqlx::{Pool, Postgres}; +use uuid::Uuid; +use windmill_test_utils::*; + +fn client() -> reqwest::Client { + reqwest::Client::new() +} + +fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + builder.header("Authorization", "Bearer SECRET_TOKEN") +} + +fn assert_2xx(status: u16, body: &str, endpoint: &str) { + assert!( + (200..300).contains(&status), + "{endpoint} returned {status}: {body}", + ); +} + +/// Insert a minimal completed job directly into the database for testing. +async fn insert_completed_job(db: &Pool) -> Uuid { + let id = Uuid::new_v4(); + sqlx::query( + "INSERT INTO v2_job (id, workspace_id, created_by, permissioned_as, kind, tag, args) + VALUES ($1, 'test-workspace', 'test-user', 'u/test-user', 'script', 'deno', '{}'::jsonb)", + ) + .bind(id) + .execute(db) + .await + .unwrap(); + + sqlx::query( + "INSERT INTO v2_job_completed (id, workspace_id, duration_ms, result, status) + VALUES ($1, 'test-workspace', 100, '42'::jsonb, 'success')", + ) + .bind(id) + .execute(db) + .await + .unwrap(); + + id +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_jobs_unauthed_endpoints(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/w/test-workspace/jobs_u"); + + let job_id = insert_completed_job(&db).await; + + // --- No-data endpoints --- + + let resp = authed(client().post(format!("{base}/queue/get_started_at_by_ids"))) + .json(&json!([])) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "POST /queue/get_started_at_by_ids", + ); + + // --- Completed job endpoints (unauthed service, with auth header) --- + + let resp = authed(client().get(format!("{base}/get/{job_id}"))) + .send() + .await?; + assert_2xx(resp.status().as_u16(), &resp.text().await?, "GET /get"); + + let resp = authed(client().get(format!("{base}/get_logs/{job_id}"))) + .send() + .await?; + assert_2xx(resp.status().as_u16(), &resp.text().await?, "GET /get_logs"); + + let resp = authed(client().get(format!("{base}/get_completed_logs_tail/{job_id}"))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /get_completed_logs_tail", + ); + + let resp = authed(client().get(format!("{base}/get_args/{job_id}"))) + .send() + .await?; + assert_2xx(resp.status().as_u16(), &resp.text().await?, "GET /get_args"); + + let resp = authed(client().get(format!("{base}/completed/get/{job_id}"))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /completed/get", + ); + + let resp = authed(client().get(format!("{base}/completed/get_result/{job_id}"))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /completed/get_result", + ); + + let resp = authed(client().get(format!("{base}/completed/get_result_maybe/{job_id}"))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /completed/get_result_maybe", + ); + + let resp = authed(client().get(format!("{base}/completed/get_timing/{job_id}"))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /completed/get_timing", + ); + + let resp = authed(client().get(format!("{base}/getupdate/{job_id}"))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /getupdate", + ); + + Ok(()) +} + +const FAKE_UUID: &str = "00000000-0000-0000-0000-000000000000"; +const FAKE_SECRET: &str = "aabb"; + +/// Reachability tests for endpoints that need complex runtime. +/// These just verify the route matches (handler runs), not 2xx. +fn assert_route_reachable(status: u16, body: &str, endpoint: &str) { + assert!( + status != 404 || !body.is_empty(), + "Router-level 404 for {endpoint}", + ); +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_jobs_unauthed_complex_reachability(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/w/test-workspace/jobs_u"); + + let resp = authed(client().get(format!("{base}/resume/{FAKE_UUID}/1/{FAKE_SECRET}"))) + .send() + .await?; + assert_route_reachable(resp.status().as_u16(), &resp.text().await?, "GET /resume"); + + let resp = authed(client().post(format!("{base}/cancel/{FAKE_UUID}/1/{FAKE_SECRET}"))) + .send() + .await?; + assert_route_reachable(resp.status().as_u16(), &resp.text().await?, "POST /cancel"); + + let resp = authed(client().get(format!("{base}/get_flow/{FAKE_UUID}/1/{FAKE_SECRET}"))) + .send() + .await?; + assert_route_reachable(resp.status().as_u16(), &resp.text().await?, "GET /get_flow"); + + let resp = authed(client().post(format!("{base}/queue/cancel/{FAKE_UUID}"))) + .json(&serde_json::json!({"reason": "test"})) + .send() + .await?; + assert_route_reachable( + resp.status().as_u16(), + &resp.text().await?, + "POST /queue/cancel", + ); + + let resp = authed(client().post(format!("{base}/queue/force_cancel/{FAKE_UUID}"))) + .json(&serde_json::json!({"reason": "test"})) + .send() + .await?; + assert_route_reachable( + resp.status().as_u16(), + &resp.text().await?, + "POST /queue/force_cancel", + ); + + let resp = authed(client().post(format!("{base}/flow/resume_suspended/{FAKE_UUID}"))) + .send() + .await?; + assert_route_reachable( + resp.status().as_u16(), + &resp.text().await?, + "POST /flow/resume_suspended", + ); + + let resp = authed(client().get(format!("{base}/flow/approval_info/{FAKE_UUID}"))) + .send() + .await?; + assert_route_reachable( + resp.status().as_u16(), + &resp.text().await?, + "GET /flow/approval_info", + ); + + let resp = authed(client().get(format!("{base}/get_root_job_id/{FAKE_UUID}"))) + .send() + .await?; + assert_route_reachable( + resp.status().as_u16(), + &resp.text().await?, + "GET /get_root_job_id", + ); + + let resp = authed(client().get(format!("{base}/get_flow_debug_info/{FAKE_UUID}"))) + .send() + .await?; + assert_route_reachable( + resp.status().as_u16(), + &resp.text().await?, + "GET /get_flow_debug_info", + ); + + let resp = authed(client().get(format!("{base}/get_log_file/{FAKE_UUID}/test.txt"))) + .send() + .await?; + assert_route_reachable( + resp.status().as_u16(), + &resp.text().await?, + "GET /get_log_file", + ); + + let resp = authed(client().post(format!("{base}/queue/cancel_persistent/u/test-user/fake"))) + .json(&serde_json::json!({"reason": "test"})) + .send() + .await?; + assert_route_reachable( + resp.status().as_u16(), + &resp.text().await?, + "POST /queue/cancel_persistent", + ); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/npm_proxy.rs b/backend/windmill-api-integration-tests/tests/npm_proxy.rs new file mode 100644 index 0000000000..9f3e813807 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/npm_proxy.rs @@ -0,0 +1,85 @@ +use serde_json::json; +use sqlx::{Pool, Postgres}; +use windmill_test_utils::*; + +fn client() -> reqwest::Client { + reqwest::Client::new() +} + +fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + builder.header("Authorization", "Bearer SECRET_TOKEN") +} + +fn assert_2xx(status: u16, body: &str, endpoint: &str) { + assert!( + (200..300).contains(&status), + "{endpoint} returned {status}: {body}", + ); +} + +/// Start a mock npm registry that returns valid JSON for any GET request. +async fn start_mock_registry() -> u16 { + use axum::{routing::get, Json, Router}; + + let app = Router::new().fallback(get(|| async { + Json(json!({ + "name": "test-package", + "versions": {"1.0.0": {"name": "test-package", "version": "1.0.0"}}, + "dist-tags": {"latest": "1.0.0"} + })) + })); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + port +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_npm_proxy_endpoints(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/w/test-workspace/npm_proxy"); + + // Start mock npm registry + let mock_port = start_mock_registry().await; + let mock_url = format!("http://127.0.0.1:{mock_port}"); + + // Configure the npm registry to point to our mock + let resp = authed( + client() + .post(format!( + "http://localhost:{port}/api/settings/global/npm_config_registry" + )) + .json(&json!({"value": mock_url})), + ) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "POST /settings/global/npm_config_registry", + ); + + // GET /metadata/{package} + let resp = authed(client().get(format!("{base}/metadata/lodash"))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /npm_proxy/metadata/lodash", + ); + + // GET /resolve/{package} + let resp = authed(client().get(format!("{base}/resolve/lodash"))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /npm_proxy/resolve/lodash", + ); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/raw_apps.rs b/backend/windmill-api-integration-tests/tests/raw_apps.rs new file mode 100644 index 0000000000..97f90709b1 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/raw_apps.rs @@ -0,0 +1,33 @@ +use sqlx::{Pool, Postgres}; +use windmill_test_utils::*; + +fn client() -> reqwest::Client { + reqwest::Client::new() +} + +fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + builder.header("Authorization", "Bearer SECRET_TOKEN") +} + +fn assert_2xx(status: u16, body: &str, endpoint: &str) { + assert!( + (200..300).contains(&status), + "{endpoint} returned {status}: {body}", + ); +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_raw_apps_endpoints(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/w/test-workspace/raw_apps"); + + // GET /raw_apps/list → 200 (empty array) + let resp = authed(client().get(format!("{base}/list"))).send().await?; + let status = resp.status().as_u16(); + let body = resp.text().await?; + assert_2xx(status, &body, "GET /raw_apps/list"); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/service_logs.rs b/backend/windmill-api-integration-tests/tests/service_logs.rs new file mode 100644 index 0000000000..0c66916053 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/service_logs.rs @@ -0,0 +1,36 @@ +use sqlx::{Pool, Postgres}; +use windmill_test_utils::*; + +fn client() -> reqwest::Client { + reqwest::Client::new() +} + +fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + builder.header("Authorization", "Bearer SECRET_TOKEN") +} + +fn assert_2xx(status: u16, body: &str, endpoint: &str) { + assert!( + (200..300).contains(&status), + "{endpoint} returned {status}: {body}", + ); +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_service_logs_2xx(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/service_logs"); + + let resp = authed(client().get(format!("{base}/list_files"))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /list_files", + ); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/settings.rs b/backend/windmill-api-integration-tests/tests/settings.rs new file mode 100644 index 0000000000..8f21ffb483 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/settings.rs @@ -0,0 +1,116 @@ +use serde_json::json; +use sqlx::{Pool, Postgres}; +use windmill_test_utils::*; + +fn client() -> reqwest::Client { + reqwest::Client::new() +} + +fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + builder.header("Authorization", "Bearer SECRET_TOKEN") +} + +fn assert_2xx(status: u16, body: &str, endpoint: &str) { + assert!( + (200..300).contains(&status), + "{endpoint} returned {status}: {body}", + ); +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_settings_2xx(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/settings"); + + let resp = authed(client().get(format!("{base}/envs"))).send().await?; + assert_2xx(resp.status().as_u16(), &resp.text().await?, "GET /envs"); + + let resp = authed(client().get(format!("{base}/global/hub_base_url"))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /global/hub_base_url", + ); + + let resp = authed(client().post(format!("{base}/global/test_key"))) + .json(&json!({"value": "test"})) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "POST /global/test_key", + ); + + let resp = authed(client().get(format!("{base}/instance_config"))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /instance_config", + ); + + let resp = authed(client().get(format!("{base}/instance_config/yaml"))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /instance_config/yaml", + ); + + let resp = authed(client().get(format!("{base}/latest_key_renewal_attempt"))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /latest_key_renewal_attempt", + ); + + let resp = authed(client().post(format!("{base}/sync_cached_resource_types"))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "POST /sync_cached_resource_types", + ); + + // --- Reachability only (need external services) --- + + let resp = authed( + client() + .post(format!("{base}/test_smtp")) + .json(&json!({"to": "test@test.com", "subject": "test", "content": "test"})), + ) + .send() + .await?; + let status = resp.status().as_u16(); + let body = resp.text().await?; + assert!( + status != 404 || !body.is_empty(), + "Router-level 404 for POST /test_smtp" + ); + + let resp = authed( + client() + .post(format!("{base}/test_license_key")) + .json(&json!({"license_key": "fake"})), + ) + .send() + .await?; + let status = resp.status().as_u16(); + let body = resp.text().await?; + assert!( + status != 404 || !body.is_empty(), + "Router-level 404 for POST /test_license_key" + ); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/trash.rs b/backend/windmill-api-integration-tests/tests/trash.rs new file mode 100644 index 0000000000..df32a5b3fc --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/trash.rs @@ -0,0 +1,41 @@ +use sqlx::{Pool, Postgres}; +use windmill_test_utils::*; + +fn client() -> reqwest::Client { + reqwest::Client::new() +} + +fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + builder.header("Authorization", "Bearer SECRET_TOKEN") +} + +fn assert_2xx(status: u16, body: &str, endpoint: &str) { + assert!( + (200..300).contains(&status), + "{endpoint} returned {status}: {body}", + ); +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_trash_endpoints(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/w/test-workspace/trash"); + + // GET /trash/list → 200 (admin, empty array) + let resp = authed(client().get(format!("{base}/list"))).send().await?; + let status = resp.status().as_u16(); + let body = resp.text().await?; + assert_2xx(status, &body, "GET /trash/list"); + + // POST /trash/empty → 200 (admin) + let resp = authed(client().post(format!("{base}/empty"))) + .send() + .await?; + let status = resp.status().as_u16(); + let body = resp.text().await?; + assert_2xx(status, &body, "POST /trash/empty"); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/workspace_deps.rs b/backend/windmill-api-integration-tests/tests/workspace_deps.rs new file mode 100644 index 0000000000..dcfe8877dd --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/workspace_deps.rs @@ -0,0 +1,39 @@ +use sqlx::{Pool, Postgres}; +use windmill_test_utils::*; + +fn client() -> reqwest::Client { + reqwest::Client::new() +} + +fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + builder.header("Authorization", "Bearer SECRET_TOKEN") +} + +fn assert_2xx(status: u16, body: &str, endpoint: &str) { + assert!( + (200..300).contains(&status), + "{endpoint} returned {status}: {body}", + ); +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_workspace_deps_2xx(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/w/test-workspace/workspace_dependencies"); + + let resp = authed(client().get(format!("{base}/list"))).send().await?; + assert_2xx(resp.status().as_u16(), &resp.text().await?, "GET /list"); + + let resp = authed(client().get(format!("{base}/get_latest/python3"))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "GET /get_latest/python3", + ); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/workspaces.rs b/backend/windmill-api-integration-tests/tests/workspaces.rs index 55e00eb05d..131cfbbae5 100644 --- a/backend/windmill-api-integration-tests/tests/workspaces.rs +++ b/backend/windmill-api-integration-tests/tests/workspaces.rs @@ -599,59 +599,60 @@ async fn test_workspace_endpoints(db: Pool) -> anyhow::Result<()> { .unwrap(); assert_eq!(resp.status(), 200, "tarball: {}", resp.status()); - // ===== Fork operations (on the newly created workspace) ===== + // ===== Fork operations (EE-only: CE limits workspace count to 2) ===== + #[cfg(feature = "enterprise")] + { + let new_ws_base = format!("http://localhost:{port}/api/w/new-test-ws/workspaces"); + let resp = authed(client().post(format!("{new_ws_base}/create_fork"))) + .json(&json!({ + "id": "wm-fork-test-ws", + "name": "Forked Test Workspace" + })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200, "create_fork: {}", resp.text().await?); - // --- create_fork (workspace-scoped, from new-test-ws) --- - let new_ws_base = format!("http://localhost:{port}/api/w/new-test-ws/workspaces"); - let resp = authed(client().post(format!("{new_ws_base}/create_fork"))) - .json(&json!({ - "id": "wm-fork-test-ws", - "name": "Forked Test Workspace" - })) - .send() - .await - .unwrap(); - assert_eq!(resp.status(), 200, "create_fork: {}", resp.text().await?); + // verify fork exists + let resp = authed(client().post(format!("{global_base}/exists"))) + .json(&json!({"id": "wm-fork-test-ws"})) + .send() + .await + .unwrap(); + assert_eq!(resp.json::().await?, true); - // verify fork exists - let resp = authed(client().post(format!("{global_base}/exists"))) - .json(&json!({"id": "wm-fork-test-ws"})) - .send() - .await - .unwrap(); - assert_eq!(resp.json::().await?, true); + // --- change_workspace_id --- + let fork_ws_base = format!("http://localhost:{port}/api/w/wm-fork-test-ws/workspaces"); + let resp = authed(client().post(format!("{fork_ws_base}/change_workspace_id"))) + .json(&json!({ + "new_id": "wm-fork-renamed", + "new_name": "Renamed Fork" + })) + .send() + .await + .unwrap(); + assert_eq!( + resp.status(), + 200, + "change_workspace_id: {}", + resp.text().await? + ); - // --- change_workspace_id --- - let fork_ws_base = format!("http://localhost:{port}/api/w/wm-fork-test-ws/workspaces"); - let resp = authed(client().post(format!("{fork_ws_base}/change_workspace_id"))) - .json(&json!({ - "new_id": "wm-fork-renamed", - "new_name": "Renamed Fork" - })) - .send() - .await - .unwrap(); - assert_eq!( - resp.status(), - 200, - "change_workspace_id: {}", - resp.text().await? - ); + // verify renamed workspace exists + let resp = authed(client().post(format!("{global_base}/exists"))) + .json(&json!({"id": "wm-fork-renamed"})) + .send() + .await + .unwrap(); + assert_eq!(resp.json::().await?, true); - // verify renamed workspace exists - let resp = authed(client().post(format!("{global_base}/exists"))) - .json(&json!({"id": "wm-fork-renamed"})) - .send() - .await - .unwrap(); - assert_eq!(resp.json::().await?, true); - - // clean up renamed fork - let resp = authed(client().delete(format!("{global_base}/delete/wm-fork-renamed"))) - .send() - .await - .unwrap(); - assert_eq!(resp.status(), 200); + // clean up renamed fork + let resp = authed(client().delete(format!("{global_base}/delete/wm-fork-renamed"))) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + } // --- archive workspace (on the newly created one, not our main test workspace) --- let new_ws_base = format!("http://localhost:{port}/api/w/new-test-ws/workspaces"); @@ -803,3 +804,21 @@ async fn test_get_copilot_info_ignores_empty_instance_ai_row( Ok(()) } + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_get_imports(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/w/test-workspace/workspaces"); + + let resp = authed(client().get(format!("{base}/get_imports/u/test-user/nonexistent_script"))) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + let imports = resp.json::>().await?; + assert!(imports.is_empty()); + + Ok(()) +} diff --git a/backend/windmill-api-jobs/src/concurrency_groups.rs b/backend/windmill-api-jobs/src/concurrency_groups.rs index 33f6045e49..f3ef3b14a8 100644 --- a/backend/windmill-api-jobs/src/concurrency_groups.rs +++ b/backend/windmill-api-jobs/src/concurrency_groups.rs @@ -25,8 +25,8 @@ use uuid::Uuid; pub fn global_service() -> Router { Router::new() .route("/list", get(list_concurrency_groups)) - .route("/prune/*concurrency_key", delete(prune_concurrency_group)) - .route("/:job_id/key", get(get_concurrency_key)) + .route("/prune/{*concurrency_key}", delete(prune_concurrency_group)) + .route("/{job_id}/key", get(get_concurrency_key)) } pub fn workspaced_service() -> Router { diff --git a/backend/windmill-api-jobs/src/job_metrics.rs b/backend/windmill-api-jobs/src/job_metrics.rs index ad316150c0..59cb23deff 100644 --- a/backend/windmill-api-jobs/src/job_metrics.rs +++ b/backend/windmill-api-jobs/src/job_metrics.rs @@ -22,13 +22,13 @@ pub fn workspaced_service() -> Router { .allow_origin(Any); Router::new() - .route("/get/:id", post(get_job_metrics).layer(cors.clone())) + .route("/get/{id}", post(get_job_metrics).layer(cors.clone())) .route( - "/set_progress/:id", + "/set_progress/{id}", post(set_job_progress).layer(cors.clone()), ) .route( - "/get_progress/:id", + "/get_progress/{id}", get(get_job_progress).layer(cors.clone()), ) } diff --git a/backend/windmill-api-jobs/src/types.rs b/backend/windmill-api-jobs/src/types.rs index c59a54bd1c..f040e6eeb7 100644 --- a/backend/windmill-api-jobs/src/types.rs +++ b/backend/windmill-api-jobs/src/types.rs @@ -511,18 +511,14 @@ pub struct ResumeUrls { pub struct QueryOrBody(pub Option); -#[axum::async_trait] -impl FromRequest for QueryOrBody +impl FromRequest for QueryOrBody where D: DeserializeOwned, S: Send + Sync, { type Rejection = Response; - async fn from_request( - req: Request, - state: &S, - ) -> std::result::Result { + async fn from_request(req: Request, state: &S) -> std::result::Result { return if req.method() == axum::http::Method::GET { let Query(InPayload { payload }) = Query::from_request(req, state) .await diff --git a/backend/windmill-api-npm-proxy/src/lib.rs b/backend/windmill-api-npm-proxy/src/lib.rs index 903c2fb33c..25a3dd22d8 100644 --- a/backend/windmill-api-npm-proxy/src/lib.rs +++ b/backend/windmill-api-npm-proxy/src/lib.rs @@ -119,10 +119,10 @@ struct FileEntry { pub fn workspaced_service() -> Router { Router::new() // Use wildcards for package names to support scoped packages like @scope/package - .route("/metadata/*package", get(get_package_metadata)) - .route("/resolve/*package", get(resolve_package_version)) - .route("/filetree/*package_version", get(get_package_filetree)) - .route("/file/*package_version_filepath", get(get_package_file)) + .route("/metadata/{*package}", get(get_package_metadata)) + .route("/resolve/{*package}", get(resolve_package_version)) + .route("/filetree/{*package_version}", get(get_package_filetree)) + .route("/file/{*package_version_filepath}", get(get_package_file)) .layer( CorsLayer::new() .allow_origin(Any) diff --git a/backend/windmill-api-schedule/src/lib.rs b/backend/windmill-api-schedule/src/lib.rs index fc12ccc043..c29d0c0ac2 100644 --- a/backend/windmill-api-schedule/src/lib.rs +++ b/backend/windmill-api-schedule/src/lib.rs @@ -56,12 +56,12 @@ pub fn workspaced_service() -> Router { Router::new() .route("/list", get(list_schedule)) .route("/list_with_jobs", get(list_schedule_with_jobs)) - .route("/get/*path", get(get_schedule)) - .route("/exists/*path", get(exists_schedule)) + .route("/get/{*path}", get(get_schedule)) + .route("/exists/{*path}", get(exists_schedule)) .route("/create", post(create_schedule)) - .route("/update/*path", post(edit_schedule)) - .route("/delete/*path", delete(delete_schedule)) - .route("/setenabled/*path", post(set_enabled)) + .route("/update/{*path}", post(edit_schedule)) + .route("/delete/{*path}", delete(delete_schedule)) + .route("/setenabled/{*path}", post(set_enabled)) .route("/setdefaulthandler", post(set_default_error_handler)) // .route("/catchup/*path", post(do_catchup).get(list_catchup)) } diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index e5b66f5c28..5e3c3eb6df 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -190,18 +190,18 @@ impl ScriptWDraft { pub fn global_service() -> Router { Router::new() .route("/hub/top", get(get_top_hub_scripts)) - .route("/hub/get/*path", get(get_hub_script_by_path)) - .route("/hub/get_full/*path", get(get_full_hub_script_by_path)) - .route("/hub/pick/*path", get(pick_hub_script_by_path)) + .route("/hub/get/{*path}", get(get_hub_script_by_path)) + .route("/hub/get_full/{*path}", get(get_full_hub_script_by_path)) + .route("/hub/pick/{*path}", get(pick_hub_script_by_path)) } pub fn global_unauthed_service() -> Router { Router::new() .route( - "/tokened_raw/:workspace/:token/*path", + "/tokened_raw/{workspace}/{token}/{*path}", get(get_tokened_raw_script_by_path), ) - .route("/empty_ts/*path", get(get_empty_ts_script_by_path)) + .route("/empty_ts/{*path}", get(get_empty_ts_script_by_path)) } pub fn workspaced_service() -> Router { @@ -210,33 +210,33 @@ pub fn workspaced_service() -> Router { .route("/list_search", get(list_search_scripts)) .route("/create", post(create_script)) .route("/create_snapshot", post(create_snapshot_script)) - .route("/archive/p/*path", post(archive_script_by_path)) - .route("/get/draft/*path", get(get_script_by_path_w_draft)) - .route("/get/p/*path", get(get_script_by_path)) - .route("/list_tokens/*path", get(list_tokens)) - .route("/raw/p/*path", get(raw_script_by_path)) - .route("/raw_unpinned/p/*path", get(raw_script_by_path_unpinned)) - .route("/exists/p/*path", get(exists_script_by_path)) - .route("/archive/h/:hash", post(archive_script_by_hash)) - .route("/delete/h/:hash", post(delete_script_by_hash)) - .route("/delete/p/*path", post(delete_script_by_path)) + .route("/archive/p/{*path}", post(archive_script_by_path)) + .route("/get/draft/{*path}", get(get_script_by_path_w_draft)) + .route("/get/p/{*path}", get(get_script_by_path)) + .route("/list_tokens/{*path}", get(list_tokens)) + .route("/raw/p/{*path}", get(raw_script_by_path)) + .route("/raw_unpinned/p/{*path}", get(raw_script_by_path_unpinned)) + .route("/exists/p/{*path}", get(exists_script_by_path)) + .route("/archive/h/{hash}", post(archive_script_by_hash)) + .route("/delete/h/{hash}", post(delete_script_by_hash)) + .route("/delete/p/{*path}", post(delete_script_by_path)) .route("/delete_bulk", delete(delete_scripts_bulk)) - .route("/get/h/:hash", get(get_script_by_hash)) - .route("/raw/h/:hash", get(raw_script_by_hash)) - .route("/deployment_status/h/:hash", get(get_deployment_status)) + .route("/get/h/{hash}", get(get_script_by_hash)) + .route("/raw/h/{hash}", get(raw_script_by_hash)) + .route("/deployment_status/h/{hash}", get(get_deployment_status)) .route("/list_paths", get(list_paths)) .route( - "/toggle_workspace_error_handler/p/*path", + "/toggle_workspace_error_handler/p/{*path}", post(toggle_workspace_error_handler), ) - .route("/history/p/*path", get(get_script_history)) - .route("/get_latest_version/*path", get(get_latest_version)) + .route("/history/p/{*path}", get(get_script_history)) + .route("/get_latest_version/{*path}", get(get_latest_version)) .route( - "/list_paths_from_workspace_runnable/*path", + "/list_paths_from_workspace_runnable/{*path}", get(list_paths_from_workspace_runnable), ) .route( - "/history_update/h/:hash/p/*path", + "/history_update/h/{hash}/p/{*path}", post(update_script_history), ) .route("/list_dedicated_with_deps", get(list_dedicated_with_deps)) diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs index a1ea80f173..17954b1f11 100644 --- a/backend/windmill-api-settings/src/lib.rs +++ b/backend/windmill-api-settings/src/lib.rs @@ -58,7 +58,7 @@ pub fn global_service() -> Router { let r = Router::new() .route("/envs", get(get_local_settings)) .route( - "/global/:key", + "/global/{key}", post(set_global_setting).get(get_global_setting), ) .route("/list_global", get(list_global_settings)) @@ -80,7 +80,7 @@ pub fn global_service() -> Router { .route("/test_critical_channels", post(test_critical_channels)) .route("/critical_alerts", get(get_critical_alerts)) .route( - "/critical_alerts/:id/acknowledge", + "/critical_alerts/{id}/acknowledge", post(acknowledge_critical_alert), ) .route( @@ -92,7 +92,7 @@ pub fn global_service() -> Router { post(refresh_custom_instance_user_pwd), ) .route( - "/setup_custom_instance_pg_database/:name", + "/setup_custom_instance_pg_database/{name}", post(setup_custom_instance_pg_database), ) .route( diff --git a/backend/windmill-api-users/src/users.rs b/backend/windmill-api-users/src/users.rs index 8790a9c541..b3b32859c7 100644 --- a/backend/windmill-api-users/src/users.rs +++ b/backend/windmill-api-users/src/users.rs @@ -66,32 +66,32 @@ pub fn workspaced_service() -> Router { .route("/list_usage", get(list_user_usage)) .route("/list_usernames", get(list_usernames)) .route("/exists", post(exists_username)) - .route("/get/:user", get(get_workspace_user)) - .route("/update/:user", post(update_workspace_user)) - .route("/delete/:user", delete(delete_workspace_user)) - .route("/convert_to_group/:user", post(convert_user_to_group)) - .route("/is_owner/*path", get(is_owner_of_path)) - .route("/whois/:username", get(whois)) + .route("/get/{user}", get(get_workspace_user)) + .route("/update/{user}", post(update_workspace_user)) + .route("/delete/{user}", delete(delete_workspace_user)) + .route("/convert_to_group/{user}", post(convert_user_to_group)) + .route("/is_owner/{*path}", get(is_owner_of_path)) + .route("/whois/{username}", get(whois)) .route("/whoami", get(whoami)) .route("/leave", post(leave_workspace)) - .route("/username_to_email/:username", get(username_to_email)) + .route("/username_to_email/{username}", get(username_to_email)) } pub fn global_service() -> Router { Router::new() - .route("/exists/:email", get(exists_email)) + .route("/exists/{email}", get(exists_email)) .route("/email", get(get_email)) .route("/whoami", get(global_whoami)) .route("/list_invites", get(list_invites)) .route("/decline_invite", post(decline_invite)) .route("/accept_invite", post(accept_invite)) .route("/list_as_super_admin", get(list_users_as_super_admin)) - .route("/set_login_type/:user", post(set_login_type)) - .route("/update/:user", post(update_user)) - .route("/delete/:user", delete(delete_user)) - .route("/username_info/:user", get(get_instance_username_info)) + .route("/set_login_type/{user}", post(set_login_type)) + .route("/update/{user}", post(update_user)) + .route("/delete/{user}", delete(delete_user)) + .route("/username_info/{user}", get(get_instance_username_info)) .route("/tokens/create", post(create_token)) - .route("/tokens/delete/:token_prefix", delete(delete_token)) + .route("/tokens/delete/{token_prefix}", delete(delete_token)) .route("/tokens/list", get(list_tokens)) .route("/tokens/impersonate", post(impersonate)) .route("/usage", get(get_usage)) diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 646a2369b9..fe7991d3a8 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -78,8 +78,8 @@ pub fn workspaced_service() -> Router { .route("/delete_invite", post(delete_invite)) .route("/rebuild_dependency_map", post(rebuild_dependency_map)) .route("/get_dependency_map", get(get_dependency_map)) - .route("/get_dependents/*imported_path", get(get_dependents)) - .route("/get_imports/*importer_path", get(get_imports)) + .route("/get_dependents/{*imported_path}", get(get_dependents)) + .route("/get_imports/{*importer_path}", get(get_imports)) .route("/get_dependents_amounts", post(get_dependents_amounts)) .route("/get_settings", get(get_settings)) .route( @@ -152,14 +152,14 @@ pub fn workspaced_service() -> Router { post(create_workspace_fork_branch), ) .route( - "/reset_diff_tally/:fork_workspace_id", + "/reset_diff_tally/{fork_workspace_id}", post(reset_workspace_diffs), ) - .route("/compare/:target_workspace_id", get(compare_workspaces)) + .route("/compare/{target_workspace_id}", get(compare_workspaces)) .route("/protection_rules", get(list_protection_rules)) .route("/protection_rules", post(create_protection_rule)) .route( - "/protection_rules/:rule_name", + "/protection_rules/{rule_name}", post(update_protection_rule).delete(delete_protection_rule), ) .route("/log_chat", post(log_ai_chat)) @@ -176,9 +176,9 @@ pub fn global_service() -> Router { .route("/exists", post(exists_workspace)) .route("/exists_username", post(exists_username)) .route("/allowed_domain_auto_invite", get(is_allowed_auto_domain)) - .route("/unarchive/:workspace", post(unarchive_workspace)) + .route("/unarchive/{workspace}", post(unarchive_workspace)) .route( - "/delete/:workspace", + "/delete/{workspace}", delete(crate::workspaces_extra::delete_workspace), ) .route( diff --git a/backend/windmill-api/src/ai.rs b/backend/windmill-api/src/ai.rs index d77a0fa8cc..16cfb0166c 100644 --- a/backend/windmill-api/src/ai.rs +++ b/backend/windmill-api/src/ai.rs @@ -607,11 +607,11 @@ fn transform_fim_to_chat_completions(body: &Bytes) -> Result<(Bytes, String)> { } pub fn global_service() -> Router { - Router::new().route("/proxy/*ai", post(global_proxy).get(global_proxy)) + Router::new().route("/proxy/{*ai}", post(global_proxy).get(global_proxy)) } pub fn workspaced_service() -> Router { - let router = Router::new().route("/proxy/*ai", post(proxy).get(proxy)); + let router = Router::new().route("/proxy/{*ai}", post(proxy).get(proxy)); #[cfg(feature = "bedrock")] let router = router.route("/check_bedrock_credentials", get(check_bedrock_credentials)); diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 9ff74387d1..33a68409a0 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -83,48 +83,54 @@ pub fn workspaced_service() -> Router { Router::new() .route("/list", get(list_apps)) .route("/list_search", get(list_search_apps)) - .route("/get/p/*path", get(get_app)) - .route("/get/lite/*path", get(get_app_lite)) - .route("/get/draft/*path", get(get_app_w_draft)) - .route("/secret_of/*path", get(get_secret_id)) + .route("/get/p/{*path}", get(get_app)) + .route("/get/lite/{*path}", get(get_app_lite)) + .route("/get/draft/{*path}", get(get_app_w_draft)) + .route("/secret_of/{*path}", get(get_secret_id)) .route( - "/secret_of_latest_version/*path", + "/secret_of_latest_version/{*path}", get(get_latest_version_secret_id), ) - .route("/get/v/*id", get(get_app_by_id)) - .route("/get_data/v/*id", get(get_raw_app_data)) - .route("/exists/*path", get(exists_app)) - .route("/update/*path", post(update_app)) - .route("/update_raw/*path", post(update_app_raw)) - .route("/delete/*path", delete(delete_app)) + .route("/get/v/{*id}", get(get_app_by_id)) + .route("/get_data/v/{*id}", get(get_raw_app_data)) + .route("/exists/{*path}", get(exists_app)) + .route("/update/{*path}", post(update_app)) + .route("/update_raw/{*path}", post(update_app_raw)) + .route("/delete/{*path}", delete(delete_app)) .route("/create", post(create_app)) .route("/create_raw", post(create_app_raw)) - .route("/history/p/*path", get(get_app_history)) - .route("/get_latest_version/*path", get(get_latest_version)) - .route("/history_update/a/:id/v/:version", post(update_app_history)) + .route("/history/p/{*path}", get(get_app_history)) + .route("/get_latest_version/{*path}", get(get_latest_version)) .route( - "/list_paths_from_workspace_runnable/:runnable_kind/*path", + "/history_update/a/{id}/v/{version}", + post(update_app_history), + ) + .route( + "/list_paths_from_workspace_runnable/{runnable_kind}/{*path}", get(list_paths_from_workspace_runnable), ) - .route("/custom_path_exists/*custom_path", get(custom_path_exists)) + .route( + "/custom_path_exists/{*custom_path}", + get(custom_path_exists), + ) .route("/sign_s3_objects", post(sign_s3_objects)) } pub fn unauthed_service() -> Router { Router::new() - .route("/execute_component/*path", post(execute_component)) - .route("/upload_s3_file/*path", post(upload_s3_file_from_app)) + .route("/execute_component/{*path}", post(execute_component)) + .route("/upload_s3_file/{*path}", post(upload_s3_file_from_app)) .route("/delete_s3_file", delete(delete_s3_file_from_app)) - .route("/download_s3_file/*path", get(download_s3_file_from_app)) - .route("/public_app/:secret", get(get_public_app_by_secret)) - .route("/public_resource/*path", get(get_public_resource)) - .route("/get_data/v/*id", get(get_raw_app_data)) + .route("/download_s3_file/{*path}", get(download_s3_file_from_app)) + .route("/public_app/{secret}", get(get_public_app_by_secret)) + .route("/public_resource/{*path}", get(get_public_resource)) + .route("/get_data/v/{*id}", get(get_raw_app_data)) } pub fn global_service() -> Router { Router::new() .route("/hub/list", get(list_hub_apps)) - .route("/hub/get/:id", get(get_hub_app_by_id)) - .route("/hub/get_raw/:id", get(get_hub_raw_app_by_id)) + .route("/hub/get/{id}", get(get_hub_app_by_id)) + .route("/hub/get_raw/{id}", get(get_hub_raw_app_by_id)) } #[derive(FromRow, Deserialize, Serialize)] diff --git a/backend/windmill-api/src/args.rs b/backend/windmill-api/src/args.rs index 2734be6740..a881fb989d 100644 --- a/backend/windmill-api/src/args.rs +++ b/backend/windmill-api/src/args.rs @@ -451,8 +451,7 @@ where } } -#[axum::async_trait] -impl FromRequest for RawWebhookArgs +impl FromRequest for RawWebhookArgs where S: Send + Sync, { diff --git a/backend/windmill-api/src/audit.rs b/backend/windmill-api/src/audit.rs index 336fd32881..7f81df849c 100644 --- a/backend/windmill-api/src/audit.rs +++ b/backend/windmill-api/src/audit.rs @@ -19,7 +19,7 @@ use crate::db::ApiAuthed; pub fn workspaced_service() -> Router { Router::new() .route("/list", get(list_audit)) - .route("/get/:id", get(get_audit)) + .route("/get/{id}", get(get_audit)) } async fn get_audit( diff --git a/backend/windmill-api/src/capture.rs b/backend/windmill-api/src/capture.rs index 277504b481..c8ddea37d2 100644 --- a/backend/windmill-api/src/capture.rs +++ b/backend/windmill-api/src/capture.rs @@ -93,22 +93,22 @@ pub fn workspaced_service() -> Router { Router::new() .route("/set_config", post(set_config)) .route( - "/ping_config/:trigger_kind/:runnable_kind/*path", + "/ping_config/{trigger_kind}/{runnable_kind}/{*path}", post(ping_config), ) - .route("/get_configs/:runnable_kind/*path", get(get_configs)) - .route("/list/:runnable_kind/*path", get(list_captures)) + .route("/get_configs/{runnable_kind}/{*path}", get(get_configs)) + .route("/list/{runnable_kind}/{*path}", get(list_captures)) .route( - "/move/:runnable_kind/*path", + "/move/{runnable_kind}/{*path}", post(move_captures_and_configs), ) - .route("/:id", delete(delete_capture)) - .route("/:id", get(get_capture)) + .route("/{id}", delete(delete_capture)) + .route("/{id}", get(get_capture)) } pub fn workspaced_unauthed_service() -> Router { let router = Router::new().route( - "/webhook/:runnable_kind/*path", + "/webhook/{runnable_kind}/{*path}", head(|| async {}).post(webhook_payload), ); @@ -118,12 +118,12 @@ pub fn workspaced_unauthed_service() -> Router { ))] { #[cfg(feature = "http_trigger")] - let router = router.route("/http/:runnable_kind/:path/*route_path", { + let router = router.route("/http/{runnable_kind}/{path}/{*route_path}", { head(|| async {}).fallback(http_payload) }); #[cfg(all(feature = "enterprise", feature = "gcp_trigger", feature = "private"))] - let router = router.route("/gcp/:runnable_kind/*path", post(gcp_payload)); + let router = router.route("/gcp/{runnable_kind}/{*path}", post(gcp_payload)); router } diff --git a/backend/windmill-api/src/drafts.rs b/backend/windmill-api/src/drafts.rs index 41e8d4709d..39a68d8f9a 100644 --- a/backend/windmill-api/src/drafts.rs +++ b/backend/windmill-api/src/drafts.rs @@ -23,7 +23,7 @@ use windmill_common::{db::UserDB, error::Result, utils::StripPath}; pub fn workspaced_service() -> Router { Router::new() .route("/create", post(create_draft)) - .route("/delete/:kind/*path", delete(delete_draft)) + .route("/delete/{kind}/{*path}", delete(delete_draft)) } #[derive(sqlx::Type, Serialize, Deserialize, Debug, PartialEq, Clone)] diff --git a/backend/windmill-api/src/flows.rs b/backend/windmill-api/src/flows.rs index c6a09a9e6a..15c9262967 100644 --- a/backend/windmill-api/src/flows.rs +++ b/backend/windmill-api/src/flows.rs @@ -21,7 +21,7 @@ use windmill_common::{error::JsonResult, utils::StripPath, DB}; /// that depends on windmill-api internals. pub fn workspaced_service() -> Router { windmill_api_flows::flows::workspaced_service() - .route("/get_triggers_count/*path", get(get_triggers_count)) + .route("/get_triggers_count/{*path}", get(get_triggers_count)) } async fn get_triggers_count( diff --git a/backend/windmill-api/src/google.rs b/backend/windmill-api/src/google.rs index dd2a47bca3..fb67f18931 100644 --- a/backend/windmill-api/src/google.rs +++ b/backend/windmill-api/src/google.rs @@ -111,17 +111,16 @@ pub async fn handle_google_ai_chat( let (contents, system_instruction) = openai_messages_to_gemini(&request.messages); - let generation_config = - if request.temperature.is_some() || request.max_tokens.is_some() { - Some(GeminiGenerationConfig { - temperature: request.temperature, - max_output_tokens: request.max_tokens, - response_mime_type: None, - response_schema: None, - }) - } else { - None - }; + let generation_config = if request.temperature.is_some() || request.max_tokens.is_some() { + Some(GeminiGenerationConfig { + temperature: request.temperature, + max_output_tokens: request.max_tokens, + response_mime_type: None, + response_schema: None, + }) + } else { + None + }; let gemini_tools = request.tools.as_ref().map(|tools| { let declarations: Vec = tools @@ -136,10 +135,7 @@ pub async fn handle_google_ai_chat( } }) .collect(); - vec![GeminiTool { - function_declarations: Some(declarations), - google_search: None, - }] + vec![GeminiTool { function_declarations: Some(declarations), google_search: None }] }); let gemini_request = GeminiTextRequest { @@ -184,9 +180,10 @@ async fn handle_streaming( .body(request_body); let request = set_auth(request, api_key, is_vertex); - let response = request.send().await.map_err(|e| { - Error::internal_err(format!("Failed to send request to Gemini API: {}", e)) - })?; + let response = request + .send() + .await + .map_err(|e| Error::internal_err(format!("Failed to send request to Gemini API: {}", e)))?; if let Err(e) = response.error_for_status_ref() { let status = e.status().map(|s| s.to_string()).unwrap_or_default(); @@ -273,9 +270,10 @@ pub async fn handle_google_ai_models( let request = HTTP_CLIENT.get(&endpoint); let request = set_auth(request, api_key, is_vertex); - let response = request.send().await.map_err(|e| { - Error::internal_err(format!("Failed to fetch Gemini models: {}", e)) - })?; + let response = request + .send() + .await + .map_err(|e| Error::internal_err(format!("Failed to fetch Gemini models: {}", e)))?; if let Err(e) = response.error_for_status_ref() { let status = e.status().map(|s| s.to_string()).unwrap_or_default(); @@ -327,9 +325,10 @@ async fn handle_non_streaming( .body(request_body); let request = set_auth(request, api_key, is_vertex); - let response = request.send().await.map_err(|e| { - Error::internal_err(format!("Failed to send request to Gemini API: {}", e)) - })?; + let response = request + .send() + .await + .map_err(|e| Error::internal_err(format!("Failed to send request to Gemini API: {}", e)))?; if let Err(e) = response.error_for_status_ref() { let status = e.status().map(|s| s.to_string()).unwrap_or_default(); @@ -337,9 +336,10 @@ async fn handle_non_streaming( return Err(Error::AIError(format!("{}: {}", status, body))); } - let body = response.bytes().await.map_err(|e| { - Error::internal_err(format!("Failed to read Gemini response body: {}", e)) - })?; + let body = response + .bytes() + .await + .map_err(|e| Error::internal_err(format!("Failed to read Gemini response body: {}", e)))?; let parsed = parse_gemini_response(&body)?; let openai_response = gemini_response_to_openai(&parsed, model); diff --git a/backend/windmill-api/src/group_history.rs b/backend/windmill-api/src/group_history.rs index 0c2c84038d..73162345bc 100644 --- a/backend/windmill-api/src/group_history.rs +++ b/backend/windmill-api/src/group_history.rs @@ -22,7 +22,7 @@ use serde::Serialize; use sqlx::FromRow; pub fn workspaced_service() -> Router { - Router::new().route("/get/:name", get(get_group_permission_history)) + Router::new().route("/get/{name}", get(get_group_permission_history)) } #[derive(Serialize, FromRow)] diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index bde3b81a14..461eb9da5d 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -134,14 +134,14 @@ pub fn workspaced_service() -> Router { Router::new() .route( - "/run/f/*script_path", + "/run/f/{*script_path}", post(run_flow_by_path) .head(|| async { "" }) .layer(cors.clone()) .layer(ce_headers.clone()), ) .route( - "/run/fv/:version", + "/run/fv/{version}", post(run_flow_by_version) .head(|| async { "" }) .layer(cors.clone()) @@ -155,25 +155,25 @@ pub fn workspaced_service() -> Router { .layer(ce_headers.clone()), ) .route( - "/run/workflow_as_code/:job_id/:entrypoint", + "/run/workflow_as_code/{job_id}/{entrypoint}", post(run_workflow_as_code) .head(|| async { "" }) .layer(cors.clone()) .layer(ce_headers.clone()), ) .route( - "/restart/f/:job_id", + "/restart/f/{job_id}", post(restart_flow).head(|| async { "" }).layer(cors.clone()), ) .route( - "/run/p/*script_path", + "/run/p/{*script_path}", post(run_script_by_path) .head(|| async { "" }) .layer(cors.clone()) .layer(ce_headers.clone()), ) .route( - "/run_wait_result/p/*script_path", + "/run_wait_result/p/{*script_path}", post(run_wait_result_script_by_path) .get(run_wait_result_job_by_path_get) .head(|| async { "" }) @@ -181,14 +181,14 @@ pub fn workspaced_service() -> Router { .layer(ce_headers.clone()), ) .route( - "/run_wait_result/h/:hash", + "/run_wait_result/h/{hash}", post(run_wait_result_script_by_hash) .head(|| async { "" }) .layer(cors.clone()) .layer(ce_headers.clone()), ) .route( - "/run_wait_result/f/*script_path", + "/run_wait_result/f/{*script_path}", post(run_wait_result_flow_by_path) .get(run_wait_result_flow_by_path_get) .head(|| async { "" }) @@ -196,7 +196,7 @@ pub fn workspaced_service() -> Router { .layer(ce_headers.clone()), ) .route( - "/run_wait_result/fv/:version", + "/run_wait_result/fv/{version}", post(run_wait_result_flow_by_version) .get(run_wait_result_flow_by_version_get) .head(|| async { "" }) @@ -204,7 +204,7 @@ pub fn workspaced_service() -> Router { .layer(ce_headers.clone()), ) .route( - "/run_and_stream/f/*script_path", + "/run_and_stream/f/{*script_path}", get(stream_flow_by_path) .post(stream_flow_by_path) .head(|| async { "" }) @@ -212,7 +212,7 @@ pub fn workspaced_service() -> Router { .layer(ce_headers.clone()), ) .route( - "/run_and_stream/fv/:version", + "/run_and_stream/fv/{version}", get(stream_flow_by_version) .post(stream_flow_by_version) .head(|| async { "" }) @@ -220,7 +220,7 @@ pub fn workspaced_service() -> Router { .layer(ce_headers.clone()), ) .route( - "/run_and_stream/p/*script_path", + "/run_and_stream/p/{*script_path}", get(stream_script_by_path) .post(stream_script_by_path) .head(|| async { "" }) @@ -228,7 +228,7 @@ pub fn workspaced_service() -> Router { .layer(ce_headers.clone()), ) .route( - "/run_and_stream/h/:hash", + "/run_and_stream/h/{hash}", get(stream_script_by_hash) .post(stream_script_by_hash) .head(|| async { "" }) @@ -236,7 +236,7 @@ pub fn workspaced_service() -> Router { .layer(ce_headers.clone()), ) .route( - "/run/h/:hash", + "/run/h/{hash}", post(run_job_by_hash) .head(|| async { "" }) .layer(cors.clone()) @@ -245,10 +245,10 @@ pub fn workspaced_service() -> Router { .route("/run/preview", post(run_preview_script)) .route("/run_inline/preview", post(run_inline_preview_script)) .route( - "/run_inline/p/*script_path", + "/run_inline/p/{*script_path}", post(run_inline_script_by_path), ) - .route("/run_inline/h/:hash", post(run_inline_script_by_hash)) + .route("/run_inline/h/{hash}", post(run_inline_script_by_hash)) .route( "/run_wait_result/preview", post(run_wait_result_preview_script), @@ -257,7 +257,7 @@ pub fn workspaced_service() -> Router { "/run/preview_bundle", post(run_bundle_preview_script).layer(axum::extract::DefaultBodyLimit::disable()), ) - .route("/add_batch_jobs/:n", post(add_batch_jobs)) + .route("/add_batch_jobs/{n}", post(add_batch_jobs)) .route("/run/preview_flow", post(run_preview_flow_job)) .route( "/run_wait_result/preview_flow", @@ -280,8 +280,8 @@ pub fn workspaced_service() -> Router { ) .route("/queue/count", get(count_queue_jobs)) .route("/queue/list_filtered_uuids", get(list_filtered_uuids)) - .route("/queue/position/:timestamp", get(get_queue_position)) - .route("/queue/scheduled_for/:id", get(get_scheduled_for)) + .route("/queue/position/{timestamp}", get(get_queue_position)) + .route("/queue/scheduled_for/{id}", get(get_scheduled_for)) .route("/queue/cancel_selection", post(cancel_selection)) .route("/completed/count", get(count_completed_jobs)) .route("/completed/count_jobs", get(count_completed_jobs_detail)) @@ -299,49 +299,49 @@ pub fn workspaced_service() -> Router { ) .route("/delete", post(crate::jobs_export::delete_jobs)) .route( - "/completed/get/:id", + "/completed/get/{id}", get(get_completed_job).layer(cors.clone()), ) .route( - "/completed/get_result/:id", + "/completed/get_result/{id}", get(get_completed_job_result).layer(cors.clone()), ) .route( - "/completed/get_result_maybe/:id", + "/completed/get_result_maybe/{id}", get(get_completed_job_result_maybe).layer(cors.clone()), ) .route( - "/completed/get_timing/:id", + "/completed/get_timing/{id}", get(get_completed_job_timing).layer(cors.clone()), ) .route( - "/completed/delete/:id", + "/completed/delete/{id}", post(delete_completed_job).layer(cors.clone()), ) .route( - "/flow/resume/:id", + "/flow/resume/{id}", post(resume_suspended_flow_as_owner).layer(cors.clone()), ) .route( - "/job_signature/:job_id/:resume_id", + "/job_signature/{job_id}/{resume_id}", get(create_job_signature).layer(cors.clone()), ) .route( - "/flow/user_states/:job_id/:key", + "/flow/user_states/{job_id}/{key}", get(get_flow_user_state) .post(set_flow_user_state) .layer(cors.clone()), ) .route( - "/resume_urls/:job_id/:resume_id", + "/resume_urls/{job_id}/{resume_id}", get(get_resume_urls).layer(cors.clone()), ) .route( - "/result_by_id/:job_id/:node_id", + "/result_by_id/{job_id}/{node_id}", get(get_result_by_id).layer(cors.clone()), ) .route( - "/flow_env_by_flow_job_id/:flow_job_id/:var_name", + "/flow_env_by_flow_job_id/{flow_job_id}/{var_name}", get(get_flow_env_by_flow_job_id).layer(cors.clone()), ) .route("/run/dependencies", post(run_dependencies_job)) @@ -350,59 +350,59 @@ pub fn workspaced_service() -> Router { "/send_email_with_instance_smtp", post(send_email_with_instance_smtp), ) - .route("/get_otel_traces/:id", get(get_otel_traces)) + .route("/get_otel_traces/{id}", get(get_otel_traces)) } pub fn workspace_unauthed_service() -> Router { Router::new() .route( - "/resume/:job_id/:resume_id/:secret", + "/resume/{job_id}/{resume_id}/{secret}", get(resume_suspended_job), ) .route( - "/resume/:job_id/:resume_id/:secret", + "/resume/{job_id}/{resume_id}/{secret}", post(resume_suspended_job), ) .route( - "/cancel/:job_id/:resume_id/:secret", + "/cancel/{job_id}/{resume_id}/{secret}", get(cancel_suspended_job), ) .route( - "/cancel/:job_id/:resume_id/:secret", + "/cancel/{job_id}/{resume_id}/{secret}", post(cancel_suspended_job), ) .route( - "/get_flow/:job_id/:resume_id/:secret", + "/get_flow/{job_id}/{resume_id}/{secret}", get(get_suspended_job_flow), ) - .route("/get_root_job_id/:id", get(get_root_job)) - .route("/get/:id", get(get_job)) - .route("/get_logs/:id", get(get_job_logs)) + .route("/get_root_job_id/{id}", get(get_root_job)) + .route("/get/{id}", get(get_job)) + .route("/get_logs/{id}", get(get_job_logs)) .route( - "/get_completed_logs_tail/:id", + "/get_completed_logs_tail/{id}", get(get_completed_job_logs_tail), ) - .route("/get_args/:id", get(get_args)) + .route("/get_args/{id}", get(get_args)) .route("/queue/get_started_at_by_ids", post(get_started_at_by_ids)) - .route("/get_flow_debug_info/:id", get(get_flow_job_debug_info)) - .route("/completed/get/:id", get(get_completed_job)) - .route("/completed/get_result/:id", get(get_completed_job_result)) + .route("/get_flow_debug_info/{id}", get(get_flow_job_debug_info)) + .route("/completed/get/{id}", get(get_completed_job)) + .route("/completed/get_result/{id}", get(get_completed_job_result)) .route( - "/completed/get_result_maybe/:id", + "/completed/get_result_maybe/{id}", get(get_completed_job_result_maybe), ) - .route("/completed/get_timing/:id", get(get_completed_job_timing)) - .route("/getupdate/:id", get(get_job_update)) - .route("/getupdate_sse/:id", get(get_job_update_sse)) - .route("/get_log_file/*file_path", get(get_log_file)) - .route("/queue/cancel/:id", post(cancel_job_api)) + .route("/completed/get_timing/{id}", get(get_completed_job_timing)) + .route("/getupdate/{id}", get(get_job_update)) + .route("/getupdate_sse/{id}", get(get_job_update_sse)) + .route("/get_log_file/{*file_path}", get(get_log_file)) + .route("/queue/cancel/{id}", post(cancel_job_api)) .route( - "/queue/cancel_persistent/*script_path", + "/queue/cancel_persistent/{*script_path}", post(cancel_persistent_script_api), ) - .route("/queue/force_cancel/:id", post(force_cancel)) - .route("/flow/resume_suspended/:job_id", post(resume_suspended)) - .route("/flow/approval_info/:job_id", get(get_approval_info)) + .route("/queue/force_cancel/{id}", post(force_cancel)) + .route("/flow/resume_suspended/{job_id}", post(resume_suspended)) + .route("/flow/approval_info/{job_id}", get(get_approval_info)) } pub fn global_root_service() -> Router { diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index 15a674ea37..450713e614 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -37,6 +37,7 @@ use axum::body::Body; use axum::extract::DefaultBodyLimit; use axum::http::HeaderValue; use axum::response::Response; +use axum::serve::ListenerExt; use axum::{middleware::from_extractor, routing::get, routing::post, Extension, Json, Router}; use db::DB; use tokio::task::JoinHandle; @@ -527,7 +528,7 @@ pub async fn run_server( "/api", Router::new() .nest( - "/w/:workspace_id", + "/w/{workspace_id}", Router::new() // Reordered alphabetically .nest("/acls", granular_acls::workspaced_service()) @@ -640,7 +641,7 @@ pub async fn run_server( .nest("/ai", ai::global_service()) .nest("/inkeep", inkeep_oss::global_service()) .nest("/indexer", indexer_oss::management_service()) - .nest("/mcp/w/:workspace_id/list_tools", mcp_list_tools_service) + .nest("/mcp/w/{workspace_id}/list_tools", mcp_list_tools_service) .nest("/health/detailed", health::detailed_service()) .nest( "/saml", @@ -659,7 +660,7 @@ pub async fn run_server( .route_layer(from_extractor::()) // Workspace-scoped OAuth endpoints that don't require authentication // (authorize and token are called by MCP client before user is authenticated) - .nest("/w/:workspace_id/mcp/oauth/server", { + .nest("/w/{workspace_id}/mcp/oauth/server", { #[cfg(feature = "mcp")] { mcp::oauth_server::workspaced_unauthed_service() @@ -680,7 +681,7 @@ pub async fn run_server( }) .nest("/jobs", jobs::global_root_service()) .nest( - "/srch/w/:workspace_id/index", + "/srch/w/{workspace_id}/index", indexer_oss::workspaced_service(), ) .nest("/srch/index", indexer_oss::global_service()) @@ -710,19 +711,19 @@ pub async fn run_server( } }) .nest( - "/w/:workspace_id/apps_u", + "/w/{workspace_id}/apps_u", apps::unauthed_service() .layer(from_extractor::()) .layer(cors.clone()), ) .layer(from_extractor::()) - // Deprecated, here for backwards compatibility: user should use /mcp/w/:workspace_id/mcp instead + // Deprecated, here for backwards compatibility: user should use /mcp/w/{workspace_id}/mcp instead .nest( - "/mcp/w/:workspace_id/sse", + "/mcp/w/{workspace_id}/sse", mcp_router.clone().layer(cors.clone()), ) .nest( - "/mcp/w/:workspace_id/mcp", + "/mcp/w/{workspace_id}/mcp", mcp_router.clone().layer(cors.clone()), ) .nest("/mcp/gateway", gateway_mcp_router.layer(cors.clone())) @@ -745,7 +746,7 @@ pub async fn run_server( Router::new() } }) - .nest("/w/:workspace_id/agent_workers", { + .nest("/w/{workspace_id}/agent_workers", { #[cfg(feature = "agent_worker_server")] { agent_workers_router @@ -762,7 +763,7 @@ pub async fn run_server( } }) .nest( - "/w/:workspace_id/jobs_u", + "/w/{workspace_id}/jobs_u", jobs::workspace_unauthed_service().layer(cors.clone()), ) .route("/slack", post(slack_approvals::slack_app_callback_handler)) @@ -778,14 +779,14 @@ pub async fn run_server( } }) .route( - "/w/:workspace_id/jobs/slack_approval/:job_id", + "/w/{workspace_id}/jobs/slack_approval/{job_id}", get(slack_approvals::request_slack_approval), ) .route( - "/w/:workspace_id/jobs/teams_approval/:job_id", + "/w/{workspace_id}/jobs/teams_approval/{job_id}", get(teams_approvals_oss::request_teams_approval), ) - .nest("/w/:workspace_id/github_app", { + .nest("/w/{workspace_id}/github_app", { #[cfg(feature = "enterprise")] { git_sync_oss::workspaced_service() @@ -804,14 +805,14 @@ pub async fn run_server( Router::new() }) .nest( - "/w/:workspace_id/resources_u", + "/w/{workspace_id}/resources_u", public_service().layer(cors.clone()), ) .nest( - "/w/:workspace_id/capture_u", + "/w/{workspace_id}/capture_u", capture::workspaced_unauthed_service().layer(cors.clone()), ) - .nest("/w/:workspace_id/s3_proxy", { + .nest("/w/{workspace_id}/s3_proxy", { s3_proxy_oss::workspaced_unauthed_service() }) .nest( @@ -856,7 +857,7 @@ pub async fn run_server( Router::new() } }) - .nest("/gcp/w/:workspace_id", { + .nest("/gcp/w/{workspace_id}", { #[cfg(all( feature = "enterprise", feature = "gcp_trigger", @@ -883,10 +884,10 @@ pub async fn run_server( .route("/openapi.json", get(openapi_json)), ) // Clients must use workspace-scoped OAuth metadata at: - // /.well-known/oauth-authorization-server/api/w/:workspace_id/mcp/oauth/server + // /.well-known/oauth-authorization-server/api/w/{workspace_id}/mcp/oauth/server // This is discovered via /.well-known/oauth-protected-resource?workspace_id=... .route( - "/.well-known/oauth-authorization-server/api/w/:workspace_id/mcp/oauth/server", + "/.well-known/oauth-authorization-server/api/w/{workspace_id}/mcp/oauth/server", { #[cfg(feature = "mcp")] { @@ -898,9 +899,9 @@ pub async fn run_server( } }, ) - // RFC 9728 path-based discovery: /.well-known/oauth-protected-resource/api/mcp/w/:workspace_id/mcp + // RFC 9728 path-based discovery: /.well-known/oauth-protected-resource/api/mcp/w/{workspace_id}/mcp .route( - "/.well-known/oauth-protected-resource/api/mcp/w/:workspace_id/mcp", + "/.well-known/oauth-protected-resource/api/mcp/w/{workspace_id}/mcp", { #[cfg(feature = "mcp")] { @@ -976,7 +977,10 @@ pub async fn run_server( if let Some(name) = name.as_ref() { tracing::info!("server starting for name={name}"); } - let server = axum::serve(listener, app.into_make_service()).tcp_nodelay(!server_mode); + let listener = listener.tap_io(move |tcp_stream| { + let _ = tcp_stream.set_nodelay(!server_mode); + }); + let server = axum::serve(listener, app.into_make_service()); tracing::info!( instance = %*INSTANCE_NAME, diff --git a/backend/windmill-api/src/raw_apps.rs b/backend/windmill-api/src/raw_apps.rs index e331aa1176..ed746b8770 100644 --- a/backend/windmill-api/src/raw_apps.rs +++ b/backend/windmill-api/src/raw_apps.rs @@ -27,7 +27,7 @@ use windmill_common::{ pub fn workspaced_service() -> Router { Router::new() .route("/list", get(list_apps)) - .route("/get_data/:version/*path", get(get_data)) + .route("/get_data/{version}/{*path}", get(get_data)) } #[derive(FromRow, Deserialize, Serialize)] diff --git a/backend/windmill-api/src/resources.rs b/backend/windmill-api/src/resources.rs index f4bcfc621b..ee26ce758e 100644 --- a/backend/windmill-api/src/resources.rs +++ b/backend/windmill-api/src/resources.rs @@ -10,7 +10,7 @@ pub fn workspaced_service() -> Router { #[cfg(feature = "mcp")] use crate::mcp_tools::get_mcp_tools; #[cfg(feature = "mcp")] - let router = router.route("/mcp_tools/*path", get(get_mcp_tools)); + let router = router.route("/mcp_tools/{*path}", get(get_mcp_tools)); router } diff --git a/backend/windmill-api/src/scim_oss.rs b/backend/windmill-api/src/scim_oss.rs index 5210411466..845c854960 100644 --- a/backend/windmill-api/src/scim_oss.rs +++ b/backend/windmill-api/src/scim_oss.rs @@ -11,9 +11,7 @@ pub use crate::scim_ee::*; */ #[cfg(not(feature = "private"))] -use axum::{middleware::Next, response::Response, routing::get, Router}; -#[cfg(not(feature = "private"))] -use hyper::Request; +use axum::{extract::Request, middleware::Next, response::Response, routing::get, Router}; #[cfg(not(feature = "private"))] pub fn global_service() -> Router { @@ -26,7 +24,7 @@ pub async fn ee() -> String { } #[cfg(not(feature = "private"))] -pub async fn has_scim_token(_request: Request, _next: Next) -> Response { +pub async fn has_scim_token(_request: Request, _next: Next) -> Response { //Not implemented in open-source version todo!() } diff --git a/backend/windmill-api/src/scripts.rs b/backend/windmill-api/src/scripts.rs index abd0b4201b..f9dd17fa83 100644 --- a/backend/windmill-api/src/scripts.rs +++ b/backend/windmill-api/src/scripts.rs @@ -21,7 +21,7 @@ use windmill_common::{error::JsonResult, utils::StripPath, DB}; /// that depends on windmill-api internals. pub fn workspaced_service() -> Router { windmill_api_scripts::scripts::workspaced_service() - .route("/get_triggers_count/*path", get(get_triggers_count)) + .route("/get_triggers_count/{*path}", get(get_triggers_count)) } async fn get_triggers_count( diff --git a/backend/windmill-api/src/service_logs.rs b/backend/windmill-api/src/service_logs.rs index c83bb21f2c..2e03a5a104 100644 --- a/backend/windmill-api/src/service_logs.rs +++ b/backend/windmill-api/src/service_logs.rs @@ -20,7 +20,7 @@ use crate::db::{ApiAuthed, DB}; pub fn global_service() -> Router { Router::new() .route("/list_files", get(list_files)) - .route("/get_log_file/*path", get(get_log_file)) + .route("/get_log_file/{*path}", get(get_log_file)) } use axum::extract::Path; diff --git a/backend/windmill-api/src/trash.rs b/backend/windmill-api/src/trash.rs index 8e7b7f0ad7..a4bc105238 100644 --- a/backend/windmill-api/src/trash.rs +++ b/backend/windmill-api/src/trash.rs @@ -17,9 +17,9 @@ use crate::db::{ApiAuthed, DB}; pub fn workspaced_service() -> Router { Router::new() .route("/list", get(list_trash)) - .route("/get/:id", get(get_trash_item)) - .route("/restore/:id", post(restore_trash_item)) - .route("/delete/:id", delete(permanently_delete_item)) + .route("/get/{id}", get(get_trash_item)) + .route("/restore/{id}", post(restore_trash_item)) + .route("/delete/{id}", delete(permanently_delete_item)) .route("/empty", post(empty_trash)) } diff --git a/backend/windmill-api/src/triggers/handler.rs b/backend/windmill-api/src/triggers/handler.rs index 78715e2661..f798b4d706 100644 --- a/backend/windmill-api/src/triggers/handler.rs +++ b/backend/windmill-api/src/triggers/handler.rs @@ -108,11 +108,11 @@ pub fn generate_trigger_routers() -> Router { router = router .route( - "/trigger/:trigger_kind/resume_suspended_trigger_jobs/*trigger_path", + "/trigger/{trigger_kind}/resume_suspended_trigger_jobs/{*trigger_path}", post(resume_suspended_trigger_jobs), ) .route( - "/trigger/:trigger_kind/cancel_suspended_trigger_jobs/*trigger_path", + "/trigger/{trigger_kind}/cancel_suspended_trigger_jobs/{*trigger_path}", post(cancel_suspended_trigger_jobs), ); } diff --git a/backend/windmill-api/src/triggers/http/handler.rs b/backend/windmill-api/src/triggers/http/handler.rs index 8af10bda18..ccde55bab1 100644 --- a/backend/windmill-api/src/triggers/http/handler.rs +++ b/backend/windmill-api/src/triggers/http/handler.rs @@ -95,7 +95,7 @@ async fn conditional_cors_middleware( pub fn http_route_trigger_handler() -> Router { Router::new() .route( - "/*path", + "/{*path}", get(route_job) .post(route_job) .delete(route_job) diff --git a/backend/windmill-api/src/triggers/http/http_trigger_args.rs b/backend/windmill-api/src/triggers/http/http_trigger_args.rs index 7df1e9f200..f8460f42e2 100644 --- a/backend/windmill-api/src/triggers/http/http_trigger_args.rs +++ b/backend/windmill-api/src/triggers/http/http_trigger_args.rs @@ -25,8 +25,7 @@ use crate::{ pub struct RawHttpTriggerArgs(pub RawWebhookArgs); -#[axum::async_trait] -impl FromRequest for RawHttpTriggerArgs +impl FromRequest for RawHttpTriggerArgs where S: Send + Sync, { diff --git a/backend/windmill-api/src/users.rs b/backend/windmill-api/src/users.rs index 498e93e61a..aec6080bfd 100644 --- a/backend/windmill-api/src/users.rs +++ b/backend/windmill-api/src/users.rs @@ -35,9 +35,9 @@ use windmill_common::{ pub fn global_service() -> Router { windmill_api_users::users::global_service() .route("/setpassword", post(set_password)) - .route("/set_password_of/:user", post(set_password_of_user)) + .route("/set_password_of/{user}", post(set_password_of_user)) .route("/create", post(create_user)) - .route("/rename/:user", post(rename_user)) + .route("/rename/{user}", post(rename_user)) .route("/onboarding", post(submit_onboarding_data)) } diff --git a/backend/windmill-api/src/workspace_dependencies.rs b/backend/windmill-api/src/workspace_dependencies.rs index e5c9377b01..194cc1f0d5 100644 --- a/backend/windmill-api/src/workspace_dependencies.rs +++ b/backend/windmill-api/src/workspace_dependencies.rs @@ -24,9 +24,9 @@ pub fn workspaced_service() -> Router { Router::new() .route("/create", post(create)) .route("/list", get(list)) - .route("/archive/:language", post(archive)) - .route("/get_latest/:language", get(get_latest)) - .route("/delete/:language", post(delete)) + .route("/archive/{language}", post(archive)) + .route("/get_latest/{language}", get(get_latest)) + .route("/delete/{language}", post(delete)) } #[axum::debug_handler] diff --git a/backend/windmill-api/src/workspaces.rs b/backend/windmill-api/src/workspaces.rs index 3438ad97af..6d644a7946 100644 --- a/backend/windmill-api/src/workspaces.rs +++ b/backend/windmill-api/src/workspaces.rs @@ -63,7 +63,7 @@ pub fn workspaced_service() -> Router { .route("/get_copilot_info", get(get_copilot_info)) .route("/critical_alerts", get(get_critical_alerts)) .route( - "/critical_alerts/:id/acknowledge", + "/critical_alerts/{id}/acknowledge", post(acknowledge_critical_alert), ) .route( diff --git a/backend/windmill-native-triggers/src/handler.rs b/backend/windmill-native-triggers/src/handler.rs index cd6e1a8100..60eb006a97 100644 --- a/backend/windmill-native-triggers/src/handler.rs +++ b/backend/windmill-native-triggers/src/handler.rs @@ -513,13 +513,13 @@ pub fn service_routes(handler: T) -> Router { let standard_routes = Router::new() .route("/create", post(create_native_trigger::)) .route("/list", get(list_native_triggers_handler::)) - .route("/get/:external_id", get(get_native_trigger_handler::)) + .route("/get/{external_id}", get(get_native_trigger_handler::)) .route( - "/update/:external_id", + "/update/{external_id}", post(update_native_trigger_handler::), ) .route( - "/delete/:external_id", + "/delete/{external_id}", delete(delete_native_trigger_handler::), ); diff --git a/backend/windmill-native-triggers/src/workspace_integrations.rs b/backend/windmill-native-triggers/src/workspace_integrations.rs index 87d40d5b05..9453f033ab 100644 --- a/backend/windmill-native-triggers/src/workspace_integrations.rs +++ b/backend/windmill-native-triggers/src/workspace_integrations.rs @@ -964,22 +964,22 @@ async fn generate_instance_connect_url( pub fn workspaced_service() -> Router { let router = Router::new() .route("/list", get(list_integrations)) - .route("/:service_name/exists", get(integration_exist)) - .route("/:service_name/create", post(create_workspace_integration)) + .route("/{service_name}/exists", get(integration_exist)) + .route("/{service_name}/create", post(create_workspace_integration)) .route( - "/:service_name/generate_connect_url", + "/{service_name}/generate_connect_url", post(generate_connect_url), ) .route( - "/:service_name/instance_sharing_available", + "/{service_name}/instance_sharing_available", get(check_instance_sharing_available), ) .route( - "/:service_name/generate_instance_connect_url", + "/{service_name}/generate_instance_connect_url", post(generate_instance_connect_url), ) - .route("/:service_name/delete", delete(delete_integration)) - .route("/:service_name/callback", post(oauth_callback)); + .route("/{service_name}/delete", delete(delete_integration)) + .route("/{service_name}/callback", post(oauth_callback)); Router::new().nest("/integrations", router) } diff --git a/backend/windmill-oauth/src/lib.rs b/backend/windmill-oauth/src/lib.rs index d614837237..eff53be481 100644 --- a/backend/windmill-oauth/src/lib.rs +++ b/backend/windmill-oauth/src/lib.rs @@ -516,7 +516,7 @@ pub async fn exchange_code( }; let csrf_state = cookies .get(name) - .map(|x| x.value().to_string()) + .map(|x| x.value_trimmed().to_string()) .unwrap_or("".to_string()); if callback.state != csrf_state { return Err(error::Error::BadRequest("csrf did not match".to_string())); diff --git a/backend/windmill-object-store/src/lib.rs b/backend/windmill-object-store/src/lib.rs index 4caf67f348..e8d33da2e2 100644 --- a/backend/windmill-object-store/src/lib.rs +++ b/backend/windmill-object-store/src/lib.rs @@ -5,13 +5,13 @@ use std::collections::HashMap; use quick_cache::sync::Cache; use windmill_common::error::{self}; +#[cfg(feature = "parquet")] +use async_trait::async_trait; #[cfg(feature = "parquet")] use aws_config::{default_provider::credentials::DefaultCredentialsChain, Region}; #[cfg(feature = "parquet")] use aws_sdk_sts::config::ProvideCredentials; #[cfg(feature = "parquet")] -use axum::async_trait; -#[cfg(feature = "parquet")] use bytes::Bytes; #[cfg(feature = "parquet")] use chrono::{DateTime, Utc}; diff --git a/backend/windmill-store/src/resources.rs b/backend/windmill-store/src/resources.rs index 102bab8585..c7a14187c8 100644 --- a/backend/windmill-store/src/resources.rs +++ b/backend/windmill-store/src/resources.rs @@ -55,26 +55,26 @@ pub fn workspaced_service() -> Router { Router::new() .route("/list", get(list_resources)) .route("/list_search", get(list_search_resources)) - .route("/list_names/:type", get(list_names)) - .route("/get/*path", get(get_resource)) - .route("/exists/*path", get(exists_resource)) - .route("/get_value/*path", get(get_resource_value)) + .route("/list_names/{type}", get(list_names)) + .route("/get/{*path}", get(get_resource)) + .route("/exists/{*path}", get(exists_resource)) + .route("/get_value/{*path}", get(get_resource_value)) .route( - "/get_value_interpolated/*path", + "/get_value_interpolated/{*path}", get(get_resource_value_interpolated), ) - .route("/update/*path", post(update_resource)) - .route("/update_value/*path", post(update_resource_value)) - .route("/delete/*path", delete(delete_resource)) + .route("/update/{*path}", post(update_resource)) + .route("/update_value/{*path}", post(update_resource_value)) + .route("/delete/{*path}", delete(delete_resource)) .route("/delete_bulk", delete(delete_resources_bulk)) .route("/create", post(create_resource)) - .route("/git_commit_hash/*path", get(get_git_commit_hash)) + .route("/git_commit_hash/{*path}", get(get_git_commit_hash)) .route("/type/list", get(list_resource_types)) .route("/type/listnames", get(list_resource_types_names)) - .route("/type/get/:name", get(get_resource_type)) - .route("/type/exists/:name", get(exists_resource_type)) - .route("/type/update/:name", post(update_resource_type)) - .route("/type/delete/:name", delete(delete_resource_type)) + .route("/type/get/{name}", get(get_resource_type)) + .route("/type/exists/{name}", get(exists_resource_type)) + .route("/type/update/{name}", post(update_resource_type)) + .route("/type/delete/{name}", delete(delete_resource_type)) .route( "/file_resource_type_to_file_ext_map", get(file_resource_ext_to_resource_type), @@ -83,7 +83,7 @@ pub fn workspaced_service() -> Router { } pub fn public_service() -> Router { - Router::new().route("/custom_component/:name", get(custom_component)) + Router::new().route("/custom_component/{name}", get(custom_component)) } #[derive(FromRow, Serialize, Deserialize)] diff --git a/backend/windmill-store/src/variables.rs b/backend/windmill-store/src/variables.rs index 964f81809c..c893f61cb0 100644 --- a/backend/windmill-store/src/variables.rs +++ b/backend/windmill-store/src/variables.rs @@ -54,11 +54,11 @@ pub fn workspaced_service() -> Router { Router::new() .route("/list", get(list_variables)) .route("/list_contextual", get(list_contextual_variables)) - .route("/get/*path", get(get_variable)) - .route("/get_value/*path", get(get_value)) - .route("/exists/*path", get(exists_variable)) - .route("/update/*path", post(update_variable)) - .route("/delete/*path", delete(delete_variable)) + .route("/get/{*path}", get(get_variable)) + .route("/get_value/{*path}", get(get_value)) + .route("/exists/{*path}", get(exists_variable)) + .route("/update/{*path}", post(update_variable)) + .route("/delete/{*path}", delete(delete_variable)) .route("/delete_bulk", delete(delete_variables_bulk)) .route("/create", post(create_variable)) .route("/encrypt", post(encrypt_value)) diff --git a/backend/windmill-test-utils/Cargo.toml b/backend/windmill-test-utils/Cargo.toml index d1729a7511..18cf8cde85 100644 --- a/backend/windmill-test-utils/Cargo.toml +++ b/backend/windmill-test-utils/Cargo.toml @@ -35,5 +35,6 @@ tokio.workspace = true uuid.workspace = true chrono.workspace = true axum.workspace = true +async-trait.workspace = true anyhow.workspace = true tracing.workspace = true diff --git a/backend/windmill-test-utils/src/lib.rs b/backend/windmill-test-utils/src/lib.rs index d9e22eaa84..6a15ffa5ae 100644 --- a/backend/windmill-test-utils/src/lib.rs +++ b/backend/windmill-test-utils/src/lib.rs @@ -478,7 +478,7 @@ pub async fn completed_job(uuid: Uuid, db: &Pool) -> CompletedJob { .unwrap() } -#[axum::async_trait(?Send)] +#[async_trait::async_trait(?Send)] pub trait StreamFind: futures::Stream + Unpin + Sized { async fn find(self, item: &Self::Item) -> Option where diff --git a/backend/windmill-trigger-email/src/handler_oss.rs b/backend/windmill-trigger-email/src/handler_oss.rs index 9579bee274..b6cac94cc2 100644 --- a/backend/windmill-trigger-email/src/handler_oss.rs +++ b/backend/windmill-trigger-email/src/handler_oss.rs @@ -8,7 +8,7 @@ pub use super::handler_ee::*; #[cfg(not(feature = "private"))] use { super::EmailTrigger, - axum::async_trait, + async_trait::async_trait, sqlx::PgConnection, windmill_api_auth::ApiAuthed, windmill_common::{ diff --git a/backend/windmill-trigger-gcp/src/handler_oss.rs b/backend/windmill-trigger-gcp/src/handler_oss.rs index b259c87834..5cf0f17c02 100644 --- a/backend/windmill-trigger-gcp/src/handler_oss.rs +++ b/backend/windmill-trigger-gcp/src/handler_oss.rs @@ -5,7 +5,7 @@ pub use super::handler_ee::*; #[cfg(not(feature = "private"))] use { super::GcpTrigger, - axum::async_trait, + async_trait::async_trait, sqlx::PgConnection, windmill_api_auth::ApiAuthed, windmill_common::{ diff --git a/backend/windmill-trigger-http/src/handler.rs b/backend/windmill-trigger-http/src/handler.rs index f6ca4739da..46e278ca5b 100644 --- a/backend/windmill-trigger-http/src/handler.rs +++ b/backend/windmill-trigger-http/src/handler.rs @@ -2,7 +2,8 @@ use super::{ validate_authentication_method, HttpConfig, HttpConfigRequest, HttpMethod, HttpTrigger, RouteExists, ROUTE_PATH_KEY_RE, VALID_ROUTE_PATH_RE, }; -use axum::{async_trait, extract::Path, routing::post, Extension, Json, Router}; +use async_trait::async_trait; +use axum::{extract::Path, routing::post, Extension, Json, Router}; use http::StatusCode; use sqlx::PgConnection; use std::collections::HashSet; diff --git a/backend/windmill-trigger-kafka/src/handler_oss.rs b/backend/windmill-trigger-kafka/src/handler_oss.rs index ace4b87d0a..2e1cb8bfb4 100644 --- a/backend/windmill-trigger-kafka/src/handler_oss.rs +++ b/backend/windmill-trigger-kafka/src/handler_oss.rs @@ -8,7 +8,7 @@ pub use super::handler_ee::*; #[cfg(not(feature = "private"))] use { super::KafkaTrigger, - axum::async_trait, + async_trait::async_trait, sqlx::PgConnection, windmill_api_auth::ApiAuthed, windmill_common::{ diff --git a/backend/windmill-trigger-mqtt/src/handler.rs b/backend/windmill-trigger-mqtt/src/handler.rs index 6edcfbabc3..49fb701241 100644 --- a/backend/windmill-trigger-mqtt/src/handler.rs +++ b/backend/windmill-trigger-mqtt/src/handler.rs @@ -1,4 +1,4 @@ -use axum::async_trait; +use async_trait::async_trait; use itertools::Itertools; use sqlx::{types::Json as SqlxJson, PgConnection}; use windmill_api_auth::ApiAuthed; diff --git a/backend/windmill-trigger-nats/src/handler_oss.rs b/backend/windmill-trigger-nats/src/handler_oss.rs index b00d973621..f322335cb8 100644 --- a/backend/windmill-trigger-nats/src/handler_oss.rs +++ b/backend/windmill-trigger-nats/src/handler_oss.rs @@ -8,7 +8,7 @@ use windmill_trigger::TriggerData; #[cfg(not(feature = "private"))] use { super::NatsTrigger, - axum::async_trait, + async_trait::async_trait, sqlx::PgConnection, windmill_api_auth::ApiAuthed, windmill_common::{ diff --git a/backend/windmill-trigger-postgres/src/handler.rs b/backend/windmill-trigger-postgres/src/handler.rs index cbb149af52..dc2f4776fd 100644 --- a/backend/windmill-trigger-postgres/src/handler.rs +++ b/backend/windmill-trigger-postgres/src/handler.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; +use async_trait::async_trait; use axum::{ - async_trait, extract::Path, routing::{delete, get, post}, Extension, Json, Router, @@ -282,10 +282,10 @@ impl TriggerCrud for PostgresTrigger { fn additional_routes(&self) -> Router { Router::new() - .route("/get_template_script/:id", get(get_template_script)) + .route("/get_template_script/{id}", get(get_template_script)) .route("/create_template_script", post(create_template_script)) .route( - "/is_valid_postgres_configuration/*path", + "/is_valid_postgres_configuration/{*path}", get(is_database_in_logical_level), ) .nest("/publication", publication_service()) @@ -296,25 +296,31 @@ impl TriggerCrud for PostgresTrigger { fn publication_service() -> Router { Router::new() - .route("/get/:publication_name/*path", get(get_publication_info)) - .route("/create/:publication_name/*path", post(create_publication)) - .route("/update/:publication_name/*path", post(alter_publication)) + .route("/get/{publication_name}/{*path}", get(get_publication_info)) .route( - "/delete/:publication_name/*path", + "/create/{publication_name}/{*path}", + post(create_publication), + ) + .route( + "/update/{publication_name}/{*path}", + post(alter_publication), + ) + .route( + "/delete/{publication_name}/{*path}", delete(delete_publication), ) - .route("/list/*path", get(list_database_publication)) + .route("/list/{*path}", get(list_database_publication)) } fn slot_service() -> Router { Router::new() - .route("/list/*path", get(list_slot_name)) - .route("/create/*path", post(create_slot)) - .route("/delete/*path", delete(drop_slot_name)) + .route("/list/{*path}", get(list_slot_name)) + .route("/create/{*path}", post(create_slot)) + .route("/delete/{*path}", delete(drop_slot_name)) } fn postgres_service() -> Router { - Router::new().route("/version/*path", get(get_postgres_version)) + Router::new().route("/version/{*path}", get(get_postgres_version)) } async fn check_if_logical_replication_slot_exist( diff --git a/backend/windmill-trigger-sqs/src/handler_oss.rs b/backend/windmill-trigger-sqs/src/handler_oss.rs index 90396e9994..fc72159e24 100644 --- a/backend/windmill-trigger-sqs/src/handler_oss.rs +++ b/backend/windmill-trigger-sqs/src/handler_oss.rs @@ -5,7 +5,7 @@ pub use super::handler_ee::*; #[cfg(not(feature = "private"))] use { super::SqsTrigger, - axum::async_trait, + async_trait::async_trait, sqlx::PgConnection, windmill_api_auth::ApiAuthed, windmill_common::{ diff --git a/backend/windmill-trigger-websocket/src/handler.rs b/backend/windmill-trigger-websocket/src/handler.rs index fd0080950d..8411bb0246 100644 --- a/backend/windmill-trigger-websocket/src/handler.rs +++ b/backend/windmill-trigger-websocket/src/handler.rs @@ -1,6 +1,6 @@ use std::borrow::Cow; -use axum::async_trait; +use async_trait::async_trait; use itertools::Itertools; use serde_json::value::RawValue; use sqlx::{types::Json as SqlxJson, PgConnection}; diff --git a/backend/windmill-trigger/src/handler.rs b/backend/windmill-trigger/src/handler.rs index 478f20811d..16f095a903 100644 --- a/backend/windmill-trigger/src/handler.rs +++ b/backend/windmill-trigger/src/handler.rs @@ -374,11 +374,11 @@ pub fn trigger_routes() -> Router { let mut router = Router::new() .route("/create", post(create_trigger::)) .route("/list", get(list_triggers::)) - .route("/get/*path", get(get_trigger::)) - .route("/update/*path", post(update_trigger::)) - .route("/delete/*path", delete(delete_trigger::)) - .route("/exists/*path", get(exists_trigger::)) - .route("/setmode/*path", post(set_trigger_mode::)); + .route("/get/{*path}", get(get_trigger::)) + .route("/update/{*path}", post(update_trigger::)) + .route("/delete/{*path}", delete(delete_trigger::)) + .route("/exists/{*path}", get(exists_trigger::)) + .route("/setmode/{*path}", post(set_trigger_mode::)); if T::SUPPORTS_TEST_CONNECTION { router = router.route("/test", post(test_connection::)); From d06b42613f73c4a7b31c990be22b0c97efab2666 Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Fri, 27 Mar 2026 12:35:28 +0100 Subject: [PATCH 010/111] feat(cli): generate commented wmill.yaml and add config reference command (#8546) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: generate commented wmill.yaml template and add config reference command Co-Authored-By: Claude Opus 4.6 (1M context) * fix: add missing options to config reference (promotion, skipBranchValidation, commonSpecificItems) Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: generate YAML template from CONFIG_REFERENCE instead of handwritten string Co-Authored-By: Claude Opus 4.6 (1M context) * fix: preserve YAML comments when binding workspace profile during init Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: simplify to `wmill config` and reorder table columns Co-Authored-By: Claude Opus 4.6 (1M context) * feat: generate JSON Schema for wmill.yaml editor autocomplete and validation Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: remove redundant templateValue fields and make specificItemsSchema data-driven Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: use native JSON Schema types in CONFIG_REFERENCE, strip non-schema keys for generation Eliminates typeToJsonSchema, specificItemsSchema, codebaseItemSchema, branchConfigSchema, and the complex generateJsonSchema body. Each CONFIG_REFERENCE entry is now a JSON Schema property with extra metadata. Schema generation just iterates and strips non-schema keys. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: remove typeLabel and displayType — use schema types directly Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: remove hidden entries, auto-expand nested schemas in reference table Sub-fields (codebases[], gitBranches..*) are now derived from the parent's inline schema instead of being maintained as duplicate hidden entries. Removes 29 entries and the hidden field entirely. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use console.log for JSON output and quote YAML-special branch names Co-Authored-By: Claude Opus 4.6 (1M context) * chore: regenerate system prompts to include new config command Co-Authored-By: Claude Opus 4.6 (1M context) * fix: review feedback + add tests for template, schema, and config reference - Use console.log for --json output (no ANSI escape codes) - Quote branch names with YAML-special characters - Add 28 tests covering template generation, JSON Schema validation, config reference formatting, and CONFIG_REFERENCE integrity Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add generate-schema script and commit wmill.schema.json to repo Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: remove schema.json generation from wmill init Co-Authored-By: Claude Opus 4.6 (1M context) * fix: eliminate read-back cycle, harden yamlKey, fix triple negation Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- cli/generate-schema.ts | 16 + cli/src/commands/config/config.ts | 26 ++ cli/src/commands/init/init.ts | 87 ++-- cli/src/commands/init/template.ts | 395 ++++++++++++++++ cli/src/guidance/skills.ts | 7 + cli/src/main.ts | 3 + cli/test/init_template.test.ts | 244 ++++++++++ cli/wmill.schema.json | 439 ++++++++++++++++++ .../auto-generated/cli/cli-commands.md | 7 + system_prompts/auto-generated/prompts.ts | 7 + .../skills/cli-commands/SKILL.md | 7 + 11 files changed, 1180 insertions(+), 58 deletions(-) create mode 100644 cli/generate-schema.ts create mode 100644 cli/src/commands/config/config.ts create mode 100644 cli/src/commands/init/template.ts create mode 100644 cli/test/init_template.test.ts create mode 100644 cli/wmill.schema.json diff --git a/cli/generate-schema.ts b/cli/generate-schema.ts new file mode 100644 index 0000000000..a5925968d6 --- /dev/null +++ b/cli/generate-schema.ts @@ -0,0 +1,16 @@ +#!/usr/bin/env npx tsx +/** + * Regenerate cli/wmill.schema.json from CONFIG_REFERENCE. + * + * Run after adding or modifying config options in src/commands/init/template.ts: + * npx tsx generate-schema.ts + */ +import { writeFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { generateJsonSchema } from "./src/commands/init/template.ts"; + +const dir = dirname(fileURLToPath(import.meta.url)); +const out = join(dir, "wmill.schema.json"); +writeFileSync(out, JSON.stringify(generateJsonSchema(), null, 2) + "\n"); +console.log(`Wrote ${out}`); diff --git a/cli/src/commands/config/config.ts b/cli/src/commands/config/config.ts new file mode 100644 index 0000000000..500e65a113 --- /dev/null +++ b/cli/src/commands/config/config.ts @@ -0,0 +1,26 @@ +import { Command } from "@cliffy/command"; +import * as log from "../../core/log.ts"; +import { + formatConfigReference, + formatConfigReferenceJson, +} from "../init/template.ts"; + +interface ConfigOptions { + json?: boolean; +} + +async function configAction(opts: ConfigOptions) { + if (opts.json) { + console.log(formatConfigReferenceJson()); + } else { + log.info(formatConfigReference()); + } +} + +const command = new Command() + .name("config") + .description("Show all available wmill.yaml configuration options") + .option("--json", "Output as JSON for programmatic consumption") + .action(configAction as any); + +export default command; diff --git a/cli/src/commands/init/init.ts b/cli/src/commands/init/init.ts index 5883967b77..dcd146b7e4 100644 --- a/cli/src/commands/init/init.ts +++ b/cli/src/commands/init/init.ts @@ -3,13 +3,14 @@ import { colors } from "@cliffy/ansi/colors"; import { Command } from "@cliffy/command"; import { Confirm } from "@cliffy/prompt/confirm"; import * as log from "../../core/log.ts"; -import { stringify as yamlStringify } from "yaml"; +import { type BranchBinding } from "./template.ts"; import { GlobalOptions } from "../../types.ts"; import { readLockfile } from "../../utils/metadata.ts"; import { getActiveWorkspaceOrFallback } from "../workspace/workspace.ts"; import { generateRTNamespace } from "../resource-type/resource-type.ts"; import { SKILLS, SKILL_CONTENT, SCHEMAS, SCHEMA_MAPPINGS } from "../../guidance/skills.ts"; import { generateAgentsMdContent } from "../../guidance/core.ts"; +import { generateCommentedTemplate } from "./template.ts"; /** * Format a YAML schema for inclusion in skill markdown files. @@ -44,59 +45,35 @@ async function initAction(opts: InitOptions) { if (await stat("wmill.yaml").catch(() => null)) { log.error(colors.red("wmill.yaml already exists")); } else { - // Import DEFAULT_SYNC_OPTIONS from conf.ts - const { DEFAULT_SYNC_OPTIONS } = await import("../../core/conf.ts"); - - // Create initial config with defaults - const initialConfig = { ...DEFAULT_SYNC_OPTIONS } as any; - - // Add branch structure + // Detect current git branch for template const { isGitRepository, getCurrentGitBranch } = await import( "../../utils/git.ts" ); + let branchName: string | undefined; + let binding: BranchBinding | undefined; if (isGitRepository()) { - const currentBranch = getCurrentGitBranch(); - if (currentBranch) { - initialConfig.gitBranches = { - [currentBranch]: { overrides: {} }, - }; - } else { - initialConfig.gitBranches = {}; - } - } else { - initialConfig.gitBranches = {}; + branchName = getCurrentGitBranch() ?? undefined; } - initialConfig.nonDottedPaths = true; - await writeFile("wmill.yaml", yamlStringify(initialConfig), "utf-8"); - log.info(colors.green("wmill.yaml created with default settings")); - - // Create lock file - await readLockfile(); - - // Offer to bind workspace profile to current branch - if (isGitRepository()) { + // Determine workspace binding before writing the template + if (isGitRepository() && branchName) { const activeWorkspace = await getActiveWorkspaceOrFallback( opts as GlobalOptions ); - const currentBranch = getCurrentGitBranch(); - if (activeWorkspace && currentBranch) { - // Determine binding behavior based on flags + if (activeWorkspace) { const shouldBind = opts.bindProfile === true; const shouldPrompt = opts.bindProfile === undefined && !!process.stdin.isTTY && !opts.useDefault; - const shouldSkip = opts.bindProfile != true && - (opts.useDefault || !!!process.stdin.isTTY); + (opts.useDefault || !process.stdin.isTTY); if (!shouldSkip) { - // Show workspace info if we're binding or prompting if (shouldBind || shouldPrompt) { log.info( - colors.yellow(`\nCurrent Git branch: ${colors.bold(currentBranch)}`) + colors.yellow(`\nCurrent Git branch: ${colors.bold(branchName)}`) ); log.info( colors.yellow( @@ -118,37 +95,31 @@ async function initAction(opts: InitOptions) { default: true, }))) ) { - // Update the config with workspace binding - const currentConfig = await import("../../core/conf.ts").then((m) => - m.readConfigFile() - ); - if (!currentConfig.gitBranches) { - currentConfig.gitBranches = {}; - } - if (!currentConfig.gitBranches[currentBranch]) { - currentConfig.gitBranches[currentBranch] = { overrides: {} }; - } - log.info( - `binding branch ${currentBranch} to workspace ${activeWorkspace.name} on ${activeWorkspace.remote}` - ); - currentConfig.gitBranches[currentBranch].baseUrl = - activeWorkspace.remote; - currentConfig.gitBranches[currentBranch].workspaceId = - activeWorkspace.workspaceId; - - await writeFile("wmill.yaml", yamlStringify(currentConfig), "utf-8"); - - log.info( - colors.green( - `✓ Bound branch '${currentBranch}' to workspace '${activeWorkspace.name}'` - ) + `binding branch ${branchName} to workspace ${activeWorkspace.name} on ${activeWorkspace.remote}` ); + binding = { + baseUrl: activeWorkspace.remote, + workspaceId: activeWorkspace.workspaceId, + }; } } } } + await writeFile("wmill.yaml", generateCommentedTemplate(branchName, binding), "utf-8"); + log.info(colors.green("wmill.yaml created with default settings")); + if (binding) { + log.info( + colors.green( + `✓ Bound branch '${branchName}' to workspace` + ) + ); + } + + // Create lock file + await readLockfile(); + // Check for backend git-sync settings unless --use-default is specified if (!opts.useDefault) { try { diff --git a/cli/src/commands/init/template.ts b/cli/src/commands/init/template.ts new file mode 100644 index 0000000000..0684b7ca14 --- /dev/null +++ b/cli/src/commands/init/template.ts @@ -0,0 +1,395 @@ +/** + * Configuration option descriptor — each entry IS a JSON Schema property + * with extra metadata for template rendering and reference table display. + * + * To generate the JSON Schema: iterate entries, strip NON_SCHEMA_KEYS, done. + * Sub-fields of complex types (codebases items, gitBranches branch config) + * are defined inline in the parent's schema — no duplicate entries needed. + * The reference table auto-expands nested schemas into rows. + * + * Adding a new option: + * 1. Add an entry to CONFIG_REFERENCE with JSON Schema type fields + description + * 2. Add template rendering hints (section, commented, templateValue, etc.) + * 3. `wmill init` (YAML template), `wmill config` (table), and wmill.schema.json all update automatically + */ +export interface ConfigOption { + // --- JSON Schema fields (kept when generating schema) --- + type: string; + description: string; + enum?: string[]; + items?: Record; + properties?: Record; + additionalProperties?: Record | boolean; + required?: string[]; + + // --- Non-schema metadata (stripped when generating schema) --- + name: string; + default: string; + + // --- Template rendering hints (also stripped) --- + section?: string; + sectionNote?: string; + commented?: boolean; + templateValue?: string; + example?: string; + inlineComment?: string; + groupNote?: string; +} + +/** Keys to strip from ConfigOption entries when generating JSON Schema. */ +const NON_SCHEMA_KEYS = new Set([ + "name", "default", + "section", "sectionNote", "commented", "templateValue", + "example", "inlineComment", "groupNote", +]); + +// Reusable sub-schemas for nested types +const SPECIFIC_ITEMS_SCHEMA = { + type: "object", + description: "Sync only specific items", + properties: { + variables: { type: "array", items: { type: "string" }, description: "Specific variable paths to sync" }, + resources: { type: "array", items: { type: "string" }, description: "Specific resource paths to sync" }, + triggers: { type: "array", items: { type: "string" }, description: "Specific trigger paths to sync" }, + folders: { type: "array", items: { type: "string" }, description: "Specific folder paths to sync" }, + settings: { type: "boolean", description: "Whether to sync settings" }, + }, + additionalProperties: false, +} as const; + +const BRANCH_CONFIG_SCHEMA = { + type: "object", + properties: { + baseUrl: { type: "string", description: "Windmill instance URL for this branch" }, + workspaceId: { type: "string", description: "Workspace ID to sync with for this branch" }, + overrides: { type: "object", description: "Override any top-level sync option for this branch" }, + promotionOverrides: { type: "object", description: "Overrides applied when using --promotion flag" }, + specificItems: SPECIFIC_ITEMS_SCHEMA, + }, + additionalProperties: false, +} as const; + +/** + * All wmill.yaml configuration options — single source of truth. + * Each entry is a JSON Schema property with extra metadata. + */ +export const CONFIG_REFERENCE: ConfigOption[] = [ + // ── Core ────────────────────────────────────────────────────────────── + { name: "defaultTs", type: "string", enum: ["bun", "deno"], default: "bun", description: "Default TypeScript runtime for new scripts" }, + { name: "includes", type: "array", items: { type: "string" }, default: '["f/**"]', description: "Glob patterns for files to include in sync", + templateValue: '\n - "f/**"' }, + { name: "extraIncludes", type: "array", items: { type: "string" }, default: "[]", description: "Additional glob patterns merged with includes (useful in branch overrides)", + commented: true }, + { name: "excludes", type: "array", items: { type: "string" }, default: "[]", description: "Glob patterns for files to exclude from sync" }, + + // ── What to sync ────────────────────────────────────────────────────── + { name: "skipVariables", type: "boolean", default: "false", description: "Skip syncing variables", + section: "What to sync", sectionNote: '"skip" options default to false (synced), "include" options default to false (not synced)' }, + { name: "skipResources", type: "boolean", default: "false", description: "Skip syncing resources" }, + { name: "skipResourceTypes", type: "boolean", default: "false", description: "Skip syncing resource types" }, + { name: "skipSecrets", type: "boolean", default: "true", description: "Skip syncing secrets (true by default for security)", + inlineComment: "true by default — secrets are not synced for security" }, + { name: "skipScripts", type: "boolean", default: "false", description: "Skip syncing scripts" }, + { name: "skipFlows", type: "boolean", default: "false", description: "Skip syncing flows" }, + { name: "skipApps", type: "boolean", default: "false", description: "Skip syncing apps" }, + { name: "skipFolders", type: "boolean", default: "false", description: "Skip syncing folders" }, + { name: "skipWorkspaceDependencies", type: "boolean", default: "false", description: "Skip syncing workspace dependencies" }, + + { name: "includeSchedules", type: "boolean", default: "false", description: "Include schedules in sync", + commented: true, templateValue: "true", groupNote: "Uncomment to include these (excluded by default):" }, + { name: "includeTriggers", type: "boolean", default: "false", description: "Include triggers (http, websocket, kafka, etc.) in sync", + commented: true, templateValue: "true" }, + { name: "includeUsers", type: "boolean", default: "false", description: "Include workspace users in sync", + commented: true, templateValue: "true" }, + { name: "includeGroups", type: "boolean", default: "false", description: "Include workspace groups in sync", + commented: true, templateValue: "true" }, + { name: "includeSettings", type: "boolean", default: "false", description: "Include workspace settings in sync", + commented: true, templateValue: "true" }, + { name: "includeKey", type: "boolean", default: "false", description: "Include encryption key in sync", + commented: true, templateValue: "true" }, + + // ── Sync behavior ───────────────────────────────────────────────────── + { name: "parallel", type: "integer", default: "(unset)", description: "Number of parallel operations during sync", + section: "Sync behavior", commented: true, templateValue: "4" }, + { name: "locksRequired", type: "boolean", default: "false", description: "Require lock files for all scripts", + commented: true, templateValue: "true" }, + { name: "lint", type: "boolean", default: "false", description: "Run linting before push", + commented: true, templateValue: "true" }, + { name: "plainSecrets", type: "boolean", default: "false", description: "Handle secrets as plain text (not recommended)", + commented: true }, + { name: "message", type: "string", default: "(unset)", description: "Default commit message for sync operations", + commented: true, templateValue: '"my commit message"' }, + { name: "promotion", type: "string", default: "(unset)", description: "Branch name to use promotion overrides from during sync", + commented: true, templateValue: "staging" }, + { name: "skipBranchValidation", type: "boolean", default: "false", description: "Skip validation that current git branch matches a configured branch", + commented: true }, + { name: "nonDottedPaths", type: "boolean", default: "true", description: "Use __flow/__app/__raw_app suffixes instead of .flow/.app/.raw_app" }, + + // ── Codebase bundling ───────────────────────────────────────────────── + { name: "codebases", type: "array", default: "[]", description: "Codebase bundling configurations for shared libraries", + items: { + type: "object", + properties: { + relative_path: { type: "string", description: "Path to the codebase directory" }, + includes: { type: "array", items: { type: "string" }, description: "Glob patterns for files to include in bundle" }, + excludes: { type: "array", items: { type: "string" }, description: "Glob patterns for files to exclude from bundle" }, + format: { type: "string", enum: ["cjs", "esm"], description: "Bundle output format" }, + external: { type: "array", items: { type: "string" }, description: "Dependencies to leave unbundled (externals)" }, + assets: { type: "array", items: { type: "object", properties: { from: { type: "string" }, to: { type: "string" } }, required: ["from", "to"] }, description: "Static files to copy into the bundle" }, + customBundler: { type: "string", description: "Path to a custom bundler script (replaces esbuild)" }, + inject: { type: "array", items: { type: "string" }, description: "Files to inject into every entry point" }, + define: { type: "object", additionalProperties: { type: "string" }, description: "Compile-time constant definitions" }, + banner: { type: "object", additionalProperties: { type: "string" }, description: "Text to prepend to output files by type" }, + loader: { type: "object", additionalProperties: { type: "string" }, description: "esbuild loader overrides by extension" }, + }, + required: ["relative_path"], + additionalProperties: false, + }, + section: "Codebase bundling (shared libraries)", + sectionNote: "Bundle TypeScript/JavaScript codebases that scripts import from.\nEach entry is bundled and uploaded so scripts can import shared code.", + example: [ + "# codebases:", + '# - relative_path: ./shared # path to the codebase', + '# includes: ["**/*.ts"] # files to include in bundle', + '# excludes: ["node_modules/**"] # files to exclude', + '# format: esm # bundle format: "cjs" or "esm"', + '# external: ["pg", "axios"] # dependencies to leave unbundled', + "# assets: # static files to copy into bundle", + "# - from: ./static", + "# to: ./dist", + "# # customBundler: ./build.ts # custom bundler script (replaces esbuild)", + '# # inject: ["./polyfills.ts"] # files to inject into every entry point', + "# # define: # compile-time constants", + "# # API_URL: '\"https://api.example.com\"'", + "# # banner: # text prepended to output files", + '# # js: "/* bundled by windmill */"', + "# # loader: # esbuild loader overrides", + '# # ".png": "dataurl"', + ].join("\n"), + }, + + // ── Git branches ────────────────────────────────────────────────────── + { name: "gitBranches", type: "object", default: "{}", description: "Map git branches to workspaces and per-branch sync overrides", + properties: { commonSpecificItems: SPECIFIC_ITEMS_SCHEMA }, + additionalProperties: BRANCH_CONFIG_SCHEMA, + section: "Git branch / environment bindings", + sectionNote: "Map git branches to Windmill workspaces and override settings per branch.\nUse \"environments\" as an alias if you prefer environment-based terminology.", + templateValue: "\n {{BRANCH}}:\n overrides: {}", + example: [ + "{{BASEURL_LINE}}", + "{{WORKSPACE_ID_LINE}}", + " # promotionOverrides: # overrides applied during --promotion", + " # skipSecrets: false", + " # specificItems: # only sync these specific items", + ' # variables: ["f/my_folder/my_var"]', + ' # resources: ["f/my_folder/my_res"]', + ' # triggers: ["f/my_folder/my_trigger"]', + ' # folders: ["my_folder"]', + " # settings: true", + "", + " # Example: staging branch bound to a different workspace", + " # staging:", + " # baseUrl: https://staging.windmill.dev", + " # workspaceId: staging-workspace", + " # overrides:", + " # skipSecrets: false", + " # includeSchedules: true", + "", + " # Items shared across ALL branches", + " # commonSpecificItems:", + ' # variables: ["f/shared/api_key"]', + ' # resources: ["f/shared/db_conn"]', + ' # folders: ["shared"]', + ].join("\n"), + }, + + { name: "environments", type: "object", default: "{}", description: "Alias for gitBranches — use if you prefer environment-based terminology", + properties: { commonSpecificItems: SPECIFIC_ITEMS_SCHEMA }, + additionalProperties: BRANCH_CONFIG_SCHEMA, + commented: true }, +]; + +// ─── Template generator ───────────────────────────────────────────────────── + +export interface BranchBinding { + baseUrl: string; + workspaceId: string; +} + +/** Quote a string for use as a YAML key if it contains special characters. */ +function yamlKey(s: string): string { + if ( + /^[a-zA-Z0-9_/.@-]+$/.test(s) && + !/^(true|false|yes|no|on|off|null|~)$/i.test(s) && + !/^\d+(\.\d+)?$/.test(s) + ) { + return s; + } + return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; +} + +export function generateCommentedTemplate(branchName?: string, binding?: BranchBinding): string { + const branch = yamlKey(branchName ?? "main"); + const lines: string[] = [ + "# yaml-language-server: $schema=wmill.schema.json", + "# wmill.yaml — Windmill CLI configuration", + '# Full reference: run "wmill config"', + "", + ]; + + for (const opt of CONFIG_REFERENCE) { + if (opt.section) { + const ruler = "-".repeat(Math.max(0, 65 - opt.section.length)); + lines.push(`# --- ${opt.section} ${ruler}`); + if (opt.sectionNote) { + for (const noteLine of opt.sectionNote.split("\n")) { + lines.push(`# ${noteLine}`); + } + } + lines.push(""); + } + + if (opt.groupNote) { + lines.push(`# ${opt.groupNote}`); + } + + const value = opt.templateValue ?? opt.default; + const resolvedValue = value.replace("{{BRANCH}}", branch); + + if (opt.commented) { + lines.push(`# ${opt.description}`); + lines.push(`# ${opt.name}: ${resolvedValue}`); + } else { + lines.push(`# ${opt.description}`); + if (opt.inlineComment) { + const base = `${opt.name}: ${resolvedValue}`; + const pad = " ".repeat(Math.max(1, 32 - base.length)); + lines.push(`${base}${pad}# ${opt.inlineComment}`); + } else { + lines.push(`${opt.name}: ${resolvedValue}`); + } + } + + if (opt.example) { + let resolvedExample = opt.example.replace(/\{\{BRANCH\}\}/g, branch); + if (binding) { + resolvedExample = resolvedExample + .replace("{{BASEURL_LINE}}", ` baseUrl: ${binding.baseUrl}`) + .replace("{{WORKSPACE_ID_LINE}}", ` workspaceId: ${binding.workspaceId}`); + } else { + resolvedExample = resolvedExample + .replace("{{BASEURL_LINE}}", " # baseUrl: https://app.windmill.dev # Windmill instance URL for this branch") + .replace("{{WORKSPACE_ID_LINE}}", " # workspaceId: my-workspace # workspace to sync with"); + } + for (const exLine of resolvedExample.split("\n")) { + lines.push(exLine); + } + } + + lines.push(""); + } + + return lines.join("\n"); +} + +// ─── Reference formatters ─────────────────────────────────────────────────── + +/** Recursively expand a schema's properties into flat reference rows. */ +function expandSchema( + prefix: string, + schema: Record, + rows: { name: string; description: string; default: string }[] +): void { + if (schema.properties) { + for (const [key, prop] of Object.entries(schema.properties) as [string, Record][]) { + const name = prefix ? `${prefix}.${key}` : key; + rows.push({ name, description: prop.description ?? "", default: "" }); + // Recurse into nested object properties (e.g., specificItems) + if (prop.properties && prop.type === "object") { + expandSchema(name, prop, rows); + } + } + } +} + +export function formatConfigReference(): string { + const nameWidth = 48; + const descWidth = 70; + + const header = [ + "OPTION".padEnd(nameWidth), + "DESCRIPTION".padEnd(descWidth), + "DEFAULT", + ].join(" "); + + const separator = "-".repeat(header.length + 10); + + const allRows: { name: string; description: string; default: string }[] = []; + for (const opt of CONFIG_REFERENCE) { + allRows.push({ name: opt.name, description: opt.description, default: opt.default }); + + // Auto-expand array item properties (e.g., codebases[].*) + if (opt.items?.properties) { + expandSchema(`${opt.name}[]`, opt.items, allRows); + } + // Auto-expand additionalProperties (e.g., gitBranches..*) + if (opt.additionalProperties && typeof opt.additionalProperties === "object" && opt.additionalProperties.properties) { + expandSchema(`${opt.name}.`, opt.additionalProperties as Record, allRows); + } + // Auto-expand named properties (e.g., gitBranches.commonSpecificItems) + if (opt.properties) { + expandSchema(opt.name, opt, allRows); + } + } + + const rows = allRows.map((r) => + [r.name.padEnd(nameWidth), r.description.padEnd(descWidth), r.default].join(" ") + ); + + return [ + "wmill.yaml — Configuration Reference", + "", + "Full documentation: https://www.windmill.dev/docs/advanced/cli", + "", + separator, + header, + separator, + ...rows, + separator, + "", + 'Run "wmill init" to generate a wmill.yaml with commented examples.', + ].join("\n"); +} + +export function formatConfigReferenceJson(): string { + const clean = CONFIG_REFERENCE.map((opt) => ({ + name: opt.name, type: opt.type, default: opt.default, description: opt.description, + })); + return JSON.stringify(clean, null, 2); +} + +// ─── JSON Schema generator ────────────────────────────────────────────────── + +/** + * Generate a JSON Schema for wmill.yaml by stripping non-schema keys from CONFIG_REFERENCE. + */ +export function generateJsonSchema(): Record { + const properties: Record = {}; + for (const opt of CONFIG_REFERENCE) { + const entry: Record = {}; + for (const [k, v] of Object.entries(opt)) { + if (!NON_SCHEMA_KEYS.has(k) && k !== "name") { + entry[k] = v; + } + } + properties[opt.name] = entry; + } + return { + $schema: "http://json-schema.org/draft-07/schema#", + title: "wmill.yaml", + description: "Windmill CLI configuration file. Full reference: wmill config", + type: "object", + properties, + additionalProperties: false, + }; +} diff --git a/cli/src/guidance/skills.ts b/cli/src/guidance/skills.ts index 81c87dd05a..7da9abd72e 100644 --- a/cli/src/guidance/skills.ts +++ b/cli/src/guidance/skills.ts @@ -4999,6 +4999,13 @@ app related commands - \`--dry-run\` - Perform a dry run without making changes - \`--default-ts \` - Default TypeScript runtime (bun or deno) +### config + +Show all available wmill.yaml configuration options + +**Options:** +- \`--json\` - Output as JSON for programmatic consumption + ### dependencies workspace dependencies related commands diff --git a/cli/src/main.ts b/cli/src/main.ts index 23e921d0b8..5126bc6c92 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -41,6 +41,7 @@ import init from "./commands/init/init.ts"; import jobs from "./commands/jobs/jobs.ts"; import generateMetadata from "./commands/generate-metadata/generate-metadata.ts"; import docs from "./commands/docs/docs.ts"; +import config from "./commands/config/config.ts"; import { fetchVersion } from "./core/context.ts"; export { @@ -62,6 +63,7 @@ export { instance, dev, docs, + config, hubPull, pull, push, @@ -132,6 +134,7 @@ const command = new Command() .command("jobs", jobs) .command("generate-metadata", generateMetadata) .command("docs", docs) + .command("config", config) .command("version --version", "Show version information") .action(async (opts: any) => { console.log("CLI version: " + VERSION); diff --git a/cli/test/init_template.test.ts b/cli/test/init_template.test.ts new file mode 100644 index 0000000000..efb6d09891 --- /dev/null +++ b/cli/test/init_template.test.ts @@ -0,0 +1,244 @@ +/** + * Unit tests for wmill.yaml template generation, config reference, and JSON Schema. + */ + +import { expect, test, describe } from "bun:test"; +import { parse } from "yaml"; +import Ajv from "ajv"; +import { + generateCommentedTemplate, + generateJsonSchema, + formatConfigReference, + formatConfigReferenceJson, + CONFIG_REFERENCE, +} from "../src/commands/init/template.ts"; + +// ============================================================================= +// generateCommentedTemplate +// ============================================================================= + +describe("generateCommentedTemplate", () => { + test("produces valid YAML that parses without errors", () => { + const yaml = generateCommentedTemplate("main"); + const config = parse(yaml); + expect(config).toBeDefined(); + expect(typeof config).toBe("object"); + }); + + test("uses provided branch name in gitBranches", () => { + const config = parse(generateCommentedTemplate("my-feature")); + expect(config.gitBranches["my-feature"]).toBeDefined(); + expect(config.gitBranches["my-feature"].overrides).toEqual({}); + }); + + test("defaults to 'main' when no branch name given", () => { + const config = parse(generateCommentedTemplate()); + expect(config.gitBranches["main"]).toBeDefined(); + }); + + test("quotes branch names with YAML-special characters", () => { + const specialBranches = ["fix: something", "feat/my branch", "release#1"]; + for (const branch of specialBranches) { + const yaml = generateCommentedTemplate(branch); + const config = parse(yaml); + expect(config.gitBranches[branch]).toBeDefined(); + } + }); + + test("contains yaml-language-server schema directive", () => { + const yaml = generateCommentedTemplate("main"); + expect(yaml.startsWith("# yaml-language-server: $schema=wmill.schema.json")).toBe(true); + }); + + test("includes all non-commented CONFIG_REFERENCE entries as active YAML keys", () => { + const config = parse(generateCommentedTemplate("main")); + for (const opt of CONFIG_REFERENCE) { + if (!opt.commented) { + expect(config).toHaveProperty(opt.name); + } + } + }); + + test("does not include commented entries as active YAML keys", () => { + const config = parse(generateCommentedTemplate("main")); + for (const opt of CONFIG_REFERENCE) { + if (opt.commented && opt.name !== "environments") { + expect(config[opt.name]).toBeUndefined(); + } + } + }); + + test("default values match expected defaults", () => { + const config = parse(generateCommentedTemplate("main")); + expect(config.defaultTs).toBe("bun"); + expect(config.skipSecrets).toBe(true); + expect(config.nonDottedPaths).toBe(true); + expect(config.codebases).toEqual([]); + expect(config.excludes).toEqual([]); + expect(config.includes).toEqual(["f/**"]); + }); +}); + +// ============================================================================= +// generateJsonSchema +// ============================================================================= + +describe("generateJsonSchema", () => { + const schema = generateJsonSchema(); + + test("is a valid JSON Schema draft-07", () => { + expect(schema.$schema).toBe("http://json-schema.org/draft-07/schema#"); + expect(schema.type).toBe("object"); + expect(schema.properties).toBeDefined(); + }); + + test("validates the generated YAML template", () => { + const config = parse(generateCommentedTemplate("main")); + const ajv = new Ajv({ allErrors: true }); + const validate = ajv.compile(schema); + expect(validate(config)).toBe(true); + }); + + test("rejects unknown keys", () => { + const ajv = new Ajv({ allErrors: true }); + const validate = ajv.compile(schema); + expect(validate({ unknownOption: true })).toBe(false); + }); + + test("rejects invalid enum values", () => { + const ajv = new Ajv({ allErrors: true }); + const validate = ajv.compile(schema); + expect(validate({ defaultTs: "python" })).toBe(false); + }); + + test("rejects wrong types", () => { + const ajv = new Ajv({ allErrors: true }); + const validate = ajv.compile(schema); + expect(validate({ skipSecrets: "yes" })).toBe(false); + }); + + test("includes codebases array schema with item properties", () => { + expect(schema.properties.codebases.type).toBe("array"); + expect(schema.properties.codebases.items.properties.relative_path).toBeDefined(); + expect(schema.properties.codebases.items.required).toContain("relative_path"); + }); + + test("includes gitBranches with branch config schema", () => { + const branchSchema = schema.properties.gitBranches.additionalProperties; + expect(branchSchema.properties.baseUrl).toBeDefined(); + expect(branchSchema.properties.workspaceId).toBeDefined(); + expect(branchSchema.properties.specificItems).toBeDefined(); + expect(branchSchema.properties.specificItems.properties.variables).toBeDefined(); + }); + + test("includes environments as alias for gitBranches", () => { + expect(schema.properties.environments).toBeDefined(); + expect(schema.properties.environments.additionalProperties).toEqual( + schema.properties.gitBranches.additionalProperties + ); + }); + + test("does not contain template-only keys in schema output", () => { + const templateKeys = ["section", "sectionNote", "commented", "templateValue", "example", "inlineComment", "groupNote"]; + const json = JSON.stringify(schema); + for (const key of templateKeys) { + expect(json).not.toContain(`"${key}"`); + } + }); +}); + +// ============================================================================= +// formatConfigReference +// ============================================================================= + +describe("formatConfigReference", () => { + const output = formatConfigReference(); + + test("includes header row", () => { + expect(output).toContain("OPTION"); + expect(output).toContain("DESCRIPTION"); + expect(output).toContain("DEFAULT"); + }); + + test("includes all top-level CONFIG_REFERENCE entries", () => { + for (const opt of CONFIG_REFERENCE) { + expect(output).toContain(opt.name); + } + }); + + test("auto-expands codebases sub-fields", () => { + expect(output).toContain("codebases[].relative_path"); + expect(output).toContain("codebases[].format"); + expect(output).toContain("codebases[].external"); + }); + + test("auto-expands gitBranches sub-fields", () => { + expect(output).toContain("gitBranches..baseUrl"); + expect(output).toContain("gitBranches..workspaceId"); + expect(output).toContain("gitBranches..specificItems.variables"); + }); + + test("auto-expands commonSpecificItems sub-fields", () => { + expect(output).toContain("gitBranches.commonSpecificItems.variables"); + expect(output).toContain("gitBranches.commonSpecificItems.settings"); + }); +}); + +// ============================================================================= +// formatConfigReferenceJson +// ============================================================================= + +describe("formatConfigReferenceJson", () => { + test("produces valid JSON", () => { + const parsed = JSON.parse(formatConfigReferenceJson()); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed.length).toBe(CONFIG_REFERENCE.length); + }); + + test("each entry has name, type, default, description", () => { + const parsed = JSON.parse(formatConfigReferenceJson()); + for (const entry of parsed) { + expect(entry).toHaveProperty("name"); + expect(entry).toHaveProperty("type"); + expect(entry).toHaveProperty("default"); + expect(entry).toHaveProperty("description"); + } + }); + + test("does not contain template-only keys", () => { + const parsed = JSON.parse(formatConfigReferenceJson()); + const templateKeys = ["section", "sectionNote", "commented", "templateValue", "example", "inlineComment", "groupNote"]; + for (const entry of parsed) { + for (const key of templateKeys) { + expect(entry).not.toHaveProperty(key); + } + } + }); +}); + +// ============================================================================= +// CONFIG_REFERENCE integrity +// ============================================================================= + +describe("CONFIG_REFERENCE integrity", () => { + test("all entries have required fields", () => { + for (const opt of CONFIG_REFERENCE) { + expect(opt.name).toBeTruthy(); + expect(opt.type).toBeTruthy(); + expect(opt.description).toBeTruthy(); + expect(opt.default).toBeDefined(); + } + }); + + test("no duplicate names", () => { + const names = CONFIG_REFERENCE.map((o) => o.name); + expect(new Set(names).size).toBe(names.length); + }); + + test("type field uses valid JSON Schema types", () => { + const validTypes = new Set(["boolean", "string", "integer", "number", "array", "object"]); + for (const opt of CONFIG_REFERENCE) { + expect(validTypes.has(opt.type)).toBe(true); + } + }); +}); diff --git a/cli/wmill.schema.json b/cli/wmill.schema.json new file mode 100644 index 0000000000..7129563f3f --- /dev/null +++ b/cli/wmill.schema.json @@ -0,0 +1,439 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "wmill.yaml", + "description": "Windmill CLI configuration file. Full reference: wmill config", + "type": "object", + "properties": { + "defaultTs": { + "type": "string", + "enum": [ + "bun", + "deno" + ], + "description": "Default TypeScript runtime for new scripts" + }, + "includes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Glob patterns for files to include in sync" + }, + "extraIncludes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Additional glob patterns merged with includes (useful in branch overrides)" + }, + "excludes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Glob patterns for files to exclude from sync" + }, + "skipVariables": { + "type": "boolean", + "description": "Skip syncing variables" + }, + "skipResources": { + "type": "boolean", + "description": "Skip syncing resources" + }, + "skipResourceTypes": { + "type": "boolean", + "description": "Skip syncing resource types" + }, + "skipSecrets": { + "type": "boolean", + "description": "Skip syncing secrets (true by default for security)" + }, + "skipScripts": { + "type": "boolean", + "description": "Skip syncing scripts" + }, + "skipFlows": { + "type": "boolean", + "description": "Skip syncing flows" + }, + "skipApps": { + "type": "boolean", + "description": "Skip syncing apps" + }, + "skipFolders": { + "type": "boolean", + "description": "Skip syncing folders" + }, + "skipWorkspaceDependencies": { + "type": "boolean", + "description": "Skip syncing workspace dependencies" + }, + "includeSchedules": { + "type": "boolean", + "description": "Include schedules in sync" + }, + "includeTriggers": { + "type": "boolean", + "description": "Include triggers (http, websocket, kafka, etc.) in sync" + }, + "includeUsers": { + "type": "boolean", + "description": "Include workspace users in sync" + }, + "includeGroups": { + "type": "boolean", + "description": "Include workspace groups in sync" + }, + "includeSettings": { + "type": "boolean", + "description": "Include workspace settings in sync" + }, + "includeKey": { + "type": "boolean", + "description": "Include encryption key in sync" + }, + "parallel": { + "type": "integer", + "description": "Number of parallel operations during sync" + }, + "locksRequired": { + "type": "boolean", + "description": "Require lock files for all scripts" + }, + "lint": { + "type": "boolean", + "description": "Run linting before push" + }, + "plainSecrets": { + "type": "boolean", + "description": "Handle secrets as plain text (not recommended)" + }, + "message": { + "type": "string", + "description": "Default commit message for sync operations" + }, + "promotion": { + "type": "string", + "description": "Branch name to use promotion overrides from during sync" + }, + "skipBranchValidation": { + "type": "boolean", + "description": "Skip validation that current git branch matches a configured branch" + }, + "nonDottedPaths": { + "type": "boolean", + "description": "Use __flow/__app/__raw_app suffixes instead of .flow/.app/.raw_app" + }, + "codebases": { + "type": "array", + "description": "Codebase bundling configurations for shared libraries", + "items": { + "type": "object", + "properties": { + "relative_path": { + "type": "string", + "description": "Path to the codebase directory" + }, + "includes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Glob patterns for files to include in bundle" + }, + "excludes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Glob patterns for files to exclude from bundle" + }, + "format": { + "type": "string", + "enum": [ + "cjs", + "esm" + ], + "description": "Bundle output format" + }, + "external": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Dependencies to leave unbundled (externals)" + }, + "assets": { + "type": "array", + "items": { + "type": "object", + "properties": { + "from": { + "type": "string" + }, + "to": { + "type": "string" + } + }, + "required": [ + "from", + "to" + ] + }, + "description": "Static files to copy into the bundle" + }, + "customBundler": { + "type": "string", + "description": "Path to a custom bundler script (replaces esbuild)" + }, + "inject": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Files to inject into every entry point" + }, + "define": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Compile-time constant definitions" + }, + "banner": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Text to prepend to output files by type" + }, + "loader": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "esbuild loader overrides by extension" + } + }, + "required": [ + "relative_path" + ], + "additionalProperties": false + } + }, + "gitBranches": { + "type": "object", + "description": "Map git branches to workspaces and per-branch sync overrides", + "properties": { + "commonSpecificItems": { + "type": "object", + "description": "Sync only specific items", + "properties": { + "variables": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specific variable paths to sync" + }, + "resources": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specific resource paths to sync" + }, + "triggers": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specific trigger paths to sync" + }, + "folders": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specific folder paths to sync" + }, + "settings": { + "type": "boolean", + "description": "Whether to sync settings" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": { + "type": "object", + "properties": { + "baseUrl": { + "type": "string", + "description": "Windmill instance URL for this branch" + }, + "workspaceId": { + "type": "string", + "description": "Workspace ID to sync with for this branch" + }, + "overrides": { + "type": "object", + "description": "Override any top-level sync option for this branch" + }, + "promotionOverrides": { + "type": "object", + "description": "Overrides applied when using --promotion flag" + }, + "specificItems": { + "type": "object", + "description": "Sync only specific items", + "properties": { + "variables": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specific variable paths to sync" + }, + "resources": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specific resource paths to sync" + }, + "triggers": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specific trigger paths to sync" + }, + "folders": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specific folder paths to sync" + }, + "settings": { + "type": "boolean", + "description": "Whether to sync settings" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "environments": { + "type": "object", + "description": "Alias for gitBranches — use if you prefer environment-based terminology", + "properties": { + "commonSpecificItems": { + "type": "object", + "description": "Sync only specific items", + "properties": { + "variables": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specific variable paths to sync" + }, + "resources": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specific resource paths to sync" + }, + "triggers": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specific trigger paths to sync" + }, + "folders": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specific folder paths to sync" + }, + "settings": { + "type": "boolean", + "description": "Whether to sync settings" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": { + "type": "object", + "properties": { + "baseUrl": { + "type": "string", + "description": "Windmill instance URL for this branch" + }, + "workspaceId": { + "type": "string", + "description": "Workspace ID to sync with for this branch" + }, + "overrides": { + "type": "object", + "description": "Override any top-level sync option for this branch" + }, + "promotionOverrides": { + "type": "object", + "description": "Overrides applied when using --promotion flag" + }, + "specificItems": { + "type": "object", + "description": "Sync only specific items", + "properties": { + "variables": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specific variable paths to sync" + }, + "resources": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specific resource paths to sync" + }, + "triggers": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specific trigger paths to sync" + }, + "folders": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specific folder paths to sync" + }, + "settings": { + "type": "boolean", + "description": "Whether to sync settings" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false +} diff --git a/system_prompts/auto-generated/cli/cli-commands.md b/system_prompts/auto-generated/cli/cli-commands.md index d76e31ded0..c582a277b4 100644 --- a/system_prompts/auto-generated/cli/cli-commands.md +++ b/system_prompts/auto-generated/cli/cli-commands.md @@ -41,6 +41,13 @@ app related commands - `--dry-run` - Perform a dry run without making changes - `--default-ts ` - Default TypeScript runtime (bun or deno) +### config + +Show all available wmill.yaml configuration options + +**Options:** +- `--json` - Output as JSON for programmatic consumption + ### dependencies workspace dependencies related commands diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index dc47b66eca..106c629021 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -1568,6 +1568,13 @@ app related commands - \`--dry-run\` - Perform a dry run without making changes - \`--default-ts \` - Default TypeScript runtime (bun or deno) +### config + +Show all available wmill.yaml configuration options + +**Options:** +- \`--json\` - Output as JSON for programmatic consumption + ### dependencies workspace dependencies related commands diff --git a/system_prompts/auto-generated/skills/cli-commands/SKILL.md b/system_prompts/auto-generated/skills/cli-commands/SKILL.md index 8a9f231fc2..30c31c4bcb 100644 --- a/system_prompts/auto-generated/skills/cli-commands/SKILL.md +++ b/system_prompts/auto-generated/skills/cli-commands/SKILL.md @@ -46,6 +46,13 @@ app related commands - `--dry-run` - Perform a dry run without making changes - `--default-ts ` - Default TypeScript runtime (bun or deno) +### config + +Show all available wmill.yaml configuration options + +**Options:** +- `--json` - Output as JSON for programmatic consumption + ### dependencies workspace dependencies related commands From e15bfbf91ee1517432a6861ebb48e129485006aa Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 27 Mar 2026 11:38:20 +0000 Subject: [PATCH 011/111] fix: sanitize flow step summaries for filesystem-safe names (#8554) * fix: sanitize flow step summaries for filesystem-safe names Co-Authored-By: Claude Opus 4.5 * chore: bump windmill-utils-internal to 1.3.6 Co-Authored-By: Claude Opus 4.5 * fix: handle Windows reserved device names in flow step sanitization Co-Authored-By: Claude Opus 4.5 * fix: collapse consecutive underscores in sanitized flow step names Co-Authored-By: Claude Opus 4.5 * chore: bump windmill-utils-internal to 1.3.7 Co-Authored-By: Claude Opus 4.5 * bump --------- Co-authored-by: Claude Opus 4.5 --- cli/windmill-utils-internal/package-lock.json | 4 +-- cli/windmill-utils-internal/package.json | 2 +- .../src/config/index.ts | 2 +- cli/windmill-utils-internal/src/index.ts | 10 +++---- .../src/inline-scripts/extractor.ts | 4 +-- .../src/inline-scripts/index.ts | 4 +-- .../src/inline-scripts/replacer.ts | 2 +- .../src/parse/index.ts | 2 +- .../src/path-utils/index.ts | 2 +- .../src/path-utils/path-assigner.ts | 28 +++++++++++++++++-- 10 files changed, 41 insertions(+), 19 deletions(-) diff --git a/cli/windmill-utils-internal/package-lock.json b/cli/windmill-utils-internal/package-lock.json index 57d295218f..e9ce5e3c9a 100644 --- a/cli/windmill-utils-internal/package-lock.json +++ b/cli/windmill-utils-internal/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-utils-internal", - "version": "1.3.5", + "version": "1.3.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-utils-internal", - "version": "1.3.5", + "version": "1.3.6", "license": "Apache 2.0", "devDependencies": { "@types/node": "^24.2.0", diff --git a/cli/windmill-utils-internal/package.json b/cli/windmill-utils-internal/package.json index d5c428b35f..07cf8b90b0 100644 --- a/cli/windmill-utils-internal/package.json +++ b/cli/windmill-utils-internal/package.json @@ -1,6 +1,6 @@ { "name": "windmill-utils-internal", - "version": "1.3.5", + "version": "1.3.7", "description": "Internal utility functions for Windmill", "main": "dist/cjs/index.js", "module": "dist/esm/index.js", diff --git a/cli/windmill-utils-internal/src/config/index.ts b/cli/windmill-utils-internal/src/config/index.ts index f3ae42b3c8..e23ba6ca86 100644 --- a/cli/windmill-utils-internal/src/config/index.ts +++ b/cli/windmill-utils-internal/src/config/index.ts @@ -1 +1 @@ -export * from "./config.ts"; \ No newline at end of file +export * from "./config"; \ No newline at end of file diff --git a/cli/windmill-utils-internal/src/index.ts b/cli/windmill-utils-internal/src/index.ts index 635893e2d1..da314a7c5a 100644 --- a/cli/windmill-utils-internal/src/index.ts +++ b/cli/windmill-utils-internal/src/index.ts @@ -8,8 +8,8 @@ * - Cross-platform path constants */ -export * from "./inline-scripts.ts"; -export * from "./path-utils.ts"; -export * from "./parse.ts"; -export * from "./config.ts"; -export { SEP, DELIMITER } from "./constants.ts"; \ No newline at end of file +export * from "./inline-scripts"; +export * from "./path-utils"; +export * from "./parse"; +export * from "./config"; +export { SEP, DELIMITER } from "./constants"; \ No newline at end of file diff --git a/cli/windmill-utils-internal/src/inline-scripts/extractor.ts b/cli/windmill-utils-internal/src/inline-scripts/extractor.ts index a3572ce7eb..0ad1bb8302 100644 --- a/cli/windmill-utils-internal/src/inline-scripts/extractor.ts +++ b/cli/windmill-utils-internal/src/inline-scripts/extractor.ts @@ -1,5 +1,5 @@ -import { newPathAssigner, PathAssigner } from "../path-utils/path-assigner.ts"; -import { FlowModule, RawScript, ScriptLang } from "../gen/types.gen.ts"; +import { newPathAssigner, PathAssigner } from "../path-utils/path-assigner"; +import { FlowModule, RawScript, ScriptLang } from "../gen/types.gen"; /** * Represents an inline script extracted from a flow module diff --git a/cli/windmill-utils-internal/src/inline-scripts/index.ts b/cli/windmill-utils-internal/src/inline-scripts/index.ts index bb3c917dbb..eace8d3e4f 100644 --- a/cli/windmill-utils-internal/src/inline-scripts/index.ts +++ b/cli/windmill-utils-internal/src/inline-scripts/index.ts @@ -1,2 +1,2 @@ -export * from "./replacer.ts"; -export * from "./extractor.ts"; \ No newline at end of file +export * from "./replacer"; +export * from "./extractor"; \ No newline at end of file diff --git a/cli/windmill-utils-internal/src/inline-scripts/replacer.ts b/cli/windmill-utils-internal/src/inline-scripts/replacer.ts index 11b2cfaa1b..c5651752f1 100644 --- a/cli/windmill-utils-internal/src/inline-scripts/replacer.ts +++ b/cli/windmill-utils-internal/src/inline-scripts/replacer.ts @@ -1,4 +1,4 @@ -import { AiAgent, FlowModule, FlowValue, RawScript } from "../gen/types.gen.ts"; +import { AiAgent, FlowModule, FlowValue, RawScript } from "../gen/types.gen"; export type LocalScriptInfo = { content: string; diff --git a/cli/windmill-utils-internal/src/parse/index.ts b/cli/windmill-utils-internal/src/parse/index.ts index 41d09ed00d..fc26ce611a 100644 --- a/cli/windmill-utils-internal/src/parse/index.ts +++ b/cli/windmill-utils-internal/src/parse/index.ts @@ -1 +1 @@ -export * from "./parse-schema.ts"; \ No newline at end of file +export * from "./parse-schema"; \ No newline at end of file diff --git a/cli/windmill-utils-internal/src/path-utils/index.ts b/cli/windmill-utils-internal/src/path-utils/index.ts index 6f5c8d68be..ef23185664 100644 --- a/cli/windmill-utils-internal/src/path-utils/index.ts +++ b/cli/windmill-utils-internal/src/path-utils/index.ts @@ -1 +1 @@ -export * from "./path-assigner.ts"; \ No newline at end of file +export * from "./path-assigner"; \ No newline at end of file diff --git a/cli/windmill-utils-internal/src/path-utils/path-assigner.ts b/cli/windmill-utils-internal/src/path-utils/path-assigner.ts index 3fedbd8d37..a2fc5b8cab 100644 --- a/cli/windmill-utils-internal/src/path-utils/path-assigner.ts +++ b/cli/windmill-utils-internal/src/path-utils/path-assigner.ts @@ -1,4 +1,4 @@ -import { RawScript } from "../gen/types.gen.ts"; +import { RawScript } from "../gen/types.gen"; const INLINE_SCRIPT_PREFIX = "inline_script"; @@ -111,6 +111,28 @@ export function getLanguageFromExtension( return undefined; } +/** + * Sanitizes a summary string for use as a filesystem-safe name. + * Removes or replaces characters that are invalid on common filesystems. + */ +const WINDOWS_RESERVED = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])$/; + +export function sanitizeForFilesystem(summary: string): string { + const name = summary + .toLowerCase() + .replaceAll(" ", "_") + // Remove characters invalid on Windows/Unix/Mac: / \ : * ? " < > | + // Also remove control characters (0x00-0x1F) and DEL (0x7F) + // deno-lint-ignore no-control-regex + .replace(/[/\\:*?"<>|\x00-\x1f\x7f]/g, "") + // Collapse consecutive underscores + .replace(/_+/g, "_") + // Trim leading/trailing dots and underscores (hidden files, Windows edge cases) + .replace(/^[._]+|[._]+$/g, ""); + // Prefix Windows reserved device names (CON, PRN, AUX, NUL, COM0-9, LPT0-9) + return WINDOWS_RESERVED.test(name) ? `_${name}` : name; +} + export interface PathAssigner { assignPath(summary: string | undefined, language: SupportedLanguage): [string, string]; } @@ -144,7 +166,7 @@ export function newPathAssigner(defaultTs: "bun" | "deno" | PathAssignerOptions, ): [string, string] { let name; - name = summary?.toLowerCase()?.replaceAll(" ", "_") ?? ""; + name = summary ? sanitizeForFilesystem(summary) : ""; let original_name = name; @@ -185,7 +207,7 @@ export function newRawAppPathAssigner(defaultTs: "bun" | "deno"): PathAssigner { ): [string, string] { let name; - name = summary?.toLowerCase()?.replaceAll(" ", "_") ?? ""; + name = summary ? sanitizeForFilesystem(summary) : ""; let original_name = name; From 943fe9c6cc9b046e24007e45b5c37afc4804256a Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 27 Mar 2026 11:54:45 +0000 Subject: [PATCH 012/111] fix: handle inline script deletion in sync push + flow new nonDottedPaths (#8553) * fix: handle inline script file deletions in app/flow folders during sync push Co-Authored-By: Claude Opus 4.6 (1M context) * test: add regression test for app inline script deletion during sync push Co-Authored-By: Claude Opus 4.6 (1M context) * fix: flow new respects nonDottedPaths setting Co-Authored-By: Claude Opus 4.6 (1M context) * test: add flow new nonDottedPaths test Co-Authored-By: Claude Opus 4.6 (1M context) * fix: separate stat from pushObj in delete handler to avoid masking errors Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- cli/src/commands/flow/flow.ts | 10 +- cli/src/commands/sync/sync.ts | 90 ++++++++++-- cli/src/types.ts | 15 +- cli/src/utils/resource_folders.ts | 22 +++ cli/test/app_inline_script_delete.test.ts | 158 ++++++++++++++++++++++ cli/test/list_get_new_commands.test.ts | 32 +++++ cli/test/resource_folders_unit.test.ts | 34 +++++ 7 files changed, 347 insertions(+), 14 deletions(-) create mode 100644 cli/test/app_inline_script_delete.test.ts diff --git a/cli/src/commands/flow/flow.ts b/cli/src/commands/flow/flow.ts index 58d0777f90..7e8bd8c28f 100644 --- a/cli/src/commands/flow/flow.ts +++ b/cli/src/commands/flow/flow.ts @@ -10,6 +10,7 @@ import { yamlParseFile } from "../../utils/yaml.ts"; import * as wmill from "../../../gen/services.gen.ts"; import { readFile } from "node:fs/promises"; import { mkdirSync, writeFileSync } from "node:fs"; +import { buildFolderPath, getMetadataFileName, loadNonDottedPathsSetting } from "../../utils/resource_folders.ts"; import { requireLogin } from "../../core/auth.ts"; import { resolveWorkspace, validatePath } from "../../core/context.ts"; @@ -516,7 +517,7 @@ export async function generateLocks( } } -export function bootstrap( +export async function bootstrap( opts: GlobalOptions & { summary: string; description: string }, flowPath: string ) { @@ -524,7 +525,9 @@ export function bootstrap( return; } - const flowDirFullPath = `${flowPath}.flow`; + await loadNonDottedPathsSetting(); + + const flowDirFullPath = buildFolderPath(flowPath, "flow"); mkdirSync(flowDirFullPath, { recursive: false }); const newFlowDefinition = defaultFlowDefinition(); @@ -539,7 +542,8 @@ export function bootstrap( newFlowDefinition as Record ); - const flowYamlPath = `${flowDirFullPath}/flow.yaml`; + const metadataFile = getMetadataFileName("flow", "yaml"); + const flowYamlPath = `${flowDirFullPath}/${metadataFile}`; writeFileSync(flowYamlPath, newFlowDefinitionYaml, { flag: "wx", encoding: "utf-8" }); } diff --git a/cli/src/commands/sync/sync.ts b/cli/src/commands/sync/sync.ts index d62c39cba9..f2d15916d6 100644 --- a/cli/src/commands/sync/sync.ts +++ b/cli/src/commands/sync/sync.ts @@ -93,6 +93,8 @@ import { isAppMetadataFile, isRawAppMetadataFile, isRawAppFolderMetadataFile, + isAppFolderMetadataFile, + isFlowFolderMetadataFile, getDeleteSuffix, transformJsonPathToDir, getFolderSuffix, @@ -3160,16 +3162,88 @@ export async function push( }); break; case "flow": - await wmill.deleteFlowByPath({ - workspace: workspaceId, - path: removeSuffix(target, getDeleteSuffix("flow", "json")), - }); + if (isFlowFolderMetadataFile(target)) { + // Metadata file deleted — delete the entire flow + await wmill.deleteFlowByPath({ + workspace: workspaceId, + path: removeSuffix(target, getDeleteSuffix("flow", "json")), + }); + } else { + // Inline script file deleted within flow folder + const flowFolder = extractFolderPath(target, "flow"); + let flowFolderExists = false; + if (flowFolder) { + try { + await stat(flowFolder); + flowFolderExists = true; + } catch { + // folder doesn't exist + } + } + if (flowFolderExists) { + // Re-push the entire flow so the backend gets the updated definition + await pushObj( + workspaceId, + target, + undefined, + undefined, + opts.plainSecrets ?? false, + alreadySynced, + opts.message, + ); + } else { + // Flow folder doesn't exist locally — delete on server + const remotePath = extractResourceName(target, "flow"); + if (remotePath) { + await wmill.deleteFlowByPath({ + workspace: workspaceId, + path: remotePath, + }); + } + } + } break; case "app": - await wmill.deleteApp({ - workspace: workspaceId, - path: removeSuffix(target, getDeleteSuffix("app", "json")), - }); + if (isAppFolderMetadataFile(target)) { + // Metadata file deleted — delete the entire app + await wmill.deleteApp({ + workspace: workspaceId, + path: removeSuffix(target, getDeleteSuffix("app", "json")), + }); + } else { + // Inline script file deleted within app folder + const appFolder = extractFolderPath(target, "app"); + let appFolderExists = false; + if (appFolder) { + try { + await stat(appFolder); + appFolderExists = true; + } catch { + // folder doesn't exist + } + } + if (appFolderExists) { + // Re-push the entire app so the backend gets the updated definition + await pushObj( + workspaceId, + target, + undefined, + undefined, + opts.plainSecrets ?? false, + alreadySynced, + opts.message, + ); + } else { + // App folder doesn't exist locally — delete on server + const remotePath = extractResourceName(target, "app"); + if (remotePath) { + await wmill.deleteApp({ + workspace: workspaceId, + path: remotePath, + }); + } + } + } break; case "raw_app": if (isRawAppFolderMetadataFile(target)) { diff --git a/cli/src/types.ts b/cli/src/types.ts index 8ba36a0ea0..56fbf384fa 100644 --- a/cli/src/types.ts +++ b/cli/src/types.ts @@ -157,17 +157,26 @@ export async function pushObj( const typeEnding = getTypeStrFromPath(p); if (typeEnding === "app") { - const appName = extractResourceName(p, "app")!; + const appName = extractResourceName(p, "app"); + if (!appName) { + throw new Error(`Could not extract app name from path: ${p}`); + } await pushApp(workspace, appName, buildFolderPath(appName, "app"), message); } else if (typeEnding === "raw_app") { - const rawAppName = extractResourceName(p, "raw_app")!; + const rawAppName = extractResourceName(p, "raw_app"); + if (!rawAppName) { + throw new Error(`Could not extract raw app name from path: ${p}`); + } await pushRawApp(workspace, rawAppName, buildFolderPath(rawAppName, "raw_app"), message); } else if (typeEnding === "folder") { await pushFolder(workspace, p, befObj, newObj); } else if (typeEnding === "variable") { await pushVariable(workspace, p, befObj, newObj, plainSecrets); } else if (typeEnding === "flow") { - const flowName = extractResourceName(p, "flow")!; + const flowName = extractResourceName(p, "flow"); + if (!flowName) { + throw new Error(`Could not extract flow name from path: ${p}`); + } await pushFlow(workspace, flowName, buildFolderPath(flowName, "flow"), message); } else if (typeEnding === "resource") { if (!alreadySynced.includes(p)) { diff --git a/cli/src/utils/resource_folders.ts b/cli/src/utils/resource_folders.ts index 1531314f04..b4f204b1c6 100644 --- a/cli/src/utils/resource_folders.ts +++ b/cli/src/utils/resource_folders.ts @@ -453,6 +453,28 @@ export function isRawAppFolderMetadataFile(p: string): boolean { ); } +/** + * Check if a path ends with a specific app metadata file + * (inside the folder, e.g., ".app/app.yaml" or "__app/app.yaml") + */ +export function isAppFolderMetadataFile(p: string): boolean { + return ( + p.endsWith(getMetadataPathSuffix("app", "yaml")) || + p.endsWith(getMetadataPathSuffix("app", "json")) + ); +} + +/** + * Check if a path ends with a specific flow metadata file + * (inside the folder, e.g., ".flow/flow.yaml" or "__flow/flow.yaml") + */ +export function isFlowFolderMetadataFile(p: string): boolean { + return ( + p.endsWith(getMetadataPathSuffix("flow", "yaml")) || + p.endsWith(getMetadataPathSuffix("flow", "json")) + ); +} + // ============================================================================ // Script Module Path Functions // ============================================================================ diff --git a/cli/test/app_inline_script_delete.test.ts b/cli/test/app_inline_script_delete.test.ts new file mode 100644 index 0000000000..a33b912971 --- /dev/null +++ b/cli/test/app_inline_script_delete.test.ts @@ -0,0 +1,158 @@ +import { expect, test } from "bun:test"; +import { withTestBackend } from "./test_backend.ts"; +import { addWorkspace } from "../workspace.ts"; +import * as path from "node:path"; +import { writeFile, readdir, stat, rm } from "node:fs/promises"; +import { getFolderSuffix, getMetadataFileName } from "../src/utils/resource_folders.ts"; + +// ============================================================================= +// APP INLINE SCRIPT DELETION TESTS +// Regression tests for: deleting inline script files within .app/ folders +// during sync push should re-push the app, not crash with TypeError. +// ============================================================================= + +async function fileExists(filePath: string): Promise { + try { + await stat(filePath); + return true; + } catch { + return false; + } +} + +test("App: delete inline script file and push does not crash", async () => { + await withTestBackend(async (backend, tempDir) => { + const testWorkspace = { + remote: backend.baseUrl, + workspaceId: backend.workspace, + name: "app_inline_delete_test", + token: backend.token, + }; + await addWorkspace(testWorkspace, { + force: true, + configDir: backend.testConfigDir, + }); + + await writeFile( + `${tempDir}/wmill.yaml`, + `defaultTs: bun\nincludes:\n - "**"\nexcludes: []`, + "utf-8" + ); + + // Create an app with an inline script via the API + const appPath = "f/test/inline_delete_app"; + const inlineContent = `export async function main() {\n return "hello";\n}`; + + // Create the folder first + await backend.apiRequest!( + `/api/w/${backend.workspace}/folders/create`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "test" }), + } + ).then((r) => r.text()); + + await backend.createAppWithInlineScript!(appPath, inlineContent, "bun"); + + // ========================================================================= + // STEP 1: Pull — get the app folder with inline script files + // ========================================================================= + const pullResult1 = await backend.runCLICommand( + ["sync", "pull", "--yes"], + tempDir, + "app_inline_delete_test" + ); + expect(pullResult1.code).toEqual(0); + + // Find the app folder and its inline script files + const appSuffix = getFolderSuffix("app"); + const appDir = path.join(tempDir, appPath + appSuffix); + expect(await fileExists(appDir)).toBeTruthy(); + + // List files in the app folder to find inline script files + const appFiles = await readdir(appDir); + const inlineScriptFiles = appFiles.filter( + (f) => f.endsWith(".ts") || f.endsWith(".js") + ); + expect(inlineScriptFiles.length).toBeGreaterThan(0); + + const inlineScriptPath = path.join(appDir, inlineScriptFiles[0]); + expect(await fileExists(inlineScriptPath)).toBeTruthy(); + + const metadataFile = getMetadataFileName("app", "yaml"); + const appYamlPath = path.join(appDir, metadataFile); + + // ========================================================================= + // STEP 2: Remove the inline script from app.yaml and delete the .ts file + // ========================================================================= + // Replace the inline script with a static text component (no inline scripts) + const updatedAppYaml = `summary: Test app with inline script +value: + type: app + grid: + - id: text1 + data: + type: textcomponent + componentInput: + type: static + value: hello world + hiddenInlineScripts: [] + css: {} + norefreshbar: false +policy: + on_behalf_of: null + on_behalf_of_email: null + triggerables: {} + execution_mode: viewer +`; + await writeFile(appYamlPath, updatedAppYaml, "utf-8"); + + // Delete the inline script file + await rm(inlineScriptPath); + expect(await fileExists(inlineScriptPath)).toBeFalsy(); + + // Also delete any lock files for the inline script + for (const f of appFiles) { + if (f.endsWith(".lock")) { + await rm(path.join(appDir, f)); + } + } + + // ========================================================================= + // STEP 3: Push — should succeed, NOT crash with TypeError + // ========================================================================= + const pushResult = await backend.runCLICommand( + ["sync", "push", "--yes"], + tempDir, + "app_inline_delete_test" + ); + + // The critical assertion: push should not crash + expect(pushResult.code).toEqual(0); + + // ========================================================================= + // STEP 4: Verify by pulling again — inline script should be gone + // ========================================================================= + await rm(appDir, { recursive: true }); + + const pullResult2 = await backend.runCLICommand( + ["sync", "pull", "--yes"], + tempDir, + "app_inline_delete_test" + ); + expect(pullResult2.code).toEqual(0); + + // App should still exist + expect(await fileExists(appDir)).toBeTruthy(); + + // But no inline script files should be present + const finalFiles = await readdir(appDir); + const finalInlineScripts = finalFiles.filter( + (f) => + (f.endsWith(".ts") || f.endsWith(".js")) && + f.includes("inline_script") + ); + expect(finalInlineScripts.length).toEqual(0); + }); +}); diff --git a/cli/test/list_get_new_commands.test.ts b/cli/test/list_get_new_commands.test.ts index 8df67cab9b..df3d109630 100644 --- a/cli/test/list_get_new_commands.test.ts +++ b/cli/test/list_get_new_commands.test.ts @@ -438,6 +438,38 @@ describe("new command", () => { }); }); + test("flow new respects nonDottedPaths: true", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + await writeFile( + join(tempDir, "wmill.yaml"), + `defaultTs: bun\nnonDottedPaths: true\nincludes:\n - "**"\nexcludes: []\n`, + "utf-8" + ); + await mkdir(join(tempDir, "f", "test"), { recursive: true }); + + const result = await backend.runCLICommand( + ["flow", "new", "f/test/nondot_flow", "--summary", "Non-dotted flow"], + tempDir + ); + + expect(result.code).toEqual(0); + + // Should use __flow suffix, not .flow + const flowYamlStat = await stat( + join(tempDir, "f/test/nondot_flow__flow/flow.yaml") + ); + expect(flowYamlStat.isFile()).toBe(true); + + const flowContent = await readFile( + join(tempDir, "f/test/nondot_flow__flow/flow.yaml"), + "utf-8" + ); + expect(flowContent).toContain("Non-dotted flow"); + }); + }); + test("flow bootstrap still works as alias", async () => { await withTestBackend(async (backend, tempDir) => { await setupWorkspaceProfile(backend); diff --git a/cli/test/resource_folders_unit.test.ts b/cli/test/resource_folders_unit.test.ts index 80732ebc1c..0d07812124 100644 --- a/cli/test/resource_folders_unit.test.ts +++ b/cli/test/resource_folders_unit.test.ts @@ -31,6 +31,8 @@ import { isAppMetadataFile, isRawAppMetadataFile, isRawAppFolderMetadataFile, + isAppFolderMetadataFile, + isFlowFolderMetadataFile, getDeleteSuffix, transformJsonPathToDir, isModuleEntryPoint, @@ -495,6 +497,38 @@ describe("isRawAppFolderMetadataFile", () => { }); }); +describe("isAppFolderMetadataFile", () => { + test("detects app folder metadata file (dotted)", () => { + expect(isAppFolderMetadataFile("f/common/landing.app/app.yaml")).toBe(true); + expect(isAppFolderMetadataFile("f/common/landing.app/app.json")).toBe(true); + }); + + test("rejects inline script files inside app folder", () => { + expect(isAppFolderMetadataFile("f/common/landing.app/eval_of_e.inline_script.frontend.js")).toBe(false); + expect(isAppFolderMetadataFile("f/common/landing.app/button1.inline_script.bun.ts")).toBe(false); + }); + + test("rejects top-level app metadata files", () => { + expect(isAppFolderMetadataFile("f/common/landing.app.json")).toBe(false); + expect(isAppFolderMetadataFile("f/common/landing.app.yaml")).toBe(false); + }); +}); + +describe("isFlowFolderMetadataFile", () => { + test("detects flow folder metadata file (dotted)", () => { + expect(isFlowFolderMetadataFile("f/common/my_flow.flow/flow.yaml")).toBe(true); + expect(isFlowFolderMetadataFile("f/common/my_flow.flow/flow.json")).toBe(true); + }); + + test("rejects inline script files inside flow folder", () => { + expect(isFlowFolderMetadataFile("f/common/my_flow.flow/step_0.inline_script.ts")).toBe(false); + }); + + test("rejects top-level flow metadata files", () => { + expect(isFlowFolderMetadataFile("f/common/my_flow.flow.json")).toBe(false); + }); +}); + // ============================================================================= // Sync-related Path Functions // ============================================================================= From 79cc4a92d88486c999799826bd0c9663767103f5 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 27 Mar 2026 11:55:09 +0000 Subject: [PATCH 013/111] fix: emit 0 for OTEL queue metrics when tag queue is empty (#8559) Previously, windmill.queue.count and windmill.queue.running_count OTEL metrics would report no data instead of 0 when a tag's queue emptied. This was because the SQL query uses GROUP BY tag, so empty tags are absent from results. The Prometheus path already handled this by tracking previously-seen tags and emitting 0, but the OTEL path was missing this logic. Co-authored-by: Claude Opus 4.6 (1M context) --- backend/src/monitor.rs | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index bb7640c5ed..2b1196c8c2 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -169,6 +169,8 @@ lazy_static::lazy_static! { static ref QUEUE_COUNT_TAGS: Arc>> = Arc::new(RwLock::new(Vec::new())); static ref QUEUE_RUNNING_COUNT_TAGS: Arc>> = Arc::new(RwLock::new(Vec::new())); + static ref OTEL_QUEUE_COUNT_TAGS: Arc>> = Arc::new(RwLock::new(Vec::new())); + static ref OTEL_QUEUE_RUNNING_COUNT_TAGS: Arc>> = Arc::new(RwLock::new(Vec::new())); static ref DISABLE_CONCURRENCY_LIMIT: bool = std::env::var("DISABLE_CONCURRENCY_LIMIT").is_ok_and(|s| s == "true"); //legacy typo @@ -2372,8 +2374,20 @@ pub async fn expose_queue_metrics(db: &Pool) { } } + let otel_enabled = OTEL_METRICS_ENABLED.load(Ordering::Relaxed); + + if otel_enabled { + for q in OTEL_QUEUE_COUNT_TAGS.read().await.iter() { + if queue_counts.get(q).is_none() { + otel_set_queue_count(q, 0); + } + } + } + #[allow(unused_mut)] let mut tags_to_watch = vec![]; + #[allow(unused_mut)] + let mut otel_tags_to_watch = vec![]; for q in queue_counts { let count = q.1; let tag = q.0; @@ -2385,6 +2399,9 @@ pub async fn expose_queue_metrics(db: &Pool) { tags_to_watch.push(tag.to_string()); } + if otel_enabled { + otel_tags_to_watch.push(tag.to_string()); + } otel_set_queue_count(&tag, count as i64); // save queue_count and delay metrics per tag @@ -2419,9 +2436,13 @@ pub async fn expose_queue_metrics(db: &Pool) { let mut w = QUEUE_COUNT_TAGS.write().await; *w = tags_to_watch; } + if otel_enabled { + let mut w = OTEL_QUEUE_COUNT_TAGS.write().await; + *w = otel_tags_to_watch; + } // Single DB query for running counts, shared by Prometheus and OTel - let otel_running = OTEL_METRICS_ENABLED.load(Ordering::Relaxed); + let otel_running = otel_enabled; #[cfg(feature = "prometheus")] let need_running_counts = metrics_enabled || otel_running; #[cfg(not(feature = "prometheus"))] @@ -2439,8 +2460,18 @@ pub async fn expose_queue_metrics(db: &Pool) { } } + if otel_running { + for q in OTEL_QUEUE_RUNNING_COUNT_TAGS.read().await.iter() { + if queue_running_counts.get(q).is_none() { + otel_set_queue_running_count(q, 0); + } + } + } + #[allow(unused_mut, unused_variables)] let mut running_tags_to_watch: Vec = vec![]; + #[allow(unused_mut, unused_variables)] + let mut otel_running_tags_to_watch: Vec = vec![]; for (tag, count) in &queue_running_counts { #[cfg(feature = "prometheus")] if metrics_enabled { @@ -2451,6 +2482,7 @@ pub async fn expose_queue_metrics(db: &Pool) { if otel_running { otel_set_queue_running_count(tag, *count as i64); + otel_running_tags_to_watch.push(tag.to_string()); } } @@ -2459,6 +2491,10 @@ pub async fn expose_queue_metrics(db: &Pool) { let mut w = QUEUE_RUNNING_COUNT_TAGS.write().await; *w = running_tags_to_watch; } + if otel_running { + let mut w = OTEL_QUEUE_RUNNING_COUNT_TAGS.write().await; + *w = otel_running_tags_to_watch; + } } } From 0fb115304afc49812420e9ce24e5048502621059 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 27 Mar 2026 11:55:47 +0000 Subject: [PATCH 014/111] fix: preserve notes on nodes inside collapsed groups (#8552) * fix: preserve notes on nodes inside collapsed groups Co-Authored-By: Claude Opus 4.6 (1M context) * fix: hide notes for nodes inside collapsed groups instead of repositioning Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .../lib/components/graph/FlowGraphV2.svelte | 17 +++++++++++++++-- .../lib/components/graph/noteEditor.svelte.ts | 18 +++++++++++++++++- .../lib/components/graph/noteUtils.svelte.ts | 14 ++++++++++++-- 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/frontend/src/lib/components/graph/FlowGraphV2.svelte b/frontend/src/lib/components/graph/FlowGraphV2.svelte index 47626fb41c..973c7172b3 100644 --- a/frontend/src/lib/components/graph/FlowGraphV2.svelte +++ b/frontend/src/lib/components/graph/FlowGraphV2.svelte @@ -698,6 +698,19 @@ ...aiToolNodesResult.toolNodes ] + // Collect module IDs hidden inside collapsed groups so note cleanup preserves them + const collapsedModuleIds = new Set() + for (const n of finalNodes) { + if (n.type === 'collapsedGroup') { + const modules = (n.data as any)?.modules as FlowModule[] | undefined + if (modules) { + for (const m of modules) { + collapsedModuleIds.add(m.id) + } + } + } + } + // Compute note nodes (no position remapping) let noteNodesResult = showNotes ? computeNoteNodes( @@ -715,7 +728,8 @@ noteManager.render() }, editMode, - noteEditorContext + noteEditorContext, + collapsedModuleIds.size > 0 ? collapsedModuleIds : undefined ) : undefined @@ -921,7 +935,6 @@ document.addEventListener('keydown', globalKeyDownHandler) - return () => { document.removeEventListener('keydown', globalKeyDownHandler) } diff --git a/frontend/src/lib/components/graph/noteEditor.svelte.ts b/frontend/src/lib/components/graph/noteEditor.svelte.ts index e59e3be4f5..50c2df7bfa 100644 --- a/frontend/src/lib/components/graph/noteEditor.svelte.ts +++ b/frontend/src/lib/components/graph/noteEditor.svelte.ts @@ -219,7 +219,10 @@ export class NoteEditor { /** * Clean up group notes using DAG path completion */ - cleanupGroupNotes(flowNodes: { id: string; parentIds?: string[] }[]): void { + cleanupGroupNotes( + flowNodes: { id: string; parentIds?: string[] }[], + collapsedModuleIds?: Set + ): void { if (!this.isAvailable()) { return } @@ -231,6 +234,13 @@ export class NoteEditor { let hasChanges = false const nodeSet = new Set(flowNodes.map((n) => n.id)) + // Include collapsed module IDs as valid — they are hidden but still exist + if (collapsedModuleIds) { + for (const id of collapsedModuleIds) { + nodeSet.add(id) + } + } + // Step 1: Clean invalid nodes from existing group notes for (const note of groupNotes) { const originalIds = note.contained_node_ids || [] @@ -249,6 +259,12 @@ export class NoteEditor { const originalNodes = note.contained_node_ids || [] if (originalNodes.length === 0) continue + // Skip path completion for notes that reference collapsed modules, + // since the DAG is incomplete when groups are collapsed + if (collapsedModuleIds && originalNodes.some((id) => collapsedModuleIds.has(id))) { + continue + } + // Use the DAG path completion and splitting algorithm const completedGroups = completeAndSplitGroup(originalNodes, flowNodes) diff --git a/frontend/src/lib/components/graph/noteUtils.svelte.ts b/frontend/src/lib/components/graph/noteUtils.svelte.ts index bd7fc2ce05..577b421a9e 100644 --- a/frontend/src/lib/components/graph/noteUtils.svelte.ts +++ b/frontend/src/lib/components/graph/noteUtils.svelte.ts @@ -249,7 +249,8 @@ export function computeNoteNodes( noteTextHeights: Record, onTextHeightChange: (noteId: string, height: number) => void, editMode: boolean = false, - noteEditorContext: NoteEditorContext | undefined + noteEditorContext: NoteEditorContext | undefined, + collapsedModuleIds?: Set ): NoteComputeResult { // Check cache first if ( @@ -263,7 +264,7 @@ export function computeNoteNodes( if (editMode) { if (noteEditorContext?.noteEditor?.isAvailable()) { - noteEditorContext.noteEditor.cleanupGroupNotes(nodes) + noteEditorContext.noteEditor.cleanupGroupNotes(nodes, collapsedModuleIds) } } @@ -290,6 +291,15 @@ export function computeNoteNodes( for (const note of notes) { const isGroupNote = note.type === 'group' + + // Skip group notes whose contained nodes are all inside collapsed groups + if (isGroupNote && collapsedModuleIds?.size) { + const ids = note.contained_node_ids ?? [] + if (ids.length > 0 && ids.every((id) => collapsedModuleIds.has(id))) { + continue + } + } + const zIndex = noteZIndexes[note.id] // Calculate position and size using node positions for group notes From ad19ac9b37b04591c921f93f180bdda961af6cef Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Fri, 27 Mar 2026 12:57:47 +0100 Subject: [PATCH 015/111] feat: support multiple folder selection in MCP scope selector (#8557) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: support multiple folder selection in MCP scope selector Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add per-folder caching for multi-folder runnables loading Co-Authored-By: Claude Opus 4.6 (1M context) * fix: address PR review — workspace prop, length check, empty folder state Co-Authored-By: Claude Opus 4.6 (1M context) * fix: cache folder names per workspace and reload on workspace change Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .../components/mcp/McpScopeSelector.svelte | 97 ++++++++++++++++--- 1 file changed, 81 insertions(+), 16 deletions(-) diff --git a/frontend/src/lib/components/mcp/McpScopeSelector.svelte b/frontend/src/lib/components/mcp/McpScopeSelector.svelte index 9fe525dd5c..3d019ff539 100644 --- a/frontend/src/lib/components/mcp/McpScopeSelector.svelte +++ b/frontend/src/lib/components/mcp/McpScopeSelector.svelte @@ -5,9 +5,8 @@ import Popover from '$lib/components/Popover.svelte' import MultiSelect from '$lib/components/select/MultiSelect.svelte' import { safeSelectItems } from '$lib/components/select/utils.svelte' - import FolderPicker from '$lib/components/FolderPicker.svelte' import TextInput from '$lib/components/text_input/TextInput.svelte' - import { FlowService, IntegrationService, ScriptService } from '$lib/gen' + import { FlowService, FolderService, IntegrationService, ScriptService } from '$lib/gen' import { mcpEndpointTools } from '$lib/mcpEndpointTools' import InfoIcon from 'lucide-svelte/icons/info' import { SvelteMap } from 'svelte/reactivity' @@ -20,7 +19,10 @@ let { workspaceId, scope = $bindable() }: Props = $props() let selectedMode = $state<'favorites' | 'all' | 'folder' | 'custom'>('favorites') - let selectedFolder = $state('') + let selectedFolders = $state([]) + let allFolders = $state([]) + let loadingFolders = $state(false) + let folderNamesCache = new Map() let selectedScripts = $state([]) let selectedFlows = $state([]) let selectedEndpoints = $state([]) @@ -70,8 +72,10 @@ scopeParts.push(`mcp:endpoints:${selectedEndpoints.join(',')}`) } } else if (selectedMode === 'folder') { - const folderPath = `f/${selectedFolder}/*` - scopeParts = [`mcp:scripts:${folderPath}`, `mcp:flows:${folderPath}`, `mcp:endpoints:*`] + const folderPaths = selectedFolders.map((f) => `f/${f}/*`).join(',') + if (selectedFolders.length > 0) { + scopeParts = [`mcp:scripts:${folderPaths}`, `mcp:flows:${folderPaths}`, `mcp:endpoints:*`] + } } else { scopeParts = [`mcp:${selectedMode}`] } @@ -91,13 +95,35 @@ } }) - // Clear folder when not in folder mode + // Clear folders when not in folder mode, load folder names when entering folder mode $effect(() => { - if (selectedMode !== 'folder') { - selectedFolder = '' + if (selectedMode === 'folder' && workspaceId) { + loadFolderNames(workspaceId) + } else { + selectedFolders = [] } }) + async function loadFolderNames(workspace: string) { + if (folderNamesCache.has(workspace)) { + allFolders = folderNamesCache.get(workspace)! + return + } + try { + loadingFolders = true + const excludedFolders = ['app_groups', 'app_custom', 'app_themes'] + const names = ( + await FolderService.listFolderNames({ workspace }) + ).filter((x) => !excludedFolders.includes(x)) + folderNamesCache.set(workspace, names) + allFolders = names + } catch { + allFolders = [] + } finally { + loadingFolders = false + } + } + // Load hub apps on mount async function getAllApps() { if (allApps.length > 0) return @@ -192,11 +218,42 @@ // Load runnables based on mode $effect(() => { if (workspaceId) { - const folderParam = selectedFolder.length > 0 ? selectedFolder : undefined - getScriptsAndFlows(selectedMode === 'favorites', workspaceId, folderParam) + if (selectedMode === 'folder') { + if (selectedFolders.length > 0) { + loadRunnablesForFolders(workspaceId, selectedFolders) + } else { + includedRunnables = [] + } + } else { + getScriptsAndFlows(selectedMode === 'favorites', workspaceId, undefined) + } } }) + async function getCachedRunnables(workspace: string, folder: string): Promise { + const cacheKey = `${workspace}-false-${folder}` + if (runnablesCache.has(cacheKey)) { + return runnablesCache.get(cacheKey) || [] + } + const [scripts, flows] = await Promise.all([ + getScripts(false, workspace, folder), + getFlows(false, workspace, folder) + ]) + const combined = [...scripts, ...flows] + runnablesCache.set(cacheKey, combined) + return combined + } + + async function loadRunnablesForFolders(workspace: string, folders: string[]) { + try { + loadingRunnables = true + const results = await Promise.all(folders.map((f) => getCachedRunnables(workspace, f))) + includedRunnables = [...new Set(results.flat())] + } finally { + loadingRunnables = false + } + } + // Load all scripts/flows for custom mode $effect(() => { if (selectedMode === 'custom' && workspaceId) { @@ -209,7 +266,7 @@ ? 'Create your first scripts or flows to make them available via MCP.' : selectedMode === 'favorites' ? `You do not have any favorite scripts or flows. You can favorite some scripts and flows to include them, or change the scope to "All scripts/flows" to include all your scripts and flows.` - : `You do not have any scripts or flows in the selected folder.` + : `You do not have any scripts or flows in the selected folder(s).` ) function selectAllScripts() { @@ -252,8 +309,8 @@ - Select Folder - + Select Folders + {#if loadingFolders} +
Loading folders...
+ {:else} + + {/if}
{/if} @@ -389,7 +454,7 @@
{/if} - {:else if selectedMode !== 'folder' || selectedFolder.length > 0} + {:else if selectedMode !== 'folder' || selectedFolders.length > 0} {#if loadingRunnables}
Date: Fri, 27 Mar 2026 11:58:24 +0000 Subject: [PATCH 016/111] perf: enable bun bundle caching for WAC v2 scripts (#8556) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WAC v2 scripts previously disabled bundle caching, forcing every execution to resolve windmill-client from node_modules at runtime (~74ms overhead per bun launch). This makes both the prebundle and execution paths WAC-aware by including WorkflowCtx/StepSuspend/setWorkflowCtx re-exports in the bundle, so the wrapper can import them from the cached bundle instead of node_modules. Benchmarked improvement: wac_inline_2 12→38 wf/s (3.2x), wac_seq_2 6→17 wf/s (2.8x) with no regression on plain bun scripts or flows. Co-authored-by: Claude Opus 4.6 (1M context) --- backend/windmill-worker/src/bun_executor.rs | 62 +++++++++++---------- backend/windmill-worker/src/wac_executor.rs | 17 ++++++ 2 files changed, 50 insertions(+), 29 deletions(-) diff --git a/backend/windmill-worker/src/bun_executor.rs b/backend/windmill-worker/src/bun_executor.rs index 6bc6cacc32..433f96bcec 100644 --- a/backend/windmill-worker/src/bun_executor.rs +++ b/backend/windmill-worker/src/bun_executor.rs @@ -1092,7 +1092,12 @@ pub async fn prebundle_bun_script( } let origin = format!("{job_dir}/main.js"); - write_file(job_dir, "main.ts", &remove_pinned_imports(inner_content)?)?; + let mut content = remove_pinned_imports(inner_content)?; + if crate::wac_executor::is_wac_v2_ts(inner_content) { + content = crate::wac_executor::inject_wac_task_names(&content); + content = format!("export {{ WorkflowCtx, StepSuspend, setWorkflowCtx }} from \"windmill-client\";\n{content}"); + } + write_file(job_dir, "main.ts", &content)?; build_loader( job_dir, base_internal_url, @@ -1318,29 +1323,12 @@ pub async fn handle_bun_job( // Also handles: export const, let, var, and optional generic type parameters. // Skips calls that already have a string argument: `task("path", async ...` let inner_content = if is_wac_v2 { - use regex::Regex; - use std::borrow::Cow; - lazy_static::lazy_static! { - static ref TASK_RE: Regex = - Regex::new(r#"(?m)((?:export\s+)?(?:const|let|var)\s+)(\w+)(\s*=\s*task\s*(?:<[^>]*>)?\s*\(\s*)(async\b)"#).unwrap(); - } - let replaced = TASK_RE.replace_all(inner_content, r#"${1}${2}${3}"${2}", ${4}"#); - match replaced { - Cow::Borrowed(_) => inner_content.to_string(), - Cow::Owned(s) => s, - } + crate::wac_executor::inject_wac_task_names(inner_content) } else { inner_content.to_string() }; let inner_content = inner_content.as_str(); - // WAC v2 scripts can't use bundle caching because the wrapper imports - // windmill-client from node_modules, which isn't available in bundle mode - if is_wac_v2 && has_bundle_cache { - has_bundle_cache = false; - let _ = write_file(job_dir, "main.ts", inner_content)?; - } - let mut format = BundleFormat::Cjs; if has_bundle_cache { let target; @@ -1561,6 +1549,12 @@ pub async fn handle_bun_job( "./main.ts" }; + let wac_client_import = if has_bundle_cache { + "./main.js" + } else { + "windmill-client" + }; + let preprocessor = if let Some(pre_args) = pre_args { let pre_spread = pre_args.into_iter().map(|x| x.name).join(","); format!( @@ -1588,7 +1582,7 @@ pub async fn handle_bun_job( format!( r#" import * as Main from "{main_import}"; -import {{ WorkflowCtx, StepSuspend, setWorkflowCtx }} from "windmill-client"; +import {{ WorkflowCtx, StepSuspend, setWorkflowCtx }} from "{wac_client_import}"; import * as fs from "fs/promises"; @@ -1779,7 +1773,6 @@ try {{ && !annotation.nobundling && !*DISABLE_BUNDLING && !codebase.is_some() - && !is_wac_v2 && (maybe_lock.get_lock().is_some() || annotation.native); let write_loader_f = async { @@ -1844,6 +1837,17 @@ try {{ } } + // Prepend WAC re-exports to main.ts so the bundle includes WorkflowCtx etc. + if build_cache && is_wac_v2 { + let main_path = format!("{job_dir}/main.ts"); + let current = read_file_content(&main_path).await?; + write_file( + job_dir, + "main.ts", + &format!("export {{ WorkflowCtx, StepSuspend, setWorkflowCtx }} from \"windmill-client\";\n{current}"), + )?; + } + if !codebase.is_some() && !has_bundle_cache { if build_cache { generate_bun_bundle( @@ -1882,14 +1886,14 @@ try {{ } if !annotation.native { let ex_wrapper = read_file_content(&format!("{job_dir}/wrapper.mjs")).await?; - write_file( - job_dir, - "wrapper.mjs", - &ex_wrapper.replace( - "import * as Main from \"./main.ts\"", - "import * as Main from \"./main.js\"", - ), - )?; + let mut rewritten = ex_wrapper.replace( + "import * as Main from \"./main.ts\"", + "import * as Main from \"./main.js\"", + ); + if is_wac_v2 { + rewritten = rewritten.replace("from \"windmill-client\"", "from \"./main.js\""); + } + write_file(job_dir, "wrapper.mjs", &rewritten)?; write_file(job_dir, "package.json", r#"{ "type": "module" }"#)?; } fs::remove_file(format!("{job_dir}/main.ts"))?; diff --git a/backend/windmill-worker/src/wac_executor.rs b/backend/windmill-worker/src/wac_executor.rs index 9b4ba3d92a..28de226102 100644 --- a/backend/windmill-worker/src/wac_executor.rs +++ b/backend/windmill-worker/src/wac_executor.rs @@ -364,6 +364,23 @@ pub fn is_wac_v2_ts(code: &str) -> bool { has_wac_import && has_workflow } +/// Inject the variable name as the first argument to `task()` calls in WAC v2 scripts. +/// `const double = task(async ...` → `const double = task("double", async ...` +/// Skips calls that already have a string argument. +pub fn inject_wac_task_names(content: &str) -> String { + use regex::Regex; + use std::borrow::Cow; + lazy_static::lazy_static! { + static ref TASK_RE: Regex = + Regex::new(r#"(?m)((?:export\s+)?(?:const|let|var)\s+)(\w+)(\s*=\s*task\s*(?:<[^>]*>)?\s*\(\s*)(async\b)"#).unwrap(); + } + let replaced = TASK_RE.replace_all(content, r#"${1}${2}${3}"${2}", ${4}"#); + match replaced { + Cow::Borrowed(_) => content.to_string(), + Cow::Owned(s) => s, + } +} + /// Detect WAC v2 patterns in Python code. /// Checks for `@workflow` decorator and `@task` decorator with wmill import, /// skipping comment lines. From 2f326758013dd1f1e6ae732e5784a32f1fb6e4bd Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 27 Mar 2026 11:59:17 +0000 Subject: [PATCH 017/111] feat: DB-coordinated graceful restart staggering for settings changes (#8555) * feat: add DB-coordinated graceful restart staggering for settings changes Co-Authored-By: Claude Opus 4.6 (1M context) * fix: preserve original instance names in restart coordination record Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: remove randomness, add drain delay for in-flight requests Co-Authored-By: Claude Opus 4.6 (1M context) * fix: spawn restart in background, deduplicate entries, clarify stale filter Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- backend/src/main.rs | 174 ++++++++++++++++-- .../windmill-common/src/global_settings.rs | 1 + .../windmill-common/src/instance_config.rs | 1 + 3 files changed, 157 insertions(+), 19 deletions(-) diff --git a/backend/src/main.rs b/backend/src/main.rs index 4fe22c517f..18ff643621 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -52,9 +52,10 @@ use windmill_common::{ NPM_CONFIG_REGISTRY_SETTING, NUGET_CONFIG_SETTING, OAUTH_SETTING, OTEL_SETTING, OTEL_TRACING_PROXY_SETTING, PIP_INDEX_URL_SETTING, POWERSHELL_REPO_PAT_SETTING, POWERSHELL_REPO_URL_SETTING, REQUEST_SIZE_LIMIT_SETTING, - REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING, RETENTION_PERIOD_SECS_SETTING, - RUBY_REPOS_SETTING, SAML_METADATA_SETTING, SCIM_TOKEN_SETTING, SMTP_SETTING, TEAMS_SETTING, - TIMEOUT_WAIT_RESULT_SETTING, UV_INDEX_STRATEGY_SETTING, WORKSPACE_REGISTRIES_SETTING, + REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING, RESTART_COORDINATION_SETTING, + RETENTION_PERIOD_SECS_SETTING, RUBY_REPOS_SETTING, SAML_METADATA_SETTING, + SCIM_TOKEN_SETTING, SMTP_SETTING, TEAMS_SETTING, TIMEOUT_WAIT_RESULT_SETTING, + UV_INDEX_STRATEGY_SETTING, WORKSPACE_REGISTRIES_SETTING, }, scripts::ScriptLang, stats_oss::schedule_stats, @@ -67,7 +68,7 @@ use windmill_common::{ is_native_mode_from_env, reload_custom_tags_setting, Connection, HUB_CACHE_DIR, HUB_RT_CACHE_DIR, NATIVE_MODE_RESOLVED, TMP_LOGS_DIR, WINDMILL_DIR, WORKER_GROUP, }, - KillpillSender, DEFAULT_HUB_BASE_URL, METRICS_ENABLED, + KillpillSender, DEFAULT_HUB_BASE_URL, INSTANCE_NAME, METRICS_ENABLED, }; #[cfg(feature = "enterprise")] @@ -1791,7 +1792,8 @@ async fn process_notify_event( reload_otel_tracing_proxy_setting(conn).await; if worker_mode { tracing::info!("OTEL tracing proxy setting changed, restarting worker"); - send_delayed_killpill(tx, 4, "OTEL tracing proxy setting change").await; + spawn_graceful_killpill(tx, db, 10, "OTEL tracing proxy setting change") + .await; } } REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING => { @@ -1799,12 +1801,12 @@ async fn process_notify_event( } EXPOSE_METRICS_SETTING => { tracing::info!("Metrics setting changed, restarting"); - send_delayed_killpill(tx, 40, "metrics setting change").await; + spawn_graceful_killpill(tx, db, 10, "metrics setting change").await; } EMAIL_DOMAIN_SETTING => { tracing::info!("Email domain setting changed"); if server_mode { - send_delayed_killpill(tx, 4, "email domain setting change").await; + spawn_graceful_killpill(tx, db, 10, "email domain setting change").await; } } EXPOSE_DEBUG_METRICS_SETTING => { @@ -1840,19 +1842,19 @@ async fn process_notify_event( } OTEL_SETTING => { tracing::info!("OTEL setting changed, restarting"); - send_delayed_killpill(tx, 4, "OTEL setting change").await; + spawn_graceful_killpill(tx, db, 10, "OTEL setting change").await; } REQUEST_SIZE_LIMIT_SETTING => { if server_mode { tracing::info!("Request limit size change detected, killing server expecting to be restarted"); - send_delayed_killpill(tx, 4, "request size limit change").await; + spawn_graceful_killpill(tx, db, 10, "request size limit change").await; } } SAML_METADATA_SETTING => { tracing::info!( "SAML metadata change detected, killing server expecting to be restarted" ); - send_delayed_killpill(tx, 0, "SAML metadata change").await; + spawn_graceful_killpill(tx, db, 10, "SAML metadata change").await; } HUB_BASE_URL_SETTING => { if let Err(e) = reload_hub_base_url_setting(conn, server_mode).await { @@ -1901,6 +1903,9 @@ async fn process_notify_event( .unwrap_or(false); tracing::info!("Workspace telemetry setting changed: enabled={}", enabled); } + RESTART_COORDINATION_SETTING => { + // Internal coordination key for staggered restarts, no action needed + } _ => { tracing::info!("Unrecognized Global Setting Change Payload: {:?}", payload); } @@ -2042,14 +2047,145 @@ pub async fn run_workers( Ok(()) } -async fn send_delayed_killpill(tx: &KillpillSender, mut max_delay_secs: u64, context: &str) { - if max_delay_secs == 0 { - max_delay_secs = 1; - } - // Random delay to avoid all servers/workers shutting down simultaneously - let rd_delay = rand::rng().random_range(0..max_delay_secs); - tracing::info!("Scheduling {context} shutdown in {rd_delay}s"); - tokio::time::sleep(Duration::from_secs(rd_delay)).await; +/// Schedule a graceful restart with DB-coordinated staggering. +/// +/// Uses a PostgreSQL advisory lock to serialize restart scheduling across server instances. +/// Each instance records its planned restart time in the `_restart_coordination` global setting; +/// subsequent instances read existing schedules and shift their restart to maintain at least +/// `safety_margin_secs` between consecutive restarts (must exceed the server startup time). +/// +/// Every server waits at least `DRAIN_DELAY_SECS` to let in-flight requests complete. +/// Each subsequent server waits an additional `safety_margin_secs` after the previous one, +/// guaranteeing zero downtime overlap. +/// +/// The DB coordination is done synchronously (fast, ~ms) to reserve our restart slot, +/// then the sleep+kill is spawned in the background so the notification handler is not blocked. +/// +/// Falls back to drain-only delay if DB coordination fails. +async fn spawn_graceful_killpill( + tx: &KillpillSender, + db: &Pool, + safety_margin_secs: u64, + context: &str, +) { + // Minimum delay before any restart to let in-flight requests drain + const DRAIN_DELAY_SECS: u64 = 3; - tx.send(); + let delay = match coordinate_restart_delay(db, safety_margin_secs, DRAIN_DELAY_SECS).await { + Ok(d) => d, + Err(e) => { + tracing::warn!( + "Failed to coordinate restart for {context}: {e:#}, \ + falling back to drain delay of {DRAIN_DELAY_SECS}s" + ); + DRAIN_DELAY_SECS + } + }; + + tracing::info!("Scheduling {context} graceful shutdown in {delay}s"); + let tx = tx.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_secs(delay)).await; + tx.send(); + }); +} + +/// Coordinate a restart delay with other instances via the DB. +/// +/// Returns the delay (in seconds from now) at which this instance should restart. +/// The first server gets `drain_delay_secs` (to let in-flight requests complete). +/// Each subsequent server is spaced `safety_margin_secs` after the latest scheduled restart. +async fn coordinate_restart_delay( + db: &Pool, + safety_margin_secs: u64, + drain_delay_secs: u64, +) -> anyhow::Result { + const RESTART_LOCK_ID: i64 = 737_483_920; + // Stale threshold: ignore coordination entries older than this + const STALE_THRESHOLD_SECS: i64 = 120; + + let now = chrono::Utc::now(); + + let mut tx = db.begin().await.context("begin restart coordination tx")?; + + // Serialize access across all instances + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(RESTART_LOCK_ID) + .execute(&mut *tx) + .await + .context("acquire restart coordination lock")?; + + // Read existing coordination record + let existing: Option = + sqlx::query_scalar("SELECT value FROM global_settings WHERE name = $1") + .bind(RESTART_COORDINATION_SETTING) + .fetch_optional(&mut *tx) + .await + .context("read restart coordination")?; + + // Parse existing scheduled restarts, filtering out stale entries + // Each entry is (instance_name, restart_at) + let mut scheduled: Vec<(String, chrono::DateTime)> = Vec::new(); + if let Some(val) = &existing { + if let Some(arr) = val.get("restarts").and_then(|v| v.as_array()) { + for entry in arr { + let instance = entry + .get("instance") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + if let Some(ts_str) = entry.get("restart_at").and_then(|v| v.as_str()) { + if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(ts_str) { + let dt = dt.with_timezone(&chrono::Utc); + let stale_cutoff = now - chrono::Duration::seconds(STALE_THRESHOLD_SECS); + if dt > stale_cutoff { + scheduled.push((instance, dt)); + } + } + } + } + } + } + + // Find the latest scheduled restart + let latest = scheduled.iter().map(|(_, dt)| *dt).max(); + let earliest_allowed = now + chrono::Duration::seconds(drain_delay_secs as i64); + + // Our restart time: drain_delay from now, or safety_margin after the latest existing restart + let our_restart = match latest { + Some(last) => { + let after_last = last + chrono::Duration::seconds(safety_margin_secs as i64); + // Use whichever is later: drain delay or staggered position + earliest_allowed.max(after_last) + } + None => earliest_allowed, + }; + + // Record our restart time (deduplicate: remove any prior entry for this instance) + scheduled.retain(|(inst, _)| inst != &*INSTANCE_NAME); + scheduled.push((INSTANCE_NAME.clone(), our_restart)); + let new_value = serde_json::json!({ + "restarts": scheduled.iter().map(|(inst, dt)| { + serde_json::json!({ + "instance": inst, + "restart_at": dt.to_rfc3339() + }) + }).collect::>() + }); + + sqlx::query( + "INSERT INTO global_settings (name, value, updated_at) \ + VALUES ($1, $2, now()) \ + ON CONFLICT (name) DO UPDATE SET value = $2, updated_at = now()", + ) + .bind(RESTART_COORDINATION_SETTING) + .bind(&new_value) + .execute(&mut *tx) + .await + .context("write restart coordination")?; + + tx.commit().await.context("commit restart coordination")?; + + let delay = (our_restart - now).num_seconds().max(0) as u64; + Ok(delay) } diff --git a/backend/windmill-common/src/global_settings.rs b/backend/windmill-common/src/global_settings.rs index 31aadb8210..bdedcbd7bb 100644 --- a/backend/windmill-common/src/global_settings.rs +++ b/backend/windmill-common/src/global_settings.rs @@ -64,6 +64,7 @@ pub const MIN_KEEP_ALIVE_VERSION_SETTING: &str = "min_keep_alive_version"; pub const GITHUB_ENTERPRISE_APP_SETTING: &str = "github_enterprise_app"; pub const INSTANCE_EVENTS_WEBHOOK_SETTING: &str = "instance_events_webhook"; pub const WORKSPACE_REGISTRIES_SETTING: &str = "workspace_registries"; +pub const RESTART_COORDINATION_SETTING: &str = "_restart_coordination"; use std::sync::Arc; use tokio::sync::RwLock; diff --git a/backend/windmill-common/src/instance_config.rs b/backend/windmill-common/src/instance_config.rs index c843cc6621..dfa0dd6454 100644 --- a/backend/windmill-common/src/instance_config.rs +++ b/backend/windmill-common/src/instance_config.rs @@ -870,6 +870,7 @@ pub const HIDDEN_SETTINGS: &[&str] = &[ "uid", "min_keep_alive_version", "automate_username_creation", + "_restart_coordination", ]; /// Top-level settings whose entire value is sensitive and must be fully redacted in logs. From 8df1d8ec17737ea54a1422150572c77e3943b32f Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 27 Mar 2026 12:28:54 +0000 Subject: [PATCH 018/111] test nits --- backend/Cargo.lock | 3 ++- backend/windmill-api-integration-tests/tests/health.rs | 1 - backend/windmill-api-integration-tests/tests/jobs_authed.rs | 1 + .../tests/workspace_dependencies_git_sync.rs | 6 ++++++ 4 files changed, 9 insertions(+), 2 deletions(-) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index df09b225b5..11c743a643 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -14606,7 +14606,7 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", - "indexmap 2.11.1", + "indexmap 2.12.0", "pin-project-lite", "slab", "sync_wrapper", @@ -16253,6 +16253,7 @@ dependencies = [ "aws-config", "aws-credential-types", "aws-sdk-sqs", + "axum 0.8.4", "base64 0.22.1", "futures", "rand 0.9.0", diff --git a/backend/windmill-api-integration-tests/tests/health.rs b/backend/windmill-api-integration-tests/tests/health.rs index c804373697..f624431510 100644 --- a/backend/windmill-api-integration-tests/tests/health.rs +++ b/backend/windmill-api-integration-tests/tests/health.rs @@ -1,4 +1,3 @@ -use serde_json::json; use sqlx::{Pool, Postgres}; use windmill_test_utils::*; diff --git a/backend/windmill-api-integration-tests/tests/jobs_authed.rs b/backend/windmill-api-integration-tests/tests/jobs_authed.rs index 4e82a4aba1..766c300d3d 100644 --- a/backend/windmill-api-integration-tests/tests/jobs_authed.rs +++ b/backend/windmill-api-integration-tests/tests/jobs_authed.rs @@ -46,6 +46,7 @@ async fn insert_completed_job(db: &Pool) -> Uuid { id } +#[allow(dead_code)] async fn create_script(port: u16) -> String { let base = format!("http://localhost:{port}/api/w/test-workspace/scripts"); let resp = authed(client().post(format!("{base}/create"))) diff --git a/backend/windmill-api-integration-tests/tests/workspace_dependencies_git_sync.rs b/backend/windmill-api-integration-tests/tests/workspace_dependencies_git_sync.rs index 72a102dfd8..9226d3f74a 100644 --- a/backend/windmill-api-integration-tests/tests/workspace_dependencies_git_sync.rs +++ b/backend/windmill-api-integration-tests/tests/workspace_dependencies_git_sync.rs @@ -14,6 +14,7 @@ use serde_json::json; use sqlx::{Pool, Postgres}; use std::time::Duration; +#[allow(unused_imports)] use windmill_test_utils::*; /// Row shape for querying deployment callback jobs from v2_job_queue @@ -27,6 +28,7 @@ struct DeploymentCallbackJob { } /// Poll for deployment callback jobs in the queue for a given script path +#[allow(dead_code)] async fn get_deployment_callback_jobs( db: &Pool, script_path: &str, @@ -63,6 +65,7 @@ async fn get_deployment_callback_jobs( } /// Configure git sync for the test workspace with workspace dependencies enabled +#[allow(dead_code)] async fn setup_git_sync_config(db: &Pool, sync_script_path: &str) -> anyhow::Result<()> { let git_sync_config = json!({ "include_type": ["workspacedependencies"], @@ -87,6 +90,7 @@ async fn setup_git_sync_config(db: &Pool, sync_script_path: &str) -> a } /// Create a git repository resource for testing +#[allow(dead_code)] async fn create_git_repo_resource(db: &Pool) -> anyhow::Result<()> { sqlx::query( r#" @@ -107,6 +111,7 @@ async fn create_git_repo_resource(db: &Pool) -> anyhow::Result<()> { } /// Create a dummy sync script for testing (with version >= 28103 for debouncing support) +#[allow(dead_code)] async fn create_sync_script(db: &Pool, path: &str) -> anyhow::Result { let hash: i64 = rand::random::().unsigned_abs() as i64; sqlx::query( @@ -126,6 +131,7 @@ async fn create_sync_script(db: &Pool, path: &str) -> anyhow::Result, name: &str) -> anyhow::Result<()> { sqlx::query( r#" From 70f3ee5ed4470e9993be822874f2b38e83a96611 Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Fri, 27 Mar 2026 16:21:42 +0100 Subject: [PATCH 019/111] fix: use admin db pool in get_copilot_settings_state (#8564) Co-authored-by: Claude Opus 4.6 (1M context) --- backend/windmill-api-workspaces/src/workspaces.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index fe7991d3a8..08b9d4d3d6 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -652,25 +652,23 @@ async fn get_settings( } async fn get_copilot_settings_state( - authed: ApiAuthed, + _authed: ApiAuthed, Path(w_id): Path, - Extension(user_db): Extension, + Extension(db): Extension, ) -> JsonResult { - let mut tx = user_db.begin(&authed).await?; let workspace_ai_config = sqlx::query_scalar!( "SELECT ai_config FROM workspace_settings WHERE workspace_id = $1", &w_id ) - .fetch_optional(&mut *tx) + .fetch_optional(&db) .await .map_err(|e| Error::internal_err(format!("getting workspace ai settings: {e:#}")))?; let workspace_ai_config = not_found_if_none(workspace_ai_config, "workspace settings", &w_id)?; let instance_ai_config: Option = sqlx::query_scalar("SELECT value FROM global_settings WHERE name = 'ai_config'") - .fetch_optional(&mut *tx) + .fetch_optional(&db) .await .map_err(|e| Error::internal_err(format!("getting instance ai settings: {e:#}")))?; - tx.commit().await?; Ok(Json(build_copilot_settings_state( has_ai_providers(workspace_ai_config.as_ref()), From 5fd2c1a1292afe2b52f7a5e90c98e79255abd830 Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Fri, 27 Mar 2026 17:13:33 +0100 Subject: [PATCH 020/111] chore(cli): separate unit tests from integration tests and fix test cleanup (#8562) * fix(cli): separate unit tests from integration tests and fix test cleanup - Rename 14 non-backend test files to *_unit.test.ts convention - Add UNIT_ONLY env var guard in setup.ts to skip cargo build/backend startup - Add test:unit and test:integration scripts to package.json - Use setsid on Linux for process group management so stop() kills both cargo and the windmill child process - Fix exit handler to kill process group instead of just the direct child - Add cleanupStaleTestResources() to drop orphaned windmill_test_* databases and kill orphaned backend processes on startup - Rewrite TESTING.md with current bun-based instructions Co-Authored-By: Claude Opus 4.6 (1M context) * fix(cli): fix process group approach - kill by db name instead of setsid The setsid approach didn't work because setsid forks, making the PID we get from Bun.spawn ephemeral. Instead, kill orphaned windmill child processes by matching our unique database name in /proc/pid/environ. Also add afterAll hook in setup.ts so full async cleanup (process kill + database drop) runs when all tests complete normally, not just on SIGINT/SIGTERM. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(cli): address PR review feedback - Remove duplicate cleanupStaleTestResources() call in getTestBackend() (already called in setup.ts) - Add regex guard on database names before SQL interpolation - Extract shared killWindmillProcessesByEnvMatch() helper to deduplicate process-killing logic - Remove redundant test:integration script (test already runs everything) - Flip setup.ts to if/else pattern for readability Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- cli/TESTING.md | 88 +++++++-------- cli/package.json | 1 + cli/test/cargo_backend.ts | 101 +++++++++++++++++- ...t.ts => conf_branch_override_unit.test.ts} | 0 ...ments_to_map_branch_specific_unit.test.ts} | 0 ...test.ts => generate_metadata_unit.test.ts} | 0 ...ate.test.ts => init_template_unit.test.ts} | 0 ...scripts_failure_preprocessor_unit.test.ts} | 0 ...mand.test.ts => lint_command_unit.test.ts} | 0 ..._locks.test.ts => lint_locks_unit.test.ts} | 0 ..._cache.test.ts => lock_cache_unit.test.ts} | 0 ...t.ts => replace_path_scripts_unit.test.ts} | 0 ...es.test.ts => script_modules_unit.test.ts} | 0 cli/test/setup.ts | 30 +++++- ...ms.test.ts => specific_items_unit.test.ts} | 0 ...tion.test.ts => tar_creation_unit.test.ts} | 0 cli/test/test_backend.ts | 15 ++- ...l_lock.test.ts => wmill_lock_unit.test.ts} | 0 ...st.ts => workspace_conflicts_unit.test.ts} | 0 19 files changed, 181 insertions(+), 54 deletions(-) rename cli/test/{conf_branch_override.test.ts => conf_branch_override_unit.test.ts} (100%) rename cli/test/{elements_to_map_branch_specific.test.ts => elements_to_map_branch_specific_unit.test.ts} (100%) rename cli/test/{generate_metadata.test.ts => generate_metadata_unit.test.ts} (100%) rename cli/test/{init_template.test.ts => init_template_unit.test.ts} (100%) rename cli/test/{inline_scripts_failure_preprocessor.test.ts => inline_scripts_failure_preprocessor_unit.test.ts} (100%) rename cli/test/{lint_command.test.ts => lint_command_unit.test.ts} (100%) rename cli/test/{lint_locks.test.ts => lint_locks_unit.test.ts} (100%) rename cli/test/{lock_cache.test.ts => lock_cache_unit.test.ts} (100%) rename cli/test/{replace_path_scripts.test.ts => replace_path_scripts_unit.test.ts} (100%) rename cli/test/{script_modules.test.ts => script_modules_unit.test.ts} (100%) rename cli/test/{specific_items.test.ts => specific_items_unit.test.ts} (100%) rename cli/test/{tar_creation.test.ts => tar_creation_unit.test.ts} (100%) rename cli/test/{wmill_lock.test.ts => wmill_lock_unit.test.ts} (100%) rename cli/test/{workspace_conflicts.test.ts => workspace_conflicts_unit.test.ts} (100%) diff --git a/cli/TESTING.md b/cli/TESTING.md index 9928e75266..542baab368 100644 --- a/cli/TESTING.md +++ b/cli/TESTING.md @@ -3,57 +3,57 @@ ## Running Tests ```bash -# Run all tests -deno test -A --no-check test/ +# Run unit tests only (fast — no backend, no database, no cargo build) +bun run test:unit + +# Run all tests (unit + integration — requires PostgreSQL + cargo) +DATABASE_URL=postgres://postgres:changeme@localhost:5432 bun run test # Run specific test files -deno test -A --no-check test/gitsync_settings_features.test.ts -deno test -A --no-check test/init_no_git_sync.test.ts -deno test -A --no-check test/multi_instance_workspace.test.ts -deno test -A --no-check test/override_settings_behavior.test.ts -deno test -A --no-check test/sync_config_resolution.test.ts -deno test -A --no-check test/workspace_conflicts.test.ts - -# Run with specific test patterns -deno test -A --no-check test/ --filter "workspace" -deno test -A --no-check test/ --filter "sync" +bun test test/sync_pull_push.test.ts +bun test test/workspace_conflicts_unit.test.ts ``` -## Test Files +## Test Categories -- **`gitsync_settings_features.test.ts`** - Git sync settings functionality -- **`init_no_git_sync.test.ts`** - Init without git sync -- **`multi_instance_workspace.test.ts`** - Multi-instance workspace handling -- **`override_settings_behavior.test.ts`** - Settings override behavior -- **`sync_config_resolution.test.ts`** - Sync configuration resolution -- **`workspace_conflicts.test.ts`** - Workspace conflict detection +### Unit tests (`*_unit.test.ts`) -## Docker Requirements +Pure local tests — no backend, no database. Uses `bunfig.unit.toml` (no preload). + +Examples: `git_unit`, `lint_command_unit`, `tar_creation_unit`, `workspace_conflicts_unit` + +### Integration tests + +Require a running backend and PostgreSQL. The `setup.ts` preload builds the backend +binary and starts a shared backend instance. + +Examples: `sync_pull_push`, `dev_server`, `standalone_commands` + +## Environment Variables + +| Variable | Purpose | Default | +|----------|---------|---------| +| `DATABASE_URL` | PostgreSQL connection string (without database name) | `postgres://postgres:changeme@localhost:5432` | +| `TEST_BACKEND` | `cargo` or `docker` | `cargo` | +| `CI_MINIMAL_FEATURES` | `true` for CI mode (zip-only features) | unset | +| `EE_LICENSE_KEY` | Enterprise license for EE feature tests | unset | +| `TEST_FEATURES` | Additional cargo features (comma-separated) | unset | +| `TEST_CLI_RUNTIME` | `node` to test npm package | unset | +| `UNIT_ONLY` | `1` to skip backend setup in preload (used by `test:unit`) | unset | +| `VERBOSE` | `1` for backend process output | unset | + +## Cleanup + +Stale test databases (`windmill_test_*`) and orphaned backend processes from +previous crashed runs are automatically cleaned up when starting a new test run. + +To manually check for leftovers: ```bash -# Ensure Docker is running -docker --version -docker-compose --version +# Check for stale test databases +psql postgres://postgres:changeme@localhost:5432/postgres -c \ + "SELECT datname FROM pg_database WHERE datname LIKE 'windmill_test_%';" -# Ensure EE license key is available -echo $EE_LICENSE_KEY +# Check for orphaned backend processes +ps aux | grep "target/debug/windmill" | grep -v grep ``` - -## Debugging Failed Tests - -```bash -# Run with verbose output -deno test -A --no-check test/ --reporter=verbose - -# Check container status -docker ps - -# View backend logs -docker logs test-test_windmill_server-1 - -# Manual container management -cd test -docker compose -f docker-compose.test.yml up -d -docker compose -f docker-compose.test.yml down -docker compose -f docker-compose.test.yml down -v -``` \ No newline at end of file diff --git a/cli/package.json b/cli/package.json index e44a215631..105915a720 100644 --- a/cli/package.json +++ b/cli/package.json @@ -9,6 +9,7 @@ "dev": "bun run src/main.ts", "build": "./build.sh", "test": "bun test test/", + "test:unit": "UNIT_ONLY=1 bun test test/*_unit*", "check": "bunx tsc --noEmit", "gen-client": "./gen_wm_client.sh && ./windmill-utils-internal/gen_wm_client.sh" }, diff --git a/cli/test/cargo_backend.ts b/cli/test/cargo_backend.ts index a25a788f7e..692fc1619b 100644 --- a/cli/test/cargo_backend.ts +++ b/cli/test/cargo_backend.ts @@ -14,11 +14,13 @@ import { resolve, dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { statSync } from "node:fs"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, rm, readFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { createServer } from "node:net"; import { Subprocess } from "bun"; +const IS_LINUX = process.platform === "linux"; + export interface CargoBackendConfig { /** PostgreSQL connection string (without database name) */ postgresUrl?: string; @@ -193,6 +195,10 @@ export class CargoBackend { this.process = null; } + // Kill any child processes (e.g. the windmill binary spawned by cargo) + // by matching our unique database name in their environment + await this.killProcessesByDbName(); + // Drop the test database await this.dropDatabase(); @@ -304,6 +310,15 @@ export class CargoBackend { } } + /** + * Kill any processes whose environment contains our unique database name. + * This catches child processes (e.g. the windmill binary spawned by cargo run) + * that survive after the direct child is killed. + */ + private async killProcessesByDbName(): Promise { + await killWindmillProcessesByEnvMatch(this.dbName); + } + /** * Start the backend process using cargo run */ @@ -762,6 +777,90 @@ export class CargoBackend { } } +/** + * Kill windmill processes whose /proc/pid/environ contains the given pattern. + * Used by both per-test cleanup (match specific DB name) and stale cleanup (match any test DB). + */ +async function killWindmillProcessesByEnvMatch(pattern: string): Promise { + if (!IS_LINUX) return; + try { + const pgrepProc = Bun.spawn(["pgrep", "-f", "target/(debug|release)/windmill"], { + stdout: "pipe", stderr: "pipe", + }); + const output = await new Response(pgrepProc.stdout).text(); + await new Response(pgrepProc.stderr).text(); + await pgrepProc.exited; + + for (const pidStr of output.trim().split("\n").filter(Boolean)) { + const pid = Number(pidStr); + if (isNaN(pid)) continue; + try { + const environ = await readFile(`/proc/${pid}/environ`, "utf-8"); + if (environ.includes(pattern)) { + console.log(`Killing orphaned test backend process: ${pid}`); + process.kill(pid, "SIGKILL"); + } + } catch { + // Process exited or we lack permissions + } + } + } catch { + // pgrep not available or no matches + } +} + +/** + * Clean up stale test databases and orphaned backend processes from previous + * test runs that crashed or were killed without proper cleanup. + * + * Should be called before starting a new test backend. + */ +export async function cleanupStaleTestResources(postgresUrl?: string): Promise { + const baseUrl = postgresUrl || process.env["DATABASE_URL"] || "postgres://postgres:changeme@localhost:5432"; + const url = new URL(baseUrl); + url.pathname = ""; + url.search = ""; + const cleanBaseUrl = url.toString().replace(/\/$/, ""); + + // 1. Find and drop stale windmill_test_* databases + try { + const listProc = Bun.spawn(["psql", `${cleanBaseUrl}/postgres`, "-t", "-c", + `SELECT datname FROM pg_database WHERE datname LIKE 'windmill_test_%';` + ], { stdout: "pipe", stderr: "pipe" }); + const output = await new Response(listProc.stdout).text(); + await new Response(listProc.stderr).text(); + await listProc.exited; + + const staleDBs = output.trim().split("\n").map(s => s.trim()).filter(Boolean); + for (const db of staleDBs) { + // Only touch databases matching the expected naming pattern + if (!/^windmill_test_[a-z0-9_]+$/.test(db)) continue; + console.log(`Cleaning up stale test database: ${db}`); + const termProc = Bun.spawn(["psql", `${cleanBaseUrl}/postgres`, "-c", + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '${db}' AND pid <> pg_backend_pid();` + ], { stdout: "pipe", stderr: "pipe" }); + await new Response(termProc.stdout).text(); + await new Response(termProc.stderr).text(); + await termProc.exited; + + const dropProc = Bun.spawn(["psql", `${cleanBaseUrl}/postgres`, "-c", + `DROP DATABASE IF EXISTS "${db}";` + ], { stdout: "pipe", stderr: "pipe" }); + await new Response(dropProc.stdout).text(); + await new Response(dropProc.stderr).text(); + await dropProc.exited; + } + if (staleDBs.length > 0) { + console.log(`Cleaned up ${staleDBs.length} stale test database(s)`); + } + } catch (err) { + console.warn(`Warning: Failed to clean up stale databases: ${err}`); + } + + // 2. Find and kill orphaned windmill processes from test runs + await killWindmillProcessesByEnvMatch("windmill_test_"); +} + // Global backend instance let globalCargoBackend: CargoBackend | null = null; diff --git a/cli/test/conf_branch_override.test.ts b/cli/test/conf_branch_override_unit.test.ts similarity index 100% rename from cli/test/conf_branch_override.test.ts rename to cli/test/conf_branch_override_unit.test.ts diff --git a/cli/test/elements_to_map_branch_specific.test.ts b/cli/test/elements_to_map_branch_specific_unit.test.ts similarity index 100% rename from cli/test/elements_to_map_branch_specific.test.ts rename to cli/test/elements_to_map_branch_specific_unit.test.ts diff --git a/cli/test/generate_metadata.test.ts b/cli/test/generate_metadata_unit.test.ts similarity index 100% rename from cli/test/generate_metadata.test.ts rename to cli/test/generate_metadata_unit.test.ts diff --git a/cli/test/init_template.test.ts b/cli/test/init_template_unit.test.ts similarity index 100% rename from cli/test/init_template.test.ts rename to cli/test/init_template_unit.test.ts diff --git a/cli/test/inline_scripts_failure_preprocessor.test.ts b/cli/test/inline_scripts_failure_preprocessor_unit.test.ts similarity index 100% rename from cli/test/inline_scripts_failure_preprocessor.test.ts rename to cli/test/inline_scripts_failure_preprocessor_unit.test.ts diff --git a/cli/test/lint_command.test.ts b/cli/test/lint_command_unit.test.ts similarity index 100% rename from cli/test/lint_command.test.ts rename to cli/test/lint_command_unit.test.ts diff --git a/cli/test/lint_locks.test.ts b/cli/test/lint_locks_unit.test.ts similarity index 100% rename from cli/test/lint_locks.test.ts rename to cli/test/lint_locks_unit.test.ts diff --git a/cli/test/lock_cache.test.ts b/cli/test/lock_cache_unit.test.ts similarity index 100% rename from cli/test/lock_cache.test.ts rename to cli/test/lock_cache_unit.test.ts diff --git a/cli/test/replace_path_scripts.test.ts b/cli/test/replace_path_scripts_unit.test.ts similarity index 100% rename from cli/test/replace_path_scripts.test.ts rename to cli/test/replace_path_scripts_unit.test.ts diff --git a/cli/test/script_modules.test.ts b/cli/test/script_modules_unit.test.ts similarity index 100% rename from cli/test/script_modules.test.ts rename to cli/test/script_modules_unit.test.ts diff --git a/cli/test/setup.ts b/cli/test/setup.ts index 7eecd8bf2d..7f27240220 100644 --- a/cli/test/setup.ts +++ b/cli/test/setup.ts @@ -1,14 +1,22 @@ /** * Global test setup — preloaded before all test files. * + * When UNIT_ONLY=1, skips all backend setup (cargo build, database, etc.) + * so that unit tests can run instantly without any external dependencies. + * + * Otherwise: * 1. Builds the backend binary so `cargo run` starts instantly. * 2. Starts a shared backend instance so integration tests don't * bear the startup cost inside their per-test timeout window. */ -import { resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import { statSync } from "node:fs"; +if (process.env["UNIT_ONLY"]) { + // Nothing to do — unit tests don't need backend setup +} else { + +const { resolve } = await import("node:path"); +const { fileURLToPath } = await import("node:url"); +const { statSync } = await import("node:fs"); const __dirname = resolve(fileURLToPath(import.meta.url), ".."); @@ -69,10 +77,22 @@ console.log("Backend build complete."); // This avoids the first integration test timing out while the backend // creates its database, starts the process, and waits for the health check. if (process.env["DATABASE_URL"]) { - const { getTestBackend } = await import("./test_backend.ts"); + // Clean up any stale databases/processes from previous crashed test runs + const { cleanupStaleTestResources } = await import("./cargo_backend.ts"); + await cleanupStaleTestResources(); + + const { getTestBackend, cleanupTestBackend } = await import("./test_backend.ts"); console.log("Pre-starting test backend..."); await getTestBackend(); console.log("Test backend is ready for all tests."); + + // Register afterAll to do full async cleanup (kill processes + drop DB) + // when all tests complete. The synchronous "exit" handler alone can't + // drop databases or scan /proc for orphaned child processes. + const { afterAll } = await import("bun:test"); + afterAll(async () => { + await cleanupTestBackend(); + }); } // When TEST_CLI_RUNTIME=node, also build the npm package so tests @@ -92,3 +112,5 @@ if (process.env["TEST_CLI_RUNTIME"] === "node") { } console.log("npm package built — tests will use Node runtime."); } + +} diff --git a/cli/test/specific_items.test.ts b/cli/test/specific_items_unit.test.ts similarity index 100% rename from cli/test/specific_items.test.ts rename to cli/test/specific_items_unit.test.ts diff --git a/cli/test/tar_creation.test.ts b/cli/test/tar_creation_unit.test.ts similarity index 100% rename from cli/test/tar_creation.test.ts rename to cli/test/tar_creation_unit.test.ts diff --git a/cli/test/test_backend.ts b/cli/test/test_backend.ts index 34ef1f240f..25943e2b98 100644 --- a/cli/test/test_backend.ts +++ b/cli/test/test_backend.ts @@ -590,11 +590,16 @@ function registerCleanup() { cleanupRegistered = true; process.on("exit", () => { if (globalBackend) { - // Synchronous kill — can't await in exit handler - try { - (globalBackend as any).backend?.process?.kill(); - } catch { - // Best effort + // Synchronous kill — can't await in exit handler. + // Kill the direct child (cargo); any orphaned windmill child processes + // will be cleaned up by cleanupStaleTestResources() on next startup. + const pid = (globalBackend as any).backend?.process?.pid; + if (pid) { + try { + process.kill(pid, "SIGKILL"); + } catch { + // Best effort — process may already be dead + } } } }); diff --git a/cli/test/wmill_lock.test.ts b/cli/test/wmill_lock_unit.test.ts similarity index 100% rename from cli/test/wmill_lock.test.ts rename to cli/test/wmill_lock_unit.test.ts diff --git a/cli/test/workspace_conflicts.test.ts b/cli/test/workspace_conflicts_unit.test.ts similarity index 100% rename from cli/test/workspace_conflicts.test.ts rename to cli/test/workspace_conflicts_unit.test.ts From 99b0ebd67701c161407221a773f6ffb19bf1fa40 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 27 Mar 2026 16:14:47 +0000 Subject: [PATCH 021/111] use fallback_service instead of nest_service for MCP router (#8566) Co-authored-by: Claude Opus 4.5 --- backend/windmill-api/src/mcp/core.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/windmill-api/src/mcp/core.rs b/backend/windmill-api/src/mcp/core.rs index 7699853559..e23f44ad35 100644 --- a/backend/windmill-api/src/mcp/core.rs +++ b/backend/windmill-api/src/mcp/core.rs @@ -546,7 +546,7 @@ pub async fn setup_mcp_server( let service = StreamableHttpService::new(move || Ok(runner.clone()), session_manager, service_config); - let router = Router::new().nest_service("/", service); + let router = Router::new().fallback_service(service); Ok((router, cancellation_token)) } From bc7007bb4265e1f1375c1f0678b74325882a4e92 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 27 Mar 2026 16:22:35 +0000 Subject: [PATCH 022/111] fix: include importer_kind in dependency debounce key to prevent cross-kind collisions (#8567) Co-authored-by: Claude Opus 4.6 (1M context) --- backend/windmill-dep-map/src/trigger_dependents.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/windmill-dep-map/src/trigger_dependents.rs b/backend/windmill-dep-map/src/trigger_dependents.rs index f17d3863ac..1254e41ed1 100644 --- a/backend/windmill-dep-map/src/trigger_dependents.rs +++ b/backend/windmill-dep-map/src/trigger_dependents.rs @@ -61,7 +61,7 @@ pub async fn trigger_dependents_to_recompute_dependencies( ); let mut debouncing_settings = DebouncingSettings { - debounce_key: Some(format!("{w_id}:{importer_path}:dependency")), + debounce_key: Some(format!("{w_id}:{importer_path}:{importer_kind}:dependency")), debounce_delay_s: Some(5), ..Default::default() }; From b592996eee98ddb664f1b007b95a2096d5d4e3a6 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 27 Mar 2026 17:52:53 +0000 Subject: [PATCH 023/111] feat: add schedule support to CLI branch-specific items (#8570) Co-authored-by: Claude Opus 4.6 (1M context) --- cli/src/core/conf.ts | 4 ++++ cli/src/core/specific_items.ts | 26 +++++++++++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/cli/src/core/conf.ts b/cli/src/core/conf.ts index e72aa14414..4a4aec7f25 100644 --- a/cli/src/core/conf.ts +++ b/cli/src/core/conf.ts @@ -57,6 +57,7 @@ export interface SyncOptions { variables?: string[]; resources?: string[]; triggers?: string[]; + schedules?: string[]; folders?: string[]; settings?: boolean; }; @@ -70,6 +71,7 @@ export interface SyncOptions { variables?: string[]; resources?: string[]; triggers?: string[]; + schedules?: string[]; folders?: string[]; settings?: boolean; }; @@ -83,6 +85,7 @@ export interface SyncOptions { variables?: string[]; resources?: string[]; triggers?: string[]; + schedules?: string[]; folders?: string[]; settings?: boolean; }; @@ -96,6 +99,7 @@ export interface SyncOptions { variables?: string[]; resources?: string[]; triggers?: string[]; + schedules?: string[]; folders?: string[]; settings?: boolean; }; diff --git a/cli/src/core/specific_items.ts b/cli/src/core/specific_items.ts index cfd806d9c6..dca403e905 100644 --- a/cli/src/core/specific_items.ts +++ b/cli/src/core/specific_items.ts @@ -8,6 +8,7 @@ export interface SpecificItemsConfig { variables?: string[]; resources?: string[]; triggers?: string[]; + schedules?: string[]; folders?: string[]; settings?: boolean; } @@ -17,6 +18,7 @@ function getBranchSpecificTypes() { return { variable: '.variable.yaml', resource: '.resource.yaml', + schedule: '.schedule.yaml', // Generate trigger patterns from the list ...Object.fromEntries( TRIGGER_TYPES.map(t => [`${t}_trigger`, `.${t}_trigger.yaml`]) @@ -31,6 +33,13 @@ function isTriggerFile(path: string): boolean { return TRIGGER_TYPES.some(type => path.endsWith(`.${type}_trigger.yaml`)); } +/** + * Check if a path is a schedule file + */ +function isScheduleFile(path: string): boolean { + return path.endsWith('.schedule.yaml'); +} + /** * Extract the file type suffix from a path */ @@ -53,7 +62,7 @@ function getFileTypeSuffix(path: string): string | null { * Build regex pattern for all supported yaml file types */ function buildYamlTypePattern(): string { - const basicTypes = ['variable', 'resource']; + const basicTypes = ['variable', 'resource', 'schedule']; const triggerTypes = TRIGGER_TYPES.map(t => `${t}_trigger`); return `((${basicTypes.join('|')})|(${triggerTypes.join('|')}))`; } @@ -100,6 +109,9 @@ export function getSpecificItemsForCurrentBranch(config: SyncOptions, branchOver if (commonItems?.triggers) { merged.triggers = [...commonItems.triggers]; } + if (commonItems?.schedules) { + merged.schedules = [...commonItems.schedules]; + } if (commonItems?.folders) { merged.folders = [...commonItems.folders]; } @@ -117,6 +129,9 @@ export function getSpecificItemsForCurrentBranch(config: SyncOptions, branchOver if (branchItems?.triggers) { merged.triggers = [...(merged.triggers || []), ...branchItems.triggers]; } + if (branchItems?.schedules) { + merged.schedules = [...(merged.schedules || []), ...branchItems.schedules]; + } if (branchItems?.folders) { merged.folders = [...(merged.folders || []), ...branchItems.folders]; } @@ -157,6 +172,10 @@ export function isItemTypeConfigured(path: string, specificItems: SpecificItemsC return specificItems.triggers !== undefined; } + if (isScheduleFile(path)) { + return specificItems.schedules !== undefined; + } + if (path.endsWith('/folder.meta.yaml')) { return specificItems.folders !== undefined; } @@ -194,6 +213,11 @@ export function isSpecificItem(path: string, specificItems: SpecificItemsConfig return specificItems.triggers ? matchesPatterns(path, specificItems.triggers) : false; } + // Check for schedule files + if (isScheduleFile(path)) { + return specificItems.schedules ? matchesPatterns(path, specificItems.schedules) : false; + } + // Check for folder meta files if (path.endsWith('/folder.meta.yaml')) { if (specificItems.folders) { From 63a3573951d1f724cc63728ed973d039a5468072 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Fri, 27 Mar 2026 18:57:57 +0100 Subject: [PATCH 024/111] fix: multi-script dedicated workers race on shared job_dir (#8551) (#8569) * [ee] fix: update ee-repo-ref for dedicated worker job_dir fix Co-Authored-By: Claude Opus 4.6 (1M context) * [ee] fix: update ee-repo-ref Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref to 5e8b1bcfc2c9ade9db39c839f2faed4f82da5efc This commit updates the EE repository reference after PR #490 was merged in windmill-ee-private. Previous ee-repo-ref: d958cd3b8a9a17b5f3cb6cb411c8ebba0c380fdd New ee-repo-ref: 5e8b1bcfc2c9ade9db39c839f2faed4f82da5efc Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: windmill-internal-app[bot] --- backend/ee-repo-ref.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 3915abda7e..d0328f83f3 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -61ae055ea31481f1899953e9d5f65566b8c707b1 +5e8b1bcfc2c9ade9db39c839f2faed4f82da5efc From 7a14d38d4a25e2238c73e2f00def5b24fc384e85 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 27 Mar 2026 18:12:52 +0000 Subject: [PATCH 025/111] use layer instead of route_layer for MCP router to prevent axum 0.8 panic (#8572) Co-authored-by: Claude Opus 4.6 (1M context) --- backend/windmill-api/src/lib.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index 450713e614..e714689df0 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -474,14 +474,17 @@ pub async fn run_server( let (mcp_router, mcp_cancellation_token) = setup_mcp_server(db.clone(), user_db, _base_internal_url.clone()).await?; // Workspace-scoped MCP router + // Use `layer` instead of `route_layer` because the MCP router only has + // a fallback_service (no explicit routes), and axum 0.8 panics on + // route_layer with no routes. let workspaced_mcp_router = mcp_router .clone() - .route_layer(from_extractor::()) + .layer(from_extractor::()) .layer(axum::middleware::from_fn(add_www_authenticate_header)) .layer(axum::middleware::from_fn(extract_and_store_workspace_id)); // Gateway MCP router — resolves workspace from token let gateway_mcp_router = mcp_router - .route_layer(from_extractor::()) + .layer(from_extractor::()) .layer(axum::middleware::from_fn( add_www_authenticate_header_gateway, )) From 5e5da4f7ef909387b28f6470b97bb235baffd9ad Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 27 Mar 2026 18:28:59 +0000 Subject: [PATCH 026/111] test: add OTEL coverage tests (#8558) * test: add OTEL coverage tests Add 38 unit tests covering OpenTelemetry infrastructure: - OtelSettings serde (empty, partial, full, roundtrip, skip_serializing) - OtelTracingProxySettings serde (defaults, languages, dedup, rejection) - ScriptLang rename cases - LogCounter initialization and CountingLayer event counting - Targets filter suppression of windmill:job_log - get_otel_context_envs traceparent format verification - Worker OtelTracingProxySettings (HashSet variant) Companion EE PR adds tests for span_cx_from_job_id, metric functions, proto conversion, SpanBuilder, and tracing proxy handler. Co-Authored-By: Claude Opus 4.6 (1M context) * test: add E2E OTEL tests with in-memory exporters Add integration tests that verify metrics and spans flow correctly through the OpenTelemetry pipeline using in-memory exporters: Metrics (1 comprehensive test): - All 20 metric names registered correctly - Counter values (push/delete/pull/zombie/execution/failed/started) - Gauge values with attributes (queue count by tag, worker busy, db pool, health) - Histogram values (execution duration, pull duration) - Health status phase encoding (healthy=1, degraded=0, unhealthy=0) Spans (6 tests): - Root job span created with "full_job" name and Ok status - Error status with "Job failed" description on failure - trace_id derived from job UUID - span_id derived from job UUID low bits - Child jobs (with parent_job) produce no span - Attribute values (job_id, workspace_id, script_path) match job data Also: - Add testing feature to opentelemetry_sdk for InMemoryMetricExporter - Update otel_oss.rs for SdkTracer type rename in 0.30 - Add opentelemetry/opentelemetry_sdk to dev-dependencies Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: remove unit tests in favor of E2E OTEL tests The E2E integration tests in backend/tests/otel.rs cover the same ground more thoroughly with in-memory exporters. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- backend/Cargo.lock | 2 + backend/Cargo.toml | 4 +- backend/tests/otel.rs | 504 ++++++++++++++++++++ backend/windmill-common/src/otel_oss.rs | 2 +- backend/windmill-common/src/tracing_init.rs | 1 + backend/windmill-worker/src/worker.rs | 1 + 6 files changed, 512 insertions(+), 2 deletions(-) create mode 100644 backend/tests/otel.rs diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 11c743a643..a476bded1f 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -15828,6 +15828,8 @@ dependencies = [ "git-version", "lazy_static", "once_cell", + "opentelemetry 0.30.0", + "opentelemetry_sdk 0.30.0", "prometheus", "rand 0.9.0", "rdkafka", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index b8e4d3d593..31d5665cd7 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -260,6 +260,8 @@ windmill-dep-map.workspace = true windmill-test-utils.workspace = true windmill-worker-volumes.workspace = true windmill-types.workspace = true +opentelemetry = { workspace = true } +opentelemetry_sdk = { workspace = true } windmill-trigger.workspace = true windmill-trigger-websocket.workspace = true windmill-trigger-postgres.workspace = true @@ -568,7 +570,7 @@ async-stream = "^0" opentelemetry = "0.30.0" tracing-opentelemetry = "0.31.0" -opentelemetry_sdk = { version = "0.30.0", features = ["rt-tokio"] } +opentelemetry_sdk = { version = "0.30.0", features = ["rt-tokio", "testing"] } opentelemetry-otlp = { version = "0.30.0", features = ["grpc-tonic", "tls"] } opentelemetry-appender-tracing = "0.30.0" opentelemetry-semantic-conventions = { version = "0.30.0", features = ["semconv_experimental"] } diff --git a/backend/tests/otel.rs b/backend/tests/otel.rs new file mode 100644 index 0000000000..dd56cbf425 --- /dev/null +++ b/backend/tests/otel.rs @@ -0,0 +1,504 @@ +//! E2E tests for OpenTelemetry integration. +//! +//! Verify that metrics are recorded with correct names/values/attributes and +//! spans are created with correct trace IDs, attributes, and status codes. +//! +//! Run with: cargo test --features enterprise,private,otel --test otel -- --test-threads=1 + +#![cfg(all(feature = "otel", feature = "enterprise"))] + +use std::sync::{atomic::Ordering, Arc}; + +use opentelemetry::global; +use opentelemetry::trace::TracerProvider as _; +use opentelemetry_sdk::{ + metrics::{InMemoryMetricExporter, PeriodicReader, SdkMeterProvider}, + trace::{InMemorySpanExporter, SdkTracerProvider, SimpleSpanProcessor}, +}; +use windmill_common::otel_ee::*; +use windmill_common::{OTEL_METRICS_ENABLED, OTEL_TRACING_ENABLED}; + +// ── Global test infrastructure ────────────────────────────────────────── + +struct OtelTestState { + metric_exporter: InMemoryMetricExporter, + span_exporter: InMemorySpanExporter, + meter_provider: SdkMeterProvider, +} + +static STATE: tokio::sync::OnceCell> = tokio::sync::OnceCell::const_new(); + +async fn ensure_setup() -> Arc { + STATE + .get_or_init(|| async { + // Metrics: InMemoryMetricExporter + PeriodicReader (needs async tokio context) + let metric_exporter = InMemoryMetricExporter::default(); + let reader = PeriodicReader::builder(metric_exporter.clone()).build(); + let meter_provider = SdkMeterProvider::builder().with_reader(reader).build(); + global::set_meter_provider(meter_provider.clone()); + OTEL_METRICS_ENABLED.store(true, Ordering::SeqCst); + + // Tracing: InMemorySpanExporter + SimpleSpanProcessor + let span_exporter = InMemorySpanExporter::default(); + let tracer_provider = SdkTracerProvider::builder() + .with_span_processor(SimpleSpanProcessor::new(span_exporter.clone())) + .build(); + let tracer = tracer_provider.tracer("windmill"); + *TRACER.write().unwrap() = Some(tracer); + OTEL_TRACING_ENABLED.store(true, Ordering::SeqCst); + + Arc::new(OtelTestState { metric_exporter, span_exporter, meter_provider }) + }) + .await + .clone() +} + +// ── Metric helper: flush + collect ────────────────────────────────────── + +fn flush_and_get_metrics( + state: &OtelTestState, +) -> Vec { + state.meter_provider.force_flush().expect("flush failed"); + state + .metric_exporter + .get_finished_metrics() + .expect("get_finished_metrics failed") +} + +fn find_metric<'a>( + all: &'a [opentelemetry_sdk::metrics::data::ResourceMetrics], + name: &str, +) -> Option<&'a opentelemetry_sdk::metrics::data::Metric> { + all.iter() + .flat_map(|rm| rm.scope_metrics()) + .flat_map(|sm| sm.metrics()) + .find(|m| m.name() == name) +} + +fn metric_names(all: &[opentelemetry_sdk::metrics::data::ResourceMetrics]) -> Vec { + all.iter() + .flat_map(|rm| rm.scope_metrics()) + .flat_map(|sm| sm.metrics()) + .map(|m| m.name().to_string()) + .collect() +} + +// ── Counter value helpers ─────────────────────────────────────────────── + +fn sum_u64_value(metric: &opentelemetry_sdk::metrics::data::Metric) -> Option { + use opentelemetry_sdk::metrics::data::{AggregatedMetrics, MetricData}; + match metric.data() { + AggregatedMetrics::U64(MetricData::Sum(sum)) => { + Some(sum.data_points().map(|dp| dp.value()).sum()) + } + _ => None, + } +} + +fn gauge_i64_values( + metric: &opentelemetry_sdk::metrics::data::Metric, +) -> Vec<(Vec, i64)> { + use opentelemetry_sdk::metrics::data::{AggregatedMetrics, MetricData}; + match metric.data() { + AggregatedMetrics::I64(MetricData::Gauge(gauge)) => gauge + .data_points() + .map(|dp| (dp.attributes().cloned().collect(), dp.value())) + .collect(), + _ => panic!("expected I64 Gauge metric"), + } +} + +fn gauge_f64_value(metric: &opentelemetry_sdk::metrics::data::Metric) -> Option { + use opentelemetry_sdk::metrics::data::{AggregatedMetrics, MetricData}; + match metric.data() { + AggregatedMetrics::F64(MetricData::Gauge(gauge)) => { + gauge.data_points().next().map(|dp| dp.value()) + } + _ => None, + } +} + +fn histogram_f64_count(metric: &opentelemetry_sdk::metrics::data::Metric) -> Option { + use opentelemetry_sdk::metrics::data::{AggregatedMetrics, MetricData}; + match metric.data() { + AggregatedMetrics::F64(MetricData::Histogram(hist)) => { + Some(hist.data_points().map(|dp| dp.count()).sum()) + } + _ => None, + } +} + +fn histogram_f64_sum(metric: &opentelemetry_sdk::metrics::data::Metric) -> Option { + use opentelemetry_sdk::metrics::data::{AggregatedMetrics, MetricData}; + match metric.data() { + AggregatedMetrics::F64(MetricData::Histogram(hist)) => { + Some(hist.data_points().map(|dp| dp.sum()).sum()) + } + _ => None, + } +} + +// ═══════════════════════════════════════════════════════════════════════ +// METRICS E2E TEST +// +// All metric assertions live in one test function because the PeriodicReader's +// background task is tied to the tokio runtime that created it. Separate +// #[tokio::test] functions each get their own runtime, and the reader becomes +// disconnected after the first test's runtime is dropped. +// ═══════════════════════════════════════════════════════════════════════ + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_all_metrics_e2e() { + let state = ensure_setup().await; + + // ── Counters ──────────────────────────────────────────────────── + + otel_incr_queue_push_count(); + otel_incr_queue_push_count(); + otel_incr_queue_push_count(); + otel_incr_queue_delete_count(); + otel_incr_queue_pull_count(); + otel_incr_zombie_restart_count(7); + otel_incr_zombie_delete_count(3); + otel_incr_worker_execution_count("bun"); + otel_incr_worker_execution_count("bun"); + otel_incr_worker_execution_failed("go"); + otel_incr_worker_started(); + + // ── Gauges ────────────────────────────────────────────────────── + + otel_set_queue_count("python3", 42); + otel_set_queue_running_count("deno", 5); + otel_set_worker_busy("worker-test-1", 1); + otel_set_db_pool(5, 10, 20); + otel_set_health_db_latency(2.5); + otel_set_worker_uptime("w-uptime", 3600.0); + otel_set_health_status_phase("healthy"); + otel_set_health_db_unresponsive(true); + + // ── Histograms ────────────────────────────────────────────────── + + otel_record_worker_execution_duration("python3", 1.5); + otel_record_worker_execution_duration("python3", 2.5); + otel_record_worker_pull_duration("w1", true, 0.05); + otel_record_worker_pull_duration("w1", false, 0.01); + + // ── Flush and collect ─────────────────────────────────────────── + + let metrics = flush_and_get_metrics(&state); + let names = metric_names(&metrics); + + // ── Verify all 20 metric names are present ────────────────────── + + let expected = [ + "windmill.queue.push_count", + "windmill.queue.delete_count", + "windmill.queue.pull_count", + "windmill.queue.zombie_restart_count", + "windmill.queue.zombie_delete_count", + "windmill.queue.count", + "windmill.queue.running_count", + "windmill.worker.execution_count", + "windmill.worker.execution_duration", + "windmill.worker.busy", + "windmill.worker.pull_duration", + "windmill.worker.execution_failed", + "windmill.db.pool.active", + "windmill.db.pool.idle", + "windmill.db.pool.max", + "windmill.health.db_latency", + "windmill.worker.started", + "windmill.worker.uptime", + "windmill.health.status", + "windmill.health.db_unresponsive", + ]; + for name in expected { + assert!( + names.iter().any(|n| n == name), + "metric '{}' not found in {:?}", + name, + names + ); + } + + // ── Counter values ────────────────────────────────────────────── + + let m = find_metric(&metrics, "windmill.queue.push_count").unwrap(); + assert!(sum_u64_value(m).unwrap() >= 3, "push_count should be >= 3"); + + let m = find_metric(&metrics, "windmill.queue.delete_count").unwrap(); + assert!(sum_u64_value(m).unwrap() >= 1); + + let m = find_metric(&metrics, "windmill.queue.pull_count").unwrap(); + assert!(sum_u64_value(m).unwrap() >= 1); + + let m = find_metric(&metrics, "windmill.queue.zombie_restart_count").unwrap(); + assert!(sum_u64_value(m).unwrap() >= 7); + + let m = find_metric(&metrics, "windmill.queue.zombie_delete_count").unwrap(); + assert!(sum_u64_value(m).unwrap() >= 3); + + let m = find_metric(&metrics, "windmill.worker.execution_count").unwrap(); + assert!(sum_u64_value(m).unwrap() >= 2); + + let m = find_metric(&metrics, "windmill.worker.execution_failed").unwrap(); + assert!(sum_u64_value(m).unwrap() >= 1); + + let m = find_metric(&metrics, "windmill.worker.started").unwrap(); + assert!(sum_u64_value(m).unwrap() >= 1); + + // ── Gauge values ──────────────────────────────────────────────── + + let m = find_metric(&metrics, "windmill.queue.count").unwrap(); + let values = gauge_i64_values(m); + let dp = values + .iter() + .find(|(attrs, _)| { + attrs + .iter() + .any(|kv| kv.key.as_str() == "tag" && kv.value.as_str() == "python3") + }) + .expect("queue.count data point with tag=python3 not found"); + assert_eq!(dp.1, 42); + + let m = find_metric(&metrics, "windmill.queue.running_count").unwrap(); + let values = gauge_i64_values(m); + let dp = values + .iter() + .find(|(attrs, _)| { + attrs + .iter() + .any(|kv| kv.key.as_str() == "tag" && kv.value.as_str() == "deno") + }) + .expect("running_count data point with tag=deno not found"); + assert_eq!(dp.1, 5); + + let m = find_metric(&metrics, "windmill.worker.busy").unwrap(); + let values = gauge_i64_values(m); + let dp = values + .iter() + .find(|(attrs, _)| { + attrs + .iter() + .any(|kv| kv.key.as_str() == "worker" && kv.value.as_str() == "worker-test-1") + }) + .expect("worker.busy data point with worker=worker-test-1 not found"); + assert_eq!(dp.1, 1); + + let m = find_metric(&metrics, "windmill.db.pool.active").unwrap(); + assert_eq!(gauge_i64_values(m)[0].1, 5); + let m = find_metric(&metrics, "windmill.db.pool.idle").unwrap(); + assert_eq!(gauge_i64_values(m)[0].1, 10); + let m = find_metric(&metrics, "windmill.db.pool.max").unwrap(); + assert_eq!(gauge_i64_values(m)[0].1, 20); + + let m = find_metric(&metrics, "windmill.health.db_latency").unwrap(); + assert!((gauge_f64_value(m).unwrap() - 2.5).abs() < f64::EPSILON); + + let m = find_metric(&metrics, "windmill.worker.uptime").unwrap(); + assert!((gauge_f64_value(m).unwrap() - 3600.0).abs() < f64::EPSILON); + + let m = find_metric(&metrics, "windmill.health.db_unresponsive").unwrap(); + assert_eq!(gauge_i64_values(m)[0].1, 1); + + // ── Health status phase (all 3 phases) ────────────────────────── + + let m = find_metric(&metrics, "windmill.health.status").unwrap(); + let values = gauge_i64_values(m); + let healthy = values + .iter() + .find(|(attrs, _)| { + attrs + .iter() + .any(|kv| kv.key.as_str() == "phase" && kv.value.as_str() == "healthy") + }) + .expect("phase=healthy"); + let degraded = values + .iter() + .find(|(attrs, _)| { + attrs + .iter() + .any(|kv| kv.key.as_str() == "phase" && kv.value.as_str() == "degraded") + }) + .expect("phase=degraded"); + let unhealthy = values + .iter() + .find(|(attrs, _)| { + attrs + .iter() + .any(|kv| kv.key.as_str() == "phase" && kv.value.as_str() == "unhealthy") + }) + .expect("phase=unhealthy"); + assert_eq!(healthy.1, 1); + assert_eq!(degraded.1, 0); + assert_eq!(unhealthy.1, 0); + + // ── Histogram values ──────────────────────────────────────────── + + let m = find_metric(&metrics, "windmill.worker.execution_duration").unwrap(); + assert!(histogram_f64_count(m).unwrap() >= 2); + assert!(histogram_f64_sum(m).unwrap() >= 4.0); + + let m = find_metric(&metrics, "windmill.worker.pull_duration").unwrap(); + assert!(histogram_f64_count(m).unwrap() >= 2); +} + +// ═══════════════════════════════════════════════════════════════════════ +// SPAN E2E TESTS +// ═══════════════════════════════════════════════════════════════════════ + +fn make_test_job(id: uuid::Uuid, parent: Option) -> windmill_queue::MiniPulledJob { + use windmill_types::jobs::JobKind; + let mut job = windmill_queue::MiniPulledJob::new_inline( + "test-workspace".to_string(), + None, + "test-user".to_string(), + "u/test-user".to_string(), + "test@example.com".to_string(), + Some("f/test/script".to_string()), + JobKind::Script, + None, + "deno".to_string(), + None, + ); + job.id = id; + job.parent_job = parent; + job.started_at = Some(chrono::Utc::now()); + job +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_root_job_span_created_on_success() { + let state = ensure_setup().await; + state.span_exporter.reset(); + + let job_id = uuid::Uuid::new_v4(); + let job = make_test_job(job_id, None); + windmill_worker::otel_ee::add_root_flow_job_to_otlp(&job, true); + + let spans = state.span_exporter.get_finished_spans().unwrap(); + let span = spans + .iter() + .find(|s| s.name == "full_job") + .expect("full_job span not found"); + + assert_eq!(span.status, opentelemetry::trace::Status::Ok,); + + // Verify attributes + let attrs: Vec<_> = span.attributes.iter().map(|kv| kv.key.as_str()).collect(); + assert!(attrs.contains(&"job_id"), "missing job_id attribute"); + assert!( + attrs.contains(&"workspace_id"), + "missing workspace_id attribute" + ); + assert!( + attrs.contains(&"script_path"), + "missing script_path attribute" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_root_job_span_error_on_failure() { + let state = ensure_setup().await; + state.span_exporter.reset(); + + let job_id = uuid::Uuid::new_v4(); + let job = make_test_job(job_id, None); + windmill_worker::otel_ee::add_root_flow_job_to_otlp(&job, false); + + let spans = state.span_exporter.get_finished_spans().unwrap(); + let span = spans + .iter() + .find(|s| s.name == "full_job") + .expect("full_job span not found"); + + match &span.status { + opentelemetry::trace::Status::Error { description } => { + assert_eq!(description.as_ref(), "Job failed"); + } + other => panic!("expected Error status, got {:?}", other), + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_root_job_trace_id_matches_uuid() { + let state = ensure_setup().await; + state.span_exporter.reset(); + + let job_id = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(); + let job = make_test_job(job_id, None); + windmill_worker::otel_ee::add_root_flow_job_to_otlp(&job, true); + + let spans = state.span_exporter.get_finished_spans().unwrap(); + let span = spans + .iter() + .find(|s| s.name == "full_job") + .expect("full_job span not found"); + + let expected_trace_id = + opentelemetry::trace::TraceId::from_bytes(job_id.as_u128().to_be_bytes()); + assert_eq!(span.span_context.trace_id(), expected_trace_id); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_root_job_span_id_matches_uuid() { + let state = ensure_setup().await; + state.span_exporter.reset(); + + let job_id = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(); + let job = make_test_job(job_id, None); + windmill_worker::otel_ee::add_root_flow_job_to_otlp(&job, true); + + let spans = state.span_exporter.get_finished_spans().unwrap(); + let span = spans + .iter() + .find(|s| s.name == "full_job") + .expect("full_job span not found"); + + let expected_span_id = + opentelemetry::trace::SpanId::from_bytes(job_id.as_u64_pair().1.to_be_bytes()); + assert_eq!(span.span_context.span_id(), expected_span_id); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_child_job_produces_no_span() { + let state = ensure_setup().await; + state.span_exporter.reset(); + + let parent_id = uuid::Uuid::new_v4(); + let job_id = uuid::Uuid::new_v4(); + let job = make_test_job(job_id, Some(parent_id)); + windmill_worker::otel_ee::add_root_flow_job_to_otlp(&job, true); + + let spans = state.span_exporter.get_finished_spans().unwrap(); + let found = spans.iter().any(|s| s.name == "full_job"); + assert!(!found, "child job should not produce a span"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_root_job_span_attributes_values() { + let state = ensure_setup().await; + state.span_exporter.reset(); + + let job_id = uuid::Uuid::new_v4(); + let job = make_test_job(job_id, None); + windmill_worker::otel_ee::add_root_flow_job_to_otlp(&job, true); + + let spans = state.span_exporter.get_finished_spans().unwrap(); + let span = spans + .iter() + .find(|s| s.name == "full_job") + .expect("full_job span not found"); + + let get_attr = |key: &str| -> String { + span.attributes + .iter() + .find(|kv| kv.key.as_str() == key) + .map(|kv| kv.value.as_str().to_string()) + .unwrap_or_default() + }; + + assert_eq!(get_attr("job_id"), job_id.to_string()); + assert_eq!(get_attr("workspace_id"), "test-workspace"); + assert_eq!(get_attr("script_path"), "f/test/script"); +} diff --git a/backend/windmill-common/src/otel_oss.rs b/backend/windmill-common/src/otel_oss.rs index 3710464607..27c7101dc0 100644 --- a/backend/windmill-common/src/otel_oss.rs +++ b/backend/windmill-common/src/otel_oss.rs @@ -59,7 +59,7 @@ pub(crate) fn init_otlp_tracer( _mode: &Mode, _hostname: &str, _env: &str, -) -> Option { +) -> Option { None } diff --git a/backend/windmill-common/src/tracing_init.rs b/backend/windmill-common/src/tracing_init.rs index 3b009a36eb..1a2ca33518 100644 --- a/backend/windmill-common/src/tracing_init.rs +++ b/backend/windmill-common/src/tracing_init.rs @@ -273,3 +273,4 @@ where } } } + diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index b75403bef7..8aeb3a9e5e 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -5412,3 +5412,4 @@ pub fn get_worker_internal_server_inline_utils( )), } } + From dc75b73edcbee0adc27cd8fef348b0f7c357bb80 Mon Sep 17 00:00:00 2001 From: Alexander Petric Date: Fri, 27 Mar 2026 14:41:10 -0400 Subject: [PATCH 027/111] improve logging for github app operations (#8568) * improve logging for github app operations * ee ref * chore: update ee-repo-ref to 0b9e92f9e089293c6d523b77ed2c11edbc7a99c0 This commit updates the EE repository reference after PR #489 was merged in windmill-ee-private. Previous ee-repo-ref: b259642e7f36b83a991034d5b28ae616f94ee5fc New ee-repo-ref: 0b9e92f9e089293c6d523b77ed2c11edbc7a99c0 Automated by sync-ee-ref workflow. --------- Co-authored-by: windmill-internal-app[bot] Co-authored-by: Ruben Fiszel --- backend/windmill-common/src/workspaces.rs | 2 +- frontend/src/lib/hubPaths.json | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index 444df60a0c..1d105e24db 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -149,7 +149,7 @@ pub enum ObjectType { WorkspaceDependencies, } -pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28180/sync-script-to-git-repo-windmill"; +pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28183/sync-script-to-git-repo-windmill"; #[derive(Serialize, Deserialize, Debug)] pub struct GitRepositorySettings { diff --git a/frontend/src/lib/hubPaths.json b/frontend/src/lib/hubPaths.json index ccdc663d18..86b8d27ef5 100644 --- a/frontend/src/lib/hubPaths.json +++ b/frontend/src/lib/hubPaths.json @@ -25,10 +25,12 @@ "deprecated_gitSync_23": "hub/28160/sync-script-to-git-repo-windmill", "deprecated_gitSync_24": "hub/28176/sync-script-to-git-repo-windmill", "deprecated_gitSync_latest": "hub/28180/sync-script-to-git-repo-windmill", - "gitSyncTest": "hub/28177/git-repo-test-read-write-windmill", + "deprecated_gitSync_25": "hub/28183/sync-script-to-git-repo-windmill", + "gitSyncTest": "hub/28184/git-repo-test-read-write-windmill", "gitInitRepo_0": "hub/28134/git-sync%3A-init-repository-windmill", "gitInitRepo_1": "hub/28158/git-sync%3A-init-repository-windmill", - "gitInitRepo": "hub/28174/git-sync%3A-init-repository-windmill", + "gitInitRepo_2": "hub/28174/git-sync%3A-init-repository-windmill", + "gitInitRepo": "hub/28181/git-sync%3A-init-repository-windmill", "slackErrorHandler": "hub/19741/workspace-or-schedule-error-handler-slack", "slackErrorHandler_0": "hub/9079/workspace-or-schedule-error-handler-slack", "slackErrorHandler_1": "hub/9206/workspace-or-schedule-error-handler-slack", @@ -45,5 +47,6 @@ "appReport": "hub/28076/app-report", "cloneRepoToS3forGitRepoViewer_0": "hub/19825/clone_repo_and_upload_to_instance_storage", "cloneRepoToS3forGitRepoViewer_1": "hub/19827/clone_repo_and_upload_to_instance_storage", - "cloneRepoToS3forGitRepoViewer": "hub/28175/clone_repo_and_upload_to_instance_storage" + "cloneRepoToS3forGitRepoViewer_2": "hub/28175/clone_repo_and_upload_to_instance_storage", + "cloneRepoToS3forGitRepoViewer": "hub/28182/clone_repo_and_upload_to_instance_storage" } From 3959fe82974f5f0383e94fd83a5d78fe4212d56a Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 27 Mar 2026 19:23:03 +0000 Subject: [PATCH 028/111] feat: add workspace-level service accounts (#8560) * feat: add workspace-level service accounts (EE) Co-Authored-By: Claude Opus 4.6 (1M context) * sqlx * sqlx * chore: update ee-repo-ref Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- ...05768ed9a679ba908ab16497a9bd55578ba1.json} | 12 +- ...685848af38a3461257a9734c43cbd7bd905cb.json | 35 ++ ...b81763d8650c1316bb0b20816f1a5d61a678c.json | 8 +- ...ea032b00fc9bd7a6db22f530f67eb9730fa3b.json | 8 +- ...8c4aed45e6b8f409bb33c2681f92265922040.json | 24 + ...4046f7586e36b7bd6a4679d70c359d4aacfcf.json | 20 + ...de553031e581b1bb173413a9a3e3eb0817b43.json | 16 + ...3bc9c48f71a44827ba0d01ac5588dc31082a2.json | 8 +- ...a2c1a2f63ab88cdbc79ffad96c6f9ffb5764b.json | 20 + ...02a9bd039d16f7dfb11e22d16ff9090456853.json | 16 + backend/ee-repo-ref.txt | 2 +- .../20260326200000_service_accounts.down.sql | 1 + .../20260326200000_service_accounts.up.sql | 1 + .../20260327000000_email_varchar_255.down.sql | 2 + .../20260327000000_email_varchar_255.up.sql | 2 + backend/windmill-api-auth/src/auth.rs | 18 +- backend/windmill-api-users/src/lib.rs | 3 + backend/windmill-api-users/src/users.rs | 77 ++- backend/windmill-api-users/src/users_oss.rs | 28 ++ .../windmill-api-workspaces/src/workspaces.rs | 15 + .../src/workspaces_oss.rs | 16 +- backend/windmill-api/openapi.yaml | 83 ++++ frontend/src/lib/components/AddUser.svelte | 114 +++-- .../settings/WorkspaceUserSettings.svelte | 80 +++- .../components/sidebar/OperatorMenu.svelte | 444 +++++++++--------- frontend/src/lib/stores.ts | 1 + .../src/routes/(root)/(logged)/+layout.svelte | 34 +- 27 files changed, 814 insertions(+), 274 deletions(-) rename backend/.sqlx/{query-6aabe704395c9be30c86d15a5d22f3509b4fcea56227b019588837132b64d58b.json => query-1cf8597b9d37ec5a924aff8cbc0a05768ed9a679ba908ab16497a9bd55578ba1.json} (75%) create mode 100644 backend/.sqlx/query-544a02447bb2cbe8354a5c4ae93685848af38a3461257a9734c43cbd7bd905cb.json create mode 100644 backend/.sqlx/query-68d1370fa02f4fe585684a91e898c4aed45e6b8f409bb33c2681f92265922040.json create mode 100644 backend/.sqlx/query-a37c2c4d5656d4b44433de84c454046f7586e36b7bd6a4679d70c359d4aacfcf.json create mode 100644 backend/.sqlx/query-add01e9e31d64e88b84c9505fe3de553031e581b1bb173413a9a3e3eb0817b43.json create mode 100644 backend/.sqlx/query-f4ad2cf2438c2ae31e388517d09a2c1a2f63ab88cdbc79ffad96c6f9ffb5764b.json create mode 100644 backend/.sqlx/query-f8654d5f50a80d862edbf57355502a9bd039d16f7dfb11e22d16ff9090456853.json create mode 100644 backend/migrations/20260326200000_service_accounts.down.sql create mode 100644 backend/migrations/20260326200000_service_accounts.up.sql create mode 100644 backend/migrations/20260327000000_email_varchar_255.down.sql create mode 100644 backend/migrations/20260327000000_email_varchar_255.up.sql create mode 100644 backend/windmill-api-users/src/users_oss.rs diff --git a/backend/.sqlx/query-6aabe704395c9be30c86d15a5d22f3509b4fcea56227b019588837132b64d58b.json b/backend/.sqlx/query-1cf8597b9d37ec5a924aff8cbc0a05768ed9a679ba908ab16497a9bd55578ba1.json similarity index 75% rename from backend/.sqlx/query-6aabe704395c9be30c86d15a5d22f3509b4fcea56227b019588837132b64d58b.json rename to backend/.sqlx/query-1cf8597b9d37ec5a924aff8cbc0a05768ed9a679ba908ab16497a9bd55578ba1.json index 6e1b36a97c..b4a9f19f45 100644 --- a/backend/.sqlx/query-6aabe704395c9be30c86d15a5d22f3509b4fcea56227b019588837132b64d58b.json +++ b/backend/.sqlx/query-1cf8597b9d37ec5a924aff8cbc0a05768ed9a679ba908ab16497a9bd55578ba1.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT usr.*, password.super_admin, password.name FROM usr LEFT JOIN password ON usr.email = password.email Where usr.username = $1 AND workspace_id = $2\n ", + "query": "SELECT usr.*, COALESCE(password.super_admin, false) as \"super_admin!\", password.name FROM usr LEFT JOIN password ON usr.email = password.email Where usr.username = $1 AND workspace_id = $2\n ", "describe": { "columns": [ { @@ -50,11 +50,16 @@ }, { "ordinal": 9, - "name": "super_admin", + "name": "is_service_account", "type_info": "Bool" }, { "ordinal": 10, + "name": "super_admin!", + "type_info": "Bool" + }, + { + "ordinal": 11, "name": "name", "type_info": "Varchar" } @@ -76,8 +81,9 @@ true, true, false, + null, true ] }, - "hash": "6aabe704395c9be30c86d15a5d22f3509b4fcea56227b019588837132b64d58b" + "hash": "1cf8597b9d37ec5a924aff8cbc0a05768ed9a679ba908ab16497a9bd55578ba1" } diff --git a/backend/.sqlx/query-544a02447bb2cbe8354a5c4ae93685848af38a3461257a9734c43cbd7bd905cb.json b/backend/.sqlx/query-544a02447bb2cbe8354a5c4ae93685848af38a3461257a9734c43cbd7bd905cb.json new file mode 100644 index 0000000000..2086242e9c --- /dev/null +++ b/backend/.sqlx/query-544a02447bb2cbe8354a5c4ae93685848af38a3461257a9734c43cbd7bd905cb.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT email, is_service_account, disabled FROM usr WHERE username = $1 AND workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "is_service_account", + "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "disabled", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "544a02447bb2cbe8354a5c4ae93685848af38a3461257a9734c43cbd7bd905cb" +} diff --git a/backend/.sqlx/query-5d6adbe21b9f8dd984d1bfc750fb81763d8650c1316bb0b20816f1a5d61a678c.json b/backend/.sqlx/query-5d6adbe21b9f8dd984d1bfc750fb81763d8650c1316bb0b20816f1a5d61a678c.json index 09775dcc3a..79625b6baf 100644 --- a/backend/.sqlx/query-5d6adbe21b9f8dd984d1bfc750fb81763d8650c1316bb0b20816f1a5d61a678c.json +++ b/backend/.sqlx/query-5d6adbe21b9f8dd984d1bfc750fb81763d8650c1316bb0b20816f1a5d61a678c.json @@ -47,6 +47,11 @@ "ordinal": 8, "name": "added_via", "type_info": "Jsonb" + }, + { + "ordinal": 9, + "name": "is_service_account", + "type_info": "Bool" } ], "parameters": { @@ -63,7 +68,8 @@ false, false, true, - true + true, + false ] }, "hash": "5d6adbe21b9f8dd984d1bfc750fb81763d8650c1316bb0b20816f1a5d61a678c" diff --git a/backend/.sqlx/query-60b3a59805d463a61eed68072d1ea032b00fc9bd7a6db22f530f67eb9730fa3b.json b/backend/.sqlx/query-60b3a59805d463a61eed68072d1ea032b00fc9bd7a6db22f530f67eb9730fa3b.json index 3a635ab004..ed09f2833f 100644 --- a/backend/.sqlx/query-60b3a59805d463a61eed68072d1ea032b00fc9bd7a6db22f530f67eb9730fa3b.json +++ b/backend/.sqlx/query-60b3a59805d463a61eed68072d1ea032b00fc9bd7a6db22f530f67eb9730fa3b.json @@ -47,6 +47,11 @@ "ordinal": 8, "name": "added_via", "type_info": "Jsonb" + }, + { + "ordinal": 9, + "name": "is_service_account", + "type_info": "Bool" } ], "parameters": { @@ -64,7 +69,8 @@ false, false, true, - true + true, + false ] }, "hash": "60b3a59805d463a61eed68072d1ea032b00fc9bd7a6db22f530f67eb9730fa3b" diff --git a/backend/.sqlx/query-68d1370fa02f4fe585684a91e898c4aed45e6b8f409bb33c2681f92265922040.json b/backend/.sqlx/query-68d1370fa02f4fe585684a91e898c4aed45e6b8f409bb33c2681f92265922040.json new file mode 100644 index 0000000000..cd1d6810cd --- /dev/null +++ b/backend/.sqlx/query-68d1370fa02f4fe585684a91e898c4aed45e6b8f409bb33c2681f92265922040.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM usr WHERE workspace_id = $1 AND (username = $2 OR email = $3))", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "68d1370fa02f4fe585684a91e898c4aed45e6b8f409bb33c2681f92265922040" +} diff --git a/backend/.sqlx/query-a37c2c4d5656d4b44433de84c454046f7586e36b7bd6a4679d70c359d4aacfcf.json b/backend/.sqlx/query-a37c2c4d5656d4b44433de84c454046f7586e36b7bd6a4679d70c359d4aacfcf.json new file mode 100644 index 0000000000..5661c59faf --- /dev/null +++ b/backend/.sqlx/query-a37c2c4d5656d4b44433de84c454046f7586e36b7bd6a4679d70c359d4aacfcf.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) FROM usr WHERE is_service_account = true", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null + ] + }, + "hash": "a37c2c4d5656d4b44433de84c454046f7586e36b7bd6a4679d70c359d4aacfcf" +} diff --git a/backend/.sqlx/query-add01e9e31d64e88b84c9505fe3de553031e581b1bb173413a9a3e3eb0817b43.json b/backend/.sqlx/query-add01e9e31d64e88b84c9505fe3de553031e581b1bb173413a9a3e3eb0817b43.json new file mode 100644 index 0000000000..2c00759a63 --- /dev/null +++ b/backend/.sqlx/query-add01e9e31d64e88b84c9505fe3de553031e581b1bb173413a9a3e3eb0817b43.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO usr_to_group (workspace_id, usr, group_) VALUES ($1, $2, $3)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "add01e9e31d64e88b84c9505fe3de553031e581b1bb173413a9a3e3eb0817b43" +} diff --git a/backend/.sqlx/query-e5fb3531f8bc7ef1f7484524f8c3bc9c48f71a44827ba0d01ac5588dc31082a2.json b/backend/.sqlx/query-e5fb3531f8bc7ef1f7484524f8c3bc9c48f71a44827ba0d01ac5588dc31082a2.json index cdeb30f672..7be961c050 100644 --- a/backend/.sqlx/query-e5fb3531f8bc7ef1f7484524f8c3bc9c48f71a44827ba0d01ac5588dc31082a2.json +++ b/backend/.sqlx/query-e5fb3531f8bc7ef1f7484524f8c3bc9c48f71a44827ba0d01ac5588dc31082a2.json @@ -47,6 +47,11 @@ "ordinal": 8, "name": "added_via", "type_info": "Jsonb" + }, + { + "ordinal": 9, + "name": "is_service_account", + "type_info": "Bool" } ], "parameters": { @@ -63,7 +68,8 @@ false, false, true, - true + true, + false ] }, "hash": "e5fb3531f8bc7ef1f7484524f8c3bc9c48f71a44827ba0d01ac5588dc31082a2" diff --git a/backend/.sqlx/query-f4ad2cf2438c2ae31e388517d09a2c1a2f63ab88cdbc79ffad96c6f9ffb5764b.json b/backend/.sqlx/query-f4ad2cf2438c2ae31e388517d09a2c1a2f63ab88cdbc79ffad96c6f9ffb5764b.json new file mode 100644 index 0000000000..24d9cd9517 --- /dev/null +++ b/backend/.sqlx/query-f4ad2cf2438c2ae31e388517d09a2c1a2f63ab88cdbc79ffad96c6f9ffb5764b.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO token\n (token_hash, token_prefix, token, email, label, expiration, super_admin, owner)\n VALUES ($1, $2, $3, $4, $5, $6, false, $7)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Timestamptz", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "f4ad2cf2438c2ae31e388517d09a2c1a2f63ab88cdbc79ffad96c6f9ffb5764b" +} diff --git a/backend/.sqlx/query-f8654d5f50a80d862edbf57355502a9bd039d16f7dfb11e22d16ff9090456853.json b/backend/.sqlx/query-f8654d5f50a80d862edbf57355502a9bd039d16f7dfb11e22d16ff9090456853.json new file mode 100644 index 0000000000..117a8bdc1b --- /dev/null +++ b/backend/.sqlx/query-f8654d5f50a80d862edbf57355502a9bd039d16f7dfb11e22d16ff9090456853.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO usr\n (workspace_id, email, username, is_admin, operator, is_service_account)\n VALUES ($1, $2, $3, false, true, true)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "f8654d5f50a80d862edbf57355502a9bd039d16f7dfb11e22d16ff9090456853" +} diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index d0328f83f3..4751795cd9 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -5e8b1bcfc2c9ade9db39c839f2faed4f82da5efc +208da6989ef606e4068663246903acbcaa90a9dc \ No newline at end of file diff --git a/backend/migrations/20260326200000_service_accounts.down.sql b/backend/migrations/20260326200000_service_accounts.down.sql new file mode 100644 index 0000000000..88a50692ab --- /dev/null +++ b/backend/migrations/20260326200000_service_accounts.down.sql @@ -0,0 +1 @@ +ALTER TABLE usr DROP COLUMN is_service_account; diff --git a/backend/migrations/20260326200000_service_accounts.up.sql b/backend/migrations/20260326200000_service_accounts.up.sql new file mode 100644 index 0000000000..b9e96d0baa --- /dev/null +++ b/backend/migrations/20260326200000_service_accounts.up.sql @@ -0,0 +1 @@ +ALTER TABLE usr ADD COLUMN IF NOT EXISTS is_service_account BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/backend/migrations/20260327000000_email_varchar_255.down.sql b/backend/migrations/20260327000000_email_varchar_255.down.sql new file mode 100644 index 0000000000..b5ff4d22c2 --- /dev/null +++ b/backend/migrations/20260327000000_email_varchar_255.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE magic_link ALTER COLUMN email TYPE VARCHAR(50); +ALTER TABLE schedule ALTER COLUMN email TYPE VARCHAR(50); diff --git a/backend/migrations/20260327000000_email_varchar_255.up.sql b/backend/migrations/20260327000000_email_varchar_255.up.sql new file mode 100644 index 0000000000..95adb957b3 --- /dev/null +++ b/backend/migrations/20260327000000_email_varchar_255.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE magic_link ALTER COLUMN email TYPE VARCHAR(255); +ALTER TABLE schedule ALTER COLUMN email TYPE VARCHAR(255); diff --git a/backend/windmill-api-auth/src/auth.rs b/backend/windmill-api-auth/src/auth.rs index f10ae321b9..b5b04cb3e8 100644 --- a/backend/windmill-api-auth/src/auth.rs +++ b/backend/windmill-api-auth/src/auth.rs @@ -225,7 +225,15 @@ impl AuthCache { t_hash, w_id.as_ref(), ) - .map(|x| (x.owner, x.email, x.super_admin, x.scopes, x.label)) + .map(|x| { + ( + x.owner, + x.email, + x.super_admin, + x.scopes, + x.label, + ) + }) .fetch_optional(&self.db) .await .ok() @@ -234,7 +242,13 @@ impl AuthCache { if let Some(user) = user_o { let authed_o = { match user { - (Some(owner), Some(email), super_admin, _, label) if w_id.is_some() => { + ( + Some(owner), + Some(email), + super_admin, + _, + label, + ) if w_id.is_some() => { let username_override = username_override_from_label(label); if let Some((prefix, name)) = owner.split_once('/') { if prefix == "u" { diff --git a/backend/windmill-api-users/src/lib.rs b/backend/windmill-api-users/src/lib.rs index 913bd46b82..ee5369e616 100644 --- a/backend/windmill-api-users/src/lib.rs +++ b/backend/windmill-api-users/src/lib.rs @@ -1 +1,4 @@ pub mod users; +#[cfg(feature = "private")] +pub mod users_ee; +mod users_oss; diff --git a/backend/windmill-api-users/src/users.rs b/backend/windmill-api-users/src/users.rs index b3b32859c7..4bb61183ce 100644 --- a/backend/windmill-api-users/src/users.rs +++ b/backend/windmill-api-users/src/users.rs @@ -58,7 +58,7 @@ use windmill_common::{ use windmill_common::{BASE_URL, HUB_BASE_URL}; use windmill_git_sync::handle_deployment_metadata; -const COOKIE_PATH: &str = "/"; +pub const COOKIE_PATH: &str = "/"; pub fn workspaced_service() -> Router { Router::new() @@ -75,6 +75,11 @@ pub fn workspaced_service() -> Router { .route("/whoami", get(whoami)) .route("/leave", post(leave_workspace)) .route("/username_to_email/{username}", get(username_to_email)) + .route( + "/impersonate_service_account", + post(impersonate_service_account), + ) + .route("/exit_impersonation", post(exit_impersonation)) } pub fn global_service() -> Router { @@ -135,6 +140,7 @@ pub struct User { pub role: Option, #[serde(skip_serializing_if = "Option::is_none")] pub added_via: Option, + pub is_service_account: bool, } #[derive(Serialize)] @@ -176,6 +182,7 @@ pub struct UserInfo { pub folders: Vec, pub folders_owners: Vec, pub name: Option, + pub is_service_account: bool, } #[derive(FromRow, Serialize)] @@ -620,8 +627,9 @@ async fn is_valid_logout_redirect(rd: &str) -> bool { async fn whoami( Extension(db): Extension, Path(w_id): Path, - ApiAuthed { username, email, is_admin, groups, folders, .. }: ApiAuthed, + authed: ApiAuthed, ) -> JsonResult { + let ApiAuthed { username, email, is_admin, groups, folders, .. } = authed; let user = get_user(&w_id, &username, &db).await?; if let Some(user) = user { Ok(Json(user)) @@ -648,6 +656,7 @@ async fn whoami( .into_iter() .filter_map(|x| if x.2 { Some(x.0) } else { None }) .collect(), + is_service_account: false, })) } } @@ -663,11 +672,11 @@ async fn global_whoami( email = $1", email ) - .fetch_one(&db) + .fetch_optional(&db) .await - .map_err(|e| Error::internal_err(format!("fetching global identity: {e:#}"))); + .map_err(|e| Error::internal_err(format!("fetching global identity: {e:#}")))?; - if let Ok(user) = user { + if let Some(user) = user { Ok(Json(user)) } else if std::env::var("SUPERADMIN_SECRET").ok() == Some(token) { Ok(Json(GlobalUserInfo { @@ -685,7 +694,21 @@ async fn global_whoami( disabled: false, })) } else { - Err(user.unwrap_err()) + // Service accounts don't have a password row + Ok(Json(GlobalUserInfo { + email: email.clone(), + login_type: Some("service_account".to_string()), + super_admin: false, + devops: false, + verified: true, + name: None, + company: None, + username: None, + operator_only: Some(true), + first_time_user: false, + role_source: "service_account".to_string(), + disabled: false, + })) } } @@ -736,12 +759,13 @@ pub struct User2 { pub name: Option, #[serde(skip_serializing_if = "Option::is_none")] pub added_via: Option, + pub is_service_account: bool, } async fn get_user(w_id: &str, username: &str, db: &DB) -> Result> { let user = sqlx::query_as!( User2, - "SELECT usr.*, password.super_admin, password.name FROM usr LEFT JOIN password ON usr.email = password.email Where usr.username = $1 AND workspace_id = $2 + "SELECT usr.*, COALESCE(password.super_admin, false) as \"super_admin!\", password.name FROM usr LEFT JOIN password ON usr.email = password.email Where usr.username = $1 AND workspace_id = $2 ", username, w_id @@ -782,6 +806,7 @@ async fn get_user(w_id: &str, username: &str, db: &DB) -> Result, + authed: ApiAuthed, + cookies: Cookies, + Tokened { token: current_token }: Tokened, + Path(w_id): Path, + Json(req): Json, +) -> Result<(StatusCode, String)> { + crate::users_oss::impersonate_service_account(db, authed, cookies, current_token, w_id, req) + .await +} + +#[derive(Deserialize)] +struct ExitImpersonationRequest { + token: String, +} + +async fn exit_impersonation( + cookies: Cookies, + Json(req): Json, +) -> Result { + let mut cookie = tower_cookies::Cookie::new(COOKIE_NAME, req.token); + cookie.set_secure(IS_SECURE.read().await.clone()); + cookie.set_same_site(Some(tower_cookies::cookie::SameSite::Lax)); + cookie.set_http_only(true); + cookie.set_path(COOKIE_PATH); + if COOKIE_DOMAIN.is_some() { + cookie.set_domain(COOKIE_DOMAIN.clone().unwrap()); + } + cookies.add(cookie); + Ok("exited impersonation".to_string()) +} + #[derive(Deserialize)] struct ListTokenQuery { exclude_ephemeral: Option, diff --git a/backend/windmill-api-users/src/users_oss.rs b/backend/windmill-api-users/src/users_oss.rs new file mode 100644 index 0000000000..a42cce8405 --- /dev/null +++ b/backend/windmill-api-users/src/users_oss.rs @@ -0,0 +1,28 @@ +#[cfg(feature = "private")] +#[allow(unused)] +pub use crate::users_ee::*; + +#[cfg(not(feature = "private"))] +use crate::users::ImpersonateServiceAccountRequest; +#[cfg(not(feature = "private"))] +use http::StatusCode; +#[cfg(not(feature = "private"))] +use tower_cookies::Cookies; +#[cfg(not(feature = "private"))] +use windmill_api_auth::ApiAuthed; +#[cfg(not(feature = "private"))] +use windmill_common::DB; + +#[cfg(not(feature = "private"))] +pub async fn impersonate_service_account( + _db: DB, + _authed: ApiAuthed, + _cookies: Cookies, + _current_token: String, + _w_id: String, + _req: ImpersonateServiceAccountRequest, +) -> windmill_common::error::Result<(StatusCode, String)> { + Err(windmill_common::error::Error::BadRequest( + "Service accounts require Windmill Enterprise Edition".to_string(), + )) +} diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 08b9d4d3d6..db09f16393 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -75,6 +75,7 @@ pub fn workspaced_service() -> Router { .route("/archive", post(archive_workspace)) .route("/invite_user", post(invite_user)) .route("/add_user", post(add_user)) + .route("/create_service_account", post(create_service_account)) .route("/delete_invite", post(delete_invite)) .route("/rebuild_dependency_map", post(rebuild_dependency_map)) .route("/get_dependency_map", get(get_dependency_map)) @@ -4232,6 +4233,20 @@ If you do not have an account on {}, login with SSO or ask an admin to create an )) } +#[derive(Deserialize)] +pub struct NewServiceAccount { + pub username: String, +} + +async fn create_service_account( + authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, + Json(nu): Json, +) -> Result<(StatusCode, String)> { + crate::workspaces_oss::create_service_account(authed, db, w_id, nu).await +} + async fn delete_invite( ApiAuthed { username, is_admin, .. }: ApiAuthed, Extension(db): Extension, diff --git a/backend/windmill-api-workspaces/src/workspaces_oss.rs b/backend/windmill-api-workspaces/src/workspaces_oss.rs index da46622c26..872061e554 100644 --- a/backend/windmill-api-workspaces/src/workspaces_oss.rs +++ b/backend/windmill-api-workspaces/src/workspaces_oss.rs @@ -3,7 +3,9 @@ pub use crate::workspaces_ee::*; #[cfg(not(feature = "private"))] -use crate::workspaces::EditAutoInvite; +use crate::workspaces::{EditAutoInvite, NewServiceAccount}; +#[cfg(not(feature = "private"))] +use http::StatusCode; #[cfg(not(feature = "private"))] use windmill_api_auth::ApiAuthed; #[cfg(not(feature = "private"))] @@ -20,3 +22,15 @@ pub async fn edit_auto_invite( "Not implemented on OSS".to_string(), )) } + +#[cfg(not(feature = "private"))] +pub async fn create_service_account( + _authed: ApiAuthed, + _db: DB, + _w_id: String, + _nu: NewServiceAccount, +) -> windmill_common::error::Result<(StatusCode, String)> { + Err(windmill_common::error::Error::BadRequest( + "Service accounts require Windmill Enterprise Edition".to_string(), + )) +} diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index aa3505eec7..082298ab03 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -2125,6 +2125,87 @@ paths: schema: type: string + /w/{workspace}/workspaces/create_service_account: + post: + summary: create a service account + operationId: createServiceAccount + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + username: + type: string + required: + - username + responses: + "201": + description: service account created + content: + text/plain: + schema: + type: string + + /w/{workspace}/users/impersonate_service_account: + post: + summary: impersonate a service account + operationId: impersonateServiceAccount + tags: + - user + parameters: + - $ref: "#/components/parameters/WorkspaceId" + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + username: + type: string + required: + - username + responses: + "201": + description: impersonation token + content: + text/plain: + schema: + type: string + + /w/{workspace}/users/exit_impersonation: + post: + summary: exit service account impersonation + operationId: exitImpersonation + tags: + - user + parameters: + - $ref: "#/components/parameters/WorkspaceId" + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + token: + type: string + required: + - token + responses: + "200": + description: exited impersonation + content: + text/plain: + schema: + type: string + /w/{workspace}/workspaces/delete_invite: post: summary: delete user invite @@ -20123,6 +20204,8 @@ components: nullable: true allOf: - $ref: "#/components/schemas/UserSource" + is_service_account: + type: boolean required: - email - username diff --git a/frontend/src/lib/components/AddUser.svelte b/frontend/src/lib/components/AddUser.svelte index 42f0e0430a..c537740480 100644 --- a/frontend/src/lib/components/AddUser.svelte +++ b/frontend/src/lib/components/AddUser.svelte @@ -1,6 +1,6 @@ @@ -80,15 +91,27 @@ {/snippet} {#snippet content()} -
+
Add a new user - Email - + {#if isServiceAccount} + Username + + {:else} + Email + - {#if !automateUsernameCreation} - Username - + {#if !automateUsernameCreation} + Username + + {/if} {/if} Role @@ -112,6 +135,13 @@ tooltip="An admin has full control over a specific Windmill workspace, including the ability to manage users, edit entities, and control permissions within the workspace." {item} /> + {/snippet} diff --git a/frontend/src/lib/components/settings/WorkspaceUserSettings.svelte b/frontend/src/lib/components/settings/WorkspaceUserSettings.svelte index 03b57f6d9a..d7221248ee 100644 --- a/frontend/src/lib/components/settings/WorkspaceUserSettings.svelte +++ b/frontend/src/lib/components/settings/WorkspaceUserSettings.svelte @@ -14,9 +14,15 @@ import Tooltip from '$lib/components/Tooltip.svelte' import type { CancelablePromise, User, UserUsage } from '$lib/gen' import { UserService, WorkspaceService, GroupService, type WorkspaceInvite } from '$lib/gen' - import { userStore, workspaceStore, superadmin, globalEmailInvite } from '$lib/stores' + import { + userStore, + workspaceStore, + superadmin, + globalEmailInvite, + enterpriseLicense + } from '$lib/stores' import { sendUserToast } from '$lib/toast' - import { Loader2, Mails, Search, Plus, UserMinus, X } from 'lucide-svelte' + import { Loader2, Mails, Search, Plus, UserMinus, X, Bot, LogIn } from 'lucide-svelte' import Select from '$lib/components/select/Select.svelte' import SearchItems from '../SearchItems.svelte' import Cell from '../table/Cell.svelte' @@ -45,6 +51,8 @@ let selectedNewInstanceGroup: string | undefined = $state(undefined) let selectedNewRole: string | undefined = $state('developer') + // Service account creation + // Available groups for dropdowns - filter out already configured groups let availableGroupItems = $derived( instanceGroups @@ -488,12 +496,14 @@ {#snippet children({ item })} {/if} - {truncate(email, 20)} - {truncate(username, 30)} + + {#if user.is_service_account} + + + {email} + + {:else} + {email} + {/if} + + {username} {#if hasNonManualUsers}
@@ -796,14 +825,21 @@ {/if} {#if usage?.[email] != undefined}{usage?.[email]}{:else}{#if usage != undefined}{usage[email] ?? 0}{:else}{/if}
- {#if added_via?.source === 'instance_group'} + {#if user.is_service_account} +
+ + Operator + + Service accounts are always operators. +
+ {:else if added_via?.source === 'instance_group'}
{is_admin ? 'Admin' : operator ? 'Operator' : 'Developer'} @@ -840,6 +876,7 @@ {#snippet children({ item })}
+ {#if user.is_service_account && $userStore?.is_admin} + + {/if} {#snippet removeUserButton(disabled: boolean)}
diff --git a/frontend/src/lib/stores.ts b/frontend/src/lib/stores.ts index 22251f6aa6..7084b9a951 100644 --- a/frontend/src/lib/stores.ts +++ b/frontend/src/lib/stores.ts @@ -28,6 +28,7 @@ export interface UserExt { pgroups: string[] folders: string[] folders_owners: string[] + is_service_account?: boolean } export interface UserWorkspace { diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index b384b11a1e..43372d8337 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -676,7 +676,7 @@
{/if} {:else} -
+
{/if} @@ -775,6 +775,38 @@
{/if}
+ {#if $userStore?.is_service_account} +
+ + Viewing workspace on behalf of {$userStore.username} + (impersonated by {$userStore.impersonating_email}) + + +
+ {/if} Date: Fri, 27 Mar 2026 20:27:56 +0100 Subject: [PATCH 029/111] fix(cli): preserve inline script files during flow generate-locks (#8561) * fix(cli): preserve inline script files during flow generate-locks Three bugs caused `wmill flow generate-locks` to destroy inline script content and rename files: 1. YAML parser stripped unquoted `!inline` tags (treated as YAML tag, not string prefix), leaving just the filename as script content. Fix: register custom YAML tags for `!inline` and `!inline_fileset`. 2. Inline script files were renamed based on step summaries because `extractInlineScriptsForFlows` was called with empty mapping `{}`. Fix: call existing `extractCurrentMapping()` before replacement and pass the mapping to preserve original filenames. 3. Lock file paths were derived from the assigner instead of the mapped content path, causing inconsistent naming. Fix: derive lock base path from mapped content path when available. Co-Authored-By: Claude Opus 4.6 (1M context) * test(cli): add unit tests for !inline YAML tag and mapping preservation - YAML tag tests: unquoted/quoted !inline parsing, !inline_fileset, nested structures, round-trip stability - Mapping tests: path preservation with mapping, fallthrough without mapping, lock path derivation from mapped content path, mixed mapped/unmapped modules, dotted path handling Co-Authored-By: Claude Opus 4.6 (1M context) * fix(cli): correct yaml parse type cast and inline prefix check Co-Authored-By: Claude Opus 4.6 (1M context) * fix(cli): harden lock path for extensionless files and merge customTags Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- cli/src/commands/flow/flow_metadata.ts | 19 ++++- cli/src/utils/yaml.ts | 39 ++++++++-- ..._scripts_failure_preprocessor_unit.test.ts | 78 +++++++++++++++++++ cli/test/yaml_inline_tag.test.ts | 58 ++++++++++++++ .../src/inline-scripts/extractor.ts | 13 +++- 5 files changed, 195 insertions(+), 12 deletions(-) create mode 100644 cli/test/yaml_inline_tag.test.ts diff --git a/cli/src/commands/flow/flow_metadata.ts b/cli/src/commands/flow/flow_metadata.ts index 1f5d86b8ea..9805391d05 100644 --- a/cli/src/commands/flow/flow_metadata.ts +++ b/cli/src/commands/flow/flow_metadata.ts @@ -18,7 +18,7 @@ import { filterWorkspaceDependenciesForScripts, } from "../../utils/metadata.ts"; import { ScriptLanguage } from "../../utils/script_common.ts"; -import { extractInlineScripts as extractInlineScriptsForFlows } from "../../../windmill-utils-internal/src/inline-scripts/extractor.ts"; +import { extractInlineScripts as extractInlineScriptsForFlows, extractCurrentMapping } from "../../../windmill-utils-internal/src/inline-scripts/extractor.ts"; import { newPathAssigner } from "../../../windmill-utils-internal/src/path-utils/path-assigner.ts"; import { generateHash, getHeaders, writeIfChanged } from "../../utils/utils.ts"; @@ -188,6 +188,17 @@ export async function generateFlowLockInternal( log.info(`Recomputing locks of ${changedScripts.join(", ")} in ${folder}`); } const fileReader = async (path: string) => await readFile(folder + SEP + path, "utf-8"); + + // Capture existing module-ID-to-file-path mapping before replaceInlineScripts + // overwrites the !inline references with actual file content. This preserves + // the original filenames when re-extracting inline scripts after lock generation. + const currentMapping = extractCurrentMapping( + flowValue.value.modules, + {}, + flowValue.value.failure_module, + flowValue.value.preprocessor_module, + ); + // In tree mode, use the tree's staleness info (which includes transitive dependency changes) // to determine which scripts need relocking, instead of only content-changed ones. const locksToRemove = (tree && !legacyBehaviour) @@ -228,16 +239,16 @@ export async function generateFlowLockInternal( }); const inlineScripts = extractInlineScriptsForFlows( flowValue.value.modules, - {}, + currentMapping, SEP, opts.defaultTs, lockAssigner ); if (flowValue.value.failure_module) { - inlineScripts.push(...extractInlineScriptsForFlows([flowValue.value.failure_module], {}, SEP, opts.defaultTs, lockAssigner)); + inlineScripts.push(...extractInlineScriptsForFlows([flowValue.value.failure_module], currentMapping, SEP, opts.defaultTs, lockAssigner)); } if (flowValue.value.preprocessor_module) { - inlineScripts.push(...extractInlineScriptsForFlows([flowValue.value.preprocessor_module], {}, SEP, opts.defaultTs, lockAssigner)); + inlineScripts.push(...extractInlineScriptsForFlows([flowValue.value.preprocessor_module], currentMapping, SEP, opts.defaultTs, lockAssigner)); } inlineScripts.forEach((s) => { writeIfChanged(process.cwd() + SEP + folder + SEP + s.path, s.content); diff --git a/cli/src/utils/yaml.ts b/cli/src/utils/yaml.ts index 9ad247c1fd..52ec682067 100644 --- a/cli/src/utils/yaml.ts +++ b/cli/src/utils/yaml.ts @@ -1,9 +1,35 @@ -import { parse as yamlParse, type ParseOptions } from "yaml"; +import { parse as yamlParse } from "yaml"; +import type { ParseOptions, DocumentOptions, SchemaOptions, ToJSOptions, ScalarTag } from "yaml"; import { readFile } from "node:fs/promises"; -export async function yamlParseFile(path: string, options: ParseOptions = {}) { +// Custom YAML tags that resolve `!inline value` and `!inline_fileset value` +// back to their string-prefix form ("!inline value"). +// Without these, the yaml parser strips the tag and returns just the scalar, +// breaking the string-prefix-based !inline detection used throughout the CLI. +const inlineTag: ScalarTag = { + tag: "!inline", + resolve(value: string) { + return "!inline " + value; + }, +}; + +const inlineFilesetTag: ScalarTag = { + tag: "!inline_fileset", + resolve(value: string) { + return "!inline_fileset " + value; + }, +}; + +const WINDMILL_CUSTOM_TAGS: ScalarTag[] = [inlineTag, inlineFilesetTag]; + +type YamlParseOptions = ParseOptions & DocumentOptions & SchemaOptions & ToJSOptions; + +export async function yamlParseFile(path: string, options: YamlParseOptions = {}) { try { - return yamlParse(await readFile(path, "utf-8"), options); + return yamlParse(await readFile(path, "utf-8"), { + ...options, + customTags: [...WINDMILL_CUSTOM_TAGS, ...((options.customTags as ScalarTag[] | undefined) ?? [])], + }); } catch (e) { throw new Error(`Error parsing yaml ${path}`, { cause: e }); } @@ -12,10 +38,13 @@ export async function yamlParseFile(path: string, options: ParseOptions = {}) { export function yamlParseContent( path: string, content: string, - options: ParseOptions = {}, + options: YamlParseOptions = {}, ) { try { - return yamlParse(content, options); + return yamlParse(content, { + ...options, + customTags: [...WINDMILL_CUSTOM_TAGS, ...((options.customTags as ScalarTag[] | undefined) ?? [])], + }); } catch (e) { throw new Error(`Error parsing yaml ${path}`, { cause: e }); } diff --git a/cli/test/inline_scripts_failure_preprocessor_unit.test.ts b/cli/test/inline_scripts_failure_preprocessor_unit.test.ts index 1a9150a257..78af37a02e 100644 --- a/cli/test/inline_scripts_failure_preprocessor_unit.test.ts +++ b/cli/test/inline_scripts_failure_preprocessor_unit.test.ts @@ -496,3 +496,81 @@ describe("extractCurrentMapping for failure_module / preprocessor_module", () => expect(mapping["failure"]).toBeUndefined(); }); }); + +// --------------------------------------------------------------------------- +// extractInlineScripts with mapping — path preservation +// --------------------------------------------------------------------------- + +describe("extractInlineScripts with mapping preserves file paths", () => { + test("uses mapped path instead of assigner-generated path", () => { + const mod = makeRawscriptModule("a", "console.log('hi')", "bun"); + mod.summary = "Get Users Data"; + + const mapping = { a: "get_users.ts" }; + const scripts = extractInlineScripts([mod], mapping, "/", "bun"); + + const contentScript = scripts.find((s) => !s.is_lock); + expect(contentScript!.path).toBe("get_users.ts"); + // Module content should reference the mapped path + expect(mod.value.content).toBe("!inline get_users.ts"); + }); + + test("falls through to assigner when module ID not in mapping", () => { + const mod = makeRawscriptModule("a", "console.log('hi')", "bun"); + mod.summary = "Get Users Data"; + + const mapping = { other_id: "other.ts" }; + const scripts = extractInlineScripts([mod], mapping, "/", "bun"); + + const contentScript = scripts.find((s) => !s.is_lock); + // Should use assigner path based on summary, not mapped + expect(contentScript!.path).toContain("get_users_data"); + }); + + test("mapped modules and unmapped modules coexist", () => { + const modA = makeRawscriptModule("a", "code_a", "bun"); + modA.summary = "Step A"; + const modB = makeRawscriptModule("b", "code_b", "bun"); + modB.summary = "Step B"; + + const mapping = { a: "my_custom_name.ts" }; // only a is mapped + const scripts = extractInlineScripts([modA, modB], mapping, "/", "bun"); + + const paths = scripts.filter((s) => !s.is_lock).map((s) => s.path); + expect(paths[0]).toBe("my_custom_name.ts"); + expect(paths[1]).toContain("step_b"); // assigner-generated from summary + }); + + test("lock path is derived from mapped content path", () => { + const mod = makeRawscriptModule("a", "code", "bun", "lock-content"); + mod.summary = "Get Users Data"; + + const mapping = { a: "get_users.ts" }; + const scripts = extractInlineScripts([mod], mapping, "/", "bun"); + + const lockScript = scripts.find((s) => s.is_lock); + expect(lockScript!.path).toBe("get_users.lock"); + expect((mod.value as any).lock).toBe("!inline get_users.lock"); + }); + + test("lock path uses assigner basePath when no mapping", () => { + const mod = makeRawscriptModule("a", "code", "bun", "lock-content"); + mod.summary = "Get Users Data"; + + const scripts = extractInlineScripts([mod], {}, "/", "bun"); + + const lockScript = scripts.find((s) => s.is_lock); + expect(lockScript!.path).toContain("get_users_data"); + expect(lockScript!.path).toEndWith(".lock"); + }); + + test("lock path handles dotted content paths correctly", () => { + const mod = makeRawscriptModule("a", "code", "bun", "lock-content"); + + const mapping = { a: "my.inline_script.ts" }; + const scripts = extractInlineScripts([mod], mapping, "/", "bun"); + + const lockScript = scripts.find((s) => s.is_lock); + expect(lockScript!.path).toBe("my.inline_script.lock"); + }); +}); diff --git a/cli/test/yaml_inline_tag.test.ts b/cli/test/yaml_inline_tag.test.ts new file mode 100644 index 0000000000..79638c7b50 --- /dev/null +++ b/cli/test/yaml_inline_tag.test.ts @@ -0,0 +1,58 @@ +/** + * Unit tests for custom !inline and !inline_fileset YAML tag handling. + * These tests require no backend — they test YAML parsing logic. + */ + +import { expect, test, describe } from "bun:test"; +import { yamlParseContent } from "../src/utils/yaml.ts"; +import { stringify as yamlStringify } from "yaml"; + +describe("YAML !inline tag resolution", () => { + test("unquoted !inline resolves to string with prefix", () => { + const result = yamlParseContent("test.yaml", "content: !inline get_users.ts"); + expect(result.content).toBe("!inline get_users.ts"); + }); + + test("quoted !inline is preserved as-is", () => { + const result = yamlParseContent("test.yaml", 'content: "!inline get_users.ts"'); + expect(result.content).toBe("!inline get_users.ts"); + }); + + test("unquoted and quoted produce identical results", () => { + const unquoted = yamlParseContent("test.yaml", "content: !inline script.ts"); + const quoted = yamlParseContent("test.yaml", 'content: "!inline script.ts"'); + expect(unquoted.content).toBe(quoted.content); + }); + + test("unquoted !inline_fileset resolves to string with prefix", () => { + const result = yamlParseContent("test.yaml", "value: !inline_fileset my_resource.fileset"); + expect(result.value).toBe("!inline_fileset my_resource.fileset"); + }); + + test("works within nested flow.yaml structure", () => { + const yaml = ` +value: + modules: + - id: a + value: + type: rawscript + content: !inline get_users.ts + language: bun + - id: b + value: + type: rawscript + content: !inline send_mail.ts + language: bun`; + const result = yamlParseContent("flow.yaml", yaml); + expect(result.value.modules[0].value.content).toBe("!inline get_users.ts"); + expect(result.value.modules[1].value.content).toBe("!inline send_mail.ts"); + }); + + test("round-trip: parse unquoted → stringify → parse preserves value", () => { + const yaml = "content: !inline my_script.ts"; + const parsed = yamlParseContent("test.yaml", yaml); + const serialized = yamlStringify(parsed); + const reparsed = yamlParseContent("test.yaml", serialized); + expect(reparsed.content).toBe("!inline my_script.ts"); + }); +}); diff --git a/cli/windmill-utils-internal/src/inline-scripts/extractor.ts b/cli/windmill-utils-internal/src/inline-scripts/extractor.ts index 0ad1bb8302..e472372a99 100644 --- a/cli/windmill-utils-internal/src/inline-scripts/extractor.ts +++ b/cli/windmill-utils-internal/src/inline-scripts/extractor.ts @@ -23,14 +23,21 @@ function extractRawscriptInline( assigner: PathAssigner ): InlineScript[] { const [basePath, ext] = assigner.assignPath(summary ?? id, rawscript.language); - const path = mapping[id] ?? basePath + ext; + const mappedPath = mapping[id]; + const path = mappedPath ?? basePath + ext; const language = rawscript.language; const content = rawscript.content; const r = [{ path: path, content: content, language, is_lock: false}]; rawscript.content = "!inline " + path.replaceAll(separator, "/"); const lock = rawscript.lock; if (lock && lock != "") { - const lockPath = basePath + "lock"; + // Derive lock path base from the mapped content path when available, + // so lock files are named consistently with their content files. + const dotIdx = mappedPath ? mappedPath.lastIndexOf('.') : -1; + const lockBasePath = mappedPath + ? (dotIdx > 0 ? mappedPath.substring(0, dotIdx + 1) : mappedPath + '.') + : basePath; + const lockPath = lockBasePath + "lock"; rawscript.lock = "!inline " + lockPath.replaceAll(separator, "/"); r.push({ path: lockPath, content: lock, language, is_lock: true}); } @@ -191,7 +198,7 @@ export function extractCurrentMapping( } else if (m.value.type === "aiagent") { (m.value.tools ?? []).forEach((tool) => { const toolValue = tool.value; - if (!toolValue || toolValue.tool_type !== 'flowmodule' || toolValue.type !== 'rawscript' || !toolValue.content || !toolValue.content.startsWith("!inline")) { + if (!toolValue || toolValue.tool_type !== 'flowmodule' || toolValue.type !== 'rawscript' || !toolValue.content || !toolValue.content.startsWith("!inline ")) { return; } mapping[tool.id] = toolValue.content.trim().split(" ")[1]; From 248188aaa2ea065fc34523dec2a62e1adb1af8ac Mon Sep 17 00:00:00 2001 From: Pyra <92104930+pyranota@users.noreply.github.com> Date: Fri, 27 Mar 2026 20:28:48 +0100 Subject: [PATCH 030/111] nit: add `workflow_dispatch` to cli tests (#8479) --- .github/workflows/cli-tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/cli-tests.yml b/.github/workflows/cli-tests.yml index 9c87a249a3..237a5ff555 100644 --- a/.github/workflows/cli-tests.yml +++ b/.github/workflows/cli-tests.yml @@ -1,6 +1,7 @@ name: CLI Tests on: + workflow_dispatch: push: branches: [main] paths: From 80cf26bb6106720de3ced640678332fe732c2326 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 27 Mar 2026 19:39:55 +0000 Subject: [PATCH 031/111] nit npm checks --- frontend/src/lib/components/AddUser.svelte | 4 ++-- .../lib/components/settings/WorkspaceUserSettings.svelte | 1 + frontend/src/lib/stores.ts | 1 + frontend/src/lib/user.ts | 6 +++++- frontend/src/routes/(root)/(logged)/+layout.svelte | 1 + 5 files changed, 10 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/components/AddUser.svelte b/frontend/src/lib/components/AddUser.svelte index c537740480..3a76bb96e1 100644 --- a/frontend/src/lib/components/AddUser.svelte +++ b/frontend/src/lib/components/AddUser.svelte @@ -10,7 +10,6 @@ import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte' import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte' import { UserPlus } from 'lucide-svelte' - import Tooltip from './Tooltip.svelte' const dispatch = createEventDispatcher() @@ -80,7 +79,8 @@ dispatch('new') } - let selected: 'operator' | 'developer' | 'admin' | 'service_account' = $state('developer') + type UserRole = 'operator' | 'developer' | 'admin' | 'service_account' + let selected: UserRole = $state('developer' as UserRole) let isServiceAccount = $derived(selected === 'service_account') diff --git a/frontend/src/lib/components/settings/WorkspaceUserSettings.svelte b/frontend/src/lib/components/settings/WorkspaceUserSettings.svelte index d7221248ee..8b245c7c70 100644 --- a/frontend/src/lib/components/settings/WorkspaceUserSettings.svelte +++ b/frontend/src/lib/components/settings/WorkspaceUserSettings.svelte @@ -942,6 +942,7 @@ }) if (oldToken) { sessionStorage.setItem('pre_impersonation_token', oldToken) + sessionStorage.setItem('pre_impersonation_email', $userStore?.email ?? '') } window.location.href = '/' } catch (e) { diff --git a/frontend/src/lib/stores.ts b/frontend/src/lib/stores.ts index 7084b9a951..6687801a29 100644 --- a/frontend/src/lib/stores.ts +++ b/frontend/src/lib/stores.ts @@ -29,6 +29,7 @@ export interface UserExt { folders: string[] folders_owners: string[] is_service_account?: boolean + impersonating_email?: string } export interface UserWorkspace { diff --git a/frontend/src/lib/user.ts b/frontend/src/lib/user.ts index 9c2965e655..aaca893636 100644 --- a/frontend/src/lib/user.ts +++ b/frontend/src/lib/user.ts @@ -11,9 +11,13 @@ export async function getUserExt(workspace: string): Promise `g/${x}`) } + if (ext.is_service_account && sessionStorage.getItem('pre_impersonation_token')) { + ext.impersonating_email = sessionStorage.getItem('pre_impersonation_email') ?? undefined + } + return ext } diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index 43372d8337..2616e87b90 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -799,6 +799,7 @@ console.error('Failed to exit impersonation', e) } sessionStorage.removeItem('pre_impersonation_token') + sessionStorage.removeItem('pre_impersonation_email') } window.location.href = '/workspace_settings?tab=users' }} From 522da50c974bee18702daa031b3b70e741e0cea9 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 27 Mar 2026 20:03:54 +0000 Subject: [PATCH 032/111] chore(main): release 1.667.0 (#8549) * chore(main): release 1.667.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- CHANGELOG.md | 29 +++ backend/Cargo.lock | 170 +++++++++--------- backend/Cargo.toml | 4 +- backend/windmill-api/openapi.yaml | 2 +- benchmarks/lib.ts | 2 +- cli/src/main.ts | 2 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- lsp/Pipfile | 2 +- openflow.openapi.yaml | 2 +- .../WindmillClient/WindmillClient.psd1 | 2 +- python-client/wmill/pyproject.toml | 2 +- typescript-client/jsr.json | 2 +- typescript-client/package.json | 2 +- version.txt | 2 +- 15 files changed, 130 insertions(+), 99 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cc5b9ad23..34ffeebd65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,34 @@ # Changelog +## [1.667.0](https://github.com/windmill-labs/windmill/compare/v1.666.0...v1.667.0) (2026-03-27) + + +### Features + +* add schedule support to CLI branch-specific items ([#8570](https://github.com/windmill-labs/windmill/issues/8570)) ([b592996](https://github.com/windmill-labs/windmill/commit/b592996eee98ddb664f1b007b95a2096d5d4e3a6)) +* add workspace-level service accounts ([#8560](https://github.com/windmill-labs/windmill/issues/8560)) ([3959fe8](https://github.com/windmill-labs/windmill/commit/3959fe82974f5f0383e94fd83a5d78fe4212d56a)) +* **cli:** generate commented wmill.yaml and add config reference command ([#8546](https://github.com/windmill-labs/windmill/issues/8546)) ([d06b426](https://github.com/windmill-labs/windmill/commit/d06b42613f73c4a7b31c990be22b0c97efab2666)) +* DB-coordinated graceful restart staggering for settings changes ([#8555](https://github.com/windmill-labs/windmill/issues/8555)) ([2f32675](https://github.com/windmill-labs/windmill/commit/2f326758013dd1f1e6ae732e5784a32f1fb6e4bd)) +* improve-replay-ui ([#8250](https://github.com/windmill-labs/windmill/issues/8250)) ([c0aafee](https://github.com/windmill-labs/windmill/commit/c0aafee9a9923d5dc2fa3b99da4378e923933a06)) +* support multiple folder selection in MCP scope selector ([#8557](https://github.com/windmill-labs/windmill/issues/8557)) ([ad19ac9](https://github.com/windmill-labs/windmill/commit/ad19ac9b37b04591c921f93f180bdda961af6cef)) + + +### Bug Fixes + +* **cli:** preserve inline script files during flow generate-locks ([#8561](https://github.com/windmill-labs/windmill/issues/8561)) ([a8b651d](https://github.com/windmill-labs/windmill/commit/a8b651da9ff86766119e14c0b61652be8a7b453a)) +* emit 0 for OTEL queue metrics when tag queue is empty ([#8559](https://github.com/windmill-labs/windmill/issues/8559)) ([79cc4a9](https://github.com/windmill-labs/windmill/commit/79cc4a92d88486c999799826bd0c9663767103f5)) +* handle inline script deletion in sync push + flow new nonDottedPaths ([#8553](https://github.com/windmill-labs/windmill/issues/8553)) ([943fe9c](https://github.com/windmill-labs/windmill/commit/943fe9c6cc9b046e24007e45b5c37afc4804256a)) +* include importer_kind in dependency debounce key to prevent cross-kind collisions ([#8567](https://github.com/windmill-labs/windmill/issues/8567)) ([bc7007b](https://github.com/windmill-labs/windmill/commit/bc7007bb4265e1f1375c1f0678b74325882a4e92)) +* multi-script dedicated workers race on shared job_dir ([#8551](https://github.com/windmill-labs/windmill/issues/8551)) ([#8569](https://github.com/windmill-labs/windmill/issues/8569)) ([63a3573](https://github.com/windmill-labs/windmill/commit/63a3573951d1f724cc63728ed973d039a5468072)) +* preserve notes on nodes inside collapsed groups ([#8552](https://github.com/windmill-labs/windmill/issues/8552)) ([0fb1153](https://github.com/windmill-labs/windmill/commit/0fb115304afc49812420e9ce24e5048502621059)) +* sanitize flow step summaries for filesystem-safe names ([#8554](https://github.com/windmill-labs/windmill/issues/8554)) ([e15bfbf](https://github.com/windmill-labs/windmill/commit/e15bfbf91ee1517432a6861ebb48e129485006aa)) +* use admin db pool in get_copilot_settings_state ([#8564](https://github.com/windmill-labs/windmill/issues/8564)) ([70f3ee5](https://github.com/windmill-labs/windmill/commit/70f3ee5ed4470e9993be822874f2b38e83a96611)) + + +### Performance Improvements + +* enable bun bundle caching for WAC v2 scripts ([#8556](https://github.com/windmill-labs/windmill/issues/8556)) ([ab868e9](https://github.com/windmill-labs/windmill/commit/ab868e9ebceadaa55e54770d9d59dc5524da13ff)) + ## [1.666.0](https://github.com/windmill-labs/windmill/compare/v1.665.0...v1.666.0) (2026-03-26) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index a476bded1f..5d1fff935e 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -2180,9 +2180,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.57" +version = "1.2.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" dependencies = [ "find-msvc-tools", "jobserver", @@ -8596,9 +8596,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi 0.11.1+wasi-snapshot-preview1", @@ -11564,9 +11564,9 @@ dependencies = [ [[package]] name = "rust_decimal" -version = "1.40.0" +version = "1.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61f703d19852dbf87cbc513643fa81428361eb6940f1ac14fd58155d295a3eb0" +checksum = "2ce901f9a19d251159075a4c37af514c3b8ef99c22e02dd8c19161cf397ee94a" dependencies = [ "arrayvec", "borsh", @@ -11577,6 +11577,7 @@ dependencies = [ "rkyv", "serde", "serde_json", + "wasm-bindgen", ] [[package]] @@ -12582,9 +12583,9 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "simd-json" @@ -14197,7 +14198,7 @@ dependencies = [ "bytes", "io-uring", "libc", - "mio 1.1.1", + "mio 1.2.0", "parking_lot", "pin-project-lite", "signal-hook-registry", @@ -15427,6 +15428,7 @@ dependencies = [ "cfg-if", "once_cell", "rustversion", + "serde", "wasm-bindgen-macro", "wasm-bindgen-shared", ] @@ -15811,7 +15813,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "async-nats", @@ -15889,7 +15891,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.666.0" +version = "1.667.0" dependencies = [ "axum 0.8.4", "chrono", @@ -15902,7 +15904,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "argon2", @@ -16043,7 +16045,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.666.0" +version = "1.667.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16066,7 +16068,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.666.0" +version = "1.667.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16079,7 +16081,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "axum 0.8.4", @@ -16105,7 +16107,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.666.0" +version = "1.667.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -16115,7 +16117,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.666.0" +version = "1.667.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16132,7 +16134,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.666.0" +version = "1.667.0" dependencies = [ "axum 0.8.4", "base64 0.22.1", @@ -16155,7 +16157,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "axum 0.8.4", @@ -16178,7 +16180,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.666.0" +version = "1.667.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16194,7 +16196,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.666.0" +version = "1.667.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16214,7 +16216,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.666.0" +version = "1.667.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16234,7 +16236,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.666.0" +version = "1.667.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16248,7 +16250,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "async-nats", @@ -16278,7 +16280,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "axum 0.8.4", @@ -16303,7 +16305,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.666.0" +version = "1.667.0" dependencies = [ "axum 0.8.4", "flate2", @@ -16321,7 +16323,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "axum 0.8.4", @@ -16343,7 +16345,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.666.0" +version = "1.667.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16363,7 +16365,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.666.0" +version = "1.667.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16393,7 +16395,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "axum 0.8.4", @@ -16420,7 +16422,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.666.0" +version = "1.667.0" dependencies = [ "lazy_static", "serde", @@ -16432,7 +16434,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.666.0" +version = "1.667.0" dependencies = [ "argon2", "axum 0.8.4", @@ -16456,7 +16458,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.666.0" +version = "1.667.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16470,7 +16472,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.666.0" +version = "1.667.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16502,7 +16504,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.666.0" +version = "1.667.0" dependencies = [ "chrono", "lazy_static", @@ -16516,7 +16518,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "axum 0.8.4", @@ -16535,7 +16537,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.666.0" +version = "1.667.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -16636,7 +16638,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.666.0" +version = "1.667.0" dependencies = [ "chrono", "itertools 0.14.0", @@ -16655,7 +16657,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.666.0" +version = "1.667.0" dependencies = [ "regex", "serde", @@ -16670,7 +16672,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -16694,7 +16696,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "futures", @@ -16711,7 +16713,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.666.0" +version = "1.667.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -16727,7 +16729,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "async-trait", @@ -16748,7 +16750,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "async-trait", @@ -16779,7 +16781,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "async-oauth2", @@ -16803,7 +16805,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "async-stream", @@ -16837,7 +16839,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "futures", @@ -16855,7 +16857,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.666.0" +version = "1.667.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -16864,7 +16866,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "lazy_static", @@ -16876,7 +16878,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "serde_json", @@ -16888,7 +16890,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "gosyn", @@ -16900,7 +16902,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "lazy_static", @@ -16912,7 +16914,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "serde_json", @@ -16924,7 +16926,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "nu-parser", @@ -16935,7 +16937,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16946,7 +16948,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16958,7 +16960,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "rustpython-ast", @@ -16969,7 +16971,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "async-recursion", @@ -16991,7 +16993,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "lazy_static", @@ -17005,7 +17007,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -17022,7 +17024,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "lazy_static", @@ -17035,7 +17037,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "serde", @@ -17047,7 +17049,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "lazy_static", @@ -17065,7 +17067,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -17081,7 +17083,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "rustpython-ast", @@ -17097,7 +17099,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "serde", @@ -17108,7 +17110,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "async-recursion", @@ -17145,7 +17147,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "const_format", @@ -17183,7 +17185,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.666.0" +version = "1.667.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -17194,7 +17196,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "async-recursion", @@ -17223,7 +17225,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "async-trait", @@ -17247,7 +17249,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "async-trait", @@ -17280,7 +17282,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "async-trait", @@ -17300,7 +17302,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "async-trait", @@ -17334,7 +17336,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "async-trait", @@ -17369,7 +17371,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "async-trait", @@ -17392,7 +17394,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "async-trait", @@ -17416,7 +17418,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "async-nats", @@ -17440,7 +17442,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "async-trait", @@ -17475,7 +17477,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "async-trait", @@ -17503,7 +17505,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "async-trait", @@ -17526,7 +17528,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "bitflags 2.9.4", @@ -17545,7 +17547,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.666.0" +version = "1.667.0" dependencies = [ "anyhow", "async-once-cell", @@ -17653,7 +17655,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.666.0" +version = "1.667.0" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 31d5665cd7..1463710d36 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.666.0" +version = "1.667.0" authors.workspace = true edition.workspace = true @@ -82,7 +82,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal"] [workspace.package] -version = "1.666.0" +version = "1.667.0" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 082298ab03..911bf25933 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.666.0 + version: 1.667.0 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index ac0e20f064..725a232d19 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.666.0"; +export const VERSION = "v1.667.0"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/src/main.ts b/cli/src/main.ts index 5126bc6c92..2cb9f1d6d4 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -70,7 +70,7 @@ export { workspaceAdd, }; -export const VERSION = "1.666.0"; +export const VERSION = "1.667.0"; // Re-exported from constants.ts to maintain backwards compatibility export { WM_FORK_PREFIX } from "./core/constants.ts"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1c3a993774..e061fac83c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-components", - "version": "1.666.0", + "version": "1.667.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-components", - "version": "1.666.0", + "version": "1.667.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 14e7119c7a..e372c2f4dd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "windmill-components", - "version": "1.666.0", + "version": "1.667.0", "scripts": { "dev": "vite dev", "build": "vite build", diff --git a/lsp/Pipfile b/lsp/Pipfile index d2a3a0360a..41d6d9f9c8 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.666.0" +wmill = ">=1.667.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index b6065e0a91..c58f491774 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.666.0 + version: 1.667.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index f68c5d40bc..55bb44d0bd 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.666.0' + ModuleVersion = '1.667.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index fdcefd7e6f..b10bf8fd48 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.666.0" +version = "1.667.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 b75bb4c78c..58feb1c707 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.666.0", + "version": "1.667.0", "exports": "./src/index.ts", "publish": { "exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts"] diff --git a/typescript-client/package.json b/typescript-client/package.json index 21a7e3055e..1e30116112 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.666.0", + "version": "1.667.0", "author": "Ruben Fiszel", "license": "Apache 2.0", "sideEffects": false, diff --git a/version.txt b/version.txt index 8a00e52c64..11172c375e 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.666.0 +1.667.0 From 56253c04cb679c58d00750da699a6cb62ed52aca Mon Sep 17 00:00:00 2001 From: Alexander Petric Date: Fri, 27 Mar 2026 17:50:29 -0400 Subject: [PATCH 033/111] feat: IAM RDS auth for PostgreSQL worker resources (#8573) * feat: add IAM RDS auth support for PostgreSQL worker resources Co-Authored-By: Claude Opus 4.6 * refactor: use Config builder for IAM RDS connections Co-Authored-By: Claude Opus 4.6 * fix: address PR review feedback for IAM RDS auth Co-Authored-By: Claude Opus 4.6 * chore: update ee-repo-ref to ebea6ef1e5bfcfc3f0151da9687dac6c61bbfab6 This commit updates the EE repository reference after PR #493 was merged in windmill-ee-private. Previous ee-repo-ref: 1228561a98c5195bb97a81d4a57ce2bb2ecfca79 New ee-repo-ref: ebea6ef1e5bfcfc3f0151da9687dac6c61bbfab6 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: windmill-internal-app[bot] --- backend/ee-repo-ref.txt | 2 +- backend/windmill-common/src/lib.rs | 73 ++++++++++++++++++++++ backend/windmill-worker/Cargo.toml | 2 +- backend/windmill-worker/src/pg_executor.rs | 26 +++++++- 4 files changed, 99 insertions(+), 4 deletions(-) diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 4751795cd9..6a22b66a17 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -208da6989ef606e4068663246903acbcaa90a9dc \ No newline at end of file +ebea6ef1e5bfcfc3f0151da9687dac6c61bbfab6 diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index b5ec518315..deb1c38a03 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -406,6 +406,8 @@ pub struct PgDatabase { pub sslmode: Option, pub dbname: String, pub root_certificate_pem: Option, + pub use_iam_auth: Option, + pub region: Option, } // Wrapper enum to hold either Tls or NoTls connection @@ -513,6 +515,75 @@ impl PgDatabase { } } + #[cfg(all(feature = "enterprise", feature = "private"))] + pub async fn connect_with_iam( + &self, + ) -> Result<(tokio_postgres::Client, TokioPgConnection), error::Error> { + use native_tls::TlsConnector; + use postgres_native_tls::MakeTlsConnector; + + // Resolve region: resource field takes priority, then env var + let region = match self.region.as_deref() { + Some(r) => r.to_string(), + None => std::env::var("AWS_REGION").map_err(|_| { + error::Error::BadConfig( + "Region is required for IAM RDS auth. Set 'region' on the resource or AWS_REGION env var".to_string(), + ) + })?, + }; + + let port = self.port.unwrap_or(5432); + let user = self.user.as_deref().unwrap_or("postgres"); + + let token = db_iam_ee::generate_auth_token(®ion, &self.host, port as u64, user) + .await + .map_err(|e| { + error::Error::InternalErr(format!("IAM token generation failed: {e:#}")) + })?; + + // RDS IAM auth requires SSL + let mut connector = TlsConnector::builder(); + if let Some(root_certificate_pem) = &self.root_certificate_pem { + if !root_certificate_pem.is_empty() { + connector.add_root_certificate( + native_tls::Certificate::from_pem(root_certificate_pem.as_bytes()) + .map_err(|e| error::Error::BadConfig(format!("Invalid Certs: {e:#}")))?, + ); + } else { + connector.danger_accept_invalid_certs(true); + connector.danger_accept_invalid_hostnames(true); + } + } else { + tracing::warn!("IAM RDS auth without root certificate: TLS certificate verification is disabled. Consider providing root_certificate_pem for production use."); + connector + .danger_accept_invalid_certs(true) + .danger_accept_invalid_hostnames(true); + } + + tracing::info!("Creating new IAM RDS connection to {}", &self.host); + + // Use Config builder directly to pass the IAM token as the password. + // This avoids needing to URL-encode the token into a connection string. + let mut config = tokio_postgres::Config::new(); + config + .host(&self.host) + .port(port as u16) + .user(user) + .password(&token) + .dbname(&self.dbname) + .ssl_mode(tokio_postgres::config::SslMode::Require); + + let (client, connection) = tokio::time::timeout( + std::time::Duration::from_secs(20), + config.connect(MakeTlsConnector::new(connector.build().map_err(to_anyhow)?)), + ) + .await + .map_err(to_anyhow)? + .map_err(to_anyhow)?; + + Ok((client, TokioPgConnection::Tls(connection))) + } + pub fn parse_uri(url: &str) -> Result { let parsed_url = url::Url::parse(url) .map_err(|_| Error::BadConfig("Invalid PostgreSQL URL".to_string()))?; @@ -551,6 +622,8 @@ impl PgDatabase { dbname, sslmode, root_certificate_pem: None, + use_iam_auth: None, + region: None, }) } } diff --git a/backend/windmill-worker/Cargo.toml b/backend/windmill-worker/Cargo.toml index c1e2a4927a..b65a2489ac 100644 --- a/backend/windmill-worker/Cargo.toml +++ b/backend/windmill-worker/Cargo.toml @@ -10,7 +10,7 @@ path = "src/lib.rs" [features] default = [] -private = ["windmill-worker-volumes/private", "windmill-queue/private"] +private = ["windmill-worker-volumes/private", "windmill-queue/private", "windmill-common/private"] mcp = ["dep:windmill-mcp"] prometheus = ["dep:prometheus", "windmill-common/prometheus"] enterprise = ["windmill-queue/enterprise", "windmill-git-sync/enterprise", "windmill-common/enterprise", "windmill-worker-volumes/enterprise", "dep:pem", "dep:tokio-util", "dep:opentelemetry-proto", "dep:prost", "dep:hudsucker", "dep:rcgen", "dep:hyper-http-proxy", "dep:hyper-tls", "dep:hyper-util"] diff --git a/backend/windmill-worker/src/pg_executor.rs b/backend/windmill-worker/src/pg_executor.rs index 5b309a611c..f769e1034d 100644 --- a/backend/windmill-worker/src/pg_executor.rs +++ b/backend/windmill-worker/src/pg_executor.rs @@ -285,7 +285,16 @@ pub async fn do_postgresql( annotations.result_collection }; - let database_string = database.to_uri(); + let use_iam_auth = database.use_iam_auth == Some(true); + + // Include use_iam_auth in cache key to distinguish IAM vs non-IAM connections to the same host. + // The cache key is static (doesn't include the token), which is correct because PostgreSQL + // connections remain valid after initial auth — fresh tokens are generated on cache miss. + let database_string = if use_iam_auth { + format!("{}?iam=true", database.to_uri()) + } else { + database.to_uri() + }; let database_string_clone = database_string.clone(); let mtex; @@ -309,7 +318,20 @@ pub async fn do_postgresql( ); (None, mtex) } else { - let (client, connection) = database.connect().await?; + let (client, connection) = if use_iam_auth { + #[cfg(all(feature = "enterprise", feature = "private"))] + { + database.connect_with_iam().await? + } + #[cfg(not(all(feature = "enterprise", feature = "private")))] + { + return Err(Error::ExecutionErr( + "IAM RDS authentication requires Windmill Enterprise Edition".to_string(), + )); + } + } else { + database.connect().await? + }; let handle = tokio::spawn(async move { if let Err(e) = connection.await { let mut mtex = CONNECTION_CACHE.lock().await; From ce2e6c8c015110d0385e6afecdc8313aabca1364 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 27 Mar 2026 23:49:40 +0000 Subject: [PATCH 034/111] fix: add Authority Key Identifier to MITM proxy leaf certs (#8576) * test: add x509-parser dev-dep for MITM proxy cert tests Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref.txt for ssl-verify-fix branch Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref.txt Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref to a90b083660b372bf1da1c18769cbd50936ea8040 This commit updates the EE repository reference after PR #494 was merged in windmill-ee-private. Previous ee-repo-ref: db665a09d5b9a485977d73c22908629e3dda6200 New ee-repo-ref: a90b083660b372bf1da1c18769cbd50936ea8040 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: windmill-internal-app[bot] --- backend/Cargo.lock | 1 + backend/Cargo.toml | 1 + backend/ee-repo-ref.txt | 2 +- backend/windmill-worker/Cargo.toml | 1 + 4 files changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 5d1fff935e..6503da8594 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -17650,6 +17650,7 @@ dependencies = [ "windmill-runtime-nativets", "windmill-types", "windmill-worker-volumes", + "x509-parser 0.16.0", "yaml-rust", ] diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 1463710d36..08726d5d7d 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -417,6 +417,7 @@ time = "^0" serde_urlencoded = "^0" astral-tokio-tar = "^0.5.6" tempfile = "^3" +x509-parser = "^0.16" tokio-util = { version = "=0.7.17", features = ["io"] } json-pointer = "^0" itertools = "^0.14.0" diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 6a22b66a17..5f5eb5d758 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -ebea6ef1e5bfcfc3f0151da9687dac6c61bbfab6 +a90b083660b372bf1da1c18769cbd50936ea8040 diff --git a/backend/windmill-worker/Cargo.toml b/backend/windmill-worker/Cargo.toml index b65a2489ac..8955041435 100644 --- a/backend/windmill-worker/Cargo.toml +++ b/backend/windmill-worker/Cargo.toml @@ -146,6 +146,7 @@ rcgen = { workspace = true, optional = true } [dev-dependencies] tempfile.workspace = true +x509-parser.workspace = true [build-dependencies] libffi-sys = { workspace = true, optional = true } From 95688884cecd5c287b2a37d34fc4ce3a9ae52b5e Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sat, 28 Mar 2026 00:09:38 +0000 Subject: [PATCH 035/111] update ee-repo-ref to fix deprecated rand API in CI (#8577) * [ee] fix: update ee-repo-ref to fix deprecated rand API in CI Updates ee-repo-ref.txt to point to a commit that replaces deprecated rand::thread_rng().gen() with rand::rng().random() in the MITM proxy cert generation, fixing the check_ee_full CI failure. Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref to 9316adc693d7f1a668df661e000109bb48b93375 This commit updates the EE repository reference after PR #495 was merged in windmill-ee-private. Previous ee-repo-ref: d311a3c6ecb50c086fb86b1f4fa3f9e62ff40df5 New ee-repo-ref: 9316adc693d7f1a668df661e000109bb48b93375 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: windmill-internal-app[bot] --- backend/ee-repo-ref.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 5f5eb5d758..e92e39c3c1 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -a90b083660b372bf1da1c18769cbd50936ea8040 +9316adc693d7f1a668df661e000109bb48b93375 From 501a4ff2a94510145952686d24ccc639781beefe Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sat, 28 Mar 2026 08:41:52 +0000 Subject: [PATCH 036/111] fix: Improve CLI developer experience: error handling, sync workflow, JSON output, workspace forks (#8578) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cli): address 28 DX friction points across CLI commands Co-Authored-By: Claude Opus 4.5 * chore(cli): regenerate system prompts after help text updates Co-Authored-By: Claude Opus 4.5 * fix(cli): address PR review feedback Co-Authored-By: Claude Opus 4.5 * fix(cli): update removeType tests to match lenient behavior Co-Authored-By: Claude Opus 4.5 * fix(cli): address CE/EE sync friction and improve JSON output Co-Authored-By: Claude Opus 4.5 * fix(cli): revert instance config masking to avoid breaking push flow Co-Authored-By: Claude Opus 4.5 * fix(cli): mask instance secrets by default with interactive prompt Co-Authored-By: Claude Opus 4.5 * chore(cli): regenerate system prompts Co-Authored-By: Claude Opus 4.5 * fix(cli): use stderr for errors, optimize skipped-files scan, rename --auto to --auto-metadata Co-Authored-By: Claude Opus 4.5 * feat(cli): improve workspace fork lifecycle — delete-fork fallback, list-forks, --workspace override Co-Authored-By: Claude Opus 4.5 * fix(cli): update fork merge instructions to reference all merge methods Co-Authored-By: Claude Opus 4.5 * fix(cli): clarify skipped-files warning comment re DynFSElement traversal Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- cli/bootstrap/flow_bootstrap.ts | 2 - cli/src/commands/app/app.ts | 23 +++- cli/src/commands/dev/dev.ts | 2 +- cli/src/commands/docs/docs.ts | 2 +- cli/src/commands/flow/flow.ts | 33 ++++- cli/src/commands/folder/folder.ts | 1 + .../generate-metadata/generate-metadata.ts | 119 +++++++++++------- cli/src/commands/instance/instance.ts | 39 +++++- .../commands/resource-type/resource-type.ts | 1 + cli/src/commands/resource/resource.ts | 1 + cli/src/commands/schedule/schedule.ts | 3 +- cli/src/commands/script/script.ts | 50 ++++++-- cli/src/commands/sync/sync.ts | 95 +++++++++++--- cli/src/commands/trigger/trigger.ts | 24 +++- cli/src/commands/user/user.ts | 2 +- cli/src/commands/variable/variable.ts | 1 + cli/src/commands/workspace/fork.ts | 85 ++++++++----- cli/src/commands/workspace/workspace.ts | 92 ++++++++++++-- cli/src/core/conf.ts | 11 +- cli/src/core/context.ts | 11 +- cli/src/core/log.ts | 9 +- cli/src/guidance/skills.ts | 13 +- cli/src/main.ts | 17 ++- cli/src/types.ts | 12 +- cli/test/utils_unit.test.ts | 8 +- .../auto-generated/cli/cli-commands.md | 13 +- system_prompts/auto-generated/prompts.ts | 13 +- .../skills/cli-commands/SKILL.md | 13 +- 28 files changed, 528 insertions(+), 167 deletions(-) diff --git a/cli/bootstrap/flow_bootstrap.ts b/cli/bootstrap/flow_bootstrap.ts index 8bae373b17..3a71051505 100644 --- a/cli/bootstrap/flow_bootstrap.ts +++ b/cli/bootstrap/flow_bootstrap.ts @@ -13,7 +13,6 @@ export interface FlowDefinition { properties: { [name: string]: SchemaProperty}, required: string[] } - ws_error_handler_muted: false } export function defaultFlowDefinition(): FlowDefinition { @@ -30,6 +29,5 @@ export function defaultFlowDefinition(): FlowDefinition { properties: {}, required: [] }, - ws_error_handler_muted: false, } } diff --git a/cli/src/commands/app/app.ts b/cli/src/commands/app/app.ts index f048aac616..13851e5f8c 100644 --- a/cli/src/commands/app/app.ts +++ b/cli/src/commands/app/app.ts @@ -5,6 +5,7 @@ import { Table } from "@cliffy/table"; import { colors } from "@cliffy/ansi/colors"; import * as log from "../../core/log.ts"; import { sep as SEP } from "node:path"; +import { stat } from "node:fs/promises"; import * as windmillUtils from "@windmill-labs/shared-utils"; import { yamlParseFile } from "../../utils/yaml.ts"; import * as wmill from "../../../gen/services.gen.ts"; @@ -241,8 +242,26 @@ async function push(opts: GlobalOptions, filePath: string, remotePath: string) { const workspace = await resolveWorkspace(opts); await requireLogin(opts); - await pushApp(workspace.workspaceId, remotePath, filePath); - log.info(colors.bold.underline.green("App pushed")); + // Detect raw apps by checking for raw_app.yaml or __raw_app/.raw_app suffix + const normalizedPath = filePath.endsWith(SEP) ? filePath.slice(0, -1) : filePath; + const isRawApp = normalizedPath.endsWith("__raw_app") || normalizedPath.endsWith(".raw_app"); + let hasRawAppYaml = false; + if (!isRawApp) { + try { + const rawAppPath = (filePath.endsWith(SEP) ? filePath : filePath + SEP) + "raw_app.yaml"; + await stat(rawAppPath); + hasRawAppYaml = true; + } catch { /* not a raw app */ } + } + + if (isRawApp || hasRawAppYaml) { + const { pushRawApp } = await import("./raw_apps.ts"); + await pushRawApp(workspace.workspaceId, remotePath, filePath); + log.info(colors.bold.underline.green("Raw app pushed")); + } else { + await pushApp(workspace.workspaceId, remotePath, filePath); + log.info(colors.bold.underline.green("App pushed")); + } } const command = new Command() diff --git a/cli/src/commands/dev/dev.ts b/cli/src/commands/dev/dev.ts index 41b6b8fd9c..5352270f15 100644 --- a/cli/src/commands/dev/dev.ts +++ b/cli/src/commands/dev/dev.ts @@ -236,7 +236,7 @@ async function dev(opts: GlobalOptions & SyncOptions) { } const command = new Command() - .description("Launch a dev server that will spawn a webserver with HMR") + .description("Launch a dev server that watches for local file changes and auto-pushes them to the remote workspace. Provides live reload for scripts and flows during development.") .option( "--includes ", "Filter paths givena glob pattern or path" diff --git a/cli/src/commands/docs/docs.ts b/cli/src/commands/docs/docs.ts index 288faf335d..d86d4ca367 100644 --- a/cli/src/commands/docs/docs.ts +++ b/cli/src/commands/docs/docs.ts @@ -106,7 +106,7 @@ async function docs( const command = new Command() .name("docs") - .description("Search Windmill documentation. Requires Enterprise Edition.") + .description("Search Windmill documentation.") .arguments("") .option("--json", "Output results as JSON.") .action(docs as any); diff --git a/cli/src/commands/flow/flow.ts b/cli/src/commands/flow/flow.ts index 7e8bd8c28f..2ca309e42b 100644 --- a/cli/src/commands/flow/flow.ts +++ b/cli/src/commands/flow/flow.ts @@ -218,6 +218,7 @@ async function push(opts: Options, filePath: string, remotePath: string) { async function list( opts: GlobalOptions & { showArchived?: boolean; includeDraftOnly?: boolean; json?: boolean } ) { + if (opts.json) log.setSilent(true); const workspace = await resolveWorkspace(opts); await requireLogin(opts); @@ -265,6 +266,16 @@ async function get(opts: GlobalOptions & { json?: boolean }, path: string) { console.log(colors.bold("Description:") + " " + (f.description ?? "")); console.log(colors.bold("Edited by:") + " " + (f.edited_by ?? "")); console.log(colors.bold("Edited at:") + " " + (f.edited_at ?? "")); + // API response type doesn't include flow value/modules — cast needed to access them + const modules = (f as any).value?.modules; + if (modules && Array.isArray(modules) && modules.length > 0) { + console.log(colors.bold("Steps:")); + for (const mod of modules) { + const type = mod.value?.type ?? "unknown"; + const detail = mod.value?.language ?? mod.value?.path ?? ""; + console.log(` ${mod.id}: ${type}${detail ? " (" + detail + ")" : ""}`); + } + } } } @@ -275,6 +286,9 @@ async function run( }, path: string ) { + if (opts.silent) { + log.setSilent(true); + } const workspace = await resolveWorkspace(opts); await requireLogin(opts); @@ -322,7 +336,11 @@ async function run( workspace: workspace.workspaceId, id, }); - log.info(JSON.stringify(jobInfo.result ?? {}, null, 2)); + if (opts.silent) { + console.log(JSON.stringify(jobInfo.result ?? {})); + } else { + log.info(JSON.stringify(jobInfo.result ?? {}, null, 2)); + } } async function preview( @@ -333,6 +351,9 @@ async function preview( } & SyncOptions, flowPath: string ) { + if (opts.silent) { + log.setSilent(true); + } const useLocalPathScripts = !opts.remote; if (useLocalPathScripts) { opts = await mergeConfigWithConfigFile(opts); @@ -341,14 +362,16 @@ async function preview( await requireLogin(opts); const codebases = useLocalPathScripts ? listSyncCodebases(opts) : []; - // Normalize path - ensure it's a directory path to a .flow folder - if (!flowPath.endsWith(".flow") && !flowPath.endsWith(".flow" + SEP)) { + // Normalize path - ensure it's a directory path to a .flow or __flow folder + const isFlowDir = flowPath.endsWith(".flow") || flowPath.endsWith(".flow" + SEP) + || flowPath.endsWith("__flow") || flowPath.endsWith("__flow" + SEP); + if (!isFlowDir) { // Check if it's a flow.yaml file if (flowPath.endsWith("flow.yaml") || flowPath.endsWith("flow.json")) { flowPath = flowPath.substring(0, flowPath.lastIndexOf(SEP)); } else { throw new Error( - "Flow path must be a .flow directory or a flow.yaml file" + "Flow path must be a .flow/__flow directory or a flow.yaml file" ); } } @@ -428,7 +451,7 @@ async function preview( } if (opts.silent) { - console.log(JSON.stringify(result, null, 2)); + console.log(JSON.stringify(result)); } else { log.info(colors.bold.underline.green("Flow preview completed")); log.info(JSON.stringify(result, null, 2)); diff --git a/cli/src/commands/folder/folder.ts b/cli/src/commands/folder/folder.ts index eb5a51c4f8..bd298e6f88 100644 --- a/cli/src/commands/folder/folder.ts +++ b/cli/src/commands/folder/folder.ts @@ -22,6 +22,7 @@ export interface FolderFile { } async function list(opts: GlobalOptions & { json?: boolean }) { + if (opts.json) log.setSilent(true); const workspace = await resolveWorkspace(opts); await requireLogin(opts); diff --git a/cli/src/commands/generate-metadata/generate-metadata.ts b/cli/src/commands/generate-metadata/generate-metadata.ts index d273f4b631..7abd155301 100644 --- a/cli/src/commands/generate-metadata/generate-metadata.ts +++ b/cli/src/commands/generate-metadata/generate-metadata.ts @@ -355,71 +355,102 @@ async function generateMetadata( return colors.dim(colors.white(`[${n}/${total}]`.padEnd(maxWidth, " "))); }; + const errors: { path: string; error: string }[] = []; + // Process scripts for (const item of scripts) { current++; log.info(`${formatProgress(current)} script ${item.path}`); - await generateScriptMetadataInternal( - item.path, // originalPath with extension - workspace, - opts, - false, // dryRun - true, // noStaleMessage - mismatchedWorkspaceDeps, - codebases, - false, - false, // legacyBehaviour - tree - ); + try { + await generateScriptMetadataInternal( + item.path, // originalPath with extension + workspace, + opts, + false, // dryRun + true, // noStaleMessage + mismatchedWorkspaceDeps, + codebases, + false, + false, // legacyBehaviour + tree + ); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + errors.push({ path: item.path, error: msg }); + log.error(` Failed: ${msg}`); + } } // Process flows for (const item of flows) { current++; - const result = await generateFlowLockInternal( - item.folder.replaceAll("/", SEP), - false, // dryRun - workspace, - opts, - false, - true, // noStaleMessage - false, // legacyBehaviour - tree - ); - const flowResult = result as FlowLocksResult | undefined; - const scriptsInfo = flowResult?.updatedScripts?.length - ? colors.dim(colors.white(`: ${flowResult.updatedScripts.join(", ")}`)) - : ""; - log.info(`${formatProgress(current)} flow ${item.path}${scriptsInfo}`); + try { + const result = await generateFlowLockInternal( + item.folder.replaceAll("/", SEP), + false, // dryRun + workspace, + opts, + false, + true, // noStaleMessage + false, // legacyBehaviour + tree + ); + const flowResult = result as FlowLocksResult | undefined; + const scriptsInfo = flowResult?.updatedScripts?.length + ? colors.dim(colors.white(`: ${flowResult.updatedScripts.join(", ")}`)) + : ""; + log.info(`${formatProgress(current)} flow ${item.path}${scriptsInfo}`); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + errors.push({ path: item.path, error: msg }); + log.info(`${formatProgress(current)} flow ${item.path}`); + log.error(` Failed: ${msg}`); + } } // Process apps for (const item of apps) { current++; - const result = await generateAppLocksInternal( - item.folder.replaceAll("/", SEP), - item.isRawApp!, // rawApp - false, // dryRun - workspace, - opts, - false, - true, // noStaleMessage - false, // legacyBehaviour - tree - ); - const appResult = result as AppLocksResult | undefined; - const scriptsInfo = appResult?.updatedScripts?.length - ? colors.dim(colors.white(`: ${appResult.updatedScripts.join(", ")}`)) - : ""; - log.info(`${formatProgress(current)} app ${item.path}${scriptsInfo}`); + try { + const result = await generateAppLocksInternal( + item.folder.replaceAll("/", SEP), + item.isRawApp!, // rawApp + false, // dryRun + workspace, + opts, + false, + true, // noStaleMessage + false, // legacyBehaviour + tree + ); + const appResult = result as AppLocksResult | undefined; + const scriptsInfo = appResult?.updatedScripts?.length + ? colors.dim(colors.white(`: ${appResult.updatedScripts.join(", ")}`)) + : ""; + log.info(`${formatProgress(current)} app ${item.path}${scriptsInfo}`); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + errors.push({ path: item.path, error: msg }); + log.info(`${formatProgress(current)} app ${item.path}`); + log.error(` Failed: ${msg}`); + } } // Persist all stale workspace dep hashes (not just filtered — deps are global, not folder-scoped) const allStaleDeps = staleItems.filter((i) => i.type === "dependencies"); await tree.persistDepsHashes(allStaleDeps.map((d) => d.path)); + const succeeded = total - errors.length; log.info(""); - log.info(`Done. Updated ${colors.bold(String(total))} item(s).`); + if (errors.length > 0) { + log.info(`Done. Updated ${colors.bold(String(succeeded))}/${total} item(s). ${colors.red(String(errors.length) + " failed")}:`); + for (const { path, error } of errors) { + log.error(` ${path}: ${error}`); + } + process.exitCode = 1; + } else { + log.info(`Done. Updated ${colors.bold(String(total))} item(s).`); + } } const command = new Command() diff --git a/cli/src/commands/instance/instance.ts b/cli/src/commands/instance/instance.ts index 6b22d49b27..d95fe7269b 100644 --- a/cli/src/commands/instance/instance.ts +++ b/cli/src/commands/instance/instance.ts @@ -219,6 +219,22 @@ export async function pickInstance( prefix: opts.prefix ?? "custom", }; } + // Try to use the active workspace profile's remote as a fallback + if (instances.length < 1) { + try { + const ws = await getActiveWorkspace({}); + if (ws?.remote && ws?.token) { + const remote = ws.remote.endsWith("/") ? ws.remote.slice(0, -1) : ws.remote; + setClient(ws.token, remote); + return { + name: ws.name, + remote: ws.remote, + token: ws.token, + prefix: ws.name, + }; + } + } catch { /* ignore */ } + } if (!allowNew && instances.length < 1) { throw new Error("No instance found, please add one first"); } @@ -648,9 +664,27 @@ export async function getActiveInstance(opts: { } } -async function getConfig(opts: InstanceSyncOptions & { outputFile?: string }) { +async function getConfig(opts: InstanceSyncOptions & { outputFile?: string; showSecrets?: boolean }) { await pickInstance(opts, false); - const config = await wmill.getInstanceConfig(); + const config = await wmill.getInstanceConfig() as any; + + // In interactive mode, mask secrets by default and prompt + const hasSecrets = config?.global_settings?.license_key || config?.global_settings?.jwt_secret; + let showSecrets = opts.showSecrets ?? false; + if (!showSecrets && hasSecrets && process.stdout.isTTY && !opts.outputFile) { + log.warn("Config contains sensitive fields (license_key, jwt_secret). They are masked by default."); + log.warn("Use --show-secrets to include them, or press Y to show them now."); + showSecrets = await Confirm.prompt({ message: "Show secrets?", default: false }); + } else if (!process.stdout.isTTY || opts.outputFile) { + // Non-interactive or writing to file: always include secrets + showSecrets = true; + } + + if (!showSecrets && config?.global_settings) { + if (config.global_settings.license_key) config.global_settings.license_key = "***"; + if (config.global_settings.jwt_secret) config.global_settings.jwt_secret = "***"; + } + const yaml = yamlStringify(config as Record); if (opts.outputFile) { await writeFile(opts.outputFile, yaml, "utf-8"); @@ -786,6 +820,7 @@ const command = new Command() .command("get-config") .description("Dump the current instance config (global settings + worker configs) as YAML") .option("-o, --output-file ", "Write YAML to a file instead of stdout") + .option("--show-secrets", "Include sensitive fields (license key, JWT secret) without prompting") .option( "--instance ", "Name of the instance, override the active instance", diff --git a/cli/src/commands/resource-type/resource-type.ts b/cli/src/commands/resource-type/resource-type.ts index 0a5afe8dcb..a4b42582a4 100644 --- a/cli/src/commands/resource-type/resource-type.ts +++ b/cli/src/commands/resource-type/resource-type.ts @@ -88,6 +88,7 @@ async function push(opts: PushOptions, filePath: string, name: string) { } async function list(opts: GlobalOptions & { schema?: boolean; json?: boolean }) { + if (opts.json) log.setSilent(true); const workspace = await resolveWorkspace(opts); await requireLogin(opts); const res = await wmill.listResourceType({ diff --git a/cli/src/commands/resource/resource.ts b/cli/src/commands/resource/resource.ts index e28a479f13..0f35c02293 100644 --- a/cli/src/commands/resource/resource.ts +++ b/cli/src/commands/resource/resource.ts @@ -155,6 +155,7 @@ async function push(opts: PushOptions, filePath: string, remotePath: string) { } async function list(opts: GlobalOptions & { json?: boolean }) { + if (opts.json) log.setSilent(true); const workspace = await resolveWorkspace(opts); await requireLogin(opts); let page = 0; diff --git a/cli/src/commands/schedule/schedule.ts b/cli/src/commands/schedule/schedule.ts index c8582c5315..5a2b22efce 100644 --- a/cli/src/commands/schedule/schedule.ts +++ b/cli/src/commands/schedule/schedule.ts @@ -29,6 +29,7 @@ export interface ScheduleFile { } async function list(opts: GlobalOptions & { json?: boolean }) { + if (opts.json) log.setSilent(true); const workspace = await resolveWorkspace(opts); await requireLogin(opts); @@ -60,7 +61,7 @@ async function newSchedule(opts: GlobalOptions, path: string) { if (e.message?.startsWith("File already exists")) throw e; } const template: ScheduleFile = { - schedule: "0 */6 * * *", + schedule: "0 0 */6 * * *", on_failure: "", script_path: "", args: {}, diff --git a/cli/src/commands/script/script.ts b/cli/src/commands/script/script.ts index dd0fd30470..49123a4c2d 100644 --- a/cli/src/commands/script/script.ts +++ b/cli/src/commands/script/script.ts @@ -858,6 +858,7 @@ async function list( json?: boolean; } ) { + if (opts.json) log.setSilent(true); const workspace = await resolveWorkspace(opts); await requireLogin(opts); @@ -920,15 +921,44 @@ async function run( }, path: string ) { + if (opts.silent) { + log.setSilent(true); + } const workspace = await resolveWorkspace(opts); await requireLogin(opts); const input = opts.data ? await resolve(opts.data) : {}; - const id = await wmill.runScriptByPath({ - workspace: workspace.workspaceId, - path, - requestBody: input, - }); + let id: string; + try { + id = await wmill.runScriptByPath({ + workspace: workspace.workspaceId, + path, + requestBody: input, + }); + } catch (e: any) { + if (e?.status === 404) { + // Script might exist but have a lock/deployment error — check before giving up + try { + const script = await wmill.getScriptByPath({ + workspace: workspace.workspaceId, + path, + }); + if (script.lock_error_logs) { + throw new Error( + `Script '${path}' has a deployment error and cannot be run:\n${script.lock_error_logs}` + ); + } + } catch (lookupErr: any) { + if (lookupErr?.message?.includes("deployment error")) throw lookupErr; + // Re-throw non-404 lookup errors (e.g. auth/network issues) + if (lookupErr?.status && lookupErr.status !== 404) throw lookupErr; + } + throw new Error( + `Script '${path}' not found. Run 'wmill script list' to see available scripts.` + ); + } + throw e; + } if (!opts.silent) { await track_job(workspace.workspaceId, id); @@ -945,7 +975,7 @@ async function run( ).result ?? {}; if (opts.silent) { - console.log(result); + console.log(JSON.stringify(result)); } else { log.info(JSON.stringify(result, null, 2)); } @@ -1087,7 +1117,10 @@ async function bootstrap( const scriptInitialCode = scriptBootstrapCode[resolvedLanguage]; if (scriptInitialCode === undefined) { - throw new Error("Language unknown"); + const validLanguages = Object.keys(scriptBootstrapCode).sort().join(", "); + throw new Error( + `Unknown language '${language}'. Valid languages: ${validLanguages}` + ); } const config = await readConfigFile(); @@ -1262,6 +1295,9 @@ async function preview( } & SyncOptions, filePath: string ) { + if (opts.silent) { + log.setSilent(true); + } opts = await mergeConfigWithConfigFile(opts); const workspace = await resolveWorkspace(opts); await requireLogin(opts); diff --git a/cli/src/commands/sync/sync.ts b/cli/src/commands/sync/sync.ts index f2d15916d6..348117a7ff 100644 --- a/cli/src/commands/sync/sync.ts +++ b/cli/src/commands/sync/sync.ts @@ -1987,9 +1987,15 @@ export async function pull( opts: GlobalOptions & SyncOptions & { repository?: string; promotion?: string; branch?: string }, ) { + if ((opts as any).jsonOutput) log.setSilent(true); const originalCliOpts = { ...opts }; opts = await mergeConfigWithConfigFile(opts); + // --include-secrets overrides skipSecrets from wmill.yaml + if ((originalCliOpts as any).includeSecrets) { + opts.skipSecrets = false; + } + // Validate branch configuration early (skipped when --branch is used) try { await validateBranchConfiguration(opts, opts.branch); @@ -2478,12 +2484,18 @@ function removeSuffix(str: string, suffix: string) { export async function push( opts: GlobalOptions & SyncOptions & { repository?: string; branch?: string }, ) { + if ((opts as any).jsonOutput) log.setSilent(true); // Save original CLI options before merging with config file const originalCliOpts = { ...opts }; // Load configuration from wmill.yaml and merge with CLI options opts = await mergeConfigWithConfigFile(opts); + // --include-secrets overrides skipSecrets from wmill.yaml + if ((originalCliOpts as any).includeSecrets) { + opts.skipSecrets = false; + } + // Validate branch configuration early (skipped when --branch is used) try { await validateBranchConfiguration(opts, opts.branch); @@ -2617,6 +2629,7 @@ export async function push( const tracker: ChangeTracker = await buildTracker(changes); + const autoRegenerate = !!(opts as any).autoMetadata; const staleScripts: string[] = []; const staleFlows: string[] = []; const staleApps: string[] = []; @@ -2626,7 +2639,7 @@ export async function push( change, workspace, opts, - true, + !autoRegenerate, // dryRun=false when --auto is set true, rawWorkspaceDependencies, codebases, @@ -2639,11 +2652,19 @@ export async function push( if (staleScripts.length > 0) { log.info(""); - log.warn( - "Stale scripts metadata found, you may want to update them using 'wmill script generate-metadata' before pushing:", - ); + if (autoRegenerate) { + log.info("Auto-regenerated metadata for stale scripts:"); + } else { + log.warn( + "Stale scripts metadata found, you may want to update them using 'wmill script generate-metadata' before pushing:", + ); + } for (const stale of staleScripts) { - log.warn(stale); + if (autoRegenerate) { + log.info(` ${stale}`); + } else { + log.warn(stale); + } } log.info(""); @@ -2652,7 +2673,7 @@ export async function push( for (const change of tracker.flows) { const stale = await generateFlowLockInternal( change, - true, + !autoRegenerate, // dryRun=false when --auto is set workspace, opts, false, @@ -2664,11 +2685,19 @@ export async function push( } if (staleFlows.length > 0) { - log.warn( - "Stale flows locks found, you may want to update them using 'wmill flow generate-locks' before pushing:", - ); + if (autoRegenerate) { + log.info("Auto-regenerated locks for stale flows:"); + } else { + log.warn( + "Stale flows locks found, you may want to update them using 'wmill flow generate-locks' before pushing:", + ); + } for (const stale of staleFlows) { - log.warn(stale); + if (autoRegenerate) { + log.info(` ${stale}`); + } else { + log.warn(stale); + } } log.info(""); } @@ -2677,7 +2706,7 @@ export async function push( const stale = await generateAppLocksInternal( change, false, - true, + !autoRegenerate, workspace, opts, true, @@ -2692,7 +2721,7 @@ export async function push( const stale = await generateAppLocksInternal( change, true, - true, + !autoRegenerate, workspace, opts, true, @@ -2704,15 +2733,46 @@ export async function push( } if (staleApps.length > 0) { - log.warn( - "Stale apps locks found, you may want to update them using 'wmill app generate-locks' before pushing:", - ); + if (autoRegenerate) { + log.info("Auto-regenerated locks for stale apps:"); + } else { + log.warn( + "Stale apps locks found, you may want to update them using 'wmill app generate-locks' before pushing:", + ); + } for (const stale of staleApps) { - log.warn(stale); + if (autoRegenerate) { + log.info(` ${stale}`); + } else { + log.warn(stale); + } } log.info(""); } + // Warn about local files for skipped types. Walks the in-memory DynFSElement tree + // (not a fresh disk scan), but does re-traverse it. Acceptable cost for a one-time check. + { + const skippedWarnings: string[] = []; + let scheduleCount = 0; + let triggerCount = 0; + for await (const entry of readDirRecursiveWithIgnore(() => false, local)) { + if (entry.isDirectory) continue; + if (!opts.includeSchedules && entry.path.endsWith(".schedule.yaml")) scheduleCount++; + if (!opts.includeTriggers && entry.path.endsWith("_trigger.yaml")) triggerCount++; + } + if (scheduleCount > 0) { + skippedWarnings.push(`Skipping ${scheduleCount} schedule file(s). Use --include-schedules or set includeSchedules: true in wmill.yaml`); + } + if (triggerCount > 0) { + skippedWarnings.push(`Skipping ${triggerCount} trigger file(s). Use --include-triggers or set includeTriggers: true in wmill.yaml`); + } + for (const warning of skippedWarnings) { + log.warn(warning); + } + if (skippedWarnings.length > 0) log.info(""); + } + await fetchRemoteVersion(workspace); log.info( @@ -3522,6 +3582,7 @@ const command = new Command() .option("--json", "Use JSON instead of YAML") .option("--skip-variables", "Skip syncing variables (including secrets)") .option("--skip-secrets", "Skip syncing only secrets variables") + .option("--include-secrets", "Include secrets in sync (overrides skipSecrets in wmill.yaml)") .option("--skip-resources", "Skip syncing resources") .option("--skip-resource-types", "Skip syncing resource types") .option("--skip-scripts", "Skip syncing scripts") @@ -3577,6 +3638,7 @@ const command = new Command() .option("--json", "Use JSON instead of YAML") .option("--skip-variables", "Skip syncing variables (including secrets)") .option("--skip-secrets", "Skip syncing only secrets variables") + .option("--include-secrets", "Include secrets in sync (overrides skipSecrets in wmill.yaml)") .option("--skip-resources", "Skip syncing resources") .option("--skip-resource-types", "Skip syncing resource types") .option("--skip-scripts", "Skip syncing scripts") @@ -3626,6 +3688,7 @@ const command = new Command() "--locks-required", "Fail if scripts or flow inline scripts that need locks have no locks", ) + .option("--auto-metadata", "Automatically regenerate stale metadata (locks and schemas) before pushing") .action(push as any); export default command; diff --git a/cli/src/commands/trigger/trigger.ts b/cli/src/commands/trigger/trigger.ts index 11f68bea96..d3c61114fc 100644 --- a/cli/src/commands/trigger/trigger.ts +++ b/cli/src/commands/trigger/trigger.ts @@ -308,11 +308,20 @@ const triggerTemplates: Record> = { http_method: "get", is_async: false, requires_auth: true, + request_type: "sync", + authentication_method: "none", + is_static_website: false, + workspaced_route: false, + wrap_body: false, + raw_string: false, }, websocket: { script_path: "", is_flow: false, url: "", + filters: [], + can_return_message: false, + can_return_error_result: false, enabled: false, }, kafka: { @@ -321,6 +330,7 @@ const triggerTemplates: Record> = { kafka_resource_path: "", group_id: "", topics: [], + filters: [], enabled: false, }, nats: { @@ -328,6 +338,7 @@ const triggerTemplates: Record> = { is_flow: false, nats_resource_path: "", subjects: [], + use_jetstream: false, enabled: false, }, postgres: { @@ -342,23 +353,25 @@ const triggerTemplates: Record> = { script_path: "", is_flow: false, mqtt_resource_path: "", - topics: [], - subscribe_qos: 0, + subscribe_topics: [], enabled: false, }, sqs: { script_path: "", is_flow: false, - sqs_resource_path: "", queue_url: "", + aws_resource_path: "", + aws_auth_resource_type: "credentials", enabled: false, }, gcp: { script_path: "", is_flow: false, gcp_resource_path: "", - subscription_id: "", topic_id: "", + subscription_id: "", + delivery_type: "pull", + subscription_mode: "create_update", enabled: false, }, email: { @@ -437,7 +450,7 @@ async function get(opts: GlobalOptions & { json?: boolean; kind?: string }, path } else { console.log(colors.bold("Path:") + " " + trigger.path); console.log(colors.bold("Kind:") + " " + kind); - console.log(colors.bold("Enabled:") + " " + (trigger.enabled ?? "-")); + console.log(colors.bold("Enabled:") + " " + (trigger.enabled ?? (trigger as any).mode ?? "-")); console.log(colors.bold("Script Path:") + " " + (trigger.script_path ?? "")); console.log(colors.bold("Is Flow:") + " " + (trigger.is_flow ? "true" : "false")); } @@ -461,6 +474,7 @@ async function listOrEmpty(fn: () => Promise): Promise { } async function list(opts: GlobalOptions & { json?: boolean }) { + if (opts.json) log.setSilent(true); const workspace = await resolveWorkspace(opts); await requireLogin(opts); diff --git a/cli/src/commands/user/user.ts b/cli/src/commands/user/user.ts index 207958ecce..b5ed0e3196 100644 --- a/cli/src/commands/user/user.ts +++ b/cli/src/commands/user/user.ts @@ -530,7 +530,7 @@ const command = new Command() .command("remove", "Delete a user") .arguments("") .action(remove as any) - .command("create-token") + .command("create-token", "Create a new API token for the authenticated user") .option( "--email ", "Specify credentials to use for authentication. This will not be stored. It will only be used to exchange for a token with the API server, which will not be stored either.", diff --git a/cli/src/commands/variable/variable.ts b/cli/src/commands/variable/variable.ts index 21b7a69eba..c01c083706 100644 --- a/cli/src/commands/variable/variable.ts +++ b/cli/src/commands/variable/variable.ts @@ -20,6 +20,7 @@ import * as wmill from "../../../gen/services.gen.ts"; import { ListableVariable } from "../../../gen/types.gen.ts"; async function list(opts: GlobalOptions & { json?: boolean }) { + if (opts.json) log.setSilent(true); const workspace = await resolveWorkspace(opts); await requireLogin(opts); diff --git a/cli/src/commands/workspace/fork.ts b/cli/src/commands/workspace/fork.ts index 619f29fa2c..92bb38b799 100644 --- a/cli/src/commands/workspace/fork.ts +++ b/cli/src/commands/workspace/fork.ts @@ -129,10 +129,16 @@ async function createWorkspaceFork( const newBranchName = `${WM_FORK_PREFIX}/${clonedBranchName}/${workspaceId}` log.info(`Created forked workspace ${trueWorkspaceId}. To start contributing to your fork, create and push edits to the branch \`${newBranchName}\` by using the command: - + \t`+colors.white(`git checkout -b ${newBranchName}`) + ` - -When doing operations on the forked workspace, it will use the remote setup in gitBranches for the branch it was forked from.`); + +When doing operations on the forked workspace, it will use the remote setup in gitBranches for the branch it was forked from. + +To merge changes back to the parent workspace, you can: + - Use the Merge UI from the forked workspace home page + - Deploy individual items via the Deploy to staging/prod UI + - Use git: ` + colors.white(`git checkout ${clonedBranchName} && git merge ${newBranchName} && wmill sync push`) + ` + See: https://www.windmill.dev/docs/advanced/workspace_forks`); } async function deleteWorkspaceFork( @@ -141,54 +147,69 @@ async function deleteWorkspaceFork( }, name: string, ) { + let forkWorkspaceId: string; + let token: string; + let remote: string; + let hasLocalProfile = false; + + // Try local profile first (existing behavior) const orgWorkspaces = await allWorkspaces(opts.configDir); - const idxOf = orgWorkspaces.findIndex((x) => x.name === name) ; - if (idxOf === -1) { - log.info( - colors.red.bold(`! Workspace profile ${name} does not exist locally`) - ); - log.info("available workspace profiles:"); - await list(opts); - return; - } + const idxOf = orgWorkspaces.findIndex((x) => x.name === name); - const workspace = orgWorkspaces[idxOf]; - - if (!workspace.workspaceId.startsWith(WM_FORK_PREFIX)) { + if (idxOf !== -1) { + const workspace = orgWorkspaces[idxOf]; + if (!workspace.workspaceId.startsWith(WM_FORK_PREFIX)) { throw new Error( `You can only delete forked workspaces where the workspace id starts with \`${WM_FORK_PREFIX}.\` Failed while attempting to delete \`${workspace.workspaceId}\``, ); + } + forkWorkspaceId = workspace.workspaceId; + token = workspace.token; + remote = workspace.remote; + hasLocalProfile = true; + } else { + // Fallback: resolve parent workspace from branch config and construct fork ID + const parentWorkspace = await tryResolveBranchWorkspace(opts); + if (!parentWorkspace) { + throw new Error( + "Could not resolve parent workspace. Make sure you are in a git repo with gitBranches configured in wmill.yaml, or create a local workspace profile for the fork.", + ); + } + forkWorkspaceId = name.startsWith(`${WM_FORK_PREFIX}-`) ? name : `${WM_FORK_PREFIX}-${name}`; + token = parentWorkspace.token; + remote = parentWorkspace.remote; } if (!opts.yes) { - const { Select } = await import("@cliffy/prompt/select"); - const choice = await Select.prompt({ - message: `Are you sure you want to delete the forked workspace with id: \`${workspace.workspaceId}\`? This action will delete the workspace `, - options: [ - { name: "Yes", value: "confirm" }, - { name: "No", value: "cancel" }, - ], - }); + const { Select } = await import("@cliffy/prompt/select"); + const choice = await Select.prompt({ + message: `Are you sure you want to delete the forked workspace \`${forkWorkspaceId}\`?`, + options: [ + { name: "Yes", value: "confirm" }, + { name: "No", value: "cancel" }, + ], + }); - if (choice === "cancel") { - log.info("Operation cancelled"); - return; - } + if (choice === "cancel") { + log.info("Operation cancelled"); + return; + } } - const remote = workspace.remote setClient( - workspace.token, + token, remote.endsWith("/") ? remote.substring(0, remote.length - 1) : remote ); const result = await wmill.deleteWorkspace({ - workspace: workspace.workspaceId + workspace: forkWorkspaceId }); log.info( - colors.green(`✅ Forked workspace '${workspace.workspaceId}' deleted successfully!\n${result}`), + colors.green(`✅ Forked workspace '${forkWorkspaceId}' deleted successfully!\n${result}`), ); - await removeWorkspace(name, false, opts); + if (hasLocalProfile) { + await removeWorkspace(name, false, opts); + } } export { createWorkspaceFork, deleteWorkspaceFork }; diff --git a/cli/src/commands/workspace/workspace.ts b/cli/src/commands/workspace/workspace.ts index d82290e743..c51544fe90 100644 --- a/cli/src/commands/workspace/workspace.ts +++ b/cli/src/commands/workspace/workspace.ts @@ -253,8 +253,12 @@ export async function add( "On that instance and with those credentials, the workspaces that you can access are:" ); const workspaces = await wmill.listWorkspaces(); - for (const workspace of workspaces) { - log.info(`- ${workspace.id} (name: ${workspace.name})`); + if (workspaces.length === 0) { + log.info(" (none)"); + } else { + for (const workspace of workspaces) { + log.info(`- ${workspace.id} (name: ${workspace.name})`); + } } process.exit(1); } @@ -411,31 +415,94 @@ async function whoami(_opts: GlobalOptions) { const whoamiInfo = await wmill.globalWhoami(); log.info(JSON.stringify(whoamiInfo, null, 2)); const activeName = await getActiveWorkspaceName(_opts); - log.info("Active: " + colors.green.bold(activeName || "none")); + const { getCurrentGitBranch, getOriginalBranchForWorkspaceForks } = await import("../../utils/git.ts"); + const branch = getCurrentGitBranch(); + const originalBranch = branch ? getOriginalBranchForWorkspaceForks(branch) : null; + if (originalBranch) { + const { resolveWorkspace } = await import("../../core/context.ts"); + try { + const ws = await resolveWorkspace(_opts); + log.info("Active: " + colors.green.bold(`${activeName || "none"}`) + ` (fork workspace: ${ws.workspaceId})`); + } catch { + log.info("Active: " + colors.green.bold(activeName || "none") + " (fork branch)"); + } + } else { + log.info("Active: " + colors.green.bold(activeName || "none")); + } } async function listRemote(_opts: GlobalOptions) { - const { resolveWorkspace } = await import("../../core/context.ts"); - const workspace = await resolveWorkspace(_opts); - await requireLogin(_opts); + let remote: string; + + if (_opts.baseUrl && _opts.token && !_opts.workspace) { + // Allow listing workspaces with just --base-url and --token (no --workspace needed) + const { setClient } = await import("../../core/client.ts"); + remote = new URL(_opts.baseUrl).toString(); + setClient(_opts.token, remote.replace(/\/$/, "")); + } else { + const { resolveWorkspace } = await import("../../core/context.ts"); + const workspace = await resolveWorkspace(_opts); + await requireLogin(_opts); + remote = workspace.remote; + } + const userWorkspaces = await wmill.listUserWorkspaces(); + const hasForks = userWorkspaces.workspaces.some((x) => x.parent_workspace_id); + const headers = hasForks + ? ["id", "name", "username", "fork of", "disabled"] + : ["id", "name", "username", "disabled"]; + new Table() - .header(["id", "name", "username", "disabled"]) + .header(headers) .padding(2) .border(true) .body( - userWorkspaces.workspaces.map((x) => [ + userWorkspaces.workspaces.map((x) => { + const row = [ + x.id, + x.name, + x.username, + ]; + if (hasForks) row.push(x.parent_workspace_id ?? "-"); + row.push(x.disabled ? colors.red("true") : "false"); + return row; + }) + ) + .render(); + + log.info(`Remote: ${colors.bold(remote)}`); + log.info(`Logged in as: ${colors.green.bold(userWorkspaces.email)}`); +} + +async function listForks(_opts: GlobalOptions) { + const { resolveWorkspace } = await import("../../core/context.ts"); + const workspace = await resolveWorkspace(_opts); + await requireLogin(_opts); + + const userWorkspaces = await wmill.listUserWorkspaces(); + const forks = userWorkspaces.workspaces.filter((w) => w.parent_workspace_id); + + if (forks.length === 0) { + log.info("No forked workspaces found."); + return; + } + + new Table() + .header(["id", "name", "fork of", "username"]) + .padding(2) + .border(true) + .body( + forks.map((x) => [ x.id, x.name, + x.parent_workspace_id ?? "", x.username, - x.disabled ? colors.red("true") : "false", ]) ) .render(); log.info(`Remote: ${colors.bold(workspace.remote)}`); - log.info(`Logged in as: ${colors.green.bold(userWorkspaces.email)}`); } export async function getActiveWorkspaceOrFallback(opts: GlobalOptions) { @@ -566,8 +633,11 @@ const command = new Command() .command("list-remote") .description("List workspaces on the remote server that you have access to") .action(listRemote as any) + .command("list-forks") + .description("List forked workspaces on the remote server") + .action(listForks as any) .command("bind") - .description("Bind the current Git branch to the active workspace") + .description("Bind the current Git branch to the active workspace. This adds the branch to gitBranches in wmill.yaml so sync operations use the correct workspace for each branch.") .option("--branch, --env ", "Specify branch/environment (defaults to current)") .action((opts) => bind(opts as any, true)) .command("unbind") diff --git a/cli/src/core/conf.ts b/cli/src/core/conf.ts index 4a4aec7f25..826b207323 100644 --- a/cli/src/core/conf.ts +++ b/cli/src/core/conf.ts @@ -195,15 +195,18 @@ export function getWmillYamlPath(): string | null { return findWmillYaml(); } -export async function readConfigFile(): Promise { +export async function readConfigFile(opts?: { warnIfMissing?: boolean }): Promise { + const warnIfMissing = opts?.warnIfMissing ?? true; try { // First, try to find wmill.yaml recursively const wmillYamlPath = findWmillYaml(); if (!wmillYamlPath) { - log.warn( - "No wmill.yaml found. Use 'wmill init' to bootstrap it." - ); + if (warnIfMissing) { + log.warn( + "No wmill.yaml found. Use 'wmill init' to bootstrap it." + ); + } return {}; } diff --git a/cli/src/core/context.ts b/cli/src/core/context.ts index 2b7a2266e2..6ff1461960 100644 --- a/cli/src/core/context.ts +++ b/cli/src/core/context.ts @@ -262,8 +262,8 @@ export async function tryResolveBranchWorkspace( } } - // Read wmill.yaml to check for branch workspace configuration - const config = await readConfigFile(); + // Read wmill.yaml to check for branch workspace configuration (silent — just probing) + const config = await readConfigFile({ warnIfMissing: false }); const branchConfig = config.gitBranches?.[currentBranch]; // Check if branch has workspace configuration @@ -458,15 +458,16 @@ export async function resolveWorkspace( const branch = branchOverride ?? getCurrentGitBranch(); // Try explicit workspace flag first (should override branch-based resolution). Unless it's a - // forked workspace, that we detect through the branch name (only when not using branchOverride) + // forked workspace, that we detect through the branch name (only when not using branchOverride + // and --workspace was not explicitly provided) const res = await tryResolveWorkspace(opts); if (!res.isError) { const workspace = (res as { isError: false; value: Workspace }).value; - if (branchOverride || !branch || !branch.startsWith(WM_FORK_PREFIX)) { + if (branchOverride || opts.workspace || !branch || !branch.startsWith(WM_FORK_PREFIX)) { return workspace; } else { log.info( - `Found an active workspace \`${workspace.name}\` but the branch name indicates this is a forked workspace. Ignoring active workspace and trying to resolve the correct workspace from the branch name \`${branch}\`` + `Found an active workspace \`${workspace.name}\` but the branch name indicates this is a forked workspace. Ignoring active workspace and trying to resolve the correct workspace from the branch name \`${branch}\`. Use --workspace to override.` ); } } else if (opts.workspace) { diff --git a/cli/src/core/log.ts b/cli/src/core/log.ts index d7bed9a0d4..034e13e3e1 100644 --- a/cli/src/core/log.ts +++ b/cli/src/core/log.ts @@ -1,4 +1,5 @@ let logLevel: "DEBUG" | "INFO" | "WARN" | "ERROR" = "INFO"; +let silentMode = false; const levels = { DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3 }; @@ -6,19 +7,25 @@ export function setup(level: "DEBUG" | "INFO" | "WARN" | "ERROR") { logLevel = level; } +export function setSilent(silent: boolean) { + silentMode = silent; +} + export function debug(msg: unknown) { if (levels[logLevel] <= levels.DEBUG) console.log(`\x1b[90m${String(msg)}\x1b[39m`); } export function info(msg: unknown) { + if (silentMode) return; console.log(`\x1b[34m${String(msg)}\x1b[39m`); } export function warn(msg: unknown) { + if (silentMode) return; console.log(`\x1b[33m${String(msg)}\x1b[39m`); } export function error(msg: unknown) { - console.log(`\x1b[31m${String(msg)}\x1b[39m`); + console.error(`\x1b[31m${String(msg)}\x1b[39m`); } diff --git a/cli/src/guidance/skills.ts b/cli/src/guidance/skills.ts index 7da9abd72e..0d9da7dc3a 100644 --- a/cli/src/guidance/skills.ts +++ b/cli/src/guidance/skills.ts @@ -5018,14 +5018,14 @@ workspace dependencies related commands ### dev -Launch a dev server that will spawn a webserver with HMR +Launch a dev server that watches for local file changes and auto-pushes them to the remote workspace. Provides live reload for scripts and flows during development. **Options:** - \`--includes \` - Filter paths givena glob pattern or path ### docs -Search Windmill documentation. Requires Enterprise Edition. +Search Windmill documentation. **Arguments:** \`\` @@ -5183,6 +5183,7 @@ sync local with a remote instance or the opposite (push or pull) - \`instance whoami\` - Display information about the currently logged-in user - \`instance get-config\` - Dump the current instance config (global settings + worker configs) as YAML - \`-o, --output-file \` - Write YAML to a file instead of stdout + - \`--show-secrets\` - Include sensitive fields (license key, JWT secret) without prompting - \`--instance \` - Name of the instance, override the active instance ### jobs @@ -5322,6 +5323,7 @@ sync local with a remote workspaces or the opposite (push or pull) - \`--json\` - Use JSON instead of YAML - \`--skip-variables\` - Skip syncing variables (including secrets) - \`--skip-secrets\` - Skip syncing only secrets variables + - \`--include-secrets\` - Include secrets in sync (overrides skipSecrets in wmill.yaml) - \`--skip-resources\` - Skip syncing resources - \`--skip-resource-types\` - Skip syncing resource types - \`--skip-scripts\` - Skip syncing scripts @@ -5351,6 +5353,7 @@ sync local with a remote workspaces or the opposite (push or pull) - \`--json\` - Use JSON instead of YAML - \`--skip-variables\` - Skip syncing variables (including secrets) - \`--skip-secrets\` - Skip syncing only secrets variables + - \`--include-secrets\` - Include secrets in sync (overrides skipSecrets in wmill.yaml) - \`--skip-resources\` - Skip syncing resources - \`--skip-resource-types\` - Skip syncing resource types - \`--skip-scripts\` - Skip syncing scripts @@ -5376,6 +5379,7 @@ sync local with a remote workspaces or the opposite (push or pull) - \`--branch, --env \` - Override the current git branch/environment (works even outside a git repository) - \`--lint\` - Run lint validation before pushing - \`--locks-required\` - Fail if scripts or flow inline scripts that need locks have no locks + - \`--auto-metadata\` - Automatically regenerate stale metadata (locks and schemas) before pushing ### trigger @@ -5406,7 +5410,7 @@ user related commands - \`--company \` - Specify to set the company of the new user. - \`--name \` - Specify to set the name of the new user. - \`user remove \` - Delete a user -- \`user create-token\` +- \`user create-token\` - Create a new API token for the authenticated user - \`--email \` - Specify credentials to use for authentication. This will not be stored. It will only be used to exchange for a token with the API server, which will not be stored either. - \`--password \` - Specify credentials to use for authentication. This will not be stored. It will only be used to exchange for a token with the API server, which will not be stored either. @@ -5474,7 +5478,8 @@ workspace related commands - \`workspace whoami\` - Show the currently active user - \`workspace list\` - List local workspace profiles - \`workspace list-remote\` - List workspaces on the remote server that you have access to -- \`workspace bind\` - Bind the current Git branch to the active workspace +- \`workspace list-forks\` - List forked workspaces on the remote server +- \`workspace bind\` - Bind the current Git branch to the active workspace. This adds the branch to gitBranches in wmill.yaml so sync operations use the correct workspace for each branch. - \`--branch, --env \` - Specify branch/environment (defaults to current) - \`workspace unbind\` - Remove workspace binding from the current Git branch - \`--branch, --env \` - Specify branch/environment (defaults to current) diff --git a/cli/src/main.ts b/cli/src/main.ts index 2cb9f1d6d4..4446b53721 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -218,11 +218,22 @@ async function main() { await command.parse(args); } catch (e) { if (e && typeof e === "object" && "name" in e && e.name === "ApiError") { - console.log( - "Server failed. " + (e as any).statusText + ": " + (e as any).body + const body = (e as any).body; + const bodyStr = typeof body === "object" && body !== null ? JSON.stringify(body) : body; + log.error( + "Server failed. " + (e as any).statusText + ": " + bodyStr ); + } else if (e instanceof Error) { + log.error(e.message); + } else if (e !== undefined && e !== null) { + log.error(String(e)); } - throw e; + const isDebug = + process.argv.includes("--verbose") || process.argv.includes("--debug"); + if (isDebug) { + throw e; + } + process.exitCode = 1; } } diff --git a/cli/src/types.ts b/cli/src/types.ts index 56fbf384fa..65157ea75c 100644 --- a/cli/src/types.ts +++ b/cli/src/types.ts @@ -358,12 +358,16 @@ export function removeType(str: string, type: string) { const normalizedStr = path.normalize(str).replaceAll(SEP, "/"); if ( - !normalizedStr.endsWith("." + type + ".yaml") && - !normalizedStr.endsWith("." + type + ".json") + normalizedStr.endsWith("." + type + ".yaml") || + normalizedStr.endsWith("." + type + ".json") ) { - throw new Error(str + " does not end with ." + type + ".(yaml|json)"); + return normalizedStr.slice(0, normalizedStr.length - type.length - 6); } - return normalizedStr.slice(0, normalizedStr.length - type.length - 6); + // Accept clean paths without the type suffix (e.g. "f/folder/name" instead of "f/folder/name.schedule.yaml") + if (normalizedStr.includes("." + type)) { + log.debug(`Path '${str}' contains '.${type}' but doesn't end with '.${type}.(yaml|json)' — treating as clean path`); + } + return normalizedStr; } /** diff --git a/cli/test/utils_unit.test.ts b/cli/test/utils_unit.test.ts index 7bc3985a9c..288814987e 100644 --- a/cli/test/utils_unit.test.ts +++ b/cli/test/utils_unit.test.ts @@ -203,12 +203,12 @@ describe("removeType", () => { expect(removeType("f/test/my_var.variable.json", "variable")).toBe("f/test/my_var"); }); - test("throws for wrong type suffix", () => { - expect(() => removeType("f/test/my_var.variable.yaml", "resource")).toThrow(); + test("passes through path with wrong type suffix as clean path", () => { + expect(removeType("f/test/my_var.variable.yaml", "resource")).toBe("f/test/my_var.variable.yaml"); }); - test("throws for no type suffix", () => { - expect(() => removeType("f/test/my_script.ts", "variable")).toThrow(); + test("passes through path with no type suffix as clean path", () => { + expect(removeType("f/test/my_script.ts", "variable")).toBe("f/test/my_script.ts"); }); }); diff --git a/system_prompts/auto-generated/cli/cli-commands.md b/system_prompts/auto-generated/cli/cli-commands.md index c582a277b4..4c38b2ffc2 100644 --- a/system_prompts/auto-generated/cli/cli-commands.md +++ b/system_prompts/auto-generated/cli/cli-commands.md @@ -60,14 +60,14 @@ workspace dependencies related commands ### dev -Launch a dev server that will spawn a webserver with HMR +Launch a dev server that watches for local file changes and auto-pushes them to the remote workspace. Provides live reload for scripts and flows during development. **Options:** - `--includes ` - Filter paths givena glob pattern or path ### docs -Search Windmill documentation. Requires Enterprise Edition. +Search Windmill documentation. **Arguments:** `` @@ -225,6 +225,7 @@ sync local with a remote instance or the opposite (push or pull) - `instance whoami` - Display information about the currently logged-in user - `instance get-config` - Dump the current instance config (global settings + worker configs) as YAML - `-o, --output-file ` - Write YAML to a file instead of stdout + - `--show-secrets` - Include sensitive fields (license key, JWT secret) without prompting - `--instance ` - Name of the instance, override the active instance ### jobs @@ -364,6 +365,7 @@ sync local with a remote workspaces or the opposite (push or pull) - `--json` - Use JSON instead of YAML - `--skip-variables` - Skip syncing variables (including secrets) - `--skip-secrets` - Skip syncing only secrets variables + - `--include-secrets` - Include secrets in sync (overrides skipSecrets in wmill.yaml) - `--skip-resources` - Skip syncing resources - `--skip-resource-types` - Skip syncing resource types - `--skip-scripts` - Skip syncing scripts @@ -393,6 +395,7 @@ sync local with a remote workspaces or the opposite (push or pull) - `--json` - Use JSON instead of YAML - `--skip-variables` - Skip syncing variables (including secrets) - `--skip-secrets` - Skip syncing only secrets variables + - `--include-secrets` - Include secrets in sync (overrides skipSecrets in wmill.yaml) - `--skip-resources` - Skip syncing resources - `--skip-resource-types` - Skip syncing resource types - `--skip-scripts` - Skip syncing scripts @@ -418,6 +421,7 @@ sync local with a remote workspaces or the opposite (push or pull) - `--branch, --env ` - Override the current git branch/environment (works even outside a git repository) - `--lint` - Run lint validation before pushing - `--locks-required` - Fail if scripts or flow inline scripts that need locks have no locks + - `--auto-metadata` - Automatically regenerate stale metadata (locks and schemas) before pushing ### trigger @@ -448,7 +452,7 @@ user related commands - `--company ` - Specify to set the company of the new user. - `--name ` - Specify to set the name of the new user. - `user remove ` - Delete a user -- `user create-token` +- `user create-token` - Create a new API token for the authenticated user - `--email ` - Specify credentials to use for authentication. This will not be stored. It will only be used to exchange for a token with the API server, which will not be stored either. - `--password ` - Specify credentials to use for authentication. This will not be stored. It will only be used to exchange for a token with the API server, which will not be stored either. @@ -516,7 +520,8 @@ workspace related commands - `workspace whoami` - Show the currently active user - `workspace list` - List local workspace profiles - `workspace list-remote` - List workspaces on the remote server that you have access to -- `workspace bind` - Bind the current Git branch to the active workspace +- `workspace list-forks` - List forked workspaces on the remote server +- `workspace bind` - Bind the current Git branch to the active workspace. This adds the branch to gitBranches in wmill.yaml so sync operations use the correct workspace for each branch. - `--branch, --env ` - Specify branch/environment (defaults to current) - `workspace unbind` - Remove workspace binding from the current Git branch - `--branch, --env ` - Specify branch/environment (defaults to current) diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index 106c629021..e6fa904052 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -1587,14 +1587,14 @@ workspace dependencies related commands ### dev -Launch a dev server that will spawn a webserver with HMR +Launch a dev server that watches for local file changes and auto-pushes them to the remote workspace. Provides live reload for scripts and flows during development. **Options:** - \`--includes \` - Filter paths givena glob pattern or path ### docs -Search Windmill documentation. Requires Enterprise Edition. +Search Windmill documentation. **Arguments:** \`\` @@ -1752,6 +1752,7 @@ sync local with a remote instance or the opposite (push or pull) - \`instance whoami\` - Display information about the currently logged-in user - \`instance get-config\` - Dump the current instance config (global settings + worker configs) as YAML - \`-o, --output-file \` - Write YAML to a file instead of stdout + - \`--show-secrets\` - Include sensitive fields (license key, JWT secret) without prompting - \`--instance \` - Name of the instance, override the active instance ### jobs @@ -1891,6 +1892,7 @@ sync local with a remote workspaces or the opposite (push or pull) - \`--json\` - Use JSON instead of YAML - \`--skip-variables\` - Skip syncing variables (including secrets) - \`--skip-secrets\` - Skip syncing only secrets variables + - \`--include-secrets\` - Include secrets in sync (overrides skipSecrets in wmill.yaml) - \`--skip-resources\` - Skip syncing resources - \`--skip-resource-types\` - Skip syncing resource types - \`--skip-scripts\` - Skip syncing scripts @@ -1920,6 +1922,7 @@ sync local with a remote workspaces or the opposite (push or pull) - \`--json\` - Use JSON instead of YAML - \`--skip-variables\` - Skip syncing variables (including secrets) - \`--skip-secrets\` - Skip syncing only secrets variables + - \`--include-secrets\` - Include secrets in sync (overrides skipSecrets in wmill.yaml) - \`--skip-resources\` - Skip syncing resources - \`--skip-resource-types\` - Skip syncing resource types - \`--skip-scripts\` - Skip syncing scripts @@ -1945,6 +1948,7 @@ sync local with a remote workspaces or the opposite (push or pull) - \`--branch, --env \` - Override the current git branch/environment (works even outside a git repository) - \`--lint\` - Run lint validation before pushing - \`--locks-required\` - Fail if scripts or flow inline scripts that need locks have no locks + - \`--auto-metadata\` - Automatically regenerate stale metadata (locks and schemas) before pushing ### trigger @@ -1975,7 +1979,7 @@ user related commands - \`--company \` - Specify to set the company of the new user. - \`--name \` - Specify to set the name of the new user. - \`user remove \` - Delete a user -- \`user create-token\` +- \`user create-token\` - Create a new API token for the authenticated user - \`--email \` - Specify credentials to use for authentication. This will not be stored. It will only be used to exchange for a token with the API server, which will not be stored either. - \`--password \` - Specify credentials to use for authentication. This will not be stored. It will only be used to exchange for a token with the API server, which will not be stored either. @@ -2043,7 +2047,8 @@ workspace related commands - \`workspace whoami\` - Show the currently active user - \`workspace list\` - List local workspace profiles - \`workspace list-remote\` - List workspaces on the remote server that you have access to -- \`workspace bind\` - Bind the current Git branch to the active workspace +- \`workspace list-forks\` - List forked workspaces on the remote server +- \`workspace bind\` - Bind the current Git branch to the active workspace. This adds the branch to gitBranches in wmill.yaml so sync operations use the correct workspace for each branch. - \`--branch, --env \` - Specify branch/environment (defaults to current) - \`workspace unbind\` - Remove workspace binding from the current Git branch - \`--branch, --env \` - Specify branch/environment (defaults to current) diff --git a/system_prompts/auto-generated/skills/cli-commands/SKILL.md b/system_prompts/auto-generated/skills/cli-commands/SKILL.md index 30c31c4bcb..8b95426b37 100644 --- a/system_prompts/auto-generated/skills/cli-commands/SKILL.md +++ b/system_prompts/auto-generated/skills/cli-commands/SKILL.md @@ -65,14 +65,14 @@ workspace dependencies related commands ### dev -Launch a dev server that will spawn a webserver with HMR +Launch a dev server that watches for local file changes and auto-pushes them to the remote workspace. Provides live reload for scripts and flows during development. **Options:** - `--includes ` - Filter paths givena glob pattern or path ### docs -Search Windmill documentation. Requires Enterprise Edition. +Search Windmill documentation. **Arguments:** `` @@ -230,6 +230,7 @@ sync local with a remote instance or the opposite (push or pull) - `instance whoami` - Display information about the currently logged-in user - `instance get-config` - Dump the current instance config (global settings + worker configs) as YAML - `-o, --output-file ` - Write YAML to a file instead of stdout + - `--show-secrets` - Include sensitive fields (license key, JWT secret) without prompting - `--instance ` - Name of the instance, override the active instance ### jobs @@ -369,6 +370,7 @@ sync local with a remote workspaces or the opposite (push or pull) - `--json` - Use JSON instead of YAML - `--skip-variables` - Skip syncing variables (including secrets) - `--skip-secrets` - Skip syncing only secrets variables + - `--include-secrets` - Include secrets in sync (overrides skipSecrets in wmill.yaml) - `--skip-resources` - Skip syncing resources - `--skip-resource-types` - Skip syncing resource types - `--skip-scripts` - Skip syncing scripts @@ -398,6 +400,7 @@ sync local with a remote workspaces or the opposite (push or pull) - `--json` - Use JSON instead of YAML - `--skip-variables` - Skip syncing variables (including secrets) - `--skip-secrets` - Skip syncing only secrets variables + - `--include-secrets` - Include secrets in sync (overrides skipSecrets in wmill.yaml) - `--skip-resources` - Skip syncing resources - `--skip-resource-types` - Skip syncing resource types - `--skip-scripts` - Skip syncing scripts @@ -423,6 +426,7 @@ sync local with a remote workspaces or the opposite (push or pull) - `--branch, --env ` - Override the current git branch/environment (works even outside a git repository) - `--lint` - Run lint validation before pushing - `--locks-required` - Fail if scripts or flow inline scripts that need locks have no locks + - `--auto-metadata` - Automatically regenerate stale metadata (locks and schemas) before pushing ### trigger @@ -453,7 +457,7 @@ user related commands - `--company ` - Specify to set the company of the new user. - `--name ` - Specify to set the name of the new user. - `user remove ` - Delete a user -- `user create-token` +- `user create-token` - Create a new API token for the authenticated user - `--email ` - Specify credentials to use for authentication. This will not be stored. It will only be used to exchange for a token with the API server, which will not be stored either. - `--password ` - Specify credentials to use for authentication. This will not be stored. It will only be used to exchange for a token with the API server, which will not be stored either. @@ -521,7 +525,8 @@ workspace related commands - `workspace whoami` - Show the currently active user - `workspace list` - List local workspace profiles - `workspace list-remote` - List workspaces on the remote server that you have access to -- `workspace bind` - Bind the current Git branch to the active workspace +- `workspace list-forks` - List forked workspaces on the remote server +- `workspace bind` - Bind the current Git branch to the active workspace. This adds the branch to gitBranches in wmill.yaml so sync operations use the correct workspace for each branch. - `--branch, --env ` - Specify branch/environment (defaults to current) - `workspace unbind` - Remove workspace binding from the current Git branch - `--branch, --env ` - Specify branch/environment (defaults to current) From 820f28f8799f8dad5cfab94b51ac9921d664f04a Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sat, 28 Mar 2026 08:53:40 +0000 Subject: [PATCH 037/111] fix: trigger capture filter and focus issues (#8579) * fix: replace label with div for filter value editor to fix focus stealing Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref.txt Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref to 02c0d34e54e71c9293f9cefb56f68652cf0db8a5 This commit updates the EE repository reference after PR #497 was merged in windmill-ee-private. Previous ee-repo-ref: 44d665af35ad23cd3549b1d094f5d6633237deb4 New ee-repo-ref: 02c0d34e54e71c9293f9cefb56f68652cf0db8a5 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: windmill-internal-app[bot] --- backend/ee-repo-ref.txt | 2 +- frontend/src/lib/components/triggers/TriggerFilters.svelte | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index e92e39c3c1..1d141c15fb 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -9316adc693d7f1a668df661e000109bb48b93375 +02c0d34e54e71c9293f9cefb56f68652cf0db8a5 diff --git a/frontend/src/lib/components/triggers/TriggerFilters.svelte b/frontend/src/lib/components/triggers/TriggerFilters.svelte index e2657f6cbd..1fb4041b48 100644 --- a/frontend/src/lib/components/triggers/TriggerFilters.svelte +++ b/frontend/src/lib/components/triggers/TriggerFilters.svelte @@ -26,11 +26,10 @@
Key
- -