diff --git a/CHANGELOG.md b/CHANGELOG.md index ab3f168064..9ce1ee61b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,49 @@ # Changelog +## [1.682.0](https://github.com/windmill-labs/windmill/compare/v1.681.0...v1.682.0) (2026-04-10) + + +### Features + +* enrich hanging flow error with worker and service log info ([#8800](https://github.com/windmill-labs/windmill/issues/8800)) ([59c457a](https://github.com/windmill-labs/windmill/commit/59c457a13881e35c229baed3edd87e618f89b9a0)) + + +### Bug Fixes + +* bypass OTEL MITM tracing proxy for git sync jobs ([#8796](https://github.com/windmill-labs/windmill/issues/8796)) ([9c85565](https://github.com/windmill-labs/windmill/commit/9c855652212dbac0e49f87dedd447d3d7d7b500a)) +* show full path on hover in deploy drawer and widen drawer ([#8799](https://github.com/windmill-labs/windmill/issues/8799)) ([b783bf2](https://github.com/windmill-labs/windmill/commit/b783bf2d835cde0843739f7d1099193bb0af042e)) + +## [1.681.0](https://github.com/windmill-labs/windmill/compare/v1.680.0...v1.681.0) (2026-04-10) + + +### Features + +* add CI test scripts with auto-trigger on deploy ([#8736](https://github.com/windmill-labs/windmill/issues/8736)) ([c57c769](https://github.com/windmill-labs/windmill/commit/c57c769deaa207e7ba7995f75649d3630774e898)) +* add edit yaml button to raw app settings ([#8771](https://github.com/windmill-labs/windmill/issues/8771)) ([b73be37](https://github.com/windmill-labs/windmill/commit/b73be37916de808dc64bec1337edf6e7d3993c5e)) +* add user offboarding flow with object reassignment ([#8647](https://github.com/windmill-labs/windmill/issues/8647)) ([435b25e](https://github.com/windmill-labs/windmill/commit/435b25e6a4c7272c0189cbcfb83526379f41ebf0)) +* allow selecting hub flows as raw app backend runnables ([#8772](https://github.com/windmill-labs/windmill/issues/8772)) ([5f57727](https://github.com/windmill-labs/windmill/commit/5f57727a4d956a9066b005b3c55f08dd6780475a)) +* list external JWT tokens in instance settings ([#8783](https://github.com/windmill-labs/windmill/issues/8783)) ([ce3e676](https://github.com/windmill-labs/windmill/commit/ce3e676f4ab0c442058c64db4ebf35545a805ef5)) +* oauth manual connect option ([#8770](https://github.com/windmill-labs/windmill/issues/8770)) ([4b87639](https://github.com/windmill-labs/windmill/commit/4b876392a0ce41ae42bd882ced10fe0187e532bc)) +* unify CLI config to workspaces, deprecate gitBranches/environments ([#8767](https://github.com/windmill-labs/windmill/issues/8767)) ([5b97092](https://github.com/windmill-labs/windmill/commit/5b9709299761b83a88df17a4259c431dfcd244f9)) +* **vault:** add skip_ssl_verify option for HashiCorp Vault ([#8791](https://github.com/windmill-labs/windmill/issues/8791)) ([6cf7ffc](https://github.com/windmill-labs/windmill/commit/6cf7ffc26bcbc8f4ef0e4ad2879fcd114332c4e2)) + + +### Bug Fixes + +* bypass sql type injection during formatting to prevent offset corruption ([#8786](https://github.com/windmill-labs/windmill/issues/8786)) ([8957d8f](https://github.com/windmill-labs/windmill/commit/8957d8f19bce3430871c2858b3accd53e0be178f)) +* CLI falls back to workspace whoami for workspace-scoped tokens ([#8789](https://github.com/windmill-labs/windmill/issues/8789)) ([d243eb3](https://github.com/windmill-labs/windmill/commit/d243eb31b014781a249f903b2a467aa58909ddd6)) +* disable scroll-to-change-number on number inputs ([#8777](https://github.com/windmill-labs/windmill/issues/8777)) ([e63924e](https://github.com/windmill-labs/windmill/commit/e63924e3778b40486813192dc2913e565e0a765e)) +* error on flow/app folder suffix format mismatch during sync push/pull ([#8775](https://github.com/windmill-labs/windmill/issues/8775)) ([1deb31f](https://github.com/windmill-labs/windmill/commit/1deb31f1e01d6168eee3c2cc242cb483272d1965)) +* flow dev page layout and compact toolbar improvements ([#8776](https://github.com/windmill-labs/windmill/issues/8776)) ([89920e7](https://github.com/windmill-labs/windmill/commit/89920e77f3f5dc45db939ec938d92c881dccc8a0)) +* Flow status viewer layout nits (avoid excess y space and scroll) ([#8780](https://github.com/windmill-labs/windmill/issues/8780)) ([6d36eca](https://github.com/windmill-labs/windmill/commit/6d36eca21684f9d3ab36658c2b66f85b9be8d331)) +* flow step testing UX improvements ([#8781](https://github.com/windmill-labs/windmill/issues/8781)) ([3fb557a](https://github.com/windmill-labs/windmill/commit/3fb557a7f51dbbd3fac445734196f1b9a1d2e287)) +* hide legacy global_settings.worker_configs ghost row ([#8790](https://github.com/windmill-labs/windmill/issues/8790)) ([4fff89f](https://github.com/windmill-labs/windmill/commit/4fff89f98ce72997a055cc313c8fe217d2f1fe78)) +* limit multi-runnable dedicated workers to one job at a time ([#8782](https://github.com/windmill-labs/windmill/issues/8782)) ([946848f](https://github.com/windmill-labs/windmill/commit/946848feef60aba2a54bc2f5b686b33cc96ec9ef)) +* normalize multi-word pg types in build_parameters to fix float8 serialization ([#8778](https://github.com/windmill-labs/windmill/issues/8778)) ([3d02be9](https://github.com/windmill-labs/windmill/commit/3d02be98f748d985f688243f3215d15ca4227f8f)) +* refresh custom instance user password if auth failed ([#8787](https://github.com/windmill-labs/windmill/issues/8787)) ([3d43d31](https://github.com/windmill-labs/windmill/commit/3d43d31aba276f23903f16f06035a4c4955b52e2)) +* treat empty global setting strings as unset ([#8793](https://github.com/windmill-labs/windmill/issues/8793)) ([ec9cec1](https://github.com/windmill-labs/windmill/commit/ec9cec1d02d87328db92a71a1b3a945e9e0c6bd2)) +* zero-downtime coordinated restarts for OTEL and other setting changes ([#8768](https://github.com/windmill-labs/windmill/issues/8768)) ([506b7f5](https://github.com/windmill-labs/windmill/commit/506b7f55e17472d1384e9676c1b6df7a9d7a118b)) + ## [1.680.0](https://github.com/windmill-labs/windmill/compare/v1.679.0...v1.680.0) (2026-04-08) diff --git a/backend/.sqlx/query-0359f73f7dd278cacd98a785d7ce13f7964fae84d98decf06a88248b740477db.json b/backend/.sqlx/query-0359f73f7dd278cacd98a785d7ce13f7964fae84d98decf06a88248b740477db.json new file mode 100644 index 0000000000..884caa1277 --- /dev/null +++ b/backend/.sqlx/query-0359f73f7dd278cacd98a785d7ce13f7964fae84d98decf06a88248b740477db.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT DISTINCT path FROM resource WHERE value::text LIKE $1 AND NOT path LIKE $2 AND workspace_id = $3", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "0359f73f7dd278cacd98a785d7ce13f7964fae84d98decf06a88248b740477db" +} diff --git a/backend/.sqlx/query-04362999299bd8026dd8d5ae6f2ce462f1dd21f0a2d5af64afd6207313258e6e.json b/backend/.sqlx/query-04362999299bd8026dd8d5ae6f2ce462f1dd21f0a2d5af64afd6207313258e6e.json new file mode 100644 index 0000000000..620d518aa8 --- /dev/null +++ b/backend/.sqlx/query-04362999299bd8026dd8d5ae6f2ce462f1dd21f0a2d5af64afd6207313258e6e.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM password WHERE email = 'test2@windmill.dev')", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null + ] + }, + "hash": "04362999299bd8026dd8d5ae6f2ce462f1dd21f0a2d5af64afd6207313258e6e" +} diff --git a/backend/.sqlx/query-04b56007b5d33d32a2e3afcaddd094c6c8d7434a31dcdeeb2875bd89cd6bd328.json b/backend/.sqlx/query-04b56007b5d33d32a2e3afcaddd094c6c8d7434a31dcdeeb2875bd89cd6bd328.json new file mode 100644 index 0000000000..c85c20905c --- /dev/null +++ b/backend/.sqlx/query-04b56007b5d33d32a2e3afcaddd094c6c8d7434a31dcdeeb2875bd89cd6bd328.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE ci_test_reference SET tested_item_path = $1 WHERE tested_item_path = $2 AND workspace_id = $3 AND tested_item_kind = 'resource'", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "04b56007b5d33d32a2e3afcaddd094c6c8d7434a31dcdeeb2875bd89cd6bd328" +} diff --git a/backend/.sqlx/query-056318f055f6ff2466952d387a3fea11132a8edf371f4cac0001804bee170c01.json b/backend/.sqlx/query-056318f055f6ff2466952d387a3fea11132a8edf371f4cac0001804bee170c01.json new file mode 100644 index 0000000000..8dbaad6ef9 --- /dev/null +++ b/backend/.sqlx/query-056318f055f6ff2466952d387a3fea11132a8edf371f4cac0001804bee170c01.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT DISTINCT path FROM script WHERE content LIKE $1 AND NOT path LIKE $2 AND workspace_id = $3 AND NOT archived AND NOT deleted", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "056318f055f6ff2466952d387a3fea11132a8edf371f4cac0001804bee170c01" +} diff --git a/backend/.sqlx/query-0607aa96fee42b302c0f2ae2b5538c1d5118cbae680c1f58731d90f57f3fd76b.json b/backend/.sqlx/query-0607aa96fee42b302c0f2ae2b5538c1d5118cbae680c1f58731d90f57f3fd76b.json new file mode 100644 index 0000000000..f86c828473 --- /dev/null +++ b/backend/.sqlx/query-0607aa96fee42b302c0f2ae2b5538c1d5118cbae680c1f58731d90f57f3fd76b.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH updated AS (\n UPDATE script SET path = REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\\1')\n WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3\n RETURNING 1\n ) SELECT COUNT(*) FROM updated", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "0607aa96fee42b302c0f2ae2b5538c1d5118cbae680c1f58731d90f57f3fd76b" +} diff --git a/backend/.sqlx/query-0738680c7e9acedb6b8038f4e2f78289ab0268bf24c1d55d4c48abf77b697b65.json b/backend/.sqlx/query-0738680c7e9acedb6b8038f4e2f78289ab0268bf24c1d55d4c48abf77b697b65.json new file mode 100644 index 0000000000..9c0d1f3a96 --- /dev/null +++ b/backend/.sqlx/query-0738680c7e9acedb6b8038f4e2f78289ab0268bf24c1d55d4c48abf77b697b65.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT path FROM app WHERE policy->>'on_behalf_of' = $1 AND NOT path LIKE $2 AND workspace_id = $3", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "0738680c7e9acedb6b8038f4e2f78289ab0268bf24c1d55d4c48abf77b697b65" +} diff --git a/backend/.sqlx/query-089d7bc7acdbb97cf477159e111bc7e9ee85289ff5c52af43166928337c257e7.json b/backend/.sqlx/query-089d7bc7acdbb97cf477159e111bc7e9ee85289ff5c52af43166928337c257e7.json index b925141065..4c69461711 100644 --- a/backend/.sqlx/query-089d7bc7acdbb97cf477159e111bc7e9ee85289ff5c52af43166928337c257e7.json +++ b/backend/.sqlx/query-089d7bc7acdbb97cf477159e111bc7e9ee85289ff5c52af43166928337c257e7.json @@ -31,7 +31,8 @@ "gcp", "mqtt", "nextcloud", - "google" + "google", + "ci_test" ] } } diff --git a/backend/.sqlx/query-0f6e5e5306dca4b28526ee8bf24fc22b3a4af62ed52e6f8a04fdcecfa131cb0a.json b/backend/.sqlx/query-0f6e5e5306dca4b28526ee8bf24fc22b3a4af62ed52e6f8a04fdcecfa131cb0a.json new file mode 100644 index 0000000000..3db5ac2a52 --- /dev/null +++ b/backend/.sqlx/query-0f6e5e5306dca4b28526ee8bf24fc22b3a4af62ed52e6f8a04fdcecfa131cb0a.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT extra_perms::text FROM http_trigger WHERE path = 'f/test-folder/webhook_shared' AND workspace_id = 'test-workspace'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "extra_perms", + "type_info": "Text" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null + ] + }, + "hash": "0f6e5e5306dca4b28526ee8bf24fc22b3a4af62ed52e6f8a04fdcecfa131cb0a" +} diff --git a/backend/.sqlx/query-123eee0c5ebf28441646fb85fa53f9cb89756c62af95f2423609b8fa74b02ce4.json b/backend/.sqlx/query-123eee0c5ebf28441646fb85fa53f9cb89756c62af95f2423609b8fa74b02ce4.json new file mode 100644 index 0000000000..96f93b44e2 --- /dev/null +++ b/backend/.sqlx/query-123eee0c5ebf28441646fb85fa53f9cb89756c62af95f2423609b8fa74b02ce4.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT extra_perms::text FROM script WHERE path = 'f/test-folder/shared_script' AND workspace_id = 'test-workspace'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "extra_perms", + "type_info": "Text" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null + ] + }, + "hash": "123eee0c5ebf28441646fb85fa53f9cb89756c62af95f2423609b8fa74b02ce4" +} diff --git a/backend/.sqlx/query-13293f669615844104c8d6c487fb011342290a0549dd30ff3a472a201893843b.json b/backend/.sqlx/query-13293f669615844104c8d6c487fb011342290a0549dd30ff3a472a201893843b.json new file mode 100644 index 0000000000..bacf6f4ca1 --- /dev/null +++ b/backend/.sqlx/query-13293f669615844104c8d6c487fb011342290a0549dd30ff3a472a201893843b.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT permissioned_as FROM http_trigger WHERE path = 'f/test-folder/webhook_shared' AND workspace_id = 'test-workspace'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "permissioned_as", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false + ] + }, + "hash": "13293f669615844104c8d6c487fb011342290a0549dd30ff3a472a201893843b" +} diff --git a/backend/.sqlx/query-173085b6e7d823ede3cd49c5a19a16519bdd3d47d538f58fbf2b64962214df8c.json b/backend/.sqlx/query-173085b6e7d823ede3cd49c5a19a16519bdd3d47d538f58fbf2b64962214df8c.json new file mode 100644 index 0000000000..a76b504182 --- /dev/null +++ b/backend/.sqlx/query-173085b6e7d823ede3cd49c5a19a16519bdd3d47d538f58fbf2b64962214df8c.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT test_script_path FROM ci_test_reference WHERE workspace_id = 'test-workspace'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "test_script_path", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false + ] + }, + "hash": "173085b6e7d823ede3cd49c5a19a16519bdd3d47d538f58fbf2b64962214df8c" +} diff --git a/backend/.sqlx/query-17b7160f69ae4f9ebb32d5e38cf8e1c0cbb4241afa8804a906795e320be240e6.json b/backend/.sqlx/query-17b7160f69ae4f9ebb32d5e38cf8e1c0cbb4241afa8804a906795e320be240e6.json new file mode 100644 index 0000000000..a492b18487 --- /dev/null +++ b/backend/.sqlx/query-17b7160f69ae4f9ebb32d5e38cf8e1c0cbb4241afa8804a906795e320be240e6.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO ci_test_reference (workspace_id, test_script_path, test_script_hash, tested_item_path, tested_item_kind) VALUES ($1, $2, $3, $4, $5)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Int8", + "Varchar", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "17b7160f69ae4f9ebb32d5e38cf8e1c0cbb4241afa8804a906795e320be240e6" +} diff --git a/backend/.sqlx/query-18da9eefebe0e8760f476c20ace2949fbd5f44329d3514da78fc75f854b2a94e.json b/backend/.sqlx/query-18da9eefebe0e8760f476c20ace2949fbd5f44329d3514da78fc75f854b2a94e.json new file mode 100644 index 0000000000..0d8b7b93c3 --- /dev/null +++ b/backend/.sqlx/query-18da9eefebe0e8760f476c20ace2949fbd5f44329d3514da78fc75f854b2a94e.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE dependency_map SET importer_path = REGEXP_REPLACE(importer_path, 'u/' || $2 || '/(.*)', $1 || '/\\1') WHERE importer_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "18da9eefebe0e8760f476c20ace2949fbd5f44329d3514da78fc75f854b2a94e" +} diff --git a/backend/.sqlx/query-19b59c478744d029c6006b01f04243ad2e0aef485a780daea5d76b0be2bb2ea2.json b/backend/.sqlx/query-19b59c478744d029c6006b01f04243ad2e0aef485a780daea5d76b0be2bb2ea2.json index 30e071f8ce..ceed3795f0 100644 --- a/backend/.sqlx/query-19b59c478744d029c6006b01f04243ad2e0aef485a780daea5d76b0be2bb2ea2.json +++ b/backend/.sqlx/query-19b59c478744d029c6006b01f04243ad2e0aef485a780daea5d76b0be2bb2ea2.json @@ -41,7 +41,8 @@ "gcp", "mqtt", "nextcloud", - "google" + "google", + "ci_test" ] } } diff --git a/backend/.sqlx/query-212553c83e4dcdc6d045eb2fe2dadbb2860ce52d37a56b2861de1215260ecff8.json b/backend/.sqlx/query-212553c83e4dcdc6d045eb2fe2dadbb2860ce52d37a56b2861de1215260ecff8.json index b0354db034..9d0ace0831 100644 --- a/backend/.sqlx/query-212553c83e4dcdc6d045eb2fe2dadbb2860ce52d37a56b2861de1215260ecff8.json +++ b/backend/.sqlx/query-212553c83e4dcdc6d045eb2fe2dadbb2860ce52d37a56b2861de1215260ecff8.json @@ -35,7 +35,8 @@ "gcp", "mqtt", "nextcloud", - "google" + "google", + "ci_test" ] } } @@ -69,7 +70,8 @@ "gcp", "mqtt", "nextcloud", - "google" + "google", + "ci_test" ] } } diff --git a/backend/.sqlx/query-235e9f3afc4127f81fd0d09e1890b9069de29c7a1bf3d59a4ecb5db9062de316.json b/backend/.sqlx/query-235e9f3afc4127f81fd0d09e1890b9069de29c7a1bf3d59a4ecb5db9062de316.json new file mode 100644 index 0000000000..d64776eb53 --- /dev/null +++ b/backend/.sqlx/query-235e9f3afc4127f81fd0d09e1890b9069de29c7a1bf3d59a4ecb5db9062de316.json @@ -0,0 +1,72 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT jwt_hash, email, username, is_admin, is_operator, workspace_id, label, scopes, last_used_at\n FROM unique_ext_jwt_token\n WHERE NOT $3 OR last_used_at > NOW() - INTERVAL '30 days'\n ORDER BY last_used_at DESC\n LIMIT $1 OFFSET $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "jwt_hash", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "email", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "username", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "is_admin", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "is_operator", + "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "workspace_id", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "label", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "scopes", + "type_info": "TextArray" + }, + { + "ordinal": 8, + "name": "last_used_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Bool" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + true, + true, + false + ] + }, + "hash": "235e9f3afc4127f81fd0d09e1890b9069de29c7a1bf3d59a4ecb5db9062de316" +} diff --git a/backend/.sqlx/query-24bc1ffd87e0e845f57b0dcb2966931f273db7551363960e71f490617e88293f.json b/backend/.sqlx/query-24bc1ffd87e0e845f57b0dcb2966931f273db7551363960e71f490617e88293f.json new file mode 100644 index 0000000000..6aaeaed41a --- /dev/null +++ b/backend/.sqlx/query-24bc1ffd87e0e845f57b0dcb2966931f273db7551363960e71f490617e88293f.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) FROM http_trigger WHERE path LIKE $1 AND workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "24bc1ffd87e0e845f57b0dcb2966931f273db7551363960e71f490617e88293f" +} diff --git a/backend/.sqlx/query-25f15dff82796a98c8ccad0cef7e5e02cb42dfe9c44c84adbe6e427ee2643467.json b/backend/.sqlx/query-25f15dff82796a98c8ccad0cef7e5e02cb42dfe9c44c84adbe6e427ee2643467.json new file mode 100644 index 0000000000..cea0f0e6a1 --- /dev/null +++ b/backend/.sqlx/query-25f15dff82796a98c8ccad0cef7e5e02cb42dfe9c44c84adbe6e427ee2643467.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT DISTINCT path FROM app WHERE (policy::text LIKE $1 OR extra_perms::text LIKE $1) AND NOT path LIKE $2 AND workspace_id = $3", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "25f15dff82796a98c8ccad0cef7e5e02cb42dfe9c44c84adbe6e427ee2643467" +} diff --git a/backend/.sqlx/query-2cd16a65b25c031c79a519f5a42bc4ce939661bbcdcb51dc75584ee287c343cd.json b/backend/.sqlx/query-2cd16a65b25c031c79a519f5a42bc4ce939661bbcdcb51dc75584ee287c343cd.json new file mode 100644 index 0000000000..9389e54f00 --- /dev/null +++ b/backend/.sqlx/query-2cd16a65b25c031c79a519f5a42bc4ce939661bbcdcb51dc75584ee287c343cd.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE schedule SET permissioned_as = $1 WHERE permissioned_as = ('u/' || $2) AND workspace_id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "2cd16a65b25c031c79a519f5a42bc4ce939661bbcdcb51dc75584ee287c343cd" +} diff --git a/backend/.sqlx/query-2f9a80f5b7e3a3d31b5871c8debbceda317ad9db1af1ac8be79b36570564776e.json b/backend/.sqlx/query-2f9a80f5b7e3a3d31b5871c8debbceda317ad9db1af1ac8be79b36570564776e.json new file mode 100644 index 0000000000..a90ba20d71 --- /dev/null +++ b/backend/.sqlx/query-2f9a80f5b7e3a3d31b5871c8debbceda317ad9db1af1ac8be79b36570564776e.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT permissioned_as FROM http_trigger WHERE path = 'u/test-user/webhook_a' AND workspace_id = 'test-workspace'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "permissioned_as", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false + ] + }, + "hash": "2f9a80f5b7e3a3d31b5871c8debbceda317ad9db1af1ac8be79b36570564776e" +} diff --git a/backend/.sqlx/query-3113479d1e3f3d47937ff34ea9b2dbcf25d50452046212e3fd3f5aff303c30a5.json b/backend/.sqlx/query-3113479d1e3f3d47937ff34ea9b2dbcf25d50452046212e3fd3f5aff303c30a5.json new file mode 100644 index 0000000000..8e1a8c5928 --- /dev/null +++ b/backend/.sqlx/query-3113479d1e3f3d47937ff34ea9b2dbcf25d50452046212e3fd3f5aff303c30a5.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM script WHERE path = 'f/test-folder/script_a' AND workspace_id = 'test-workspace')", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null + ] + }, + "hash": "3113479d1e3f3d47937ff34ea9b2dbcf25d50452046212e3fd3f5aff303c30a5" +} diff --git a/backend/.sqlx/query-3148573724ca9118a8bf9df4db3e35440932464329602ecfcc847efff257e7b4.json b/backend/.sqlx/query-3148573724ca9118a8bf9df4db3e35440932464329602ecfcc847efff257e7b4.json new file mode 100644 index 0000000000..23f70639cf --- /dev/null +++ b/backend/.sqlx/query-3148573724ca9118a8bf9df4db3e35440932464329602ecfcc847efff257e7b4.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM usr WHERE email = 'test2@windmill.dev' AND workspace_id = 'test-workspace-2')", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null + ] + }, + "hash": "3148573724ca9118a8bf9df4db3e35440932464329602ecfcc847efff257e7b4" +} diff --git a/backend/.sqlx/query-32de3e7e8d1378059766fcb22ddd7da34d98b6b62311fc20dd99722eeb47355a.json b/backend/.sqlx/query-32de3e7e8d1378059766fcb22ddd7da34d98b6b62311fc20dd99722eeb47355a.json new file mode 100644 index 0000000000..5843a897ac --- /dev/null +++ b/backend/.sqlx/query-32de3e7e8d1378059766fcb22ddd7da34d98b6b62311fc20dd99722eeb47355a.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT file_path, log_ts, ok_lines, err_lines\n FROM log_file\n WHERE hostname = $1\n AND log_ts BETWEEN $2::timestamp - interval '90 seconds' AND $2::timestamp + interval '30 seconds'\n ORDER BY log_ts DESC\n LIMIT 10", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "file_path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "log_ts", + "type_info": "Timestamp" + }, + { + "ordinal": 2, + "name": "ok_lines", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "err_lines", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Timestamp" + ] + }, + "nullable": [ + false, + false, + true, + true + ] + }, + "hash": "32de3e7e8d1378059766fcb22ddd7da34d98b6b62311fc20dd99722eeb47355a" +} diff --git a/backend/.sqlx/query-3474805749cc2c0ccee995690e7a83bad2af8f3f57a0b5b713e38db473a38507.json b/backend/.sqlx/query-3474805749cc2c0ccee995690e7a83bad2af8f3f57a0b5b713e38db473a38507.json new file mode 100644 index 0000000000..b9bff1449b --- /dev/null +++ b/backend/.sqlx/query-3474805749cc2c0ccee995690e7a83bad2af8f3f57a0b5b713e38db473a38507.json @@ -0,0 +1,21 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO unique_ext_jwt_token (jwt_hash, last_used_at, email, username, is_admin, is_operator, workspace_id, label, scopes)\n VALUES ($1, NOW(), $2, $3, $4, $5, $6, $7, $8)\n ON CONFLICT (jwt_hash)\n DO UPDATE SET last_used_at = NOW(), email = $2, username = $3, is_admin = $4, is_operator = $5, workspace_id = $6, label = $7, scopes = $8", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Text", + "Text", + "Bool", + "Bool", + "Text", + "Text", + "TextArray" + ] + }, + "nullable": [] + }, + "hash": "3474805749cc2c0ccee995690e7a83bad2af8f3f57a0b5b713e38db473a38507" +} diff --git a/backend/.sqlx/query-35e313145a84cd1c2452a2b8c08cb38a334c1e24a34fb77054ac017e2da40952.json b/backend/.sqlx/query-35e313145a84cd1c2452a2b8c08cb38a334c1e24a34fb77054ac017e2da40952.json new file mode 100644 index 0000000000..44485ebfc6 --- /dev/null +++ b/backend/.sqlx/query-35e313145a84cd1c2452a2b8c08cb38a334c1e24a34fb77054ac017e2da40952.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE flow SET on_behalf_of_email = $1 WHERE on_behalf_of_email = $2 AND workspace_id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "35e313145a84cd1c2452a2b8c08cb38a334c1e24a34fb77054ac017e2da40952" +} diff --git a/backend/.sqlx/query-3671d5b751781a6908b55ad13cff2589a761744d8ccf21936714f13e3b9566de.json b/backend/.sqlx/query-3671d5b751781a6908b55ad13cff2589a761744d8ccf21936714f13e3b9566de.json new file mode 100644 index 0000000000..1c60d19f33 --- /dev/null +++ b/backend/.sqlx/query-3671d5b751781a6908b55ad13cff2589a761744d8ccf21936714f13e3b9566de.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM input WHERE created_by = $1 AND workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "3671d5b751781a6908b55ad13cff2589a761744d8ccf21936714f13e3b9566de" +} diff --git a/backend/.sqlx/query-38b1e6fa587cf56a6669d7acaf8a282d7dea1767c407455a1d0ff8168c7a0e00.json b/backend/.sqlx/query-38b1e6fa587cf56a6669d7acaf8a282d7dea1767c407455a1d0ff8168c7a0e00.json new file mode 100644 index 0000000000..703e60af06 --- /dev/null +++ b/backend/.sqlx/query-38b1e6fa587cf56a6669d7acaf8a282d7dea1767c407455a1d0ff8168c7a0e00.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT path FROM flow WHERE path LIKE $1 AND workspace_id = $2 AND NOT archived", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "38b1e6fa587cf56a6669d7acaf8a282d7dea1767c407455a1d0ff8168c7a0e00" +} diff --git a/backend/.sqlx/query-3df0333bc51041f5ac250cd83064f1c2e0bf28040ca588f93b02e2d49f896134.json b/backend/.sqlx/query-3df0333bc51041f5ac250cd83064f1c2e0bf28040ca588f93b02e2d49f896134.json new file mode 100644 index 0000000000..3c53335d01 --- /dev/null +++ b/backend/.sqlx/query-3df0333bc51041f5ac250cd83064f1c2e0bf28040ca588f93b02e2d49f896134.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT path FROM resource WHERE path LIKE $1 AND workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "3df0333bc51041f5ac250cd83064f1c2e0bf28040ca588f93b02e2d49f896134" +} diff --git a/backend/.sqlx/query-3f77282b7e0611ef7e6e8de654381208165ba7414fc592ca8dfffc9838ac715e.json b/backend/.sqlx/query-3f77282b7e0611ef7e6e8de654381208165ba7414fc592ca8dfffc9838ac715e.json new file mode 100644 index 0000000000..66d7fd5e1f --- /dev/null +++ b/backend/.sqlx/query-3f77282b7e0611ef7e6e8de654381208165ba7414fc592ca8dfffc9838ac715e.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace_integrations SET resource_path = REGEXP_REPLACE(resource_path, 'u/' || $2 || '/(.*)', $1 || '/\\1') WHERE resource_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "3f77282b7e0611ef7e6e8de654381208165ba7414fc592ca8dfffc9838ac715e" +} diff --git a/backend/.sqlx/query-427946d1b1ab5afb1bc5ca0a7cf5d0bf280695523b9dfc607e6911221f1c889e.json b/backend/.sqlx/query-427946d1b1ab5afb1bc5ca0a7cf5d0bf280695523b9dfc607e6911221f1c889e.json new file mode 100644 index 0000000000..3cf945243f --- /dev/null +++ b/backend/.sqlx/query-427946d1b1ab5afb1bc5ca0a7cf5d0bf280695523b9dfc607e6911221f1c889e.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE schedule SET script_path = REGEXP_REPLACE(script_path, 'u/' || $2 || '/(.*)', $1 || '/\\1')\n WHERE script_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "427946d1b1ab5afb1bc5ca0a7cf5d0bf280695523b9dfc607e6911221f1c889e" +} diff --git a/backend/.sqlx/query-43f685b7f3f827cb046c298d6ea9615ed3ded0f59cf717c167b08036f6f7ec01.json b/backend/.sqlx/query-43f685b7f3f827cb046c298d6ea9615ed3ded0f59cf717c167b08036f6f7ec01.json new file mode 100644 index 0000000000..205ad2f882 --- /dev/null +++ b/backend/.sqlx/query-43f685b7f3f827cb046c298d6ea9615ed3ded0f59cf717c167b08036f6f7ec01.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) FROM script WHERE path LIKE 'u/test-user/%' AND workspace_id = 'test-workspace'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null + ] + }, + "hash": "43f685b7f3f827cb046c298d6ea9615ed3ded0f59cf717c167b08036f6f7ec01" +} diff --git a/backend/.sqlx/query-4cbef0a0acfffd18125cb346f52720147d461f8a857e34538cf9802a09913fff.json b/backend/.sqlx/query-4cbef0a0acfffd18125cb346f52720147d461f8a857e34538cf9802a09913fff.json new file mode 100644 index 0000000000..23fe691ada --- /dev/null +++ b/backend/.sqlx/query-4cbef0a0acfffd18125cb346f52720147d461f8a857e34538cf9802a09913fff.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH deleted AS (\n DELETE FROM draft WHERE path LIKE ('u/' || $1 || '/%') AND workspace_id = $2\n RETURNING 1\n ) SELECT COUNT(*) FROM deleted", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "4cbef0a0acfffd18125cb346f52720147d461f8a857e34538cf9802a09913fff" +} diff --git a/backend/.sqlx/query-4d80985dd0794a01a2af18ae7abf4a3ab8ba3d162ed5d04735caea7295da0b20.json b/backend/.sqlx/query-4d80985dd0794a01a2af18ae7abf4a3ab8ba3d162ed5d04735caea7295da0b20.json deleted file mode 100644 index 4847a9a2b1..0000000000 --- a/backend/.sqlx/query-4d80985dd0794a01a2af18ae7abf4a3ab8ba3d162ed5d04735caea7295da0b20.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n j.id AS \"id!\", j.workspace_id AS \"workspace_id!\", j.parent_job, j.flow_step_id IS NOT NULL AS \"is_flow_step?\",\n COALESCE(s.flow_status, s.workflow_as_code_status) AS \"flow_status: Box\", r.ping AS last_ping, j.same_worker AS \"same_worker?\"\n FROM v2_job_queue q JOIN v2_job j USING (id) LEFT JOIN v2_job_runtime r USING (id) LEFT JOIN v2_job_status s USING (id)\n WHERE q.running = true AND q.suspend = 0 AND q.suspend_until IS null AND q.scheduled_for <= now()\n AND (j.kind = 'flow' OR j.kind = 'flowpreview' OR j.kind = 'flownode')\n AND r.ping IS NOT NULL AND r.ping < NOW() - ($1 || ' seconds')::interval\n AND q.canceled_by IS NULL\n\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id!", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "workspace_id!", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "parent_job", - "type_info": "Uuid" - }, - { - "ordinal": 3, - "name": "is_flow_step?", - "type_info": "Bool" - }, - { - "ordinal": 4, - "name": "flow_status: Box", - "type_info": "Jsonb" - }, - { - "ordinal": 5, - "name": "last_ping", - "type_info": "Timestamptz" - }, - { - "ordinal": 6, - "name": "same_worker?", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - false, - true, - null, - null, - true, - false - ] - }, - "hash": "4d80985dd0794a01a2af18ae7abf4a3ab8ba3d162ed5d04735caea7295da0b20" -} diff --git a/backend/.sqlx/query-53c75d2e3b28fe532eb0350b325127bf86556d88fad3dc400ed688a52e354fa6.json b/backend/.sqlx/query-53c75d2e3b28fe532eb0350b325127bf86556d88fad3dc400ed688a52e354fa6.json new file mode 100644 index 0000000000..3b36575f83 --- /dev/null +++ b/backend/.sqlx/query-53c75d2e3b28fe532eb0350b325127bf86556d88fad3dc400ed688a52e354fa6.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace_runnable_dependencies SET flow_path = REGEXP_REPLACE(flow_path, 'u/' || $2 || '/(.*)', $1 || '/\\1') WHERE flow_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "53c75d2e3b28fe532eb0350b325127bf86556d88fad3dc400ed688a52e354fa6" +} diff --git a/backend/.sqlx/query-5425e2c5e29fc5145dde5ea53d5307ca90d8dd76da2ca560b310b12820be2576.json b/backend/.sqlx/query-5425e2c5e29fc5145dde5ea53d5307ca90d8dd76da2ca560b310b12820be2576.json index 7c13ded9a3..7cb6932851 100644 --- a/backend/.sqlx/query-5425e2c5e29fc5145dde5ea53d5307ca90d8dd76da2ca560b310b12820be2576.json +++ b/backend/.sqlx/query-5425e2c5e29fc5145dde5ea53d5307ca90d8dd76da2ca560b310b12820be2576.json @@ -124,7 +124,8 @@ "gcp", "mqtt", "nextcloud", - "google" + "google", + "ci_test" ] } } diff --git a/backend/.sqlx/query-576699a445cfd4dda351fd8959e79f95d935c74c1ac20c038d09db7949288d82.json b/backend/.sqlx/query-576699a445cfd4dda351fd8959e79f95d935c74c1ac20c038d09db7949288d82.json new file mode 100644 index 0000000000..f2befec93b --- /dev/null +++ b/backend/.sqlx/query-576699a445cfd4dda351fd8959e79f95d935c74c1ac20c038d09db7949288d82.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE raw_app SET path = REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\\1')\n WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "576699a445cfd4dda351fd8959e79f95d935c74c1ac20c038d09db7949288d82" +} diff --git a/backend/.sqlx/query-5813a60051218e35fe4e8d91fe483dd49aab1c7964c6010c45bebf3a1e6fce7b.json b/backend/.sqlx/query-5813a60051218e35fe4e8d91fe483dd49aab1c7964c6010c45bebf3a1e6fce7b.json new file mode 100644 index 0000000000..4f07ac2df9 --- /dev/null +++ b/backend/.sqlx/query-5813a60051218e35fe4e8d91fe483dd49aab1c7964c6010c45bebf3a1e6fce7b.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT DISTINCT path FROM flow WHERE value::text LIKE $1 AND NOT path LIKE $2 AND workspace_id = $3 AND NOT archived", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "5813a60051218e35fe4e8d91fe483dd49aab1c7964c6010c45bebf3a1e6fce7b" +} 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-5bf200f2c8db25ddf231b564503c6c70f7f3958564a79bb0c6b3863b1ebb0cbf.json b/backend/.sqlx/query-5bf200f2c8db25ddf231b564503c6c70f7f3958564a79bb0c6b3863b1ebb0cbf.json index 0df68ba631..2f86bc4178 100644 --- a/backend/.sqlx/query-5bf200f2c8db25ddf231b564503c6c70f7f3958564a79bb0c6b3863b1ebb0cbf.json +++ b/backend/.sqlx/query-5bf200f2c8db25ddf231b564503c6c70f7f3958564a79bb0c6b3863b1ebb0cbf.json @@ -247,7 +247,8 @@ "gcp", "mqtt", "nextcloud", - "google" + "google", + "ci_test" ] } } diff --git a/backend/.sqlx/query-5ec55b8c06defc589f6cefe2dfa8ed403f97fe6eee9d9bb16f8895259ee5780d.json b/backend/.sqlx/query-5ec55b8c06defc589f6cefe2dfa8ed403f97fe6eee9d9bb16f8895259ee5780d.json new file mode 100644 index 0000000000..c413679d7c --- /dev/null +++ b/backend/.sqlx/query-5ec55b8c06defc589f6cefe2dfa8ed403f97fe6eee9d9bb16f8895259ee5780d.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT permissioned_as FROM schedule WHERE path = 'f/test-folder/sched_a' AND workspace_id = 'test-workspace'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "permissioned_as", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false + ] + }, + "hash": "5ec55b8c06defc589f6cefe2dfa8ed403f97fe6eee9d9bb16f8895259ee5780d" +} diff --git a/backend/.sqlx/query-63570a77d384dc39dca51c3769b2b76fd06148822ad121c590ebc07b56a1bc05.json b/backend/.sqlx/query-63570a77d384dc39dca51c3769b2b76fd06148822ad121c590ebc07b56a1bc05.json new file mode 100644 index 0000000000..0fb3edf292 --- /dev/null +++ b/backend/.sqlx/query-63570a77d384dc39dca51c3769b2b76fd06148822ad121c590ebc07b56a1bc05.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) FROM script WHERE path LIKE 'u/test-user-2/%' AND workspace_id = 'test-workspace' AND NOT archived AND NOT deleted", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null + ] + }, + "hash": "63570a77d384dc39dca51c3769b2b76fd06148822ad121c590ebc07b56a1bc05" +} diff --git a/backend/.sqlx/query-635c578cee24c547a457cada97208f2fe7dd634795ee8b619e57ea0ec537e3bc.json b/backend/.sqlx/query-635c578cee24c547a457cada97208f2fe7dd634795ee8b619e57ea0ec537e3bc.json new file mode 100644 index 0000000000..4bd0f0049a --- /dev/null +++ b/backend/.sqlx/query-635c578cee24c547a457cada97208f2fe7dd634795ee8b619e57ea0ec537e3bc.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT label, scopes, expiration FROM token WHERE email = $1 AND workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "label", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "scopes", + "type_info": "TextArray" + }, + { + "ordinal": 2, + "name": "expiration", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + true, + true, + true + ] + }, + "hash": "635c578cee24c547a457cada97208f2fe7dd634795ee8b619e57ea0ec537e3bc" +} diff --git a/backend/.sqlx/query-65f30242dc5ce7c951fc973f03f27e5c00149dd5f3dba68699da46edb5761677.json b/backend/.sqlx/query-65f30242dc5ce7c951fc973f03f27e5c00149dd5f3dba68699da46edb5761677.json new file mode 100644 index 0000000000..0e84ac6a74 --- /dev/null +++ b/backend/.sqlx/query-65f30242dc5ce7c951fc973f03f27e5c00149dd5f3dba68699da46edb5761677.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM capture WHERE created_by = $1 AND workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "65f30242dc5ce7c951fc973f03f27e5c00149dd5f3dba68699da46edb5761677" +} diff --git a/backend/.sqlx/query-69e2179625c84462945a4d523f59c54447a1c219cfa1c873934da727227797b0.json b/backend/.sqlx/query-69e2179625c84462945a4d523f59c54447a1c219cfa1c873934da727227797b0.json new file mode 100644 index 0000000000..5ad91bf63e --- /dev/null +++ b/backend/.sqlx/query-69e2179625c84462945a4d523f59c54447a1c219cfa1c873934da727227797b0.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT path FROM flow WHERE on_behalf_of_email = $1 AND NOT path LIKE $2 AND workspace_id = $3 AND NOT archived", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "69e2179625c84462945a4d523f59c54447a1c219cfa1c873934da727227797b0" +} diff --git a/backend/.sqlx/query-6a3b60d089b66233c5116d58cc265cb8c4bbcb5ea153e92b4918c1050704b80f.json b/backend/.sqlx/query-6a3b60d089b66233c5116d58cc265cb8c4bbcb5ea153e92b4918c1050704b80f.json new file mode 100644 index 0000000000..d4eec9aa5b --- /dev/null +++ b/backend/.sqlx/query-6a3b60d089b66233c5116d58cc265cb8c4bbcb5ea153e92b4918c1050704b80f.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE dependency_map SET imported_path = REGEXP_REPLACE(imported_path, 'u/' || $2 || '/(.*)', $1 || '/\\1') WHERE imported_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "6a3b60d089b66233c5116d58cc265cb8c4bbcb5ea153e92b4918c1050704b80f" +} diff --git a/backend/.sqlx/query-6ba01a0bfcefa19ee475146dd24d8777744e116861f6fe9226bd9c95ed75d61b.json b/backend/.sqlx/query-6ba01a0bfcefa19ee475146dd24d8777744e116861f6fe9226bd9c95ed75d61b.json new file mode 100644 index 0000000000..27343b7dd5 --- /dev/null +++ b/backend/.sqlx/query-6ba01a0bfcefa19ee475146dd24d8777744e116861f6fe9226bd9c95ed75d61b.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO ci_test_reference (workspace_id, test_script_path, test_script_hash, tested_item_path, tested_item_kind)\n SELECT $2, test_script_path, test_script_hash, tested_item_path, tested_item_kind\n FROM ci_test_reference WHERE workspace_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "6ba01a0bfcefa19ee475146dd24d8777744e116861f6fe9226bd9c95ed75d61b" +} diff --git a/backend/.sqlx/query-6eef5c6220057cba722ed835c5fe045f3e53a811b0d43738ca0ce96739785fc4.json b/backend/.sqlx/query-6eef5c6220057cba722ed835c5fe045f3e53a811b0d43738ca0ce96739785fc4.json new file mode 100644 index 0000000000..e4bc3acc19 --- /dev/null +++ b/backend/.sqlx/query-6eef5c6220057cba722ed835c5fe045f3e53a811b0d43738ca0ce96739785fc4.json @@ -0,0 +1,32 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT j.id, j.runnable_path, j.trigger_kind::text as trigger_kind FROM v2_job j WHERE j.workspace_id = 'test-workspace' AND j.trigger_kind = 'ci_test'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "runnable_path", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "trigger_kind", + "type_info": "Text" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + true, + null + ] + }, + "hash": "6eef5c6220057cba722ed835c5fe045f3e53a811b0d43738ca0ce96739785fc4" +} diff --git a/backend/.sqlx/query-6ff66494a782787e4cb6666035e7996bc7f11ff61cb32514ea9ccc1f9cf9784c.json b/backend/.sqlx/query-6ff66494a782787e4cb6666035e7996bc7f11ff61cb32514ea9ccc1f9cf9784c.json new file mode 100644 index 0000000000..753d107bf1 --- /dev/null +++ b/backend/.sqlx/query-6ff66494a782787e4cb6666035e7996bc7f11ff61cb32514ea9ccc1f9cf9784c.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM script WHERE hash IN (1003, 1004) AND workspace_id = 'test-workspace'", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "6ff66494a782787e4cb6666035e7996bc7f11ff61cb32514ea9ccc1f9cf9784c" +} diff --git a/backend/.sqlx/query-72b2d517141d24155fd3f2476497691989ae8d780986d1f67e7d2e09a5452056.json b/backend/.sqlx/query-72b2d517141d24155fd3f2476497691989ae8d780986d1f67e7d2e09a5452056.json new file mode 100644 index 0000000000..0853e58305 --- /dev/null +++ b/backend/.sqlx/query-72b2d517141d24155fd3f2476497691989ae8d780986d1f67e7d2e09a5452056.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT path FROM variable WHERE path LIKE $1 AND workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "72b2d517141d24155fd3f2476497691989ae8d780986d1f67e7d2e09a5452056" +} diff --git a/backend/.sqlx/query-73929b32bd20f344c5004e41e140c346a183c4981f60af1a7fba3a5ea87072d9.json b/backend/.sqlx/query-73929b32bd20f344c5004e41e140c346a183c4981f60af1a7fba3a5ea87072d9.json new file mode 100644 index 0000000000..6fc108d2d9 --- /dev/null +++ b/backend/.sqlx/query-73929b32bd20f344c5004e41e140c346a183c4981f60af1a7fba3a5ea87072d9.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH updated AS (\n UPDATE app SET path = REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\\1')\n WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3\n RETURNING 1\n ) SELECT COUNT(*) FROM updated", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "73929b32bd20f344c5004e41e140c346a183c4981f60af1a7fba3a5ea87072d9" +} diff --git a/backend/.sqlx/query-757ef6215d3d385cb3a69e26ee4ca846dd5e7fe7ceb1aa8b3fcd26a2bd30eb2c.json b/backend/.sqlx/query-757ef6215d3d385cb3a69e26ee4ca846dd5e7fe7ceb1aa8b3fcd26a2bd30eb2c.json index 59b56ceda7..9bf61b09a1 100644 --- a/backend/.sqlx/query-757ef6215d3d385cb3a69e26ee4ca846dd5e7fe7ceb1aa8b3fcd26a2bd30eb2c.json +++ b/backend/.sqlx/query-757ef6215d3d385cb3a69e26ee4ca846dd5e7fe7ceb1aa8b3fcd26a2bd30eb2c.json @@ -41,7 +41,8 @@ "gcp", "mqtt", "nextcloud", - "google" + "google", + "ci_test" ] } } diff --git a/backend/.sqlx/query-778ab8ceb2a84978919ceb07f399468e01c4bee4cd755322eb2a83353a279a2b.json b/backend/.sqlx/query-778ab8ceb2a84978919ceb07f399468e01c4bee4cd755322eb2a83353a279a2b.json deleted file mode 100644 index a35e6560e3..0000000000 --- a/backend/.sqlx/query-778ab8ceb2a84978919ceb07f399468e01c4bee4cd755322eb2a83353a279a2b.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO unique_ext_jwt_token (jwt_hash, last_used_at)\n VALUES ($1, NOW())\n ON CONFLICT (jwt_hash)\n DO UPDATE SET last_used_at = NOW()", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [] - }, - "hash": "778ab8ceb2a84978919ceb07f399468e01c4bee4cd755322eb2a83353a279a2b" -} diff --git a/backend/.sqlx/query-7c0eb584b90636098cc5e0b84aa0da8cadce2fa949b2260d02c8cf99e5324d19.json b/backend/.sqlx/query-7c0eb584b90636098cc5e0b84aa0da8cadce2fa949b2260d02c8cf99e5324d19.json new file mode 100644 index 0000000000..c75b06a4f9 --- /dev/null +++ b/backend/.sqlx/query-7c0eb584b90636098cc5e0b84aa0da8cadce2fa949b2260d02c8cf99e5324d19.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM token WHERE email = $1 AND workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "7c0eb584b90636098cc5e0b84aa0da8cadce2fa949b2260d02c8cf99e5324d19" +} diff --git a/backend/.sqlx/query-811861c8be204de3fcdc7635af56b9905f9c1846c8081a3b8fd265f4c484e809.json b/backend/.sqlx/query-811861c8be204de3fcdc7635af56b9905f9c1846c8081a3b8fd265f4c484e809.json new file mode 100644 index 0000000000..9cafe23200 --- /dev/null +++ b/backend/.sqlx/query-811861c8be204de3fcdc7635af56b9905f9c1846c8081a3b8fd265f4c484e809.json @@ -0,0 +1,32 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT test_script_path, tested_item_path, tested_item_kind FROM ci_test_reference WHERE workspace_id = 'test-workspace'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "test_script_path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "tested_item_path", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "tested_item_kind", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "811861c8be204de3fcdc7635af56b9905f9c1846c8081a3b8fd265f4c484e809" +} diff --git a/backend/.sqlx/query-82e4b4a76ff6d80feeb66c1ce54bcb85cb181d00dd545f5163c71aa2e92df9c2.json b/backend/.sqlx/query-82e4b4a76ff6d80feeb66c1ce54bcb85cb181d00dd545f5163c71aa2e92df9c2.json new file mode 100644 index 0000000000..4cf199f941 --- /dev/null +++ b/backend/.sqlx/query-82e4b4a76ff6d80feeb66c1ce54bcb85cb181d00dd545f5163c71aa2e92df9c2.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT path FROM schedule WHERE path LIKE $1 AND workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "82e4b4a76ff6d80feeb66c1ce54bcb85cb181d00dd545f5163c71aa2e92df9c2" +} diff --git a/backend/.sqlx/query-84422a0a867b0b104e67d5a39c788aa3bcafbe3ae6ce4869a9faeea7b2f5547f.json b/backend/.sqlx/query-84422a0a867b0b104e67d5a39c788aa3bcafbe3ae6ce4869a9faeea7b2f5547f.json new file mode 100644 index 0000000000..df89a421c9 --- /dev/null +++ b/backend/.sqlx/query-84422a0a867b0b104e67d5a39c788aa3bcafbe3ae6ce4869a9faeea7b2f5547f.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM draft WHERE path LIKE ('u/' || $1 || '/%') AND workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "84422a0a867b0b104e67d5a39c788aa3bcafbe3ae6ce4869a9faeea7b2f5547f" +} diff --git a/backend/.sqlx/query-84f5f871801e568bf1701342ded7a5254bfc33870a112f6e8d4477fff39e351b.json b/backend/.sqlx/query-84f5f871801e568bf1701342ded7a5254bfc33870a112f6e8d4477fff39e351b.json new file mode 100644 index 0000000000..0eed773275 --- /dev/null +++ b/backend/.sqlx/query-84f5f871801e568bf1701342ded7a5254bfc33870a112f6e8d4477fff39e351b.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT path FROM script WHERE on_behalf_of_email = $1 AND NOT path LIKE $2 AND workspace_id = $3 AND NOT archived AND NOT deleted", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "84f5f871801e568bf1701342ded7a5254bfc33870a112f6e8d4477fff39e351b" +} diff --git a/backend/.sqlx/query-871e9024861a8462cef64f19e3a100fd80f6b113a45a66c03125b93ce2ca2a83.json b/backend/.sqlx/query-871e9024861a8462cef64f19e3a100fd80f6b113a45a66c03125b93ce2ca2a83.json new file mode 100644 index 0000000000..8d79a3603c --- /dev/null +++ b/backend/.sqlx/query-871e9024861a8462cef64f19e3a100fd80f6b113a45a66c03125b93ce2ca2a83.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM ci_test_reference WHERE workspace_id = $1 AND (test_script_path = $2 OR test_script_path = $3)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "871e9024861a8462cef64f19e3a100fd80f6b113a45a66c03125b93ce2ca2a83" +} diff --git a/backend/.sqlx/query-8c63ed6be090c4ff3cc67fb52589142954032db7e332e7628234438b9b2c629c.json b/backend/.sqlx/query-8c63ed6be090c4ff3cc67fb52589142954032db7e332e7628234438b9b2c629c.json new file mode 100644 index 0000000000..dfd004fc5c --- /dev/null +++ b/backend/.sqlx/query-8c63ed6be090c4ff3cc67fb52589142954032db7e332e7628234438b9b2c629c.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH updated AS (\n UPDATE resource SET path = REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\\1')\n WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3\n RETURNING 1\n ) SELECT COUNT(*) FROM updated", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "8c63ed6be090c4ff3cc67fb52589142954032db7e332e7628234438b9b2c629c" +} diff --git a/backend/.sqlx/query-9654740e91d92ea321baebd1ea2f5d8d102d111c298ba5132f297cd86cbe23d8.json b/backend/.sqlx/query-9654740e91d92ea321baebd1ea2f5d8d102d111c298ba5132f297cd86cbe23d8.json new file mode 100644 index 0000000000..9fc03de8ff --- /dev/null +++ b/backend/.sqlx/query-9654740e91d92ea321baebd1ea2f5d8d102d111c298ba5132f297cd86cbe23d8.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM favorite WHERE usr = $1 AND workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "9654740e91d92ea321baebd1ea2f5d8d102d111c298ba5132f297cd86cbe23d8" +} diff --git a/backend/.sqlx/query-9851729d72b6341c76bd49d0a04ffb0a844db48277e02139b0b12450a1e11a01.json b/backend/.sqlx/query-9851729d72b6341c76bd49d0a04ffb0a844db48277e02139b0b12450a1e11a01.json new file mode 100644 index 0000000000..c33aef2c3f --- /dev/null +++ b/backend/.sqlx/query-9851729d72b6341c76bd49d0a04ffb0a844db48277e02139b0b12450a1e11a01.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) FROM http_trigger WHERE path = 'u/test-user/webhook_a' AND workspace_id = 'test-workspace'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null + ] + }, + "hash": "9851729d72b6341c76bd49d0a04ffb0a844db48277e02139b0b12450a1e11a01" +} diff --git a/backend/.sqlx/query-99db4c9cb15a6780c1965f0a9e0b20f047c0b4a84e870806391c8c5051a0e24e.json b/backend/.sqlx/query-99db4c9cb15a6780c1965f0a9e0b20f047c0b4a84e870806391c8c5051a0e24e.json new file mode 100644 index 0000000000..7bccbcac44 --- /dev/null +++ b/backend/.sqlx/query-99db4c9cb15a6780c1965f0a9e0b20f047c0b4a84e870806391c8c5051a0e24e.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM script WHERE hash = 1004 AND workspace_id = 'test-workspace'", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "99db4c9cb15a6780c1965f0a9e0b20f047c0b4a84e870806391c8c5051a0e24e" +} diff --git a/backend/.sqlx/query-9ecb404e46a4eac55f977f05a3afbafe5dc3cdecc17a3d5a7476b160c1b6e7e1.json b/backend/.sqlx/query-9ecb404e46a4eac55f977f05a3afbafe5dc3cdecc17a3d5a7476b160c1b6e7e1.json index b6fe1b7fa0..09cc00f737 100644 --- a/backend/.sqlx/query-9ecb404e46a4eac55f977f05a3afbafe5dc3cdecc17a3d5a7476b160c1b6e7e1.json +++ b/backend/.sqlx/query-9ecb404e46a4eac55f977f05a3afbafe5dc3cdecc17a3d5a7476b160c1b6e7e1.json @@ -31,7 +31,8 @@ "gcp", "mqtt", "nextcloud", - "google" + "google", + "ci_test" ] } } diff --git a/backend/.sqlx/query-a1745a4f525b251d2f5a602ab2b2ede46b4471e21b11f607573a844013911abe.json b/backend/.sqlx/query-a1745a4f525b251d2f5a602ab2b2ede46b4471e21b11f607573a844013911abe.json index 8a0b536f44..490e821025 100644 --- a/backend/.sqlx/query-a1745a4f525b251d2f5a602ab2b2ede46b4471e21b11f607573a844013911abe.json +++ b/backend/.sqlx/query-a1745a4f525b251d2f5a602ab2b2ede46b4471e21b11f607573a844013911abe.json @@ -157,7 +157,8 @@ "gcp", "mqtt", "nextcloud", - "google" + "google", + "ci_test" ] } } diff --git a/backend/.sqlx/query-a4b6371d33206010b2f3ffd2b09e33244fe8ab9a803248fc23f334034d24aad4.json b/backend/.sqlx/query-a4b6371d33206010b2f3ffd2b09e33244fe8ab9a803248fc23f334034d24aad4.json index 0a8e61a3f7..f60512fa07 100644 --- a/backend/.sqlx/query-a4b6371d33206010b2f3ffd2b09e33244fe8ab9a803248fc23f334034d24aad4.json +++ b/backend/.sqlx/query-a4b6371d33206010b2f3ffd2b09e33244fe8ab9a803248fc23f334034d24aad4.json @@ -187,7 +187,8 @@ "gcp", "mqtt", "nextcloud", - "google" + "google", + "ci_test" ] } } diff --git a/backend/.sqlx/query-a4e759ec0c5ff37fe5c280c2d2f816db2dad8fdf02aecbceac0979ce5a7982c8.json b/backend/.sqlx/query-a4e759ec0c5ff37fe5c280c2d2f816db2dad8fdf02aecbceac0979ce5a7982c8.json new file mode 100644 index 0000000000..83efc5d35c --- /dev/null +++ b/backend/.sqlx/query-a4e759ec0c5ff37fe5c280c2d2f816db2dad8fdf02aecbceac0979ce5a7982c8.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH inserted AS (\n INSERT INTO flow\n (workspace_id, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs)\n SELECT workspace_id, REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\\1'), summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs\n FROM flow\n WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3\n RETURNING 1\n ) SELECT COUNT(*) FROM inserted", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "a4e759ec0c5ff37fe5c280c2d2f816db2dad8fdf02aecbceac0979ce5a7982c8" +} diff --git a/backend/.sqlx/query-ab48f6bd60ca0cc6d892a44304d3c32b887c01d26b0ec64279263da055c3de6c.json b/backend/.sqlx/query-ab48f6bd60ca0cc6d892a44304d3c32b887c01d26b0ec64279263da055c3de6c.json new file mode 100644 index 0000000000..2837f94b85 --- /dev/null +++ b/backend/.sqlx/query-ab48f6bd60ca0cc6d892a44304d3c32b887c01d26b0ec64279263da055c3de6c.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT permissioned_as FROM schedule WHERE path = 'f/test-folder/sched_shared' AND workspace_id = 'test-workspace'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "permissioned_as", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false + ] + }, + "hash": "ab48f6bd60ca0cc6d892a44304d3c32b887c01d26b0ec64279263da055c3de6c" +} diff --git a/backend/.sqlx/query-b1c8d94d3e81acc37a5ead4ffb487a9e79e5c0a9176d81d14dbd6d6c5407ef77.json b/backend/.sqlx/query-b1c8d94d3e81acc37a5ead4ffb487a9e79e5c0a9176d81d14dbd6d6c5407ef77.json new file mode 100644 index 0000000000..deb03d7792 --- /dev/null +++ b/backend/.sqlx/query-b1c8d94d3e81acc37a5ead4ffb487a9e79e5c0a9176d81d14dbd6d6c5407ef77.json @@ -0,0 +1,25 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH updated AS (\n UPDATE schedule SET\n path = REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\\1'),\n script_path = REGEXP_REPLACE(script_path, 'u/' || $2 || '/(.*)', $1 || '/\\1'),\n permissioned_as = $4\n WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3\n RETURNING 1\n ) SELECT COUNT(*) FROM updated", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Varchar" + ] + }, + "nullable": [ + null + ] + }, + "hash": "b1c8d94d3e81acc37a5ead4ffb487a9e79e5c0a9176d81d14dbd6d6c5407ef77" +} diff --git a/backend/.sqlx/query-b2cadb84a35cd7307be77672e760cd9b0b0240f8cf101e7963a83a72ca8f39d5.json b/backend/.sqlx/query-b2cadb84a35cd7307be77672e760cd9b0b0240f8cf101e7963a83a72ca8f39d5.json new file mode 100644 index 0000000000..86f5da7969 --- /dev/null +++ b/backend/.sqlx/query-b2cadb84a35cd7307be77672e760cd9b0b0240f8cf101e7963a83a72ca8f39d5.json @@ -0,0 +1,42 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n ctr.test_script_path,\n j.id as \"job_id?\",\n COALESCE(jc.status::text, CASE WHEN j.id IS NOT NULL THEN 'running' ELSE NULL END) as \"status?\",\n j.created_at as \"started_at?\"\n FROM ci_test_reference ctr\n LEFT JOIN LATERAL (\n SELECT id, created_at FROM v2_job\n WHERE workspace_id = $1\n AND runnable_path = ctr.test_script_path\n AND trigger_kind = 'ci_test'\n ORDER BY created_at DESC\n LIMIT 1\n ) j ON true\n LEFT JOIN v2_job_completed jc ON jc.id = j.id\n WHERE ctr.workspace_id = $1\n AND ctr.tested_item_path = $2\n AND ctr.tested_item_kind = $3", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "test_script_path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "job_id?", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "status?", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "started_at?", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + null, + false + ] + }, + "hash": "b2cadb84a35cd7307be77672e760cd9b0b0240f8cf101e7963a83a72ca8f39d5" +} diff --git a/backend/.sqlx/query-b3771b690c5966272b1f42c9965bb6a8f961c119516e4c33dc928cd3b4f4edbc.json b/backend/.sqlx/query-b3771b690c5966272b1f42c9965bb6a8f961c119516e4c33dc928cd3b4f4edbc.json index c6fd65605f..5bccf10f4f 100644 --- a/backend/.sqlx/query-b3771b690c5966272b1f42c9965bb6a8f961c119516e4c33dc928cd3b4f4edbc.json +++ b/backend/.sqlx/query-b3771b690c5966272b1f42c9965bb6a8f961c119516e4c33dc928cd3b4f4edbc.json @@ -162,7 +162,8 @@ "gcp", "mqtt", "nextcloud", - "google" + "google", + "ci_test" ] } } diff --git a/backend/.sqlx/query-b50cd4a5236fe5810cb79e9defadc82e82caecb3a1348658f605ddbbcb725f6d.json b/backend/.sqlx/query-b50cd4a5236fe5810cb79e9defadc82e82caecb3a1348658f605ddbbcb725f6d.json new file mode 100644 index 0000000000..9980259caa --- /dev/null +++ b/backend/.sqlx/query-b50cd4a5236fe5810cb79e9defadc82e82caecb3a1348658f605ddbbcb725f6d.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) FROM script WHERE path LIKE 'u/test-user/%' AND workspace_id = 'test-workspace' AND NOT archived AND NOT deleted", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null + ] + }, + "hash": "b50cd4a5236fe5810cb79e9defadc82e82caecb3a1348658f605ddbbcb725f6d" +} diff --git a/backend/.sqlx/query-b588b15ee882d4e85094d050a16f65108ebcf1ab70d5c988cd325f69f1717d1e.json b/backend/.sqlx/query-b588b15ee882d4e85094d050a16f65108ebcf1ab70d5c988cd325f69f1717d1e.json new file mode 100644 index 0000000000..255bad5479 --- /dev/null +++ b/backend/.sqlx/query-b588b15ee882d4e85094d050a16f65108ebcf1ab70d5c988cd325f69f1717d1e.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH updated AS (\n UPDATE variable SET path = REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\\1')\n WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3\n RETURNING 1\n ) SELECT COUNT(*) FROM updated", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "b588b15ee882d4e85094d050a16f65108ebcf1ab70d5c988cd325f69f1717d1e" +} diff --git a/backend/.sqlx/query-b71faa3d183816b248635ccdd5f6e41a122476bcce634656eb0eef9960783983.json b/backend/.sqlx/query-b71faa3d183816b248635ccdd5f6e41a122476bcce634656eb0eef9960783983.json new file mode 100644 index 0000000000..01341eecaf --- /dev/null +++ b/backend/.sqlx/query-b71faa3d183816b248635ccdd5f6e41a122476bcce634656eb0eef9960783983.json @@ -0,0 +1,62 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n EXISTS(SELECT 1 FROM workspace WHERE deleted = false) as \"has_workspace!\",\n EXISTS(SELECT 1 FROM script WHERE archived = false AND deleted = false) as \"has_script!\",\n EXISTS(SELECT 1 FROM flow WHERE archived = false) as \"has_flow!\",\n EXISTS(SELECT 1 FROM app) as \"has_app!\",\n EXISTS(SELECT 1 FROM schedule WHERE enabled = true) as \"has_schedule!\",\n EXISTS(SELECT 1 FROM v2_job_completed) as \"has_job_run!\",\n (SELECT COUNT(*) FROM password) > 1 as \"has_multiple_users!\",\n EXISTS(SELECT 1 FROM resource) as \"has_resource!\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "has_workspace!", + "type_info": "Bool" + }, + { + "ordinal": 1, + "name": "has_script!", + "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "has_flow!", + "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "has_app!", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "has_schedule!", + "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "has_job_run!", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "has_multiple_users!", + "type_info": "Bool" + }, + { + "ordinal": 7, + "name": "has_resource!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null, + null, + null, + null, + null, + null, + null, + null + ] + }, + "hash": "b71faa3d183816b248635ccdd5f6e41a122476bcce634656eb0eef9960783983" +} diff --git a/backend/.sqlx/query-b92e48a18bff6708178e262206a8d293b67fb63ffbfccff55c2d9ae679c9018f.json b/backend/.sqlx/query-b92e48a18bff6708178e262206a8d293b67fb63ffbfccff55c2d9ae679c9018f.json new file mode 100644 index 0000000000..900422c09f --- /dev/null +++ b/backend/.sqlx/query-b92e48a18bff6708178e262206a8d293b67fb63ffbfccff55c2d9ae679c9018f.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE flow_version SET path = REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\\1') WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "b92e48a18bff6708178e262206a8d293b67fb63ffbfccff55c2d9ae679c9018f" +} diff --git a/backend/.sqlx/query-bb0f1b4e0d0832d886a84def2f167ef3411c18b85e1522ea3ef49042b1a27953.json b/backend/.sqlx/query-bb0f1b4e0d0832d886a84def2f167ef3411c18b85e1522ea3ef49042b1a27953.json new file mode 100644 index 0000000000..9b0150b4dc --- /dev/null +++ b/backend/.sqlx/query-bb0f1b4e0d0832d886a84def2f167ef3411c18b85e1522ea3ef49042b1a27953.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE script SET on_behalf_of_email = $1 WHERE on_behalf_of_email = $2 AND workspace_id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "bb0f1b4e0d0832d886a84def2f167ef3411c18b85e1522ea3ef49042b1a27953" +} diff --git a/backend/.sqlx/query-bd000b7ab6438826093ba556af819741b493a91babaeb103852d3912f520719f.json b/backend/.sqlx/query-bd000b7ab6438826093ba556af819741b493a91babaeb103852d3912f520719f.json new file mode 100644 index 0000000000..42b90ef229 --- /dev/null +++ b/backend/.sqlx/query-bd000b7ab6438826093ba556af819741b493a91babaeb103852d3912f520719f.json @@ -0,0 +1,112 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n j.id AS \"id!\", j.workspace_id AS \"workspace_id!\", j.parent_job, j.flow_step_id IS NOT NULL AS \"is_flow_step?\",\n COALESCE(s.flow_status, s.workflow_as_code_status) AS \"flow_status: Box\", r.ping AS last_ping, j.same_worker AS \"same_worker?\",\n q.worker AS \"worker?\",\n wp.ping_at AS \"worker_last_ping?\",\n wp.memory_usage AS \"worker_memory_usage?\",\n wp.wm_memory_usage AS \"worker_wm_memory_usage?\",\n wp.memory AS \"worker_memory_total?\",\n wp.worker_group AS \"worker_group?\",\n wp.wm_version AS \"worker_version?\",\n wp.current_job_id AS \"worker_current_job_id?\",\n wp.worker_instance AS \"worker_instance?\"\n FROM v2_job_queue q JOIN v2_job j USING (id) LEFT JOIN v2_job_runtime r USING (id) LEFT JOIN v2_job_status s USING (id)\n LEFT JOIN worker_ping wp ON wp.worker = q.worker\n WHERE q.running = true AND q.suspend = 0 AND q.suspend_until IS null AND q.scheduled_for <= now()\n AND (j.kind = 'flow' OR j.kind = 'flowpreview' OR j.kind = 'flownode')\n AND r.ping IS NOT NULL AND r.ping < NOW() - ($1 || ' seconds')::interval\n AND q.canceled_by IS NULL\n\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "workspace_id!", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "parent_job", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "is_flow_step?", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "flow_status: Box", + "type_info": "Jsonb" + }, + { + "ordinal": 5, + "name": "last_ping", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "same_worker?", + "type_info": "Bool" + }, + { + "ordinal": 7, + "name": "worker?", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "worker_last_ping?", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "worker_memory_usage?", + "type_info": "Int8" + }, + { + "ordinal": 10, + "name": "worker_wm_memory_usage?", + "type_info": "Int8" + }, + { + "ordinal": 11, + "name": "worker_memory_total?", + "type_info": "Int8" + }, + { + "ordinal": 12, + "name": "worker_group?", + "type_info": "Varchar" + }, + { + "ordinal": 13, + "name": "worker_version?", + "type_info": "Varchar" + }, + { + "ordinal": 14, + "name": "worker_current_job_id?", + "type_info": "Uuid" + }, + { + "ordinal": 15, + "name": "worker_instance?", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + true, + null, + null, + true, + false, + true, + false, + true, + true, + true, + false, + false, + true, + false + ] + }, + "hash": "bd000b7ab6438826093ba556af819741b493a91babaeb103852d3912f520719f" +} diff --git a/backend/.sqlx/query-bd49e278137a115a922322377332a1bcb317e36762821a2039954006a8f9e7a5.json b/backend/.sqlx/query-bd49e278137a115a922322377332a1bcb317e36762821a2039954006a8f9e7a5.json new file mode 100644 index 0000000000..46691c25bb --- /dev/null +++ b/backend/.sqlx/query-bd49e278137a115a922322377332a1bcb317e36762821a2039954006a8f9e7a5.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT path FROM script WHERE path LIKE $1 AND workspace_id = $2 AND NOT archived AND NOT deleted", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "bd49e278137a115a922322377332a1bcb317e36762821a2039954006a8f9e7a5" +} diff --git a/backend/.sqlx/query-be6d2c92a62b7b284651c45af809746147aa9b8d0a81642a7b7cb4738a0cad66.json b/backend/.sqlx/query-be6d2c92a62b7b284651c45af809746147aa9b8d0a81642a7b7cb4738a0cad66.json index e7ee8296d5..d3494214dc 100644 --- a/backend/.sqlx/query-be6d2c92a62b7b284651c45af809746147aa9b8d0a81642a7b7cb4738a0cad66.json +++ b/backend/.sqlx/query-be6d2c92a62b7b284651c45af809746147aa9b8d0a81642a7b7cb4738a0cad66.json @@ -107,7 +107,8 @@ "gcp", "mqtt", "nextcloud", - "google" + "google", + "ci_test" ] } } diff --git a/backend/.sqlx/query-c14e482354ae3eca7893855346e64fa4c0774227fd15e81d708f2457955b87d4.json b/backend/.sqlx/query-c14e482354ae3eca7893855346e64fa4c0774227fd15e81d708f2457955b87d4.json new file mode 100644 index 0000000000..e393cc0838 --- /dev/null +++ b/backend/.sqlx/query-c14e482354ae3eca7893855346e64fa4c0774227fd15e81d708f2457955b87d4.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE folder SET owners = array_remove(owners, 'u/' || $1) WHERE ('u/' || $1) = ANY(owners) AND workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "c14e482354ae3eca7893855346e64fa4c0774227fd15e81d708f2457955b87d4" +} diff --git a/backend/.sqlx/query-c2f9db734fcb234ec34280205689c7f3891251bd7adff5a05b8cac341a1b9a32.json b/backend/.sqlx/query-c2f9db734fcb234ec34280205689c7f3891251bd7adff5a05b8cac341a1b9a32.json new file mode 100644 index 0000000000..c9b16bfb2e --- /dev/null +++ b/backend/.sqlx/query-c2f9db734fcb234ec34280205689c7f3891251bd7adff5a05b8cac341a1b9a32.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace_runnable_dependencies SET app_path = REGEXP_REPLACE(app_path, 'u/' || $2 || '/(.*)', $1 || '/\\1') WHERE app_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "c2f9db734fcb234ec34280205689c7f3891251bd7adff5a05b8cac341a1b9a32" +} diff --git a/backend/.sqlx/query-c547516873b02e06d229b1c7f8e6619f1e1300f9f9969911608f690b78d93099.json b/backend/.sqlx/query-c547516873b02e06d229b1c7f8e6619f1e1300f9f9969911608f690b78d93099.json new file mode 100644 index 0000000000..821faaf66f --- /dev/null +++ b/backend/.sqlx/query-c547516873b02e06d229b1c7f8e6619f1e1300f9f9969911608f690b78d93099.json @@ -0,0 +1,68 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT jwt_hash, email, username, is_admin, is_operator, workspace_id, label, scopes, last_used_at\n FROM unique_ext_jwt_token\n WHERE last_used_at > NOW() - INTERVAL '30 days'\n ORDER BY last_used_at DESC", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "jwt_hash", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "email", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "username", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "is_admin", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "is_operator", + "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "workspace_id", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "label", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "scopes", + "type_info": "TextArray" + }, + { + "ordinal": 8, + "name": "last_used_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + true, + true, + false + ] + }, + "hash": "c547516873b02e06d229b1c7f8e6619f1e1300f9f9969911608f690b78d93099" +} diff --git a/backend/.sqlx/query-c77f73485d34d30a8a5407bf246d336d0c925bd51fe3d327641cb259a264387f.json b/backend/.sqlx/query-c77f73485d34d30a8a5407bf246d336d0c925bd51fe3d327641cb259a264387f.json new file mode 100644 index 0000000000..cd7413f331 --- /dev/null +++ b/backend/.sqlx/query-c77f73485d34d30a8a5407bf246d336d0c925bd51fe3d327641cb259a264387f.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) FROM token WHERE owner = 'u/test-user-2' AND workspace_id = 'test-workspace'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null + ] + }, + "hash": "c77f73485d34d30a8a5407bf246d336d0c925bd51fe3d327641cb259a264387f" +} diff --git a/backend/.sqlx/query-c8819e8ea6eab04f5c90236dcbc29b11df6c6dd79f02f16a05b2fa852456b127.json b/backend/.sqlx/query-c8819e8ea6eab04f5c90236dcbc29b11df6c6dd79f02f16a05b2fa852456b127.json new file mode 100644 index 0000000000..e78a47ba6a --- /dev/null +++ b/backend/.sqlx/query-c8819e8ea6eab04f5c90236dcbc29b11df6c6dd79f02f16a05b2fa852456b127.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT permissioned_as FROM schedule WHERE path = 'u/test-user/sched_a' AND workspace_id = 'test-workspace'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "permissioned_as", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false + ] + }, + "hash": "c8819e8ea6eab04f5c90236dcbc29b11df6c6dd79f02f16a05b2fa852456b127" +} diff --git a/backend/.sqlx/query-cc8326c8ea6aa1c81977d920da595da2385252be0b5c2ede7cc8fb8579b1c431.json b/backend/.sqlx/query-cc8326c8ea6aa1c81977d920da595da2385252be0b5c2ede7cc8fb8579b1c431.json new file mode 100644 index 0000000000..71104a0a81 --- /dev/null +++ b/backend/.sqlx/query-cc8326c8ea6aa1c81977d920da595da2385252be0b5c2ede7cc8fb8579b1c431.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE app SET policy = jsonb_set(policy, ARRAY['on_behalf_of'], to_jsonb($1::text)) WHERE policy->>'on_behalf_of' = ('u/' || $2) AND workspace_id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "cc8326c8ea6aa1c81977d920da595da2385252be0b5c2ede7cc8fb8579b1c431" +} diff --git a/backend/.sqlx/query-cde8b310e378d8db32403c481011e5bbe5050a373dd3ebe6d383b09789c61810.json b/backend/.sqlx/query-cde8b310e378d8db32403c481011e5bbe5050a373dd3ebe6d383b09789c61810.json new file mode 100644 index 0000000000..663f6c21b9 --- /dev/null +++ b/backend/.sqlx/query-cde8b310e378d8db32403c481011e5bbe5050a373dd3ebe6d383b09789c61810.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE ci_test_reference SET tested_item_path = $1 WHERE tested_item_path = $2 AND workspace_id = $3 AND tested_item_kind = 'flow'", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "cde8b310e378d8db32403c481011e5bbe5050a373dd3ebe6d383b09789c61810" +} diff --git a/backend/.sqlx/query-d1a33665e47e10c4f992e018e234496935fc3415c7aa00642626494a2940cdf2.json b/backend/.sqlx/query-d1a33665e47e10c4f992e018e234496935fc3415c7aa00642626494a2940cdf2.json new file mode 100644 index 0000000000..08862eab8a --- /dev/null +++ b/backend/.sqlx/query-d1a33665e47e10c4f992e018e234496935fc3415c7aa00642626494a2940cdf2.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE flow_node SET path = REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\\1') WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "d1a33665e47e10c4f992e018e234496935fc3415c7aa00642626494a2940cdf2" +} diff --git a/backend/.sqlx/query-d41ea93fd58381b89e151c965eae1ea2fe96a1b94f5a92953fb1c1642d15c016.json b/backend/.sqlx/query-d41ea93fd58381b89e151c965eae1ea2fe96a1b94f5a92953fb1c1642d15c016.json index e27d91ceb3..49ec375dab 100644 --- a/backend/.sqlx/query-d41ea93fd58381b89e151c965eae1ea2fe96a1b94f5a92953fb1c1642d15c016.json +++ b/backend/.sqlx/query-d41ea93fd58381b89e151c965eae1ea2fe96a1b94f5a92953fb1c1642d15c016.json @@ -107,7 +107,8 @@ "gcp", "mqtt", "nextcloud", - "google" + "google", + "ci_test" ] } } diff --git a/backend/.sqlx/query-d62427330bee5576555ac7f7483b373b9855c9c437b27c4452761eda5cce78f4.json b/backend/.sqlx/query-d62427330bee5576555ac7f7483b373b9855c9c437b27c4452761eda5cce78f4.json new file mode 100644 index 0000000000..3510b5fd76 --- /dev/null +++ b/backend/.sqlx/query-d62427330bee5576555ac7f7483b373b9855c9c437b27c4452761eda5cce78f4.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE ci_test_reference SET tested_item_path = $1 WHERE tested_item_path = $2 AND workspace_id = $3 AND tested_item_kind = 'script'", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "d62427330bee5576555ac7f7483b373b9855c9c437b27c4452761eda5cce78f4" +} diff --git a/backend/.sqlx/query-d72005e7d45edf09b5f05a9472361d40e211e912beeb7f4c275c2f2696fd6438.json b/backend/.sqlx/query-d72005e7d45edf09b5f05a9472361d40e211e912beeb7f4c275c2f2696fd6438.json new file mode 100644 index 0000000000..6cb2d52cb3 --- /dev/null +++ b/backend/.sqlx/query-d72005e7d45edf09b5f05a9472361d40e211e912beeb7f4c275c2f2696fd6438.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM usr WHERE email = 'test2@windmill.dev' AND workspace_id = 'test-workspace')", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null + ] + }, + "hash": "d72005e7d45edf09b5f05a9472361d40e211e912beeb7f4c275c2f2696fd6438" +} diff --git a/backend/.sqlx/query-dabb00256a2b44e1ce0aacd124ba4dc07d8fa57ade272abb9172e9dc74119e43.json b/backend/.sqlx/query-dabb00256a2b44e1ce0aacd124ba4dc07d8fa57ade272abb9172e9dc74119e43.json new file mode 100644 index 0000000000..911984002d --- /dev/null +++ b/backend/.sqlx/query-dabb00256a2b44e1ce0aacd124ba4dc07d8fa57ade272abb9172e9dc74119e43.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE asset SET usage_path = REGEXP_REPLACE(usage_path, 'u/' || $2 || '/(.*)', $1 || '/\\1') WHERE usage_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "dabb00256a2b44e1ce0aacd124ba4dc07d8fa57ade272abb9172e9dc74119e43" +} diff --git a/backend/.sqlx/query-de3dd6c373ca696962d67aebe43fd77c7172df6f1a2c654993927350bcdc7d24.json b/backend/.sqlx/query-de3dd6c373ca696962d67aebe43fd77c7172df6f1a2c654993927350bcdc7d24.json new file mode 100644 index 0000000000..a36a98d7d1 --- /dev/null +++ b/backend/.sqlx/query-de3dd6c373ca696962d67aebe43fd77c7172df6f1a2c654993927350bcdc7d24.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) FROM email_trigger WHERE path LIKE $1 AND workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "de3dd6c373ca696962d67aebe43fd77c7172df6f1a2c654993927350bcdc7d24" +} diff --git a/backend/.sqlx/query-e4d2e59ec87c86fba227d997dd2c34ae5978bfcd92ec6028c66d6596e0108ff3.json b/backend/.sqlx/query-e4d2e59ec87c86fba227d997dd2c34ae5978bfcd92ec6028c66d6596e0108ff3.json new file mode 100644 index 0000000000..0f12305e4d --- /dev/null +++ b/backend/.sqlx/query-e4d2e59ec87c86fba227d997dd2c34ae5978bfcd92ec6028c66d6596e0108ff3.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT path FROM schedule WHERE permissioned_as = $1 AND NOT path LIKE $2 AND workspace_id = $3", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "e4d2e59ec87c86fba227d997dd2c34ae5978bfcd92ec6028c66d6596e0108ff3" +} diff --git a/backend/.sqlx/query-e4d71278fb80126a7a9da73f1889352d4d1e3cb3a8a08f1c9c03055a1cab1235.json b/backend/.sqlx/query-e4d71278fb80126a7a9da73f1889352d4d1e3cb3a8a08f1c9c03055a1cab1235.json index 9dffee0d1d..6fb03a4523 100644 --- a/backend/.sqlx/query-e4d71278fb80126a7a9da73f1889352d4d1e3cb3a8a08f1c9c03055a1cab1235.json +++ b/backend/.sqlx/query-e4d71278fb80126a7a9da73f1889352d4d1e3cb3a8a08f1c9c03055a1cab1235.json @@ -187,7 +187,8 @@ "gcp", "mqtt", "nextcloud", - "google" + "google", + "ci_test" ] } } diff --git a/backend/.sqlx/query-e877622d0b58e45ca840ec8c5df3aa68c775357920d84d53737acab3d3dee00d.json b/backend/.sqlx/query-e877622d0b58e45ca840ec8c5df3aa68c775357920d84d53737acab3d3dee00d.json new file mode 100644 index 0000000000..1c88f11310 --- /dev/null +++ b/backend/.sqlx/query-e877622d0b58e45ca840ec8c5df3aa68c775357920d84d53737acab3d3dee00d.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT test_script_path FROM ci_test_reference WHERE workspace_id = $1 AND tested_item_path = $2 AND tested_item_kind = $3", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "test_script_path", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "e877622d0b58e45ca840ec8c5df3aa68c775357920d84d53737acab3d3dee00d" +} diff --git a/backend/.sqlx/query-ea0c2e912bfc7c01fa5b1e68ab3269deebb8ab5b7ebdab7002a17dc840a11279.json b/backend/.sqlx/query-ea0c2e912bfc7c01fa5b1e68ab3269deebb8ab5b7ebdab7002a17dc840a11279.json new file mode 100644 index 0000000000..ca548b02e4 --- /dev/null +++ b/backend/.sqlx/query-ea0c2e912bfc7c01fa5b1e68ab3269deebb8ab5b7ebdab7002a17dc840a11279.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace_runnable_dependencies SET runnable_path = REGEXP_REPLACE(runnable_path, 'u/' || $2 || '/(.*)', $1 || '/\\1') WHERE runnable_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "ea0c2e912bfc7c01fa5b1e68ab3269deebb8ab5b7ebdab7002a17dc840a11279" +} diff --git a/backend/.sqlx/query-ea397829236a7d05f5d11b784035c3216e1bfad20df6ca99158cc4d1ee7d5af0.json b/backend/.sqlx/query-ea397829236a7d05f5d11b784035c3216e1bfad20df6ca99158cc4d1ee7d5af0.json new file mode 100644 index 0000000000..3dd371cb46 --- /dev/null +++ b/backend/.sqlx/query-ea397829236a7d05f5d11b784035c3216e1bfad20df6ca99158cc4d1ee7d5af0.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM usr WHERE username = 'test-user-2' AND workspace_id = 'test-workspace')", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null + ] + }, + "hash": "ea397829236a7d05f5d11b784035c3216e1bfad20df6ca99158cc4d1ee7d5af0" +} diff --git a/backend/.sqlx/query-f1a1ab178e20ba2842e8025aad5b5aac4b443673066a15079c149e1e6207f153.json b/backend/.sqlx/query-f1a1ab178e20ba2842e8025aad5b5aac4b443673066a15079c149e1e6207f153.json new file mode 100644 index 0000000000..dff9760d92 --- /dev/null +++ b/backend/.sqlx/query-f1a1ab178e20ba2842e8025aad5b5aac4b443673066a15079c149e1e6207f153.json @@ -0,0 +1,42 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n ctr.test_script_path,\n j.id as \"job_id?\",\n COALESCE(jc.status::text, CASE WHEN j.id IS NOT NULL THEN 'running' ELSE NULL END) as \"status?\",\n j.created_at as \"started_at?\"\n FROM ci_test_reference ctr\n LEFT JOIN LATERAL (\n SELECT id, created_at FROM v2_job\n WHERE workspace_id = $1\n AND runnable_path = ctr.test_script_path\n AND trigger_kind = 'ci_test'\n ORDER BY created_at DESC\n LIMIT 1\n ) j ON true\n LEFT JOIN v2_job_completed jc ON jc.id = j.id\n WHERE ctr.workspace_id = $1\n AND ctr.tested_item_path = $2\n AND ctr.tested_item_kind = $3", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "test_script_path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "job_id?", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "status?", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "started_at?", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + null, + false + ] + }, + "hash": "f1a1ab178e20ba2842e8025aad5b5aac4b443673066a15079c149e1e6207f153" +} diff --git a/backend/.sqlx/query-f265e67a47a593d55dca76848276bf79f8f732f188e776c42b811cd7852e99b3.json b/backend/.sqlx/query-f265e67a47a593d55dca76848276bf79f8f732f188e776c42b811cd7852e99b3.json new file mode 100644 index 0000000000..b6a489bebe --- /dev/null +++ b/backend/.sqlx/query-f265e67a47a593d55dca76848276bf79f8f732f188e776c42b811cd7852e99b3.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) FROM script WHERE path LIKE 'u/test-user/%' AND workspace_id = 'test-workspace-2' AND NOT archived AND NOT deleted", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null + ] + }, + "hash": "f265e67a47a593d55dca76848276bf79f8f732f188e776c42b811cd7852e99b3" +} diff --git a/backend/.sqlx/query-fbd0c71bc033ca62437008985792964e487a0b077e2cc0537515fef6e2a9285f.json b/backend/.sqlx/query-fbd0c71bc033ca62437008985792964e487a0b077e2cc0537515fef6e2a9285f.json new file mode 100644 index 0000000000..9488d04d6f --- /dev/null +++ b/backend/.sqlx/query-fbd0c71bc033ca62437008985792964e487a0b077e2cc0537515fef6e2a9285f.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT path FROM app WHERE path LIKE $1 AND workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "fbd0c71bc033ca62437008985792964e487a0b077e2cc0537515fef6e2a9285f" +} diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 58adf9273b..0a9b0cdc2e 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -421,7 +421,7 @@ dependencies = [ "arrow-schema", "chrono", "half", - "indexmap 2.12.0", + "indexmap 2.14.0", "lexical-core", "memchr", "num", @@ -2213,9 +2213,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.59" +version = "1.2.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" dependencies = [ "find-msvc-tools", "jobserver", @@ -3203,7 +3203,7 @@ dependencies = [ "base64 0.22.1", "half", "hashbrown 0.14.5", - "indexmap 2.12.0", + "indexmap 2.14.0", "libc", "log", "object_store", @@ -3382,7 +3382,7 @@ dependencies = [ "datafusion-functions-aggregate-common", "datafusion-functions-window-common", "datafusion-physical-expr-common", - "indexmap 2.12.0", + "indexmap 2.14.0", "paste", "recursive", "serde_json", @@ -3397,7 +3397,7 @@ checksum = "422ac9cf3b22bbbae8cdf8ceb33039107fde1b5492693168f13bd566b1bcc839" dependencies = [ "arrow", "datafusion-common", - "indexmap 2.12.0", + "indexmap 2.14.0", "itertools 0.14.0", "paste", ] @@ -3551,7 +3551,7 @@ dependencies = [ "datafusion-common", "datafusion-expr", "datafusion-physical-expr", - "indexmap 2.12.0", + "indexmap 2.14.0", "itertools 0.14.0", "log", "recursive", @@ -3574,7 +3574,7 @@ dependencies = [ "datafusion-physical-expr-common", "half", "hashbrown 0.14.5", - "indexmap 2.12.0", + "indexmap 2.14.0", "itertools 0.14.0", "log", "paste", @@ -3636,7 +3636,7 @@ dependencies = [ "futures", "half", "hashbrown 0.14.5", - "indexmap 2.12.0", + "indexmap 2.14.0", "itertools 0.14.0", "log", "parking_lot", @@ -3678,7 +3678,7 @@ dependencies = [ "bigdecimal", "datafusion-common", "datafusion-expr", - "indexmap 2.12.0", + "indexmap 2.14.0", "log", "recursive", "regex", @@ -3796,7 +3796,7 @@ dependencies = [ "deno_media_type", "deno_path_util", "http 1.4.0", - "indexmap 2.12.0", + "indexmap 2.14.0", "log", "once_cell", "parking_lot", @@ -3837,7 +3837,7 @@ dependencies = [ "glob", "ignore", "import_map", - "indexmap 2.12.0", + "indexmap 2.14.0", "jsonc-parser", "log", "percent-encoding", @@ -3878,7 +3878,7 @@ dependencies = [ "deno_path_util", "deno_unsync", "futures", - "indexmap 2.12.0", + "indexmap 2.14.0", "libc", "memoffset", "parking_lot", @@ -4289,7 +4289,7 @@ dependencies = [ "hyper 1.9.0", "hyper-util", "idna", - "indexmap 2.12.0", + "indexmap 2.14.0", "ipnetwork", "k256", "lazy-regex", @@ -4365,7 +4365,7 @@ version = "0.212.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2d328067139909aa81522a5d90f119368b541fbddd73ab630e4d9f777865f0d" dependencies = [ - "indexmap 2.12.0", + "indexmap 2.14.0", "proc-macro-rules", "proc-macro2", "quote", @@ -4409,7 +4409,7 @@ dependencies = [ "deno_error", "deno_path_util", "deno_semver", - "indexmap 2.12.0", + "indexmap 2.14.0", "serde", "serde_json", "sys_traits", @@ -6549,7 +6549,7 @@ dependencies = [ "futures-sink", "futures-util", "http 0.2.12", - "indexmap 2.12.0", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", @@ -6568,7 +6568,7 @@ dependencies = [ "futures-core", "futures-sink", "http 1.4.0", - "indexmap 2.12.0", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", @@ -6643,11 +6643,18 @@ dependencies = [ ] [[package]] -name = "hashify" -version = "0.2.7" +name = "hashbrown" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "149e3ea90eb5a26ad354cfe3cb7f7401b9329032d0235f2687d03a35f30e5d4c" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "hashify" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd1246c0e5493286aeb2dde35b1f4eb9c4ce00e628641210a5e553fc001a1f26" dependencies = [ + "indexmap 2.14.0", "proc-macro2", "quote", "syn 2.0.117", @@ -7404,7 +7411,7 @@ checksum = "1215d4d92511fbbdaea50e750e91f2429598ef817f02b579158e92803b52c00a" dependencies = [ "boxed_error", "deno_error", - "indexmap 2.12.0", + "indexmap 2.14.0", "log", "percent-encoding", "serde", @@ -7426,12 +7433,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.12.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.0", + "hashbrown 0.17.0", "serde", "serde_core", ] @@ -7797,7 +7804,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ee7893dab2e44ae5f9d0173f26ff4aa327c10b01b06a72b52dd9405b628640d" dependencies = [ - "indexmap 2.12.0", + "indexmap 2.14.0", ] [[package]] @@ -8155,14 +8162,14 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.15" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ "bitflags 2.9.4", "libc", "plain", - "redox_syscall 0.7.3", + "redox_syscall 0.7.4", ] [[package]] @@ -8884,7 +8891,7 @@ dependencies = [ "bitflags 2.9.4", "codespan-reporting", "hexf-parse", - "indexmap 2.12.0", + "indexmap 2.14.0", "log", "num-traits", "rustc-hash 1.1.0", @@ -9175,7 +9182,7 @@ dependencies = [ "dirs-sys 0.4.1", "fancy-regex 0.14.0", "heck 0.5.0", - "indexmap 2.12.0", + "indexmap 2.14.0", "log", "lru 0.12.5", "miette", @@ -10158,7 +10165,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" dependencies = [ "fixedbitset", - "indexmap 2.12.0", + "indexmap 2.14.0", ] [[package]] @@ -10593,7 +10600,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3ef4f2f0422f23a82ec9f628ea2acd12871c81a9362b02c43c1aa86acfc3ba1" dependencies = [ "futures", - "indexmap 2.12.0", + "indexmap 2.14.0", "nix 0.30.1", "tokio", "tracing", @@ -11157,9 +11164,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" dependencies = [ "bitflags 2.9.4", ] @@ -11578,7 +11585,7 @@ dependencies = [ "convert_case 0.10.0", "fnv", "ident_case", - "indexmap 2.12.0", + "indexmap 2.14.0", "proc-macro-crate", "proc-macro2", "quote", @@ -11820,7 +11827,7 @@ dependencies = [ "once_cell", "ring 0.17.14", "rustls-pki-types", - "rustls-webpki 0.103.10", + "rustls-webpki 0.103.11", "subtle", "zeroize", ] @@ -11904,7 +11911,7 @@ dependencies = [ "rustls 0.23.35", "rustls-native-certs 0.8.3", "rustls-platform-verifier-android", - "rustls-webpki 0.103.10", + "rustls-webpki 0.103.11", "security-framework 3.6.0", "security-framework-sys", "webpki-root-certs 1.0.6", @@ -11952,9 +11959,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.10" +version = "0.103.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +checksum = "20a6af516fea4b20eccceaf166e8aa666ac996208e8a644ce3ef5aa783bc7cd4" dependencies = [ "aws-lc-rs", "ring 0.17.14", @@ -12445,7 +12452,7 @@ version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ - "indexmap 2.12.0", + "indexmap 2.14.0", "itoa", "memchr", "serde", @@ -12538,7 +12545,7 @@ dependencies = [ "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.12.0", + "indexmap 2.14.0", "schemars 0.9.0", "schemars 1.2.1", "serde", @@ -12566,7 +12573,7 @@ version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap 2.12.0", + "indexmap 2.14.0", "itoa", "ryu", "serde", @@ -12579,7 +12586,7 @@ version = "0.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd" dependencies = [ - "indexmap 2.12.0", + "indexmap 2.14.0", "itoa", "libyml", "memchr", @@ -13083,7 +13090,7 @@ dependencies = [ "futures-util", "hashbrown 0.15.5", "hashlink 0.10.0", - "indexmap 2.12.0", + "indexmap 2.14.0", "log", "memchr", "once_cell", @@ -13254,9 +13261,9 @@ dependencies = [ [[package]] name = "sse-stream" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb4dc4d33c68ec1f27d386b5610a351922656e1fdf5c05bbaad930cd1519479a" +checksum = "2c5e6deb40826033bd7b11c7ef25ef71193fabd71f680f40dd16538a2704d2f4" dependencies = [ "bytes", "futures-util", @@ -13483,7 +13490,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4740e53eaf68b101203c1df0937d5161a29f3c13bceed0836ddfe245b72dd000" dependencies = [ "anyhow", - "indexmap 2.12.0", + "indexmap 2.14.0", "serde", "serde_json", "swc_cached", @@ -13595,7 +13602,7 @@ checksum = "65f21494e75d0bd8ef42010b47cabab9caaed8f2207570e809f6f4eb51a710d1" dependencies = [ "better_scoped_tls", "bitflags 2.9.4", - "indexmap 2.12.0", + "indexmap 2.14.0", "once_cell", "phf 0.11.3", "rustc-hash 1.1.0", @@ -13664,7 +13671,7 @@ checksum = "76c76d8b9792ce51401d38da0fa62158d61f6d80d16d68fe5b03ce4bf5fba383" dependencies = [ "base64 0.21.7", "dashmap 5.5.3", - "indexmap 2.12.0", + "indexmap 2.14.0", "once_cell", "serde", "sha1", @@ -13704,7 +13711,7 @@ version = "0.134.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "029eec7dd485923a75b5a45befd04510288870250270292fc2c1b3a9e7547408" dependencies = [ - "indexmap 2.12.0", + "indexmap 2.14.0", "num_cpus", "once_cell", "rustc-hash 1.1.0", @@ -14670,7 +14677,7 @@ version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ - "indexmap 2.12.0", + "indexmap 2.14.0", "serde", "serde_spanned", "toml_datetime 0.6.11", @@ -14683,7 +14690,7 @@ version = "0.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7211ff1b8f0d3adae1663b7da9ffe396eabe1ca25f0b0bee42b0da29a9ddce93" dependencies = [ - "indexmap 2.12.0", + "indexmap 2.14.0", "toml_datetime 0.7.0", "toml_parser", "winnow 0.7.15", @@ -14791,7 +14798,7 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", - "indexmap 2.12.0", + "indexmap 2.14.0", "pin-project-lite", "slab", "sync_wrapper", @@ -15506,7 +15513,7 @@ checksum = "97599c400fc79925922b58303e98fcb8fa88f573379a08ddb652e72cbd2e70f6" dependencies = [ "bitflags 2.9.4", "encoding_rs", - "indexmap 2.12.0", + "indexmap 2.14.0", "num-bigint", "serde", "thiserror 1.0.69", @@ -15727,7 +15734,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap 2.12.0", + "indexmap 2.14.0", "wasm-encoder", "wasmparser", ] @@ -15763,7 +15770,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags 2.9.4", "hashbrown 0.15.5", - "indexmap 2.12.0", + "indexmap 2.14.0", "semver 1.0.28", ] @@ -15849,7 +15856,7 @@ dependencies = [ "cfg_aliases 0.1.1", "codespan-reporting", "document-features", - "indexmap 2.12.0", + "indexmap 2.14.0", "log", "naga", "once_cell", @@ -16007,7 +16014,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.680.0" +version = "1.682.0" dependencies = [ "anyhow", "async-nats", @@ -16086,7 +16093,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.680.0" +version = "1.682.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16099,7 +16106,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.680.0" +version = "1.682.0" dependencies = [ "anyhow", "argon2", @@ -16135,7 +16142,7 @@ dependencies = [ "hmac 0.12.1", "http 1.4.0", "hyper 1.9.0", - "indexmap 2.12.0", + "indexmap 2.14.0", "itertools 0.14.0", "jsonwebtoken 8.3.0", "lazy_static", @@ -16240,7 +16247,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.680.0" +version = "1.682.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16263,7 +16270,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.680.0" +version = "1.682.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16276,7 +16283,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.680.0" +version = "1.682.0" dependencies = [ "anyhow", "axum 0.8.4", @@ -16302,7 +16309,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.680.0" +version = "1.682.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -16312,7 +16319,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.680.0" +version = "1.682.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16329,7 +16336,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.680.0" +version = "1.682.0" dependencies = [ "axum 0.8.4", "base64 0.22.1", @@ -16352,7 +16359,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.680.0" +version = "1.682.0" dependencies = [ "anyhow", "axum 0.8.4", @@ -16375,7 +16382,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.680.0" +version = "1.682.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16391,7 +16398,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.680.0" +version = "1.682.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16400,6 +16407,7 @@ dependencies = [ "serde_json", "sql-builder", "sqlx", + "tokio", "tracing", "windmill-api-auth", "windmill-audit", @@ -16411,7 +16419,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.680.0" +version = "1.682.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16431,7 +16439,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.680.0" +version = "1.682.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16445,7 +16453,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.680.0" +version = "1.682.0" dependencies = [ "anyhow", "async-nats", @@ -16476,7 +16484,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.680.0" +version = "1.682.0" dependencies = [ "anyhow", "axum 0.8.4", @@ -16501,7 +16509,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.680.0" +version = "1.682.0" dependencies = [ "axum 0.8.4", "flate2", @@ -16519,12 +16527,12 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.680.0" +version = "1.682.0" dependencies = [ "anyhow", "axum 0.8.4", "http 1.4.0", - "indexmap 2.12.0", + "indexmap 2.14.0", "itertools 0.14.0", "lazy_static", "serde", @@ -16541,7 +16549,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.680.0" +version = "1.682.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16561,7 +16569,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.680.0" +version = "1.682.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16591,7 +16599,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.680.0" +version = "1.682.0" dependencies = [ "anyhow", "axum 0.8.4", @@ -16618,7 +16626,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.680.0" +version = "1.682.0" dependencies = [ "lazy_static", "serde", @@ -16630,7 +16638,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.680.0" +version = "1.682.0" dependencies = [ "argon2", "axum 0.8.4", @@ -16655,7 +16663,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.680.0" +version = "1.682.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16669,7 +16677,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.680.0" +version = "1.682.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16702,7 +16710,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.680.0" +version = "1.682.0" dependencies = [ "chrono", "lazy_static", @@ -16716,7 +16724,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.680.0" +version = "1.682.0" dependencies = [ "anyhow", "axum 0.8.4", @@ -16735,7 +16743,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.680.0" +version = "1.682.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -16774,7 +16782,7 @@ dependencies = [ "hex", "hmac 0.12.1", "hyper 1.9.0", - "indexmap 2.12.0", + "indexmap 2.14.0", "itertools 0.14.0", "jsonwebtoken 8.3.0", "lazy_static", @@ -16838,7 +16846,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.680.0" +version = "1.682.0" dependencies = [ "chrono", "itertools 0.14.0", @@ -16857,7 +16865,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.680.0" +version = "1.682.0" dependencies = [ "regex", "serde", @@ -16872,7 +16880,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.680.0" +version = "1.682.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -16896,7 +16904,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.680.0" +version = "1.682.0" dependencies = [ "anyhow", "futures", @@ -16913,7 +16921,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.680.0" +version = "1.682.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -16929,7 +16937,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.680.0" +version = "1.682.0" dependencies = [ "anyhow", "async-trait", @@ -16950,7 +16958,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.680.0" +version = "1.682.0" dependencies = [ "anyhow", "async-trait", @@ -16981,7 +16989,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.680.0" +version = "1.682.0" dependencies = [ "anyhow", "async-oauth2", @@ -17005,7 +17013,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.680.0" +version = "1.682.0" dependencies = [ "anyhow", "async-stream", @@ -17039,7 +17047,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.680.0" +version = "1.682.0" dependencies = [ "anyhow", "futures", @@ -17057,7 +17065,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.680.0" +version = "1.682.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -17066,7 +17074,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.680.0" +version = "1.682.0" dependencies = [ "anyhow", "lazy_static", @@ -17078,7 +17086,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.680.0" +version = "1.682.0" dependencies = [ "anyhow", "serde_json", @@ -17090,7 +17098,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.680.0" +version = "1.682.0" dependencies = [ "anyhow", "gosyn", @@ -17102,7 +17110,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.680.0" +version = "1.682.0" dependencies = [ "anyhow", "lazy_static", @@ -17114,7 +17122,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.680.0" +version = "1.682.0" dependencies = [ "anyhow", "serde_json", @@ -17126,7 +17134,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.680.0" +version = "1.682.0" dependencies = [ "anyhow", "nu-parser", @@ -17137,7 +17145,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.680.0" +version = "1.682.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -17148,7 +17156,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.680.0" +version = "1.682.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -17160,7 +17168,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.680.0" +version = "1.682.0" dependencies = [ "anyhow", "rustpython-ast", @@ -17171,7 +17179,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.680.0" +version = "1.682.0" dependencies = [ "anyhow", "async-recursion", @@ -17193,7 +17201,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.680.0" +version = "1.682.0" dependencies = [ "anyhow", "serde_json", @@ -17205,7 +17213,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.680.0" +version = "1.682.0" dependencies = [ "anyhow", "lazy_static", @@ -17219,7 +17227,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.680.0" +version = "1.682.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -17236,7 +17244,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.680.0" +version = "1.682.0" dependencies = [ "anyhow", "lazy_static", @@ -17249,7 +17257,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.680.0" +version = "1.682.0" dependencies = [ "anyhow", "serde", @@ -17261,7 +17269,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.680.0" +version = "1.682.0" dependencies = [ "anyhow", "lazy_static", @@ -17279,7 +17287,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.680.0" +version = "1.682.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -17295,7 +17303,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.680.0" +version = "1.682.0" dependencies = [ "anyhow", "rustpython-ast", @@ -17311,7 +17319,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.680.0" +version = "1.682.0" dependencies = [ "anyhow", "serde", @@ -17322,7 +17330,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.680.0" +version = "1.682.0" dependencies = [ "anyhow", "async-recursion", @@ -17359,7 +17367,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.680.0" +version = "1.682.0" dependencies = [ "anyhow", "const_format", @@ -17397,7 +17405,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.680.0" +version = "1.682.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -17408,7 +17416,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.680.0" +version = "1.682.0" dependencies = [ "anyhow", "async-recursion", @@ -17430,6 +17438,7 @@ dependencies = [ "windmill-api-auth", "windmill-audit", "windmill-common", + "windmill-dep-map", "windmill-git-sync", "windmill-mcp", "windmill-oauth", @@ -17437,7 +17446,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.680.0" +version = "1.682.0" dependencies = [ "anyhow", "async-trait", @@ -17461,7 +17470,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.680.0" +version = "1.682.0" dependencies = [ "anyhow", "async-trait", @@ -17494,7 +17503,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.680.0" +version = "1.682.0" dependencies = [ "anyhow", "async-trait", @@ -17514,7 +17523,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.680.0" +version = "1.682.0" dependencies = [ "anyhow", "async-trait", @@ -17548,7 +17557,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.680.0" +version = "1.682.0" dependencies = [ "anyhow", "async-trait", @@ -17584,7 +17593,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.680.0" +version = "1.682.0" dependencies = [ "anyhow", "async-trait", @@ -17607,7 +17616,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.680.0" +version = "1.682.0" dependencies = [ "anyhow", "async-trait", @@ -17631,7 +17640,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.680.0" +version = "1.682.0" dependencies = [ "anyhow", "async-nats", @@ -17655,7 +17664,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.680.0" +version = "1.682.0" dependencies = [ "anyhow", "async-trait", @@ -17690,7 +17699,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.680.0" +version = "1.682.0" dependencies = [ "anyhow", "async-trait", @@ -17718,7 +17727,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.680.0" +version = "1.682.0" dependencies = [ "anyhow", "async-trait", @@ -17741,7 +17750,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.680.0" +version = "1.682.0" dependencies = [ "anyhow", "bitflags 2.9.4", @@ -17760,7 +17769,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.680.0" +version = "1.682.0" dependencies = [ "anyhow", "async-once-cell", @@ -17871,7 +17880,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.680.0" +version = "1.682.0" dependencies = [ "bytes", "futures", @@ -18509,7 +18518,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck 0.5.0", - "indexmap 2.12.0", + "indexmap 2.14.0", "prettyplease", "syn 2.0.117", "wasm-metadata", @@ -18540,7 +18549,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags 2.9.4", - "indexmap 2.12.0", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -18559,7 +18568,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap 2.12.0", + "indexmap 2.14.0", "log", "semver 1.0.28", "serde", @@ -18840,7 +18849,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c42e33efc22a0650c311c2ef19115ce232583abbe80850bc8b66509ebef02de0" dependencies = [ "crc32fast", - "indexmap 2.12.0", + "indexmap 2.14.0", "memchr", "typed-path", ] diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 4d1d2f8df4..ca820d28bc 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.680.0" +version = "1.682.0" authors.workspace = true edition.workspace = true @@ -85,7 +85,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"] [workspace.package] -version = "1.680.0" +version = "1.682.0" authors = ["Ruben Fiszel "] edition = "2021" @@ -108,7 +108,7 @@ strip = "none" [features] default = [] -private = ["windmill-api/private", "windmill-api-agent-workers?/private", "windmill-autoscaling/private", "windmill-common/private", "windmill-object-store/private", "windmill-git-sync/private", "windmill-indexer/private", "windmill-operator?/private", "windmill-queue/private", "windmill-worker/private", "windmill-test-utils/private"] +private = ["windmill-api/private", "windmill-api-agent-workers?/private", "windmill-autoscaling/private", "windmill-common/private", "windmill-dep-map/private", "windmill-object-store/private", "windmill-git-sync/private", "windmill-indexer/private", "windmill-operator?/private", "windmill-queue/private", "windmill-worker/private", "windmill-test-utils/private"] agent_worker_server = ["windmill-api/agent_worker_server", "dep:windmill-api-agent-workers", "windmill-test-utils/agent_worker_server"] enterprise = ["windmill-worker/enterprise", "windmill-queue/enterprise", "windmill-api/enterprise", "windmill-api-agent-workers?/enterprise", "dep:windmill-autoscaling", "windmill-autoscaling/enterprise", "windmill-git-sync/enterprise", "windmill-common/prometheus", "windmill-common/enterprise", "windmill-object-store/enterprise"] local_reports = ["windmill-common/local_reports"] diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index fd2e07ccdd..a77dab77e5 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -9ea1259fa6a95e6b24d1523b2f6ec6ae0042ecfe +5f912375340225876a8c1740c3301f39cd6cbd6d diff --git a/backend/migrations/20260406104248_ci_test_references.down.sql b/backend/migrations/20260406104248_ci_test_references.down.sql new file mode 100644 index 0000000000..d3bea6d2a3 --- /dev/null +++ b/backend/migrations/20260406104248_ci_test_references.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS ci_test_reference; diff --git a/backend/migrations/20260406104248_ci_test_references.up.sql b/backend/migrations/20260406104248_ci_test_references.up.sql new file mode 100644 index 0000000000..4f4a4b79fd --- /dev/null +++ b/backend/migrations/20260406104248_ci_test_references.up.sql @@ -0,0 +1,22 @@ +-- CI test references: maps test scripts to the items they test +CREATE TABLE ci_test_reference ( + workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id), + test_script_path VARCHAR(510) NOT NULL, + test_script_hash BIGINT NOT NULL, + tested_item_path VARCHAR(510) NOT NULL, + tested_item_kind VARCHAR(10) NOT NULL, + PRIMARY KEY (workspace_id, test_script_path, tested_item_path, tested_item_kind) +); + +-- Fast lookup: "which tests cover this item?" (used on deploy) +CREATE INDEX idx_ci_test_ref_tested_item + ON ci_test_reference (workspace_id, tested_item_path, tested_item_kind); + +-- Fast lookup: "which items does this test cover?" (used on test script update) +CREATE INDEX idx_ci_test_ref_test_script + ON ci_test_reference (workspace_id, test_script_path); + +GRANT ALL ON ci_test_reference TO windmill_user; +GRANT ALL ON ci_test_reference TO windmill_admin; + +ALTER TYPE job_trigger_kind ADD VALUE IF NOT EXISTS 'ci_test'; diff --git a/backend/migrations/20260409145556_add_ext_jwt_metadata.down.sql b/backend/migrations/20260409145556_add_ext_jwt_metadata.down.sql new file mode 100644 index 0000000000..df7d494680 --- /dev/null +++ b/backend/migrations/20260409145556_add_ext_jwt_metadata.down.sql @@ -0,0 +1,10 @@ +-- Remove metadata columns from unique_ext_jwt_token + +ALTER TABLE unique_ext_jwt_token + DROP COLUMN IF EXISTS email, + DROP COLUMN IF EXISTS username, + DROP COLUMN IF EXISTS is_admin, + DROP COLUMN IF EXISTS is_operator, + DROP COLUMN IF EXISTS workspace_id, + DROP COLUMN IF EXISTS label, + DROP COLUMN IF EXISTS scopes; diff --git a/backend/migrations/20260409145556_add_ext_jwt_metadata.up.sql b/backend/migrations/20260409145556_add_ext_jwt_metadata.up.sql new file mode 100644 index 0000000000..5195934968 --- /dev/null +++ b/backend/migrations/20260409145556_add_ext_jwt_metadata.up.sql @@ -0,0 +1,10 @@ +-- Add metadata columns to unique_ext_jwt_token for listing external JWTs + +ALTER TABLE unique_ext_jwt_token + ADD COLUMN IF NOT EXISTS email TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS username TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS is_admin BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS is_operator BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS workspace_id TEXT, + ADD COLUMN IF NOT EXISTS label TEXT, + ADD COLUMN IF NOT EXISTS scopes TEXT[]; diff --git a/backend/migrations/20260409160116_ci_test_reference_cascade.down.sql b/backend/migrations/20260409160116_ci_test_reference_cascade.down.sql new file mode 100644 index 0000000000..fcc88c45bf --- /dev/null +++ b/backend/migrations/20260409160116_ci_test_reference_cascade.down.sql @@ -0,0 +1,4 @@ +ALTER TABLE ci_test_reference + DROP CONSTRAINT ci_test_reference_workspace_id_fkey, + ADD CONSTRAINT ci_test_reference_workspace_id_fkey + FOREIGN KEY (workspace_id) REFERENCES workspace(id); diff --git a/backend/migrations/20260409160116_ci_test_reference_cascade.up.sql b/backend/migrations/20260409160116_ci_test_reference_cascade.up.sql new file mode 100644 index 0000000000..57f6261eaf --- /dev/null +++ b/backend/migrations/20260409160116_ci_test_reference_cascade.up.sql @@ -0,0 +1,4 @@ +ALTER TABLE ci_test_reference + DROP CONSTRAINT ci_test_reference_workspace_id_fkey, + ADD CONSTRAINT ci_test_reference_workspace_id_fkey + FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE; diff --git a/backend/src/main.rs b/backend/src/main.rs index 1386a4b9bf..6468ef3072 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -533,51 +533,6 @@ fn print_help() { println!("- At startup, Windmill logs currently set configuration keys for visibility."); } -async fn resync_custom_instance_user_pwd_if_needed(db: &Pool) { - use windmill_common::utils::get_custom_pg_instance_password; - use windmill_common::{get_database_url, PgDatabase}; - - let user_pwd = match get_custom_pg_instance_password(db).await { - Ok(pwd) => pwd, - Err(_) => { - // Setting doesn't exist yet (fresh install or pre-migration), skip check - return; - } - }; - - let mut pg_creds = match get_database_url().await { - Ok(url) => match PgDatabase::parse_uri(&url.as_str().await) { - Ok(creds) => creds, - Err(e) => { - tracing::warn!("Failed to parse database URL for custom_instance_user check: {e}"); - return; - } - }, - Err(e) => { - tracing::warn!("Failed to get database URL for custom_instance_user check: {e}"); - return; - } - }; - - pg_creds.user = Some("custom_instance_user".to_string()); - pg_creds.password = Some(user_pwd); - - match pg_creds.connect().await { - Ok(_) => { - tracing::info!("custom_instance_user password is in sync"); - } - Err(e) => { - tracing::warn!("custom_instance_user password is out of sync ({e}), refreshing..."); - if let Err(e) = windmill_api_settings::refresh_custom_instance_user_pwd_inner(db).await - { - tracing::error!("Failed to refresh custom_instance_user password: {e}"); - } else { - tracing::info!("Successfully refreshed custom_instance_user password"); - } - } - } -} - async fn windmill_main() -> anyhow::Result<()> { let (killpill_tx, mut killpill_rx) = KillpillSender::new(2); let mut monitor_killpill_rx = killpill_tx.subscribe(); @@ -973,11 +928,6 @@ async fn windmill_main() -> anyhow::Result<()> { // NOTE: Variable/resource cache initialization moved to API server in windmill-api - // Check if custom_instance_user password is in sync - if server_mode { - resync_custom_instance_user_pwd_if_needed(&db).await; - } - Connection::Sql(db) }; diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index 5044d26a80..af2a66031c 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -3309,8 +3309,18 @@ async fn handle_zombie_flows(db: &DB) -> error::Result<()> { r#" SELECT j.id AS "id!", j.workspace_id AS "workspace_id!", j.parent_job, j.flow_step_id IS NOT NULL AS "is_flow_step?", - COALESCE(s.flow_status, s.workflow_as_code_status) AS "flow_status: Box", r.ping AS last_ping, j.same_worker AS "same_worker?" + COALESCE(s.flow_status, s.workflow_as_code_status) AS "flow_status: Box", r.ping AS last_ping, j.same_worker AS "same_worker?", + q.worker AS "worker?", + wp.ping_at AS "worker_last_ping?", + wp.memory_usage AS "worker_memory_usage?", + wp.wm_memory_usage AS "worker_wm_memory_usage?", + wp.memory AS "worker_memory_total?", + wp.worker_group AS "worker_group?", + wp.wm_version AS "worker_version?", + wp.current_job_id AS "worker_current_job_id?", + wp.worker_instance AS "worker_instance?" FROM v2_job_queue q JOIN v2_job j USING (id) LEFT JOIN v2_job_runtime r USING (id) LEFT JOIN v2_job_status s USING (id) + LEFT JOIN worker_ping wp ON wp.worker = q.worker WHERE q.running = true AND q.suspend = 0 AND q.suspend_until IS null AND q.scheduled_for <= now() AND (j.kind = 'flow' OR j.kind = 'flowpreview' OR j.kind = 'flownode') AND r.ping IS NOT NULL AND r.ping < NOW() - ($1 || ' seconds')::interval @@ -3377,8 +3387,129 @@ async fn handle_zombie_flows(db: &DB) -> error::Result<()> { let now = now_from_db(db).await?; let base_url = BASE_URL.read().await; let workspace_id = flow.workspace_id.clone(); + + let fmt_mb = |b: i64| format!("{:.1} MB", b as f64 / 1024.0 / 1024.0); + let worker_ping_stale = flow + .worker_last_ping + .map(|wp| (now - wp).num_seconds() > 60); + let worker_info = if let Some(worker_name) = flow.worker.as_deref() { + let mut s = format!("\nWorker handling the flow: {worker_name}"); + match (flow.worker_group.as_deref(), flow.worker_version.as_deref()) { + (Some(g), Some(v)) => s.push_str(&format!(" (group: {g}, version: {v})")), + (Some(g), None) => s.push_str(&format!(" (group: {g})")), + (None, Some(v)) => s.push_str(&format!(" (version: {v})")), + (None, None) => {} + } + if let Some(wp) = flow.worker_last_ping { + let age = (now - wp).num_seconds(); + let status = if worker_ping_stale.unwrap_or(false) { + "APPEARS DEAD OR CRASHED — worker ping is stale" + } else { + "worker still pinging — likely deadlocked or blocking on the state transition" + }; + s.push_str(&format!("\nWorker last ping: {wp} ({age}s ago) — {status}")); + if let Some(cjid) = flow.worker_current_job_id { + if cjid != flow.id { + s.push_str(&format!("\nWorker has since moved on to job {cjid}")); + } + } + } else { + s.push_str("\nWorker last ping: unknown (no worker_ping record)"); + } + let total_suffix = flow + .worker_memory_total + .map(|t| format!(" (total available: {})", fmt_mb(t))) + .unwrap_or_default(); + match (flow.worker_memory_usage, flow.worker_wm_memory_usage) { + (Some(host), Some(wm)) => s.push_str(&format!( + "\nWorker memory at last ping: host={}, wm process={}{}", + fmt_mb(host), + fmt_mb(wm), + total_suffix + )), + (Some(host), None) => s.push_str(&format!( + "\nWorker memory at last ping: host={}{}", + fmt_mb(host), + total_suffix + )), + (None, Some(wm)) => s.push_str(&format!( + "\nWorker memory at last ping: wm process={}{}", + fmt_mb(wm), + total_suffix + )), + (None, None) => { + if let Some(total) = flow.worker_memory_total { + s.push_str(&format!("\nWorker total memory: {}", fmt_mb(total))); + } + } + } + s + } else { + "\nWorker handling the flow: unknown (no worker recorded on v2_job_queue)" + .to_string() + }; + + let hint = match worker_ping_stale { + Some(true) => "\nThe worker's own ping is stale — it likely crashed, was killed (OOM?), or lost network connectivity. Check worker logs, k8s/docker events, or host dmesg around the worker's last ping time.", + Some(false) => "\nThe worker is still pinging — it is likely deadlocked or stuck in a blocking call during the state transition. Capture a stack trace from the worker process (e.g. via SIGQUIT or a debugger).", + None => "", + }; + + let service_logs_info = match (flow.worker_instance.as_deref(), flow.worker_last_ping) { + (Some(host), Some(wlp)) => { + let after = (wlp - chrono::Duration::seconds(90)) + .to_rfc3339_opts(chrono::SecondsFormat::Millis, true); + let before = (wlp + chrono::Duration::seconds(30)) + .to_rfc3339_opts(chrono::SecondsFormat::Millis, true); + let log_files_result = sqlx::query!( + "SELECT file_path, log_ts, ok_lines, err_lines + FROM log_file + WHERE hostname = $1 + AND log_ts BETWEEN $2::timestamp - interval '90 seconds' AND $2::timestamp + interval '30 seconds' + ORDER BY log_ts DESC + LIMIT 10", + host, + wlp.naive_utc(), + ) + .fetch_all(db) + .await; + let log_files = match log_files_result { + Ok(rows) => rows, + Err(e) => { + tracing::warn!( + "failed to query log_file for hanging-flow diagnostics (host={host}): {e:#}" + ); + Vec::new() + } + }; + if log_files.is_empty() { + format!( + "\nService logs: no log_file rows found for hostname '{host}' between {after} and {before}. If service log collection is enabled (requires S3/parquet), try /api/service_logs/list_files?after={after}&before={before}." + ) + } else { + let listed = log_files + .iter() + .map(|f| { + format!( + " - {} (log_ts: {}, ok_lines: {}, err_lines: {}) — GET /api/service_logs/get_log_file/{}/{}", + f.file_path, + f.log_ts, + f.ok_lines.unwrap_or(0), + f.err_lines.unwrap_or(0), + host, + f.file_path, + ) + }) + .collect::>() + .join("\n"); + format!("\nService logs for worker instance '{host}' around last ping ({after} to {before}) (download URLs require S3/parquet service log collection):\n{listed}") + } + } + _ => String::new(), + }; + let reason = format!( - "{} was hanging in between 2 steps. Last ping: {last_ping:?} (now: {now})", + "{} was hanging in between 2 steps. Last ping: {last_ping:?} (now: {now}){worker_info}{hint}{service_logs_info}", if flow.is_flow_step.unwrap_or(false) && flow.parent_job.is_some() { format!("Flow was cancelled because subflow {id} ({base_url}/run/{id}?workspace={workspace_id})") } else { diff --git a/backend/summarized_schema.txt b/backend/summarized_schema.txt index 8446017263..1517a0e780 100644 --- a/backend/summarized_schema.txt +++ b/backend/summarized_schema.txt @@ -16,7 +16,7 @@ http_method: get, post, put, delete, patch importer_kind: script, flow, app job_kind: script, preview, flow, dependencies, flowpreview, script_hub, identity, flowdependencies, http, graphql, postgresql, noop, appdependencies, deploymentcallback, singlestepflow, flowscript, flownode, appscript, aiagent, unassigned_script, unassigned_flow, unassigned_singlestepflow job_status: success, failure, canceled, skipped -job_trigger_kind: webhook, http, websocket, kafka, email, nats, schedule, app, ui, postgres, sqs, gcp, mqtt, nextcloud +job_trigger_kind: webhook, http, websocket, kafka, email, nats, schedule, app, ui, postgres, sqs, gcp, mqtt, nextcloud, ci_test log_mode: standalone, server, worker, agent, indexer, mcp login_type: password, github message_type: user, assistant, tool @@ -60,6 +60,8 @@ cloud_workspace_settings: workspace_id(char), threshold_alert_amount(int), last_ concurrency_counter: concurrency_id(char), job_uuids(jsonb) concurrency_key: key(char), ended_at(ts), job_id(uuid) concurrency_locks: id(char), last_locked_at(ts), owner(char) +ci_test_reference: workspace_id(char), test_script_path(char), test_script_hash(bigint), tested_item_path(char), tested_item_kind(char) + FK: (workspace_id) -> workspace(id) concurrency_settings: hash(bigint), concurrency_key(char), concurrent_limit(int), concurrency_time_window_s(int) config: name(char), config(jsonb) custom_concurrency_key_ended: key(char), ended_at(ts) @@ -159,7 +161,7 @@ token: token_hash(char), token_prefix(char), token(char), label(char), expiratio token_expiry_notification: token_hash(char), expiration(ts) INDEX: idx_token_expiry_notification_expiration (expiration) tutorial_progress: email(char), progress(bit64), skipped_all(bool) -unique_ext_jwt_token: jwt_hash(bigint), last_used_at(ts) +unique_ext_jwt_token: jwt_hash(bigint), last_used_at(ts), email(text), username(text), is_admin(bool), is_operator(bool), workspace_id(text?), label(text?), scopes(text[]?) usage: id(char), is_workspace(bool), month_(int), usage(int) usr: workspace_id(char), username(char), email(char), is_admin(bool), created_at(ts), operator(bool), disabled(bool), role(char), added_via(jsonb) FK: (workspace_id) -> workspace(id) diff --git a/backend/tests/ci_tests.rs b/backend/tests/ci_tests.rs new file mode 100644 index 0000000000..c8551a30a5 --- /dev/null +++ b/backend/tests/ci_tests.rs @@ -0,0 +1,116 @@ +#[cfg(all(feature = "python", feature = "private"))] +mod ci_tests { + use std::collections::HashMap; + + use sqlx::{Pool, Postgres}; + use tokio_stream::StreamExt; + + use windmill_api_client::types::{NewScript, ScriptLang}; + use windmill_test_utils::{in_test_worker, init_client, listen_for_completed_jobs}; + + fn quick_ns(content: &str, path: &str, parent_hash: Option) -> NewScript { + NewScript { + content: content.into(), + language: ScriptLang::Python3, + lock: None, + parent_hash, + path: path.into(), + concurrent_limit: None, + concurrency_time_window_s: None, + cache_ttl: None, + dedicated_worker: None, + description: "".to_string(), + draft_only: None, + envs: vec![], + is_template: None, + kind: None, + summary: "".to_string(), + tag: None, + schema: HashMap::new(), + ws_error_handler_muted: Some(false), + priority: None, + delete_after_secs: None, + timeout: None, + restart_unless_cancelled: None, + deployment_message: None, + concurrency_key: None, + visible_to_runner_only: None, + auto_kind: None, + codebase: None, + has_preprocessor: None, + on_behalf_of_email: None, + assets: vec![], + modules: None, + } + } + + /// Test 2: Deploying a script automatically triggers CI test jobs for test scripts + /// that reference it via the `# test:` annotation. + #[sqlx::test(fixtures("base"))] + async fn test_ci_test_trigger_on_deploy(db: Pool) -> anyhow::Result<()> { + let (client, port, _s) = init_client(db.clone()).await; + + // Step 1: Create the test script with a CI annotation targeting deploy_target. + // This inserts a ci_test_reference row. + client + .create_script( + "test-workspace", + &quick_ns( + "# test: script/u/test-user/deploy_target\ndef main():\n return True", + "u/test-user/ci_test_for_deploy", + None, + ), + ) + .await + .unwrap(); + + // Process the test script's dependency (lock generation) job + let mut completed = listen_for_completed_jobs(&db).await; + in_test_worker(&db, completed.next(), port).await; + + // Step 2: Create the target script. Its dependency job, once processed, + // will call trigger_ci_tests_for_item which finds our test script. + client + .create_script( + "test-workspace", + &quick_ns( + "def main():\n return 42", + "u/test-user/deploy_target", + None, + ), + ) + .await + .unwrap(); + + // Process the target script's dependency job → CI trigger fires in tokio::spawn + let mut completed = listen_for_completed_jobs(&db).await; + in_test_worker(&db, completed.next(), port).await; + + // Give the spawned trigger task time to push the CI test job + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + + // Verify a CI test job was queued + let ci_jobs = sqlx::query!( + "SELECT j.id, j.runnable_path, j.trigger_kind::text as trigger_kind \ + FROM v2_job j \ + WHERE j.workspace_id = 'test-workspace' \ + AND j.trigger_kind = 'ci_test'" + ) + .fetch_all(&db) + .await?; + + assert_eq!(ci_jobs.len(), 1, "expected exactly 1 CI test job"); + assert_eq!( + ci_jobs[0].runnable_path.as_deref(), + Some("u/test-user/ci_test_for_deploy"), + "CI test job should run the test script" + ); + assert_eq!( + ci_jobs[0].trigger_kind.as_deref(), + Some("ci_test"), + "trigger_kind should be ci_test" + ); + + Ok(()) + } +} diff --git a/backend/tests/instance_config.rs b/backend/tests/instance_config.rs index 055b633d28..7f50f66768 100644 --- a/backend/tests/instance_config.rs +++ b/backend/tests/instance_config.rs @@ -172,6 +172,164 @@ async fn test_from_db_unknown_settings_go_to_extra(db: Pool) { ); } +/// Regression: a legacy `global_settings.worker_configs` row must not leak +/// into `GlobalSettings::extra`. Older Windmill versions stored worker configs +/// as a single blob in `global_settings`; the row could silently drift from +/// the real `config WHERE name LIKE 'worker__%'` rows and get resurrected on +/// every bulk InstanceSettings save via the flatten+extra round-trip. +#[sqlx::test(fixtures("base"))] +async fn test_from_db_hides_legacy_worker_configs_ghost_row(db: Pool) { + clear_settings_and_configs(&db).await; + + insert_global_setting( + &db, + "worker_configs", + serde_json::json!({ + "default": {"worker_tags": ["deno", "python3"]}, + "sldc-standard": {"worker_tags": ["sldc-standard"]} + }), + ) + .await; + insert_config(&db, "worker__sldc-standard", serde_json::json!({})).await; + + let config = InstanceConfig::from_db(&db).await.unwrap(); + assert!( + !config.global_settings.extra.contains_key("worker_configs"), + "legacy worker_configs row must be filtered out of GlobalSettings::extra" + ); + assert!( + !config + .global_settings + .to_settings_map() + .contains_key("worker_configs"), + "legacy worker_configs row must not re-appear in to_settings_map()" + ); + // The real config-table row is still read normally. + assert!(config.worker_configs.contains_key("sldc-standard")); +} + +/// Regression: a bulk `diff_global_settings` desired map that contains an +/// empty-string value for a key must route that key to `deletes` rather than +/// upserting `""`. Mirrors the single-key endpoint's behavior and prevents +/// stale `""` rows (e.g. `npmrc`) from tripping the EE registry gate on CE. +#[sqlx::test(fixtures("base"))] +async fn test_diff_global_settings_empty_string_routes_to_delete(db: Pool) { + clear_settings_and_configs(&db).await; + + insert_global_setting(&db, "npmrc", serde_json::json!("registry=https://x/")).await; + + let current_map = InstanceConfig::from_db(&db) + .await + .unwrap() + .global_settings + .to_settings_map(); + + let mut desired: BTreeMap = BTreeMap::new(); + desired.insert("npmrc".to_string(), serde_json::json!("")); + + let diff = diff_global_settings(¤t_map, &desired, ApplyMode::Merge); + assert!( + !diff.upserts.contains_key("npmrc"), + "empty-string value must not upsert" + ); + assert!( + diff.deletes.iter().any(|k| k == "npmrc"), + "empty-string value must route to deletes" + ); + + apply_settings_diff(&db, &diff).await.unwrap(); + assert!( + get_global_setting(&db, "npmrc").await.is_none(), + "npmrc row must be deleted after apply" + ); + + // Second case: key not currently present → no-op (no upsert, no delete). + clear_settings_and_configs(&db).await; + let current_map = InstanceConfig::from_db(&db) + .await + .unwrap() + .global_settings + .to_settings_map(); + let diff = diff_global_settings(¤t_map, &desired, ApplyMode::Merge); + assert!(!diff.upserts.contains_key("npmrc")); + assert!(!diff.deletes.iter().any(|k| k == "npmrc")); +} + +/// Regression: whitespace-only values for protected settings (e.g. +/// `jwt_secret`) must not leak into `deletes`. The empty-string-unset branch +/// in `diff_global_settings` relies on the `PROTECTED_SETTINGS` guard ahead +/// of it to catch whitespace, so `is_empty_or_null` must also trim. +#[sqlx::test(fixtures("base"))] +async fn test_diff_global_settings_protected_whitespace_not_deleted(db: Pool) { + clear_settings_and_configs(&db).await; + + insert_global_setting(&db, "jwt_secret", serde_json::json!("s3cret")).await; + + let current_map = InstanceConfig::from_db(&db) + .await + .unwrap() + .global_settings + .to_settings_map(); + + for whitespace in [" ", " ", "\t", "\n", ""] { + let mut desired: BTreeMap = BTreeMap::new(); + desired.insert("jwt_secret".to_string(), serde_json::json!(whitespace)); + + let diff = diff_global_settings(¤t_map, &desired, ApplyMode::Merge); + assert!( + !diff.deletes.iter().any(|k| k == "jwt_secret"), + "protected setting must not be routed to deletes for value {whitespace:?}" + ); + assert!( + !diff.upserts.contains_key("jwt_secret"), + "protected setting must not be overwritten with whitespace for value {whitespace:?}" + ); + } + + // The underlying secret must still be present after applying each diff. + assert_eq!( + get_global_setting(&db, "jwt_secret").await, + Some(serde_json::json!("s3cret")) + ); +} + +/// Regression: a bulk `diff_global_settings` upsert must reject a +/// `worker_configs` key, even if a client PUT carries one in the flattened +/// extra map. This is the write-side guard that prevents the ghost row from +/// being resurrected on every InstanceSettings YAML save. +#[sqlx::test(fixtures("base"))] +async fn test_diff_global_settings_rejects_worker_configs_upsert(db: Pool) { + clear_settings_and_configs(&db).await; + + let current: BTreeMap = BTreeMap::new(); + let mut desired: BTreeMap = BTreeMap::new(); + desired.insert( + "base_url".to_string(), + serde_json::json!("https://windmill.test"), + ); + desired.insert( + "worker_configs".to_string(), + serde_json::json!({"default": {"worker_tags": ["deno"]}}), + ); + + let diff = diff_global_settings(¤t, &desired, ApplyMode::Merge); + assert!(diff.upserts.contains_key("base_url")); + assert!( + !diff.upserts.contains_key("worker_configs"), + "worker_configs must never be written as a global setting" + ); + + apply_settings_diff(&db, &diff).await.unwrap(); + assert!( + get_global_setting(&db, "worker_configs").await.is_none(), + "worker_configs row must not exist in global_settings after apply" + ); + assert_eq!( + get_global_setting(&db, "base_url").await, + Some(serde_json::json!("https://windmill.test")) + ); +} + #[sqlx::test(fixtures("base"))] async fn test_from_db_with_worker_configs(db: Pool) { clear_settings_and_configs(&db).await; diff --git a/backend/windmill-api-flows/Cargo.toml b/backend/windmill-api-flows/Cargo.toml index 5407e38068..5796c8fad8 100644 --- a/backend/windmill-api-flows/Cargo.toml +++ b/backend/windmill-api-flows/Cargo.toml @@ -11,7 +11,7 @@ path = "src/lib.rs" [features] default = [] enterprise = ["windmill-common/enterprise"] -private = ["windmill-common/private"] +private = ["windmill-common/private", "windmill-dep-map/private"] [dependencies] windmill-common = { workspace = true, default-features = false } @@ -27,5 +27,6 @@ serde.workspace = true serde_json.workspace = true sql-builder.workspace = true sqlx.workspace = true +tokio.workspace = true tracing.workspace = true chrono.workspace = true diff --git a/backend/windmill-api-flows/src/flows.rs b/backend/windmill-api-flows/src/flows.rs index e78331a57d..51b17a1c52 100644 --- a/backend/windmill-api-flows/src/flows.rs +++ b/backend/windmill-api-flows/src/flows.rs @@ -1059,6 +1059,16 @@ async fn update_flow( ) .execute(&mut *tx) .await?; + + // Update ci_test_reference when a tested flow is renamed + sqlx::query!( + "UPDATE ci_test_reference SET tested_item_path = $1 WHERE tested_item_path = $2 AND workspace_id = $3 AND tested_item_kind = 'flow'", + nf.path, + flow_path, + w_id + ) + .execute(&mut *tx) + .await?; } // tracing::error!("Updating flow: {:?}", nf.value.get()); @@ -1277,6 +1287,29 @@ async fn update_flow( new_tx.commit().await?; + // Trigger CI tests for items that reference this flow + { + let db2 = db.clone(); + let w_id2 = w_id.clone(); + let flow_path2 = nf.path.clone(); + let email2 = authed.email.clone(); + let username2 = authed.username.clone(); + tokio::spawn(async move { + if let Err(e) = windmill_dep_map::ci_tests::trigger_ci_tests_for_item( + &db2, + &w_id2, + &flow_path2, + "flow", + &email2, + &username2, + ) + .await + { + tracing::error!(%e, "error triggering CI tests after flow deploy"); + } + }); + } + Ok(nf.path.to_string()) } diff --git a/backend/windmill-api-integration-tests/tests/ci_tests.rs b/backend/windmill-api-integration-tests/tests/ci_tests.rs new file mode 100644 index 0000000000..64e6aaa181 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/ci_tests.rs @@ -0,0 +1,200 @@ +use serde_json::json; +use sqlx::{Pool, Postgres}; + +use windmill_test_utils::*; + +fn script_url(port: u16, endpoint: &str, path: &str) -> String { + format!("http://localhost:{port}/api/w/test-workspace/scripts/{endpoint}/{path}") +} + +fn client() -> reqwest::Client { + reqwest::Client::new() +} + +fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + builder.header("Authorization", "Bearer SECRET_TOKEN") +} + +fn new_script(path: &str, content: &str) -> serde_json::Value { + json!({ + "path": path, + "summary": "", + "description": "", + "content": content, + "language": "deno", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": {}, + "required": [] + } + }) +} + +/// Test 1: Creating a script with a CI test annotation inserts rows into ci_test_reference, +/// and deploying a new version without the annotation removes them. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_ci_test_annotation_creates_and_removes_references( + 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 target script (no annotation) + let resp = authed(client().post(format!("{base}/create"))) + .json(&new_script( + "u/test-user/target_script", + "export async function main() { return 42; }", + )) + .send() + .await?; + assert_eq!(resp.status(), 201, "create target: {}", resp.text().await?); + + // Create test script with CI annotation + let resp = authed(client().post(format!("{base}/create"))) + .json(&new_script( + "u/test-user/ci_test_script", + "// test: script/u/test-user/target_script\nexport async function main() { return 'test passed'; }", + )) + .send() + .await?; + assert_eq!(resp.status(), 201, "create test: {}", resp.text().await?); + + // Verify ci_test_reference row exists + let refs = sqlx::query!( + "SELECT test_script_path, tested_item_path, tested_item_kind \ + FROM ci_test_reference WHERE workspace_id = 'test-workspace'" + ) + .fetch_all(&db) + .await?; + + assert_eq!(refs.len(), 1, "expected 1 ci_test_reference row"); + assert_eq!(refs[0].test_script_path, "u/test-user/ci_test_script"); + assert_eq!(refs[0].tested_item_path, "u/test-user/target_script"); + assert_eq!(refs[0].tested_item_kind, "script"); + + // Get the hash of the test script (needed as parent_hash for the update) + let resp = authed(client().get(script_url(port, "get/p", "u/test-user/ci_test_script"))) + .send() + .await?; + let body = resp.json::().await?; + let hash = body["hash"].as_str().unwrap().to_string(); + + // Create new version of test script WITHOUT annotation + let mut updated = new_script( + "u/test-user/ci_test_script", + "export async function main() { return 'no longer a test'; }", + ); + updated["parent_hash"] = json!(hash); + let resp = authed(client().post(format!("{base}/create"))) + .json(&updated) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "remove annotation: {}", + resp.text().await? + ); + + // Verify ci_test_reference row was deleted + let refs = sqlx::query!( + "SELECT test_script_path FROM ci_test_reference \ + WHERE workspace_id = 'test-workspace'" + ) + .fetch_all(&db) + .await?; + assert_eq!( + refs.len(), + 0, + "ci_test_reference should be empty after removing annotation" + ); + + Ok(()) +} + +/// Test 2: The CI test results API returns test references (with null job info when +/// no CI test job has run yet), and the batch endpoint aggregates correctly. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_ci_test_results_api(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 target script + let resp = authed(client().post(format!("{base}/create"))) + .json(&new_script( + "u/test-user/target_for_results", + "export async function main() { return 1; }", + )) + .send() + .await?; + assert_eq!(resp.status(), 201, "create target: {}", resp.text().await?); + + // Create test script with annotation + let resp = authed(client().post(format!("{base}/create"))) + .json(&new_script( + "u/test-user/test_for_results", + "// test: script/u/test-user/target_for_results\nexport async function main() { return true; }", + )) + .send() + .await?; + assert_eq!(resp.status(), 201, "create test: {}", resp.text().await?); + + // --- Single item endpoint --- + let resp = authed(client().get(script_url( + port, + "ci_test_results/script", + "u/test-user/target_for_results", + ))) + .send() + .await?; + assert_eq!(resp.status(), 200); + let results = resp.json::>().await?; + + assert_eq!(results.len(), 1, "expected 1 CI test result"); + assert_eq!( + results[0]["test_script_path"], + "u/test-user/test_for_results" + ); + // No CI test job has been triggered yet + assert!(results[0]["job_id"].is_null()); + assert!(results[0]["status"].is_null()); + + // --- Batch endpoint --- + let resp = authed(client().post(format!( + "http://localhost:{port}/api/w/test-workspace/scripts/ci_test_results_batch" + ))) + .json(&json!({ + "items": [ + {"path": "u/test-user/target_for_results", "kind": "script"}, + {"path": "u/test-user/nonexistent", "kind": "script"} + ] + })) + .send() + .await?; + assert_eq!(resp.status(), 200); + let batch = resp + .json::>() + .await?; + + // Target should have one test + let key = "script:u/test-user/target_for_results"; + assert!(batch.contains_key(key), "missing key {key}"); + let target_results = batch[key].as_array().unwrap(); + assert_eq!(target_results.len(), 1); + assert_eq!( + target_results[0]["test_script_path"], + "u/test-user/test_for_results" + ); + + // Nonexistent should have empty array + let key = "script:u/test-user/nonexistent"; + assert!(batch.contains_key(key), "missing key {key}"); + assert_eq!(batch[key].as_array().unwrap().len(), 0); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/fixtures/offboarding_test.sql b/backend/windmill-api-integration-tests/tests/fixtures/offboarding_test.sql new file mode 100644 index 0000000000..6b65135611 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/fixtures/offboarding_test.sql @@ -0,0 +1,71 @@ +-- Test data for offboarding integration tests + +-- Folder for reassign-to-folder tests +INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, created_by) +VALUES ('test-workspace', 'test-folder', 'Test Folder', ARRAY['u/test-user'], '{}', 'test-user'); + +-- Scripts owned by test-user-2 +INSERT INTO script (workspace_id, hash, path, summary, description, content, created_by, schema, language, kind, lock, extra_perms) +VALUES + ('test-workspace', 1001, 'u/test-user-2/script_a', 'Script A', '', 'print("a")', 'test-user-2', '{}', 'python3', 'script', '', '{}'), + ('test-workspace', 1002, 'u/test-user-2/script_b', 'Script B', '', 'print("b")', 'test-user-2', '{}', 'python3', 'script', '', '{}'); + +-- Flow owned by test-user-2 +INSERT INTO flow (workspace_id, path, summary, description, value, schema, edited_by, edited_at, extra_perms, versions) +VALUES ('test-workspace', 'u/test-user-2/flow_a', 'Flow A', '', '{"modules":[]}', '{}', 'test-user-2', NOW(), '{}', ARRAY[]::BIGINT[]); + +-- Resource owned by test-user-2 +INSERT INTO resource (workspace_id, path, value, description, resource_type, extra_perms, created_by) +VALUES ('test-workspace', 'u/test-user-2/res_a', '{"key": "val"}', 'Resource A', 'object', '{}', 'test-user-2'); + +-- Variable owned by test-user-2 +INSERT INTO variable (workspace_id, path, value, is_secret, description, extra_perms) +VALUES ('test-workspace', 'u/test-user-2/var_a', 'my_value', false, 'Variable A', '{}'); + +-- Schedule owned by test-user-2 +INSERT INTO schedule (workspace_id, path, edited_by, edited_at, schedule, timezone, enabled, script_path, is_flow, email, permissioned_as, extra_perms) +VALUES ('test-workspace', 'u/test-user-2/sched_a', 'test-user-2', NOW(), '0 * * * *', 'UTC', false, 'u/test-user-2/script_a', false, 'test2@windmill.dev', 'u/test-user-2', '{}'); + +-- Workspace-scoped token owned by test-user-2 +INSERT INTO token (token_hash, token_prefix, token, email, label, workspace_id, owner) +VALUES (encode(sha256('OFFBOARD_TOKEN_1'::bytea), 'hex'), 'OFFBOARD_T', 'OFFBOARD_TOKEN_1', 'test2@windmill.dev', 'offboard test', 'test-workspace', 'u/test-user-2'); + +-- HTTP trigger under test-user-2's path (tests dynamic trigger queries) +INSERT INTO http_trigger (workspace_id, path, route_path, route_path_key, script_path, is_flow, edited_by, permissioned_as, edited_at, extra_perms, http_method, authentication_method, mode) +VALUES ('test-workspace', 'u/test-user-2/webhook_a', '/test-webhook', 'test-webhook', 'u/test-user-2/script_a', false, 'test-user-2', 'u/test-user-2', NOW(), '{}', 'post', 'none', 'enabled'); + +-- Script with extra_perms referencing test-user-2 (tests extra_perms cleanup) +INSERT INTO script (workspace_id, hash, path, summary, description, content, created_by, schema, language, kind, lock, extra_perms) +VALUES ('test-workspace', 1005, 'f/test-folder/shared_script', 'Shared Script', '', 'print("shared")', 'test-user', '{}', 'python3', 'script', '', '{"u/test-user-2": true}'); + +-- Schedule NOT under test-user-2 path but permissioned_as = u/test-user-2 (tests operator update) +INSERT INTO schedule (workspace_id, path, edited_by, edited_at, schedule, timezone, enabled, script_path, is_flow, email, permissioned_as, extra_perms) +VALUES ('test-workspace', 'f/test-folder/sched_shared', 'test-user', NOW(), '0 * * * *', 'UTC', false, 'f/test-folder/shared_script', false, 'test@windmill.dev', 'u/test-user-2', '{}'); + +-- HTTP trigger NOT under test-user-2 path but permissioned_as = u/test-user-2 (tests line 232 + 951) +INSERT INTO http_trigger (workspace_id, path, route_path, route_path_key, script_path, is_flow, edited_by, permissioned_as, edited_at, extra_perms, http_method, authentication_method, mode) +VALUES ('test-workspace', 'f/test-folder/webhook_shared', '/shared-webhook', 'shared-webhook', 'f/test-folder/shared_script', false, 'test-user', 'u/test-user-2', NOW(), '{"u/test-user-2": true}', 'post', 'none', 'enabled'); + +-- For conflict test: script at the target path +INSERT INTO script (workspace_id, hash, path, summary, description, content, created_by, schema, language, kind, lock, extra_perms) +VALUES ('test-workspace', 1003, 'u/test-user/conflict_script', 'Conflict Script', '', 'print("conflict")', 'test-user', '{}', 'python3', 'script', '', '{}'); + +-- Script under test-user-2 that would conflict +INSERT INTO script (workspace_id, hash, path, summary, description, content, created_by, schema, language, kind, lock, extra_perms) +VALUES ('test-workspace', 1004, 'u/test-user-2/conflict_script', 'Conflict Script 2', '', 'print("conflict2")', 'test-user-2', '{}', 'python3', 'script', '', '{}'); + +-- Second workspace for global offboard tests +INSERT INTO workspace (id, name, owner) VALUES ('test-workspace-2', 'test-workspace-2', 'test-user'); +INSERT INTO workspace_settings (workspace_id) VALUES ('test-workspace-2'); +INSERT INTO workspace_key (workspace_id, kind, key) VALUES ('test-workspace-2', 'cloud', 'test-key-2'); +INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES ('test-workspace-2', 'all', 'All users', '{}'); +INSERT INTO usr (workspace_id, email, username, is_admin, role) VALUES ('test-workspace-2', 'test@windmill.dev', 'test-user', true, 'Admin'); +INSERT INTO usr (workspace_id, email, username, is_admin, role) VALUES ('test-workspace-2', 'test2@windmill.dev', 'test-user-2', false, 'User'); + +-- Script owned by test-user-2 in second workspace +INSERT INTO script (workspace_id, hash, path, summary, description, content, created_by, schema, language, kind, lock, extra_perms) +VALUES ('test-workspace-2', 2001, 'u/test-user-2/script_ws2', 'Script WS2', '', 'print("ws2")', 'test-user-2', '{}', 'python3', 'script', '', '{}'); + +-- Folder in second workspace for reassignment +INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, created_by) +VALUES ('test-workspace-2', 'shared', 'Shared', ARRAY['u/test-user'], '{}', 'test-user'); diff --git a/backend/windmill-api-integration-tests/tests/offboarding.rs b/backend/windmill-api-integration-tests/tests/offboarding.rs new file mode 100644 index 0000000000..be7a94d655 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/offboarding.rs @@ -0,0 +1,580 @@ +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 ws_url(port: u16, endpoint: &str) -> String { + format!("http://localhost:{port}/api/w/test-workspace/users/{endpoint}") +} + +fn global_url(port: u16, endpoint: &str) -> String { + format!("http://localhost:{port}/api/users/{endpoint}") +} + +#[sqlx::test(migrations = "../migrations", fixtures("base", "offboarding_test"))] +async fn test_offboard_preview(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let resp = authed(client().get(ws_url(port, "offboard_preview/test-user-2"))) + .send() + .await?; + assert_eq!(resp.status(), 200); + + let body: serde_json::Value = resp.json().await?; + assert_eq!(body["owned"]["scripts"].as_array().unwrap().len(), 3); // script_a, script_b, conflict_script + assert_eq!(body["owned"]["flows"].as_array().unwrap().len(), 1); // flow_a + assert_eq!(body["owned"]["resources"].as_array().unwrap().len(), 1); // res_a + assert_eq!(body["owned"]["variables"].as_array().unwrap().len(), 1); // var_a + assert_eq!(body["owned"]["schedules"].as_array().unwrap().len(), 1); // sched_a + assert_eq!( + body["owned"]["triggers"]["http_trigger"] + .as_array() + .unwrap() + .len(), + 1 + ); // webhook_a (dynamic trigger query) + assert_eq!(body["tokens"].as_array().unwrap().len(), 1); // OFFBOARD_TOKEN_1 + assert_eq!(body["tokens"][0]["label"], "offboard test"); + assert_eq!(body["http_triggers"], 1); // webhook_a + assert_eq!(body["email_triggers"], 0); + // Operator references: schedule + trigger at f/test-folder/ run as u/test-user-2 + assert_eq!( + body["executing_on_behalf"]["schedules"] + .as_array() + .unwrap() + .len(), + 1 + ); + assert_eq!( + body["executing_on_behalf"]["triggers"]["http_trigger"] + .as_array() + .unwrap() + .len(), + 1, + "shared trigger permissioned_as should be detected" + ); + + Ok(()) +} + +#[sqlx::test(migrations = "../migrations", fixtures("base", "offboarding_test"))] +async fn test_offboard_to_user(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + // Remove conflict scripts so offboard can proceed + sqlx::query!( + "DELETE FROM script WHERE hash IN (1003, 1004) AND workspace_id = 'test-workspace'" + ) + .execute(&db) + .await?; + + let resp = authed(client().post(ws_url(port, "offboard/test-user-2"))) + .json(&json!({ + "reassign_to": "u/test-user", + "new_on_behalf_of_user": "test-user", + "delete_user": true + })) + .send() + .await?; + assert_eq!(resp.status(), 200); + + let body: serde_json::Value = resp.json().await?; + assert!(body["conflicts"].as_array().map_or(true, |a| a.is_empty())); + assert!(body["summary"].is_object()); + + let summary = &body["summary"]; + assert!(summary["scripts_reassigned"].as_i64().unwrap() > 0); + assert!(summary["flows_reassigned"].as_i64().unwrap() > 0); + assert!(summary["resources_reassigned"].as_i64().unwrap() > 0); + assert!(summary["variables_reassigned"].as_i64().unwrap() > 0); + assert!(summary["schedules_reassigned"].as_i64().unwrap() > 0); + + // Verify scripts moved + let moved = sqlx::query_scalar!( + "SELECT COUNT(*) FROM script WHERE path LIKE 'u/test-user/%' AND workspace_id = 'test-workspace' AND NOT archived AND NOT deleted" + ) + .fetch_one(&db) + .await? + .unwrap_or(0); + assert!(moved > 0, "scripts should be under u/test-user now"); + + let old = sqlx::query_scalar!( + "SELECT COUNT(*) FROM script WHERE path LIKE 'u/test-user-2/%' AND workspace_id = 'test-workspace' AND NOT archived AND NOT deleted" + ) + .fetch_one(&db) + .await? + .unwrap_or(0); + assert_eq!(old, 0, "no scripts should remain under u/test-user-2"); + + // Verify schedule permissioned_as updated + let perm = sqlx::query_scalar!( + "SELECT permissioned_as FROM schedule WHERE path = 'u/test-user/sched_a' AND workspace_id = 'test-workspace'" + ) + .fetch_optional(&db) + .await?; + assert_eq!(perm.as_deref(), Some("u/test-user")); + + // Verify user deleted from workspace + let user_exists = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM usr WHERE username = 'test-user-2' AND workspace_id = 'test-workspace')" + ) + .fetch_one(&db) + .await? + .unwrap_or(true); + assert!(!user_exists, "user should be removed from workspace"); + + // Verify tokens revoked (default behavior when no reassign_tokens_to) + let token_count = sqlx::query_scalar!( + "SELECT COUNT(*) FROM token WHERE owner = 'u/test-user-2' AND workspace_id = 'test-workspace'" + ) + .fetch_one(&db) + .await? + .unwrap_or(1); + assert_eq!(token_count, 0, "tokens should be revoked"); + + // Verify HTTP trigger reassigned (dynamic trigger query) + let trigger_moved = sqlx::query_scalar!( + "SELECT COUNT(*) FROM http_trigger WHERE path = 'u/test-user/webhook_a' AND workspace_id = 'test-workspace'" + ) + .fetch_one(&db) + .await? + .unwrap_or(0); + assert_eq!(trigger_moved, 1, "http trigger should be reassigned"); + + let trigger_perm = sqlx::query_scalar!( + "SELECT permissioned_as FROM http_trigger WHERE path = 'u/test-user/webhook_a' AND workspace_id = 'test-workspace'" + ) + .fetch_optional(&db) + .await?; + assert_eq!( + trigger_perm.as_deref(), + Some("u/test-user"), + "trigger permissioned_as should be updated" + ); + + // Verify extra_perms cleaned up (dynamic extra_perms query) + let extra_perms = sqlx::query_scalar!( + "SELECT extra_perms::text FROM script WHERE path = 'f/test-folder/shared_script' AND workspace_id = 'test-workspace'" + ) + .fetch_optional(&db) + .await?; + assert!( + !extra_perms + .unwrap_or_default() + .unwrap_or_default() + .contains("test-user-2"), + "extra_perms should no longer reference test-user-2" + ); + + // Verify operator schedule updated (schedule not under user's path) + let shared_sched_perm = sqlx::query_scalar!( + "SELECT permissioned_as FROM schedule WHERE path = 'f/test-folder/sched_shared' AND workspace_id = 'test-workspace'" + ) + .fetch_optional(&db) + .await?; + assert_eq!( + shared_sched_perm.as_deref(), + Some("u/test-user"), + "shared schedule permissioned_as should be updated" + ); + + // Verify shared trigger permissioned_as updated (line 951 - dynamic query) + let shared_trigger_perm = sqlx::query_scalar!( + "SELECT permissioned_as FROM http_trigger WHERE path = 'f/test-folder/webhook_shared' AND workspace_id = 'test-workspace'" + ) + .fetch_optional(&db) + .await?; + assert_eq!( + shared_trigger_perm.as_deref(), + Some("u/test-user"), + "shared trigger permissioned_as should be updated" + ); + + // Verify extra_perms cleaned on trigger (line 983 - dynamic query on trigger table) + let trigger_extra_perms = sqlx::query_scalar!( + "SELECT extra_perms::text FROM http_trigger WHERE path = 'f/test-folder/webhook_shared' AND workspace_id = 'test-workspace'" + ) + .fetch_optional(&db) + .await?; + assert!( + !trigger_extra_perms + .unwrap_or_default() + .unwrap_or_default() + .contains("test-user-2"), + "trigger extra_perms should no longer reference test-user-2" + ); + + Ok(()) +} + +#[sqlx::test(migrations = "../migrations", fixtures("base", "offboarding_test"))] +async fn test_offboard_to_folder(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let resp = authed(client().post(ws_url(port, "offboard/test-user-2"))) + .json(&json!({ + "reassign_to": "f/test-folder", + "new_on_behalf_of_user": "test-user", + "delete_user": true + })) + .send() + .await?; + assert_eq!(resp.status(), 200); + + let body: serde_json::Value = resp.json().await?; + assert!(body["conflicts"].as_array().map_or(true, |a| a.is_empty())); + + // Verify specific script moved to folder (not just pre-existing shared_script) + let script_a_moved = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM script WHERE path = 'f/test-folder/script_a' AND workspace_id = 'test-workspace')" + ) + .fetch_one(&db) + .await? + .unwrap_or(false); + assert!(script_a_moved, "script_a should be moved to f/test-folder/"); + + // Verify no scripts remain under old path + let old_scripts = sqlx::query_scalar!( + "SELECT COUNT(*) FROM script WHERE path LIKE 'u/test-user-2/%' AND workspace_id = 'test-workspace' AND NOT archived AND NOT deleted" + ) + .fetch_one(&db) + .await? + .unwrap_or(1); + assert_eq!( + old_scripts, 0, + "no scripts should remain under u/test-user-2" + ); + + // Verify schedule permissioned_as is u/test-user (operator), not g/f/test-folder + let perm = sqlx::query_scalar!( + "SELECT permissioned_as FROM schedule WHERE path = 'f/test-folder/sched_a' AND workspace_id = 'test-workspace'" + ) + .fetch_optional(&db) + .await?; + assert_eq!(perm.as_deref(), Some("u/test-user")); + + Ok(()) +} + +#[sqlx::test(migrations = "../migrations", fixtures("base", "offboarding_test"))] +async fn test_offboard_reassign_only(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + // Remove conflict scripts so reassignment can proceed + sqlx::query!( + "DELETE FROM script WHERE hash IN (1003, 1004) AND workspace_id = 'test-workspace'" + ) + .execute(&db) + .await?; + + let resp = authed(client().post(ws_url(port, "offboard/test-user-2"))) + .json(&json!({ + "reassign_to": "u/test-user", + "new_on_behalf_of_user": "test-user", + "delete_user": false + })) + .send() + .await?; + assert_eq!(resp.status(), 200); + + let body: serde_json::Value = resp.json().await?; + assert!( + body["conflicts"].as_array().map_or(true, |c| c.is_empty()), + "should have no conflicts" + ); + assert!(body["summary"].is_object(), "should have a summary"); + + // Verify user still exists in workspace + let user_exists = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM usr WHERE username = 'test-user-2' AND workspace_id = 'test-workspace')" + ) + .fetch_one(&db) + .await? + .unwrap_or(false); + assert!( + user_exists, + "user should still exist when delete_user=false" + ); + + // Verify items were actually reassigned + let moved = sqlx::query_scalar!( + "SELECT COUNT(*) FROM script WHERE path LIKE 'u/test-user/%' AND workspace_id = 'test-workspace'" + ) + .fetch_one(&db) + .await? + .unwrap_or(0); + assert!(moved > 0, "scripts should be under u/test-user now"); + + Ok(()) +} + +#[sqlx::test(migrations = "../migrations", fixtures("base", "offboarding_test"))] +async fn test_offboard_conflicts(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + // conflict_script exists under both u/test-user-2/ and u/test-user/ + let resp = authed(client().post(ws_url(port, "offboard/test-user-2"))) + .json(&json!({ + "reassign_to": "u/test-user", + "delete_user": true + })) + .send() + .await?; + assert_eq!(resp.status(), 200); + + let body: serde_json::Value = resp.json().await?; + let conflicts = body["conflicts"] + .as_array() + .expect("conflicts should be array"); + assert!(!conflicts.is_empty(), "should have path conflicts"); + assert!( + conflicts + .iter() + .any(|c| c.as_str().unwrap().contains("conflict_script")), + "conflict should mention conflict_script" + ); + // No summary when conflicts exist + assert!(body["summary"].is_null()); + + Ok(()) +} + +#[sqlx::test(migrations = "../migrations", fixtures("base", "offboarding_test"))] +async fn test_offboard_tokens_deleted(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + // Remove conflict_script so offboard can proceed + sqlx::query!("DELETE FROM script WHERE hash = 1004 AND workspace_id = 'test-workspace'") + .execute(&db) + .await?; + + let resp = authed(client().post(ws_url(port, "offboard/test-user-2"))) + .json(&json!({ + "reassign_to": "u/test-user", + "delete_user": true + })) + .send() + .await?; + assert_eq!(resp.status(), 200); + + // Verify tokens are deleted + let token_count = sqlx::query_scalar!( + "SELECT COUNT(*) FROM token WHERE owner = 'u/test-user-2' AND workspace_id = 'test-workspace'" + ) + .fetch_one(&db) + .await? + .unwrap_or(1); + assert_eq!(token_count, 0, "tokens should be deleted after offboarding"); + + Ok(()) +} + +#[sqlx::test(migrations = "../migrations", fixtures("base", "offboarding_test"))] +async fn test_offboard_folder_requires_operator(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + // Missing new_on_behalf_of_user when reassigning to folder should fail + let resp = authed(client().post(ws_url(port, "offboard/test-user-2"))) + .json(&json!({ + "reassign_to": "f/test-folder", + "delete_user": true + })) + .send() + .await?; + assert_eq!(resp.status(), 400); + + Ok(()) +} + +#[sqlx::test(migrations = "../migrations", fixtures("base", "offboarding_test"))] +async fn test_global_offboard_preview(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let resp = authed(client().get(global_url(port, "offboard_preview/test2@windmill.dev"))) + .send() + .await?; + assert_eq!(resp.status(), 200); + + let body: serde_json::Value = resp.json().await?; + let workspaces = body["workspaces"] + .as_array() + .expect("should have workspaces"); + assert_eq!(workspaces.len(), 2, "user is in two workspaces"); + + // Both workspaces should have the user's items + for ws in workspaces { + assert_eq!(ws["username"], "test-user-2"); + assert!( + !ws["preview"]["owned"]["scripts"] + .as_array() + .unwrap() + .is_empty(), + "workspace {} should have owned scripts", + ws["workspace_id"] + ); + } + + Ok(()) +} + +#[sqlx::test(migrations = "../migrations", fixtures("base", "offboarding_test"))] +async fn test_global_offboard_execution(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + // Remove conflict scripts so offboard can proceed in test-workspace + sqlx::query!( + "DELETE FROM script WHERE hash IN (1003, 1004) AND workspace_id = 'test-workspace'" + ) + .execute(&db) + .await?; + + let resp = authed(client().post(global_url(port, "offboard/test2@windmill.dev"))) + .json(&json!({ + "reassignments": { + "test-workspace": { + "reassign_to": "u/test-user", + "new_on_behalf_of_user": "test-user" + }, + "test-workspace-2": { + "reassign_to": "u/test-user", + "new_on_behalf_of_user": "test-user" + } + }, + "delete_user": true + })) + .send() + .await?; + assert_eq!(resp.status(), 200); + + let body: serde_json::Value = resp.json().await?; + assert!( + body["conflicts"].as_array().map_or(true, |c| c.is_empty()), + "should have no conflicts" + ); + assert!(body["summary"].is_object(), "should have a summary"); + let summary = &body["summary"]; + assert!( + summary["scripts_reassigned"].as_i64().unwrap() > 0, + "should have reassigned scripts across workspaces" + ); + + // Verify items moved in workspace 1 + let ws1_moved = sqlx::query_scalar!( + "SELECT COUNT(*) FROM script WHERE path LIKE 'u/test-user/%' AND workspace_id = 'test-workspace' AND NOT archived AND NOT deleted" + ) + .fetch_one(&db) + .await? + .unwrap_or(0); + assert!(ws1_moved > 0, "scripts should be moved in test-workspace"); + + // Verify items moved in workspace 2 + let ws2_moved = sqlx::query_scalar!( + "SELECT COUNT(*) FROM script WHERE path LIKE 'u/test-user/%' AND workspace_id = 'test-workspace-2' AND NOT archived AND NOT deleted" + ) + .fetch_one(&db) + .await? + .unwrap_or(0); + assert!(ws2_moved > 0, "scripts should be moved in test-workspace-2"); + + // Verify user deleted from both workspaces + let ws1_user = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM usr WHERE email = 'test2@windmill.dev' AND workspace_id = 'test-workspace')" + ) + .fetch_one(&db) + .await? + .unwrap_or(true); + assert!(!ws1_user, "user should be removed from test-workspace"); + + let ws2_user = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM usr WHERE email = 'test2@windmill.dev' AND workspace_id = 'test-workspace-2')" + ) + .fetch_one(&db) + .await? + .unwrap_or(true); + assert!(!ws2_user, "user should be removed from test-workspace-2"); + + // Verify global auth deleted (password row) + let password_exists = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM password WHERE email = 'test2@windmill.dev')" + ) + .fetch_one(&db) + .await? + .unwrap_or(true); + assert!(!password_exists, "password should be deleted from instance"); + + Ok(()) +} + +#[sqlx::test(migrations = "../migrations", fixtures("base", "offboarding_test"))] +async fn test_offboard_invalid_target(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + // Reassign to nonexistent user + let resp = authed(client().post(ws_url(port, "offboard/test-user-2"))) + .json(&json!({ + "reassign_to": "u/nonexistent-user", + "delete_user": true + })) + .send() + .await?; + assert_eq!( + resp.status(), + 400, + "should reject reassignment to nonexistent user" + ); + + // Reassign to nonexistent folder + let resp = authed(client().post(ws_url(port, "offboard/test-user-2"))) + .json(&json!({ + "reassign_to": "f/nonexistent-folder", + "new_on_behalf_of_user": "test-user", + "delete_user": true + })) + .send() + .await?; + assert_eq!( + resp.status(), + 400, + "should reject reassignment to nonexistent folder" + ); + + // Invalid target format + let resp = authed(client().post(ws_url(port, "offboard/test-user-2"))) + .json(&json!({ + "reassign_to": "invalid-format", + "delete_user": true + })) + .send() + .await?; + assert_eq!(resp.status(), 400, "should reject invalid target format"); + + Ok(()) +} diff --git a/backend/windmill-api-scripts/Cargo.toml b/backend/windmill-api-scripts/Cargo.toml index d905882b09..dcae7b779f 100644 --- a/backend/windmill-api-scripts/Cargo.toml +++ b/backend/windmill-api-scripts/Cargo.toml @@ -11,7 +11,7 @@ path = "src/lib.rs" [features] default = [] enterprise = ["windmill-common/enterprise"] -private = ["windmill-common/private"] +private = ["windmill-common/private", "windmill-dep-map/private"] python = ["dep:windmill-parser-py"] [dependencies] diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index 8c54378ea5..dc6026a4ca 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -249,6 +249,9 @@ pub fn workspaced_service() -> Router { // Temporary raw script storage for CLI lock generation .route("/raw_temp/store", post(store_raw_script_temp)) .route("/raw_temp/diff", post(diff_raw_scripts_with_deployed)) + // CI test results + .route("/ci_test_results/{kind}/{*path}", get(get_ci_test_results)) + .route("/ci_test_results_batch", post(get_ci_test_results_batch)) } #[derive(Serialize, FromRow)] @@ -582,11 +585,31 @@ async fn create_script( { return Err(Error::PermissionDenied(msg)); } + let script_path = ns.path.clone(); + let email = authed.email.clone(); + let username = authed.username.clone(); let (hash, tx, hdm) = - create_script_internal(ns, w_id, authed, db.clone(), user_db, webhook).await?; + create_script_internal(ns, w_id.clone(), authed, db.clone(), user_db, webhook).await?; tx.commit().await?; if let Some(hdm) = hdm { + // hdm is Some when no lock generation is needed (script is ready immediately). + // Trigger CI tests for any items that reference this script. hdm.handle(&db).await?; + let db2 = db.clone(); + tokio::spawn(async move { + if let Err(e) = windmill_dep_map::ci_tests::trigger_ci_tests_for_item( + &db2, + &w_id, + &script_path, + "script", + &email, + &username, + ) + .await + { + tracing::error!(%e, "error triggering CI tests after script deploy"); + } + }); } Ok((StatusCode::CREATED, format!("{}", hash))) } @@ -914,6 +937,14 @@ async fn create_script_internal<'c>( } }; + let ci_test_refs = + windmill_common::schema::parse_ci_test_annotation(&ns.content, &lang.as_comment_lit()); + let auto_kind = if ci_test_refs.is_some() { + Some("test".to_string()) + } else { + auto_kind + }; + let runnable_settings_handle = windmill_common::runnable_settings::insert_rs( RunnableSettings { debouncing_settings: ns.debouncing_settings.insert_cached(&db).await?, @@ -997,6 +1028,34 @@ async fn create_script_internal<'c>( .execute(&mut *tx) .await?; + // Update ci_test_reference table for test scripts + // Delete by both new and old path to handle renames + let old_path = parent_hashes_and_perms.as_ref().map(|x| x.p_path.as_str()); + sqlx::query!( + "DELETE FROM ci_test_reference WHERE workspace_id = $1 AND (test_script_path = $2 OR test_script_path = $3)", + &w_id, + &ns.path, + old_path.unwrap_or(&ns.path) + ) + .execute(&mut *tx) + .await?; + + if let Some(ref refs) = ci_test_refs { + for item in refs { + sqlx::query!( + "INSERT INTO ci_test_reference (workspace_id, test_script_path, test_script_hash, tested_item_path, tested_item_kind) \ + VALUES ($1, $2, $3, $4, $5)", + &w_id, + &ns.path, + &hash.0, + &item.path, + &item.kind + ) + .execute(&mut *tx) + .await?; + } + } + let p_path_opt = parent_hashes_and_perms.as_ref().map(|x| x.p_path.clone()); if let Some(ref p_path) = p_path_opt { sqlx::query!( @@ -1055,6 +1114,16 @@ async fn create_script_internal<'c>( .execute(&mut *tx) .await?; + // Update ci_test_reference when a tested item is renamed + sqlx::query!( + "UPDATE ci_test_reference SET tested_item_path = $1 WHERE tested_item_path = $2 AND workspace_id = $3 AND tested_item_kind = 'script'", + &ns.path, + &p_path, + &w_id + ) + .execute(&mut *tx) + .await?; + for schedule in schedulables { clear_schedule(&mut tx, &schedule.path, &w_id).await?; @@ -2750,3 +2819,107 @@ async fn diff_raw_scripts_with_deployed( Ok(Json(mismatched)) } + +#[derive(Serialize, FromRow)] +struct CiTestResult { + test_script_path: String, + job_id: Option, + status: Option, + started_at: Option>, +} + +async fn get_ci_test_results( + _authed: ApiAuthed, + Extension(db): Extension, + Path((w_id, kind, path)): Path<(String, String, StripPath)>, +) -> JsonResult> { + let path = path.to_path(); + let results = sqlx::query_as!( + CiTestResult, + r#"SELECT + ctr.test_script_path, + j.id as "job_id?", + COALESCE(jc.status::text, CASE WHEN j.id IS NOT NULL THEN 'running' ELSE NULL END) as "status?", + j.created_at as "started_at?" + FROM ci_test_reference ctr + LEFT JOIN LATERAL ( + SELECT id, created_at FROM v2_job + WHERE workspace_id = $1 + AND runnable_path = ctr.test_script_path + AND trigger_kind = 'ci_test' + ORDER BY created_at DESC + LIMIT 1 + ) j ON true + LEFT JOIN v2_job_completed jc ON jc.id = j.id + WHERE ctr.workspace_id = $1 + AND ctr.tested_item_path = $2 + AND ctr.tested_item_kind = $3"#, + &w_id, + path, + &kind + ) + .fetch_all(&db) + .await?; + + Ok(Json(results)) +} + +#[derive(Deserialize)] +struct CiTestBatchItem { + path: String, + kind: String, +} + +#[derive(Deserialize)] +struct CiTestBatchRequest { + items: Vec, +} + +async fn get_ci_test_results_batch( + _authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, + Json(req): Json, +) -> JsonResult>> { + if req.items.len() > 200 { + return Err(Error::BadRequest( + "too many items in batch request (max 200)".to_string(), + )); + } + + let mut result_map: HashMap> = HashMap::new(); + + for item in &req.items { + let results = sqlx::query_as!( + CiTestResult, + r#"SELECT + ctr.test_script_path, + j.id as "job_id?", + COALESCE(jc.status::text, CASE WHEN j.id IS NOT NULL THEN 'running' ELSE NULL END) as "status?", + j.created_at as "started_at?" + FROM ci_test_reference ctr + LEFT JOIN LATERAL ( + SELECT id, created_at FROM v2_job + WHERE workspace_id = $1 + AND runnable_path = ctr.test_script_path + AND trigger_kind = 'ci_test' + ORDER BY created_at DESC + LIMIT 1 + ) j ON true + LEFT JOIN v2_job_completed jc ON jc.id = j.id + WHERE ctr.workspace_id = $1 + AND ctr.tested_item_path = $2 + AND ctr.tested_item_kind = $3"#, + &w_id, + &item.path, + &item.kind + ) + .fetch_all(&db) + .await?; + + let key = format!("{}:{}", item.kind, item.path); + result_map.insert(key, results); + } + + Ok(Json(result_map)) +} diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs index 0e5defe07a..dbd61531bf 100644 --- a/backend/windmill-api-settings/src/lib.rs +++ b/backend/windmill-api-settings/src/lib.rs @@ -382,7 +382,7 @@ pub async fn set_global_setting_internal( } delete_global_setting(db, &key).await?; } - serde_json::Value::String(x) if x.is_empty() => { + serde_json::Value::String(ref x) if x.trim().is_empty() => { if instance_config::PROTECTED_SETTINGS.contains(&key.as_str()) { return Err(error::Error::BadRequest(format!( "{key} is a protected setting and cannot be set to empty" @@ -997,49 +997,12 @@ async fn list_custom_instance_pg_databases( return Ok(Json(result)); } -pub async fn refresh_custom_instance_user_pwd_inner(db: &DB) -> Result<()> { - // 20251208123907_safety_custom_instance_db_user_pwd.up - let query = r#" - DO $$ - DECLARE - pwd text; - BEGIN - SELECT gen_random_uuid()::text INTO pwd; - - IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'custom_instance_user') THEN - EXECUTE format('ALTER USER custom_instance_user WITH PASSWORD %L', pwd); - RAISE NOTICE 'Updated password for existing user custom_instance_user'; - ELSE - EXECUTE format('CREATE USER custom_instance_user WITH PASSWORD %L', pwd); - RAISE NOTICE 'Created new user custom_instance_user'; - END IF; - - IF NOT EXISTS (SELECT 1 FROM global_settings WHERE name = 'custom_instance_pg_databases') THEN - INSERT INTO global_settings (name, value) - VALUES ('custom_instance_pg_databases', jsonb_build_object( - 'user_pwd', pwd::text, - 'databases', jsonb_build_object() - )); - RAISE NOTICE 'Inserted new global setting for custom_instance_pg_databases'; - ELSE - UPDATE global_settings - SET value = jsonb_set(COALESCE(value, '{}'::jsonb), '{user_pwd}', to_jsonb(pwd::text)::jsonb) - WHERE name = 'custom_instance_pg_databases'; - RAISE NOTICE 'Updated user_pwd in existing global setting for custom_instance_pg_databases'; - END IF; - END - $$; - "#; - sqlx::query(query).execute(db).await?; - Ok(()) -} - async fn refresh_custom_instance_user_pwd( authed: ApiAuthed, Extension(db): Extension, ) -> JsonResult<()> { require_super_admin(&db, &authed.email).await?; - refresh_custom_instance_user_pwd_inner(&db).await?; + windmill_common::utils::refresh_custom_instance_user_pwd(&db).await?; Ok(Json(())) } @@ -1140,7 +1103,7 @@ async fn setup_custom_instance_pg_database_inner( } // We have to connect to the newly created database as admin to grant permissions - let (client, connection) = pg_creds.connect().await?; + let (client, connection) = pg_creds.connect(Some(db)).await?; let join_handle = tokio::spawn(async move { connection.await }); logs.db_connect = "OK".to_string(); diff --git a/backend/windmill-api-users/src/users.rs b/backend/windmill-api-users/src/users.rs index dcab4c62c9..f346fb5123 100644 --- a/backend/windmill-api-users/src/users.rs +++ b/backend/windmill-api-users/src/users.rs @@ -1667,6 +1667,78 @@ pub async fn delete_workspace_user_internal( tx: &mut Transaction<'_, Postgres>, authed: Option<&ApiAuthed>, // None for system operations ) -> Result<()> { + // ---- Clean up extra_perms referencing this user ---- + let extra_perms_tables = [ + "script", + "flow", + "app", + "resource", + "variable", + "schedule", + "group_", + "folder", + "raw_app", + "http_trigger", + "websocket_trigger", + "kafka_trigger", + "postgres_trigger", + "mqtt_trigger", + "nats_trigger", + "sqs_trigger", + "gcp_trigger", + "email_trigger", + ]; + for table in &extra_perms_tables { + sqlx::query(&format!( + "UPDATE {table} SET extra_perms = extra_perms - ('u/' || $1) \ + WHERE extra_perms ? ('u/' || $1) AND workspace_id = $2" + )) + .bind(username_to_delete) + .bind(w_id) + .execute(&mut **tx) + .await?; + } + + // ---- Clean up folder owners ---- + sqlx::query!( + "UPDATE folder SET owners = array_remove(owners, 'u/' || $1) WHERE ('u/' || $1) = ANY(owners) AND workspace_id = $2", + username_to_delete, w_id + ).execute(&mut **tx).await?; + + // ---- Delete personal data ---- + sqlx::query!( + "DELETE FROM draft WHERE path LIKE ('u/' || $1 || '/%') AND workspace_id = $2", + username_to_delete, + w_id + ) + .execute(&mut **tx) + .await?; + + sqlx::query!( + "DELETE FROM favorite WHERE usr = $1 AND workspace_id = $2", + username_to_delete, + w_id + ) + .execute(&mut **tx) + .await?; + + sqlx::query!( + "DELETE FROM input WHERE created_by = $1 AND workspace_id = $2", + username_to_delete, + w_id + ) + .execute(&mut **tx) + .await?; + + sqlx::query!( + "DELETE FROM capture WHERE created_by = $1 AND workspace_id = $2", + username_to_delete, + w_id + ) + .execute(&mut **tx) + .await?; + + // ---- Delete user records ---- sqlx::query_scalar!( "DELETE FROM usr WHERE email = $1 AND workspace_id = $2", email_to_delete, @@ -1683,6 +1755,14 @@ pub async fn delete_workspace_user_internal( .execute(&mut **tx) .await?; + sqlx::query!( + "DELETE FROM token WHERE email = $1 AND workspace_id = $2", + email_to_delete, + w_id + ) + .execute(&mut **tx) + .await?; + // Only audit if we have an authenticated user (API calls) if let Some(auth) = authed { audit_log( diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 15399fbe74..5e8c2494b1 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -1397,7 +1397,7 @@ async fn get_datatable_schema(db: &DB, w_id: &str, datatable_name: &str) -> Resu .map_err(|e| Error::internal_err(format!("Failed to parse database credentials: {}", e)))?; // Connect to the datatable database - let (client, connection) = pg_db.connect().await?; + let (client, connection) = pg_db.connect(Some(db)).await?; // Spawn the connection handler tokio::spawn(async move { @@ -1733,7 +1733,7 @@ async fn create_pg_database( } else { let source_pg = resolve_pg_source_checked(&db, &user_db, &authed, &w_id, &req.source).await?; - let (client, connection) = source_pg.connect().await?; + let (client, connection) = source_pg.connect(Some(&db)).await?; let join_handle = tokio::spawn(async move { connection.await }); let row = client @@ -1865,7 +1865,7 @@ async fn get_datatable_full_schema( Json(req): Json, ) -> JsonResult { let pg = resolve_pg_source_checked(&db, &user_db, &authed, &w_id, &req.source).await?; - let (client, connection) = pg.connect().await?; + let (client, connection) = pg.connect(Some(&db)).await?; let join_handle = tokio::spawn(async move { connection.await }); let result = windmill_common::query_builders::pg_get_full_schema(&client) @@ -3490,6 +3490,9 @@ async fn clone_workspace_data( // Clone scripts with new hashes clone_scripts(tx, source_workspace_id, target_workspace_id).await?; + // Clone CI test references + clone_ci_test_references(tx, source_workspace_id, target_workspace_id).await?; + // Clone flows with new versions clone_flows(tx, source_workspace_id, target_workspace_id).await?; @@ -3747,6 +3750,23 @@ async fn clone_scripts( Ok(()) } +async fn clone_ci_test_references( + tx: &mut Transaction<'_, Postgres>, + source_workspace_id: &str, + target_workspace_id: &str, +) -> Result<()> { + sqlx::query!( + "INSERT INTO ci_test_reference (workspace_id, test_script_path, test_script_hash, tested_item_path, tested_item_kind) + SELECT $2, test_script_path, test_script_hash, tested_item_path, tested_item_kind + FROM ci_test_reference WHERE workspace_id = $1", + source_workspace_id, + target_workspace_id, + ) + .execute(&mut **tx) + .await?; + Ok(()) +} + async fn clone_flows( tx: &mut Transaction<'_, Postgres>, source_workspace_id: &str, @@ -4170,7 +4190,7 @@ async fn snapshot_datatable_schema( let pg = get_datatable_resource_from_db_unchecked(db, parent_w_id, dt_name).await?; let pg: PgDatabase = serde_json::from_value(pg) .map_err(|e| Error::internal_err(format!("Failed to parse db credentials: {}", e)))?; - let (client, connection) = pg.connect().await?; + let (client, connection) = pg.connect(Some(db)).await?; let join_handle = tokio::spawn(async move { connection.await }); let schema = windmill_common::query_builders::pg_get_full_schema(&client) diff --git a/backend/windmill-api-workspaces/src/workspaces_extra.rs b/backend/windmill-api-workspaces/src/workspaces_extra.rs index 231f7cb429..24d4192299 100644 --- a/backend/windmill-api-workspaces/src/workspaces_extra.rs +++ b/backend/windmill-api-workspaces/src/workspaces_extra.rs @@ -983,7 +983,7 @@ pub async fn drop_forked_datatable_databases( continue; } - match parent_pg.connect().await { + match parent_pg.connect(Some(&db)).await { Ok((client, connection)) => { let join_handle = tokio::spawn(async move { connection.await }); if let Err(e) = client diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 60ee3b8470..2805098c93 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.680.0 + version: 1.682.0 title: Windmill API contact: @@ -726,6 +726,36 @@ paths: items: $ref: "#/components/schemas/ExportedUser" + /users/ext_jwt_tokens: + get: + summary: list external JWT tokens (ee only) + operationId: listExtJwtTokens + tags: + - user + parameters: + - name: page + in: query + schema: + type: integer + - name: per_page + in: query + schema: + type: integer + - name: active_only + in: query + description: only tokens used in the last 30 days + schema: + type: boolean + responses: + "200": + description: list of external JWT tokens + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/ExternalJwtToken" + /users/onboarding: post: summary: Submit user onboarding data @@ -772,6 +802,98 @@ paths: text/plain: schema: type: string + /w/{workspace}/users/offboard_preview/{username}: + get: + summary: preview offboarding for a workspace user (require admin privilege) + operationId: offboardPreview + tags: + - user + - admin + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: username + in: path + required: true + schema: + type: string + responses: + "200": + description: offboard preview with object counts + content: + application/json: + schema: + $ref: "#/components/schemas/OffboardPreview" + /w/{workspace}/users/offboard/{username}: + post: + summary: offboard a workspace user (reassign objects, optionally delete user) + operationId: offboardWorkspaceUser + tags: + - user + - admin + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: username + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/OffboardRequest" + responses: + "200": + description: offboard response with conflicts or summary + content: + application/json: + schema: + $ref: "#/components/schemas/OffboardResponse" + /users/offboard_preview/{email}: + get: + summary: preview global offboarding for a user across all workspaces (require super admin) + operationId: globalOffboardPreview + tags: + - user + parameters: + - name: email + in: path + required: true + schema: + type: string + responses: + "200": + description: per-workspace offboard previews + content: + application/json: + schema: + $ref: "#/components/schemas/GlobalOffboardPreview" + /users/offboard/{email}: + post: + summary: offboard a user globally (reassign objects across workspaces, optionally delete) + operationId: offboardGlobalUser + tags: + - user + parameters: + - name: email + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/GlobalOffboardRequest" + responses: + "200": + description: offboard result + content: + application/json: + schema: + $ref: "#/components/schemas/OffboardResponse" /w/{workspace}/users/convert_to_group/{username}: post: summary: convert manual user to group user (require admin privilege) @@ -7771,6 +7893,77 @@ paths: type: string format: uuid + /w/{workspace}/scripts/ci_test_results/{kind}/{path}: + get: + summary: get CI test results for a script, flow, or resource + operationId: getCiTestResults + tags: + - script + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: kind + in: path + required: true + schema: + type: string + enum: [script, flow, resource] + - name: path + in: path + required: true + schema: + type: string + responses: + "200": + description: CI test results + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/CiTestResult" + + /w/{workspace}/scripts/ci_test_results_batch: + post: + summary: get CI test results for multiple items + operationId: getCiTestResultsBatch + tags: + - script + parameters: + - $ref: "#/components/parameters/WorkspaceId" + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - items + properties: + items: + type: array + items: + type: object + required: + - path + - kind + properties: + path: + type: string + kind: + type: string + enum: [script, flow, resource] + responses: + "200": + description: CI test results by item key + content: + application/json: + schema: + type: object + additionalProperties: + type: array + items: + $ref: "#/components/schemas/CiTestResult" + /w/{workspace}/scripts/raw_temp/store: post: summary: store raw script content temporarily for CLI lock generation @@ -19397,6 +19590,25 @@ components: # -- INLINE END -- # Do not change line above + CiTestResult: + type: object + properties: + test_script_path: + type: string + job_id: + type: string + format: uuid + nullable: true + status: + type: string + nullable: true + started_at: + type: string + format: date-time + nullable: true + required: + - test_script_path + HealthStatusResponse: type: object description: Health status response (cached with 5s TTL) @@ -19692,6 +19904,9 @@ components: token: type: string description: Static Vault token for testing/development (optional, if provided this is used instead of JWT authentication) + skip_ssl_verify: + type: boolean + description: Skip TLS certificate verification when connecting to Vault. Only use for self-signed certificates in development environments. AzureKeyVaultSettings: type: object @@ -21075,6 +21290,191 @@ components: disabled: type: boolean + OffboardAffectedPaths: + type: object + properties: + scripts: + type: array + items: + type: string + flows: + type: array + items: + type: string + apps: + type: array + items: + type: string + resources: + type: array + items: + type: string + variables: + type: array + items: + type: string + schedules: + type: array + items: + type: string + triggers: + type: object + additionalProperties: + type: array + items: + type: string + + OffboardPreview: + type: object + properties: + owned: + $ref: "#/components/schemas/OffboardAffectedPaths" + description: "Objects under u/{username}/ that will be reassigned" + executing_on_behalf: + $ref: "#/components/schemas/OffboardAffectedPaths" + description: "Objects not under the user's path but that execute on behalf of this user (permissioned_as/on_behalf_of will be updated)" + referencing: + $ref: "#/components/schemas/OffboardAffectedPaths" + description: "Scripts/flows/apps/resources whose content or value references this user's paths (may break after reassignment)" + tokens: + type: array + items: + $ref: "#/components/schemas/OffboardTokenInfo" + description: "Tokens owned by this user (will be deleted)" + http_triggers: + type: integer + description: "HTTP triggers under the user's path (webhook URLs will change)" + email_triggers: + type: integer + description: "Email triggers under the user's path (email addresses will change)" + required: + - owned + - executing_on_behalf + - referencing + - tokens + - http_triggers + - email_triggers + + OffboardTokenInfo: + type: object + properties: + label: + type: string + scopes: + type: array + items: + type: string + expiration: + type: string + required: + - label + - scopes + + OffboardRequest: + type: object + properties: + reassign_to: + type: string + description: "Target for reassignment: 'u/{username}' or 'f/{folder}'" + new_on_behalf_of_user: + type: string + description: "Required when reassign_to is a folder. The username whose identity will be used as permissioned_as for schedules and triggers." + delete_user: + type: boolean + default: true + description: "Whether to also remove the user from the workspace" + required: + - reassign_to + + OffboardResponse: + type: object + properties: + conflicts: + type: array + items: + type: string + description: "List of path conflicts that block the offboarding. Empty on success." + summary: + $ref: "#/components/schemas/OffboardSummary" + + OffboardSummary: + type: object + properties: + scripts_reassigned: + type: integer + flows_reassigned: + type: integer + apps_reassigned: + type: integer + resources_reassigned: + type: integer + variables_reassigned: + type: integer + schedules_reassigned: + type: integer + triggers_reassigned: + type: integer + drafts_deleted: + type: integer + required: + - scripts_reassigned + - flows_reassigned + - apps_reassigned + - resources_reassigned + - variables_reassigned + - schedules_reassigned + - triggers_reassigned + - drafts_deleted + + GlobalOffboardPreview: + type: object + properties: + workspaces: + type: array + items: + $ref: "#/components/schemas/WorkspaceOffboardPreview" + required: + - workspaces + + WorkspaceOffboardPreview: + type: object + properties: + workspace_id: + type: string + username: + type: string + preview: + $ref: "#/components/schemas/OffboardPreview" + required: + - workspace_id + - username + - preview + + GlobalOffboardRequest: + type: object + properties: + reassignments: + type: object + additionalProperties: + $ref: "#/components/schemas/WorkspaceReassignment" + description: "Map of workspace_id to reassignment config" + delete_user: + type: boolean + default: true + description: "Whether to also remove the user from the instance" + + WorkspaceReassignment: + type: object + properties: + reassign_to: + type: string + description: "Target: 'u/{username}' or 'f/{folder}'" + new_on_behalf_of_user: + type: string + description: "Required when reassign_to is a folder. Username to use as permissioned_as." + required: + - reassign_to + TruncatedToken: type: object properties: @@ -21102,6 +21502,39 @@ components: - created_at - last_used_at + ExternalJwtToken: + type: object + properties: + jwt_hash: + type: integer + format: int64 + email: + type: string + username: + type: string + is_admin: + type: boolean + is_operator: + type: boolean + workspace_id: + type: string + label: + type: string + scopes: + type: array + items: + type: string + last_used_at: + type: string + format: date-time + required: + - jwt_hash + - email + - username + - is_admin + - is_operator + - last_used_at + NewToken: type: object properties: diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index 255223ad6d..50ed10578f 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -154,6 +154,7 @@ mod teams_approvals_oss; #[cfg(feature = "native_trigger")] pub mod native_triggers; +mod offboarding; mod public_app_layer; mod public_app_rate_limit; mod s3_log_batching; diff --git a/backend/windmill-api/src/offboarding.rs b/backend/windmill-api/src/offboarding.rs new file mode 100644 index 0000000000..b1a2f86930 --- /dev/null +++ b/backend/windmill-api/src/offboarding.rs @@ -0,0 +1,1119 @@ +use std::collections::HashMap; + +use crate::db::ApiAuthed; +use crate::secret_backend_ext::rename_vault_secrets_with_prefix; +use axum::{ + extract::{Extension, Path}, + Json, +}; +use serde::{Deserialize, Serialize}; +use windmill_api_auth::require_super_admin; +use windmill_api_users::users::delete_workspace_user_internal; +use windmill_audit::audit_oss::audit_log; +use windmill_audit::ActionKind; +use windmill_common::utils::require_admin; +use windmill_common::{ + error::{Error, JsonResult, Result}, + DB, +}; +use windmill_git_sync::handle_deployment_metadata; + +// ---- Types ---- + +#[derive(Serialize, Deserialize)] +pub(crate) struct OffboardPreview { + /// Objects under u/{username}/ that will be reassigned + owned: OffboardAffectedPaths, + /// Objects NOT under the user's path but that execute on behalf of this user + /// (will have their permissioned_as/on_behalf_of updated) + executing_on_behalf: OffboardAffectedPaths, + /// Scripts/flows/apps/resources whose content/value references u/{username}/ paths + /// (these references may break after path changes — admin should review) + referencing: OffboardAffectedPaths, + /// Tokens owned by this user (will be deleted) + tokens: Vec, + /// HTTP triggers under the user's path (webhook URLs will change) + http_triggers: i64, + /// Email triggers under the user's path (email addresses will change) + email_triggers: i64, +} + +#[derive(Serialize, Deserialize, Default)] +struct OffboardAffectedPaths { + #[serde(skip_serializing_if = "Vec::is_empty")] + scripts: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + flows: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + apps: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + resources: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + variables: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + schedules: Vec, + #[serde(skip_serializing_if = "HashMap::is_empty")] + triggers: HashMap>, +} + +#[derive(Serialize, Deserialize)] +struct OffboardTokenInfo { + label: String, + scopes: Vec, + expiration: Option, +} + +#[derive(Deserialize)] +pub(crate) struct OffboardRequest { + reassign_to: String, + /// Required when reassign_to is a folder (f/...). The username whose identity + /// will be used as permissioned_as for schedules and triggers. + new_on_behalf_of_user: Option, + #[serde(default = "default_true")] + delete_user: bool, +} + +fn default_true() -> bool { + true +} + +#[derive(Serialize)] +pub(crate) struct OffboardResponse { + #[serde(skip_serializing_if = "Vec::is_empty")] + conflicts: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + summary: Option, +} + +#[derive(Serialize)] +struct OffboardSummary { + scripts_reassigned: i64, + flows_reassigned: i64, + apps_reassigned: i64, + resources_reassigned: i64, + variables_reassigned: i64, + schedules_reassigned: i64, + triggers_reassigned: i64, + drafts_deleted: i64, +} + +#[derive(Serialize)] +pub(crate) struct GlobalOffboardPreview { + workspaces: Vec, +} + +#[derive(Serialize)] +struct WorkspaceOffboardPreview { + workspace_id: String, + username: String, + preview: OffboardPreview, +} + +#[derive(Deserialize)] +pub(crate) struct GlobalOffboardRequest { + #[serde(default)] + reassignments: HashMap, + #[serde(default = "default_true")] + delete_user: bool, +} + +#[derive(Deserialize)] +struct WorkspaceReassignment { + reassign_to: String, + new_on_behalf_of_user: Option, +} + +// ---- Preview helpers ---- + +async fn get_offboard_preview( + db: impl sqlx::PgExecutor<'_> + Copy, + w_id: &str, + username: &str, + email: &str, +) -> Result { + let user_prefix = format!("u/{}/%", username); + let user_owner = format!("u/{}", username); + + // ---- Owned objects (under u/{username}/) ---- + let scripts = sqlx::query_scalar!( + "SELECT path FROM script WHERE path LIKE $1 AND workspace_id = $2 AND NOT archived AND NOT deleted", + &user_prefix, w_id + ).fetch_all(db).await?; + + let flows = sqlx::query_scalar!( + "SELECT path FROM flow WHERE path LIKE $1 AND workspace_id = $2 AND NOT archived", + &user_prefix, + w_id + ) + .fetch_all(db) + .await?; + + let apps = sqlx::query_scalar!( + "SELECT path FROM app WHERE path LIKE $1 AND workspace_id = $2", + &user_prefix, + w_id + ) + .fetch_all(db) + .await?; + + let resources = sqlx::query_scalar!( + "SELECT path FROM resource WHERE path LIKE $1 AND workspace_id = $2", + &user_prefix, + w_id + ) + .fetch_all(db) + .await?; + + let variables = sqlx::query_scalar!( + "SELECT path FROM variable WHERE path LIKE $1 AND workspace_id = $2", + &user_prefix, + w_id + ) + .fetch_all(db) + .await?; + + let schedules = sqlx::query_scalar!( + "SELECT path FROM schedule WHERE path LIKE $1 AND workspace_id = $2", + &user_prefix, + w_id + ) + .fetch_all(db) + .await?; + + let trigger_tables = [ + "http_trigger", + "websocket_trigger", + "kafka_trigger", + "postgres_trigger", + "mqtt_trigger", + "nats_trigger", + "sqs_trigger", + "gcp_trigger", + "email_trigger", + ]; + let mut triggers = HashMap::new(); + for table in &trigger_tables { + let paths: Vec = sqlx::query_scalar(&format!( + "SELECT path FROM {table} WHERE path LIKE $1 AND workspace_id = $2" + )) + .bind(&user_prefix) + .bind(w_id) + .fetch_all(db) + .await?; + if !paths.is_empty() { + triggers.insert(table.to_string(), paths); + } + } + + // ---- Tokens (all workspace-scoped tokens that will be deleted) ---- + let token_rows = sqlx::query!( + "SELECT label, scopes, expiration FROM token WHERE email = $1 AND workspace_id = $2", + email, + w_id + ) + .fetch_all(db) + .await?; + + let tokens: Vec = token_rows + .into_iter() + .map(|r| OffboardTokenInfo { + label: r.label.unwrap_or_default(), + scopes: r.scopes.unwrap_or_default(), + expiration: r.expiration.map(|e| e.to_string()), + }) + .collect(); + + // ---- Operator references (not under user's path) ---- + let obo_scripts = sqlx::query_scalar!( + "SELECT path FROM script WHERE on_behalf_of_email = $1 AND NOT path LIKE $2 AND workspace_id = $3 AND NOT archived AND NOT deleted", + email, &user_prefix, w_id + ).fetch_all(db).await?; + + let obo_flows = sqlx::query_scalar!( + "SELECT path FROM flow WHERE on_behalf_of_email = $1 AND NOT path LIKE $2 AND workspace_id = $3 AND NOT archived", + email, &user_prefix, w_id + ).fetch_all(db).await?; + + let obo_apps = sqlx::query_scalar!( + "SELECT path FROM app WHERE policy->>'on_behalf_of' = $1 AND NOT path LIKE $2 AND workspace_id = $3", + &user_owner, &user_prefix, w_id + ).fetch_all(db).await?; + + let obo_schedules = sqlx::query_scalar!( + "SELECT path FROM schedule WHERE permissioned_as = $1 AND NOT path LIKE $2 AND workspace_id = $3", + &user_owner, &user_prefix, w_id + ).fetch_all(db).await?; + + let mut obo_triggers = HashMap::new(); + for table in &trigger_tables { + let paths: Vec = sqlx::query_scalar(&format!( + "SELECT path FROM {table} WHERE permissioned_as = $1 AND NOT path LIKE $2 AND workspace_id = $3" + )) + .bind(&user_owner) + .bind(&user_prefix) + .bind(w_id) + .fetch_all(db) + .await?; + if !paths.is_empty() { + obo_triggers.insert(table.to_string(), paths); + } + } + + // ---- Objects whose content/value references u/{username}/ paths ---- + let ref_pattern = format!("%u/{}/%", username); + + let ref_scripts = sqlx::query_scalar!( + "SELECT DISTINCT path FROM script WHERE content LIKE $1 AND NOT path LIKE $2 AND workspace_id = $3 AND NOT archived AND NOT deleted", + &ref_pattern, &user_prefix, w_id + ).fetch_all(db).await?; + + let ref_flows = sqlx::query_scalar!( + "SELECT DISTINCT path FROM flow WHERE value::text LIKE $1 AND NOT path LIKE $2 AND workspace_id = $3 AND NOT archived", + &ref_pattern, &user_prefix, w_id + ).fetch_all(db).await?; + + let ref_apps = sqlx::query_scalar!( + "SELECT DISTINCT path FROM app WHERE (policy::text LIKE $1 OR extra_perms::text LIKE $1) AND NOT path LIKE $2 AND workspace_id = $3", + &ref_pattern, &user_prefix, w_id + ).fetch_all(db).await?; + + let ref_resources = sqlx::query_scalar!( + "SELECT DISTINCT path FROM resource WHERE value::text LIKE $1 AND NOT path LIKE $2 AND workspace_id = $3", + &ref_pattern, &user_prefix, w_id + ).fetch_all(db).await?; + + // ---- Specific trigger warnings ---- + let http_triggers = sqlx::query_scalar!( + "SELECT COUNT(*) FROM http_trigger WHERE path LIKE $1 AND workspace_id = $2", + &user_prefix, + w_id + ) + .fetch_one(db) + .await? + .unwrap_or(0); + + let email_triggers = sqlx::query_scalar!( + "SELECT COUNT(*) FROM email_trigger WHERE path LIKE $1 AND workspace_id = $2", + &user_prefix, + w_id + ) + .fetch_one(db) + .await? + .unwrap_or(0); + + Ok(OffboardPreview { + owned: OffboardAffectedPaths { + scripts, + flows, + apps, + resources, + variables, + schedules, + triggers, + }, + executing_on_behalf: OffboardAffectedPaths { + scripts: obo_scripts, + flows: obo_flows, + apps: obo_apps, + schedules: obo_schedules, + triggers: obo_triggers, + ..Default::default() + }, + referencing: OffboardAffectedPaths { + scripts: ref_scripts, + flows: ref_flows, + apps: ref_apps, + resources: ref_resources, + ..Default::default() + }, + tokens, + http_triggers, + email_triggers, + }) +} + +// ---- Workspace-level endpoints ---- + +pub(crate) async fn offboard_preview( + authed: ApiAuthed, + Extension(db): Extension, + Path((w_id, username)): Path<(String, String)>, +) -> JsonResult { + require_admin(authed.is_admin, &authed.username)?; + let email = sqlx::query_scalar!( + "SELECT email FROM usr WHERE username = $1 AND workspace_id = $2", + &username, + &w_id + ) + .fetch_optional(&db) + .await? + .ok_or_else(|| { + Error::NotFound(format!( + "user {} not found in workspace {}", + &username, &w_id + )) + })?; + let preview = get_offboard_preview(&db, &w_id, &username, &email).await?; + Ok(Json(preview)) +} + +pub(crate) async fn offboard_workspace_user( + authed: ApiAuthed, + Extension(db): Extension, + Path((w_id, username)): Path<(String, String)>, + Json(req): Json, +) -> JsonResult { + require_admin(authed.is_admin, &authed.username)?; + + let (target_kind, target_name) = parse_reassign_target(&req.reassign_to)?; + validate_target(&db, &w_id, target_kind, target_name).await?; + + let new_permissioned_as = resolve_new_permissioned_as( + target_kind, + &req.reassign_to, + req.new_on_behalf_of_user.as_deref(), + &db, + &w_id, + ) + .await?; + + let conflicts = check_path_conflicts(&db, &w_id, &username, &req.reassign_to).await?; + if !conflicts.is_empty() { + return Ok(Json(OffboardResponse { conflicts, summary: None })); + } + + let email = sqlx::query_scalar!( + "SELECT email FROM usr WHERE username = $1 AND workspace_id = $2", + &username, + &w_id + ) + .fetch_optional(&db) + .await? + .ok_or_else(|| { + Error::NotFound(format!( + "user {} not found in workspace {}", + &username, &w_id + )) + })?; + + let mut tx = db.begin().await?; + + let summary = offboard_user_from_workspace( + &mut tx, + &db, + &w_id, + &username, + &email, + &req.reassign_to, + &new_permissioned_as, + ) + .await?; + + if req.delete_user { + delete_workspace_user_internal(&w_id, &username, &email, &mut tx, Some(&authed)).await?; + } else { + audit_log( + &mut *tx, + &authed, + "users.offboard_reassign", + ActionKind::Update, + &w_id, + Some(&username), + Some([("reassign_to", req.reassign_to.as_str())].into()), + ) + .await?; + } + + tx.commit().await?; + + if req.delete_user { + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + windmill_git_sync::DeployedObject::User { email: email.clone() }, + Some(format!( + "Offboarded user '{}' from workspace (objects reassigned to {})", + &email, &req.reassign_to + )), + true, + None, + ) + .await?; + } + + Ok(Json(OffboardResponse { + conflicts: vec![], + summary: Some(summary), + })) +} + +// ---- Instance-level endpoints ---- + +pub(crate) async fn global_offboard_preview( + authed: ApiAuthed, + Extension(db): Extension, + Path(email): Path, +) -> JsonResult { + require_super_admin(&db, &authed.email).await?; + + let workspaces = sqlx::query!( + "SELECT workspace_id, username FROM usr WHERE email = $1", + &email + ) + .fetch_all(&db) + .await?; + + let mut previews = Vec::new(); + for w in workspaces { + let preview = get_offboard_preview(&db, &w.workspace_id, &w.username, &email).await?; + previews.push(WorkspaceOffboardPreview { + workspace_id: w.workspace_id, + username: w.username, + preview, + }); + } + + Ok(Json(GlobalOffboardPreview { workspaces: previews })) +} + +pub(crate) async fn offboard_global_user( + authed: ApiAuthed, + Extension(db): Extension, + Path(email): Path, + Json(req): Json, +) -> Result> { + require_super_admin(&db, &authed.email).await?; + + let workspaces = sqlx::query!( + "SELECT workspace_id, username FROM usr WHERE email = $1", + &email + ) + .fetch_all(&db) + .await?; + + // Validate all targets and resolve permissioned_as upfront + let mut resolved_permissioned_as: HashMap = HashMap::new(); + for (w_id, reassignment) in &req.reassignments { + let (target_kind, target_name) = parse_reassign_target(&reassignment.reassign_to)?; + validate_target(&db, w_id, target_kind, target_name).await?; + let perm_as = resolve_new_permissioned_as( + target_kind, + &reassignment.reassign_to, + reassignment.new_on_behalf_of_user.as_deref(), + &db, + w_id, + ) + .await?; + resolved_permissioned_as.insert(w_id.clone(), perm_as); + } + + // Check for conflicts in all workspaces + let mut all_conflicts = Vec::new(); + for ws in &workspaces { + if let Some(reassignment) = req.reassignments.get(&ws.workspace_id) { + let conflicts = check_path_conflicts( + &db, + &ws.workspace_id, + &ws.username, + &reassignment.reassign_to, + ) + .await?; + for c in conflicts { + all_conflicts.push(format!("[{}] {}", &ws.workspace_id, c)); + } + } + } + if !all_conflicts.is_empty() { + return Ok(Json(OffboardResponse { + conflicts: all_conflicts, + summary: None, + })); + } + + let mut tx = db.begin().await?; + + let mut total_summary = OffboardSummary { + scripts_reassigned: 0, + flows_reassigned: 0, + apps_reassigned: 0, + resources_reassigned: 0, + variables_reassigned: 0, + schedules_reassigned: 0, + triggers_reassigned: 0, + drafts_deleted: 0, + }; + + for ws in &workspaces { + if let Some(reassignment) = req.reassignments.get(&ws.workspace_id) { + let perm_as = resolved_permissioned_as + .get(&ws.workspace_id) + .ok_or_else(|| { + Error::InternalErr("missing resolved permissioned_as".to_string()) + })?; + let ws_summary = offboard_user_from_workspace( + &mut tx, + &db, + &ws.workspace_id, + &ws.username, + &email, + &reassignment.reassign_to, + perm_as, + ) + .await?; + total_summary.scripts_reassigned += ws_summary.scripts_reassigned; + total_summary.flows_reassigned += ws_summary.flows_reassigned; + total_summary.apps_reassigned += ws_summary.apps_reassigned; + total_summary.resources_reassigned += ws_summary.resources_reassigned; + total_summary.variables_reassigned += ws_summary.variables_reassigned; + total_summary.schedules_reassigned += ws_summary.schedules_reassigned; + total_summary.triggers_reassigned += ws_summary.triggers_reassigned; + total_summary.drafts_deleted += ws_summary.drafts_deleted; + } + + if req.delete_user { + delete_workspace_user_internal(&ws.workspace_id, &ws.username, &email, &mut tx, None) + .await?; + } + } + + if req.delete_user { + sqlx::query!("DELETE FROM token WHERE email = $1", &email) + .execute(&mut *tx) + .await?; + sqlx::query!("DELETE FROM password WHERE email = $1", &email) + .execute(&mut *tx) + .await?; + sqlx::query!("DELETE FROM workspace_invite WHERE email = $1", &email) + .execute(&mut *tx) + .await?; + sqlx::query!("DELETE FROM email_to_igroup WHERE email = $1", &email) + .execute(&mut *tx) + .await?; + } + + audit_log( + &mut *tx, + &authed, + if req.delete_user { + "users.offboard_delete" + } else { + "users.offboard_reassign" + }, + if req.delete_user { + ActionKind::Delete + } else { + ActionKind::Update + }, + "global", + Some(&email), + None, + ) + .await?; + + tx.commit().await?; + + Ok(Json(OffboardResponse { + conflicts: vec![], + summary: Some(total_summary), + })) +} + +// ---- Validation helpers ---- + +fn parse_reassign_target(target: &str) -> Result<(&str, &str)> { + if let Some(name) = target.strip_prefix("u/") { + if name.is_empty() { + return Err(Error::BadRequest( + "empty username in reassign_to".to_string(), + )); + } + Ok(("user", name)) + } else if let Some(name) = target.strip_prefix("f/") { + if name.is_empty() { + return Err(Error::BadRequest("empty folder in reassign_to".to_string())); + } + Ok(("folder", name)) + } else { + Err(Error::BadRequest( + "reassign_to must start with 'u/' (user) or 'f/' (folder)".to_string(), + )) + } +} + +async fn validate_target(db: &DB, w_id: &str, kind: &str, name: &str) -> Result<()> { + match kind { + "user" => { + let exists = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM usr WHERE username = $1 AND workspace_id = $2)", + name, + w_id + ) + .fetch_one(db) + .await? + .unwrap_or(false); + if !exists { + return Err(Error::BadRequest(format!( + "target user '{}' not found in workspace '{}'", + name, w_id + ))); + } + } + "folder" => { + let exists = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM folder WHERE name = $1 AND workspace_id = $2)", + name, + w_id + ) + .fetch_one(db) + .await? + .unwrap_or(false); + if !exists { + return Err(Error::BadRequest(format!( + "target folder '{}' not found in workspace '{}'", + name, w_id + ))); + } + } + _ => unreachable!(), + } + Ok(()) +} + +/// Resolve the permissioned_as value for schedules/triggers. +/// If new_on_behalf_of_user is provided, use that user. +/// Otherwise, default to the target user (for user targets) or error (for folder targets). +async fn resolve_new_permissioned_as( + target_kind: &str, + reassign_to: &str, + new_on_behalf_of_user: Option<&str>, + db: &DB, + w_id: &str, +) -> Result { + let username = if let Some(user) = new_on_behalf_of_user { + user.to_string() + } else { + match target_kind { + "user" => reassign_to + .strip_prefix("u/") + .unwrap_or(reassign_to) + .to_string(), + "folder" => { + return Err(Error::BadRequest( + "new_on_behalf_of_user is required when reassigning to a folder".to_string(), + )); + } + _ => unreachable!(), + } + }; + let exists = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM usr WHERE username = $1 AND workspace_id = $2)", + &username, + w_id + ) + .fetch_one(db) + .await? + .unwrap_or(false); + if !exists { + return Err(Error::NotFound(format!( + "new_on_behalf_of_user '{}' not found in workspace '{}'", + username, w_id + ))); + } + Ok(format!("u/{}", username)) +} + +async fn check_path_conflicts( + db: &DB, + w_id: &str, + username: &str, + reassign_to: &str, +) -> Result> { + let new_prefix = format!("{}/", reassign_to); + let mut conflicts = Vec::new(); + + let tables = [ + "script", + "flow", + "app", + "resource", + "variable", + "schedule", + "http_trigger", + "websocket_trigger", + "kafka_trigger", + "postgres_trigger", + "mqtt_trigger", + "nats_trigger", + "sqs_trigger", + "gcp_trigger", + "email_trigger", + ]; + + for table_name in &tables { + let extra_filter = match *table_name { + "script" => " AND NOT t1.archived AND NOT t1.deleted", + "flow" => " AND NOT t1.archived", + _ => "", + }; + let rows: Vec = sqlx::query_scalar(&format!( + "SELECT REGEXP_REPLACE(t1.path, '^u/' || $1 || '/', $3) \ + FROM {table} t1 \ + WHERE t1.path LIKE ('u/' || $1 || '/%') AND t1.workspace_id = $2{extra_filter} \ + AND EXISTS ( \ + SELECT 1 FROM {table} t2 \ + WHERE t2.path = REGEXP_REPLACE(t1.path, '^u/' || $1 || '/', $3) \ + AND t2.workspace_id = $2 \ + )", + table = table_name + )) + .bind(username) + .bind(w_id) + .bind(&new_prefix) + .fetch_all(db) + .await?; + + for path in rows { + conflicts.push(format!("{}: {}", table_name, path)); + } + } + + Ok(conflicts) +} + +// ---- Core reassignment logic ---- + +async fn offboard_user_from_workspace<'c>( + tx: &mut sqlx::Transaction<'c, sqlx::Postgres>, + db: &DB, + w_id: &str, + username: &str, + email: &str, + reassign_to: &str, + new_permissioned_as: &str, +) -> Result { + let new_prefix = reassign_to.to_string(); + + // Resolve the new operator's email for on_behalf_of_email on scripts/flows + let new_on_behalf_of_user_username = new_permissioned_as + .strip_prefix("u/") + .unwrap_or(new_permissioned_as); + let new_on_behalf_of_user_email = sqlx::query_scalar!( + "SELECT email FROM usr WHERE username = $1 AND workspace_id = $2", + new_on_behalf_of_user_username, + w_id + ) + .fetch_optional(&mut **tx) + .await?; + + // ---- scripts ---- + let scripts_reassigned = sqlx::query_scalar!( + r#"WITH updated AS ( + UPDATE script SET path = REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\1') + WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3 + RETURNING 1 + ) SELECT COUNT(*) FROM updated"#, + &new_prefix, + username, + w_id + ) + .fetch_one(&mut **tx) + .await? + .unwrap_or(0); + + if let Some(ref new_email) = new_on_behalf_of_user_email { + sqlx::query!( + "UPDATE script SET on_behalf_of_email = $1 WHERE on_behalf_of_email = $2 AND workspace_id = $3", + new_email, + email, + w_id + ) + .execute(&mut **tx) + .await?; + } + + // ---- flows ---- + let flows_reassigned = sqlx::query_scalar!( + r#"WITH inserted AS ( + INSERT INTO flow + (workspace_id, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs) + SELECT workspace_id, REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\1'), summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs + FROM flow + WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3 + RETURNING 1 + ) SELECT COUNT(*) FROM inserted"#, + &new_prefix, username, w_id + ).fetch_one(&mut **tx).await?.unwrap_or(0); + + sqlx::query!( + r#"UPDATE flow_version SET path = REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\1') WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#, + &new_prefix, username, w_id + ).execute(&mut **tx).await?; + + sqlx::query!( + r#"UPDATE flow_node SET path = REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\1') WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#, + &new_prefix, username, w_id + ).execute(&mut **tx).await?; + + sqlx::query!( + "DELETE FROM flow WHERE path LIKE ('u/' || $1 || '/%') AND workspace_id = $2", + username, + w_id + ) + .execute(&mut **tx) + .await?; + + if let Some(ref new_email) = new_on_behalf_of_user_email { + sqlx::query!( + "UPDATE flow SET on_behalf_of_email = $1 WHERE on_behalf_of_email = $2 AND workspace_id = $3", + new_email, + email, + w_id + ) + .execute(&mut **tx) + .await?; + } + + // ---- apps ---- + let apps_reassigned = sqlx::query_scalar!( + r#"WITH updated AS ( + UPDATE app SET path = REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\1') + WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3 + RETURNING 1 + ) SELECT COUNT(*) FROM updated"#, + &new_prefix, + username, + w_id + ) + .fetch_one(&mut **tx) + .await? + .unwrap_or(0); + + sqlx::query!( + "UPDATE app SET policy = jsonb_set(policy, ARRAY['on_behalf_of'], to_jsonb($1::text)) WHERE policy->>'on_behalf_of' = ('u/' || $2) AND workspace_id = $3", + &new_permissioned_as, username, w_id + ).execute(&mut **tx).await?; + + // ---- raw_app (mirrors app paths) ---- + sqlx::query!( + r#"UPDATE raw_app SET path = REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\1') + WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#, + &new_prefix, + username, + w_id + ) + .execute(&mut **tx) + .await?; + + // ---- resources ---- + let resources_reassigned = sqlx::query_scalar!( + r#"WITH updated AS ( + UPDATE resource SET path = REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\1') + WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3 + RETURNING 1 + ) SELECT COUNT(*) FROM updated"#, + &new_prefix, + username, + w_id + ) + .fetch_one(&mut **tx) + .await? + .unwrap_or(0); + + // ---- variables (with Vault secret handling) ---- + let old_var_prefix = format!("u/{}/", username); + let new_var_prefix = format!("{}/", reassign_to); + + let vault_secrets: Vec<(String, String)> = sqlx::query!( + r#"SELECT path, value FROM variable + WHERE path LIKE ('u/' || $1 || '/%') + AND workspace_id = $2 + AND is_secret = true + AND (value LIKE '$vault:%' OR value LIKE '$azure_kv:%')"#, + username, + w_id + ) + .fetch_all(&mut **tx) + .await? + .into_iter() + .map(|r| (r.path, r.value)) + .collect(); + + let vault_updates = + rename_vault_secrets_with_prefix(db, w_id, &old_var_prefix, &new_var_prefix, vault_secrets) + .await?; + + for (old_path, new_value) in vault_updates { + sqlx::query!( + "UPDATE variable SET value = $1 WHERE path = $2 AND workspace_id = $3", + new_value, + old_path, + w_id + ) + .execute(&mut **tx) + .await?; + } + + let variables_reassigned = sqlx::query_scalar!( + r#"WITH updated AS ( + UPDATE variable SET path = REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\1') + WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3 + RETURNING 1 + ) SELECT COUNT(*) FROM updated"#, + &new_prefix, + username, + w_id + ) + .fetch_one(&mut **tx) + .await? + .unwrap_or(0); + + // ---- schedules ---- + let schedules_reassigned = sqlx::query_scalar!( + r#"WITH updated AS ( + UPDATE schedule SET + path = REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\1'), + script_path = REGEXP_REPLACE(script_path, 'u/' || $2 || '/(.*)', $1 || '/\1'), + permissioned_as = $4 + WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3 + RETURNING 1 + ) SELECT COUNT(*) FROM updated"#, + &new_prefix, + username, + w_id, + &new_permissioned_as + ) + .fetch_one(&mut **tx) + .await? + .unwrap_or(0); + + sqlx::query!( + r#"UPDATE schedule SET script_path = REGEXP_REPLACE(script_path, 'u/' || $2 || '/(.*)', $1 || '/\1') + WHERE script_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#, + &new_prefix, username, w_id + ).execute(&mut **tx).await?; + + sqlx::query!( + "UPDATE schedule SET permissioned_as = $1 WHERE permissioned_as = ('u/' || $2) AND workspace_id = $3", + &new_permissioned_as, username, w_id + ).execute(&mut **tx).await?; + + // ---- triggers (all 9 types with path/permissioned_as) ---- + let trigger_tables = [ + "http_trigger", + "websocket_trigger", + "kafka_trigger", + "postgres_trigger", + "mqtt_trigger", + "nats_trigger", + "sqs_trigger", + "gcp_trigger", + "email_trigger", + ]; + + let mut triggers_reassigned: i64 = 0; + for table in &trigger_tables { + let count: i64 = sqlx::query_scalar(&format!( + "WITH updated AS ( \ + UPDATE {table} SET \ + path = REGEXP_REPLACE(path, 'u/' || $1 || '/(.*)', $2 || '/\\1'), \ + script_path = REGEXP_REPLACE(script_path, 'u/' || $1 || '/(.*)', $2 || '/\\1'), \ + permissioned_as = $4 \ + WHERE path LIKE ('u/' || $1 || '/%') AND workspace_id = $3 \ + RETURNING 1 \ + ) SELECT COUNT(*) FROM updated" + )) + .bind(username) + .bind(&new_prefix) + .bind(w_id) + .bind(&new_permissioned_as) + .fetch_one(&mut **tx) + .await?; + triggers_reassigned += count; + + sqlx::query(&format!( + "UPDATE {table} SET script_path = REGEXP_REPLACE(script_path, 'u/' || $1 || '/(.*)', $2 || '/\\1') \ + WHERE script_path LIKE ('u/' || $1 || '/%') AND workspace_id = $3" + )) + .bind(username) + .bind(&new_prefix) + .bind(w_id) + .execute(&mut **tx) + .await?; + + sqlx::query(&format!( + "UPDATE {table} SET permissioned_as = $1 WHERE permissioned_as = ('u/' || $2) AND workspace_id = $3" + )) + .bind(&new_permissioned_as) + .bind(username) + .bind(w_id) + .execute(&mut **tx) + .await?; + } + + // NOTE: extra_perms cleanup, folder owners, favorites, inputs, captures, + // and token deletion are handled by delete_workspace_user_internal when delete_user=true. + // They are NOT done here for reassign-only (delete_user=false) since the user stays. + + // ---- Related path tables ---- + sqlx::query!( + r#"UPDATE workspace_integrations SET resource_path = REGEXP_REPLACE(resource_path, 'u/' || $2 || '/(.*)', $1 || '/\1') WHERE resource_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#, + &new_prefix, username, w_id + ).execute(&mut **tx).await?; + + sqlx::query!( + r#"UPDATE workspace_runnable_dependencies SET flow_path = REGEXP_REPLACE(flow_path, 'u/' || $2 || '/(.*)', $1 || '/\1') WHERE flow_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#, + &new_prefix, username, w_id + ).execute(&mut **tx).await?; + + sqlx::query!( + r#"UPDATE workspace_runnable_dependencies SET app_path = REGEXP_REPLACE(app_path, 'u/' || $2 || '/(.*)', $1 || '/\1') WHERE app_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#, + &new_prefix, username, w_id + ).execute(&mut **tx).await?; + + sqlx::query!( + r#"UPDATE workspace_runnable_dependencies SET runnable_path = REGEXP_REPLACE(runnable_path, 'u/' || $2 || '/(.*)', $1 || '/\1') WHERE runnable_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#, + &new_prefix, username, w_id + ).execute(&mut **tx).await?; + + sqlx::query!( + r#"UPDATE asset SET usage_path = REGEXP_REPLACE(usage_path, 'u/' || $2 || '/(.*)', $1 || '/\1') WHERE usage_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#, + &new_prefix, username, w_id + ).execute(&mut **tx).await?; + + // ---- dependency_map ---- + sqlx::query!( + r#"UPDATE dependency_map SET importer_path = REGEXP_REPLACE(importer_path, 'u/' || $2 || '/(.*)', $1 || '/\1') WHERE importer_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#, + &new_prefix, username, w_id + ).execute(&mut **tx).await?; + + sqlx::query!( + r#"UPDATE dependency_map SET imported_path = REGEXP_REPLACE(imported_path, 'u/' || $2 || '/(.*)', $1 || '/\1') WHERE imported_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#, + &new_prefix, username, w_id + ).execute(&mut **tx).await?; + + // ---- Drafts (stale after path reassignment) ---- + let drafts_deleted = sqlx::query_scalar!( + r#"WITH deleted AS ( + DELETE FROM draft WHERE path LIKE ('u/' || $1 || '/%') AND workspace_id = $2 + RETURNING 1 + ) SELECT COUNT(*) FROM deleted"#, + username, + w_id + ) + .fetch_one(&mut **tx) + .await? + .unwrap_or(0); + + Ok(OffboardSummary { + scripts_reassigned, + flows_reassigned, + apps_reassigned, + resources_reassigned, + variables_reassigned, + schedules_reassigned, + triggers_reassigned, + drafts_deleted, + }) +} diff --git a/backend/windmill-api/src/users.rs b/backend/windmill-api/src/users.rs index 10101bff05..186d2cc168 100644 --- a/backend/windmill-api/src/users.rs +++ b/backend/windmill-api/src/users.rs @@ -15,8 +15,8 @@ use crate::db::ApiAuthed; use crate::secret_backend_ext::rename_vault_secrets_with_prefix; use argon2::Argon2; use axum::{ - extract::{Extension, Path}, - routing::post, + extract::{Extension, Path, Query}, + routing::{get, post}, Json, Router, }; use hyper::StatusCode; @@ -31,6 +31,19 @@ use windmill_common::{ DB, }; +/// Wraps the subcrate's workspaced_service with offboarding routes. +pub fn workspaced_service() -> Router { + windmill_api_users::users::workspaced_service() + .route( + "/offboard_preview/{user}", + get(crate::offboarding::offboard_preview), + ) + .route( + "/offboard/{user}", + post(crate::offboarding::offboard_workspace_user), + ) +} + /// Wraps the subcrate's global_service with routes that depend on windmill-api internals. pub fn global_service() -> Router { windmill_api_users::users::global_service() @@ -39,6 +52,15 @@ pub fn global_service() -> Router { .route("/create", post(create_user)) .route("/rename/{user}", post(rename_user)) .route("/onboarding", post(submit_onboarding_data)) + .route("/ext_jwt_tokens", get(list_ext_jwt_tokens)) + .route( + "/offboard_preview/{user}", + get(crate::offboarding::global_offboard_preview), + ) + .route( + "/offboard/{user}", + post(crate::offboarding::offboard_global_user), + ) } /// Wraps the subcrate's make_unauthed_service with routes that depend on windmill-api internals. @@ -65,6 +87,56 @@ async fn submit_onboarding_data( crate::users_oss::submit_onboarding_data(authed, Extension(db), Json(data)).await } +#[derive(serde::Serialize)] +pub struct ExternalJwtToken { + pub jwt_hash: i64, + pub email: String, + pub username: String, + pub is_admin: bool, + pub is_operator: bool, + pub workspace_id: Option, + pub label: Option, + pub scopes: Option>, + pub last_used_at: chrono::DateTime, +} + +#[derive(serde::Deserialize)] +struct ListExtJwtTokensQuery { + page: Option, + per_page: Option, + #[serde(default)] + active_only: bool, +} + +async fn list_ext_jwt_tokens( + authed: ApiAuthed, + Extension(db): Extension, + Query(query): Query, +) -> Result>> { + require_super_admin(&db, &authed.email).await?; + + let (per_page, offset) = windmill_common::utils::paginate(windmill_common::utils::Pagination { + page: query.page, + per_page: query.per_page, + }); + + let rows = sqlx::query_as!( + ExternalJwtToken, + "SELECT jwt_hash, email, username, is_admin, is_operator, workspace_id, label, scopes, last_used_at + FROM unique_ext_jwt_token + WHERE NOT $3 OR last_used_at > NOW() - INTERVAL '30 days' + ORDER BY last_used_at DESC + LIMIT $1 OFFSET $2", + per_page as i64, + offset as i64, + query.active_only, + ) + .fetch_all(&db) + .await?; + + Ok(Json(rows)) +} + async fn set_password( Extension(db): Extension, Extension(argon2): Extension>>, diff --git a/backend/windmill-common/Cargo.toml b/backend/windmill-common/Cargo.toml index c5a45428e9..9e7f4cd42e 100644 --- a/backend/windmill-common/Cargo.toml +++ b/backend/windmill-common/Cargo.toml @@ -21,6 +21,7 @@ otel = ["dep:opentelemetry-semantic-conventions", "dep:opentelemetry-otlp", "dep smtp = ["dep:mail-send"] scoped_cache = [] cloud = [] +dev_override = [] openidconnect = ["dep:openidconnect"] bedrock = ["dep:aws-sdk-bedrockruntime", "dep:aws-credential-types", "dep:aws-smithy-types", "dep:aws-config"] python = ["dep:windmill-parser-py"] diff --git a/backend/windmill-common/src/ai_bedrock.rs b/backend/windmill-common/src/ai_bedrock.rs index 9a7c5baeeb..a740b202cb 100644 --- a/backend/windmill-common/src/ai_bedrock.rs +++ b/backend/windmill-common/src/ai_bedrock.rs @@ -410,9 +410,7 @@ fn mime_to_document_format(mime_type: &str) -> DocumentFormat { "application/vnd.openxmlformats-officedocument.wordprocessingml.document" => { DocumentFormat::Docx } - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => { - DocumentFormat::Xlsx - } + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => DocumentFormat::Xlsx, _ => DocumentFormat::Pdf, } } diff --git a/backend/windmill-common/src/ai_google.rs b/backend/windmill-common/src/ai_google.rs index b76d976a4c..f0591b9b81 100644 --- a/backend/windmill-common/src/ai_google.rs +++ b/backend/windmill-common/src/ai_google.rs @@ -356,9 +356,7 @@ pub fn convert_content_to_gemini_parts(content: &OpenAIContent) -> Vec { parse_data_url(&file.file_data).map(|(mime_type, data)| { - GeminiPart::InlineData { - inline_data: GeminiInlineData { mime_type, data }, - } + GeminiPart::InlineData { inline_data: GeminiInlineData { mime_type, data } } }) } // S3Objects are handled by the worker diff --git a/backend/windmill-common/src/flows.rs b/backend/windmill-common/src/flows.rs index a0923ccabf..01d714ea01 100644 --- a/backend/windmill-common/src/flows.rs +++ b/backend/windmill-common/src/flows.rs @@ -81,8 +81,7 @@ pub async fn get_full_hub_flow_by_path( .map_err(to_anyhow) { Ok(response) => response, - Err(_) if hub_base_url != DEFAULT_HUB_BASE_URL && flow_id < PRIVATE_HUB_MIN_VERSION => - { + Err(_) if hub_base_url != DEFAULT_HUB_BASE_URL && flow_id < PRIVATE_HUB_MIN_VERSION => { tracing::info!("Not found on private hub, fallback to default hub for hub flow {path}"); let fallback_url = format!("{DEFAULT_HUB_BASE_URL}/flows/{flow_id}/json"); http_get_from_hub(http_client, &fallback_url, false, None, db) diff --git a/backend/windmill-common/src/instance_config.rs b/backend/windmill-common/src/instance_config.rs index 46224c29ec..aa413649cf 100644 --- a/backend/windmill-common/src/instance_config.rs +++ b/backend/windmill-common/src/instance_config.rs @@ -874,6 +874,12 @@ pub const HIDDEN_SETTINGS: &[&str] = &[ "min_keep_alive_version", "automate_username_creation", "_restart_coordination", + // Legacy ghost: worker configs live in the `config` table with a + // `worker__` prefix, not as a single blob in `global_settings`. Older + // Windmill versions stored them here and the row would be resurrected on + // every bulk InstanceSettings save via `GlobalSettings::extra`. Hiding it + // on read + rejecting it in `diff_global_settings` breaks that loop. + "worker_configs", ]; /// Top-level settings whose entire value is sensitive and must be fully redacted in logs. @@ -899,7 +905,10 @@ const SENSITIVE_SETTINGS: &[&str] = &[ /// Maps a top-level key to the sub-field names that must be redacted. const NESTED_SENSITIVE_FIELDS: &[(&str, &[&str])] = &[ ("smtp_settings", &["smtp_password"]), - ("secret_backend", &["token", "client_secret", "secret_access_key"]), + ( + "secret_backend", + &["token", "client_secret", "secret_access_key"], + ), ( "object_store_cache_config", &["secret_key", "serviceAccountKey"], @@ -1006,7 +1015,7 @@ fn license_keys_same_client(a: &serde_json::Value, b: &serde_json::Value) -> boo } fn is_empty_or_null(value: &serde_json::Value) -> bool { - value.is_null() || value.as_str().map_or(false, |s| s.is_empty()) + value.is_null() || value.as_str().map_or(false, |s| s.trim().is_empty()) } /// Maximum retention period in seconds for CE builds (30 days). @@ -1042,9 +1051,21 @@ pub fn diff_global_settings( mode: ApplyMode, ) -> SettingsDiff { let mut upserts = BTreeMap::new(); + let mut deletes = Vec::new(); let mut previous_values = BTreeMap::new(); let mut unchanged_count: usize = 0; for (key, desired_value) in desired { + // `worker_configs` is a legacy ghost: worker configs belong in the + // `config` table with a `worker__` prefix. If a client PUT carries a + // top-level `worker_configs` key (it flattens into + // `GlobalSettings::extra` on deserialize), drop it here instead of + // letting it resurrect a stale `global_settings` row. + if key == "worker_configs" { + tracing::warn!( + "Ignoring 'worker_configs' in global_settings diff: worker configs must be written to the config table (worker__ prefix), not global_settings" + ); + continue; + } if PROTECTED_SETTINGS.contains(&key.as_str()) && is_empty_or_null(desired_value) && current.contains_key(key) @@ -1054,6 +1075,23 @@ pub fn diff_global_settings( ); continue; } + // An empty string means "unset": route it to deletes so clearing a + // setting via YAML sync has the same effect as clearing it via the UI + // (`set_global_setting_internal` already treats `""` as a delete). + // Without this, stale `""` rows linger and trip the EE registry gate, + // producing spurious "requires Enterprise" warnings on every CE job. + if desired_value + .as_str() + .map_or(false, |s| s.trim().is_empty()) + { + if let Some(existing) = current.get(key) { + previous_values.insert(key.clone(), existing.clone()); + deletes.push(key.clone()); + } else { + unchanged_count += 1; + } + continue; + } let mut value = if key == "retention_period_secs" { clamp_retention_period(desired_value.clone()) } else { @@ -1104,7 +1142,6 @@ pub fn diff_global_settings( } } } - let mut deletes = Vec::new(); if matches!(mode, ApplyMode::Replace) { for key in current.keys() { if !desired.contains_key(key) diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index fa48415f80..a607e2dea5 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -464,6 +464,32 @@ impl PgDatabase { pub async fn connect( &self, + main_db: Option<&DB>, + ) -> Result<(tokio_postgres::Client, TokioPgConnection), error::Error> { + match self.connect_inner().await { + Ok(result) => Ok(result), + Err(e) => { + let err_str = e.to_string(); + if err_str.contains("password authentication failed for user") && err_str.contains("custom_instance_user") + { + if let Some(db) = main_db { + tracing::warn!( + "custom_instance_user password auth failed, refreshing and retrying..." + ); + crate::utils::refresh_custom_instance_user_pwd(db).await?; + let new_pwd = crate::utils::get_custom_pg_instance_password(db).await?; + let mut retried = self.clone(); + retried.password = Some(new_pwd); + return retried.connect_inner().await; + } + } + Err(e) + } + } + } + + async fn connect_inner( + &self, ) -> Result<(tokio_postgres::Client, TokioPgConnection), error::Error> { use native_tls::{Certificate, TlsConnector}; use postgres_native_tls::MakeTlsConnector; @@ -757,7 +783,7 @@ pub async fn create_custom_instance_database( // Grant permissions to custom_instance_user let wmill_pg_creds = PgDatabase::parse_uri(&get_database_url().await?.as_str().await)?; let new_pg_creds = PgDatabase { dbname: dbname.to_string(), ..wmill_pg_creds }; - let (client, connection) = new_pg_creds.connect().await?; + let (client, connection) = new_pg_creds.connect(Some(db)).await?; let join_handle = tokio::spawn(async move { connection.await }); if let Err(e) = client diff --git a/backend/windmill-common/src/schema.rs b/backend/windmill-common/src/schema.rs index 6c5d3cedd2..2116435de3 100644 --- a/backend/windmill-common/src/schema.rs +++ b/backend/windmill-common/src/schema.rs @@ -343,6 +343,81 @@ fn find_annotation(comm_lit: &str, annotation: &str, code: &str) -> bool { false } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CiTestedItem { + pub path: String, + pub kind: String, +} + +/// Parse a CI test annotation from the top of a script. +/// +/// Multi-line format: +/// ``` +/// // test: +/// // script/u/admin/my_script +/// // flow/u/admin/my_flow +/// // resource/u/admin/my_resource +/// ``` +/// +/// One-line format: +/// ``` +/// // test: script/u/admin/my_script +/// ``` +pub fn parse_ci_test_annotation(code: &str, comment_prefix: &str) -> Option> { + let test_marker = format!("{comment_prefix} test:"); + let mut lines = code.lines(); + + let first = lines.next()?; + let first_trimmed = first.trim_end(); + if !first_trimmed.starts_with(&test_marker) { + return None; + } + + let mut items = Vec::new(); + + // Check for one-line format: "// test: script/path" + let inline = first_trimmed[test_marker.len()..].trim(); + if !inline.is_empty() { + if let Some(item) = parse_ci_test_item(inline) { + items.push(item); + } + } + + for line in lines { + let Some(after_prefix) = line.strip_prefix(comment_prefix) else { + break; + }; + let stripped = after_prefix.trim(); + if stripped.is_empty() { + continue; + } + + if let Some(item) = parse_ci_test_item(stripped) { + items.push(item); + } else { + break; + } + } + + if items.is_empty() { + None + } else { + Some(items) + } +} + +fn parse_ci_test_item(s: &str) -> Option { + if let Some(path) = s.strip_prefix("script/") { + Some(CiTestedItem { path: path.trim().to_string(), kind: "script".to_string() }) + } else if let Some(path) = s.strip_prefix("flow/") { + Some(CiTestedItem { path: path.trim().to_string(), kind: "flow".to_string() }) + } else if let Some(path) = s.strip_prefix("resource/") { + Some(CiTestedItem { path: path.trim().to_string(), kind: "resource".to_string() }) + } else { + None + } +} + pub fn should_validate_schema(code: &str, lang: &ScriptLang) -> bool { let annotation = "schema_validation"; find_annotation(&lang.as_comment_lit(), annotation, code) diff --git a/backend/windmill-common/src/secret_backend/mod.rs b/backend/windmill-common/src/secret_backend/mod.rs index 2e0d6d0f21..4c9389cac9 100644 --- a/backend/windmill-common/src/secret_backend/mod.rs +++ b/backend/windmill-common/src/secret_backend/mod.rs @@ -17,10 +17,10 @@ pub mod database; #[cfg(feature = "private")] pub mod vault_ee; -pub mod vault_oss; #[cfg(feature = "private")] pub mod azure_kv_ee; pub mod azure_kv_oss; +pub mod vault_oss; #[cfg(feature = "private")] pub mod aws_sm_ee; @@ -129,6 +129,10 @@ pub struct VaultSettings { /// If provided, this is used instead of JWT authentication #[serde(skip_serializing_if = "Option::is_none")] pub token: Option, + /// Skip TLS certificate verification when connecting to Vault + /// Only use for self-signed certificates in development environments + #[serde(skip_serializing_if = "Option::is_none")] + pub skip_ssl_verify: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] diff --git a/backend/windmill-common/src/secret_backend/tests.rs b/backend/windmill-common/src/secret_backend/tests.rs index 2a6962f8dd..630b6cf023 100644 --- a/backend/windmill-common/src/secret_backend/tests.rs +++ b/backend/windmill-common/src/secret_backend/tests.rs @@ -28,6 +28,7 @@ mod tests { jwt_role: Some("windmill-secrets".to_string()), namespace: None, token: Some("test-root-token".to_string()), + skip_ssl_verify: None, } } diff --git a/backend/windmill-common/src/tracing_init.rs b/backend/windmill-common/src/tracing_init.rs index 1a2ca33518..3b009a36eb 100644 --- a/backend/windmill-common/src/tracing_init.rs +++ b/backend/windmill-common/src/tracing_init.rs @@ -273,4 +273,3 @@ where } } } - diff --git a/backend/windmill-common/src/utils.rs b/backend/windmill-common/src/utils.rs index 10b1a3b408..e35adc53d2 100644 --- a/backend/windmill-common/src/utils.rs +++ b/backend/windmill-common/src/utils.rs @@ -986,6 +986,38 @@ impl ExpiringCacheEntry { } } +pub async fn refresh_custom_instance_user_pwd(db: &DB) -> Result<()> { + let query = r#" + DO $$ + DECLARE + pwd text; + BEGIN + SELECT gen_random_uuid()::text INTO pwd; + + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'custom_instance_user') THEN + EXECUTE format('ALTER USER custom_instance_user WITH PASSWORD %L', pwd); + ELSE + EXECUTE format('CREATE USER custom_instance_user WITH PASSWORD %L', pwd); + END IF; + + IF NOT EXISTS (SELECT 1 FROM global_settings WHERE name = 'custom_instance_pg_databases') THEN + INSERT INTO global_settings (name, value) + VALUES ('custom_instance_pg_databases', jsonb_build_object( + 'user_pwd', pwd::text, + 'databases', jsonb_build_object() + )); + ELSE + UPDATE global_settings + SET value = jsonb_set(COALESCE(value, '{}'::jsonb), '{user_pwd}', to_jsonb(pwd::text)::jsonb) + WHERE name = 'custom_instance_pg_databases'; + END IF; + END + $$; + "#; + sqlx::query(query).execute(db).await?; + Ok(()) +} + pub async fn get_custom_pg_instance_password(db: &DB) -> Result { sqlx::query_scalar!( "SELECT value->>'user_pwd' FROM global_settings WHERE name = 'custom_instance_pg_databases';" diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index 4f1e1fbea2..ed51728bdd 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/28186/sync-script-to-git-repo-windmill"; +pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28191/sync-script-to-git-repo-windmill"; #[derive(Serialize, Deserialize, Debug)] pub struct GitRepositorySettings { diff --git a/backend/windmill-common/tests/secret_backend_integration.rs b/backend/windmill-common/tests/secret_backend_integration.rs index 817a8d0e65..542dba12b4 100644 --- a/backend/windmill-common/tests/secret_backend_integration.rs +++ b/backend/windmill-common/tests/secret_backend_integration.rs @@ -96,6 +96,7 @@ mod tests { token: Some( std::env::var("VAULT_TOKEN").unwrap_or_else(|_| "test-root-token".to_string()), ), + skip_ssl_verify: None, } } @@ -108,6 +109,7 @@ mod tests { jwt_role: Some("windmill-secrets".to_string()), // JWT mode namespace: None, token: None, // No static token - use JWT + skip_ssl_verify: None, } } diff --git a/backend/windmill-common/tests/secret_backend_migration.rs b/backend/windmill-common/tests/secret_backend_migration.rs index baa2940319..1915b90630 100644 --- a/backend/windmill-common/tests/secret_backend_migration.rs +++ b/backend/windmill-common/tests/secret_backend_migration.rs @@ -36,6 +36,7 @@ fn test_vault_settings() -> VaultSettings { token: Some( std::env::var("VAULT_TOKEN").unwrap_or_else(|_| "test-root-token".to_string()), ), + skip_ssl_verify: None, } } diff --git a/backend/windmill-dep-map/Cargo.toml b/backend/windmill-dep-map/Cargo.toml index ae81f5d1a7..28b8552e8e 100644 --- a/backend/windmill-dep-map/Cargo.toml +++ b/backend/windmill-dep-map/Cargo.toml @@ -11,6 +11,7 @@ path = "src/lib.rs" [features] default = [] python = ["dep:windmill-parser-py-imports"] +private = [] [dependencies] windmill-common = { workspace = true, default-features = false } diff --git a/backend/windmill-dep-map/src/ci_tests.rs b/backend/windmill-dep-map/src/ci_tests.rs new file mode 100644 index 0000000000..79f1b7e30b --- /dev/null +++ b/backend/windmill-dep-map/src/ci_tests.rs @@ -0,0 +1,21 @@ +#[cfg(feature = "private")] +#[allow(unused)] +pub use crate::ci_tests_ee::*; + +#[cfg(not(feature = "private"))] +use uuid::Uuid; + +#[cfg(not(feature = "private"))] +use windmill_common::error; + +#[cfg(not(feature = "private"))] +pub async fn trigger_ci_tests_for_item( + _db: &sqlx::Pool, + _w_id: &str, + _item_path: &str, + _item_kind: &str, + _email: &str, + _username: &str, +) -> error::Result> { + Ok(vec![]) +} diff --git a/backend/windmill-dep-map/src/lib.rs b/backend/windmill-dep-map/src/lib.rs index 39f2413bf7..ee6a3c3fd4 100644 --- a/backend/windmill-dep-map/src/lib.rs +++ b/backend/windmill-dep-map/src/lib.rs @@ -1,3 +1,6 @@ +pub mod ci_tests; +#[cfg(feature = "private")] +pub mod ci_tests_ee; pub mod scoped_dependency_map; pub mod trigger_dependents; pub mod workspace_dependencies; @@ -88,7 +91,13 @@ pub fn extract_referenced_paths( ) -> Option> { let mut referenced_paths = vec![]; if let Some(wk_deps_refs) = language - .and_then(|l| windmill_common::scripts::extract_workspace_dependencies_annotated_refs(&l, raw_code, script_path)) + .and_then(|l| { + windmill_common::scripts::extract_workspace_dependencies_annotated_refs( + &l, + raw_code, + script_path, + ) + }) .map(|r| r.external) { let l = language.expect("should be some"); diff --git a/backend/windmill-store/Cargo.toml b/backend/windmill-store/Cargo.toml index 3eddd95a39..9fee538ef6 100644 --- a/backend/windmill-store/Cargo.toml +++ b/backend/windmill-store/Cargo.toml @@ -29,6 +29,7 @@ openidconnect = ["windmill-common/openidconnect"] windmill-common = { workspace = true, default-features = false } windmill-api-auth.workspace = true windmill-audit.workspace = true +windmill-dep-map.workspace = true windmill-git-sync.workspace = true windmill-mcp = { workspace = true, optional = true } windmill-oauth = { workspace = true, optional = true } diff --git a/backend/windmill-store/src/resources.rs b/backend/windmill-store/src/resources.rs index 94617f85ec..1a716fff7f 100644 --- a/backend/windmill-store/src/resources.rs +++ b/backend/windmill-store/src/resources.rs @@ -1286,6 +1286,16 @@ async fn update_resource( .execute(&mut *tx) .await?; + // Update ci_test_reference when a tested resource is renamed + sqlx::query!( + "UPDATE ci_test_reference SET tested_item_path = $1 WHERE tested_item_path = $2 AND workspace_id = $3 AND tested_item_kind = 'resource'", + npath, + path, + w_id + ) + .execute(&mut *tx) + .await?; + sqlx::query!( "UPDATE ws_specific SET path = $1 WHERE workspace_id = $2 AND item_kind = 'resource' AND path = $3", npath, @@ -1343,12 +1353,29 @@ async fn update_resource( webhook.send_message( w_id.clone(), WebhookMessage::UpdateResource { - workspace: w_id, + workspace: w_id.clone(), old_path: path.to_owned(), new_path: npath.clone(), }, ); + // Trigger CI tests for items that reference this resource + { + let db2 = db.clone(); + let npath2 = npath.clone(); + let email2 = authed.email.clone(); + let username2 = authed.username.clone(); + tokio::spawn(async move { + if let Err(e) = windmill_dep_map::ci_tests::trigger_ci_tests_for_item( + &db2, &w_id, &npath2, "resource", &email2, &username2, + ) + .await + { + tracing::error!(%e, "error triggering CI tests after resource update"); + } + }); + } + Ok(format!("resource {} updated (npath: {:?})", path, npath)) } @@ -1416,12 +1443,29 @@ async fn update_resource_value( webhook.send_message( w_id.clone(), WebhookMessage::UpdateResource { - workspace: w_id, + workspace: w_id.clone(), old_path: path.to_owned(), new_path: path.to_owned(), }, ); + // Trigger CI tests for items that reference this resource + { + let db2 = db.clone(); + let path2 = path.to_string(); + let email2 = authed.email.clone(); + let username2 = authed.username.clone(); + tokio::spawn(async move { + if let Err(e) = windmill_dep_map::ci_tests::trigger_ci_tests_for_item( + &db2, &w_id, &path2, "resource", &email2, &username2, + ) + .await + { + tracing::error!(%e, "error triggering CI tests after resource value update"); + } + }); + } + Ok(format!("value of resource {} updated", path)) } diff --git a/backend/windmill-types/src/jobs.rs b/backend/windmill-types/src/jobs.rs index 1eed946ad8..9261302272 100644 --- a/backend/windmill-types/src/jobs.rs +++ b/backend/windmill-types/src/jobs.rs @@ -38,6 +38,9 @@ pub enum JobTriggerKind { Gcp, Nextcloud, Google, + #[serde(rename = "ci_test")] + #[sqlx(rename = "ci_test")] + CiTest, } impl std::fmt::Display for JobTriggerKind { @@ -56,6 +59,7 @@ impl std::fmt::Display for JobTriggerKind { JobTriggerKind::Gcp => "gcp", JobTriggerKind::Nextcloud => "nextcloud", JobTriggerKind::Google => "google", + JobTriggerKind::CiTest => "ci_test", }; write!(f, "{}", kind) } diff --git a/backend/windmill-worker/Cargo.toml b/backend/windmill-worker/Cargo.toml index 62cb9de4bb..c436277ee1 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", "windmill-common/private"] +private = ["windmill-worker-volumes/private", "windmill-queue/private", "windmill-common/private", "windmill-dep-map/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/bash_executor.rs b/backend/windmill-worker/src/bash_executor.rs index 011a72fe15..31254cf0cf 100644 --- a/backend/windmill-worker/src/bash_executor.rs +++ b/backend/windmill-worker/src/bash_executor.rs @@ -231,8 +231,14 @@ exit $exit_status .env_clear() .envs(reserved_variables) .envs( - get_proxy_envs_for_lang(&ScriptLang::Bash, &job.id, &job.workspace_id, conn) - .await?, + get_proxy_envs_for_lang( + &ScriptLang::Bash, + job.kind, + &job.id, + &job.workspace_id, + conn, + ) + .await?, ) .env("PATH", PATH_ENV.as_str()) .env("BASE_INTERNAL_URL", base_internal_url) @@ -261,8 +267,14 @@ exit $exit_status .envs(envs) .envs(reserved_variables) .envs( - get_proxy_envs_for_lang(&ScriptLang::Bash, &job.id, &job.workspace_id, conn) - .await?, + get_proxy_envs_for_lang( + &ScriptLang::Bash, + job.kind, + &job.id, + &job.workspace_id, + conn, + ) + .await?, ) .env("PATH", PATH_ENV.as_str()) .env("BASE_INTERNAL_URL", base_internal_url) diff --git a/backend/windmill-worker/src/bun_executor.rs b/backend/windmill-worker/src/bun_executor.rs index 7e2d85f745..0e44cc4750 100644 --- a/backend/windmill-worker/src/bun_executor.rs +++ b/backend/windmill-worker/src/bun_executor.rs @@ -2134,7 +2134,14 @@ try {{ .envs(envs) .envs(reserved_variables) .envs( - get_proxy_envs_for_lang(&ScriptLang::Bun, &job.id, &job.workspace_id, conn).await?, + get_proxy_envs_for_lang( + &ScriptLang::Bun, + job.kind, + &job.id, + &job.workspace_id, + conn, + ) + .await?, ) .envs(common_bun_proc_envs) .env("PATH", PATH_ENV.as_str()) @@ -2154,8 +2161,14 @@ try {{ .envs(envs) .envs(reserved_variables) .envs( - get_proxy_envs_for_lang(&ScriptLang::Bun, &job.id, &job.workspace_id, conn) - .await?, + get_proxy_envs_for_lang( + &ScriptLang::Bun, + job.kind, + &job.id, + &job.workspace_id, + conn, + ) + .await?, ) .envs(common_bun_proc_envs) .stdin(Stdio::null()) @@ -2188,8 +2201,14 @@ try {{ .envs(envs) .envs(reserved_variables) .envs( - get_proxy_envs_for_lang(&ScriptLang::Bun, &job.id, &job.workspace_id, conn) - .await?, + get_proxy_envs_for_lang( + &ScriptLang::Bun, + job.kind, + &job.id, + &job.workspace_id, + conn, + ) + .await?, ) .envs(common_bun_proc_envs) .stdin(Stdio::null()) @@ -3574,6 +3593,7 @@ pub async fn start_worker( jobs_rx: Receiver, killpill_rx: tokio::sync::broadcast::Receiver<()>, client: windmill_common::client::AuthedClient, + concurrency_semaphore: Option>, ) -> Result<()> { let mut logs = "".to_string(); let mut mem_peak: i32 = 0; @@ -3841,6 +3861,7 @@ pub async fn start_worker( "nodejs", client, false, + concurrency_semaphore, ) .await } else { @@ -3869,6 +3890,7 @@ pub async fn start_worker( "bun", client, false, + concurrency_semaphore, ) .await } diff --git a/backend/windmill-worker/src/csharp_executor.rs b/backend/windmill-worker/src/csharp_executor.rs index e6f484a8ac..e00b8ac5da 100644 --- a/backend/windmill-worker/src/csharp_executor.rs +++ b/backend/windmill-worker/src/csharp_executor.rs @@ -616,8 +616,14 @@ pub async fn handle_csharp_job( .envs(envs) .envs(reserved_variables) .envs( - get_proxy_envs_for_lang(&ScriptLang::CSharp, &job.id, &job.workspace_id, conn) - .await?, + get_proxy_envs_for_lang( + &ScriptLang::CSharp, + job.kind, + &job.id, + &job.workspace_id, + conn, + ) + .await?, ) .env("PATH", PATH_ENV.as_str()) .env("TZ", TZ_ENV.as_str()) @@ -652,8 +658,14 @@ pub async fn handle_csharp_job( .envs(envs) .envs(reserved_variables) .envs( - get_proxy_envs_for_lang(&ScriptLang::CSharp, &job.id, &job.workspace_id, conn) - .await?, + get_proxy_envs_for_lang( + &ScriptLang::CSharp, + job.kind, + &job.id, + &job.workspace_id, + conn, + ) + .await?, ) .env("PATH", PATH_ENV.as_str()) .env("TZ", TZ_ENV.as_str()) diff --git a/backend/windmill-worker/src/deno_executor.rs b/backend/windmill-worker/src/deno_executor.rs index e48a6028ef..207cf5aba4 100644 --- a/backend/windmill-worker/src/deno_executor.rs +++ b/backend/windmill-worker/src/deno_executor.rs @@ -20,7 +20,9 @@ use windmill_common::client::AuthedClient; use windmill_common::worker::TypeScriptAnnotations; use tokio::{fs::File, io::AsyncReadExt, process::Command}; -use windmill_common::{error::Result, scripts::ScriptLang, worker::write_file, BASE_URL}; +use windmill_common::{ + error::Result, jobs::JobKind, scripts::ScriptLang, worker::write_file, BASE_URL, +}; use windmill_common::{ error::{self}, worker::Connection, @@ -66,6 +68,7 @@ lazy_static::lazy_static! { async fn get_common_deno_proc_envs( token: &str, base_internal_url: &str, + job_kind: JobKind, job_id: &Uuid, w_id: &str, conn: Option<&Connection>, @@ -141,7 +144,7 @@ async fn get_common_deno_proc_envs( // Add proxy envs (including OTEL tracing proxy if enabled for deno) if let Some(conn) = conn { - for (k, v) in get_proxy_envs_for_lang(&ScriptLang::Deno, job_id, w_id, conn) + for (k, v) in get_proxy_envs_for_lang(&ScriptLang::Deno, job_kind, job_id, w_id, conn) .await .unwrap_or_default() { @@ -177,7 +180,8 @@ pub async fn generate_deno_lock( write_file(job_dir, "import_map.json", &import_map)?; write_file(job_dir, "empty.ts", "")?; - let deno_envs = get_common_deno_proc_envs("", base_internal_url, job_id, w_id, db).await; + let deno_envs = + get_common_deno_proc_envs("", base_internal_url, JobKind::Script, job_id, w_id, db).await; let mut child_cmd = Command::new(DENO_PATH.as_str()); child_cmd @@ -408,6 +412,7 @@ try {{ let mut common_deno_proc_envs = get_common_deno_proc_envs( &client.token, base_internal_url, + job.kind, &job.id, &job.workspace_id, Some(conn), @@ -722,6 +727,7 @@ pub async fn start_worker( killpill_rx: tokio::sync::broadcast::Receiver<()>, db: &sqlx::Pool, client: windmill_common::client::AuthedClient, + concurrency_semaphore: Option>, ) -> Result<()> { use windmill_common::variables; @@ -731,6 +737,7 @@ pub async fn start_worker( let common_deno_proc_envs = get_common_deno_proc_envs( &token, base_internal_url, + JobKind::Script, &Uuid::nil(), w_id, Some(&db.into()), @@ -791,6 +798,7 @@ pub async fn start_worker( "deno", client, false, + concurrency_semaphore, ) .await } diff --git a/backend/windmill-worker/src/go_executor.rs b/backend/windmill-worker/src/go_executor.rs index d57b757e40..10639e5d5d 100644 --- a/backend/windmill-worker/src/go_executor.rs +++ b/backend/windmill-worker/src/go_executor.rs @@ -358,7 +358,16 @@ func Run(req Req) (interface{{}}, error){{ .env_clear() .envs(envs) .envs(reserved_variables) - .envs(get_proxy_envs_for_lang(&ScriptLang::Go, &job.id, &job.workspace_id, conn).await?) + .envs( + get_proxy_envs_for_lang( + &ScriptLang::Go, + job.kind, + &job.id, + &job.workspace_id, + conn, + ) + .await?, + ) .env("PATH", PATH_ENV.as_str()) .env("TZ", TZ_ENV.as_str()) .env("BASE_INTERNAL_URL", base_internal_url) @@ -379,7 +388,16 @@ func Run(req Req) (interface{{}}, error){{ .env_clear() .envs(envs) .envs(reserved_variables) - .envs(get_proxy_envs_for_lang(&ScriptLang::Go, &job.id, &job.workspace_id, conn).await?) + .envs( + get_proxy_envs_for_lang( + &ScriptLang::Go, + job.kind, + &job.id, + &job.workspace_id, + conn, + ) + .await?, + ) .env("PATH", PATH_ENV.as_str()) .env("TZ", TZ_ENV.as_str()) .env("BASE_INTERNAL_URL", base_internal_url) diff --git a/backend/windmill-worker/src/nu_executor.rs b/backend/windmill-worker/src/nu_executor.rs index 8a6adf4a80..c79f8ba968 100644 --- a/backend/windmill-worker/src/nu_executor.rs +++ b/backend/windmill-worker/src/nu_executor.rs @@ -268,7 +268,16 @@ async fn run<'a>( .env("BASE_INTERNAL_URL", base_internal_url) .envs(envs) .envs(reserved_variables) - .envs(get_proxy_envs_for_lang(&ScriptLang::Nu, &job.id, &job.workspace_id, conn).await?) + .envs( + get_proxy_envs_for_lang( + &ScriptLang::Nu, + job.kind, + &job.id, + &job.workspace_id, + conn, + ) + .await?, + ) .args(vec![ "--config", "run.config.proto", @@ -307,7 +316,16 @@ async fn run<'a>( .env("BASE_INTERNAL_URL", base_internal_url) .envs(envs) .envs(reserved_variables) - .envs(get_proxy_envs_for_lang(&ScriptLang::Nu, &job.id, &job.workspace_id, conn).await?) + .envs( + get_proxy_envs_for_lang( + &ScriptLang::Nu, + job.kind, + &job.id, + &job.workspace_id, + conn, + ) + .await?, + ) // TODO(v1): // "--plugins", // &format!( diff --git a/backend/windmill-worker/src/pg_executor.rs b/backend/windmill-worker/src/pg_executor.rs index 2e29e4ef89..3a70db5ecb 100644 --- a/backend/windmill-worker/src/pg_executor.rs +++ b/backend/windmill-worker/src/pg_executor.rs @@ -28,7 +28,7 @@ use windmill_common::worker::{ to_raw_value, Connection, SqlResultCollectionStrategy, CLOUD_HOSTED, }; use windmill_common::workspaces::get_datatable_resource_from_db_unchecked; -use windmill_common::{PgDatabase, PrepareQueryColumnInfo, PrepareQueryResult}; +use windmill_common::{PgDatabase, PrepareQueryColumnInfo, PrepareQueryResult, DB}; use windmill_object_store::convert_json_line_stream; use windmill_parser::{Arg, Typ}; use windmill_parser_sql::{ @@ -67,6 +67,7 @@ pub async fn clear_pg_cache() { async fn new_pg_connection( database: &PgDatabase, _use_iam_auth: bool, + main_db: Option<&DB>, ) -> error::Result<(tokio_postgres::Client, tokio::task::JoinHandle<()>)> { let (client, connection) = if _use_iam_auth { #[cfg(all(feature = "enterprise", feature = "private"))] @@ -80,7 +81,7 @@ async fn new_pg_connection( )); } } else { - database.connect().await? + database.connect(main_db).await? }; let handle = tokio::spawn(async move { if let Err(e) = connection.await { @@ -361,18 +362,18 @@ pub async fn do_postgresql( } drop(guard); cached_client = None; - new_client = Some(new_pg_connection(&database, use_iam_auth).await?); + new_client = Some(new_pg_connection(&database, use_iam_auth, conn.as_sql()).await?); } } else { // Release the lock before connecting so the post-query caching // code can re-acquire it. drop(guard); cached_client = None; - new_client = Some(new_pg_connection(&database, use_iam_auth).await?); + new_client = Some(new_pg_connection(&database, use_iam_auth, conn.as_sql()).await?); } } else { cached_client = None; - new_client = Some(new_pg_connection(&database, use_iam_auth).await?); + new_client = Some(new_pg_connection(&database, use_iam_auth, conn.as_sql()).await?); } let (sig, typed_schema) = parse_pgsql_sig_with_typed_schema(&query) diff --git a/backend/windmill-worker/src/python_executor.rs b/backend/windmill-worker/src/python_executor.rs index 453b4e986c..e00ae24905 100644 --- a/backend/windmill-worker/src/python_executor.rs +++ b/backend/windmill-worker/src/python_executor.rs @@ -957,8 +957,14 @@ mount {{ // inject PYTHONPATH here - for some reason I had to do it in nsjail conf .envs(reserved_variables) .envs( - get_proxy_envs_for_lang(&ScriptLang::Python3, &job.id, &job.workspace_id, conn) - .await?, + get_proxy_envs_for_lang( + &ScriptLang::Python3, + job.kind, + &job.id, + &job.workspace_id, + conn, + ) + .await?, ) .env("PATH", PATH_ENV.as_str()) .env("TZ", TZ_ENV.as_str()) @@ -986,8 +992,14 @@ mount {{ .envs(envs) .envs(reserved_variables) .envs( - get_proxy_envs_for_lang(&ScriptLang::Python3, &job.id, &job.workspace_id, conn) - .await?, + get_proxy_envs_for_lang( + &ScriptLang::Python3, + job.kind, + &job.id, + &job.workspace_id, + conn, + ) + .await?, ) .env("PATH", PATH_ENV.as_str()) .env("TZ", TZ_ENV.as_str()) @@ -2780,6 +2792,7 @@ pub async fn start_worker( jobs_rx: tokio::sync::mpsc::Receiver, killpill_rx: tokio::sync::broadcast::Receiver<()>, client: windmill_common::client::AuthedClient, + concurrency_semaphore: Option>, ) -> error::Result<()> { use crate::PyV; tracing::info!("script path: {}", script_path); @@ -2922,6 +2935,7 @@ pub async fn start_worker( "python", client, false, + concurrency_semaphore, ) .await } diff --git a/backend/windmill-worker/src/r_executor.rs b/backend/windmill-worker/src/r_executor.rs index fa8a1809b1..70e188dc46 100644 --- a/backend/windmill-worker/src/r_executor.rs +++ b/backend/windmill-worker/src/r_executor.rs @@ -591,8 +591,14 @@ async fn run<'a>( .envs(reserved_variables) .envs(R_PROXY_ENVS.clone()) .envs( - get_proxy_envs_for_lang(&ScriptLang::Rlang, &job.id, &job.workspace_id, conn) - .await?, + get_proxy_envs_for_lang( + &ScriptLang::Rlang, + job.kind, + &job.id, + &job.workspace_id, + conn, + ) + .await?, ); if let Some(lp) = lib_path { cmd.env("R_LIBS_USER", r_libs_user(lp)); @@ -632,8 +638,14 @@ async fn run<'a>( .envs(reserved_variables) .envs(R_PROXY_ENVS.clone()) .envs( - get_proxy_envs_for_lang(&ScriptLang::Rlang, &job.id, &job.workspace_id, conn) - .await?, + get_proxy_envs_for_lang( + &ScriptLang::Rlang, + job.kind, + &job.id, + &job.workspace_id, + conn, + ) + .await?, ) .envs(envs); if let Some(lp) = lib_path { diff --git a/backend/windmill-worker/src/ruby_executor.rs b/backend/windmill-worker/src/ruby_executor.rs index d94864766b..aff842eb9f 100644 --- a/backend/windmill-worker/src/ruby_executor.rs +++ b/backend/windmill-worker/src/ruby_executor.rs @@ -29,7 +29,9 @@ use crate::{ get_proxy_envs_for_lang, handle_child::{self}, is_sandboxing_enabled, read_ee_registry_url_list_with_workspace_override, - universal_pkg_installer::{par_install_language_dependencies_seq, InstallDeps, RequiredDependency}, + universal_pkg_installer::{ + par_install_language_dependencies_seq, InstallDeps, RequiredDependency, + }, DISABLE_NUSER, NSJAIL_PATH, PATH_ENV, PROXY_ENVS, RUBY_CACHE_DIR, RUBY_REPOS, TRACING_PROXY_CA_CERT_PATH, }; @@ -820,8 +822,14 @@ mount {{ .envs(reserved_variables) .envs(RUBY_PROXY_ENVS.clone()) .envs( - get_proxy_envs_for_lang(&ScriptLang::Ruby, &job.id, &job.workspace_id, conn) - .await?, + get_proxy_envs_for_lang( + &ScriptLang::Ruby, + job.kind, + &job.id, + &job.workspace_id, + conn, + ) + .await?, ) .args(vec![ "--config", @@ -862,8 +870,14 @@ mount {{ .envs(reserved_variables) .envs(RUBY_PROXY_ENVS.clone()) .envs( - get_proxy_envs_for_lang(&ScriptLang::Ruby, &job.id, &job.workspace_id, conn) - .await?, + get_proxy_envs_for_lang( + &ScriptLang::Ruby, + job.kind, + &job.id, + &job.workspace_id, + conn, + ) + .await?, ) .envs(envs); diff --git a/backend/windmill-worker/src/rust_executor.rs b/backend/windmill-worker/src/rust_executor.rs index b65407bc6d..96470f20d9 100644 --- a/backend/windmill-worker/src/rust_executor.rs +++ b/backend/windmill-worker/src/rust_executor.rs @@ -708,8 +708,14 @@ pub async fn handle_rust_job( .envs(envs) .envs(reserved_variables) .envs( - get_proxy_envs_for_lang(&ScriptLang::Rust, &job.id, &job.workspace_id, conn) - .await?, + get_proxy_envs_for_lang( + &ScriptLang::Rust, + job.kind, + &job.id, + &job.workspace_id, + conn, + ) + .await?, ) .env("PATH", PATH_ENV.as_str()) .env("TZ", TZ_ENV.as_str()) @@ -727,8 +733,14 @@ pub async fn handle_rust_job( .envs(envs) .envs(reserved_variables) .envs( - get_proxy_envs_for_lang(&ScriptLang::Rust, &job.id, &job.workspace_id, conn) - .await?, + get_proxy_envs_for_lang( + &ScriptLang::Rust, + job.kind, + &job.id, + &job.workspace_id, + conn, + ) + .await?, ) .env("PATH", PATH_ENV.as_str()) .env("TZ", TZ_ENV.as_str()) diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index 67b3580a40..21c4648569 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -769,7 +769,11 @@ pub async fn read_ee_registry_with_workspace_override( _ => None, }) }; - let value = ws_value.or(global_value); + // An empty/whitespace-only value (from either source) means "unset" — a + // workspace override of `""` still takes precedence over the global + // value, but neither triggers the spurious "requires Enterprise" warning + // on CE jobs. + let value = ws_value.or(global_value).filter(|s| !s.trim().is_empty()); read_ee_registry(value, display_name, job_id, w_id, conn).await } @@ -912,8 +916,14 @@ pub fn get_otel_context_envs(job_id: &uuid::Uuid) -> Vec<(&'static str, String)> /// Get proxy environment variables for job execution for a specific language. /// When OTEL tracing proxy is enabled for this language, routes all traffic through the proxy. /// Otherwise, uses the standard HTTP_PROXY/HTTPS_PROXY from environment. +/// +/// Deployment callback jobs (git sync) always bypass the MITM tracing proxy and use the +/// stock corporate proxy. Routing git's HTTPS through the local MITM breaks TLS for +/// GitHub/GitLab in chained-upstream-proxy setups, and we don't need HTTP spans for the +/// system git sync script anyway. pub async fn get_proxy_envs_for_lang( lang: &ScriptLang, + job_kind: JobKind, job_id: &uuid::Uuid, w_id: &str, conn: &Connection, @@ -921,14 +931,16 @@ pub async fn get_proxy_envs_for_lang( #[allow(unused_mut)] let mut envs; #[cfg(all(feature = "private", feature = "enterprise"))] - if is_otel_tracing_proxy_enabled_for_lang(lang).await { + if !matches!(job_kind, JobKind::DeploymentCallback) + && is_otel_tracing_proxy_enabled_for_lang(lang).await + { envs = get_otel_tracing_proxy_envs(job_id, w_id, conn).await?; } else { envs = PROXY_ENVS.clone(); } #[cfg(not(all(feature = "private", feature = "enterprise")))] { - let _ = (lang, w_id, conn); + let _ = (lang, job_kind, w_id, conn); envs = PROXY_ENVS.clone(); } envs.extend(get_otel_context_envs(job_id)); diff --git a/backend/windmill-worker/src/worker_lockfiles.rs b/backend/windmill-worker/src/worker_lockfiles.rs index 670c593dcf..4275bb7014 100644 --- a/backend/windmill-worker/src/worker_lockfiles.rs +++ b/backend/windmill-worker/src/worker_lockfiles.rs @@ -303,6 +303,33 @@ pub async fn handle_dependency_job( ) .await?; + // Trigger CI tests for items that reference this script + tracing::debug!( + "CI test trigger: checking for tests referencing script {}", + script_path + ); + { + let db2 = db.clone(); + let w_id2 = w_id.to_string(); + let script_path2 = script_path.to_string(); + let email2 = job.permissioned_as_email.clone(); + let username2 = job.created_by.clone(); + tokio::spawn(async move { + if let Err(e) = windmill_dep_map::ci_tests::trigger_ci_tests_for_item( + &db2, + &w_id2, + &script_path2, + "script", + &email2, + &username2, + ) + .await + { + tracing::error!(%e, "error triggering CI tests after script lock generation"); + } + }); + } + Ok(to_raw_value_owned( json!({ "status": "Successful lock file generation", "lock": content }), )) diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 67009ca9df..38cd31c1e7 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.680.0"; +export const VERSION = "v1.682.0"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/src/commands/config/config.ts b/cli/src/commands/config/config.ts index 500e65a113..0d9d4b6fa3 100644 --- a/cli/src/commands/config/config.ts +++ b/cli/src/commands/config/config.ts @@ -1,9 +1,17 @@ import { Command } from "@cliffy/command"; import * as log from "../../core/log.ts"; +import { colors } from "@cliffy/ansi/colors"; +import { readFile, writeFile } from "node:fs/promises"; +import { stringify as yamlStringify } from "yaml"; import { formatConfigReference, formatConfigReferenceJson, } from "../init/template.ts"; +import { + getWmillYamlPath, + convertGitBranchesToWorkspaces, +} from "../../core/conf.ts"; +import { yamlParseFile } from "../../utils/yaml.ts"; interface ConfigOptions { json?: boolean; @@ -17,10 +25,90 @@ async function configAction(opts: ConfigOptions) { } } +async function migrateAction() { + const wmillYamlPath = getWmillYamlPath(); + if (!wmillYamlPath) { + log.error("No wmill.yaml found. Nothing to migrate."); + return; + } + + const conf = (await yamlParseFile(wmillYamlPath)) as Record; + if (!conf) { + log.error("wmill.yaml is empty. Nothing to migrate."); + return; + } + + // Collect all legacy keys present + const legacyKeys: string[] = []; + for (const key of ["gitBranches", "environments", "git_branches"]) { + if (key in conf && conf[key]) { + legacyKeys.push(key); + } + } + + // If no legacy keys, check if already migrated + if (legacyKeys.length === 0) { + if ("workspaces" in conf) { + log.info("Already using 'workspaces' format. No migration needed."); + } else { + log.info( + "No gitBranches/environments/git_branches found. Nothing to migrate." + ); + } + return; + } + + // Use the first legacy key found (priority order) + const legacyKey = legacyKeys[0]; + const legacyData = conf[legacyKey]; + + // Convert + const workspaces = convertGitBranchesToWorkspaces(legacyData); + const wsNames = Object.keys(workspaces).filter( + (k) => k !== "commonSpecificItems" + ); + + // Build new config: remove ALL legacy keys, add/merge workspaces + const newConf = { ...conf }; + for (const key of legacyKeys) { + delete newConf[key]; + } + if (newConf.workspaces) { + // Merge: legacy entries into existing workspaces (don't overwrite existing) + for (const [name, entry] of Object.entries(workspaces)) { + if (!(newConf.workspaces as any)[name]) { + (newConf.workspaces as any)[name] = entry; + } + } + } else { + newConf.workspaces = workspaces; + } + + await writeFile(wmillYamlPath, yamlStringify(newConf), "utf-8"); + log.info( + colors.green( + `✅ Migrated '${legacyKey}' to 'workspaces' in ${wmillYamlPath}` + ) + ); + if (wsNames.length > 0) { + log.info(` Workspace entries: ${wsNames.join(", ")}`); + } + if (legacyKeys.length > 1) { + log.info( + ` Also removed additional legacy keys: ${legacyKeys.slice(1).join(", ")}` + ); + } +} + 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); + .action(configAction as any) + .command("migrate") + .description( + "Migrate wmill.yaml from gitBranches/environments to workspaces format" + ) + .action(migrateAction as any); export default command; diff --git a/cli/src/commands/gitsync-settings/pull.ts b/cli/src/commands/gitsync-settings/pull.ts index bea37743a4..910ba17942 100644 --- a/cli/src/commands/gitsync-settings/pull.ts +++ b/cli/src/commands/gitsync-settings/pull.ts @@ -6,7 +6,7 @@ import { GlobalOptions } from "../../types.ts"; import { requireLogin } from "../../core/auth.ts"; import { resolveWorkspace } from "../../core/context.ts"; import * as wmill from "../../../gen/services.gen.ts"; -import { SyncOptions, readConfigFile, getEffectiveSettings, DEFAULT_SYNC_OPTIONS, getWmillYamlPath } from "../../core/conf.ts"; +import { SyncOptions, readConfigFile, getEffectiveSettings, DEFAULT_SYNC_OPTIONS, getWmillYamlPath, findWorkspaceByGitBranch, WorkspacesConfig } from "../../core/conf.ts"; import { yamlOptions } from "../sync/sync.ts"; import { deepEqual } from "../../utils/utils.ts"; import { getCurrentGitBranch, isGitRepository } from "../../utils/git.ts"; @@ -167,11 +167,11 @@ export async function pullGitSyncSettings( if (isGitRepository()) { const currentBranch = getCurrentGitBranch(); if (currentBranch) { - if (!updatedConfig.gitBranches) { - updatedConfig.gitBranches = {}; + if (!updatedConfig.workspaces) { + updatedConfig.workspaces = {}; } - if (!updatedConfig.gitBranches[currentBranch]) { - updatedConfig.gitBranches[currentBranch] = { overrides: {} }; + if (!updatedConfig.workspaces[currentBranch]) { + updatedConfig.workspaces[currentBranch] = {}; } } } @@ -360,16 +360,16 @@ export async function pullGitSyncSettings( let needsBranchStructure = false; if (isGitRepository()) { const currentBranch = getCurrentGitBranch(); - if (currentBranch && (!localConfig.gitBranches || !localConfig.gitBranches[currentBranch])) { + if (currentBranch && (!localConfig.workspaces || !findWorkspaceByGitBranch(localConfig.workspaces, currentBranch))) { needsBranchStructure = true; // Create empty branch structure const updatedConfig = { ...localConfig }; - if (!updatedConfig.gitBranches) { - updatedConfig.gitBranches = {}; + if (!updatedConfig.workspaces) { + updatedConfig.workspaces = {}; } - if (!updatedConfig.gitBranches[currentBranch]) { - updatedConfig.gitBranches[currentBranch] = { overrides: {} }; + if (!updatedConfig.workspaces[currentBranch]) { + updatedConfig.workspaces[currentBranch] = {}; } // Write updated configuration @@ -431,11 +431,11 @@ export async function pullGitSyncSettings( const currentBranch = getCurrentGitBranch(); if (currentBranch) { log.info(`Detected Git repository, adding empty branch structure for: ${currentBranch}`); - if (!updatedConfig.gitBranches) { - updatedConfig.gitBranches = {}; + if (!updatedConfig.workspaces) { + updatedConfig.workspaces = {}; } - if (!updatedConfig.gitBranches[currentBranch]) { - updatedConfig.gitBranches[currentBranch] = { overrides: {} }; + if (!updatedConfig.workspaces[currentBranch]) { + updatedConfig.workspaces[currentBranch] = {}; } } } diff --git a/cli/src/commands/gitsync-settings/utils.ts b/cli/src/commands/gitsync-settings/utils.ts index 8d255d9f92..3119f2bcda 100644 --- a/cli/src/commands/gitsync-settings/utils.ts +++ b/cli/src/commands/gitsync-settings/utils.ts @@ -26,17 +26,17 @@ export function normalizeRepoPath(path: string): string { return path.replace(/^\$res:/, ""); } -// Helper to get or create branch configuration +// Helper to get or create workspace configuration entry for a branch export function getOrCreateBranchConfig(config: SyncOptions, branchName: string): { config: SyncOptions; branchKey: string; } { - if (!config.gitBranches) { - config.gitBranches = {}; + if (!config.workspaces) { + config.workspaces = {} as any; } - if (!config.gitBranches[branchName]) { - config.gitBranches[branchName] = {}; + if (!(config.workspaces as any)[branchName]) { + (config.workspaces as any)[branchName] = {}; } return { @@ -54,12 +54,12 @@ export function applyBackendSettingsToBranch( const { config: updatedConfig } = getOrCreateBranchConfig(config, branchName); // Get the base settings (top-level + defaults) to compare against - const { gitBranches, ...topLevelSettings } = config; + const { workspaces, ...topLevelSettings } = config; const baseSettings: Partial = { ...DEFAULT_SYNC_OPTIONS, ...topLevelSettings }; // Only store fields that differ from the base settings Object.keys(backendSettings).forEach(key => { - if (key !== 'gitBranches' && backendSettings[key as keyof SyncOptions] !== undefined) { + if (key !== 'workspaces' && key !== 'gitBranches' && backendSettings[key as keyof SyncOptions] !== undefined) { const backendValue = backendSettings[key as keyof SyncOptions]; const baseValue = baseSettings[key as keyof SyncOptions]; @@ -67,10 +67,10 @@ export function applyBackendSettingsToBranch( const isDifferent = GitSyncSettingsConverter.isDifferent(backendValue, baseValue); if (isDifferent) { - if (!updatedConfig.gitBranches![branchName].overrides) { - updatedConfig.gitBranches![branchName].overrides = {}; + if (!(updatedConfig.workspaces as any)![branchName].overrides) { + (updatedConfig.workspaces as any)![branchName].overrides = {}; } - (updatedConfig.gitBranches![branchName].overrides as any)[key] = backendValue; + ((updatedConfig.workspaces as any)![branchName].overrides as any)[key] = backendValue; } } }); diff --git a/cli/src/commands/init/init.ts b/cli/src/commands/init/init.ts index db4950575b..345eda8e77 100644 --- a/cli/src/commands/init/init.ts +++ b/cli/src/commands/init/init.ts @@ -2,11 +2,19 @@ import { stat, writeFile, rm, mkdir } from "node:fs/promises"; import { colors } from "@cliffy/ansi/colors"; import { Command } from "@cliffy/command"; import { Confirm } from "@cliffy/prompt/confirm"; +import { Select } from "@cliffy/prompt/select"; +import { Input } from "@cliffy/prompt/input"; import * as log from "../../core/log.ts"; -import { type BranchBinding } from "./template.ts"; +import { type WorkspaceBinding } from "./template.ts"; import { GlobalOptions } from "../../types.ts"; import { readLockfile } from "../../utils/metadata.ts"; -import { getActiveWorkspaceOrFallback } from "../workspace/workspace.ts"; +import { + getActiveWorkspaceOrFallback, + getActiveWorkspace, + allWorkspaces, + add as addWorkspaceProfile, + type Workspace, +} 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"; @@ -42,6 +50,9 @@ export interface InitOptions { * Bootstrap a windmill project with a wmill.yaml file */ async function initAction(opts: InitOptions) { + let didBindWorkspace = false; + let boundProfile: Workspace | undefined; + if (await stat("wmill.yaml").catch(() => null)) { log.info("wmill.yaml already exists, skipping config generation"); } else { @@ -50,171 +61,180 @@ async function initAction(opts: InitOptions) { "../../utils/git.ts" ); let branchName: string | undefined; - let binding: BranchBinding | undefined; - if (isGitRepository()) { + let wsBindings: WorkspaceBinding[] | undefined; + // boundProfile is set during bind (hoisted to function scope) + const inGitRepo = isGitRepository(); + if (inGitRepo) { branchName = getCurrentGitBranch() ?? undefined; } - // Determine workspace binding before writing the template - if (isGitRepository() && branchName) { + const isInteractive = !!process.stdin.isTTY && !opts.useDefault; + + if (isInteractive && opts.bindProfile !== false) { + const shouldBind = opts.bindProfile === true || await Confirm.prompt({ + message: "Bind a workspace?", + default: true, + }); + + if (shouldBind) { + // Step 1: Pick workspace profile (same as wmill workspace bind) + let profiles = await allWorkspaces(opts.configDir); + let selectedProfile: Workspace | undefined; + + if (profiles.length === 0) { + log.info(colors.yellow("No workspace profiles found. Let's create one.")); + await addWorkspaceProfile(opts as any, undefined, undefined, undefined); + profiles = await allWorkspaces(opts.configDir); + selectedProfile = profiles.length > 0 + ? await getActiveWorkspace(opts as GlobalOptions) + : undefined; + } else { + const activeProfile = await getActiveWorkspace(opts as GlobalOptions); + const selectedName = await Select.prompt({ + message: "Select workspace profile", + options: profiles.map((p) => ({ + name: `${p.name} (${p.workspaceId} on ${p.remote})`, + value: p.name, + })), + default: activeProfile?.name, + }); + selectedProfile = profiles.find((p) => p.name === selectedName); + } + + if (selectedProfile) { + // Step 2: Pick workspace name + const wsName = await Input.prompt({ + message: "Workspace name (key in wmill.yaml)", + default: selectedProfile.workspaceId, + }); + + // Step 3: Pick git branch (only in git repos) + let gitBranch: string | undefined; + if (inGitRepo) { + const branchInput = await Input.prompt({ + message: "Git branch to associate", + default: branchName ?? wsName, + }); + if (branchInput !== wsName) { + gitBranch = branchInput; + } + } + + wsBindings = [{ + name: wsName, + baseUrl: selectedProfile.remote, + workspaceId: selectedProfile.workspaceId !== wsName ? selectedProfile.workspaceId : wsName, + gitBranch, + }]; + boundProfile = selectedProfile; + } + } + } else if (opts.bindProfile === true) { + // Non-interactive bind: create a single workspace entry from active profile const activeWorkspace = await getActiveWorkspaceOrFallback( opts as GlobalOptions ); 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); - - if (!shouldSkip) { - if (shouldBind || shouldPrompt) { - log.info( - colors.yellow(`\nCurrent Git branch: ${colors.bold(branchName)}`) - ); - log.info( - colors.yellow( - `Active workspace profile: ${colors.bold(activeWorkspace.name)}` - ) - ); - log.info( - colors.yellow( - ` ${activeWorkspace.workspaceId} on ${activeWorkspace.remote}` - ) - ); - } - - if ( - shouldBind || - (shouldPrompt && - (await Confirm.prompt({ - message: "Bind workspace profile to current Git branch?", - default: true, - }))) - ) { - log.info( - `binding branch ${branchName} to workspace ${activeWorkspace.name} on ${activeWorkspace.remote}` - ); - binding = { - baseUrl: activeWorkspace.remote, - workspaceId: activeWorkspace.workspaceId, - }; - } - } + const wsName = branchName ?? activeWorkspace.workspaceId; + wsBindings = [{ + name: wsName, + baseUrl: activeWorkspace.remote, + workspaceId: activeWorkspace.workspaceId !== wsName ? activeWorkspace.workspaceId : wsName, + }]; + boundProfile = activeWorkspace; } } - await writeFile("wmill.yaml", generateCommentedTemplate(branchName, binding), "utf-8"); + await writeFile("wmill.yaml", generateCommentedTemplate(branchName, undefined, wsBindings), "utf-8"); log.info(colors.green("wmill.yaml created with default settings")); - if (binding) { + if (wsBindings && wsBindings.length > 0) { + didBindWorkspace = true; log.info( colors.green( - `✓ Bound branch '${branchName}' to workspace` + `✓ Bound workspace '${wsBindings[0].name}' → ${wsBindings[0].workspaceId} on ${wsBindings[0].baseUrl}` ) ); + log.info( + colors.gray("To bind additional workspaces, run: wmill workspace bind") + ); + } else { + log.warn( + "⚠️ No workspace bound. Sync commands will not work without a workspace.\n" + + " Run 'wmill workspace bind' to bind a workspace to this project." + ); } // Create lock file await readLockfile(); - // Check for backend git-sync settings unless --use-default is specified - if (!opts.useDefault) { + // Check for backend git-sync settings — only if a workspace was bound and not --use-default + if (!opts.useDefault && didBindWorkspace && boundProfile) { try { - const { requireLogin } = await import("../../core/auth.ts"); - const { resolveWorkspace } = await import("../../core/context.ts"); + const { setClient } = await import("../../core/client.ts"); - // Check if user has workspace configured - const { getActiveWorkspace } = await import( - "../workspace/workspace.ts" - ); - const activeWorkspace = await getActiveWorkspace(opts as GlobalOptions); + // Use the bound profile directly — skip requireLogin which would resolve to the active profile + setClient(boundProfile.token, boundProfile.remote.replace(/\/$/, "")); - if (!activeWorkspace) { - log.info("No workspace configured. Using default settings."); - log.info( - "You can configure a workspace later with 'wmill workspace add'" - ); - } else { - await requireLogin(opts as GlobalOptions); - const workspace = await resolveWorkspace(opts as GlobalOptions); + const wmill = await import("../../../gen/services.gen.ts"); + const settings = await wmill.getSettings({ + workspace: boundProfile.workspaceId, + }); - const wmill = await import("../../../gen/services.gen.ts"); - const settings = await wmill.getSettings({ - workspace: workspace.workspaceId, - }); + if ( + settings.git_sync?.repositories && + settings.git_sync.repositories.length > 0 + ) { + let useBackendSettings = opts.useBackend; - if ( - settings.git_sync?.repositories && - settings.git_sync.repositories.length > 0 - ) { - let useBackendSettings = opts.useBackend; + // If repository is specified, implicitly use backend settings + if (opts.repository && !opts.useDefault) { + useBackendSettings = true; + } - // If repository is specified, implicitly use backend settings - if (opts.repository && !opts.useDefault) { - useBackendSettings = true; - } + if (useBackendSettings === undefined) { + const choice = await Select.prompt({ + message: + "Git-sync settings found on backend. What would you like to do?", + options: [ + { name: "Use backend git-sync settings", value: "backend" }, + { name: "Use default settings", value: "default" }, + { name: "Cancel", value: "cancel" }, + ], + }); - if (useBackendSettings === undefined) { - // Interactive prompt - const { Select } = await import("@cliffy/prompt/select"); - const choice = await Select.prompt({ - message: - "Git-sync settings found on backend. What would you like to do?", - options: [ - { - name: "Use backend git-sync settings", - value: "backend", - }, - { - name: "Use default settings", - value: "default", - }, - { - name: "Cancel", - value: "cancel", - }, - ], - }); - - if (choice === "cancel") { - // Clean up the created files - try { - await rm("wmill.yaml"); - await rm("wmill-lock.yaml"); - } catch (e) { - // Ignore cleanup errors - } - log.info("Init cancelled"); - process.exit(0); + if (choice === "cancel") { + try { + await rm("wmill.yaml"); + await rm("wmill-lock.yaml"); + } catch { + // Ignore cleanup errors } - - useBackendSettings = choice === "backend"; + log.info("Init cancelled"); + process.exit(0); } - if (useBackendSettings) { - log.info("Applying git-sync settings from backend..."); + useBackendSettings = choice === "backend"; + } - // Import and run the pull git-sync settings logic - const { pullGitSyncSettings } = await import( - "../gitsync-settings/gitsync-settings.ts" - ); - await pullGitSyncSettings({ - ...(opts as GlobalOptions), - repository: opts.repository, - jsonOutput: false, - diff: false, - replace: true, // Auto-replace when using backend settings during init - }); - - log.info(colors.green("Git-sync settings applied from backend")); - } + if (useBackendSettings) { + log.info("Applying git-sync settings from backend..."); + const { pullGitSyncSettings } = await import( + "../gitsync-settings/gitsync-settings.ts" + ); + const gsOpts = { + ...(opts as GlobalOptions), + workspace: boundProfile.name, + repository: opts.repository, + jsonOutput: false, + diff: false, + replace: true, + }; + (gsOpts as any).__secret_workspace = boundProfile; + await pullGitSyncSettings(gsOpts); + log.info(colors.green("Git-sync settings applied from backend")); } } } catch (error) { - // If there's an error checking backend settings, just continue with defaults log.warn( `Could not check backend for git-sync settings: ${(error as Error).message}` ); @@ -325,14 +345,23 @@ async function initAction(opts: InitOptions) { } } - // Generate resource type namespace - try { - await generateRTNamespace(opts as GlobalOptions); - } catch (error) { - log.warn( - `Could not pull resource types and generate TypeScript namespace: ${ - error instanceof Error ? error.message : error - }` + // Generate resource type namespace (only if a workspace was bound) + if (didBindWorkspace && boundProfile) { + try { + // Cache the bound profile so resolveWorkspace doesn't re-resolve and prompt again + const rtOpts = { ...opts } as GlobalOptions; + (rtOpts as any).__secret_workspace = boundProfile; + await generateRTNamespace(rtOpts); + } catch (error) { + log.warn( + `Could not pull resource types and generate TypeScript namespace: ${ + error instanceof Error ? error.message : error + }` + ); + } + } else { + log.info( + colors.gray("Skipped resource type namespace generation (no workspace bound). Run 'wmill workspace bind' then 'wmill init' to generate it.") ); } } diff --git a/cli/src/commands/init/template.ts b/cli/src/commands/init/template.ts index 0684b7ca14..046667b62c 100644 --- a/cli/src/commands/init/template.ts +++ b/cli/src/commands/init/template.ts @@ -34,35 +34,37 @@ export interface ConfigOption { example?: string; inlineComment?: string; groupNote?: string; + skipInTemplate?: boolean; } /** 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", + "example", "inlineComment", "groupNote", "skipInTemplate", ]); // Reusable sub-schemas for nested types const SPECIFIC_ITEMS_SCHEMA = { type: "object", - description: "Sync only specific items", + description: "Items to sync per-workspace (stored as ...yaml on disk)", 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" }, + variables: { type: "array", items: { type: "string" }, description: "Variable path patterns to sync per-workspace" }, + resources: { type: "array", items: { type: "string" }, description: "Resource path patterns to sync per-workspace" }, + triggers: { type: "array", items: { type: "string" }, description: "Trigger path patterns to sync per-workspace" }, + folders: { type: "array", items: { type: "string" }, description: "Folder path patterns to sync per-workspace" }, + settings: { type: "boolean", description: "Whether to sync settings per-workspace" }, }, additionalProperties: false, } as const; -const BRANCH_CONFIG_SCHEMA = { +const WORKSPACE_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" }, + gitBranch: { type: "string", description: "Git branch name (defaults to the workspace name/key)" }, + workspaceId: { type: "string", description: "Workspace ID to sync with (defaults to the workspace name/key)" }, + baseUrl: { type: "string", description: "Windmill instance URL for this workspace" }, + overrides: { type: "object", description: "Override any top-level sync option for this workspace" }, promotionOverrides: { type: "object", description: "Overrides applied when using --promotion flag" }, specificItems: SPECIFIC_ITEMS_SCHEMA, }, @@ -75,10 +77,11 @@ const BRANCH_CONFIG_SCHEMA = { */ export const CONFIG_REFERENCE: ConfigOption[] = [ // ── Core ────────────────────────────────────────────────────────────── - { name: "defaultTs", type: "string", enum: ["bun", "deno"], default: "bun", description: "Default TypeScript runtime for new scripts" }, + { name: "defaultTs", type: "string", enum: ["bun", "deno"], default: "bun", description: "Default TypeScript runtime for new scripts", + inlineComment: "bun or deno" }, { 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)", + templateValue: '\n - "f/**"', inlineComment: 'use ** for all, f/** for folder-scoped' }, + { name: "extraIncludes", type: "array", items: { type: "string" }, default: "[]", description: "Additional glob patterns merged with includes (useful in workspace overrides)", commented: true }, { name: "excludes", type: "array", items: { type: "string" }, default: "[]", description: "Glob patterns for files to exclude from sync" }, @@ -123,7 +126,8 @@ export const CONFIG_REFERENCE: ConfigOption[] = [ 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" }, + { name: "nonDottedPaths", type: "boolean", default: "true", description: "Use __flow/__app/__raw_app suffixes instead of .flow/.app/.raw_app", + inlineComment: "recommended for new projects" }, // ── Codebase bundling ───────────────────────────────────────────────── { name: "codebases", type: "array", default: "[]", description: "Codebase bundling configurations for shared libraries", @@ -168,34 +172,36 @@ export const CONFIG_REFERENCE: ConfigOption[] = [ ].join("\n"), }, - // ── Git branches ────────────────────────────────────────────────────── - { name: "gitBranches", type: "object", default: "{}", description: "Map git branches to workspaces and per-branch sync overrides", + // ── Workspace bindings ───────────────────────────────────────────────── + { name: "workspaces", type: "object", default: "{}", description: "Map workspace names to Windmill instances and per-workspace 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: {}", + additionalProperties: WORKSPACE_CONFIG_SCHEMA, + section: "Workspace bindings", + sectionNote: "Map workspace names to Windmill instances and override settings per workspace.\nThe key is a human-friendly workspace name. gitBranch and workspaceId default to the key name.", + templateValue: "\n {{BRANCH}}: {}", example: [ "{{BASEURL_LINE}}", "{{WORKSPACE_ID_LINE}}", - " # promotionOverrides: # overrides applied during --promotion", + " # gitBranch: main # git branch (defaults to workspace name)", + " # promotionOverrides: # overrides applied during --promotion", " # skipSecrets: false", - " # specificItems: # only sync these specific items", + " # specificItems: # items stored per-workspace on disk", + " # # e.g. file.staging.variable.yaml (suffix = workspace name)", ' # 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", + " # Example: staging workspace on a different instance", " # staging:", " # baseUrl: https://staging.windmill.dev", - " # workspaceId: staging-workspace", + " # workspaceId: staging-workspace # defaults to 'staging' if omitted", " # overrides:", " # skipSecrets: false", " # includeSchedules: true", "", - " # Items shared across ALL branches", + " # Items shared across ALL workspaces", " # commonSpecificItems:", ' # variables: ["f/shared/api_key"]', ' # resources: ["f/shared/db_conn"]', @@ -203,19 +209,28 @@ export const CONFIG_REFERENCE: ConfigOption[] = [ ].join("\n"), }, - { name: "environments", type: "object", default: "{}", description: "Alias for gitBranches — use if you prefer environment-based terminology", + { name: "gitBranches", type: "object", default: "{}", description: "[Deprecated] Use 'workspaces' instead. Map git branches to workspaces.", properties: { commonSpecificItems: SPECIFIC_ITEMS_SCHEMA }, - additionalProperties: BRANCH_CONFIG_SCHEMA, - commented: true }, + additionalProperties: WORKSPACE_CONFIG_SCHEMA, + commented: true, skipInTemplate: true }, + { name: "environments", type: "object", default: "{}", description: "[Deprecated] Use 'workspaces' instead.", + properties: { commonSpecificItems: SPECIFIC_ITEMS_SCHEMA }, + additionalProperties: WORKSPACE_CONFIG_SCHEMA, + commented: true, skipInTemplate: true }, ]; // ─── Template generator ───────────────────────────────────────────────────── -export interface BranchBinding { +export interface WorkspaceBinding { + name: string; baseUrl: string; workspaceId: string; + gitBranch?: string; } +/** @deprecated Use WorkspaceBinding instead */ +export type BranchBinding = Pick; + /** Quote a string for use as a YAML key if it contains special characters. */ function yamlKey(s: string): string { if ( @@ -228,7 +243,7 @@ function yamlKey(s: string): string { return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; } -export function generateCommentedTemplate(branchName?: string, binding?: BranchBinding): string { +export function generateCommentedTemplate(branchName?: string, binding?: BranchBinding, bindings?: WorkspaceBinding[]): string { const branch = yamlKey(branchName ?? "main"); const lines: string[] = [ "# yaml-language-server: $schema=wmill.schema.json", @@ -238,6 +253,8 @@ export function generateCommentedTemplate(branchName?: string, binding?: BranchB ]; for (const opt of CONFIG_REFERENCE) { + if (opt.skipInTemplate) continue; + if (opt.section) { const ruler = "-".repeat(Math.max(0, 65 - opt.section.length)); lines.push(`# --- ${opt.section} ${ruler}`); @@ -253,6 +270,38 @@ export function generateCommentedTemplate(branchName?: string, binding?: BranchB lines.push(`# ${opt.groupNote}`); } + // For the workspaces section, generate from bindings if available + if (opt.name === "workspaces" && bindings && bindings.length > 0) { + lines.push(`# ${opt.description}`); + lines.push("workspaces:"); + for (const ws of bindings) { + const key = yamlKey(ws.name); + lines.push(` ${key}:`); + lines.push(` baseUrl: ${ws.baseUrl}`); + if (ws.workspaceId !== ws.name) { + lines.push(` workspaceId: ${ws.workspaceId}${" ".repeat(Math.max(1, 30 - ws.workspaceId.length))}# windmill workspace id (defaults to key name)`); + } + if (ws.gitBranch && ws.gitBranch !== ws.name) { + lines.push(` gitBranch: ${ws.gitBranch}${" ".repeat(Math.max(1, 32 - ws.gitBranch.length))}# git branch to auto-detect this workspace (defaults to key name)`); + } + lines.push(` # overrides: # override top-level sync options for this workspace`); + lines.push(` # skipSecrets: false`); + lines.push(` # includeSchedules: true`); + lines.push(` # promotionOverrides: # overrides when using --promotion`); + lines.push(` # skipSecrets: false`); + lines.push(` # specificItems: # items synced per-workspace (file suffix = workspace name)`); + lines.push(` # variables: []`); + lines.push(` # resources: []`); + lines.push(` # triggers: []`); + lines.push(` # folders: []`); + lines.push(` # settings: false`); + + } + lines.push(""); + // Skip the default template/example rendering for this opt + continue; + } + const value = opt.templateValue ?? opt.default; const resolvedValue = value.replace("{{BRANCH}}", branch); @@ -278,8 +327,8 @@ export function generateCommentedTemplate(branchName?: string, binding?: BranchB .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"); + .replace("{{BASEURL_LINE}}", " # baseUrl: https://app.windmill.dev # Windmill instance URL") + .replace("{{WORKSPACE_ID_LINE}}", " # workspaceId: my-workspace # defaults to workspace name"); } for (const exLine of resolvedExample.split("\n")) { lines.push(exLine); @@ -328,15 +377,18 @@ export function formatConfigReference(): string { for (const opt of CONFIG_REFERENCE) { allRows.push({ name: opt.name, description: opt.description, default: opt.default }); + // Don't expand deprecated entries (they duplicate the primary entry's schema) + if (opt.skipInTemplate) continue; + // Auto-expand array item properties (e.g., codebases[].*) if (opt.items?.properties) { expandSchema(`${opt.name}[]`, opt.items, allRows); } - // Auto-expand additionalProperties (e.g., gitBranches..*) + // Auto-expand additionalProperties (e.g., workspaces..*) if (opt.additionalProperties && typeof opt.additionalProperties === "object" && opt.additionalProperties.properties) { - expandSchema(`${opt.name}.`, opt.additionalProperties as Record, allRows); + expandSchema(`${opt.name}.`, opt.additionalProperties as Record, allRows); } - // Auto-expand named properties (e.g., gitBranches.commonSpecificItems) + // Auto-expand named properties (e.g., workspaces.commonSpecificItems) if (opt.properties) { expandSchema(opt.name, opt, allRows); } diff --git a/cli/src/commands/resource/resource.ts b/cli/src/commands/resource/resource.ts index e1c9052261..054ed91d06 100644 --- a/cli/src/commands/resource/resource.ts +++ b/cli/src/commands/resource/resource.ts @@ -18,7 +18,7 @@ import { sep as SEP } from "node:path"; import * as wmill from "../../../gen/services.gen.ts"; import { Resource } from "../../../gen/types.gen.ts"; import { readInlinePathSync } from "../../utils/utils.ts"; -import { isBranchSpecificFile } from "../../core/specific_items.ts"; +import { isWorkspaceSpecificFile } from "../../core/specific_items.ts"; import { getCurrentGitBranch } from "../../utils/git.ts"; export interface ResourceFile { @@ -75,7 +75,7 @@ export async function pushResource( let pathToRead = basePath; - if (originalLocalPath && isBranchSpecificFile(originalLocalPath)) { + if (originalLocalPath && isWorkspaceSpecificFile(originalLocalPath)) { const currentBranch = getCurrentGitBranch(); if (currentBranch) { // Directly construct branch-specific resource file path diff --git a/cli/src/commands/script/script.ts b/cli/src/commands/script/script.ts index dc5d90f4bb..1657d2832e 100644 --- a/cli/src/commands/script/script.ts +++ b/cli/src/commands/script/script.ts @@ -187,11 +187,11 @@ export async function findResourceFile(path: string) { if (currentBranch) { // Add branch-specific candidates at the beginning (higher priority) - const branchSpecificJSON = specificItems.toBranchSpecificPath( + const branchSpecificJSON = specificItems.toWorkspaceSpecificPath( contentBasePathJSON, currentBranch ); - const branchSpecificYAML = specificItems.toBranchSpecificPath( + const branchSpecificYAML = specificItems.toWorkspaceSpecificPath( contentBasePathYAML, currentBranch ); diff --git a/cli/src/commands/sync/sync.ts b/cli/src/commands/sync/sync.ts index 383df9bed1..caecd08870 100644 --- a/cli/src/commands/sync/sync.ts +++ b/cli/src/commands/sync/sync.ts @@ -49,13 +49,15 @@ import { parseCliBehavior, SyncOptions, validateBranchConfiguration, + findWorkspaceByGitBranch, + WorkspaceEntryConfig, } from "../../core/conf.ts"; import { - fromBranchSpecificPath, - getBranchSpecificPath, + fromWorkspaceSpecificPath, + getWorkspaceSpecificPath, getSpecificItemsForCurrentBranch, - isBranchSpecificFile, - isCurrentBranchFile, + isWorkspaceSpecificFile, + isCurrentWorkspaceFile, isItemTypeConfigured, isSpecificItem, SpecificItemsConfig, @@ -110,8 +112,78 @@ import { getModuleFolderSuffix, isModuleEntryPoint, getScriptBasePathFromModulePath, + hasWrongFormatSuffix, } from "../../utils/resource_folders.ts"; +let branchDeprecationWarned = false; + +// Resolve workspace name from a --branch override (git branch → workspace name). +// Falls back to using the branch value as-is (backward compat: old key = branch name). +function resolveWsNameFromBranch(opts: SyncOptions, branchName: string): string { + const match = findWorkspaceByGitBranch(opts.workspaces, branchName); + return match ? match[0] : branchName; +} + +// Warn if --workspace overrides auto-detected branch or if workspace not in config. +function warnWorkspaceOverride(opts: SyncOptions, wsNameForConfig: string | undefined): void { + if (!wsNameForConfig || !opts.workspaces) return; + + // Check if workspace exists in config + const wsEntry = (opts.workspaces as any)?.[wsNameForConfig] as WorkspaceEntryConfig | undefined; + if (!wsEntry) { + const wsNames = Object.keys(opts.workspaces).filter((k) => k !== "commonSpecificItems"); + if (wsNames.length > 0) { + log.warn( + `⚠️ Workspace '${wsNameForConfig}' is not defined in the 'workspaces' section of wmill.yaml.\n` + + ` No workspace-specific overrides will be applied. Available workspaces: ${wsNames.join(", ")}` + ); + } + return; + } + + // Check if current git branch maps to a different workspace + if (isGitRepository()) { + const currentBranch = getCurrentGitBranch(); + if (currentBranch) { + const autoMatch = findWorkspaceByGitBranch(opts.workspaces, currentBranch); + if (autoMatch && autoMatch[0] !== wsNameForConfig) { + log.info( + `Current git branch '${currentBranch}' maps to workspace '${autoMatch[0]}', ` + + `but --workspace overrides to '${wsNameForConfig}'.` + ); + } + } + } +} + +// The workspace name is used as the file suffix for workspace-specific files. +// This is a pass-through — the workspace name (config key) IS the suffix. +function resolveWsNameForFiles(_opts: SyncOptions, wsName: string): string { + return wsName; +} + +// After resolveWorkspace, infer the workspace config name from the resolved profile +// by matching baseUrl + workspaceId against the workspaces config entries. +function inferWsNameFromProfile(opts: SyncOptions, profile: { remote: string; workspaceId: string }): string | undefined { + if (!opts.workspaces) return undefined; + const wsNames = Object.keys(opts.workspaces).filter((k) => k !== "commonSpecificItems"); + for (const name of wsNames) { + const entry = (opts.workspaces as any)[name] as WorkspaceEntryConfig; + if (!entry?.baseUrl) continue; + try { + const entryUrl = new URL(entry.baseUrl).toString(); + const profileUrl = new URL(profile.remote).toString(); + const entryWsId = entry.workspaceId ?? name; + if (entryUrl === profileUrl && entryWsId === profile.workspaceId) { + return name; + } + } catch { + continue; + } + } + return undefined; +} + // Merge CLI options with effective settings, preserving CLI flags as overrides function mergeCliWithEffectiveOptions< T extends GlobalOptions & SyncOptions & { repository?: string }, @@ -120,17 +192,14 @@ function mergeCliWithEffectiveOptions< return Object.assign({}, effectiveOpts, cliOpts) as T; } -// Resolve effective sync options using branch-based configuration +// Resolve effective sync options using workspace-based configuration async function resolveEffectiveSyncOptions( workspace: Workspace, localConfig: SyncOptions, promotion?: string, - branchOverride?: string, + workspaceNameOverride?: string, ): Promise { - return await getEffectiveSettings(localConfig, promotion, false, false, branchOverride, { - workspaceId: workspace.workspaceId, - remote: workspace.remote, - }); + return await getEffectiveSettings(localConfig, promotion, false, false, workspaceNameOverride); } type DynFSElement = { @@ -1282,11 +1351,22 @@ export async function elementsToMap( ): Promise<{ [key: string]: string }> { const map: { [key: string]: string } = {}; const processedBasePaths = new Set(); + const wrongFormatPaths: string[] = []; // Cache git branch at the start to avoid repeated execSync calls per file - const cachedBranch = branchOverride ?? getCurrentGitBranch() ?? undefined; + const cachedWsName = branchOverride ?? getCurrentGitBranch() ?? undefined; for await (const entry of readDirRecursiveWithIgnore(ignore, els)) { // console.log("FOO", entry.path, entry.ignored, entry.isDirectory) - if (entry.isDirectory || entry.ignored) { + if (entry.isDirectory) { + // Check for folder suffix format mismatch (only for local paths) + if (!isRemote) { + const dirName = entry.path.split(SEP).pop() ?? ""; + if (hasWrongFormatSuffix(dirName)) { + wrongFormatPaths.push(entry.path); + } + } + continue; + } + if (entry.ignored) { continue; } const path = entry.path; @@ -1390,10 +1470,10 @@ export async function elementsToMap( // If getTypeStrFromPath can't determine the type, continue processing the file } - // Handle branch-specific files - skip files for other branches - if (specificItems && isBranchSpecificFile(path)) { - if (!isCurrentBranchFile(path, cachedBranch)) { - // Skip branch-specific files for other branches + // Handle workspace-specific files - skip files for other branches + if (specificItems && isWorkspaceSpecificFile(path)) { + if (!isCurrentWorkspaceFile(path, cachedWsName)) { + // Skip workspace-specific files for other branches continue; } } @@ -1426,13 +1506,13 @@ export async function elementsToMap( } } - // Handle branch-specific path mapping after all filtering - if (cachedBranch && isCurrentBranchFile(path, cachedBranch)) { - // This is a branch-specific file for current branch - const currentBranch = cachedBranch; - const basePath = fromBranchSpecificPath(path, currentBranch); + // Handle workspace-specific path mapping after all filtering + if (cachedWsName && isCurrentWorkspaceFile(path, cachedWsName)) { + // This is a workspace-specific file for current branch + const currentBranch = cachedWsName; + const basePath = fromWorkspaceSpecificPath(path, currentBranch); - // Only use branch-specific files if the item type IS configured as branch-specific + // Only use workspace-specific files if the item type IS configured as branch-specific // AND matches the pattern. Otherwise, skip and use base file instead. if (!isItemTypeConfigured(basePath, specificItems)) { // Type not configured as branch-specific - skip, use base file instead @@ -1446,10 +1526,10 @@ export async function elementsToMap( // Type configured AND matches - map to base path map[basePath] = content; processedBasePaths.add(basePath); - } else if (!isBranchSpecificFile(path)) { + } else if (!isWorkspaceSpecificFile(path)) { // This is a regular base file if (processedBasePaths.has(path)) { - // Skip base file, we already processed branch-specific version + // Skip base file, we already processed workspace-specific version continue; } // Skip base file if it's configured as branch-specific (expect branch version) @@ -1459,8 +1539,22 @@ export async function elementsToMap( } map[path] = content; } - // Note: branch-specific files for other branches are already filtered out earlier + // Note: workspace-specific files for other branches are already filtered out earlier } + + if (wrongFormatPaths.length > 0) { + const isNonDotted = getNonDottedPaths(); + const foundFormat = isNonDotted ? ".flow/.app/.raw_app" : "__flow/__app/__raw_app"; + const expectedFormat = isNonDotted ? "__flow/__app/__raw_app" : ".flow/.app/.raw_app"; + const configHint = isNonDotted + ? "Either remove 'nonDottedPaths: true' from wmill.yaml, or rename these directories to use __flow/__app/__raw_app format." + : "Either add 'nonDottedPaths: true' to wmill.yaml, or rename these directories to use .flow/.app/.raw_app format."; + const pathList = wrongFormatPaths.map((p) => ` ${p}`).join("\n"); + throw new Error( + `Found ${wrongFormatPaths.length} directory(ies) using ${foundFormat} format, but wmill.yaml expects ${expectedFormat}:\n${pathList}\n${configHint}` + ); + } + return map; } @@ -2027,9 +2121,29 @@ export async function pull( opts.skipSecrets = false; } - // Validate branch configuration early (skipped when --branch is used) + // Resolve workspace name for config lookups. + // --branch resolves git branch → workspace name (deprecated but still supported). + // --workspace (without --base-url) selects a workspace config entry by name. + // When --base-url is used with --workspace, --workspace is a profile selector only; + // --branch should still drive config lookups. + const hasExplicitCredentials = !!opts.baseUrl; + let wsNameForConfig: string | undefined; + + if (opts.branch) { + if (!hasExplicitCredentials && !branchDeprecationWarned) { + log.warn("⚠️ --branch/--env is deprecated. Use --workspace instead."); + branchDeprecationWarned = true; + } + wsNameForConfig = resolveWsNameFromBranch(opts, opts.branch); + } else if (opts.workspace && !hasExplicitCredentials) { + // --workspace without --base-url: use as workspace config name + wsNameForConfig = opts.workspace; + warnWorkspaceOverride(opts, wsNameForConfig); + } + + // Validate workspace configuration early (skipped when override is used) try { - await validateBranchConfiguration(opts, opts.branch); + await validateBranchConfiguration(opts, wsNameForConfig); } catch (error) { if (error instanceof Error && error.message.includes("overrides")) { log.error(error.message); @@ -2042,19 +2156,27 @@ export async function pull( await mkdir(path.join(process.cwd(), ".wmill"), { recursive: true }); } - const workspace = await resolveWorkspace(opts, opts.branch); + const workspace = await resolveWorkspace(opts, wsNameForConfig); await requireLogin(opts); - // Resolve effective sync options with branch awareness + // If wsNameForConfig wasn't set from flags, infer from the resolved profile + if (!wsNameForConfig) { + wsNameForConfig = inferWsNameFromProfile(opts, workspace); + } + + // Resolve effective sync options with workspace awareness const effectiveOpts = await resolveEffectiveSyncOptions( workspace, opts, opts.promotion, - opts.branch, + wsNameForConfig, ); - // Extract specific items configuration before merging overwrites gitBranches - const specificItems = getSpecificItemsForCurrentBranch(opts, opts.branch); + // Extract specific items configuration + const specificItems = getSpecificItemsForCurrentBranch(opts, wsNameForConfig); + + // Compute the workspace name for file naming + const wsNameForFiles = wsNameForConfig ? resolveWsNameForFiles(opts, wsNameForConfig) : undefined; // Merge CLI flags with resolved settings (CLI flags take precedence only for explicit overrides) opts = mergeCliWithEffectiveOptions(originalCliOpts, effectiveOpts); @@ -2121,7 +2243,7 @@ export async function pull( codebases, true, specificItems, - opts.branch, + wsNameForFiles, true, // els1 (remote) is the remote source ); @@ -2141,11 +2263,11 @@ export async function pull( : {}), ...(specificItems && isSpecificItem(change.path, specificItems) ? { - branch_specific: true, - branch_specific_path: getBranchSpecificPath( + workspace_specific: true, + workspace_specific_path: getWorkspaceSpecificPath( change.path, specificItems, - opts.branch, + wsNameForFiles, ), } : {}), @@ -2158,7 +2280,7 @@ export async function pull( if (changes.length > 0) { if (!opts.jsonOutput) { - prettyChanges(changes, specificItems, opts.branch); + prettyChanges(changes, specificItems, wsNameForFiles); } if (opts.dryRun) { log.info(colors.gray(`Dry run complete.`)); @@ -2178,16 +2300,16 @@ export async function pull( log.info(colors.gray(`Applying changes to files ...`)); for await (const change of changes) { - // Determine if this file should be written to a branch-specific path + // Determine if this file should be written to a workspace-specific path let targetPath = change.path; if (specificItems && isSpecificItem(change.path, specificItems)) { - const branchSpecificPath = getBranchSpecificPath( + const workspaceSpecificPath = getWorkspaceSpecificPath( change.path, specificItems, - opts.branch, + wsNameForFiles, ); - if (branchSpecificPath) { - targetPath = branchSpecificPath; + if (workspaceSpecificPath) { + targetPath = workspaceSpecificPath; } } @@ -2238,7 +2360,7 @@ export async function pull( log.info( `Editing script content of ${targetPath}${ targetPath !== change.path - ? colors.gray(` (branch-specific override for ${change.path})`) + ? colors.gray(` (workspace-specific override for ${change.path})`) : "" }`, ); @@ -2249,7 +2371,7 @@ export async function pull( log.info( `Editing ${getTypeStrFromPath(change.path)} ${targetPath}${ targetPath !== change.path - ? colors.gray(` (branch-specific override for ${change.path})`) + ? colors.gray(` (workspace-specific override for ${change.path})`) : "" }`, ); @@ -2267,7 +2389,7 @@ export async function pull( log.info( `Adding ${getTypeStrFromPath(change.path)} ${targetPath}${ targetPath !== change.path - ? colors.gray(` (branch-specific override for ${change.path})`) + ? colors.gray(` (workspace-specific override for ${change.path})`) : "" }`, ); @@ -2276,7 +2398,7 @@ export async function pull( log.info( `Writing ${getTypeStrFromPath(change.path)} ${targetPath}${ targetPath !== change.path - ? colors.gray(` (branch-specific override for ${change.path})`) + ? colors.gray(` (workspace-specific override for ${change.path})`) : "" }`, ); @@ -2390,11 +2512,11 @@ export async function pull( : {}), ...(specificItems && isSpecificItem(change.path, specificItems) ? { - branch_specific: true, - branch_specific_path: getBranchSpecificPath( + workspace_specific: true, + workspace_specific_path: getWorkspaceSpecificPath( change.path, specificItems, - opts.branch, + wsNameForFiles, ), } : {}), @@ -2423,18 +2545,18 @@ export async function pull( function prettyChanges(changes: Change[], specificItems?: SpecificItemsConfig, branchOverride?: string) { for (const change of changes) { let displayPath = change.path; - let branchNote = ""; + let wsNote = ""; - // Check if this will be written as a branch-specific file + // Check if this will be written as a workspace-specific file if (specificItems && isSpecificItem(change.path, specificItems)) { - const branchSpecificPath = getBranchSpecificPath( + const workspaceSpecificPath = getWorkspaceSpecificPath( change.path, specificItems, branchOverride, ); - if (branchSpecificPath) { - displayPath = branchSpecificPath; - branchNote = " (branch-specific)"; + if (workspaceSpecificPath) { + displayPath = workspaceSpecificPath; + wsNote = " (workspace-specific)"; } } @@ -2443,7 +2565,7 @@ function prettyChanges(changes: Change[], specificItems?: SpecificItemsConfig, b colors.green( `+ ${getTypeStrFromPath(change.path)} ` + displayPath + - colors.gray(branchNote), + colors.gray(wsNote), ), ); } else if (change.name === "deleted") { @@ -2451,7 +2573,7 @@ function prettyChanges(changes: Change[], specificItems?: SpecificItemsConfig, b colors.red( `- ${getTypeStrFromPath(change.path)} ` + displayPath + - colors.gray(branchNote), + colors.gray(wsNote), ), ); } else if (change.name === "edited") { @@ -2459,7 +2581,7 @@ function prettyChanges(changes: Change[], specificItems?: SpecificItemsConfig, b colors.yellow( `~ ${getTypeStrFromPath(change.path)} ` + displayPath + - colors.gray(branchNote) + + colors.gray(wsNote) + (change.codebase ? ` (codebase changed)` : ""), ), ); @@ -2528,9 +2650,24 @@ export async function push( opts.skipSecrets = false; } - // Validate branch configuration early (skipped when --branch is used) + // Resolve workspace name for config lookups (same logic as pull) + const hasExplicitCredentials = !!opts.baseUrl; + let wsNameForConfig: string | undefined; + + if (opts.branch) { + if (!hasExplicitCredentials && !branchDeprecationWarned) { + log.warn("⚠️ --branch/--env is deprecated. Use --workspace instead."); + branchDeprecationWarned = true; + } + wsNameForConfig = resolveWsNameFromBranch(opts, opts.branch); + } else if (opts.workspace && !hasExplicitCredentials) { + wsNameForConfig = opts.workspace; + warnWorkspaceOverride(opts, wsNameForConfig); + } + + // Validate workspace configuration early (skipped when override is used) try { - await validateBranchConfiguration(opts, opts.branch); + await validateBranchConfiguration(opts, wsNameForConfig); } catch (error) { if (error instanceof Error && error.message.includes("overrides")) { log.error(error.message); @@ -2539,19 +2676,27 @@ export async function push( throw error; } - const workspace = await resolveWorkspace(opts, opts.branch); + const workspace = await resolveWorkspace(opts, wsNameForConfig); await requireLogin(opts); - // Resolve effective sync options with branch awareness + // If wsNameForConfig wasn't set from flags, infer from the resolved profile + if (!wsNameForConfig) { + wsNameForConfig = inferWsNameFromProfile(opts, workspace); + } + + // Resolve effective sync options with workspace awareness const effectiveOpts = await resolveEffectiveSyncOptions( workspace, opts, opts.promotion, - opts.branch, + wsNameForConfig, ); - // Extract specific items configuration BEFORE merging overwrites gitBranches - const specificItems = getSpecificItemsForCurrentBranch(opts, opts.branch); + // Extract specific items configuration + const specificItems = getSpecificItemsForCurrentBranch(opts, wsNameForConfig); + + // Compute the workspace name for file naming + const wsNameForFiles = wsNameForConfig ? resolveWsNameForFiles(opts, wsNameForConfig) : undefined; // Merge CLI flags with resolved settings (CLI flags take precedence only for explicit overrides) opts = mergeCliWithEffectiveOptions(originalCliOpts, effectiveOpts); @@ -2654,7 +2799,7 @@ export async function push( codebases, false, specificItems, - opts.branch, + wsNameForFiles, false, // els1 (local) is not the remote source ); @@ -2823,10 +2968,10 @@ export async function push( } for (const folderName of folderNames) { const basePath = path.join("f", folderName, "folder.meta.yaml"); - const branchPath = getBranchSpecificPath( + const branchPath = getWorkspaceSpecificPath( `f/${folderName}/folder.meta.yaml`, specificItems, - opts.branch, + wsNameForFiles, ); let found = false; // Check branch-specific variant first (e.g. folder.dev.meta.yaml) @@ -2885,11 +3030,11 @@ export async function push( : {}), ...(specificItems && isSpecificItem(change.path, specificItems) ? { - branch_specific: true, - branch_specific_path: getBranchSpecificPath( + workspace_specific: true, + workspace_specific_path: getWorkspaceSpecificPath( change.path, specificItems, - opts.branch, + wsNameForFiles, ), } : {}), @@ -2902,7 +3047,7 @@ export async function push( if (changes.length > 0) { if (!opts.jsonOutput) { - prettyChanges(changes, specificItems, opts.branch); + prettyChanges(changes, specificItems, wsNameForFiles); } if (opts.dryRun) { @@ -2992,7 +3137,7 @@ export async function push( const pool = new Set(); const queue = [...groupedChangesArray]; // Cache git branch at the start to avoid repeated execSync calls per change - const cachedBranchForPush = opts.branch || (isGitRepository() ? getCurrentGitBranch() : null); + const cachedWsNameForPush = wsNameForFiles || (isGitRepository() ? getCurrentGitBranch() : null); while (queue.length > 0 || pool.size > 0) { // Fill the pool until we reach parallelizationFactor @@ -3093,12 +3238,12 @@ export async function push( ); // For branch-specific resources, push to the base path on the workspace server - // This ensures branch-specific files are stored with their base names in the workspace + // This ensures workspace-specific files are stored with their base names in the workspace let serverPath = resourceFilePath; - const currentBranch = cachedBranchForPush; + const currentBranch = cachedWsNameForPush; - if (currentBranch && isBranchSpecificFile(resourceFilePath)) { - serverPath = fromBranchSpecificPath( + if (currentBranch && isWorkspaceSpecificFile(resourceFilePath)) { + serverPath = fromWorkspaceSpecificPath( resourceFilePath, currentBranch, ); @@ -3128,10 +3273,10 @@ export async function push( ); let serverPath = resourceFilePath; - const currentBranch = cachedBranchForPush; + const currentBranch = cachedWsNameForPush; - if (currentBranch && isBranchSpecificFile(resourceFilePath)) { - serverPath = fromBranchSpecificPath( + if (currentBranch && isWorkspaceSpecificFile(resourceFilePath)) { + serverPath = fromWorkspaceSpecificPath( resourceFilePath, currentBranch, ); @@ -3153,13 +3298,13 @@ export async function push( const oldObj = parseFromPath(change.path, change.before); const newObj = parseFromPath(change.path, change.after); - // Check if this is a branch-specific item and get the original branch-specific path - let originalBranchSpecificPath: string | undefined; + // Check if this is a branch-specific item and get the original workspace-specific path + let originalWorkspaceSpecificPath: string | undefined; if (specificItems && isSpecificItem(change.path, specificItems)) { - originalBranchSpecificPath = getBranchSpecificPath( + originalWorkspaceSpecificPath = getWorkspaceSpecificPath( change.path, specificItems, - opts.branch, + wsNameForFiles, ); } @@ -3171,7 +3316,7 @@ export async function push( opts.plainSecrets ?? false, alreadySynced, opts.message, - originalBranchSpecificPath, + originalWorkspaceSpecificPath, permissionedAsContext, ); @@ -3221,16 +3366,16 @@ export async function push( const obj = parseFromPath(change.path, change.content); // Determine the actual local file path for this change - // For branch-specific items, we read from branch-specific files but push to base server paths + // For branch-specific items, we read from workspace-specific files but push to base server paths let localFilePath = change.path; if (specificItems && isSpecificItem(change.path, specificItems)) { - const branchSpecificPath = getBranchSpecificPath( + const workspaceSpecificPath = getWorkspaceSpecificPath( change.path, specificItems, - opts.branch, + wsNameForFiles, ); - if (branchSpecificPath) { - localFilePath = branchSpecificPath; + if (workspaceSpecificPath) { + localFilePath = workspaceSpecificPath; } } @@ -3618,11 +3763,11 @@ export async function push( : {}), ...(specificItems && isSpecificItem(change.path, specificItems) ? { - branch_specific: true, - branch_specific_path: getBranchSpecificPath( + workspace_specific: true, + workspace_specific_path: getWorkspaceSpecificPath( change.path, specificItems, - opts.branch, + wsNameForFiles, ), } : {}), @@ -3715,7 +3860,7 @@ const command = new Command() ) .option( "--branch, --env ", - "Override the current git branch/environment (works even outside a git repository)", + "[Deprecated: use --workspace] Override the current git branch/environment", ) .action(pull as any) .command("push") @@ -3772,7 +3917,7 @@ const command = new Command() ) .option( "--branch, --env ", - "Override the current git branch/environment (works even outside a git repository)", + "[Deprecated: use --workspace] Override the current git branch/environment", ) .option("--lint", "Run lint validation before pushing") .option( diff --git a/cli/src/commands/trigger/trigger.ts b/cli/src/commands/trigger/trigger.ts index 6099672ad7..23e979b35b 100644 --- a/cli/src/commands/trigger/trigger.ts +++ b/cli/src/commands/trigger/trigger.ts @@ -31,8 +31,8 @@ import { extractNativeTriggerInfo, } from "../../types.ts"; import { - fromBranchSpecificPath, - isBranchSpecificFile, + fromWorkspaceSpecificPath, + isWorkspaceSpecificFile, } from "../../core/specific_items.ts"; import { getCurrentGitBranch } from "../../utils/git.ts"; import { requireLogin } from "../../core/auth.ts"; @@ -593,10 +593,10 @@ function extractTriggerKindFromPath(filePath: string): string | undefined { let pathToAnalyze = filePath; // If this is a branch-specific file, convert it to the base path first - if (isBranchSpecificFile(filePath)) { + if (isWorkspaceSpecificFile(filePath)) { const currentBranch = getCurrentGitBranch(); if (currentBranch) { - pathToAnalyze = fromBranchSpecificPath(filePath, currentBranch); + pathToAnalyze = fromWorkspaceSpecificPath(filePath, currentBranch); } } diff --git a/cli/src/commands/workspace/fork.ts b/cli/src/commands/workspace/fork.ts index 21b5cf5a26..bc6cf57fe1 100644 --- a/cli/src/commands/workspace/fork.ts +++ b/cli/src/commands/workspace/fork.ts @@ -249,7 +249,7 @@ async function createWorkspaceFork( \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 the workspaces section for the branch it was forked from. To merge changes back to the parent workspace, you can: - Use the CLI: ` + colors.white(`git checkout ${newBranchName} && wmill workspace merge`) + ` @@ -289,7 +289,7 @@ async function deleteWorkspaceFork( 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.", + "Could not resolve parent workspace. Make sure you are in a git repo with 'workspaces' configured in wmill.yaml, or create a local workspace profile for the fork.", ); } forkWorkspaceId = name.startsWith(`${WM_FORK_PREFIX}-`) ? name : `${WM_FORK_PREFIX}-${name}`; diff --git a/cli/src/commands/workspace/merge.ts b/cli/src/commands/workspace/merge.ts index c2b6532254..73e77efb07 100644 --- a/cli/src/commands/workspace/merge.ts +++ b/cli/src/commands/workspace/merge.ts @@ -78,7 +78,7 @@ async function mergeWorkspaces( const workspace = await tryResolveBranchWorkspace(opts); if (!workspace) { throw new Error( - "Could not resolve workspace from branch name. Make sure you are in a git repo with gitBranches configured." + "Could not resolve workspace from branch name. Make sure you are in a git repo with 'workspaces' configured in wmill.yaml." ); } diff --git a/cli/src/commands/workspace/workspace.ts b/cli/src/commands/workspace/workspace.ts index 6cd0eeb3fd..d2bc2a5bbc 100644 --- a/cli/src/commands/workspace/workspace.ts +++ b/cli/src/commands/workspace/workspace.ts @@ -412,8 +412,7 @@ async function remove(_opts: GlobalOptions, name: string) { } async function whoami(_opts: GlobalOptions) { - await requireLogin(_opts); - const whoamiInfo = await wmill.globalWhoami(); + const whoamiInfo = await requireLogin(_opts); log.info(JSON.stringify(whoamiInfo, null, 2)); const activeName = await getActiveWorkspaceName(_opts); const { getCurrentGitBranch, getOriginalBranchForWorkspaceForks } = await import("../../utils/git.ts"); @@ -519,81 +518,194 @@ export async function getActiveWorkspaceOrFallback(opts: GlobalOptions) { return activeWorkspace; } async function bind( - opts: GlobalOptions & { branch?: string }, - bindWorkspace?: boolean + opts: GlobalOptions & { workspace?: string; branch?: string }, + doBind: boolean ) { const { isGitRepository, getCurrentGitBranch } = await import( "../../utils/git.ts" ); - - if (!isGitRepository()) { - log.error(colors.red("Not in a Git repository")); - return; - } - - const branch = opts.branch || getCurrentGitBranch(); - if (!branch) { - log.error(colors.red("Could not determine current Git branch")); - return; - } - - const { readConfigFile } = await import("../../core/conf.ts"); + const { readConfigFile, findWorkspaceByGitBranch, getWorkspaceNames } = await import("../../core/conf.ts"); + const { stringify: yamlStringify } = await import("yaml"); const config = await readConfigFile(); - const activeWorkspace = await getActiveWorkspaceOrFallback(opts); - if (!activeWorkspace && bindWorkspace) { - log.error( - colors.red( - "No active workspace. Use 'wmill workspace add' or 'wmill workspace switch' first" - ) - ); - return; + if (!config.workspaces) { + config.workspaces = {} as any; } - // For unbind, check if branch exists - if (!bindWorkspace && (!config.gitBranches || !config.gitBranches[branch])) { - log.error( - colors.red(`Branch '${branch}' not found in wmill.yaml gitBranches`) - ); - return; - } + const isInteractive = !!process.stdin.isTTY; + const inGitRepo = isGitRepository(); + const currentBranch = inGitRepo ? getCurrentGitBranch() : null; - // Update the branch configuration with workspace binding - if (!config.gitBranches) { - config.gitBranches = {}; - } - if (!config.gitBranches[branch]) { - config.gitBranches[branch] = { overrides: {} }; - } + if (doBind) { + // ── BIND ────────────────────────────────────────────────── - if (bindWorkspace && activeWorkspace) { - config.gitBranches[branch].baseUrl = activeWorkspace.remote; - config.gitBranches[branch].workspaceId = activeWorkspace.workspaceId; + // Step 1: Pick workspace profile (the source of baseUrl/workspaceId/token) + const profiles = await allWorkspaces(opts.configDir); + let selectedProfile: Workspace | undefined; + + if (profiles.length === 0) { + // No profiles — run wmill workspace add flow + log.info(colors.yellow("No workspace profiles found. Let's create one.")); + await add(opts as any, undefined, undefined, undefined); + // Re-read profiles after add + const updatedProfiles = await allWorkspaces(opts.configDir); + selectedProfile = updatedProfiles.length > 0 + ? await getActiveWorkspace(opts) + : undefined; + if (!selectedProfile) { + log.error(colors.red("Profile creation failed or was cancelled.")); + return; + } + } else if (opts.workspace && profiles.find((p) => p.name === opts.workspace)) { + // --workspace flag matches a profile name: use it directly + selectedProfile = profiles.find((p) => p.name === opts.workspace); + } else if (isInteractive) { + const { Select } = await import("@cliffy/prompt/select"); + const activeProfile = await getActiveWorkspace(opts); + const selectedName = await Select.prompt({ + message: "Select workspace profile to bind", + options: profiles.map((p) => ({ + name: `${p.name} (${p.workspaceId} on ${p.remote})`, + value: p.name, + })), + default: activeProfile?.name, + }); + selectedProfile = profiles.find((p) => p.name === selectedName); + } else { + // Non-interactive: use active profile + selectedProfile = await getActiveWorkspaceOrFallback(opts); + } + + if (!selectedProfile) { + log.error(colors.red("No workspace profile selected. Aborting.")); + return; + } + + // Step 2: Pick workspace name (the key in wmill.yaml workspaces section) + let wsName: string; + if (opts.workspace) { + wsName = opts.workspace; + } else if (isInteractive) { + const { Input } = await import("@cliffy/prompt/input"); + wsName = await Input.prompt({ + message: "Workspace name (key in wmill.yaml)", + default: selectedProfile.workspaceId, + }); + } else { + wsName = selectedProfile.workspaceId; + } + + // Step 3: Pick git branch (only in git repos) + let gitBranch: string | undefined; + if (inGitRepo) { + if (opts.branch) { + if (opts.branch !== wsName) { + gitBranch = opts.branch; + } + } else if (isInteractive) { + const { Input } = await import("@cliffy/prompt/input"); + const branchInput = await Input.prompt({ + message: "Git branch to associate", + default: currentBranch ?? wsName, + }); + if (branchInput !== wsName) { + gitBranch = branchInput; + } + } else if (currentBranch && currentBranch !== wsName) { + gitBranch = currentBranch; + } + } + + // Step 4: Write the entry + const entry = (config.workspaces as any)[wsName] ?? {}; + entry.baseUrl = selectedProfile.remote; + if (selectedProfile.workspaceId !== wsName) { + entry.workspaceId = selectedProfile.workspaceId; + } else { + delete entry.workspaceId; // clean up if it matches + } + if (gitBranch) { + entry.gitBranch = gitBranch; + } else { + delete entry.gitBranch; // clean up if it matches + } + (config.workspaces as any)[wsName] = entry; log.info( colors.green( - `✓ Bound branch '${branch}' to workspace '${activeWorkspace.name}'\n` + - ` ${activeWorkspace.workspaceId} on ${activeWorkspace.remote}` + `✓ Bound workspace '${wsName}'` + + (gitBranch ? ` (gitBranch: ${gitBranch})` : "") + + ` → ${selectedProfile.workspaceId} on ${selectedProfile.remote}` ) ); } else { - // Unbind - delete config.gitBranches[branch].baseUrl; - delete config.gitBranches[branch].workspaceId; + // ── UNBIND ──────────────────────────────────────────────── + let wsName: string | undefined; - log.info( - colors.green(`✓ Removed workspace binding from branch '${branch}'`) - ); + if (opts.workspace) { + wsName = opts.workspace; + } else if (currentBranch) { + const match = findWorkspaceByGitBranch(config.workspaces, currentBranch); + wsName = match?.[0]; + } + + if (!wsName && isInteractive) { + const names = getWorkspaceNames(config.workspaces); + if (names.length === 0) { + log.error(colors.red("No workspaces configured in wmill.yaml.")); + return; + } + const { Select } = await import("@cliffy/prompt/select"); + wsName = await Select.prompt({ + message: "Select workspace to unbind", + options: names, + }); + } + + if (!wsName || !(config.workspaces as any)[wsName]) { + log.error(colors.red( + wsName + ? `Workspace '${wsName}' not found in wmill.yaml.` + : "Could not determine workspace. Use --workspace to specify." + )); + return; + } + + const entry = (config.workspaces as any)[wsName]; + delete entry.baseUrl; + delete entry.workspaceId; + log.info(colors.green(`✓ Removed binding from workspace '${wsName}'`)); } - // Write back the updated config - const { stringify: yamlStringify } = await import("yaml"); try { await writeFile("wmill.yaml", yamlStringify(config), "utf-8"); } catch (error) { log.error(colors.red(`Failed to save configuration: ${(error as Error).message}`)); return; } + + // After a successful bind, offer to generate resource type namespace + if (doBind && isInteractive) { + const { stat: statFile } = await import("node:fs/promises"); + const rtExists = await statFile("rt.d.ts").then(() => true, () => false); + const { Confirm } = await import("@cliffy/prompt/confirm"); + const generate = await Confirm.prompt({ + message: "Generate rt.d.ts? (TypeScript types for your workspace's resource types, useful for autocompletion)", + default: !rtExists, + }); + if (generate) { + try { + const { generateRTNamespace } = await import("../resource-type/resource-type.ts"); + await generateRTNamespace(opts); + } catch (error) { + log.warn( + `Could not generate resource type namespace: ${ + error instanceof Error ? error.message : error + }` + ); + } + } + } } const command = new Command() @@ -638,12 +750,13 @@ const command = new Command() .description("List forked workspaces on the remote server") .action(listForks as any) .command("bind") - .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)") + .description("Create or update a workspace entry in wmill.yaml from the active profile") + .option("--workspace ", "Workspace name (default: current branch or workspaceId)") + .option("--branch ", "Git branch to associate (default: workspace name)") .action((opts) => bind(opts as any, true)) .command("unbind") - .description("Remove workspace binding from the current Git branch") - .option("--branch, --env ", "Specify branch/environment (defaults to current)") + .description("Remove baseUrl and workspaceId from a workspace entry") + .option("--workspace ", "Workspace to unbind") .action((opts) => bind(opts as any, false)) .command("fork") .description("Create a forked workspace") diff --git a/cli/src/core/auth.ts b/cli/src/core/auth.ts index 037555a1a9..dcf8c40391 100644 --- a/cli/src/core/auth.ts +++ b/cli/src/core/auth.ts @@ -2,12 +2,56 @@ import { colors } from "@cliffy/ansi/colors"; import * as log from "./log.ts"; import { setClient } from "./client.ts"; import * as wmill from "../../gen/services.gen.ts"; -import { GlobalUserInfo } from "../../gen/types.gen.ts"; +import { GlobalUserInfo, User } from "../../gen/types.gen.ts"; import { loginInteractive, tryGetLoginInfo } from "./login.ts"; import { GlobalOptions } from "../types.ts"; +// Workspace-scoped tokens (tokens bound to a workspace via token.workspace_id) +// cannot call /api/users/whoami because the backend rejects any token with a +// workspace binding when the request path has no workspace in it. Fall back to +// the workspace-scoped whoami endpoint in that case so the CLI still works. +// Uses a name-based ApiError check rather than `instanceof` to match the +// pattern in cli/src/main.ts: bundling (bun build for npm, JSR dev path) can +// produce multiple module instances of gen/core/ApiError.ts, making +// `instanceof` silently return false and reintroducing the bug this fixes. +async function fetchWhoami(workspaceId: string): Promise { + try { + return await wmill.globalWhoami(); + } catch (error) { + if ( + error && typeof error === "object" && + "name" in error && (error as { name: unknown }).name === "ApiError" && + (error as { status?: number }).status === 401 + ) { + const user = await wmill.whoami({ workspace: workspaceId }); + return workspaceUserToGlobalUserInfo(user); + } + throw error; + } +} + +// Adapter for the 401 fallback path. `login_type`, `verified`, `first_time_user` +// and `role_source` are NOT derivable from the workspace-scoped User response +// and are filled with best-effort defaults — do not trust them downstream after +// a fallback whoami. Today only cli/src/commands/hub/hub.ts reads this return +// value, and only `.email`. +function workspaceUserToGlobalUserInfo(user: User): GlobalUserInfo { + return { + email: user.email, + login_type: "password", + super_admin: user.is_super_admin, + verified: true, + name: user.name, + username: user.username, + operator_only: user.operator, + first_time_user: false, + role_source: "manual", + disabled: user.disabled, + }; +} + /** * Main authentication function - moved from context.ts to break circular dependencies * This function maintains the original API signature from context.ts @@ -28,7 +72,7 @@ export async function requireLogin( setClient(token, workspace.remote.substring(0, workspace.remote.length - 1)); try { - return await wmill.globalWhoami(); + return await fetchWhoami(workspace.workspaceId); } catch (error) { // Check for network errors and provide clearer messages const errorMsg = error instanceof Error ? error.message : String(error); @@ -62,6 +106,6 @@ export async function requireLogin( newToken, workspace.remote.substring(0, workspace.remote.length - 1) ); - return await wmill.globalWhoami(); + return await fetchWhoami(workspace.workspaceId); } } diff --git a/cli/src/core/conf.ts b/cli/src/core/conf.ts index dbcb30636b..cc3b455ee7 100644 --- a/cli/src/core/conf.ts +++ b/cli/src/core/conf.ts @@ -28,6 +28,44 @@ export function setShowDiffs(value: boolean) { showDiffs = value; } +export interface SpecificItemsConfig_Yaml { + variables?: string[]; + resources?: string[]; + triggers?: string[]; + schedules?: string[]; + folders?: string[]; + settings?: boolean; +} + +export interface WorkspaceEntryConfig extends SyncOptions { + gitBranch?: string; + workspaceId?: string; + baseUrl?: string; + overrides?: Partial; + promotionOverrides?: Partial; + specificItems?: SpecificItemsConfig_Yaml; + defaultPermissionedAs?: PermissionedAsRule[]; +} + +export type WorkspacesConfig = { + commonSpecificItems?: SpecificItemsConfig_Yaml; +} & { + [workspaceName: string]: WorkspaceEntryConfig; +}; + +// Legacy type alias for backward compat +type LegacyBranchesConfig = { + commonSpecificItems?: SpecificItemsConfig_Yaml; +} & { + [branchName: string]: SyncOptions & { + overrides?: Partial; + promotionOverrides?: Partial; + baseUrl?: string; + workspaceId?: string; + specificItems?: SpecificItemsConfig_Yaml; + }; +}; + export interface SyncOptions { stateful?: boolean; raw?: boolean; @@ -62,59 +100,12 @@ export interface SyncOptions { parallel?: number; jsonOutput?: boolean; nonDottedPaths?: boolean; - gitBranches?: { - commonSpecificItems?: { - variables?: string[]; - resources?: string[]; - triggers?: string[]; - schedules?: string[]; - folders?: string[]; - settings?: boolean; - }; - } & { - [branchName: string]: SyncOptions & { - overrides?: Partial; - promotionOverrides?: Partial; - baseUrl?: string; - workspaceId?: string; - specificItems?: { - variables?: string[]; - resources?: string[]; - triggers?: string[]; - schedules?: string[]; - folders?: string[]; - settings?: boolean; - }; - }; - }; - // Alias for gitBranches - for users who prefer environment-based terminology - environments?: SyncOptions["gitBranches"]; - // Legacy field - deprecated, use gitBranches instead - git_branches?: { - commonSpecificItems?: { - variables?: string[]; - resources?: string[]; - triggers?: string[]; - schedules?: string[]; - folders?: string[]; - settings?: boolean; - }; - } & { - [branchName: string]: SyncOptions & { - overrides?: Partial; - promotionOverrides?: Partial; - baseUrl?: string; - workspaceId?: string; - specificItems?: { - variables?: string[]; - resources?: string[]; - triggers?: string[]; - schedules?: string[]; - folders?: string[]; - settings?: boolean; - }; - }; - }; + // Primary config key — maps workspace names to workspace configurations + workspaces?: WorkspacesConfig; + // Deprecated aliases — normalized to `workspaces` in readConfigFile + gitBranches?: LegacyBranchesConfig; + environments?: LegacyBranchesConfig; + git_branches?: LegacyBranchesConfig; promotion?: string; lint?: boolean; locksRequired?: boolean; @@ -207,6 +198,8 @@ export function getWmillYamlPath(): string | null { return findWmillYaml(); } +let legacyConfigWarned = false; + export async function readConfigFile(opts?: { warnIfMissing?: boolean }): Promise { const warnIfMissing = opts?.warnIfMissing ?? true; try { @@ -224,10 +217,6 @@ export async function readConfigFile(opts?: { warnIfMissing?: boolean }): Promis const conf = (await yamlParseFile(wmillYamlPath)) as SyncOptions; - // Handle legacy format migrations (combine overrides and git_branches) - let needsConfigWrite = false; - const migrationMessages: string[] = []; - // Handle obsolete overrides format if (conf && "overrides" in conf) { const overrides = conf.overrides as any; @@ -239,81 +228,55 @@ export async function readConfigFile(opts?: { warnIfMissing?: boolean }): Promis if (hasSettings) { throw new Error( "❌ The 'overrides' field is no longer supported.\n" + - " The configuration system now uses Git branch-based configuration only.\n" + + " The configuration system now uses workspace-based configuration.\n" + " Please delete your wmill.yaml and run 'wmill init' to recreate it with the new format." ); } else { - // Remove empty overrides delete conf.overrides; - needsConfigWrite = true; - migrationMessages.push( - "ℹ️ Removing empty 'overrides: {}' from wmill.yaml (migrated to gitBranches format)" - ); } } - // Handle environments -> gitBranches alias (permanent alias, not a deprecation) - if (conf && "environments" in conf) { - if (!conf.gitBranches) { - conf.gitBranches = conf.environments as any; - } else { - log.warn( - "⚠️ Both 'environments' and 'gitBranches' found in wmill.yaml. Using 'gitBranches' and ignoring 'environments'." - ); - } - delete (conf as any).environments; - } + // Normalize legacy formats to `workspaces` in memory (no file rewrite). + // Priority: workspaces > gitBranches > environments > git_branches + if (conf && !conf.workspaces) { + let legacyKey: string | null = null; + let legacyData: LegacyBranchesConfig | undefined; - // Handle git_branches to gitBranches migration - if (conf && "git_branches" in conf) { - if (!conf.gitBranches) { - // Deep copy git_branches to gitBranches (even if empty) - conf.gitBranches = JSON.parse(JSON.stringify(conf.git_branches)); - needsConfigWrite = true; - migrationMessages.push( - "⚠️ Migrating 'git_branches' to 'gitBranches' (camelCase). The snake_case format is deprecated." - ); - migrationMessages.push( - "✅ Successfully migrated 'git_branches' to 'gitBranches' in wmill.yaml" - ); - } else { - migrationMessages.push( - "⚠️ Both 'git_branches' and 'gitBranches' found in wmill.yaml. Using 'gitBranches' and ignoring 'git_branches'." - ); + if (conf.gitBranches) { + legacyKey = "gitBranches"; + legacyData = conf.gitBranches; + } else if (conf.environments) { + legacyKey = "environments"; + legacyData = conf.environments; + } else if (conf.git_branches) { + legacyKey = "git_branches"; + legacyData = conf.git_branches; } - // Always remove the old field from config object (both file and memory) - delete conf.git_branches; - } - // Perform single atomic write if any migrations are needed - if (needsConfigWrite) { - try { - await writeFile(wmillYamlPath, yamlStringify(conf), "utf-8"); - // Log all migration messages after successful write - migrationMessages.forEach((msg) => { - if (msg.startsWith("⚠️")) { - log.warn(msg); - } else { - log.info(msg); - } - }); - } catch (error) { - log.warn( - `Could not update wmill.yaml to apply migrations: ${ - error instanceof Error ? error.message : error - }` - ); - } - } else if (migrationMessages.length > 0) { - // Log messages for non-write cases (like "both found") - migrationMessages.forEach((msg) => { - if (msg.startsWith("⚠️")) { - log.warn(msg); - } else { - log.info(msg); + if (legacyKey && legacyData) { + conf.workspaces = convertGitBranchesToWorkspaces(legacyData); + if (!legacyConfigWarned) { + log.warn( + `⚠️ '${legacyKey}' in wmill.yaml is deprecated. Use 'workspaces' instead.\n` + + ` Run 'wmill config migrate' to update your configuration automatically.` + ); + legacyConfigWarned = true; } - }); + } + } else if (conf?.workspaces) { + // If both workspaces and any legacy key exist, warn and use workspaces + for (const legacyKey of ["gitBranches", "environments", "git_branches"] as const) { + if ((conf as any)[legacyKey]) { + log.warn( + `⚠️ Both 'workspaces' and '${legacyKey}' found in wmill.yaml. Using 'workspaces' and ignoring '${legacyKey}'.` + ); + } + } } + // Clean legacy keys from in-memory config + delete conf?.gitBranches; + delete (conf as any)?.environments; + delete conf?.git_branches; if (conf?.defaultTs == undefined) { log.warn( @@ -426,18 +389,18 @@ export async function mergeConfigWithConfigFile( return Object.assign(configFile ?? {}, opts); } -// Validate branch configuration early in the process +// Validate workspace configuration early in the process export async function validateBranchConfiguration( opts: Pick, - branchOverride?: string + workspaceNameOverride?: string ): Promise { - // When branch override is provided, skip validation - user is explicitly specifying the branch - if (opts.skipBranchValidation || branchOverride || !isGitRepository()) { + // When workspace override is provided, skip validation - user is explicitly specifying the workspace + if (opts.skipBranchValidation || workspaceNameOverride || !isGitRepository()) { return; } const config = await readConfigFile(); - const { gitBranches } = config; + const { workspaces } = config; const rawBranch = getCurrentGitBranch(); @@ -446,37 +409,66 @@ export async function validateBranchConfiguration( let currentBranch: string | null; if (originalBranchIfForked) { log.info( - `Workspace fork detected from branch name \`${rawBranch}\`. Validating branch configuration using original branch \`${originalBranchIfForked}\`` + `Workspace fork detected from branch name \`${rawBranch}\`. Validating workspace configuration using original branch \`${originalBranchIfForked}\`` ); currentBranch = originalBranchIfForked; } else { currentBranch = rawBranch; } - // In a git repository, gitBranches section is recommended - if (!gitBranches || Object.keys(gitBranches).length === 0) { + // In a git repository, workspaces section is recommended + if (!workspaces || getWorkspaceNames(workspaces).length === 0) { log.warn( - "⚠️ WARNING: In a Git repository, the 'gitBranches' section is recommended in wmill.yaml.\n" + - " Consider adding a gitBranches section with configuration for your Git branches.\n" + - " Run 'wmill init' to recreate the configuration file with proper branch setup." + "⚠️ WARNING: In a Git repository, the 'workspaces' section is recommended in wmill.yaml.\n" + + " Consider adding a workspaces section to map workspace names to Windmill instances.\n" + + " Run 'wmill init' to recreate the configuration file with proper workspace setup." ); return; } - // Current branch must be defined in gitBranches config - if (currentBranch && !gitBranches[currentBranch]) { - // In interactive mode, offer to create the branch + // Warn if multiple workspaces map to the same git branch + const branchToWsNames = new Map(); + for (const name of getWorkspaceNames(workspaces)) { + const entry = workspaces[name] as WorkspaceEntryConfig; + const branch = getEffectiveGitBranch(name, entry); + const existing = branchToWsNames.get(branch); + if (existing) { + existing.push(name); + } else { + branchToWsNames.set(branch, [name]); + } + } + for (const [branch, names] of branchToWsNames) { + if (names.length > 1) { + log.warn( + `⚠️ WARNING: Multiple workspaces map to git branch '${branch}': ${names.join(", ")}.\n` + + ` Only the first ('${names[0]}') will be used during auto-detection. Use --workspace to select explicitly.` + ); + } + } + + // Current branch must match a configured workspace's gitBranch + if (currentBranch && !findWorkspaceByGitBranch(workspaces, currentBranch)) { + const wsNames = getWorkspaceNames(workspaces); + const availableInfo = wsNames + .map((n) => { + const entry = workspaces[n] as WorkspaceEntryConfig; + const branch = getEffectiveGitBranch(n, entry); + return branch !== n ? `${n} (gitBranch: ${branch})` : n; + }) + .join(", "); + + // In interactive mode, offer to create a workspace entry if (!!process.stdin.isTTY) { - const availableBranches = Object.keys(gitBranches).join(", "); log.info( - `Current Git branch '${currentBranch}' is not defined in the gitBranches configuration.\n` + - `Available branches: ${availableBranches}` + `Current Git branch '${currentBranch}' does not match any workspace in the configuration.\n` + + `Available workspaces: ${availableInfo}` ); const shouldCreate = opts.yes || (await Confirm.prompt({ - message: `Create empty branch configuration for '${currentBranch}'?`, + message: `Create empty workspace configuration for branch '${currentBranch}'?`, default: true, })); @@ -498,22 +490,22 @@ export async function validateBranchConfiguration( ); } - // Read current config, add branch, and write it back + // Read current config, add workspace entry, and write it back const currentConfig = await readConfigFile(); - if (!currentConfig.gitBranches) { - currentConfig.gitBranches = {}; + if (!currentConfig.workspaces) { + currentConfig.workspaces = {} as WorkspacesConfig; } - currentConfig.gitBranches[currentBranch] = { overrides: {} }; + (currentConfig.workspaces as any)[currentBranch] = {}; await writeFile("wmill.yaml", yamlStringify(currentConfig), "utf-8"); log.info( - `✅ Created empty branch configuration for '${currentBranch}'` + `✅ Created empty workspace configuration for '${currentBranch}'` ); } else { log.warn( - "⚠️ WARNING: Branch creation cancelled. You can manually add the branch to wmill.yaml or use 'wmill gitsync-settings pull' to pull configuration from an existing windmill workspace git-sync configuration." + "⚠️ WARNING: Workspace creation cancelled. You can manually add a workspace to the 'workspaces' section in wmill.yaml or use 'wmill gitsync-settings pull' to pull configuration from an existing windmill workspace git-sync configuration." ); return; } @@ -533,131 +525,181 @@ export async function validateBranchConfiguration( } log.warn( - `⚠️ WARNING: Current Git branch '${currentBranch}' is not defined in the gitBranches configuration.\n` + - ` Consider adding configuration for branch '${currentBranch}' in the gitBranches section of wmill.yaml.\n` + - ` Available branches: ${Object.keys(gitBranches).join(", ")}` + `⚠️ WARNING: Current Git branch '${currentBranch}' does not match any workspace in the configuration.\n` + + ` Consider adding a workspace entry for branch '${currentBranch}' in the 'workspaces' section of wmill.yaml.\n` + + ` Available workspaces: ${availableInfo}` ); return; } } } -// Get effective settings by merging top-level settings with branch-specific overrides +// Get effective settings by merging top-level settings with workspace-specific overrides. +// workspaceNameOverride selects a workspace by name directly. +// When not provided, auto-detects from the current git branch. export async function getEffectiveSettings( config: SyncOptions, promotion?: string, skipBranchValidation?: boolean, suppressLogs?: boolean, - branchOverride?: string, - targetWorkspace?: { workspaceId: string; remote: string }, + workspaceNameOverride?: string ): Promise { // Start with top-level settings from config - const { gitBranches, ...topLevelSettings } = config; + const { workspaces, ...topLevelSettings } = config; const effective = { ...topLevelSettings }; - // Determine the branch to use: branchOverride takes precedence, then git detection - let currentBranch: string | null = null; + // Resolve the workspace entry to use + let resolvedWsName: string | null = null; + let resolvedWsEntry: WorkspaceEntryConfig | null = null; let originalBranchIfForked: string | null = null; let rawGitBranch: string | null = null; - if (branchOverride) { - currentBranch = branchOverride; - // Note: "Using branch override" is logged in context.ts when resolving workspace + if (workspaceNameOverride) { + // Direct lookup by workspace name + if (workspaces && (workspaces as any)[workspaceNameOverride]) { + resolvedWsName = workspaceNameOverride; + resolvedWsEntry = (workspaces as any)[workspaceNameOverride] as WorkspaceEntryConfig; + } } else if (isGitRepository()) { rawGitBranch = getCurrentGitBranch(); originalBranchIfForked = getOriginalBranchForWorkspaceForks(rawGitBranch); + const branch = originalBranchIfForked ?? rawGitBranch; if (originalBranchIfForked) { log.info( `Using overrides from original branch \`${originalBranchIfForked}\`` ); - currentBranch = originalBranchIfForked; - } else { - currentBranch = rawGitBranch; + } + + if (branch) { + const match = findWorkspaceByGitBranch(workspaces, branch); + if (match) { + [resolvedWsName, resolvedWsEntry] = match; + } } } else { - log.debug("Not in a Git repository and no branch override provided, using top-level settings"); + log.debug("Not in a Git repository and no workspace override provided, using top-level settings"); } // If promotion is specified, use that branch's promotionOverrides or overrides - if (promotion && gitBranches && gitBranches[promotion]) { - const targetBranch = gitBranches[promotion]; + if (promotion && workspaces) { + const promotionMatch = findWorkspaceByGitBranch(workspaces, promotion); + if (promotionMatch) { + const [, targetWs] = promotionMatch; - // First try promotionOverrides, then fall back to overrides - if (targetBranch.promotionOverrides) { - Object.assign(effective, targetBranch.promotionOverrides); - if (!suppressLogs) { - log.info(`Applied promotion settings from branch: ${promotion}`); - } - } else if (targetBranch.overrides) { - Object.assign(effective, targetBranch.overrides); - if (!suppressLogs) { - log.info( - `Applied settings from branch: ${promotion} (no promotionOverrides found)` + // First try promotionOverrides, then fall back to overrides + if (targetWs.promotionOverrides) { + Object.assign(effective, targetWs.promotionOverrides); + if (!suppressLogs) { + log.info(`Applied promotion settings from workspace for branch: ${promotion}`); + } + } else if (targetWs.overrides) { + Object.assign(effective, targetWs.overrides); + if (!suppressLogs) { + log.info( + `Applied settings from workspace for branch: ${promotion} (no promotionOverrides found)` + ); + } + } else { + log.debug( + `No promotion or regular overrides found for '${promotion}', using top-level settings` ); } - } else { - log.debug( - `No promotion or regular overrides found for branch '${promotion}', using top-level settings` - ); - } - // Branch-level defaultPermissionedAs takes priority over root-level - if (targetBranch.defaultPermissionedAs) { - effective.defaultPermissionedAs = targetBranch.defaultPermissionedAs; + // Workspace-level defaultPermissionedAs takes priority over root-level + if (targetWs.defaultPermissionedAs) { + effective.defaultPermissionedAs = targetWs.defaultPermissionedAs; + } } } - // Otherwise use current branch overrides (existing behavior) - else if ( - currentBranch && - gitBranches && - gitBranches[currentBranch] - ) { - if (gitBranches[currentBranch].overrides) { - Object.assign(effective, gitBranches[currentBranch].overrides); + // Otherwise use resolved workspace's overrides + else if (resolvedWsEntry) { + if (resolvedWsEntry.overrides) { + Object.assign(effective, resolvedWsEntry.overrides); if (!suppressLogs) { const extraLog = originalBranchIfForked ? ` (because it is the origin of the workspace fork branch \`${rawGitBranch}\`)` : ""; log.info( - `Applied settings for Git branch: ${currentBranch}${extraLog}` + `Applied settings for workspace '${resolvedWsName}'${extraLog}` ); } - } else { + } else if (resolvedWsName) { log.debug( - `No branch-specific overrides found for '${currentBranch}', using top-level settings` + `No overrides found for workspace '${resolvedWsName}', using top-level settings` ); } - // Branch-level defaultPermissionedAs takes priority over root-level - if (gitBranches[currentBranch].defaultPermissionedAs) { - effective.defaultPermissionedAs = gitBranches[currentBranch].defaultPermissionedAs; - } - } else if (currentBranch) { - log.debug( - `No branch-specific overrides found for '${currentBranch}', using top-level settings` - ); - } - - // If a target workspace is provided, resolve defaultPermissionedAs from the - // branch config that matches it (rules are workspace-specific) - if (targetWorkspace && gitBranches) { - const matchingEntry = Object.entries(gitBranches).find( - ([_, branchConfig]) => - branchConfig.workspaceId === targetWorkspace.workspaceId && - branchConfig.baseUrl === targetWorkspace.remote - ); - if (matchingEntry) { - const [, branchConfig] = matchingEntry; - if (branchConfig.defaultPermissionedAs) { - effective.defaultPermissionedAs = branchConfig.defaultPermissionedAs; - } else { - // Matching branch has no rules — fall back to root-level - effective.defaultPermissionedAs = topLevelSettings.defaultPermissionedAs; - } - } else { - // No branch matches the target workspace — use root-level rules - effective.defaultPermissionedAs = topLevelSettings.defaultPermissionedAs; + // Workspace-level defaultPermissionedAs takes priority over root-level + if (resolvedWsEntry.defaultPermissionedAs) { + effective.defaultPermissionedAs = resolvedWsEntry.defaultPermissionedAs; } } return effective; } + +const RESERVED_WORKSPACE_KEYS = new Set(["commonSpecificItems"]); + +/** + * Find a workspace config entry whose effective git branch matches branchName. + * Returns [workspaceName, config] or undefined. + */ +export function findWorkspaceByGitBranch( + workspaces: WorkspacesConfig | undefined, + branchName: string +): [string, WorkspaceEntryConfig] | undefined { + if (!workspaces) return undefined; + for (const [name, entry] of Object.entries(workspaces)) { + if (RESERVED_WORKSPACE_KEYS.has(name)) continue; + const effectiveBranch = (entry as WorkspaceEntryConfig).gitBranch ?? name; + if (effectiveBranch === branchName) { + return [name, entry as WorkspaceEntryConfig]; + } + } + return undefined; +} + +/** Get the effective workspaceId for a workspace entry (defaults to workspace name). */ +export function getEffectiveWorkspaceId( + workspaceName: string, + config: WorkspaceEntryConfig +): string { + return config.workspaceId ?? workspaceName; +} + +/** Get the effective git branch for a workspace entry (defaults to workspace name). */ +export function getEffectiveGitBranch( + workspaceName: string, + config: WorkspaceEntryConfig +): string { + return config.gitBranch ?? workspaceName; +} + +/** Get all workspace names from config, excluding reserved keys like commonSpecificItems. */ +export function getWorkspaceNames( + workspaces: WorkspacesConfig | undefined +): string[] { + if (!workspaces) return []; + return Object.keys(workspaces).filter((k) => !RESERVED_WORKSPACE_KEYS.has(k)); +} + +/** + * Convert legacy gitBranches/environments format to the new workspaces format. + * Since old keys were branch names, workspace name = branch name. + * gitBranch is not set (defaults to key name). All existing fields are preserved. + */ +export function convertGitBranchesToWorkspaces( + gitBranches: LegacyBranchesConfig +): WorkspacesConfig { + const workspaces: Record = {}; + for (const [key, value] of Object.entries(gitBranches)) { + if (key === "commonSpecificItems") { + workspaces.commonSpecificItems = value; + continue; + } + // Copy the entire entry as-is. Old format keys = branch names, so gitBranch + // doesn't need to be set (defaults to key name). All SyncOptions fields, + // overrides, promotionOverrides, specificItems, baseUrl, workspaceId are preserved. + workspaces[key] = { ...value }; + } + return workspaces as WorkspacesConfig; +} diff --git a/cli/src/core/context.ts b/cli/src/core/context.ts index b65cdcab49..dc822e5b98 100644 --- a/cli/src/core/context.ts +++ b/cli/src/core/context.ts @@ -17,7 +17,12 @@ import { addWorkspace, } from "../commands/workspace/workspace.ts"; import { getLastUsedProfile, setLastUsedProfile } from "./branch-profiles.ts"; -import { readConfigFile } from "./conf.ts"; +import { + readConfigFile, + findWorkspaceByGitBranch, + getEffectiveWorkspaceId, + WorkspaceEntryConfig, +} from "./conf.ts"; import { getCurrentGitBranch, getOriginalBranchForWorkspaceForks, @@ -207,11 +212,71 @@ async function tryResolveWorkspace( if (cache) return { isError: false, value: cache }; if (opts.workspace) { + // First try: look up workspace by name in wmill.yaml workspaces config + const config = await readConfigFile({ warnIfMissing: false }); + const wsEntry = config.workspaces?.[opts.workspace] as WorkspaceEntryConfig | undefined; + if (wsEntry?.baseUrl) { + const workspaceId = getEffectiveWorkspaceId(opts.workspace, wsEntry); + let normalizedBaseUrl: string; + try { + normalizedBaseUrl = new URL(wsEntry.baseUrl).toString(); + } catch { + return { + isError: true, + error: colors.red.underline(`Invalid baseUrl in workspace '${opts.workspace}' configuration: ${wsEntry.baseUrl}`), + }; + } + + // Find matching profile by baseUrl + workspaceId + const allProfs = await allWorkspaces(opts.configDir); + const matching = allProfs.filter( + (w) => w.remote === normalizedBaseUrl && w.workspaceId === workspaceId + ); + + if (matching.length >= 1) { + const selected = matching.length === 1 + ? matching[0] + : await selectFromMultipleProfiles( + matching, + normalizedBaseUrl, + workspaceId, + `workspace '${opts.workspace}'`, + opts.configDir + ); + log.info( + colors.green( + `Using workspace profile '${selected.name}' for workspace '${opts.workspace}' (${workspaceId} on ${normalizedBaseUrl})` + ) + ); + (opts as any).__secret_workspace = selected; + return { isError: false, value: selected }; + } + + // No matching profile — offer to create one + log.info( + `No profile found for workspace '${opts.workspace}' (${workspaceId} on ${normalizedBaseUrl})` + ); + const ws = await createWorkspaceProfileInteractively( + normalizedBaseUrl, + workspaceId, + opts.workspace, + opts, + { rawBranch: opts.workspace, isForked: false } + ); + if (ws) { + (opts as any).__secret_workspace = ws; + return { isError: false, value: ws }; + } + } + + // Fall back: look up profile by name directly (old behavior) const e = await getWorkspaceByName(opts.workspace, opts.configDir); if (!e) { return { isError: true, - error: colors.red.underline("Given workspace does not exist."), + error: colors.red.underline( + `Workspace '${opts.workspace}' not found in wmill.yaml config or local profiles.` + ), }; } (opts as any).__secret_workspace = e; @@ -227,17 +292,24 @@ async function tryResolveWorkspace( export async function tryResolveBranchWorkspace( opts: GlobalOptions, - branchOverride?: string + workspaceNameOverride?: string ): Promise { let rawBranch: string | null = null; - let currentBranch: string; + let wsName: string | undefined; + let wsEntry: WorkspaceEntryConfig | undefined; let originalBranchIfForked: string | null = null; let workspaceIdIfForked: string | null = null; - if (branchOverride) { - // Use branch override directly - currentBranch = branchOverride; - log.info(`Using branch override: ${branchOverride}`); + // Read wmill.yaml (silent — just probing) + const config = await readConfigFile({ warnIfMissing: false }); + + if (workspaceNameOverride) { + // Direct lookup by workspace name + wsEntry = config.workspaces?.[workspaceNameOverride] as WorkspaceEntryConfig | undefined; + if (wsEntry) { + wsName = workspaceNameOverride; + log.info(`Using workspace override: ${workspaceNameOverride}`); + } } else { // Only try branch-based resolution if in a Git repository if (!isGitRepository()) { @@ -252,37 +324,61 @@ export async function tryResolveBranchWorkspace( originalBranchIfForked = getOriginalBranchForWorkspaceForks(rawBranch); workspaceIdIfForked = getWorkspaceIdForWorkspaceForkFromBranchName(rawBranch); + + const branchToLookup = originalBranchIfForked ?? rawBranch; if (originalBranchIfForked) { log.info( - `Using original branch \`${originalBranchIfForked}\` for finding workspace profile from gitBranches section in wmill.yaml` + `Using original branch \`${originalBranchIfForked}\` for finding workspace from workspaces section in wmill.yaml` ); - currentBranch = originalBranchIfForked; - } else { - currentBranch = rawBranch; + } + + const match = findWorkspaceByGitBranch(config.workspaces, branchToLookup); + if (match) { + [wsName, wsEntry] = match; } } - // 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 - if (!branchConfig?.baseUrl || !branchConfig?.workspaceId) { + if (!wsName || !wsEntry) { return undefined; } - log.info( - `Using branch configuration for branch \`${currentBranch}\` set in gitBranches` - ); + if (!wsEntry.baseUrl) { + if (workspaceNameOverride) { + // User explicitly asked for this workspace but it has no baseUrl + log.warn( + `⚠️ Workspace '${wsName}' has no baseUrl configured. Cannot resolve a profile.\n` + + ` Add baseUrl to workspace '${wsName}' in wmill.yaml, or use --base-url flag.` + ); + } + return undefined; + } - const { baseUrl, workspaceId } = branchConfig; + const workspaceId = getEffectiveWorkspaceId(wsName, wsEntry); + const effectiveGitBranch = (wsEntry as any).gitBranch ?? wsName; + const { baseUrl } = wsEntry; + + // Explain why this workspace was selected + let reason: string; + if (workspaceNameOverride) { + reason = `selected via --workspace`; + } else if (originalBranchIfForked) { + reason = `matched via fork branch '${rawBranch}' → original branch '${originalBranchIfForked}'`; + } else if (effectiveGitBranch !== wsName) { + reason = `matched git branch '${effectiveGitBranch}' on current branch '${rawBranch}'`; + } else { + reason = `matched current git branch '${rawBranch}'`; + } + + log.info( + `Using workspace '${wsName}' (${reason}) → ${workspaceId} on ${baseUrl}` + ); let normalizedBaseUrl: string; try { normalizedBaseUrl = new URL(baseUrl).toString(); } catch (error) { log.error( - colors.red(`Invalid baseUrl in branch configuration: ${baseUrl}`) + colors.red(`Invalid baseUrl in workspace '${wsName}' configuration: ${baseUrl}`) ); return undefined; } @@ -298,26 +394,25 @@ export async function tryResolveBranchWorkspace( return await createWorkspaceProfileInteractively( normalizedBaseUrl, workspaceId, - currentBranch, + wsName, opts, - { rawBranch: rawBranch ?? currentBranch, isForked: !!originalBranchIfForked } + { rawBranch: rawBranch ?? wsName, isForked: !!originalBranchIfForked } ); } - // Handle multiple profiles - use special branch-aware logic + // Handle multiple profiles let selectedProfile: Workspace; if (matchingProfiles.length === 1) { selectedProfile = matchingProfiles[0]; log.info( colors.green( - `Using workspace profile '${selectedProfile.name}' for branch '${currentBranch}' with workspace id \`${workspaceId}\`` + `Using workspace profile '${selectedProfile.name}' for workspace '${wsName}' with workspace id \`${workspaceId}\`` ) ); } else { - // For multiple profiles, check branch-specific last used first const lastUsedName = await getLastUsedProfile( - currentBranch, + wsName, normalizedBaseUrl, workspaceId, opts.configDir @@ -330,25 +425,23 @@ export async function tryResolveBranchWorkspace( if (lastUsedProfile) { log.info( colors.green( - `Using workspace profile '${lastUsedProfile.name}' for branch '${currentBranch}' (last used)` + `Using workspace profile '${lastUsedProfile.name}' for workspace '${wsName}' (last used)` ) ); return lastUsedProfile; } } - // Fall back to general selection logic selectedProfile = await selectFromMultipleProfiles( matchingProfiles, normalizedBaseUrl, workspaceId, - `branch '${currentBranch}'`, + `workspace '${wsName}'`, opts.configDir ); - // Save branch-specific selection await setLastUsedProfile( - currentBranch, + wsName, normalizedBaseUrl, workspaceId, selectedProfile.name, @@ -357,7 +450,7 @@ export async function tryResolveBranchWorkspace( log.info( colors.green( - `Using workspace profile '${selectedProfile.name}' for branch '${currentBranch}'` + `Using workspace profile '${selectedProfile.name}' for workspace '${wsName}'` ) ); } @@ -375,7 +468,7 @@ export async function tryResolveBranchWorkspace( export async function resolveWorkspace( opts: GlobalOptions, - branchOverride?: string + workspaceNameOverride?: string ): Promise { const cache = (opts as any).__secret_workspace; if (cache) return cache; @@ -384,7 +477,7 @@ export async function resolveWorkspace( if (opts.workspace && opts.token) { let normalizedBaseUrl: string; try { - normalizedBaseUrl = new URL(opts.baseUrl).toString(); // add trailing slash if not present + normalizedBaseUrl = new URL(opts.baseUrl).toString(); } catch (error) { log.info(colors.red(`Invalid base URL: ${opts.baseUrl}`)); return process.exit(-1); @@ -392,19 +485,17 @@ export async function resolveWorkspace( // Try to find existing workspace profile by name, then by workspaceId + remote if (opts.workspace) { - // Try by workspace name first let existingWorkspace = await getWorkspaceByName( opts.workspace, opts.configDir ); - // If not found by name, try to find by workspaceId + remote match if (!existingWorkspace) { const { allWorkspaces } = await import( "../commands/workspace/workspace.ts" ); - const workspaces = await allWorkspaces(opts.configDir); - const matchingWorkspaces = workspaces.filter( + const profiles = await allWorkspaces(opts.configDir); + const matchingWorkspaces = profiles.filter( (w) => w.workspaceId === opts.workspace && w.remote === normalizedBaseUrl ); @@ -421,7 +512,6 @@ export async function resolveWorkspace( } if (existingWorkspace) { - // Validate that the base URL matches the profile's remote if (existingWorkspace.remote !== normalizedBaseUrl) { log.info( colors.red( @@ -430,15 +520,13 @@ export async function resolveWorkspace( ); return process.exit(-1); } - // Use the existing workspace profile (preserves workspace name) return { ...existingWorkspace, - token: opts.token, // Use the provided token + token: opts.token, }; } } - // No existing profile found, create temporary workspace return { remote: normalizedBaseUrl, workspaceId: opts.workspace, @@ -455,15 +543,15 @@ export async function resolveWorkspace( } } - const branch = branchOverride ?? getCurrentGitBranch(); + const branch = workspaceNameOverride ? null : 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 workspaceNameOverride // 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 || opts.workspace || !branch || !branch.startsWith(WM_FORK_PREFIX)) { + if (workspaceNameOverride || opts.workspace || !branch || !branch.startsWith(WM_FORK_PREFIX)) { return workspace; } else { log.info( @@ -472,8 +560,8 @@ export async function resolveWorkspace( } } else if (opts.workspace) { // --workspace was explicitly provided but not found — fail immediately - const workspaces = await allWorkspaces(opts.configDir); - const names = workspaces.map((w) => w.name); + const profiles = await allWorkspaces(opts.configDir); + const names = profiles.map((w) => w.name); let msg = `Workspace "${opts.workspace}" not found.`; const suggestions = names .map((n) => ({ name: n, dist: levenshteinDistance(opts.workspace!, n) })) @@ -484,37 +572,91 @@ export async function resolveWorkspace( msg += ` Did you mean: ${suggestions.map((s) => `"${s.name}"`).join(", ")}?`; } log.info(colors.red.bold(msg)); - if (workspaces.length > 0) { + if (profiles.length > 0) { log.info("\nAvailable workspaces:"); new Table() .header(["name", "remote", "workspace id"]) .padding(2) .border(true) - .body(workspaces.map((w) => [w.name, w.remote, w.workspaceId])) + .body(profiles.map((w) => [w.name, w.remote, w.workspaceId])) .render(); } return process.exit(-1); } // Try branch-based resolution (medium priority) - const branchWorkspace = await tryResolveBranchWorkspace(opts, branchOverride); + const branchWorkspace = await tryResolveBranchWorkspace(opts, workspaceNameOverride); if (branchWorkspace) { (opts as any).__secret_workspace = branchWorkspace; return branchWorkspace; - } else if (!branchOverride) { - // Only check for fork errors when not using branchOverride + } else if (!workspaceNameOverride) { + // Only check for fork errors when not using workspaceNameOverride const originalBranch = getOriginalBranchForWorkspaceForks(branch); if (originalBranch) { log.error( colors.red.bold( - `Failed to resolve workspace profile for workspace fork. This most likely means that the original branch \`${originalBranch}\` where \`${branch}\` is originally forked from, is not setup in the wmill.yaml. You need to update the \`gitBranches\` section for \`${originalBranch}\` to include workspaceId and baseUrl.` + `Failed to resolve workspace profile for workspace fork. The original branch \`${originalBranch}\` (forked from \`${branch}\`) is not configured in wmill.yaml.\n` + + ` Add to the 'workspaces' section: \`${originalBranch}: { baseUrl: "https://...", workspaceId: "..." }\`` ) ); return process.exit(-1); } } - // Fall back to active workspace + // If workspaces config exists, use it rather than falling back to active profile + const config = await readConfigFile({ warnIfMissing: false }); + const { getWorkspaceNames } = await import("./conf.ts"); + const wsNames = getWorkspaceNames(config.workspaces); + + if (wsNames.length > 0) { + let pickedWsName: string; + + const wsListStr = wsNames.map((n) => { + const entry = (config.workspaces as any)[n]; + const info = entry.baseUrl ? ` (${entry.workspaceId ?? n} on ${entry.baseUrl})` : ""; + return ` - ${n}${info}`; + }).join("\n"); + + if (wsNames.length === 1) { + pickedWsName = wsNames[0]; + log.info( + `Auto-selected workspace '${pickedWsName}' (only workspace in config).\n` + + `Use --workspace to override or 'wmill workspace bind' to add more workspaces.` + ); + } else if (process.stdin.isTTY) { + log.info( + `Multiple workspaces configured but none matched the current context.\n` + + `Configured workspaces:\n${wsListStr}\n` + + `Use --workspace to skip this prompt.` + ); + pickedWsName = await Select.prompt({ + message: "Select workspace", + options: wsNames.map((n) => { + const entry = (config.workspaces as any)[n]; + const info = entry.baseUrl ? ` (${entry.workspaceId ?? n} on ${entry.baseUrl})` : ""; + return { name: `${n}${info}`, value: n }; + }), + }); + } else { + log.error( + colors.red.bold( + `Multiple workspaces configured but none matched the current context.\n` + + `Configured workspaces:\n${wsListStr}\n` + + `Use --workspace to select one.` + ) + ); + return process.exit(-1); + } + + // Resolve the picked workspace via config + const pickedResult = await tryResolveBranchWorkspace(opts, pickedWsName); + if (pickedResult) { + (opts as any).__secret_workspace = pickedResult; + return pickedResult; + } + } + + // Fall back to active workspace (only when no workspaces config) const activeWorkspace = await getActiveWorkspace(opts); if (activeWorkspace) { (opts as any).__secret_workspace = activeWorkspace; @@ -522,7 +664,6 @@ export async function resolveWorkspace( } // Last resort: auto-configure from Windmill environment variables - // (set by the worker for bash/script execution) const envWorkspace = process.env["WM_WORKSPACE"]; const envToken = process.env["WM_TOKEN"]; const envBaseUrl = @@ -549,7 +690,6 @@ export async function resolveWorkspace( return ws; } - // If everything failed, show error log.info(colors.red.bold("No workspace given and no default set. Run 'wmill workspace add' to configure one.")); return process.exit(-1); } diff --git a/cli/src/core/specific_items.ts b/cli/src/core/specific_items.ts index dca403e905..3d0d9df248 100644 --- a/cli/src/core/specific_items.ts +++ b/cli/src/core/specific_items.ts @@ -1,7 +1,11 @@ import { minimatch } from "minimatch"; import { getCurrentGitBranch, isGitRepository } from "../utils/git.ts"; import { isFileResource, isFilesetResource } from "../utils/utils.ts"; -import { SyncOptions } from "./conf.ts"; +import { + SyncOptions, + findWorkspaceByGitBranch, + WorkspaceEntryConfig, +} from "./conf.ts"; import { TRIGGER_TYPES } from "../types.ts"; export interface SpecificItemsConfig { @@ -13,8 +17,8 @@ export interface SpecificItemsConfig { settings?: boolean; } -// Define all branch-specific file types (computed lazily) -function getBranchSpecificTypes() { +// Define all workspace-specific file types (computed lazily) +function getWorkspaceSpecificTypes() { return { variable: '.variable.yaml', resource: '.resource.yaml', @@ -44,7 +48,7 @@ function isScheduleFile(path: string): boolean { * Extract the file type suffix from a path */ function getFileTypeSuffix(path: string): string | null { - for (const [_, suffix] of Object.entries(getBranchSpecificTypes())) { + for (const [_, suffix] of Object.entries(getWorkspaceSpecificTypes())) { if (path.endsWith(suffix)) { return suffix; } @@ -68,35 +72,39 @@ function buildYamlTypePattern(): string { } /** - * Get the specific items configuration for the current git branch - * Merges commonSpecificItems with branch-specific specificItems + * Get the specific items configuration for the current workspace. + * workspaceNameOverride selects by workspace name (O(1)). + * When not provided, auto-detects from the current git branch. + * Merges commonSpecificItems with workspace-specific specificItems. */ -export function getSpecificItemsForCurrentBranch(config: SyncOptions, branchOverride?: string): SpecificItemsConfig | undefined { - if (!config.gitBranches) { +export function getSpecificItemsForCurrentBranch(config: SyncOptions, workspaceNameOverride?: string): SpecificItemsConfig | undefined { + if (!config.workspaces) { return undefined; } - // Use branch override if provided, otherwise detect from git - let currentBranch: string | null = null; - if (branchOverride) { - currentBranch = branchOverride; + let wsEntry: WorkspaceEntryConfig | undefined; + + if (workspaceNameOverride) { + wsEntry = config.workspaces[workspaceNameOverride] as WorkspaceEntryConfig | undefined; } else if (isGitRepository()) { - currentBranch = getCurrentGitBranch(); + const currentWorkspace = getCurrentGitBranch(); + if (currentWorkspace) { + const match = findWorkspaceByGitBranch(config.workspaces, currentWorkspace); + if (match) { + wsEntry = match[1]; + } + } } - if (!currentBranch) { + const commonItems = config.workspaces.commonSpecificItems; + const wsItems = wsEntry?.specificItems; + + // If neither common nor workspace-specific items exist, return undefined + if (!commonItems && !wsItems) { return undefined; } - const commonItems = config.gitBranches.commonSpecificItems; - const branchItems = config.gitBranches[currentBranch]?.specificItems; - - // If neither common nor branch-specific items exist, return undefined - if (!commonItems && !branchItems) { - return undefined; - } - - // Merge common and branch-specific items + // Merge common and workspace-specific items const merged: SpecificItemsConfig = {}; // Add common items @@ -119,25 +127,25 @@ export function getSpecificItemsForCurrentBranch(config: SyncOptions, branchOver merged.settings = commonItems.settings; } - // Add branch-specific items (extending common items) - if (branchItems?.variables) { - merged.variables = [...(merged.variables || []), ...branchItems.variables]; + // Add workspace-specific items (extending common items) + if (wsItems?.variables) { + merged.variables = [...(merged.variables || []), ...wsItems.variables]; } - if (branchItems?.resources) { - merged.resources = [...(merged.resources || []), ...branchItems.resources]; + if (wsItems?.resources) { + merged.resources = [...(merged.resources || []), ...wsItems.resources]; } - if (branchItems?.triggers) { - merged.triggers = [...(merged.triggers || []), ...branchItems.triggers]; + if (wsItems?.triggers) { + merged.triggers = [...(merged.triggers || []), ...wsItems.triggers]; } - if (branchItems?.schedules) { - merged.schedules = [...(merged.schedules || []), ...branchItems.schedules]; + if (wsItems?.schedules) { + merged.schedules = [...(merged.schedules || []), ...wsItems.schedules]; } - if (branchItems?.folders) { - merged.folders = [...(merged.folders || []), ...branchItems.folders]; + if (wsItems?.folders) { + merged.folders = [...(merged.folders || []), ...wsItems.folders]; } - // For settings (boolean), branch-specific overrides common - if (branchItems?.settings !== undefined) { - merged.settings = branchItems.settings; + // For settings (boolean), workspace-specific overrides common + if (wsItems?.settings !== undefined) { + merged.settings = wsItems.settings; } return merged; @@ -153,7 +161,7 @@ function matchesPatterns(path: string, patterns: string[]): boolean { /** * Check if the item type for a given path is configured in specificItems. * This checks if the TYPE is configured, not whether it matches the pattern. - * Used to determine if branch-specific files should be used for this type. + * Used to determine if workspace-specific files should be used for this type. */ export function isItemTypeConfigured(path: string, specificItems: SpecificItemsConfig | undefined): boolean { if (!specificItems) { @@ -192,7 +200,7 @@ export function isItemTypeConfigured(path: string, specificItems: SpecificItemsC } /** - * Check if a file path should be treated as branch-specific + * Check if a file path should be treated as workspace-specific */ export function isSpecificItem(path: string, specificItems: SpecificItemsConfig | undefined): boolean { if (!specificItems) { @@ -255,26 +263,26 @@ export function isSpecificItem(path: string, specificItems: SpecificItemsConfig } /** - * Convert a base path to a branch-specific path + * Convert a base path to a workspace-specific path */ -export function toBranchSpecificPath(basePath: string, branchName: string): string { +export function toWorkspaceSpecificPath(basePath: string, workspaceName: string): string { // Sanitize branch name to be filesystem-safe - const sanitizedBranchName = branchName.replace(/[\/\\:*?"<>|.]/g, '_'); + const sanitizedName = workspaceName.replace(/[\/\\:*?"<>|.]/g, '_'); // Warn about potential collisions if sanitization occurred - if (sanitizedBranchName !== branchName) { - console.warn(`Warning: Branch name "${branchName}" contains filesystem-unsafe characters (/ \\ : * ? " < > | .) and was sanitized to "${sanitizedBranchName}". This may cause collisions with other similarly named branches.`); + if (sanitizedName !== workspaceName) { + console.warn(`Warning: Workspace name "${workspaceName}" contains filesystem-unsafe characters (/ \\ : * ? " < > | .) and was sanitized to "${sanitizedName}". This may cause collisions with other similarly named branches.`); } - // Check for folder meta file pattern: folder.meta.yaml -> folder.branchName.meta.yaml + // Check for folder meta file pattern: folder.meta.yaml -> folder.workspaceName.meta.yaml if (basePath.endsWith('/folder.meta.yaml')) { const pathWithoutMeta = basePath.substring(0, basePath.length - '/folder.meta.yaml'.length); - return `${pathWithoutMeta}/folder.${sanitizedBranchName}.meta.yaml`; + return `${pathWithoutMeta}/folder.${sanitizedName}.meta.yaml`; } - // Check for settings.yaml: settings.yaml -> settings.branchName.yaml + // Check for settings.yaml: settings.yaml -> settings.workspaceName.yaml if (basePath === 'settings.yaml') { - return `settings.${sanitizedBranchName}.yaml`; + return `settings.${sanitizedName}.yaml`; } // Check for resource file pattern (e.g., .resource.file.ini) @@ -296,132 +304,133 @@ export function toBranchSpecificPath(basePath: string, branchName: string): stri pathWithoutExtension = basePath.substring(0, basePath.length - extension.length); } - return `${pathWithoutExtension}.${sanitizedBranchName}${extension}`; + return `${pathWithoutExtension}.${sanitizedName}${extension}`; } /** - * Convert a branch-specific path back to a base path + * Convert a workspace-specific path back to a base path */ -export function fromBranchSpecificPath(branchSpecificPath: string, branchName: string): string { - // Sanitize branch name the same way as in toBranchSpecificPath - const sanitizedBranchName = branchName.replace(/[\/\\:*?"<>|.]/g, '_'); - const escapedBranchName = sanitizedBranchName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +export function fromWorkspaceSpecificPath(workspaceSpecificPath: string, workspaceName: string): string { + // Sanitize branch name the same way as in toWorkspaceSpecificPath + const sanitizedName = workspaceName.replace(/[\/\\:*?"<>|.]/g, '_'); + const escapedName = sanitizedName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - // Check for folder meta file pattern: /folder.branchName.meta.yaml -> /folder.meta.yaml - const folderPattern = new RegExp(`/folder\\.${escapedBranchName}\\.meta\\.yaml$`); - if (folderPattern.test(branchSpecificPath)) { - return branchSpecificPath.replace(folderPattern, '/folder.meta.yaml'); + // Check for folder meta file pattern: /folder.workspaceName.meta.yaml -> /folder.meta.yaml + const folderPattern = new RegExp(`/folder\\.${escapedName}\\.meta\\.yaml$`); + if (folderPattern.test(workspaceSpecificPath)) { + return workspaceSpecificPath.replace(folderPattern, '/folder.meta.yaml'); } - // Check for settings file pattern: settings.branchName.yaml -> settings.yaml - const settingsPattern = new RegExp(`^settings\\.${escapedBranchName}\\.yaml$`); - if (settingsPattern.test(branchSpecificPath)) { + // Check for settings file pattern: settings.workspaceName.yaml -> settings.yaml + const settingsPattern = new RegExp(`^settings\\.${escapedName}\\.yaml$`); + if (settingsPattern.test(workspaceSpecificPath)) { return 'settings.yaml'; } // Check for resource file pattern - const resourceFilePattern = new RegExp(`\\.${escapedBranchName}(\\.resource\\.file\\..+)$`); - const resourceFileMatch = branchSpecificPath.match(resourceFilePattern); + const resourceFilePattern = new RegExp(`\\.${escapedName}(\\.resource\\.file\\..+)$`); + const resourceFileMatch = workspaceSpecificPath.match(resourceFilePattern); if (resourceFileMatch) { const extension = resourceFileMatch[1]; - const pathWithoutBranchAndExtension = branchSpecificPath.substring( + const pathWithoutBranchAndExtension = workspaceSpecificPath.substring( 0, - branchSpecificPath.length - `.${sanitizedBranchName}${extension}`.length + workspaceSpecificPath.length - `.${sanitizedName}${extension}`.length ); return `${pathWithoutBranchAndExtension}${extension}`; } - const yamlPattern = new RegExp(`\\.${escapedBranchName}(\\.${buildYamlTypePattern()}\\.yaml)$`); - const yamlMatch = branchSpecificPath.match(yamlPattern); + const yamlPattern = new RegExp(`\\.${escapedName}(\\.${buildYamlTypePattern()}\\.yaml)$`); + const yamlMatch = workspaceSpecificPath.match(yamlPattern); if (!yamlMatch) { - return branchSpecificPath; // Return unchanged if not a branch-specific path + return workspaceSpecificPath; // Return unchanged if not a workspace-specific path } const extension = yamlMatch[1]; - const pathWithoutBranchAndExtension = branchSpecificPath.substring( + const pathWithoutBranchAndExtension = workspaceSpecificPath.substring( 0, - branchSpecificPath.length - `.${sanitizedBranchName}${extension}`.length + workspaceSpecificPath.length - `.${sanitizedName}${extension}`.length ); return `${pathWithoutBranchAndExtension}${extension}`; } /** - * Get the branch-specific path for the current branch if the item should be branch-specific + * Get the workspace-specific path if the item should be workspace-specific. + * workspaceNameOverride is the workspace name used as file suffix. + * Falls back to current git branch when no override (backward compat: old key = branch name). */ -export function getBranchSpecificPath( +export function getWorkspaceSpecificPath( basePath: string, specificItems: SpecificItemsConfig | undefined, - branchOverride?: string + workspaceNameOverride?: string ): string | undefined { if (!specificItems) { return undefined; } - // Use branch override if provided, otherwise detect from git - let currentBranch: string | null = null; - if (branchOverride) { - currentBranch = branchOverride; + let currentWorkspace: string | null = null; + if (workspaceNameOverride) { + currentWorkspace = workspaceNameOverride; } else if (isGitRepository()) { - currentBranch = getCurrentGitBranch(); + currentWorkspace = getCurrentGitBranch(); } - if (!currentBranch) { + if (!currentWorkspace) { return undefined; } if (isSpecificItem(basePath, specificItems)) { - return toBranchSpecificPath(basePath, currentBranch); + return toWorkspaceSpecificPath(basePath, currentWorkspace); } return undefined; } // Cache for compiled regex patterns to avoid recompilation -const branchPatternCache = new Map(); +const workspacePatternCache = new Map(); /** - * Check if a path is a branch-specific file for the current branch + * Check if a path is a workspace-specific file for the current branch. + * workspaceNameOverride is the effective git branch name (for file naming on disk). */ -export function isCurrentBranchFile(path: string, branchOverride?: string): boolean { - // Use branch override if provided, otherwise detect from git - let currentBranch: string | null = null; - if (branchOverride) { - currentBranch = branchOverride; +export function isCurrentWorkspaceFile(path: string, workspaceNameOverride?: string): boolean { + let currentWorkspace: string | null = null; + if (workspaceNameOverride) { + currentWorkspace = workspaceNameOverride; } else if (isGitRepository()) { - currentBranch = getCurrentGitBranch(); + currentWorkspace = getCurrentGitBranch(); } - if (!currentBranch) { + if (!currentWorkspace) { return false; } // Sanitize branch name to match what would be used in file naming - const sanitizedBranchName = currentBranch.replace(/[\/\\:*?"<>|.]/g, '_'); - const escapedBranchName = sanitizedBranchName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const sanitizedName = currentWorkspace.replace(/[\/\\:*?"<>|.]/g, '_'); + const escapedName = sanitizedName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // Use cached pattern or create and cache new one - let pattern = branchPatternCache.get(currentBranch); + let pattern = workspacePatternCache.get(currentWorkspace); if (!pattern) { pattern = new RegExp( - `\\.${escapedBranchName}\\.${buildYamlTypePattern()}\\.yaml$|` + - `\\.${escapedBranchName}\\.resource\\.file\\..+$|` + - `/folder\\.${escapedBranchName}\\.meta\\.yaml$|` + - `^settings\\.${escapedBranchName}\\.yaml$` + `\\.${escapedName}\\.${buildYamlTypePattern()}\\.yaml$|` + + `\\.${escapedName}\\.resource\\.file\\..+$|` + + `/folder\\.${escapedName}\\.meta\\.yaml$|` + + `^settings\\.${escapedName}\\.yaml$` ); - branchPatternCache.set(currentBranch, pattern); + workspacePatternCache.set(currentWorkspace, pattern); } return pattern.test(path); } /** - * Check if a path is a branch-specific file for ANY branch (not necessarily current) + * Check if a path is a workspace-specific file for ANY branch (not necessarily current) * Used to identify and skip files from other branches during sync operations */ -export function isBranchSpecificFile(path: string): boolean { +export function isWorkspaceSpecificFile(path: string): boolean { const yamlTypePattern = buildYamlTypePattern(); return new RegExp( `\\.[^.]+\\.${yamlTypePattern}\\.yaml$|` + diff --git a/cli/src/guidance/skills.ts b/cli/src/guidance/skills.ts index b1a6dcff09..5eb785faa0 100644 --- a/cli/src/guidance/skills.ts +++ b/cli/src/guidance/skills.ts @@ -5127,6 +5127,10 @@ Show all available wmill.yaml configuration options **Options:** - \`--json\` - Output as JSON for programmatic consumption +**Subcommands:** + +- \`config migrate\` - Migrate wmill.yaml from gitBranches/environments to workspaces format + ### dependencies workspace dependencies related commands @@ -5498,7 +5502,7 @@ sync local with a remote workspaces or the opposite (push or pull) - \`--extra-includes \` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string). Useful to still take wmill.yaml into account and act as a second pattern to satisfy - \`--repository \` - Specify repository path (e.g., u/user/repo) when multiple repositories exist - \`--promotion \` - Use promotionOverrides from the specified branch instead of regular overrides - - \`--branch, --env \` - Override the current git branch/environment (works even outside a git repository) + - \`--branch, --env \` - [Deprecated: use --workspace] Override the current git branch/environment - \`sync push\` - Push any local changes and apply them remotely. - \`--yes\` - Push without needing confirmation - \`--dry-run\` - Show changes that would be pushed without actually pushing @@ -5529,7 +5533,7 @@ sync local with a remote workspaces or the opposite (push or pull) - \`--message \` - Include a message that will be added to all scripts/flows/apps updated during this push - \`--parallel \` - Number of changes to process in parallel - \`--repository \` - Specify repository path (e.g., u/user/repo) when multiple repositories exist - - \`--branch, --env \` - Override the current git branch/environment (works even outside a git repository) + - \`--branch, --env \` - [Deprecated: use --workspace] Override the current git branch/environment - \`--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 @@ -5648,10 +5652,11 @@ workspace related commands - \`workspace list\` - List local workspace profiles - \`workspace list-remote\` - List workspaces on the remote server that you have access to - \`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) +- \`workspace bind\` - Create or update a workspace entry in wmill.yaml from the active profile + - \`--workspace \` - Workspace name (default: current branch or workspaceId) + - \`--branch \` - Git branch to associate (default: workspace name) +- \`workspace unbind\` - Remove baseUrl and workspaceId from a workspace entry + - \`--workspace \` - Workspace to unbind - \`workspace fork [workspace_name:string] [workspace_id:string]\` - Create a forked workspace - \`--create-workspace-name \` - Specify the workspace name. Ignored if --create is not specified or the workspace already exists. Will default to the workspace id. - \`--color \` - Workspace color (hex code, e.g. #ff0000) diff --git a/cli/src/main.ts b/cli/src/main.ts index 1457756a18..169776803f 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -78,7 +78,7 @@ export { token, }; -export const VERSION = "1.680.0"; +export const VERSION = "1.682.0"; // Re-exported from constants.ts to maintain backwards compatibility export { WM_FORK_PREFIX } from "./core/constants.ts"; diff --git a/cli/src/utils/resource_folders.ts b/cli/src/utils/resource_folders.ts index f720fdc680..8a72a8b9ff 100644 --- a/cli/src/utils/resource_folders.ts +++ b/cli/src/utils/resource_folders.ts @@ -156,6 +156,24 @@ export function getMetadataPathSuffix( // Path Detection Functions // ============================================================================ +/** + * Check if a directory name uses the *wrong* folder suffix format for the + * current nonDottedPaths setting. Returns the resource type if mismatched, + * null if the name is fine (or not a resource folder at all). + * + * - nonDottedPaths=false (dotted mode): flags __flow, __app, __raw_app + * - nonDottedPaths=true (non-dotted): flags .flow, .app, .raw_app + */ +export function hasWrongFormatSuffix(dirName: string): FolderResourceType | null { + const wrongSuffixes = _nonDottedPaths ? DOTTED_SUFFIXES : NON_DOTTED_SUFFIXES; + for (const [type, suffix] of Object.entries(wrongSuffixes)) { + if (dirName.endsWith(suffix)) { + return type as FolderResourceType; + } + } + return null; +} + /** Normalize path separators to forward slash for cross-platform matching */ function normalizeSep(p: string): string { return p.replaceAll("\\", "/"); diff --git a/cli/test/conf_branch_override_unit.test.ts b/cli/test/conf_branch_override_unit.test.ts index 1bacea0636..95c1c447aa 100644 --- a/cli/test/conf_branch_override_unit.test.ts +++ b/cli/test/conf_branch_override_unit.test.ts @@ -2,15 +2,15 @@ import { expect, test } from "bun:test"; import { getEffectiveSettings, type SyncOptions } from "../src/core/conf.ts"; // ============================================================================= -// CONF.TS BRANCH OVERRIDE TESTS -// Tests for getEffectiveSettings with branchOverride parameter +// CONF.TS WORKSPACE OVERRIDE TESTS +// Tests for getEffectiveSettings with workspaceNameOverride parameter // ============================================================================= -test("getEffectiveSettings: applies branch overrides when branchOverride is provided", async () => { +test("getEffectiveSettings: applies workspace overrides when workspaceNameOverride is provided", async () => { const config: SyncOptions = { defaultTs: "bun", includes: ["f/**"], - gitBranches: { + workspaces: { staging: { overrides: { includes: ["staging/**"], @@ -26,25 +26,25 @@ test("getEffectiveSettings: applies branch overrides when branchOverride is prov }, }; - // Test with staging branch override + // Test with staging workspace override const stagingSettings = await getEffectiveSettings(config, undefined, true, true, "staging"); expect(stagingSettings.includes).toEqual(["staging/**"]); expect(stagingSettings.skipVariables).toEqual(true); expect(stagingSettings.skipSecrets).toEqual(undefined); - // Test with production branch override + // Test with production workspace override const prodSettings = await getEffectiveSettings(config, undefined, true, true, "production"); expect(prodSettings.includes).toEqual(["prod/**"]); expect(prodSettings.skipSecrets).toEqual(true); expect(prodSettings.skipVariables).toEqual(undefined); }); -test("getEffectiveSettings: uses top-level settings when branchOverride has no overrides", async () => { +test("getEffectiveSettings: uses top-level settings when workspace has no overrides", async () => { const config: SyncOptions = { defaultTs: "bun", includes: ["f/**"], skipVariables: true, - gitBranches: { + workspaces: { staging: { // No overrides defined }, @@ -57,11 +57,11 @@ test("getEffectiveSettings: uses top-level settings when branchOverride has no o expect(settings.defaultTs).toEqual("bun"); }); -test("getEffectiveSettings: uses top-level settings for unknown branch", async () => { +test("getEffectiveSettings: uses top-level settings for unknown workspace", async () => { const config: SyncOptions = { defaultTs: "bun", includes: ["f/**"], - gitBranches: { + workspaces: { staging: { overrides: { includes: ["staging/**"], @@ -79,7 +79,7 @@ test("getEffectiveSettings: promotionOverrides take precedence when promotion sp const config: SyncOptions = { defaultTs: "bun", includes: ["f/**"], - gitBranches: { + workspaces: { production: { overrides: { includes: ["prod/**"], @@ -103,13 +103,13 @@ test("getEffectiveSettings: promotionOverrides take precedence when promotion sp expect(promoSettings.skipVariables).toEqual(true); }); -test("getEffectiveSettings: branchOverride works without gitBranches config", async () => { +test("getEffectiveSettings: workspaceNameOverride works without workspaces config", async () => { const config: SyncOptions = { defaultTs: "bun", includes: ["f/**"], }; - // Should not throw even with branchOverride but no gitBranches + // Should not throw even with workspaceNameOverride but no workspaces const settings = await getEffectiveSettings(config, undefined, true, true, "staging"); expect(settings.includes).toEqual(["f/**"]); expect(settings.defaultTs).toEqual("bun"); @@ -124,7 +124,7 @@ test("getEffectiveSettings: preserves all top-level settings in merged result", skipResources: false, skipFlows: false, parallel: 4, - gitBranches: { + workspaces: { staging: { overrides: { skipVariables: true, // Override just this one diff --git a/cli/test/init_bind_flow.test.ts b/cli/test/init_bind_flow.test.ts new file mode 100644 index 0000000000..dedb6f7df4 --- /dev/null +++ b/cli/test/init_bind_flow.test.ts @@ -0,0 +1,197 @@ +import { expect, test } from "bun:test"; +import { writeFile, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { withTestBackend } from "./test_backend.ts"; +import { addWorkspace } from "../workspace.ts"; +import { parseJsonFromCLIOutput } from "./test_config_helpers.ts"; + +async function setupProfile(backend: any, name: string): Promise { + await addWorkspace({ + remote: backend.baseUrl, + workspaceId: backend.workspace, + name, + token: backend.token, + }, { force: true, configDir: backend.testConfigDir }); +} + +// ============================================================================= +// Test 1: Full sync flow with new workspaces config — overrides, gitBranch ≠ key, +// workspace-specific file suffix, legacy back-compat, and promotion +// ============================================================================= + +test("Full workspaces config: overrides, gitBranch mapping, specific items suffix, legacy compat, promotion", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupProfile(backend, "test_profile"); + + // --- Part A: New workspaces config with gitBranch ≠ key --- + await writeFile(join(tempDir, "wmill.yaml"), `defaultTs: bun +includes: + - "**" +skipVariables: false + +workspaces: + production: + gitBranch: prod_branch + baseUrl: ${backend.baseUrl} + workspaceId: ${backend.workspace} + overrides: + skipVariables: true + promotionOverrides: + skipVariables: true + skipResources: true + specificItems: + variables: + - "f/**" + development: + gitBranch: dev_branch + baseUrl: ${backend.baseUrl} + workspaceId: ${backend.workspace} + overrides: + skipVariables: false`, "utf-8"); + + // A1: --branch prod_branch → resolves to workspace "production", skipVariables: true + const prodResult = await backend.runCLICommand([ + 'sync', 'pull', '--branch', 'prod_branch', '--dry-run', '--json-output', + ], tempDir); + expect(prodResult.code).toEqual(0); + const prodChanges = parseJsonFromCLIOutput(prodResult.stdout).changes || []; + const prodPaths = prodChanges.map((c: any) => c.path); + expect(prodPaths.some((p: string) => p.includes('.variable.yaml'))).toEqual(false); + + // A2: --branch dev_branch → resolves to workspace "development", skipVariables: false + const devResult = await backend.runCLICommand([ + 'sync', 'pull', '--branch', 'dev_branch', '--dry-run', '--json-output', + ], tempDir); + expect(devResult.code).toEqual(0); + const devPaths = (parseJsonFromCLIOutput(devResult.stdout).changes || []).map((c: any) => c.path); + expect(devPaths.some((p: string) => p.includes('.variable.yaml'))).toEqual(true); + + // A3: workspace-specific file suffix uses workspace name "production", not "prod_branch" + const wsSpecificPaths = prodChanges + .filter((c: any) => c.workspace_specific_path) + .map((c: any) => c.workspace_specific_path); + for (const p of wsSpecificPaths) { + expect(p).toContain(".production."); + expect(p).not.toContain(".prod_branch."); + } + + // A4: --promotion prod_branch applies promotionOverrides (skipResources: true) + const promoResult = await backend.runCLICommand([ + 'sync', 'pull', '--branch', 'prod_branch', '--promotion', 'prod_branch', + '--dry-run', '--json-output', + ], tempDir); + expect(promoResult.code).toEqual(0); + const promoPaths = (parseJsonFromCLIOutput(promoResult.stdout).changes || []).map((c: any) => c.path); + expect(promoPaths.some((p: string) => p.includes('.variable.yaml'))).toEqual(false); + expect(promoPaths.some((p: string) => p.includes('.resource.yaml'))).toEqual(false); + + // --- Part B: Legacy gitBranches config works via normalization --- + await writeFile(join(tempDir, "wmill.yaml"), `defaultTs: bun +includes: + - "**" +skipVariables: false + +gitBranches: + legacy_branch: + overrides: + skipVariables: true`, "utf-8"); + + const legacyResult = await backend.runCLICommand([ + 'sync', 'pull', '--branch', 'legacy_branch', '--dry-run', '--json-output', + ], tempDir); + expect(legacyResult.code).toEqual(0); + const legacyPaths = (parseJsonFromCLIOutput(legacyResult.stdout).changes || []).map((c: any) => c.path); + expect(legacyPaths.some((p: string) => p.includes('.variable.yaml'))).toEqual(false); + + // --- Part C: Legacy environments config --- + await writeFile(join(tempDir, "wmill.yaml"), `defaultTs: bun +includes: + - "**" +skipVariables: false + +environments: + env_branch: + overrides: + skipVariables: true`, "utf-8"); + + const envResult = await backend.runCLICommand([ + 'sync', 'pull', '--branch', 'env_branch', '--dry-run', '--json-output', + ], tempDir); + expect(envResult.code).toEqual(0); + const envPaths = (parseJsonFromCLIOutput(envResult.stdout).changes || []).map((c: any) => c.path); + expect(envPaths.some((p: string) => p.includes('.variable.yaml'))).toEqual(false); + }); +}); + +// ============================================================================= +// Test 2: Config migrate and workspace resolution fallbacks +// ============================================================================= + +test("Config migrate and workspace resolution fallbacks", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupProfile(backend, "fallback_test"); + + // --- Part A: config migrate converts gitBranches → workspaces --- + await writeFile(join(tempDir, "wmill.yaml"), `defaultTs: bun +includes: + - "f/**" +gitBranches: + main: + baseUrl: https://app.windmill.dev + workspaceId: production + overrides: + skipSecrets: false + commonSpecificItems: + variables: + - "f/shared/**"`, "utf-8"); + + const migrateResult = await backend.runCLICommand(['config', 'migrate'], tempDir); + expect(migrateResult.code).toEqual(0); + + const migrated = await readFile(join(tempDir, "wmill.yaml"), "utf-8"); + expect(migrated).toContain("workspaces:"); + expect(migrated).not.toContain("gitBranches:"); + expect(migrated).toContain("production"); + expect(migrated).toContain("commonSpecificItems"); + + // config migrate is idempotent + const migrateAgain = await backend.runCLICommand(['config', 'migrate'], tempDir); + expect(migrateAgain.code).toEqual(0); + + // --- Part B: single workspace auto-selected --- + await writeFile(join(tempDir, "wmill.yaml"), `defaultTs: bun +includes: + - "**" +skipVariables: false +workspaces: + only_ws: + baseUrl: ${backend.baseUrl} + workspaceId: ${backend.workspace} + overrides: + skipVariables: true`, "utf-8"); + + // No --branch, no --workspace: should auto-select "only_ws" + const singleResult = await backend.runCLICommand([ + 'sync', 'pull', '--dry-run', '--json-output', + ], tempDir); + expect(singleResult.code).toEqual(0); + const singlePaths = (parseJsonFromCLIOutput(singleResult.stdout).changes || []).map((c: any) => c.path); + // skipVariables: true should be applied from auto-selected workspace + expect(singlePaths.some((p: string) => p.includes('.variable.yaml'))).toEqual(false); + + // --- Part C: no workspaces config falls back to active profile --- + await writeFile(join(tempDir, "wmill.yaml"), `defaultTs: bun +includes: + - "**"`, "utf-8"); + + const noWsResult = await backend.runCLICommand([ + 'sync', 'pull', '--dry-run', '--json-output', + ], tempDir); + expect(noWsResult.code).toEqual(0); + // Should succeed using active profile, no overrides applied (all defaults) + const noWsPaths = (parseJsonFromCLIOutput(noWsResult.stdout).changes || []).map((c: any) => c.path); + expect(noWsPaths.length).toBeGreaterThan(0); + // Variables should be included (no skipVariables override) + expect(noWsPaths.some((p: string) => p.includes('.variable.yaml'))).toEqual(true); + }); +}); diff --git a/cli/test/init_no_git_sync.test.ts b/cli/test/init_no_git_sync.test.ts index a5914cd7ce..d16c9e771f 100644 --- a/cli/test/init_no_git_sync.test.ts +++ b/cli/test/init_no_git_sync.test.ts @@ -129,7 +129,7 @@ test.skipIf(shouldSkipOnCI())("Init: --use-backend flag applies git-sync setting expect(wmillYaml).toContain("g/**"); // Should have empty overrides section for consistency - expect(wmillYaml).toContain("gitBranches: {}"); + expect(wmillYaml).toContain("workspaces:"); }); }); @@ -182,6 +182,6 @@ test.skipIf(shouldSkipOnCI())("Init: --use-default bypasses backend settings che // Should NOT have backend-specific settings expect(wmillYaml.includes("f/should-be-ignored/**")).toEqual(false); - expect(wmillYaml).toContain("gitBranches: {}"); + expect(wmillYaml).toContain("workspaces:"); }); }); diff --git a/cli/test/init_template_unit.test.ts b/cli/test/init_template_unit.test.ts index efb6d09891..e7d69c2184 100644 --- a/cli/test/init_template_unit.test.ts +++ b/cli/test/init_template_unit.test.ts @@ -25,15 +25,14 @@ describe("generateCommentedTemplate", () => { expect(typeof config).toBe("object"); }); - test("uses provided branch name in gitBranches", () => { + test("uses provided branch name in workspaces", () => { const config = parse(generateCommentedTemplate("my-feature")); - expect(config.gitBranches["my-feature"]).toBeDefined(); - expect(config.gitBranches["my-feature"].overrides).toEqual({}); + expect(config.workspaces["my-feature"]).toBeDefined(); }); test("defaults to 'main' when no branch name given", () => { const config = parse(generateCommentedTemplate()); - expect(config.gitBranches["main"]).toBeDefined(); + expect(config.workspaces["main"]).toBeDefined(); }); test("quotes branch names with YAML-special characters", () => { @@ -41,7 +40,7 @@ describe("generateCommentedTemplate", () => { for (const branch of specialBranches) { const yaml = generateCommentedTemplate(branch); const config = parse(yaml); - expect(config.gitBranches[branch]).toBeDefined(); + expect(config.workspaces[branch]).toBeDefined(); } }); @@ -50,19 +49,19 @@ describe("generateCommentedTemplate", () => { expect(yaml.startsWith("# yaml-language-server: $schema=wmill.schema.json")).toBe(true); }); - test("includes all non-commented CONFIG_REFERENCE entries as active YAML keys", () => { + test("includes all non-commented, non-skipped CONFIG_REFERENCE entries as active YAML keys", () => { const config = parse(generateCommentedTemplate("main")); for (const opt of CONFIG_REFERENCE) { - if (!opt.commented) { + if (!opt.commented && !opt.skipInTemplate) { expect(config).toHaveProperty(opt.name); } } }); - test("does not include commented entries as active YAML keys", () => { + test("does not include commented or skipped entries as active YAML keys", () => { const config = parse(generateCommentedTemplate("main")); for (const opt of CONFIG_REFERENCE) { - if (opt.commented && opt.name !== "environments") { + if (opt.commented || opt.skipInTemplate) { expect(config[opt.name]).toBeUndefined(); } } @@ -123,23 +122,31 @@ describe("generateJsonSchema", () => { 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 workspaces with workspace config schema", () => { + const wsSchema = schema.properties.workspaces.additionalProperties; + expect(wsSchema.properties.gitBranch).toBeDefined(); + expect(wsSchema.properties.baseUrl).toBeDefined(); + expect(wsSchema.properties.workspaceId).toBeDefined(); + expect(wsSchema.properties.specificItems).toBeDefined(); + expect(wsSchema.properties.specificItems.properties.variables).toBeDefined(); }); - test("includes environments as alias for gitBranches", () => { + test("includes gitBranches as deprecated alias for workspaces", () => { + expect(schema.properties.gitBranches).toBeDefined(); + expect(schema.properties.gitBranches.additionalProperties).toEqual( + schema.properties.workspaces.additionalProperties + ); + }); + + test("includes environments as deprecated alias for workspaces", () => { expect(schema.properties.environments).toBeDefined(); expect(schema.properties.environments.additionalProperties).toEqual( - schema.properties.gitBranches.additionalProperties + schema.properties.workspaces.additionalProperties ); }); test("does not contain template-only keys in schema output", () => { - const templateKeys = ["section", "sectionNote", "commented", "templateValue", "example", "inlineComment", "groupNote"]; + const templateKeys = ["section", "sectionNote", "commented", "templateValue", "example", "inlineComment", "groupNote", "skipInTemplate"]; const json = JSON.stringify(schema); for (const key of templateKeys) { expect(json).not.toContain(`"${key}"`); @@ -172,15 +179,23 @@ describe("formatConfigReference", () => { 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 workspaces sub-fields", () => { + expect(output).toContain("workspaces..gitBranch"); + expect(output).toContain("workspaces..baseUrl"); + expect(output).toContain("workspaces..workspaceId"); + expect(output).toContain("workspaces..specificItems.variables"); }); test("auto-expands commonSpecificItems sub-fields", () => { - expect(output).toContain("gitBranches.commonSpecificItems.variables"); - expect(output).toContain("gitBranches.commonSpecificItems.settings"); + expect(output).toContain("workspaces.commonSpecificItems.variables"); + expect(output).toContain("workspaces.commonSpecificItems.settings"); + }); + + test("deprecated entries are listed but not expanded", () => { + expect(output).toContain("gitBranches"); + expect(output).toContain("[Deprecated]"); + // Should NOT have expanded sub-fields for deprecated entries + expect(output).not.toContain("gitBranches."); }); }); @@ -207,7 +222,7 @@ describe("formatConfigReferenceJson", () => { test("does not contain template-only keys", () => { const parsed = JSON.parse(formatConfigReferenceJson()); - const templateKeys = ["section", "sectionNote", "commented", "templateValue", "example", "inlineComment", "groupNote"]; + const templateKeys = ["section", "sectionNote", "commented", "templateValue", "example", "inlineComment", "groupNote", "skipInTemplate"]; for (const entry of parsed) { for (const key of templateKeys) { expect(entry).not.toHaveProperty(key); diff --git a/cli/test/override_settings_behavior.test.ts b/cli/test/override_settings_behavior.test.ts index bbbcd75bff..0de8b7f305 100644 --- a/cli/test/override_settings_behavior.test.ts +++ b/cli/test/override_settings_behavior.test.ts @@ -17,7 +17,7 @@ test("Override Settings: branch override inherits non-overridden settings from b skipResources: true, // Base has this as true skipApps: false, // Base has this as false defaultTs: "bun" as const, - gitBranches: { + workspaces: { main: { overrides: { includes: ["override/**"], @@ -49,7 +49,7 @@ test("Override Settings: branch-specific settings take precedence", async () => const config = { includes: ["default/**"], skipVariables: false, - gitBranches: { + workspaces: { main: { overrides: { skipVariables: true, diff --git a/cli/test/specific_items_unit.test.ts b/cli/test/specific_items_unit.test.ts index 3b72861f88..ae94603dbc 100644 --- a/cli/test/specific_items_unit.test.ts +++ b/cli/test/specific_items_unit.test.ts @@ -2,93 +2,93 @@ import { expect, test } from "bun:test"; // ============================================================================= // SPECIFIC ITEMS UNIT TESTS -// Tests for branch-specific file path functions (no Docker required) +// Tests for workspace-specific file path functions (no Docker required) // ============================================================================= // Import the functions we need to test import { isSpecificItem, isItemTypeConfigured, - toBranchSpecificPath, - fromBranchSpecificPath, - isBranchSpecificFile, - isCurrentBranchFile, - getBranchSpecificPath, + toWorkspaceSpecificPath, + fromWorkspaceSpecificPath, + isWorkspaceSpecificFile, + isCurrentWorkspaceFile, + getWorkspaceSpecificPath, getSpecificItemsForCurrentBranch, } from "../src/core/specific_items.ts"; import type { SpecificItemsConfig } from "../src/core/specific_items.ts"; // ============================================================================= -// toBranchSpecificPath TESTS +// toWorkspaceSpecificPath TESTS // ============================================================================= -test("toBranchSpecificPath: converts variable path to branch-specific", () => { - const result = toBranchSpecificPath("f/test.variable.yaml", "main"); +test("toWorkspaceSpecificPath: converts variable path to workspace-specific", () => { + const result = toWorkspaceSpecificPath("f/test.variable.yaml", "main"); expect(result).toEqual("f/test.main.variable.yaml"); }); -test("toBranchSpecificPath: converts resource path to branch-specific", () => { - const result = toBranchSpecificPath("u/admin/db.resource.yaml", "develop"); +test("toWorkspaceSpecificPath: converts resource path to workspace-specific", () => { + const result = toWorkspaceSpecificPath("u/admin/db.resource.yaml", "develop"); expect(result).toEqual("u/admin/db.develop.resource.yaml"); }); -test("toBranchSpecificPath: converts trigger path to branch-specific", () => { - const result = toBranchSpecificPath("f/my_trigger.http_trigger.yaml", "feature-x"); +test("toWorkspaceSpecificPath: converts trigger path to workspace-specific", () => { + const result = toWorkspaceSpecificPath("f/my_trigger.http_trigger.yaml", "feature-x"); expect(result).toEqual("f/my_trigger.feature-x.http_trigger.yaml"); }); -test("toBranchSpecificPath: sanitizes branch names with slashes", () => { - const result = toBranchSpecificPath("f/test.variable.yaml", "feature/my-feature"); +test("toWorkspaceSpecificPath: sanitizes branch names with slashes", () => { + const result = toWorkspaceSpecificPath("f/test.variable.yaml", "feature/my-feature"); expect(result).toEqual("f/test.feature_my-feature.variable.yaml"); }); -test("toBranchSpecificPath: sanitizes branch names with dots", () => { - const result = toBranchSpecificPath("f/test.variable.yaml", "release.1.0"); +test("toWorkspaceSpecificPath: sanitizes branch names with dots", () => { + const result = toWorkspaceSpecificPath("f/test.variable.yaml", "release.1.0"); expect(result).toEqual("f/test.release_1_0.variable.yaml"); }); -test("toBranchSpecificPath: leaves non-specific files unchanged", () => { - const result = toBranchSpecificPath("f/script.ts", "main"); +test("toWorkspaceSpecificPath: leaves non-specific files unchanged", () => { + const result = toWorkspaceSpecificPath("f/script.ts", "main"); expect(result).toEqual("f/script.ts"); }); -test("toBranchSpecificPath: handles resource files with extensions", () => { - const result = toBranchSpecificPath("f/config.resource.file.json", "main"); +test("toWorkspaceSpecificPath: handles resource files with extensions", () => { + const result = toWorkspaceSpecificPath("f/config.resource.file.json", "main"); expect(result).toEqual("f/config.main.resource.file.json"); }); // ============================================================================= -// fromBranchSpecificPath TESTS +// fromWorkspaceSpecificPath TESTS // ============================================================================= -test("fromBranchSpecificPath: converts branch-specific variable back to base", () => { - const result = fromBranchSpecificPath("f/test.main.variable.yaml", "main"); +test("fromWorkspaceSpecificPath: converts workspace-specific variable back to base", () => { + const result = fromWorkspaceSpecificPath("f/test.main.variable.yaml", "main"); expect(result).toEqual("f/test.variable.yaml"); }); -test("fromBranchSpecificPath: converts branch-specific resource back to base", () => { - const result = fromBranchSpecificPath("u/admin/db.develop.resource.yaml", "develop"); +test("fromWorkspaceSpecificPath: converts workspace-specific resource back to base", () => { + const result = fromWorkspaceSpecificPath("u/admin/db.develop.resource.yaml", "develop"); expect(result).toEqual("u/admin/db.resource.yaml"); }); -test("fromBranchSpecificPath: converts branch-specific trigger back to base", () => { - const result = fromBranchSpecificPath("f/my_trigger.feature-x.http_trigger.yaml", "feature-x"); +test("fromWorkspaceSpecificPath: converts workspace-specific trigger back to base", () => { + const result = fromWorkspaceSpecificPath("f/my_trigger.feature-x.http_trigger.yaml", "feature-x"); expect(result).toEqual("f/my_trigger.http_trigger.yaml"); }); -test("fromBranchSpecificPath: handles sanitized branch names", () => { - const result = fromBranchSpecificPath("f/test.feature_my-feature.variable.yaml", "feature/my-feature"); +test("fromWorkspaceSpecificPath: handles sanitized branch names", () => { + const result = fromWorkspaceSpecificPath("f/test.feature_my-feature.variable.yaml", "feature/my-feature"); expect(result).toEqual("f/test.variable.yaml"); }); -test("fromBranchSpecificPath: returns unchanged if not branch-specific", () => { - const result = fromBranchSpecificPath("f/test.variable.yaml", "main"); +test("fromWorkspaceSpecificPath: returns unchanged if not workspace-specific", () => { + const result = fromWorkspaceSpecificPath("f/test.variable.yaml", "main"); expect(result).toEqual("f/test.variable.yaml"); }); -test("fromBranchSpecificPath: handles resource files with extensions", () => { - const result = fromBranchSpecificPath("f/config.main.resource.file.json", "main"); +test("fromWorkspaceSpecificPath: handles resource files with extensions", () => { + const result = fromWorkspaceSpecificPath("f/config.main.resource.file.json", "main"); expect(result).toEqual("f/config.resource.file.json"); }); @@ -143,36 +143,36 @@ test("isSpecificItem: handles exact path patterns", () => { }); // ============================================================================= -// isBranchSpecificFile TESTS +// isWorkspaceSpecificFile TESTS // ============================================================================= -test("isBranchSpecificFile: detects branch-specific variable files", () => { - expect(isBranchSpecificFile("f/test.main.variable.yaml")).toEqual(true); - expect(isBranchSpecificFile("f/test.develop.variable.yaml")).toEqual(true); - expect(isBranchSpecificFile("f/test.feature_branch.variable.yaml")).toEqual(true); +test("isWorkspaceSpecificFile: detects workspace-specific variable files", () => { + expect(isWorkspaceSpecificFile("f/test.main.variable.yaml")).toEqual(true); + expect(isWorkspaceSpecificFile("f/test.develop.variable.yaml")).toEqual(true); + expect(isWorkspaceSpecificFile("f/test.feature_branch.variable.yaml")).toEqual(true); }); -test("isBranchSpecificFile: detects branch-specific resource files", () => { - expect(isBranchSpecificFile("u/admin/db.main.resource.yaml")).toEqual(true); - expect(isBranchSpecificFile("u/admin/db.staging.resource.yaml")).toEqual(true); +test("isWorkspaceSpecificFile: detects workspace-specific resource files", () => { + expect(isWorkspaceSpecificFile("u/admin/db.main.resource.yaml")).toEqual(true); + expect(isWorkspaceSpecificFile("u/admin/db.staging.resource.yaml")).toEqual(true); }); -test("isBranchSpecificFile: detects branch-specific trigger files", () => { - expect(isBranchSpecificFile("f/my.main.http_trigger.yaml")).toEqual(true); - expect(isBranchSpecificFile("f/my.develop.kafka_trigger.yaml")).toEqual(true); - expect(isBranchSpecificFile("f/my.main.websocket_trigger.yaml")).toEqual(true); +test("isWorkspaceSpecificFile: detects workspace-specific trigger files", () => { + expect(isWorkspaceSpecificFile("f/my.main.http_trigger.yaml")).toEqual(true); + expect(isWorkspaceSpecificFile("f/my.develop.kafka_trigger.yaml")).toEqual(true); + expect(isWorkspaceSpecificFile("f/my.main.websocket_trigger.yaml")).toEqual(true); }); -test("isBranchSpecificFile: returns false for non-branch-specific files", () => { - expect(isBranchSpecificFile("f/test.variable.yaml")).toEqual(false); - expect(isBranchSpecificFile("u/admin/db.resource.yaml")).toEqual(false); - expect(isBranchSpecificFile("f/my.http_trigger.yaml")).toEqual(false); - expect(isBranchSpecificFile("f/script.ts")).toEqual(false); +test("isWorkspaceSpecificFile: returns false for non-workspace-specific files", () => { + expect(isWorkspaceSpecificFile("f/test.variable.yaml")).toEqual(false); + expect(isWorkspaceSpecificFile("u/admin/db.resource.yaml")).toEqual(false); + expect(isWorkspaceSpecificFile("f/my.http_trigger.yaml")).toEqual(false); + expect(isWorkspaceSpecificFile("f/script.ts")).toEqual(false); }); -test("isBranchSpecificFile: handles resource files with extensions", () => { - expect(isBranchSpecificFile("f/config.main.resource.file.json")).toEqual(true); - expect(isBranchSpecificFile("f/config.resource.file.json")).toEqual(false); +test("isWorkspaceSpecificFile: handles resource files with extensions", () => { + expect(isWorkspaceSpecificFile("f/config.main.resource.file.json")).toEqual(true); + expect(isWorkspaceSpecificFile("f/config.resource.file.json")).toEqual(false); }); // ============================================================================= @@ -182,32 +182,32 @@ test("isBranchSpecificFile: handles resource files with extensions", () => { test("round-trip: variable file path conversion", () => { const original = "f/my/nested/config.variable.yaml"; const branch = "feature/test-branch"; - const branchSpecific = toBranchSpecificPath(original, branch); - const restored = fromBranchSpecificPath(branchSpecific, branch); + const branchSpecific = toWorkspaceSpecificPath(original, branch); + const restored = fromWorkspaceSpecificPath(branchSpecific, branch); expect(restored).toEqual(original); }); test("round-trip: resource file path conversion", () => { const original = "u/admin/database.resource.yaml"; const branch = "develop"; - const branchSpecific = toBranchSpecificPath(original, branch); - const restored = fromBranchSpecificPath(branchSpecific, branch); + const branchSpecific = toWorkspaceSpecificPath(original, branch); + const restored = fromWorkspaceSpecificPath(branchSpecific, branch); expect(restored).toEqual(original); }); test("round-trip: trigger file path conversion", () => { const original = "f/webhooks/handler.http_trigger.yaml"; const branch = "main"; - const branchSpecific = toBranchSpecificPath(original, branch); - const restored = fromBranchSpecificPath(branchSpecific, branch); + const branchSpecific = toWorkspaceSpecificPath(original, branch); + const restored = fromWorkspaceSpecificPath(branchSpecific, branch); expect(restored).toEqual(original); }); test("round-trip: resource file with extension", () => { const original = "f/configs/settings.resource.file.ini"; const branch = "release/v1.0"; - const branchSpecific = toBranchSpecificPath(original, branch); - const restored = fromBranchSpecificPath(branchSpecific, branch); + const branchSpecific = toWorkspaceSpecificPath(original, branch); + const restored = fromWorkspaceSpecificPath(branchSpecific, branch); expect(restored).toEqual(original); }); @@ -216,58 +216,58 @@ test("round-trip: resource file with extension", () => { // These tests validate that functions work correctly with explicit branch override // ============================================================================= -test("branchOverride: getBranchSpecificPath with override returns branch-specific path", () => { +test("branchOverride: getWorkspaceSpecificPath with override returns workspace-specific path", () => { // This test verifies that when branchOverride is provided, the function uses it // instead of detecting the current git branch const config: SpecificItemsConfig = { variables: ["f/**"], }; - // When override is provided, it should return the branch-specific path even outside git repo - const result = getBranchSpecificPath("f/test.variable.yaml", config, "staging"); + // When override is provided, it should return the workspace-specific path even outside git repo + const result = getWorkspaceSpecificPath("f/test.variable.yaml", config, "staging"); expect(result).toEqual("f/test.staging.variable.yaml"); }); -test("branchOverride: getBranchSpecificPath without override and not in git repo returns undefined", () => { +test("branchOverride: getWorkspaceSpecificPath without override and not in git repo returns undefined", () => { const config: SpecificItemsConfig = { variables: ["f/**"], }; // Without override and outside git repo (or if git returns null), should return undefined // Note: This test's behavior depends on whether we're in a git repo - const result = getBranchSpecificPath("f/test.variable.yaml", config); - // In a git repo, this would return a branch-specific path; outside, it would be undefined + const result = getWorkspaceSpecificPath("f/test.variable.yaml", config); + // In a git repo, this would return a workspace-specific path; outside, it would be undefined // We test the override case above which is deterministic }); -test("branchOverride: isCurrentBranchFile with override uses provided branch", () => { - // Test that isCurrentBranchFile uses the override branch instead of git detection - const result = isCurrentBranchFile("f/test.staging.variable.yaml", "staging"); +test("branchOverride: isCurrentWorkspaceFile with override uses provided branch", () => { + // Test that isCurrentWorkspaceFile uses the override branch instead of git detection + const result = isCurrentWorkspaceFile("f/test.staging.variable.yaml", "staging"); expect(result).toEqual(true); // Should return false for different branch - const resultOther = isCurrentBranchFile("f/test.staging.variable.yaml", "production"); + const resultOther = isCurrentWorkspaceFile("f/test.staging.variable.yaml", "production"); expect(resultOther).toEqual(false); - // Should return false for non-branch-specific file - const resultNonSpecific = isCurrentBranchFile("f/test.variable.yaml", "staging"); + // Should return false for non-workspace-specific file + const resultNonSpecific = isCurrentWorkspaceFile("f/test.variable.yaml", "staging"); expect(resultNonSpecific).toEqual(false); }); -test("branchOverride: isCurrentBranchFile with override handles sanitized branch names", () => { +test("branchOverride: isCurrentWorkspaceFile with override handles sanitized branch names", () => { // Test with branch names that get sanitized - const result = isCurrentBranchFile("f/test.feature_my-branch.variable.yaml", "feature/my-branch"); + const result = isCurrentWorkspaceFile("f/test.feature_my-branch.variable.yaml", "feature/my-branch"); expect(result).toEqual(true); // Different sanitized branch should return false - const resultOther = isCurrentBranchFile("f/test.feature_my-branch.variable.yaml", "feature/other-branch"); + const resultOther = isCurrentWorkspaceFile("f/test.feature_my-branch.variable.yaml", "feature/other-branch"); expect(resultOther).toEqual(false); }); test("branchOverride: getSpecificItemsForCurrentBranch with override returns correct config", () => { // Test that getSpecificItemsForCurrentBranch uses the override branch const config = { - gitBranches: { + workspaces: { staging: { specificItems: { variables: ["f/**"], @@ -298,7 +298,7 @@ test("branchOverride: getSpecificItemsForCurrentBranch with override returns cor test("branchOverride: getSpecificItemsForCurrentBranch with non-existent branch returns undefined", () => { const config = { - gitBranches: { + workspaces: { staging: { specificItems: { variables: ["f/**"], @@ -314,7 +314,7 @@ test("branchOverride: getSpecificItemsForCurrentBranch with non-existent branch test("branchOverride: getSpecificItemsForCurrentBranch merges common and branch items", () => { const config = { - gitBranches: { + workspaces: { commonSpecificItems: { variables: ["common/**"], resources: ["shared/**"], @@ -329,7 +329,7 @@ test("branchOverride: getSpecificItemsForCurrentBranch merges common and branch }; const result = getSpecificItemsForCurrentBranch(config as any, "develop"); - // Should merge common and branch-specific + // Should merge common and workspace-specific expect(result?.variables).toEqual(["common/**", "dev/**"]); expect(result?.resources).toEqual(["shared/**"]); expect(result?.triggers).toEqual(["dev/triggers/**"]); @@ -340,34 +340,34 @@ test("branchOverride: getSpecificItemsForCurrentBranch merges common and branch // Format: f/folder/folder.branchName.meta.yaml // ============================================================================= -test("toBranchSpecificPath: converts folder meta path to branch-specific", () => { +test("toWorkspaceSpecificPath: converts folder meta path to workspace-specific", () => { // f/my_folder/folder.meta.yaml -> f/my_folder/folder.main.meta.yaml - const result = toBranchSpecificPath("f/my_folder/folder.meta.yaml", "main"); + const result = toWorkspaceSpecificPath("f/my_folder/folder.meta.yaml", "main"); expect(result).toEqual("f/my_folder/folder.main.meta.yaml"); }); -test("toBranchSpecificPath: converts nested folder meta path to branch-specific", () => { - const result = toBranchSpecificPath("f/parent/child/folder.meta.yaml", "develop"); +test("toWorkspaceSpecificPath: converts nested folder meta path to workspace-specific", () => { + const result = toWorkspaceSpecificPath("f/parent/child/folder.meta.yaml", "develop"); expect(result).toEqual("f/parent/child/folder.develop.meta.yaml"); }); -test("toBranchSpecificPath: sanitizes branch name in folder path", () => { - const result = toBranchSpecificPath("f/env/folder.meta.yaml", "feature/test"); +test("toWorkspaceSpecificPath: sanitizes branch name in folder path", () => { + const result = toWorkspaceSpecificPath("f/env/folder.meta.yaml", "feature/test"); expect(result).toEqual("f/env/folder.feature_test.meta.yaml"); }); -test("fromBranchSpecificPath: converts branch-specific folder back to base", () => { - const result = fromBranchSpecificPath("f/my_folder/folder.main.meta.yaml", "main"); +test("fromWorkspaceSpecificPath: converts workspace-specific folder back to base", () => { + const result = fromWorkspaceSpecificPath("f/my_folder/folder.main.meta.yaml", "main"); expect(result).toEqual("f/my_folder/folder.meta.yaml"); }); -test("fromBranchSpecificPath: handles nested branch-specific folder", () => { - const result = fromBranchSpecificPath("f/parent/child/folder.develop.meta.yaml", "develop"); +test("fromWorkspaceSpecificPath: handles nested workspace-specific folder", () => { + const result = fromWorkspaceSpecificPath("f/parent/child/folder.develop.meta.yaml", "develop"); expect(result).toEqual("f/parent/child/folder.meta.yaml"); }); -test("fromBranchSpecificPath: handles sanitized branch names for folders", () => { - const result = fromBranchSpecificPath("f/env/folder.feature_test.meta.yaml", "feature/test"); +test("fromWorkspaceSpecificPath: handles sanitized branch names for folders", () => { + const result = fromWorkspaceSpecificPath("f/env/folder.feature_test.meta.yaml", "feature/test"); expect(result).toEqual("f/env/folder.meta.yaml"); }); @@ -388,43 +388,43 @@ test("isSpecificItem: matches folder paths with exact pattern", () => { expect(isSpecificItem("f/other/folder.meta.yaml", config)).toEqual(false); }); -test("isBranchSpecificFile: detects branch-specific folder files", () => { - expect(isBranchSpecificFile("f/my_folder/folder.main.meta.yaml")).toEqual(true); - expect(isBranchSpecificFile("f/my_folder/folder.develop.meta.yaml")).toEqual(true); - expect(isBranchSpecificFile("f/nested/path/folder.staging.meta.yaml")).toEqual(true); +test("isWorkspaceSpecificFile: detects workspace-specific folder files", () => { + expect(isWorkspaceSpecificFile("f/my_folder/folder.main.meta.yaml")).toEqual(true); + expect(isWorkspaceSpecificFile("f/my_folder/folder.develop.meta.yaml")).toEqual(true); + expect(isWorkspaceSpecificFile("f/nested/path/folder.staging.meta.yaml")).toEqual(true); }); -test("isBranchSpecificFile: returns false for non-branch-specific folder files", () => { - expect(isBranchSpecificFile("f/my_folder/folder.meta.yaml")).toEqual(false); - expect(isBranchSpecificFile("f/nested/path/folder.meta.yaml")).toEqual(false); +test("isWorkspaceSpecificFile: returns false for non-workspace-specific folder files", () => { + expect(isWorkspaceSpecificFile("f/my_folder/folder.meta.yaml")).toEqual(false); + expect(isWorkspaceSpecificFile("f/nested/path/folder.meta.yaml")).toEqual(false); }); -test("isCurrentBranchFile: detects branch-specific folder for current branch", () => { - expect(isCurrentBranchFile("f/my_folder/folder.staging.meta.yaml", "staging")).toEqual(true); - expect(isCurrentBranchFile("f/my_folder/folder.staging.meta.yaml", "production")).toEqual(false); - expect(isCurrentBranchFile("f/my_folder/folder.meta.yaml", "staging")).toEqual(false); +test("isCurrentWorkspaceFile: detects workspace-specific folder for current branch", () => { + expect(isCurrentWorkspaceFile("f/my_folder/folder.staging.meta.yaml", "staging")).toEqual(true); + expect(isCurrentWorkspaceFile("f/my_folder/folder.staging.meta.yaml", "production")).toEqual(false); + expect(isCurrentWorkspaceFile("f/my_folder/folder.meta.yaml", "staging")).toEqual(false); }); -test("isCurrentBranchFile: handles sanitized branch for folders", () => { - expect(isCurrentBranchFile("f/env/folder.feature_test.meta.yaml", "feature/test")).toEqual(true); - expect(isCurrentBranchFile("f/env/folder.feature_test.meta.yaml", "feature/other")).toEqual(false); +test("isCurrentWorkspaceFile: handles sanitized branch for folders", () => { + expect(isCurrentWorkspaceFile("f/env/folder.feature_test.meta.yaml", "feature/test")).toEqual(true); + expect(isCurrentWorkspaceFile("f/env/folder.feature_test.meta.yaml", "feature/other")).toEqual(false); }); test("round-trip: folder meta path conversion", () => { const original = "f/configs/env_folder/folder.meta.yaml"; const branch = "main"; - const branchSpecific = toBranchSpecificPath(original, branch); + const branchSpecific = toWorkspaceSpecificPath(original, branch); expect(branchSpecific).toEqual("f/configs/env_folder/folder.main.meta.yaml"); - const restored = fromBranchSpecificPath(branchSpecific, branch); + const restored = fromWorkspaceSpecificPath(branchSpecific, branch); expect(restored).toEqual(original); }); test("round-trip: folder meta with sanitized branch", () => { const original = "f/env/folder.meta.yaml"; const branch = "feature/new-env"; - const branchSpecific = toBranchSpecificPath(original, branch); + const branchSpecific = toWorkspaceSpecificPath(original, branch); expect(branchSpecific).toEqual("f/env/folder.feature_new-env.meta.yaml"); - const restored = fromBranchSpecificPath(branchSpecific, branch); + const restored = fromWorkspaceSpecificPath(branchSpecific, branch); expect(restored).toEqual(original); }); @@ -432,23 +432,23 @@ test("round-trip: folder meta with sanitized branch", () => { // SETTINGS BRANCH-SPECIFIC TESTS // ============================================================================= -test("toBranchSpecificPath: converts settings.yaml to branch-specific", () => { - const result = toBranchSpecificPath("settings.yaml", "main"); +test("toWorkspaceSpecificPath: converts settings.yaml to workspace-specific", () => { + const result = toWorkspaceSpecificPath("settings.yaml", "main"); expect(result).toEqual("settings.main.yaml"); }); -test("toBranchSpecificPath: sanitizes branch name in settings path", () => { - const result = toBranchSpecificPath("settings.yaml", "feature/test"); +test("toWorkspaceSpecificPath: sanitizes branch name in settings path", () => { + const result = toWorkspaceSpecificPath("settings.yaml", "feature/test"); expect(result).toEqual("settings.feature_test.yaml"); }); -test("fromBranchSpecificPath: converts branch-specific settings back to base", () => { - const result = fromBranchSpecificPath("settings.main.yaml", "main"); +test("fromWorkspaceSpecificPath: converts workspace-specific settings back to base", () => { + const result = fromWorkspaceSpecificPath("settings.main.yaml", "main"); expect(result).toEqual("settings.yaml"); }); -test("fromBranchSpecificPath: handles sanitized branch names for settings", () => { - const result = fromBranchSpecificPath("settings.feature_test.yaml", "feature/test"); +test("fromWorkspaceSpecificPath: handles sanitized branch names for settings", () => { + const result = fromWorkspaceSpecificPath("settings.feature_test.yaml", "feature/test"); expect(result).toEqual("settings.yaml"); }); @@ -473,49 +473,49 @@ test("isSpecificItem: does not match settings.yaml when settings is undefined", expect(isSpecificItem("settings.yaml", config)).toEqual(false); }); -test("isBranchSpecificFile: detects branch-specific settings files", () => { - expect(isBranchSpecificFile("settings.main.yaml")).toEqual(true); - expect(isBranchSpecificFile("settings.develop.yaml")).toEqual(true); - expect(isBranchSpecificFile("settings.feature_test.yaml")).toEqual(true); +test("isWorkspaceSpecificFile: detects workspace-specific settings files", () => { + expect(isWorkspaceSpecificFile("settings.main.yaml")).toEqual(true); + expect(isWorkspaceSpecificFile("settings.develop.yaml")).toEqual(true); + expect(isWorkspaceSpecificFile("settings.feature_test.yaml")).toEqual(true); }); -test("isBranchSpecificFile: returns false for non-branch-specific settings", () => { - expect(isBranchSpecificFile("settings.yaml")).toEqual(false); +test("isWorkspaceSpecificFile: returns false for non-workspace-specific settings", () => { + expect(isWorkspaceSpecificFile("settings.yaml")).toEqual(false); }); -test("isCurrentBranchFile: detects branch-specific settings for current branch", () => { - expect(isCurrentBranchFile("settings.staging.yaml", "staging")).toEqual(true); - expect(isCurrentBranchFile("settings.staging.yaml", "production")).toEqual(false); - expect(isCurrentBranchFile("settings.yaml", "staging")).toEqual(false); +test("isCurrentWorkspaceFile: detects workspace-specific settings for current branch", () => { + expect(isCurrentWorkspaceFile("settings.staging.yaml", "staging")).toEqual(true); + expect(isCurrentWorkspaceFile("settings.staging.yaml", "production")).toEqual(false); + expect(isCurrentWorkspaceFile("settings.yaml", "staging")).toEqual(false); }); -test("isCurrentBranchFile: handles sanitized branch for settings", () => { - expect(isCurrentBranchFile("settings.feature_test.yaml", "feature/test")).toEqual(true); - expect(isCurrentBranchFile("settings.feature_test.yaml", "feature/other")).toEqual(false); +test("isCurrentWorkspaceFile: handles sanitized branch for settings", () => { + expect(isCurrentWorkspaceFile("settings.feature_test.yaml", "feature/test")).toEqual(true); + expect(isCurrentWorkspaceFile("settings.feature_test.yaml", "feature/other")).toEqual(false); }); test("round-trip: settings path conversion", () => { const original = "settings.yaml"; const branch = "main"; - const branchSpecific = toBranchSpecificPath(original, branch); + const branchSpecific = toWorkspaceSpecificPath(original, branch); expect(branchSpecific).toEqual("settings.main.yaml"); - const restored = fromBranchSpecificPath(branchSpecific, branch); + const restored = fromWorkspaceSpecificPath(branchSpecific, branch); expect(restored).toEqual(original); }); test("round-trip: settings with sanitized branch", () => { const original = "settings.yaml"; const branch = "release/v1.0"; - const branchSpecific = toBranchSpecificPath(original, branch); + const branchSpecific = toWorkspaceSpecificPath(original, branch); expect(branchSpecific).toEqual("settings.release_v1_0.yaml"); - const restored = fromBranchSpecificPath(branchSpecific, branch); + const restored = fromWorkspaceSpecificPath(branchSpecific, branch); expect(restored).toEqual(original); }); // ============================================================================= // isItemTypeConfigured TESTS // This function checks if the TYPE is configured, not whether it matches pattern. -// Used to determine if branch-specific files should be used for this type. +// Used to determine if workspace-specific files should be used for this type. // ============================================================================= test("isItemTypeConfigured: returns false when specificItems is undefined", () => { @@ -628,11 +628,11 @@ test("isItemTypeConfigured: returns false for resource files when resources is N // ============================================================================= // BRANCH-SPECIFIC FILE FILTERING TESTS // These tests verify the expected filtering behavior: -// - When type IS configured: use branch-specific files, skip base files -// - When type is NOT configured: skip branch-specific files, use base files +// - When type IS configured: use workspace-specific files, skip base files +// - When type is NOT configured: skip workspace-specific files, use base files // ============================================================================= -test("filtering logic: folders - when NOT configured, branch-specific should be ignored", () => { +test("filtering logic: folders - when NOT configured, workspace-specific should be ignored", () => { // Config has variables but NOT folders const config: SpecificItemsConfig = { variables: ["f/**"], @@ -644,15 +644,15 @@ test("filtering logic: folders - when NOT configured, branch-specific should be // Folder type is NOT configured expect(isItemTypeConfigured(basePath, config)).toEqual(false); - // Therefore, branch-specific file detection should not apply to this type + // Therefore, workspace-specific file detection should not apply to this type // The sync logic should: - // 1. Skip branch-specific folder files (isBranchSpecificFile returns true) + // 1. Skip workspace-specific folder files (isWorkspaceSpecificFile returns true) // 2. Use the base file - expect(isBranchSpecificFile(branchSpecificPath)).toEqual(true); - expect(isBranchSpecificFile(basePath)).toEqual(false); + expect(isWorkspaceSpecificFile(branchSpecificPath)).toEqual(true); + expect(isWorkspaceSpecificFile(basePath)).toEqual(false); }); -test("filtering logic: folders - when IS configured and matches, use branch-specific", () => { +test("filtering logic: folders - when IS configured and matches, use workspace-specific", () => { const config: SpecificItemsConfig = { folders: ["f/my_folder"], }; @@ -667,15 +667,15 @@ test("filtering logic: folders - when IS configured and matches, use branch-spec expect(isSpecificItem(basePath, config)).toEqual(true); // The sync logic should: - // 1. Use branch-specific folder file (map to base path) + // 1. Use workspace-specific folder file (map to base path) // 2. Skip the base file - expect(isBranchSpecificFile(branchSpecificPath)).toEqual(true); - expect(fromBranchSpecificPath(branchSpecificPath, "main")).toEqual(basePath); + expect(isWorkspaceSpecificFile(branchSpecificPath)).toEqual(true); + expect(fromWorkspaceSpecificPath(branchSpecificPath, "main")).toEqual(basePath); }); -test("filtering logic: folders - when IS configured but doesn't match, skip branch-specific", () => { +test("filtering logic: folders - when IS configured but doesn't match, skip workspace-specific", () => { const config: SpecificItemsConfig = { - folders: ["f/env_*"], // Only env_ folders are branch-specific + folders: ["f/env_*"], // Only env_ folders are workspace-specific }; const basePath = "f/other_folder/folder.meta.yaml"; @@ -688,11 +688,11 @@ test("filtering logic: folders - when IS configured but doesn't match, skip bran expect(isSpecificItem(basePath, config)).toEqual(false); // The sync logic should: - // 1. Skip the branch-specific file (type configured but doesn't match) + // 1. Skip the workspace-specific file (type configured but doesn't match) // 2. Use the base file }); -test("filtering logic: settings - when NOT configured, branch-specific should be ignored", () => { +test("filtering logic: settings - when NOT configured, workspace-specific should be ignored", () => { // Config has variables but NOT settings const config: SpecificItemsConfig = { variables: ["f/**"], @@ -704,12 +704,12 @@ test("filtering logic: settings - when NOT configured, branch-specific should be // Settings type is NOT configured expect(isItemTypeConfigured(basePath, config)).toEqual(false); - // Therefore, branch-specific file detection should not apply to this type - expect(isBranchSpecificFile(branchSpecificPath)).toEqual(true); - expect(isBranchSpecificFile(basePath)).toEqual(false); + // Therefore, workspace-specific file detection should not apply to this type + expect(isWorkspaceSpecificFile(branchSpecificPath)).toEqual(true); + expect(isWorkspaceSpecificFile(basePath)).toEqual(false); }); -test("filtering logic: settings - when IS configured (true), use branch-specific", () => { +test("filtering logic: settings - when IS configured (true), use workspace-specific", () => { const config: SpecificItemsConfig = { settings: true, }; @@ -723,12 +723,12 @@ test("filtering logic: settings - when IS configured (true), use branch-specific // And settings: true means it matches expect(isSpecificItem(basePath, config)).toEqual(true); - // The sync logic should use branch-specific file - expect(isBranchSpecificFile(branchSpecificPath)).toEqual(true); - expect(fromBranchSpecificPath(branchSpecificPath, "main")).toEqual(basePath); + // The sync logic should use workspace-specific file + expect(isWorkspaceSpecificFile(branchSpecificPath)).toEqual(true); + expect(fromWorkspaceSpecificPath(branchSpecificPath, "main")).toEqual(basePath); }); -test("filtering logic: settings - when IS configured (false), skip branch-specific", () => { +test("filtering logic: settings - when IS configured (false), skip workspace-specific", () => { // settings: false means type is configured but explicitly disabled const config: SpecificItemsConfig = { settings: false, @@ -743,10 +743,10 @@ test("filtering logic: settings - when IS configured (false), skip branch-specif // But settings: false means it doesn't match (not a specific item) expect(isSpecificItem(basePath, config)).toEqual(false); - // The sync logic should skip branch-specific file and use base + // The sync logic should skip workspace-specific file and use base }); -test("filtering logic: variables - when NOT configured, branch-specific should be ignored", () => { +test("filtering logic: variables - when NOT configured, workspace-specific should be ignored", () => { // Config has folders but NOT variables const config: SpecificItemsConfig = { folders: ["f/env_*"], @@ -759,11 +759,11 @@ test("filtering logic: variables - when NOT configured, branch-specific should b expect(isItemTypeConfigured(basePath, config)).toEqual(false); // Branch-specific variable files should be ignored - expect(isBranchSpecificFile(branchSpecificPath)).toEqual(true); - expect(isBranchSpecificFile(basePath)).toEqual(false); + expect(isWorkspaceSpecificFile(branchSpecificPath)).toEqual(true); + expect(isWorkspaceSpecificFile(basePath)).toEqual(false); }); -test("filtering logic: resources - when NOT configured, branch-specific should be ignored", () => { +test("filtering logic: resources - when NOT configured, workspace-specific should be ignored", () => { // Config has folders but NOT resources const config: SpecificItemsConfig = { folders: ["f/env_*"], @@ -775,11 +775,11 @@ test("filtering logic: resources - when NOT configured, branch-specific should b // Resource type is NOT configured expect(isItemTypeConfigured(basePath, config)).toEqual(false); - expect(isBranchSpecificFile(branchSpecificPath)).toEqual(true); - expect(isBranchSpecificFile(basePath)).toEqual(false); + expect(isWorkspaceSpecificFile(branchSpecificPath)).toEqual(true); + expect(isWorkspaceSpecificFile(basePath)).toEqual(false); }); -test("filtering logic: triggers - when NOT configured, branch-specific should be ignored", () => { +test("filtering logic: triggers - when NOT configured, workspace-specific should be ignored", () => { // Config has folders but NOT triggers const config: SpecificItemsConfig = { folders: ["f/env_*"], @@ -791,8 +791,8 @@ test("filtering logic: triggers - when NOT configured, branch-specific should be // Trigger type is NOT configured expect(isItemTypeConfigured(basePath, config)).toEqual(false); - expect(isBranchSpecificFile(branchSpecificPath)).toEqual(true); - expect(isBranchSpecificFile(basePath)).toEqual(false); + expect(isWorkspaceSpecificFile(branchSpecificPath)).toEqual(true); + expect(isWorkspaceSpecificFile(basePath)).toEqual(false); }); // ============================================================================= diff --git a/cli/test/sync_pull_push.test.ts b/cli/test/sync_pull_push.test.ts index 93f42bee54..07f3e9226b 100644 --- a/cli/test/sync_pull_push.test.ts +++ b/cli/test/sync_pull_push.test.ts @@ -41,6 +41,7 @@ import { isFlowInlineScriptPath, isRawAppBackendPath, getModuleFolderSuffix, + hasWrongFormatSuffix, } from "../src/utils/resource_folders.ts"; import { newPathAssigner } from "../windmill-utils-internal/src/path-utils/path-assigner.ts"; @@ -1240,6 +1241,32 @@ test("setNonDottedPaths and getNonDottedPaths work correctly", () => { expect(getNonDottedPaths()).toEqual(false); }); +test("hasWrongFormatSuffix detects mismatched folder suffixes", () => { + // In dotted mode (default), non-dotted dirs are wrong + setNonDottedPaths(false); + expect(hasWrongFormatSuffix("my_flow__flow")).toEqual("flow"); + expect(hasWrongFormatSuffix("my_app__app")).toEqual("app"); + expect(hasWrongFormatSuffix("my_raw_app__raw_app")).toEqual("raw_app"); + // Correct format should return null + expect(hasWrongFormatSuffix("my_flow.flow")).toBeNull(); + expect(hasWrongFormatSuffix("my_app.app")).toBeNull(); + expect(hasWrongFormatSuffix("my_raw_app.raw_app")).toBeNull(); + // Non-resource dirs return null + expect(hasWrongFormatSuffix("some_folder")).toBeNull(); + expect(hasWrongFormatSuffix("node_modules")).toBeNull(); + + // In non-dotted mode, dotted dirs are wrong + setNonDottedPaths(true); + expect(hasWrongFormatSuffix("my_flow.flow")).toEqual("flow"); + expect(hasWrongFormatSuffix("my_app.app")).toEqual("app"); + expect(hasWrongFormatSuffix("my_raw_app.raw_app")).toEqual("raw_app"); + // Correct format should return null + expect(hasWrongFormatSuffix("my_flow__flow")).toBeNull(); + expect(hasWrongFormatSuffix("my_app__app")).toBeNull(); + expect(hasWrongFormatSuffix("my_raw_app__raw_app")).toBeNull(); + setNonDottedPaths(false); // Reset +}); + // ============================================================================= // nonDottedPaths Fixture Functions // ============================================================================= diff --git a/cli/test/workspaces_config.test.ts b/cli/test/workspaces_config.test.ts new file mode 100644 index 0000000000..cda8f5bc70 --- /dev/null +++ b/cli/test/workspaces_config.test.ts @@ -0,0 +1,520 @@ +import { expect, test, describe } from "bun:test"; +import { writeFile, mkdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { withTestBackend } from "./test_backend.ts"; +import { addWorkspace } from "../workspace.ts"; +import { parseJsonFromCLIOutput } from "./test_config_helpers.ts"; +import { getEffectiveSettings, readConfigFile, convertGitBranchesToWorkspaces, findWorkspaceByGitBranch, getEffectiveWorkspaceId, getEffectiveGitBranch, getWorkspaceNames, type SyncOptions } from "../src/core/conf.ts"; +import { getSpecificItemsForCurrentBranch } from "../src/core/specific_items.ts"; + +// ============================================================================= +// Helper +// ============================================================================= + +async function setupWorkspaceProfile(backend: any, workspaceName: string): Promise { + await addWorkspace({ + remote: backend.baseUrl, + workspaceId: backend.workspace, + name: workspaceName, + token: backend.token, + }, { force: true, configDir: backend.testConfigDir }); +} + +// ============================================================================= +// UNIT TESTS: workspaces config helpers +// ============================================================================= + +describe("findWorkspaceByGitBranch", () => { + test("finds workspace by key when gitBranch not set (default)", () => { + const ws = { staging: { baseUrl: "https://staging.wm.dev" } }; + const match = findWorkspaceByGitBranch(ws as any, "staging"); + expect(match).toBeDefined(); + expect(match![0]).toEqual("staging"); + }); + + test("finds workspace by explicit gitBranch", () => { + const ws = { production: { gitBranch: "main", baseUrl: "https://app.wm.dev" } }; + const match = findWorkspaceByGitBranch(ws as any, "main"); + expect(match).toBeDefined(); + expect(match![0]).toEqual("production"); + }); + + test("does not find workspace when gitBranch differs", () => { + const ws = { production: { gitBranch: "main", baseUrl: "https://app.wm.dev" } }; + const match = findWorkspaceByGitBranch(ws as any, "production"); + expect(match).toBeUndefined(); + }); + + test("returns undefined for undefined workspaces", () => { + expect(findWorkspaceByGitBranch(undefined, "main")).toBeUndefined(); + }); + + test("skips commonSpecificItems", () => { + const ws = { commonSpecificItems: { variables: ["f/**"] }, staging: { baseUrl: "x" } }; + const match = findWorkspaceByGitBranch(ws as any, "commonSpecificItems"); + expect(match).toBeUndefined(); + }); + + test("returns first match when multiple map to same gitBranch", () => { + const ws = { + alpha: { gitBranch: "develop", baseUrl: "a" }, + beta: { gitBranch: "develop", baseUrl: "b" }, + }; + const match = findWorkspaceByGitBranch(ws as any, "develop"); + expect(match![0]).toEqual("alpha"); + }); +}); + +describe("getEffectiveWorkspaceId", () => { + test("returns workspaceId when set", () => { + expect(getEffectiveWorkspaceId("prod", { workspaceId: "production" })).toEqual("production"); + }); + + test("defaults to workspace name when workspaceId not set", () => { + expect(getEffectiveWorkspaceId("staging", {})).toEqual("staging"); + }); +}); + +describe("getEffectiveGitBranch", () => { + test("returns gitBranch when set", () => { + expect(getEffectiveGitBranch("production", { gitBranch: "main" })).toEqual("main"); + }); + + test("defaults to workspace name when gitBranch not set", () => { + expect(getEffectiveGitBranch("staging", {})).toEqual("staging"); + }); +}); + +describe("getWorkspaceNames", () => { + test("excludes commonSpecificItems", () => { + const ws = { staging: {}, production: {}, commonSpecificItems: { variables: [] } }; + expect(getWorkspaceNames(ws as any)).toEqual(["staging", "production"]); + }); + + test("returns empty for undefined", () => { + expect(getWorkspaceNames(undefined)).toEqual([]); + }); +}); + +// ============================================================================= +// UNIT TESTS: convertGitBranchesToWorkspaces +// ============================================================================= + +describe("convertGitBranchesToWorkspaces", () => { + test("preserves all fields from old branch entries", () => { + const old = { + main: { + baseUrl: "https://app.wm.dev", + workspaceId: "production", + overrides: { skipSecrets: false }, + promotionOverrides: { skipSecrets: true }, + specificItems: { variables: ["f/**"] }, + stateful: true, + message: "auto", + }, + }; + const ws = convertGitBranchesToWorkspaces(old as any); + const entry = (ws as any).main; + expect(entry.baseUrl).toEqual("https://app.wm.dev"); + expect(entry.workspaceId).toEqual("production"); + expect(entry.overrides).toEqual({ skipSecrets: false }); + expect(entry.promotionOverrides).toEqual({ skipSecrets: true }); + expect(entry.specificItems).toEqual({ variables: ["f/**"] }); + expect(entry.stateful).toEqual(true); + expect(entry.message).toEqual("auto"); + }); + + test("preserves commonSpecificItems", () => { + const old = { + commonSpecificItems: { variables: ["f/shared/**"], settings: true }, + main: { overrides: {} }, + }; + const ws = convertGitBranchesToWorkspaces(old as any); + expect(ws.commonSpecificItems).toEqual({ variables: ["f/shared/**"], settings: true }); + }); + + test("handles empty entries", () => { + const ws = convertGitBranchesToWorkspaces({ main: {} } as any); + expect((ws as any).main).toEqual({}); + }); + + test("does not set gitBranch (defaults to key name)", () => { + const ws = convertGitBranchesToWorkspaces({ main: { baseUrl: "x" } } as any); + expect((ws as any).main.gitBranch).toBeUndefined(); + }); +}); + +// ============================================================================= +// UNIT TESTS: getEffectiveSettings with workspaces config +// ============================================================================= + +describe("getEffectiveSettings with workspaces", () => { + test("applies overrides by workspace name", async () => { + const config: SyncOptions = { + includes: ["f/**"], + workspaces: { + staging: { overrides: { includes: ["staging/**"], skipVariables: true } }, + production: { overrides: { skipSecrets: false } }, + }, + }; + const s = await getEffectiveSettings(config, undefined, true, true, "staging"); + expect(s.includes).toEqual(["staging/**"]); + expect(s.skipVariables).toEqual(true); + }); + + test("returns top-level settings for unknown workspace", async () => { + const config: SyncOptions = { includes: ["f/**"], workspaces: { staging: { overrides: {} } } }; + const s = await getEffectiveSettings(config, undefined, true, true, "nonexistent"); + expect(s.includes).toEqual(["f/**"]); + }); + + test("promotion resolves by gitBranch", async () => { + const config: SyncOptions = { + workspaces: { + production: { + gitBranch: "main", + promotionOverrides: { skipSecrets: false }, + }, + }, + }; + // --promotion main should find workspace "production" via gitBranch match + const s = await getEffectiveSettings(config, "main", true, true); + expect(s.skipSecrets).toEqual(false); + }); +}); + +// ============================================================================= +// UNIT TESTS: getSpecificItemsForCurrentBranch with workspaces +// ============================================================================= + +describe("getSpecificItemsForCurrentBranch with workspaces", () => { + test("looks up by workspace name override", () => { + const config: SyncOptions = { + workspaces: { + staging: { specificItems: { variables: ["f/staging/**"] } }, + production: { specificItems: { variables: ["f/prod/**"] } }, + commonSpecificItems: { resources: ["f/shared/**"] }, + }, + }; + const items = getSpecificItemsForCurrentBranch(config, "staging"); + expect(items?.variables).toEqual(["f/staging/**"]); + expect(items?.resources).toEqual(["f/shared/**"]); + }); + + test("returns undefined for unknown workspace", () => { + const config: SyncOptions = { + workspaces: { staging: { specificItems: { variables: ["f/**"] } } }, + }; + const items = getSpecificItemsForCurrentBranch(config, "nonexistent"); + expect(items).toBeUndefined(); + }); + + test("returns undefined when no workspaces config", () => { + expect(getSpecificItemsForCurrentBranch({}, "staging")).toBeUndefined(); + }); + + test("merges common and workspace-specific items", () => { + const config: SyncOptions = { + workspaces: { + commonSpecificItems: { variables: ["common/**"], resources: ["shared/**"] }, + dev: { specificItems: { variables: ["dev/**"], triggers: ["dev/triggers/**"] } }, + }, + }; + const items = getSpecificItemsForCurrentBranch(config, "dev"); + expect(items?.variables).toEqual(["common/**", "dev/**"]); + expect(items?.resources).toEqual(["shared/**"]); + expect(items?.triggers).toEqual(["dev/triggers/**"]); + }); +}); + +// ============================================================================= +// INTEGRATION TESTS: new workspaces config format +// ============================================================================= + +test("Integration: workspaces config with --branch applies correct overrides", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend, "ws_test"); + + // New workspaces config with workspace name ≠ gitBranch + await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun +includes: + - "**" +skipVariables: false + +workspaces: + production: + gitBranch: prod_branch + overrides: + skipVariables: true + staging: + overrides: + skipVariables: false`, "utf-8"); + + // --branch prod_branch should resolve to workspace "production" and skip variables + const result = await backend.runCLICommand([ + 'sync', 'pull', + '--branch', 'prod_branch', + '--dry-run', + '--json-output' + ], tempDir); + + expect(result.code).toEqual(0); + const output = parseJsonFromCLIOutput(result.stdout); + const paths = (output.changes || []).map((c: any) => c.path); + const hasVariables = paths.some((p: string) => p.includes('.variable.yaml')); + expect(hasVariables).toEqual(false); + }); +}); + +test("Integration: workspaces config with --branch resolves default gitBranch", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend, "ws_test"); + + // Workspace name = "staging", gitBranch defaults to "staging" + await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun +includes: + - "**" +skipVariables: false + +workspaces: + staging: + overrides: + skipVariables: true`, "utf-8"); + + // --branch staging resolves to workspace "staging" + const result = await backend.runCLICommand([ + 'sync', 'pull', + '--branch', 'staging', + '--dry-run', + '--json-output' + ], tempDir); + + expect(result.code).toEqual(0); + const output = parseJsonFromCLIOutput(result.stdout); + const paths = (output.changes || []).map((c: any) => c.path); + const hasVariables = paths.some((p: string) => p.includes('.variable.yaml')); + expect(hasVariables).toEqual(false); + }); +}); + +test("Integration: different workspaces have different overrides", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend, "ws_test"); + + await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun +includes: + - "**" +skipVariables: false +skipResources: false + +workspaces: + production: + gitBranch: prod + overrides: + skipVariables: true + skipResources: true + development: + gitBranch: dev + overrides: + skipVariables: false + skipResources: false`, "utf-8"); + + // prod should skip both + const prodResult = await backend.runCLICommand([ + 'sync', 'pull', '--branch', 'prod', '--dry-run', '--json-output' + ], tempDir); + expect(prodResult.code).toEqual(0); + const prodPaths = (parseJsonFromCLIOutput(prodResult.stdout).changes || []).map((c: any) => c.path); + expect(prodPaths.some((p: string) => p.includes('.variable.yaml'))).toEqual(false); + expect(prodPaths.some((p: string) => p.includes('.resource.yaml'))).toEqual(false); + + // dev should include both + const devResult = await backend.runCLICommand([ + 'sync', 'pull', '--branch', 'dev', '--dry-run', '--json-output' + ], tempDir); + expect(devResult.code).toEqual(0); + const devPaths = (parseJsonFromCLIOutput(devResult.stdout).changes || []).map((c: any) => c.path); + expect(devPaths.some((p: string) => p.includes('.variable.yaml'))).toEqual(true); + expect(devPaths.some((p: string) => p.includes('.resource.yaml'))).toEqual(true); + }); +}); + +test("Integration: legacy gitBranches config still works via normalization", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend, "legacy_test"); + + // Old gitBranches format on disk + await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun +includes: + - "**" +skipVariables: false + +gitBranches: + legacy_branch: + overrides: + skipVariables: true`, "utf-8"); + + const result = await backend.runCLICommand([ + 'sync', 'pull', '--branch', 'legacy_branch', '--dry-run', '--json-output' + ], tempDir); + expect(result.code).toEqual(0); + const paths = (parseJsonFromCLIOutput(result.stdout).changes || []).map((c: any) => c.path); + expect(paths.some((p: string) => p.includes('.variable.yaml'))).toEqual(false); + }); +}); + +test("Integration: legacy environments config still works via normalization", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend, "env_test"); + + // Old environments format on disk + await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun +includes: + - "**" +skipVariables: false + +environments: + env_branch: + overrides: + skipVariables: true`, "utf-8"); + + const result = await backend.runCLICommand([ + 'sync', 'pull', '--branch', 'env_branch', '--dry-run', '--json-output' + ], tempDir); + expect(result.code).toEqual(0); + const paths = (parseJsonFromCLIOutput(result.stdout).changes || []).map((c: any) => c.path); + expect(paths.some((p: string) => p.includes('.variable.yaml'))).toEqual(false); + }); +}); + +test("Integration: workspace-specific files use workspace name as suffix", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend, "specific_test"); + + // Workspace "production" with gitBranch "main" and specificItems + await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun +includes: + - "**" + +workspaces: + production: + gitBranch: prod_branch + specificItems: + variables: + - "f/**"`, "utf-8"); + + // Pull with --branch prod_branch → resolves to workspace "production" + // workspace-specific files should use "production" as suffix (the workspace name) + const result = await backend.runCLICommand([ + 'sync', 'pull', '--branch', 'prod_branch', '--dry-run', '--json-output' + ], tempDir); + expect(result.code).toEqual(0); + + const output = parseJsonFromCLIOutput(result.stdout); + const changes = output.changes || []; + + // Check that workspace-specific paths use the workspace name "production" as suffix + const wsSpecificPaths = changes + .filter((c: any) => c.workspace_specific_path) + .map((c: any) => c.workspace_specific_path); + + for (const p of wsSpecificPaths) { + expect(p).toContain(".production."); + expect(p).not.toContain(".prod_branch."); + } + }); +}); + +test("Integration: promotion with workspaces config", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend, "promo_test"); + + await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun +includes: + - "**" +skipVariables: false + +workspaces: + staging: + gitBranch: staging_branch + overrides: + skipVariables: false + promotionOverrides: + skipVariables: true`, "utf-8"); + + // --promotion staging_branch should find workspace "staging" via gitBranch + // and apply promotionOverrides (skipVariables: true) + const result = await backend.runCLICommand([ + 'sync', 'pull', + '--branch', 'staging_branch', + '--promotion', 'staging_branch', + '--dry-run', + '--json-output' + ], tempDir); + expect(result.code).toEqual(0); + const paths = (parseJsonFromCLIOutput(result.stdout).changes || []).map((c: any) => c.path); + expect(paths.some((p: string) => p.includes('.variable.yaml'))).toEqual(false); + }); +}); + +test("Integration: config migrate converts gitBranches to workspaces", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend, "migrate_test"); + + // Write old format + const yamlPath = `${tempDir}/wmill.yaml`; + await writeFile(yamlPath, `defaultTs: bun +includes: + - "f/**" + +gitBranches: + main: + baseUrl: https://app.windmill.dev + workspaceId: production + overrides: + skipSecrets: false + staging: + baseUrl: https://staging.windmill.dev + overrides: + includeSchedules: true + commonSpecificItems: + variables: + - "f/shared/**"`, "utf-8"); + + // Run migrate + const result = await backend.runCLICommand([ + 'config', 'migrate', + ], tempDir); + expect(result.code).toEqual(0); + + // Read back and verify + const migrated = await readFile(yamlPath, "utf-8"); + expect(migrated).toContain("workspaces:"); + expect(migrated).not.toContain("gitBranches:"); + expect(migrated).toContain("production"); + expect(migrated).toContain("staging"); + expect(migrated).toContain("commonSpecificItems"); + expect(migrated).toContain("f/shared/**"); + }); +}); + +test("Integration: config migrate is idempotent", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend, "idempotent_test"); + + const yamlPath = `${tempDir}/wmill.yaml`; + await writeFile(yamlPath, `defaultTs: bun +workspaces: + staging: + baseUrl: https://staging.wm.dev + overrides: {}`, "utf-8"); + + const result = await backend.runCLICommand(['config', 'migrate'], tempDir); + expect(result.code).toEqual(0); + + // File should be unchanged + const content = await readFile(yamlPath, "utf-8"); + expect(content).toContain("workspaces:"); + expect(content).not.toContain("gitBranches:"); + }); +}); diff --git a/cli/wmill.schema.json b/cli/wmill.schema.json index 7129563f3f..6acbd4794b 100644 --- a/cli/wmill.schema.json +++ b/cli/wmill.schema.json @@ -222,45 +222,45 @@ "additionalProperties": false } }, - "gitBranches": { + "workspaces": { "type": "object", - "description": "Map git branches to workspaces and per-branch sync overrides", + "description": "Map workspace names to Windmill instances and per-workspace sync overrides", "properties": { "commonSpecificItems": { "type": "object", - "description": "Sync only specific items", + "description": "Items to sync per-workspace (stored as ...yaml on disk)", "properties": { "variables": { "type": "array", "items": { "type": "string" }, - "description": "Specific variable paths to sync" + "description": "Variable path patterns to sync per-workspace" }, "resources": { "type": "array", "items": { "type": "string" }, - "description": "Specific resource paths to sync" + "description": "Resource path patterns to sync per-workspace" }, "triggers": { "type": "array", "items": { "type": "string" }, - "description": "Specific trigger paths to sync" + "description": "Trigger path patterns to sync per-workspace" }, "folders": { "type": "array", "items": { "type": "string" }, - "description": "Specific folder paths to sync" + "description": "Folder path patterns to sync per-workspace" }, "settings": { "type": "boolean", - "description": "Whether to sync settings" + "description": "Whether to sync settings per-workspace" } }, "additionalProperties": false @@ -269,17 +269,21 @@ "additionalProperties": { "type": "object", "properties": { - "baseUrl": { + "gitBranch": { "type": "string", - "description": "Windmill instance URL for this branch" + "description": "Git branch name (defaults to the workspace name/key)" }, "workspaceId": { "type": "string", - "description": "Workspace ID to sync with for this branch" + "description": "Workspace ID to sync with (defaults to the workspace name/key)" + }, + "baseUrl": { + "type": "string", + "description": "Windmill instance URL for this workspace" }, "overrides": { "type": "object", - "description": "Override any top-level sync option for this branch" + "description": "Override any top-level sync option for this workspace" }, "promotionOverrides": { "type": "object", @@ -287,39 +291,149 @@ }, "specificItems": { "type": "object", - "description": "Sync only specific items", + "description": "Items to sync per-workspace (stored as ...yaml on disk)", "properties": { "variables": { "type": "array", "items": { "type": "string" }, - "description": "Specific variable paths to sync" + "description": "Variable path patterns to sync per-workspace" }, "resources": { "type": "array", "items": { "type": "string" }, - "description": "Specific resource paths to sync" + "description": "Resource path patterns to sync per-workspace" }, "triggers": { "type": "array", "items": { "type": "string" }, - "description": "Specific trigger paths to sync" + "description": "Trigger path patterns to sync per-workspace" }, "folders": { "type": "array", "items": { "type": "string" }, - "description": "Specific folder paths to sync" + "description": "Folder path patterns to sync per-workspace" }, "settings": { "type": "boolean", - "description": "Whether to sync settings" + "description": "Whether to sync settings per-workspace" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "gitBranches": { + "type": "object", + "description": "[Deprecated] Use 'workspaces' instead. Map git branches to workspaces.", + "properties": { + "commonSpecificItems": { + "type": "object", + "description": "Items to sync per-workspace (stored as ...yaml on disk)", + "properties": { + "variables": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Variable path patterns to sync per-workspace" + }, + "resources": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Resource path patterns to sync per-workspace" + }, + "triggers": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Trigger path patterns to sync per-workspace" + }, + "folders": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Folder path patterns to sync per-workspace" + }, + "settings": { + "type": "boolean", + "description": "Whether to sync settings per-workspace" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": { + "type": "object", + "properties": { + "gitBranch": { + "type": "string", + "description": "Git branch name (defaults to the workspace name/key)" + }, + "workspaceId": { + "type": "string", + "description": "Workspace ID to sync with (defaults to the workspace name/key)" + }, + "baseUrl": { + "type": "string", + "description": "Windmill instance URL for this workspace" + }, + "overrides": { + "type": "object", + "description": "Override any top-level sync option for this workspace" + }, + "promotionOverrides": { + "type": "object", + "description": "Overrides applied when using --promotion flag" + }, + "specificItems": { + "type": "object", + "description": "Items to sync per-workspace (stored as ...yaml on disk)", + "properties": { + "variables": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Variable path patterns to sync per-workspace" + }, + "resources": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Resource path patterns to sync per-workspace" + }, + "triggers": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Trigger path patterns to sync per-workspace" + }, + "folders": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Folder path patterns to sync per-workspace" + }, + "settings": { + "type": "boolean", + "description": "Whether to sync settings per-workspace" } }, "additionalProperties": false @@ -330,43 +444,43 @@ }, "environments": { "type": "object", - "description": "Alias for gitBranches — use if you prefer environment-based terminology", + "description": "[Deprecated] Use 'workspaces' instead.", "properties": { "commonSpecificItems": { "type": "object", - "description": "Sync only specific items", + "description": "Items to sync per-workspace (stored as ...yaml on disk)", "properties": { "variables": { "type": "array", "items": { "type": "string" }, - "description": "Specific variable paths to sync" + "description": "Variable path patterns to sync per-workspace" }, "resources": { "type": "array", "items": { "type": "string" }, - "description": "Specific resource paths to sync" + "description": "Resource path patterns to sync per-workspace" }, "triggers": { "type": "array", "items": { "type": "string" }, - "description": "Specific trigger paths to sync" + "description": "Trigger path patterns to sync per-workspace" }, "folders": { "type": "array", "items": { "type": "string" }, - "description": "Specific folder paths to sync" + "description": "Folder path patterns to sync per-workspace" }, "settings": { "type": "boolean", - "description": "Whether to sync settings" + "description": "Whether to sync settings per-workspace" } }, "additionalProperties": false @@ -375,17 +489,21 @@ "additionalProperties": { "type": "object", "properties": { - "baseUrl": { + "gitBranch": { "type": "string", - "description": "Windmill instance URL for this branch" + "description": "Git branch name (defaults to the workspace name/key)" }, "workspaceId": { "type": "string", - "description": "Workspace ID to sync with for this branch" + "description": "Workspace ID to sync with (defaults to the workspace name/key)" + }, + "baseUrl": { + "type": "string", + "description": "Windmill instance URL for this workspace" }, "overrides": { "type": "object", - "description": "Override any top-level sync option for this branch" + "description": "Override any top-level sync option for this workspace" }, "promotionOverrides": { "type": "object", @@ -393,39 +511,39 @@ }, "specificItems": { "type": "object", - "description": "Sync only specific items", + "description": "Items to sync per-workspace (stored as ...yaml on disk)", "properties": { "variables": { "type": "array", "items": { "type": "string" }, - "description": "Specific variable paths to sync" + "description": "Variable path patterns to sync per-workspace" }, "resources": { "type": "array", "items": { "type": "string" }, - "description": "Specific resource paths to sync" + "description": "Resource path patterns to sync per-workspace" }, "triggers": { "type": "array", "items": { "type": "string" }, - "description": "Specific trigger paths to sync" + "description": "Trigger path patterns to sync per-workspace" }, "folders": { "type": "array", "items": { "type": "string" }, - "description": "Specific folder paths to sync" + "description": "Folder path patterns to sync per-workspace" }, "settings": { "type": "boolean", - "description": "Whether to sync settings" + "description": "Whether to sync settings per-workspace" } }, "additionalProperties": false diff --git a/ee-repo-ref.txt b/ee-repo-ref.txt deleted file mode 100644 index 6dcdb4be96..0000000000 --- a/ee-repo-ref.txt +++ /dev/null @@ -1 +0,0 @@ -bb2c05c399d27b0fadedbf6e1fa5c4f19e71f37f diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c38109cad6..9bbad1dc71 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-components", - "version": "1.680.0", + "version": "1.682.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-components", - "version": "1.680.0", + "version": "1.682.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 68bb4487c9..628eb66508 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "windmill-components", - "version": "1.680.0", + "version": "1.682.0", "scripts": { "dev": "vite dev", "build": "vite build", diff --git a/frontend/src/lib/components/AppConnectInner.svelte b/frontend/src/lib/components/AppConnectInner.svelte index 891d391395..dd9f767c8e 100644 --- a/frontend/src/lib/components/AppConnectInner.svelte +++ b/frontend/src/lib/components/AppConnectInner.svelte @@ -830,6 +830,12 @@ >Create a resource backed by an OAuth connection, whose token is fetched from the external services and refreshed automatically if needed before expiration. + {#if resourceTypeInfo?.description} diff --git a/frontend/src/lib/components/CiTestResults.svelte b/frontend/src/lib/components/CiTestResults.svelte new file mode 100644 index 0000000000..b3256a5b21 --- /dev/null +++ b/frontend/src/lib/components/CiTestResults.svelte @@ -0,0 +1,75 @@ + + +{#if hasResults} +
+
+ + CI tests +
+
+ {#each results as test (test.test_script_path)} +
+ {#if test.status === 'success'} + + {:else if test.status === 'failure' || test.status === 'canceled'} + + {:else if test.status === 'running'} + + {:else} +
+ {/if} + + {test.test_script_path} + + {#if test.job_id} + + view run + + {/if} +
+ {/each} +
+
+{/if} diff --git a/frontend/src/lib/components/CompareWorkspaces.svelte b/frontend/src/lib/components/CompareWorkspaces.svelte index 68f1625e17..0b62cea9d7 100644 --- a/frontend/src/lib/components/CompareWorkspaces.svelte +++ b/frontend/src/lib/components/CompareWorkspaces.svelte @@ -7,13 +7,17 @@ ArrowUp, ArrowUpRight, Building, + CircleCheck, + CircleX, DiffIcon, FileJson, + FlaskConical, GitFork, Loader2, Trash2, Upload } from 'lucide-svelte' + import type { CiTestResult } from '$lib/gen' import { Alert, Badge } from './common' import { AppService, @@ -503,6 +507,57 @@ extraLabel?: string } + let ciTestResults = $state>({}) + + async function fetchCiTests() { + if (!comparison?.diffs || !currentWorkspaceId) return + const items = comparison.diffs + .filter((d) => d.kind === 'script' || d.kind === 'flow' || d.kind === 'resource') + .map((d) => ({ path: d.path, kind: d.kind as 'script' | 'flow' | 'resource' })) + if (items.length === 0) return + try { + ciTestResults = await ScriptService.getCiTestResultsBatch({ + workspace: currentWorkspaceId, + requestBody: { items } + }) + } catch (e) { + console.error('Failed to fetch CI test results:', e) + } + } + + $effect(() => { + if (comparison && comparison.diffs.length > 0) { + fetchCiTests() + } + }) + + function getCiTestStatus(diff: WorkspaceItemDiff): 'pass' | 'fail' | 'running' | null { + const key = `${diff.kind}:${diff.path}` + const results = ciTestResults[key] + if (!results || results.length === 0) return null + if (results.some((r) => r.status === 'failure' || r.status === 'canceled')) return 'fail' + if (results.some((r) => r.status === 'running' || (r.job_id && !r.status))) return 'running' + if (results.every((r) => r.status === 'success')) return 'pass' + return null + } + + // Deduplicated list of all CI tests across all items + let allCiTests = $derived.by(() => { + const seen = new Map() + for (const results of Object.values(ciTestResults)) { + for (const r of results) { + const existing = seen.get(r.test_script_path) + if ( + !existing || + (r.started_at && (!existing.started_at || r.started_at > existing.started_at)) + ) { + seen.set(r.test_script_path, r) + } + } + } + return [...seen.values()] + }) + let forkTriggers = $state([]) let loadingTriggers = $state(true) let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state(undefined) @@ -827,6 +882,38 @@ {/snippet} + {#if allCiTests.length > 0} +
+
+ + CI tests +
+
+ {#each allCiTests as test (test.test_script_path)} +
+ {#if test.status === 'success'} + + {:else if test.status === 'failure' || test.status === 'canceled'} + + {:else if test.status === 'running'} + + {:else} +
+ {/if} + {test.test_script_path} + {#if test.job_id} + + view run + + {/if} +
+ {/each} +
+
+ {/if} {#snippet alerts()} {#if mergeIntoParent} @@ -965,6 +1052,16 @@ size="xs">New {/if} + {@const ciStatus = getCiTestStatus(diff)} + {#if ciStatus === 'pass'} + CI pass + {:else if ciStatus === 'fail'} + CI fail + {:else if ciStatus === 'running'} + CI + {/if} {#if !deploymentStatus[key] || deploymentStatus[key].status != 'deployed'}
{#if isConflict || existsInBothWorkspaces} diff --git a/frontend/src/lib/components/DedicatedWorkersSelector.svelte b/frontend/src/lib/components/DedicatedWorkersSelector.svelte index 670e0384bd..d318faf9e5 100644 --- a/frontend/src/lib/components/DedicatedWorkersSelector.svelte +++ b/frontend/src/lib/components/DedicatedWorkersSelector.svelte @@ -307,13 +307,15 @@ }) $effect(() => { - loadWorkspaces() + if (!disabled) { + loadWorkspaces() + } }) async function loadWorkspaces() { try { workspacesLoading = true - const ws = await WorkspaceService.listWorkspaces() + const ws = await WorkspaceService.listWorkspacesAsSuperAdmin() workspaces = ws.map((w) => ({ id: w.id, name: w.name })) } catch (e) { console.error('Failed to load workspaces', e) diff --git a/frontend/src/lib/components/DeployWorkspaceDrawer.svelte b/frontend/src/lib/components/DeployWorkspaceDrawer.svelte index 8fdc594e56..a8ab488d35 100644 --- a/frontend/src/lib/components/DeployWorkspaceDrawer.svelte +++ b/frontend/src/lib/components/DeployWorkspaceDrawer.svelte @@ -24,7 +24,7 @@ } - + {#if (kind != 'trigger' && kind != undefined && initialPath != undefined) || (kind === 'trigger' && initialPath != undefined && additionalInformation?.triggers != undefined)} @@ -870,7 +877,7 @@ }} />
- + {#if flowStore.val?.value?.modules}
@@ -925,6 +932,17 @@ {/key}
+ {#if selectedModule} +
+ {selectedModule.id} summary + +
+ {/if} {/if} diff --git a/frontend/src/lib/components/EditorBar.svelte b/frontend/src/lib/components/EditorBar.svelte index 0b2e5ae6f8..b6addb6176 100644 --- a/frontend/src/lib/components/EditorBar.svelte +++ b/frontend/src/lib/components/EditorBar.svelte @@ -89,6 +89,8 @@ | 'claudesandbox' | 'wac_python' | 'wac_typescript' + | 'ci_test_bun' + | 'ci_test_python' collabMode?: boolean collabLive?: boolean collabUsers?: { name: string }[] diff --git a/frontend/src/lib/components/FlowStatusViewerInner.svelte b/frontend/src/lib/components/FlowStatusViewerInner.svelte index c33ab7cd0a..d109a23b97 100644 --- a/frontend/src/lib/components/FlowStatusViewerInner.svelte +++ b/frontend/src/lib/components/FlowStatusViewerInner.svelte @@ -884,7 +884,8 @@ previewResult: job['result'], previewArgs: job.args, previewJobId: job.id, - previewSuccess: job['success'] + previewSuccess: job['success'], + previewLogs: job['logs'] } } @@ -1863,7 +1864,7 @@
-
+
{#each Object.values(retryStatus?.val ?? {}) as count} {#if count} @@ -1883,6 +1884,7 @@ triggerNode={true} download={!hideDownloadInGraph} minHeight={wrapperHeight - retryStatusHeight} + outerDivClass="max-h-screen" success={jobId != undefined && isSuccess(job?.['success'])} flowModuleStates={localModuleStates} bind:expandedSubflows @@ -1931,8 +1933,8 @@
-
- +
+ {#if !hideTimeline} {/if} @@ -1945,238 +1947,244 @@ {/if} - {#if rightColumnSelect == 'timeline'} - buildSubflowKey(key, prefix)} - durationStatuses={localDurationStatuses} - /> - {:else if rightColumnSelect == 'node_status'} -
- {#if selectedNode?.startsWith(AI_TOOL_MESSAGE_PREFIX)} -
+
+ {#if rightColumnSelect == 'timeline'} + buildSubflowKey(key, prefix)} + durationStatuses={localDurationStatuses} + /> + {:else if rightColumnSelect == 'node_status'} +
+ {#if selectedNode?.startsWith(AI_TOOL_MESSAGE_PREFIX)} +
+ +
+ {:else if selectedNode?.startsWith(AI_MCP_TOOL_CALL_PREFIX)} + {@const [, agentModuleId, toolCallIndex] = selectedNode.split('-')} + {@const agentNode = localModuleStates?.[agentModuleId]} + {@const agentActions = agentNode?.agent_actions} + {@const mcpActionIndex = parseInt(toolCallIndex)} + {@const mcpAction = + agentActions && + mcpActionIndex >= 0 && + mcpActionIndex < agentActions.length + ? agentActions[mcpActionIndex] + : undefined} + {#if mcpAction?.type === 'mcp_tool_call' && agentNode?.result?.messages} + {@const message = agentNode.result.messages.find( + (m: { agent_action?: { call_id?: string }; content?: any }) => + m.agent_action?.call_id === mcpAction.call_id + )} + + {/if} + {:else if selectedNode?.startsWith(AI_WEBSEARCH_PREFIX)} -
- {:else if selectedNode?.startsWith(AI_MCP_TOOL_CALL_PREFIX)} - {@const [, agentModuleId, toolCallIndex] = selectedNode.split('-')} - {@const agentNode = localModuleStates?.[agentModuleId]} - {@const agentActions = agentNode?.agent_actions} - {@const mcpActionIndex = parseInt(toolCallIndex)} - {@const mcpAction = - agentActions && mcpActionIndex >= 0 && mcpActionIndex < agentActions.length - ? agentActions[mcpActionIndex] - : undefined} - {#if mcpAction?.type === 'mcp_tool_call' && agentNode?.result?.messages} - {@const message = agentNode.result.messages.find( - (m: { agent_action?: { call_id?: string }; content?: any }) => - m.agent_action?.call_id === mcpAction.call_id - )} - - {/if} - {:else if selectedNode?.startsWith(AI_WEBSEARCH_PREFIX)} - - {:else if selectedNode} - {@const node = localModuleStates[selectedNode]} - {#if selectedNode == 'end'} - - {:else if selectedNode == 'start'} - {#if job.args} - - {:else} -

No arguments

- {/if} - {:else if node} - {@const module = - stepDetail && typeof stepDetail !== 'string' ? stepDetail : undefined} - {@const agentTools = - module && module.value.type === 'aiagent' - ? module.value.tools - : undefined} - {@const parentLoopsPrefix = getParentLoopsPrefix(module?.id ?? '')} - {#if node.flow_jobs_results} -
- Result of step as collection of all subflows -
- -
-
- {/if} -
- {#if node.flow_jobs_results} - Selected subflow + {:else if selectedNode == 'start'} + {#if job.args} + + {:else} +

No arguments

{/if} -
-
-
- + Result of step as collection of all subflows +
+ - {#if node.duration_ms} - - - {msToSec(node.duration_ms)} s - - {/if} - {#if node.job_id && !isReplay} -
- +
+ {/if} +
+ {#if node.flow_jobs_results} + Selected subflow + {/if} +
+
+ + {#if !node.isListJob} +
+
Inputs
- {truncateRev(node.job_id ?? '', 10)} - - + +
+ {/if} + {#if node.workflow_as_code_status} +
+
Workflow timeline
+
{/if}
- {#if !node.isListJob} -
-
Inputs
- -
- {/if} - {#if node.workflow_as_code_status} -
-
Workflow timeline
- -
- {/if} -
- { - if (module) { - const storeKey = parentLoopsPrefix + module.id + '-' + idx - toolCallStore?.setStoredToolCallJob(storeKey, job) + { + if (module) { + const storeKey = parentLoopsPrefix + module.id + '-' + idx + toolCallStore?.setStoredToolCallJob(storeKey, job) + } } } - } - : undefined} - /> + : undefined} + /> +
-
- {:else} -

The execution of this node has no information attached to it. The job - likely did not run yet

- {/if} - {:else}

Select a node to see its details here

{/if} -
- {:else if rightColumnSelect == 'node_definition'} - {@const node = selectedNode ? localModuleStates[selectedNode] : undefined} - - {:else if rightColumnSelect == 'user_states'} -
- -
- {:else if rightColumnSelect == 'tracing'} - {@const node = selectedNode ? localModuleStates[selectedNode] : undefined} - {#if node?.job_id} - - {:else} -
Select a node with a job to see HTTP request traces
+ {:else} +

The execution of this node has no information attached to it. The job + likely did not run yet

+ {/if} + {:else}

Select a node to see its details here

{/if} +
+ {:else if rightColumnSelect == 'node_definition'} + {@const node = selectedNode ? localModuleStates[selectedNode] : undefined} + + {:else if rightColumnSelect == 'user_states'} +
+ +
+ {:else if rightColumnSelect == 'tracing'} + {@const node = selectedNode ? localModuleStates[selectedNode] : undefined} + {#if node?.job_id} + + {:else} +
Select a node with a job to see HTTP request traces
+ {/if} {/if} - {/if} +
diff --git a/frontend/src/lib/components/ForkWorkspaceBanner.svelte b/frontend/src/lib/components/ForkWorkspaceBanner.svelte index df3469f57d..92cea4bdf8 100644 --- a/frontend/src/lib/components/ForkWorkspaceBanner.svelte +++ b/frontend/src/lib/components/ForkWorkspaceBanner.svelte @@ -1,9 +1,9 @@ + +{#if open} + +{/if} diff --git a/frontend/src/lib/components/InstanceSettings.svelte b/frontend/src/lib/components/InstanceSettings.svelte index 909fcbf13f..731ff161ae 100644 --- a/frontend/src/lib/components/InstanceSettings.svelte +++ b/frontend/src/lib/components/InstanceSettings.svelte @@ -657,8 +657,12 @@ 'workspace_registries' ]) - // Settings that should never appear in YAML export/import - const excludedKeys: Set = new Set([]) + // Settings that should never appear in YAML export/import. + // `worker_configs` is a legacy ghost key: worker configs live in the `config` + // table (managed from /workers), not in `global_settings`. Older DBs may + // still carry a stale `global_settings.worker_configs` row; filter it here + // so it never round-trips through this editor. + const excludedKeys: Set = new Set(['worker_configs']) // Nested fields inside object-valued settings that contain secrets. // Each entry maps a top-level key to its sensitive sub-field names. diff --git a/frontend/src/lib/components/ModuleTest.svelte b/frontend/src/lib/components/ModuleTest.svelte index 37cb96bea7..9e408e234d 100644 --- a/frontend/src/lib/components/ModuleTest.svelte +++ b/frontend/src/lib/components/ModuleTest.svelte @@ -142,7 +142,8 @@ ...flowStateStore.val[mod.id], previewResult: testJob.result, previewSuccess: testJob.success, - previewJobId: testJob.id + previewJobId: testJob.id, + previewLogs: testJob['logs'] } } stepHistoryLoader?.resetInitial(mod.id) diff --git a/frontend/src/lib/components/NoMainFuncBadge.svelte b/frontend/src/lib/components/NoMainFuncBadge.svelte index c72e05583c..250c4fa6e4 100644 --- a/frontend/src/lib/components/NoMainFuncBadge.svelte +++ b/frontend/src/lib/components/NoMainFuncBadge.svelte @@ -7,5 +7,5 @@ {#snippet text()} Library script (no exported main function) {/snippet} - Library + Library diff --git a/frontend/src/lib/components/OffboardItemsBox.svelte b/frontend/src/lib/components/OffboardItemsBox.svelte new file mode 100644 index 0000000000..8e97c5bf21 --- /dev/null +++ b/frontend/src/lib/components/OffboardItemsBox.svelte @@ -0,0 +1,70 @@ + + +
+
+ + {#if headerExtra} + {@render headerExtra()} + {/if} +
+ {#if description} +

+ {description} +

+ {/if} + {#if expanded} +
    + {#each flattenPaths(paths) as { kind, path }} +
  • {kindLabel(kind)}{#if itemHref(kind, path)}{path}{:else}{path}{/if}
  • + {/each} +
+ {/if} +
diff --git a/frontend/src/lib/components/OffboardReassignControls.svelte b/frontend/src/lib/components/OffboardReassignControls.svelte new file mode 100644 index 0000000000..b15f1322de --- /dev/null +++ b/frontend/src/lib/components/OffboardReassignControls.svelte @@ -0,0 +1,72 @@ + + +{#if showTargetSelector} +
+ Move u/{username}/* items to + { + targetKind = value + }} + class={size === 'sm' ? 'mb-1.5' : 'mb-2'} + > + {#snippet children({ item })} + + + {/snippet} + + {#if targetKind === 'user'} + + {/if} +
+{/if} + +
+ Update triggers/runnables permissions to + +
+ {/if}
diff --git a/lsp/Pipfile b/lsp/Pipfile index d87c40fe7b..9a24e5e9ed 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.680.0" +wmill = ">=1.682.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index a8a09fa08d..6aa9eed276 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.680.0 + version: 1.682.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index 67163773fc..be17b08b4c 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.680.0' + ModuleVersion = '1.682.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index eb903f8c87..a56c7c0ff6 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.680.0" +version = "1.682.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/system_prompts/auto-generated/cli/cli-commands.md b/system_prompts/auto-generated/cli/cli-commands.md index f9da0f9de7..ce616a1101 100644 --- a/system_prompts/auto-generated/cli/cli-commands.md +++ b/system_prompts/auto-generated/cli/cli-commands.md @@ -54,6 +54,10 @@ Show all available wmill.yaml configuration options **Options:** - `--json` - Output as JSON for programmatic consumption +**Subcommands:** + +- `config migrate` - Migrate wmill.yaml from gitBranches/environments to workspaces format + ### dependencies workspace dependencies related commands @@ -425,7 +429,7 @@ sync local with a remote workspaces or the opposite (push or pull) - `--extra-includes ` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string). Useful to still take wmill.yaml into account and act as a second pattern to satisfy - `--repository ` - Specify repository path (e.g., u/user/repo) when multiple repositories exist - `--promotion ` - Use promotionOverrides from the specified branch instead of regular overrides - - `--branch, --env ` - Override the current git branch/environment (works even outside a git repository) + - `--branch, --env ` - [Deprecated: use --workspace] Override the current git branch/environment - `sync push` - Push any local changes and apply them remotely. - `--yes` - Push without needing confirmation - `--dry-run` - Show changes that would be pushed without actually pushing @@ -456,7 +460,7 @@ sync local with a remote workspaces or the opposite (push or pull) - `--message ` - Include a message that will be added to all scripts/flows/apps updated during this push - `--parallel ` - Number of changes to process in parallel - `--repository ` - Specify repository path (e.g., u/user/repo) when multiple repositories exist - - `--branch, --env ` - Override the current git branch/environment (works even outside a git repository) + - `--branch, --env ` - [Deprecated: use --workspace] Override the current git branch/environment - `--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 @@ -575,10 +579,11 @@ workspace related commands - `workspace list` - List local workspace profiles - `workspace list-remote` - List workspaces on the remote server that you have access to - `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) +- `workspace bind` - Create or update a workspace entry in wmill.yaml from the active profile + - `--workspace ` - Workspace name (default: current branch or workspaceId) + - `--branch ` - Git branch to associate (default: workspace name) +- `workspace unbind` - Remove baseUrl and workspaceId from a workspace entry + - `--workspace ` - Workspace to unbind - `workspace fork [workspace_name:string] [workspace_id:string]` - Create a forked workspace - `--create-workspace-name ` - Specify the workspace name. Ignored if --create is not specified or the workspace already exists. Will default to the workspace id. - `--color ` - Workspace color (hex code, e.g. #ff0000) diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index 9e2b01a6df..3ef60f6cd0 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -1588,6 +1588,10 @@ Show all available wmill.yaml configuration options **Options:** - \`--json\` - Output as JSON for programmatic consumption +**Subcommands:** + +- \`config migrate\` - Migrate wmill.yaml from gitBranches/environments to workspaces format + ### dependencies workspace dependencies related commands @@ -1959,7 +1963,7 @@ sync local with a remote workspaces or the opposite (push or pull) - \`--extra-includes \` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string). Useful to still take wmill.yaml into account and act as a second pattern to satisfy - \`--repository \` - Specify repository path (e.g., u/user/repo) when multiple repositories exist - \`--promotion \` - Use promotionOverrides from the specified branch instead of regular overrides - - \`--branch, --env \` - Override the current git branch/environment (works even outside a git repository) + - \`--branch, --env \` - [Deprecated: use --workspace] Override the current git branch/environment - \`sync push\` - Push any local changes and apply them remotely. - \`--yes\` - Push without needing confirmation - \`--dry-run\` - Show changes that would be pushed without actually pushing @@ -1990,7 +1994,7 @@ sync local with a remote workspaces or the opposite (push or pull) - \`--message \` - Include a message that will be added to all scripts/flows/apps updated during this push - \`--parallel \` - Number of changes to process in parallel - \`--repository \` - Specify repository path (e.g., u/user/repo) when multiple repositories exist - - \`--branch, --env \` - Override the current git branch/environment (works even outside a git repository) + - \`--branch, --env \` - [Deprecated: use --workspace] Override the current git branch/environment - \`--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 @@ -2109,10 +2113,11 @@ workspace related commands - \`workspace list\` - List local workspace profiles - \`workspace list-remote\` - List workspaces on the remote server that you have access to - \`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) +- \`workspace bind\` - Create or update a workspace entry in wmill.yaml from the active profile + - \`--workspace \` - Workspace name (default: current branch or workspaceId) + - \`--branch \` - Git branch to associate (default: workspace name) +- \`workspace unbind\` - Remove baseUrl and workspaceId from a workspace entry + - \`--workspace \` - Workspace to unbind - \`workspace fork [workspace_name:string] [workspace_id:string]\` - Create a forked workspace - \`--create-workspace-name \` - Specify the workspace name. Ignored if --create is not specified or the workspace already exists. Will default to the workspace id. - \`--color \` - Workspace color (hex code, e.g. #ff0000) diff --git a/system_prompts/auto-generated/skills/cli-commands/SKILL.md b/system_prompts/auto-generated/skills/cli-commands/SKILL.md index dadd6ce5aa..3da4f46f26 100644 --- a/system_prompts/auto-generated/skills/cli-commands/SKILL.md +++ b/system_prompts/auto-generated/skills/cli-commands/SKILL.md @@ -59,6 +59,10 @@ Show all available wmill.yaml configuration options **Options:** - `--json` - Output as JSON for programmatic consumption +**Subcommands:** + +- `config migrate` - Migrate wmill.yaml from gitBranches/environments to workspaces format + ### dependencies workspace dependencies related commands @@ -430,7 +434,7 @@ sync local with a remote workspaces or the opposite (push or pull) - `--extra-includes ` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string). Useful to still take wmill.yaml into account and act as a second pattern to satisfy - `--repository ` - Specify repository path (e.g., u/user/repo) when multiple repositories exist - `--promotion ` - Use promotionOverrides from the specified branch instead of regular overrides - - `--branch, --env ` - Override the current git branch/environment (works even outside a git repository) + - `--branch, --env ` - [Deprecated: use --workspace] Override the current git branch/environment - `sync push` - Push any local changes and apply them remotely. - `--yes` - Push without needing confirmation - `--dry-run` - Show changes that would be pushed without actually pushing @@ -461,7 +465,7 @@ sync local with a remote workspaces or the opposite (push or pull) - `--message ` - Include a message that will be added to all scripts/flows/apps updated during this push - `--parallel ` - Number of changes to process in parallel - `--repository ` - Specify repository path (e.g., u/user/repo) when multiple repositories exist - - `--branch, --env ` - Override the current git branch/environment (works even outside a git repository) + - `--branch, --env ` - [Deprecated: use --workspace] Override the current git branch/environment - `--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 @@ -580,10 +584,11 @@ workspace related commands - `workspace list` - List local workspace profiles - `workspace list-remote` - List workspaces on the remote server that you have access to - `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) +- `workspace bind` - Create or update a workspace entry in wmill.yaml from the active profile + - `--workspace ` - Workspace name (default: current branch or workspaceId) + - `--branch ` - Git branch to associate (default: workspace name) +- `workspace unbind` - Remove baseUrl and workspaceId from a workspace entry + - `--workspace ` - Workspace to unbind - `workspace fork [workspace_name:string] [workspace_id:string]` - Create a forked workspace - `--create-workspace-name ` - Specify the workspace name. Ignored if --create is not specified or the workspace already exists. Will default to the workspace id. - `--color ` - Workspace color (hex code, e.g. #ff0000) diff --git a/typescript-client/jsr.json b/typescript-client/jsr.json index 5f02558fa8..c032c85267 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.680.0", + "version": "1.682.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 a9c56dbbb7..841ba08155 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.680.0", + "version": "1.682.0", "author": "Ruben Fiszel", "license": "Apache 2.0", "sideEffects": false, diff --git a/version.txt b/version.txt index dcd8d38bfe..42bd631960 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.680.0 +1.682.0