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
158 changed files with 1329 additions and 5608 deletions

View File

@@ -1,23 +1,5 @@
# Changelog
## [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

@@ -46,11 +46,11 @@
]
},
"nullable": [
false,
false,
false,
false,
false,
true,
true,
true,
true,
true,
true,
true
]

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,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

@@ -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

@@ -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

@@ -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"
]
}
}

119
backend/Cargo.lock generated
View File

@@ -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",
@@ -15725,7 +15725,7 @@ dependencies = [
[[package]]
name = "windmill"
version = "1.630.0"
version = "1.629.1"
dependencies = [
"anyhow",
"async-nats",
@@ -15783,7 +15783,7 @@ dependencies = [
[[package]]
name = "windmill-api"
version = "1.630.0"
version = "1.629.1"
dependencies = [
"anyhow",
"argon2",
@@ -15907,7 +15907,7 @@ dependencies = [
[[package]]
name = "windmill-api-auth"
version = "1.630.0"
version = "1.629.1"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -15933,7 +15933,7 @@ dependencies = [
[[package]]
name = "windmill-api-client"
version = "1.630.0"
version = "1.629.1"
dependencies = [
"reqwest 0.12.28",
"serde",
@@ -15943,7 +15943,7 @@ dependencies = [
[[package]]
name = "windmill-api-embeddings"
version = "1.630.0"
version = "1.629.1"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -15966,7 +15966,7 @@ dependencies = [
[[package]]
name = "windmill-api-groups"
version = "1.630.0"
version = "1.629.1"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -15986,7 +15986,7 @@ dependencies = [
[[package]]
name = "windmill-api-jobs"
version = "1.630.0"
version = "1.629.1"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -16011,7 +16011,7 @@ dependencies = [
[[package]]
name = "windmill-api-scripts"
version = "1.630.0"
version = "1.629.1"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16040,7 +16040,7 @@ dependencies = [
[[package]]
name = "windmill-api-sse"
version = "1.630.0"
version = "1.629.1"
dependencies = [
"lazy_static",
"serde",
@@ -16052,7 +16052,7 @@ dependencies = [
[[package]]
name = "windmill-api-users"
version = "1.630.0"
version = "1.629.1"
dependencies = [
"argon2",
"axum 0.7.9",
@@ -16075,7 +16075,7 @@ dependencies = [
[[package]]
name = "windmill-api-workspaces"
version = "1.630.0"
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.0"
version = "1.629.1"
dependencies = [
"chrono",
"lazy_static",
@@ -16118,7 +16117,7 @@ dependencies = [
[[package]]
name = "windmill-autoscaling"
version = "1.630.0"
version = "1.629.1"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -16137,7 +16136,7 @@ dependencies = [
[[package]]
name = "windmill-common"
version = "1.630.0"
version = "1.629.1"
dependencies = [
"aes-gcm",
"anyhow",
@@ -16236,7 +16235,7 @@ dependencies = [
[[package]]
name = "windmill-dep-map"
version = "1.630.0"
version = "1.629.1"
dependencies = [
"chrono",
"itertools 0.14.0",
@@ -16255,7 +16254,7 @@ dependencies = [
[[package]]
name = "windmill-git-sync"
version = "1.630.0"
version = "1.629.1"
dependencies = [
"regex",
"serde",
@@ -16270,7 +16269,7 @@ dependencies = [
[[package]]
name = "windmill-indexer"
version = "1.630.0"
version = "1.629.1"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -16294,7 +16293,7 @@ dependencies = [
[[package]]
name = "windmill-jseval"
version = "1.630.0"
version = "1.629.1"
dependencies = [
"anyhow",
"futures",
@@ -16311,7 +16310,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.630.0"
version = "1.629.1"
dependencies = [
"itertools 0.14.0",
"lazy_static",
@@ -16327,7 +16326,7 @@ dependencies = [
[[package]]
name = "windmill-mcp"
version = "1.630.0"
version = "1.629.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16348,7 +16347,7 @@ dependencies = [
[[package]]
name = "windmill-native-triggers"
version = "1.630.0"
version = "1.629.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16379,7 +16378,7 @@ dependencies = [
[[package]]
name = "windmill-oauth"
version = "1.630.0"
version = "1.629.1"
dependencies = [
"anyhow",
"async-oauth2",
@@ -16403,7 +16402,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.630.0"
version = "1.629.1"
dependencies = [
"convert_case 0.6.0",
"serde",
@@ -16412,7 +16411,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.630.0"
version = "1.629.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -16424,7 +16423,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.630.0"
version = "1.629.1"
dependencies = [
"anyhow",
"serde_json",
@@ -16436,7 +16435,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.630.0"
version = "1.629.1"
dependencies = [
"anyhow",
"gosyn",
@@ -16448,7 +16447,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.630.0"
version = "1.629.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -16460,7 +16459,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.630.0"
version = "1.629.1"
dependencies = [
"anyhow",
"serde_json",
@@ -16472,7 +16471,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.630.0"
version = "1.629.1"
dependencies = [
"anyhow",
"nu-parser",
@@ -16483,7 +16482,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.630.0"
version = "1.629.1"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -16494,7 +16493,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.630.0"
version = "1.629.1"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -16507,7 +16506,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.630.0"
version = "1.629.1"
dependencies = [
"anyhow",
"async-recursion",
@@ -16531,7 +16530,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.630.0"
version = "1.629.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -16545,7 +16544,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.630.0"
version = "1.629.1"
dependencies = [
"anyhow",
"convert_case 0.6.0",
@@ -16562,7 +16561,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.630.0"
version = "1.629.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -16576,7 +16575,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.630.0"
version = "1.629.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -16595,7 +16594,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.630.0"
version = "1.629.1"
dependencies = [
"anyhow",
"serde",
@@ -16606,7 +16605,7 @@ dependencies = [
[[package]]
name = "windmill-queue"
version = "1.630.0"
version = "1.629.1"
dependencies = [
"anyhow",
"async-recursion",
@@ -16643,7 +16642,7 @@ dependencies = [
[[package]]
name = "windmill-runtime-nativets"
version = "1.630.0"
version = "1.629.1"
dependencies = [
"anyhow",
"const_format",
@@ -16681,7 +16680,7 @@ dependencies = [
[[package]]
name = "windmill-sql-datatype-parser-wasm"
version = "1.630.0"
version = "1.629.1"
dependencies = [
"wasm-bindgen",
"wasm-bindgen-test",
@@ -16691,7 +16690,7 @@ dependencies = [
[[package]]
name = "windmill-store"
version = "1.630.0"
version = "1.629.1"
dependencies = [
"anyhow",
"async-recursion",
@@ -16720,7 +16719,7 @@ dependencies = [
[[package]]
name = "windmill-trigger"
version = "1.630.0"
version = "1.629.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16753,7 +16752,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-email"
version = "1.630.0"
version = "1.629.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16773,7 +16772,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-gcp"
version = "1.630.0"
version = "1.629.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16807,7 +16806,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-http"
version = "1.630.0"
version = "1.629.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16841,7 +16840,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-kafka"
version = "1.630.0"
version = "1.629.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16864,7 +16863,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-mqtt"
version = "1.630.0"
version = "1.629.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16888,7 +16887,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-nats"
version = "1.630.0"
version = "1.629.1"
dependencies = [
"anyhow",
"async-nats",
@@ -16912,7 +16911,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-postgres"
version = "1.630.0"
version = "1.629.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16947,7 +16946,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-sqs"
version = "1.630.0"
version = "1.629.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16975,7 +16974,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-websocket"
version = "1.630.0"
version = "1.629.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16998,7 +16997,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.630.0"
version = "1.629.1"
dependencies = [
"anyhow",
"chrono",
@@ -17015,7 +17014,7 @@ dependencies = [
[[package]]
name = "windmill-worker"
version = "1.630.0"
version = "1.629.1"
dependencies = [
"anyhow",
"async-once-cell",

View File

@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.630.0"
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.0"
version = "1.629.1"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"

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

@@ -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

@@ -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.0
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()
@@ -3474,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 => (
@@ -4238,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(
@@ -4253,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,
@@ -4266,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,
@@ -4281,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,
@@ -4295,8 +4289,8 @@ pub async fn stream_job(
args,
None,
)
.await?;
(uuid, early_return)
.await?
.0
}
};
@@ -4327,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));
@@ -4650,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,
@@ -5839,7 +5825,6 @@ async fn get_job_update(
no_logs,
is_flow,
None,
None,
)
.await?,
))
@@ -5880,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| {
@@ -5918,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;
@@ -5946,7 +5929,6 @@ pub fn start_job_update_sse_stream(
no_logs,
is_flow,
flow_stream_job_id,
early_return.as_deref(),
)
.await
{
@@ -6064,7 +6046,6 @@ pub fn start_job_update_sse_stream(
no_logs,
is_flow,
flow_stream_job_id,
early_return.as_deref(),
)
.await
{
@@ -6195,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(
@@ -6359,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

@@ -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,

View File

@@ -13,103 +13,6 @@ use crate::{
PgDatabase, DB,
};
macro_rules! sqlx_bitflags {
(
$flags:ty => $repr:ty
) => {
// ---- Type ----
impl sqlx::Type<sqlx::Postgres> for $flags {
fn type_info() -> sqlx::postgres::PgTypeInfo {
<$repr as sqlx::Type<sqlx::Postgres>>::type_info()
}
}
// ---- Encode ----
impl<'q> sqlx::Encode<'q, sqlx::Postgres> for $flags {
fn encode_by_ref(
&self,
buf: &mut sqlx::postgres::PgArgumentBuffer,
) -> std::result::Result<sqlx::encode::IsNull, Box<dyn std::error::Error + Send + Sync>>
{
let bits: $repr = self.bits();
<$repr as sqlx::Encode<sqlx::Postgres>>::encode(bits, buf)
}
}
// ---- Decode ----
impl<'r> sqlx::Decode<'r, sqlx::Postgres> for $flags {
fn decode(
value: sqlx::postgres::PgValueRef<'r>,
) -> std::result::Result<Self, Box<dyn std::error::Error + Send + Sync>> {
let bits = <$repr as sqlx::Decode<sqlx::Postgres>>::decode(value)?;
<$flags>::from_bits(bits)
.ok_or_else(|| "invalid bitflags value from database".into())
}
}
};
}
// Protection Rules - for fine-grained workspace access control
/// API representation of a protection rule
#[derive(Debug, Clone)]
pub struct ProtectionRuleset {
pub workspace_id: String,
pub name: String,
pub rules: ProtectionRules,
pub bypass_groups: Vec<String>,
pub bypass_users: Vec<String>,
}
bitflags::bitflags! {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
// #[sqlx(transparent)]
pub struct ProtectionRules: i32 {
const DISABLE_DIRECT_DEPLOYMENT = 1 << 0;
const DISABLE_WORKSPACE_FORKING = 1 << 1;
}
}
sqlx_bitflags!(ProtectionRules => i32);
#[derive(Serialize, Deserialize, strum_macros::EnumIter)]
pub enum ProtectionRuleKind {
DisableDirectDeployment,
DisableWorkspaceForking,
}
impl ProtectionRuleKind {
pub const fn flag(&self) -> ProtectionRules {
match self {
ProtectionRuleKind::DisableDirectDeployment => {
ProtectionRules::DISABLE_DIRECT_DEPLOYMENT
}
ProtectionRuleKind::DisableWorkspaceForking => {
ProtectionRules::DISABLE_WORKSPACE_FORKING
}
}
}
pub const fn msg(&self) -> &str {
match self {
ProtectionRuleKind::DisableDirectDeployment => {
"Cannot directly deploy in this workspace. Fork or Pull request required."
}
ProtectionRuleKind::DisableWorkspaceForking => "Forking this workspace is forbidden",
}
}
}
impl From<&Vec<ProtectionRuleKind>> for ProtectionRules {
fn from(value: &Vec<ProtectionRuleKind>) -> Self {
let mut r = ProtectionRules::empty();
for rule in value {
r = r | rule.flag();
}
r
}
}
#[derive(Serialize, Deserialize, Debug, Default)]
pub struct WorkspaceGitSyncSettings {
#[serde(skip_serializing_if = "Option::is_none")]
@@ -273,111 +176,6 @@ pub async fn get_team_plan_status(_db: &crate::DB, _w_id: &str) -> Result<TeamPl
Ok(team_plan_info)
}
// Protection Rules Cache
lazy_static::lazy_static! {
pub static ref PROTECTION_RULES_CACHE: Cache<String, (std::sync::Arc<Vec<ProtectionRuleset>>, i64)> = Cache::new(100);
}
/// Get all protection rules for a workspace with caching (60s TTL)
pub async fn get_protection_rules(
workspace_id: &str,
db: &DB,
) -> Result<std::sync::Arc<Vec<ProtectionRuleset>>> {
let now = chrono::Utc::now().timestamp();
// Check cache and expiry
if let Some((cached_rules, expiry)) = PROTECTION_RULES_CACHE.get(workspace_id) {
if expiry > now {
return Ok(cached_rules);
}
}
// Query database
let rulesets = sqlx::query_as!(
ProtectionRuleset,
r#"
SELECT
workspace_id,
name,
rules as "rules: ProtectionRules",
bypass_groups,
bypass_users
FROM workspace_protection_rule
WHERE workspace_id = $1
ORDER BY name
"#,
workspace_id
)
.fetch_all(db)
.await
.map_err(|e| Error::internal_err(format!("Failed to fetch protection rules: {}", e)))?;
// Cache with 60s TTL
let arc_rules = std::sync::Arc::new(rulesets);
let expiry = now + 60;
PROTECTION_RULES_CACHE.insert(workspace_id.to_string(), (arc_rules.clone(), expiry));
Ok(arc_rules)
}
/// Invalidate the protection rules cache for a workspace
pub fn invalidate_protection_rules_cache(workspace_id: &str) {
PROTECTION_RULES_CACHE.remove(workspace_id);
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RuleCheckResult {
Allowed,
Blocked(String),
}
/// Check if a user can bypass a protection rule
///
/// Returns `Allowed` if:
/// - User is in the rule's bypass users list (u/<username>)
/// - User's group is in the rule's bypass groups list (g/<groupname>)
///
/// Returns `Blocked` if:
/// - User is not in bypass lists
///
/// Returns `Err` if the rule is not found
pub async fn check_user_against_rule(
workspace_id: &str,
rule: &ProtectionRuleKind,
username: &str,
user_groups: &[String],
is_admin: bool,
db: &DB,
) -> Result<RuleCheckResult> {
if is_admin {
return Ok(RuleCheckResult::Allowed);
}
let rulesets = get_protection_rules(workspace_id, db).await?;
for ruleset in rulesets.iter() {
if ruleset.rules.contains(rule.flag()) {
if ruleset.bypass_users.iter().any(|u| u == username)
|| ruleset
.bypass_groups
.iter()
.any(|g| user_groups.contains(g))
{
continue;
}
return Ok(RuleCheckResult::Blocked(format!(
"Ruleset {} of {} blocked this action: {}",
ruleset.name,
workspace_id,
rule.msg()
)));
}
}
Ok(RuleCheckResult::Allowed)
}
#[derive(Deserialize, Serialize, Debug)]
pub struct DataTable {
pub database: DataTableDatabase,

View File

@@ -19,8 +19,6 @@
use std::collections::HashMap;
use std::sync::Arc;
pub const EVAL_TIMEOUT_MS: u64 = 20000;
use lazy_static::lazy_static;
use regex::Regex;
#[cfg(feature = "quickjs")]
@@ -276,7 +274,7 @@ pub async fn eval_timeout_quickjs(
// Run the QuickJS evaluation with a timeout
tokio::time::timeout(
std::time::Duration::from_millis(EVAL_TIMEOUT_MS),
std::time::Duration::from_millis(10000),
tokio::task::spawn_blocking(move || {
// Create a new tokio runtime for async operations within the blocking context
let rt = tokio::runtime::Builder::new_current_thread()
@@ -300,7 +298,7 @@ pub async fn eval_timeout_quickjs(
)
.await
.map_err(|_| {
anyhow::anyhow!("The expression evaluation `{expr}` took too long to execute (>{EVAL_TIMEOUT_MS}ms)")
anyhow::anyhow!("The expression evaluation `{expr}` took too long to execute (>10000ms)")
})??
}
@@ -788,7 +786,7 @@ pub async fn eval_simple_js(
globals: HashMap<String, serde_json::Value>,
) -> anyhow::Result<Box<RawValue>> {
tokio::time::timeout(
std::time::Duration::from_millis(EVAL_TIMEOUT_MS),
std::time::Duration::from_millis(10000),
tokio::task::spawn_blocking(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
@@ -821,7 +819,7 @@ pub async fn eval_simple_js(
)
.await
.map_err(|_| {
anyhow::anyhow!("The expression evaluation took too long to execute (>{EVAL_TIMEOUT_MS}ms)")
anyhow::anyhow!("The expression evaluation took too long to execute (>10000ms)")
})??
}

View File

@@ -21,9 +21,6 @@ pub struct EndpointTool {
pub path_params_schema: Option<serde_json::Value>,
pub query_params_schema: Option<serde_json::Value>,
pub body_schema: Option<serde_json::Value>,
pub path_field_renames: Option<serde_json::Value>,
pub query_field_renames: Option<serde_json::Value>,
pub body_field_renames: Option<serde_json::Value>,
}
/// Convert a single endpoint tool to MCP tool
@@ -103,9 +100,7 @@ fn merge_schema_into(
if let Some(required) = schema.get("required").and_then(|r| r.as_array()) {
for req in required.iter().filter_map(|r| r.as_str()) {
if !combined_required.contains(&req.to_string()) {
combined_required.push(req.to_string());
}
combined_required.push(req.to_string());
}
}
}

View File

@@ -13,7 +13,6 @@ use windmill_api_auth::{
Tokened,
};
use windmill_common::db::DB;
use windmill_common::workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult};
use crate::secret_backend_ext::rename_vault_secret;
use crate::var_resource_cache::{cache_resource, get_cached_resource};
@@ -684,18 +683,6 @@ async fn create_resource(
Json(resource): Json<CreateResource>,
) -> Result<(StatusCode, String)> {
check_scopes(&authed, || format!("resources:write:{}", resource.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));
}
if *CLOUD_HOSTED {
let nb_resources = sqlx::query_scalar!(
"SELECT COUNT(*) FROM resource WHERE workspace_id = $1",
@@ -806,18 +793,6 @@ async fn delete_resource(
let path = path.to_path();
check_scopes(&authed, || format!("resources: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?;
let deleted_path = sqlx::query_scalar!(
@@ -879,19 +854,6 @@ async fn delete_resources_bulk(
check_scopes(&authed, || format!("resources: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?;
let deleted_paths = sqlx::query_scalar!(
@@ -954,18 +916,6 @@ async fn update_resource(
let path = path.to_path();
check_scopes(&authed, || format!("resources: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 sqlb = SqlBuilder::update_table("resource");
sqlb.and_where_eq("path", "?".bind(&path));
@@ -1092,18 +1042,6 @@ async fn update_resource_value(
) -> Result<String> {
let path = path.to_path();
check_scopes(&authed, || format!("resources: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!(
@@ -1255,19 +1193,6 @@ async fn create_resource_type(
Path(w_id): Path<String>,
Json(resource_type): Json<CreateResourceType>,
) -> 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 mut tx = user_db.begin(&authed).await?;
check_rt_path_conflict(&mut tx, &w_id, &resource_type.name).await?;
@@ -1354,18 +1279,6 @@ async fn delete_resource_type(
Path((w_id, name)): Path<(String, String)>,
) -> Result<String> {
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?;
@@ -1420,18 +1333,6 @@ async fn update_resource_type(
Json(ns): Json<EditResourceType>,
) -> 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("resource_type");
sqlb.and_where_eq("name", "?".bind(&name));

View File

@@ -8,7 +8,6 @@
use windmill_api_auth::{check_scopes, maybe_refresh_folders, require_owner_of_path, ApiAuthed};
use windmill_common::db::DB;
use windmill_common::workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult};
use crate::secret_backend_ext::{
delete_secret_from_backend, get_secret_value, is_vault_stored_value, rename_vault_secret,
@@ -340,19 +339,6 @@ async fn create_variable(
Json(variable): Json<CreateVariable>,
) -> Result<(StatusCode, String)> {
check_scopes(&authed, || format!("variables:write:{}", variable.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));
}
if *CLOUD_HOSTED {
let nb_variables = sqlx::query_scalar!(
"SELECT COUNT(*) FROM variable WHERE workspace_id = $1",
@@ -452,18 +438,6 @@ async fn delete_variable(
let path = path.to_path();
check_scopes(&authed, || format!("variables: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));
}
// Check if variable is a secret before deleting (for Vault cleanup)
let is_secret = sqlx::query_scalar!(
@@ -541,19 +515,6 @@ async fn delete_variables_bulk(
check_scopes(&authed, || format!("variables: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));
}
// Query which paths are secrets before deletion (for Vault cleanup)
let secret_paths: Vec<String> = sqlx::query_scalar!(
"SELECT path FROM variable WHERE path = ANY($1) AND workspace_id = $2 AND is_secret = true",
@@ -652,19 +613,6 @@ async fn update_variable(
) -> 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 path = path.to_path();
check_scopes(&authed, || format!("variables:write:{}", path))?;
let authed = maybe_refresh_folders(&path, &w_id, authed, &db).await;

View File

@@ -16,17 +16,6 @@ const ANTHROPIC_VERSION_STANDARD: &str = "2023-06-01";
/// Anthropic API version for Google Vertex AI
const ANTHROPIC_VERSION_VERTEX: &str = "vertex-2023-10-16";
#[derive(Serialize, Debug, Clone)]
pub struct CacheControl {
pub r#type: String,
}
impl CacheControl {
pub fn ephemeral() -> Self {
Self { r#type: "ephemeral".to_string() }
}
}
/// Custom tool for Anthropic native API (flat structure with type: "custom")
#[derive(Serialize, Debug)]
pub struct AnthropicCustomTool {
@@ -35,8 +24,6 @@ pub struct AnthropicCustomTool {
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub input_schema: Box<RawValue>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cache_control: Option<CacheControl>,
}
impl From<&ToolDef> for AnthropicCustomTool {
@@ -46,7 +33,6 @@ impl From<&ToolDef> for AnthropicCustomTool {
name: tool.function.name.clone(),
description: tool.function.description.clone(),
input_schema: tool.function.parameters.clone(),
cache_control: None,
}
}
}
@@ -84,22 +70,13 @@ impl AnthropicToolChoice {
#[serde(tag = "type")]
pub enum AnthropicRequestContent {
#[serde(rename = "text")]
Text {
text: String,
#[serde(skip_serializing_if = "Option::is_none")]
cache_control: Option<CacheControl>,
},
Text { text: String },
#[serde(rename = "image")]
Image { source: AnthropicImageSource },
#[serde(rename = "tool_use")]
ToolUse { id: String, name: String, input: Box<RawValue> },
#[serde(rename = "tool_result")]
ToolResult {
tool_use_id: String,
content: String,
#[serde(skip_serializing_if = "Option::is_none")]
cache_control: Option<CacheControl>,
},
ToolResult { tool_use_id: String, content: String },
}
/// Image source for Anthropic API
@@ -115,8 +92,6 @@ pub struct AnthropicImageSource {
pub struct AnthropicSystemContent {
pub r#type: String,
pub text: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub cache_control: Option<CacheControl>,
}
/// Message format for Anthropic API requests
@@ -188,7 +163,7 @@ fn convert_messages_to_anthropic(messages: &[OpenAIMessage]) -> Vec<AnthropicMes
if let Some(ref c) = msg.content {
let text = extract_text_content(c);
if !text.is_empty() {
content.push(AnthropicRequestContent::Text { text, cache_control: None });
content.push(AnthropicRequestContent::Text { text });
}
}
@@ -227,7 +202,6 @@ fn convert_messages_to_anthropic(messages: &[OpenAIMessage]) -> Vec<AnthropicMes
content: vec![AnthropicRequestContent::ToolResult {
tool_use_id: tool_call_id.clone(),
content: content_text,
cache_control: None,
}],
});
}
@@ -252,7 +226,7 @@ fn convert_content_to_anthropic(content: &Option<OpenAIContent>) -> Vec<Anthropi
if text.is_empty() {
Vec::new()
} else {
vec![AnthropicRequestContent::Text { text: text.clone(), cache_control: None }]
vec![AnthropicRequestContent::Text { text: text.clone() }]
}
}
OpenAIContent::Parts(parts) => {
@@ -261,10 +235,7 @@ fn convert_content_to_anthropic(content: &Option<OpenAIContent>) -> Vec<Anthropi
match part {
ContentPart::Text { text } => {
if !text.is_empty() {
result.push(AnthropicRequestContent::Text {
text: text.clone(),
cache_control: None,
});
result.push(AnthropicRequestContent::Text { text: text.clone() });
}
}
ContentPart::ImageUrl { image_url } => {
@@ -376,7 +347,7 @@ impl AnthropicQueryBuilder {
prepare_messages_for_api(args.messages, client, workspace_id).await?;
// Convert to Anthropic native message format
let mut anthropic_messages = convert_messages_to_anthropic(&prepared_messages);
let anthropic_messages = convert_messages_to_anthropic(&prepared_messages);
// Build tools array using typed structs
let mut tools: Vec<AnthropicTool> = Vec::new();
@@ -401,11 +372,6 @@ impl AnthropicQueryBuilder {
Some(s) if !s.is_empty() => Some(vec![AnthropicSystemContent {
r#type: "text".to_string(),
text: s.to_string(),
cache_control: if self.is_vertex() {
None
} else {
Some(CacheControl::ephemeral())
},
}]),
_ => None,
};
@@ -427,35 +393,9 @@ impl AnthropicQueryBuilder {
None
};
let mut tools_option = if tools.is_empty() { None } else { Some(tools) };
let tools_option = if tools.is_empty() { None } else { Some(tools) };
let max_tokens = Some(args.max_tokens.unwrap_or(64000));
// Apply cache_control on the last custom tool
if !self.is_vertex() {
if let Some(ref mut tools_vec) = tools_option {
if let Some(AnthropicTool::Custom(ref mut custom)) = tools_vec.last_mut() {
custom.cache_control = Some(CacheControl::ephemeral());
}
}
}
// Apply cache_control on the last content block of the last message
if !self.is_vertex() {
if let Some(last_msg) = anthropic_messages.last_mut() {
if let Some(last_block) = last_msg.content.last_mut() {
match last_block {
AnthropicRequestContent::Text { cache_control, .. } => {
*cache_control = Some(CacheControl::ephemeral());
}
AnthropicRequestContent::ToolResult { cache_control, .. } => {
*cache_control = Some(CacheControl::ephemeral());
}
_ => {}
}
}
}
}
// Build request based on platform
if self.is_vertex() {
// For Vertex AI: no model field, anthropic_version in body

View File

@@ -78,33 +78,16 @@ pub async fn eval_timeout(
}
}
let mut attempts = 0;
loop {
let result = windmill_jseval::eval_timeout_quickjs(
expr.clone(),
transform_context.clone(),
flow_input.clone(),
flow_env,
authed_client,
by_id,
ctx.clone(),
)
.await;
match result {
Ok(v) => return Ok(v),
Err(e) if attempts < 2 && e.to_string().contains("took too long") => {
attempts += 1;
tracing::warn!(
"js eval timed out (attempt {}/3), retrying in 5s: {}",
attempts,
expr
);
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
}
Err(e) => return Err(e),
}
}
windmill_jseval::eval_timeout_quickjs(
expr,
transform_context,
flow_input,
flow_env,
authed_client,
by_id,
ctx,
)
.await
}
#[cfg(feature = "deno_core")]

View File

@@ -68,7 +68,6 @@ async fn process_jc(
stats_map: &JobStatsMap,
killpill_rx: &tokio::sync::broadcast::Receiver<()>,
#[cfg(feature = "benchmark")] bench: &mut BenchmarkIter,
#[cfg(feature = "benchmark")] bench_infos: &mut BenchmarkInfo,
) {
let success: bool = jc.success;
@@ -167,13 +166,6 @@ async fn process_jc(
if let Some(root_job) = root_job {
add_root_flow_job_to_otlp(&root_job, success);
#[cfg(feature = "benchmark")]
if bench_infos.count_top_level(root_job.id) {
bench_infos
.shared_iters
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
}
// Accumulate job stats if duration is available
@@ -217,7 +209,7 @@ pub fn start_background_processor(
let JobCompletedReceiver { bounded_rx, mut killpill_rx, unbounded_rx } = job_completed_rx;
#[cfg(feature = "benchmark")]
let mut infos = BenchmarkInfo::new(windmill_common::bench::shared_bench_iters());
let mut infos = BenchmarkInfo::new();
// Start periodic stats flush task
let db_clone = db.clone();
@@ -286,10 +278,6 @@ pub fn start_background_processor(
jc.job.kind,
JobKind::Dependencies | JobKind::FlowDependencies
);
#[cfg(feature = "benchmark")]
let bench_job_id = jc.job.id;
#[cfg(feature = "benchmark")]
let is_top_level_job = jc.job.parent_job.is_none();
process_jc(
jc,
@@ -303,8 +291,6 @@ pub fn start_background_processor(
&killpill_rx,
#[cfg(feature = "benchmark")]
&mut bench,
#[cfg(feature = "benchmark")]
&mut infos,
)
.warn_after_seconds(10)
.await;
@@ -329,9 +315,7 @@ pub fn start_background_processor(
#[cfg(feature = "benchmark")]
{
if infos.add_iter(bench, bench_job_id, is_top_level_job) {
infos.shared_iters.fetch_add(1, Ordering::Relaxed);
}
infos.add_iter(bench, true);
}
last_processing_duration
.store(time.elapsed().as_secs() as u16, Ordering::SeqCst);
@@ -381,12 +365,6 @@ pub fn start_background_processor(
{
tracing::error!("Error updating flow status after job completion for {flow} on {worker_name}: {e:#}");
}
#[cfg(feature = "benchmark")]
{
if infos.add_iter(bench, flow, true) {
infos.shared_iters.fetch_add(1, Ordering::Relaxed);
}
}
last_processing_duration
.store(time.elapsed().as_secs() as u16, Ordering::SeqCst);
}

View File

@@ -188,7 +188,7 @@ use crate::mssql_executor::do_mssql;
use crate::bigquery_executor::do_bigquery;
#[cfg(feature = "benchmark")]
use windmill_common::bench::{benchmark_init, benchmark_verify, BenchmarkInfo, BenchmarkIter};
use windmill_common::bench::{benchmark_init, BenchmarkInfo, BenchmarkIter};
use windmill_common::add_time;
@@ -1109,60 +1109,51 @@ fn start_interactive_worker_shell(
if let Ok(_) = killpill_rx.try_recv() {
tracing::info!("Received killpill, exiting worker shell");
break;
}
} else {
let pulled_job = match &conn {
Connection::Sql(db) => {
let common_worker_prefix = retrieve_common_worker_prefix(&worker_name);
let query = ("".to_string(), make_pull_query(&[common_worker_prefix]));
#[cfg(feature = "benchmark")]
let mut bench = windmill_common::bench::BenchmarkIter::new();
let pulled_job = tokio::select! {
_ = killpill_rx.recv() => {
tracing::info!("Received killpill during pull, exiting worker shell");
break;
}
result = async {
match &conn {
Connection::Sql(db) => {
let common_worker_prefix = retrieve_common_worker_prefix(&worker_name);
let query = ("".to_string(), make_pull_query(&[common_worker_prefix]));
let job = pull(
&db,
false,
&worker_name,
Some(&query),
#[cfg(feature = "benchmark")]
let mut bench = windmill_common::bench::BenchmarkIter::new();
&mut bench,
)
.await;
let job = pull(
&db,
false,
&worker_name,
Some(&query),
#[cfg(feature = "benchmark")]
&mut bench,
)
.await;
use PulledJobResultToJobErr::*;
match job {
Ok(j) => match j.to_pulled_job() {
Ok(j) => Ok(j
.clone()
.map(|job| NextJob::Sql { flow_runners: None, job })),
Err(MissingConcurrencyKey(jc))
| Err(ErrorWhilePreprocessing(jc)) => {
if let Err(err) = job_completed_tx.send_job(jc, true).await {
tracing::error!(
"An error occurred while sending job completed: {:#?}",
err
)
}
Ok(None)
use PulledJobResultToJobErr::*;
match job {
Ok(j) => match j.to_pulled_job() {
Ok(j) => Ok(j
.clone()
.map(|job| NextJob::Sql { flow_runners: None, job })),
Err(MissingConcurrencyKey(jc))
| Err(ErrorWhilePreprocessing(jc)) => {
if let Err(err) = job_completed_tx.send_job(jc, true).await {
tracing::error!(
"An error occurred while sending job completed: {:#?}",
err
)
}
},
Err(err) => Err(err),
}
}
Connection::Http(client) => {
crate::agent_workers::pull_job(&client, None, Some(true))
.await
.map_err(|e| error::Error::InternalErr(e.to_string()))
.map(|x| x.map(|y| NextJob::Http(y)))
Ok(None)
}
},
Err(err) => Err(err),
}
}
} => result,
};
Connection::Http(client) => {
crate::agent_workers::pull_job(&client, None, Some(true))
.await
.map_err(|e| error::Error::InternalErr(e.to_string()))
.map(|x| x.map(|y| NextJob::Http(y)))
}
};
match pulled_job {
Ok(Some(job)) => {
@@ -1242,6 +1233,7 @@ fn start_interactive_worker_shell(
tokio::time::sleep(Duration::from_millis(*SLEEP_QUEUE * 20)).await;
}
};
}
}
})
}
@@ -1579,7 +1571,6 @@ pub async fn run_worker(
// This is used to wake up the background processor when main loop is done and just waiting for new same workers jobs, and that bg processor is also not processing any jobs, bg processing can exit if no more same worker jobs
let wake_up_notify = Arc::new(tokio::sync::Notify::new());
let stats_map = JobStatsMap::default();
let send_result = match (conn, job_completed_rx) {
(Connection::Sql(db), Some(job_completed_receiver)) => Some(start_background_processor(
job_completed_receiver,
@@ -1624,10 +1615,7 @@ pub async fn run_worker(
let mut started = false;
#[cfg(feature = "benchmark")]
let mut infos = BenchmarkInfo::new(windmill_common::bench::shared_bench_iters());
#[cfg(feature = "benchmark")]
let mut bench_empty_queue_count: u64 = 0;
let mut infos = BenchmarkInfo::new();
#[cfg(feature = "benchmark")]
if let Some(db) = conn.as_sql() {
@@ -1819,60 +1807,15 @@ pub async fn run_worker(
// }
#[cfg(feature = "benchmark")]
{
let total_iters = infos.shared_iters.load(std::sync::atomic::Ordering::Relaxed);
if benchmark_jobs > 0 && total_iters >= benchmark_jobs as u64 {
tracing::info!("benchmark finished, exiting (total iters: {}, worker iters: {})", total_iters, infos.iters);
job_completed_tx
.kill()
.await
.expect("send kill to job completed tx");
killpill_tx.send();
break;
} else if benchmark_jobs > 0 && bench_empty_queue_count > 2000 {
tracing::warn!(
"benchmark stalled: no jobs in queue for 2000 polls, exiting (total iters: {}, worker iters: {}/{})",
total_iters,
infos.iters,
benchmark_jobs
);
job_completed_tx
.kill()
.await
.expect("send kill to job completed tx");
killpill_tx.send();
break;
} else if bench_empty_queue_count % 100 == 0 {
if let Some(db) = conn.as_sql() {
let remaining = sqlx::query_as::<_, (uuid::Uuid, String, bool, Option<String>, Option<uuid::Uuid>)>(
"SELECT q.id, q.tag, q.running, j.kind::text, j.parent_job
FROM v2_job_queue q JOIN v2_job j ON q.id = j.id
WHERE q.workspace_id = 'admins' LIMIT 10"
)
.fetch_all(db)
.await;
match remaining {
Ok(rows) => {
let total_remaining = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM v2_job_queue WHERE workspace_id = 'admins'"
).fetch_one(db).await.unwrap_or(0);
for (id, tag, running, kind, parent) in &rows {
tracing::info!(
" pending job: id={id}, tag={tag}, running={running}, kind={}, parent={:?}",
kind.as_deref().unwrap_or("?"), parent
);
}
tracing::info!(
"benchmark not finished (total: {}, worker: {}, queue: {})",
total_iters, infos.iters, total_remaining
);
}
Err(e) => {
tracing::info!("benchmark not finished (total: {}, worker: {}), queue query err: {e}", total_iters, infos.iters);
}
}
}
}
if benchmark_jobs > 0 && infos.iters == benchmark_jobs as u64 {
tracing::info!("benchmark finished, exiting");
job_completed_tx
.kill()
.await
.expect("send kill to job completed tx");
break;
} else {
tracing::info!("benchmark not finished, still pulling jobs {}", infos.iters);
}
let next_job = {
@@ -2086,15 +2029,6 @@ pub async fn run_worker(
match next_job {
Ok(Some(job)) => {
#[cfg(feature = "benchmark")]
{
bench_empty_queue_count = 0;
}
#[cfg(feature = "benchmark")]
let is_top_level_job = job.parent_job.is_none() && !job.kind.is_flow();
#[cfg(feature = "benchmark")]
let bench_job_id = job.id;
#[cfg(feature = "prometheus")]
if let Some(wb) = worker_busy.as_ref() {
wb.set(1);
@@ -2140,7 +2074,7 @@ pub async fn run_worker(
if let Some(db) = conn.as_sql() {
infos.sample_pool(db.size(), db.num_idle() as u32);
}
infos.add_iter(bench, bench_job_id, is_top_level_job);
infos.add_iter(bench, true);
}
continue;
@@ -2213,7 +2147,7 @@ pub async fn run_worker(
if let Some(db) = conn.as_sql() {
infos.sample_pool(db.size(), db.num_idle() as u32);
}
infos.add_iter(bench, bench_job_id, is_top_level_job);
infos.add_iter(bench, true);
}
continue;
@@ -2516,7 +2450,7 @@ pub async fn run_worker(
if let Some(db) = conn.as_sql() {
infos.sample_pool(db.size(), db.num_idle() as u32);
}
infos.add_iter(bench, bench_job_id, is_top_level_job);
infos.add_iter(bench, true);
}
}
}
@@ -2543,12 +2477,11 @@ pub async fn run_worker(
#[cfg(feature = "benchmark")]
{
bench_empty_queue_count += 1;
add_time!(bench, "sleep because empty job queue");
if let Some(db) = conn.as_sql() {
infos.sample_pool(db.size(), db.num_idle() as u32);
}
infos.add_iter(bench, uuid::Uuid::nil(), false);
infos.add_iter(bench, false);
}
#[cfg(feature = "prometheus")]
_timer.map(|timer| {
@@ -2577,6 +2510,13 @@ pub async fn run_worker(
}
}
#[cfg(feature = "benchmark")]
{
infos
.write_to_file("profiling_main.json")
.expect("write to file profiling");
}
drop(dedicated_workers);
let has_dedicated_workers = !dedicated_handles.is_empty();
@@ -2597,17 +2537,6 @@ pub async fn run_worker(
tracing::error!("error in awaiting send_result process: {e:?}")
}
}
#[cfg(feature = "benchmark")]
{
infos
.write_to_file("profiling_main.json")
.expect("write to file profiling");
if let Some(db) = conn.as_sql() {
benchmark_verify(benchmark_jobs, db).await;
}
}
tracing::info!(worker = %worker_name, hostname = %hostname, "waiting for interactive_shell to finish");
if let Some(interactive_shell) = interactive_shell {
match tokio::time::timeout(Duration::from_secs(10), interactive_shell).await {

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