Compare commits

..

1 Commits

Author SHA1 Message Date
centdix
78bc6b498c feat: add workspace script search tools to script mode
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 12:31:38 +00:00
169 changed files with 1403 additions and 6069 deletions

View File

@@ -78,7 +78,7 @@ jobs:
- name: install xmlsec1 and gssapi
run: |
sudo apt-get update
sudo apt-get install -y libxml2-dev libxmlsec1-dev libkrb5-dev libsasl2-dev libcurl4-openssl-dev mold clang
sudo apt-get install -y libxml2-dev libxmlsec1-dev libkrb5-dev libsasl2-dev libcurl4-openssl-dev
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
@@ -89,7 +89,7 @@ jobs:
working-directory: ./backend
timeout-minutes: 16
run: |
SQLX_OFFLINE=true cargo check --features all_sqlx_features
SQLX_OFFLINE=true cargo check --features $(./all_features_oss.sh)
- name: Run Claude PR Action
uses: anthropics/claude-code-action@v1
@@ -119,7 +119,7 @@ jobs:
- Fix all warnings and errors before proceeding
**Backend Changes:**
- Run: \`cargo check --features all_sqlx_features\` in the backend directory
- Run: \`cargo check --features $(./all_features_oss.sh)\` in the backend directory
- Fix all warnings and errors before proceeding
**Pull Request Creation:**

View File

@@ -62,17 +62,17 @@ jobs:
path: windmill-ee-private
token: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }}
# Setup Rust toolchain
- uses: actions-rust-lang/setup-rust-toolchain@v1
# Cache rust dependencies
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
cache-workspaces: backend
toolchain: 1.90.0
workspaces: "./backend -> target"
- name: Install xmlsec and gssapi build-time deps
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
pkg-config libxml2-dev libssl-dev libkrb5-dev libsasl2-dev libcurl4-openssl-dev mold clang \
pkg-config libxml2-dev libssl-dev libkrb5-dev \
xmlsec1 libxmlsec1-dev libxmlsec1-openssl
- name: Run update-sqlx script

View File

@@ -1,30 +1,5 @@
# Changelog
## [1.630.1](https://github.com/windmill-labs/windmill/compare/v1.630.0...v1.630.1) (2026-02-10)
### Bug Fixes
* enforce self-approval check on flow resume owner endpoint ([#7886](https://github.com/windmill-labs/windmill/issues/7886)) ([7147dde](https://github.com/windmill-labs/windmill/commit/7147dde5118d7b3a179e4b310c74b148838b5afe))
## [1.630.0](https://github.com/windmill-labs/windmill/compare/v1.629.1...v1.630.0) (2026-02-10)
### Features
* add workspace search and runnable details tools to AI chat modes ([#7874](https://github.com/windmill-labs/windmill/issues/7874)) ([a7e269f](https://github.com/windmill-labs/windmill/commit/a7e269f9f3c82db0d7e6a70e174ac19d3df730d2))
* **aiagent:** add prompt caching for Anthropic models ([#7878](https://github.com/windmill-labs/windmill/issues/7878)) ([6272cd1](https://github.com/windmill-labs/windmill/commit/6272cd17a4f1300e22e7f0ae27b1a57571deb203))
* download encrypted usage ([#7804](https://github.com/windmill-labs/windmill/issues/7804)) ([8363ff1](https://github.com/windmill-labs/windmill/commit/8363ff1eeef06f284e6d165fbf2dfb190ead573d))
* **mcp:** add endpoint tools for scripts, flows, apps, and jobs ([#7859](https://github.com/windmill-labs/windmill/issues/7859)) ([03eb16a](https://github.com/windmill-labs/windmill/commit/03eb16a7c6c3cd9411840814940d09e22ce23305))
* restriction rulesets for workspaces ([#7879](https://github.com/windmill-labs/windmill/issues/7879)) ([2851b6b](https://github.com/windmill-labs/windmill/commit/2851b6b7caac4a55f5202ace82aba68fd157c52a))
### Bug Fixes
* **backend:** correct early return with stream + prevent delta miss ([#7872](https://github.com/windmill-labs/windmill/issues/7872)) ([1150eec](https://github.com/windmill-labs/windmill/commit/1150eec7571d5828d10b295cb61cca8edfbdffe0))
* gate Permissions import behind #[cfg(unix)] for Windows build ([cf596f3](https://github.com/windmill-labs/windmill/commit/cf596f370ae7cc232ca63f4752d7727a74cd449b))
* retry js eval up to 3 times on timeout from slow DB ([#7890](https://github.com/windmill-labs/windmill/issues/7890)) ([4c87e7a](https://github.com/windmill-labs/windmill/commit/4c87e7ac2e09ec83cfb998a1cebcb9b9c5ef8027))
## [1.629.1](https://github.com/windmill-labs/windmill/compare/v1.629.0...v1.629.1) (2026-02-10)

View File

@@ -12,7 +12,8 @@
"name": "native_trigger_service",
"kind": {
"Enum": [
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -17,7 +17,8 @@
"name": "native_trigger_service",
"kind": {
"Enum": [
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -30,7 +30,8 @@
"sqs",
"gcp",
"mqtt",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -24,7 +24,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -122,7 +122,8 @@
"sqs",
"gcp",
"mqtt",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -24,7 +24,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -40,7 +40,8 @@
"sqs",
"gcp",
"mqtt",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -34,7 +34,8 @@
"sqs",
"gcp",
"mqtt",
"nextcloud"
"nextcloud",
"google"
]
}
}
@@ -67,7 +68,8 @@
"sqs",
"gcp",
"mqtt",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -24,7 +24,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -40,7 +40,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -16,7 +16,8 @@
"name": "native_trigger_service",
"kind": {
"Enum": [
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -11,7 +11,8 @@
"name": "native_trigger_service",
"kind": {
"Enum": [
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -11,7 +11,8 @@
"name": "native_trigger_service",
"kind": {
"Enum": [
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -15,7 +15,8 @@
"name": "native_trigger_service",
"kind": {
"Enum": [
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -12,7 +12,8 @@
"name": "native_trigger_service",
"kind": {
"Enum": [
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -12,7 +12,8 @@
"name": "native_trigger_service",
"kind": {
"Enum": [
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -16,7 +16,8 @@
"name": "native_trigger_service",
"kind": {
"Enum": [
"nextcloud"
"nextcloud",
"google"
]
}
}
@@ -51,7 +52,8 @@
"name": "native_trigger_service",
"kind": {
"Enum": [
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -24,7 +24,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -30,7 +30,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -1,20 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM job_result_stream_v2\n WHERE job_id NOT IN (SELECT id FROM v2_job_queue)\n AND job_id NOT IN (\n SELECT id FROM v2_job_completed\n WHERE completed_at > NOW() - INTERVAL '60 seconds'\n )\n RETURNING job_id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "job_id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": []
},
"nullable": [
false
]
},
"hash": "454a611a5a162b2ace137c139bd5383bc7fe142c515dac3edd47991839485e51"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT j.id AS \"id!\", COALESCE(s.flow_status, s.workflow_as_code_status) as flow_status, q.suspend AS \"suspend!\", j.runnable_path as script_path, j.permissioned_as_email as email\n FROM v2_job_queue q JOIN v2_job j USING (id) LEFT JOIN v2_job_status s USING (id)\n WHERE j.id = $1\n ",
"query": "\n SELECT j.id AS \"id!\", COALESCE(s.flow_status, s.workflow_as_code_status) as flow_status, q.suspend AS \"suspend!\", j.runnable_path as script_path\n FROM v2_job_queue q JOIN v2_job j USING (id) LEFT JOIN v2_job_status s USING (id)\n WHERE j.id = $1\n ",
"describe": {
"columns": [
{
@@ -22,11 +22,6 @@
"ordinal": 3,
"name": "script_path",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "email",
"type_info": "Varchar"
}
],
"parameters": {
@@ -38,9 +33,8 @@
false,
null,
false,
true,
false
true
]
},
"hash": "5c6e158aee5db3c4bf41a0aedfa5d6c73f5e04ebfd8703dc631bc49f2e797dd4"
"hash": "485dc289a61a06595acae28d4968f3b6b2aeb6a8aee863dc999d6c8d58397814"
}

View File

@@ -1,46 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n workspace_id,\n name,\n rules as \"rules: ProtectionRules\",\n bypass_groups,\n bypass_users\n FROM workspace_protection_rule\n WHERE workspace_id = $1\n ORDER BY name\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "rules: ProtectionRules",
"type_info": "Int4"
},
{
"ordinal": 3,
"name": "bypass_groups",
"type_info": "TextArray"
},
{
"ordinal": 4,
"name": "bypass_users",
"type_info": "TextArray"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
false,
false
]
},
"hash": "4cfb35e423a75ca2701f03d5a30a7c0778af5e548254f3e0f29004d7f2058eef"
}

View File

@@ -37,7 +37,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -32,7 +32,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}
@@ -70,7 +71,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -16,7 +16,8 @@
"name": "native_trigger_service",
"kind": {
"Enum": [
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -245,7 +245,8 @@
"sqs",
"gcp",
"mqtt",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -1,18 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE workspace_protection_rule\n SET rules = $1, bypass_groups = $2, bypass_users = $3\n WHERE workspace_id = $4 AND name = $5\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int4",
"TextArray",
"TextArray",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "65b12bed9438900518b20dc268d71a2dba6ec66aee2971faef76b6ed56a05b6f"
}

View File

@@ -35,7 +35,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n WITH job_info AS (\n SELECT id, kind::text AS kind, parent_job\n FROM v2_job\n WHERE id = $1\n )\n SELECT\n q.id AS \"id!\",\n s.flow_status,\n q.suspend AS \"suspend!\",\n j.runnable_path AS script_path,\n j.permissioned_as_email AS email,\n (ji.kind IN ('flow', 'flowpreview')) AS \"is_flow_level!\"\n FROM job_info ji\n JOIN v2_job_queue q ON q.id = CASE\n WHEN ji.kind IN ('flow', 'flowpreview') THEN ji.id\n ELSE ji.parent_job\n END\n JOIN v2_job j ON j.id = q.id\n JOIN v2_job_status s ON s.id = q.id\n FOR UPDATE OF q\n ",
"query": "\n WITH job_info AS (\n SELECT id, kind::text AS kind, parent_job\n FROM v2_job\n WHERE id = $1\n )\n SELECT\n q.id AS \"id!\",\n s.flow_status,\n q.suspend AS \"suspend!\",\n j.runnable_path AS script_path,\n (ji.kind IN ('flow', 'flowpreview')) AS \"is_flow_level!\"\n FROM job_info ji\n JOIN v2_job_queue q ON q.id = CASE\n WHEN ji.kind IN ('flow', 'flowpreview') THEN ji.id\n ELSE ji.parent_job\n END\n JOIN v2_job j ON j.id = q.id\n JOIN v2_job_status s ON s.id = q.id\n FOR UPDATE OF q\n ",
"describe": {
"columns": [
{
@@ -25,11 +25,6 @@
},
{
"ordinal": 4,
"name": "email",
"type_info": "Varchar"
},
{
"ordinal": 5,
"name": "is_flow_level!",
"type_info": "Bool"
}
@@ -44,9 +39,8 @@
true,
false,
true,
false,
null
]
},
"hash": "1a0ab65bbf2751f702fc696c1e32a7dd9524cdd806be1ad8e9ab88d4c88d3f82"
"hash": "66e66da2ed6eace5d7ec2a41a7b11ae255f5dc212d1ff41c2905b303c8c13b18"
}

View File

@@ -29,7 +29,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -24,7 +24,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -40,7 +40,8 @@
"sqs",
"gcp",
"mqtt",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM workspace_protection_rule WHERE workspace_id = $1 AND name = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "7ddc2c3f1ce74f9b7acdd51b94431386e7782c52251cf094e06ddb198a958b43"
}

View File

@@ -27,7 +27,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -24,7 +24,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -35,7 +35,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -17,7 +17,8 @@
"name": "native_trigger_service",
"kind": {
"Enum": [
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -24,7 +24,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -17,7 +17,8 @@
"name": "native_trigger_service",
"kind": {
"Enum": [
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -32,7 +32,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -30,7 +30,8 @@
"sqs",
"gcp",
"mqtt",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -155,7 +155,8 @@
"sqs",
"gcp",
"mqtt",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM job_result_stream_v2 WHERE job_id NOT IN (SELECT id FROM v2_job_queue) RETURNING job_id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "job_id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": []
},
"nullable": [
false
]
},
"hash": "a3e75f0309be42aca0fd74834f34b3f18dbb388bd8b9bc88b99aebedae9c3fec"
}

View File

@@ -185,7 +185,8 @@
"sqs",
"gcp",
"mqtt",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n flow_version.id AS version,\n flow_version.value->>'early_return' as early_return,\n flow_version.value->>'preprocessor_module' IS NOT NULL as has_preprocessor,\n (flow_version.value->>'chat_input_enabled')::boolean as chat_input_enabled,\n flow.tag,\n flow.dedicated_worker,\n flow.on_behalf_of_email,\n flow.edited_by\n FROM\n flow_version\n INNER JOIN flow\n ON flow.path = flow_version.path AND\n flow.workspace_id = flow_version.workspace_id\n WHERE\n flow_version.workspace_id = $1 AND\n flow_version.path = $2 AND\n flow_version.id = $3\n ",
"query": "\n SELECT\n flow_version.id AS version,\n flow_version.value->>'early_return' as early_return, \n flow_version.value->>'preprocessor_module' IS NOT NULL as has_preprocessor, \n (flow_version.value->>'chat_input_enabled')::boolean as chat_input_enabled, \n flow.tag, \n flow.dedicated_worker, \n flow.on_behalf_of_email, \n flow.edited_by\n FROM \n flow_version\n INNER JOIN flow\n ON flow.path = flow_version.path AND\n flow.workspace_id = flow_version.workspace_id\n WHERE \n flow_version.workspace_id = $1 AND\n flow_version.path = $2 AND\n flow_version.id = $3\n ",
"describe": {
"columns": [
{
@@ -62,5 +62,5 @@
false
]
},
"hash": "209dc4c1b91eeab1c12ffcd9f9e16f315c689ca772c736b333dcdf07c8086087"
"hash": "a7468e9054beed88636786c5495ac3b9d9a6086ae6212ad9237b39a0346d7d26"
}

View File

@@ -1,23 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(SELECT 1 FROM workspace_protection_rule WHERE workspace_id = $1 AND name = $2)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "b1b26cb02dcc0748c63411c933e5e6bdfe82466134ba28408a530e22c66656de"
}

View File

@@ -160,7 +160,8 @@
"sqs",
"gcp",
"mqtt",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -18,8 +18,8 @@
"Left": []
},
"nullable": [
true,
false
false,
true
]
},
"hash": "b3dbdfb50ee8118bdaed3164b210cb549a34b96554ae1872355b90304f5dcb76"

View File

@@ -24,7 +24,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -59,9 +59,7 @@
"failure",
"command",
"approval",
"preprocessor",
"schedule_handler_old",
"dynamic_skip"
"preprocessor"
]
}
}

View File

@@ -21,7 +21,8 @@
"name": "native_trigger_service",
"kind": {
"Enum": [
"nextcloud"
"nextcloud",
"google"
]
}
}
@@ -71,7 +72,8 @@
"name": "native_trigger_service",
"kind": {
"Enum": [
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -105,7 +105,8 @@
"sqs",
"gcp",
"mqtt",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -31,7 +31,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -11,7 +11,8 @@
"name": "native_trigger_service",
"kind": {
"Enum": [
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -24,7 +24,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -12,7 +12,8 @@
"name": "native_trigger_service",
"kind": {
"Enum": [
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -105,7 +105,8 @@
"sqs",
"gcp",
"mqtt",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -25,7 +25,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -1,18 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO workspace_protection_rule (workspace_id, name, rules, bypass_groups, bypass_users)\n VALUES ($1, $2, $3, $4, $5)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Int4",
"TextArray",
"TextArray"
]
},
"nullable": []
},
"hash": "dbf2982f43577999dec0c488dfb67f56e5bd0e5fbf8da5132f6fc5a282d2b0e1"
}

View File

@@ -185,7 +185,8 @@
"sqs",
"gcp",
"mqtt",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -31,7 +31,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -24,7 +24,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -21,7 +21,8 @@
"name": "native_trigger_service",
"kind": {
"Enum": [
"nextcloud"
"nextcloud",
"google"
]
}
}
@@ -71,7 +72,8 @@
"name": "native_trigger_service",
"kind": {
"Enum": [
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -24,7 +24,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -21,7 +21,8 @@
"name": "native_trigger_service",
"kind": {
"Enum": [
"nextcloud"
"nextcloud",
"google"
]
}
}
@@ -71,7 +72,8 @@
"name": "native_trigger_service",
"kind": {
"Enum": [
"nextcloud"
"nextcloud",
"google"
]
}
}

197
backend/Cargo.lock generated
View File

@@ -890,7 +890,7 @@ dependencies = [
"aws-sigv4",
"aws-smithy-async",
"aws-smithy-eventstream",
"aws-smithy-http 0.63.4",
"aws-smithy-http 0.63.3",
"aws-smithy-runtime",
"aws-smithy-runtime-api",
"aws-smithy-types",
@@ -914,8 +914,8 @@ dependencies = [
"aws-credential-types",
"aws-runtime",
"aws-smithy-async",
"aws-smithy-http 0.63.4",
"aws-smithy-json 0.62.4",
"aws-smithy-http 0.63.3",
"aws-smithy-json 0.62.3",
"aws-smithy-observability",
"aws-smithy-runtime",
"aws-smithy-runtime-api",
@@ -980,16 +980,16 @@ dependencies = [
[[package]]
name = "aws-sdk-rds"
version = "1.125.0"
version = "1.124.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6897e8c3b3621b5616ccb838b1d30b8e18fb0c0699cea8e235e5005f902f12a"
checksum = "24627b8374f8f242f351aa8a69adae8a416826a89819a7bfd010fc48b81f51e8"
dependencies = [
"aws-credential-types",
"aws-runtime",
"aws-sigv4",
"aws-smithy-async",
"aws-smithy-http 0.63.4",
"aws-smithy-json 0.62.4",
"aws-smithy-http 0.63.3",
"aws-smithy-json 0.62.3",
"aws-smithy-observability",
"aws-smithy-query",
"aws-smithy-runtime",
@@ -1036,8 +1036,8 @@ dependencies = [
"aws-credential-types",
"aws-runtime",
"aws-smithy-async",
"aws-smithy-http 0.63.4",
"aws-smithy-json 0.62.4",
"aws-smithy-http 0.63.3",
"aws-smithy-json 0.62.3",
"aws-smithy-observability",
"aws-smithy-runtime",
"aws-smithy-runtime-api",
@@ -1060,8 +1060,8 @@ dependencies = [
"aws-credential-types",
"aws-runtime",
"aws-smithy-async",
"aws-smithy-http 0.63.4",
"aws-smithy-json 0.62.4",
"aws-smithy-http 0.63.3",
"aws-smithy-json 0.62.3",
"aws-smithy-observability",
"aws-smithy-runtime",
"aws-smithy-runtime-api",
@@ -1106,7 +1106,7 @@ checksum = "efa49f3c607b92daae0c078d48a4571f599f966dce3caee5f1ea55c4d9073f99"
dependencies = [
"aws-credential-types",
"aws-smithy-eventstream",
"aws-smithy-http 0.63.4",
"aws-smithy-http 0.63.3",
"aws-smithy-runtime-api",
"aws-smithy-types",
"bytes",
@@ -1123,9 +1123,9 @@ dependencies = [
[[package]]
name = "aws-smithy-async"
version = "1.2.12"
version = "1.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3cba48474f1d6807384d06fec085b909f5807e16653c5af5c45dfe89539f0b70"
checksum = "52eec3db979d18cb807fc1070961cc51d87d069abe9ab57917769687368a8c6c"
dependencies = [
"futures-util",
"pin-project-lite",
@@ -1134,9 +1134,9 @@ dependencies = [
[[package]]
name = "aws-smithy-eventstream"
version = "0.60.19"
version = "0.60.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c0b3e587fbaa5d7f7e870544508af8ce82ea47cd30376e69e1e37c4ac746f79"
checksum = "35b9c7354a3b13c66f60fe4616d6d1969c9fd36b1b5333a5dfb3ee716b33c588"
dependencies = [
"aws-smithy-types",
"bytes",
@@ -1167,9 +1167,9 @@ dependencies = [
[[package]]
name = "aws-smithy-http"
version = "0.63.4"
version = "0.63.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af4a8a5fe3e4ac7ee871237c340bbce13e982d37543b65700f4419e039f5d78e"
checksum = "630e67f2a31094ffa51b210ae030855cb8f3b7ee1329bdd8d085aaf61e8b97fc"
dependencies = [
"aws-smithy-runtime-api",
"aws-smithy-types",
@@ -1188,9 +1188,9 @@ dependencies = [
[[package]]
name = "aws-smithy-http-client"
version = "1.1.10"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0709f0083aa19b704132684bc26d3c868e06bd428ccc4373b0b55c3e8748a58b"
checksum = "12fb0abf49ff0cab20fd31ac1215ed7ce0ea92286ba09e2854b42ba5cabe7525"
dependencies = [
"aws-smithy-async",
"aws-smithy-runtime-api",
@@ -1227,27 +1227,27 @@ dependencies = [
[[package]]
name = "aws-smithy-json"
version = "0.62.4"
version = "0.62.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27b3a779093e18cad88bbae08dc4261e1d95018c4c5b9356a52bcae7c0b6e9bb"
checksum = "3cb96aa208d62ee94104645f7b2ecaf77bf27edf161590b6224bfbac2832f979"
dependencies = [
"aws-smithy-types",
]
[[package]]
name = "aws-smithy-observability"
version = "0.2.5"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d3f39d5bb871aaf461d59144557f16d5927a5248a983a40654d9cf3b9ba183b"
checksum = "c0a46543fbc94621080b3cf553eb4cbbdc41dd9780a30c4756400f0139440a1d"
dependencies = [
"aws-smithy-runtime-api",
]
[[package]]
name = "aws-smithy-query"
version = "0.60.14"
version = "0.60.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f76a580e3d8f8961e5d48763214025a2af65c2fa4cd1fb7f270a0e107a71b0"
checksum = "0cebbddb6f3a5bd81553643e9c7daf3cc3dc5b0b5f398ac668630e8a84e6fff0"
dependencies = [
"aws-smithy-types",
"urlencoding",
@@ -1255,12 +1255,12 @@ dependencies = [
[[package]]
name = "aws-smithy-runtime"
version = "1.10.1"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd3dfc18c1ce097cf81fced7192731e63809829c6cbf933c1ec47452d08e1aa"
checksum = "f3df87c14f0127a0d77eb261c3bc45d5b4833e2a1f63583ebfb728e4852134ee"
dependencies = [
"aws-smithy-async",
"aws-smithy-http 0.63.4",
"aws-smithy-http 0.63.3",
"aws-smithy-http-client",
"aws-smithy-observability",
"aws-smithy-runtime-api",
@@ -1280,9 +1280,9 @@ dependencies = [
[[package]]
name = "aws-smithy-runtime-api"
version = "1.11.4"
version = "1.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c55e0837e9b8526f49e0b9bfa9ee18ddee70e853f5bc09c5d11ebceddcb0fec"
checksum = "49952c52f7eebb72ce2a754d3866cc0f87b97d2a46146b79f80f3a93fb2b3716"
dependencies = [
"aws-smithy-async",
"aws-smithy-types",
@@ -1297,9 +1297,9 @@ dependencies = [
[[package]]
name = "aws-smithy-types"
version = "1.4.4"
version = "1.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "576b0d6991c9c32bc14fc340582ef148311f924d41815f641a308b5d11e8e7cd"
checksum = "3b3a26048eeab0ddeba4b4f9d51654c79af8c3b32357dc5f336cee85ab331c33"
dependencies = [
"base64-simd 0.8.0",
"bytes",
@@ -1323,9 +1323,9 @@ dependencies = [
[[package]]
name = "aws-smithy-types-convert"
version = "0.60.13"
version = "0.60.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b62e5e75fed51b75fbc63966c039ca403aa980ba637c0ccce86c3c3144629303"
checksum = "059deaa8583331f9f610b44c7cbc005d0cccec6dec3a7b387de096dbe6c06b8a"
dependencies = [
"aws-smithy-types",
"chrono",
@@ -1333,9 +1333,9 @@ dependencies = [
[[package]]
name = "aws-smithy-xml"
version = "0.60.14"
version = "0.60.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b53543b4b86ed43f051644f704a98c7291b3618b67adf057ee77a366fa52fcaa"
checksum = "11b2f670422ff42bf7065031e72b45bc52a3508bd089f743ea90731ca2b6ea57"
dependencies = [
"xmlparser",
]
@@ -8960,9 +8960,9 @@ dependencies = [
[[package]]
name = "ntapi"
version = "0.4.3"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
checksum = "c70f219e21142367c70c0b30c6a9e3a14d55b4d12a204d897fbec83a0363f081"
dependencies = [
"winapi",
]
@@ -11317,9 +11317,9 @@ dependencies = [
[[package]]
name = "rmcp"
version = "0.15.0"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bef41ebc9ebed2c1b1d90203e9d1756091e8a00bbc3107676151f39868ca0ee"
checksum = "0a621b37a548ff6ab6292d57841eb25785a7f146d89391a19c9f199414bd13da"
dependencies = [
"async-trait",
"axum 0.8.4",
@@ -11352,9 +11352,9 @@ dependencies = [
[[package]]
name = "rmcp-macros"
version = "0.15.0"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e88ad84b8b6237a934534a62b379a5be6388915663c0cc598ceb9b3292bbbfe"
checksum = "6b79ed92303f9262db79575aa8c3652581668e9d136be6fd0b9ededa78954c95"
dependencies = [
"darling 0.23.0",
"proc-macro2",
@@ -14451,9 +14451,9 @@ dependencies = [
[[package]]
name = "toml_parser"
version = "1.0.7+spec-1.1.0"
version = "1.0.6+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "247eaa3197818b831697600aadf81514e577e0cba5eab10f7e064e78ae154df1"
checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44"
dependencies = [
"winnow 0.7.14",
]
@@ -15725,7 +15725,7 @@ dependencies = [
[[package]]
name = "windmill"
version = "1.630.1"
version = "1.629.1"
dependencies = [
"anyhow",
"async-nats",
@@ -15783,7 +15783,7 @@ dependencies = [
[[package]]
name = "windmill-api"
version = "1.630.1"
version = "1.629.1"
dependencies = [
"anyhow",
"argon2",
@@ -15907,7 +15907,7 @@ dependencies = [
[[package]]
name = "windmill-api-auth"
version = "1.630.1"
version = "1.629.1"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -15933,7 +15933,7 @@ dependencies = [
[[package]]
name = "windmill-api-client"
version = "1.630.1"
version = "1.629.1"
dependencies = [
"reqwest 0.12.28",
"serde",
@@ -15943,7 +15943,7 @@ dependencies = [
[[package]]
name = "windmill-api-embeddings"
version = "1.630.1"
version = "1.629.1"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -15966,7 +15966,7 @@ dependencies = [
[[package]]
name = "windmill-api-groups"
version = "1.630.1"
version = "1.629.1"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -15986,7 +15986,7 @@ dependencies = [
[[package]]
name = "windmill-api-jobs"
version = "1.630.1"
version = "1.629.1"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -16011,7 +16011,7 @@ dependencies = [
[[package]]
name = "windmill-api-scripts"
version = "1.630.1"
version = "1.629.1"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16040,7 +16040,7 @@ dependencies = [
[[package]]
name = "windmill-api-sse"
version = "1.630.1"
version = "1.629.1"
dependencies = [
"lazy_static",
"serde",
@@ -16052,7 +16052,7 @@ dependencies = [
[[package]]
name = "windmill-api-users"
version = "1.630.1"
version = "1.629.1"
dependencies = [
"argon2",
"axum 0.7.9",
@@ -16075,7 +16075,7 @@ dependencies = [
[[package]]
name = "windmill-api-workspaces"
version = "1.630.1"
version = "1.629.1"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16088,7 +16088,6 @@ dependencies = [
"serde_json",
"sha2 0.10.9",
"sqlx",
"strum 0.27.2",
"tokio",
"tracing",
"uuid",
@@ -16104,7 +16103,7 @@ dependencies = [
[[package]]
name = "windmill-audit"
version = "1.630.1"
version = "1.629.1"
dependencies = [
"chrono",
"lazy_static",
@@ -16118,7 +16117,7 @@ dependencies = [
[[package]]
name = "windmill-autoscaling"
version = "1.630.1"
version = "1.629.1"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -16137,7 +16136,7 @@ dependencies = [
[[package]]
name = "windmill-common"
version = "1.630.1"
version = "1.629.1"
dependencies = [
"aes-gcm",
"anyhow",
@@ -16236,7 +16235,7 @@ dependencies = [
[[package]]
name = "windmill-dep-map"
version = "1.630.1"
version = "1.629.1"
dependencies = [
"chrono",
"itertools 0.14.0",
@@ -16255,7 +16254,7 @@ dependencies = [
[[package]]
name = "windmill-git-sync"
version = "1.630.1"
version = "1.629.1"
dependencies = [
"regex",
"serde",
@@ -16270,7 +16269,7 @@ dependencies = [
[[package]]
name = "windmill-indexer"
version = "1.630.1"
version = "1.629.1"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -16294,7 +16293,7 @@ dependencies = [
[[package]]
name = "windmill-jseval"
version = "1.630.1"
version = "1.629.1"
dependencies = [
"anyhow",
"futures",
@@ -16311,7 +16310,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.630.1"
version = "1.629.1"
dependencies = [
"itertools 0.14.0",
"lazy_static",
@@ -16327,7 +16326,7 @@ dependencies = [
[[package]]
name = "windmill-mcp"
version = "1.630.1"
version = "1.629.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16348,7 +16347,7 @@ dependencies = [
[[package]]
name = "windmill-native-triggers"
version = "1.630.1"
version = "1.629.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16379,7 +16378,7 @@ dependencies = [
[[package]]
name = "windmill-oauth"
version = "1.630.1"
version = "1.629.1"
dependencies = [
"anyhow",
"async-oauth2",
@@ -16403,7 +16402,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.630.1"
version = "1.629.1"
dependencies = [
"convert_case 0.6.0",
"serde",
@@ -16412,7 +16411,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.630.1"
version = "1.629.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -16424,7 +16423,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.630.1"
version = "1.629.1"
dependencies = [
"anyhow",
"serde_json",
@@ -16436,7 +16435,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.630.1"
version = "1.629.1"
dependencies = [
"anyhow",
"gosyn",
@@ -16448,7 +16447,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.630.1"
version = "1.629.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -16460,7 +16459,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.630.1"
version = "1.629.1"
dependencies = [
"anyhow",
"serde_json",
@@ -16472,7 +16471,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.630.1"
version = "1.629.1"
dependencies = [
"anyhow",
"nu-parser",
@@ -16483,7 +16482,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.630.1"
version = "1.629.1"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -16494,7 +16493,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.630.1"
version = "1.629.1"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -16507,7 +16506,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.630.1"
version = "1.629.1"
dependencies = [
"anyhow",
"async-recursion",
@@ -16531,7 +16530,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.630.1"
version = "1.629.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -16545,7 +16544,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.630.1"
version = "1.629.1"
dependencies = [
"anyhow",
"convert_case 0.6.0",
@@ -16562,7 +16561,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.630.1"
version = "1.629.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -16576,7 +16575,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.630.1"
version = "1.629.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -16595,7 +16594,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.630.1"
version = "1.629.1"
dependencies = [
"anyhow",
"serde",
@@ -16606,7 +16605,7 @@ dependencies = [
[[package]]
name = "windmill-queue"
version = "1.630.1"
version = "1.629.1"
dependencies = [
"anyhow",
"async-recursion",
@@ -16643,7 +16642,7 @@ dependencies = [
[[package]]
name = "windmill-runtime-nativets"
version = "1.630.1"
version = "1.629.1"
dependencies = [
"anyhow",
"const_format",
@@ -16681,7 +16680,7 @@ dependencies = [
[[package]]
name = "windmill-sql-datatype-parser-wasm"
version = "1.630.1"
version = "1.629.1"
dependencies = [
"wasm-bindgen",
"wasm-bindgen-test",
@@ -16691,7 +16690,7 @@ dependencies = [
[[package]]
name = "windmill-store"
version = "1.630.1"
version = "1.629.1"
dependencies = [
"anyhow",
"async-recursion",
@@ -16720,7 +16719,7 @@ dependencies = [
[[package]]
name = "windmill-trigger"
version = "1.630.1"
version = "1.629.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16753,7 +16752,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-email"
version = "1.630.1"
version = "1.629.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16773,7 +16772,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-gcp"
version = "1.630.1"
version = "1.629.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16807,7 +16806,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-http"
version = "1.630.1"
version = "1.629.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16841,7 +16840,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-kafka"
version = "1.630.1"
version = "1.629.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16864,7 +16863,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-mqtt"
version = "1.630.1"
version = "1.629.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16888,7 +16887,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-nats"
version = "1.630.1"
version = "1.629.1"
dependencies = [
"anyhow",
"async-nats",
@@ -16912,7 +16911,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-postgres"
version = "1.630.1"
version = "1.629.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16947,7 +16946,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-sqs"
version = "1.630.1"
version = "1.629.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16975,7 +16974,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-websocket"
version = "1.630.1"
version = "1.629.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16998,7 +16997,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.630.1"
version = "1.629.1"
dependencies = [
"anyhow",
"chrono",
@@ -17015,7 +17014,7 @@ dependencies = [
[[package]]
name = "windmill-worker"
version = "1.630.1"
version = "1.629.1"
dependencies = [
"anyhow",
"async-once-cell",

View File

@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.630.1"
version = "1.629.1"
authors.workspace = true
edition.workspace = true
@@ -59,7 +59,7 @@ members = [
exclude = ["./windmill-duckdb-ffi-internal"]
[workspace.package]
version = "1.630.1"
version = "1.629.1"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
@@ -469,7 +469,7 @@ nkeys = "0.4.4"
nu-parser = { version = "0.101.0", default-features = false }
globset = "0.4.16"
croner = "2.2.0"
rmcp = { version = "=0.15.0", features = ["client", "transport-streamable-http-client", "transport-streamable-http-client-reqwest"] }
rmcp = { version = "^0", features = ["client", "transport-streamable-http-client", "transport-streamable-http-client-reqwest"] }
rquickjs = { version = "0.8", features = ["futures", "parallel", "macro"] }
process-wrap = { version = "8.2.1", features = ["tokio1"] }

21
backend/all_features_oss.sh Executable file
View File

@@ -0,0 +1,21 @@
#!/bin/bash
# This script outputs all features except private. Usage :
# > cargo build --features $(./all_features_oss.sh)
# Path to the Cargo.toml file
CARGO_TOML_PATH="./Cargo.toml"
# Extract features from Cargo.toml and output them separated by commas
if [[ -f "$CARGO_TOML_PATH" ]]; then
grep -A 100 '\[features\]' "$CARGO_TOML_PATH" | \
sed -n '/\[features\]/,/^\[/p' | \
grep -E '^[a-zA-Z0-9_-]+' | \
grep -v 'private' | \
grep -v 'benchmark' | \
cut -d' ' -f1 | \
paste -sd ',' -
else
echo "Cargo.toml not found at $CARGO_TOML_PATH"
exit 1
fi

View File

@@ -1 +1 @@
de9ff0d258f5a1a3739d9693531bad3972ddfd22
7596cefdba81482c0b0c0b61be26369f112d8009

View File

@@ -29,39 +29,7 @@ def load_openapi_spec(file_path: str) -> Dict[str, Any]:
print(f"Error loading OpenAPI spec: {e}", file=sys.stderr)
sys.exit(1)
def flatten_allof_schema(schema: Dict[str, Any]) -> Dict[str, Any]:
"""Flatten an allOf schema into a single object schema by merging all properties."""
if 'allOf' not in schema:
return schema
merged = {"type": "object", "properties": {}, "required": []}
def collect_from(s: Dict[str, Any]):
if 'allOf' in s:
for item in s['allOf']:
if isinstance(item, dict):
collect_from(item)
if 'properties' in s:
merged['properties'].update(s['properties'])
if 'required' in s and isinstance(s['required'], list):
merged['required'].extend(s['required'])
if 'description' in s and 'description' not in merged:
merged['description'] = s['description']
collect_from(schema)
# Preserve additional top-level keys from the original schema
preserved_keys = {'additionalProperties', 'title', 'nullable', 'default', 'example'}
for key in preserved_keys:
if key in schema and key not in merged:
merged[key] = schema[key]
if not merged['required']:
del merged['required']
return merged
def extract_separate_schemas(parameters: List[Dict[str, Any]], request_body: Optional[Dict[str, Any]], spec: Dict[str, Any], required_fields: Optional[List[str]] = None, base_path: str = "", include_fields: Optional[List[str]] = None, opaque_fields: Optional[List[str]] = None, include_query_params: Optional[List[str]] = None) -> tuple:
def extract_separate_schemas(parameters: List[Dict[str, Any]], request_body: Optional[Dict[str, Any]], spec: Dict[str, Any], required_fields: Optional[List[str]] = None, base_path: str = "") -> tuple:
"""Extract separate schemas for path parameters, query parameters, and request body."""
path_params_schema = {
"type": "object",
@@ -104,7 +72,7 @@ def extract_separate_schemas(parameters: List[Dict[str, Any]], request_body: Opt
path_params_schema['properties'][param_name] = param_schema
if param_required:
path_params_schema['required'].append(param_name)
elif param_in == 'query' and (include_query_params is None or param_name in include_query_params):
elif param_in == 'query':
query_params_schema['properties'][param_name] = param_schema
if param_required:
query_params_schema['required'].append(param_name)
@@ -112,28 +80,7 @@ def extract_separate_schemas(parameters: List[Dict[str, Any]], request_body: Opt
# Process request body if present
if request_body:
body_schema = extract_request_body_schema(request_body, spec, base_path)
# Flatten allOf schemas into a single object schema for filtering
if body_schema and (include_fields is not None or opaque_fields):
body_schema = flatten_allof_schema(body_schema)
# Apply include_fields filter: only keep listed top-level properties
if body_schema and include_fields is not None and 'properties' in body_schema:
body_schema['properties'] = {
k: v for k, v in body_schema['properties'].items()
if k in include_fields
}
if 'required' in body_schema:
body_schema['required'] = [
r for r in body_schema['required'] if r in include_fields
]
# Apply opaque_fields: simplify listed properties to {"type": "object"}
if body_schema and opaque_fields and 'properties' in body_schema:
for field in opaque_fields:
if field in body_schema['properties']:
body_schema['properties'][field] = {"type": "object"}
# If we have required fields specified and a body schema, update the required array
if body_schema and required_fields:
if 'required' not in body_schema:
@@ -148,60 +95,11 @@ def extract_separate_schemas(parameters: List[Dict[str, Any]], request_body: Opt
# Log warning when a required field is missing from schema properties
print(f"Warning: Required field '{field}' not found in body schema properties", file=sys.stderr)
# Sanitize empty schemas for JSON Schema draft 2020-12 compliance
path_params_schema = sanitize_empty_schemas(path_params_schema)
query_params_schema = sanitize_empty_schemas(query_params_schema)
body_schema = sanitize_empty_schemas(body_schema)
# Convert enums to descriptions for client compatibility
path_params_schema = convert_enums_to_descriptions(path_params_schema)
query_params_schema = convert_enums_to_descriptions(query_params_schema)
body_schema = convert_enums_to_descriptions(body_schema)
# Detect overlapping property names across schemas and rename with suffixes
path_keys = set(path_params_schema['properties'].keys()) if path_params_schema and path_params_schema.get('properties') else set()
query_keys = set(query_params_schema['properties'].keys()) if query_params_schema and query_params_schema.get('properties') else set()
body_keys = set(body_schema['properties'].keys()) if body_schema and body_schema.get('properties') else set()
conflicts = (path_keys & query_keys) | (path_keys & body_keys) | (query_keys & body_keys)
path_field_renames = {}
query_field_renames = {}
body_field_renames = {}
for field in conflicts:
schemas_and_renames = [
(path_params_schema, path_keys, '__path', path_field_renames),
(query_params_schema, query_keys, '__query', query_field_renames),
(body_schema, body_keys, '__body', body_field_renames),
]
for schema, keys, suffix, renames_map in schemas_and_renames:
if field in keys and schema and 'properties' in schema:
new_name = field + suffix
# Rename in properties
schema['properties'][new_name] = schema['properties'].pop(field)
# Update description to clarify the renamed field
if 'description' not in schema['properties'][new_name]:
schema['properties'][new_name] = dict(schema['properties'][new_name])
prop = schema['properties'][new_name]
if isinstance(prop, dict):
existing_desc = prop.get('description', '')
location = suffix.lstrip('_')
if not existing_desc:
prop['description'] = f"({location} parameter)"
else:
prop['description'] = f"{existing_desc} ({location} parameter)"
# Rename in required array
if 'required' in schema and field in schema['required']:
schema['required'] = [new_name if r == field else r for r in schema['required']]
# Store the reverse mapping: renamed -> original
renames_map[new_name] = field
# Return None for empty schemas
path_params_schema = path_params_schema if path_params_schema and path_params_schema.get('properties') else None
query_params_schema = query_params_schema if query_params_schema and query_params_schema.get('properties') else None
return (path_params_schema, query_params_schema, body_schema, path_field_renames, query_field_renames, body_field_renames)
path_params_schema = path_params_schema if path_params_schema['properties'] else None
query_params_schema = query_params_schema if query_params_schema['properties'] else None
return (path_params_schema, query_params_schema, body_schema)
# Cache for loaded external files
_external_file_cache: Dict[str, Dict[str, Any]] = {}
@@ -262,25 +160,18 @@ def resolve_ref(ref_path: str, spec: Dict[str, Any], base_path: str = "") -> tup
return (current if isinstance(current, dict) else None), spec
def resolve_schema_refs(schema: Dict[str, Any], spec: Dict[str, Any], base_path: str = "", _visited_refs: Optional[set] = None) -> Dict[str, Any]:
def resolve_schema_refs(schema: Dict[str, Any], spec: Dict[str, Any], base_path: str = "") -> Dict[str, Any]:
"""Recursively resolve all $ref references in a schema."""
if _visited_refs is None:
_visited_refs = set()
if not isinstance(schema, dict):
return schema
# If this is a $ref, resolve it
if '$ref' in schema:
ref_path = schema['$ref']
if ref_path in _visited_refs:
# Circular reference detected - return empty object to break the cycle
return {"type": "object"}
_visited_refs = _visited_refs | {ref_path}
resolved, resolved_spec = resolve_ref(ref_path, spec, base_path)
if resolved:
# Recursively resolve any refs in the resolved schema using the appropriate spec
return resolve_schema_refs(resolved, resolved_spec, base_path, _visited_refs)
return resolve_schema_refs(resolved, resolved_spec, base_path)
else:
print(f"Warning: Could not resolve $ref: {ref_path}")
return schema
@@ -289,10 +180,10 @@ def resolve_schema_refs(schema: Dict[str, Any], spec: Dict[str, Any], base_path:
resolved_schema = {}
for key, value in schema.items():
if isinstance(value, dict):
resolved_schema[key] = resolve_schema_refs(value, spec, base_path, _visited_refs)
resolved_schema[key] = resolve_schema_refs(value, spec, base_path)
elif isinstance(value, list):
resolved_schema[key] = [
resolve_schema_refs(item, spec, base_path, _visited_refs) if isinstance(item, dict) else item
resolve_schema_refs(item, spec, base_path) if isinstance(item, dict) else item
for item in value
]
else:
@@ -300,56 +191,6 @@ def resolve_schema_refs(schema: Dict[str, Any], spec: Dict[str, Any], base_path:
return resolved_schema
def convert_enums_to_descriptions(schema: Any) -> Any:
"""Recursively convert enum arrays into description text to avoid client compatibility issues."""
if isinstance(schema, list):
return [convert_enums_to_descriptions(item) for item in schema]
if not isinstance(schema, dict):
return schema
result = {}
enum_value = None
# First pass: copy all non-enum keys so 'description' is available before enum processing
for key, value in schema.items():
if key == 'enum':
enum_value = value
else:
result[key] = convert_enums_to_descriptions(value)
# Second pass: process enum using the already-copied description
if enum_value is not None:
values_str = ', '.join(str(v) for v in enum_value)
existing = result.get('description', '')
enum_desc = f"Possible values: {values_str}"
result['description'] = f"{existing}. {enum_desc}" if existing else enum_desc
return result
def sanitize_empty_schemas(schema: Any) -> Any:
"""Replace empty {} schemas with valid JSON Schema draft 2020-12 equivalents.
In OpenAPI, {} means 'any value' but strict JSON Schema validators (e.g. Claude's API)
reject empty objects. This converts them to proper schemas.
"""
if isinstance(schema, list):
return [sanitize_empty_schemas(item) for item in schema]
if not isinstance(schema, dict):
return schema
result = {}
for key, value in schema.items():
if key == 'additionalProperties' and isinstance(value, dict) and len(value) == 0:
result[key] = True
elif key == 'properties' and isinstance(value, dict):
# properties is a map of name -> schema; sanitize each property schema
result[key] = {
k: {"type": "object"} if isinstance(v, dict) and len(v) == 0 else sanitize_empty_schemas(v)
for k, v in value.items()
}
else:
result[key] = sanitize_empty_schemas(value)
return result
def extract_request_body_schema(request_body: Dict[str, Any], spec: Dict[str, Any], base_path: str = "") -> Optional[Dict[str, Any]]:
"""Extract request body schema from OpenAPI requestBody definition and resolve refs."""
if not request_body:
@@ -402,9 +243,6 @@ def find_mcp_tools(spec: Dict[str, Any]) -> List[Dict[str, Any]]:
'parameters': operation.get('parameters', []),
'requestBody': operation.get('requestBody'),
'required_fields': operation.get('x-mcp-required-fields', []),
'include_fields': operation.get('x-mcp-tool-include-fields'),
'opaque_fields': operation.get('x-mcp-tool-opaque-fields'),
'include_query_params': operation.get('x-mcp-tool-include-query-params'),
}
tools.append(tool)
@@ -440,18 +278,14 @@ export const mcpEndpointTools: EndpointTool[] = [];
method = tool['method'].upper()
# Generate separate schemas
path_params_schema, query_params_schema, body_schema, path_field_renames, query_field_renames, body_field_renames = extract_separate_schemas(
tool['parameters'], tool['requestBody'], spec, tool['required_fields'], base_path,
tool.get('include_fields'), tool.get('opaque_fields'), tool.get('include_query_params')
path_params_schema, query_params_schema, body_schema = extract_separate_schemas(
tool['parameters'], tool['requestBody'], spec, tool['required_fields'], base_path
)
# Convert schemas to TypeScript - use 'as const' for better type inference
path_params_ts = json.dumps(path_params_schema, indent=8) if path_params_schema else "undefined"
query_params_ts = json.dumps(query_params_schema, indent=8) if query_params_schema else "undefined"
body_schema_ts = json.dumps(body_schema, indent=8) if body_schema else "undefined"
path_field_renames_ts = json.dumps(path_field_renames, indent=8) if path_field_renames else "undefined"
query_field_renames_ts = json.dumps(query_field_renames, indent=8) if query_field_renames else "undefined"
body_field_renames_ts = json.dumps(body_field_renames, indent=8) if body_field_renames else "undefined"
# Generate tool definition
tool_def = f""" {{
@@ -462,10 +296,7 @@ export const mcpEndpointTools: EndpointTool[] = [];
method: "{method}",
pathParamsSchema: {path_params_ts},
queryParamsSchema: {query_params_ts},
bodySchema: {body_schema_ts},
pathFieldRenames: {path_field_renames_ts},
queryFieldRenames: {query_field_renames_ts},
bodyFieldRenames: {body_field_renames_ts}
bodySchema: {body_schema_ts}
}}"""
tool_definitions.append(tool_def)
@@ -484,9 +315,6 @@ export interface EndpointTool {{
pathParamsSchema?: object;
queryParamsSchema?: object;
bodySchema?: object;
pathFieldRenames?: Record<string, string>;
queryFieldRenames?: Record<string, string>;
bodyFieldRenames?: Record<string, string>;
}}
export const mcpEndpointTools: EndpointTool[] = [
@@ -516,17 +344,13 @@ pub fn all_tools() -> Vec<EndpointTool> {{
method = tool['method'].upper()
# Generate separate schemas
path_params_schema, query_params_schema, body_schema, path_field_renames, query_field_renames, body_field_renames = extract_separate_schemas(
tool['parameters'], tool['requestBody'], spec, tool['required_fields'], base_path,
tool.get('include_fields'), tool.get('opaque_fields'), tool.get('include_query_params')
path_params_schema, query_params_schema, body_schema = extract_separate_schemas(
tool['parameters'], tool['requestBody'], spec, tool['required_fields'], base_path
)
path_params_rust = schema_to_rust_value(path_params_schema)
query_params_rust = schema_to_rust_value(query_params_schema)
body_schema_rust = schema_to_rust_value(body_schema)
path_field_renames_rust = schema_to_rust_value(path_field_renames if path_field_renames else None)
query_field_renames_rust = schema_to_rust_value(query_field_renames if query_field_renames else None)
body_field_renames_rust = schema_to_rust_value(body_field_renames if body_field_renames else None)
# Generate tool definition
tool_def = f""" EndpointTool {{
@@ -538,9 +362,6 @@ pub fn all_tools() -> Vec<EndpointTool> {{
path_params_schema: {path_params_rust},
query_params_schema: {query_params_rust},
body_schema: {body_schema_rust},
path_field_renames: {path_field_renames_rust},
query_field_renames: {query_field_renames_rust},
body_field_renames: {body_field_renames_rust},
}}"""
tool_definitions.append(tool_def)

View File

@@ -1,2 +0,0 @@
-- Drop the workspace_protection_rule table and its indexes
DROP TABLE IF EXISTS workspace_protection_rule;

View File

@@ -1,10 +0,0 @@
-- Add workspace_protection_rule table for fine-grained access control
CREATE TABLE workspace_protection_rule (
workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
rules INTEGER NOT NULL,
bypass_groups TEXT[] NOT NULL DEFAULT '{}',
bypass_users TEXT[] NOT NULL DEFAULT '{}',
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
PRIMARY KEY (workspace_id, name)
);

View File

@@ -1,159 +0,0 @@
use windmill_common::{
error::{self, Error},
get_database_url, DatabaseUrl,
};
pub const DEFAULT_MAX_CONNECTIONS_SERVER: u32 = 50;
pub const DEFAULT_MAX_CONNECTIONS_WORKER: u32 = 5;
pub const DEFAULT_MAX_CONNECTIONS_INDEXER: u32 = 5;
pub async fn initial_connection() -> Result<sqlx::Pool<sqlx::Postgres>, error::Error> {
let connect_options = get_database_url().await?.connect_options().await?;
sqlx::postgres::PgPoolOptions::new()
.max_connections(2)
.connect_with(connect_options)
.await
.map_err(|err| Error::ConnectingToDatabase(err.to_string()))
}
pub async fn connect_db(
server_mode: bool,
indexer_mode: bool,
worker_mode: bool,
#[cfg(feature = "private")] mut killpill_rx: tokio::sync::broadcast::Receiver<()>,
) -> anyhow::Result<sqlx::Pool<sqlx::Postgres>> {
use anyhow::Context;
let database_url = get_database_url().await?;
let max_connections = match std::env::var("DATABASE_CONNECTIONS") {
Ok(n) => n.parse::<u32>().context("invalid DATABASE_CONNECTIONS")?,
Err(_) => {
if server_mode {
DEFAULT_MAX_CONNECTIONS_SERVER
} else if indexer_mode {
DEFAULT_MAX_CONNECTIONS_INDEXER
} else {
DEFAULT_MAX_CONNECTIONS_WORKER
+ std::env::var("NUM_WORKERS")
.ok()
.map(|x| x.parse().ok())
.flatten()
.unwrap_or(1)
- 1
}
}
};
let pool = connect(database_url.clone(), max_connections, worker_mode).await?;
#[cfg(all(feature = "enterprise", feature = "private"))]
let pool2 = pool.clone();
#[cfg(all(feature = "enterprise", feature = "private"))]
if let DatabaseUrl::IamRds(database_url) = database_url {
tokio::spawn(async move {
loop {
tokio::select! {
_ = killpill_rx.recv() => {
break;
}
_ = tokio::time::sleep(std::time::Duration::from_secs(10)) => {
let needs_refresh = {
let read_guard = database_url.read().await;
read_guard.needs_refresh()
};
if needs_refresh {
let new_url = tokio::time::timeout(std::time::Duration::from_secs(10), get_database_url()).await;
match new_url {
Ok(Ok(new_url)) => {
match new_url.connect_options().await {
Ok(connect_options) => {
pool2.set_connect_options(connect_options);
tracing::info!("Refreshed IAM RDS URL successfully");
}
Err(e) => {
tracing::error!("Error getting IAM RDS connect options, retrying in 10s: {}", e);
continue;
}
}
}
Ok(Err(e)) => {
tracing::error!("Error refreshing IAM RDS URL, trying again in 10s: {}", e);
continue;
}
Err(e) => {
tracing::error!("Timeout after 10s refreshing IAM RDS URL, trying again in 10 seconds: {}", e);
continue;
}
}
}
}
}
}
});
}
Ok(pool)
}
pub async fn connect(
database_url: DatabaseUrl,
max_connections: u32,
worker_mode: bool,
) -> Result<sqlx::Pool<sqlx::Postgres>, error::Error> {
use sqlx::Executor;
use std::time::Duration;
let mut pool_options = sqlx::postgres::PgPoolOptions::new()
.min_connections((max_connections / 5).clamp(1, max_connections))
.max_connections(max_connections)
.max_lifetime(Duration::from_secs(30 * 60)); // 30 mins
if worker_mode {
pool_options = pool_options.idle_timeout(Duration::from_secs(60));
}
pool_options
.after_connect(move |conn, _| {
if worker_mode {
Box::pin(async move {
if let Err(e) = conn
.execute(
r#"
SET enable_seqscan = OFF;
SET statement_timeout = '5min';
SET idle_in_transaction_session_timeout = '10min';
SET tcp_keepalives_idle = 300;
SET tcp_keepalives_interval = 60;
SET tcp_keepalives_count = 10;"#,
)
.await
{
tracing::error!("Error setting postgres settings: {}", e);
}
Ok(())
})
} else {
Box::pin(async move {
if let Err(e) = conn
.execute(
r#"
SET statement_timeout = '5min';
SET idle_in_transaction_session_timeout = '10min';
SET tcp_keepalives_idle = 300;
SET tcp_keepalives_interval = 60;
SET tcp_keepalives_count = 10;"#,
)
.await
{
tracing::error!("Error setting postgres settings: {}", e);
}
Ok(())
})
}
})
.connect_with(
database_url
.connect_options()
.await?
.statement_cache_capacity(400),
)
.await
.map_err(|err| Error::ConnectingToDatabase(err.to_string()))
}

View File

@@ -117,7 +117,6 @@ const BIND_ADDR_ENV: &str = "SERVER_BIND_ADDR";
#[cfg(target_os = "linux")]
mod cgroups;
mod db_connect;
#[cfg(feature = "private")]
pub mod ee;
mod ee_oss;
@@ -666,7 +665,7 @@ async fn windmill_main() -> anyhow::Result<()> {
} else {
println!("Connecting to database...");
let db = crate::db_connect::initial_connection().await?;
let db = windmill_common::initial_connection().await?;
let num_version = sqlx::query_scalar!("SELECT version()").fetch_one(&db).await;
@@ -771,12 +770,8 @@ async fn windmill_main() -> anyhow::Result<()> {
let conn = if mode == Mode::Agent {
conn
} else {
// Drop the initial connection pool before creating the main one.
// With low PostgreSQL max_connections, both pools existing simultaneously
// can exhaust all available connection slots, causing connect_db to hang.
drop(conn);
let db = crate::db_connect::connect_db(
// This time we use a pool of connections
let db = windmill_common::connect_db(
server_mode,
indexer_mode,
worker_mode,

View File

@@ -3092,13 +3092,7 @@ RETURNING job_id"
async fn cleanup_job_result_stream_orphaned_jobs(db: &DB) -> error::Result<()> {
let result = sqlx::query!(
"DELETE FROM job_result_stream_v2
WHERE job_id NOT IN (SELECT id FROM v2_job_queue)
AND job_id NOT IN (
SELECT id FROM v2_job_completed
WHERE completed_at > NOW() - INTERVAL '60 seconds'
)
RETURNING job_id",
"DELETE FROM job_result_stream_v2 WHERE job_id NOT IN (SELECT id FROM v2_job_queue) RETURNING job_id",
)
.fetch_all(db)
.await?;

View File

@@ -227,52 +227,6 @@ impl RunJob {
uuid
}
/// Push the job as a specific user (for testing permissions)
pub async fn push_as(self, db: &Pool<Postgres>, username: &str, email: &str) -> Uuid {
let RunJob { payload, args, scheduled_for_o, .. } = self;
let mut hm_args = std::collections::HashMap::new();
for (k, v) in args {
hm_args.insert(k, windmill_common::worker::to_raw_value(&v));
}
let tx = PushIsolationLevel::IsolatedRoot(db.clone());
let (uuid, tx) = windmill_queue::push(
db,
tx,
"test-workspace",
payload,
windmill_queue::PushArgs::from(&hm_args),
username,
email,
format!("u/{}", username),
/* token_prefix */ None,
scheduled_for_o,
/* schedule_path */ None,
/* parent_job */ None,
/* root job */ None,
/* flow_innermost_root_job */ None,
/* job_id */ None,
/* is_flow_step */ false,
/* same_worker */ false,
None,
true,
None,
None,
None,
None,
None,
false,
None,
None,
None,
)
.await
.expect("push has to succeed");
tx.commit().await.unwrap();
uuid
}
/// push the job, spawn a worker, wait until the job is in completed_job
pub async fn run_until_complete(
self,

View File

@@ -1,208 +0,0 @@
//! Integration tests for workspace protection rulesets.
//!
//! Tests verify that DisableDirectDeployment protection rules correctly
//! block/allow operations based on user permissions.
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_common::workspaces::invalidate_protection_rules_cache;
mod common;
use common::*;
fn client() -> reqwest::Client {
reqwest::Client::new()
}
fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder {
builder.header("Authorization", format!("Bearer {}", token))
}
fn new_script(path: &str, summary: &str) -> serde_json::Value {
json!({
"path": path,
"summary": summary,
"description": "",
"content": "export async function main() { return 42; }",
"language": "deno",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {},
"required": []
}
})
}
fn new_flow(path: &str, summary: &str) -> serde_json::Value {
json!({
"path": path,
"summary": summary,
"description": "",
"value": { "modules": [] },
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {},
"required": []
}
})
}
/// Comprehensive test for protection rules functionality.
/// Tests all essential cases in a single test to avoid cache interference.
#[sqlx::test(fixtures("base"))]
async fn test_protection_rules(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
invalidate_protection_rules_cache("test-workspace");
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api/w/test-workspace");
// ========================================
// 1. Without protection rule, non-admin can create scripts and flows
// ========================================
let resp = authed(client().post(format!("{base}/scripts/create")), "SECRET_TOKEN_2")
.json(&new_script("u/test-user-2/script_no_rule", "No rule"))
.send()
.await?;
assert_eq!(resp.status(), 201, "Should create script without rule: {}", resp.text().await?);
let resp = authed(client().post(format!("{base}/flows/create")), "SECRET_TOKEN_2")
.json(&new_flow("u/test-user-2/flow_no_rule", "No rule"))
.send()
.await?;
assert_eq!(resp.status(), 201, "Should create flow without rule: {}", resp.text().await?);
// ========================================
// 2. Non-admin cannot create protection rules
// ========================================
let resp = authed(client().post(format!("{base}/workspaces/protection_rules")), "SECRET_TOKEN_2")
.json(&json!({
"name": "unauthorized-rule",
"rules": ["DisableDirectDeployment"],
"bypass_users": [],
"bypass_groups": []
}))
.send()
.await?;
assert!(!resp.status().is_success(), "Non-admin should not create rules: {}", resp.status());
// ========================================
// 3. Admin creates protection rule
// ========================================
let resp = authed(client().post(format!("{base}/workspaces/protection_rules")), "SECRET_TOKEN")
.json(&json!({
"name": "test-rule",
"rules": ["DisableDirectDeployment"],
"bypass_users": [],
"bypass_groups": []
}))
.send()
.await?;
assert_eq!(resp.status(), 200, "Admin should create rule: {}", resp.text().await?);
// ========================================
// 4. With rule, non-admin is blocked from creating scripts/flows
// ========================================
let resp = authed(client().post(format!("{base}/scripts/create")), "SECRET_TOKEN_2")
.json(&new_script("u/test-user-2/blocked_script", "Blocked"))
.send()
.await?;
assert!(!resp.status().is_success(), "Non-admin should be blocked from scripts: {}", resp.status());
let body = resp.text().await?;
assert!(body.contains("blocked") || body.contains("Blocked"), "Error should mention blocking: {}", body);
let resp = authed(client().post(format!("{base}/flows/create")), "SECRET_TOKEN_2")
.json(&new_flow("u/test-user-2/blocked_flow", "Blocked"))
.send()
.await?;
assert!(!resp.status().is_success(), "Non-admin should be blocked from flows: {}", resp.status());
// ========================================
// 5. Admin bypasses protection rule
// ========================================
let resp = authed(client().post(format!("{base}/scripts/create")), "SECRET_TOKEN")
.json(&new_script("u/test-user/admin_script", "Admin"))
.send()
.await?;
assert_eq!(resp.status(), 201, "Admin should bypass rule: {}", resp.text().await?);
// ========================================
// 6. Update rule to bypass test-user-2
// ========================================
let resp = authed(client().post(format!("{base}/workspaces/protection_rules/test-rule")), "SECRET_TOKEN")
.json(&json!({
"rules": ["DisableDirectDeployment"],
"bypass_users": ["test-user-2"],
"bypass_groups": []
}))
.send()
.await?;
assert_eq!(resp.status(), 200, "Should update rule: {}", resp.text().await?);
// Invalidate cache to pick up the update
invalidate_protection_rules_cache("test-workspace");
// ========================================
// 7. Bypassed user can now create
// ========================================
let resp = authed(client().post(format!("{base}/scripts/create")), "SECRET_TOKEN_2")
.json(&new_script("u/test-user-2/bypassed_script", "Bypassed"))
.send()
.await?;
assert_eq!(resp.status(), 201, "Bypassed user should create: {}", resp.text().await?);
// ========================================
// 8. Non-bypassed user (test-user-3) is still blocked
// ========================================
let resp = authed(client().post(format!("{base}/scripts/create")), "SECRET_TOKEN_3")
.json(&new_script("u/test-user-3/still_blocked", "Blocked"))
.send()
.await?;
assert!(!resp.status().is_success(), "Non-bypassed user should be blocked: {}", resp.status());
// ========================================
// 9. Delete rule
// ========================================
let resp = authed(client().delete(format!("{base}/workspaces/protection_rules/test-rule")), "SECRET_TOKEN")
.send()
.await?;
assert_eq!(resp.status(), 200, "Should delete rule: {}", resp.text().await?);
// Invalidate cache to pick up the deletion
invalidate_protection_rules_cache("test-workspace");
// ========================================
// 10. After deletion, non-admin can create again
// ========================================
let resp = authed(client().post(format!("{base}/scripts/create")), "SECRET_TOKEN_3")
.json(&new_script("u/test-user-3/after_delete", "After delete"))
.send()
.await?;
assert_eq!(resp.status(), 201, "Should create after rule deletion: {}", resp.text().await?);
// ========================================
// 11. Verify rule list is empty
// ========================================
let resp = authed(client().get(format!("{base}/workspaces/protection_rules")), "SECRET_TOKEN")
.send()
.await?;
assert_eq!(resp.status(), 200);
let rules: Vec<serde_json::Value> = resp.json().await?;
assert!(rules.is_empty(), "Should have no rules after deletion");
Ok(())
}

View File

@@ -237,331 +237,6 @@ mod suspend_resume {
Ok(())
}
/// Test that self-approval is blocked when self_approval_disabled is true.
///
/// This test verifies that when a flow has an approval step with self_approval_disabled=true,
/// the user who triggered the flow cannot approve it themselves via the owner endpoint
/// (POST /jobs/flow/resume/:id).
///
/// Bug context: The owner endpoint was missing the approval condition check, allowing
/// users to bypass self-approval restrictions by using the UI resume button instead
/// of the HMAC-signed approval link.
#[cfg(feature = "enterprise")]
#[cfg(feature = "deno_core")]
#[sqlx::test(fixtures("base"))]
async fn test_self_approval_disabled_blocks_owner_resume(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
// Flow with self_approval_disabled=true on the approval step
let flow_with_self_approval_disabled: FlowValue = serde_json::from_value(json!({
"modules": [{
"id": "a",
"value": {
"type": "rawscript",
"language": "deno",
"content": "export function main() { return 'step1'; }"
},
"suspend": {
"required_events": 1,
"user_auth_required": true,
"self_approval_disabled": true
}
}, {
"id": "b",
"value": {
"type": "rawscript",
"language": "deno",
"content": "export function main() { return 'step2 - after approval'; }"
}
}]
}))
.unwrap();
// Push flow as NON-ADMIN user (test-user-2) - admins bypass self-approval check
// Use a path owned by test-user-2 so require_owner_of_path succeeds
let flow = RunJob::from(JobPayload::RawFlow {
value: flow_with_self_approval_disabled,
path: Some("u/test-user-2/test_approval".to_string()),
restarted_from: None,
})
.push_as(&db, "test-user-2", "test2@windmill.dev")
.await;
let queue = listen_for_queue(&db).await;
let db_ = db.clone();
in_test_worker(
&db,
async move {
let db = db_;
// Wait for flow to suspend at approval step
wait_until_flow_suspends(flow, queue, &db).await;
// Create a token for the same non-admin user who triggered the flow
// This simulates clicking "Resume" in the UI as the flow owner
// Args: db, w_id, owner, label, expires_in, email, job_id, perms, audit_span
let token = windmill_common::auth::create_token_for_owner(
&db,
"test-workspace",
"u/test-user-2",
"test-token",
100,
"test2@windmill.dev",
&Uuid::nil(),
None,
None,
)
.await
.unwrap();
// Try to resume via the owner endpoint (POST /jobs/flow/resume/:id)
// This should FAIL because self_approval_disabled=true and the user
// is the same as the one who triggered the flow
let response = reqwest::Client::new()
.post(format!(
"http://localhost:{port}/api/w/test-workspace/jobs/flow/resume/{flow}"
))
.header("Authorization", format!("Bearer {token}"))
.header("Content-Type", "application/json")
.body("{}")
.send()
.await
.unwrap();
let status = response.status();
// The request should be rejected with 403 Forbidden
// (currently this test FAILS because the bug allows self-approval)
assert!(
status == reqwest::StatusCode::FORBIDDEN,
"Self-approval should be blocked when self_approval_disabled=true. \
Expected 403 Forbidden, got {}. Response: {}",
status,
response.text().await.unwrap_or_default()
);
},
port,
)
.await;
server.close().await.unwrap();
Ok(())
}
/// Test that self-approval WORKS when self_approval_disabled is false (default behavior).
///
/// This is the complementary test to test_self_approval_disabled_blocks_owner_resume.
/// When self_approval_disabled is NOT set, the flow owner should be able to approve
/// their own flow via the owner endpoint.
#[cfg(feature = "enterprise")]
#[cfg(feature = "deno_core")]
#[sqlx::test(fixtures("base"))]
async fn test_self_approval_allowed_when_not_disabled(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
// Flow with user_auth_required but WITHOUT self_approval_disabled
let flow_without_self_approval_disabled: FlowValue = serde_json::from_value(json!({
"modules": [{
"id": "a",
"value": {
"type": "rawscript",
"language": "deno",
"content": "export function main() { return 'step1'; }"
},
"suspend": {
"required_events": 1,
"user_auth_required": true
// self_approval_disabled is NOT set (defaults to false)
}
}, {
"id": "b",
"value": {
"type": "rawscript",
"language": "deno",
"content": "export function main() { return 'step2 - after approval'; }"
}
}]
}))
.unwrap();
// Push flow as non-admin user
let flow = RunJob::from(JobPayload::RawFlow {
value: flow_without_self_approval_disabled,
path: Some("u/test-user-2/test_approval_allowed".to_string()),
restarted_from: None,
})
.push_as(&db, "test-user-2", "test2@windmill.dev")
.await;
let queue = listen_for_queue(&db).await;
let db_ = db.clone();
in_test_worker(
&db,
async move {
let db = db_;
// Wait for flow to suspend at approval step
wait_until_flow_suspends(flow, queue, &db).await;
// Create a token for the same user who triggered the flow
let token = windmill_common::auth::create_token_for_owner(
&db,
"test-workspace",
"u/test-user-2",
"test-token",
100,
"test2@windmill.dev",
&Uuid::nil(),
None,
None,
)
.await
.unwrap();
// Try to resume via the owner endpoint - this SHOULD succeed
let response = reqwest::Client::new()
.post(format!(
"http://localhost:{port}/api/w/test-workspace/jobs/flow/resume/{flow}"
))
.header("Authorization", format!("Bearer {token}"))
.header("Content-Type", "application/json")
.body("{}")
.send()
.await
.unwrap();
let status = response.status();
// Self-approval should be allowed when self_approval_disabled is not set
assert!(
status.is_success(),
"Self-approval should be allowed when self_approval_disabled is not set. \
Expected 2xx, got {}. Response: {}",
status,
response.text().await.unwrap_or_default()
);
},
port,
)
.await;
server.close().await.unwrap();
Ok(())
}
/// Test that a DIFFERENT user can approve a flow even when self_approval_disabled is true.
///
/// This verifies that the self_approval_disabled setting only blocks the flow trigger,
/// not other users. A different user should always be able to approve.
#[cfg(feature = "enterprise")]
#[cfg(feature = "deno_core")]
#[sqlx::test(fixtures("base"))]
async fn test_different_user_can_approve_when_self_approval_disabled(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
// Flow with self_approval_disabled=true
let flow_with_self_approval_disabled: FlowValue = serde_json::from_value(json!({
"modules": [{
"id": "a",
"value": {
"type": "rawscript",
"language": "deno",
"content": "export function main() { return 'step1'; }"
},
"suspend": {
"required_events": 1,
"user_auth_required": true,
"self_approval_disabled": true
}
}, {
"id": "b",
"value": {
"type": "rawscript",
"language": "deno",
"content": "export function main() { return 'step2 - after approval'; }"
}
}]
}))
.unwrap();
// Push flow as test-user-2
let flow = RunJob::from(JobPayload::RawFlow {
value: flow_with_self_approval_disabled,
path: Some("u/test-user-2/test_approval_by_other".to_string()),
restarted_from: None,
})
.push_as(&db, "test-user-2", "test2@windmill.dev")
.await;
let queue = listen_for_queue(&db).await;
let db_ = db.clone();
in_test_worker(
&db,
async move {
let db = db_;
// Wait for flow to suspend at approval step
wait_until_flow_suspends(flow, queue, &db).await;
// Create a token for a DIFFERENT user (test-user, who is admin)
// This simulates a different person approving the flow
let token = windmill_common::auth::create_token_for_owner(
&db,
"test-workspace",
"u/test-user",
"test-token",
100,
"test@windmill.dev",
&Uuid::nil(),
None,
None,
)
.await
.unwrap();
// Try to resume via the owner endpoint as a different user - this SHOULD succeed
let response = reqwest::Client::new()
.post(format!(
"http://localhost:{port}/api/w/test-workspace/jobs/flow/resume/{flow}"
))
.header("Authorization", format!("Bearer {token}"))
.header("Content-Type", "application/json")
.body("{}")
.send()
.await
.unwrap();
let status = response.status();
// A different user should be able to approve even with self_approval_disabled
assert!(
status.is_success(),
"Different user should be able to approve even with self_approval_disabled=true. \
Expected 2xx, got {}. Response: {}",
status,
response.text().await.unwrap_or_default()
);
},
port,
)
.await;
server.close().await.unwrap();
Ok(())
}
#[cfg(feature = "deno_core")]
#[sqlx::test(fixtures("base"))]
async fn cancel_after_suspend(db: Pool<Postgres>) -> anyhow::Result<()> {

View File

@@ -10,11 +10,22 @@ if [[ "$(uname)" == "Darwin" ]]; then
# Uncomment the git-based samael dependency
sed -i '' 's/^# \(samael = { git="https:\/\/github.com\/njaremko\/samael", rev="464d015e3ae393e4b5dd00b4d6baa1b617de0dd6", features = \["xmlsec"\] }\)/\1/' Cargo.toml
# Run cargo sqlx prepare with deno_core_mac
# Run cargo sqlx prepare with deno_core_mac
echo "Running cargo sqlx prepare with deno_core_mac..."
cargo sqlx prepare --workspace -- --all-targets --features all_sqlx_features,private,deno_core_mac
else
# Run cargo sqlx prepare
echo "Running cargo sqlx prepare..."
cargo sqlx prepare --workspace -- --all-targets --features all_sqlx_features,ee
cargo sqlx prepare --workspace -- --all-targets --features all_sqlx_features,private
fi
# Undo the samael changes on macOS
if [[ "$(uname)" == "Darwin" ]]; then
echo "Reverting samael changes..."
# Uncomment the version-based samael dependency
sed -i '' 's/^#samael = { version="0.0.14", features = \["xmlsec"\] }/samael = { version="0.0.14", features = ["xmlsec"] }/' Cargo.toml
# Comment out the git-based samael dependency
sed -i '' 's/^\(samael = { git="https:\/\/github.com\/njaremko\/samael", rev="464d015e3ae393e4b5dd00b4d6baa1b617de0dd6", features = \["xmlsec"\] }\)/# \1/' Cargo.toml
fi

BIN
backend/v8.snap Normal file

Binary file not shown.

View File

@@ -16,9 +16,9 @@ use axum::{
use lazy_static::lazy_static;
use regex::Regex;
use windmill_api_auth::{check_scopes, ApiAuthed, AuthCache, Tokened};
use windmill_audit::audit_oss::{audit_log, AuditAuthorable};
use windmill_audit::audit_oss::audit_log;
use windmill_audit::ActionKind;
use windmill_common::{error::Error, webhook::{WebhookMessage, WebhookShared}, workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult}};
use windmill_common::webhook::{WebhookMessage, WebhookShared};
use windmill_common::DB;
use windmill_common::{
db::UserDB,
@@ -174,19 +174,6 @@ async fn create_folder(
Path(w_id): Path<String>,
Json(ng): Json<NewFolder>,
) -> Result<String> {
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
&w_id,
&ProtectionRuleKind::DisableDirectDeployment,
AuditAuthorable::username(&authed),
&authed.groups,
authed.is_admin,
&db,
)
.await?
{
return Err(Error::PermissionDenied(msg));
}
let mut tx = user_db.clone().begin(&authed).await?;
if !VALID_FOLDER_NAME.is_match(&ng.name) {
@@ -328,19 +315,6 @@ async fn update_folder(
) -> Result<String> {
use sql_builder::prelude::*;
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
&w_id,
&ProtectionRuleKind::DisableDirectDeployment,
AuditAuthorable::username(&authed),
&authed.groups,
authed.is_admin,
&db,
)
.await?
{
return Err(Error::PermissionDenied(msg));
}
let mut sqlb = SqlBuilder::update_table("folder");
sqlb.and_where_eq("name", "?".bind(&name));
sqlb.and_where_eq("workspace_id", "?".bind(&w_id));
@@ -630,18 +604,6 @@ async fn delete_folder(
Extension(webhook): Extension<WebhookShared>,
Path((w_id, name)): Path<(String, String)>,
) -> Result<String> {
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
&w_id,
&ProtectionRuleKind::DisableDirectDeployment,
AuditAuthorable::username(&authed),
&authed.groups,
authed.is_admin,
&db,
)
.await?
{
return Err(Error::PermissionDenied(msg));
}
let mut tx = user_db.begin(&authed).await?;
not_found_if_none(get_folderopt(&mut tx, &w_id, &name).await?, "Folder", &name)?;

View File

@@ -18,7 +18,6 @@ use windmill_api_auth::{
auth::{list_tokens_internal, TruncatedTokenWithEmail},
check_scopes, maybe_refresh_folders, require_owner_of_path, ApiAuthed,
};
use windmill_common::workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult};
use windmill_common::{
utils::{WithStarredInfoQuery, HTTP_CLIENT},
webhook::{WebhookMessage, WebhookShared},
@@ -30,7 +29,7 @@ use hyper::StatusCode;
use serde::{Deserialize, Serialize};
use sql_builder::prelude::*;
use sqlx::{FromRow, Postgres, Transaction};
use windmill_audit::audit_oss::{audit_log, AuditAuthorable};
use windmill_audit::audit_oss::audit_log;
use windmill_audit::ActionKind;
use windmill_common::assets::{clear_static_asset_usage, AssetUsageKind};
use windmill_common::min_version::{
@@ -430,20 +429,6 @@ async fn create_flow(
));
}
check_scopes(&authed, || format!("flows:write:{}", nf.path))?;
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
&w_id,
&ProtectionRuleKind::DisableDirectDeployment,
AuditAuthorable::username(&authed),
&authed.groups,
authed.is_admin,
&db,
)
.await?
{
return Err(Error::PermissionDenied(msg));
}
validate_flow(&nf).await?;
if *CLOUD_HOSTED {
let nb_flows =
@@ -878,20 +863,6 @@ async fn update_flow(
}
let flow_path = flow_path.to_path();
check_scopes(&authed, || format!("flows:write:{}", flow_path))?;
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
&w_id,
&ProtectionRuleKind::DisableDirectDeployment,
AuditAuthorable::username(&authed),
&authed.groups,
authed.is_admin,
&db,
)
.await?
{
return Err(Error::PermissionDenied(msg));
}
validate_flow(&nf).await?;
let authed = maybe_refresh_folders(&flow_path, &w_id, authed, &db).await;
@@ -1446,18 +1417,6 @@ async fn archive_flow_by_path(
) -> Result<String> {
let path = path.to_path();
check_scopes(&authed, || format!("flows:write:{}", path))?;
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
&w_id,
&ProtectionRuleKind::DisableDirectDeployment,
AuditAuthorable::username(&authed),
&authed.groups,
authed.is_admin,
&db,
)
.await?
{
return Err(Error::PermissionDenied(msg));
}
let mut tx = user_db.begin(&authed).await?;
sqlx::query!(
@@ -1563,18 +1522,6 @@ async fn delete_flow_by_path(
) -> Result<String> {
let path = path.to_path();
check_scopes(&authed, || format!("flows:write:{}", path))?;
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
&w_id,
&ProtectionRuleKind::DisableDirectDeployment,
AuditAuthorable::username(&authed),
&authed.groups,
authed.is_admin,
&db,
)
.await?
{
return Err(Error::PermissionDenied(msg));
}
let mut tx = user_db.begin(&authed).await?;
sqlx::query!(

View File

@@ -12,7 +12,9 @@ use windmill_api_auth::{
check_scopes, maybe_refresh_folders, require_owner_of_path, ApiAuthed,
};
use windmill_common::{
utils::{BulkDeleteRequest, WithStarredInfoQuery, HTTP_CLIENT}, webhook::{WebhookMessage, WebhookShared}, workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult}, DB
utils::{BulkDeleteRequest, WithStarredInfoQuery, HTTP_CLIENT},
webhook::{WebhookMessage, WebhookShared},
DB,
};
use windmill_queue::schedule::clear_schedule;
@@ -33,7 +35,7 @@ use serde_json::value::RawValue;
use sql_builder::prelude::*;
use sqlx::{FromRow, Postgres, Transaction};
use std::{collections::HashMap, sync::Arc};
use windmill_audit::audit_oss::{audit_log, AuditAuthorable};
use windmill_audit::audit_oss::audit_log;
use windmill_audit::ActionKind;
use windmill_dep_map::process_relative_imports;
use windmill_dep_map::scoped_dependency_map::ScopedDependencyMap;
@@ -546,18 +548,6 @@ async fn create_script(
Path(w_id): Path<String>,
Json(ns): Json<NewScript>,
) -> Result<(StatusCode, String)> {
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
&w_id,
&ProtectionRuleKind::DisableDirectDeployment,
AuditAuthorable::username(&authed),
&authed.groups,
authed.is_admin,
&db,
)
.await?
{
return Err(Error::PermissionDenied(msg));
}
let (hash, tx, hdm) =
create_script_internal(ns, w_id, authed, db.clone(), user_db, webhook).await?;
tx.commit().await?;
@@ -1916,18 +1906,6 @@ async fn archive_script_by_path(
}
let path = path.to_path();
check_scopes(&authed, || format!("scripts:write:{}", path))?;
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
&w_id,
&ProtectionRuleKind::DisableDirectDeployment,
AuditAuthorable::username(&authed),
&authed.groups,
authed.is_admin,
&db,
)
.await?
{
return Err(Error::PermissionDenied(msg));
}
let mut tx = user_db.begin(&authed).await?;
require_owner_of_path(&authed, path)?;
@@ -1995,18 +1973,6 @@ async fn archive_script_by_hash(
"Operators cannot archive scripts for security reasons".to_string(),
));
}
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
&w_id,
&ProtectionRuleKind::DisableDirectDeployment,
AuditAuthorable::username(&authed),
&authed.groups,
authed.is_admin,
&db,
)
.await?
{
return Err(Error::PermissionDenied(msg));
}
let mut tx = user_db.begin(&authed).await?;
let script = sqlx::query_as::<_, Script<ScriptRunnableSettingsHandle>>(
@@ -2052,20 +2018,9 @@ async fn delete_script_by_hash(
Extension(db): Extension<DB>,
Path((w_id, hash)): Path<(String, ScriptHash)>,
) -> JsonResult<Script<ScriptRunnableSettingsInline>> {
require_admin(authed.is_admin, &authed.username)?;
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
&w_id,
&ProtectionRuleKind::DisableDirectDeployment,
AuditAuthorable::username(&authed),
&authed.groups,
authed.is_admin,
&db,
)
.await?
{
return Err(Error::PermissionDenied(msg));
}
let mut tx = user_db.begin(&authed).await?;
require_admin(authed.is_admin, &authed.username)?;
let script = sqlx::query_as::<_, Script<ScriptRunnableSettingsHandle>>(
"UPDATE script SET content = '', archived = true, deleted = true, lock = '', schema = null WHERE hash = $1 AND \
workspace_id = $2 RETURNING *",
@@ -2123,19 +2078,6 @@ async fn delete_script_by_path(
));
}
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
&w_id,
&ProtectionRuleKind::DisableDirectDeployment,
AuditAuthorable::username(&authed),
&authed.groups,
authed.is_admin,
&db,
)
.await?
{
return Err(Error::PermissionDenied(msg));
}
let mut tx = user_db.begin(&authed).await?;
let draft_only = sqlx::query_scalar!(
@@ -2262,19 +2204,6 @@ async fn delete_scripts_bulk(
));
}
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
&w_id,
&ProtectionRuleKind::DisableDirectDeployment,
AuditAuthorable::username(&authed),
&authed.groups,
authed.is_admin,
&db,
)
.await?
{
return Err(Error::PermissionDenied(msg));
}
let mut tx = db.begin().await?;
let mut deleted_paths = sqlx::query_scalar!(

View File

@@ -39,4 +39,3 @@ sqlx.workspace = true
tokio.workspace = true
tracing.workspace = true
uuid.workspace = true
strum.workspace = true

View File

@@ -26,8 +26,7 @@ use hex;
use sha2::{Digest, Sha256};
use std::collections::{HashMap, HashSet};
use uuid::Uuid;
use strum::{IntoEnumIterator};
use windmill_audit::audit_oss::{audit_log, AuditAuthorable};
use windmill_audit::audit_oss::audit_log;
use windmill_audit::ActionKind;
use windmill_common::db::UserDB;
use windmill_common::s3_helpers::LargeFileStorage;
@@ -39,7 +38,8 @@ use windmill_common::workspaces::GitRepositorySettings;
#[cfg(feature = "enterprise")]
use windmill_common::workspaces::WorkspaceDeploymentUISettings;
use windmill_common::workspaces::{
check_user_against_rule, get_datatable_resource_from_db_unchecked, DataTable, DataTableCatalogResourceType, ProtectionRuleKind, ProtectionRules, ProtectionRuleset, RuleCheckResult, WorkspaceGitSyncSettings
get_datatable_resource_from_db_unchecked, DataTable, DataTableCatalogResourceType,
WorkspaceGitSyncSettings,
};
use windmill_common::workspaces::{Ducklake, DucklakeCatalogResourceType};
use windmill_common::PgDatabase;
@@ -147,12 +147,6 @@ pub fn workspaced_service() -> Router {
post(reset_workspace_diffs),
)
.route("/compare/:target_workspace_id", get(compare_workspaces))
.route("/protection_rules", get(list_protection_rules))
.route("/protection_rules", post(create_protection_rule))
.route(
"/protection_rules/:rule_name",
post(update_protection_rule).delete(delete_protection_rule),
)
}
pub fn global_service() -> Router {
Router::new()
@@ -3388,18 +3382,6 @@ async fn create_workspace_fork_branch(
if *DISABLE_WORKSPACE_FORK {
require_super_admin(&db, &authed.email).await?;
}
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
&w_id,
&ProtectionRuleKind::DisableWorkspaceForking,
AuditAuthorable::username(&authed),
&authed.groups,
authed.is_admin,
&db,
)
.await?
{
return Err(Error::PermissionDenied(msg));
}
Ok(Json(
handle_fork_branch_creation(&authed.email, &authed.username, &db, &w_id, &nw.id).await?,
@@ -3421,18 +3403,6 @@ async fn create_workspace_fork(
if *DISABLE_WORKSPACE_FORK {
require_super_admin(&db, &authed.email).await?;
}
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
&parent_workspace_id,
&ProtectionRuleKind::DisableWorkspaceForking,
AuditAuthorable::username(&authed),
&authed.groups,
authed.is_admin,
&db,
)
.await?
{
return Err(Error::PermissionDenied(msg));
}
let mut tx: Transaction<'_, Postgres> = db.begin().await?;
@@ -4315,268 +4285,6 @@ async fn update_operator_settings(
Ok("Operator settings updated successfully".to_string())
}
// Protection Rules API endpoints
#[derive(Deserialize)]
struct CreateProtectionRuleRequest {
name: String,
rules: Vec<ProtectionRuleKind>,
bypass_groups: Vec<String>,
bypass_users: Vec<String>,
}
#[derive(Deserialize)]
struct UpdateProtectionRuleRequest {
rules: Vec<ProtectionRuleKind>,
bypass_groups: Vec<String>,
bypass_users: Vec<String>,
}
#[derive(Serialize)]
struct ProtectionRulesetResponse {
pub workspace_id: String,
pub name: String,
pub rules: Vec<ProtectionRuleKind>,
pub bypass_groups: Vec<String>,
pub bypass_users: Vec<String>,
}
impl From<ProtectionRuleset> for ProtectionRulesetResponse {
fn from(value: ProtectionRuleset) -> Self {
let mut rules = vec![];
for rule in ProtectionRuleKind::iter() {
if value.rules.contains(rule.flag()) {
rules.push(rule)
}
}
ProtectionRulesetResponse {
rules,
workspace_id: value.workspace_id,
name: value.name,
bypass_groups: value.bypass_groups,
bypass_users: value.bypass_users,
}
}
}
/// List all protection rules for a workspace
async fn list_protection_rules(
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
) -> JsonResult<Vec<ProtectionRulesetResponse>> {
let rules =
(*windmill_common::workspaces::get_protection_rules(&w_id, &db).await?).clone();
Ok(Json(rules.into_iter().map(ProtectionRulesetResponse::from).collect()))
}
/// Create a new protection rule
async fn create_protection_rule(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Json(req): Json<CreateProtectionRuleRequest>,
) -> Result<String> {
require_admin(authed.is_admin, &authed.username)?;
let mut tx = db.begin().await?;
// Check if rule with this name already exists
let exists = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM workspace_protection_rule WHERE workspace_id = $1 AND name = $2)",
&w_id,
&req.name
)
.fetch_one(&mut *tx)
.await?
.unwrap_or(false);
if exists {
return Err(Error::BadRequest(format!(
"Protection rule with name '{}' already exists",
req.name
)));
}
// Insert the new rule
sqlx::query!(
r#"
INSERT INTO workspace_protection_rule (workspace_id, name, rules, bypass_groups, bypass_users)
VALUES ($1, $2, $3, $4, $5)
"#,
&w_id,
&req.name,
ProtectionRules::from(&req.rules).bits(),
&req.bypass_groups,
&req.bypass_users,
)
.execute(&mut *tx)
.await?;
audit_log(
&mut *tx,
&authed,
"workspaces.create_protection_rule",
ActionKind::Create,
&w_id,
Some(&req.name),
Some([("name", &req.name[..])].into()),
)
.await?;
tx.commit().await?;
// Invalidate cache
windmill_common::workspaces::invalidate_protection_rules_cache(&w_id);
handle_deployment_metadata(
&authed.email,
&authed.username,
&db,
&w_id,
DeployedObject::Settings { setting_type: format!("protection_rule_{}", req.name) },
None,
false,
None,
)
.await?;
Ok(format!("Created protection rule '{}'", req.name))
}
/// Update an existing protection rule
async fn update_protection_rule(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path((w_id, rule_name)): Path<(String, String)>,
Json(req): Json<UpdateProtectionRuleRequest>,
) -> Result<String> {
require_admin(authed.is_admin, &authed.username)?;
let mut tx = db.begin().await?;
// Check if rule exists
let exists = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM workspace_protection_rule WHERE workspace_id = $1 AND name = $2)",
&w_id,
&rule_name
)
.fetch_one(&mut *tx)
.await?
.unwrap_or(false);
if !exists {
return Err(Error::NotFound(format!(
"Protection rule '{}' not found",
rule_name
)));
}
// Update the rule
sqlx::query!(
r#"
UPDATE workspace_protection_rule
SET rules = $1, bypass_groups = $2, bypass_users = $3
WHERE workspace_id = $4 AND name = $5
"#,
ProtectionRules::from(&req.rules).bits(),
&req.bypass_groups,
&req.bypass_users,
&w_id,
&rule_name
)
.execute(&mut *tx)
.await?;
audit_log(
&mut *tx,
&authed,
"workspaces.update_protection_rule",
ActionKind::Update,
&w_id,
Some(&rule_name),
Some([("name", &rule_name[..])].into()),
)
.await?;
tx.commit().await?;
// Invalidate cache
windmill_common::workspaces::invalidate_protection_rules_cache(&w_id);
handle_deployment_metadata(
&authed.email,
&authed.username,
&db,
&w_id,
DeployedObject::Settings { setting_type: format!("protection_rule_{}", rule_name) },
None,
false,
None,
)
.await?;
Ok(format!("Updated protection rule '{}'", rule_name))
}
/// Delete a protection rule
async fn delete_protection_rule(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path((w_id, rule_name)): Path<(String, String)>,
) -> Result<String> {
require_admin(authed.is_admin, &authed.username)?;
let mut tx = db.begin().await?;
// Delete the rule
let result = sqlx::query!(
"DELETE FROM workspace_protection_rule WHERE workspace_id = $1 AND name = $2",
&w_id,
&rule_name
)
.execute(&mut *tx)
.await?;
if result.rows_affected() == 0 {
return Err(Error::NotFound(format!(
"Protection rule '{}' not found",
rule_name
)));
}
audit_log(
&mut *tx,
&authed,
"workspaces.delete_protection_rule",
ActionKind::Delete,
&w_id,
Some(&rule_name),
Some([("name", &rule_name[..])].into()),
)
.await?;
tx.commit().await?;
// Invalidate cache
windmill_common::workspaces::invalidate_protection_rules_cache(&w_id);
handle_deployment_metadata(
&authed.email,
&authed.username,
&db,
&w_id,
DeployedObject::Settings { setting_type: format!("protection_rule_{}", rule_name) },
None,
false,
None,
)
.await?;
Ok(format!("Deleted protection rule '{}'", rule_name))
}
#[derive(Serialize)]
pub struct WorkspaceComparison {
pub all_ahead_items_visible: bool,

View File

@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.630.1
version: 1.629.1
title: Windmill API
contact:
@@ -4169,126 +4169,6 @@ paths:
type: string
example: "Updated mute critical alert UI settings for workspace: workspace_id"
/w/{workspace}/workspaces/protection_rules:
get:
summary: list all protection rules for a workspace
operationId: listProtectionRules
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
responses:
"200":
description: list of protection rules
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/ProtectionRuleset"
post:
summary: create a new protection rule
operationId: createProtectionRule
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
description: New protection rule configuration
required: true
content:
application/json:
schema:
type: object
required:
- name
- rules
- bypass_groups
- bypass_users
properties:
name:
type: string
description: Unique name for the protection rule
example: "Production Protection"
rules:
$ref: "#/components/schemas/ProtectionRules"
bypass_groups:
$ref: "#/components/schemas/RuleBypasserGroups"
bypass_users:
$ref: "#/components/schemas/RuleBypasserUsers"
responses:
"200":
description: protection rule created successfully
content:
text/plain:
schema:
type: string
"400":
description: rule with this name already exists
/w/{workspace}/workspaces/protection_rules/{rule_name}:
post:
summary: update an existing protection rule
operationId: updateProtectionRule
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: rule_name
in: path
required: true
schema:
type: string
description: Name of the protection rule to update
requestBody:
description: Updated protection rule configuration
required: true
content:
application/json:
schema:
type: object
required:
- rules
- bypass_groups
- bypass_users
properties:
rules:
$ref: "#/components/schemas/ProtectionRules"
bypass_groups:
$ref: "#/components/schemas/RuleBypasserGroups"
bypass_users:
$ref: "#/components/schemas/RuleBypasserUsers"
responses:
"200":
description: protection rule updated successfully
content:
text/plain:
schema:
type: string
"404":
description: protection rule not found
delete:
summary: delete a protection rule
operationId: deleteProtectionRule
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: rule_name
in: path
required: true
schema:
type: string
description: Name of the protection rule to delete
responses:
"200":
description: protection rule deleted successfully
content:
text/plain:
schema:
type: string
"404":
description: protection rule not found
/w/{workspace}/workspaces/public_app_rate_limit:
post:
summary: Set public app rate limit for this workspace
@@ -6052,17 +5932,6 @@ paths:
post:
summary: create script
operationId: createScript
x-mcp-tool: true
x-mcp-instructions: "To create a script, specify the path (e.g., 'f/my_folder/my_script'), the content (source code), and the language. For TypeScript, use 'bun' unless deno-specific APIs are needed."
x-mcp-tool-include-fields:
- path
- content
- language
- summary
- description
- kind
- tag
- deployment_message
tags:
- script
parameters:
@@ -6339,7 +6208,6 @@ paths:
post:
summary: delete script by hash (erase content but keep hash, require admin)
operationId: deleteScriptByHash
x-mcp-tool: true
tags:
- script
parameters:
@@ -6357,7 +6225,6 @@ paths:
post:
summary: delete script at a given path (require admin)
operationId: deleteScriptByPath
x-mcp-tool: true
tags:
- script
parameters:
@@ -6748,9 +6615,6 @@ paths:
post:
summary: run script by path
operationId: runScriptByPath
x-mcp-tool: true
x-mcp-instructions: "You should first use getScriptByPath to retrieve the script's schema and understand what arguments are expected."
x-mcp-tool-include-query-params: []
tags:
- job
parameters:
@@ -7693,18 +7557,6 @@ paths:
post:
summary: create flow
operationId: createFlow
x-mcp-tool: true
x-mcp-tool-include-fields:
- path
- summary
- description
- value
- schema
- tag
- deployment_message
x-mcp-tool-opaque-fields:
- value
- schema
tags:
- flow
parameters:
@@ -7735,18 +7587,6 @@ paths:
post:
summary: update flow
operationId: updateFlow
x-mcp-tool: true
x-mcp-tool-include-fields:
- path
- summary
- description
- value
- schema
- tag
- deployment_message
x-mcp-tool-opaque-fields:
- value
- schema
tags:
- flow
parameters:
@@ -7804,7 +7644,6 @@ paths:
delete:
summary: delete flow by path
operationId: deleteFlowByPath
x-mcp-tool: true
tags:
- flow
parameters:
@@ -8047,16 +7886,6 @@ paths:
post:
summary: create app
operationId: createApp
x-mcp-tool: true
x-mcp-tool-include-fields:
- path
- value
- summary
- policy
- deployment_message
x-mcp-tool-opaque-fields:
- value
- policy
tags:
- app
parameters:
@@ -8404,16 +8233,6 @@ paths:
post:
summary: update app
operationId: updateApp
x-mcp-tool: true
x-mcp-tool-include-fields:
- path
- value
- summary
- policy
- deployment_message
x-mcp-tool-opaque-fields:
- value
- policy
tags:
- app
parameters:
@@ -8695,9 +8514,6 @@ paths:
post:
summary: run flow by path
operationId: runFlowByPath
x-mcp-tool: true
x-mcp-instructions: "You should first use getFlowByPath to retrieve the flow's schema and understand what arguments are expected."
x-mcp-tool-include-query-params: []
tags:
- job
parameters:
@@ -18093,7 +17909,7 @@ components:
ScriptArgs:
type: object
description: The arguments to pass to the script or flow
additionalProperties: true
additionalProperties: {}
Input:
type: object
@@ -22100,7 +21916,8 @@ components:
created_at:
type: string
format: date-time
value: {}
value:
type: object
policy:
$ref: "#/components/schemas/Policy"
execution_mode:
@@ -22649,7 +22466,8 @@ components:
properties:
name:
type: string
value: {}
value:
type: object
required:
- name
- value
@@ -23103,47 +22921,7 @@ components:
kind:
$ref: "#/components/schemas/AssetKind"
required: [path, kind]
ProtectionRuleset:
type: object
description: A workspace protection rule defining restrictions and bypass permissions
required:
- name
- rules
- bypass_groups
- bypass_users
properties:
name:
type: string
description: Unique name for the protection rule
example: "Production Protection"
workspace_id:
type: string
rules:
$ref: "#/components/schemas/ProtectionRules"
bypass_groups:
$ref: "#/components/schemas/RuleBypasserGroups"
bypass_users:
$ref: "#/components/schemas/RuleBypasserUsers"
ProtectionRules:
type: array
description: Configuration of protection restrictions
items:
$ref: "#/components/schemas/ProtectionRuleKind"
ProtectionRuleKind:
type: string
enum:
- DisableDirectDeployment
- DisableWorkspaceForking
RuleBypasserGroups:
type: array
description: Groups that can bypass this ruleset
items:
type: string
RuleBypasserUsers:
type: array
description: Users that can bypass this ruleset
items:
type: string
NativeServiceName:
type: string
enum:

View File

@@ -49,7 +49,7 @@ use sha2::{Digest, Sha256};
use sql_builder::{bind::Bind, SqlBuilder};
use sqlx::{types::Uuid, FromRow};
use std::str;
use windmill_audit::audit_oss::{audit_log, AuditAuthorable};
use windmill_audit::audit_oss::audit_log;
use windmill_audit::ActionKind;
use windmill_common::{
apps::{AppScriptId, ListAppQuery, APP_WORKSPACED_ROUTE},
@@ -65,7 +65,6 @@ use windmill_common::{
},
variables::{build_crypt, build_crypt_with_key_suffix, encrypt},
worker::{to_raw_value, CLOUD_HOSTED},
workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult},
HUB_BASE_URL,
};
@@ -1050,20 +1049,6 @@ async fn create_app_raw<'a>(
"Operators cannot create apps for security reasons".to_string(),
));
}
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
&w_id,
&ProtectionRuleKind::DisableDirectDeployment,
AuditAuthorable::username(&authed),
&authed.groups,
authed.is_admin,
&db,
)
.await?
{
return Err(Error::PermissionDenied(msg));
}
let (path, _id) = process_app_multipart!(
authed,
user_db,
@@ -1124,19 +1109,6 @@ async fn create_app(
let path = app.path.clone();
check_scopes(&authed, || format!("apps:write:{}", &path))?;
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
&w_id,
&ProtectionRuleKind::DisableDirectDeployment,
AuditAuthorable::username(&authed),
&authed.groups,
authed.is_admin,
&db,
)
.await?
{
return Err(Error::PermissionDenied(msg));
}
let (new_tx, _path, _id) = create_app_internal(authed, db, user_db, &w_id, false, app).await?;
new_tx.commit().await?;
@@ -1375,19 +1347,6 @@ async fn delete_app(
));
}
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
&w_id,
&ProtectionRuleKind::DisableDirectDeployment,
AuditAuthorable::username(&authed),
&authed.groups,
authed.is_admin,
&db,
)
.await?
{
return Err(Error::PermissionDenied(msg));
}
// Check if it's a raw app before deletion
let is_raw_app = sqlx::query_scalar!(
"SELECT app_version.raw_app FROM app
@@ -1493,21 +1452,6 @@ async fn update_app(
// create_app_internal(authed, user_db, db, &w_id, &mut app).await?;
let path = path.to_path();
check_scopes(&authed, || format!("apps:write:{}", path))?;
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
&w_id,
&ProtectionRuleKind::DisableDirectDeployment,
AuditAuthorable::username(&authed),
&authed.groups,
authed.is_admin,
&db,
)
.await?
{
return Err(Error::PermissionDenied(msg));
}
let opath = path.to_string();
let (new_tx, npath, _v_id) =
update_app_internal(authed, db, user_db, &w_id, path, false, ns).await?;
@@ -1538,21 +1482,6 @@ async fn update_app_raw<'a>(
"Operators cannot update apps for security reasons".to_string(),
));
}
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
&w_id,
&ProtectionRuleKind::DisableDirectDeployment,
AuditAuthorable::username(&authed),
&authed.groups,
authed.is_admin,
&db,
)
.await?
{
return Err(Error::PermissionDenied(msg));
}
let path = path.to_path();
check_scopes(&authed, || format!("apps:write:{}", path))?;
let opath = path.to_string();

View File

@@ -114,10 +114,7 @@ use windmill_common::{
get_latest_flow_version_info_for_path, get_script_info_for_hash, utils::empty_as_none,
ScriptHashInfo, BASE_URL,
};
use windmill_queue::{
get_result_and_success_by_id_from_flow, job_is_complete, push, PushArgs, PushArgsOwned,
PushIsolationLevel,
};
use windmill_queue::{job_is_complete, push, PushArgs, PushArgsOwned, PushIsolationLevel};
pub fn workspaced_service() -> Router {
let cors = CorsLayer::new()
@@ -2133,16 +2130,6 @@ pub async fn resume_suspended_flow_as_owner(
require_owner_of_path(&authed, flow_path)?;
check_scopes(&authed, || format!("jobs:run:flows:{}", flow_path))?;
// Check approval conditions (self-approval, required groups, etc.)
if let Some(ref flow_status_value) = flow.flow_status {
if let Ok(flow_status) =
serde_json::from_value::<FlowStatus>(flow_status_value.clone())
{
let trigger_email = flow.email.as_deref().unwrap_or("");
conditionally_require_authed_user(Some(authed.clone()), flow_status, trigger_email)?;
}
}
let value = value.unwrap_or(serde_json::Value::Null);
insert_resume_job(
@@ -2421,7 +2408,6 @@ struct FlowInfo {
flow_status: Option<serde_json::Value>,
suspend: i32,
script_path: Option<String>,
email: Option<String>,
}
/// Get flow info from either a step job (by looking up its parent) or a flow job directly.
@@ -2440,7 +2426,6 @@ async fn get_flow_info_for_resume(job_id: Uuid, db: &DB) -> error::Result<(FlowI
s.flow_status,
q.suspend AS "suspend!",
j.runnable_path AS script_path,
j.permissioned_as_email AS email,
(ji.kind IN ('flow', 'flowpreview')) AS "is_flow_level!"
FROM job_info ji
JOIN v2_job_queue q ON q.id = CASE
@@ -2462,7 +2447,6 @@ async fn get_flow_info_for_resume(job_id: Uuid, db: &DB) -> error::Result<(FlowI
flow_status: result.flow_status,
suspend: result.suspend,
script_path: result.script_path,
email: Some(result.email),
};
Ok((flow_info, result.is_flow_level))
@@ -2475,7 +2459,7 @@ async fn get_suspended_flow_info<'c>(
let flow = sqlx::query_as!(
FlowInfo,
r#"
SELECT j.id AS "id!", COALESCE(s.flow_status, s.workflow_as_code_status) as flow_status, q.suspend AS "suspend!", j.runnable_path as script_path, j.permissioned_as_email as email
SELECT j.id AS "id!", COALESCE(s.flow_status, s.workflow_as_code_status) as flow_status, q.suspend AS "suspend!", j.runnable_path as script_path
FROM v2_job_queue q JOIN v2_job j USING (id) LEFT JOIN v2_job_status s USING (id)
WHERE j.id = $1
"#,
@@ -3487,11 +3471,8 @@ pub async fn run_workflow_as_code(
let JobExtended { inner: job, raw_code, raw_lock, .. } = job;
let (_debouncing_settings, concurrency_settings) =
windmill_common::runnable_settings::prefetch_cached_from_handle(
job.runnable_settings_handle,
&db,
)
.await?;
windmill_common::runnable_settings::prefetch_cached_from_handle(job.runnable_settings_handle, &db)
.await?;
let (job_payload, tag, _delete_after_use, timeout, on_behalf_of) = match job.job_kind {
JobKind::Preview => (
@@ -4251,7 +4232,7 @@ pub async fn stream_job(
};
let poll_delay_ms = run_query.poll_delay_ms;
let (uuid, early_return) = match runnable_id {
let uuid = match runnable_id {
RunnableId::ScriptId(ScriptId::ScriptPath(script_path))
| RunnableId::HubScript(script_path) => {
let (uuid, _, _) = push_script_job_by_path_into_queue(
@@ -4266,10 +4247,10 @@ pub async fn stream_job(
None,
)
.await?;
(uuid, None)
uuid
}
RunnableId::ScriptId(ScriptId::ScriptHash(script_hash)) => {
let (uuid, _) = run_job_by_hash_inner(
run_job_by_hash_inner(
authed.clone(),
db.clone(),
user_db,
@@ -4279,11 +4260,11 @@ pub async fn stream_job(
args,
None,
)
.await?;
(uuid, None)
.await?
.0
}
RunnableId::FlowId(FlowId::FlowPath(flow_path)) => {
let (uuid, early_return, _) = push_flow_job_by_path_into_queue(
push_flow_job_by_path_into_queue(
authed.clone(),
db.clone(),
None,
@@ -4294,11 +4275,11 @@ pub async fn stream_job(
args,
None,
)
.await?;
(uuid, early_return)
.await?
.0
}
RunnableId::FlowId(FlowId::FlowVersion(version)) => {
let (uuid, early_return) = run_flow_by_version_inner(
run_flow_by_version_inner(
authed.clone(),
db.clone(),
user_db,
@@ -4308,8 +4289,8 @@ pub async fn stream_job(
args,
None,
)
.await?;
(uuid, early_return)
.await?
.0
}
};
@@ -4340,7 +4321,6 @@ pub async fn stream_job(
None,
tx,
poll_delay_ms,
early_return,
);
let body = axum::body::Body::from_stream(stream.map(Result::<_, std::convert::Infallible>::Ok));
@@ -4663,19 +4643,12 @@ fn register_potential_assets_on_inline_execution(
let columns = asset.columns.as_ref().map(|cols| {
cols.iter()
.map(|(col_name, col_access_type)| {
(
col_name.clone(),
windmill_common::assets::asset_access_type_from_parser(
*col_access_type,
),
)
(col_name.clone(), windmill_common::assets::asset_access_type_from_parser(*col_access_type))
})
.collect()
});
register_runtime_asset(InsertRuntimeAssetParams {
access_type: asset
.access_type
.map(windmill_common::assets::asset_access_type_from_parser),
access_type: asset.access_type.map(windmill_common::assets::asset_access_type_from_parser),
asset_kind: windmill_common::assets::asset_kind_from_parser(asset.kind),
asset_path: asset.path,
columns,
@@ -5852,7 +5825,6 @@ async fn get_job_update(
no_logs,
is_flow,
None,
None,
)
.await?,
))
@@ -5893,7 +5865,6 @@ async fn get_job_update_sse(
is_flow,
tx,
poll_delay_ms,
None,
);
let stream = tokio_stream::wrappers::ReceiverStream::new(rx).map(|x| {
@@ -5931,7 +5902,6 @@ pub fn start_job_update_sse_stream(
is_flow: Option<bool>,
tx: tokio::sync::mpsc::Sender<JobUpdateSSEStream>,
poll_delay_ms: Option<u64>,
early_return: Option<String>,
) -> () {
tokio::spawn(async move {
let mut log_offset = initial_log_offset;
@@ -5959,7 +5929,6 @@ pub fn start_job_update_sse_stream(
no_logs,
is_flow,
flow_stream_job_id,
early_return.as_deref(),
)
.await
{
@@ -6077,7 +6046,6 @@ pub fn start_job_update_sse_stream(
no_logs,
is_flow,
flow_stream_job_id,
early_return.as_deref(),
)
.await
{
@@ -6208,7 +6176,6 @@ async fn get_job_update_data(
no_logs: Option<bool>,
is_flow: Option<bool>,
flow_stream_job_id: Option<Uuid>,
early_return: Option<&str>,
) -> error::Result<JobUpdate> {
let tags = if log_view {
log_job_view(
@@ -6372,16 +6339,6 @@ async fn get_job_update_data(
let flow_stream_job_id = flow_stream_job_id.or(new_flow_stream_job_id);
let result = if let Some(early_return) = early_return {
match get_result_and_success_by_id_from_flow(db, w_id, job_id, early_return, None).await
{
Ok((early_result, _)) => Some(early_result),
Err(_) => result,
}
} else {
result
};
let flow_stream_delta =
get_flow_stream_delta(db, flow_stream_job_id, stream_offset).await?;

File diff suppressed because it is too large Load Diff

View File

@@ -323,25 +323,16 @@ impl McpBackend for WindmillBackend {
workspace_id,
args_map,
&endpoint_tool.path_params_schema,
&endpoint_tool.path_field_renames,
)?;
let query_string = build_query_string(
args_map,
&endpoint_tool.query_params_schema,
&endpoint_tool.query_field_renames,
);
let query_string = build_query_string(args_map, &endpoint_tool.query_params_schema);
let full_url = format!(
"{}/api{}{}",
self.base_internal_url, path_template, query_string
);
// Prepare request body
let body_json = build_request_body(
&endpoint_tool.method,
args_map,
&endpoint_tool.body_schema,
&endpoint_tool.body_field_renames,
);
let body_json =
build_request_body(&endpoint_tool.method, args_map, &endpoint_tool.body_schema);
// Create and execute request
let response = create_http_request(

View File

@@ -242,34 +242,19 @@ pub async fn get_hub_script_schema(path: &str, db: &DB) -> Result<Option<Schema>
// HTTP request utilities for endpoint tools
// ============================================================================
/// Look up the original field name from a field_renames map.
/// field_renames maps renamed_key -> original_key (e.g. {"path__path": "path"}).
fn get_original_name(renamed_key: &str, field_renames: &Option<Value>) -> String {
field_renames
.as_ref()
.and_then(|v| v.as_object())
.and_then(|m| m.get(renamed_key))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| renamed_key.to_string())
}
/// Substitute path parameters in the URL template
pub fn substitute_path_params(
path: &str,
workspace_id: &str,
args_map: &serde_json::Map<String, Value>,
path_schema: &Option<Value>,
path_field_renames: &Option<Value>,
) -> BackendResult<String> {
let mut path_template = path.replace("{workspace}", workspace_id);
if let Some(schema) = path_schema {
if let Some(props) = schema.get("properties").and_then(|p| p.as_object()) {
for (param_name, _) in props {
// param_name may be renamed (e.g. "path__path"), get original for URL placeholder
let original_name = get_original_name(param_name, path_field_renames);
let placeholder = format!("{{{}}}", original_name);
let placeholder = format!("{{{}}}", param_name);
match args_map.get(param_name) {
Some(param_value) => {
if let Some(str_val) = param_value.as_str() {
@@ -295,7 +280,6 @@ pub fn substitute_path_params(
pub fn build_query_string(
args_map: &serde_json::Map<String, Value>,
query_schema: &Option<Value>,
query_field_renames: &Option<Value>,
) -> String {
let Some(schema) = query_schema else {
return String::new();
@@ -311,13 +295,11 @@ pub fn build_query_string(
.get(param_name)
.filter(|v| !v.is_null())
.map(|value| {
// Use the original name for the query parameter key
let original_name = get_original_name(param_name, query_field_renames);
let value_str = value.to_string();
let str_val = value_str.trim_matches('"');
format!(
"{}={}",
urlencoding::encode(&original_name),
urlencoding::encode(param_name),
urlencoding::encode(str_val)
)
})
@@ -336,7 +318,6 @@ pub fn build_request_body(
method: &str,
args_map: &serde_json::Map<String, Value>,
body_schema: &Option<Value>,
body_field_renames: &Option<Value>,
) -> Option<Value> {
if method == "GET" {
return None;
@@ -348,11 +329,9 @@ pub fn build_request_body(
let body_map: serde_json::Map<String, Value> = props
.keys()
.filter_map(|param_name| {
args_map.get(param_name).map(|value| {
// Use the original name as the key in the request body
let original_name = get_original_name(param_name, body_field_renames);
(original_name, value.clone())
})
args_map
.get(param_name)
.map(|value| (param_name.clone(), value.clone()))
})
.collect();

View File

@@ -2,7 +2,9 @@
pub use windmill_store::resources::*;
#[cfg(feature = "mcp")]
use axum::{ extract::{Extension, Path}, routing::get,
use axum::{
extract::{Extension, Path},
routing::get,
Json, Router,
};
#[cfg(feature = "mcp")]

View File

@@ -505,7 +505,7 @@ async fn route_job(
match trigger.request_type {
RequestType::SyncSse => {
// Trigger the job (always async when streaming)
let (uuid, _, early_return, _) = trigger_runnable_inner(
let (uuid, _, _, _) = trigger_runnable_inner(
&db,
None,
Some(user_db.clone()),
@@ -553,7 +553,6 @@ async fn route_job(
None,
tx,
None,
early_return,
);
let body = axum::body::Body::from_stream(

View File

@@ -3,20 +3,7 @@ use crate::{
DB,
};
use serde::Serialize;
use std::collections::HashSet;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use tokio::time::Instant;
use uuid::Uuid;
static BENCHMARK_INITIALIZED: AtomicBool = AtomicBool::new(false);
static SHARED_BENCH_ITERS: std::sync::LazyLock<Arc<AtomicU64>> =
std::sync::LazyLock::new(|| Arc::new(AtomicU64::new(0)));
pub fn shared_bench_iters() -> Arc<AtomicU64> {
SHARED_BENCH_ITERS.clone()
}
#[derive(Serialize)]
pub struct PoolStats {
@@ -49,10 +36,6 @@ pub struct BenchmarkInfo {
pub start: Instant,
#[serde(skip)]
pub iters: u64,
#[serde(skip)]
seen_top_level: HashSet<Uuid>,
#[serde(skip)]
pub shared_iters: Arc<AtomicU64>,
timings: Vec<BenchmarkIter>,
pub iter_durations: Vec<u64>,
pub total_duration: Option<u64>,
@@ -60,11 +43,9 @@ pub struct BenchmarkInfo {
}
impl BenchmarkInfo {
pub fn new(shared_iters: Arc<AtomicU64>) -> Self {
pub fn new() -> Self {
BenchmarkInfo {
iters: 0,
seen_top_level: HashSet::new(),
shared_iters,
timings: vec![],
start: Instant::now(),
iter_durations: vec![],
@@ -83,23 +64,13 @@ impl BenchmarkInfo {
}
}
pub fn count_top_level(&mut self, job_id: Uuid) -> bool {
if self.seen_top_level.insert(job_id) {
self.iters += 1;
return true;
}
false
}
pub fn add_iter(&mut self, bench: BenchmarkIter, job_id: Uuid, is_top_level: bool) -> bool {
let newly_counted = is_top_level && self.seen_top_level.insert(job_id);
if newly_counted {
pub fn add_iter(&mut self, bench: BenchmarkIter, inc_iters: bool) {
if inc_iters {
self.iters += 1;
}
let elapsed_total = bench.start.elapsed().as_nanos() as u64;
self.timings.push(bench);
self.iter_durations.push(elapsed_total);
newly_counted
}
pub fn write_to_file(&mut self, path: &str) -> anyhow::Result<()> {
@@ -147,120 +118,12 @@ impl BenchmarkIter {
}
}
pub async fn benchmark_verify(benchmark_jobs: i32, db: &DB) {
let benchmark_kind = std::env::var("BENCHMARK_KIND").unwrap_or("noop".to_string());
if benchmark_jobs <= 0 || benchmark_kind == "none" {
return;
}
// For flows, child jobs are created dynamically so only check top-level (parent_job IS NULL).
// "parallelflow" inserts only 1 top-level flow regardless of benchmark_jobs.
let expected_top_level = match benchmark_kind.as_str() {
"parallelflow" => 1i64,
_ => benchmark_jobs as i64,
};
let row = sqlx::query!(
"SELECT
COUNT(*) FILTER (WHERE status = 'success') AS succeeded,
COUNT(*) FILTER (WHERE status = 'failure') AS failed,
COUNT(*) FILTER (WHERE status = 'canceled') AS canceled
FROM v2_job_completed
JOIN v2_job USING (id)
WHERE v2_job.workspace_id = 'admins' AND v2_job.parent_job IS NULL",
)
.fetch_one(db)
.await
.expect("benchmark verify query failed");
let succeeded = row.succeeded.unwrap_or(0);
let failed = row.failed.unwrap_or(0);
let canceled = row.canceled.unwrap_or(0);
let total = succeeded + failed + canceled;
let remaining_in_queue = sqlx::query_scalar!(
"SELECT COUNT(*) FROM v2_job_queue WHERE workspace_id = 'admins'",
)
.fetch_one(db)
.await
.expect("benchmark verify queue query failed")
.unwrap_or(0);
println!("=== BENCHMARK VERIFICATION ===");
println!(" kind: {benchmark_kind}");
println!(" expected top-level: {expected_top_level}");
println!(" completed total: {total}");
println!(" succeeded: {succeeded}");
println!(" failed: {failed}");
println!(" canceled: {canceled}");
println!(" still in queue: {remaining_in_queue}");
if failed > 0 || canceled > 0 {
tracing::error!(
"BENCHMARK VERIFICATION FAILED: {failed} failed, {canceled} canceled out of {total} completed"
);
}
if remaining_in_queue > 0 {
tracing::warn!(
"BENCHMARK VERIFICATION: {remaining_in_queue} jobs still in queue after benchmark"
);
}
if succeeded != expected_top_level {
tracing::error!(
"BENCHMARK VERIFICATION FAILED: expected {expected_top_level} succeeded top-level jobs, got {succeeded}"
);
} else if failed == 0 && canceled == 0 && remaining_in_queue == 0 {
println!(" result: ALL PASSED");
}
println!("==============================");
}
pub async fn benchmark_init(benchmark_jobs: i32, db: &DB) {
use crate::{jobs::JobKind, scripts::ScriptLang};
// Only the first worker to reach this point runs init
if BENCHMARK_INITIALIZED.swap(true, Ordering::SeqCst) {
return;
}
let benchmark_kind = std::env::var("BENCHMARK_KIND").unwrap_or("noop".to_string());
if benchmark_jobs > 0 {
// Clean up data from previous benchmark runs
sqlx::query!("DELETE FROM v2_job_completed WHERE workspace_id = 'admins'")
.execute(db)
.await
.unwrap_or_else(|e| panic!("failed to clean up v2_job_completed: {e:#}"));
sqlx::query!("DELETE FROM v2_job_queue WHERE workspace_id = 'admins'")
.execute(db)
.await
.unwrap_or_else(|e| panic!("failed to clean up v2_job_queue: {e:#}"));
sqlx::query!("DELETE FROM v2_job_status WHERE id IN (SELECT id FROM v2_job WHERE workspace_id = 'admins')")
.execute(db)
.await
.unwrap_or_else(|e| panic!("failed to clean up v2_job_status: {e:#}"));
sqlx::query!("DELETE FROM v2_job_runtime WHERE id IN (SELECT id FROM v2_job WHERE workspace_id = 'admins')")
.execute(db)
.await
.unwrap_or_else(|e| panic!("failed to clean up v2_job_runtime: {e:#}"));
sqlx::query("DELETE FROM job_perms WHERE workspace_id = 'admins'")
.execute(db)
.await
.unwrap_or_else(|e| panic!("failed to clean up job_perms: {e:#}"));
sqlx::query!("DELETE FROM concurrency_key WHERE key LIKE 'bench_%' OR key LIKE 'u/admin/bench_%'")
.execute(db)
.await
.unwrap_or_else(|e| panic!("failed to clean up concurrency_key: {e:#}"));
sqlx::query!("DELETE FROM concurrency_counter WHERE concurrency_id LIKE 'bench_%' OR concurrency_id LIKE 'u/admin/bench_%'")
.execute(db)
.await
.unwrap_or_else(|e| panic!("failed to clean up concurrency_counter: {e:#}"));
sqlx::query!("DELETE FROM v2_job WHERE workspace_id = 'admins'")
.execute(db)
.await
.unwrap_or_else(|e| panic!("failed to clean up v2_job: {e:#}"));
let mut tx = db.begin().await.unwrap();
match benchmark_kind.as_str() {
"dedicated" => {
@@ -397,477 +260,6 @@ pub async fn benchmark_init(benchmark_jobs: i32, db: &DB) {
.await
.unwrap_or_else(|_e| panic!("failed to insert parallelflow jobs (4)"));
}
"sequentialflow" => {
let uuids = sqlx::query_scalar!("INSERT INTO v2_job (id, runnable_id, runnable_path, kind, script_lang, tag, created_by, permissioned_as, permissioned_as_email, workspace_id, raw_flow) (SELECT gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 FROM generate_series(1, $11)) RETURNING id",
None::<i64>,
None::<String>,
JobKind::FlowPreview as JobKind,
ScriptLang::Deno as ScriptLang,
"flow",
"admin",
"u/admin",
"admin@windmill.dev",
"admins",
serde_json::from_str::<serde_json::Value>(r#"
{
"modules": [
{
"id": "a",
"value": {
"type": "rawscript",
"content": "export async function main() { return 'a'; }",
"language": "deno",
"input_transforms": {}
}
},
{
"id": "b",
"value": {
"type": "rawscript",
"content": "export async function main() { return 'b'; }",
"language": "deno",
"input_transforms": {}
}
},
{
"id": "c",
"value": {
"type": "rawscript",
"content": "export async function main() { return 'c'; }",
"language": "deno",
"input_transforms": {}
}
},
{
"id": "d",
"value": {
"type": "rawscript",
"content": "export async function main() { return 'd'; }",
"language": "deno",
"input_transforms": {}
}
}
],
"preprocessor_module": null
}
"#).unwrap(),
benchmark_jobs,
)
.fetch_all(&mut *tx)
.await.unwrap_or_else(|_e| panic!("failed to insert sequentialflow jobs (1)"));
sqlx::query!("INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag) SELECT unnest($1::uuid[]), $2, now(), $3", &uuids, "admins", "flow")
.execute(&mut *tx)
.await.unwrap_or_else(|_e| panic!("failed to insert sequentialflow jobs (2)"));
sqlx::query!(
"INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])",
&uuids
)
.execute(&mut *tx)
.await
.unwrap_or_else(|_e| panic!("failed to insert sequentialflow jobs (3)"));
sqlx::query!(
"INSERT INTO v2_job_status (id, flow_status) SELECT unnest($1::uuid[]), $2",
&uuids,
serde_json::from_str::<serde_json::Value>(
r#"
{
"step": 0,
"modules": [
{ "id": "a", "type": "WaitingForPriorSteps" },
{ "id": "b", "type": "WaitingForPriorSteps" },
{ "id": "c", "type": "WaitingForPriorSteps" },
{ "id": "d", "type": "WaitingForPriorSteps" }
],
"cleanup_module": {},
"failure_module": {
"id": "failure",
"type": "WaitingForPriorSteps"
},
"preprocessor_module": null
}
"#
)
.unwrap()
)
.execute(&mut *tx)
.await
.unwrap_or_else(|_e| panic!("failed to insert sequentialflow jobs (4)"));
}
"scriptlogs" => {
let uuids = sqlx::query_scalar!("INSERT INTO v2_job (id, runnable_id, runnable_path, kind, script_lang, tag, created_by, permissioned_as, permissioned_as_email, workspace_id, raw_code) (SELECT gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 FROM generate_series(1, $11)) RETURNING id",
None::<i64>,
None::<String>,
JobKind::Preview as JobKind,
ScriptLang::Deno as ScriptLang,
"deno",
"admin",
"u/admin",
"admin@windmill.dev",
"admins",
"export async function main() { for (let i = 0; i < 1000; i++) { console.log('benchmark log line ' + i); } return 'done'; }",
benchmark_jobs,
)
.fetch_all(&mut *tx)
.await.unwrap_or_else(|_e| panic!("failed to insert scriptlogs jobs (1)"));
sqlx::query!("INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag) SELECT unnest($1::uuid[]), $2, now(), $3", &uuids, "admins", "deno")
.execute(&mut *tx)
.await.unwrap_or_else(|_e| panic!("failed to insert scriptlogs jobs (2)"));
sqlx::query!(
"INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])",
&uuids
)
.execute(&mut *tx)
.await
.unwrap_or_else(|_e| panic!("failed to insert scriptlogs jobs (3)"));
}
"concurrencylimit" => {
let uuids = sqlx::query_scalar!("INSERT INTO v2_job (id, runnable_id, runnable_path, kind, script_lang, tag, created_by, permissioned_as, permissioned_as_email, workspace_id, raw_code, concurrent_limit, concurrency_time_window_s) (SELECT gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12 FROM generate_series(1, $13)) RETURNING id",
None::<i64>,
Some("u/admin/bench_conclimit"),
JobKind::Preview as JobKind,
ScriptLang::Deno as ScriptLang,
"deno",
"admin",
"u/admin",
"admin@windmill.dev",
"admins",
"export async function main() { return 'done'; }",
2i32,
0i32,
benchmark_jobs,
)
.fetch_all(&mut *tx)
.await.unwrap_or_else(|_e| panic!("failed to insert concurrencylimit jobs (1)"));
sqlx::query!("INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag) SELECT unnest($1::uuid[]), $2, now(), $3", &uuids, "admins", "deno")
.execute(&mut *tx)
.await.unwrap_or_else(|_e| panic!("failed to insert concurrencylimit jobs (2)"));
sqlx::query!(
"INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])",
&uuids
)
.execute(&mut *tx)
.await
.unwrap_or_else(|_e| panic!("failed to insert concurrencylimit jobs (3)"));
let concurrency_id = "u/admin/bench_conclimit";
sqlx::query!(
"INSERT INTO concurrency_counter (concurrency_id, job_uuids) VALUES ($1, '{}'::jsonb) ON CONFLICT (concurrency_id) DO NOTHING",
concurrency_id,
)
.execute(&mut *tx)
.await
.unwrap_or_else(|_e| panic!("failed to insert concurrencylimit counter"));
for uuid in &uuids {
sqlx::query!(
"INSERT INTO concurrency_key (key, ended_at, job_id) VALUES ($1, now() - INTERVAL '1 hour', $2)",
concurrency_id,
uuid,
)
.execute(&mut *tx)
.await
.unwrap_or_else(|_e| panic!("failed to insert concurrencylimit key"));
}
}
"concurrencykey" => {
let uuids = sqlx::query_scalar!("INSERT INTO v2_job (id, runnable_id, runnable_path, kind, script_lang, tag, created_by, permissioned_as, permissioned_as_email, workspace_id, raw_code, concurrent_limit, concurrency_time_window_s) (SELECT gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12 FROM generate_series(1, $13)) RETURNING id",
None::<i64>,
Some("u/admin/bench_conckey"),
JobKind::Preview as JobKind,
ScriptLang::Deno as ScriptLang,
"deno",
"admin",
"u/admin",
"admin@windmill.dev",
"admins",
"export async function main() { return 'done'; }",
1i32,
0i32,
benchmark_jobs,
)
.fetch_all(&mut *tx)
.await.unwrap_or_else(|_e| panic!("failed to insert concurrencykey jobs (1)"));
sqlx::query!("INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag) SELECT unnest($1::uuid[]), $2, now(), $3", &uuids, "admins", "deno")
.execute(&mut *tx)
.await.unwrap_or_else(|_e| panic!("failed to insert concurrencykey jobs (2)"));
sqlx::query!(
"INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])",
&uuids
)
.execute(&mut *tx)
.await
.unwrap_or_else(|_e| panic!("failed to insert concurrencykey jobs (3)"));
let concurrency_id = "bench_shared_concurrency_key";
sqlx::query!(
"INSERT INTO concurrency_counter (concurrency_id, job_uuids) VALUES ($1, '{}'::jsonb) ON CONFLICT (concurrency_id) DO NOTHING",
concurrency_id,
)
.execute(&mut *tx)
.await
.unwrap_or_else(|_e| panic!("failed to insert concurrencykey counter"));
for uuid in &uuids {
sqlx::query!(
"INSERT INTO concurrency_key (key, ended_at, job_id) VALUES ($1, now() - INTERVAL '1 hour', $2)",
concurrency_id,
uuid,
)
.execute(&mut *tx)
.await
.unwrap_or_else(|_e| panic!("failed to insert concurrencykey key"));
}
}
"mixed" => {
let portion = benchmark_jobs / 5;
let remainder = benchmark_jobs % 5;
// 1) noop jobs
let noop_count = portion + remainder;
let noop_uuids = sqlx::query_scalar!("INSERT INTO v2_job (id, runnable_id, runnable_path, kind, script_lang, tag, created_by, permissioned_as, permissioned_as_email, workspace_id) (SELECT gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9 FROM generate_series(1, $10)) RETURNING id",
None::<i64>,
None::<String>,
JobKind::Noop as JobKind,
ScriptLang::Deno as ScriptLang,
"deno",
"admin",
"u/admin",
"admin@windmill.dev",
"admins",
noop_count,
)
.fetch_all(&mut *tx)
.await.unwrap_or_else(|_e| panic!("failed to insert mixed noop jobs"));
sqlx::query!("INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag) SELECT unnest($1::uuid[]), $2, now(), $3", &noop_uuids, "admins", "deno")
.execute(&mut *tx)
.await.unwrap_or_else(|_e| panic!("failed to insert mixed noop queue"));
sqlx::query!("INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])", &noop_uuids)
.execute(&mut *tx)
.await.unwrap_or_else(|_e| panic!("failed to insert mixed noop runtime"));
// 2) sequentialflow jobs
if portion > 0 {
let sf_uuids = sqlx::query_scalar!("INSERT INTO v2_job (id, runnable_id, runnable_path, kind, script_lang, tag, created_by, permissioned_as, permissioned_as_email, workspace_id, raw_flow) (SELECT gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 FROM generate_series(1, $11)) RETURNING id",
None::<i64>,
None::<String>,
JobKind::FlowPreview as JobKind,
ScriptLang::Deno as ScriptLang,
"flow",
"admin",
"u/admin",
"admin@windmill.dev",
"admins",
serde_json::from_str::<serde_json::Value>(r#"{"modules":[{"id":"a","value":{"type":"rawscript","content":"export async function main() { return 'a'; }","language":"deno","input_transforms":{}}},{"id":"b","value":{"type":"rawscript","content":"export async function main() { return 'b'; }","language":"deno","input_transforms":{}}},{"id":"c","value":{"type":"rawscript","content":"export async function main() { return 'c'; }","language":"deno","input_transforms":{}}},{"id":"d","value":{"type":"rawscript","content":"export async function main() { return 'd'; }","language":"deno","input_transforms":{}}}],"preprocessor_module":null}"#).unwrap(),
portion,
)
.fetch_all(&mut *tx)
.await.unwrap_or_else(|_e| panic!("failed to insert mixed sequentialflow jobs"));
sqlx::query!("INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag) SELECT unnest($1::uuid[]), $2, now(), $3", &sf_uuids, "admins", "flow")
.execute(&mut *tx)
.await.unwrap_or_else(|_e| panic!("failed to insert mixed sequentialflow queue"));
sqlx::query!("INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])", &sf_uuids)
.execute(&mut *tx)
.await.unwrap_or_else(|_e| panic!("failed to insert mixed sequentialflow runtime"));
sqlx::query!(
"INSERT INTO v2_job_status (id, flow_status) SELECT unnest($1::uuid[]), $2",
&sf_uuids,
serde_json::from_str::<serde_json::Value>(r#"{"step":0,"modules":[{"id":"a","type":"WaitingForPriorSteps"},{"id":"b","type":"WaitingForPriorSteps"},{"id":"c","type":"WaitingForPriorSteps"},{"id":"d","type":"WaitingForPriorSteps"}],"cleanup_module":{},"failure_module":{"id":"failure","type":"WaitingForPriorSteps"},"preprocessor_module":null}"#).unwrap()
)
.execute(&mut *tx)
.await.unwrap_or_else(|_e| panic!("failed to insert mixed sequentialflow status"));
}
// 3) scriptlogs jobs
if portion > 0 {
let sl_uuids = sqlx::query_scalar!("INSERT INTO v2_job (id, runnable_id, runnable_path, kind, script_lang, tag, created_by, permissioned_as, permissioned_as_email, workspace_id, raw_code) (SELECT gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 FROM generate_series(1, $11)) RETURNING id",
None::<i64>,
None::<String>,
JobKind::Preview as JobKind,
ScriptLang::Deno as ScriptLang,
"deno",
"admin",
"u/admin",
"admin@windmill.dev",
"admins",
"export async function main() { for (let i = 0; i < 1000; i++) { console.log('benchmark log line ' + i); } return 'done'; }",
portion,
)
.fetch_all(&mut *tx)
.await.unwrap_or_else(|_e| panic!("failed to insert mixed scriptlogs jobs"));
sqlx::query!("INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag) SELECT unnest($1::uuid[]), $2, now(), $3", &sl_uuids, "admins", "deno")
.execute(&mut *tx)
.await.unwrap_or_else(|_e| panic!("failed to insert mixed scriptlogs queue"));
sqlx::query!("INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])", &sl_uuids)
.execute(&mut *tx)
.await.unwrap_or_else(|_e| panic!("failed to insert mixed scriptlogs runtime"));
}
// 4) concurrencylimit jobs
if portion > 0 {
let cl_uuids = sqlx::query_scalar!("INSERT INTO v2_job (id, runnable_id, runnable_path, kind, script_lang, tag, created_by, permissioned_as, permissioned_as_email, workspace_id, raw_code, concurrent_limit, concurrency_time_window_s) (SELECT gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12 FROM generate_series(1, $13)) RETURNING id",
None::<i64>,
Some("u/admin/bench_conclimit"),
JobKind::Preview as JobKind,
ScriptLang::Deno as ScriptLang,
"deno",
"admin",
"u/admin",
"admin@windmill.dev",
"admins",
"export async function main() { return 'done'; }",
2i32,
0i32,
portion,
)
.fetch_all(&mut *tx)
.await.unwrap_or_else(|_e| panic!("failed to insert mixed concurrencylimit jobs"));
sqlx::query!("INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag) SELECT unnest($1::uuid[]), $2, now(), $3", &cl_uuids, "admins", "deno")
.execute(&mut *tx)
.await.unwrap_or_else(|_e| panic!("failed to insert mixed concurrencylimit queue"));
sqlx::query!("INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])", &cl_uuids)
.execute(&mut *tx)
.await.unwrap_or_else(|_e| panic!("failed to insert mixed concurrencylimit runtime"));
let cl_concurrency_id = "u/admin/bench_conclimit";
sqlx::query!(
"INSERT INTO concurrency_counter (concurrency_id, job_uuids) VALUES ($1, '{}'::jsonb) ON CONFLICT (concurrency_id) DO NOTHING",
cl_concurrency_id,
)
.execute(&mut *tx)
.await.unwrap_or_else(|_e| panic!("failed to insert mixed concurrencylimit counter"));
for uuid in &cl_uuids {
sqlx::query!(
"INSERT INTO concurrency_key (key, ended_at, job_id) VALUES ($1, now() - INTERVAL '1 hour', $2)",
cl_concurrency_id,
uuid,
)
.execute(&mut *tx)
.await.unwrap_or_else(|_e| panic!("failed to insert mixed concurrencylimit key"));
}
}
// 5) concurrencykey jobs
if portion > 0 {
let ck_uuids = sqlx::query_scalar!("INSERT INTO v2_job (id, runnable_id, runnable_path, kind, script_lang, tag, created_by, permissioned_as, permissioned_as_email, workspace_id, raw_code, concurrent_limit, concurrency_time_window_s) (SELECT gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12 FROM generate_series(1, $13)) RETURNING id",
None::<i64>,
Some("u/admin/bench_conckey"),
JobKind::Preview as JobKind,
ScriptLang::Deno as ScriptLang,
"deno",
"admin",
"u/admin",
"admin@windmill.dev",
"admins",
"export async function main() { return 'done'; }",
1i32,
0i32,
portion,
)
.fetch_all(&mut *tx)
.await.unwrap_or_else(|_e| panic!("failed to insert mixed concurrencykey jobs"));
sqlx::query!("INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag) SELECT unnest($1::uuid[]), $2, now(), $3", &ck_uuids, "admins", "deno")
.execute(&mut *tx)
.await.unwrap_or_else(|_e| panic!("failed to insert mixed concurrencykey queue"));
sqlx::query!("INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])", &ck_uuids)
.execute(&mut *tx)
.await.unwrap_or_else(|_e| panic!("failed to insert mixed concurrencykey runtime"));
let ck_concurrency_id = "bench_shared_concurrency_key";
sqlx::query!(
"INSERT INTO concurrency_counter (concurrency_id, job_uuids) VALUES ($1, '{}'::jsonb) ON CONFLICT (concurrency_id) DO NOTHING",
ck_concurrency_id,
)
.execute(&mut *tx)
.await.unwrap_or_else(|_e| panic!("failed to insert mixed concurrencykey counter"));
for uuid in &ck_uuids {
sqlx::query!(
"INSERT INTO concurrency_key (key, ended_at, job_id) VALUES ($1, now() - INTERVAL '1 hour', $2)",
ck_concurrency_id,
uuid,
)
.execute(&mut *tx)
.await.unwrap_or_else(|_e| panic!("failed to insert mixed concurrencykey key"));
}
}
}
"mixed_no_cc" => {
let portion = benchmark_jobs / 3;
let remainder = benchmark_jobs % 3;
// 1) noop jobs
let noop_count = portion + remainder;
let noop_uuids = sqlx::query_scalar!("INSERT INTO v2_job (id, runnable_id, runnable_path, kind, script_lang, tag, created_by, permissioned_as, permissioned_as_email, workspace_id) (SELECT gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9 FROM generate_series(1, $10)) RETURNING id",
None::<i64>,
None::<String>,
JobKind::Noop as JobKind,
ScriptLang::Deno as ScriptLang,
"deno",
"admin",
"u/admin",
"admin@windmill.dev",
"admins",
noop_count,
)
.fetch_all(&mut *tx)
.await.unwrap_or_else(|_e| panic!("failed to insert mixed_no_cc noop jobs"));
sqlx::query!("INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag) SELECT unnest($1::uuid[]), $2, now(), $3", &noop_uuids, "admins", "deno")
.execute(&mut *tx)
.await.unwrap_or_else(|_e| panic!("failed to insert mixed_no_cc noop queue"));
sqlx::query!("INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])", &noop_uuids)
.execute(&mut *tx)
.await.unwrap_or_else(|_e| panic!("failed to insert mixed_no_cc noop runtime"));
// 2) sequentialflow jobs
if portion > 0 {
let sf_uuids = sqlx::query_scalar!("INSERT INTO v2_job (id, runnable_id, runnable_path, kind, script_lang, tag, created_by, permissioned_as, permissioned_as_email, workspace_id, raw_flow) (SELECT gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 FROM generate_series(1, $11)) RETURNING id",
None::<i64>,
None::<String>,
JobKind::FlowPreview as JobKind,
ScriptLang::Deno as ScriptLang,
"flow",
"admin",
"u/admin",
"admin@windmill.dev",
"admins",
serde_json::from_str::<serde_json::Value>(r#"{"modules":[{"id":"a","value":{"type":"rawscript","content":"export async function main() { return 'a'; }","language":"deno","input_transforms":{}}},{"id":"b","value":{"type":"rawscript","content":"export async function main() { return 'b'; }","language":"deno","input_transforms":{}}},{"id":"c","value":{"type":"rawscript","content":"export async function main() { return 'c'; }","language":"deno","input_transforms":{}}},{"id":"d","value":{"type":"rawscript","content":"export async function main() { return 'd'; }","language":"deno","input_transforms":{}}}],"preprocessor_module":null}"#).unwrap(),
portion,
)
.fetch_all(&mut *tx)
.await.unwrap_or_else(|_e| panic!("failed to insert mixed_no_cc sequentialflow jobs"));
sqlx::query!("INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag) SELECT unnest($1::uuid[]), $2, now(), $3", &sf_uuids, "admins", "flow")
.execute(&mut *tx)
.await.unwrap_or_else(|_e| panic!("failed to insert mixed_no_cc sequentialflow queue"));
sqlx::query!("INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])", &sf_uuids)
.execute(&mut *tx)
.await.unwrap_or_else(|_e| panic!("failed to insert mixed_no_cc sequentialflow runtime"));
sqlx::query!(
"INSERT INTO v2_job_status (id, flow_status) SELECT unnest($1::uuid[]), $2",
&sf_uuids,
serde_json::from_str::<serde_json::Value>(r#"{"step":0,"modules":[{"id":"a","type":"WaitingForPriorSteps"},{"id":"b","type":"WaitingForPriorSteps"},{"id":"c","type":"WaitingForPriorSteps"},{"id":"d","type":"WaitingForPriorSteps"}],"cleanup_module":{},"failure_module":{"id":"failure","type":"WaitingForPriorSteps"},"preprocessor_module":null}"#).unwrap()
)
.execute(&mut *tx)
.await.unwrap_or_else(|_e| panic!("failed to insert mixed_no_cc sequentialflow status"));
}
// 3) scriptlogs jobs
if portion > 0 {
let sl_uuids = sqlx::query_scalar!("INSERT INTO v2_job (id, runnable_id, runnable_path, kind, script_lang, tag, created_by, permissioned_as, permissioned_as_email, workspace_id, raw_code) (SELECT gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 FROM generate_series(1, $11)) RETURNING id",
None::<i64>,
None::<String>,
JobKind::Preview as JobKind,
ScriptLang::Deno as ScriptLang,
"deno",
"admin",
"u/admin",
"admin@windmill.dev",
"admins",
"export async function main() { for (let i = 0; i < 1000; i++) { console.log('benchmark log line ' + i); } return 'done'; }",
portion,
)
.fetch_all(&mut *tx)
.await.unwrap_or_else(|_e| panic!("failed to insert mixed_no_cc scriptlogs jobs"));
sqlx::query!("INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag) SELECT unnest($1::uuid[]), $2, now(), $3", &sl_uuids, "admins", "deno")
.execute(&mut *tx)
.await.unwrap_or_else(|_e| panic!("failed to insert mixed_no_cc scriptlogs queue"));
sqlx::query!("INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])", &sl_uuids)
.execute(&mut *tx)
.await.unwrap_or_else(|_e| panic!("failed to insert mixed_no_cc scriptlogs runtime"));
}
}
"none" => {}
_ => {
let uuids = sqlx::query_scalar!("INSERT INTO v2_job (id, runnable_id, runnable_path, kind, script_lang, tag, created_by, permissioned_as, permissioned_as_email, workspace_id) (SELECT gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9 FROM generate_series(1, $10)) RETURNING id",
@@ -896,16 +288,6 @@ pub async fn benchmark_init(benchmark_jobs: i32, db: &DB) {
.unwrap_or_else(|_e| panic!("failed to insert noop jobs (3)"));
}
}
// Insert job_perms for all benchmark jobs so workers don't fall back to slow permission lookups
sqlx::query(
"INSERT INTO job_perms (job_id, email, username, is_admin, is_operator, groups, folders, workspace_id)
SELECT id, 'admin@windmill.dev', 'admin', true, false, ARRAY['all']::text[], ARRAY[]::jsonb[], 'admins'
FROM v2_job WHERE workspace_id = 'admins'"
)
.execute(&mut *tx)
.await
.unwrap_or_else(|e| panic!("failed to insert job_perms: {e:#}"));
tx.commit().await.unwrap();
}
}

View File

@@ -249,7 +249,7 @@ impl IntoResponse for Error {
let status = match self {
Self::NotFound(_) => axum::http::StatusCode::NOT_FOUND,
Self::NotAuthorized(_) => axum::http::StatusCode::UNAUTHORIZED,
Self::RequireAdmin(_) | Self::PermissionDenied(_) => axum::http::StatusCode::FORBIDDEN,
Self::RequireAdmin(_) => axum::http::StatusCode::FORBIDDEN,
Self::SqlErr { .. }
| Self::BadRequest(_)
| Self::AIError(_)

View File

@@ -664,6 +664,153 @@ pub async fn get_database_url() -> Result<DatabaseUrl, Error> {
Ok(database_url.clone())
}
pub async fn initial_connection() -> Result<sqlx::Pool<sqlx::Postgres>, error::Error> {
let connect_options = get_database_url().await?.connect_options().await?;
sqlx::postgres::PgPoolOptions::new()
.max_connections(2)
.connect_with(connect_options)
.await
.map_err(|err| Error::ConnectingToDatabase(err.to_string()))
}
pub async fn connect_db(
server_mode: bool,
indexer_mode: bool,
worker_mode: bool,
#[cfg(feature = "private")] mut killpill_rx: tokio::sync::broadcast::Receiver<()>,
) -> anyhow::Result<sqlx::Pool<sqlx::Postgres>> {
use anyhow::Context;
let database_url = get_database_url().await?;
let max_connections = match std::env::var("DATABASE_CONNECTIONS") {
Ok(n) => n.parse::<u32>().context("invalid DATABASE_CONNECTIONS")?,
Err(_) => {
if server_mode {
DEFAULT_MAX_CONNECTIONS_SERVER
} else if indexer_mode {
DEFAULT_MAX_CONNECTIONS_INDEXER
} else {
DEFAULT_MAX_CONNECTIONS_WORKER
+ std::env::var("NUM_WORKERS")
.ok()
.map(|x| x.parse().ok())
.flatten()
.unwrap_or(1)
- 1
}
}
};
let pool = connect(database_url.clone(), max_connections, worker_mode).await?;
#[cfg(all(feature = "enterprise", feature = "private"))]
let pool2 = pool.clone();
#[cfg(all(feature = "enterprise", feature = "private"))]
if let DatabaseUrl::IamRds(database_url) = database_url {
tokio::spawn(async move {
loop {
tokio::select! {
_ = killpill_rx.recv() => {
break;
}
_ = tokio::time::sleep(std::time::Duration::from_secs(10)) => {
let needs_refresh = {
let read_guard = database_url.read().await;
read_guard.needs_refresh()
};
if needs_refresh {
let new_url = tokio::time::timeout(std::time::Duration::from_secs(10), get_database_url()).await;
match new_url {
Ok(Ok(new_url)) => {
match new_url.connect_options().await {
Ok(connect_options) => {
pool2.set_connect_options(connect_options);
tracing::info!("Refreshed IAM RDS URL successfully");
}
Err(e) => {
tracing::error!("Error getting IAM RDS connect options, retrying in 10s: {}", e);
continue;
}
}
}
Ok(Err(e)) => {
tracing::error!("Error refreshing IAM RDS URL, trying again in 10s: {}", e);
continue;
}
Err(e) => {
tracing::error!("Timeout after 10s refreshing IAM RDS URL, trying again in 10 seconds: {}", e);
continue;
}
}
}
}
}
}
});
}
Ok(pool)
}
pub async fn connect(
database_url: DatabaseUrl,
max_connections: u32,
worker_mode: bool,
) -> Result<sqlx::Pool<sqlx::Postgres>, error::Error> {
use sqlx::Executor;
use std::time::Duration;
sqlx::postgres::PgPoolOptions::new()
.min_connections((max_connections / 5).clamp(3, max_connections))
.max_connections(max_connections)
.max_lifetime(Duration::from_secs(30 * 60)) // 30 mins
.after_connect(move |conn, _| {
if worker_mode {
Box::pin(async move {
if let Err(e) = conn
.execute(
r#"
SET enable_seqscan = OFF;
SET statement_timeout = '5min';
SET idle_in_transaction_session_timeout = '10min';
SET tcp_keepalives_idle = 300;
SET tcp_keepalives_interval = 60;
SET tcp_keepalives_count = 10;"#,
)
.await
{
tracing::error!("Error setting postgres settings: {}", e);
}
Ok(())
})
} else {
Box::pin(async move {
if let Err(e) = conn
.execute(
r#"
SET statement_timeout = '5min';
SET idle_in_transaction_session_timeout = '10min';
SET tcp_keepalives_idle = 300;
SET tcp_keepalives_interval = 60;
SET tcp_keepalives_count = 10;"#,
)
.await
{
tracing::error!("Error setting postgres settings: {}", e);
}
Ok(())
})
}
})
.connect_with(
database_url
.connect_options()
.await?
.statement_cache_capacity(400),
)
.await
.map_err(|err| Error::ConnectingToDatabase(err.to_string()))
}
type Tag = String;
pub use db::DB;
@@ -705,9 +852,11 @@ impl ScriptHashInfo<ScriptRunnableSettingsHandle> {
self,
db: &DB,
) -> error::Result<ScriptHashInfo<ScriptRunnableSettingsInline>> {
let rs =
runnable_settings::from_handle(self.runnable_settings.runnable_settings_handle, db)
.await?;
let rs = runnable_settings::from_handle(
self.runnable_settings.runnable_settings_handle,
db,
)
.await?;
let (debouncing_settings, concurrency_settings) =
runnable_settings::prefetch_cached(&rs, db).await?;
@@ -1027,25 +1176,25 @@ pub fn get_flow_version_info_from_version<
_ => {
tracing::debug!("Fetching flow version info for {version} ({path})");
let mut conn = db.acquire().await?;
let flow_info =
let flow_info =
sqlx::query_as!(
FlowVersionInfo,
r#"
SELECT
flow_version.id AS version,
flow_version.value->>'early_return' as early_return,
flow_version.value->>'preprocessor_module' IS NOT NULL as has_preprocessor,
(flow_version.value->>'chat_input_enabled')::boolean as chat_input_enabled,
flow.tag,
flow.dedicated_worker,
flow.on_behalf_of_email,
flow_version.value->>'early_return' as early_return,
flow_version.value->>'preprocessor_module' IS NOT NULL as has_preprocessor,
(flow_version.value->>'chat_input_enabled')::boolean as chat_input_enabled,
flow.tag,
flow.dedicated_worker,
flow.on_behalf_of_email,
flow.edited_by
FROM
FROM
flow_version
INNER JOIN flow
ON flow.path = flow_version.path AND
flow.workspace_id = flow_version.workspace_id
WHERE
WHERE
flow_version.workspace_id = $1 AND
flow_version.path = $2 AND
flow_version.id = $3

View File

@@ -145,13 +145,6 @@ lazy_static::lazy_static! {
tracing::info!("Mode not specified, defaulting to standalone");
Mode::Standalone
});
#[cfg(feature = "benchmark")]
let mode = {
if mode != Mode::Worker {
println!("Benchmark mode: forcing MODE=worker");
}
Mode::Worker
};
ModeAndAddons {
indexer: search_addon,
mode,

Some files were not shown because too many files have changed in this diff Show More